Open
Description
Describe the bug
There are two distinct issues observed when using the MCP Python SDK with the stdio transport:
- Inconsistent Exception Handling: Exceptions raised within handlers decorated with @app.call_tool are not correctly translated into JSON-RPC error responses sent to the client. Instead, the server catches the exception and sends a successful response where the content field contains the exception's message as plain text. This contrasts with handlers like @app.list_resources, where exceptions are correctly translated into McpError responses received by the client.
- Undetected Server Termination: When the server process terminates abruptly (e.g., via sys.exit(1) during a tool call), the client connected via mcp.client.stdio.stdio_client does not detect the broken pipe or EOF. Client calls awaiting a response from the terminated server hang indefinitely until an application-level timeout (like asyncio.wait_for) expires, instead of raising a transport-level error (e.g., BrokenPipeError, EOFError, anyio.EndOfStream, etc.).
To Reproduce
- Save the attached minimal_server.py and minimal_client.py files.
- Ensure the mcp library is installed (pip install mcp).
- Run the client from the command line: python -m minimal_client
- Observe the output:
- The "Testing list_resources" step correctly shows an McpError being caught.
- The "Testing working_tool" step correctly shows a successful call.
- The "Testing normal_error_tool" step incorrectly shows a successful call, with the ValueError message appearing inside the Result: [TextContent(...)].
- The "Testing exit_tool" step hangs for the duration of the asyncio.wait_for timeout (2 seconds in the example) and then prints the timeout error message, instead of failing immediately due to the server process termination.
Expected behavior
- Consistent Exception Handling: Exceptions raised within @app.call_tool handlers should be treated the same way as exceptions in @app.list_resources. The server should send a standard JSON-RPC error response, which the client should receive as an McpError (or a subclass thereof). The client should not receive a successful response containing the error message text.
- Server Termination Detection: When the server process connected via stdio terminates unexpectedly, the client's read/write operations on the transport should fail immediately with an appropriate transport-level exception (like BrokenPipeError, EOFError, anyio.EndOfStream, anyio.BrokenResourceError, etc.), allowing the client application to detect and handle the disconnection promptly without relying on application-level timeouts for pending requests.
Screenshots
The console output provided in the previous conversation turn serves as evidence for the actual behavior:
PS G:\projects\mcp-exp> python -m minimal_client
Connecting to server
--- Testing list_resources ---
Calling list_resources tool
Error calling list_resources
McpError: This is a deliberate error from list_resources
Traceback (most recent call last):
File "G:\projects\mcp-exp\minimal_client.py", line 74, in call_list_resources
response = await self.session.list_resources()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\ProgramData\Anaconda3\envs\py3128\Lib\site-packages\mcp\client\session.py", line 196, in list_resources
return await self.send_request(
^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\ProgramData\Anaconda3\envs\py3128\Lib\site-packages\mcp\shared\session.py", line 266, in send_request
raise McpError(response_or_error.error)
mcp.shared.exceptions.McpError: This is a deliberate error from list_resources
--- Testing working_tool ---
Calling tool: working_tool
Successfully called tool 'working_tool'
Result: [TextContent(type='text', text='Hello from working_tool! Args: {}', annotations=None)]
--- Testing normal_error_tool ---
Calling tool: normal_error_tool
Successfully called tool 'normal_error_tool'
Result: [TextContent(type='text', text='This is a deliberate error from normal_error_tool', annotations=None)]
--- Testing exit_tool ---
Calling tool: exit_tool
ERROR calling tool 'exit_tool': Call timed out after 2.0s.
This is expected for 'exit_tool' due to undetected server termination.
Cleaning up client resources
Desktop (please complete the following information):
- OS: Windows 11 (but likely affects other OS)
- Environment: Python 3.12.8 (via Anaconda)
- Version: MCP version 1.6.1.dev4+2ea1495
Additional context
The minimal reproducible example files (minimal_client.py and minimal_server.py) are below (attaching .py isn't allowed). The key issue seems to be how the mcp.server handles exceptions differently based on the decorator used (@app.call_tool vs @app.list_resources) and how the mcp.client.stdio.stdio_client transport interacts with terminated subprocesses.
====minimal_server.py====
import asyncio
import sys
import mcp.types as types
from mcp.server import Server
from mcp.server.stdio import stdio_server
# Create a minimal server
app = Server("minimal-server")
@app.list_resources()
async def handle_list_resources() -> list[types.Resource]:
"""Handles the list_resources request."""
# Simulate a deliberate error for testing purposes
# This will be caught by MCP and sent back to client as an error
raise ValueError("This is a deliberate error from list_resources")
@app.call_tool()
async def handle_tool_call(name: str, arguments: dict) -> list:
"""Handles calls for working_tool, normal_error_tool, and exit_tool."""
if name == "working_tool":
# Return the content part directly
return [
types.TextContent(type="text", text=f'Hello from working_tool! Args: {arguments}')
]
elif name == "normal_error_tool":
# Raise a standard Python exception
raise ValueError("This is a deliberate error from normal_error_tool")
# No return needed here
elif name == "exit_tool":
# Abruptly terminate the server process.
sys.exit(1)
# No return needed here
else:
# Handle unknown tool names if necessary
raise ValueError(f"Unknown tool: {name}") # Or return an error content dict
# Main execution function
async def main():
async with stdio_server() as streams:
read_stream, write_stream = streams
await app.run(
read_stream,
write_stream,
app.create_initialization_options()
)
if __name__ == "__main__":
asyncio.run(main())
====minimal_client.py====
import asyncio
import traceback
from typing import Optional
from mcp import ClientSession, StdioServerParameters, McpError
from mcp.client.stdio import stdio_client
from contextlib import AsyncExitStack
class MinimalClient:
def __init__(self):
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
self.stdio = None
self.write = None
async def connect(self):
print("Connecting to server")
server_params = StdioServerParameters(
command="python",
args=['minimal_server.py'],
)
# Create stdio connection
stdio_transport = await self.exit_stack.enter_async_context(
stdio_client(server_params)
)
self.stdio, self.write = stdio_transport
# Create client session with timeout and log handler
self.session = await self.exit_stack.enter_async_context(
ClientSession(
self.stdio,
self.write
)
)
await self.session.initialize()
async def call_tool(self, tool_name: str, timeout_seconds: float = 2.0):
"""Call a tool with timeout"""
if not self.session:
raise RuntimeError("Client not connected to server")
print(f"Calling tool: {tool_name}")
try:
#response = await self.session.call_tool(name=tool_name, arguments={})
response = await asyncio.wait_for(
self.session.call_tool(name=tool_name, arguments={}),
timeout=timeout_seconds
)
print(f"\nSuccessfully called tool '{tool_name}'")
print(f"Result: {response.content}")
return response.content
except TimeoutError: # *** Catch TimeoutError ***
print(f"ERROR calling tool '{tool_name}': Call timed out after {timeout_seconds}s.")
print(" This is expected for 'exit_tool' due to undetected server termination.")
except McpError as e:
print(f"\nERROR calling tool '{tool_name}':")
print(f"{type(e).__name__}: {e}")
traceback.print_exc()
async def call_list_resources(self):
"""Call list_resources tool"""
if not self.session:
raise RuntimeError("Client not connected to server")
print("Calling list_resources tool")
try:
response = await self.session.list_resources()
print(f"Resources: {response.resources}")
return response.resources
except McpError as e:
print(f"Error calling list_resources")
print(f"{type(e).__name__}: {e}")
traceback.print_exc()
async def cleanup(self):
"""Clean up resources"""
print("Cleaning up client resources")
await self.exit_stack.aclose()
async def main():
client = MinimalClient()
try:
# Connect to server
await client.connect()
# Test list_resources that raises an error (This is a deliberate error from handle_list_resources)
print("\n--- Testing list_resources ---")
# Ideal: Expecting the client to receive an error representing the server's ValueError
# Actual = Ideal (The client correctly receives an McpError, matching the ideal behavior for this specific handler type)
await client.call_list_resources()
# Test working tool first
print("\n--- Testing working_tool ---")
# Ideal: Expecting a successful call
# Actual = Ideal (The call succeeds as expected)
await client.call_tool("working_tool")
# Test normal error tool (This is a deliberate error from normal_error_tool)
print("\n--- Testing normal_error_tool ---")
# Ideal: Expecting the client to receive an error representing the server's ValueError
# Actual: The client incorrectly receives a successful response containing the error message text, highlighting the inconsistent error handling for @app.call_tool.
await client.call_tool("normal_error_tool")
# Test exit tool
print("\n--- Testing exit_tool ---")
# Ideal: Expecting the client to detect the broken stdio pipe, likely resulting in a transport-level error (e.g., BrokenPipeError, EOFError, or similar).
# Actual: The client fails to detect the termination and hangs until the asyncio.wait_for timeout occurs, demonstrating the core issue with termination detection.
await client.call_tool("exit_tool")
except Exception as e:
print(f"\nError in main execution: {type(e).__name__}: {e}")
# traceback.print_exc() # Uncomment for full traceback if needed
finally:
await client.cleanup()
if __name__ == "__main__":
asyncio.run(main())
Metadata
Metadata
Assignees
Labels
No labels