Skip to content

Commit 24d2c57

Browse files
committed
fix(mcp): surface tool-level isError and structuredContent
1 parent 3cfdc9c commit 24d2c57

4 files changed

Lines changed: 122 additions & 27 deletions

File tree

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

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2+
import { defined } from "../../tests/helpers/defined.js";
23
import { withMockedModule } from "../../tests/helpers/mock-module.js";
34
import { connectMCPServer } from "./client.js";
45

@@ -25,14 +26,6 @@ await withMockedModule(
2526
}),
2627
);
2728

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-
3629
describe("mcp client tool envelope", () => {
3730
test("callResult preserves isError and structuredContent from the SDK", async () => {
3831
scriptedCallToolResult = {
@@ -45,11 +38,10 @@ describe("mcp client tool envelope", () => {
4538
{},
4639
);
4740
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-
);
41+
const envelope = await defined(
42+
connected.client.callResult,
43+
"mcp client callResult",
44+
)("do_thing", {}, new AbortController().signal);
5345

5446
expect(envelope.isError).toBe(true);
5547
expect(envelope.blocks).toEqual([

‎src/mcp/client.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ export interface MCPContentBlock {
2727
[key: string]: unknown;
2828
}
2929

30+
/**
31+
* Scope lock (CL-8992): the pinned @modelcontextprotocol/sdk v1 CallToolResult
32+
* is `{ content: blocks[] (default []), structuredContent?: Record<string,
33+
* unknown>, isError?: boolean }` — a tool-level failure still succeeds at the
34+
* protocol layer. The envelope carries all three so the plugin can surface
35+
* failures as errors and structured-only payloads as readable text.
36+
* `structuredContent` reaches the model JSON-serialized into the content
37+
* string under MCP_STRUCTURED_CONTENT_MARKER (see plugin.ts) with the raw
38+
* (policy-scrubbed) record preserved under ToolResult `detail` and in the
39+
* evidence archive — never raw.
40+
*/
41+
export interface MCPToolResultEnvelope {
42+
blocks: MCPContentBlock[];
43+
isError: boolean;
44+
structuredContent?: Record<string, unknown>;
45+
}
46+
3047
export interface MCPClient {
3148
serverName: string;
3249
tools: MCPTool[];
@@ -41,6 +58,12 @@ export interface MCPClient {
4158
args: Record<string, unknown>,
4259
signal: AbortSignal,
4360
): Promise<MCPContentBlock[]>;
61+
/** Full tool-result envelope: blocks plus tool-level isError/structuredContent. */
62+
callResult?(
63+
toolName: string,
64+
args: Record<string, unknown>,
65+
signal: AbortSignal,
66+
): Promise<MCPToolResultEnvelope>;
4467
close(): Promise<void>;
4568
}
4669

@@ -566,6 +589,29 @@ async function finishClient(
566589
return {
567590
serverName,
568591
tools,
592+
async callResult(toolName, args, signal) {
593+
const context =
594+
authContext === undefined ? undefined : { ...authContext, signal };
595+
const result = await withHTTPAuthorizationRecovery(context, () =>
596+
client.callTool({ name: toolName, arguments: args }, undefined, {
597+
signal,
598+
}),
599+
);
600+
const envelope: MCPToolResultEnvelope = {
601+
blocks: validateMcpContentBlocks(result.content),
602+
isError: result.isError === true,
603+
};
604+
if (
605+
result.structuredContent !== null &&
606+
typeof result.structuredContent === "object"
607+
) {
608+
envelope.structuredContent = result.structuredContent as Record<
609+
string,
610+
unknown
611+
>;
612+
}
613+
return envelope;
614+
},
569615
async callBlocks(toolName, args, signal) {
570616
const context =
571617
authContext === undefined ? undefined : { ...authContext, signal };

‎src/mcp/plugin.ts‎

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,24 @@ import {
1111
type SpillBlobWriter,
1212
} from "../plugins/result-truncation-plugin.js";
1313
import type { CompactionArchive } from "../session/compaction-archive.js";
14-
import type { MCPClient, MCPContentBlock } from "./client.js";
14+
import type {
15+
MCPClient,
16+
MCPContentBlock,
17+
MCPToolResultEnvelope,
18+
} from "./client.js";
1519
import { mcpToolName } from "./tool-name.js";
1620
import { unwrapToolContent } from "./client.js";
1721

1822
export const MCP_RECONNECTING_TOOL_ERROR =
1923
"MCP server is reconnecting; retry the call once it reports connected.";
2024

25+
/**
26+
* Stable marker prefixing JSON-serialized `structuredContent` when it is the
27+
* only payload (or supplements an empty flatten). Lets the model — and log
28+
* grep — distinguish server-structured data from free text.
29+
*/
30+
export const MCP_STRUCTURED_CONTENT_MARKER = "mcp structured result:";
31+
2132
/** True while the server keeps its tools mounted but cannot execute. */
2233
export function isDegradedMcpState(state: { state: string }): boolean {
2334
return state.state === "reconnecting";
@@ -90,22 +101,51 @@ export function mcpClientTools(
90101
signal: AbortSignal,
91102
): Promise<ToolResult> => {
92103
try {
93-
const rawBlocks =
94-
typeof client.callBlocks === "function"
95-
? await client.callBlocks(tool.name, call.arguments, signal)
96-
: [
97-
{
98-
type: "text",
99-
text: await client.call(tool.name, call.arguments, signal),
100-
} satisfies MCPContentBlock,
101-
];
102-
const authorizedBlocks = applyPolicyToBlocks(rawBlocks);
104+
const envelope: MCPToolResultEnvelope =
105+
typeof client.callResult === "function"
106+
? await client.callResult(tool.name, call.arguments, signal)
107+
: typeof client.callBlocks === "function"
108+
? {
109+
blocks: await client.callBlocks(
110+
tool.name,
111+
call.arguments,
112+
signal,
113+
),
114+
isError: false,
115+
}
116+
: {
117+
blocks: [
118+
{
119+
type: "text",
120+
text: await client.call(
121+
tool.name,
122+
call.arguments,
123+
signal,
124+
),
125+
} satisfies MCPContentBlock,
126+
],
127+
isError: false,
128+
};
129+
const authorizedBlocks = applyPolicyToBlocks(envelope.blocks);
130+
const scrubbedStructured =
131+
envelope.structuredContent === undefined
132+
? undefined
133+
: (scrubSecretShapedValue(envelope.structuredContent) as Record<
134+
string,
135+
unknown
136+
>);
103137
const archive = getEvidenceArchive?.();
104138
if (archive !== undefined) {
105139
try {
106140
await archive.recordAuthorizedPayload({
107141
kind: "tool_result",
108-
payload: { blocks: authorizedBlocks },
142+
payload:
143+
scrubbedStructured === undefined
144+
? { blocks: authorizedBlocks }
145+
: {
146+
blocks: authorizedBlocks,
147+
structuredContent: scrubbedStructured,
148+
},
109149
callId: call.id,
110150
provenance: "mcp:post-policy-pre-flatten",
111151
});
@@ -114,6 +154,15 @@ export function mcpClientTools(
114154
}
115155
}
116156
const flattened = unwrapToolContent(authorizedBlocks);
157+
const isError = envelope.isError === true;
158+
const baseContent =
159+
flattened !== ""
160+
? flattened
161+
: scrubbedStructured !== undefined
162+
? `${MCP_STRUCTURED_CONTENT_MARKER}\n${JSON.stringify(scrubbedStructured)}`
163+
: isError
164+
? `MCP tool ${client.serverName}/${tool.name} reported an error with empty content.`
165+
: flattened;
117166
const writeBlob = getBlobWriter?.();
118167
const contextDir = getContextDir?.();
119168
const spill =
@@ -124,8 +173,15 @@ export function mcpClientTools(
124173
...(contextDir !== undefined ? { contextDir } : {}),
125174
}
126175
: undefined;
127-
const content = await sanitizeMcpResultContent(flattened, spill);
128-
return { callId: call.id, content };
176+
const content = await sanitizeMcpResultContent(baseContent, spill);
177+
return {
178+
callId: call.id,
179+
content,
180+
...(isError ? { isError: true as const } : {}),
181+
...(scrubbedStructured !== undefined
182+
? { detail: scrubbedStructured }
183+
: {}),
184+
};
129185
} catch (err) {
130186
const message = err instanceof Error ? err.message : String(err);
131187
const scrubbed = scrubSecretShapedContent(message);

‎src/session/hooks.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ function truncateToolResultForHookPayload(result: ToolResult): ToolResult {
501501
callId: result.callId,
502502
content,
503503
...(result.isError !== undefined ? { isError: result.isError } : {}),
504+
...(result.detail !== undefined ? { detail: result.detail } : {}),
504505
...(result.pendingMarker !== undefined
505506
? { pendingMarker: result.pendingMarker }
506507
: {}),

0 commit comments

Comments
 (0)