Files
PaperClipAI/server/src/services/native-runtime/assigned-mcp-tools.ts
T
DottaandPaperclip 1e731520bb fix(mcp): deduplicate discovered tool descriptors (#16281)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents receive governed app tools through the MCP gateway.
> - GitHub review tasks can contain a PR discussion and inline finding
threads.
> - Discovery repeated each tool for every conversation linked to the
task.
> - Native tool registration treated these repeated descriptors as a
fatal collision.
> - This pull request lists GitHub tools once and deduplicates identical
descriptors across providers.
> - Agents can continue work while conflicting definitions and normal
tool permissions remain enforced.

## Linked Issues or Issue Description

**What happened?**

A follow-up run on a GitHub review task failed with
`assigned_mcp_tool_name_collision` before the provider started. The task
had a main conversation and an inline finding thread. Discovery returned
the same seven tools twice. Repeated descriptors from other providers
could cause the same failure.

**Expected behavior**

Discovery exposes each identical tool once. Separate connection names
remain separate tools. Conflicting definitions remain an error. Every
call still passes the gateway's policy, approval, revocation, and audit
checks.

**Steps to reproduce**

1. Run a GitHub review and publish an inline finding.
2. Send a follow-up request on the same PR task.
3. Inspect the task-bound bot catalog and the next native startup.
4. Separately, return two identical descriptors from an assigned app or
plugin catalog.

Related: #13717 added the GitHub review workflow.

## What Changed

- Use a distinct GitHub discovery query across conversations that share
an endpoint and task. Keep both thread mappings.
- Deduplicate identical gateway descriptors by their full gateway name
across providers. Use this rule for MCP listings, plugin listings,
assigned-tool startup, and fresh assigned-tool discovery.
- Reject conflicting descriptors with `tool_name_collision`. Keep the
projected-name collision guard.
- Cover GitHub inline-thread discovery, repeated plugin/app tools,
direct and on-demand catalogs, revocation, conflicting bindings, and
separate connection names.
- Update the execution contract, GitHub documentation, and agent access
map.

## Verification

- Regression baseline: six new assertions failed on the original
implementation. GitHub returned fourteen entries, plugin discovery
repeated a tool, and native startup failed on repeated app tools.
- Focused verification passed: 272 GitHub cases, all 29 assigned-tool
cases, and all 88 gateway acceptance cases.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `node scripts/check-maps.mjs` passed.
- Current-head CI passed: 55 checks passed, two inapplicable checks
skipped. Greptile scored the current head 5/5 with no unresolved
threads.
- Full local `pnpm test:run` encountered a 15-second timeout in the
existing bearer-auth throttling test, before tool discovery. That test
passed in the separate 88-case gateway run and on one isolated rerun.
The duplicate broad local run was then stopped; the complete
current-head CI matrix passed.

## Risks

- Deduplication keeps the first identical descriptor and preserves
listing order. Same-name descriptors with different definitions fail
with HTTP 409 rather than selecting an ambiguous binding.
- The GitHub query changes result cardinality. It retains the existing
task/company/agent filters and leaves conversation rows intact.
- No schema migration or credential change. Live staging recovery
requires deployment and a new authorized run.

## Model Used

OpenAI Codex, based on GPT-6, with code inspection, editing, browser
investigation, and test execution. The exact serving model ID and
context window are not exposed 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-10-14 07:39:54 -05:00

200 lines
10 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";
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. 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 },
schemaTool: { type: "string", description: "Exact inputSchemaRef from a search result." },
schemaOffset: { type: "integer", minimum: 0 },
},
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;
}) {
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);
if (tools.has(name)) throw new Error("assigned_mcp_tool_name_collision");
tools.set(name, descriptor);
}
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
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;
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)) {
throw new Error("assigned_mcp_tool_invalid_arguments");
}
// 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 (typeof args.schemaTool === "string") {
const selected = authorized.find(([name]) => name === args.schemaTool);
if (!selected) throw new Error("assigned_mcp_tool_unknown");
const schema = JSON.stringify(selected[1].parametersSchema);
const end = schemaOffset + SCHEMA_CHUNK_CHARACTERS;
return {
name: selected[0], schemaJson: schema.slice(schemaOffset, end),
nextSchemaOffset: end < schema.length ? end : null,
};
}
const matches = authorized.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[] = [];
for (const [name, tool] of matches.slice(offset, offset + limit)) {
let next = definition(name, tool);
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > SEARCH_PAGE_BYTES) {
// 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") > SEARCH_PAGE_BYTES) {
break;
}
page.push(next);
}
return { tools: page, nextOffset: offset + page.length < matches.length ? offset + page.length : null };
}
return {
definitions(fits?: (definitions: ToolDefinition[]) => boolean): ToolDefinition[] {
const direct = [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => definition(name, tool));
if (!fits || fits(direct)) return direct;
const compact = structuredClone(ON_DEMAND_TOOLS);
if (!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;
},
};
}