From 005926c10b79ea866c138ae7c4fce1c5fe25195e Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 14 Apr 2026 02:23:35 -0500 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20accept=20wildcard=20patterns=20in=20?= =?UTF-8?q?Accept=20header=20per=20RFC=207231=20=C2=A75.3.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mcp/server/streamable_http.py | 10 +++++++++- tests/shared/test_streamable_http.py | 14 ++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index f14201857..1989df10b 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -396,11 +396,19 @@ def _check_accept_headers(self, request: Request) -> tuple[bool, bool]: """Check if the request accepts the required media types. Supports wildcard media types per RFC 7231, section 5.3.2: + - Missing Accept header matches any media type - */* matches any media type - application/* matches any application/ subtype - text/* matches any text/ subtype """ - accept_header = request.headers.get("accept", "") + accept_header = request.headers.get("accept") + + # RFC 7231, Section 5.3.2: + # A request without any Accept header field implies that the user agent + # will accept any media type in response. + if not accept_header: + return True, True + accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")] has_wildcard = "*/*" in accept_types diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 3d5770fb6..eacec34a1 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -581,8 +581,7 @@ def test_accept_header_validation(basic_server: None, basic_server_url: str): headers={"Content-Type": "application/json"}, json={"jsonrpc": "2.0", "method": "initialize", "id": 1}, ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + assert response.status_code == 200 @pytest.mark.parametrize( @@ -613,8 +612,9 @@ def test_accept_header_wildcard(basic_server: None, basic_server_url: str, accep "accept_header", [ "text/html", - "application/*", - "text/*", + "text/html", + "image/*", + "audio/*", ], ) def test_accept_header_incompatible(basic_server: None, basic_server_url: str, accept_header: str): @@ -885,8 +885,7 @@ def test_json_response_missing_accept_header(json_response_server: None, json_se }, json=INIT_REQUEST, ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + assert response.status_code == 200 def test_json_response_incorrect_accept_header(json_response_server: None, json_server_url: str): @@ -1027,8 +1026,7 @@ def test_get_validation(basic_server: None, basic_server_url: str): }, stream=True, ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + assert response.status_code == 200 # Test with wrong Accept header response = requests.get( From a8f72c1afea8342f0b37ec360f14e90ed53cfa2b Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 14 Apr 2026 02:45:35 -0500 Subject: [PATCH 2/3] Fix #915: Resolve AnyIO cancellation scope RuntimeError in ClientSessionGroup on connection disconnects --- src/mcp/client/session_group.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 961021264..4c4856aed 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -165,10 +165,11 @@ async def __aexit__( if self._owns_exit_stack: await self._exit_stack.aclose() - # Concurrently close session stacks. - async with anyio.create_task_group() as tg: - for exit_stack in self._session_exit_stacks.values(): - tg.start_soon(exit_stack.aclose) + # Sequentially close session stacks to preserve AnyIO task contexts. + # Concurrent teardown spawns task groups that cross cancel scopes, leading + # to RuntimeError: Attempted to exit cancel scope in a different task. + for exit_stack in list(self._session_exit_stacks.values()): + await exit_stack.aclose() @property def sessions(self) -> list[mcp.ClientSession]: From 3da79fe272c44719b99f2089def91ccfc2a1880b Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Fri, 17 Apr 2026 01:14:18 -0500 Subject: [PATCH 3/3] fix: remove unused anyio import and unnecessary pragma: no cover annotations --- src/mcp/client/session_group.py | 1 - tests/server/test_streamable_http_manager.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 4c4856aed..e97e4de5d 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/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 47cfbf14a..c7426c087 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -119,7 +119,7 @@ async def mock_send(message: Message): "headers": [(b"content-type", b"application/json")], } - async def mock_receive(): # pragma: no cover + async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} # Trigger session creation @@ -178,7 +178,7 @@ async def mock_send(message: Message): "headers": [(b"content-type", b"application/json")], } - async def mock_receive(): # pragma: no cover + async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} # Trigger session creation @@ -357,7 +357,7 @@ async def mock_send(message: Message): "headers": [(b"content-type", b"application/json")], } - async def mock_receive(): # pragma: no cover + async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} await manager.handle_request(scope, mock_receive, mock_send)