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 {