Title: CancelledError from connection failures is indistinguishable from external cancellation · Issue #1830 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: CancelledError from connection failures is indistinguishable from external cancellation · Issue #1830 · modelcontextprotocol/python-sdk
X Title: CancelledError from connection failures is indistinguishable from external cancellation · Issue #1830 · modelcontextprotocol/python-sdk
Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue Description Summary The MCP Python...
Open Graph Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this ...
X Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening t...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/issues/1830
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"CancelledError from connection failures is indistinguishable from external cancellation","articleBody":"### Initial Checks\n\n- [ ] I confirm that I'm using the latest version of MCP Python SDK\n- [x] I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue\n\n### Description\n\n## Summary\n\nThe MCP Python SDK raises `asyncio.CancelledError` when a server connection fails. This is structurally identical to external task cancellation (Ctrl+C, SIGTERM), making it impossible for client code to correctly handle both scenarios.\n\n## Problem\n\nWhen an MCP server becomes unreachable during a request:\n\n```python\ntry:\n result = await session.list_tools()\nexcept asyncio.CancelledError:\n # Is this:\n # A) Server died (should reconnect/retry)\n # B) Operator hit Ctrl+C (should propagate for clean shutdown)\n #\n # Cannot distinguish.\n```\n\n## Root Cause\n\nThe SDK uses anyio for structured concurrency. Transport layers (`mcp/client/sse.py`, `mcp/client/streamable_http.py`) create task groups:\n\n```python\n# mcp/client/sse.py (simplified)\nasync with anyio.create_task_group() as tg:\n tg.start_soon(sse_reader) # Reads from server\n tg.start_soon(post_writer) # Writes to server\n yield read_stream, write_stream\n```\n\nWhen the server connection fails:\n\n1. `sse_reader` task fails (connection lost)\n2. anyio's task group cancels sibling tasks\n3. `CancelledError` propagates to `response_stream_reader.receive()` in `session.py`\n4. Client code catches `CancelledError`\n\nThis is the same exception type raised by `task.cancel()` during external shutdown.\n\n## Evidence\n\nException characteristics when catching `CancelledError`:\n\n| Scenario | `ex.args` | `task.cancelling()` delta |\n|----------|-----------|---------------------------|\n| SSE server dies | `()` | +1 |\n| Streaming HTTP server dies | `('Cancelled by cancel scope...',)` | +1 |\n| External `task.cancel()` | `()` | +1 |\n\nSSE internal failure and external cancellation have **identical characteristics**.\n\n## Impact\n\n**If client converts CancelledError → ConnectionError:**\n- External shutdown (Ctrl+C) raises ConnectionError instead of CancelledError\n- Retry loops may continue instead of exiting\n- asyncio's cooperative cancellation model is broken\n\n**If client propagates CancelledError:**\n- Server failures escape as BaseException\n- Callers must use `except BaseException` to handle failures\n- Poor error messages (\"CancelledError\" vs \"connection lost\")\n\n## Files Involved\n\n| File | Role |\n|------|------|\n| `mcp/client/sse.py` | SSE transport - creates task group |\n| `mcp/client/streamable_http.py` | Streaming HTTP transport - creates task group |\n| `mcp/shared/session.py` | `response_stream_reader.receive()` - where CancelledError surfaces |\n\n\n### Example Code\n\n```Python\n#!/usr/bin/env python3\n\"\"\"\nMinimal reproduction: CancelledError ambiguity in MCP SDK.\n\nThis script demonstrates that when an MCP server dies mid-request,\nthe client receives asyncio.CancelledError - the same exception type\nraised by external task cancellation (Ctrl+C, SIGTERM).\n\nRun: python repro_cancelled_error_ambiguity.py\n\nExpected output:\n - Test 1 (server dies): CancelledError\n - Test 2 (external cancel): CancelledError\n\nBoth scenarios produce identical exceptions, making it impossible\nfor client code to distinguish server failure from intentional shutdown.\n\"\"\"\nimport asyncio\nimport multiprocessing\nimport socket\nimport time\nfrom typing import Any\n\nimport uvicorn\nfrom starlette.applications import Starlette\nfrom starlette.requests import Request\nfrom starlette.responses import Response\nfrom starlette.routing import Mount, Route\n\nfrom mcp.client.session import ClientSession\nfrom mcp.client.sse import sse_client\nfrom mcp.server import Server\nfrom mcp.server.sse import SseServerTransport\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom mcp.types import TextContent, Tool\n\n\n# === Minimal MCP Server ===\n\nclass SlowToolServer(Server):\n def __init__(self):\n super().__init__(\"test-server\")\n\n @self.list_tools()\n async def handle_list_tools() -\u003e list[Tool]:\n return [Tool(\n name=\"slow_tool\",\n description=\"Takes 10 seconds\",\n inputSchema={\"type\": \"object\", \"properties\": {}},\n )]\n\n @self.call_tool()\n async def handle_call_tool(name: str, args: dict[str, Any]) -\u003e list[TextContent]:\n await asyncio.sleep(10.0)\n return [TextContent(type=\"text\", text=\"Done\")]\n\n\ndef run_server(port: int) -\u003e None:\n security = TransportSecuritySettings(\n allowed_hosts=[\"127.0.0.1:*\"],\n allowed_origins=[\"http://127.0.0.1:*\"],\n )\n sse = SseServerTransport(\"/messages/\", security_settings=security)\n server = SlowToolServer()\n\n async def handle_sse(request: Request) -\u003e Response:\n async with sse.connect_sse(request.scope, request.receive, request._send) as streams:\n await server.run(streams[0], streams[1], server.create_initialization_options())\n return Response()\n\n app = Starlette(routes=[\n Route(\"/sse\", endpoint=handle_sse),\n Mount(\"/messages/\", app=sse.handle_post_message),\n ])\n uvicorn.Server(uvicorn.Config(app=app, host=\"127.0.0.1\", port=port, log_level=\"error\")).run()\n\n\ndef get_free_port() -\u003e int:\n with socket.socket() as s:\n s.bind((\"127.0.0.1\", 0))\n return s.getsockname()[1]\n\n\ndef wait_for_server(port: int, timeout: float = 5.0) -\u003e None:\n start = time.time()\n while time.time() - start \u003c timeout:\n try:\n with socket.socket() as s:\n s.settimeout(0.1)\n s.connect((\"127.0.0.1\", port))\n return\n except (ConnectionRefusedError, OSError):\n time.sleep(0.01)\n raise TimeoutError(f\"Server did not start within {timeout}s\")\n\n\n# === Test 1: Server dies mid-request ===\n\nasync def test_server_dies() -\u003e str:\n \"\"\"Kill server while request is in flight. What exception do we get?\"\"\"\n port = get_free_port()\n proc = multiprocessing.Process(target=run_server, kwargs={\"port\": port}, daemon=True)\n proc.start()\n wait_for_server(port)\n\n exception_type = None\n try:\n async with sse_client(f\"http://127.0.0.1:{port}/sse\") as (r, w):\n async with ClientSession(r, w) as session:\n await session.initialize()\n\n task = asyncio.create_task(session.call_tool(\"slow_tool\", {}))\n await asyncio.sleep(0.3)\n\n # Kill server while request is pending\n proc.kill()\n proc.join(timeout=1)\n\n await asyncio.wait_for(task, timeout=5.0)\n\n except asyncio.CancelledError:\n exception_type = \"CancelledError\"\n except Exception as ex:\n exception_type = type(ex).__name__\n finally:\n if proc.is_alive():\n proc.kill()\n\n return exception_type or \"None\"\n\n\n# === Test 2: External cancellation ===\n\nasync def test_external_cancel() -\u003e str:\n \"\"\"Cancel task externally (simulating Ctrl+C). What exception do we get?\"\"\"\n port = get_free_port()\n proc = multiprocessing.Process(target=run_server, kwargs={\"port\": port}, daemon=True)\n proc.start()\n wait_for_server(port)\n\n exception_type = None\n try:\n async with sse_client(f\"http://127.0.0.1:{port}/sse\") as (r, w):\n async with ClientSession(r, w) as session:\n await session.initialize()\n\n task = asyncio.create_task(session.call_tool(\"slow_tool\", {}))\n await asyncio.sleep(0.3)\n\n # External cancellation\n task.cancel()\n\n await task\n\n except asyncio.CancelledError:\n exception_type = \"CancelledError\"\n except Exception as ex:\n exception_type = type(ex).__name__\n finally:\n if proc.is_alive():\n proc.kill()\n\n return exception_type or \"None\"\n\n\n# === Main ===\n\nif __name__ == \"__main__\":\n print(\"Test 1: Server dies mid-request\")\n result1 = asyncio.run(test_server_dies())\n print(f\" Exception: {result1}\")\n\n print()\n print(\"Test 2: External cancellation (Ctrl+C simulation)\")\n result2 = asyncio.run(test_external_cancel())\n print(f\" Exception: {result2}\")\n\n print()\n print(\"Result:\")\n if result1 == result2 == \"CancelledError\":\n print(\" Both scenarios raise CancelledError.\")\n print(\" Client code cannot distinguish server failure from shutdown request.\")\n else:\n print(f\" Test 1: {result1}\")\n print(f\" Test 2: {result2}\")\n```\n\n### Python \u0026 MCP Python SDK\n\n```Text\nPython: 3.12.12\nMCP SDK: 1.20.0\n```","author":{"url":"https://github.com/saintx","@type":"Person","name":"saintx"},"datePublished":"2026-01-06T18:00:58.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":1},"url":"https://github.com/1830/python-sdk/issues/1830"}
| 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:e92fc2d9-daa3-d902-c609-d175d9f597a4 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | EAF4:1F68DB:2600C2:324AB3:6A5BEE87 |
| html-safe-nonce | 59104eab5d205cf1bacbc62eb739b306bc4e66c21f789c635a75e73956202238 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJFQUY0OjFGNjhEQjoyNjAwQzI6MzI0QUIzOjZBNUJFRTg3IiwidmlzaXRvcl9pZCI6IjYxODU1Nzg5MzIxOTQ0NjQwNyIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9 |
| visitor-hmac | 3817dcfa046d2463b7462192c2e1c5c39e5a37da9f1bbb08c11f1f2162c2b9e6 |
| hovercard-subject-tag | issue:3786017837 |
| 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/1830/issue_layout |
| twitter:image | https://opengraph.githubassets.com/86b8d9a3ec6d558dfad45d7d893aa88b3ab69031b9d3da40c96f908ed2727a65/modelcontextprotocol/python-sdk/issues/1830 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/86b8d9a3ec6d558dfad45d7d893aa88b3ab69031b9d3da40c96f908ed2727a65/modelcontextprotocol/python-sdk/issues/1830 |
| og:image:alt | Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this ... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | saintx |
| hostname | github.com |
| expected-hostname | github.com |
| None | 5290d7e14309ad1e76106a9c4237bd1041517e83ea182c8ab756752cb0c6940b |
| 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 | 9c975978430e9ad293956f2bbdaf153b1bd84a99 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width