Add authenticated remote MCP HTTP entrypoint (#10)

Add a bearer-protected streamable HTTP MCP ASGI entrypoint for production deployment.

Includes remote host/origin configuration, health checks, fallback token handling, documentation, and tests covering authentication behavior.

Verified with full local pytest suite and GitHub CI. Copilot review feedback addressed before merge.
This commit is contained in:
Christian Krakau-Louis
2026-05-21 19:06:43 +02:00
committed by GitHub
parent 74e5e5b531
commit ed57d84199
3 changed files with 171 additions and 0 deletions
+10
View File
@@ -13,6 +13,16 @@ pip install -r requirements.txt
python -m musicround.mcp_server
```
For a production streamable HTTP endpoint, run the authenticated ASGI entrypoint:
```bash
MCP_BEARER_TOKEN=... uvicorn musicround.mcp_http:app --host 0.0.0.0 --port 8000
```
If `MCP_BEARER_TOKEN` is not set, the HTTP entrypoint falls back to
`AUTOMATION_TOKEN`. Set `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS` when the
server is exposed behind a reverse proxy or ingress.
The server uses the normal Quizzical Beats Flask configuration. Set the same
environment variables you use for the web app, including `SECRET_KEY`,
`AUTOMATION_TOKEN`, database configuration, mail settings, and any Spotify,
+92
View File
@@ -0,0 +1,92 @@
"""Authenticated streamable HTTP entrypoint for the Quizzical Beats MCP server."""
from __future__ import annotations
import os
from secrets import compare_digest
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from musicround.mcp_server import mcp
_BASE_ALLOWED_HOSTS = tuple(mcp.settings.transport_security.allowed_hosts)
_BASE_ALLOWED_ORIGINS = tuple(mcp.settings.transport_security.allowed_origins)
class BearerAuthMiddleware:
"""Require a bearer token for MCP HTTP traffic."""
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
if scope.get("path") == "/healthz":
await JSONResponse({"ok": True})(scope, receive, send)
return
expected = os.getenv("MCP_BEARER_TOKEN") or os.getenv("AUTOMATION_TOKEN")
if not expected:
await JSONResponse(
{"error": "MCP bearer token is not configured."},
status_code=500,
)(scope, receive, send)
return
headers = dict(scope.get("headers", []))
authorization = headers.get(b"authorization", b"").decode("latin1")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not compare_digest(token.strip(), expected):
await JSONResponse(
{"error": "Unauthorized."},
headers={"WWW-Authenticate": "Bearer"},
status_code=401,
)(scope, receive, send)
return
await self.app(scope, receive, send)
def _configure_server() -> None:
host = os.getenv("MCP_HOST", "0.0.0.0")
port = int(os.getenv("MCP_PORT", "8000"))
mcp.settings.host = host
mcp.settings.port = port
allowed_hosts = [
value.strip()
for value in os.getenv("MCP_ALLOWED_HOSTS", "qb.kaufdeinquiz.com").split(",")
if value.strip()
]
allowed_origins = [
value.strip()
for value in os.getenv(
"MCP_ALLOWED_ORIGINS", "https://qb.kaufdeinquiz.com"
).split(",")
if value.strip()
]
security = mcp.settings.transport_security
security.allowed_hosts = list(dict.fromkeys([*_BASE_ALLOWED_HOSTS, *allowed_hosts]))
security.allowed_origins = list(
dict.fromkeys([*_BASE_ALLOWED_ORIGINS, *allowed_origins])
)
def build_app() -> ASGIApp:
"""Build the authenticated streamable HTTP MCP ASGI app."""
_configure_server()
return BearerAuthMiddleware(mcp.streamable_http_app())
app = build_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=mcp.settings.host, port=mcp.settings.port)
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the authenticated MCP HTTP entrypoint."""
import os
from starlette.responses import Response
from starlette.testclient import TestClient
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only")
os.environ.setdefault("AUTOMATION_TOKEN", "test-automation-token-for-testing")
from musicround.mcp_http import BearerAuthMiddleware, build_app, mcp # noqa: E402
async def _dummy_app(scope, receive, send):
await Response(status_code=204)(scope, receive, send)
def test_healthz_does_not_require_auth(monkeypatch):
monkeypatch.setenv("MCP_BEARER_TOKEN", "test-mcp-token")
client = TestClient(build_app())
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"ok": True}
def test_mcp_requires_bearer_token(monkeypatch):
monkeypatch.setenv("MCP_BEARER_TOKEN", "test-mcp-token")
client = TestClient(BearerAuthMiddleware(_dummy_app))
missing = client.get("/mcp")
wrong = client.get("/mcp", headers={"Authorization": "Bearer wrong"})
accepted = client.get("/mcp", headers={"Authorization": "Bearer test-mcp-token"})
assert missing.status_code == 401
assert missing.headers["WWW-Authenticate"] == "Bearer"
assert wrong.status_code == 401
assert accepted.status_code != 401
def test_mcp_falls_back_to_automation_token(monkeypatch):
monkeypatch.delenv("MCP_BEARER_TOKEN", raising=False)
monkeypatch.setenv("AUTOMATION_TOKEN", "automation-token")
client = TestClient(BearerAuthMiddleware(_dummy_app))
accepted = client.get("/mcp", headers={"Authorization": "Bearer automation-token"})
assert accepted.status_code == 204
def test_mcp_reports_missing_bearer_configuration(monkeypatch):
monkeypatch.delenv("MCP_BEARER_TOKEN", raising=False)
monkeypatch.delenv("AUTOMATION_TOKEN", raising=False)
client = TestClient(BearerAuthMiddleware(_dummy_app))
response = client.get("/mcp", headers={"Authorization": "Bearer anything"})
assert response.status_code == 500
def test_allowed_hosts_are_replaced_between_builds(monkeypatch):
monkeypatch.setenv("MCP_ALLOWED_HOSTS", "first.example")
build_app()
monkeypatch.setenv("MCP_ALLOWED_HOSTS", "second.example")
build_app()
assert "first.example" not in mcp.settings.transport_security.allowed_hosts
assert "second.example" in mcp.settings.transport_security.allowed_hosts