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>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user