From 8ce3389fb5168d0f7a2399845b8d27708f7e5ecf Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Nov 2026 22:08:52 -0600 Subject: [PATCH] fix(api): tell agents only the person can revoke assistant connections, and where A chat agent asked to "disconnect my old assistant" found DELETE /api/mcp/connections/{id}, was refused by call_api as a protocol endpoint, and then spent its whole turn searching for an agent route that does not exist, never replying. Neither the catalog entry nor the rejection said the action is person-only or which page holds it. Both assistant connection catalog entries now say only the signed-in person can list or revoke them, link the Assistant connections page, say not to search for another route, and state that revoking leaves already-delegated tasks running. The call_api protocol rejection now says no other API route substitutes for the endpoint. Co-Authored-By: Claude Opus 5.5 --- .../assistant-connections-guidance.test.ts | 25 +++++++++++++++++++ server/src/routes/openapi.ts | 7 ++++++ .../native-runtime/runner-api-client.ts | 2 +- 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/assistant-connections-guidance.test.ts 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..33e421c484d --- /dev/null +++ b/server/src/__tests__/assistant-connections-guidance.test.ts @@ -0,0 +1,25 @@ +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", () => { + // 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).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\./); +}); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 318fd711e08..254b6b64541 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -12967,14 +12967,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 }, }); +/** 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. " + + "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..2c34e479389 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. 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");