Skip to content

Commit 15f4e8f

Browse files
fix(client): unwrap ExceptionGroup in transport clients
ROOT CAUSE: Transport clients propagate ExceptionGroup wrapping real errors. CHANGES: - Added exception unwrapping in streamable_http_client - Added exception unwrapping in websocket_client - Added exception unwrapping in sse_client - Added exception unwrapping in stdio_client IMPACT: - Callers can catch specific exceptions directly FILES MODIFIED: - src/mcp/client/streamable_http.py - src/mcp/client/websocket.py - src/mcp/client/sse.py - src/mcp/client/stdio.py
1 parent 34c26fa commit 15f4e8f

4 files changed

Lines changed: 62 additions & 35 deletions

File tree

src/mcp/client/sse.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ async def post_writer(endpoint_url: str):
157157
yield read_stream, write_stream
158158
finally:
159159
tg.cancel_scope.cancel()
160+
except BaseExceptionGroup as e:
161+
from mcp.shared.exceptions import unwrap_task_group_exception
162+
163+
real_exc = unwrap_task_group_exception(e)
164+
if real_exc is not e:
165+
raise real_exc
160166
finally:
161167
await read_stream_writer.aclose()
162168
await write_stream.aclose()

src/mcp/client/stdio.py

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -178,37 +178,44 @@ async def stdin_writer():
178178
await anyio.lowlevel.checkpoint()
179179

180180
async with anyio.create_task_group() as tg, process:
181-
tg.start_soon(stdout_reader)
182-
tg.start_soon(stdin_writer)
183181
try:
184-
yield read_stream, write_stream
185-
finally:
186-
# MCP spec: stdio shutdown sequence
187-
# 1. Close input stream to server
188-
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
189-
# 3. Send SIGKILL if still not exited
190-
if process.stdin: # pragma: no branch
182+
tg.start_soon(stdout_reader)
183+
tg.start_soon(stdin_writer)
184+
try:
185+
yield read_stream, write_stream
186+
finally:
187+
# MCP spec: stdio shutdown sequence
188+
# 1. Close input stream to server
189+
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
190+
# 3. Send SIGKILL if still not exited
191+
if process.stdin: # pragma: no branch
192+
try:
193+
await process.stdin.aclose()
194+
except Exception: # pragma: no cover
195+
# stdin might already be closed, which is fine
196+
pass
197+
191198
try:
192-
await process.stdin.aclose()
193-
except Exception: # pragma: no cover
194-
# stdin might already be closed, which is fine
199+
# Give the process time to exit gracefully after stdin closes
200+
with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
201+
await process.wait()
202+
except TimeoutError:
203+
# Process didn't exit from stdin closure, use platform-specific termination
204+
# which handles SIGTERM -> SIGKILL escalation
205+
await _terminate_process_tree(process)
206+
except ProcessLookupError: # pragma: no cover
207+
# Process already exited, which is fine
195208
pass
196-
197-
try:
198-
# Give the process time to exit gracefully after stdin closes
199-
with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
200-
await process.wait()
201-
except TimeoutError:
202-
# Process didn't exit from stdin closure, use platform-specific termination
203-
# which handles SIGTERM -> SIGKILL escalation
204-
await _terminate_process_tree(process)
205-
except ProcessLookupError: # pragma: no cover
206-
# Process already exited, which is fine
207-
pass
208-
await read_stream.aclose()
209-
await write_stream.aclose()
210-
await read_stream_writer.aclose()
211-
await write_stream_reader.aclose()
209+
await read_stream.aclose()
210+
await write_stream.aclose()
211+
await read_stream_writer.aclose()
212+
await write_stream_reader.aclose()
213+
except BaseExceptionGroup as e:
214+
from mcp.shared.exceptions import unwrap_task_group_exception
215+
216+
real_exc = unwrap_task_group_exception(e)
217+
if real_exc is not e:
218+
raise real_exc
212219

213220

214221
def _get_executable_command(command: str) -> str:

src/mcp/client/streamable_http.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,13 @@ def start_get_stream() -> None:
574574
if transport.session_id and terminate_on_close:
575575
await transport.terminate_session(client)
576576
tg.cancel_scope.cancel()
577+
except BaseExceptionGroup as e:
578+
# Unwrap ExceptionGroup to get only the real error
579+
from mcp.shared.exceptions import unwrap_task_group_exception
580+
581+
real_exc = unwrap_task_group_exception(e)
582+
if real_exc is not e:
583+
raise real_exc
577584
finally:
578585
await read_stream_writer.aclose()
579586
await write_stream.aclose()

src/mcp/client/websocket.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,19 @@ async def ws_writer():
6969
await ws.send(json.dumps(msg_dict))
7070

7171
async with anyio.create_task_group() as tg:
72-
# Start reader and writer tasks
73-
tg.start_soon(ws_reader)
74-
tg.start_soon(ws_writer)
72+
try:
73+
# Start reader and writer tasks
74+
tg.start_soon(ws_reader)
75+
tg.start_soon(ws_writer)
7576

76-
# Yield the receive/send streams
77-
yield (read_stream, write_stream)
77+
# Yield the receive/send streams
78+
yield (read_stream, write_stream)
7879

79-
# Once the caller's 'async with' block exits, we shut down
80-
tg.cancel_scope.cancel()
80+
# Once the caller's 'async with' block exits, we shut down
81+
tg.cancel_scope.cancel()
82+
except BaseExceptionGroup as e:
83+
from mcp.shared.exceptions import unwrap_task_group_exception
84+
85+
real_exc = unwrap_task_group_exception(e)
86+
if real_exc is not e:
87+
raise real_exc

0 commit comments

Comments
 (0)