Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/mcp/client-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { defined } from "../../tests/helpers/defined.js";
import { withMockedModule } from "../../tests/helpers/mock-module.js";
import { connectMCPServer } from "./client.js";

let scriptedCallToolResult: unknown = { content: [] };

await withMockedModule(
import.meta.resolve("@modelcontextprotocol/sdk/client/index.js"),
(real: typeof import("@modelcontextprotocol/sdk/client/index.js")) => ({
...real,
Client: class {
async connect(): Promise<void> {
return undefined;
}
async listTools(): Promise<{ tools: [] }> {
return { tools: [] };
}
async callTool(): Promise<unknown> {
return scriptedCallToolResult;
}
async close(): Promise<void> {
return undefined;
}
},
}),
);

describe("mcp client tool envelope", () => {
test("callResult preserves isError and structuredContent from the SDK", async () => {
scriptedCallToolResult = {
content: [{ type: "text", text: "tool failed: bad input" }],
isError: true,
structuredContent: { reason: "bad input" },
};
const connected = await connectMCPServer(
{ name: "envelope", command: "true" },
{},
);
if (!connected.ok) throw new Error("expected stdio connect to succeed");
const envelope = await defined(
connected.client.callResult,
"mcp client callResult",
)("do_thing", {}, new AbortController().signal);

expect(envelope.isError).toBe(true);
expect(envelope.blocks).toEqual([
{ type: "text", text: "tool failed: bad input" },
]);
expect(envelope.structuredContent).toEqual({ reason: "bad input" });
await connected.client.close();
});

test("legacy call still flattens text blocks", async () => {
scriptedCallToolResult = {
content: [{ type: "text", text: "hello" }],
};
const connected = await connectMCPServer(
{ name: "envelope", command: "true" },
{},
);
if (!connected.ok) throw new Error("expected stdio connect to succeed");
const text = await connected.client.call(
"do_thing",
{},
new AbortController().signal,
);

expect(text).toBe("hello");
await connected.client.close();
});
});
46 changes: 46 additions & 0 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ export interface MCPContentBlock {
[key: string]: unknown;
}

/**
* Scope lock (CL-8992): the pinned @modelcontextprotocol/sdk v1 CallToolResult
* is `{ content: blocks[] (default []), structuredContent?: Record<string,
* unknown>, isError?: boolean }` — a tool-level failure still succeeds at the
* protocol layer. The envelope carries all three so the plugin can surface
* failures as errors and structured-only payloads as readable text.
* `structuredContent` reaches the model JSON-serialized into the content
* string under MCP_STRUCTURED_CONTENT_MARKER (see plugin.ts). Small
* policy-scrubbed records are preserved under ToolResult `detail`; the full
* scrubbed record is retained in the evidence archive — never raw.
*/
export interface MCPToolResultEnvelope {
blocks: MCPContentBlock[];
isError: boolean;
structuredContent?: Record<string, unknown>;
}

export interface MCPClient {
serverName: string;
tools: MCPTool[];
Expand All @@ -41,6 +58,12 @@ export interface MCPClient {
args: Record<string, unknown>,
signal: AbortSignal,
): Promise<MCPContentBlock[]>;
/** Full tool-result envelope: blocks plus tool-level isError/structuredContent. */
callResult?(
toolName: string,
args: Record<string, unknown>,
signal: AbortSignal,
): Promise<MCPToolResultEnvelope>;
close(): Promise<void>;
}

Expand Down Expand Up @@ -566,6 +589,29 @@ async function finishClient(
return {
serverName,
tools,
async callResult(toolName, args, signal) {
const context =
authContext === undefined ? undefined : { ...authContext, signal };
const result = await withHTTPAuthorizationRecovery(context, () =>
client.callTool({ name: toolName, arguments: args }, undefined, {
signal,
}),
);
const envelope: MCPToolResultEnvelope = {
blocks: validateMcpContentBlocks(result.content),
isError: result.isError === true,
};
if (
result.structuredContent !== null &&
typeof result.structuredContent === "object"
) {
envelope.structuredContent = result.structuredContent as Record<
string,
unknown
>;
}
return envelope;
},
async callBlocks(toolName, args, signal) {
const context =
authContext === undefined ? undefined : { ...authContext, signal };
Expand Down
Loading
Loading