user [email protected] usertags 1145925 python3.15 tags 1145925 patch thanks
Hi! While rebuilding the python related packages against the Python 3.15rc2 version I ran into this FTBFS [1]. To solve this, I had to backport 3 upstream commits: - Upstream commit ae3338f: Complete the FastMCP Settings model at import time (#3352) [2] - Upstream commit b3025f9: Run transport security tests in process instead of over sockets (#2764) [3] - Upstream commit ddb7b78: Verbatim commit message: Deflake the issue-1363 tests: wait for lifespan startup instead of sleeping (#2879) [4] This should make the tests run successfully. I've applied these fixes in the sandbox [5] to verify that python-mcp builds successfully, these should be applied in Debian to get the package back to a healthy state. Happy hacking, [1]: https://debusine.debian.net/debian/r-python-python3.15/artifact/4516818/ [2]: https://github.com/modelcontextprotocol/python-sdk/commit/ae3338f6b396378217921aeadeca10a68e273c2d [3]: https://github.com/modelcontextprotocol/python-sdk/commit/b3025f93b4ba63c6d0d04b1357947ecdb78eb0bd [4]: https://github.com/modelcontextprotocol/python-sdk/commit/ddb7b78ef4dbf54906a7cb1f7efbf880503baba9 [5]: https://debusine.debian.net/debian/r-python-python3.15/ -- "Can you imagine what I would do if I could do all I can?" -- Sun Tzu Saludos /\/\ /\ >< `/
From: Max <[email protected]> Date: Mon, 15 Jun 2026 16:50:57 +0100 Subject: Deflake the issue-1363 tests: wait for lifespan startup instead of sleeping (#2879) --- tests/issues/test_1363_race_condition_streamable_http.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) Index: python-mcp/tests/issues/test_1363_race_condition_streamable_http.py =================================================================== --- python-mcp.orig/tests/issues/test_1363_race_condition_streamable_http.py +++ python-mcp/tests/issues/test_1363_race_condition_streamable_http.py @@ -21,6 +21,7 @@ from collections.abc import AsyncGenerat from contextlib import asynccontextmanager import anyio +import anyio.to_thread import httpx import pytest from starlette.applications import Starlette @@ -68,6 +69,7 @@ class ServerThread(threading.Thread): super().__init__(daemon=True) self.app = app self._stop_event = threading.Event() + self._ready_event = threading.Event() def run(self) -> None: """Run the lifespan in a new event loop.""" @@ -78,12 +80,19 @@ class ServerThread(threading.Thread): lifespan_context = getattr(self.app.router, "lifespan_context", None) assert lifespan_context is not None # Tests always create apps with lifespan async with lifespan_context(self.app): + # Only signal readiness once lifespan startup has completed, i.e. the + # session manager's task group exists and requests can be handled. + self._ready_event.set() # Wait until stop is requested while not self._stop_event.is_set(): await anyio.sleep(0.1) anyio.run(run_lifespan) + def wait_ready(self, timeout: float = 5.0) -> None: + """Block until the lifespan has started; call from a worker thread, not the event loop.""" + assert self._ready_event.wait(timeout), "server thread did not start its lifespan in time" + def stop(self) -> None: """Signal the thread to stop.""" self._stop_event.set() @@ -134,8 +143,8 @@ async def test_race_condition_invalid_ac server_thread.start() try: - # Give the server thread a moment to start - await anyio.sleep(0.1) + # Wait for the server thread to enter the lifespan before sending requests + await anyio.to_thread.run_sync(server_thread.wait_ready) # Suppress WARNING logs (expected validation errors) and capture ERROR logs with caplog.at_level(logging.ERROR): @@ -206,8 +215,8 @@ async def test_race_condition_invalid_co server_thread.start() try: - # Give the server thread a moment to start - await anyio.sleep(0.1) + # Wait for the server thread to enter the lifespan before sending requests + await anyio.to_thread.run_sync(server_thread.wait_ready) # Suppress WARNING logs (expected validation errors) and capture ERROR logs with caplog.at_level(logging.ERROR): @@ -247,8 +256,8 @@ async def test_race_condition_message_ro server_thread.start() try: - # Give the server thread a moment to start - await anyio.sleep(0.1) + # Wait for the server thread to enter the lifespan before sending requests + await anyio.to_thread.run_sync(server_thread.wait_ready) # Suppress WARNING logs (expected validation errors) and capture ERROR logs with caplog.at_level(logging.ERROR):
From: Max <[email protected]> Date: Tue, 2 Jun 2026 20:30:17 +0100 Subject: Run transport security tests in process instead of over sockets (#2764) Index: python-mcp/tests/server/test_streamable_http_security.py =================================================================== --- python-mcp.orig/tests/server/test_streamable_http_security.py +++ python-mcp/tests/server/test_streamable_http_security.py @@ -1,293 +1,130 @@ """Tests for StreamableHTTP server DNS rebinding protection.""" -import logging -import multiprocessing -import socket -from collections.abc import AsyncGenerator +from collections.abc import AsyncIterator from contextlib import asynccontextmanager import httpx import pytest -import uvicorn from starlette.applications import Starlette from starlette.routing import Mount -from starlette.types import Receive, Scope, Send from mcp.server import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings -from mcp.types import Tool -from tests.test_helpers import wait_for_server +from tests.interaction.transports import StreamingASGITransport -logger = logging.getLogger(__name__) SERVER_NAME = "test_streamable_http_security_server" +# The in-process app is mounted at this origin purely so URLs are well-formed and the default +# Host header is a localhost form; nothing listens here. +BASE_URL = "http://127.0.0.1:8000" [email protected] -def server_port() -> int: - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] +@asynccontextmanager +async def streamable_http_security_client( + security_settings: TransportSecuritySettings | None = None, +) -> AsyncIterator[httpx.AsyncClient]: + """Yield an httpx client served in process by a StreamableHTTP app with the given settings.""" + session_manager = StreamableHTTPSessionManager(app=Server(SERVER_NAME), security_settings=security_settings) + app = Starlette(routes=[Mount("/", app=session_manager.handle_request)]) [email protected] -def server_url(server_port: int) -> str: # pragma: no cover - return f"http://127.0.0.1:{server_port}" + async with session_manager.run(): + async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as client: + yield client -class SecurityTestServer(Server): # pragma: no cover - def __init__(self): - super().__init__(SERVER_NAME) +def _base_headers() -> dict[str, str]: + """Headers every well-formed request carries, so each test varies only the header under test.""" + return {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} - async def on_list_tools(self) -> list[Tool]: - return [] - -def run_server_with_settings(port: int, security_settings: TransportSecuritySettings | None = None): # pragma: no cover - """Run the StreamableHTTP server with specified security settings.""" - app = SecurityTestServer() - - # Create session manager with security settings - session_manager = StreamableHTTPSessionManager( - app=app, - json_response=False, - stateless=False, - security_settings=security_settings, - ) - - # Create the ASGI handler - async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: - await session_manager.handle_request(scope, receive, send) - - # Create Starlette app with lifespan - @asynccontextmanager - async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: - async with session_manager.run(): - yield - - routes = [ - Mount("/", app=handle_streamable_http), - ] - - starlette_app = Starlette(routes=routes, lifespan=lifespan) - uvicorn.run(starlette_app, host="127.0.0.1", port=port, log_level="error") - - -def start_server_process(port: int, security_settings: TransportSecuritySettings | None = None): - """Start server in a separate process.""" - process = multiprocessing.Process(target=run_server_with_settings, args=(port, security_settings)) - process.start() - # Wait for server to be ready to accept connections - wait_for_server(port) - return process +def _initialize_body() -> dict[str, object]: + """A minimal initialize POST body; these tests assert header validation, not the handshake.""" + return {"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}} @pytest.mark.anyio -async def test_streamable_http_security_default_settings(server_port: int): - """Test StreamableHTTP with default security settings (protection enabled).""" - process = start_server_process(server_port) - - try: - # Test with valid localhost headers - async with httpx.AsyncClient(timeout=5.0) as client: - # POST request to initialize session - response = await client.post( - f"http://127.0.0.1:{server_port}/", - json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - ) - assert response.status_code == 200 - assert "mcp-session-id" in response.headers - - finally: - process.terminate() - process.join() +async def test_streamable_http_security_default_settings() -> None: + """With default security settings, a request with localhost headers is served.""" + async with streamable_http_security_client() as client: + response = await client.post("/", json=_initialize_body(), headers=_base_headers()) + assert response.status_code == 200 + assert "mcp-session-id" in response.headers @pytest.mark.anyio -async def test_streamable_http_security_invalid_host_header(server_port: int): - """Test StreamableHTTP with invalid Host header.""" +async def test_streamable_http_security_invalid_host_header() -> None: + """A Host header outside allowed_hosts is rejected with 421.""" security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True) - process = start_server_process(server_port, security_settings) - try: - # Test with invalid host header - headers = { - "Host": "evil.com", - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - } - - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.post( - f"http://127.0.0.1:{server_port}/", - json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, - headers=headers, - ) - assert response.status_code == 421 - assert response.text == "Invalid Host header" - - finally: - process.terminate() - process.join() + async with streamable_http_security_client(security_settings) as client: + response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "evil.com"}) + assert response.status_code == 421 + assert response.text == "Invalid Host header" @pytest.mark.anyio -async def test_streamable_http_security_invalid_origin_header(server_port: int): - """Test StreamableHTTP with invalid Origin header.""" +async def test_streamable_http_security_invalid_origin_header() -> None: + """An Origin header outside allowed_origins is rejected with 403.""" security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"]) - process = start_server_process(server_port, security_settings) - try: - # Test with invalid origin header - headers = { - "Origin": "http://evil.com", - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - } - - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.post( - f"http://127.0.0.1:{server_port}/", - json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, - headers=headers, - ) - assert response.status_code == 403 - assert response.text == "Invalid Origin header" - - finally: - process.terminate() - process.join() + async with streamable_http_security_client(security_settings) as client: + response = await client.post( + "/", json=_initialize_body(), headers=_base_headers() | {"Origin": "http://evil.com"} + ) + assert response.status_code == 403 + assert response.text == "Invalid Origin header" @pytest.mark.anyio -async def test_streamable_http_security_invalid_content_type(server_port: int): - """Test StreamableHTTP POST with invalid Content-Type header.""" - process = start_server_process(server_port) - - try: - async with httpx.AsyncClient(timeout=5.0) as client: - # Test POST with invalid content type - response = await client.post( - f"http://127.0.0.1:{server_port}/", - headers={ - "Content-Type": "text/plain", - "Accept": "application/json, text/event-stream", - }, - content="test", - ) - assert response.status_code == 400 - assert response.text == "Invalid Content-Type header" - - # Test POST with missing content type - response = await client.post( - f"http://127.0.0.1:{server_port}/", - headers={"Accept": "application/json, text/event-stream"}, - content="test", - ) - assert response.status_code == 400 - assert response.text == "Invalid Content-Type header" - - finally: - process.terminate() - process.join() +async def test_streamable_http_security_invalid_content_type() -> None: + """A POST whose Content-Type is not application/json (or is missing) is rejected with 400.""" + async with streamable_http_security_client() as client: + response = await client.post("/", headers=_base_headers() | {"Content-Type": "text/plain"}, content="test") + assert response.status_code == 400 + assert response.text == "Invalid Content-Type header" + + response = await client.post("/", headers={"Accept": "application/json, text/event-stream"}, content="test") + assert response.status_code == 400 + assert response.text == "Invalid Content-Type header" @pytest.mark.anyio -async def test_streamable_http_security_disabled(server_port: int): - """Test StreamableHTTP with security disabled.""" +async def test_streamable_http_security_disabled() -> None: + """With protection explicitly disabled, a disallowed Host is still served.""" settings = TransportSecuritySettings(enable_dns_rebinding_protection=False) - process = start_server_process(server_port, settings) - try: - # Test with invalid host header - should still work - headers = { - "Host": "evil.com", - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - } - - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.post( - f"http://127.0.0.1:{server_port}/", - json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, - headers=headers, - ) - # Should connect successfully even with invalid host - assert response.status_code == 200 - - finally: - process.terminate() - process.join() + async with streamable_http_security_client(settings) as client: + response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "evil.com"}) + assert response.status_code == 200 @pytest.mark.anyio -async def test_streamable_http_security_custom_allowed_hosts(server_port: int): - """Test StreamableHTTP with custom allowed hosts.""" +async def test_streamable_http_security_custom_allowed_hosts() -> None: + """A custom entry in allowed_hosts is served.""" settings = TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=["localhost", "127.0.0.1", "custom.host"], allowed_origins=["http://localhost", "http://127.0.0.1", "http://custom.host"], ) - process = start_server_process(server_port, settings) - try: - # Test with custom allowed host - headers = { - "Host": "custom.host", - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - } - - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.post( - f"http://127.0.0.1:{server_port}/", - json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, - headers=headers, - ) - # Should connect successfully with custom host - assert response.status_code == 200 - finally: - process.terminate() - process.join() + async with streamable_http_security_client(settings) as client: + response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "custom.host"}) + assert response.status_code == 200 @pytest.mark.anyio -async def test_streamable_http_security_get_request(server_port: int): - """Test StreamableHTTP GET request with security.""" +async def test_streamable_http_security_get_request() -> None: + """GET requests pass the same Host validation before any session handling.""" security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1"]) - process = start_server_process(server_port, security_settings) - try: - # Test GET request with invalid host header - headers = { - "Host": "evil.com", - "Accept": "text/event-stream", - } - - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(f"http://127.0.0.1:{server_port}/", headers=headers) - assert response.status_code == 421 - assert response.text == "Invalid Host header" - - # Test GET request with valid host header - headers = { - "Host": "127.0.0.1", - "Accept": "text/event-stream", - } - - async with httpx.AsyncClient(timeout=5.0) as client: - # GET requests need a session ID in StreamableHTTP - # So it will fail with "Missing session ID" not security error - response = await client.get(f"http://127.0.0.1:{server_port}/", headers=headers) - # This should pass security but fail on session validation - assert response.status_code == 400 - body = response.json() - assert "Missing session ID" in body["error"]["message"] - - finally: - process.terminate() - process.join() + async with streamable_http_security_client(security_settings) as client: + response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "evil.com"}) + assert response.status_code == 421 + assert response.text == "Invalid Host header" + + response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "127.0.0.1"}) + # An allowed host passes security and fails on session validation instead. + assert response.status_code == 400 + body = response.json() + assert "Missing session ID" in body["error"]["message"] Index: python-mcp/src/mcp/server/session.py =================================================================== --- python-mcp.orig/src/mcp/server/session.py +++ python-mcp/src/mcp/server/session.py @@ -103,6 +103,7 @@ class ServerSession( ServerRequestResponder ](0) self._exit_stack.push_async_callback(lambda: self._incoming_message_stream_reader.aclose()) + self._exit_stack.push_async_callback(lambda: self._incoming_message_stream_writer.aclose()) @property def client_params(self) -> types.InitializeRequestParams | None: Index: python-mcp/src/mcp/server/streamable_http.py =================================================================== --- python-mcp.orig/src/mcp/server/streamable_http.py +++ python-mcp/src/mcp/server/streamable_http.py @@ -391,8 +391,9 @@ class StreamableHTTPServerTransport: if request_id in self._request_streams: try: # Close the request stream - await self._request_streams[request_id][0].aclose() - await self._request_streams[request_id][1].aclose() + with anyio.CancelScope(shield=True): + await self._request_streams[request_id][0].aclose() + await self._request_streams[request_id][1].aclose() except Exception: # During cleanup, we catch all exceptions since streams might be in various states logger.debug("Error closing memory streams - may already be closed") @@ -642,9 +643,11 @@ class StreamableHTTPServerTransport: await writer.send(session_message) except Exception: logger.exception("SSE response error") - await sse_stream_writer.aclose() - await sse_stream_reader.aclose() - await self._clean_up_memory_streams(request_id) + finally: + with anyio.CancelScope(shield=True): + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + await self._clean_up_memory_streams(request_id) except Exception as err: logger.exception("Error handling POST request") @@ -747,9 +750,11 @@ class StreamableHTTPServerTransport: await response(request.scope, request.receive, send) except Exception: logger.exception("Error in standalone SSE response") - await sse_stream_writer.aclose() - await sse_stream_reader.aclose() - await self._clean_up_memory_streams(GET_STREAM_KEY) + finally: + with anyio.CancelScope(shield=True): + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + await self._clean_up_memory_streams(GET_STREAM_KEY) async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover """Handle DELETE requests for explicit session termination.""" @@ -791,23 +796,24 @@ class StreamableHTTPServerTransport: request_stream_keys = list(self._request_streams.keys()) # Close all request streams asynchronously - for key in request_stream_keys: # pragma: no cover - await self._clean_up_memory_streams(key) + with anyio.CancelScope(shield=True): + for key in request_stream_keys: # pragma: no cover + await self._clean_up_memory_streams(key) - # Clear the request streams dictionary immediately - self._request_streams.clear() - try: - if self._read_stream_writer is not None: # pragma: no branch - await self._read_stream_writer.aclose() - if self._read_stream is not None: # pragma: no branch - await self._read_stream.aclose() - if self._write_stream_reader is not None: # pragma: no branch - await self._write_stream_reader.aclose() - if self._write_stream is not None: # pragma: no branch - await self._write_stream.aclose() - except Exception as e: # pragma: no cover - # During cleanup, we catch all exceptions since streams might be in various states - logger.debug(f"Error closing streams: {e}") + # Clear the request streams dictionary immediately + self._request_streams.clear() + try: + if self._read_stream_writer is not None: # pragma: no branch + await self._read_stream_writer.aclose() + if self._read_stream is not None: # pragma: no branch + await self._read_stream.aclose() + if self._write_stream_reader is not None: # pragma: no branch + await self._write_stream_reader.aclose() + if self._write_stream is not None: # pragma: no branch + await self._write_stream.aclose() + except Exception as e: # pragma: no cover + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug(f"Error closing streams: {e}") async def _handle_unsupported_request(self, request: Request, send: Send) -> None: # pragma: no cover """Handle unsupported HTTP methods.""" @@ -1071,16 +1077,17 @@ class StreamableHTTPServerTransport: # Yield the streams for the caller to use yield read_stream, write_stream finally: - for stream_id in list(self._request_streams.keys()): # pragma: no cover - await self._clean_up_memory_streams(stream_id) - self._request_streams.clear() - - # Clean up the read and write streams - try: - await read_stream_writer.aclose() - await read_stream.aclose() - await write_stream_reader.aclose() - await write_stream.aclose() - except Exception as e: # pragma: no cover - # During cleanup, we catch all exceptions since streams might be in various states - logger.debug(f"Error closing streams: {e}") + with anyio.CancelScope(shield=True): + for stream_id in list(self._request_streams.keys()): # pragma: no cover + await self._clean_up_memory_streams(stream_id) + self._request_streams.clear() + + # Clean up the read and write streams + try: + await read_stream_writer.aclose() + await read_stream.aclose() + await write_stream_reader.aclose() + await write_stream.aclose() + except Exception as e: # pragma: no cover + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug(f"Error closing streams: {e}") Index: python-mcp/tests/interaction/transports/__init__.py =================================================================== --- /dev/null +++ python-mcp/tests/interaction/transports/__init__.py @@ -0,0 +1,9 @@ +"""Transport-specific interaction tests, and the in-process streaming bridge they are built on. + +`StreamingASGITransport` is re-exported here as the sanctioned import point for test code +outside this suite (the bridge module itself is suite-private). +""" + +from tests.interaction.transports._bridge import StreamingASGITransport + +__all__ = ["StreamingASGITransport"] Index: python-mcp/tests/interaction/transports/_bridge.py =================================================================== --- /dev/null +++ python-mcp/tests/interaction/transports/_bridge.py @@ -0,0 +1,169 @@ +"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx. + +`httpx.ASGITransport` runs the application to completion and only then hands the buffered +response to the caller, so a server that streams its response — the streamable HTTP transport's +SSE responses — can never converse with the client mid-request: a server-initiated request +nested inside a still-open call deadlocks. `StreamingASGITransport` removes that limitation by +running the application as a background task and forwarding every `http.response.body` chunk to +the client the moment it is sent. Everything happens on the one event loop: no sockets, no +threads, no sleeps, no extra dependencies. + +The behavioural contract, pinned by `test_bridge.py`: + +- The request body is buffered before the application is invoked (MCP requests are small JSON + documents); the response streams chunk by chunk. +- Closing the response — or the whole client — delivers `http.disconnect` to the application, + exactly as a real server sees when its peer goes away. +- An exception the application raises before sending `http.response.start` fails the originating + request with that same exception. After the response has started, a failure is visible to the + client only through the response itself (status code, truncated body) — the same signal a real + server over a real socket would give. + +The transport owns an anyio task group for the application tasks; it is opened and closed by +`httpx.AsyncClient`'s own context manager, so use the client as a context manager (the suite +always does). Closing the transport cancels every running application task by default; set +`cancel_on_close=False` to wait for the application's own disconnect handling instead. +""" + +import math +from collections.abc import AsyncIterator +from types import TracebackType + +import anyio +import anyio.abc +import httpx +from anyio.streams.memory import MemoryObjectReceiveStream +from starlette.types import ASGIApp, Message, Scope + + +class _StreamingResponseBody(httpx.AsyncByteStream): + """A response body that yields chunks as the application produces them. + + Closing it tells the application the client has gone away (`http.disconnect`), mirroring a + peer that drops the connection mid-response. + """ + + def __init__(self, chunks: MemoryObjectReceiveStream[bytes], client_disconnected: anyio.Event) -> None: + self._chunks = chunks + self._client_disconnected = client_disconnected + + async def __aiter__(self) -> AsyncIterator[bytes]: + async for chunk in self._chunks: + yield chunk + + async def aclose(self) -> None: + self._client_disconnected.set() + await self._chunks.aclose() + + +class StreamingASGITransport(httpx.AsyncBaseTransport): + """Drive an ASGI application in-process, streaming each response as it is produced. + + With `cancel_on_close` (the default), closing the transport cancels every application task + still running so harness teardown can never hang. Setting it to False makes the transport wait + for the application's own disconnect handling to complete instead, which is the path the legacy + SSE server transport relies on for resource cleanup. + """ + + _task_group: anyio.abc.TaskGroup + + def __init__(self, app: ASGIApp, *, cancel_on_close: bool = True) -> None: + self._app = app + self._cancel_on_close = cancel_on_close + + async def __aenter__(self) -> "StreamingASGITransport": + self._task_group = anyio.create_task_group() + await self._task_group.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: + # httpx closes every streamed response before closing the transport, so by now each + # application task has been delivered `http.disconnect`. Either cancel immediately, or wait + # for the application's own disconnect handling to unwind. + if self._cancel_on_close: + self._task_group.cancel_scope.cancel() + await self._task_group.__aexit__(exc_type, exc_value, traceback) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + assert isinstance(request.stream, httpx.AsyncByteStream) + request_body = b"".join([chunk async for chunk in request.stream]) + + scope: Scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": request.method, + "scheme": request.url.scheme, + "path": request.url.path, + "raw_path": request.url.raw_path.split(b"?", maxsplit=1)[0], + "query_string": request.url.query, + "root_path": "", + "headers": [(name.lower(), value) for name, value in request.headers.raw], + "server": (request.url.host, request.url.port), + "client": ("127.0.0.1", 1234), + } + + request_delivered = False + client_disconnected = anyio.Event() + response_started = anyio.Event() + response_status = 0 + response_headers: list[tuple[bytes, bytes]] = [] + application_error: Exception | None = None + chunk_writer, chunk_reader = anyio.create_memory_object_stream[bytes](math.inf) + + async def receive_request() -> Message: + nonlocal request_delivered + if not request_delivered: + request_delivered = True + return {"type": "http.request", "body": request_body, "more_body": False} + await client_disconnected.wait() + return {"type": "http.disconnect"} + + async def send_response(message: Message) -> None: + nonlocal response_status, response_headers + if message["type"] == "http.response.start": + response_status = message["status"] + response_headers = list(message.get("headers", [])) + response_started.set() + return + assert message["type"] == "http.response.body" + body: bytes = message.get("body", b"") + if body: + await chunk_writer.send(body) + if not message.get("more_body", False): + await chunk_writer.aclose() + + async def run_application() -> None: + nonlocal application_error + try: + await self._app(scope, receive_request, send_response) + except Exception as exc: # The bridge is the application's outermost boundary: a crash + # must fail the originating request (or show up in the already-started response), + # never tear down the task group shared with every other in-flight request. + application_error = exc + finally: + response_started.set() + await chunk_writer.aclose() + + self._task_group.start_soon(run_application) + try: + await response_started.wait() + if application_error is not None: + raise application_error + except BaseException: + # No response will be built, so close the reader the response body would have owned + # and tell the application its peer has gone away. + client_disconnected.set() + await chunk_reader.aclose() + raise + return httpx.Response( + status_code=response_status, + headers=response_headers, + stream=_StreamingResponseBody(chunk_reader, client_disconnected), + request=request, + )
From: Max <[email protected]> Date: Fri, 21 Aug 2026 12:02:46 +0100 Subject: [v1.x] Complete the FastMCP Settings model at import time (#3352) --- src/mcp/server/fastmcp/server.py | 5 +++++ tests/server/fastmcp/test_server.py | 6 ++++++ 2 files changed, 11 insertions(+) Index: python-mcp/src/mcp/server/fastmcp/server.py =================================================================== --- python-mcp.orig/src/mcp/server/fastmcp/server.py +++ python-mcp/src/mcp/server/fastmcp/server.py @@ -1083,6 +1083,11 @@ class FastMCP(Generic[LifespanResultT]): raise ValueError(str(e)) +# `Settings.lifespan` refers to FastMCP, which is only defined above; complete the model now so +# settings sources never see an unresolved annotation when a FastMCP instance is created. +Settings.model_rebuild() + + class StreamableHTTPASGIApp: """ ASGI application for Streamable HTTP server transport. Index: python-mcp/tests/server/fastmcp/test_server.py =================================================================== --- python-mcp.orig/tests/server/fastmcp/test_server.py +++ python-mcp/tests/server/fastmcp/test_server.py @@ -10,6 +10,7 @@ from starlette.routing import Mount, Rou from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.prompts.base import Message, UserMessage from mcp.server.fastmcp.resources import FileResource, FunctionResource +from mcp.server.fastmcp.server import Settings from mcp.server.fastmcp.utilities.types import Audio, Image from mcp.server.session import ServerSession from mcp.server.transport_security import TransportSecuritySettings @@ -31,6 +32,11 @@ if TYPE_CHECKING: from mcp.server.fastmcp import Context +def test_settings_model_is_complete_at_import(): + """The Settings model resolves its FastMCP annotation at import, so building one needs no deferred rebuild.""" + assert Settings.__pydantic_complete__ + + class TestServer: @pytest.mark.anyio async def test_create_server(self):

