Files
app/backend/app/main.py
T
rootandClaude Fable 5 f8693daad6 Split the node types into a package, and stop reconnecting per message
nodes.py had grown to 2k lines holding every integration behind a single
blanket mypy exemption. It is now a package split by the outside world
each node talks to, so the exemption shrinks to the four integration
modules; base and mlp are type-checked, which turned up a dozen missing
annotations.

The senders opened a fresh connection — and, in the MQTT case, a fresh
thread pool and event loop — for every single message. HTTP senders now
share one pooled client, and a publisher holds one broker connection for
its lifetime, fed from a bounded queue that drops the oldest value when
the broker cannot keep up.

An HTTP sender also no longer trips over a JSON reply that is not an
object: outputs are keyed by port, so a bare scalar is a valid reply with
nothing to publish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:33:10 +02:00

155 lines
5.1 KiB
Python

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.core import security
from app.core.config import settings
from app.flow import logs
from app.flow.controller import FlowController
from app.flow.events import event_bus
from app.flow.nodes.http import close_shared_client
from app.flow.plugins import load_plugins
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 _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()
controller = FlowController(
store=FlowStore(settings.FLOWS_DIR),
state=_state_backend(),
events=event_bus,
max_workers=settings.FLOW_MAX_WORKERS,
fastapi_app=app,
)
app.state.flow_controller = controller
watchdog = LoopWatchdog(event_bus)
app.state.watchdog = watchdog
watchdog_task = asyncio.create_task(watchdog.run(), name="loop-watchdog")
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()
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))