Files
PaperClipAI/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts
T
faae6b919f fix(runner): explain task scope on missing tool inputs (#19774)
## 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>
2026-11-10 09:07:11 -06:00

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 } }));
});