mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents read saved task documents through native tools.
> - Active-task tools use the task bound to the run. Other task tools
require a task UUID.
> - Agents sometimes call the second set with only a document key.
> - The validator rejects the call, but gives no instruction about the
active-task tool.
> - This pull request adds scope instructions to the tool schemas and
returns them for missing required fields.
> - Agents can correct a rejected call. Task selection and permission
checks stay in place.
## Linked Issues or Issue Description
**What happened?**
Two observed document reads sent only `{"key":"sentry-case"}` to
`read_task_document`. Each returned `Invalid tool input: / must have
required property 'taskId'`. Each later retry with a task UUID returned
without an error.
Pattern: [Task reads fail when agents omit
taskId](https://www.braintrust.dev/app/Paperclip/p/Paperclip%20Staging/patterns/703d8c29-6a40-4aa2-b2c6-e2c5c36b5657).
Related PR: #18824 changed document key validation. It does not address
task scope.
**Expected behavior**
The tool contract must state which task it reads. A missing-field error
must include the schema owner's recovery instructions. A retry must
still pass validation and task permission checks.
**Steps to reproduce**
1. Call `read_task_document` through the MCP bridge with only a document
key.
2. Inspect the error sent to the caller.
3. Retry with an authorized task UUID. Confirm the returned document
belongs to that task.
### Hypothesis and thesis for a fix
Hypothesis: generic tool descriptions contribute to confusion between
active-task and explicit-task tools. An agent can also ignore a correct
schema. These tests do not measure the rate of model mistakes.
Thesis: Paperclip owns task scope instructions. Its five explicit-task
tools now describe the required UUID and the active-task alternative.
The two MCP transports return direct missing-property descriptions from
the admitted schema. The transports do not infer task IDs or apply
Paperclip task policy.
Proof: the same HTTP MCP regression fails on baseline
`f673bd3cb5b5b65ec51781e37355e168c3fe8ae4` and passes with the fix. It
still rejects before handler dispatch. An explicit retry reaches the
requested task. An isolated real API and PostgreSQL test checks the
saved revision, active-task selection, malformed UUIDs, and
cross-company denial through both transports.
## What Changed
- Add UUID and active-task guidance to `get_task`, `comment_on_task`,
and the three explicit-task document tools.
- Return bounded, distinct descriptions for up to three direct missing
required fields. Keep ordinary validation errors unchanged.
- Share this formatter between the Runner and OpenCode MCP bridges.
- Add transport regressions, real route and database checks, and tool
documentation.
## Verification
- Baseline red: both transport regressions return only the
required-property error and fail the recovery instruction assertion.
- Candidate green: 58 Runner and OpenCode transport and error formatter
tests pass on Linux with Node 24.22.2.
- Final-head server verification passed all 115 tests: 113 document-key
checks and two real API integration cases.
- The two local integration cases use real MCP HTTP, agent authority,
API routes, and disposable PostgreSQL. They read a selected other task
and its saved revision, read the active task separately, reject missing
IDs for all five tools, reject malformed UUIDs, and return 404 for a
foreign company task.
- Runner TypeScript typecheck and build pass.
- The full server typecheck passed on head
`03a37e9e50896416cfd45be33bf6dbe3e13169df` with
`PAPERCLIP_TYPECHECK_LOW_MEMORY=1 node scripts/typecheck-layers.mjs`.
The first run ended without a compiler diagnostic. Final-head CI passes
and Greptile is 5/5.
- Full preflight stops because cargo is absent. Rust checks were not run
locally.
- One existing delayed-body fetch test failed with a Node 25 fetch
runtime error. All transport tests pass on Node 24. The first
integration attempt used the wrong fixture return shape; the test was
corrected before the passing run.
- CI follow-up: the Runner test program reported four TS18046 errors
because `Response.json()` returns `unknown` under CI typings. Both tests
now declare the response type. `pnpm exec tsc -p tsconfig.test.json
--noEmit` passes, and both transport suites pass again (56 tests). Final
head `ffccebca19fe9e21a097829adc020679ffa7d86c` passes CI and Greptile
5/5.
- Review follow-up: the foreign task now has a saved document with a
distinct body. Both integration cases passed again after this test-only
change. The read must return 404 and must not return that body.
Current-head CI passes and Greptile is 5/5.
- Final CI:
https://github.com/paperclipai/paperclip/actions/runs/42352822739 . All
required jobs passed for `ffccebca19fe9e21a097829adc020679ffa7d86c`.
Greptile is 5/5 with no unresolved review thread.
- Real local API behavior is verified. External model behavior and
deployed recovery are not verified. No merge or deployment is included.
## Risks
- Missing-field errors now contain schema descriptions. The text is
limited to direct properties in the admitted schema, at most three
distinct descriptions and 1,000 characters each.
- Nested schemas and references do not get recovery text. Their
validation errors remain intact.
- Clearer instructions can help callers recover. They do not guarantee
that a model will choose the correct tool.
## Model Used
- OpenAI GPT-6 via Codex, with tool use and code execution. The exact
deployment 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 (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>
Co-authored-by: Claude <noreply@anthropic.com>
420 lines
22 KiB
TypeScript
420 lines
22 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA,
|
|
PRP_BLOCK_TOOL_DESCRIPTION,
|
|
PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA,
|
|
PRP_COMPLETION_TOOL_DESCRIPTION,
|
|
} from "../../contracts/completion-result.js";
|
|
|
|
import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
|
|
|
|
const bridges: OpenCodeMcpBridge[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(bridges.splice(0).map((bridge) => bridge.close()));
|
|
});
|
|
|
|
async function rpc(
|
|
bridge: OpenCodeMcpBridge,
|
|
body: Record<string, unknown>,
|
|
secret = bridge.secret,
|
|
) {
|
|
return fetch(bridge.url, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${secret}`,
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/event-stream",
|
|
},
|
|
body: JSON.stringify({ jsonrpc: "2.0", ...body }),
|
|
});
|
|
}
|
|
|
|
describe("OpenCode MCP bridge", () => {
|
|
it("keeps a durable call pending across the ordinary timeout and replays its one result", async () => {
|
|
let started!: () => void;
|
|
const begun = new Promise<void>(resolve => { started = resolve; });
|
|
let complete!: (value: unknown) => void;
|
|
const result = new Promise<unknown>(resolve => { complete = resolve; });
|
|
const aborted = vi.fn();
|
|
const handler = vi.fn(({ signal }) => {
|
|
signal.addEventListener("abort", aborted, { once: true });
|
|
started();
|
|
return result;
|
|
});
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.write", inputSchema: { type: "object" } }],
|
|
timeoutMs: null,
|
|
handler,
|
|
});
|
|
bridges.push(bridge);
|
|
const request = { id: "durable-write", method: "tools/call", params: { name: "documents.write", arguments: {} } };
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const pending = rpc(bridge, request).then(response => response.json());
|
|
try {
|
|
await begun;
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
expect(aborted).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
complete({ receipt: "saved-once" });
|
|
}
|
|
const delivered = await pending;
|
|
expect(delivered).toMatchObject({ result: { content: [{ text: '{"receipt":"saved-once"}' }] } });
|
|
expect(await (await rpc(bridge, request)).json()).toEqual(delivered);
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("cancels an unanswered durable call when the bridge closes without inventing a result", async () => {
|
|
let started!: () => void;
|
|
const begun = new Promise<void>(resolve => { started = resolve; });
|
|
const aborted = vi.fn();
|
|
const handler = vi.fn(({ signal }) => new Promise((_resolve, reject) => {
|
|
signal.addEventListener("abort", () => { aborted(); reject(new Error("unknown outcome")); }, { once: true });
|
|
started();
|
|
}));
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.write", inputSchema: { type: "object" } }], timeoutMs: null, handler,
|
|
});
|
|
const pending = rpc(bridge, { id: "close-durable", method: "tools/call", params: { name: "documents.write", arguments: {} } });
|
|
await begun;
|
|
await bridge.close();
|
|
expect(await (await pending).json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] } });
|
|
expect(aborted).toHaveBeenCalledOnce();
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("abandons a call on disconnect only after every waiting connection is gone", async () => {
|
|
let started!: () => void;
|
|
const begun = new Promise<void>(resolve => { started = resolve; });
|
|
const aborted = vi.fn();
|
|
const handler = vi.fn(({ signal }) => new Promise((_resolve, reject) => {
|
|
signal.addEventListener("abort", () => { aborted(); reject(new Error("abandoned")); }, { once: true });
|
|
started();
|
|
}));
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.write", inputSchema: { type: "object" } }], timeoutMs: null, handler,
|
|
});
|
|
bridges.push(bridge);
|
|
const body = JSON.stringify({ jsonrpc: "2.0", id: "retried", method: "tools/call", params: { name: "documents.write", arguments: {} } });
|
|
const post = (signal: AbortSignal) => fetch(bridge.url, {
|
|
method: "POST", body, signal,
|
|
headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
|
});
|
|
const first = new AbortController();
|
|
const retry = new AbortController();
|
|
const firstResponse = post(first.signal).catch(() => "dropped");
|
|
await begun;
|
|
const retryResponse = post(retry.signal).catch(() => "dropped");
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
first.abort();
|
|
expect(await firstResponse).toBe("dropped");
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
expect(aborted).not.toHaveBeenCalled();
|
|
retry.abort();
|
|
expect(await retryResponse).toBe("dropped");
|
|
for (let attempt = 0; attempt < 100 && !aborted.mock.calls.length; attempt += 1) {
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
}
|
|
expect(aborted).toHaveBeenCalledOnce();
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("binds to loopback, requires authentication, and exposes only its closed catalog", async () => {
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
secret: "session-secret",
|
|
tools: [{ name: "documents.read", description: "Read", inputSchema: { type: "object" } }],
|
|
handler: async () => ({ ok: true }),
|
|
});
|
|
bridges.push(bridge);
|
|
expect(new URL(bridge.url).hostname).toBe("127.0.0.1");
|
|
expect((await rpc(bridge, { id: 1, method: "tools/list" }, "wrong")).status).toBe(401);
|
|
const listed = await rpc(bridge, { id: 2, method: "tools/list" });
|
|
expect(await listed.json()).toMatchObject({
|
|
result: { tools: [
|
|
{ name: "documents.read" },
|
|
{ name: "paperclip_finish", description: PRP_COMPLETION_TOOL_DESCRIPTION, inputSchema: PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA },
|
|
{ name: "paperclip_block", description: PRP_BLOCK_TOOL_DESCRIPTION, inputSchema: PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA },
|
|
] },
|
|
});
|
|
});
|
|
|
|
it("delivers a complete large assigned-tool result and replays its receipt without repeating the action", async () => {
|
|
const files = Array.from({ length: 27 }, (_, index) => ({
|
|
filename: `src/public-fixture-${index}.ts`,
|
|
patch: `@@ synthetic public fixture ${index} @@\n${"+ documented change\n".repeat(220)}`,
|
|
}));
|
|
const data = { items: files, page: 1, hasMore: false, receipt: "LAST_FILE_AND_RECEIPT_INTACT" };
|
|
const result = { content: [{ type: "text", text: JSON.stringify(data) }], data };
|
|
expect(JSON.stringify(result).length).toBeGreaterThan(111_877);
|
|
const handler = vi.fn(async () => result);
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "paperclip_call_assigned_tool", inputSchema: { type: "object" } }], handler,
|
|
});
|
|
bridges.push(bridge);
|
|
const request = { id: "large-result", method: "tools/call", params: { name: "paperclip_call_assigned_tool", arguments: {} } };
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
const response = await (await rpc(bridge, request)).json() as { result: { content: Array<{ text: string }> } };
|
|
expect(JSON.parse(response.result.content[0]!.text)).toEqual(result);
|
|
}
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("preserves producer-sized pages with UTF-8 and heavily escaped nested data", async () => {
|
|
const chunk = '\"\\\n\t\u0000😀';
|
|
const result = { responseText: { nextOffsetBytes: 300_000, totalBytes: 600_000 }, data: chunk.repeat(25_000) };
|
|
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(500_000);
|
|
expect(Buffer.byteLength(JSON.stringify(result))).toBeGreaterThan(64 * 1024);
|
|
// The driver returns a controller text item, not the raw app page. Its
|
|
// encoded JSON must survive a second layer of escaping at this boundary.
|
|
const controllerResult = { text: JSON.stringify(result), type: "inputText" };
|
|
expect(Buffer.byteLength(JSON.stringify(controllerResult))).toBeGreaterThan(500_000);
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "read_tool_result", inputSchema: { type: "object" } }], handler: async () => controllerResult,
|
|
});
|
|
bridges.push(bridge);
|
|
const response = await (await rpc(bridge, { id: "page", method: "tools/call", params: { name: "read_tool_result", arguments: {} } })).json() as { result: { content: Array<{ text: string }> } };
|
|
const delivered = JSON.parse(response.result.content[0]!.text) as typeof controllerResult;
|
|
expect(delivered).toEqual(controllerResult);
|
|
expect(JSON.parse(delivered.text)).toEqual(result);
|
|
});
|
|
|
|
it.each([
|
|
["UTF-8 scalar", () => ({ data: "😀".repeat(300_000) })],
|
|
["escaped scalar", () => ({ data: "\u0000".repeat(200_000) })],
|
|
["many fields", () => Array.from({ length: 600_000 }, () => 0)],
|
|
])("rejects an oversized %s without a partial success or repeating the completed call", async (_name, makeResult) => {
|
|
const handler = vi.fn(async () => makeResult());
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "assigned.write", inputSchema: { type: "object" } }], handler,
|
|
});
|
|
bridges.push(bridge);
|
|
const request = { id: "write-receipt", method: "tools/call", params: { name: "assigned.write", arguments: {} } };
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
expect(await (await rpc(bridge, request)).json()).toMatchObject({
|
|
result: { isError: true, content: [{ text: expect.stringContaining("may already have completed; do not repeat it") }] },
|
|
});
|
|
}
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("admits runner-private extension operations without advertising them to the model", async () => {
|
|
const handler = vi.fn(async ({ tool }) => ({ decision: tool === "__paperclip_permission" ? "accept" : "decline" }));
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
|
privateTools: [{ name: "__paperclip_permission", inputSchema: { type: "object" } }],
|
|
handler,
|
|
});
|
|
bridges.push(bridge);
|
|
const listed = await (await rpc(bridge, { id: 1, method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
|
|
expect(listed.result.tools.map((tool) => tool.name)).not.toContain("__paperclip_permission");
|
|
expect(await (await rpc(bridge, {
|
|
id: "permission-1",
|
|
method: "tools/call",
|
|
params: { name: "__paperclip_permission", arguments: {} },
|
|
})).json()).toMatchObject({ result: { content: [{ text: "{\"decision\":\"accept\"}" }] } });
|
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "__paperclip_permission" }));
|
|
});
|
|
|
|
it("maps prefixed names and replays identical duplicate calls exactly once", async () => {
|
|
const handler = vi.fn(async () => ({ value: 7 }));
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
|
handler,
|
|
});
|
|
bridges.push(bridge);
|
|
const request = { id: "call-1", method: "tools/call", params: { name: "paperclip_documents.read", arguments: { id: "doc" } } };
|
|
expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
|
|
expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "documents.read", callId: "call-1", arguments: { id: "doc" } }));
|
|
});
|
|
|
|
it("fails conflicting duplicate identities and handler errors closed", async () => {
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
|
handler: async () => { throw new Error("denied"); },
|
|
});
|
|
bridges.push(bridge);
|
|
const failed = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "a" } } });
|
|
expect(await failed.json()).toMatchObject({ result: { isError: true, content: [{ text: "denied" }] } });
|
|
const conflict = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "b" } } });
|
|
expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
|
|
});
|
|
|
|
it("preserves declared prefixed operation IDs through HTTP MCP and rejects undeclared aliases", async () => {
|
|
const names = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "search_assigned_tools"];
|
|
const handler = vi.fn(async call => ({ operation: call.tool }));
|
|
const bridge = await startOpenCodeMcpBridge({ tools: names.map(name => ({ name, inputSchema: { type: "object" } })), handler });
|
|
bridges.push(bridge);
|
|
const listed = await (await rpc(bridge, { id: "list", method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
|
|
expect(listed.result.tools.map(tool => tool.name)).toEqual([...names, "paperclip_finish", "paperclip_block"]);
|
|
for (const [i, name] of names.entries()) {
|
|
for (const [j, prefix] of ["", "paperclip_", "paperclip__", "paperclip."].entries()) {
|
|
const called = prefix === "paperclip_" && name === "search_assigned_tools" ? name : prefix + name;
|
|
const result = await (await rpc(bridge, { id: `call-${i}-${j}`, method: "tools/call", params: { name: called, arguments: {} } })).json();
|
|
expect(result).toMatchObject({ result: { content: [{ text: JSON.stringify({ operation: name }) }] } });
|
|
}
|
|
}
|
|
const before = handler.mock.calls.length;
|
|
expect(await (await rpc(bridge, { id: "unknown", method: "tools/call", params: { name: "paperclip_unassigned_tool", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
|
|
expect(handler).toHaveBeenCalledTimes(before);
|
|
});
|
|
|
|
it("does not authorize a shortened operation when only its prefixed name is declared", async () => {
|
|
const handler = vi.fn(async () => ({ ok: true }));
|
|
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_search_assigned_tools", inputSchema: { type: "object" } }], handler });
|
|
bridges.push(bridge);
|
|
expect(await (await rpc(bridge, { id: "short", method: "tools/call", params: { name: "search_assigned_tools", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("normalizes all supported OpenCode MCP prefixes", () => {
|
|
expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
|
|
expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
|
|
expect(canonicalOpenCodeMcpToolName("paperclip.documents.read")).toBe("documents.read");
|
|
expect(canonicalOpenCodeMcpToolName("paperclip_paperclip_finish")).toBe("paperclip_finish");
|
|
});
|
|
|
|
it("validates inputs before dispatch", async () => {
|
|
const handler = vi.fn(async () => ({ ok: true }));
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{
|
|
name: "documents.read",
|
|
inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } }, additionalProperties: false },
|
|
}],
|
|
handler,
|
|
});
|
|
bridges.push(bridge);
|
|
const response = await rpc(bridge, {
|
|
id: "invalid",
|
|
method: "tools/call",
|
|
params: { name: "documents.read", arguments: { id: 9 } },
|
|
});
|
|
expect(await response.json()).toMatchObject({ result: { isError: true } });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("times out and cancels in-flight calls", async () => {
|
|
const observed = vi.fn();
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
|
timeoutMs: 20,
|
|
handler: ({ signal }) => new Promise((_resolve, reject) => {
|
|
signal.addEventListener("abort", () => { observed(); reject(new Error("aborted")); }, { once: true });
|
|
}),
|
|
});
|
|
bridges.push(bridge);
|
|
const response = await rpc(bridge, {
|
|
id: "slow-call",
|
|
method: "tools/call",
|
|
params: { name: "documents.read", arguments: {} },
|
|
});
|
|
expect(await response.json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call timed out" }] } });
|
|
expect(observed).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("allows human-gated private calls to outlive ordinary tool timeouts", async () => {
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
|
privateTools: [{ name: "__paperclip_runtime_input", inputSchema: { type: "object" } }],
|
|
timeoutMs: 20,
|
|
privateToolTimeoutMs: 200,
|
|
handler: async ({ tool }) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
return { tool, resolved: true };
|
|
},
|
|
});
|
|
bridges.push(bridge);
|
|
const response = await rpc(bridge, {
|
|
id: "human-input",
|
|
method: "tools/call",
|
|
params: { name: "__paperclip_runtime_input", arguments: {} },
|
|
});
|
|
expect(await response.json()).toMatchObject({
|
|
result: { content: [{ text: expect.stringContaining("\"resolved\":true") }] },
|
|
});
|
|
});
|
|
|
|
it.each([undefined, null])("honors MCP cancellation notifications (timeout=%s)", async timeoutMs => {
|
|
const started = vi.fn();
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
|
timeoutMs,
|
|
handler: ({ signal }) => new Promise((_resolve, reject) => {
|
|
started();
|
|
signal.addEventListener("abort", () => reject(new Error("handler aborted")), { once: true });
|
|
}),
|
|
});
|
|
bridges.push(bridge);
|
|
const pending = rpc(bridge, {
|
|
id: "cancel-me",
|
|
method: "tools/call",
|
|
params: { name: "documents.read", arguments: {} },
|
|
});
|
|
await vi.waitFor(() => expect(started).toHaveBeenCalledOnce());
|
|
expect((await rpc(bridge, {
|
|
method: "notifications/cancelled",
|
|
params: { requestId: "cancel-me", reason: "turn stopped" },
|
|
})).status).toBe(202);
|
|
expect(await (await pending).json()).toMatchObject({
|
|
result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("reports admitted schema rejections without invoking handlers or widening authentication", async () => {
|
|
const handler = vi.fn(async () => ({ accepted: true }));
|
|
const rejected = vi.fn();
|
|
const bridge = await startOpenCodeMcpBridge({
|
|
tools: [{ name: "probe", inputSchema: { type: "object" } }], handler, onRejectedCall: rejected,
|
|
});
|
|
bridges.push(bridge);
|
|
const body = { id: "bad", method: "tools/call", params: { name: "probe", arguments: [] } };
|
|
expect((await rpc(bridge, body, "wrong")).status).toBe(401);
|
|
await rpc(bridge, { ...body, params: { name: "unassigned", arguments: [] } });
|
|
expect(rejected).not.toHaveBeenCalled();
|
|
expect(await (await rpc(bridge, body)).json()).toMatchObject({ result: { isError: true } });
|
|
expect(rejected).toHaveBeenCalledExactlyOnceWith({ tool: "probe", callId: "bad", arguments: [] }, expect.stringContaining("Invalid tool input"));
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
it("keeps schema rejection intact when its observer fails", async () => {
|
|
const handler = vi.fn(async () => ({}));
|
|
const bridge = await startOpenCodeMcpBridge({ handler, onRejectedCall: () => { throw new Error("observer unavailable"); } });
|
|
bridges.push(bridge);
|
|
expect(await (await rpc(bridge, { id: 1, method: "tools/call", params: { name: "paperclip_finish", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
it("reports conflicting request IDs without repeating the accepted action", async () => {
|
|
const handler = vi.fn(async () => ({ accepted: true }));
|
|
const rejected = vi.fn();
|
|
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "probe", inputSchema: { type: "object" } }], handler, onRejectedCall: rejected });
|
|
bridges.push(bridge);
|
|
const body = { id: "same", method: "tools/call", params: { name: "probe", arguments: { name: "first" } } };
|
|
await rpc(bridge, body);
|
|
expect(await (await rpc(bridge, { ...body, params: { name: "probe", arguments: { name: "second" } } })).json()).toMatchObject({ result: { isError: true } });
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
expect(rejected).toHaveBeenCalledExactlyOnceWith({ tool: "probe", callId: "same", arguments: { name: "second" } }, "Duplicate call identity conflict.");
|
|
});
|
|
|
|
// The schema owner supplies scope policy; the transport only returns its guidance.
|
|
it("returns missing-field guidance before dispatch and accepts an explicit retry", async () => {
|
|
const guidance = "Use the requested task UUID. For the active task, use read_document with key only.";
|
|
const handler = vi.fn(async () => ({ accepted: true }));
|
|
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "read_task_document", inputSchema: {
|
|
type: "object", required: ["taskId", "key"], additionalProperties: false,
|
|
properties: { taskId: { type: "string", description: guidance }, key: { type: "string" } },
|
|
} }], handler });
|
|
bridges.push(bridge);
|
|
const request = { id: "scope-retry", method: "tools/call", params: { name: "read_task_document", arguments: { key: "sentry-case" } } };
|
|
type ScopeResponse = { result: { isError?: boolean; content: Array<{ text: string }> } };
|
|
const rejected = await (await rpc(bridge, request)).json() as ScopeResponse;
|
|
expect(rejected).toMatchObject({ result: { isError: true } });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(rejected.result.content[0].text).toContain(guidance);
|
|
const taskId = "00000000-0000-4000-8000-000000000005";
|
|
const accepted = await (await rpc(bridge, { ...request, params: { ...request.params, arguments: { key: "sentry-case", taskId } } })).json() as ScopeResponse;
|
|
expect(accepted.result.isError).not.toBe(true);
|
|
expect(handler).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ arguments: { key: "sentry-case", taskId } }));
|
|
});
|