openclaw - ✅(Solved) Fix MCP tool result with type: "resource" is converted to "(see attached image)" placeholder [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#75674Fetched 2026-05-02 05:31:55
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Author
Timeline (top)
commented ×1cross-referenced ×1

Error Message

Additionally, the transport layer should not blindly fall back to (see attached image) for unknown content types. It should either extract text from known types or pass through an error/unknown-type indicator.

Root Cause

Root Cause Analysis (Source Code Level)

Fix Action

Fixed

PR fix notes

PR #75714: fix(agents): normalize MCP EmbeddedResource content into text at bundle-MCP boundary

Description (problem / solution / changelog)

Fixes #75674.

Bug / behavior being fixed

MCP tools that return EmbeddedResource blocks (type: "resource") reach the model as a (see attached image) placeholder instead of the actual resource payload. Reporter (@Jedi-Pz) traced the problem and the clawsweeper review confirmed it: toAgentToolResult in src/agents/pi-bundle-mcp-materialize.ts:21 casts CallToolResult.content straight to AgentToolResult["content"], so resource blocks reach OpenAI/Anthropic transports which only recognize text/image. The OpenAI-compatible path then falls through to sanitizeTransportPayloadText("" || "(see attached image)") (src/agents/openai-transport-stream.ts:330), and the Anthropic converter emits empty strings (src/agents/anthropic-transport-stream.ts:234).

Why this is the best fix

The clawsweeper review explicitly recommends "Normalize MCP tool-result content at the bundle-MCP to Pi AgentToolResult boundary", and that matches the architecture rule in AGENTS.md to keep transport-layer providers generic — the bundle-MCP boundary is the single point that owns the MCP→Pi shape conversion, so handling resource blocks here covers OpenAI replay (openai-ws-message-conversion.ts:62), OpenAI transport (openai-transport-stream.ts:330), Anthropic transport (anthropic-transport-stream.ts:234), and any future provider transport without further patching.

Patching the four downstream sites independently would leave any new provider transport vulnerable to the same regression and is rejected by the reviewer for that reason.

Affected surface

  • src/agents/pi-bundle-mcp-materialize.ts — adds normalizeMcpContent, swaps the cast in toAgentToolResult for a normalized array
  • src/agents/pi-bundle-mcp-tools.materialize.test.ts — three new regression tests + a small additive resultContent/resultIsError override on the existing makeToolRuntime helper
  • CHANGELOG.md — single-line user-facing entry under ## Unreleased### Fixes, crediting @Jedi-Pz

No public type, plugin SDK, or transport-layer change. Existing text/image content blocks pass through unchanged (covered by the third regression test).

Behavior

  • type: "resource" with resource.text: string → unwraps into { type: "text", text: resource.text }
  • type: "resource" with resource.uri/mimeType (binary/blob) → renders [Resource: <uri> (<mimeType>)] as a text marker so the result stays informative instead of being dropped or showing the misleading image placeholder
  • Non-object parts and parts of unknown type → preserved as-is so future MCP block variants are not silently lost

Tests

  • it("normalizes MCP resource content with text into a text block (#75674)") — text-resource → text block
  • it("renders binary MCP resource content as an informative text marker") — binary-resource → marker
  • it("passes existing text and image content blocks through unchanged") — regression guard for existing pipelines

Targeted run intended on green CI: pnpm test src/agents/pi-bundle-mcp-tools.materialize.test.ts plus the wider pnpm check:changed lane that this surface selects. Local pnpm/tsgo were not available in the contributor environment; relying on CI for full validation.

Notes for reviewer

  • The change is additive at the boundary; no existing test was modified beyond the helper accepting an additional optional resultContent override (backwards-compatible with all current call sites).
  • The binary-resource marker text is intentionally bounded and free of resource.blob payload to avoid leaking opaque binary into prompt context.
  • Happy to fold this into a wider normalization pass at the same boundary (e.g., audio/video resource variants) in a follow-up if the maintainer prefers a more exhaustive sweep.

https://github.com/openclaw/openclaw/issues/75674

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/agents/pi-bundle-mcp-materialize.ts (modified, +33/-1)
  • src/agents/pi-bundle-mcp-tools.materialize.test.ts (modified, +74/-4)

Code Example

{"name": "get", "arguments": {"file": "memory/2026-05-01.md"}}

---

{
     "content": [
       {
         "type": "resource",
         "resource": {
           "uri": "qmd://memory-root-main/memory/2026-05-01.md",
           "mimeType": "text/markdown",
           "text": "# 2026-05-01\n\nSome markdown content here..."
         }
       }
     ]
   }

---

function contentToText(content) {
  // ...
  return content.filter((part) => Boolean(part) && typeof part === "object")
    .filter((part) => (part.type === "text" || part.type === "input_text" || part.type === "output_text") && typeof part.text === "string")
    .map((part) => part.text).join("");
}

---

for (const part of content) {
  if ((part.type === "text" || part.type === "input_text" || part.type === "output_text") && typeof part.text === "string") {
    parts.push({ type: "input_text", text: part.text });
    continue;
  }
  if (!includeImages) continue;
  if (part.type === "image" && typeof part.data === "string") { /* ... */ }
  if (part.type === "input_image" && part.source && ...) { /* ... */ }
}

---

} else if (msg.role === "toolResult") {
  const textResult = msg.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
  const hasImages = msg.content.some((item) => item.type === "image");
  // ...
  output: hasImages && model.input.includes("image")
    ? [...]
    : sanitizeTransportPayloadText(textResult || "(see attached image)")
}

---

function convertContentBlocks(content) {
  if (!content.some((item) => item.type === "image"))
    return sanitizeNonEmptyTransportPayloadText(content.map((item) => "text" in item ? item.text : "").join("\n"));
  // ...
  if (!blocks.some((block) => block.type === "text"))
    blocks.unshift({ type: "text", text: "(see attached image)" });
}

