MCP tool text output is JSON-string-encoded before reaching the model
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 →\nadds 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
- For text results, concatenate the text blocks into the payload string instead of
serde_json::to_string-ing the wholecontentarray - dropping at least the array +"type"/"text"wrapper and the outer quoting. - Honor a
_metaflag (e.g.codex/rawTextOutput) that emits a chosen text block as a rawFunctionCallOutputBody::Textwithout re-stringifying, giving MCP servers an opt-in clean-text channel to the model.
Related
- PR #2594 (merged): added
structuredContenthandling; both it andcontentare 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-sdkJSONL parse failure.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