Skip to content

Commit db283cd

Browse files
authored
Python: Fix MCP tool result serialization for list[TextContent] (#2523)
* Fix MCP tool result serialization for list[TextContent] When MCP tools return results containing list[TextContent], they were incorrectly serialized to object repr strings like: '[<agent_framework._types.TextContent object at 0x...>]' This fix properly extracts text content from list items by: 1. Checking if items have a 'text' attribute (TextContent) 2. Using model_dump() for items that support it 3. Falling back to str() for other types 4. Joining single items as plain text, multiple items as JSON array Fixes #2509 * Address PR review feedback for MCP tool result serialization - Extract serialize_content_result() to shared _utils.py - Fix logic: use texts[0] instead of join for single item - Add type annotation: texts: list[str] = [] - Return empty string for empty list instead of '[]' - Move import json to file top level - Add comprehensive unit tests for serialization * Address PR review feedback: fix type checking and double serialization - Add isinstance(item.text, str) check to ensure text attribute is a string - Fix double-serialization issue by keeping model_dump results as dicts until final json.dumps (removes escaped JSON strings in arrays) - Improve docstring with detailed return value documentation - Add test for non-string text attribute handling - Add tests for list type tool results in _events.py path * Simplify PR: minimal changes to fix MCP tool result serialization Addresses reviewer feedback about excessive refactoring: - Reset _events.py to original structure - Only add import and use serialize_content_result in one location - All review comments addressed in serialize_content_result(): - Added isinstance(item.text, str) check - Use model_dump(mode="json") to avoid double-serialization - Improved docstring with explicit return value documentation - Empty list returns "" instead of "[]" * Refactor: Move MCP TextContent serialization to core prepare_function_call_results Per reviewer feedback, moved the TextContent serialization logic from ag-ui's serialize_content_result to the core package's prepare_function_call_results function. Changes: - Added handling for objects with 'text' attribute (like MCP TextContent) in _prepare_function_call_results_as_dumpable - Removed serialize_content_result from ag-ui/_utils.py - Updated _events.py and _message_adapters.py to use prepare_function_call_results from core package - Updated tests to match the core function's behavior * Fix failing tests for prepare_function_call_results behavior - test_tool_result_with_none: Update expected value to 'null' (JSON serialization of None) - test_tool_result_with_model_dump_objects: Use Pydantic BaseModel instead of plain class * Fix B903 linter error: Convert MockTextContent to dataclass The ruff linter was reporting B903 (class could be dataclass or namedtuple) for the MockTextContent test helper classes. This commit converts them to dataclasses to satisfy the linter check.
1 parent f49e537 commit db283cd

File tree

7 files changed

+280
-19
lines changed

7 files changed

+280
-19
lines changed

python/packages/ag-ui/agent_framework_ag_ui/_events.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
FunctionCallContent,
3232
FunctionResultContent,
3333
TextContent,
34+
prepare_function_call_results,
3435
)
3536

3637
from ._utils import generate_event_id
@@ -391,12 +392,7 @@ def _handle_function_result_content(self, content: FunctionResultContent) -> lis
391392
self.state_delta_count = 0
392393

393394
result_message_id = generate_event_id()
394-
if isinstance(content.result, dict):
395-
result_content = json.dumps(content.result) # type: ignore[arg-type]
396-
elif content.result is not None:
397-
result_content = str(content.result)
398-
else:
399-
result_content = ""
395+
result_content = prepare_function_call_results(content.result)
400396

