Files
app/backend/fluksio/mcp/http.py
T
stroblmeandClaude Opus 5 60d7ec81c0 Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:48:05 +02:00

115 lines
4.1 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``.
"""
from __future__ import annotations
import logging
from urllib.parse import urlparse
import httpx
import jwt
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.mcp import server
logger = logging.getLogger(__name__)
#: The tool calls never leave the process, so the host is a label, not a route.
_INTERNAL_BASE = "http://fluksio-mcp.internal"
class _JWTVerifier:
"""Accept only tokens minted for the MCP channel.
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.
"""
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
return AccessToken(
token=token,
client_id=str(payload.get("client_id", "")),
scopes=[security.MCP_SCOPE],
subject=str(payload.get("sub", "")),
)
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"]
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=allowed_hosts,
allowed_origins=allowed_origins,
)
def build_http_app(app: object) -> Starlette:
"""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.set_client(
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url=_INTERNAL_BASE,
# Running a flow is synchronous work on a threadpool, so a slow
# flow must not look like a dead endpoint.
timeout=120.0,
event_hooks={"request": [server.forward_caller_auth]},
)
)
return server.mcp.streamable_http_app()
async def aclose() -> None:
if server._client is not None: # noqa: SLF001
await server._client.aclose() # noqa: SLF001
server.set_client(None)