Initial Checks
Description
Summary
The 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.
Problem
When an MCP server becomes unreachable during a request:
try:
result=awaitsession.list_tools()
exceptasyncio.CancelledError:
# Is this:# A) Server died (should reconnect/retry)# B) Operator hit Ctrl+C (should propagate for clean shutdown)## Cannot distinguish.
Root Cause
The SDK uses anyio for structured concurrency. Transport layers (mcp/client/sse.py, mcp/client/streamable_http.py) create task groups:
# mcp/client/sse.py (simplified)asyncwithanyio.create_task_group() astg:
tg.start_soon(sse_reader) # Reads from servertg.start_soon(post_writer) # Writes to serveryieldread_stream, write_stream
When the server connection fails:
sse_reader task fails (connection lost)- anyio's task group cancels sibling tasks
CancelledError propagates to response_stream_reader.receive() in session.py- Client code catches
CancelledError
This is the same exception type raised by task.cancel() during external shutdown.
Evidence
Exception characteristics when catching CancelledError:
| Scenario | ex.args | task.cancelling() delta |
|---|
| SSE server dies | () | +1 |
| Streaming HTTP server dies | ('Cancelled by cancel scope...',) | +1 |
External task.cancel() | () | +1 |
SSE internal failure and external cancellation have identical characteristics.
Impact
If client converts CancelledError → ConnectionError:
- External shutdown (Ctrl+C) raises ConnectionError instead of CancelledError
- Retry loops may continue instead of exiting
- asyncio's cooperative cancellation model is broken
If client propagates CancelledError:
- Server failures escape as BaseException
- Callers must use
except BaseException to handle failures - Poor error messages ("CancelledError" vs "connection lost")
Files Involved
| File | Role |
|---|
mcp/client/sse.py | SSE transport - creates task group |
mcp/client/streamable_http.py | Streaming HTTP transport - creates task group |
mcp/shared/session.py | response_stream_reader.receive() - where CancelledError surfaces |
Example Code
#!/usr/bin/env python3"""Minimal reproduction: CancelledError ambiguity in MCP SDK.This script demonstrates that when an MCP server dies mid-request,the client receives asyncio.CancelledError - the same exception typeraised by external task cancellation (Ctrl+C, SIGTERM).Run: python repro_cancelled_error_ambiguity.pyExpected output: - Test 1 (server dies): CancelledError - Test 2 (external cancel): CancelledErrorBoth scenarios produce identical exceptions, making it impossiblefor client code to distinguish server failure from intentional shutdown."""importasyncioimportmultiprocessingimportsocketimporttimefromtypingimportAnyimportuvicornfromstarlette.applicationsimportStarlettefromstarlette.requestsimportRequestfromstarlette.responsesimportResponsefromstarlette.routingimportMount, Routefrommcp.client.sessionimportClientSessionfrommcp.client.sseimportsse_clientfrommcp.serverimportServerfrommcp.server.sseimportSseServerTransportfrommcp.server.transport_securityimportTransportSecuritySettingsfrommcp.typesimportTextContent, Tool# === Minimal MCP Server ===classSlowToolServer(Server):
def__init__(self):
super().__init__("test-server")
@self.list_tools()asyncdefhandle_list_tools() ->list[Tool]:
return [Tool(
name="slow_tool",
description="Takes 10 seconds",
inputSchema={"type": "object", "properties": {}},
)]
@self.call_tool()asyncdefhandle_call_tool(name: str, args: dict[str, Any]) ->list[TextContent]:
awaitasyncio.sleep(10.0)
return [TextContent(type="text", text="Done")]
defrun_server(port: int) ->None:
security=TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*"],
allowed_origins=["http://127.0.0.1:*"],
)
sse=SseServerTransport("/messages/", security_settings=security)
server=SlowToolServer()
asyncdefhandle_sse(request: Request) ->Response:
asyncwithsse.connect_sse(request.scope, request.receive, request._send) asstreams:
awaitserver.run(streams[0], streams[1], server.create_initialization_options())
returnResponse()
app=Starlette(routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message),
])
uvicorn.Server(uvicorn.Config(app=app, host="127.0.0.1", port=port, log_level="error")).run()
defget_free_port() ->int:
withsocket.socket() ass:
s.bind(("127.0.0.1", 0))
returns.getsockname()[1]
defwait_for_server(port: int, timeout: float=5.0) ->None:
start=time.time()
whiletime.time() -start<timeout:
try:
withsocket.socket() ass:
s.settimeout(0.1)
s.connect(("127.0.0.1", port))
returnexcept (ConnectionRefusedError, OSError):
time.sleep(0.01)
raiseTimeoutError(f"Server did not start within {timeout}s")
# === Test 1: Server dies mid-request ===asyncdeftest_server_dies() ->str:
"""Kill server while request is in flight. What exception do we get?"""port=get_free_port()
proc=multiprocessing.Process(target=run_server, kwargs={"port": port}, daemon=True)
proc.start()
wait_for_server(port)
exception_type=Nonetry:
asyncwithsse_client(f"http://127.0.0.1:{port}/sse") as (r, w):
asyncwithClientSession(r, w) assession:
awaitsession.initialize()
task=asyncio.create_task(session.call_tool("slow_tool", {}))
awaitasyncio.sleep(0.3)
# Kill server while request is pendingproc.kill()
proc.join(timeout=1)
awaitasyncio.wait_for(task, timeout=5.0)
exceptasyncio.CancelledError:
exception_type="CancelledError"exceptExceptionasex:
exception_type=type(ex).__name__finally:
ifproc.is_alive():
proc.kill()
returnexception_typeor"None"# === Test 2: External cancellation ===asyncdeftest_external_cancel() ->str:
"""Cancel task externally (simulating Ctrl+C). What exception do we get?"""port=get_free_port()
proc=multiprocessing.Process(target=run_server, kwargs={"port": port}, daemon=True)
proc.start()
wait_for_server(port)
exception_type=Nonetry:
asyncwithsse_client(f"http://127.0.0.1:{port}/sse") as (r, w):
asyncwithClientSession(r, w) assession:
awaitsession.initialize()
task=asyncio.create_task(session.call_tool("slow_tool", {}))
awaitasyncio.sleep(0.3)
# External cancellationtask.cancel()
awaittaskexceptasyncio.CancelledError:
exception_type="CancelledError"exceptExceptionasex:
exception_type=type(ex).__name__finally:
ifproc.is_alive():
proc.kill()
returnexception_typeor"None"# === Main ===if__name__=="__main__":
print("Test 1: Server dies mid-request")
result1=asyncio.run(test_server_dies())
print(f" Exception: {result1}")
print()
print("Test 2: External cancellation (Ctrl+C simulation)")
result2=asyncio.run(test_external_cancel())
print(f" Exception: {result2}")
print()
print("Result:")
ifresult1==result2=="CancelledError":
print(" Both scenarios raise CancelledError.")
print(" Client code cannot distinguish server failure from shutdown request.")
else:
print(f" Test 1: {result1}")
print(f" Test 2: {result2}")Python & MCP Python SDK
Python: 3.12.12
MCP SDK: 1.20.0
Initial Checks
Description
Summary
The MCP Python SDK raises
asyncio.CancelledErrorwhen 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.Problem
When an MCP server becomes unreachable during a request:
Root Cause
The SDK uses anyio for structured concurrency. Transport layers (
mcp/client/sse.py,mcp/client/streamable_http.py) create task groups:When the server connection fails:
sse_readertask fails (connection lost)CancelledErrorpropagates toresponse_stream_reader.receive()insession.pyCancelledErrorThis is the same exception type raised by
task.cancel()during external shutdown.Evidence
Exception characteristics when catching
CancelledError:ex.argstask.cancelling()delta()('Cancelled by cancel scope...',)task.cancel()()SSE internal failure and external cancellation have identical characteristics.
Impact
If client converts CancelledError → ConnectionError:
If client propagates CancelledError:
except BaseExceptionto handle failuresFiles Involved
mcp/client/sse.pymcp/client/streamable_http.pymcp/shared/session.pyresponse_stream_reader.receive()- where CancelledError surfacesExample Code
Python & MCP Python SDK