Skip to content

Python: fix(tools): preserve UTF-8 characters without unicode escaping in tool results - #9400

Open
Nithin (nithin42) wants to merge 4 commits into
microsoft:mainfrom
nithin42:fix/tool-results-unicode-escaping
Open

Nithin (nithin42) wants to merge 4 commits into
microsoft:mainfrom
nithin42:fix/tool-results-unicode-escaping

Conversation

@nithin42

@nithin42 Nithin (nithin42) commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Motivation & Context

Fixes #9366

When MCP tool results (structuredContent) and standard FunctionTool / Content.from_function_result serialize structured output to JSON text, json.dumps() default ensure_ascii=True escapes non-ASCII characters (e.g. Spanish, German, CJK, Cyrillic) into \uXXXX sequences.

This causes:

  1. Unnecessary token bloat in LLM context (e.g. 45 tokens vs 27 tokens for international column names).
  2. Degraded tokenization and model reasoning on multilingual tool outputs.

Precedent PRs across the repository (#6627, #6628, #7124, #3894) have standardized on ensure_ascii=False when serializing structured tool and agent payloads.

Description & Review Guide

  • agent_framework/_mcp.py: Added ensure_ascii=False to json.dumps(mcp_type.structuredContent, ...).
  • agent_framework/_tools.py: Added ensure_ascii=False to json.dumps(...) in FunctionTool.parse_result for item lists and dict results.
  • agent_framework/_types.py: Added ensure_ascii=False to json.dumps(...) in Content.from_function_result.
  • Added regression tests in test_mcp.py and test_types.py verifying characters such as ñ, é, í, and ü are preserved without unicode escape sequences.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two new tests need non-None text assertions to avoid type-check errors.

1 open finding
What changed in this PR

Preserves non-ASCII characters in Python tool-result JSON, addressing #9366 and matching existing serialization behavior elsewhere in the framework.

Changes:

  • Adds ensure_ascii=False to MCP and function-result serialization.
  • Adds regression tests for multilingual tool outputs.
File Description
python/​packages/​core/​tests/​core/​test_types.py Tests Unicode preservation in function results.
python/​packages/​core/​tests/​core/​test_mcp.py Tests Unicode preservation in MCP structured results.
python/​packages/​core/​agent_framework/​_types.py Preserves Unicode in Content.from_function_result.
python/​packages/​core/​agent_framework/​_tools.py Preserves Unicode in FunctionTool.parse_result.
python/​packages/​core/​agent_framework/​_mcp.py Preserves Unicode when serializing structuredContent.

🧠 Review effort: Balanced


💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/core/tests/core/test_types.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The serialization changes are focused and correct, with only an optional regression-test improvement remaining.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

@Somay-kousis

Copy link
Copy Markdown
Contributor

One more spot in _mcp.py still escapes. _parse_prompt_result_from_mcp joins a multi-message prompt with json.dumps(parts, default=str) (line 1204 on this branch), and with load_prompts=True that string goes back to the model as the prompt tool's result.

Ran this on the PR branch (d21f61d), with a stub session whose prompt returns two text messages, "Año" and "Género":

tool structuredContent: {"col": "Año"}
prompt, two messages:   ["Año", "Género"]
get_prompt() result:    ["Año", "Género"]

Adding ensure_ascii=False to that json.dumps would cover prompts too. A test with two PromptMessages would catch it.

@nithin42

Nithin (nithin42) commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Somay kousis (@Somay-kousis) Good catch, thanks for the review! I've added ensure_ascii=False to _parse_prompt_result_from_mcp and included a regression test with multi-message prompts containing non-ASCII text in test_mcp.py.

@nithin42
Nithin (nithin42) force-pushed the fix/tool-results-unicode-escaping branch from e71b5a7 to 762d1d4 Compare October 22, 2026 15:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are narrowly scoped, preserve JSON semantics, and include targeted regression tests, with no blocking issues identified.

No open issues 🎉

Review effort: Balanced

@kimnamu Lucas Kim (kimnamu) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for turning #9366 around the same day it was filed, with a test for each call site, and for adding the multi-message prompt case within half an hour of it being pointed out.

I checked what this changes for Bedrock. BedrockChatClient re-parses tool-result text with json.loads before it builds the Converse toolResult (_chat_client.py:681), so single results were already decoded. Nothing regresses there, and the joined-text cases stop sending escapes to the model:

Tool result toolResult.content on main 4982e4c6 with this PR 762d1d4f
function tool returns {"col": "Año"} [{"json": {"col": "Año"}}] same ✅
MCP structured_first (default) [{"json": {"col": "Año"}}] same ✅
function tool returns [Content.from_text("cols:"), {"col": "Año"}] [{"text": "cols:\n{\"col\": \"A\\u00f1o\"}"}] [{"text": "cols:\n{\"col\": \"Año\"}"}]
MCP both [{"text": "Año, Género\n{\"col\": \"A\\u00f1o\"}"}] [{"text": "Año, Género\n{\"col\": \"Año\"}"}]

The last two rows differ because several text items are joined with "\n" first (_chat_client.py:657), the result no longer parses, and it goes out as a text block.

One more spot in a function you already touched, if you'd like it here: _parse_prompt_result_from_mcp still dumps ResourceLink parts with the default escaping (_mcp.py:1300). For a prompt with a text message plus a link named Categoría.csv, the second Bedrock block is {"text": "{\"name\": \"Categor\\u00eda.csv\", \"uri\": \"file:///data/c.csv\", \"type\": \"resource_link\"}"}. A single-message prompt is fine for the same json.loads reason. Extending your new test catches it:

diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py
index 091342c5..9ea61493 100644
--- a/python/packages/core/agent_framework/_mcp.py
+++ b/python/packages/core/agent_framework/_mcp.py
@@ -1297,7 +1297,9 @@ class MCPTool:
                             )
                         )
             elif isinstance(content, types.ResourceLink):
