diff --git a/server/src/__tests__/agent-refusal-finality.test.ts b/server/src/__tests__/agent-refusal-finality.test.ts index 55e01273c5e..203fb6d6e5c 100644 --- a/server/src/__tests__/agent-refusal-finality.test.ts +++ b/server/src/__tests__/agent-refusal-finality.test.ts @@ -11,7 +11,6 @@ import { unreachableServerRemediation } from "../services/tool-access/catalog/fa const COMPANY_ID = "10000000-0000-4000-8000-000000000001"; const OTHER_COMPANY_ID = "10000000-0000-4000-8000-000000000009"; -const CONNECTION_ID = "30000000-0000-4000-8000-000000000003"; 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" }; @@ -56,25 +55,25 @@ describe("agent refusals that are final", () => { 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, CONNECTION_ID, { actorType: "agent" }) as { remediation: string }; + 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 the connection](//apps/${CONNECTION_ID}/permissions)`); + 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, CONNECTION_ID, { actorType: "user" })).toEqual({}); - expect(unreachableServerRemediation(new Error("tool list was invalid"), CONNECTION_ID, { actorType: "agent" })).toEqual({}); + expect(unreachableServerRemediation(error, { actorType: "user" })).toEqual({}); + expect(unreachableServerRemediation(new Error("tool list was invalid"), { 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, CONNECTION_ID, { actorType: "agent" }) }); + 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); @@ -82,12 +81,25 @@ describe("agent refusals that are final", () => { 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" }); + }); + 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("These calls are the complete answer"); + 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/routes/openapi.ts b/server/src/routes/openapi.ts index e6b5761491b..bc75caf2a22 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1430,6 +1430,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", @@ -4125,7 +4133,7 @@ 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 and its defaultEnvironmentId; the response is the 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. These calls are the complete answer: report the options with that caveat and reply, rather than enumerating providers through other routes, the shell or the web.", + 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({ @@ -11817,7 +11825,7 @@ registerCurrentRoute({ path: "/api/companies/{companyId}/tools/connections", tags: ["tool-access"], summary: "Create a tool connection", - 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 before saving and keeps an unreachable server as an editable draft.", + 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 nothing when the server cannot be reached.", body: createToolConnectionSchema, responses: { 201: r.ok(), diff --git a/server/src/services/tool-access/catalog/failures.ts b/server/src/services/tool-access/catalog/failures.ts index 166ecd82eca..1c6a4503769 100644 --- a/server/src/services/tool-access/catalog/failures.ts +++ b/server/src/services/tool-access/catalog/failures.ts @@ -172,7 +172,6 @@ const UNREACHABLE_SERVER_REASONS: Readonly> = { */ export function unreachableServerRemediation( source: unknown, - connectionId: string, actor?: Pick, ): { remediation: string } | Record { if (actor?.actorType !== "agent") return {}; @@ -180,6 +179,6 @@ export function unreachableServerRemediation( 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 the connection as [Open the connection](//apps/${connectionId}/permissions), with this conversation's company prefix as .`, + 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.`, }; } diff --git a/server/src/services/tool-access/catalog/health.ts b/server/src/services/tool-access/catalog/health.ts index cc8647e79ec..e7b4271ab30 100644 --- a/server/src/services/tool-access/catalog/health.ts +++ b/server/src/services/tool-access/catalog/health.ts @@ -226,7 +226,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d setupUrl: connectionSetupUrl(connection), reconnectUrl: connectionReconnectUrl(connection), ...githubRecovery, - ...unreachableServerRemediation(error, connection.id, actor), + ...unreachableServerRemediation(error, actor), })); } } diff --git a/server/src/services/tool-access/catalog/publication.ts b/server/src/services/tool-access/catalog/publication.ts index 20d97192f45..f47c06c50f7 100644 --- a/server/src/services/tool-access/catalog/publication.ts +++ b/server/src/services/tool-access/catalog/publication.ts @@ -98,7 +98,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection code: failure.code, setupUrl: connectionSetupUrl(connection), reconnectUrl: connectionReconnectUrl(connection), - ...unreachableServerRemediation(error, connection.id, actor), + ...unreachableServerRemediation(error, actor), })); }