MCP tool text output is JSON-string-encoded before reaching the model

Open 💬 2 comments Opened Jul 5, 2026 by jens-f

MCP tool text output is JSON-string-encoded before reaching the model

Summary

When an MCP server returns a tool result, the text delivered to the model is always serde_json::to_string(...)'d. Multi-line or structured text therefore reaches the model escaped . Every newline becomes \n and every " becomes \". And in the common case it is additionally wrapped in a JSON array with "type"/"text" keys. This inflates token count and forces the model to mentally un-escape its own tool output before it can use it.

What the model receives

An MCP tool returning a text block like:

<file path="src/user_service.ts" size="2.1 KB">
export class UserService { ... }
</file>

reaches the model as:

[{"type":"text","text":"<file path=\"src/user_service.ts\" size=\"2.1 KB\">\nexport class UserService { ... }\n</file>"}]

Observe the escaped quotes, literal \n, plus the array + key wrapper instead of the clean text. (When the server sets structuredContent instead, the model gets {"text":"<file path=\"...\">\n..."} i.e. same escaping, different wrapper.)

Why it matters

  • Token cost. Every " → \" and newline → \n adds a character, and the [{"type":"text","text":…}] wrapper adds fixed overhead on every call. Tools that return code, XML, JSON, or any quoted/multi-line content pay this tax on every result.
  • Model burden. The model has to decode the escaping itself to read its own tool output, which is error-prone for large or deeply-nested payloads and wastes reasoning on transport bookkeeping.

Root cause (confirmed on tag rust-v0.142.5)

CallToolResult::as_function_call_output_payload (codex-rs/protocol/src/models.rs:1999):
if structured_content is present → FunctionCallOutputBody::Text(serde_json::to_string(structured_content))(models.rs:2003);
else → Text(serde_json::to_string(&self.content)) (models.rs:2019).

Both paths JSON-string-encode. The only non-encoded path is convert_mcp_content_to_items (models.rs:2047), which emits raw InputText items only when a content block is an image (if saw_image { Some } else { None }, models.rs:2111) . So plain text output can never take it.

The escaping is incidental, not fundamental: the one path that already delivers clean text is code mode (Feature::CodeMode, experimental/off by default), where an MCP CallToolResult reaches the in-process JS runtime as a real JSON value (core/src/tools/context.rs:94-97) rather than a to_string'd string. A normal (direct) function-call tool result has no equivalent.

Proposed options

  1. For text results, concatenate the text blocks into the payload string instead of serde_json::to_string-ing the whole content array - dropping at least the array + "type"/"text" wrapper and the outer quoting.
  2. Honor a _meta flag (e.g. codex/rawTextOutput) that emits a chosen text block as a raw FunctionCallOutputBody::Text without re-stringifying, giving MCP servers an opt-in clean-text channel to the model.

Related

  • PR #2594 (merged): added structuredContent handling; both it and content are stringified before reaching the model.
  • Issue #23131 (open): same escaped payload in the wild (Figma MCP: "text":"<canvas id=\"1173:53\"...>\n..."), filed as an @openai/codex-sdk JSONL parse failure.

View original on GitHub ↗

2 Comments

Vijaydaswani · 1 month ago

I have a fix prepared and pushed here:

Branch: https://github.com/Vijaydaswani/codex/tree/codex/fix-mcp-raw-text-output
Commit: https://github.com/Vijaydaswani/codex/commit/65c76c1b023d9f1fa9621eeaa51c32490080b6c6
Patch: https://github.com/Vijaydaswani/codex/commit/65c76c1b023d9f1fa9621eeaa51c32490080b6c6.patch
Summary of the change:

Text-only MCP content is now concatenated and emitted as raw FunctionCallOutputBody::Text.
MCP results containing images still use content items.
Unknown non-image content keeps the existing JSON fallback.
Added regression coverage for quoted, multi-line MCP text output and updated downstream expectations.
I tried opening a PR from the fork, but this repository currently limits PR creation to collaborators, so GitHub blocks non-collaborator PRs. Could a maintainer/collaborator please import this branch or patch and open the PR internally?

Dave-London · 1 month ago

Nice root-cause writeup — the escaping tax lands hardest on tools returning large multi-line or quoted blobs, and as you note structuredContent gets stringified too. One orthogonal mitigation while the transport fix lands is to shrink the payload itself: I build Pare as MCP servers that return stripped, minified JSON instead of raw CLI output, so even with the \n/\" overhead the base is far smaller per call. Doesn't replace fixing the double-encoding — just bounds the blast radius in the meantime.