"""In-process stdio transport for Litestar MCP applications."""
import asyncio
import contextlib
import logging
import math
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, TypeVar, cast
import anyio
import httpx
from litestar_mcp.__metadata__ import __version__
from litestar_mcp.mcp.bridge import (
BEARER_TOKEN_PREFIX,
DEFAULT_AUTH_HEADER_NAME,
DEFAULT_MAX_STDIN_MESSAGE_SIZE,
TokenProvider,
run_stdio_streamable_http_bridge,
)
from litestar_mcp.mcp.routes import MCP_OWNER_ID_SCOPE_KEY
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable
from anyio.abc import ByteReceiveStream, ByteSendStream
from litestar import Litestar
from litestar.types import ASGIApp
__all__ = ("ASGIStreamingTransport", "MCPStdioContext", "run_stdio", "run_stdio_async")
_DEFAULT_CLIENT = ("mcp-stdio", 0)
_T = TypeVar("_T")
_logger = logging.getLogger(__name__)
[docs]
@dataclass(frozen=True, slots=True)
class MCPStdioContext:
"""Runtime identity context for standalone MCP stdio transports."""
client_id: "str" = "stdio"
owner_id: "str | None" = None
user: "Any" = None
auth: "Any" = None
session: "Mapping[str, Any] | None" = None
state: "Mapping[str, Any] | None" = None
class _ASGIResponseState:
__slots__ = ("closed", "disconnected", "headers", "receiver", "sender", "started", "status_code")
def __init__(self) -> None:
self.status_code: int | None = None
self.headers: list[tuple[bytes, bytes]] = []
self.sender, self.receiver = anyio.create_memory_object_stream[bytes](1)
self.closed = False
self.started = asyncio.Event()
self.disconnected = asyncio.Event()
async def _wait(awaitable: "Awaitable[_T]", read_timeout: "float | None", request: "httpx.Request") -> "_T":
if read_timeout is None:
return await awaitable
try:
return await asyncio.wait_for(awaitable, read_timeout)
except asyncio.TimeoutError as exc:
msg = "In-process ASGI response timed out"
raise httpx.ReadTimeout(msg, request=request) from exc
class _ASGIResponseStream(httpx.AsyncByteStream):
def __init__(
self,
state: "_ASGIResponseState",
task: "asyncio.Task[None]",
*,
read_timeout: "float | None",
shutdown_timeout: "float",
request: "httpx.Request",
) -> None:
self._state = state
self._task = task
self._read_timeout = read_timeout
self._shutdown_timeout = shutdown_timeout
self._request = request
async def __aiter__(self) -> "AsyncIterator[bytes]":
while True:
try:
chunk = await _wait(self._state.receiver.receive(), self._read_timeout, self._request)
except anyio.EndOfStream:
break
yield chunk
await asyncio.shield(self._task)
async def aclose(self) -> None:
await _shutdown_app_task(self._state, self._task, self._shutdown_timeout)
def _retrieve_app_exception(task: "asyncio.Task[None]") -> None:
if not task.cancelled() and (error := task.exception()) is not None:
_logger.error("ASGI application failed after incomplete cleanup", exc_info=error)
async def _shutdown_app_task(
state: "_ASGIResponseState", task: "asyncio.Task[None]", shutdown_timeout: "float"
) -> None:
if state.closed:
return
state.closed = True
state.receiver.close()
state.disconnected.set()
if not task.done():
task.cancel()
deadline = asyncio.get_running_loop().time() + shutdown_timeout
cancelled = False
with anyio.CancelScope(shield=True):
while not task.done():
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
_logger.warning("ASGI response cleanup incomplete after %s seconds", shutdown_timeout)
task.cancel()
task.add_done_callback(_retrieve_app_exception)
break
try:
await asyncio.wait({task}, timeout=remaining)
except asyncio.CancelledError:
cancelled = True
if task.done():
with contextlib.suppress(asyncio.CancelledError):
task.result()
if cancelled:
raise asyncio.CancelledError
def _build_scope(request: "httpx.Request", client: "tuple[str, int]", root_path: "str") -> "dict[str, Any]":
server_port = request.url.port or {"http": 80, "https": 443}.get(request.url.scheme)
return {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.4"},
"http_version": "1.1",
"method": request.method,
"headers": [(key.lower(), value) for key, value in request.headers.raw],
"scheme": request.url.scheme,
"path": request.url.path,
"raw_path": request.url.raw_path.split(b"?")[0],
"query_string": request.url.query,
"server": (request.url.host, server_port),
"client": client,
"root_path": root_path,
"extensions": {},
}
[docs]
class ASGIStreamingTransport(httpx.AsyncBaseTransport):
"""Run an ASGI application in-process and stream its response body to httpx.
``httpx.ASGITransport`` buffers every ``http.response.body`` chunk until
the application returns, so Server-Sent Events never reach the client
while the stream is open. This transport returns the response as soon as
``http.response.start`` arrives, hands each body chunk to the response
stream as it is sent, and cancels the application task when the response
is closed, or when the caller is cancelled before ``http.response.start``
arrives, so an aborted request observes ``http.disconnect`` and task
cancellation. The httpx ``read`` timeout bounds the wait for
``http.response.start`` and for each body chunk (``httpx.ReadTimeout``).
Args:
app: The ASGI application to call for every request.
client: The ``scope["client"]`` tuple presented to the application.
root_path: The ``scope["root_path"]`` presented to the application.
shutdown_timeout: Positive finite seconds allowed for application cleanup.
Expiry is logged; cancellation-resistant application code may outlive
the response.
"""
[docs]
def __init__(
self,
app: "ASGIApp",
*,
client: "tuple[str, int]" = _DEFAULT_CLIENT,
root_path: "str" = "",
shutdown_timeout: "float" = 5.0,
) -> None:
if not math.isfinite(shutdown_timeout) or shutdown_timeout <= 0:
msg = "shutdown_timeout must be positive and finite"
raise ValueError(msg)
self._app = app
self._client = client
self._root_path = root_path
self._shutdown_timeout = shutdown_timeout
[docs]
async def handle_async_request(self, request: "httpx.Request") -> "httpx.Response":
"""Dispatch ``request`` to the application and return a streaming response."""
request_stream = cast("httpx.AsyncByteStream", request.stream)
scope = _build_scope(request, self._client, self._root_path)
state = _ASGIResponseState()
request_chunks = request_stream.__aiter__()
request_complete = False
async def receive() -> "dict[str, Any]":
nonlocal request_complete
if request_complete:
await state.disconnected.wait()
return {"type": "http.disconnect"}
try:
body = await request_chunks.__anext__()
except StopAsyncIteration:
request_complete = True
return {"type": "http.request", "body": b"", "more_body": False}
return {"type": "http.request", "body": body, "more_body": True}
async def send(message: "Mapping[str, Any]") -> None:
message_type = message["type"]
if message_type == "http.response.start":
state.status_code = int(message["status"])
state.headers = [(bytes(key), bytes(value)) for key, value in message.get("headers", [])]
state.started.set()
elif message_type == "http.response.body":
body = message.get("body", b"")
if body:
try:
await state.sender.send(body)
except (anyio.BrokenResourceError, anyio.ClosedResourceError) as exc:
if state.closed:
raise asyncio.CancelledError from exc
raise
if not message.get("more_body", False):
state.sender.close()
async def run_app() -> None:
try:
await self._app(cast("Any", scope), cast("Any", receive), cast("Any", send))
finally:
state.started.set()
state.sender.close()
read_timeout = cast("float | None", request.extensions.get("timeout", {}).get("read"))
task = asyncio.create_task(run_app())
try:
await _wait(state.started.wait(), read_timeout, request)
except BaseException:
await _shutdown_app_task(state, task, self._shutdown_timeout)
raise
if state.status_code is None:
await task
msg = "ASGI application completed without sending http.response.start"
raise RuntimeError(msg)
return httpx.Response(
state.status_code,
headers=state.headers,
stream=_ASGIResponseStream(
state, task, read_timeout=read_timeout, shutdown_timeout=self._shutdown_timeout, request=request
),
request=request,
)
def _seed_stdio_identity(app: "ASGIApp", context: "MCPStdioContext") -> "ASGIApp":
async def asgi(scope: "Any", receive: "Any", send: "Any") -> None:
if scope["type"] == "http":
scope["user"] = dict(context.user) if isinstance(context.user, Mapping) else context.user
scope["auth"] = dict(context.auth) if isinstance(context.auth, Mapping) else context.auth
scope["session"] = dict(context.session or {})
scope["state"] = dict(context.state or {})
if context.owner_id is not None:
scope[MCP_OWNER_ID_SCOPE_KEY] = context.owner_id
await app(scope, receive, send)
return asgi
def _wraps_only(exc: "BaseException", target: "BaseException") -> "bool":
"""Return whether ``exc`` is ``target`` or an exception group holding nothing else."""
if exc is target:
return True
members = getattr(exc, "exceptions", None)
if not isinstance(members, tuple):
return False
return all(_wraps_only(member, target) for member in members)
def _only_cancellations(exc: "BaseException") -> "bool":
"""Return whether ``exc`` is a cancellation or a group holding only cancellations."""
if isinstance(exc, (asyncio.CancelledError, anyio.get_cancelled_exc_class())):
return True
members = getattr(exc, "exceptions", None)
if not isinstance(members, tuple):
return False
return all(_only_cancellations(member) for member in members)
@contextlib.asynccontextmanager
async def _app_lifespan(app: "Litestar", *, shutdown_timeout: "float" = 5.0) -> "AsyncIterator[None]":
"""Bound post-startup shutdown in the same task and preserve body failures.
Startup unwind retains Litestar's native exception and cancellation
semantics. Application hooks must bound and shield their own rollback
where needed; the cleanup deadline is armed only after lifespan entry.
"""
if not math.isfinite(shutdown_timeout) or shutdown_timeout <= 0:
msg = "shutdown_timeout must be positive and finite"
raise ValueError(msg)
body_error: BaseException | None = None
with anyio.CancelScope() as cleanup_scope:
try:
async with app.lifespan():
try:
yield
except BaseException as exc:
body_error = exc
raise
finally:
cleanup_scope.shield = True
cleanup_scope.deadline = anyio.current_time() + shutdown_timeout
except BaseException as exc:
# A body failure is re-raised below, outside this handler, so the
# exception keeps its own cause and context instead of gaining the
# lifespan's wrapping group as implicit context.
if body_error is None:
raise
if not _wraps_only(exc, body_error) and not _only_cancellations(exc):
_logger.warning("Lifespan shutdown failed after a body error", exc_info=True)
finally:
if cleanup_scope.cancel_called:
_logger.warning("Lifespan shutdown incomplete after %s seconds", shutdown_timeout)
if body_error is not None:
raise body_error
def _resolve_plugin(app: "Litestar") -> "Any":
from litestar_mcp.mcp.plugin import LitestarMCP
try:
return app.plugins.get(LitestarMCP)
except KeyError as exc:
msg = "The LitestarMCP plugin is not installed on this application"
raise RuntimeError(msg) from exc
[docs]
async def run_stdio_async(
app: "Litestar",
*,
stdio_context: "MCPStdioContext | None" = None,
headers: "Mapping[str, str] | None" = None,
token_provider: "TokenProvider | None" = None,
header_name: "str" = DEFAULT_AUTH_HEADER_NAME,
token_prefix: "str" = BEARER_TOKEN_PREFIX,
sse_read_timeout: "float | None" = 300.0,
stdin: "ByteReceiveStream | None" = None,
stdout: "ByteSendStream | None" = None,
stderr: "Any | None" = None,
max_message_size: "int" = DEFAULT_MAX_STDIN_MESSAGE_SIZE,
shutdown_timeout: "float" = 5.0,
) -> "int":
"""Serve a Litestar MCP endpoint to a local stdio client without a socket.
``shutdown_timeout`` bounds in-process request cleanup and application
shutdown after native lifespan entry succeeds. Bridge errors and body
cancellation are preserved if that shutdown fails or times out.
Startup and its unwind follow Litestar's native semantics, including
exception grouping and chaining. Application startup, lifespan, and
shutdown hooks must bound and shield their own cleanup where needed.
"""
plugin = _resolve_plugin(app)
endpoint = f"http://mcp-stdio/{plugin.config.base_path.strip('/')}"
asgi_app = app if stdio_context is None else _seed_stdio_identity(app, stdio_context)
client_info = None if stdio_context is None else {"name": stdio_context.client_id, "version": __version__}
async with _app_lifespan(app, shutdown_timeout=shutdown_timeout):
return await run_stdio_streamable_http_bridge(
endpoint,
transport=ASGIStreamingTransport(asgi_app, shutdown_timeout=shutdown_timeout),
client_info=client_info,
headers=headers,
token_provider=token_provider,
header_name=header_name,
token_prefix=token_prefix,
sse_read_timeout=sse_read_timeout,
stdin=stdin,
stdout=stdout,
stderr=stderr,
max_message_size=max_message_size,
)
[docs]
def run_stdio(
app: "Litestar",
*,
stdio_context: "MCPStdioContext | None" = None,
headers: "Mapping[str, str] | None" = None,
token_provider: "TokenProvider | None" = None,
header_name: "str" = DEFAULT_AUTH_HEADER_NAME,
token_prefix: "str" = BEARER_TOKEN_PREFIX,
sse_read_timeout: "float | None" = 300.0,
stdin: "ByteReceiveStream | None" = None,
stdout: "ByteSendStream | None" = None,
stderr: "Any | None" = None,
max_message_size: "int" = DEFAULT_MAX_STDIN_MESSAGE_SIZE,
shutdown_timeout: "float" = 5.0,
) -> "int":
"""Synchronously run :func:`run_stdio_async`; ``KeyboardInterrupt`` exits 0."""
try:
return asyncio.run(
run_stdio_async(
app,
stdio_context=stdio_context,
headers=headers,
token_provider=token_provider,
header_name=header_name,
token_prefix=token_prefix,
sse_read_timeout=sse_read_timeout,
stdin=stdin,
stdout=stdout,
stderr=stderr,
max_message_size=max_message_size,
shutdown_timeout=shutdown_timeout,
)
)
except KeyboardInterrupt:
return 0