From 008150a0ca230d39296c786a6fe002c52e05148e Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 13:15:32 +0100 Subject: [PATCH 01/10] fix: collapse BaseExceptionGroup to surface real errors from task groups When a task in an anyio task group fails, sibling tasks are cancelled. The resulting BaseExceptionGroup contains the real error alongside Cancelled exceptions from those siblings. This makes error classification extremely difficult for callers. Add open_task_group() context manager and collapse_exception_group() utility that detect this pattern and re-raise just the original error, keeping the full group as __cause__ for debugging. Applied to all 16 create_task_group() sites across: - Client transports (sse, stdio, websocket, streamable_http) - Server transports (sse, stdio, websocket, streamable_http) - Session __aexit__ - Server lowlevel run loop - StreamableHTTP session manager - SessionGroup, InMemoryTransport - Experimental task support Fixes #2114 --- src/mcp/client/_memory.py | 3 +- src/mcp/client/session_group.py | 3 +- src/mcp/client/sse.py | 3 +- src/mcp/client/stdio.py | 3 +- src/mcp/client/streamable_http.py | 3 +- src/mcp/client/websocket.py | 3 +- .../experimental/task_result_handler.py | 3 +- src/mcp/server/experimental/task_support.py | 3 +- src/mcp/server/lowlevel/server.py | 3 +- src/mcp/server/sse.py | 3 +- src/mcp/server/stdio.py | 3 +- src/mcp/server/streamable_http.py | 5 +- src/mcp/server/streamable_http_manager.py | 3 +- src/mcp/server/websocket.py | 3 +- src/mcp/shared/_exception_utils.py | 82 ++++++++++++ src/mcp/shared/session.py | 10 +- tests/shared/test_exception_utils.py | 124 ++++++++++++++++++ 17 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 src/mcp/shared/_exception_utils.py create mode 100644 tests/shared/test_exception_utils.py diff --git a/src/mcp/client/_memory.py b/src/mcp/client/_memory.py index e6e938673..69eb2ddf8 100644 --- a/src/mcp/client/_memory.py +++ b/src/mcp/client/_memory.py @@ -12,6 +12,7 @@ from mcp.client._transport import TransportStreams from mcp.server import Server from mcp.server.mcpserver import MCPServer +from mcp.shared._exception_utils import open_task_group from mcp.shared.memory import create_client_server_memory_streams @@ -48,7 +49,7 @@ async def _connect(self) -> AsyncIterator[TransportStreams]: client_read, client_write = client_streams server_read, server_write = server_streams - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: # Start server in background tg.start_soon( lambda: actual_server.run( diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 961021264..f5e7fdfb1 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -24,6 +24,7 @@ from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters from mcp.client.streamable_http import streamable_http_client +from mcp.shared._exception_utils import open_task_group from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.exceptions import MCPError from mcp.shared.session import ProgressFnT @@ -166,7 +167,7 @@ async def __aexit__( await self._exit_stack.aclose() # Concurrently close session stacks. - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: for exit_stack in self._session_exit_stacks.values(): tg.start_soon(exit_stack.aclose) diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 61026aa0c..f87a6b88a 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -12,6 +12,7 @@ from httpx_sse._exceptions import SSEError from mcp import types +from mcp.shared._exception_utils import open_task_group from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client from mcp.shared.message import SessionMessage @@ -60,7 +61,7 @@ async def sse_client( read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: try: logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}") async with httpx_client_factory( diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 902dc8576..0cdf7b560 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -20,6 +20,7 @@ get_windows_executable_command, terminate_windows_process_tree, ) +from mcp.shared._exception_utils import open_task_group from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) @@ -177,7 +178,7 @@ async def stdin_writer(): except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() - async with anyio.create_task_group() as tg, process: + async with open_task_group() as tg, process: tg.start_soon(stdout_reader) tg.start_soon(stdin_writer) try: diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 9f3dd5e0b..c9a86aff0 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -16,6 +16,7 @@ from pydantic import ValidationError from mcp.client._transport import TransportStreams +from mcp.shared._exception_utils import open_task_group from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( @@ -546,7 +547,7 @@ async def streamable_http_client( transport = StreamableHTTPTransport(url) - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: try: logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") diff --git a/src/mcp/client/websocket.py b/src/mcp/client/websocket.py index 79e75fad1..3c34b7fee 100644 --- a/src/mcp/client/websocket.py +++ b/src/mcp/client/websocket.py @@ -9,6 +9,7 @@ from websockets.typing import Subprotocol from mcp import types +from mcp.shared._exception_utils import open_task_group from mcp.shared.message import SessionMessage @@ -68,7 +69,7 @@ async def ws_writer(): msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True) await ws.send(json.dumps(msg_dict)) - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: # Start reader and writer tasks tg.start_soon(ws_reader) tg.start_soon(ws_writer) diff --git a/src/mcp/server/experimental/task_result_handler.py b/src/mcp/server/experimental/task_result_handler.py index b2268bc1c..9a81ec584 100644 --- a/src/mcp/server/experimental/task_result_handler.py +++ b/src/mcp/server/experimental/task_result_handler.py @@ -15,6 +15,7 @@ import anyio from mcp.server.session import ServerSession +from mcp.shared._exception_utils import open_task_group from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY, is_terminal from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue @@ -162,7 +163,7 @@ async def _wait_for_task_update(self, task_id: str) -> None: Races between store update and queue message - first one wins. """ - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: async def wait_for_store() -> None: try: diff --git a/src/mcp/server/experimental/task_support.py b/src/mcp/server/experimental/task_support.py index b54219504..c7871b148 100644 --- a/src/mcp/server/experimental/task_support.py +++ b/src/mcp/server/experimental/task_support.py @@ -13,6 +13,7 @@ from mcp.server.experimental.task_result_handler import TaskResultHandler from mcp.server.session import ServerSession +from mcp.shared._exception_utils import open_task_group from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue from mcp.shared.experimental.tasks.store import TaskStore @@ -79,7 +80,7 @@ async def run(self) -> AsyncIterator[None]: # Task group is now available ... """ - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: self._task_group = tg try: yield diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index aee644040..1e54c25f8 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -66,6 +66,7 @@ async def main(): from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared._exception_utils import open_task_group from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder @@ -389,7 +390,7 @@ async def run( task_support.configure_session(session) await stack.enter_async_context(task_support.run()) - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: async for message in session.incoming_messages: logger.debug("Received message: %s", message) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 9007230ce..53831f9be 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -55,6 +55,7 @@ async def handle_sse(request): TransportSecurityMiddleware, TransportSecuritySettings, ) +from mcp.shared._exception_utils import open_task_group from mcp.shared.message import ServerMessageMetadata, SessionMessage logger = logging.getLogger(__name__) @@ -174,7 +175,7 @@ async def sse_writer(): } ) - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: async def response_wrapper(scope: Scope, receive: Receive, send: Send): """The EventSourceResponse returning signals a client close / disconnect. diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index e526bab56..8983116fc 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -26,6 +26,7 @@ async def run_server(): from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import types +from mcp.shared._exception_utils import open_task_group from mcp.shared.message import SessionMessage @@ -77,7 +78,7 @@ async def stdout_writer(): except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: tg.start_soon(stdin_reader) tg.start_soon(stdout_writer) yield read_stream, write_stream diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 04aed345e..a012b0718 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -25,6 +25,7 @@ from starlette.types import Receive, Scope, Send from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings +from mcp.shared._exception_utils import open_task_group from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( @@ -614,7 +615,7 @@ async def sse_writer(): # pragma: lax no cover # Start the SSE response (this will send headers immediately) try: # First send the response to establish the SSE connection - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: tg.start_soon(response, scope, receive, send) # Then send the message to be processed by the server session_message = self._create_session_message(message, request, request_id, protocol_version) @@ -970,7 +971,7 @@ async def connect( self._write_stream = write_stream # Start a task group for message routing - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: # Create a message router that distributes messages to request streams async def message_router(): try: diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 50bcd5e79..3d90e50bb 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -21,6 +21,7 @@ StreamableHTTPServerTransport, ) from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared._exception_utils import open_task_group from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError if TYPE_CHECKING: @@ -122,7 +123,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: ) self._has_started = True - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: # Store the task group for later use self._task_group = tg logger.info("StreamableHTTP session manager started") diff --git a/src/mcp/server/websocket.py b/src/mcp/server/websocket.py index 3e675da5f..ca23e093f 100644 --- a/src/mcp/server/websocket.py +++ b/src/mcp/server/websocket.py @@ -7,6 +7,7 @@ from starlette.websockets import WebSocket from mcp import types +from mcp.shared._exception_utils import open_task_group from mcp.shared.message import SessionMessage @@ -52,7 +53,7 @@ async def ws_writer(): except anyio.ClosedResourceError: await websocket.close() - async with anyio.create_task_group() as tg: + async with open_task_group() as tg: tg.start_soon(ws_reader) tg.start_soon(ws_writer) yield (read_stream, write_stream) diff --git a/src/mcp/shared/_exception_utils.py b/src/mcp/shared/_exception_utils.py new file mode 100644 index 000000000..2cad98a2a --- /dev/null +++ b/src/mcp/shared/_exception_utils.py @@ -0,0 +1,82 @@ +"""Utilities for collapsing BaseExceptionGroup noise from anyio task groups. + +When a task in an anyio task group fails, sibling tasks are cancelled. The +resulting ``BaseExceptionGroup`` contains the real error alongside +``Cancelled`` exceptions from those siblings. This module provides helpers +that detect that pattern and re-raise just the original error, preserving the +full group as ``__cause__`` for debugging. + +If multiple tasks fail with non-cancellation errors concurrently, the full +``BaseExceptionGroup`` is preserved unchanged. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator + +import anyio +import anyio.abc + + +def collapse_exception_group( + eg: BaseExceptionGroup, + cancelled_type: type[BaseException] = asyncio.CancelledError, +) -> BaseException: + """Extract the single real error from a *BaseExceptionGroup* if possible. + + Args: + eg: The exception group to collapse. + cancelled_type: The cancellation exception class to filter out. + Defaults to ``asyncio.CancelledError``. The ``open_task_group`` + context manager passes ``anyio.get_cancelled_exc_class()`` so the + correct type is used for any backend. + + Returns: + * The single non-cancelled exception if exactly one exists. + * A filtered group (cancelled noise stripped) if multiple real errors. + * A single ``Cancelled`` if all exceptions are cancellations. + """ + + # split(type) uses isinstance on leaf exceptions, NOT on the group. + # Using split(lambda) is incorrect because the lambda would first be + # called on the group object itself. + cancelled, non_cancelled = eg.split(cancelled_type) + + if non_cancelled is None: + # Every exception is a cancellation – surface just one. + return eg.exceptions[0] + + if len(non_cancelled.exceptions) == 1: + return non_cancelled.exceptions[0] + + # Multiple real errors – return the filtered group (without Cancelled). + return non_cancelled if non_cancelled is not eg else eg + + +@contextlib.asynccontextmanager +async def open_task_group() -> AsyncIterator[anyio.abc.TaskGroup]: + """Drop-in replacement for ``anyio.create_task_group()`` that collapses + exception groups containing a single real error plus cancellation noise. + + Usage:: + + async with open_task_group() as tg: + tg.start_soon(some_task) + ... + + If *some_task* raises ``ConnectionError`` and all siblings are cancelled, + this context manager will raise ``ConnectionError`` directly (with the + original ``BaseExceptionGroup`` attached as ``__cause__``). + """ + + try: + async with anyio.create_task_group() as tg: + yield tg + except BaseExceptionGroup as eg: + cancelled_cls = anyio.get_cancelled_exc_class() + collapsed = collapse_exception_group(eg, cancelled_type=cancelled_cls) + if collapsed is not eg: + raise collapsed from eg + raise diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index b617d702f..de9b13e12 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, TypeAdapter from typing_extensions import Self +from mcp.shared._exception_utils import collapse_exception_group from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.response_router import ResponseRouter @@ -228,7 +229,14 @@ async def __aexit__( # would be very surprising behavior), so make sure to cancel the tasks # in the task group. self._task_group.cancel_scope.cancel() - return await self._task_group.__aexit__(exc_type, exc_val, exc_tb) + try: + return await self._task_group.__aexit__(exc_type, exc_val, exc_tb) + except BaseExceptionGroup as eg: + cancelled_cls = anyio.get_cancelled_exc_class() + collapsed = collapse_exception_group(eg, cancelled_type=cancelled_cls) + if collapsed is not eg: + raise collapsed from eg + raise async def send_request( self, diff --git a/tests/shared/test_exception_utils.py b/tests/shared/test_exception_utils.py new file mode 100644 index 000000000..69718a1d7 --- /dev/null +++ b/tests/shared/test_exception_utils.py @@ -0,0 +1,124 @@ +"""Tests for mcp.shared._exception_utils — ExceptionGroup collapsing.""" + +from __future__ import annotations + +import asyncio + +import anyio +import pytest + +from mcp.shared._exception_utils import collapse_exception_group, open_task_group + + +# --------------------------------------------------------------------------- +# collapse_exception_group() unit tests +# --------------------------------------------------------------------------- + + +class TestCollapseExceptionGroup: + """Unit tests for the pure-function collapser.""" + + def test_single_real_error_with_cancelled_siblings(self) -> None: + """One real error + N Cancelled → unwrap to the real error.""" + real = ConnectionError("lost connection") + eg = BaseExceptionGroup( + "task group", + [real, asyncio.CancelledError(), asyncio.CancelledError()], + ) + result = collapse_exception_group(eg) + assert result is real + + def test_all_cancelled(self) -> None: + """Only Cancelled exceptions → return a single Cancelled.""" + c1 = asyncio.CancelledError() + c2 = asyncio.CancelledError() + eg = BaseExceptionGroup("task group", [c1, c2]) + result = collapse_exception_group(eg) + assert isinstance(result, asyncio.CancelledError) + + def test_multiple_real_errors(self) -> None: + """Multiple non-Cancelled errors → return filtered group (no Cancelled).""" + e1 = ValueError("bad value") + e2 = RuntimeError("runtime issue") + eg = BaseExceptionGroup( + "task group", + [e1, asyncio.CancelledError(), e2], + ) + result = collapse_exception_group(eg) + assert isinstance(result, BaseExceptionGroup) + # Should contain only the two real errors + assert len(result.exceptions) == 2 + assert e1 in result.exceptions + assert e2 in result.exceptions + + def test_single_real_error_no_cancelled(self) -> None: + """One real error, no Cancelled → unwrap to the real error.""" + real = TypeError("wrong type") + eg = BaseExceptionGroup("task group", [real]) + result = collapse_exception_group(eg) + assert result is real + + def test_multiple_real_errors_no_cancelled(self) -> None: + """Multiple real errors, no Cancelled → return group with same exceptions.""" + e1 = ValueError("a") + e2 = ValueError("b") + eg = BaseExceptionGroup("task group", [e1, e2]) + result = collapse_exception_group(eg) + assert isinstance(result, BaseExceptionGroup) + assert len(result.exceptions) == 2 + assert e1 in result.exceptions + assert e2 in result.exceptions + + +# --------------------------------------------------------------------------- +# open_task_group() integration tests +# --------------------------------------------------------------------------- + + +class TestOpenTaskGroup: + """Integration tests for the context manager.""" + + @pytest.mark.anyio + async def test_single_task_failure_unwrapped(self) -> None: + """A single failing task should raise its error directly, not wrapped.""" + with pytest.raises(ConnectionError, match="server gone"): + async with open_task_group() as tg: + tg.start_soon(self._fail_with, ConnectionError("server gone")) + # Keep the group alive so the failure propagates + await anyio.sleep_forever() + + @pytest.mark.anyio + async def test_no_failure_no_exception(self) -> None: + """Normal exit — no exception raised.""" + async with open_task_group() as tg: + tg.start_soon(anyio.sleep, 0) + + @pytest.mark.anyio + async def test_multiple_failures_preserved(self) -> None: + """Multiple concurrent failures should still raise BaseExceptionGroup.""" + with pytest.raises(BaseExceptionGroup) as exc_info: + async with open_task_group() as tg: + tg.start_soon(self._fail_with, ValueError("a")) + tg.start_soon(self._fail_with, RuntimeError("b")) + await anyio.sleep_forever() + + # The group should contain both real errors + eg = exc_info.value + types = {type(e) for e in eg.exceptions} + assert ValueError in types + assert RuntimeError in types + + @pytest.mark.anyio + async def test_cause_chain_preserved(self) -> None: + """The original BaseExceptionGroup should be attached as __cause__.""" + with pytest.raises(ConnectionError) as exc_info: + async with open_task_group() as tg: + tg.start_soon(self._fail_with, ConnectionError("oops")) + await anyio.sleep_forever() + + assert exc_info.value.__cause__ is not None + assert isinstance(exc_info.value.__cause__, BaseExceptionGroup) + + @staticmethod + async def _fail_with(exc: BaseException) -> None: + raise exc From 3224737517a1e37c0a427ff290d049624232fcd9 Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 14:04:59 +0100 Subject: [PATCH 02/10] fix: add Python 3.10 compat import for BaseExceptionGroup On Python < 3.11, BaseExceptionGroup is not a builtin and must be imported from the exceptiongroup backport package (transitive dep via anyio). --- src/mcp/shared/_exception_utils.py | 4 ++++ src/mcp/shared/session.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/mcp/shared/_exception_utils.py b/src/mcp/shared/_exception_utils.py index 2cad98a2a..3a0235407 100644 --- a/src/mcp/shared/_exception_utils.py +++ b/src/mcp/shared/_exception_utils.py @@ -14,11 +14,15 @@ import asyncio import contextlib +import sys from collections.abc import AsyncIterator import anyio import anyio.abc +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + def collapse_exception_group( eg: BaseExceptionGroup, diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index de9b13e12..6fbdc11f6 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys from collections.abc import Callable from contextlib import AsyncExitStack from types import TracebackType @@ -12,6 +13,9 @@ from typing_extensions import Self from mcp.shared._exception_utils import collapse_exception_group + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.response_router import ResponseRouter From edf1fd89ff2deefb51fbdef45f4f9f80452da629 Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 15:27:11 +0100 Subject: [PATCH 03/10] fix: add BaseExceptionGroup import for Python 3.10 compat in tests --- tests/shared/test_exception_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/shared/test_exception_utils.py b/tests/shared/test_exception_utils.py index 69718a1d7..b4519d73a 100644 --- a/tests/shared/test_exception_utils.py +++ b/tests/shared/test_exception_utils.py @@ -3,12 +3,16 @@ from __future__ import annotations import asyncio +import sys import anyio import pytest from mcp.shared._exception_utils import collapse_exception_group, open_task_group +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + # --------------------------------------------------------------------------- # collapse_exception_group() unit tests From e5c4542c83d5cdf7be12d4a888a4132a0fd96a3c Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 15:39:01 +0100 Subject: [PATCH 04/10] fix: remove unused anyio imports and add coverage pragmas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `import anyio` from 4 modules where anyio.create_task_group was replaced by open_task_group - Add `# pragma: no cover` to sys.version_info < (3, 11) checks since coverage is per-Python-version and each version only covers one branch - Add `# pragma: lax no cover` to defensive raise paths in open_task_group and BaseSession.__aexit__ (triggered when exception group has no cancellation noise — extremely rare with anyio task groups) --- src/mcp/client/_memory.py | 2 -- src/mcp/client/session_group.py | 1 - src/mcp/server/experimental/task_result_handler.py | 2 -- src/mcp/server/experimental/task_support.py | 1 - src/mcp/shared/_exception_utils.py | 4 ++-- src/mcp/shared/session.py | 5 ++--- tests/shared/test_exception_utils.py | 2 +- 7 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/mcp/client/_memory.py b/src/mcp/client/_memory.py index 69eb2ddf8..ac24115ea 100644 --- a/src/mcp/client/_memory.py +++ b/src/mcp/client/_memory.py @@ -7,8 +7,6 @@ from types import TracebackType from typing import Any -import anyio - from mcp.client._transport import TransportStreams from mcp.server import Server from mcp.server.mcpserver import MCPServer diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index f5e7fdfb1..1e342d7a0 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -13,7 +13,6 @@ from types import TracebackType from typing import Any, TypeAlias -import anyio import httpx from pydantic import BaseModel, Field from typing_extensions import Self diff --git a/src/mcp/server/experimental/task_result_handler.py b/src/mcp/server/experimental/task_result_handler.py index 9a81ec584..f922f447f 100644 --- a/src/mcp/server/experimental/task_result_handler.py +++ b/src/mcp/server/experimental/task_result_handler.py @@ -12,8 +12,6 @@ import logging from typing import Any -import anyio - from mcp.server.session import ServerSession from mcp.shared._exception_utils import open_task_group from mcp.shared.exceptions import MCPError diff --git a/src/mcp/server/experimental/task_support.py b/src/mcp/server/experimental/task_support.py index c7871b148..a9279665c 100644 --- a/src/mcp/server/experimental/task_support.py +++ b/src/mcp/server/experimental/task_support.py @@ -8,7 +8,6 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field -import anyio from anyio.abc import TaskGroup from mcp.server.experimental.task_result_handler import TaskResultHandler diff --git a/src/mcp/shared/_exception_utils.py b/src/mcp/shared/_exception_utils.py index 3a0235407..a8744d878 100644 --- a/src/mcp/shared/_exception_utils.py +++ b/src/mcp/shared/_exception_utils.py @@ -20,7 +20,7 @@ import anyio import anyio.abc -if sys.version_info < (3, 11): +if sys.version_info < (3, 11): # pragma: no cover from exceptiongroup import BaseExceptionGroup @@ -83,4 +83,4 @@ async def open_task_group() -> AsyncIterator[anyio.abc.TaskGroup]: collapsed = collapse_exception_group(eg, cancelled_type=cancelled_cls) if collapsed is not eg: raise collapsed from eg - raise + raise # pragma: lax no cover \ No newline at end of file diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 6fbdc11f6..d8a6b94fb 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -14,7 +14,7 @@ from mcp.shared._exception_utils import collapse_exception_group -if sys.version_info < (3, 11): +if sys.version_info < (3, 11): # pragma: no cover from exceptiongroup import BaseExceptionGroup from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage @@ -240,8 +240,7 @@ async def __aexit__( collapsed = collapse_exception_group(eg, cancelled_type=cancelled_cls) if collapsed is not eg: raise collapsed from eg - raise - + raise # pragma: lax no cover async def send_request( self, request: SendRequestT, diff --git a/tests/shared/test_exception_utils.py b/tests/shared/test_exception_utils.py index b4519d73a..540994b16 100644 --- a/tests/shared/test_exception_utils.py +++ b/tests/shared/test_exception_utils.py @@ -10,7 +10,7 @@ from mcp.shared._exception_utils import collapse_exception_group, open_task_group -if sys.version_info < (3, 11): +if sys.version_info < (3, 11): # pragma: no cover from exceptiongroup import BaseExceptionGroup From b453b7b10484c25691fefb1b654d81b63ad9086e Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 15:55:12 +0100 Subject: [PATCH 05/10] fix: add trailing newline to _exception_utils.py --- src/mcp/shared/_exception_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/shared/_exception_utils.py b/src/mcp/shared/_exception_utils.py index a8744d878..5f0fb2780 100644 --- a/src/mcp/shared/_exception_utils.py +++ b/src/mcp/shared/_exception_utils.py @@ -83,4 +83,4 @@ async def open_task_group() -> AsyncIterator[anyio.abc.TaskGroup]: collapsed = collapse_exception_group(eg, cancelled_type=cancelled_cls) if collapsed is not eg: raise collapsed from eg - raise # pragma: lax no cover \ No newline at end of file + raise # pragma: lax no cover From 632a2266cf46d7b23c248ce545771b53a6263298 Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 15:55:55 +0100 Subject: [PATCH 06/10] fix: use pragma lax no cover for version-dependent imports strict-no-cover flags 'pragma: no cover' as incorrect when the lines ARE covered on the running Python version. Use 'pragma: lax no cover' instead, which is excluded from both coverage counting and strict checking. --- src/mcp/shared/_exception_utils.py | 2 +- src/mcp/shared/session.py | 2 +- tests/shared/test_exception_utils.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/shared/_exception_utils.py b/src/mcp/shared/_exception_utils.py index 5f0fb2780..ee8deda4c 100644 --- a/src/mcp/shared/_exception_utils.py +++ b/src/mcp/shared/_exception_utils.py @@ -20,7 +20,7 @@ import anyio import anyio.abc -if sys.version_info < (3, 11): # pragma: no cover +if sys.version_info < (3, 11): # pragma: lax no cover from exceptiongroup import BaseExceptionGroup diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index d8a6b94fb..d2e4c801c 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -14,7 +14,7 @@ from mcp.shared._exception_utils import collapse_exception_group -if sys.version_info < (3, 11): # pragma: no cover +if sys.version_info < (3, 11): # pragma: lax no cover from exceptiongroup import BaseExceptionGroup from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage diff --git a/tests/shared/test_exception_utils.py b/tests/shared/test_exception_utils.py index 540994b16..51acaf368 100644 --- a/tests/shared/test_exception_utils.py +++ b/tests/shared/test_exception_utils.py @@ -10,7 +10,7 @@ from mcp.shared._exception_utils import collapse_exception_group, open_task_group -if sys.version_info < (3, 11): # pragma: no cover +if sys.version_info < (3, 11): # pragma: lax no cover from exceptiongroup import BaseExceptionGroup From ff7d4b6f6abbd70cdcba7e8d4f9f2f65892bc1e4 Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 16:05:35 +0100 Subject: [PATCH 07/10] fix: add blank line before send_request method in session.py Pre-commit end-of-file fixer requires a blank line between the except block and the next method definition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp/shared/session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index d2e4c801c..1931eaace 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -241,6 +241,7 @@ async def __aexit__( if collapsed is not eg: raise collapsed from eg raise # pragma: lax no cover + async def send_request( self, request: SendRequestT, From db0dab7083c272edeb727a296e8576914457d4af Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 16:14:44 +0100 Subject: [PATCH 08/10] fix: resolve pyright strict-mode errors in test_exception_utils Add _get_exceptions() helper to provide typed access to BaseExceptionGroup.exceptions, avoiding reportUnknownMemberType errors. Use pyright: ignore[reportUnknownArgumentType] for the narrowed BaseExceptionGroup[Unknown] type after isinstance checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/shared/test_exception_utils.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/shared/test_exception_utils.py b/tests/shared/test_exception_utils.py index 51acaf368..e6a150659 100644 --- a/tests/shared/test_exception_utils.py +++ b/tests/shared/test_exception_utils.py @@ -4,6 +4,7 @@ import asyncio import sys +from collections.abc import Sequence import anyio import pytest @@ -14,6 +15,11 @@ from exceptiongroup import BaseExceptionGroup +def _get_exceptions(eg: BaseExceptionGroup[BaseException]) -> Sequence[BaseException]: + """Return the exceptions tuple with a known type for pyright.""" + return eg.exceptions + + # --------------------------------------------------------------------------- # collapse_exception_group() unit tests # --------------------------------------------------------------------------- @@ -51,9 +57,10 @@ def test_multiple_real_errors(self) -> None: result = collapse_exception_group(eg) assert isinstance(result, BaseExceptionGroup) # Should contain only the two real errors - assert len(result.exceptions) == 2 - assert e1 in result.exceptions - assert e2 in result.exceptions + excs = _get_exceptions(result) # pyright: ignore[reportUnknownArgumentType] + assert len(excs) == 2 + assert e1 in excs + assert e2 in excs def test_single_real_error_no_cancelled(self) -> None: """One real error, no Cancelled → unwrap to the real error.""" @@ -69,9 +76,10 @@ def test_multiple_real_errors_no_cancelled(self) -> None: eg = BaseExceptionGroup("task group", [e1, e2]) result = collapse_exception_group(eg) assert isinstance(result, BaseExceptionGroup) - assert len(result.exceptions) == 2 - assert e1 in result.exceptions - assert e2 in result.exceptions + excs = _get_exceptions(result) # pyright: ignore[reportUnknownArgumentType] + assert len(excs) == 2 + assert e1 in excs + assert e2 in excs # --------------------------------------------------------------------------- @@ -108,7 +116,8 @@ async def test_multiple_failures_preserved(self) -> None: # The group should contain both real errors eg = exc_info.value - types = {type(e) for e in eg.exceptions} + excs = _get_exceptions(eg) # pyright: ignore[reportUnknownArgumentType] + types = {type(e) for e in excs} assert ValueError in types assert RuntimeError in types From ed7649012f72fba0069c0afd9e8aef1615efac1d Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 16:20:50 +0100 Subject: [PATCH 09/10] fix: prefix unused 'cancelled' variable with underscore Resolves pyright reportUnusedVariable error. --- src/mcp/shared/_exception_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/shared/_exception_utils.py b/src/mcp/shared/_exception_utils.py index ee8deda4c..4a9f22742 100644 --- a/src/mcp/shared/_exception_utils.py +++ b/src/mcp/shared/_exception_utils.py @@ -46,7 +46,7 @@ def collapse_exception_group( # split(type) uses isinstance on leaf exceptions, NOT on the group. # Using split(lambda) is incorrect because the lambda would first be # called on the group object itself. - cancelled, non_cancelled = eg.split(cancelled_type) + _cancelled, non_cancelled = eg.split(cancelled_type) if non_cancelled is None: # Every exception is a cancellation – surface just one. From 234b2bfeb0437aa1fad935730052281a64fd6e8d Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 16:25:56 +0100 Subject: [PATCH 10/10] fix: rename _cancelled to _ to satisfy pyright reportUnusedVariable The split() return value for the cancelled subgroup is intentionally discarded. Use bare _ instead of _cancelled so pyright strict mode recognises it as an unused-by-design binding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp/shared/_exception_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/shared/_exception_utils.py b/src/mcp/shared/_exception_utils.py index 4a9f22742..47f790584 100644 --- a/src/mcp/shared/_exception_utils.py +++ b/src/mcp/shared/_exception_utils.py @@ -46,7 +46,7 @@ def collapse_exception_group( # split(type) uses isinstance on leaf exceptions, NOT on the group. # Using split(lambda) is incorrect because the lambda would first be # called on the group object itself. - _cancelled, non_cancelled = eg.split(cancelled_type) + _, non_cancelled = eg.split(cancelled_type) if non_cancelled is None: # Every exception is a cancellation – surface just one.