netx/netx_api/mcp_remote/mount.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

316 lines
10 KiB
Python

"""Mount Streamable HTTP MCP at /mcp on the FastAPI app."""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
from urllib.parse import urlparse
from mcp.server.transport_security import TransportSecuritySettings
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.types import ASGIApp, Receive, Scope, Send
from ..auth_deps import resolve_user_from_token
from ..config import settings
from ..db import SessionLocal
from . import server as server_mod
from .context import McpRequestContext, get_app, reset_request_context, set_app, set_request_context
from .registry import parse_groups_param
from .server import build_mcp_server
_MCP_ASGI: ASGIApp | None = None
def _mcp_transport_security() -> TransportSecuritySettings:
"""Host allow-list for MCP DNS-rebinding guard.
Bearer auth is the real gate. Default: disable DNS-rebinding protection so
LAN IPs / reverse-proxy Host headers work for remote MCP. Set
``NETX_MCP_ALLOWED_HOSTS`` (comma-separated, supports ``host:*``) to turn
protection back on with an explicit list.
"""
raw = str(getattr(settings, "mcp_allowed_hosts", "") or "").strip()
if not raw:
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
hosts: list[str] = []
for part in raw.split(","):
h = part.strip()
if h:
hosts.append(h)
# Always keep loopback + ASGI test hosts when an allow-list is configured.
for extra in (
"localhost",
"localhost:*",
"127.0.0.1",
"127.0.0.1:*",
"[::1]",
"[::1]:*",
"test",
"test:*",
"testserver",
"testserver:*",
"mcp.internal",
"mcp.internal:*",
):
if extra not in hosts:
hosts.append(extra)
# Include configured bind / frontend hosts when they look like hostnames.
for candidate in (settings.host, getattr(settings, "frontend_url", "")):
try:
parsed = urlparse(str(candidate) if "://" in str(candidate) else f"http://{candidate}")
hostname = (parsed.hostname or "").strip()
except Exception:
hostname = ""
if hostname and hostname not in hosts:
hosts.append(hostname)
hosts.append(f"{hostname}:*")
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=hosts,
)
def _header_map(scope: Scope) -> dict[str, str]:
out: dict[str, str] = {}
for key, value in scope.get("headers") or []:
out[key.decode("latin-1").lower()] = value.decode("latin-1")
return out
def _bearer_from_scope(scope: Scope) -> str:
auth = str(_header_map(scope).get("authorization") or "").strip()
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return ""
def _groups_from_scope(scope: Scope) -> frozenset[str] | None:
qs = (scope.get("query_string") or b"").decode("latin-1")
from urllib.parse import parse_qs
values = parse_qs(qs).get("groups")
if not values:
return parse_groups_param(None)
return parse_groups_param(values[0])
def _resolve_lang_from_scope(scope: Scope) -> str:
env = str(os.getenv("NETX_LANG") or "").strip().lower()
if env.startswith("en"):
return "en"
if env.startswith("zh"):
return "zh"
al = str(_header_map(scope).get("accept-language") or "").strip().lower()
if al.startswith("en"):
return "en"
return "zh"
async def _drain_body(receive: Receive) -> None:
while True:
message = await receive()
if message["type"] != "http.request" or not message.get("more_body"):
return
async def _send_json(scope: Scope, receive: Receive, send: Send, *, status: int, payload: dict) -> None:
from starlette.responses import JSONResponse
await JSONResponse(payload, status_code=status)(scope, receive, send)
class McpAuthMiddleware:
"""Pure ASGI Bearer gate wrapping only the MCP sub-app."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = (scope.get("method") or "GET").upper()
if method == "OPTIONS":
await self.app(scope, receive, send)
return
groups = _groups_from_scope(scope)
lang = _resolve_lang_from_scope(scope)
if not bool(settings.auth_enabled):
ctx = McpRequestContext(
token="disabled",
scopes=None,
groups=groups,
lang=lang,
auth_via="disabled",
)
token = set_request_context(ctx)
try:
await self.app(scope, receive, send)
finally:
reset_request_context(token)
return
raw = _bearer_from_scope(scope)
if not raw:
await _drain_body(receive)
await _send_json(
scope,
receive,
send,
status=401,
payload={"error": "unauthorized", "detail": "missing_bearer_token"},
)
return
db = SessionLocal()
try:
resolved = resolve_user_from_token(db, raw)
finally:
db.close()
if resolved is None:
await _drain_body(receive)
await _send_json(
scope,
receive,
send,
status=401,
payload={"error": "unauthorized", "detail": "invalid_token"},
)
return
user, via, scopes, _api_token_id, _jti = resolved
ctx = McpRequestContext(
token=raw,
scopes=frozenset(str(s) for s in scopes),
groups=groups,
lang=lang,
user_id=str(getattr(user, "id", "") or ""),
auth_via=via,
)
token = set_request_context(ctx)
try:
await self.app(scope, receive, send)
finally:
reset_request_context(token)
class _McpSlashRewrite:
"""Rewrite bare ``/mcp`` → ``/mcp/`` so Starlette Mount does not 307 POST."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope.get("path") == "/mcp":
scope = dict(scope)
scope["path"] = "/mcp/"
raw = scope.get("raw_path")
if isinstance(raw, (bytes, bytearray)) and raw == b"/mcp":
scope["raw_path"] = b"/mcp/"
await self.app(scope, receive, send)
class _PreferChunkedEncoding:
"""Strip ``Content-Length`` so the ASGI server emits proper chunked framing.
Some WinSock LSPs (observed: path matching ``*/mcp*``) rewrite
``Content-Length`` responses into ``Transfer-Encoding: chunked`` **without**
framing the body. Pre-chunked responses pass through. This is not a response
buffer/rewrite hack — it only drops CL so h11/uvicorn chunk-encode correctly.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def send_wrapper(message: dict) -> None:
if message["type"] == "http.response.start":
headers = [
(k, v)
for k, v in (message.get("headers") or [])
if k.lower() != b"content-length"
]
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_wrapper)
def _build_streamable_asgi() -> ASGIApp:
app = server_mod.mcp_server.streamable_http_app(
streamable_http_path="/",
stateless_http=True,
json_response=True,
transport_security=_mcp_transport_security(),
)
if isinstance(app, Starlette):
app.add_middleware(McpAuthMiddleware)
# Outermost on the sub-app: survive refresh_mcp_for_lifespan remounts.
return _PreferChunkedEncoding(app)
def build_mcp_asgi_app(*, force_new: bool = False) -> ASGIApp:
"""Stateless Streamable HTTP ASGI app mounted at ``/mcp``."""
global _MCP_ASGI
if _MCP_ASGI is not None and not force_new:
return _MCP_ASGI
_MCP_ASGI = _build_streamable_asgi()
return _MCP_ASGI
def ensure_mcp_session_manager() -> None:
"""Create streamable app so ``mcp_server.session_manager`` exists for lifespan."""
build_mcp_asgi_app()
def _replace_mcp_mount(host_app: Any, mcp_asgi: ASGIApp) -> None:
for route in getattr(host_app, "routes", []) or []:
if isinstance(route, Mount) and str(getattr(route, "path", "")) == "/mcp":
route.app = mcp_asgi
return
def refresh_mcp_for_lifespan(host_app: Any | None = None) -> Any:
"""Rebuild MCP Server + Streamable app (session manager can only run() once)."""
global _MCP_ASGI
server_mod.mcp_server = build_mcp_server()
_MCP_ASGI = None
mcp_asgi = build_mcp_asgi_app(force_new=True)
app = host_app or get_app()
if app is not None:
_replace_mcp_mount(app, mcp_asgi)
app.state.mcp_server = server_mod.mcp_server
app.state.mcp_asgi = mcp_asgi
set_app(app)
return server_mod.mcp_server
@asynccontextmanager
async def mcp_lifespan_cm(*, host_app: Any | None = None) -> AsyncIterator[None]:
"""Enter a fresh MCP session manager for this lifespan cycle."""
mcp = refresh_mcp_for_lifespan(host_app if host_app is not None else get_app())
async with mcp.session_manager.run():
yield
def mount_mcp(app: Any) -> None:
"""``app.mount('/mcp', ...)`` + slash rewrite; auth is on the sub-app only."""
set_app(app)
mcp_asgi = build_mcp_asgi_app()
app.mount("/mcp", mcp_asgi)
# Outermost: clients may POST http://host:port/mcp without a trailing slash.
app.add_middleware(_McpSlashRewrite)
app.state.mcp_server = server_mod.mcp_server
app.state.mcp_mounted = True
app.state.mcp_asgi = mcp_asgi