Skip to content

MCP stdio transport inconsistent CallTooResult return #592

Description

@wilson-urdaneta

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 entireCallToolResult 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:

  1. Create an MCP stdio server (stdio_server_script.py) with a simple tool that returns a primitive type within TextContent:

    # stdio_server_script.pyimportasyncioimportjsonimportloggingimportsysfromtypingimportAnyfrommcp.server.fastmcpimportFastMCP, Contextfrommcp.typesimportCallToolResult, TextContentlogging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
    server=FastMCP("TestStdioServer", log_level="DEBUG")
    @server.tool("echo_primitive", description="Echo back primitive as string")asyncdefecho_primitive(context: Context, message: Any=None) ->CallToolResult:
    text_value=str(message) ifmessageisnotNoneelse"null"logging.debug(f"Echo returning primitive text: {text_value}")
    # Attempt to return primitive string representation in TextContentreturnCallToolResult(content=[TextContent(type="text", text=text_value)])
    asyncdefmain():
    awaitserver.run_stdio_async()
    if__name__=="__main__":
    asyncio.run(main())
  2. Create an MCP client that connects via stdio and calls the tool:

    # stdio_client_test.pyimportasyncioimportsysimportjsonimportloggingfrommcp.client.sessionimportClientSessionfrommcp.client.stdioimportStdioServerParameters, stdio_clientfrommcp.typesimportTextContentlogging.basicConfig(level=logging.DEBUG)
    asyncdefrun_test():
    server_script="stdio_server_script.py"params=StdioServerParameters(command=sys.executable, args=[server_script])
    asyncwithstdio_client(params) asstreams:
    read_stream, write_stream=streamsasyncwithClientSession(read_stream, write_stream) assession:
    awaitasyncio.wait_for(session.initialize(), timeout=5.0)
    awaitasyncio.sleep(1.0) # Ensure initialization completesinput_value=42expected_text="42"result=awaitsession.call_tool("echo_primitive", {"message": input_value})
    logging.info(f"Received result: {result}")
    assertresult.contentandlen(result.content) >0text_content=result.content[0]
    assertisinstance(text_content, TextContent)
    actual_text=text_content.textlogging.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 failsassertactual_text==expected_text, f"Assertion Failed: Expected '{expected_text}', got '{actual_text}'"if__name__=="__main__":
    asyncio.run(run_test())
  3. Run the client script: python stdio_client_test.py

  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds confirmationNeeds confirmation that the PR is actually required or needed.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions