66mapping in ``handle()``, and the request-validation ladder in ``handle_modern_request``.
77"""
88
9+ import logging
910from collections .abc import Mapping
1011from typing import Any
1112
13+ import anyio
1214import httpx
1315import pytest
1416from starlette .requests import Request
1517from starlette .types import Receive , Scope , Send
1618
17- from mcp .server import Server
19+ import mcp .server ._experimental .streamable_http_modern as modern
20+ from mcp .server import Server , ServerRequestContext
1821from mcp .server ._experimental .streamable_http_modern import (
1922 SingleExchangeDispatcher ,
2023 _SingleExchangeDispatchContext ,
2427from mcp .shared .dispatcher import DispatchContext
2528from mcp .shared .exceptions import NoBackChannelError
2629from mcp .shared .transport_context import TransportContext
27- from mcp .types import INVALID_PARAMS , PARSE_ERROR , JSONRPCError , JSONRPCRequest
30+ from mcp .types import INVALID_PARAMS , PARSE_ERROR , JSONRPCError , JSONRPCRequest , ListToolsResult , PaginatedRequestParams
2831
2932pytestmark = pytest .mark .anyio
3033
@@ -98,6 +101,7 @@ async def test_handle_modern_request_rejects_non_post_with_405() -> None:
98101 async with _asgi_client (Server ("test" )) as http :
99102 response = await http .get ("/mcp" )
100103 assert response .status_code == 405
104+ assert response .headers ["allow" ] == "POST"
101105
102106
103107async def test_handle_modern_request_rejects_malformed_body_with_parse_error () -> None :
@@ -106,7 +110,11 @@ async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -
106110 response = await http .post ("/mcp" , content = b"not json" , headers = {"content-type" : "application/json" })
107111 assert response .status_code == 400
108112 assert response .headers ["content-type" ].split (";" , 1 )[0 ] == "application/json"
109- assert response .json () == {"jsonrpc" : "2.0" , "error" : {"code" : PARSE_ERROR , "message" : "Parse error" }}
113+ assert response .json () == {
114+ "jsonrpc" : "2.0" ,
115+ "id" : None ,
116+ "error" : {"code" : PARSE_ERROR , "message" : "Parse error" , "data" : None },
117+ }
110118
111119
112120async def test_handle_modern_request_returns_transport_security_error_response () -> None :
@@ -116,3 +124,66 @@ async def test_handle_modern_request_returns_transport_security_error_response()
116124 response = await http .post ("/mcp" , json = {}, headers = {"content-type" : "application/json" })
117125 assert response .status_code == 421
118126 assert response .text == "Invalid Host header"
127+
128+
129+ def _list_tools_body () -> dict [str , Any ]:
130+ """A minimal valid 2026-07-28 ``tools/list`` request body, including the required ``_meta`` envelope."""
131+ meta = {
132+ "io.modelcontextprotocol/protocolVersion" : "2026-07-28" ,
133+ "io.modelcontextprotocol/clientInfo" : {"name" : "raw" , "version" : "0.0.0" },
134+ "io.modelcontextprotocol/clientCapabilities" : {},
135+ }
136+ return {"jsonrpc" : "2.0" , "id" : 1 , "method" : "tools/list" , "params" : {"_meta" : meta }}
137+
138+
139+ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_raises (
140+ caplog : pytest .LogCaptureFixture ,
141+ ) -> None :
142+ """A raising ``connection.exit_stack`` callback is logged and swallowed; the computed result still ships.
143+
144+ The exit-stack guard mirrors ``ServerRunner.run``: cleanup runs in a ``finally`` after the
145+ handler, and an exception there must not displace the JSON-RPC response that was already built.
146+ """
147+
148+ async def boom () -> None :
149+ raise RuntimeError ("cleanup failed" )
150+
151+ async def list_tools (ctx : ServerRequestContext , params : PaginatedRequestParams | None ) -> ListToolsResult :
152+ ctx .session ._connection .exit_stack .push_async_callback (boom )
153+ return ListToolsResult (tools = [], ttl_ms = 0 , cache_scope = "public" )
154+
155+ with caplog .at_level (logging .ERROR , logger = modern .__name__ ):
156+ async with _asgi_client (Server ("test" , on_list_tools = list_tools )) as http :
157+ response = await http .post ("/mcp" , json = _list_tools_body (), headers = {"content-type" : "application/json" })
158+
159+ assert response .status_code == 200
160+ assert response .json ()["result" ]["tools" ] == []
161+ assert "connection exit_stack cleanup raised" in caplog .text
162+
163+
164+ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hangs (
165+ monkeypatch : pytest .MonkeyPatch , caplog : pytest .LogCaptureFixture
166+ ) -> None :
167+ """A blocking ``connection.exit_stack`` callback is abandoned at the grace deadline; the response still ships.
168+
169+ Grace patched to 0 so the deadline is already expired on entry: the bounded unwind cancels the
170+ blocker at its first checkpoint, the abandonment warning is logged, and the JSON-RPC response
171+ that was built before cleanup is sent unchanged.
172+ """
173+ monkeypatch .setattr (modern , "_EXIT_STACK_CLOSE_TIMEOUT" , 0 )
174+
175+ async def block () -> None :
176+ await anyio .Event ().wait ()
177+ raise AssertionError ("unreachable" ) # pragma: no cover
178+
179+ async def list_tools (ctx : ServerRequestContext , params : PaginatedRequestParams | None ) -> ListToolsResult :
180+ ctx .session ._connection .exit_stack .push_async_callback (block )
181+ return ListToolsResult (tools = [], ttl_ms = 0 , cache_scope = "public" )
182+
183+ with anyio .fail_after (5 ), caplog .at_level (logging .WARNING , logger = modern .__name__ ):
184+ async with _asgi_client (Server ("test" , on_list_tools = list_tools )) as http :
185+ response = await http .post ("/mcp" , json = _list_tools_body (), headers = {"content-type" : "application/json" })
186+
187+ assert response .status_code == 200
188+ assert response .json ()["result" ]["tools" ] == []
189+ assert "abandoning remaining callbacks" in caplog .text
0 commit comments