mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
## 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>
401 lines
25 KiB
TypeScript
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" });
|
|
});
|
|
});
|