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 ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