Skip to content
Merged
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
73 changes: 73 additions & 0 deletions packages/hosts/mcp/src/passthrough-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,79 @@ describe("passthrough mode server", () => {
);
});

const invokeWith = async (data: unknown) => {
const recording = makeRecordingEngine({ ok: true, data });
let result: Awaited<ReturnType<Client["callTool"]>> | undefined;
await withClient(
{ engine: recording.engine, mode: "passthrough", tools: toolPort(CATALOG) },
async (client) => {
result = await client.callTool({
name: "invoke",
arguments: { tool: "tools.github.org.main.issues.list", arguments: {} },
});
},
);
return result!;
};

it("forwards an upstream image block as a native image block", async () => {
const result = await invokeWith({
content: [{ type: "image", data: "AQID", mimeType: "image/jpeg" }],
});
expect(result.content).toEqual([{ type: "image", data: "AQID", mimeType: "image/jpeg" }]);
expect(result.isError ?? false).toBe(false);
});

it("keeps mixed text and image blocks in order and keeps bytes out of structuredContent", async () => {
const result = await invokeWith({
content: [
{ type: "text", text: "before" },
{ type: "image", data: "AQID", mimeType: "image/png" },
{ type: "text", text: "after" },
{ type: "audio", data: "BAUG", mimeType: "audio/wav" },
],
structuredContent: { name: "x.png" },
});
expect(result.content).toEqual([
{ type: "text", text: "before" },
{ type: "image", data: "AQID", mimeType: "image/png" },
{ type: "text", text: "after" },
{ type: "audio", data: "BAUG", mimeType: "audio/wav" },
]);
expect(result.structuredContent).toEqual({ name: "x.png" });
expect(JSON.stringify(result.structuredContent)).not.toContain("AQID");
});

it("omits structuredContent when the upstream result has none", async () => {
const result = await invokeWith({
content: [{ type: "image", data: "AQID", mimeType: "image/png" }],
});
expect(result.structuredContent).toBeUndefined();
});

it("still bounds a large text block next to an image", async () => {
const result = await invokeWith({
content: [
{ type: "text", text: "x".repeat(30_005) },
{ type: "image", data: "AQID", mimeType: "image/png" },
],
});
expect(result.content).toEqual([
{ type: "text", text: `${"x".repeat(30_000)}\n... [truncated 5 chars]` },
{ type: "image", data: "AQID", mimeType: "image/png" },
]);
});

it("keeps a text-only upstream result on the standard rendering", async () => {
const result = await invokeWith({ content: [{ type: "text", text: "hi" }] });
expect((result.content as Array<{ type: string }>).map((b) => b.type)).toEqual(["text"]);
expect(result.structuredContent).toEqual({
status: "completed",
result: { content: [{ type: "text", text: "hi" }] },
logs: [],
});
});

/** An engine whose tool raises the given elicitations in order and records
* each answer. `source` is what the executor stamps: `policy` for its own
* approval gate, `tool` for anything the tool asked for itself. */
Expand Down
32 changes: 31 additions & 1 deletion packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,36 @@ const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {
};
};

const MAX_PASSTHROUGH_TEXT_CHARS = 30_000;

const isMediaBlock = (block: ContentBlock): boolean =>
block.type === "image" || block.type === "audio";

const boundText = (block: ContentBlock): ContentBlock =>
block.type === "text" && block.text.length > MAX_PASSTHROUGH_TEXT_CHARS
? {
...block,
text: `${block.text.slice(0, MAX_PASSTHROUGH_TEXT_CHARS)}\n... [truncated ${block.text.length - MAX_PASSTHROUGH_TEXT_CHARS} chars]`,
}
: block;

/**
* An upstream MCP tool result that carries image or audio blocks is forwarded
* as native MCP content, so the bytes reach the client as media and never as
* JSON text or `structuredContent`. Any other shape returns undefined and
* keeps the standard rendering.
*/
const upstreamMediaResult = (data: unknown): McpToolResult | undefined => {
if (!isRecord(data) || data.isError === true || !Array.isArray(data.content)) return undefined;
const blocks: unknown[] = data.content;
if (!blocks.every(isMcpContentBlock)) return undefined;
if (!blocks.some(isMediaBlock)) return undefined;
return {
content: blocks.map(boundText),
...(isRecord(data.structuredContent) ? { structuredContent: data.structuredContent } : {}),
};
};

/**
* A passthrough call's result IS the tool's `ToolResult`. Inside `execute`
* the model reads `{ ok, data | error }` and branches; here nothing runs
Expand All @@ -731,7 +761,7 @@ const toPassthroughResult = (outcome: FormattedExecuteInput): McpToolResult => {
const value = outcome.result;
if (outcome.error || !isToolResult(value)) return toMcpResult(outcome);
if (value.ok) {
return toMcpResult({ ...outcome, result: value.data });
return upstreamMediaResult(value.data) ?? toMcpResult({ ...outcome, result: value.data });
}
const message = `${value.error.code}: ${value.error.message}`;
return {
Expand Down
Loading