mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
fix(server): disclose assigned tool search page bounds (#18056)
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/187/changes/paperclip/187 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the control plane for AI agents at work. > - Native agents find connected app tools through the assigned-tool search tool. > - Large catalogs require small pages and an offset for the next page. > - The input schema enforces a maximum of 20 results, but its descriptions omit that number. > - Providers can render the integer field as a plain number and hide its numeric bounds. > - This pull request puts the existing range and default in both descriptions. > - Agents can choose a valid page size before they make a search call. ## Linked Issues or Issue Description **What happened?** An agent can request `limit: 50` for `paperclip_search_assigned_tools`. The tool rejects the call with `invalid_tool_call` at `/properties/limit/maximum`. Its model-visible description does not state the accepted range. **Expected behavior** The tool description and the `limit` description must state the range of 1 to 20 and the default of 5. Agents can use `nextOffset` to read more results. **Steps to reproduce** 1. Inspect the assigned-tool search description in a provider tool list. 2. Render the integer schema as a TypeScript number field. 3. Request `limit: 50` without a visible page-size bound. 4. Observe the validation error. Retry with a valid size and offset. ### Hypothesis and thesis for a fix The numeric schema is correct, but the descriptions omit the page limit. Put the existing range and default in text that all providers can see. Keep the strict validation in the assigned-tool discovery owner. This should reduce avoidable retries. It does not guarantee that every model call will be valid. ## What Changed - State the accepted range and default in the search description and the `limit` property. Add the existing default as schema metadata. - Test the tool listing and calls through the real HTTP MCP bridge for both compact delivery paths. - Document the page size and offset procedure in the Runner README and verification maps. ## Verification - Red: the two new bridge tests fail against the unchanged implementation because the listing omits the numeric guidance. - Green: all 37 tests in `server/src/services/native-runtime/assigned-mcp-tools.test.ts` pass after the change. The tests cover default pages, a 20-result page, offset continuation without duplicates, and rejection of 21 and 50. Existing tests cover grants, revocation, modes, approvals, aliases, and schema chunks. - Current head `7d658d0c4e4d9db3feb53a5c508214440ff058c4`: 37/37 focused tests pass locally. Greptile is 5/5 with no unresolved threads. Current-head CI is green, including typecheck, build, unit tests, Runner checks, and all browser shards ([CI run, attempt 2](https://github.com/paperclipai/paperclip/actions/runs/40733284523/attempts/2)). Browser shard 7 initially failed in a newly merged agent YAML editor test when its clipboard read included the whole page. The single retry passed without a code change; the exact cause is unconfirmed. The original failure remains in [the CI log](https://github.com/paperclipai/paperclip/actions/runs/40733284523/job/122975224906). - Run the focused suite from `server/`: `pnpm exec vitest run src/services/native-runtime/assigned-mcp-tools.test.ts`. - `node scripts/check-maps.mjs` and `git diff --check` pass. - The bridge uses real local HTTP. The app gateway uses synthetic descriptors. No deployed build or live model improvement is claimed. - Full local verification is incomplete. Recursive typecheck and server typecheck require `cargo`, which this workspace lacks. A direct server TypeScript check was killed by the workspace. The full test suite had no final verdict before the resource stop. CI must supply the remaining verification. ## Risks Low risk. This change describes existing limits and adds default metadata. It does not change search execution, page caps, gateway access, revocation, approvals, or audit. Models can still send invalid calls. This change has not measured their future retry rate. ## Model Used OpenAI Codex, GPT-6 family, with tool use and code execution. The exact provider model ID and context window are not exposed to 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, its capabilities, and unavailable model metadata - [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 - [x] I have described the issue in-PR following the bug report fields - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] The public branch name describes the change and has no internal task ID - [x] I have run the focused tests locally and they pass - [x] I have added tests for the reported failure - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open recommendations or follow-ups - [x] I will address reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1aa3a21ac4
commit
c4b6168e08
5 files changed
+54
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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) => ({
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user