Skip to content

Python: [Bug]: MCP structuredContent never reaches AG-UI snapshot/history when it is kept out of the model context #7959

Closed

Description

@likebean

Description

MCP Apps (and similar Hosts) need a split that MAF currently cannot express end-to-end:

  • LLM / next-turn function_result.result: only MCP CallToolResult.content (short unstructured text).
  • Every Host/UI surface: the full CallToolResult, including structuredContent (widget payload such as image_url).

Today those two channels are fused into one string, then the UI path keeps only the LLM string.

This is related to but distinct from #7866. That issue is about which field the model should see (and the duplicate tokens from dumping structuredContent as a second text item). This issue is: even if the model only gets content, AG-UI must still deliver the full tool result to the Host. A content-only parser (or a preference enum from #7866) without preserving structuredContent for AG-UI will break MCP Apps iframes after the run ends or on history reload.

What happens

  1. Parser dumps structuredContent into model-visible text in _parse_tool_result_from_mcp:
if mcp_type.structuredContent is not None:
    result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str)))

Content.from_function_result then concatenates that into .result, so the LLM sees image_url / large JSON. MCP Apps guidance is the opposite: content is for model context; structuredContent is for UI hydration and should not be added to the model context (ext-apps draft).

  1. Live AG-UI already has a split via TOOL_RESULT_DISPLAY_KEY / state_update(..., tool_result=...). TOOL_CALL_RESULT can carry a UI-only payload.

  2. MESSAGES_SNAPSHOT and history conversion throw that payload away. _emit_tool_result_common stores the LLM string in flow.tool_results, which _build_messages_snapshot uses. agent_framework_messages_to_agui copies content.result only. Tests document this as intentional (test_display_payload_routes_to_ui_only: UI event gets display JSON, flow.tool_results keeps the short text).

So a Host that shows a widget from structuredContent works during streaming and then loses it when the snapshot replaces the thread (e.g. iframe: No image_url in tool result.). Reloading the session has the same gap.

Core cannot import agent_framework_ag_ui, so the parser should not stamp DISPLAY_KEY itself.

What I expected

Surface Expected
Model / persisted function_result.result MCP content text only
Live TOOL_CALL_RESULT Full tool result (content + structuredContent + isError)
MESSAGES_SNAPSHOT (flow.tool_results) Same full tool result
agent_framework_messages_to_agui (history GET) Same full tool result

Next model turn should still use stored Message text (content only), not the AG-UI snapshot.

Suggested split (for discussion)

  1. python/packages/core/agent_framework/_mcp.py (_parse_tool_result_from_mcp): parse content into Content items for the LLM; do not append json.dumps(structuredContent) as extra text; persist the full CallToolResult (or at least structuredContent + isError) on Content.additional_properties with a core-owned key (not TOOL_RESULT_DISPLAY_KEY).
  2. python/packages/ag-ui/agent_framework_ag_ui/_run_common.py (_extract_tool_result_display / _emit_tool_result_common): treat that core key like a display payload; when present, put the full result in both TOOL_CALL_RESULT and flow.tool_results (today only the live event gets display).
  3. python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py (agent_framework_messages_to_agui): if the stored items carry the full result, use it as the AG-UI tool-message content; otherwise keep content.result.

Optional compatibility with #7866: a MCPTool preference for what the model sees (content / structuredContent / both) and always keep the full wire result on additional_properties for Hosts. MCP Apps Hosts would choose content-only for the model.

Layering: core must not depend on AG-UI. AG-UI should recognize the core-owned structured payload alongside the existing DISPLAY_KEY.

Changing flow.tool_results to store the UI payload when a display/structured marker exists would change the current DISPLAY_KEY contract (test_display_payload_routes_to_ui_only, test_mcp_tool_result_routes_display_payload_to_ui_only). That is required for Host widgets to survive snapshot; happy to discuss keeping LLM text in snapshot for non-MCP state_update tools if you want a narrower change.

Code Sample

# MCP server returns:
#   content = [TextContent("QR ready for hello")]
#   structuredContent = {"text": "hello", "image_url": "http://.../qr.png"}
#
# Observed after _parse_tool_result_from_mcp:
#   items[0].text == "QR ready for hello"
#   items[1].text == '{"text": "hello", "image_url": "..."}'  # leaked into the LLM
#
# After the AG-UI run, MESSAGES_SNAPSHOT tool message content is the concatenated
# LLM string (or, if a custom parser drops structuredContent, only the short text).
# The Host iframe needs structuredContent.image_url in that snapshot/history
# message, without putting it in function_result.result for the next model turn.

Package Versions

agent-framework-core / agent-framework-ag-ui: current main (parser around _parse_tool_result_from_mcp lines 719–720; emit around _emit_tool_result_common storing result_content into flow.tool_results).