401397
result_event = ToolCallResultEvent(
402398
message_id=result_message_id,

python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
"""Message format conversion between AG-UI and Agent Framework."""
44

5+
import json
56
from typing import Any, cast
67

78
from agent_framework import (
@@ -11,6 +12,7 @@
1112
FunctionResultContent,
1213
Role,
1314
TextContent,
15+
prepare_function_call_results,
1416
)
1517

1618
# Role mapping constants
@@ -59,10 +61,8 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
5961
# Distinguish approval payloads from actual tool results
6062
is_approval = False
6163
if isinstance(result_content, str) and result_content:
62-
import json as _json
63-
6464
try:
65-
parsed = _json.loads(result_content)
65+
parsed = json.loads(result_content)
6666
is_approval = isinstance(parsed, dict) and "accepted" in parsed
6767
except Exception:
6868
is_approval = False
@@ -237,13 +237,8 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
237237
elif isinstance(content, FunctionResultContent):
238238
# Tool result content - extract call_id and result
239239
tool_result_call_id = content.call_id
240-
# Serialize result to string
241-
if isinstance(content.result, dict):
242-
import json
243-
244-
content_text = json.dumps(content.result) # type: ignore
245-
elif content.result is not None:
246-
content_text = str(content.result)
240+
# Serialize result to string using core utility
241+
content_text = prepare_function_call_results(content.result)
247242

248243
agui_msg: dict[str, Any] = {
249244
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id

python/packages/ag-ui/tests/test_events_comprehensive.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ async def test_tool_result_with_none():
201201
assert len(events) == 2
202202
assert events[0].type == "TOOL_CALL_END"
203203
assert events[1].type == "TOOL_CALL_RESULT"
204-
assert events[1].content == ""
204+
# prepare_function_call_results serializes None as JSON "null"
205+
assert events[1].content == "null"
205206

206207

207208
async def test_multiple_tool_results_in_sequence():
@@ -688,3 +689,97 @@ async def test_state_delta_count_logging():
688689

689690
# State delta count should have incremented (one per unique state update)
690691
assert bridge.state_delta_count >= 1
692+
693+
694+
# Tests for list type tool results (MCP tool serialization)
695+
696+
697+
async def test_tool_result_with_empty_list():
698+
"""Test FunctionResultContent with empty list result."""
699+
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
700+
701+
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
702+
703+
update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])])
704+
events = await bridge.from_agent_run_update(update)
705+
706+
assert len(events) == 2
707+
assert events[0].type == "TOOL_CALL_END"
708+
assert events[1].type == "TOOL_CALL_RESULT"
709+
# Empty list serializes as JSON empty array
710+
assert events[1].content == "[]"
711+
712+
713+
async def test_tool_result_with_single_text_content():
714+
"""Test FunctionResultContent with single TextContent-like item (MCP tool result)."""
715+
from dataclasses import dataclass
716+
717+
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
718+
719+
@dataclass
720+
class MockTextContent:
721+
text: str
722+
723+
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
724+
725+
update = AgentRunResponseUpdate(
726+
contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
727+
)
728+
events = await bridge.from_agent_run_update(update)
729+
730+
assert len(events) == 2
731+
assert events[0].type == "TOOL_CALL_END"
732+
assert events[1].type == "TOOL_CALL_RESULT"
733+
# TextContent text is extracted and serialized as JSON array
734+
assert events[1].content == '["Hello from MCP tool!"]'
735+
736+
737+
async def test_tool_result_with_multiple_text_contents():
738+
"""Test FunctionResultContent with multiple TextContent-like items (MCP tool result)."""
739+
from dataclasses import dataclass
740+
741+
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
742+
743+
@dataclass
744+
class MockTextContent:
745+
text: str
746+
747+
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
748+
749+
update = AgentRunResponseUpdate(
750+
contents=[
751+
FunctionResultContent(
752+
call_id="call_123",
753+
result=[MockTextContent("First result"), MockTextContent("Second result")],
754+
)
755+
]
756+
)
757+
events = await bridge.from_agent_run_update(update)
758+
759+
assert len(events) == 2
760+
assert events[0].type == "TOOL_CALL_END"
761+
assert events[1].type == "TOOL_CALL_RESULT"
762+
# Multiple TextContent items should return JSON array
763+
assert events[1].content == '["First result", "Second result"]'
764+
765+
766+
async def test_tool_result_with_model_dump_objects():
767+
"""Test FunctionResultContent with Pydantic BaseModel objects."""
768+
from pydantic import BaseModel
769+
770+
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
771+
772+
class MockModel(BaseModel):
773+
value: int
774+
775+
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
776+
777+
update = AgentRunResponseUpdate(
778+
contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
779+
)
780+
events = await bridge.from_agent_run_update(update)
781+
782+
assert len(events) == 2
783+
assert events[1].type == "TOOL_CALL_RESULT"
784+
# Should be properly serialized JSON array without double escaping
785+
assert events[1].content == '[{"value": 1}, {"value": 2}]'

