Skip to content

Commit f70dccf

Browse files
committed
fix(agents): coerce non-text/image MCP tool-result blocks to text (fixes #90710)
MCP CallToolResult.content can include resource_link, resource, and audio blocks, but toAgentToolResult cast them straight into AgentToolResult, which only carries text/image. Downstream the Anthropic provider/transport image branch then built an image block with undefined data/media_type, Anthropic returned 400, and because the bad block stayed in history every later turn 400d too -> permanently poisoned session. Normalize MCP content at the materialize boundary so the text/image contract stays honest: pass through valid images, coerce resource_link/resource/audio and malformed images to text. Fixes all providers, not just Anthropic.
1 parent c1e1321 commit f70dccf

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,5 +1,5 @@
11
import crypto from "node:crypto";
2-
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2+
import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/sdk/types.js";
33
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
44
import type { OpenClawConfig } from "../config/types.openclaw.js";
55
import { logWarn } from "../logger.js";
@@ -19,13 +19,49 @@ import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js"
1919
import type { AgentToolResult } from "./runtime/index.js";
2020
import type { AnyAgentTool } from "./tools/common.js";
2121

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

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

0 commit comments

Comments
 (0)