From 5f0bb518b04d3c401c7ca8d291d3fa94ff92186c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:07:28 -0600 Subject: [PATCH] fix(runner): handle blank assigned-tool schema references (#18637) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents discover assigned app tools through a governed gateway. > - OpenCode receives search and call tools instead of the full app catalog. > - A blank optional schema reference selected schema lookup instead of search. > - Agents received an unknown-tool error and could abandon valid app access. > - This pull request treats blank references as search and explains invalid schema requests. > - Agents can find their assigned tools while the gateway keeps its access checks. ## Linked Issues or Issue Description **What happened?** An agent called `paperclip_search_assigned_tools` with `{"query":"github","schemaTool":""}`. The server tried to find a tool with an empty name. It returned `assigned_mcp_tool_unknown` instead of search results. The agent then reported that its GitHub tools were missing. **Expected behavior** A blank optional schema reference must permit normal tool discovery. A bad schema reference must explain how to search again. **Steps to reproduce** 1. Assign an app tool to a native agent that uses on-demand discovery. 2. Search with `{"query":"github","schemaTool":""}`. 3. Observe the unknown-tool error instead of the assigned tool. **Paperclip version or commit** Reproduced from source before this fix, on master at d26628e35a. **Deployment mode** Native OpenCode with assigned MCP tools. The shared search code also serves other native providers. Related implementation: Refs #17740. A search for `schemaTool` found no duplicate issue or pull request. ## What Changed - Treat empty and whitespace-only schema references as normal search. Accept a zero schema offset in this mode. - Explain how to correct unknown references and positive offsets without a reference. - Test discovery, invocation, and revocation through the authenticated OpenCode MCP bridge. - Update tool descriptions, developer guidance, and the execution maps. ## Verification - The three new regression cases failed before the fix. - `pnpm exec vitest run server/src/services/native-runtime/assigned-mcp-tools.test.ts`: 43 tests passed after the fix. - Existing tests cover schema chunks, work-mode restrictions, revocation, approvals, and pinned tool catalogs. - `node scripts/check-maps.mjs`: passed. - `pnpm build`: passed. - `pnpm -r typecheck`: passed. - Full CI passed on commit `26d20fc19d846d46d7656a9f0160db5e81479dbc`: 55 successful checks, one neutral review check, and three skipped checks. Nothing is pending or failing. This includes all CI test shards, Runner verification, build, typecheck, security checks, and the canary dry run. - The duplicate local `pnpm test:run` completed its first five chat integration shards without failures. I stopped the remaining local run after the complete CI suite passed. The full local suite did not finish; the complete-suite result is from CI. - Greptile and the Paperclip code review each gave commit `26d20fc19d846d46d7656a9f0160db5e81479dbc` a 5/5 score with no findings. There are no unresolved review threads. - The initial focused test could not collect tests until the fresh worktree built its plugin SDK dependencies. `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps` resolved that setup requirement. ## Risks Low risk. Blank references and zero schema offsets now select normal search. Nonblank references still require an exact authorized tool name. Discovery and calls keep their existing gateway checks. The change needs no database migration. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The host does not expose the exact backend model ID or context window size in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/DEVELOPING.md | 10 +++++ maps/agent-access-map/runs-adapters.md | 4 +- maps/feature-map/runs-adapters.md | 4 ++ .../native-runtime/assigned-mcp-tools.test.ts | 44 +++++++++++++++++++ .../native-runtime/assigned-mcp-tools.ts | 15 ++++--- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c0827418fe7..d2058a79615 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2621,6 +2621,16 @@ connections, including provider-native tools. Company permissions, approval gates, and workspace isolation still apply. Explicit restrictive modes remain restrictive; omitted settings use full auto. +Assigned app tools can use `paperclip_search_assigned_tools` and +`paperclip_call_assigned_tool` instead of direct tool definitions. Search with +`{"query":"github"}` and call an exact returned tool name. Omitted, empty or +whitespace-only `schemaTool` values perform normal discovery, including when +`schemaOffset` is zero. To read a large schema, set `schemaTool` to the exact +`inputSchemaRef` from discovery and follow `nextSchemaOffset`. A positive +`schemaOffset` requires a nonblank reference. Unknown or revoked references +return guidance to search again; discovery still checks current grants and work +modes against the session's pinned catalog. + Test questions, saved plan revisions, approval before task handoff, status lookups, and `/new` preserving chat history. Hiring additionally requires the operator-controlled [runner API tools](runner-api-tools.md) rollout; enabling diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index a16aef4da4d..53d9400f4de 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -214,7 +214,9 @@ use the server-owned authenticated relay. The provider sees bounded then uses pinned aliases. Search describes its 1–20 page limit and default of 5 in the tool and parameter descriptions. It intersects fresh grants and work modes; calls retain the run's original gateway policy, validation, approvals, revocation and -audit path. Prompt arguments cannot select gateway authority. Assigned gateway +audit path. Blank schema references perform normal discovery; positive schema +offsets require an exact reference, and unavailable references explain how to +search again. Prompt arguments cannot select gateway authority. Assigned gateway launch credentials stay on the controller. Former direct-catalog OpenCode checkpoints refresh; other provider fingerprints stay unchanged. The [projection controls](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts) diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 11aa505a521..b728b8bac04 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -273,6 +273,10 @@ For native OpenCode with assigned connections, inspect the provider tool surface on both local and sandbox execution. It should contain bounded assigned-tool search/call schemas. Confirm the search description states the 1–20 page limit and default of 5. Follow `nextOffset` to inspect later pages. +Repeat discovery with empty and whitespace-only `schemaTool` values, including +`schemaOffset: 0`, and confirm it still returns searchable tools. An unknown +schema reference or a positive schema offset without a reference must explain +how to correct the call. Discover an allowed tool, call its returned alias, revoke its grant, and confirm discovery and execution enforce that revocation. Repeat in planning/ask mode with a write tool and confirm denial. The controller must diff --git a/server/src/services/native-runtime/assigned-mcp-tools.test.ts b/server/src/services/native-runtime/assigned-mcp-tools.test.ts index a5708ba1668..cfcef3b18e2 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.test.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.test.ts @@ -19,6 +19,50 @@ describe("assigned MCP runner tools", () => { const searchName = "paperclip_search_assigned_tools"; const callName = "paperclip_call_assigned_tool"; + it.each(["", " \t\n"])("discovers and calls assigned tools through MCP with a blank schema reference %j", async schemaTool => { + const github = descriptor("github_bot:read_pull_request"); + const f = fixture([github, descriptor("calendar.search")]); + const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" }); + const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) }); + let id = 0; + const call = async (name: string, args: Record) => { + const response = await fetch(bridge.url, { + method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }), + }); + const result = (await response.json()).result; + expect(result.isError).not.toBe(true); + return JSON.parse(result.content[0].text); + }; + try { + const page = await call(searchName, { query: "github", schemaTool }); + expect(page.tools).toHaveLength(1); + expect(page.tools[0]).toMatchObject({ description: expect.stringContaining(github.name), inputSchema: github.parametersSchema }); + expect(await call(searchName, { query: "github", schemaTool, schemaOffset: 0 })).toEqual(page); + expect((await call(searchName, { query: "", schemaTool })).tools).toHaveLength(2); + await call(callName, { name: page.tools[0].name, arguments: { query: "fixture" } }); + expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + tool: github.name, parameters: { query: "fixture" }, gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken, + })); + f.listToolsForNamedGateway.mockResolvedValue([]); + expect(await call(searchName, { query: "github", schemaTool })).toEqual({ tools: [], nextOffset: null }); + } finally { + await bridge.close(); + } + }); + + it("explains how to recover from an unknown schema reference or an offset without a reference", async () => { + const f = fixture([descriptor("github_bot:read_pull_request")]); + const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" }); + await expect(assigned.execute({ tool: searchName, arguments: { query: "github", schemaTool: "unknown" } })) + .rejects.toThrow("Omit schemaTool to search assigned tools"); + for (const schemaTool of [undefined, "", " \t\n"]) { + await expect(assigned.execute({ tool: searchName, arguments: { query: "github", schemaTool, schemaOffset: 1 } })) + .rejects.toThrow("A positive schemaOffset requires an exact inputSchemaRef in schemaTool"); + } + expect(f.executeTool).not.toHaveBeenCalled(); + }); + it.each([undefined, "on_demand"] as const)("advertises usable page limits through MCP discovery (%s)", async delivery => { const f = fixture(Array.from({ length: 25 }, (_, i) => descriptor(`operation_${i}`))); const assigned = await createAssignedMcpTools({ ...f, delivery }); diff --git a/server/src/services/native-runtime/assigned-mcp-tools.ts b/server/src/services/native-runtime/assigned-mcp-tools.ts index 00473c3b5d5..6e5bf20bf23 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.ts @@ -21,8 +21,8 @@ const ON_DEMAND_TOOLS: ToolDefinition[] = [ query: { type: "string", maxLength: 200 }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 20, default: 5, description: "Maximum results per page (1 to 20; default 5)." }, - schemaTool: { type: "string", description: "Exact inputSchemaRef from a search result." }, - schemaOffset: { type: "integer", minimum: 0 }, + schemaTool: { type: "string", description: "Exact inputSchemaRef from a search result. Omit or leave blank for a normal tool search." }, + schemaOffset: { type: "integer", minimum: 0, description: "Schema chunk offset. A positive offset requires an exact inputSchemaRef in schemaTool." }, }, required: ["query"], }, @@ -116,14 +116,17 @@ export async function createAssignedMcpTools(input: { const offset = args.offset ?? 0; const limit = args.limit ?? 5; const schemaOffset = args.schemaOffset ?? 0; + const hasSchemaTool = typeof args.schemaTool === "string" && args.schemaTool.trim().length > 0; if (typeof args.query !== "string" || args.query.length > 200 || typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > 20 || (args.schemaTool !== undefined && typeof args.schemaTool !== "string") || - typeof schemaOffset !== "number" || !Number.isSafeInteger(schemaOffset) || schemaOffset < 0 || - (args.schemaOffset !== undefined && args.schemaTool === undefined)) { + typeof schemaOffset !== "number" || !Number.isSafeInteger(schemaOffset) || schemaOffset < 0) { throw new Error("assigned_mcp_tool_invalid_arguments"); } + if (schemaOffset > 0 && !hasSchemaTool) { + throw new Error("assigned_mcp_tool_invalid_arguments: A positive schemaOffset requires an exact inputSchemaRef in schemaTool. Omit schemaTool and schemaOffset to search assigned tools."); + } // Intersect fresh grants with this session's pinned catalog. Revocations // take effect immediately; newly assigned tools require a new session. const current = new Map(deduplicateToolDescriptors(await input.gateway.listToolsForNamedGateway({ @@ -135,9 +138,9 @@ export async function createAssignedMcpTools(input: { return fresh && permits(tool) && permits(tool, currentWorkMode) && permits(fresh) && permits(fresh, currentWorkMode); }); - if (typeof args.schemaTool === "string") { + if (hasSchemaTool) { const selected = authorized.find(([name]) => name === args.schemaTool); - if (!selected) throw new Error("assigned_mcp_tool_unknown"); + if (!selected) throw new Error("assigned_mcp_tool_unknown: schemaTool must match an authorized inputSchemaRef. Omit schemaTool to search assigned tools, then use an exact inputSchemaRef from the results."); const schema = JSON.stringify(selected[1].parametersSchema); const end = schemaOffset + schemaChunkCharacters; return {