Files
PaperClipAI/server/src/services/native-runtime/assigned-mcp-tools.test.ts
T
DottaandPaperclip c4b6168e08 fix(server): disclose assigned tool search page bounds (#18056)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/187/changes/paperclip/187
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the control plane for AI agents at work.
> - Native agents find connected app tools through the assigned-tool
search tool.
> - Large catalogs require small pages and an offset for the next page.
> - The input schema enforces a maximum of 20 results, but its
descriptions omit that number.
> - Providers can render the integer field as a plain number and hide
its numeric bounds.
> - This pull request puts the existing range and default in both
descriptions.
> - Agents can choose a valid page size before they make a search call.

## Linked Issues or Issue Description

**What happened?**

An agent can request `limit: 50` for `paperclip_search_assigned_tools`.
The tool rejects the call with `invalid_tool_call` at
`/properties/limit/maximum`. Its model-visible description does not
state the accepted range.

**Expected behavior**

The tool description and the `limit` description must state the range of
1 to 20 and the default of 5. Agents can use `nextOffset` to read more
results.

**Steps to reproduce**

1. Inspect the assigned-tool search description in a provider tool list.
2. Render the integer schema as a TypeScript number field.
3. Request `limit: 50` without a visible page-size bound.
4. Observe the validation error. Retry with a valid size and offset.

### Hypothesis and thesis for a fix

The numeric schema is correct, but the descriptions omit the page limit.
Put the existing range and default in text that all providers can see.
Keep the strict validation in the assigned-tool discovery owner. This
should reduce avoidable retries. It does not guarantee that every model
call will be valid.

## What Changed

- State the accepted range and default in the search description and the
`limit` property. Add the existing default as schema metadata.
- Test the tool listing and calls through the real HTTP MCP bridge for
both compact delivery paths.
- Document the page size and offset procedure in the Runner README and
verification maps.

## Verification

- Red: the two new bridge tests fail against the unchanged
implementation because the listing omits the numeric guidance.
- Green: all 37 tests in
`server/src/services/native-runtime/assigned-mcp-tools.test.ts` pass
after the change. The tests cover default pages, a 20-result page,
offset continuation without duplicates, and rejection of 21 and 50.
Existing tests cover grants, revocation, modes, approvals, aliases, and
schema chunks.
- Current head `7d658d0c4e4d9db3feb53a5c508214440ff058c4`: 37/37 focused
tests pass locally. Greptile is 5/5 with no unresolved threads.
Current-head CI is green, including typecheck, build, unit tests, Runner
checks, and all browser shards ([CI run, attempt
2](https://github.com/paperclipai/paperclip/actions/runs/40733284523/attempts/2)).
Browser shard 7 initially failed in a newly merged agent YAML editor
test when its clipboard read included the whole page. The single retry
passed without a code change; the exact cause is unconfirmed. The
original failure remains in [the CI
log](https://github.com/paperclipai/paperclip/actions/runs/40733284523/job/122975224906).
- Run the focused suite from `server/`: `pnpm exec vitest run
src/services/native-runtime/assigned-mcp-tools.test.ts`.
- `node scripts/check-maps.mjs` and `git diff --check` pass.
- The bridge uses real local HTTP. The app gateway uses synthetic
descriptors. No deployed build or live model improvement is claimed.
- Full local verification is incomplete. Recursive typecheck and server
typecheck require `cargo`, which this workspace lacks. A direct server
TypeScript check was killed by the workspace. The full test suite had no
final verdict before the resource stop. CI must supply the remaining
verification.

## Risks

Low risk. This change describes existing limits and adds default
metadata. It does not change search execution, page caps, gateway
access, revocation, approvals, or audit. Models can still send invalid
calls. This change has not measured their future retry rate.

## Model Used

OpenAI Codex, GPT-6 family, with tool use and code execution. The exact
provider model ID and context window are not exposed to 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, its capabilities, and unavailable
model metadata
- [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
- [x] I have described the issue in-PR following the bug report fields
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] The public branch name describes the change and has no internal
task ID
- [x] I have run the focused tests locally and they pass
- [x] I have added tests for the reported failure
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open recommendations or follow-ups
- [x] I will address reviewer comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-28 13:55:48 -05:00

444 lines
28 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([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.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" });
});
});