Files
app/backend/app/mcp/http.py
T
Melvin StroblandClaude Fable 5 8d82d6c4ec
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Let agents drive the flow API over MCP
The engine now speaks MCP at /mcp, with a built-in OAuth 2.1 authorization
server in front of it: an agent registers itself, sends a human to the browser
to approve it, and exchanges the resulting code for a token. PKCE is required,
codes are single-use and stored only as hashes, the browser is redirected to
the URI that was registered rather than the one asked for, and refresh tokens
rotate so that replaying a spent one revokes the whole line.

Twenty tools cover reading, building, publishing and running flows, and each
one calls the same REST endpoint the dashboard calls, in-process, carrying the
caller's own token. That keeps one description of what a flow is and what may
be done to it — validation, the draft/publish split, the version check — and
means an agent can do nothing a person could not do in the browser.

Agent tokens are RS256 with a keypair of their own rather than the secret that
signs browser sessions, so deleting the key withdraws every agent without
logging anyone out, and deps.decode_token grew the branch that trusting a
second issuer will need when the hosted login arrives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 00:22:41 +02:00

109 lines
4.0 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 app.core import security
from app.core.config import settings
from app.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)