structuredContent never reaches the model
What MCP clients actually do with tool results
The companion to the server survey. That one asks what servers put on the wire; this one asks what clients forward to the model. The MCP spec gives a tool result two places for data — unstructured content and a structuredContent object. For tools with an outputSchema, servers MUST populate structuredContent — and SHOULD also duplicate it into a content text block for backwards compatibility. What a client then forwards to the model is left open — hence this survey.
content text dropped when structured is present
The same tool result, handled four ways
Take one tool that returns a dataset. Because it declares an outputSchema, it must populate structuredContent — and it also serializes the rows into content, so the payload sits in both fields. Here is the one response — then what each variant forwards to the model.
One server response
a dataset-returning tool · ~250 rows �— 9 fields
{
"content": [
{ "type": "text",
"text": "[{\"id\":\"row-0001\",\"name\":\"Acme Corp\",\"category\":\"Enterprise\",
\"revenue\":48200000,\"headcount\":1200,\"ageMonths\":31,\"score\":78,
\"openTickets\":4,\"nps\":62}, … 249 more ]" }
],
"structuredContent": { "rows": [ { … same ~250 × 9 … } ] } // identical payload
}
Model receives the content text — the full ~250-row string. structuredContent is never sent.
Model receives JSON.stringify(structuredContent). The content text is dropped.
Model receives both content and structuredContent — the dataset lands twice.
Both present → both forwarded; structuredContent substitutes only when content is empty. For this response, still a double dump.
Keep content a concise, model-useful summary and put the bulk dataset in structuredContent (large or binary blobs behind a resource). The outputSchema contract is still satisfied, Variant A still gets something readable, and no variant dumps the full dataset into the model.
Nineteen clients, four variants
Each row was verified by reading the client's tool-result handling at a pinned SHA — each client name links to its repo at that commit. Open-source clients only: the conversion is checked against source, so closed-source agents are out of scope.
Last updated · commit history
19 rows across 18 projects — agent-framework is counted twice (Python → Variant A, .NET → Variant C).
| Client | What reaches the model | Client Variant | Related links / PRs |
|---|---|---|---|
| strands | content only structuredContent kept as a separate field on the result for hooks/programmatic access; not forwarded to the model (providers serialize only content) | A | #528 |
| agent-framework Python | content only at top level structuredContent read only for embedded blocks; code mode reuses the same parser | A | #3313 · #4763 |
| cline | content text/images only empty → "(No response)"; no structuredContent read | A | — |
| crewAI + crewAI-tools | content[0].text only crewAI-tools delegates the same content-only path via mcpadapt | A | — |
| deepagents | content only structuredContent → LangChain artifact (tooling, not the model) | A | — |
| gemini-cli | content only reverse shim backfills content from structuredContent; never sends sC to the model | A | #27045 |
| goose | conversational: content only code-mode TS sandbox prefers structuredContent — two paths, one client | A B | — |
| LibreChat | content only empty → "(No response)"; PTC code paths decided in external @librechat/agents | A | #8447 |
| Roo-Code | content text/images only empty → "(No response)" | A | — |
| zed | content only structuredContent deserialized then discarded on the model path | A | — |
| codex | structuredContent as whole body if present, else content code-mode runtime/TS sandbox hands the whole result (both) — a second path | B | #10334 · #2594 |
| fast-agent | compact JSON of structuredContent replaces text, else content | B | #703 |
| mastra | structuredContent alone if present, else whole envelope execute_typescript code mode inherits the same wrapper | B | #10430 |
| VS Code + Copilot Chat | JSON.stringify(structuredContent), dropping content text; else content a separate agentHost/Claude path forwards both; copilot-chat inherits, no own conversion | B | #290063 |
| adk-python | entire CallToolResult dict (model_dump) — both fields generic dump, no field-specific logic | C | #3893 |
| agent-framework .NET | whole result serialized — both fields delegated to the .NET MCP SDK; same project as the Variant-A Python binding | C | — |
| kilocode | raw CallToolResult → AI SDK — both fields delegated | C | — |
| opencode | whole CallToolResult JSON.stringify'd — both fields incidental, no field-specific logic | C | #27263 · #28567 |
| hermes-agent | both when both present; structuredContent if content empty; else content closest to SEP-2200's fallback — yet still double-dumps this response | D | #7043 · #7118 |
- goose — conversational replies use
content; the code-mode TS sandbox prefersstructuredContent. - codex — conversational prefers
structuredContentalone; the code-mode sandbox gets the whole result (both fields). - VS Code — the LanguageModelTool path serializes
structuredContentand drops content text; a separate agentHost/Claude path forwards both. - agent-framework — same project, two bindings, two variants: Python is content-only (A), .NET forwards both (C).
The finding
The duplication isn't author sloppiness — it's structurally forced. Per the spec, a tool that declares an outputSchema must return structuredContent conforming to it. Yet the Variant A majority (10 of 19) forwards only content to the model — so anything the model needs to read has to live in content too. Declare an output schema and write for the dominant client, and the same payload lands in both fields by construction — the dump in §1. The flooding is a rational response to client fragmentation, not a mistake.