---

// In contentToText
if (part.type === "resource" && part.resource && typeof part.resource.text === "string") {
  return part.resource.text;
}

// In contentToOpenAIParts
if (part.type === "resource" && part.resource && typeof part.resource.text === "string") {
  parts.push({ type: "input_text", text: part.resource.text });
  continue;
}
RAW_BUFFERClick to expand / collapse

Bug Description

When an MCP tool returns type: "resource" in its content array, OpenClaw incorrectly converts it to a (see attached image) placeholder instead of extracting the resource.text field.

Reproduction Steps

  1. Configure QMD as the memory backend ("memory": { "backend": "qmd" })
  2. Call the qmd__get tool via OpenClaw:
    {"name": "get", "arguments": {"file": "memory/2026-05-01.md"}}
  3. The MCP server returns a valid EmbeddedResource:
    {
      "content": [
        {
          "type": "resource",
          "resource": {
            "uri": "qmd://memory-root-main/memory/2026-05-01.md",
            "mimeType": "text/markdown",
            "text": "# 2026-05-01\n\nSome markdown content here..."
          }
        }
      ]
    }
  4. OpenClaw displays (see attached image) in the tool output instead of the markdown text.

Expected vs Actual Behavior

AspectExpectedActual
Tool outputThe resource.text content (markdown text)(see attached image)
LLM contextThe text is passed to the modelA placeholder is passed

Root Cause Analysis (Source Code Level)

I traced the issue through the OpenClaw distribution bundle (2026.4.27). The problem is that the entire content pipeline lacks support for the MCP EmbeddedResource type.

1. contentToText ignores resource

File: dist/compaction-successor-transcript-BwDunTlM.js:581-585

function contentToText(content) {
  // ...
  return content.filter((part) => Boolean(part) && typeof part === "object")
    .filter((part) => (part.type === "text" || part.type === "input_text" || part.type === "output_text") && typeof part.text === "string")
    .map((part) => part.text).join("");
}

Only text / input_text / output_text are recognized. type: "resource" is silently filtered out, returning an empty string.

2. contentToOpenAIParts ignores resource

File: dist/compaction-successor-transcript-BwDunTlM.js:586-644

for (const part of content) {
  if ((part.type === "text" || part.type === "input_text" || part.type === "output_text") && typeof part.text === "string") {
    parts.push({ type: "input_text", text: part.text });
    continue;
  }
  if (!includeImages) continue;
  if (part.type === "image" && typeof part.data === "string") { /* ... */ }
  if (part.type === "input_image" && part.source && ...) { /* ... */ }
}

Only text and image/input_image are handled. resource is skipped, producing an empty parts array.

3. OpenAI transport falls back to placeholder

File: dist/openai-transport-stream-DwlW8wDu.js:634-649

} else if (msg.role === "toolResult") {
  const textResult = msg.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
  const hasImages = msg.content.some((item) => item.type === "image");
  // ...
  output: hasImages && model.input.includes("image")
    ? [...]
    : sanitizeTransportPayloadText(textResult || "(see attached image)")
}
  • textResult is empty (resource is not text)
  • hasImages is false (resource is not image)
  • Falls through to sanitizeTransportPayloadText("" || "(see attached image)")

4. Anthropic transport has the same issue

File: dist/provider-stream-CQzDRxyR.js:104-126

function convertContentBlocks(content) {
  if (!content.some((item) => item.type === "image"))
    return sanitizeNonEmptyTransportPayloadText(content.map((item) => "text" in item ? item.text : "").join("\n"));
  // ...
  if (!blocks.some((block) => block.type === "text"))
    blocks.unshift({ type: "text", text: "(see attached image)" });
}

Non-text content is treated as image, and when no text blocks exist, the placeholder is inserted.

Proposed Fix

Add resource type handling to the content pipeline:

// In contentToText
if (part.type === "resource" && part.resource && typeof part.resource.text === "string") {
  return part.resource.text;
}

// In contentToOpenAIParts
if (part.type === "resource" && part.resource && typeof part.resource.text === "string") {
  parts.push({ type: "input_text", text: part.resource.text });
  continue;
}

Additionally, the transport layer should not blindly fall back to (see attached image) for unknown content types. It should either extract text from known types or pass through an error/unknown-type indicator.

Environment

  • OpenClaw version: 2026.4.27
  • Platform: macOS
  • Memory backend: QMD
  • API provider: Xiaomi MiMo (openai-completions)
  • Node.js: v22.22.2

extent analysis

TL;DR

The issue can be fixed by adding support for the MCP EmbeddedResource type in the OpenClaw content pipeline.

Guidance

  • Modify the contentToText function to handle resource type by adding a conditional statement to check for part.type === "resource" and return part.resource.text if it exists.
  • Update the contentToOpenAIParts function to handle resource type by adding a conditional statement to check for part.type === "resource" and push a new part with type: "input_text" and text: part.resource.text if it exists.
  • Review the transport layer to ensure it handles unknown content types correctly and does not blindly fall back to (see attached image).
  • Test the changes with the provided reproduction steps to verify the fix.

Example

// In contentToText
if (part.type === "resource" && part.resource && typeof part.resource.text === "string") {
  return part.resource.text;
}

// In contentToOpenAIParts
if (part.type === "resource" && part.resource && typeof part.resource.text === "string") {
  parts.push({ type: "input_text", text: part.resource.text });
  continue;
}

Notes

The proposed fix assumes that the resource type always contains a text property. Additional error handling may be necessary to handle cases where this property is missing or empty.

Recommendation

Apply the workaround by modifying the contentToText and contentToOpenAIParts functions to handle the resource type, as this is the most direct way to address the issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING