Files
PaperClipAI/server/src/services/native-runtime/assigned-mcp-tools.test.ts
T
DottaandPaperclip 063a498b54 Bound native OpenCode assigned-tool catalog projection (#17740)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native agents use assigned connection tools through a governed
gateway.
> - OpenCode received the gateway catalog directly, outside the bounded
server tool projection.
> - A large catalog can exceed a provider grammar compiler's capacity
even when it fits the transport limits.
> - Synthetic requests reproduced the Fireworks grammar timeout on the
configured DeepSeek model.
> - This pull request gives OpenCode two assigned-tool discovery and
call schemas through the existing server relay.
> - The gateway keeps the same grants, validation, approvals,
revocation, and audit rules.

## Linked Issues or Issue Description

**What happened?**

A native OpenCode run could fail during provider grammar compilation
before its first assigned-tool call. OpenCode advertised the full
assigned gateway catalog. A synthetic catalog of 741 tool schemas
reproduced `Grammar processing took too much time` on Fireworks through
OpenRouter with `deepseek/deepseek-v4.1-flash-1017`. The same model and
backend accepted a compact two-tool discovery/call catalog. This
reproduces the mechanism; it does not replay a historical private
request.

**Expected behavior**

Assigned-tool discovery must keep provider grammar size bounded. Every
call must retain the run's original gateway binding and permissions.

**Steps to reproduce**

1. Give a native OpenCode run a large assigned connection catalog.
2. Use a provider that compiles tool schemas into a constrained output
grammar.
3. Observe the full catalog in the provider tool surface before this
change.
4. After this change, observe only `paperclip_search_assigned_tools` and
`paperclip_call_assigned_tool` for assigned tools. Discover and call a
permitted tool through its pinned alias.

**Paperclip version or commit**

Source checkout based on current `master`.

**Deployment mode**

Native local or sandbox execution with OpenCode 1.18.34.

Related work: Refs #14258 (discovery paging), #16678 (local adapter MCP
launch), and #17688 (gateway capability advertisement). This change
covers native OpenCode catalog projection.

## What Changed

- Route native OpenCode assigned tools through the existing server-owned
authenticated relay.
- Use bounded discovery/call schemas for OpenCode, including small
catalogs. Preserve transport capacity checks.
- Keep assigned gateway credentials on the controller. Ignore gateway
and credential values in tool arguments.
- Preserve exact declared MCP operation names through admission and
driver dispatch. Resolve transport prefixes only against the closed
catalog. Reject unknown or shortened names.
- Refresh former OpenCode direct-catalog and shortened-name checkpoints.
Keep the existing fingerprints for other providers.
- Add local and remote wiring, catalog capacity, alias, scope,
work-mode, approval, and revocation tests.
- Allow the native test files to use a validated dedicated external test
database when embedded PostgreSQL is unavailable.
- Document the assigned-tool execution contract.

- Bound OpenCode discovery pages to 32 KiB and large-schema chunks to
16K characters. Keep other harness budgets unchanged.
- Test medium pages and escaped large schemas through the actual HTTP
MCP bridge on the explicit Runner testing surface.

## Verification

- Red before the catalog fix: OpenCode did not create the server relay,
and forced compact delivery advertised 1 or 741 direct schemas.
- Red before the alias repair: HTTP MCP changed declared operation
names, and the driver sent `search_assigned_tools` instead of the
authorized `paperclip_search_assigned_tools`.
- Current repair tests: 112 OpenCode/proxy tests pass, plus one actual
Rust/proxy/HTTP MCP regression. Two checkpoint regressions fail with the
former scoped revision and pass with the new revision. Other providers
keep their prior fingerprints.
- The combined QA candidate with this exact PR head passes all 916
native tests and all 113 transport tests on supported Node 24.22. An
earlier Node 26.4/macOS run passed the 113 assertions but had an
uncaught networking error. That non-clean run remains recorded; its
stack matches [the upstream Undici
defect](https://github.com/nodejs/undici/issues/5544). No production
workaround or error suppression was added.
- `pnpm -r typecheck`, `pnpm build`, and `git diff --check` pass on
`bc41ba72be2195eabeb0d7727227ee0cd02e8532`. The maps structural check
passes.
- The local `pnpm test:run` attempt stopped after host embedded
PostgreSQL initialization failures. The relevant database suites pass
with isolated PostgreSQL 18. All repository CI gates, including
aggregate `ci / verify`, pass on the current head.
- Synthetic live provider comparison: 741 invented schemas produced the
exact Fireworks grammar timeout in 20.9 seconds. Two compact invented
schemas produced a discovery call in 585 ms on the same model and
backend.
- The first hosted QA attempt exposed the alias failure described above.
On the patched source, a real local Rust runner → proxy → OpenCode
1.18.34 → exact DeepSeek smoke completed discovery, one synthetic
gateway read, and `paperclip_finish`, then emitted `turn/completed`. It
used the production compact projection and unchanged Rust allowlist.
- Two earlier local smoke attempts exposed missing terminal authority
and completion-contract fields in the smoke harness. Those failures
remain recorded. The corrected ordinary bindings produced the clean
result.
- Fresh Greptile: 5/5 on `bc41ba72be2195eabeb0d7727227ee0cd02e8532`,
with no new actionable findings or unresolved threads.
- Hosted QA publication and lifecycle acceptance remain pending. The
separate deployed-agent `Paperclip Review` check is also pending; it
follows a task that failed on the earlier deployed runtime.

- Local OpenCode/OpenRouter acceptance at `bc41ba72`: 530 synthetic
tools were authorized and installed. A simple model-ID reply completed
in 17.3 seconds with no app calls. A discovery task completed in 37.6
seconds, used the search/call relay, made one upstream call, and
returned the exact synthetic value. This is local source acceptance, not
a staging sandbox timing comparison.
- The large-result review finding has a failing-before/passing-after
HTTP MCP regression. Five medium schemas now page without invalid JSON.
A large schema with escaped text reconstructs exactly through bounded
chunks. All 35 assigned-tool tests and the server typecheck pass after
this follow-up.
- Final head `d84ce4856a6e65a35ad5fb8b561492598b525f4e`: all CI gates
pass. Greptile and Paperclip Review both rate the change 5/5. No review
threads remain open. The final full build, server typecheck, and 35
assigned-tool tests pass locally.

## Risks

- OpenCode uses discovery before it calls an assigned tool, including
for small catalogs. This can add a model turn.
- An existing OpenCode provider checkpoint rotates once because it
retains the former tool declarations. Paperclip task history remains
available.
- The historical filtered catalog was not captured. The synthetic
comparison proves the failure mechanism, not the exact cardinality of an
earlier private request.
- No model, provider route, permission, or automatic retry policy
changes.

## Model Used

The bounded-result follow-up also used OpenAI Codex (GPT-6 family). The
exact backend model ID and context-window size were not exposed for that
session. It used reasoning, code execution, and tool use.

OpenAI `gpt-6.1-sol` in Codex, with reasoning, tool use, code execution,
and source inspection. The runtime does not expose the context-window
size.

## 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-26 10:49:23 -05:00

401 lines
25 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(["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.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"] 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"] 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" });
});
});