# ruff: noqa: C901, PLR0911
"""MCP 2026-07-28 JSON-RPC transport for Litestar applications."""
import asyncio
import base64
import binascii
from typing import TYPE_CHECKING, Any
from litestar import Controller, Litestar, MediaType, Request, Response, post
from litestar.di import NamedDependency # noqa: TC002
from litestar.exceptions import SerializationException
from litestar.response import ServerSentEvent, ServerSentEventMessage
from litestar.serialization import decode_json, encode_json
from litestar.status_codes import (
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from litestar_mcp.config import MCPConfig # noqa: TC001
from litestar_mcp.jsonrpc import (
INVALID_PARAMS,
METHOD_NOT_FOUND,
PARSE_ERROR,
JSONRPCError,
JSONRPCErrorException,
JSONRPCRouter,
error_response,
parse_request,
)
from litestar_mcp.registry import PromptRegistration, Registry # noqa: TC001
from litestar_mcp.schema_builder import generate_schema_for_handler, iter_mcp_header_fields
from litestar_mcp.services.handler import MCPHandlerService, MCPRequestContext
from litestar_mcp.tasks import MCPTaskStore # noqa: TC001
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from litestar_mcp.jsonrpc import JSONRPCRequest
MCP_PROTOCOL_VERSION = "2026-07-28"
MCP_PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
MCP_METHOD_HEADER = "Mcp-Method"
MCP_NAME_HEADER = "Mcp-Name"
HEADER_MISMATCH = -32020
MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
UNSUPPORTED_PROTOCOL_VERSION = -32022
_NAME_FIELDS = {
"tools/call": "name",
"resources/read": "uri",
"prompts/get": "name",
"tasks/get": "taskId",
"tasks/update": "taskId",
"tasks/cancel": "taskId",
}
_CACHEABLE_METHODS = {
"server/discover",
"tools/list",
"resources/list",
"resources/templates/list",
"resources/read",
"prompts/list",
}
_BASE64_PREFIX = "=?base64?"
_BASE64_SUFFIX = "?="
def _error(
request_id: "Any",
*,
code: "int",
message: "str",
status_code: "int",
data: "Any | None" = None,
) -> "Response[Any]":
response = Response(
content=error_response(request_id, JSONRPCError(code=code, message=message, data=data)),
status_code=status_code,
media_type=MediaType.JSON,
)
response.headers[MCP_PROTOCOL_VERSION_HEADER] = MCP_PROTOCOL_VERSION
return response
def _request_origin(request: "Request[Any, Any, Any]") -> "str":
return f"{request.url.scheme}://{request.url.netloc}"
def _validate_origin(request: "Request[Any, Any, Any]", config: "MCPConfig") -> "Response[Any] | None":
"""Reject a present Origin unless it is same-origin or explicitly allowed."""
origin = request.headers.get("origin")
if origin is None:
return None
allowed = set(config.allowed_origins or ())
allowed.add(_request_origin(request))
if origin in allowed:
return None
return _error(
None,
code=INVALID_PARAMS,
message="Origin not allowed",
status_code=HTTP_403_FORBIDDEN,
)
def decode_mcp_header_value(value: "str") -> "str":
"""Decode the MCP Base64 sentinel encoding, returning plain values unchanged."""
if not (value.startswith(_BASE64_PREFIX) and value.endswith(_BASE64_SUFFIX)):
return value
payload = value[len(_BASE64_PREFIX) : -len(_BASE64_SUFFIX)]
try:
return base64.b64decode(payload, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError) as exc:
msg = "Invalid MCP Base64 header value"
raise ValueError(msg) from exc
def _request_metadata_error(
request: "Request[Any, Any, Any]",
rpc_request: "JSONRPCRequest",
discovered_tools: "dict[str, Any]",
) -> "Response[Any] | None":
params = rpc_request.params
meta = params.get("_meta") if isinstance(params, dict) else None
if not isinstance(meta, dict):
return _error(
rpc_request.id,
code=INVALID_PARAMS,
message="Missing or invalid params._meta",
status_code=HTTP_400_BAD_REQUEST,
)
body_version = meta.get("io.modelcontextprotocol/protocolVersion")
client_capabilities = meta.get("io.modelcontextprotocol/clientCapabilities")
if not isinstance(body_version, str) or not isinstance(client_capabilities, dict):
return _error(
rpc_request.id,
code=INVALID_PARAMS,
message="Missing required MCP request metadata",
status_code=HTTP_400_BAD_REQUEST,
)
raw_header_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)
raw_header_method = request.headers.get(MCP_METHOD_HEADER)
header_version = raw_header_version.strip() if raw_header_version is not None else None
header_method = raw_header_method.strip() if raw_header_method is not None else None
if header_version != body_version:
return _error(
rpc_request.id,
code=HEADER_MISMATCH,
message="MCP-Protocol-Version header does not match request metadata",
status_code=HTTP_400_BAD_REQUEST,
)
if header_method != rpc_request.method:
return _error(
rpc_request.id,
code=HEADER_MISMATCH,
message="Mcp-Method header does not match request method",
status_code=HTTP_400_BAD_REQUEST,
)
if body_version != MCP_PROTOCOL_VERSION:
return _error(
rpc_request.id,
code=UNSUPPORTED_PROTOCOL_VERSION,
message=f"Unsupported protocol version: {body_version}",
status_code=HTTP_400_BAD_REQUEST,
data={
"supported": [MCP_PROTOCOL_VERSION],
"supportedVersions": [MCP_PROTOCOL_VERSION],
"requested": body_version,
},
)
name_field = _NAME_FIELDS.get(rpc_request.method)
if name_field is not None:
body_name = params.get(name_field)
header_name = request.headers.get(MCP_NAME_HEADER)
try:
decoded_name = decode_mcp_header_value(header_name.strip()) if header_name is not None else None
except ValueError as exc:
return _error(
rpc_request.id,
code=HEADER_MISMATCH,
message=str(exc),
status_code=HTTP_400_BAD_REQUEST,
)
if not isinstance(body_name, str) or decoded_name != body_name:
return _error(
rpc_request.id,
code=HEADER_MISMATCH,
message="Mcp-Name header does not match request parameters",
status_code=HTTP_400_BAD_REQUEST,
)
if rpc_request.method != "tools/call":
return None
tool_name = params.get("name")
handler = discovered_tools.get(tool_name) if isinstance(tool_name, str) else None
if handler is None:
return None
arguments = params.get("arguments")
if not isinstance(arguments, dict):
return None
for path, custom_name, _property_schema in iter_mcp_header_fields(generate_schema_for_handler(handler)):
value: Any = arguments
present = True
for part in path:
if not isinstance(value, dict) or part not in value:
present = False
break
value = value[part]
header_value = request.headers.get(f"Mcp-Param-{custom_name}")
if not present or value is None:
if header_value is None:
continue
elif header_value is not None:
try:
decoded_value = decode_mcp_header_value(header_value.strip())
except ValueError:
decoded_value = None
expected = ("true" if value else "false") if isinstance(value, bool) else str(value)
if decoded_value == expected:
continue
return _error(
rpc_request.id,
code=HEADER_MISMATCH,
message=f"Mcp-Param-{custom_name} header does not match request arguments",
status_code=HTTP_400_BAD_REQUEST,
)
return None
def _request_subject(request: "Request[Any, Any, Any]") -> "str | None":
auth = request.scope.get("auth")
if isinstance(auth, dict):
sub = auth.get("sub")
if isinstance(sub, str) and sub:
return sub
user = request.scope.get("user")
if isinstance(user, dict):
for key in ("id", "sub"):
value = user.get(key)
if value is not None:
return str(value)
else:
for attribute in ("id", "sub"):
value = getattr(user, attribute, None)
if value is not None:
return str(value)
return None
def _build_request_context(request: "Request[Any, Any, Any]", rpc_request: "JSONRPCRequest") -> "MCPRequestContext":
meta = rpc_request.params["_meta"]
client_info = meta.get("io.modelcontextprotocol/clientInfo")
client_id = client_info.get("name") if isinstance(client_info, dict) else None
sub = _request_subject(request)
return MCPRequestContext(
client_id=client_id or "anonymous",
owner_id=f"user:{sub}" if sub is not None else None,
request=request,
protocol_version=MCP_PROTOCOL_VERSION,
client_capabilities=meta["io.modelcontextprotocol/clientCapabilities"],
client_info=client_info if isinstance(client_info, dict) else None,
input_responses=rpc_request.params.get("inputResponses"),
request_state=rpc_request.params.get("requestState"),
)
def _build_cached_router(
app: "Litestar",
config: "MCPConfig",
discovered_tools: "dict[str, Any]",
discovered_resources: "dict[str, Any]",
discovered_prompts: "dict[str, PromptRegistration]",
registry: "Registry",
task_store: "MCPTaskStore | None",
) -> "JSONRPCRouter":
router = JSONRPCRouter()
def service() -> "MCPHandlerService":
return MCPHandlerService(
config=config,
discovered_tools=discovered_tools,
discovered_resources=discovered_resources,
discovered_prompts=discovered_prompts,
app_ref=app,
registry=registry,
task_store=task_store,
)
router.register("server/discover", lambda params, ctx: service().server_discover(params, ctx))
router.register("tools/list", lambda params, ctx: service().tools_list(params, ctx))
router.register("tools/call", lambda params, ctx: service().tools_call(params, ctx))
router.register("resources/list", lambda params, ctx: service().resources_list(params, ctx))
router.register("resources/templates/list", lambda params, ctx: service().resources_templates_list(params, ctx))
router.register("resources/read", lambda params, ctx: service().resources_read(params, ctx))
router.register("completion/complete", lambda params, ctx: service().completion_complete(params, ctx))
router.register("prompts/list", lambda params, ctx: service().prompts_list(params, ctx))
router.register("prompts/get", lambda params, ctx: service().prompts_get(params, ctx))
if task_store is not None and config.task_config is not None:
router.register("tasks/get", lambda params, ctx: service().tasks_get(params, ctx))
router.register("tasks/update", lambda params, ctx: service().tasks_update(params, ctx))
router.register("tasks/cancel", lambda params, ctx: service().tasks_cancel(params, ctx))
return router
def _server_info(app: "Litestar", config: "MCPConfig") -> "dict[str, str]":
openapi = app.openapi_config
return {
"name": config.name or (openapi.title if openapi is not None else "Litestar MCP Server"),
"version": openapi.version if openapi is not None else "1.0.0",
}
def _finalize_result(
payload: "dict[str, Any]",
*,
method: "str",
app: "Litestar",
config: "MCPConfig",
) -> "None":
result = payload.get("result")
if not isinstance(result, dict):
return
result.setdefault("resultType", "complete")
meta = result.setdefault("_meta", {})
meta.setdefault("io.modelcontextprotocol/serverInfo", _server_info(app, config))
if method in _CACHEABLE_METHODS:
result.setdefault("ttlMs", config.cache_ttl_ms)
result.setdefault("cacheScope", config.cache_scope)
async def _subscription_response(
rpc_request: "JSONRPCRequest",
registry: "Registry",
config: "MCPConfig",
) -> "Response[Any]":
notifications = rpc_request.params.get("notifications")
if not isinstance(notifications, dict):
return _error(
rpc_request.id,
code=INVALID_PARAMS,
message="subscriptions/listen notifications must be an object",
status_code=HTTP_400_BAD_REQUEST,
)
try:
stream_id, stream = await registry.subscription_manager.open(rpc_request.id, notifications)
except Exception as exc:
from litestar_mcp.sse import StreamLimitExceeded
if not isinstance(exc, StreamLimitExceeded):
raise
return _error(
rpc_request.id,
code=INVALID_PARAMS,
message=str(exc),
status_code=HTTP_400_BAD_REQUEST,
)
async def event_stream() -> "AsyncGenerator[ServerSentEventMessage, None]":
next_message = asyncio.create_task(stream.__anext__())
try:
while True:
done, _ = await asyncio.wait(
{next_message},
timeout=config.subscription_keepalive_seconds,
)
if not done:
yield ServerSentEventMessage(comment="keepalive")
continue
try:
message = next_message.result()
except StopAsyncIteration:
return
yield ServerSentEventMessage(data=encode_json(message).decode("utf-8"))
next_message = asyncio.create_task(stream.__anext__())
finally:
next_message.cancel()
await registry.subscription_manager.disconnect(stream_id)
response = ServerSentEvent(event_stream())
response.headers[MCP_PROTOCOL_VERSION_HEADER] = MCP_PROTOCOL_VERSION
response.headers["X-Accel-Buffering"] = "no"
return response
[docs]
class MCPController(Controller):
"""POST-only MCP JSON-RPC controller."""
@post("/", name="mcp_jsonrpc", status_code=HTTP_200_OK)
async def handle_jsonrpc(
self,
request: "Request[Any, Any, Any]",
config: "NamedDependency[MCPConfig]",
discovered_tools: "NamedDependency[dict[str, Any]]",
discovered_resources: "NamedDependency[dict[str, Any]]",
discovered_prompts: "NamedDependency[dict[str, PromptRegistration]]",
registry: "NamedDependency[Registry]",
task_store: "NamedDependency[MCPTaskStore | None]" = None,
) -> "Response[Any]":
"""Validate and dispatch one independent MCP request."""
origin_error = _validate_origin(request, config)
if origin_error is not None:
return origin_error
try:
raw = decode_json(await request.body())
except (SerializationException, ValueError):
return _error(None, code=PARSE_ERROR, message="Parse error", status_code=HTTP_400_BAD_REQUEST)
try:
rpc_request = parse_request(raw)
except JSONRPCErrorException as exc:
return _error(
raw.get("id") if isinstance(raw, dict) else None,
code=exc.error.code,
message=exc.error.message,
data=exc.error.data,
status_code=HTTP_400_BAD_REQUEST,
)
metadata_error = _request_metadata_error(request, rpc_request, discovered_tools)
if metadata_error is not None:
return metadata_error
if rpc_request.method == "subscriptions/listen":
return await _subscription_response(rpc_request, registry, config)
app = request.app
if not hasattr(app.state, "mcp_router"):
app.state.mcp_router = _build_cached_router(
app,
config,
discovered_tools,
discovered_resources,
discovered_prompts,
registry,
task_store,
)
router: JSONRPCRouter = app.state.mcp_router
if rpc_request.method not in router.methods:
return _error(
rpc_request.id,
code=METHOD_NOT_FOUND,
message=f"Method not found: {rpc_request.method}",
status_code=HTTP_404_NOT_FOUND,
)
result = await router.dispatch(rpc_request, _build_request_context(request, rpc_request))
if result is None:
return _error(
rpc_request.id,
code=INVALID_PARAMS,
message="Client notifications are not supported over Streamable HTTP",
status_code=HTTP_400_BAD_REQUEST,
)
_finalize_result(result, method=rpc_request.method, app=app, config=config)
error = result.get("error")
error_code = error.get("code") if isinstance(error, dict) else None
status_code = (
HTTP_400_BAD_REQUEST
if error_code
in {
INVALID_PARAMS,
HEADER_MISMATCH,
MISSING_REQUIRED_CLIENT_CAPABILITY,
UNSUPPORTED_PROTOCOL_VERSION,
}
else HTTP_200_OK
)
response = Response(content=result, status_code=status_code, media_type=MediaType.JSON)
response.headers[MCP_PROTOCOL_VERSION_HEADER] = MCP_PROTOCOL_VERSION
return response
__all__ = (
"HEADER_MISMATCH",
"MCP_METHOD_HEADER",
"MCP_NAME_HEADER",
"MCP_PROTOCOL_VERSION",
"MCP_PROTOCOL_VERSION_HEADER",
"MISSING_REQUIRED_CLIENT_CAPABILITY",
"UNSUPPORTED_PROTOCOL_VERSION",
"MCPController",
"decode_mcp_header_value",
)