Skip to content

Python: [Bug]: MCP structuredContent and function tool results escape non-ASCII characters to \uXXXX #9366

Description

Observed Behavior

When MAF turns a JSON-like tool result into model-visible text, it calls json.dumps(..., default=str) without ensure_ascii=False. Every non-ASCII character is therefore sent to the model as a \uXXXX escape: Año becomes A\u00f1o, Género becomes G\u00e9nero.

This affects:

The impact:

  • More tokens on non-English data. For a short list of Spanish column names, the escaped JSON is 45 tokens against 27 for the UTF-8 form (o200k_base), about 67% more.
  • The model echoes the escapes. It can copy \u00f1 sequences into its answers or into the arguments of the next tool call.
  • The default MCP mode cannot be used for this data. The only way to avoid the escaping is tool_result_content="content_first" or "content_only", and that works only when the server's own content block is UTF-8. There is no workaround for function tools.

The MCP server's own content text is passed through unescaped, so the escaping comes from MAF's serialization, not from the server.

Expected Behavior

Tool results reach the model as UTF-8 JSON ("Año", "Género"), the same way MAF already serializes text elsewhere. _compaction.py, _sessions.py, observability.py and the tool-arguments logging in _tools.py all pass ensure_ascii=False. Earlier fixes for the same problem: #6627 / #6628 (Bedrock JSON content blocks), #7124 (compaction token counts) and #3894 (span attributes).

Suggested fix: add ensure_ascii=False to the three json.dumps calls above.

Steps to Reproduce

  1. Save server.py and repro.py from Minimal Reproduction in one folder.
  2. Install agent-framework-core and mcp.
  3. Run python -X utf8 repro.py. The -X utf8 only makes the Windows console print UTF-8; it does not change MAF's output.
  4. Observe that the structuredContent blocks and the function-tool result contain \u00f1 / \u00e9 / \u00ed, while the MCP server's own content block contains ñ / é / í.

Minimal Reproduction

server.py, a FastMCP server whose typed return value produces both content and structuredContent:

from typing import TypedDict

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("demo", log_level="WARNING")


class Columns(TypedDict):
    columns: list[str]


@mcp.tool()
def get_columns() -> Columns:
    """Return dataset column names."""
    return {"columns": ["Año", "Género", "Categoría"]}


if __name__ == "__main__":
    mcp.run()

repro.py:

import asyncio
import sys

from agent_framework import MCPStdioTool, tool


@tool
def get_columns_local() -> dict:
    """Return dataset column names."""
    return {"columns": ["Año", "Género", "Categoría"]}


async def main() -> None:
    for mode in ("structured_first", "content_first", "both"):
        async with MCPStdioTool(
            name="demo",
            command=sys.executable,
            args=["server.py"],
            tool_result_content=mode,
        ) as mcp_tool:
            func = next(f for f in mcp_tool.functions if f.name == "get_columns")
            for content in await func.invoke():
                print(f"MCP {mode}: {content.text}")

    for content in await get_columns_local.invoke():
        print(f"FunctionTool: {content.text}")


asyncio.run(main())

Output (server logs on stderr omitted):

MCP structured_first: {"columns": ["A\u00f1o", "G\u00e9nero", "Categor\u00eda"]}
MCP content_first: {
  "columns": [
    "Año",
    "Género",
    "Categoría"
  ]
}
MCP both: {
  "columns": [
    "Año",
    "Género",
    "Categoría"
  ]
}
MCP both: {"columns": ["A\u00f1o", "G\u00e9nero", "Categor\u00eda"]}
FunctionTool: {"columns": ["A\u00f1o", "G\u00e9nero", "Categor\u00eda"]}

Error Messages and Stack Traces

None. Nothing is raised; the model just receives escaped text.

Package Versions

agent-framework-core: 1.21.0, mcp: 1.30.0

Python Version

Python 3.13.1

Operating System

Windows

Regression

No

Additional Context

Token counts, using tiktoken with o200k_base, for {"columns": ["Año", "Género", "Categoría", "Líquido medio", "Cotización"]}:

Serialization Tokens
json.dumps(data) 45
json.dumps(data, ensure_ascii=False) 27

We hit this on an MCP server that returns Spanish dataset metadata. #7866 fixed the duplicated output by adding tool_result_content, but the default structured_first still sends the escaped form, so on non-English data we have to move off the default to keep token use down.