python/packages/ag-ui/tests/test_message_adapters.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""Tests for message adapters."""
44

55
import pytest
6-
from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent
6+
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
77

88
from agent_framework_ag_ui._message_adapters import (
99
agent_framework_messages_to_agui,
@@ -278,3 +278,119 @@ def test_extract_text_from_custom_contents():
278278
result = extract_text_from_contents(contents)
279279

280280
assert result == "Custom Mixed"
281+
282+
283+
# Tests for FunctionResultContent serialization in agent_framework_messages_to_agui
284+
285+
286+
def test_agent_framework_to_agui_function_result_dict():
287+
"""Test converting FunctionResultContent with dict result to AG-UI."""
288+
msg = ChatMessage(
289+
role=Role.TOOL,
290+
contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})],
291+
message_id="msg-789",
292+
)
293+
294+
messages = agent_framework_messages_to_agui([msg])
295+
296+
assert len(messages) == 1
297+
agui_msg = messages[0]
298+
assert agui_msg["role"] == "tool"
299+
assert agui_msg["toolCallId"] == "call-123"
300+
assert agui_msg["content"] == '{"key": "value", "count": 42}'
301+
302+
303+
def test_agent_framework_to_agui_function_result_none():
304+
"""Test converting FunctionResultContent with None result to AG-UI."""
305+
msg = ChatMessage(
306+
role=Role.TOOL,
307+
contents=[FunctionResultContent(call_id="call-123", result=None)],
308+
message_id="msg-789",
309+
)
310+
311+
messages = agent_framework_messages_to_agui([msg])
312+
313+
assert len(messages) == 1
314+
agui_msg = messages[0]
315+
# None serializes as JSON null
316+
assert agui_msg["content"] == "null"
317+
318+
319+
def test_agent_framework_to_agui_function_result_string():
320+
"""Test converting FunctionResultContent with string result to AG-UI."""
321+
msg = ChatMessage(
322+
role=Role.TOOL,
323+
contents=[FunctionResultContent(call_id="call-123", result="plain text result")],
324+
message_id="msg-789",
325+
)
326+
327+
messages = agent_framework_messages_to_agui([msg])
328+
329+
assert len(messages) == 1
330+
agui_msg = messages[0]
331+
assert agui_msg["content"] == "plain text result"
332+
333+
334+
def test_agent_framework_to_agui_function_result_empty_list():
335+
"""Test converting FunctionResultContent with empty list result to AG-UI."""
336+
msg = ChatMessage(
337+
role=Role.TOOL,
338+
contents=[FunctionResultContent(call_id="call-123", result=[])],
339+
message_id="msg-789",
340+
)
341+
342+
messages = agent_framework_messages_to_agui([msg])
343+
344+
assert len(messages) == 1
345+
agui_msg = messages[0]
346+
# Empty list serializes as JSON empty array
347+
assert agui_msg["content"] == "[]"
348+
349+
350+
def test_agent_framework_to_agui_function_result_single_text_content():
351+
"""Test converting FunctionResultContent with single TextContent-like item."""
352+
from dataclasses import dataclass
353+
354+
@dataclass
355+
class MockTextContent:
356+
text: str
357+
358+
msg = ChatMessage(
359+
role=Role.TOOL,
360+
contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
361+
message_id="msg-789",
362+
)
363+
364+
messages = agent_framework_messages_to_agui([msg])
365+
366+
assert len(messages) == 1
367+
agui_msg = messages[0]
368+
# TextContent text is extracted and serialized as JSON array
369+
assert agui_msg["content"] == '["Hello from MCP!"]'
370+
371+
372+
def test_agent_framework_to_agui_function_result_multiple_text_contents():
373+
"""Test converting FunctionResultContent with multiple TextContent-like items."""
374+
from dataclasses import dataclass
375+
376+
@dataclass
377+
class MockTextContent:
378+
text: str
379+
380+
msg = ChatMessage(
381+
role=Role.TOOL,
382+
contents=[
383+
FunctionResultContent(
384+
call_id="call-123",
385+
result=[MockTextContent("First result"), MockTextContent("Second result")],
386+
)
387+
],
388+
message_id="msg-789",
389+
)
390+
391+
messages = agent_framework_messages_to_agui([msg])
392+
393+
assert len(messages) == 1
394+
agui_msg = messages[0]
395+
# Multiple items should return JSON array
396+
assert agui_msg["content"] == '["First result", "Second result"]'

python/packages/ag-ui/tests/test_utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from dataclasses import dataclass
66
from datetime import date, datetime
77

8-
from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state
8+
from agent_framework_ag_ui._utils import (
9+
generate_event_id,
10+
make_json_safe,
11+
merge_state,
12+
)
913

1014

1115
def test_generate_event_id():

python/packages/core/agent_framework/_types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1869,6 +1869,9 @@ def _prepare_function_call_results_as_dumpable(content: Contents | Any | list[Co
18691869
return content.model_dump()
18701870
if hasattr(content, "to_dict"):
18711871
return content.to_dict(exclude={"raw_representation", "additional_properties"})
1872+
# Handle objects with text attribute (e.g., MCP TextContent)
1873+
if hasattr(content, "text") and isinstance(content.text, str):
1874+
return content.text
18721875
return content
18731876

18741877

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,3 +2133,55 @@ def test_prepare_function_call_results_nested_pydantic_model():
21332133
assert "Seattle" in json_result
21342134
assert "rainy" in json_result
21352135
assert "18.0" in json_result or "18" in json_result
2136+
2137+
2138+
# region prepare_function_call_results with MCP TextContent-like objects
2139+
2140+
2141+
def test_prepare_function_call_results_text_content_single():
2142+
"""Test that objects with text attribute (like MCP TextContent) are properly handled."""
2143+
from dataclasses import dataclass
2144+
2145+
@dataclass
2146+
class MockTextContent:
2147+
text: str
2148+
2149+
result = [MockTextContent("Hello from MCP tool!")]
2150+
json_result = prepare_function_call_results(result)
2151+
2152+
# Should extract text and serialize as JSON array of strings
2153+
assert isinstance(json_result, str)
2154+
assert json_result == '["Hello from MCP tool!"]'
2155+
2156+
2157+
def test_prepare_function_call_results_text_content_multiple():
2158+
"""Test that multiple TextContent-like objects are serialized correctly."""
2159+
from dataclasses import dataclass
2160+
2161+
@dataclass
2162+
class MockTextContent:
2163+
text: str
2164+
2165+
result = [MockTextContent("First result"), MockTextContent("Second result")]
2166+
json_result = prepare_function_call_results(result)
2167+
2168+
# Should extract text from each and serialize as JSON array
2169+
assert isinstance(json_result, str)
2170+
assert json_result == '["First result", "Second result"]'
2171+
2172+
2173+
def test_prepare_function_call_results_text_content_with_non_string_text():
2174+
"""Test that objects with non-string text attribute are not treated as TextContent."""
2175+
2176+
class BadTextContent:
2177+
def __init__(self):
2178+
self.text = 12345 # Not a string!
2179+
2180+
result = [BadTextContent()]
2181+
json_result = prepare_function_call_results(result)
2182+
2183+
# Should not extract text since it's not a string, will serialize the object
2184+
assert isinstance(json_result, str)
2185+
2186+
2187+
# endregion

0 commit comments

Comments
 (0)