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 within TextContent:
# 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. The actual_text will 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
mcp Library 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.text contains a JSON string representing the full response and parse it manually to extract the actual intended text field.
- The issue seems specific to the
stdio transport combined with primitive return types in TextContent. Returning complex types (dict/list) as JSON strings works as expected (client receives the JSON string in TextContent.text and can parse it). Returning primitives via SSE transport might behave differently (needs verification).
Describe the bug
When using the MCP stdio transport (
mcp.client.stdio.stdio_clientandmcp.client.session.ClientSession), if a server tool returns aCallToolResultcontainingTextContentwhere thetextfield holds the string representation of a primitive type (string, number, boolean, null), the client-sideClientSessionincorrectly populates the resultingTextContent.textattribute. Instead of containing the simple string representation (e.g.,"42"), it contains the full JSON string representation of the entireCallToolResultobject (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:Create an MCP client that connects via stdio and calls the tool:
Run the client script:
python stdio_client_test.pySee error: Observe the logged output and the
AssertionError. Theactual_textwill contain the full JSON string, not just"42".Expected behavior
The
text_content.textattribute on the client side should contain the exact string value that the server placed in theTextContent'stextfield when returning a primitive type. In the example above,actual_textshould be equal to"42".Screenshots
N/A (See console output above).
Desktop (please complete the following information):
mcpLibrary Version: 1.6.0 (Assuming based on project instructions)Smartphone (please complete the following information):
N/A
Additional context
TextContent.textcontains a JSON string representing the full response and parse it manually to extract the actual intended text field.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).