Skip to content

Commit b1e4b6b

Browse files
849261680clawsweeper[bot]Takhoffman
authored
fix(agents): coerce non-text/image MCP tool-result blocks to text (fixes #90710) (#90728)
Summary: - The PR converts wider MCP CallToolResult content blocks into text/image AgentToolResult blocks at the bundle-MCP materialization boundary and adds regression tests. - PR surface: Source +36, Tests +66. Total +102 across 2 files. - Reproducibility: yes. Source inspection shows current main lets MCP resource/audio blocks cross into a text/ ... a spawned stdio MCP server; I did not run a live hosted Anthropic API round trip in this read-only review. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head f70dccf. - Required merge gates passed before the squash merge. Prepared head SHA: f70dccf Review: #90728 (comment) Co-authored-by: 宇宙熊Yzx <[email protected]> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <[email protected]>
1 parent 9e29375 commit b1e4b6b

2 files changed

Lines changed: 105 additions & 3 deletions

File tree

src/agents/agent-bundle-mcp-materialize.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** Materializes configured MCP catalog entries into agent tools and runtime helpers. */
22
import crypto from "node:crypto";
3-
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3+
import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/sdk/types.js";
44
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
55
import type { OpenClawConfig } from "../config/types.openclaw.js";
66
import { logWarn } from "../logger.js";
@@ -20,13 +20,49 @@ import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js"
2020
import type { AgentToolResult } from "./runtime/index.js";
2121
import type { AnyAgentTool } from "./tools/common.js";
2222

23+
type ToolResultContentBlock = AgentToolResult<unknown>["content"][number];
24+
25+
// AgentToolResult only carries text/image, but an MCP CallToolResult can also
26+
// return resource_link, resource, and audio blocks (MCP SDK ContentBlock union).
27+
// Coercing those into the text/image contract here keeps the boundary honest so
28+
// downstream provider converters never build an image block with undefined
29+
// data/media_type, which makes Anthropic 400 and poisons the whole session
30+
// history (every later turn replays the bad block and 400s too). See #90710.
31+
function mcpContentBlockToToolResult(block: ContentBlock): ToolResultContentBlock {
32+
switch (block.type) {
33+
case "text":
34+
return { type: "text", text: block.text };
35+
case "image":
36+
// Only emit an image when the base64 source is actually present.
37+
if (block.data && block.mimeType) {
38+
return { type: "image", data: block.data, mimeType: block.mimeType };
39+
}
40+
return { type: "text", text: JSON.stringify(block) };
41+
case "audio":
42+
return { type: "text", text: `[audio ${block.mimeType}]` };
43+
case "resource_link": {
44+
const label = block.title ?? block.name;
45+
return { type: "text", text: label ? `[${label}] ${block.uri}` : block.uri };
46+
}
47+
case "resource": {
48+
const resource = block.resource;
49+
const text = "text" in resource ? resource.text : undefined;
50+
return { type: "text", text: text ?? resource.uri };
51+
}
52+
default:
53+
// Forward-compat / untrusted-server guard: stringify any block type the
54+
// installed MCP SDK union does not cover instead of dropping it.
55+
return { type: "text", text: JSON.stringify(block) };
56+
}
57+
}
58+
2359
function toAgentToolResult(params: {
2460
serverName: string;
2561
toolName: string;
2662
result: CallToolResult;
2763
}): AgentToolResult<unknown> {
28-
const content = Array.isArray(params.result.content)
29-
? (params.result.content as AgentToolResult<unknown>["content"])
64+
const content: AgentToolResult<unknown>["content"] = Array.isArray(params.result.content)
65+
? params.result.content.map(mcpContentBlockToToolResult)
3066
: [];
3167
const structuredContentBlock =
3268
params.result.structuredContent !== undefined

src/agents/agent-bundle-mcp-tools.materialize.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,72 @@ describe("createBundleMcpToolRuntime", () => {
156156
});
157157
});
158158

159+
it("coerces non-text/image MCP tool-result blocks to text (resource_link/resource/audio)", async () => {
160+
// resource_link/resource/audio blocks have no base64 image source; if they
161+
// leaked into the provider image branch Anthropic would 400 on an image with
162+
// undefined data/media_type and poison the whole session history (#90710).
163+
const runtime = await materializeBundleMcpToolsForRun({
164+
runtime: makeToolRuntime({
165+
result: {
166+
content: [
167+
{ type: "text", text: "intro" },
168+
{
169+
type: "resource_link",
170+
uri: "https://example.com/a.docx",
171+
name: "a.docx",
172+
title: "Quarterly report",
173+
},
174+
{
175+
type: "resource_link",
176+
uri: "https://example.com/bare",
177+
name: "",
178+
},
179+
{
180+
type: "resource",
181+
resource: { uri: "memo://one", text: "memo body" },
182+
},
183+
{
184+
type: "resource",
185+
resource: { uri: "blob://two", blob: "AAAA", mimeType: "application/pdf" },
186+
},
187+
{ type: "audio", data: "AAAA", mimeType: "audio/mpeg" },
188+
{ type: "image", data: "iVBOR", mimeType: "image/png" },
189+
],
190+
isError: false,
191+
} as CallToolResult,
192+
}),
193+
});
194+
195+
const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined);
196+
197+
expect(result.content).toEqual([
198+
{ type: "text", text: "intro" },
199+
{ type: "text", text: "[Quarterly report] https://example.com/a.docx" },
200+
{ type: "text", text: "https://example.com/bare" },
201+
{ type: "text", text: "memo body" },
202+
{ type: "text", text: "blob://two" },
203+
{ type: "text", text: "[audio audio/mpeg]" },
204+
{ type: "image", data: "iVBOR", mimeType: "image/png" },
205+
]);
206+
});
207+
208+
it("coerces a malformed image block (missing base64 source) to text", async () => {
209+
// A real-world poison case: image block with undefined data/media_type.
210+
const runtime = await materializeBundleMcpToolsForRun({
211+
runtime: makeToolRuntime({
212+
result: {
213+
content: [{ type: "image" } as unknown as CallToolResult["content"][number]],
214+
isError: false,
215+
} as CallToolResult,
216+
}),
217+
});
218+
219+
const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined);
220+
221+
expect(result.content).toHaveLength(1);
222+
expect(result.content[0]).toEqual({ type: "text", text: JSON.stringify({ type: "image" }) });
223+
});
224+
159225
it("disambiguates bundle MCP tools that collide with existing tool names", async () => {
160226
const runtime = await materializeBundleMcpToolsForRun({
161227
runtime: makeToolRuntime(),

0 commit comments

Comments
 (0)