Files
PaperClipAI/server/src/services/native-runtime/assigned-mcp-tools.ts
T
DottaandPaperclip 5f0bb518b0 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>
2026-11-02 12:07:28 -06:00

225 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { deduplicateToolDescriptors, ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
type WorkMode = "standard" | "planning" | "ask" | "skill_test";
type ToolDefinition = Record<string, unknown>;
const SEARCH_TOOL = "paperclip_search_assigned_tools";
const CALL_TOOL = "paperclip_call_assigned_tool";
// A single valid runner schema can be 512 KiB. Leave room for its description
// while keeping the complete result below the 768 KiB provider-result bound.
const SEARCH_PAGE_BYTES = 640 * 1024;
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. 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, default: 5, description: "Maximum results per page (1 to 20; default 5)." },
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"],
},
},
{
name: CALL_TOOL,
description: "Call an assigned app tool discovered with paperclip_search_assigned_tools. Use its exact returned name and arguments matching its inputSchema. The same permissions, approvals, and audit rules apply as for direct app tools.",
inputSchema: {
type: "object", additionalProperties: false,
properties: { name: { type: "string" }, arguments: { type: "object", additionalProperties: true } },
required: ["name", "arguments"],
},
},
];
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("assigned_mcp_tool_invalid_arguments");
}
return value as Record<string, unknown>;
}
function definition(name: string, tool: ToolGatewayDescriptor): ToolDefinition {
return { name, description: `${tool.displayName}: ${tool.description}`, inputSchema: structuredClone(tool.parametersSchema) };
}
// Execution must use the app's configured gateway, including deployment
// restrictions, OAuth refresh, and approval delivery. Never fall back to an
// isolated gateway whose defaults differ from the running instance.
const assignedMcpGateways = new WeakMap<Db, ToolGatewayService>();
export function registerAssignedMcpGateway(db: Db, gateway: ToolGatewayService): void {
assignedMcpGateways.set(db, gateway);
}
export function getAssignedMcpGateway(db: Db): ToolGatewayService {
const gateway = assignedMcpGateways.get(db);
if (!gateway) throw new Error("assigned_mcp_gateway_unavailable");
return gateway;
}
/** No configured gateway means no authorized connection tools or instructions. */
export async function resolveAssignedConnectionInstructionsForRun(db: Db, binding: { companyId: string; agentId: string; runId: string }) {
const gateway = assignedMcpGateways.get(db);
return gateway ? gateway.resolveConnectionInstructionsForRun(binding) : null;
}
function assignedToolName(name: string): string {
const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
const separator = name.lastIndexOf(":");
const readable = separator >= 0
? `${sanitize(name.slice(0, separator)).slice(0, 20) || "tool"}_${sanitize(name.slice(separator + 1)).slice(-26) || "action"}`
: sanitize(name).slice(0, 47) || "tool";
const digest = createHash("sha256").update(name).digest("hex").slice(0, 12);
return `app_${readable}_${digest}`;
}
/** Server-owned MCP bindings projected into the runner's existing tool channel. */
export async function createAssignedMcpTools(input: {
gateway: ToolGatewayService;
gatewayPublicId: string;
bearerToken: string;
workMode?: WorkMode;
/** Bound provider grammar complexity independently of protocol byte capacity. */
delivery?: "on_demand";
}) {
const listed = await input.gateway.listToolsForNamedGateway({
gatewayPublicId: input.gatewayPublicId,
bearerToken: input.bearerToken,
});
const tools = new Map<string, ToolGatewayDescriptor>();
for (const descriptor of deduplicateToolDescriptors(listed)) {
const name = assignedToolName(descriptor.name);
const previous = tools.get(name);
if (previous) {
throw new ToolGatewayHttpError(409,
`Assigned MCP tool name collision for ${JSON.stringify([previous.name, descriptor.name])}.`,
"assigned_mcp_tool_name_collision");
}
tools.set(name, descriptor);
}
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
// OpenCode's HTTP bridge returns at most 64K JSON characters. Leave room
// for the envelope and for JSON escaping when returning a schema chunk.
// Other harnesses retain the larger PRP page and chunk budgets.
const searchPageBytes = input.delivery === "on_demand" ? 32 * 1024 : SEARCH_PAGE_BYTES;
const schemaChunkCharacters = input.delivery === "on_demand" ? 16 * 1024 : SCHEMA_CHUNK_CHARACTERS;
async function search(argumentsValue: unknown, currentWorkMode?: WorkMode) {
const args = object(argumentsValue);
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) {
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({
gatewayPublicId: input.gatewayPublicId, bearerToken: input.bearerToken,
})).map(tool => [tool.name, tool]));
const terms = args.query.toLowerCase().trim().split(/\s+/).filter(Boolean);
const authorized = [...tools].filter(([, tool]) => {
const fresh = current.get(tool.name);
return fresh && permits(tool) && permits(tool, currentWorkMode) &&
permits(fresh) && permits(fresh, currentWorkMode);
});
if (hasSchemaTool) {
const selected = authorized.find(([name]) => name === args.schemaTool);
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 {
name: selected[0], schemaJson: schema.slice(schemaOffset, end),
nextSchemaOffset: end < schema.length ? end : null,
};
}
// Keep offsets anchored to the pinned catalog while fresh grants only
// decide which entries are returned from that stable sequence.
const authorizedNames = new Set(authorized.map(([name]) => name));
const matches = [...tools].filter(([, tool]) => permits(tool)).filter(([name, tool]) =>
terms.every(term => `${name} ${tool.displayName} ${tool.description}`.toLowerCase().includes(term)))
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
const page: ToolDefinition[] = [];
let nextOffset = offset;
while (nextOffset < matches.length && page.length < limit) {
const [name, tool] = matches[nextOffset]!;
if (!authorizedNames.has(name)) {
nextOffset += 1;
continue;
}
let next = definition(name, tool);
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > searchPageBytes) {
// One large schema must not block browsing the tools after it. Expose
// a reference whose schema can be fetched in bounded, reauthorized
// chunks, without weakening the gateway's argument validation.
next = { name, description: String(next.description).slice(0, 2000), inputSchemaRef: name };
}
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > searchPageBytes) {
break;
}
page.push(next);
nextOffset += 1;
}
return { tools: page, nextOffset: nextOffset < matches.length ? nextOffset : null };
}
return {
definitions(fits?: (definitions: ToolDefinition[]) => boolean): ToolDefinition[] {
const direct = [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => definition(name, tool));
if (input.delivery !== "on_demand" && (!fits || fits(direct))) return direct;
const compact = structuredClone(ON_DEMAND_TOOLS);
if (fits && !fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
return compact;
},
has(name: string): boolean {
return tools.has(name) || name === SEARCH_TOOL || name === CALL_TOOL;
},
async execute(call: { tool: string; arguments: unknown }, currentWorkMode?: WorkMode): Promise<unknown> {
if (call.tool === SEARCH_TOOL) return search(call.arguments, currentWorkMode);
let name = call.tool;
let parameters = call.arguments;
if (name === CALL_TOOL) {
const args = object(parameters);
if (typeof args.name !== "string") throw new Error("assigned_mcp_tool_invalid_arguments");
name = args.name;
parameters = object(args.arguments);
}
const descriptor = tools.get(name);
if (!descriptor) throw new Error("assigned_mcp_tool_unknown");
if (!permits(descriptor) || !permits(descriptor, currentWorkMode)) throw new Error("paperclip_runner_tool_mode_denied");
// Reauthorize through the existing gateway on every call. Discovery is
// not a grant: revocation, policy, approval, and audit remain server-owned.
const result = await input.gateway.executeTool({
gatewayPublicId: input.gatewayPublicId,
sessionToken: input.bearerToken,
tool: descriptor.name,
parameters,
});
if (result.status !== "completed" && result.status !== "replayed") {
throw new Error("assigned_mcp_tool_execution_incomplete");
}
// The gateway normally throws provider errors. Keep a malformed or older
// provider response from being reported as successful through PRP.
if (result.result && typeof result.result === "object" && "isError" in result.result && result.result.isError === true) {
throw new ToolGatewayHttpError(502, "The assigned tool returned an error.", "tool_error");
}
return result.result;
},
};
}