diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 286665f80e5..7031173d4ec 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -171,7 +171,8 @@ remain separate from live latency and permitted/denied agent acceptance. Scoped OpenCode assigned-tool source evidence: local and remote native sessions use the server-owned authenticated relay. The provider sees bounded `paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` schemas, -then uses pinned aliases. Search intersects fresh grants and work modes; calls +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 launch credentials stay on the controller. Former direct-catalog OpenCode diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 104aee6ae38..407ccd8c1ec 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -212,7 +212,9 @@ cover ordering, failure drain and credential projection; they do not qualify a live response-time target. 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. Discover an allowed tool, call its returned alias, revoke +search/call schemas. Confirm the search description states the 1–20 page limit +and default of 5. Follow `nextOffset` to inspect later pages. +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 retain the assigned gateway URL and token. An older direct-catalog OpenCode diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 4003cb8da6f..7eafd5afabf 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -333,6 +333,10 @@ Remote Codex sessions relay assigned app tools through the server's configured gateway. Small catalogs are sent directly. When a catalog would exceed the runner's 256-operation or 768 KiB contract limit, the server exposes `paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` instead. +Search accepts a `limit` from 1 to 20 and defaults to 5 results per page. +Use the returned `nextOffset` as `offset` to read the next page. The tool and +parameter descriptions include these bounds for providers that render numeric +schema constraints as plain number types. Search returns bounded pages of names, descriptions, and input schemas. Each page intersects the session's pinned assignments with current gateway grants. An individual schema that exceeds a page returns an `inputSchemaRef`. The same 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 21cacfd0845..8073774e0fd 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,49 @@ describe("assigned MCP runner tools", () => { const searchName = "paperclip_search_assigned_tools"; const callName = "paperclip_call_assigned_tool"; + 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 }); + const bridge = await startOpenCodeMcpBridge({ + tools: assigned.definitions(tools => tools.length <= 2), handler: call => assigned.execute(call), + }); + let id = 0; + const rpc = async (method: string, params?: 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, params }), + }); + return (await response.json()).result; + }; + try { + const listing = await rpc("tools/list"); + const search = listing.tools.find((tool: { name: string }) => tool.name === searchName); + const limit = search.inputSchema.properties.limit; + for (const description of [search.description, limit.description]) { + expect(description).toContain(`${limit.minimum} to ${limit.maximum}`); + expect(description).toContain(`default ${limit.default}`); + } + expect(limit).toMatchObject({ minimum: 1, maximum: 20, default: 5 }); + const page = async (args: Record) => { + const result = await rpc("tools/call", { name: searchName, arguments: args }); + expect(result.isError).not.toBe(true); + return JSON.parse(result.content[0].text); + }; + expect((await page({ query: "" })).tools).toHaveLength(limit.default); + const first = await page({ query: "", limit: limit.maximum }); + expect(first.tools).toHaveLength(limit.maximum); + const second = await page({ query: "", limit: limit.maximum, offset: first.nextOffset }); + expect(second.nextOffset).toBeNull(); + expect(new Set([...first.tools, ...second.tools].map((tool: { name: string }) => tool.name)).size).toBe(25); + for (const invalid of [21, 50]) { + await expect(assigned.execute({ tool: searchName, arguments: { query: "", limit: invalid } })) + .rejects.toThrow("assigned_mcp_tool_invalid_arguments"); + } + } finally { + await bridge.close(); + } + }); + it.each(["page", "escaped schema"])("keeps on-demand %s results valid through the OpenCode MCP bridge", async variant => { const schema = { type: "object", description: variant === "page" ? "x".repeat(20_000) : "\"\\\u0000🙂".repeat(30_000) }; const f = fixture(Array.from({ length: variant === "page" ? 5 : 1 }, (_, i) => ({ diff --git a/server/src/services/native-runtime/assigned-mcp-tools.ts b/server/src/services/native-runtime/assigned-mcp-tools.ts index ff43b86a6dd..0c56c8c6e7d 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.ts @@ -14,13 +14,13 @@ const SCHEMA_CHUNK_CHARACTERS = 64 * 1024; const ON_DEMAND_TOOLS: ToolDefinition[] = [ { name: SEARCH_TOOL, - description: "Search your assigned app tools by name or description and read their input schemas. Pass {\"query\":\"search terms\"}, or an empty query to browse. Omit unused optional fields instead of sending null. To fetch the next page, pass the returned nextOffset as offset. A large definition returns inputSchemaRef: set schemaTool to that name with an empty query, then pass the returned nextSchemaOffset as schemaOffset until null. Concatenate schemaJson chunks and parse JSON. Use paperclip_call_assigned_tool with a returned name and arguments matching its inputSchema.", + description: "Search your assigned app tools by name or description and read their input schemas. Pass {\"query\":\"search terms\"}, or an empty query to browse. Use limit from 1 to 20 (default 5). Omit unused optional fields instead of sending null. To fetch the next page, pass the returned nextOffset as offset. A large definition returns inputSchemaRef: set schemaTool to that name with an empty query, then pass the returned nextSchemaOffset as schemaOffset until null. Concatenate schemaJson chunks and parse JSON. Use paperclip_call_assigned_tool with a returned name and arguments matching its inputSchema.", inputSchema: { type: "object", additionalProperties: false, properties: { query: { type: "string", maxLength: 200 }, offset: { type: "integer", minimum: 0 }, - limit: { type: "integer", minimum: 1, maximum: 20 }, + 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 }, },