Files
PaperClipAI/server/src/services/native-runtime/assigned-mcp-tools.test.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

509 lines
31 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js";
import { startOpenCodeMcpBridge } from "../../vendor/paperclip-runner/testing.js";
function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor {
return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk };
}
function fixture(tools: ToolGatewayDescriptor[]) {
const listToolsForNamedGateway = vi.fn().mockResolvedValue(tools);
const executeTool = vi.fn().mockResolvedValue({ status: "completed", result: { content: [{ type: "text", text: "memory found" }] } });
const gateway = { listToolsForNamedGateway, executeTool } as unknown as ToolGatewayService;
return { gateway, listToolsForNamedGateway, executeTool, gatewayPublicId: "gateway-fixture", bearerToken: "private-run-token-never-project" };
}
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 });
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) => ({
...descriptor(`large_${i}`), parametersSchema: schema,
})));
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) });
let id = 0;
const search = async (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: searchName, arguments: args } }),
});
const rpc = await response.json() as { result: { isError?: boolean; content: Array<{ text: string }> } };
expect(rpc.result.isError).not.toBe(true);
return JSON.parse(rpc.result.content[0]!.text);
};
try {
let page = await search({ query: "", limit: 20 });
if (variant === "page") {
const seen = [...page.tools];
while (page.nextOffset !== null) {
page = await search({ query: "", offset: page.nextOffset, limit: 20 });
seen.push(...page.tools);
}
expect(seen).toHaveLength(5);
expect(seen.every(tool => JSON.stringify(tool.inputSchema) === JSON.stringify(schema))).toBe(true);
} else {
const name = page.tools[0]!.inputSchemaRef;
expect(name).toBeTypeOf("string");
let offset: number | null = 0;
let reconstructed = "";
do {
const chunk = await search({ query: "", schemaTool: name, schemaOffset: offset });
reconstructed += chunk.schemaJson;
offset = chunk.nextSchemaOffset;
} while (offset !== null);
expect(JSON.parse(reconstructed)).toEqual(schema);
}
} finally {
await bridge.close();
}
});
it.each([1, 741])("uses bounded on-demand delivery for %i tools even when the direct catalog fits", async count => {
const f = fixture(Array.from({ length: count }, (_, i) => descriptor(`app.operation_${i}`)));
const direct = await createAssignedMcpTools(f);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const fits = vi.fn(() => true);
expect(assigned.definitions(fits).map(tool => tool.name)).toEqual([searchName, callName]);
expect(fits).toHaveBeenCalledExactlyOnceWith(assigned.definitions());
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as { tools: Array<{ name: string }> };
expect(page.tools[0]!.name).toBe(direct.definitions().map(tool => String(tool.name)).sort()[0]);
await assigned.execute({ tool: callName, arguments: { name: page.tools[0]!.name, arguments: { query: "fixture" }, gatewayPublicId: "untrusted", bearerToken: "untrusted" } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken }));
f.listToolsForNamedGateway.mockResolvedValue([]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).resolves.toEqual({ tools: [], nextOffset: null });
});
it("keeps on-demand aliases pinned, mode restricted, revocable and approval gated", async () => {
const read = descriptor("mcp.first:search");
const write = descriptor("mcp.second:search", "write");
const f = fixture([read, write]);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const page = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string; description: string }> };
expect(new Set(page.tools.map(tool => tool.name)).size).toBe(2);
const writeAlias = page.tools.find(tool => tool.description.includes(write.name))!.name;
const call = { tool: callName, arguments: { name: writeAlias, arguments: {} } };
await expect(assigned.execute(call, "planning")).rejects.toThrow("paperclip_runner_tool_mode_denied");
await expect(assigned.execute(call, "ask")).rejects.toThrow("paperclip_runner_tool_mode_denied");
expect(f.executeTool).not.toHaveBeenCalled();
const denied = new ToolGatewayHttpError(403, "Approval required", "approval_required");
f.executeTool.mockRejectedValueOnce(denied);
await expect(assigned.execute(call)).rejects.toBe(denied);
f.listToolsForNamedGateway.mockResolvedValue([read, descriptor("newly.granted")]);
const refreshed = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string }> };
expect(refreshed.tools).toHaveLength(1);
expect(refreshed.tools[0]!.name).not.toBe(writeAlias);
// Execution still goes through the original gateway, which reauthorizes
// each call; discovery never grants an added tool to this pinned session.
const revoked = new ToolGatewayHttpError(403, "Revoked", "connection_revoked");
f.executeTool.mockRejectedValueOnce(revoked);
await expect(assigned.execute(call)).rejects.toBe(revoked);
await expect(assigned.execute({ tool: callName, arguments: { name: "newly.granted", arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
});
it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => {
const read = descriptor("memory.search");
const write = descriptor("memory.store", "write");
const f = fixture([read, structuredClone(read), write, structuredClone(write)]);
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
expect(direct).toHaveLength(2);
expect(assigned.definitions(tools => tools.length <= 2)).toEqual(direct);
expect(assigned.definitions(tools => tools[0]?.name === searchName).map(tool => tool.name)).toEqual([searchName, callName]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } }, "planning"))
.resolves.toEqual({ tools: [direct[0]], nextOffset: null });
await assigned.execute({ tool: callName, arguments: { name: direct[0]!.name, arguments: { query: "fixture" } } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tool: read.name }));
f.listToolsForNamedGateway.mockResolvedValue([write, structuredClone(write)]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } }))
.resolves.toEqual({ tools: [direct[1]], nextOffset: null });
});
it.each([
{ risk: "write" as const },
{ connectionId: "another-connection" },
{ parametersSchema: { type: "object", properties: { secret: { type: "string" } } } },
])("rejects conflicting definitions with the same gateway name: %j", async change => {
const tool = descriptor("memory.search");
const f = fixture([tool, { ...tool, ...change }]);
await expect(createAssignedMcpTools(f)).rejects.toMatchObject({ reasonCode: "tool_name_collision" });
expect(f.executeTool).not.toHaveBeenCalled();
});
it("identifies the conflicting tool and both connection bindings without exporting descriptor metadata", async () => {
const tool = { ...descriptor("calendar.search"), connectionId: "first-connection", providerMetadata: { token: "private-token" } };
const f = fixture([tool, { ...tool, connectionId: "second-connection" }]);
await expect(createAssignedMcpTools(f)).rejects.toMatchObject({ reasonCode: "tool_name_collision",
message: 'Conflicting tool definitions share the name "calendar.search" (connections ["first-connection","second-connection"]).' });
expect(f.executeTool).not.toHaveBeenCalled();
});
it("keeps same-action tools from distinct connections separately callable", async () => {
const f = fixture([descriptor("mcp.memory-first:search"), descriptor("mcp.memory-second:search")]);
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
expect(new Set(direct.map(tool => tool.name)).size).toBe(2);
for (const tool of direct) await assigned.execute({ tool: tool.name as string, arguments: {} });
expect(f.executeTool.mock.calls.map(([call]) => call.tool)).toEqual(["mcp.memory-first:search", "mcp.memory-second:search"]);
});
it("pages through an oversized catalog and calls every tool through the original gateway", async () => {
const f = fixture(Array.from({ length: 224 }, (_, i) => descriptor(`app.action_${i}`)));
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
const compact = assigned.definitions(tools => tools.length <= 200);
expect(compact.map(tool => tool.name)).toEqual([searchName, callName]);
expect(assigned.definitions(tools => tools.length <= 224)).toEqual(direct);
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
const seen: string[] = [];
let offset: number | null = 0;
do {
const page = await assigned.execute({ tool: searchName, arguments: { query: "", offset, limit: 20 } }) as {
tools: Array<{ name: string; inputSchema: unknown }>; nextOffset: number | null;
};
expect(page.tools.length).toBeLessThanOrEqual(20);
for (const tool of page.tools) {
seen.push(tool.name);
expect(tool.inputSchema).toEqual(descriptor("any").parametersSchema);
await assigned.execute({ tool: callName, arguments: { name: tool.name, arguments: { query: "fixture" } } });
}
offset = page.nextOffset;
} while (offset !== null);
expect(seen).toEqual(direct.map(tool => tool.name).sort());
expect(f.executeTool).toHaveBeenCalledTimes(224);
expect(new Set(f.executeTool.mock.calls.map(([call]) => call.tool)).size).toBe(224);
for (const [call] of f.executeTool.mock.calls) {
expect(call).toMatchObject({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken, parameters: { query: "fixture" } });
expect(call).not.toHaveProperty("approvedActionRequestId");
}
});
it("searches only pinned tools still granted by fresh discovery without projecting metadata", async () => {
const f = fixture([descriptor("calendar.search"), descriptor("mail.search"), descriptor("calendar.remove", "write")]);
const assigned = await createAssignedMcpTools(f);
f.listToolsForNamedGateway.mockResolvedValue([
{ ...descriptor("calendar.search"), providerMetadata: { token: "secret-provider-token" } },
descriptor("calendar.remove", "write"), descriptor("calendar.new_grant"),
]);
const result = await assigned.execute({ tool: searchName, arguments: { query: "calendar" } }, "planning");
expect(result).toEqual({ tools: [assigned.definitions()[0]], nextOffset: null });
expect(JSON.stringify(result)).not.toMatch(/private-|secret-provider|gateway-fixture|new_grant/);
f.listToolsForNamedGateway.mockRejectedValue(new Error("gateway_token_revoked"));
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).rejects.toThrow("gateway_token_revoked");
});
it("keeps pagination stable when an earlier tool is revoked between pages", async () => {
const descriptors = ["alpha", "bravo", "charlie", "delta"].map(name => descriptor(name));
const f = fixture(descriptors);
const assigned = await createAssignedMcpTools(f);
const definitions = assigned.definitions();
const first = await assigned.execute({
tool: searchName, arguments: { query: "", offset: 0, limit: 2 },
}) as { tools: unknown[]; nextOffset: number };
expect(first).toEqual({ tools: definitions.slice(0, 2), nextOffset: 2 });
f.listToolsForNamedGateway.mockResolvedValue(descriptors.slice(1));
await expect(assigned.execute({
tool: searchName, arguments: { query: "", offset: first.nextOffset, limit: 2 },
})).resolves.toEqual({ tools: definitions.slice(2), nextOffset: null });
f.listToolsForNamedGateway.mockResolvedValue([descriptors[1]!, descriptors[3]!]);
await expect(assigned.execute({
tool: searchName, arguments: { query: "", offset: first.nextOffset, limit: 2 },
})).resolves.toEqual({ tools: definitions.slice(3), nextOffset: null });
});
it.each(["planning", "ask"] as const)("preserves pinned and fresh %s restrictions through the call wrapper", async mode => {
const f = fixture([descriptor("read"), descriptor("write", "write")]);
const assigned = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode: mode });
const call = { tool: callName, arguments: { name: assigned.definitions()[1]!.name, arguments: {} } };
await expect(assigned.execute(call, mode)).rejects.toThrow("paperclip_runner_tool_mode_denied");
await expect(restricted.execute(call, "standard")).rejects.toThrow("paperclip_runner_tool_mode_denied");
expect(f.executeTool).not.toHaveBeenCalled();
const discovery = await restricted.execute({ tool: searchName, arguments: { query: "" } }, "standard");
expect(discovery).toEqual({ tools: [assigned.definitions()[0]], nextOffset: null });
});
it.each(["approval_required", "connection_revoked", "tool_error"])("preserves %s through the call wrapper", async reason => {
const f = fixture([descriptor("write", "write")]);
const assigned = await createAssignedMcpTools(f);
const error = new ToolGatewayHttpError(403, "Denied", reason);
f.executeTool.mockRejectedValue(error);
await expect(assigned.execute({ tool: callName, arguments: { name: assigned.definitions()[0]!.name, arguments: {} } })).rejects.toBe(error);
});
it("bounds search pages by bytes and rejects invalid wrapper arguments and unassigned targets", async () => {
const f = fixture(Array.from({ length: 4 }, (_, i) => ({
...descriptor(`large_${i}`),
parametersSchema: { type: "object", description: "x".repeat(250 * 1024) },
})));
const assigned = await createAssignedMcpTools(f);
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 20 } }) as { tools: unknown[]; nextOffset: number };
expect(page.tools).toHaveLength(2);
expect(page.nextOffset).toBe(2);
for (const args of [null, [], { query: "x".repeat(201) }, { query: "", offset: -1 }, { query: "", limit: 21 }, { query: "", offset: 0.5 }]) {
await expect(assigned.execute({ tool: searchName, arguments: args })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
}
for (const name of ["unassigned", searchName, callName]) {
await expect(assigned.execute({ tool: callName, arguments: { name, arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
}
await expect(assigned.execute({ tool: callName, arguments: { name: "unassigned", arguments: [] } })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
expect(f.executeTool).not.toHaveBeenCalled();
});
it("keeps individually oversized schemas and later tools discoverable with bounded schema chunks", async () => {
const schema = { type: "object", description: "\u0000🙂".repeat(150_000), properties: {} };
const f = fixture([{ ...descriptor("a_large"), parametersSchema: schema }, descriptor("z_small")]);
const assigned = await createAssignedMcpTools(f);
const large = assigned.definitions()[0]!.name as string;
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as {
tools: Array<Record<string, unknown>>; nextOffset: number;
};
expect(page.tools).toEqual([{ name: large, description: "a_large: Use a_large", inputSchemaRef: large }]);
expect(page.nextOffset).toBe(1);
await expect(assigned.execute({ tool: searchName, arguments: { query: "", offset: page.nextOffset } }))
.resolves.toEqual({ tools: [assigned.definitions()[1]], nextOffset: null });
let schemaOffset: number | null = 0;
let serialized = "";
do {
const chunk = await assigned.execute({ tool: searchName, arguments: { query: "", schemaTool: large, schemaOffset } }) as {
schemaJson: string; nextSchemaOffset: number | null;
};
expect(Buffer.byteLength(JSON.stringify(chunk))).toBeLessThan(640 * 1024);
serialized += chunk.schemaJson;
schemaOffset = chunk.nextSchemaOffset;
} while (schemaOffset !== null);
expect(JSON.parse(serialized)).toEqual(schema);
await assigned.execute({ tool: callName, arguments: { name: large, arguments: {} } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tool: "a_large" }));
f.listToolsForNamedGateway.mockResolvedValue([descriptor("z_small")]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "", schemaTool: large } })).rejects.toThrow("assigned_mcp_tool_unknown");
await expect(assigned.execute({ tool: searchName, arguments: { query: "", schemaOffset: 1 } })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
});
it("requires a configured gateway registered for the exact database instance", () => {
const firstDb = {} as Db;
const secondDb = {} as Db;
const first = fixture([]).gateway;
const second = fixture([]).gateway;
expect(() => getAssignedMcpGateway(firstDb)).toThrow("assigned_mcp_gateway_unavailable");
registerAssignedMcpGateway(firstDb, first);
expect(getAssignedMcpGateway(firstDb)).toBe(first);
expect(() => getAssignedMcpGateway(secondDb)).toThrow("assigned_mcp_gateway_unavailable");
registerAssignedMcpGateway(secondDb, second);
expect(getAssignedMcpGateway(secondDb)).toBe(second);
expect(getAssignedMcpGateway(firstDb)).toBe(first);
registerAssignedMcpGateway(firstDb, second);
expect(getAssignedMcpGateway(firstDb)).toBe(second);
});
it("projects only authorized gateway tools with stable bounded collision-resistant names", async () => {
const names = ["memory.search", "memory-search", "x".repeat(120), "🧠"];
const f = fixture(names.map(name => descriptor(name)));
const first = await createAssignedMcpTools(f);
const reversed = await createAssignedMcpTools({ ...f, gateway: fixture([...names].reverse().map(name => descriptor(name))).gateway });
const projected = first.definitions().map(tool => tool.name as string);
expect(new Set(projected).size).toBe(names.length);
for (const name of projected) {
expect(name).toMatch(/^app_[a-zA-Z0-9_]+_[a-f0-9]{12}$/);
expect(name.length).toBeLessThanOrEqual(64);
expect(first.has(name)).toBe(true);
}
expect(reversed.definitions().map(tool => tool.name)).toEqual([...projected].reverse());
expect(f.listToolsForNamedGateway).toHaveBeenCalledWith({ gatewayPublicId: f.gatewayPublicId, bearerToken: f.bearerToken });
});
it("keeps the provider and action readable for fully namespaced gateway tools", async () => {
const prefix = "mcp.app-gallery-mem0-60062edb-c211-4aae-a787-b4fa12a5ea24-f4ece062";
const tools = ["add-memory", "search-memories", "delete-memory"].map(action => ({
...descriptor(`${prefix}:${action}`), displayName: `Mem0 ${action}`,
}));
const assigned = await createAssignedMcpTools(fixture(tools));
for (const [index, definition] of assigned.definitions().entries()) {
const name = definition.name as string;
expect(name).toContain("mem0");
expect(name).toContain(tools[index]!.name.split(":").at(-1)!.replaceAll("-", "_"));
expect(name.length).toBeLessThanOrEqual(64);
expect(definition.description).toBe(`${tools[index]!.displayName}: ${tools[index]!.description}`);
}
const long = await createAssignedMcpTools(fixture([descriptor(`${prefix}:${"long-action-".repeat(10)}`)]));
expect((long.definitions()[0]!.name as string).length).toBeLessThanOrEqual(64);
});
it("does not project gateway secrets, URLs, or provider metadata and returns only the tool result", async () => {
const tool = { ...descriptor("recall"), providerMetadata: { token: "private-provider-token", url: "https://private-gateway.example" } };
const f = fixture([tool]);
f.executeTool.mockResolvedValue({ status: "completed", invocationId: "internal", result: { memories: [] } });
const assigned = await createAssignedMcpTools(f);
const definitions = assigned.definitions();
expect(Object.keys(definitions[0]!)).toEqual(["name", "description", "inputSchema"]);
expect(JSON.stringify(definitions)).not.toMatch(/private-|gateway-fixture|https:/);
const args = { query: "synthetic memory" };
expect(await assigned.execute({ tool: definitions[0]!.name as string, arguments: args })).toEqual({ memories: [] });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken, tool: "recall", parameters: args });
(definitions[0]!.inputSchema as Record<string, unknown>).type = "string";
expect(assigned.definitions()[0]!.inputSchema).toEqual(tool.parametersSchema);
});
it("rejects unknown names without invoking the gateway", async () => {
const f = fixture([descriptor("recall")]);
const assigned = await createAssignedMcpTools(f);
expect(assigned.has("unassigned")).toBe(false);
await expect(assigned.execute({ tool: "unassigned", arguments: {} })).rejects.toThrow("assigned_mcp_tool_unknown");
expect(f.executeTool).not.toHaveBeenCalled();
});
it.each(["planning", "ask", "skill_test"] as const)("exposes and permits only read tools in %s mode", async workMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write"), descriptor("forget", "destructive")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode });
expect(restricted.definitions()).toEqual([standard.definitions()[0]]);
for (const tool of standard.definitions().slice(1)) {
await expect(restricted.execute({ tool: tool.name as string, arguments: {} })).rejects.toThrow("paperclip_runner_tool_mode_denied");
}
expect(f.executeTool).not.toHaveBeenCalled();
await restricted.execute({ tool: restricted.definitions()[0]!.name as string, arguments: {} });
expect(f.executeTool).toHaveBeenCalledOnce();
});
it("uses the gateway for write and destructive tools under standard mode without approval overrides", async () => {
const f = fixture([descriptor("remember", "write"), descriptor("forget", "destructive")]);
const assigned = await createAssignedMcpTools(f);
for (const tool of assigned.definitions()) await assigned.execute({ tool: tool.name as string, arguments: {} });
expect(f.executeTool).toHaveBeenCalledTimes(2);
for (const [call] of f.executeTool.mock.calls) expect(call).not.toHaveProperty("approvedActionRequestId");
});
it.each(["planning", "ask", "skill_test"] as const)("enforces fresh %s mode without relaxing the pinned mode", async currentMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode: currentMode });
const write = { tool: standard.definitions()[1]!.name as string, arguments: {} };
await expect(standard.execute(write, currentMode)).rejects.toThrow("paperclip_runner_tool_mode_denied");
await expect(restricted.execute(write, "standard")).rejects.toThrow("paperclip_runner_tool_mode_denied");
expect(f.executeTool).not.toHaveBeenCalled();
await standard.execute({ tool: standard.definitions()[0]!.name as string, arguments: {} }, currentMode);
expect(f.executeTool).toHaveBeenCalledOnce();
});
it.each(["tool_error", "approval_required", "connection_revoked"])("propagates %s without converting it to success", async reason => {
const f = fixture([descriptor("recall")]);
const error = new ToolGatewayHttpError(403, "Gateway refused execution", reason);
f.executeTool.mockRejectedValue(error);
const assigned = await createAssignedMcpTools(f);
await expect(assigned.execute({ tool: assigned.definitions()[0]!.name as string, arguments: {} })).rejects.toBe(error);
});
it("propagates discovery denial", async () => {
const f = fixture([]);
const error = new Error("gateway_token_revoked");
f.listToolsForNamedGateway.mockRejectedValue(error);
await expect(createAssignedMcpTools(f)).rejects.toBe(error);
});
it("rejects error payloads and incomplete outcomes, and accepts successful replay", async () => {
const f = fixture([descriptor("recall")]);
const assigned = await createAssignedMcpTools(f);
const call = { tool: assigned.definitions()[0]!.name as string, arguments: {} };
f.executeTool.mockResolvedValueOnce({ status: "completed", result: { isError: true, content: [] } });
await expect(assigned.execute(call)).rejects.toMatchObject({ reasonCode: "tool_error" });
f.executeTool.mockResolvedValueOnce({ status: "pending", result: {} });
await expect(assigned.execute(call)).rejects.toThrow("assigned_mcp_tool_execution_incomplete");
f.executeTool.mockResolvedValueOnce({ status: "replayed", result: { memory: "synthetic" } });
await expect(assigned.execute(call)).resolves.toEqual({ memory: "synthetic" });
});
});