-                parts.append(json.dumps(content.model_dump(by_alias=True, exclude_none=True), default=str))
+                parts.append(
+                    json.dumps(content.model_dump(by_alias=True, exclude_none=True), default=str, ensure_ascii=False)
+                )
             else:
                 parts.append(str(content))
         if not parts:
diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py
index 2f173abd..61a1eb00 100644
--- a/python/packages/core/tests/core/test_mcp.py
+++ b/python/packages/core/tests/core/test_mcp.py
@@ -1118,6 +1118,10 @@ def test_parse_prompt_result_from_mcp_unicode_unescaped():
                 role="assistant",
                 content=types.TextContent(type="text", text="Género"),
             ),
+            types.PromptMessage(
+                role="user",
+                content=types.ResourceLink(type="resource_link", name="Categoría.csv", uri="file:///data/c.csv"),
+            ),
         ],
     )
 
@@ -1127,6 +1131,7 @@ def test_parse_prompt_result_from_mcp_unicode_unescaped():
     assert "Género" in result
     assert "\\u00f1" not in result
     assert "\\u00e9" not in result
+    assert "Categoría.csv" in result
 
 
 def test_parse_tool_result_from_mcp():
# extended test, source at 762d1d4f
E       assert 'Categoría.csv' in '["Año", "Género", "{\\"name\\": \\"Categor\\\\u00eda.csv\\", \\"uri\\": \\"file:///data/c.csv\\", \\"type\\": \\"resource_link\\"}"]'
1 failed, 466 deselected
# plus the _mcp.py change
1 passed, 466 deselected
Red/green on the PR's own tests and local runs
  • The 5 new tests with only _mcp.py, _tools.py and _types.py put back to the merge base c3ce2506: 5 failed (for example assert 'Año' in '{"columns": ["A\\u00f1o", "G\\u00e9nero", "Categor\\u00eda"]}'). With the PR sources restored: 5 passed.
  • test_mcp.py + test_types.py at 762d1d4f: 950 passed, 2 skipped. Same count with the diff above applied (git apply), and ruff format / ruff check are clean on _mcp.py.
  • packages/bedrock/tests -m "not integration" at 762d1d4f: 92 passed.
  • The function-tool rows come from an Agent run against a stubbed converse (tool call on the first turn, text on the second), reading the toolResult from the second request. The MCP rows pass MCPTool._parse_tool_result_from_mcp output through _convert_content_to_bedrock_block.

Claude (via Claude Code) assisted with the reproduction and this write-up.

@nithin42

Copy link
Copy Markdown
Contributor Author

Lucas Kim (@kimnamu)
Thanks for testing this against Bedrock and checking the multi-part cases, really appreciate the detailed breakdown!

Good catch on the ResourceLink part too. I have that change tested and ready to go locally, and I am happy to push it up if the maintainers would like it included in this PR. I will wait for their review and feedback first so we do not churn the branch.

This branch was successfully deployed

1 active deployment
github-app-auth — 762d1d4f Deployed Oct 22, 2026 by nithin42 via add_label #25940
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants