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) Waiting to run
173 lines
7.6 KiB
Python
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,
|
|
)
|