import asyncio import contextlib from collections.abc import AsyncIterator 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.api.routes.alerts import read_config as read_alerts_config from app.core import security from app.core.config import settings from app.flow import logs from app.flow.alerts import AlertManager from app.flow.controller import FlowController from app.flow.dashboards import DashboardStore from app.flow.events import event_bus from app.flow.executor import ExecutionService from app.flow.nodes.http import close_shared_client from app.flow.plugins import load_plugins from app.flow.queue import MemoryWorkQueue, RedisWorkQueue, WorkQueue from app.flow.secrets import init_secrets from app.flow.state import MemoryState, RedisState, StateBackend from app.flow.store import FlowStore from app.flow.watchdog import LoopWatchdog def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) def _state_backend() -> StateBackend: if settings.REDIS_HOST: return RedisState(host=settings.REDIS_HOST, port=settings.REDIS_PORT) return MemoryState() def _work_queue() -> WorkQueue: """Redis makes queued work survive the process; memory does not pretend to.""" if settings.REDIS_HOST: return RedisWorkQueue(host=settings.REDIS_HOST, port=settings.REDIS_PORT) return MemoryWorkQueue() 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.""" event_bus.bind(asyncio.get_running_loop()) # Node code is user code, and `print` is how it says things. logs.install() init_secrets(settings.SECRETS_FILE, settings.SECRET_KEY) # Connectors register their node types before any flow is built with them. load_plugins() alerts = AlertManager(event_bus, config=read_alerts_config()) execution = ExecutionService( queue=_work_queue(), max_workers=settings.FLOW_MAX_WORKERS, events=event_bus, ) controller = FlowController( store=FlowStore(settings.FLOWS_DIR), state=_state_backend(), events=event_bus, max_workers=settings.FLOW_MAX_WORKERS, fastapi_app=app, execution=execution, alerts=alerts, ) app.state.flow_controller = controller dashboards = DashboardStore(controller.store) app.state.dashboard_store = dashboards controller.dashboards = dashboards # Charts need a deeper series than the default; tell the engine # before it starts recording. controller.set_history_limits(dashboards.history_requirements()) watchdog = LoopWatchdog(event_bus) app.state.watchdog = watchdog watchdog_task = asyncio.create_task(watchdog.run(), name="loop-watchdog") alerts_task = asyncio.create_task(alerts.run(), name="alert-manager") await controller.start() try: # 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: watchdog_task.cancel() alerts_task.cancel() await controller.stop() close_shared_client() if settings.MCP_ENABLED: from app.mcp.http import aclose await aclose() app = FastAPI( title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, lifespan=lifespan, ) # Set all CORS enabled origins if settings.all_cors_origins: app.add_middleware( CORSMiddleware, allow_origins=settings.all_cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) 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))