# Structured Content vs Content: What MCP clients actually do with tool results

*MCP tool results · field-visibility survey*

The companion to the [server survey](server-survey.md). That one asks what *servers* put on the wire; this one asks what *clients* forward to the model. The [MCP spec](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-result) gives a tool result two places for data — unstructured `content` and a `structuredContent` object. For tools with an [`outputSchema`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#output-schema), 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.

Four variants for how a client forwards a `tools/call` result to the model:

- **A · content only**
- **B · structuredContent first**
- **C · forward both**
- **D · fallback**

| Count | Variant | What it means |
|------:|------|---------------|
| **10** | content only | `structuredContent` never reaches the model |
| **4** | structuredContent first | `content` text dropped when structured is present |
| **4** | forward both | whole envelope serialized — payload lands in context twice |
| **1** | fallback | content, falling back to structuredContent |

---

## 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

```json
{
  "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
}
```

**Client Variant A — content only.** Model receives the `content` text — the full ~250-row string. `structuredContent` is never sent.
→ strands, agent-framework·py, cline, crewAI +tools, deepagents, gemini-cli, goose\*, LibreChat, Roo-Code, zed

**Client Variant B — structuredContent first.** Model receives `JSON.stringify(structuredContent)`. The `content` text is dropped.
→ codex\*, fast-agent, mastra, VS Code\*

**Client Variant C — forward both.** Model receives **both** `content` and `structuredContent` — the dataset lands **twice**.
→ adk-python, agent-framework·net, kilocode, opencode

**Client Variant D — fallback.** Both present → **both forwarded**; `structuredContent` substitutes only when `content` is empty. For this response, still a double dump.
→ hermes-agent

---

## 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 2026-06-04 · [commit history](https://github.com/olaservo/research-hub/commits/main)*

*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](https://github.com/strands-agents/sdk-python/tree/dd7b41de) | 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](https://github.com/strands-agents/sdk-python/pull/528) |
| [agent-framework](https://github.com/microsoft/agent-framework/tree/edcc7866) (Python) | content only at top level — *structuredContent read only for embedded blocks; code mode reuses the same parser* | A | [#3313](https://github.com/microsoft/agent-framework/issues/3313) · [#4763](https://github.com/microsoft/agent-framework/pull/4763) |
| [cline](https://github.com/cline/cline/tree/31a118fc) | content text/images only — *empty → "(No response)"; no structuredContent read* | A | — |
| [crewAI](https://github.com/crewAIInc/crewAI/tree/5cdc420c) + [crewAI-tools](https://github.com/crewAIInc/crewAI-tools/tree/62f4d7ef) | content[0].text only — *crewAI-tools delegates the same content-only path via mcpadapt* | A | — |
| [deepagents](https://github.com/langchain-ai/deepagents/tree/1906af98) | content only — *structuredContent → LangChain artifact (tooling, not the model)* | A | — |
| [gemini-cli](https://github.com/google-gemini/gemini-cli/tree/01391407) | content only — *reverse shim backfills content from structuredContent; never sends sC to the model* | A | [#27045](https://github.com/google-gemini/gemini-cli/pull/27045) |
| [goose](https://github.com/aaif-goose/goose/tree/13f7be2e) | conversational: content only — *code-mode TS sandbox prefers structuredContent — two paths, one client* | A / B | — |
| [LibreChat](https://github.com/danny-avila/LibreChat/tree/e3cc2a9c) | content only — *empty → "(No response)"; PTC code paths decided in external @librechat/agents* | A | [#8447](https://github.com/danny-avila/LibreChat/issues/8447) |
| [Roo-Code](https://github.com/RooCodeInc/Roo-Code/tree/b867ec91) | content text/images only — *empty → "(No response)"* | A | — |
| [zed](https://github.com/zed-industries/zed/tree/876ec5a8) | content only — *structuredContent deserialized then discarded on the model path* | A | — |
| [codex](https://github.com/openai/codex/tree/cdde711f) | structuredContent as whole body if present, else content — *code-mode runtime/TS sandbox hands the whole result (both) — a second path* | B | [#10334](https://github.com/openai/codex/issues/10334) · [#2594](https://github.com/openai/codex/pull/2594) |
| [fast-agent](https://github.com/evalstate/fast-agent/tree/dc2a8617) | compact JSON of structuredContent replaces text, else content | B | [#703](https://github.com/evalstate/fast-agent/pull/703) |
| [mastra](https://github.com/mastra-ai/mastra/tree/4517213d) | structuredContent alone if present, else whole envelope — *execute_typescript code mode inherits the same wrapper* | B | [#10430](https://github.com/mastra-ai/mastra/issues/10430) |
| [VS Code](https://github.com/microsoft/vscode/tree/c6baf764) + [Copilot Chat](https://github.com/microsoft/vscode-copilot-chat/tree/5863f5a7) | JSON.stringify(structuredContent), dropping content text; else content — *a separate agentHost/Claude path forwards both; copilot-chat inherits, no own conversion* | B | [#290063](https://github.com/microsoft/vscode/issues/290063) |
| [adk-python](https://github.com/google/adk-python/tree/4006fe40) | entire CallToolResult dict (model_dump) — both fields — *generic dump, no field-specific logic* | C | [#3893](https://github.com/google/adk-python/discussions/3893) |
| [agent-framework](https://github.com/microsoft/agent-framework/tree/edcc7866) (.NET) | whole result serialized — both fields — *delegated to the .NET MCP SDK; same project as the Variant-A Python binding* | C | — |
| [kilocode](https://github.com/Kilo-Org/kilocode/tree/ad908e28) | raw CallToolResult → AI SDK — both fields — *delegated* | C | — |
| [opencode](https://github.com/anomalyco/opencode/tree/8f2afba7) | whole CallToolResult JSON.stringify'd — both fields — *incidental, no field-specific logic* | C | [#27263](https://github.com/anomalyco/opencode/issues/27263) · [#28567](https://github.com/anomalyco/opencode/issues/28567) |
| [hermes-agent](https://github.com/NousResearch/hermes-agent/tree/1044d9f2) | both when both present; structuredContent if content empty; else content — *closest to a clean fallback — yet still double-dumps this response* | D | [#7043](https://github.com/NousResearch/hermes-agent/issues/7043) · [#7118](https://github.com/NousResearch/hermes-agent/pull/7118) |
