Skip to content

Commit 3cfdc9c

Browse files
committed
test(mcp): cover tool-level isError and structuredContent
1 parent 6d07b36 commit 3cfdc9c

2 files changed

Lines changed: 224 additions & 1 deletion

File tree

‎src/mcp/client-envelope.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { withMockedModule } from "../../tests/helpers/mock-module.js";
3+
import { connectMCPServer } from "./client.js";
4+
5+
let scriptedCallToolResult: unknown = { content: [] };
6+
7+
await withMockedModule(
8+
import.meta.resolve("@modelcontextprotocol/sdk/client/index.js"),
9+
(real: typeof import("@modelcontextprotocol/sdk/client/index.js")) => ({
10+
...real,
11+
Client: class {
12+
async connect(): Promise<void> {
13+
return undefined;
14+
}
15+
async listTools(): Promise<{ tools: [] }> {
16+
return { tools: [] };
17+
}
18+
async callTool(): Promise<unknown> {
19+
return scriptedCallToolResult;
20+
}
21+
async close(): Promise<void> {
22+
return undefined;
23+
}
24+
},
25+
}),
26+
);
27+
28+
await withMockedModule(
29+
import.meta.resolve("@modelcontextprotocol/sdk/client/stdio.js"),
30+
(real: typeof import("@modelcontextprotocol/sdk/client/stdio.js")) => ({
31+
...real,
32+
StdioClientTransport: class {},
33+
}),
34+
);
35+
36+
describe("mcp client tool envelope", () => {
37+
test("callResult preserves isError and structuredContent from the SDK", async () => {
38+
scriptedCallToolResult = {
39+
content: [{ type: "text", text: "tool failed: bad input" }],
40+
isError: true,
41+
structuredContent: { reason: "bad input" },
42+
};
43+
const connected = await connectMCPServer(
44+
{ name: "envelope", command: "true" },
45+
{},
46+
);
47+
if (!connected.ok) throw new Error("expected stdio connect to succeed");
48+
const envelope = await connected.client.callResult(
49+
"do_thing",
50+
{},
51+
new AbortController().signal,
52+
);
53+
54+
expect(envelope.isError).toBe(true);
55+
expect(envelope.blocks).toEqual([
56+
{ type: "text", text: "tool failed: bad input" },
57+
]);
58+
expect(envelope.structuredContent).toEqual({ reason: "bad input" });
59+
await connected.client.close();
60+
});
61+
62+
test("legacy call still flattens text blocks", async () => {
63+
scriptedCallToolResult = {
64+
content: [{ type: "text", text: "hello" }],
65+
};
66+
const connected = await connectMCPServer(
67+
{ name: "envelope", command: "true" },
68+
{},
69+
);
70+
if (!connected.ok) throw new Error("expected stdio connect to succeed");
71+
const text = await connected.client.call(
72+
"do_thing",
73+
{},
74+
new AbortController().signal,
75+
);
76+
77+
expect(text).toBe("hello");
78+
await connected.client.close();
79+
});
80+
});

‎src/mcp/plugin.test.ts‎

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,49 @@ import {
88
} from "../plugins/result-truncation-plugin.js";
99
import { toolOutputAbsolutePath } from "../plugins/tool-result-materialize.js";
1010
import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js";
11-
import type { MCPClient } from "./client.js";
11+
import type { MCPClient, MCPContentBlock } from "./client.js";
12+
13+
interface ScriptedMcpEnvelope {
14+
blocks: MCPContentBlock[];
15+
isError?: boolean;
16+
structuredContent?: Record<string, unknown>;
17+
}
18+
19+
function fakeEnvelopeClient(envelope: ScriptedMcpEnvelope): MCPClient {
20+
const client: MCPClient = {
21+
serverName: "acme",
22+
tools: [
23+
{
24+
name: "fetch_secret",
25+
description: "returns a value",
26+
inputSchema: { type: "object", properties: {} },
27+
},
28+
],
29+
call: async () => "",
30+
callBlocks: async () => envelope.blocks,
31+
close: async () => undefined,
32+
};
33+
// callResult is the new envelope channel (GREEN); absent on RED code.
34+
Object.assign(client, {
35+
callResult: async () => envelope,
36+
});
37+
return client;
38+
}
39+
40+
async function runEnvelopeTool(
41+
envelope: ScriptedMcpEnvelope,
42+
callId: string,
43+
spillOptions?: Parameters<typeof mcpClientToAgentTools>[2],
44+
) {
45+
const gate = skipGate();
46+
const client = fakeEnvelopeClient(envelope);
47+
const [tool] = mcpClientToAgentTools(client, gate, spillOptions);
48+
if (tool?.kind !== "full") throw new Error("expected full tool");
49+
return tool.handler(
50+
{ id: callId, name: "mcp__acme__fetch_secret", arguments: {} },
51+
new AbortController().signal,
52+
);
53+
}
1254

