Skip to content

Commit f815324

Browse files
sanchitrkclaude
andcommitted
fix(harness): send MCP images through the shared inline image pipeline
Tool and resource images are converted to a format providers take inline and resized to their limits, as the read tool does; one that cannot be becomes a note instead of an image that would fail the next request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c6e7a8d commit f815324

5 files changed

Lines changed: 64 additions & 28 deletions

File tree

‎packages/harness/src/mcp/content.ts‎

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Message } from "@codeworksh/aikit";
22
import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/client";
33
import { Effect } from "effect";
4+
import { Image } from "../tool/image/index.ts";
45
import { formatSize, truncateMiddle } from "../tool/truncate.ts";
56

67
export type Content = Message.TextContent | Message.ImageContent;
@@ -33,56 +34,76 @@ const extensionOf = (uri: string) => {
3334
return /\.[A-Za-z0-9]{1,8}$/.exec(path)?.[0] ?? ".bin";
3435
};
3536

37+
/**
38+
* An image as the model can take it inline, as the read tool does: in a supported format and within
39+
* the provider's limits, or a note saying why it was left out.
40+
*/
41+
const image = (data: string, mimeType: string) =>
42+
Image.inline(Buffer.from(data, "base64"), mimeType).pipe(
43+
Effect.map((inline): ReadonlyArray<Content> =>
44+
inline.ok
45+
? [
46+
{ type: "image", data: inline.data, mimeType: inline.mimeType },
47+
...inline.hints.map((text): Content => ({ type: "text", text })),
48+
]
49+
: [{ type: "text", text: inline.message }],
50+
),
51+
);
52+
3653
/** Blocks that need neither a file nor the server's name. */
37-
const plain = (block: ContentBlock): Content => {
54+
const plain = (block: ContentBlock): Effect.Effect<ReadonlyArray<Content>> => {
3855
switch (block.type) {
3956
case "text":
40-
return { type: "text", text: block.text };
57+
return Effect.succeed([{ type: "text", text: block.text }]);
4158
case "image":
42-
return { type: "image", data: block.data, mimeType: block.mimeType };
59+
return image(block.data, block.mimeType);
4360
case "audio":
44-
return { type: "text", text: `[audio ${block.mimeType} omitted]` };
61+
return Effect.succeed([{ type: "text", text: `[audio ${block.mimeType} omitted]` }]);
4562
case "resource": {
4663
const resource = block.resource;
4764
return "text" in resource
48-
? { type: "text", text: resource.text }
49-
: { type: "image", data: resource.blob, mimeType: resource.mimeType ?? "image/png" };
65+
? Effect.succeed([{ type: "text", text: resource.text }])
66+
: image(resource.blob, resource.mimeType ?? "image/png");
5067
}
5168
default:
52-
return { type: "text", text: `[unsupported MCP content ${block.type}]` };
69+
return Effect.succeed([{ type: "text", text: `[unsupported MCP content ${block.type}]` }]);
5370
}
5471
};
5572

5673
/** One block of `server`'s output as model content. */
57-
const fromBlock = Effect.fnUntraced(function* (server: string, block: ContentBlock, options: Options) {
74+
const fromBlock = Effect.fnUntraced(function* (
75+
server: string,
76+
block: ContentBlock,
77+
options: Options,
78+
): Effect.fn.Return<ReadonlyArray<Content>> {
5879
if (block.type === "resource_link") {
5980
const details = [block.mimeType, block.size === undefined ? undefined : formatSize(block.size)].filter(Boolean);
6081
const read = options.readable ? `. Read it with ${READ_RESOURCE_TOOL} (server "${server}")` : "";
6182
const description = block.description ? `: ${block.description}` : "";
62-
return {
63-
type: "text",
64-
text: `[Resource ${block.uri} "${block.title ?? block.name}"${details.length > 0 ? ` (${details.join(", ")})` : ""}${description}${read}]`,
65-
} satisfies Content;
83+
return [
84+
{
85+
type: "text",
86+
text: `[Resource ${block.uri} "${block.title ?? block.name}"${details.length > 0 ? ` (${details.join(", ")})` : ""}${description}${read}]`,
87+
},
88+
];
6689
}
6790
if (block.type === "resource" && "blob" in block.resource && !block.resource.mimeType?.startsWith("image/")) {
6891
const { uri, mimeType, blob } = block.resource;
6992
const data = Buffer.from(blob, "base64");
70-
if (isText(mimeType)) return { type: "text", text: data.toString("utf8") } satisfies Content;
93+
if (isText(mimeType)) return [{ type: "text", text: data.toString("utf8") }];
7194
const kind = `${mimeType ?? "unknown type"}, ${formatSize(data.length)}`;
7295
return yield* options.save(data, extensionOf(uri)).pipe(
7396
Effect.match({
74-
onSuccess: (path): Content => ({
75-
type: "text",
76-
text: `[Binary resource ${uri} (${kind}) saved to ${path}]`,
77-
}),
78-
onFailure: (reason): Content => ({
79-
type: "text",
80-
text: `[Binary resource ${uri} (${kind}) could not be saved: ${reason}]`,
81-
}),
97+
onSuccess: (path): ReadonlyArray<Content> => [
98+
{ type: "text", text: `[Binary resource ${uri} (${kind}) saved to ${path}]` },
99+
],
100+
onFailure: (reason): ReadonlyArray<Content> => [
101+
{ type: "text", text: `[Binary resource ${uri} (${kind}) could not be saved: ${reason}]` },
102+
],
82103
}),
83104
);
84105
}
85-
return plain(block);
106+
return yield* plain(block);
86107
});
87108

88109
/**
@@ -106,7 +127,7 @@ export const limit = Effect.fnUntraced(function* (content: ReadonlyArray<Content
106127

107128
/** Blocks of `server`'s output as model content, before {@link limit}. */
108129
export const fromBlocks = (server: string, blocks: ReadonlyArray<ContentBlock>, options: Options) =>
109-
Effect.forEach(blocks, (block) => fromBlock(server, block, options));
130+
Effect.forEach(blocks, (block) => fromBlock(server, block, options)).pipe(Effect.map((all) => all.flat()));
110131

111132
/**
112133
* A tool result as model content. A result without content blocks but with `structuredContent`

‎packages/harness/test/__artifacts__/mcp.client.json‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ second line",
300300
"type": "text",
301301
},
302302
{
303-
"data": "iVBORw0KGgo=",
303+
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
304304
"mimeType": "image/png",
305305
"type": "image",
306306
},
@@ -309,7 +309,7 @@ second line",
309309
"type": "text",
310310
},
311311
{
312-
"data": "iVBORw0KGgo=",
312+
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
313313
"mimeType": "image/png",
314314
"type": "image",
315315
},
@@ -325,6 +325,10 @@ second line",
325325
"text": "[Resource file:///README.md "readme"]",
326326
"type": "text",
327327
},
328+
{
329+
"text": "[Image omitted: could not be converted to a supported inline image format.]",
330+
"type": "text",
331+
},
328332
],
329333
"serverInfo": {
330334
"name": "stdio-fixture",

‎packages/harness/test/__artifacts__/mcp.session.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@
158158
"Invalid arguments for mcp__e2e__args: Expected a string matching the RegExp ^[a-z]+$\n at [\"word\"]"
159159
]
160160
},
161+
"excess": {
162+
"status": "error",
163+
"isError": true,
164+
"content": [
165+
"Invalid arguments for mcp__e2e__args: Expected no excess property\n at [\"extra\"]"
166+
]
167+
},
161168
"cwd": {
162169
"status": "completed",
163170
"isError": false,

‎packages/harness/test/fixtures/mcp/tools.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Tools shared by the stdio fixture and the in-process streamable HTTP fixture.
22
import { spawn } from "node:child_process";
33

4-
const PNG = "iVBORw0KGgo=";
4+
// One transparent pixel.
5+
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
56
const sleep = (ms, signal) =>
67
new Promise((resolve) => {
78
const timer = setTimeout(resolve, ms);
@@ -71,6 +72,8 @@ export const install = (server) => {
7172
{ type: "resource", resource: { uri: "file:///data.bin", blob: "AAEC" } },
7273
{ type: "audio", data: "AAAA", mimeType: "audio/wav" },
7374
{ type: "resource_link", name: "readme", uri: "file:///README.md" },
75+
// No provider takes SVG inline.
76+
{ type: "image", data: Buffer.from("<svg/>").toString("base64"), mimeType: "image/svg+xml" },
7477
],
7578
};
7679
case "structured":

‎packages/harness/test/mcp.client.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,13 @@ describe("mcp client", () => {
159159
expect(yield* Effect.promise(() => readFile(saved!))).toEqual(Buffer.from([0, 1, 2]));
160160
expect(converted.mixed).toEqual([
161161
{ type: "text", text: "hello" },
162-
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
162+
{ type: "image", data: expect.any(String), mimeType: "image/png" },
163163
{ type: "text", text: "embedded notes" },
164-
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
164+
{ type: "image", data: expect.any(String), mimeType: "image/png" },
165165
{ type: "text", text: `[Binary resource file:///data.bin (unknown type, 3B) saved to ${saved}]` },
166166
{ type: "text", text: "[audio audio/wav omitted]" },
167167
{ type: "text", text: '[Resource file:///README.md "readme"]' },
168+
{ type: "text", text: "[Image omitted: could not be converted to a supported inline image format.]" },
168169
]);
169170
expect(converted.structured).toEqual([
170171
{ type: "text", text: JSON.stringify({ answer: 42, items: ["a", "b"] }, null, 2) },

0 commit comments

Comments
 (0)