Coverage for src/lilbee/server/mcp_mount.py: 100%
70 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-12 00:44 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-12 00:44 +0000
1"""Mount the MCP tool server over streamable-http on the Litestar daemon."""
3from __future__ import annotations
5import logging
6from collections.abc import AsyncIterator, Callable
7from contextlib import AbstractAsyncContextManager, asynccontextmanager
8from typing import TYPE_CHECKING, cast
10from litestar.handlers import asgi
11from litestar.types import (
12 ASGIApp,
13 HTTPResponseBodyEvent,
14 HTTPResponseStartEvent,
15 HTTPScope,
16 Receive,
17 Scope,
18 Send,
19)
20from mcp.server.transport_security import TransportSecuritySettings
22from lilbee.app.endpoints import MCP_PATH
23from lilbee.core.config import cfg
24from lilbee.mcp_server import build_mcp_server, set_http_mounted
26if TYPE_CHECKING:
27 from litestar import Litestar
28 from litestar.handlers import ASGIRouteHandler
30log = logging.getLogger(__name__)
32_Lifespan = Callable[["Litestar"], AbstractAsyncContextManager[None]]
34_LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost")
35# Wildcard binds cannot be enumerated for a Host allowlist. Sentinels for
36# comparison, not bind addresses.
37_WILDCARD_BINDS = ("0.0.0.0", "::") # noqa: S104
40def _fmt_host(host: str) -> str:
41 """Bracket an IPv6 literal so it matches Host/Origin header syntax."""
42 return f"[{host}]" if ":" in host else host
45def _transport_security() -> TransportSecuritySettings:
46 """DNS-rebinding allowlist scoped to the configured bind host.
48 Defaults to loopback (the usual bind). When the daemon is bound to a
49 specific non-loopback host, that host is added so the mount does not
50 fail closed and reject every request. A wildcard bind (0.0.0.0 / ::)
51 cannot be enumerated, so only loopback is allowed there.
52 """
53 hosts = [f"{_fmt_host(h)}:*" for h in _LOOPBACK_HOSTS]
54 origins = [
55 f"{scheme}://{_fmt_host(h)}:*" for h in _LOOPBACK_HOSTS for scheme in ("http", "https")
56 ]
57 bind = cfg.server_host
58 if bind in _WILDCARD_BINDS:
59 # The REST API on this port serves LAN clients fine, so without this
60 # only /mcp fails, with an opaque transport-security rejection.
61 log.warning(
62 "Bound to %s, which cannot be enumerated for a Host allowlist, so %s "
63 "accepts loopback Host headers only. Bind to a specific address to "
64 "reach the MCP endpoint from other machines.",
65 bind,
66 MCP_PATH,
67 )
68 elif bind and bind not in _LOOPBACK_HOSTS:
69 hosts.append(f"{_fmt_host(bind)}:*")
70 origins.extend(f"{scheme}://{_fmt_host(bind)}:*" for scheme in ("http", "https"))
71 return TransportSecuritySettings(
72 enable_dns_rebinding_protection=True,
73 allowed_hosts=hosts,
74 allowed_origins=origins,
75 )
78# Message-post path of the legacy HTTP+SSE transport, inside the mount.
79_SSE_MESSAGE_PATH = "/messages/"
82def _header(scope: HTTPScope, name: bytes) -> bytes | None:
83 """Return the first value of *name* among the request headers, if any."""
84 return next((v for k, v in scope["headers"] if k == name), None)
87def _is_legacy_sse_contact(scope: HTTPScope) -> bool:
88 """True for a sessionless GET asking for SSE at the mount root.
90 That request is the legacy HTTP+SSE (2024-11-05) handshake, which clients
91 fall back to when streamable-http first contact fails. A GET carrying a
92 session ID is the streamable transport's own standalone stream instead.
93 """
94 if scope["method"] != "GET" or scope["path"] not in ("", "/"):
95 return False
96 if _header(scope, b"mcp-session-id") is not None:
97 return False
98 accept = _header(scope, b"accept") or b""
99 return b"text/event-stream" in accept
102async def _method_not_allowed(send: Send) -> None:
103 """Answer 405: a sessionless GET with no SSE accept has no stream to serve."""
104 start: HTTPResponseStartEvent = {
105 "type": "http.response.start",
106 "status": 405,
107 "headers": [(b"allow", b"GET, POST, DELETE")],
108 }
109 body: HTTPResponseBodyEvent = {
110 "type": "http.response.body",
111 "body": b"",
112 "more_body": False,
113 }
114 await send(start)
115 await send(body)
118def build_mcp_mount() -> tuple[ASGIRouteHandler, _Lifespan]:
119 """Return the MCP route handler and the session-manager lifespan.
121 Each mount builds its own MCP server: the SDK caches a single session
122 manager per server and ``run()`` is single-use, so a shared server's
123 second lifespan would raise.
124 """
125 # Mark MCP as served over the shared HTTP daemon so single-vault-only tools
126 # (init, reset) refuse runtime vault-switch / teardown that would race
127 # concurrent in-flight handlers on the process-global Services singleton.
128 set_http_mounted(True)
129 server = build_mcp_server()
130 security = _transport_security()
131 streamable_app = cast(
132 "ASGIApp",
133 server.streamable_http_app(
134 streamable_http_path="/",
135 transport_security=security,
136 ),
137 )
138 # The SDK ships the two transports as separate apps and no combined mount,
139 # so this dispatcher serves both at one endpoint per the spec's
140 # backwards-compatibility flow: without the legacy app a falling-back
141 # client meets a 400 and reports the server as disconnected.
142 sse_app = cast(
143 "ASGIApp",
144 server.sse_app(
145 sse_path="/",
146 message_path=_SSE_MESSAGE_PATH,
147 transport_security=security,
148 ),
149 )
150 manager = server.session_manager
152 async def _forward(scope: Scope, receive: Receive, send: Send) -> None:
153 if scope["type"] != "http":
154 await streamable_app(scope, receive, send)
155 return
156 # The union does not narrow through the "type" comparison above.
157 http_scope = cast("HTTPScope", scope)
158 # Starlette route matching needs the bare mount root spelled "/".
159 if http_scope["path"] == "":
160 http_scope["path"] = "/"
161 if http_scope["path"].startswith(_SSE_MESSAGE_PATH) or _is_legacy_sse_contact(http_scope):
162 # The endpoint event advertises root_path + message path, and
163 # the mount strips MCP_PATH from the path it forwards.
164 http_scope["root_path"] = MCP_PATH
165 await sse_app(scope, receive, send)
166 return
167 if (
168 http_scope["method"] == "GET"
169 and http_scope["path"] == "/"
170 and _header(http_scope, b"mcp-session-id") is None
171 ):
172 await _method_not_allowed(send)
173 return
174 await streamable_app(scope, receive, send)
176 handler = asgi(MCP_PATH, is_mount=True, copy_scope=True)(_forward)
178 @asynccontextmanager
179 async def _session_lifespan(app: Litestar) -> AsyncIterator[None]:
180 async with manager.run():
181 yield
183 return handler, _session_lifespan