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:
DottaandPaperclip authored and GitHub committed 2026-10-28 13:55:48 -05:00
1 parent 1aa3a21ac4
commit c4b6168e08
5 files changed
+54 -4

No files matched your search

+2 -1
View File
@@ -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
+3 -1
View File
@@ -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
+4
View File
@@ -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 },
},