fix(api): keep the protocol refusal and revoke guidance accurate

- Point a call_api protocol refusal at the catalog description only when
  that operation has one; undocumented protocol routes no longer send the
  agent to an empty description.
- Say the connected assistant can still revoke its own token through OAuth
  token revocation instead of claiming no route exists.
- Cover the signed-in-person gate on listing and revoking assistant
  connections for agents and non-session board actors.
- Drop the code comments the contribution guide disallows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-08 23:52:56 -06:00
1 parent 6c27248643
commit 843e16db8b
4 files changed
+16 -5

No files matched your search

@@ -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](/<prefix>/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");
});
+2
View File
@@ -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() };
+2 -2
View File
@@ -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](/<prefix>/apps/assistant-connection), with this conversation's company prefix as <prefix>. "
+ "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"],
@@ -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");