"""Stdio to Streamable HTTP bridge for Litestar MCP endpoints."""
import asyncio
import base64
import inspect
import sys
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterator, Mapping
from types import TracebackType
from typing import Any
import anyio
import httpx2
from anyio import EndOfStream, get_cancelled_exc_class
from anyio.abc import ByteReceiveStream, ByteSendStream
from anyio.to_thread import run_sync as run_sync_in_worker_thread
from litestar.exceptions import SerializationException
from litestar.status_codes import HTTP_202_ACCEPTED, HTTP_401_UNAUTHORIZED
from typing_extensions import Self
from litestar_mcp.__metadata__ import __version__
from litestar_mcp.core.exceptions import BridgeConnectionError, BridgeMessageTooLargeError
from litestar_mcp.core.jsonrpc import JSONRPCError, error_response
from litestar_mcp.core.serialization import from_json, to_json
from litestar_mcp.mcp.client import mcp_request_headers, prepare_mcp_request
from litestar_mcp.mcp.routes import (
MCP_METHOD_HEADER,
MCP_PROTOCOL_VERSION,
MCP_PROTOCOL_VERSION_HEADER,
)
TokenProvider = Callable[[], str] | Callable[[], Awaitable[str]]
BRIDGE_ERROR = -32001
DEFAULT_MAX_STDIN_MESSAGE_SIZE = 16 * 1024 * 1024
_MIN_VISIBLE_ASCII = 0x20
_MAX_VISIBLE_ASCII = 0x7E
_DEFAULT_CLIENT_INFO = {"name": "litestar-mcp-stdio-bridge", "version": __version__}
BEARER_TOKEN_PREFIX = "Bearer " # noqa: S105
DEFAULT_AUTH_HEADER_NAME = "Authorization"
__all__ = (
"BEARER_TOKEN_PREFIX",
"BRIDGE_ERROR",
"DEFAULT_AUTH_HEADER_NAME",
"DEFAULT_MAX_STDIN_MESSAGE_SIZE",
"BridgeConnectionError",
"BridgeMessageTooLargeError",
"TokenProvider",
"run_bridge",
"run_stdio_streamable_http_bridge",
)
[docs]
async def run_stdio_streamable_http_bridge(
endpoint: str,
*,
transport: "httpx2.AsyncBaseTransport | None" = None,
client_info: "Mapping[str, str] | 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,
timeout: float = 30.0, # noqa: ASYNC109
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,
) -> int:
"""Bridge local stdio JSON-RPC to a Litestar Streamable HTTP MCP endpoint.
Args:
endpoint: Full MCP Streamable HTTP endpoint URL.
transport: Optional httpx2 transport. Pass
:class:`~litestar_mcp.mcp.stdio.ASGIStreamingTransport` to serve an
in-process ASGI app instead of a remote endpoint.
client_info: Default ``io.modelcontextprotocol/clientInfo`` injected
into requests that do not carry one.
headers: Static HTTP headers sent to the endpoint.
token_provider: Optional callable that returns a fresh token per request.
header_name: Header used for token auth.
token_prefix: Prefix prepended to token values.
timeout: HTTP connect, write, and pool timeout in seconds.
sse_read_timeout: Read timeout for streaming SSE responses. ``None``
or ``0`` disables quiet-period timeouts for server streams.
stdin: Optional byte receive stream for tests or embedding.
stdout: Optional byte send stream for tests or embedding.
stderr: Optional diagnostic text stream. Defaults to ``sys.stderr``.
max_message_size: Maximum bytes allowed for one newline-delimited
JSON-RPC message, applied both to stdin lines and to server-sent
events read from the endpoint. Set to ``-1`` to disable the limit.
Returns:
Process-style exit code. ``0`` means clean EOF/shutdown; non-zero means
a transport or pump error was surfaced to the local stdio client.
"""
stdin_stream: ByteReceiveStream = stdin if stdin is not None else _StdinByteReceiveStream()
stdout_stream: ByteSendStream = stdout if stdout is not None else _StdoutByteSendStream()
stderr_stream = stderr or sys.stderr
auth = (
_TokenProviderAuth(token_provider, header_name=header_name, token_prefix=token_prefix)
if token_provider is not None
else None
)
error: BaseException | None = None
try:
async with (
_StreamableHTTPBridgeClient(
endpoint,
transport=transport,
client_info=client_info,
headers=headers,
auth=auth,
timeout=timeout,
sse_read_timeout=_normalize_sse_read_timeout(sse_read_timeout),
stdout=stdout_stream,
stderr=stderr_stream,
max_event_size=None if max_message_size < 0 else max_message_size,
) as bridge_client,
anyio.create_task_group() as task_group,
):
def record_error(exc: BaseException) -> None:
nonlocal error
if error is None:
error = exc
task_group.start_soon(
_run_bridge_pump,
_pump_stdin_to_remote(
stdin_stream,
bridge_client,
max_message_size=max_message_size,
),
task_group.cancel_scope,
record_error,
True,
)
except Exception as exc: # noqa: BLE001
error = exc
if error is None:
return 0
await _write_bridge_error(stdout_stream, str(error))
print(f"litestar mcp bridge transport error: {error}", file=stderr_stream)
return 1
[docs]
def run_bridge(
endpoint: str,
*,
headers: Mapping[str, str] | None = None,
token_provider: TokenProvider | None = None,
header_name: str = DEFAULT_AUTH_HEADER_NAME,
token_prefix: str = BEARER_TOKEN_PREFIX,
timeout: float = 30.0,
sse_read_timeout: float | None = 300.0,
stdout: ByteSendStream | None = None,
stderr: Any | None = None,
max_message_size: int = DEFAULT_MAX_STDIN_MESSAGE_SIZE,
) -> int:
"""Synchronously run the stdio bridge for CLI integrations."""
try:
return asyncio.run(
run_stdio_streamable_http_bridge(
endpoint,
headers=headers,
token_provider=token_provider,
header_name=header_name,
token_prefix=token_prefix,
timeout=timeout,
sse_read_timeout=sse_read_timeout,
stdout=stdout,
stderr=stderr,
max_message_size=max_message_size,
)
)
except KeyboardInterrupt:
return 0
class _TokenProviderAuth(httpx2.Auth):
"""Resolve a bearer-style token per request and retry once on 401."""
def __init__(self, token_provider: TokenProvider, *, header_name: str, token_prefix: str) -> None:
self._token_provider = token_provider
self._header_name = header_name
self._token_prefix = token_prefix
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
request.headers[self._header_name] = await self._header_value()
response = yield request
if response.status_code == HTTP_401_UNAUTHORIZED:
await response.aread()
request.headers[self._header_name] = await self._header_value()
yield request
async def _header_value(self) -> str:
if inspect.iscoroutinefunction(self._token_provider):
token = await self._token_provider()
else:
token = await run_sync_in_worker_thread(self._token_provider)
if inspect.isawaitable(token):
token = await token
return f"{self._token_prefix}{token}"
class _StreamableHTTPBridgeClient:
"""Minimal Streamable HTTP client for the stdio bridge."""
def __init__(
self,
endpoint: str,
*,
headers: Mapping[str, str] | None,
auth: httpx2.Auth | None,
transport: "httpx2.AsyncBaseTransport | None" = None,
client_info: "Mapping[str, str] | None" = None,
timeout: float,
sse_read_timeout: float | None,
stdout: ByteSendStream,
stderr: Any,
max_event_size: int | None,
) -> None:
self._endpoint = endpoint
self._stdout = stdout
self._stderr = stderr
self._max_event_size = max_event_size
self._client_info: dict[str, str] = dict(client_info or _DEFAULT_CLIENT_INFO)
self._client = httpx2.AsyncClient(
headers=dict(headers or {}),
timeout=httpx2.Timeout(timeout, read=sse_read_timeout),
auth=auth,
follow_redirects=True,
transport=transport,
)
self._tool_headers: dict[str, list[tuple[tuple[str, ...], str]]] | None = None
self._tool_headers_lock = asyncio.Lock()
self._inflight: dict[Any, anyio.CancelScope] = {}
self._inflight_lock = asyncio.Lock()
self._stdout_lock = asyncio.Lock()
self._subscriptions: set[Any] = set()
self._stdin_closed = False
async def __aenter__(self) -> Self:
await self._client.__aenter__()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.close()
await self._client.__aexit__(exc_type, exc_val, exc_tb)
async def close(self) -> None:
async with self._inflight_lock:
scopes = tuple(self._inflight.values())
self._inflight.clear()
for scope in scopes:
scope.cancel()
async def post_message(self, message: dict[str, Any]) -> None:
if message.get("method") == "notifications/cancelled":
params = message.get("params")
if isinstance(params, dict):
await self.cancel_request(params.get("requestId"))
return
if message.get("method") == "subscriptions/listen":
if self._stdin_closed:
return
if message.get("id") is not None:
self._subscriptions.add(message["id"])
prepared = self._prepare_message(message)
headers = await self._mcp_headers(prepared)
request_id = prepared.get("id")
cancel_scope = anyio.CancelScope()
if request_id is not None:
async with self._inflight_lock:
self._inflight[request_id] = cancel_scope
try:
with cancel_scope:
try:
async with self._client.stream(
"POST",
self._endpoint,
json=prepared,
headers=headers,
) as response:
if response.status_code == HTTP_202_ACCEPTED:
return
content_type = response.headers.get("content-type", "").lower()
if content_type.startswith("application/json"):
# JSON-RPC error envelopes travel with 4xx statuses; forward them as
# protocol responses. Any other JSON body (for example an HTTP
# authentication failure) is a transport error.
try:
payload = from_json(await response.aread())
except SerializationException:
response.raise_for_status()
raise
if not (isinstance(payload, dict) and payload.get("jsonrpc") == "2.0"):
response.raise_for_status()
async with self._stdout_lock:
await _write_json_line(self._stdout, payload)
return
response.raise_for_status()
if content_type.startswith("text/event-stream"):
await self._consume_sse_response(response, expected_id=request_id)
else:
msg = f"Unexpected Streamable HTTP content type: {content_type or '<empty>'}"
raise RuntimeError(msg)
except (httpx2.ConnectError, httpx2.ConnectTimeout) as exc:
raise BridgeConnectionError(self._endpoint) from exc
except (httpx2.ConnectError, httpx2.ConnectTimeout) as exc:
raise BridgeConnectionError(self._endpoint) from exc
finally:
if request_id is not None:
async with self._inflight_lock:
self._inflight.pop(request_id, None)
async def cancel_request(self, request_id: Any) -> None:
"""Close the HTTP response stream associated with a stdio request."""
async with self._inflight_lock:
scope = self._inflight.get(request_id)
if scope is not None:
scope.cancel()
async def close_subscriptions(self) -> None:
"""Cancel open ``subscriptions/listen`` streams once local stdin has reached EOF."""
self._stdin_closed = True
async with self._inflight_lock:
scopes = [self._inflight[request_id] for request_id in self._subscriptions if request_id in self._inflight]
for scope in scopes:
scope.cancel()
async def _mcp_headers(self, message: dict[str, Any]) -> dict[str, str]:
headers = mcp_request_headers(message)
method = str(message.get("method", ""))
params = message.get("params")
if isinstance(params, dict) and method == "tools/call":
headers.update(await self._custom_tool_headers(params))
return headers
def _prepare_message(self, message: dict[str, Any]) -> dict[str, Any]:
return prepare_mcp_request(message, client_info=self._client_info)
async def _custom_tool_headers(self, params: dict[str, Any]) -> dict[str, str]:
tool_name = params.get("name")
arguments = params.get("arguments")
if not isinstance(tool_name, str) or not isinstance(arguments, dict):
return {}
if self._tool_headers is None:
await self._load_tool_headers()
headers: dict[str, str] = {}
for path, header_name in (self._tool_headers or {}).get(tool_name, ()):
value: Any = arguments
for part in path:
if not isinstance(value, dict) or part not in value:
break
value = value[part]
else:
if value is not None:
rendered = ("true" if value else "false") if isinstance(value, bool) else str(value)
headers[f"Mcp-Param-{header_name}"] = _encode_header_value(rendered)
return headers
async def _load_tool_headers(self) -> None:
async with self._tool_headers_lock:
if self._tool_headers is not None:
return
cache: dict[str, list[tuple[tuple[str, ...], str]]] = {}
cursor: str | None = None
page = 0
while True:
params = {"cursor": cursor} if cursor is not None else {}
request = self._prepare_message(
{
"jsonrpc": "2.0",
"id": f"litestar-mcp-schema-cache-{page}",
"method": "tools/list",
"params": params,
}
)
response = await self._client.post(
self._endpoint,
json=request,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
MCP_PROTOCOL_VERSION_HEADER: MCP_PROTOCOL_VERSION,
MCP_METHOD_HEADER: "tools/list",
},
)
response.raise_for_status()
payload = from_json(response.content)
result = payload.get("result") if isinstance(payload, dict) else None
for tool in result.get("tools", ()) if isinstance(result, dict) else ():
if isinstance(tool, dict) and isinstance(tool.get("name"), str):
cache[tool["name"]] = list(_iter_header_schema(tool.get("inputSchema")))
cursor = result.get("nextCursor") if isinstance(result, dict) else None
if not isinstance(cursor, str):
break
page += 1
self._tool_headers = cache
async def _consume_sse_response(self, response: httpx2.Response, *, expected_id: Any | None) -> None:
async for event in httpx2.EventSource(response, max_event_size=self._max_event_size):
if not event.data:
continue
payload = from_json(event.data)
async with self._stdout_lock:
await _write_json_line(self._stdout, payload)
if expected_id is not None and isinstance(payload, dict) and payload.get("id") == expected_id:
return
class _StdinByteReceiveStream(ByteReceiveStream):
async def receive(self, max_bytes: int = 65536) -> bytes:
return await run_sync_in_worker_thread(sys.stdin.buffer.readline, max_bytes, abandon_on_cancel=True)
async def aclose(self) -> None:
return None
class _StdoutByteSendStream(ByteSendStream):
async def send(self, item: bytes) -> None:
def write() -> None:
sys.stdout.buffer.write(item)
sys.stdout.buffer.flush()
await run_sync_in_worker_thread(write, abandon_on_cancel=True)
async def aclose(self) -> None:
return None
async def _run_bridge_pump(
awaitable: Awaitable[None],
cancel_scope: anyio.CancelScope,
record_error: Callable[[BaseException], None],
terminal: bool,
) -> None:
try:
await awaitable
except get_cancelled_exc_class():
raise
except Exception as exc: # noqa: BLE001
record_error(_unwrap_exception_group(exc))
cancel_scope.cancel()
else:
if terminal:
cancel_scope.cancel()
async def _pump_stdin_to_remote(
stdin: ByteReceiveStream,
bridge_client: _StreamableHTTPBridgeClient,
*,
max_message_size: int,
) -> None:
async with anyio.create_task_group() as task_group:
async for line in _iter_stdin_lines(stdin, max_message_size=max_message_size):
if not line.strip():
continue
raw = from_json(line)
if not isinstance(raw, dict):
msg = "JSON-RPC messages must be JSON objects"
raise TypeError(msg)
task_group.start_soon(bridge_client.post_message, raw)
await bridge_client.close_subscriptions()
async def _iter_stdin_lines(stdin: ByteReceiveStream, *, max_message_size: int) -> AsyncIterator[bytes]:
buffer = b""
while True:
try:
chunk = await stdin.receive(65536)
except EndOfStream:
chunk = b""
if not chunk:
if buffer:
if max_message_size > 0 and len(buffer) > max_message_size:
raise BridgeMessageTooLargeError(max_message_size)
yield buffer
return
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
if max_message_size > 0 and len(line) + 1 > max_message_size:
raise BridgeMessageTooLargeError(max_message_size)
yield line + b"\n"
if max_message_size > 0 and len(buffer) > max_message_size:
raise BridgeMessageTooLargeError(max_message_size)
async def _write_bridge_error(stdout: ByteSendStream, message: str) -> None:
await _write_json_line(stdout, error_response(None, JSONRPCError(code=BRIDGE_ERROR, message=message)))
async def _write_json_line(stdout: ByteSendStream, payload: dict[str, Any]) -> None:
await stdout.send(to_json(payload, as_bytes=True) + b"\n")
def _normalize_sse_read_timeout(value: float | None) -> float | None:
return None if value is None or value <= 0 else value
def _unwrap_exception_group(exc: BaseException) -> BaseException:
while exceptions := getattr(exc, "exceptions", None):
exc = exceptions[0]
return exc
def _encode_header_value(value: str) -> str:
if (
value
and all(_MIN_VISIBLE_ASCII <= ord(char) <= _MAX_VISIBLE_ASCII for char in value)
and not value.startswith("=?base64?")
):
return value
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
return f"=?base64?{encoded}?="
def _iter_header_schema(schema: Any, path: tuple[str, ...] = ()) -> Iterator[tuple[tuple[str, ...], str]]:
"""Yield ``(property_path, header_name)`` pairs from an input schema."""
if not isinstance(schema, dict):
return
header_name = schema.get("x-mcp-header")
if isinstance(header_name, str):
yield path, header_name
properties = schema.get("properties")
if isinstance(properties, dict):
for name, child in properties.items():
yield from _iter_header_schema(child, (*path, name))