diff --git a/server/src/__tests__/assistant-connections-guidance.test.ts b/server/src/__tests__/assistant-connections-guidance.test.ts new file mode 100644 index 00000000000..d03719da11a --- /dev/null +++ b/server/src/__tests__/assistant-connections-guidance.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; +import { searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js"; +import { validateRunnerApiCall } from "../services/native-runtime/runner-api-client.js"; + +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", () => { + 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 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]!; + expect(list.description).toContain("[Assistant connections](//apps/assistant-connection)"); +}); + +it("says the call_api protocol rejection is final instead of inviting another search", () => { + 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 b3a9bb92797..5efd8ba619b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -12957,14 +12957,21 @@ registerCurrentRoute({ body: mcpConsentSchema.extend({ userCode: z.string().min(8).max(12) }), responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, }); +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 . " + + "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"], summary: "List the signed-in person's assistant connections", + description: ASSISTANT_CONNECTIONS_PERSON_ONLY, responses: { 200: r.ok(), 401: r.unauthorized }, }); registerCurrentRoute({ method: "delete", path: "/api/mcp/connections/{id}", tags: ["tool-gateway"], summary: "Revoke an assistant connection using a same-origin browser session", + description: ASSISTANT_CONNECTIONS_PERSON_ONLY, responses: { 204: { description: "Connection revoked" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, }); diff --git a/server/src/services/native-runtime/runner-api-client.ts b/server/src/services/native-runtime/runner-api-client.ts index 2f051b7309b..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"); + 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");