Let agents drive the flow API over MCP
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

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>
This commit is contained in:
Melvin Strobl
2026-08-16 00:22:41 +02:00
co-authored by Claude Fable 5
parent 3724b68f23
commit 8d82d6c4ec
28 changed files with 2459 additions and 555 deletions
+4
View File
@@ -9,3 +9,7 @@ package — keeps a test run from touching the development data.
import os
os.environ["POSTGRES_DB"] = "app_test"
# The MCP session manager can only be entered once per instance, and the suite
# builds a TestClient — and so a lifespan — per test module. Tests that want the
# endpoint mount it themselves.
os.environ["MCP_ENABLED"] = "false"
+226
View File
@@ -0,0 +1,226 @@
"""The authorization flow an agent goes through to reach the MCP endpoint."""
import base64
import hashlib
import secrets
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
PREFIX = f"{settings.API_V1_STR}/oauth"
REDIRECT = "http://127.0.0.1:41234/callback"
@pytest.fixture(autouse=True)
def mcp_on(monkeypatch: pytest.MonkeyPatch) -> None:
"""The OAuth endpoints only answer when the MCP endpoint is wanted."""
monkeypatch.setattr(settings, "MCP_ENABLED", True)
def pkce() -> tuple[str, str]:
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def register(client: TestClient, **overrides) -> dict:
body = {"client_name": "Test agent", "redirect_uris": [REDIRECT], **overrides}
return client.post(f"{PREFIX}/register", json=body).json()
def approve(
client: TestClient, headers: dict[str, str], client_id: str, challenge: str
) -> str:
"""Walk the consent step and return the code it hands back."""
response = client.post(
f"{PREFIX}/authorize",
headers=headers,
json={
"client_id": client_id,
"redirect_uri": REDIRECT,
"code_challenge": challenge,
"code_challenge_method": "S256",
"resource": settings.mcp_resource,
},
)
assert response.status_code == 200, response.text
url = response.json()["redirect_url"]
return parse_qs(urlparse(url).query)["code"][0]
def exchange(client: TestClient, client_id: str, code: str, verifier: str):
return client.post(
f"{PREFIX}/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT,
"client_id": client_id,
"code_verifier": verifier,
},
)
def test_the_metadata_says_how_to_get_in(client: TestClient) -> None:
document = client.get("/.well-known/oauth-authorization-server").json()
assert document["issuer"] == settings.oauth_issuer
assert document["code_challenge_methods_supported"] == ["S256"]
assert document["token_endpoint_auth_methods_supported"] == ["none"]
assert set(document["grant_types_supported"]) == {
"authorization_code",
"refresh_token",
}
def test_a_full_authorization_reaches_the_api(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
verifier, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
tokens = exchange(client, registered["client_id"], code, verifier).json()
assert tokens["token_type"] == "Bearer"
assert tokens["scope"] == "mcp"
# The token works against the ordinary API, as the user who approved it.
whoami = client.post(
f"{settings.API_V1_STR}/login/test-token",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert whoami.status_code == 200
def test_a_wrong_verifier_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
_, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
response = exchange(client, registered["client_id"], code, secrets.token_urlsafe(48))
assert response.status_code == 400
assert response.json()["error"] == "invalid_grant"
def test_replaying_a_code_withdraws_what_it_produced(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
verifier, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
first = exchange(client, registered["client_id"], code, verifier).json()
replay = exchange(client, registered["client_id"], code, verifier)
assert replay.status_code == 400
assert replay.json()["error"] == "invalid_grant"
# The refresh token the first exchange handed out is no longer any good:
# the replay says someone else may be holding a copy.
refreshed = client.post(
f"{PREFIX}/token",
data={
"grant_type": "refresh_token",
"refresh_token": first["refresh_token"],
"client_id": registered["client_id"],
},
)
assert refreshed.status_code == 400
def test_a_refresh_token_rotates_and_cannot_be_reused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
verifier, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
first = exchange(client, registered["client_id"], code, verifier).json()
refresh = lambda token: client.post( # noqa: E731
f"{PREFIX}/token",
data={
"grant_type": "refresh_token",
"refresh_token": token,
"client_id": registered["client_id"],
},
)
second = refresh(first["refresh_token"])
assert second.status_code == 200
assert second.json()["refresh_token"] != first["refresh_token"]
# Presenting the spent one again is what a stolen token looks like.
assert refresh(first["refresh_token"]).status_code == 400
assert refresh(second.json()["refresh_token"]).status_code == 400
def test_an_unregistered_redirect_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
_, challenge = pkce()
response = client.post(
f"{PREFIX}/authorize",
headers=superuser_token_headers,
json={
"client_id": registered["client_id"],
"redirect_uri": "https://somewhere.else/callback",
"code_challenge": challenge,
"code_challenge_method": "S256",
},
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_request"
def test_registration_refuses_plain_http_off_the_loopback(
client: TestClient,
) -> None:
response = client.post(
f"{PREFIX}/register",
json={"client_name": "Nope", "redirect_uris": ["http://example.com/cb"]},
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_redirect_uri"
def test_approving_needs_a_signed_in_user(client: TestClient) -> None:
registered = register(client)
_, challenge = pkce()
response = client.post(
f"{PREFIX}/authorize",
json={
"client_id": registered["client_id"],
"redirect_uri": REDIRECT,
"code_challenge": challenge,
"code_challenge_method": "S256",
},
)
assert response.status_code == 401
def test_an_unsupported_grant_says_so(client: TestClient) -> None:
response = client.post(
f"{PREFIX}/token", data={"grant_type": "client_credentials"}
)
assert response.status_code == 400
assert response.json()["error"] == "unsupported_grant_type"
def test_everything_is_refused_while_mcp_is_off(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "MCP_ENABLED", False)
assert client.post(f"{PREFIX}/register", json={"redirect_uris": [REDIRECT]}).status_code == 403
assert (
client.post(f"{PREFIX}/token", data={"grant_type": "authorization_code"}).status_code
== 403
)
View File
+123
View File
@@ -0,0 +1,123 @@
"""The MCP endpoint: who it lets in, and what happens once they are in."""
import asyncio
import uuid
from collections.abc import Awaitable, Callable
from datetime import timedelta
from typing import Any
import httpx
import pytest
from fastapi.testclient import TestClient
from app.core import security
from app.core.config import settings
from app.main import app
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
def mcp_token(user_id: uuid.UUID) -> str:
return security.create_oauth_access_token(
user_id, uuid.uuid4(), timedelta(minutes=5)
)
@pytest.fixture
def over_mcp(monkeypatch: pytest.MonkeyPatch):
"""Run a block of calls against a live MCP endpoint.
The session manager and the requests have to share one event loop, so the
whole exchange happens inside a single ``asyncio.run``.
"""
monkeypatch.setattr(settings, "MCP_ENABLED", True)
from app.mcp import http as mcp_http
from app.mcp import server as mcp_server
# A FastMCP instance enters its session manager once, and another test in
# the run may have built one already.
monkeypatch.setattr(mcp_server.mcp, "_session_manager", None, raising=False)
mcp_app = mcp_http.build_http_app(app)
def run(block: Callable[[httpx.AsyncClient], Awaitable[Any]]) -> Any:
async def main() -> Any:
async with mcp_server.mcp.session_manager.run():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_app),
base_url="http://api.localhost",
) as client:
return await block(client)
return asyncio.run(main())
return run
def rpc(token: str | None, method: str, **params: Any) -> dict[str, Any]:
headers = dict(MCP_HEADERS)
if token:
headers["Authorization"] = f"Bearer {token}"
body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": method}
if params:
body["params"] = params
return {"json": body, "headers": headers}
def test_an_unauthenticated_call_says_where_to_authenticate(over_mcp) -> None:
async def block(client: httpx.AsyncClient) -> httpx.Response:
return await client.post("/mcp", **rpc(None, "tools/list"))
response = over_mcp(block)
assert response.status_code == 401
assert "resource_metadata=" in response.headers.get("www-authenticate", "")
def test_the_resource_metadata_names_the_authorization_server(over_mcp) -> None:
async def block(client: httpx.AsyncClient) -> httpx.Response:
return await client.get("/.well-known/oauth-protected-resource/mcp")
document = over_mcp(block).json()
# The SDK normalises the URL, so compare without the trailing slash.
listed = [url.rstrip("/") for url in document["authorization_servers"]]
assert settings.oauth_issuer in listed
def test_a_browser_token_is_not_an_agent_token(
over_mcp, superuser_token_headers: dict[str, str]
) -> None:
session_token = superuser_token_headers["Authorization"].removeprefix("Bearer ")
async def block(client: httpx.AsyncClient) -> httpx.Response:
return await client.post("/mcp", **rpc(session_token, "tools/list"))
# It validates perfectly well against the API; it is refused here because
# it was issued for a person's session, not for an agent.
assert over_mcp(block).status_code == 401
def test_an_agent_can_list_and_call_tools(
over_mcp, client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
me = client.get(
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
).json()
token = mcp_token(uuid.UUID(me["id"]))
async def block(http: httpx.AsyncClient) -> tuple[Any, Any]:
listed = await http.post("/mcp", **rpc(token, "tools/list"))
called = await http.post(
"/mcp",
**rpc(token, "tools/call", name="list_flows", arguments={}),
)
return listed, called
listed, called = over_mcp(block)
assert listed.status_code == 200
names = {tool["name"] for tool in listed.json()["result"]["tools"]}
assert {"list_flows", "save_flow", "publish_flow", "run_flow"} <= names
# The call reached the real API, carrying the agent's own token.
assert called.status_code == 200
assert "error" not in called.json()
assert called.json()["result"]["isError"] is False