Skip to content

Commit 2b90c2f

Browse files
feat: add exception group unwrapping utility
ROOT CAUSE: Task groups wrap real errors with CancelledError from siblings, making error handling difficult for callers. CHANGES: - Added unwrap_task_group_exception() utility function - Extracts real error from ExceptionGroup, ignores cancelled siblings IMPACT: - Enables clean error handling for SDK users FILES MODIFIED: - src/mcp/shared/exceptions.py: Added unwrap_task_group_exception() - tests/shared/test_exceptions.py: Added tests for unwrapping behavior
1 parent 0fe16dd commit 2b90c2f

2 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/mcp/shared/exceptions.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,47 @@ def from_error(cls, error: ErrorData) -> UrlElicitationRequiredError:
104104
raw_elicitations = cast(list[dict[str, Any]], data.get("elicitations", []))
105105
elicitations = [ElicitRequestURLParams.model_validate(e) for e in raw_elicitations]
106106
return cls(elicitations, error.message)
107+
108+
109+
def unwrap_task_group_exception(exc: BaseException) -> BaseException:
110+
"""Unwrap an exception from a task group, extracting only the real error.
111+
112+
When anyio task groups fail, they raise BaseExceptionGroup containing:
113+
- The original error that caused the failure
114+
- CancelledError from sibling tasks that were cancelled
115+
116+
This function extracts only the real error, ignoring cancelled siblings.
117+
118+
Args:
119+
exc: The exception to unwrap (could be any exception)
120+
121+
Returns:
122+
The unwrapped exception if it was an ExceptionGroup with a real error,
123+
otherwise the original exception
124+
125+
Example:
126+
```python
127+
try:
128+
async with anyio.create_task_group() as tg:
129+
tg.start_soon(task1)
130+
tg.start_soon(task2)
131+
except BaseExceptionGroup as e:
132+
# Extract only the real error, ignore CancelledError
133+
real_exc = unwrap_task_group_exception(e)
134+
raise real_exc
135+
```
136+
"""
137+
import anyio
138+
139+
# If not an exception group, return as-is
140+
if not isinstance(exc, BaseExceptionGroup):
141+
return exc
142+
143+
# Find the first non-cancelled exception
144+
cancelled_exc_class = anyio.get_cancelled_exc_class()
145+
for sub_exc in exc.exceptions:
146+
if not isinstance(sub_exc, cancelled_exc_class):
147+
return sub_exc
148+
149+
# All were cancelled, return the group
150+
return exc

tests/shared/test_exceptions.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,66 @@ def test_url_elicitation_required_error_exception_message() -> None:
162162

163163
# The exception's string representation should match the message
164164
assert str(error) == "URL elicitation required"
165+
166+
167+
# Tests for unwrap_task_group_exception
168+
import anyio
169+
170+
171+
@pytest.mark.anyio
172+
async def test_unwrap_single_error() -> None:
173+
"""Test that a single exception is returned as-is."""
174+
from mcp.shared.exceptions import unwrap_task_group_exception
175+
176+
error = ValueError("test error")
177+
result = unwrap_task_group_exception(error)
178+
assert result is error
179+
180+
181+
@pytest.mark.anyio
182+
async def test_unwrap_exception_group_with_real_error() -> None:
183+
"""Test that real error is extracted from ExceptionGroup."""
184+
from mcp.shared.exceptions import unwrap_task_group_exception
185+
186+
real_error = ConnectionError("connection failed")
187+
188+
# Simulate what anyio does: create exception group with real error + cancelled
189+
try:
190+
async with anyio.create_task_group() as tg:
191+
tg.start_soon(lambda: (_ for _ in ()).throw(real_error))
192+
tg.start_soon(anyio.sleep, 999) # Will be cancelled
193+
except BaseExceptionGroup as e:
194+
result = unwrap_task_group_exception(e)
195+
assert isinstance(result, ConnectionError)
196+
assert str(result) == "connection failed"
197+
198+
199+
@pytest.mark.anyio
200+
async def test_unwrap_exception_group_all_cancelled() -> None:
201+
"""Test that when all exceptions are cancelled, the group is re-raised."""
202+
from mcp.shared.exceptions import unwrap_task_group_exception
203+
204+
try:
205+
async with anyio.create_task_group() as tg:
206+
tg.start_soon(anyio.sleep, 999)
207+
tg.cancel_scope.cancel()
208+
except BaseExceptionGroup as e:
209+
# Should return the group if all are cancelled
210+
result = unwrap_task_group_exception(e)
211+
assert isinstance(result, BaseExceptionGroup)
212+
213+
214+
@pytest.mark.anyio
215+
async def test_unwrap_preserves_non_cancelled_errors() -> None:
216+
"""Test that all non-cancelled exceptions are preserved."""
217+
from mcp.shared.exceptions import unwrap_task_group_exception
218+
219+
error1 = ValueError("error 1")
220+
error2 = RuntimeError("error 2")
221+
222+
# Create an exception group with multiple real errors
223+
group = BaseExceptionGroup("multiple", [error1, error2])
224+
225+
result = unwrap_task_group_exception(group)
226+
# Should return the first non-cancelled error
227+
assert result is error1

0 commit comments

Comments
 (0)