mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
60a99307b3
commit
5f0bb518b0
5 files changed
+70
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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: "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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user