diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index 3233fae8705..43dcc815fc7 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -232,15 +232,17 @@ describeEmbeddedPostgres("connectionIntentService", () => { const service = connectionIntentService(db); try { await settings.updateExperimental({ enableChatConnectors: false }); - // A longer request, as an agent words it, still finds the hidden channel. expect((await service.search(claims, "iMessage chat messaging with a contact")).results.find(item => item.service === "imessage-photon")?.state).toBe("unavailable"); const off = await service.search(claims, "iMessage"); const imessage = off.results.find(item => item.service === "imessage-photon"); expect(imessage).toMatchObject({ state: "unavailable", methods: [] }); expect(imessage?.reason).toMatch(/\[Experimental settings\]\(\/[A-Z0-9]+\/company\/settings\/instance\/experimental\)/); expect(off.instruction).toContain("Explain the reason"); - // Channels that also offer tools keep their usual result; only their chat methods stay hidden. expect((await service.search(claims, "slack")).results.some(item => item.state === "unavailable" && item.methods.length === 0)).toBe(false); + expect((await service.search(claims, "discord")).results.some(item => item.service === "discord" && item.state === "unavailable")).toBe(false); + const partial = await service.search(claims, "imess"); + expect(partial.results.map(item => item.service)).toEqual(["imessage-photon"]); + expect(partial.instruction).toContain("Explain the reason"); await settings.updateExperimental({ enableChatConnectors: true }); const on = (await service.search(claims, "iMessage")).results.find(item => item.service === "imessage-photon"); expect(on?.state).not.toBe("unavailable"); @@ -251,6 +253,9 @@ describeEmbeddedPostgres("connectionIntentService", () => { }); it("points a service the catalog does not list at the secure own-server setup page", async () => { + const ranked = await connectionIntentService(db).search(claims, "Synthetic notes service connect my account with an API key"); + expect(ranked.results.length).toBeGreaterThan(1); + expect(ranked.instruction).toMatch(/^These are ranked connection matches[\s\S]*\[Connect your own MCP server\]\(\/[A-Z0-9]+\/apps\/byo\)/); const result = await connectionIntentService(db).search(claims, "Synthetic notes service"); expect(result.instruction).toMatch(/\[Connect your own MCP server\]\(\/[A-Z0-9]+\/apps\/byo\)/); expect(result.instruction).toContain("Do not create the connection yourself, ask for the key in chat"); diff --git a/server/src/__tests__/person-only-page-guidance.test.ts b/server/src/__tests__/person-only-page-guidance.test.ts index 747e7fd16b8..1a9be567acc 100644 --- a/server/src/__tests__/person-only-page-guidance.test.ts +++ b/server/src/__tests__/person-only-page-guidance.test.ts @@ -6,8 +6,6 @@ import { runnerApiCatalog, searchRunnerApi } from "../services/native-runtime/ru const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); -// Agents asked for a step only a person can take used to search the API until their turn ran out, -// because no catalog entry they read said who acts or where. Each entry below names both. it.each([ ["connect my assistant mcp setup", "GET /api/mcp/setup"], ["assistant connection consent approve access", "POST /api/mcp/requests/{id}/consent"], @@ -52,8 +50,6 @@ it("says on the interaction listing that only the addressee resolves a connectio expect(list?.description).toContain("Only that person can connect or decline it"); }); -// The catalog text is the only place these page paths live on the server, so a renamed UI route -// would leave agents linking a page that no longer exists. Every page named must be mounted. it("names only pages that the board UI mounts", () => { const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8"); const pages = new Set( @@ -68,3 +64,12 @@ it("names only pages that the board UI mounts", () => { for (const page of pages) expect(appRoutes, page).toContain(` { + const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8"); + const search = readFileSync(path.join(repoRoot, "server/src/services/connection-intents.ts"), "utf8"); + for (const page of ["apps/byo", "company/settings/instance/experimental"]) { + expect(search, page).toContain(`}/${page})`); + expect(appRoutes, page).toContain(` { const companyId = req.params.companyId as string; await assertConnectionManager(req, companyId); - // Setup pages are hidden while the provider's experiment is off; the API must not create the endpoint either. assertChatProviderEnabledForCreate(req.body.provider, await instanceSettingsService(db).getExperimental()); if (req.body.provider === "slack" && !req.body.slackSetupMethod) { req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod(); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 97da67811fd..5bedd837dae 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -11353,7 +11353,6 @@ registerCurrentRoute({ body: connectionRequestInputSchema, }); -/** Agents read this in API search; without it they look for a way to resolve another person's card. */ const ADDRESSED_CONNECTION_REQUEST = "Person-only: a connection request card is addressed to one person, its addresseeUserId. " + "Only that person, signed in to their own browser, can open its setup, connect their account or decline it. The server refuses every other member, board user and agent, so nobody can finish it for them. " + "When someone else offers to finish it, tell them the addressee must connect their own account from the card on its task, linked as [](//issues/). " @@ -11722,7 +11721,6 @@ registerCurrentRoute({ }, }); -/** Agents read this in API search; without it they cannot find where a quarantined action is reviewed. */ const QUARANTINED_ACTION_REVIEW = "Catalog entries with status quarantined are new actions that a catalog refresh found and holds for a person's review. " + "No agent can call a quarantined action, through this connection or any other transport, until a person who manages the connection allows it on the app's Permissions page, " + "[Permissions](//apps//permissions), with this conversation's company prefix and the connection's id. Allowing it is that person's decision, not an agent's. " @@ -12945,7 +12943,6 @@ registerCurrentRoute({ 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, }); -/** Agents read this in API search; without it they hunt for the page where a person connects an assistant. */ const ASSISTANT_CONNECTION_SETUP_PERSON_ONLY = "Person-only: a person connects their own assistant, such as Claude, ChatGPT or another MCP client, in their browser on " + "[Assistant connections](//apps/assistant-connection), with this conversation's company prefix as . " + "That page gives the invitation and server URL to paste into the assistant; the assistant then opens a Paperclip consent screen where the person picks the company and approves access. " diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 047af685d0e..683de9b6ce5 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -543,10 +543,8 @@ export function connectionIntentService(db: Db) { connectionId: ready?.id ?? null, }}); } - // A chat channel hidden by the instance setting stays findable, so the agent can say why it is - // unavailable and where it is turned on instead of searching for a route that is not offered. if (!settings.enableChatConnectors) for (const app of APP_STORE_DEFINITIONS) { - if (!isConnectorFeatureEnabled(app, settings) || discoveryMethods(app).length + if (!isConnectorFeatureEnabled(app, settings) || discoveryMethods(app).length || findAggregatorService(app.slug) || findAggregatorService(app.name) || !getAvailableConnectionMethods(app).some(method => method.purpose === "channel" && method.provider !== "github")) continue; const channelMethods = getAvailableConnectionMethods(app).filter(method => method.purpose === "channel"); const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name], @@ -714,7 +712,6 @@ export function connectionIntentService(db: Db) { } function directSearchResult(query: string, results: ConnectionSearchResultItem[], prefix: string, suggestions = false): ConnectionsSearchResult { - // A service the catalog does not list can still be the person's own MCP server; name its secure setup page. const ownServer = `the person adds it as their own MCP server on [Connect your own MCP server](/${prefix}/apps/byo): they enter the URL and type any API key into that page's secure field themselves. ` + "An agent cannot enter the key. Do not create the connection yourself, ask for the key in chat or search for another route; link that page and reply."; return { version: 1, query, results, instruction: !results.length @@ -732,7 +729,6 @@ export function connectionIntentService(db: Db) { : results[0]!.methods.every(method => method.purpose && method.purpose !== "tool") ? "Choose the method relevant to the task using its purpose and label. If method.management.allowed, configure through search_api/call_api using nextApiOperation. Hand off only required human authentication or consent at the saved setupPath. These methods do not use the tool connection_request card. Do not claim tools or an inbox are ready before setup finishes." : results[0]!.state === "ready" ? "Use the installed connection. Do not create another connection request." - : results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider." : "The user asked to connect. If method.management.allowed, use search_api/call_api with nextApiOperation to configure it. Reuse a saved draft. Use connection_request only for required human input or access. Do not ask a generic confirmation or silently change policy to bypass a failed tool call." }; }