1355
function fakeClient(reply: string): MCPClient {
1456
return {
@@ -179,4 +221,105 @@ describe("mcpClientToAgentTools", () => {
179221
toolOutputAbsolutePath(contextDir, key, "text/plain"),
180222
);
181223
});
224+
225+
test("tool-level failure surfaces as an error result with text preserved", async () => {
226+
const result = await runEnvelopeTool(
227+
{
228+
blocks: [{ type: "text", text: "tool failed: bad input" }],
229+
isError: true,
230+
},
231+
"c-mcp-iserror-text",
232+
);
233+
234+
expect(result.isError).toBe(true);
235+
expect(result.content).toContain("tool failed: bad input");
236+
});
237+
238+
test("tool-level failure with empty content still yields a failure message", async () => {
239+
const result = await runEnvelopeTool(
240+
{ blocks: [], isError: true },
241+
"c-mcp-iserror-empty",
242+
);
243+
244+
expect(result.isError).toBe(true);
245+
expect(typeof result.content).toBe("string");
246+
expect((result.content as string).length).toBeGreaterThan(0);
247+
});
248+
249+
test("structured-only result surfaces a scrubbed JSON string, not empty text", async () => {
250+
const result = await runEnvelopeTool(
251+
{ blocks: [], structuredContent: { answer: 42 } },
252+
"c-mcp-structured-only",
253+
);
254+
255+
expect(result.isError).toBeUndefined();
256+
expect(typeof result.content).toBe("string");
257+
expect(result.content).toContain("42");
258+
expect(result.detail).toEqual({ answer: 42 });
259+
});
260+
261+
test("text plus structured content keeps the text and preserves structured detail", async () => {
262+
const result = await runEnvelopeTool(
263+
{
264+
blocks: [{ type: "text", text: "hello from tool" }],
265+
structuredContent: { answer: 42 },
266+
},
267+
"c-mcp-text-plus-structured",
268+
);
269+
270+
expect(result.isError).toBeUndefined();
271+
expect(result.content).toContain("hello from tool");
272+
expect(result.detail).toEqual({ answer: 42 });
273+
});
274+
275+
test("credential-shaped values inside structured content are redacted", async () => {
276+
const secret = "sk-live-abcdefghij1234567890";
277+
const result = await runEnvelopeTool(
278+
{
279+
blocks: [],
280+
structuredContent: { token: secret },
281+
},
282+
"c-mcp-structured-secret",
283+
);
284+
285+
expect(result.content).toContain(CREDENTIAL_REDACTION);
286+
expect(result.content).not.toContain(secret);
287+
expect(JSON.stringify(result.detail)).not.toContain(secret);
288+
});
289+
290+
test("thrown transport failures still surface as error results", async () => {
291+
const gate = skipGate();
292+
const client: MCPClient = {
293+
serverName: "acme",
294+
tools: [
295+
{
296+
name: "fetch_secret",
297+
description: "returns a value",
298+
inputSchema: { type: "object", properties: {} },
299+
},
300+
],
301+
call: async () => {
302+
throw new Error("transport exploded");
303+
},
304+
callBlocks: async () => {
305+
throw new Error("transport exploded");
306+
},
307+
close: async () => undefined,
308+
};
309+
Object.assign(client, {
310+
callResult: async () => {
311+
throw new Error("transport exploded");
312+
},
313+
});
314+
const [tool] = mcpClientToAgentTools(client, gate);
315+
if (tool?.kind !== "full") throw new Error("expected full tool");
316+
317+
const result = await tool.handler(
318+
{ id: "c-mcp-throw", name: "mcp__acme__fetch_secret", arguments: {} },
319+
new AbortController().signal,
320+
);
321+
322+
expect(result.isError).toBe(true);
323+
expect(result.content).toContain("transport exploded");
324+
});
182325
});

0 commit comments

Comments
 (0)