Files
app/backend/fluksio/mcp/http.py
T
stroblmeandClaude Opus 5.5 391edc8766 Serve MCP and hooks with a secret through the portal tunnel
The connector now serves /mcp, its OAuth documents and /hooks/ besides
/api/v1/, refuses any dot segment (httpx resolves them, which carried
/api/v1/../../mcp onto the MCP mount), addresses calls to the hub's host,
and bridges served sockets by calling the app with a websocket scope.
A hook without a secret answers the tunnel with the 404 (or 1008) of a
wrong secret. Requests through the hub get the hub's issuer, resource and
consent page in the OAuth metadata and the 401, and the MCP host check
admits the hub while enrolled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
2026-10-15 11:00:19 +02:00

181 lines
7.2 KiB
Python

"""Mounting the MCP endpoint, and deciding whose tokens it accepts.
The transport is streamable HTTP, stateless, one JSON response per call: no
sticky sessions to route and nothing for a proxy to buffer. The SDK serves both
``/mcp`` and the protected-resource metadata beside it, which is why the sub-app
is mounted at the root rather than under ``/mcp``.
An enrolled instance also answers at ``/i/{id}/mcp`` on its hub, so an agent
that came through the tunnel is shown the hub's addresses instead.
"""
from __future__ import annotations
import logging
from urllib.parse import urlparse
import jwt
from fastapi.concurrency import run_in_threadpool
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.routes import build_resource_metadata_url
from mcp.server.auth.settings import AuthSettings
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.datastructures import Headers
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from fluksio.api.deps import oauth_client_lives
from fluksio.cloud import config as cloud_config
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.mcp import server
logger = logging.getLogger(__name__)
class _JWTVerifier:
"""Accept only tokens minted for the MCP channel, by agents still registered.
A perfectly valid browser token is refused: it was issued for a person's
session, and honouring it here would make agent traffic indistinguishable
from theirs.
The client row is looked up because the token itself cannot be withdrawn:
it is stateless and good until ``MCP_TOKEN_EXPIRE_MINUTES`` runs out, so
the registration it names is the thing revoking an agent actually removes.
Refusing at the door rather than leaving it to the API the tools call means
a revoked agent gets the 401 that sends it back to authorize, instead of a
tool listing that works and a tool call that does not.
"""
async def verify_token(self, token: str) -> AccessToken | None:
try:
payload = security.decode_oauth_token(token)
except jwt.InvalidTokenError as exc:
logger.debug("MCP token rejected: %s", exc)
return None
if payload.get("mcp") is not True:
logger.debug("MCP token rejected: not an MCP-channel token")
return None
client_id = str(payload.get("client_id", ""))
# Off the event loop: the lookup is SQLite, like every other read this
# process makes, and the session it opens is blocking.
if not await run_in_threadpool(oauth_client_lives, client_id):
logger.debug(
"MCP token rejected: agent %s is no longer registered", client_id
)
return None
return AccessToken(
token=token,
client_id=client_id,
scopes=[security.MCP_SCOPE],
subject=str(payload.get("sub", "")),
)
class _AndTheHub(list[str]):
"""The hosts named here, plus the hub's while this instance is enrolled.
A call through the tunnel is addressed to the hub, as its Host says. The
hub is looked up per request, since enrolment comes and goes while the
endpoint is up. Its origin is not needed: the hub never forwards one.
"""
def __contains__(self, host: object) -> bool:
config = cloud_config.load()
hub = urlparse(config.issuer).netloc if config is not None else None
return super().__contains__(host) or (hub is not None and host == hub)
def _transport_security() -> TransportSecuritySettings:
"""Which Host headers to trust.
The SDK defaults to localhost only and answers 421 to anything arriving
through a reverse proxy under the real hostname, so the deployment's own
host is named here rather than the protection being switched off.
"""
host = urlparse(settings.oauth_issuer).netloc
allowed_hosts = [host, f"{host}:*"]
allowed_origins = [f"https://{host}", f"http://{host}"]
if settings.ENVIRONMENT == "local":
# The test client and a direct uvicorn run do not go through Traefik.
allowed_hosts += [
"localhost",
"localhost:*",
"127.0.0.1",
"127.0.0.1:*",
"testserver",
]
allowed_origins += ["http://localhost", "http://127.0.0.1"]
# Unvalidated, since validating would copy the hosts into a plain list.
return TransportSecuritySettings.model_construct(
enable_dns_rebinding_protection=True,
allowed_hosts=_AndTheHub(allowed_hosts),
allowed_origins=allowed_origins,
)
class _ThroughThePortal:
"""Show an agent that came through the hub the hub's addresses.
The SDK builds its metadata once, for the API host, and names it in every
401. An agent at ``/i/{id}/mcp`` on the hub cannot reach that host, so its
discovery has to lead to the hub's documents instead.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
base = None
if scope["type"] == "http":
base = cloud_config.portal_base(Headers(scope=scope))
if base is None:
await self.app(scope, receive, send)
return
if scope["path"] == "/.well-known/oauth-protected-resource/mcp":
document = {
"resource": f"{base}/mcp",
"authorization_servers": [base],
"bearer_methods_supported": ["header"],
}
headers = {"Cache-Control": "public, max-age=3600"}
await JSONResponse(document, headers=headers)(scope, receive, send)
return
here = str(build_resource_metadata_url(AnyHttpUrl(settings.mcp_resource)))
there = str(build_resource_metadata_url(AnyHttpUrl(f"{base}/mcp")))
async def send_theirs(message: Message) -> None:
if message["type"] == "http.response.start":
message["headers"] = [
(
key,
value.replace(here.encode(), there.encode())
if key == b"www-authenticate"
else value,
)
for key, value in message["headers"]
]
await send(message)
await self.app(scope, receive, send_theirs)
def build_http_app(app: object) -> ASGIApp:
"""The MCP sub-app, wired to this app's API and its OAuth server."""
server.mcp.settings.stateless_http = True
server.mcp.settings.json_response = True
server.mcp.settings.auth = AuthSettings(
issuer_url=AnyHttpUrl(settings.oauth_issuer),
resource_server_url=AnyHttpUrl(settings.mcp_resource),
required_scopes=None,
)
server.mcp.settings.transport_security = _transport_security()
# The SDK only takes a verifier through its constructor, and the instance is
# module-level so the tools can be declared at import time.
server.mcp._token_verifier = _JWTVerifier() # noqa: SLF001
server.connect(app)
return _ThroughThePortal(server.mcp.streamable_http_app())