Files
modelcontextprotocol--pytho…/tests/interaction/transports/_bridge.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

173 lines
7.6 KiB
Python

"""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
from mcp.shared._compat import resync_tracer
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)
await resync_tracer()
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,
)