diff --git a/server/src/__tests__/assistant-connections-guidance.test.ts b/server/src/__tests__/assistant-connections-guidance.test.ts index 33e421c484d..d03719da11a 100644 --- a/server/src/__tests__/assistant-connections-guidance.test.ts +++ b/server/src/__tests__/assistant-connections-guidance.test.ts @@ -5,14 +5,14 @@ import { validateRunnerApiCall } from "../services/native-runtime/runner-api-cli const context = { companyId: "00000000-0000-4000-8000-000000000121", issueId: "00000000-0000-4000-8000-000000000122", runId: "00000000-0000-4000-8000-000000000123", workMode: "standard" }; it("tells agents searching for assistant disconnection that only the person can do it, and where", () => { - // The queries a chat agent used while looking for a way to disconnect a person's old assistant. for (const query of ["chat client assistant disconnect connection", "assistant connections revoke list", "mcp connection revoke REST"]) { const found = searchRunnerApi({ query, limit: 10 }).results; const revoke = found.find(row => row.operationId === "DELETE /api/mcp/connections/{id}"); expect(revoke, query).toBeDefined(); expect(revoke!.description).toContain("[Assistant connections](//apps/assistant-connection)"); expect(revoke!.description).toContain("only the signed-in person"); - expect(revoke!.description).toContain("do not search for one"); + expect(revoke!.description).toContain("Do not search for another route"); + expect(revoke!.description).toContain("OAuth token revocation sent by the connected assistant itself"); expect(revoke!.description).toMatch(/already created keep their assignees and running work/); } const list = searchRunnerApi({ query: "GET /api/mcp/connections" }).results[0]!; @@ -23,3 +23,12 @@ it("says the call_api protocol rejection is final instead of inviting another se expect(() => validateRunnerApiCall({ operationId: "GET /api/mcp/connections" }, context)) .toThrow(/no other API route substitutes for it\. Its catalog description says who can use it\./); }); + +it("points at the catalog description only when the protocol endpoint has one", () => { + const undocumented = searchRunnerApi({ query: "POST /api/mcp/project-tools" }).results.find(row => row.operationId === "POST /api/mcp/project-tools"); + expect(undocumented?.description).toBe(""); + let message = ""; + try { validateRunnerApiCall({ operationId: "POST /api/mcp/project-tools" }, context); } catch (error) { message = (error as Error).message; } + expect(message).toContain("no other API route substitutes for it."); + expect(message).not.toContain("catalog description"); +}); diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 808bb56847c..c5e406b360a 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -466,6 +466,8 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => ] as Request["actor"][]) { actor = candidate; expect((await request(app).get("/api/mcp/setup")).status).toBe(401); + expect((await request(app).get("/api/mcp/connections")).status).toBe(401); + expect((await request(app).delete(`/api/mcp/connections/${randomUUID()}`).set("Origin", config.origin)).status).toBe(401); } for (const source of ["session", "cloud_tenant"] as const) { actor = { type: "board", source, userId: randomUUID() }; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 3b9471a4164..5efd8ba619b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -12957,10 +12957,10 @@ registerCurrentRoute({ body: mcpConsentSchema.extend({ userCode: z.string().min(8).max(12) }), responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, }); -/** Agents read this in API search; without it they keep searching for an agent route that does not exist. */ const ASSISTANT_CONNECTIONS_PERSON_ONLY = "Personal and person-only: only the signed-in person can list or revoke their own assistant connections, in their browser on " + "[Assistant connections](//apps/assistant-connection), with this conversation's company prefix as . " - + "No agent credential, tool or other API route can do it, so do not search for one; link that page and tell the person which connection to revoke. " + + "An agent cannot do it for them: these routes accept only the person's own signed-in browser session, and the only other revocation is OAuth token revocation sent by the connected assistant itself with its own token. " + + "Do not search for another route; link that page and tell the person which connection to revoke. " + "Revoking stops that assistant's further calls; tasks it already created keep their assignees and running work."; registerCurrentRoute({ method: "get", path: "/api/mcp/connections", tags: ["tool-gateway"], diff --git a/server/src/services/native-runtime/runner-api-client.ts b/server/src/services/native-runtime/runner-api-client.ts index 2c34e479389..42a1af9ad19 100644 --- a/server/src/services/native-runtime/runner-api-client.ts +++ b/server/src/services/native-runtime/runner-api-client.ts @@ -54,7 +54,7 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext) if (!parsed.success) throw badRequest("Invalid call_api arguments", { issues: parsed.error.issues }); const input = parsed.data; const operation = runnerApiOperation(input.operationId); - if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it. Its catalog description says who can use it."); + if (operation.transport !== "rest") throw unprocessable(`This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it.${operation.description.trim() ? " Its catalog description says who can use it." : ""}`); if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation"); if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools"); if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company");