Skip to content

fix: collapse single-exception ExceptionGroups from task groups#2245

Open
Varun6578 wants to merge 5 commits intomodelcontextprotocol:mainfrom
Varun6578:fix/collapse-exception-group
Open

fix: collapse single-exception ExceptionGroups from task groups#2245
Varun6578 wants to merge 5 commits intomodelcontextprotocol:mainfrom
Varun6578:fix/collapse-exception-group

Conversation

@Varun6578
Copy link
Contributor

Summary

Fixes #2114 — ExceptionGroup wrapping obscures real errors from task groups.

When an anyio task group contains tasks and one fails, the exception is always wrapped in an ExceptionGroup — even if there is only a single real exception. This makes it impossible for callers to catch specific error types with except ConnectionError:.

Changes

  • New module src/mcp/shared/_task_group.py:

    • collapse_exception_group() — recursively unwraps single-exception BaseExceptionGroups
    • _CollapsingTaskGroup — drop-in wrapper around anyio's TaskGroup that collapses on __aexit__
    • create_mcp_task_group() — factory function replacing anyio.create_task_group()
  • All 16 anyio.create_task_group() call sites updated across:

    • Client transports: sse.py, stdio.py, websocket.py, streamable_http.py, _memory.py
    • Server transports: sse.py, stdio.py, websocket.py, streamable_http.py
    • Shared: session.py, session_group.py
    • Server internals: lowlevel/server.py, streamable_http_manager.py, task_support.py, task_result_handler.py
  • 12 new tests covering collapse logic, integration with task groups, edge cases

  • Ruff config: added builtins for BaseExceptionGroup/ExceptionGroup (Python 3.10 compat)

Behavior

Scenario Before After
Single task fails ExceptionGroup([ConnectionError]) ConnectionError raised directly
Single task fails + siblings cancelled ExceptionGroup([ConnectionError]) ConnectionError raised directly
Multiple tasks fail ExceptionGroup([A, B]) ExceptionGroup([A, B]) (unchanged)
No failures Clean exit Clean exit (unchanged)

The collapsed exception preserves the ExceptionGroup as __cause__ for debugging.

Varun Sharma and others added 5 commits March 7, 2026 15:46
Replace all 16 anyio.create_task_group() calls with create_mcp_task_group()
which automatically unwraps BaseExceptionGroups containing a single exception.

This allows callers to catch specific error types (e.g. except ConnectionError)
instead of having to handle ExceptionGroup wrapping.

- Add src/mcp/shared/_task_group.py with collapse_exception_group() utility
  and _CollapsingTaskGroup wrapper class
- Update all client transports (sse, stdio, websocket, streamable_http, memory)
- Update all server transports (sse, stdio, websocket, streamable_http)
- Update shared session, session_group, lowlevel server, task_support,
  task_result_handler, and streamable_http_manager
- Add builtins config for BaseExceptionGroup/ExceptionGroup in ruff
- Add 12 comprehensive tests covering collapse logic and integration

Closes modelcontextprotocol#2114

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On Python < 3.11, BaseExceptionGroup and ExceptionGroup are not builtins.
Import them from the exceptiongroup backport package conditionally.
Also fix coverage miss on unreachable pytest.fail line and revert
unnecessary builtins ruff config.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add pragma no cover to version-conditional imports (Python 3.10 compat)
- Add type: ignore for pyright false positives (isinstance in loop, duck-type assignment)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ression tests

- Replace 'pragma: no cover' with 'pragma: lax no cover' (matches repo convention
  for lines that may/may not be covered per environment)
- Add test_start_failure_is_unwrapped for start() error path
- Add test_issue_2114_except_specific_error_type regression test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reported-by: maxisbey <modelcontextprotocol#2114>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ExceptionGroup wrapping obscures real errors from task groups

1 participant