From 267f54ef7264cca7c630f9f8b6383e6766397cf2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:30:19 -0600 Subject: [PATCH] fix(chat): make task-not-found, unreachable-MCP and board denials final for agents (#20208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People talk to their agents in Agent Chat, and the agents act through the Paperclip API > - A board-only denial now tells the agent that it is final and where the person acts (#20151, #20182). Other refusals and errors still give no sign that they are final > - So the agent keeps trying until the reply deadline, and the person gets no answer. In chat evaluations this happened on a task lookup that returned 404, on an MCP server that Paperclip could not reach, on a model question, and after a final budget denial > - This pull request makes those answers say that they are final and what the person must do, and it tightens the chat guidance after a board-only denial > - The benefit is a prompt, correct reply instead of a long search or forbidden workarounds ## Linked Issues or Issue Description Refs #20151, #20182 (same remedy for board-only denials). **What happened** In four Agent Chat evaluation cases on a slower model, the agent did not reply before its deadline: - Asked to open a task from an old link, the agent got `404 {"error":"Issue not found"}`. It quoted the private-task guidance, then searched archived projects, activity and the old host for 3 more minutes. - Asked to connect an MCP server at an unreachable URL, the agent got `502 {"error":"fetch failed","code":"runtime_error"}` from the health check. It retried, then used `curl` on the host and gateway IP addresses to look for the server. - Asked which coding models an agent can use, the agent got the runtime's model list in its first minute. The catalog did not say how to list OpenRouter models or that these lists answer the question, so it enumerated OpenRouter with the shell until the deadline. - Asked to raise a budget, the agent got the final board-only denial. The chat guidance said "Complete other permitted work", so it probed approvals and other routes and replied too late. **Expected behavior** The answer the agent receives says whether it is final and what the person must do, with a link when the page is known. The agent replies at once. It does not search other routes, retry, or contact other network addresses. **Steps to reproduce** 1. Open Agent Chat with a CEO agent. 2. Ask it to open a deleted task by its old link, or to connect an MCP server at a closed local port. 3. The agent calls `GET /api/issues/{id}` (404) or `POST /api/tool-connections/{id}/health-check` (502 "fetch failed"). 4. The body says nothing about finality. The agent keeps searching or probing and replies late or not at all. ## What Changed - `GET /api/issues/:id`: for an agent, the 404 body adds `code: "issue_not_visible"` and a `remediation`. It says that the lookup is final, that the agent must not search other routes or open a similarly named task, and that it should reply now and offer sharing or pasting. A missing task, a private task and another company's task get the same body, so the body reveals nothing. People and other routes keep the plain body. - MCP health check and catalog refresh: when Paperclip cannot reach the server (connection refused, DNS, network, reset, timeout or TLS), an agent's error adds a `remediation`. It says the person must correct the URL or start the server, that the agent must not contact other hosts, ports or network addresses, and it links Connectors (`//apps`). Status, `error` and `code` do not change. - `AGENT_CHAT_DIRECTIVE`: after a board-only denial, the agent replies now instead of making more calls. "Complete other permitted work" is removed. - API catalog: `POST /api/companies/{companyId}/tools/connections` says it saves without a check and points MCP-by-URL setup at `tools/apps/connect`, which checks the server first and saves nothing for a fresh setup that cannot reach it. `GET /api/companies/{companyId}/adapters/{type}/models` says which lists answer a model question (the runtime's list with `provider`, `acpxAgent` for ACPX agents and `environmentId`, and `opencode_local` with `provider=openrouter` for OpenRouter keys), that the OpenRouter list is not a qualification, and that with those inputs these calls answer the question. `openrouter` is added to the documented `provider` values. - API catalog authorization: budget policy upsert, budget incident resolution, company and agent budget updates, and approval approve/reject are now listed as board-only. Their routes already call `assertBoard`, but the catalog said `board_or_agent`, so an agent explored them as its own action before it got the 403. - Tests: new `agent-refusal-finality.test.ts` (including the catalog actor for the budget and approval routes); the private-task access test now checks that a private and a missing task give an agent the same 404 body; the guidance test checks the new wording. ## Verification - `cd server && npx vitest run src/__tests__/agent-refusal-finality.test.ts src/__tests__/board-access-denial.test.ts src/__tests__/issue-access-grants-routes.test.ts src/__tests__/authz-existence-oracle-guard.test.ts`: all pass (embedded Postgres included). - `cd server && npx vitest run src/__tests__/error-handler.test.ts src/__tests__/generic-mcp-connection.test.ts src/__tests__/openapi-routes.test.ts src/__tests__/remote-http-rebinding.test.ts src/__tests__/cloud-portfolio-error.test.ts src/__tests__/database-backup-catalog-guidance.test.ts`: 183 tests pass. - `npx tsc --noEmit -p server`: passes. - Real chat journeys on the chat evaluation branch (runner-opencode): the results will follow in a comment. ## Risks - Low risk. HTTP status, `error` and `code` stay the same for each changed error. Agents get extra top-level fields (`code` on the task 404, `remediation`). A client that compares a whole agent 404 body for `GET /api/issues/:id` exactly will see a change. - The task 404 body is the same for missing, private and other-company tasks, so it adds no existence oracle. Other issue routes keep their plain body. - The guidance change can make an agent stop after a board-only denial in a request that had other, permitted parts. The agent still reports what it finished. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../__tests__/agent-refusal-finality.test.ts | 121 ++++++++++++++++++ .../src/__tests__/board-access-denial.test.ts | 2 + .../issue-access-grants-routes.test.ts | 8 ++ server/src/routes/authz.ts | 20 ++- server/src/routes/issues.ts | 10 +- server/src/routes/openapi.ts | 16 ++- server/src/services/agent-conversations.ts | 2 +- server/src/services/mcp-connection-failure.ts | 6 + .../services/tool-access/catalog/discovery.ts | 6 +- .../services/tool-access/catalog/failures.ts | 41 ++++++ .../services/tool-access/catalog/health.ts | 3 +- .../tool-access/catalog/publication.ts | 3 +- 12 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 server/src/__tests__/agent-refusal-finality.test.ts diff --git a/server/src/__tests__/agent-refusal-finality.test.ts b/server/src/__tests__/agent-refusal-finality.test.ts new file mode 100644 index 00000000000..542eb937bc8 --- /dev/null +++ b/server/src/__tests__/agent-refusal-finality.test.ts @@ -0,0 +1,121 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; + +import { HttpError } from "../errors.js"; +import { errorHandler } from "../middleware/index.js"; +import { getAccessibleResource, issueNotFoundBody, ISSUE_NOT_VISIBLE_CODE } from "../routes/authz.js"; +import { buildOpenApiDocument } from "../routes/openapi.js"; +import { withMcpConnectionFailure } from "../services/mcp-connection-failure.js"; +import { googleWorkspaceServicesUnavailable, unreachableServerRemediation } from "../services/tool-access/catalog/failures.js"; + +const COMPANY_ID = "10000000-0000-4000-8000-000000000001"; +const OTHER_COMPANY_ID = "10000000-0000-4000-8000-000000000009"; + +const agentActor: Express.Request["actor"] = { type: "agent", source: "agent_key", agentId: "agent-1", companyId: COMPANY_ID }; +const boardActor: Express.Request["actor"] = { type: "board", source: "local_implicit", userId: "local-board" }; + +function lookupApp(actor: Express.Request["actor"], issue: { companyId: string } | null) { + const app = express(); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.get("/api/issues/:id", async (req, res) => { + const found = await getAccessibleResource(req, res, issue, "Issue not found", issueNotFoundBody(req)); + if (found) res.json(found); + }); + app.use(errorHandler); + return app; +} + +async function refusedConnection(code: string) { + return withMcpConnectionFailure(async () => { + throw new TypeError("fetch failed", { cause: Object.assign(new Error("connect failed"), { code }) }); + }).catch((error: unknown) => error); +} + +describe("agent refusals that are final", () => { + it("tells an agent a task lookup is final, with one body for missing and other-company tasks", async () => { + const missing = await request(lookupApp(agentActor, null)).get("/api/issues/COM-2").expect(404); + const otherCompany = await request(lookupApp(agentActor, { companyId: OTHER_COMPANY_ID })).get("/api/issues/OTH-1").expect(404); + expect(missing.body).toEqual(otherCompany.body); + expect(missing.body).toEqual(issueNotFoundBody({ actor: agentActor } as express.Request)); + expect(missing.body.error).toBe("Issue not found"); + expect(missing.body.code).toBe(ISSUE_NOT_VISIBLE_CODE); + expect(missing.body.remediation).toContain("this lookup is final"); + expect(missing.body.remediation).toContain("do not open a similarly named task in its place"); + expect(missing.body.remediation).toContain("Reply now"); + }); + + it("keeps the plain task 404 for people", async () => { + const res = await request(lookupApp(boardActor, null)).get("/api/issues/COM-2").expect(404); + expect(res.body).toEqual({ error: "Issue not found" }); + }); + + it("tells an agent an unreachable MCP server is the person's to fix, and not to probe other addresses", async () => { + const error = await refusedConnection("ECONNREFUSED"); + const { remediation } = unreachableServerRemediation(error, { actorType: "agent" }) as { remediation: string }; + expect(remediation).toContain("nothing accepted the connection"); + expect(remediation).toContain("they must correct the URL, or start the server"); + expect(remediation).toContain("you must not contact other hosts, ports or network addresses yourself"); + expect(remediation).toContain("Reply now"); + expect(remediation).toContain("[Open Connectors](//apps)"); + }); + + it("adds no MCP remediation for people or for failures that reached the server", async () => { + const error = await refusedConnection("ECONNREFUSED"); + expect(unreachableServerRemediation(error, { actorType: "user" })).toEqual({}); + expect(unreachableServerRemediation(new Error("tool list was invalid"), { actorType: "agent" })).toEqual({}); + }); + + it("keeps the unreachable-server remediation when every Google Workspace service fails", async () => { + const aggregate = googleWorkspaceServicesUnavailable(["gmail", "docs"], [ + await refusedConnection("ECONNREFUSED"), + await refusedConnection("ECONNREFUSED"), + ]); + expect(aggregate.details).toMatchObject({ code: "google_workspace_services_unavailable" }); + const { remediation } = unreachableServerRemediation(aggregate, { actorType: "agent" }) as { remediation: string }; + expect(remediation).toContain("nothing accepted the connection"); + expect(remediation).toContain("Reply now"); + const unclassified = googleWorkspaceServicesUnavailable(["gmail"], [new Error("tool list was invalid")]); + expect(unreachableServerRemediation(unclassified, { actorType: "agent" })).toEqual({}); + }); + + it("returns the MCP remediation in the error body an agent receives", async () => { + const source = await refusedConnection("ENOTFOUND"); + const routed = express(); + routed.post("/check", () => { + throw new HttpError(502, "fetch failed", { code: "runtime_error", ...unreachableServerRemediation(source, { actorType: "agent" }) }); + }); + routed.use(errorHandler); + const res = await request(routed).post("/check").expect(502); + expect(res.body.code).toBe("runtime_error"); + expect(res.body.remediation).toContain("its host name did not resolve"); + }); + + it("marks budget changes and approval decisions board-only in the API catalog", () => { + const paths = buildOpenApiDocument().paths; + for (const [path, method] of [ + ["/api/companies/{companyId}/budgets/policies", "post"], + ["/api/companies/{companyId}/budget-incidents/{incidentId}/resolve", "post"], + ["/api/companies/{companyId}/budgets", "patch"], + ["/api/agents/{agentId}/budgets", "patch"], + ["/api/approvals/{id}/approve", "post"], + ["/api/approvals/{id}/reject", "post"], + ] as const) expect(paths[path][method]["x-paperclip-authorization"], `${method} ${path}`).toEqual({ actor: "board" }); + expect(paths["/api/companies/{companyId}/budget-incidents/{incidentId}/resolve"].post.description).toContain("[Open budgets](//activity/budgets)"); + expect(paths["/api/agents/{agentId}/budgets"].patch.description).toContain("[Open budget](//agents//budgets)"); + }); + + it("points the API catalog at the checked MCP setup route and the complete model lists", () => { + const paths = buildOpenApiDocument().paths; + expect(paths["/api/companies/{companyId}/tools/connections"].post.description).toContain("POST /api/companies/{companyId}/tools/apps/connect with link"); + const models = paths["/api/companies/{companyId}/adapters/{type}/models"].get; + expect(models.description).toContain("type opencode_local and provider=openrouter"); + expect(models.description).toContain("its adapterConfig.acpxAgent when the provider is acpx"); + expect(models.description).toContain("these calls answer the question"); + const provider = models.parameters.find((row: { name: string }) => row.name === "provider"); + expect(provider.schema.enum).toContain("openrouter"); + }); +}); diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 4729c740d6e..166ddc0653a 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -140,6 +140,8 @@ describe("board-only denial", () => { expect(AGENT_CHAT_DIRECTIVE).toContain("so stop searching for one"); expect(AGENT_CHAT_DIRECTIVE).toContain("tell the person which control in the Paperclip web app to use, link its page when you know the route"); expect(AGENT_CHAT_DIRECTIVE).not.toContain("use permitted discovery to identify the supported operator path"); + expect(AGENT_CHAT_DIRECTIVE).toContain("Reply now instead of making more calls"); + expect(AGENT_CHAT_DIRECTIVE).not.toContain("Complete other permitted work"); }); it("says in the API catalog who sets up a Claude Managed Agent profile", () => { diff --git a/server/src/__tests__/issue-access-grants-routes.test.ts b/server/src/__tests__/issue-access-grants-routes.test.ts index b642460a120..c6e5dae4c73 100644 --- a/server/src/__tests__/issue-access-grants-routes.test.ts +++ b/server/src/__tests__/issue-access-grants-routes.test.ts @@ -298,6 +298,14 @@ describeEmbeddedPostgres("issue access grant and locked edge routes", { concurre )).get(`/api/issues/${fixture.privateChild.id}`); expect(deniedAfterRevoke.status).toBe(404); expect(deniedAfterRevoke.body.error).toBe("Issue not found"); + // A private task and a missing one must give an agent the same final answer. + const missing = await request(await createApp( + db, + agentActor(fixture.companyId, fixture.assignedAgent.id), + )).get(`/api/issues/${randomUUID()}`); + expect(missing.status).toBe(404); + expect(deniedAfterRevoke.body).toEqual(missing.body); + expect(deniedAfterRevoke.body.code).toBe("issue_not_visible"); }, 20_000); it("creates and enriches explicit grants idempotently while rejecting unauthorized and open shares", async () => { diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index b8d4dcd5fb4..4fab0967fa7 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -127,6 +127,23 @@ export function boardAccessRequired(req: Request, controlPage?: BoardControlPage }); } +export const ISSUE_NOT_VISIBLE_CODE = "issue_not_visible"; + +/** + * The 404 for a task lookup. A deleted task, a private task the caller cannot + * see and another company's task get this same body, so it never says which. + * Agents also learn that the lookup is final, so they reply instead of + * searching other routes for the task. + */ +export function issueNotFoundBody(req: Request): Record { + if (req.actor.type !== "agent") return { error: "Issue not found" }; + return { + error: "Issue not found", + code: ISSUE_NOT_VISIBLE_CODE, + remediation: "No task with this ID or identifier is visible to you. A deleted task, a private task you were not given and a task in another company all get this same answer, so this lookup is final: do not search for the task through other routes, lists, activity, files or hosts, and do not open a similarly named task in its place. Reply now: tell the person you cannot open it here, and that if it still exists they can share it with you from its sharing controls or paste the parts they need.", + }; +} + /** * Board-only check. Pass `controlPage` when the board control lives on a different * page from the one the route path implies, e.g. a budget field on a general route. @@ -286,10 +303,11 @@ export async function getAccessibleResource( res: Response, resource: T | null | undefined | Promise, notFoundMessage: string, + notFoundBody: Record = { error: notFoundMessage }, ): Promise { const resolved = await resource; if (!resolved || !hasCompanyAccess(req, resolved.companyId)) { - res.status(404).json({ error: notFoundMessage }); + res.status(404).json(notFoundBody); return null; } assertCompanyAccess(req, resolved.companyId); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7c191590925..f703afa58cf 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -286,6 +286,7 @@ import { assertCompanyAccess, getAccessibleResource, getActorInfo, + issueNotFoundBody, } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, @@ -5227,7 +5228,7 @@ export function issueRoutes( req: Request, res: Response, issue: Parameters[1] & { identifier?: string | null }, - options: { allowBreakGlass?: boolean } = {}, + options: { allowBreakGlass?: boolean; finalForAgents?: boolean } = {}, ) { const decision = await decideIssueAccess(req, issue, "issue:read"); if (decision.allowed) return true; @@ -5240,7 +5241,9 @@ export function issueRoutes( await recordIssueBreakGlassRead(req, issue); return true; } - res.status(404).json({ error: "Issue not found" }); + // Must match the missing-task body on the same route, or the 404 would + // reveal that a private task exists. + res.status(404).json(options.finalForAgents ? issueNotFoundBody(req) : { error: "Issue not found" }); return false; } @@ -9775,9 +9778,10 @@ export function issueRoutes( res, timing.time("lookup", () => getIssueById(req, id)), "Issue not found", + issueNotFoundBody(req), ); if (!issue) return; - if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue, { allowBreakGlass: true })))) return; + if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue, { allowBreakGlass: true, finalForAgents: true })))) return; // Saved cards are structural conversation data. Include their local // projection before first paint, independently of slow recovery, workspace // and related-task enrichment. This never refreshes external providers. diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 16376e8b8df..896ce60a514 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1429,6 +1429,14 @@ const browserUseOperations = [ ] as const; const BOARD_ONLY_OPERATIONS = new Set([ + // Budget changes are board-only (assertBoard in routes/costs.ts). + "POST /api/companies/{companyId}/budgets/policies", + "POST /api/companies/{companyId}/budget-incidents/{incidentId}/resolve", + "PATCH /api/companies/{companyId}/budgets", + "PATCH /api/agents/{agentId}/budgets", + // Approval decisions are board-only (assertBoard in routes/approvals.ts). + "POST /api/approvals/{id}/approve", + "POST /api/approvals/{id}/reject", "GET /api/agents/{id}/project-access", "POST /api/agents/{id}/project-access", "GET /api/issues/{id}/pending-questions", @@ -4123,11 +4131,12 @@ registry.registerPath({ path: "/api/companies/{companyId}/adapters/{type}/models", tags: ["adapters"], summary: "List models for an adapter type and runner provider", + description: "Answers which models an agent's runtime can select. For a paperclip_runner agent, pass its adapterConfig.provider, its adapterConfig.acpxAgent when the provider is acpx, and its defaultEnvironmentId; the response is that runtime's own list. An OpenCode agent whose model starts with openrouter/ uses an OpenRouter key: list those choices with type opencode_local and provider=openrouter. That list is OpenRouter's public catalog, so whether a model works depends on the key's account and is not a qualification. With those inputs, these calls answer the question: report the options with that caveat and reply, rather than enumerating providers through other routes, the shell or the web.", request: { params: z.object({ companyId: z.string(), type: z.string() }), query: z.object({ provider: z - .enum(["codex", "acpx", "opencode", "claude_managed", "aws_agentcore"]) + .enum(["codex", "acpx", "opencode", "claude_managed", "aws_agentcore", "openrouter"]) .optional(), environmentId: z.string().optional(), refresh: z.string().optional(), @@ -6038,6 +6047,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/budget-incidents/{incidentId}/resolve", tags: ["costs"], summary: "Resolve a budget incident", + description: "Board only: agent credentials get 403, so an agent cannot raise a limit or resume a budget-stopped agent. A board user chooses the new amount and resumes on the Budgets page: link [Open budgets](//activity/budgets) with this conversation's company prefix as , and reply.", request: { params: z.object({ companyId: z.string(), incidentId: z.string() }), body: jsonBody(resolveBudgetIncidentSchema), @@ -6076,6 +6086,7 @@ registry.registerPath({ path: "/api/agents/{agentId}/budgets", tags: ["costs"], summary: "Update agent budget", + description: "Board only: agent credentials get 403. A board user changes the limit on the agent's budget tab: link [Open budget](//agents//budgets) with this conversation's company prefix as , and reply.", request: { params: z.object({ agentId: z.string() }), body: jsonBody(updateBudgetSchema), @@ -11828,7 +11839,8 @@ registerCurrentRoute({ path: "/api/companies/{companyId}/tools/connections", tags: ["tool-access"], summary: "Create a tool connection", - description: OWN_MCP_SERVER_KEY, + description: "Saves a connection as given without checking that its server answers. To connect an MCP server from its URL, use POST /api/companies/{companyId}/tools/apps/connect with link instead: it checks the server first, and a fresh setup saves no connection when the server cannot be reached. " + + OWN_MCP_SERVER_KEY, body: createToolConnectionSchema, responses: { 201: r.ok(), diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index 2f81391892c..04d35092920 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -93,7 +93,7 @@ export const AGENT_CHAT_DIRECTIVE = `You are in an ongoing conversation with the When changing defaults or policy for new or future work, update the resource that owns those defaults. Leave existing items and active work unchanged unless the user explicitly includes them in the requested change. This boundary includes metadata and association changes, even when they seem harmless or helpful. Do not broaden the request to align related existing items. Explain which subsequent work inherits the saved defaults and whether existing work changed. -If an operation needs board or instance-operator identity that this execution lacks (its API catalog authorization actor is board, or a call returns 403 "Board access required"), that boundary is final: no agent credential, tool or other route can do it, so stop searching for one. Complete other permitted work, then tell the person which control in the Paperclip web app to use, link its page when you know the route (a denial's remediation may name it), and end your response. A user's request or confirmation does not expand your credential's scope. Treat permission denials as boundaries: do not probe host processes, private control files, or unrelated endpoints for alternate access. Distinguish a requested action from a verified action. +If an operation needs board or instance-operator identity that this execution lacks (its API catalog authorization actor is board, or a call returns 403 "Board access required"), that boundary is final: no agent credential, tool or other route can do it, so stop searching for one. Reply now instead of making more calls: say what you already finished, tell the person which control in the Paperclip web app to use, link its page when you know the route (a denial's remediation may name it), and end your response. Do not call other routes to look for a way around it. A user's request or confirmation does not expand your credential's scope. Treat permission denials as boundaries: do not probe host processes, private control files, or unrelated endpoints for alternate access. Distinguish a requested action from a verified action. Research, clarify, and develop full plans here using the conversation's plan document. Revise the draft as the discussion develops. Planning alone does not create execution tasks. Put implementation and substantial execution into separate tasks. diff --git a/server/src/services/mcp-connection-failure.ts b/server/src/services/mcp-connection-failure.ts index b3cbb816e0b..16e6413544a 100644 --- a/server/src/services/mcp-connection-failure.ts +++ b/server/src/services/mcp-connection-failure.ts @@ -61,3 +61,9 @@ export function retainMcpConnectionFailure(source: unknown, tar export function isExpectedMcpConnectionFailure(error: unknown): boolean { return error instanceof HttpError && error.status === 502 && !hasUnclassifiedRemoteConnectionAttempts(error) && failures.has(error); } + +/** Why an outbound MCP request did not reach its server, when that is known. */ +export function mcpConnectionFailureReason(error: unknown): Reason | null { + if (!(error instanceof Error) || hasUnclassifiedRemoteConnectionAttempts(error)) return null; + return failures.get(error)?.reason ?? readRemoteConnectionFailure(error); +} diff --git a/server/src/services/tool-access/catalog/discovery.ts b/server/src/services/tool-access/catalog/discovery.ts index 340de2e0052..d2caf7abc17 100644 --- a/server/src/services/tool-access/catalog/discovery.ts +++ b/server/src/services/tool-access/catalog/discovery.ts @@ -34,7 +34,7 @@ import type { OAuthCredentialStore } from "../oauth/credential-store.js"; import type { ConnectionSetupPolicy } from "../connections/setup-policy.js"; import type { ToolRemoteHttp } from "../remote-http.js"; import { normalizeToolDescriptor, type McpToolDescriptor } from "./descriptors.js"; -import { unsupportedToolConnectionTransport } from "./failures.js"; +import { googleWorkspaceServicesUnavailable, unsupportedToolConnectionTransport } from "./failures.js"; function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; @@ -99,8 +99,8 @@ export function createToolCatalogDiscovery(db: Db, { now, audit, secrets, creden const currentGrant = await vaultGrantForConnection(connection, actor); if (!currentGrant) throw unprocessable("OAuth authorization must be reconnected", { code: "oauth_reauthorization_required" }); if (catalogs.length > 0 && catalogs.every((catalog) => catalog.status === "rejected")) { - throw new HttpError(502, `Google services unavailable: ${unavailableServices.join(", ")}. Retry refreshing actions.`, - { code: "google_workspace_services_unavailable" }); + throw googleWorkspaceServicesUnavailable(unavailableServices, + catalogs.map((catalog) => catalog.status === "rejected" ? catalog.reason : undefined)); } // A service's authentication retry may have refreshed into a narrower // grant. Never publish actions using the pre-refresh permission snapshot. diff --git a/server/src/services/tool-access/catalog/failures.ts b/server/src/services/tool-access/catalog/failures.ts index ff1fe8a14bf..106a3323755 100644 --- a/server/src/services/tool-access/catalog/failures.ts +++ b/server/src/services/tool-access/catalog/failures.ts @@ -1,5 +1,7 @@ import type { ToolConnectionHealthStatus } from "@paperclipai/shared"; import { HttpError, unprocessable } from "../../../errors.js"; +import { mcpConnectionFailureReason, retainMcpConnectionFailure } from "../../mcp-connection-failure.js"; +import type { ActorInfo } from "../types.js"; function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) @@ -153,3 +155,42 @@ export function sanitizeHttpFailure(error: unknown): { code: "runtime_error", }; } + +const UNREACHABLE_SERVER_REASONS: Readonly> = { + connection_refused: "nothing accepted the connection", + dns_failure: "its host name did not resolve", + network_unreachable: "its network address could not be reached", + connection_reset: "the connection was dropped", + connection_timeout: "it did not answer in time", + tls_failure: "its TLS certificate was not accepted", +}; + +/** + * For an agent, a check that could not reach the MCP server is final until the + * person fixes the URL or the server. Say so, so the agent hands it back instead + * of retrying or contacting other network addresses as a workaround. + */ +export function unreachableServerRemediation( + source: unknown, + actor?: Pick, +): { remediation: string } | Record { + if (actor?.actorType !== "agent") return {}; + const reason = mcpConnectionFailureReason(source); + const detail = reason ? UNREACHABLE_SERVER_REASONS[reason] : undefined; + if (!detail) return {}; + return { + remediation: `Paperclip could not reach this MCP server at its saved URL: ${detail}. This result is final until the person fixes it: they must correct the URL, or start the server or make it reachable from Paperclip, and then check it again. Retrying now will not connect it, and you must not contact other hosts, ports or network addresses yourself as a workaround. Reply now: tell the person the server could not be reached and what they need to fix, do not say it is connected, and link [Open Connectors](//apps), with this conversation's company prefix as , where they can fix or remove the connection or set it up again.`, + }; +} + +/** + * Every Google Workspace service failed. Carry the first classified cause onto + * the aggregate so an unreachable server still gets the final remediation + * instead of a bare "retry". + */ +export function googleWorkspaceServicesUnavailable(services: readonly string[], causes: readonly unknown[]): HttpError { + const error = new HttpError(502, `Google services unavailable: ${services.join(", ")}. Retry refreshing actions.`, + { code: "google_workspace_services_unavailable" }); + const cause = causes.find((candidate) => mcpConnectionFailureReason(candidate) !== null); + return cause === undefined ? error : retainMcpConnectionFailure(cause, error); +} diff --git a/server/src/services/tool-access/catalog/health.ts b/server/src/services/tool-access/catalog/health.ts index 9de76a44c64..e7b4271ab30 100644 --- a/server/src/services/tool-access/catalog/health.ts +++ b/server/src/services/tool-access/catalog/health.ts @@ -21,7 +21,7 @@ import type { ConnectionCredentialAccess } from "../credential-access.js"; import type { ConnectionSetupPolicy } from "../connections/setup-policy.js"; import { toConnection } from "../connections/projection.js"; import { isAgentMailConnection, type ToolCatalogDiscovery } from "./discovery.js"; -import { healthFailureHttpStatus, sanitizeHttpFailure, unsupportedToolConnectionTransport } from "./failures.js"; +import { healthFailureHttpStatus, sanitizeHttpFailure, unreachableServerRemediation, unsupportedToolConnectionTransport } from "./failures.js"; function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; @@ -226,6 +226,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d setupUrl: connectionSetupUrl(connection), reconnectUrl: connectionReconnectUrl(connection), ...githubRecovery, + ...unreachableServerRemediation(error, actor), })); } } diff --git a/server/src/services/tool-access/catalog/publication.ts b/server/src/services/tool-access/catalog/publication.ts index cfb25f98386..f47c06c50f7 100644 --- a/server/src/services/tool-access/catalog/publication.ts +++ b/server/src/services/tool-access/catalog/publication.ts @@ -27,7 +27,7 @@ import { isAgentMailConnection, type ToolCatalogDiscovery } from "./discovery.js import { classifyRisk, shouldQuarantineNewEntries, isGoogleWorkspaceToolAllowed, type McpToolDescriptor } from "./descriptors.js"; import { toConnection } from "../connections/projection.js"; import { toCatalogEntry } from "./projection.js"; -import { healthFailureHttpStatus, sanitizeHttpFailure } from "./failures.js"; +import { healthFailureHttpStatus, sanitizeHttpFailure, unreachableServerRemediation } from "./failures.js"; function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; @@ -98,6 +98,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection code: failure.code, setupUrl: connectionSetupUrl(connection), reconnectUrl: connectionReconnectUrl(connection), + ...unreachableServerRemediation(error, actor), })); }