Repository navigation
MCP stdio transport inconsistent CallTooResult return #592
Description
Describe the bug
When using the MCP stdio transport (mcp.client.stdio.stdio_client and mcp.client.session.ClientSession), if a server tool returns a CallToolResult containing TextContent where the text field holds the string representation of a primitive type (string, number, boolean, null), the client-side ClientSession incorrectly populates the resulting TextContent.text attribute. Instead of containing the simple string representation (e.g., "42"), it contains the full JSON string representation of the entire CallToolResult object (e.g., '{"_meta": null, "content": [{"type": "text", "text": "42", "annotations": null}], "isError": false}').
This issue does not seem to affect cases where the server returns complex types (dicts, lists) serialized as JSON strings within TextContent.text.
To Reproduce
Steps to reproduce the behavior:
-
Create an MCP stdio server (
stdio_server_script.py) with a simple tool that returns a primitive type withinTextContent:# stdio_server_script.py import asyncio import json import logging import sys from typing import Any from mcp.server.fastmcp import FastMCP, Context from mcp.types import CallToolResult, TextContent logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) server = FastMCP("TestStdioServer", log_level="DEBUG") @server.tool("echo_primitive", description="Echo back primitive as string") async def echo_primitive(context: Context, message: Any = None) -> CallToolResult: text_value = str(message) if message is not None else "null" logging.debug(f"Echo returning primitive text: {text_value}") # Attempt to return primitive string representation in TextContent return CallToolResult(content=[TextContent(type="text", text=text_value)]) async def main(): await server.run_stdio_async() if __name__ == "__main__": asyncio.run(main())
-
Create an MCP client that connects via stdio and calls the tool:
# stdio_client_test.py import asyncio import sys import json import logging from mcp.client.session import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.types import TextContent logging.basicConfig(level=logging.DEBUG) async def run_test(): server_script = "stdio_server_script.py" params = StdioServerParameters(command=sys.executable, args=[server_script]) async with stdio_client(params) as streams: read_stream, write_stream = streams async with ClientSession(read_stream, write_stream) as session: await asyncio.wait_for(session.initialize(), timeout=5.0) await asyncio.sleep(1.0) # Ensure initialization completes input_value = 42 expected_text = "42" result = await session.call_tool("echo_primitive", {"message": input_value}) logging.info(f"Received result: {result}") assert result.content and len(result.content) > 0 text_content = result.content[0] assert isinstance(text_content, TextContent) actual_text = text_content.text logging.info(f"Input value: {input_value}") logging.info(f"Expected text: '{expected_text}'") logging.info(f"Actual text in text_content.text: '{actual_text}'") # This assertion fails assert actual_text == expected_text, f"Assertion Failed: Expected '{expected_text}', got '{actual_text}'" if __name__ == "__main__": asyncio.run(run_test())
-
Run the client script:
python stdio_client_test.py -
See error: Observe the logged output and the
AssertionError. Theactual_textwill contain the full JSON string, not just"42".# Example Output Snippet INFO:root:Received result: CallToolResult(_meta=None, content=[TextContent(type='text', text='{"_meta": null, "content": [{"type": "text", "text": "42", "annotations": null}], "isError": false}', annotations=None)], isError=False) INFO:root:Input value: 42 INFO:root:Expected text: '42' INFO:root:Actual text in text_content.text: '{"_meta": null, "content": [{"type": "text", "text": "42", "annotations": null}], "isError": false}' ... AssertionError: Assertion Failed: Expected '42', got '{"_meta": null, "content": [{"type": "text", "text": "42", "annotations": null}], "isError": false}'
Expected behavior
The text_content.text attribute on the client side should contain the exact string value that the server placed in the TextContent's text field when returning a primitive type. In the example above, actual_text should be equal to "42".
Screenshots
N/A (See console output above).
Desktop (please complete the following information):
- OS: macOS Sonoma 14.4 (Darwin 24.4.0)
- Python Version: 3.10.17
mcpLibrary Version: 1.6.0 (Assuming based on project instructions)
Smartphone (please complete the following information):
N/A
Additional context
- This bug forces clients using the stdio transport to implement a workaround where they check if
TextContent.textcontains a JSON string representing the full response and parse it manually to extract the actual intended text field. - The issue seems specific to the
stdiotransport combined with primitive return types inTextContent. Returning complex types (dict/list) as JSON strings works as expected (client receives the JSON string inTextContent.textand can parse it). Returning primitives via SSE transport might behave differently (needs verification).
Activity
felixweinberger commentedon Oct 6, 2025
Hi @wilson-urdaneta thanks for this report - given this has been open a while is this still an issue?
fix: handle CallToolResult in _convert_to_content to prevent double-s…
maxisbey commentedon Feb 24, 2026
This was fixed in v1.19.0 by #1459, which added explicit handling for
CallToolResultreturns inconvert_result().On v1.6.0 (the version in the report), there was no
CallToolResultguard anywhere — the direct return went straight into_convert_to_content, which JSON-serialized the entire object. Upgrading to v1.19.0+ resolves this.We verified the exact reproduction from this issue (stdio transport,
-> CallToolResultannotation, primitive text value) passes on currentmain:Closing as fixed.
AI Disclaimer