Skip to content

convertContentBlocks coerces MCP resource_link/resource/audio blocks into malformed image blocks -> Anthropic 400 -> poisoned session history #90710

@RanSHammer

Description

@RanSHammer

Summary

convertContentBlocks in the Anthropic provider/transport mistranslates non-image, non-text MCP tool-result blocks (notably resource_link, also resource/audio) into malformed image blocks with undefined data/media_type. Anthropic's API rejects these with HTTP 400, and because the bad block is now in the conversation history, every subsequent request in the session also 400s — the session is permanently stuck until history is reset.

Affected code

  • src/llm/providers/anthropic.tsconvertContentBlocks (~L121)
  • src/agents/anthropic-transport-stream.tsconvertContentBlocks (~L261)

Both have the same shape:

const hasImages = content.some((c) => c.type === "image");
if (!hasImages) { /* join text */ }
const blocks = content.map((block) => {
  if (block.type === "text") return { type: "text", ... };
  return { type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } }; // <-- bug
});

The else branch assumes "not text => image". But MCP CallToolResult.content[] can include resource_link, resource, and audio blocks. A resource_link has no data/mimeType, so the produced block is { type:"image", source:{ type:"base64", media_type: undefined, data: undefined } }.

Trigger / repro

  1. Use an MCP server whose tool returns a resource_link content block (e.g. Superhuman Mail get_attachment for non-image files like .docx/.pdf).
  2. The tool result is materialized verbatim by toAgentToolResult (agent-bundle-mcp-materialize.ts) — resource_link passes through untouched.
  3. convertContentBlocks coerces it to an empty image block.
  4. Anthropic returns 400; the malformed block stays in history; all later turns 400.

Expected

Only emit an image block when the block is actually a valid image (non-empty data + mimeType). Convert all other block types to text:

  • resource_linktitle ?? name ?? uri
  • resourceresource.text ?? resource.uri
  • audio[audio <mime>]
  • unknown → JSON.stringify(block)

This also matches the existing extractText/contentToUserContent handling elsewhere in the runtime (runtime ACP path already handles resource_link).

Suggested fix

Add isValidImageBlock() + blockToText() helpers and route non-image blocks through blockToText instead of the image branch (in both files).

Environment

  • openclaw 2026.6.2 (also reproduced in installed dist)
  • Provider: anthropic (claude-opus-4-8)
  • Repro MCP: Superhuman Mail remote MCP, get_attachment on a .docx

Notes

I've patched my local dist/anthropic-*.js with the helper approach and the 400/poison loop is gone. Happy to open a PR for both source files if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions