Title: MCP Server: Inconsistent Exception Handling in @app.call_tool and Client Undetected Server Termination via Stdio · Issue #396 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: MCP Server: Inconsistent Exception Handling in @app.call_tool and Client Undetected Server Termination via Stdio · Issue #396 · modelcontextprotocol/python-sdk
X Title: MCP Server: Inconsistent Exception Handling in @app.call_tool and Client Undetected Server Termination via Stdio · Issue #396 · modelcontextprotocol/python-sdk
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...
Open Graph 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 @ap...
X 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 @ap...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/issues/396
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"MCP Server: Inconsistent Exception Handling in @app.call_tool and Client Undetected Server Termination via Stdio","articleBody":"**Describe the bug**\nThere are two distinct issues observed when using the MCP Python SDK with the stdio transport:\n\n1. 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.\n2. 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.).\n\n\n**To Reproduce**\n\n1. Save the attached minimal_server.py and minimal_client.py files.\n2. Ensure the mcp library is installed (pip install mcp).\n3. Run the client from the command line: python -m minimal_client\n4. Observe the output:\n* The \"Testing list_resources\" step correctly shows an McpError being caught.\n* The \"Testing working_tool\" step correctly shows a successful call.\n* The \"Testing normal_error_tool\" step incorrectly shows a successful call, with the ValueError message appearing inside the Result: [TextContent(...)].\n* 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.\n\n**Expected behavior**\n\n1. 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.\n2. 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.\n\n**Screenshots**\nThe console output provided in the previous conversation turn serves as evidence for the actual behavior:\n```\nPS G:\\projects\\mcp-exp\u003e python -m minimal_client\nConnecting to server\n\n--- Testing list_resources ---\nCalling list_resources tool\nError calling list_resources\nMcpError: This is a deliberate error from list_resources\nTraceback (most recent call last):\n File \"G:\\projects\\mcp-exp\\minimal_client.py\", line 74, in call_list_resources\n response = await self.session.list_resources()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"C:\\ProgramData\\Anaconda3\\envs\\py3128\\Lib\\site-packages\\mcp\\client\\session.py\", line 196, in list_resources\n return await self.send_request(\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"C:\\ProgramData\\Anaconda3\\envs\\py3128\\Lib\\site-packages\\mcp\\shared\\session.py\", line 266, in send_request\n raise McpError(response_or_error.error)\nmcp.shared.exceptions.McpError: This is a deliberate error from list_resources\n\n--- Testing working_tool ---\nCalling tool: working_tool\n\nSuccessfully called tool 'working_tool'\nResult: [TextContent(type='text', text='Hello from working_tool! Args: {}', annotations=None)]\n\n--- Testing normal_error_tool ---\nCalling tool: normal_error_tool\n\nSuccessfully called tool 'normal_error_tool'\nResult: [TextContent(type='text', text='This is a deliberate error from normal_error_tool', annotations=None)]\n\n--- Testing exit_tool ---\nCalling tool: exit_tool\nERROR calling tool 'exit_tool': Call timed out after 2.0s.\n This is expected for 'exit_tool' due to undetected server termination.\nCleaning up client resources\n```\n\n**Desktop (please complete the following information):**\n - OS: Windows 11 (but likely affects other OS)\n - Environment: Python 3.12.8 (via Anaconda)\n - Version: MCP version 1.6.1.dev4+2ea1495\n\n\n**Additional context**\nThe 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.\n\n`====minimal_server.py====`\n```python\n import asyncio\n import sys\n import mcp.types as types\n from mcp.server import Server\n from mcp.server.stdio import stdio_server\n\n # Create a minimal server\n app = Server(\"minimal-server\")\n\n\n @app.list_resources()\n async def handle_list_resources() -\u003e list[types.Resource]:\n \"\"\"Handles the list_resources request.\"\"\"\n # Simulate a deliberate error for testing purposes\n # This will be caught by MCP and sent back to client as an error\n raise ValueError(\"This is a deliberate error from list_resources\")\n\n\n @app.call_tool()\n async def handle_tool_call(name: str, arguments: dict) -\u003e list:\n \"\"\"Handles calls for working_tool, normal_error_tool, and exit_tool.\"\"\"\n\n if name == \"working_tool\":\n # Return the content part directly\n return [\n types.TextContent(type=\"text\", text=f'Hello from working_tool! Args: {arguments}')\n ]\n elif name == \"normal_error_tool\":\n # Raise a standard Python exception\n raise ValueError(\"This is a deliberate error from normal_error_tool\")\n # No return needed here\n elif name == \"exit_tool\":\n # Abruptly terminate the server process.\n sys.exit(1)\n # No return needed here\n else:\n # Handle unknown tool names if necessary\n raise ValueError(f\"Unknown tool: {name}\") # Or return an error content dict\n\n\n # Main execution function\n async def main():\n async with stdio_server() as streams:\n read_stream, write_stream = streams\n await app.run(\n read_stream,\n write_stream,\n app.create_initialization_options()\n )\n\n\n if __name__ == \"__main__\":\n asyncio.run(main())\n```\n\n`====minimal_client.py====`\n```python\nimport asyncio\nimport traceback\nfrom typing import Optional\n\nfrom mcp import ClientSession, StdioServerParameters, McpError\nfrom mcp.client.stdio import stdio_client\nfrom contextlib import AsyncExitStack\n\n\nclass MinimalClient:\n def __init__(self):\n self.session: Optional[ClientSession] = None\n self.exit_stack = AsyncExitStack()\n self.stdio = None\n self.write = None\n \n async def connect(self):\n print(\"Connecting to server\")\n \n server_params = StdioServerParameters(\n command=\"python\",\n args=['minimal_server.py'],\n )\n \n # Create stdio connection\n stdio_transport = await self.exit_stack.enter_async_context(\n stdio_client(server_params)\n )\n \n self.stdio, self.write = stdio_transport\n \n # Create client session with timeout and log handler\n self.session = await self.exit_stack.enter_async_context(\n ClientSession(\n self.stdio,\n self.write\n )\n )\n \n await self.session.initialize()\n \n \n async def call_tool(self, tool_name: str, timeout_seconds: float = 2.0):\n \"\"\"Call a tool with timeout\"\"\"\n if not self.session:\n raise RuntimeError(\"Client not connected to server\")\n \n print(f\"Calling tool: {tool_name}\")\n try:\n #response = await self.session.call_tool(name=tool_name, arguments={}) \n response = await asyncio.wait_for(\n self.session.call_tool(name=tool_name, arguments={}),\n timeout=timeout_seconds\n ) \n print(f\"\\nSuccessfully called tool '{tool_name}'\")\n print(f\"Result: {response.content}\")\n return response.content\n except TimeoutError: # *** Catch TimeoutError ***\n print(f\"ERROR calling tool '{tool_name}': Call timed out after {timeout_seconds}s.\")\n print(\" This is expected for 'exit_tool' due to undetected server termination.\")\n except McpError as e:\n print(f\"\\nERROR calling tool '{tool_name}':\")\n print(f\"{type(e).__name__}: {e}\")\n traceback.print_exc()\n\n\n async def call_list_resources(self):\n \"\"\"Call list_resources tool\"\"\"\n if not self.session:\n raise RuntimeError(\"Client not connected to server\")\n \n print(\"Calling list_resources tool\")\n try:\n response = await self.session.list_resources()\n print(f\"Resources: {response.resources}\")\n return response.resources\n except McpError as e:\n print(f\"Error calling list_resources\")\n print(f\"{type(e).__name__}: {e}\")\n traceback.print_exc()\n \n \n async def cleanup(self):\n \"\"\"Clean up resources\"\"\"\n print(\"Cleaning up client resources\")\n await self.exit_stack.aclose()\n \n\nasync def main():\n \n \n client = MinimalClient()\n try:\n # Connect to server\n await client.connect()\n\n # Test list_resources that raises an error (This is a deliberate error from handle_list_resources)\n print(\"\\n--- Testing list_resources ---\")\n # Ideal: Expecting the client to receive an error representing the server's ValueError\n # Actual = Ideal (The client correctly receives an McpError, matching the ideal behavior for this specific handler type)\n await client.call_list_resources()\n\n # Test working tool first\n print(\"\\n--- Testing working_tool ---\")\n # Ideal: Expecting a successful call\n # Actual = Ideal (The call succeeds as expected)\n await client.call_tool(\"working_tool\")\n\n # Test normal error tool (This is a deliberate error from normal_error_tool)\n print(\"\\n--- Testing normal_error_tool ---\")\n # Ideal: Expecting the client to receive an error representing the server's ValueError\n # Actual: The client incorrectly receives a successful response containing the error message text, highlighting the inconsistent error handling for @app.call_tool.\n await client.call_tool(\"normal_error_tool\")\n\n # Test exit tool\n print(\"\\n--- Testing exit_tool ---\")\n # Ideal: Expecting the client to detect the broken stdio pipe, likely resulting in a transport-level error (e.g., BrokenPipeError, EOFError, or similar).\n # Actual: The client fails to detect the termination and hangs until the asyncio.wait_for timeout occurs, demonstrating the core issue with termination detection.\n await client.call_tool(\"exit_tool\")\n\n\n except Exception as e:\n print(f\"\\nError in main execution: {type(e).__name__}: {e}\")\n # traceback.print_exc() # Uncomment for full traceback if needed\n finally:\n await client.cleanup()\n\n\nif __name__ == \"__main__\":\n asyncio.run(main())\n```","author":{"url":"https://github.com/omasoud","@type":"Person","name":"omasoud"},"datePublished":"2025-03-31T04:58:36.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":1},"url":"https://github.com/396/python-sdk/issues/396"}
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:8642aace-1b2b-1b03-7a57-da9a91a27175 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | CFF6:EC4FB:34307:47004:6A5A6463 |
| html-safe-nonce | af6022c44041290766bab972feb2bf7062201b628a03dca695b0c23ca745a283 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJDRkY2OkVDNEZCOjM0MzA3OjQ3MDA0OjZBNUE2NDYzIiwidmlzaXRvcl9pZCI6Ijc5MzIxODU0OTUxNDA0NTk2MTkiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 1bc08a000f7e7a149de2a05d1962b882aec81bc1e360c36b9827e7a5723d1fff |
| hovercard-subject-tag | issue:2959475133 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/modelcontextprotocol/python-sdk/396/issue_layout |
| twitter:image | https://opengraph.githubassets.com/4de2962c2b9b577462eae45b410030d1167c7cabacc018188a56375f2e7d6f73/modelcontextprotocol/python-sdk/issues/396 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/4de2962c2b9b577462eae45b410030d1167c7cabacc018188a56375f2e7d6f73/modelcontextprotocol/python-sdk/issues/396 |
| og:image:alt | 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 @ap... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | omasoud |
| hostname | github.com |
| expected-hostname | github.com |
| None | 05b9ddf6a47d2dbe13944873a99f5fb4b83ba4871f9cb8a8e256793a63ca9687 |
| turbo-cache-control | no-preview |
| go-import | github.com/modelcontextprotocol/python-sdk git https://github.com/modelcontextprotocol/python-sdk.git |
| octolytics-dimension-user_id | 182288589 |
| octolytics-dimension-user_login | modelcontextprotocol |
| octolytics-dimension-repository_id | 862584018 |
| octolytics-dimension-repository_nwo | modelcontextprotocol/python-sdk |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 862584018 |
| octolytics-dimension-repository_network_root_nwo | modelcontextprotocol/python-sdk |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | f8d29d1bd03dda2dd14b3f80b8bc27e1111f43bd |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width