Acknowledgements

  • I searched existing issues and did not find a duplicate.
  • I personally verified this behavior and the reproduction details are authentic.
  • I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 14, 2026
  2. eavanvalkenburg commented on Oct 14, 2026

    @eavanvalkenburg
    Member

    /plan

  3. github-actions commented on Oct 14, 2026

    @github-actions
    Contributor

    Summary

    The issue reports that Model Context Protocol (MCP) tool results and function-tool results are turned into text with Python's json.dumps(..., default=str) without ensure_ascii=False. By default, json.dumps escapes every non-ASCII character to a \uXXXX sequence, so "Año" becomes "A\u00f1o" before it reaches the model. This happens in three places:

    • _mcp.py (around line 1290), where structuredContent returned by an MCP server is serialized for the model-visible text (the default tool_result_content="structured_first" mode, plus structured_only/both).
    • _tools.py (around lines 1412/1416), where FunctionTool.parse_result turns a dict/list/model return value from a @tool-decorated function into text.
    • _types.py (around line 1040 in Content.from_function_result), the same pattern for a raw (non-Content) result.

    I checked these locations directly in the current main branch and confirmed none of the three calls pass ensure_ascii=False, while other serialization call sites in the same package (_compaction.py, _sessions.py, observability.py, and the tool-argument logging line in _tools.py itself) already do. So the inconsistency is real and verified, not just as described in the issue.

    Recommendation

    Code changes are warranted. This is a real inconsistency, not a documented limitation or something an existing feature already solves:

    • The only built-in workaround today is setting tool_result_content="content_first" or "content_only" on MCPTool, and that only helps if the MCP server's own content block is UTF‐8 text — it does nothing for structuredContent-only servers, and there is no workaround at all for function tools returning dicts/lists directly.
    • The fix is narrowly scoped (adding one keyword argument, ensure_ascii=False, to three existing json.dumps calls) and matches a serialization convention the codebase has already adopted elsewhere, so the risk of introducing new bugs or compatibility problems is low.
    • The benefit is concrete: fewer tokens sent to the model for any non-ASCII tool output (the issue's own token count example shows a real reduction), and it removes a surprising behavior where the model can echo back \uXXXX escapes into later tool-call arguments instead of the original characters.

    I did not find any indication that non-ASCII JSON escaping is an intentional design choice anywhere in these three call sites (no comments or tests asserting escaped output), so this looks like an oversight rather than a deliberate boundary.

    Proposed plan

    1. _mcp.py: add ensure_ascii=False to the json.dumps(mcp_type.structuredContent, default=str) call used to build the structuredContent text block.
    2. _tools.py: add ensure_ascii=False to both json.dumps(dumpable, default=str) calls in FunctionTool.parse_result (the list-item branch and the single-result branch).
    3. _types.py: add ensure_ascii=False to the json.dumps(result, default=str) call in Content.from_function_result.
    4. Tests: add or extend test cases (likely in test_mcp.py and test_tools.py, where MCP and function-tool result parsing are already covered) that return non-ASCII data (e.g. a dict with accented characters) and assert the produced text contains the literal characters ("Año") rather than \uXXXX escapes, for each of the three code paths. Also confirm nearby behavior is unchanged — e.g. that ASCII-only results, non-dict/list results, and the existing content_first/both modes still serialize the same as before except for the escaping change.
    5. You'd know the issue is resolved when the reproduction script in the issue (repro.py) prints {"columns": ["Año", "Género", "Categoría"]} instead of the escaped form for all three reported cases (structured_first, both, and the plain function tool).

    This is a proposal only — no code has been changed. The main open question is just confirming the three call sites above are the complete set MAF currently has for this pattern (a quick repo-wide search for json.dumps without ensure_ascii=False during implementation would catch any sibling occurrence missed here).

    Generated by Issue Planning for #9366 · copilot · auto · 37.4 AIC · ⌖ 9.44 AIC · ⊞ 10.8K · ◷

  4. ZedingZhang commented on Oct 14, 2026

    @ZedingZhang
    Contributor

    I'd like to work on this based on the proposed plan above. Add ensure_ascii=False to the result serialization in _mcp.py, both JSON serialization branches of FunctionTool.parse_result in _tools.py, and Content.from_function_result in _types.py. Add regression tests asserting that non-ASCII characters are preserved in the resulting text, covering MCP structured results and function-tool results, while checking that ASCII output and existing content-selection behavior remain unchanged

  5. eavanvalkenburg commented on Oct 27, 2026

    @eavanvalkenburg
    Member

    Thanks for the detailed report. We will consider this in core MAF as a consistent policy for framework-generated, model-visible JSON in function/MCP results and prompts. Literal Unicode and escaped Unicode preserve the same decoded JSON values; the benefit here is readable multilingual model context rather than fixing demonstrated data corruption.

    The scope should stay model-facing: unrelated transport serialization, Host-payload byte budgeting and checkpoint persistence should not change. The existing planning result and #9400 are the coordination points, so another /plan is not needed. Scope still needs to account for the ResourceLink prompt branch and the current MCP v2 structured-result conversion, with focused coverage of multilingual output and unchanged decoded values.

    This records core consideration, not an implementation/timing commitment or implementation-ready approval. The existing volunteer and PR author should be coordinated before any new assignment.

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

Metadata

Metadata

Labels

pythonUsage: [Issues, PRs], Target: Python

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions