Initial Checks
Release line
2.x (current stable)
Description
#2047 made non-2xx responses request-scoped (#2604). A POST that raises before there is any response -- connection refused or similar -- still escapes handle_request_async into post_writer's task group. The waiting caller gets CancelledError with WouldBlock as __context__, identical to what happens in cancellation. The ConnectError never reaches the caller; it only appears in the ExceptionGroup raised when streamable_http_client exits.
A caller therefore can't tell "server went away" from "this task is being cancelled", and can't retry the first without swallowing the second.
Expected: the failed call raises MCPError with the transport exception reachable as its cause. External cancellation still raises CancelledError.
#1830 reported this and was closed after 1.25.0 appeared to produce an ExceptionGroup instead. Both happen: the caller gets CancelledError, and the ExceptionGroup surfaces later at transport exit. #1830's repro used only sse_client.
Example Code
# /// script
# requires-python = ">=3.11"
# dependencies = ["mcp>=2.2.0", "uvicorn"]
# ///
"""A: kill the server mid-session, then call a tool. B: cancel an in-flight call.
Prints the exception each caller receives, with its __cause__/__context__ chain.
"""
import asyncio
import importlib.metadata
import multiprocessing
import socket
import time
import uvicorn
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
HOST = "127.0.0.1"
def get_free_port() -> int:
with socket.socket() as sock:
sock.bind((HOST, 0))
return int(sock.getsockname()[1])
def run_server(port: int) -> None:
server = MCPServer("repro", log_level="CRITICAL")
@server.tool()
def echo(text: str) -> str:
return text
@server.tool()
async def slow() -> str:
await asyncio.sleep(30)
return "done"
uvicorn.run(server.streamable_http_app(), host=HOST, port=port, log_level="critical")
def start_server() -> tuple[multiprocessing.Process, str]:
port = get_free_port()
proc = multiprocessing.Process(target=run_server, args=(port,), daemon=True)
proc.start()
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
try:
with socket.create_connection((HOST, port), timeout=0.25):
return proc, f"http://{HOST}:{port}/mcp"
except OSError:
time.sleep(0.05)
raise RuntimeError("server did not come up")
def report(label: str, exc: BaseException) -> None:
print(f" {label}:")
seen: set[int] = set()
current: BaseException | None = exc
depth = 2
while current is not None and id(current) not in seen:
seen.add(id(current))
print(f"{' ' * depth}{type(current).__name__}: {current!r}")
if isinstance(current, BaseExceptionGroup):
for sub in current.exceptions:
print(f"{' ' * (depth + 1)}| {type(sub).__name__}: {sub!r}")
current = current.__cause__ or current.__context__
depth += 1
async def scenario_server_dies() -> None:
proc, url = start_server()
try:
async with streamable_http_client(url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
await session.call_tool("echo", {"text": "before"})
proc.kill()
proc.join()
try:
await session.call_tool("echo", {"text": "after"})
except BaseException as exc:
report("caller", exc)
return
print(" caller: no exception")
except BaseException as exc:
report("streamable_http_client exit", exc)
finally:
if proc.is_alive():
proc.kill()
async def scenario_external_cancel() -> None:
proc, url = start_server()
try:
async with streamable_http_client(url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
task = asyncio.create_task(session.call_tool("slow", {}))
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except BaseException as exc:
report("caller", exc)
return
print(" caller: no exception")
except BaseException as exc:
report("streamable_http_client exit", exc)
finally:
if proc.is_alive():
proc.kill()
async def main() -> None:
print(f"mcp {importlib.metadata.version('mcp')}")
print("A: server killed mid-session")
await scenario_server_dies()
print("B: in-flight call cancelled externally")
await scenario_external_cancel()
if __name__ == "__main__":
multiprocessing.set_start_method("spawn")
asyncio.run(main())
Output from running the above:
mcp 2.2.0
A: server killed mid-session
caller:
CancelledError: CancelledError('Cancelled via cancel scope 10bbefe30')
WouldBlock: WouldBlock()
streamable_http_client exit:
ExceptionGroup: ExceptionGroup('unhandled errors in a TaskGroup', [ConnectError('All connection attempts failed')])
| ConnectError: ConnectError('All connection attempts failed')
CancelledError: CancelledError('Cancelled via cancel scope 10bbefe30')
CancelledError: CancelledError("Cancelled via cancel scope 10bc90410 by <Task cancelling name='Task-1' ...>")
B: in-flight call cancelled externally
caller:
CancelledError: CancelledError()
WouldBlock: WouldBlock()
Python & MCP Python SDK
Python 3.13.14, macOS arm64
mcp 2.2.0
Initial Checks
Release line
2.x (current stable)
Description
#2047 made non-2xx responses request-scoped (#2604). A POST that raises before there is any response -- connection refused or similar -- still escapes
handle_request_asyncintopost_writer's task group. The waiting caller getsCancelledErrorwithWouldBlockas__context__, identical to what happens in cancellation. TheConnectErrornever reaches the caller; it only appears in theExceptionGroupraised whenstreamable_http_clientexits.A caller therefore can't tell "server went away" from "this task is being cancelled", and can't retry the first without swallowing the second.
Expected: the failed call raises
MCPErrorwith the transport exception reachable as its cause. External cancellation still raisesCancelledError.#1830 reported this and was closed after 1.25.0 appeared to produce an
ExceptionGroupinstead. Both happen: the caller getsCancelledError, and theExceptionGroupsurfaces later at transport exit. #1830's repro used onlysse_client.Example Code
Output from running the above:
Python & MCP Python SDK