Python Version

3.12

Additional Context

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Aug 31, 2026
  2. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Aug 31, 2026
  3. github-actions commented on Aug 31, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    Repro: python/packages/core/agent_framework/_mcp.py::_parse_tool_result_from_mcp appends structuredContent as model-visible text, while python/packages/ag-ui/agent_framework_ag_ui/_run_common.py::_emit_tool_result_common stores only the short result in flow.tool_results and _message_adapters.py::agent_framework_messages_to_agui copies only content.result. It triggers when an MCP result has short text plus a distinct structured display payload. Minimal repro: emit an MCP result with TOL_RESULT_DISPLAY_KEY, verify the live event has the full payload, then observe the snapshot accumulator and history conversion retain only the short text.

    • Failing test: python/packages/ag-ui/tests/ag_ui/test_run_common.py
    • Files examined: python/packages/core/agent_framework/_mcp.py, python/packages/core/tests/core/test_mcp.py, python/packages/core/pyproject.toml, python/packages/ag-ui/agent_framework_ag_ui/_run_common.py, python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py, python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py, python/packages/ag-ui/tests/ag_ui/test_run_common.py, python/packages/ag-ui/pyproject.toml
    • Tests run: test_parse_tool_result_from_mcp_structured_content_with_text, test_mcp_tool_result_routes_display_payload_to_ui_only, test_mcp_tool_result_preserves_display_payload_for_messages_snapshot, test_tool_result_preserves_display_payload_for_history_conversion
    • Current version: agent-framework-core 1.16.0, agent-framework-ag-ui 1.2.1
  4. added
    agentsUsage: [Issues, PRs], Target: Single agent
    mcpUsage: [Issues, PRs], Target: MCP
    and removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Aug 31, 2026
  5. ronronner02 commented on Aug 31, 2026

    @ronronner02
    Contributor

    Following the contribution ownership rule in python/AGENTS.md for this area, I'd like to check the intended contract with the core team before opening a PR.

    I have a working local fix, but the Host payload shape is a new cross-package data contract and I don't want to pick it unilaterally. Six questions below; happy to adjust and open the PR once you've confirmed the direction.

    What I found

    Three places drop the structured half of an MCP tool result before it reaches a Host:

    • _mcp.py::_parse_tool_result_from_mcp folds content into model-visible text and appends structuredContent as an extra text item; nothing structured survives for Host surfaces.
    • _run_common.py::_emit_tool_result_common stores only the model string in flow.tool_results, which MESSAGES_SNAPSHOT is built from.
    • _message_adapters.py::agent_framework_messages_to_agui copies content.result only.

    So a Host that renders a widget from structuredContent sees it during streaming, then loses it when the snapshot replaces the thread or the session is reloaded. Worth noting: an MCP error result with a readable body but no structuredContent currently loses the error text on every Host surface too, so the UI can show a failed widget with no diagnosable reason.

    Approach I've implemented locally

    Core stamps the wire result onto each produced Content under a core-owned additional_properties key, following the existing _meta precedent (core stamps, downstream reads). AG-UI recognizes that key through its existing marker extraction and uses it for the live event, the snapshot accumulator, and the history conversion.

    Model-visible text is unchanged — which field the model should see is #7866's scope, and this key is orthogonal to whichever preference that issue settles on. The existing TOOL_RESULT_DISPLAY_KEY contract is also unchanged: an explicitly authored display payload still owns the live UI event, and both tests that pin that behavior pass unmodified.

    Current payload shape:

    {
      "content": [ "...MCP content blocks..." ],
      "structuredContent": { },
      "isError": false
    }

    Questions

    1. Should the Host payload be the complete CallToolResult — content + structuredContent + isError — or a narrower projection?
    2. Should _meta be part of it? It currently rides on its own key, consumed by SecureMCPToolProxy.
    3. Must the live event, MESSAGES_SNAPSHOT, and history conversion all use the same shape? I've made them identical; a deliberate asymmetry would also be easy.
    4. Should this wait on, or coordinate with, Python: [Bug]: MCP tools return double output if CallToolResult contains both content and structuredContent #7866? My read is that they're orthogonal (that one decides what the model sees, this one decides what the Host receives), but you own that call.
    5. Which scenario-matrix row(s) in docs/specs/004-python-function-calling-loop.md should this land under? Happy to write the spec update once the shape is settled.
    6. How should the core→AG-UI contract be versioned? agent-framework-ag-ui declares agent-framework-core>=1.15.0,<2, and 1.15.0 has no such marker. I've used a guarded import so an older core falls back to existing behavior, but raising the floor or moving the key to a supported shared location may be what you'd prefer.

    Happy to take this in whatever direction you prefer, or to hand the findings over if the core team would rather own the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentmcpUsage: [Issues, PRs], Target: MCPpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions