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.
This commit is contained in:
+72
-2
@@ -1,13 +1,16 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.main import api_router
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.flow import logs
|
||||
from app.flow.controller import FlowController
|
||||
@@ -32,6 +35,15 @@ def _state_backend() -> StateBackend:
|
||||
return MemoryState()
|
||||
|
||||
|
||||
def _mcp_sessions() -> AbstractAsyncContextManager[None]:
|
||||
"""The MCP session manager's run scope, or nothing when MCP is off."""
|
||||
if not settings.MCP_ENABLED:
|
||||
return contextlib.nullcontext()
|
||||
from app.mcp.server import mcp as mcp_server
|
||||
|
||||
return mcp_server.session_manager.run()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Start the flow engine alongside the API."""
|
||||
@@ -52,9 +64,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app.state.flow_controller = controller
|
||||
await controller.start()
|
||||
try:
|
||||
yield
|
||||
# A mounted sub-app gets no lifespan of its own, so the MCP session
|
||||
# manager is entered here; without it every /mcp request fails.
|
||||
async with _mcp_sessions():
|
||||
yield
|
||||
finally:
|
||||
await controller.stop()
|
||||
if settings.MCP_ENABLED:
|
||||
from app.mcp.http import aclose
|
||||
|
||||
await aclose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -75,3 +94,54 @@ if settings.all_cors_origins:
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
# Tagged because the operation-id builder reads the first tag; the route
|
||||
# itself stays out of the schema.
|
||||
@app.get(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
include_in_schema=False,
|
||||
tags=["oauth"],
|
||||
)
|
||||
def oauth_authorization_server() -> JSONResponse:
|
||||
"""RFC 8414 metadata, so an agent can find its way in unaided.
|
||||
|
||||
The authorization endpoint is the dashboard rather than the API: approving
|
||||
a client needs a signed-in human, and the browser session lives there.
|
||||
"""
|
||||
issuer = settings.oauth_issuer
|
||||
return JSONResponse(
|
||||
content={
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": (
|
||||
f"{settings.FRONTEND_HOST.rstrip('/')}/oauth/authorize"
|
||||
),
|
||||
"token_endpoint": f"{issuer}{settings.API_V1_STR}/oauth/token",
|
||||
"registration_endpoint": f"{issuer}{settings.API_V1_STR}/oauth/register",
|
||||
"jwks_uri": f"{issuer}/.well-known/jwks.json",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"scopes_supported": [security.MCP_SCOPE],
|
||||
},
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/.well-known/jwks.json", include_in_schema=False, tags=["oauth"])
|
||||
def jwks() -> JSONResponse:
|
||||
"""The public half of the MCP signing key."""
|
||||
return JSONResponse(
|
||||
content=security.public_jwks(),
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
# Mounted last, and at the root: the SDK serves both /mcp and the protected
|
||||
# resource metadata that has to sit beside it, so mounting under /mcp would put
|
||||
# that metadata somewhere no client looks for it.
|
||||
if settings.MCP_ENABLED:
|
||||
from app.mcp.http import build_http_app
|
||||
|
||||
app.mount("/", build_http_app(app))
|
||||
|
||||
Reference in New Issue
Block a user