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");