feat: mount MCP server, add bearer auth, and guard outbound calls
The MCP server was created but never mounted — no client could reach
it. Mount it at /mcp/ over streamable HTTP with a combined lifespan,
resolving the gateway lazily so mounting happens at app construction.
Security and safety for the agent surface:
- One static API_TOKEN (SecretStr) enforced across REST (dependency),
WebSocket (query param/header before accept), and MCP
(StaticTokenVerifier). Startup refuses tokenless non-loopback binds.
- Emergency numbers (911/9911/112) always refused on make_call, plus a
MAX_CONCURRENT_CALLS cap; ValueError surfaces as 400/ToolError.
- Safe defaults: debug off, no credential in default DATABASE_URL,
SIP/LLM/TTS secrets as SecretStr.
Cleanups:
- Delete broken learn_call_flow tool (wrong ctor args, nonexistent
method) and the never-fed CallAnalytics service; keep
call_flow_learner for proper wiring later.
- Trim dial_plan to what is actually used (emergency guard, extension
allocation); delete the unreferenced matcher/normaliser.
- Register call_history before calls so /api/calls/history is no
longer shadowed by /api/calls/{call_id}.
- fastmcp pinned >=3.0 (http_app + StaticTokenVerifier).
New tests: MCP in-memory client (tool surface, lazy gateway, emergency
refusal, call cap) and API security (401 paths, route order, mount).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
159
main.py
159
main.py
@@ -15,11 +15,12 @@ import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import call_flows, call_history, calls, devices, routing, websocket
|
||||
from config import get_settings
|
||||
from api.deps import require_token
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import close_db, init_db
|
||||
from mcp_server.server import create_mcp_server
|
||||
@@ -84,76 +85,105 @@ def _handle_db_error(exc: Exception) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _check_startup_config(settings: Settings) -> None:
|
||||
"""Refuse insecure or incomplete configurations before booting anything."""
|
||||
if not settings.database_url:
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ DATABASE_URL is not set.\n"
|
||||
" Add it to your .env file, e.g.:\n"
|
||||
" DATABASE_URL=postgresql+asyncpg://holdslayer:<password>@localhost:5432/holdslayer"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
token = settings.api_token.get_secret_value()
|
||||
if not token and settings.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ API_TOKEN is not set but HOST binds beyond loopback "
|
||||
f"({settings.host}).\n"
|
||||
" Every surface (REST, WebSocket, MCP make_call) would be open "
|
||||
"to the network.\n"
|
||||
" Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), or set "
|
||||
"HOST=127.0.0.1 for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: Initialize database, SIP engine, and services."""
|
||||
settings = get_settings()
|
||||
_check_startup_config(settings)
|
||||
|
||||
# Initialize database
|
||||
logger.info("Initializing database...")
|
||||
try:
|
||||
await init_db()
|
||||
except Exception as e:
|
||||
_handle_db_error(e)
|
||||
# The MCP session manager lives in the mounted sub-app's lifespan;
|
||||
# without entering it, every /mcp request 500s.
|
||||
async with mcp_http_app.lifespan(app):
|
||||
# Initialize database
|
||||
logger.info("Initializing database...")
|
||||
try:
|
||||
await init_db()
|
||||
except Exception as e:
|
||||
_handle_db_error(e)
|
||||
|
||||
# Boot the telephony engine
|
||||
gateway = AIPSTNGateway.from_config()
|
||||
await gateway.start()
|
||||
app.state.gateway = gateway
|
||||
# Boot the telephony engine
|
||||
gateway = AIPSTNGateway.from_config()
|
||||
await gateway.start()
|
||||
app.state.gateway = gateway
|
||||
|
||||
# Start auxiliary services
|
||||
from services.notification import NotificationService
|
||||
from services.recording import RecordingService
|
||||
from services.call_analytics import CallAnalytics
|
||||
from services.call_flow_learner import CallFlowLearner
|
||||
# Start auxiliary services
|
||||
from services.notification import NotificationService
|
||||
from services.recording import RecordingService
|
||||
|
||||
notification_svc = NotificationService(gateway.event_bus, settings)
|
||||
await notification_svc.start()
|
||||
app.state.notification_service = notification_svc
|
||||
notification_svc = NotificationService(gateway.event_bus, settings)
|
||||
await notification_svc.start()
|
||||
app.state.notification_service = notification_svc
|
||||
|
||||
recording_svc = RecordingService()
|
||||
await recording_svc.start()
|
||||
app.state.recording_service = recording_svc
|
||||
gateway._recording_service = recording_svc
|
||||
recording_svc = RecordingService()
|
||||
await recording_svc.start()
|
||||
app.state.recording_service = recording_svc
|
||||
gateway._recording_service = recording_svc
|
||||
|
||||
analytics_svc = CallAnalytics()
|
||||
app.state.analytics_service = analytics_svc
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔥 Hold Slayer Gateway is LIVE")
|
||||
# Show a usable URL — 0.0.0.0 is the bind address, not a browser URL
|
||||
display_host = "localhost" if settings.host in ("0.0.0.0", "::") else settings.host
|
||||
# When launched via `uvicorn main:app --port XXXX`, the CLI --port arg
|
||||
# takes precedence over settings.port (which comes from .env).
|
||||
display_port = settings.port
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg in ("--port", "-p") and i + 1 < len(sys.argv):
|
||||
try:
|
||||
display_port = int(sys.argv[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
auth_state = "bearer token required" if settings.api_token.get_secret_value() else "auth disabled (loopback)"
|
||||
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
|
||||
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
||||
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
||||
logger.info(f" MCP: http://{display_host}:{display_port}/mcp/ (streamable HTTP)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
flow_learner = CallFlowLearner()
|
||||
app.state.flow_learner = flow_learner
|
||||
yield
|
||||
|
||||
# Create and mount MCP server
|
||||
mcp = create_mcp_server(gateway)
|
||||
app.state.mcp = mcp
|
||||
# Shutdown
|
||||
logger.info("Shutting down Hold Slayer Gateway...")
|
||||
await notification_svc.stop()
|
||||
await gateway.stop()
|
||||
await close_db()
|
||||
logger.info("Gateway shut down cleanly. 👋")
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔥 Hold Slayer Gateway is LIVE")
|
||||
# Show a usable URL — 0.0.0.0 is the bind address, not a browser URL
|
||||
display_host = "localhost" if settings.host in ("0.0.0.0", "::") else settings.host
|
||||
# When launched via `uvicorn main:app --port XXXX`, the CLI --port arg
|
||||
# takes precedence over settings.port (which comes from .env).
|
||||
display_port = settings.port
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg in ("--port", "-p") and i + 1 < len(sys.argv):
|
||||
try:
|
||||
display_port = int(sys.argv[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
logger.info(f" API: http://{display_host}:{display_port}")
|
||||
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
||||
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
||||
logger.info(f" MCP: Available via FastMCP")
|
||||
logger.info("=" * 60)
|
||||
|
||||
yield
|
||||
def _get_gateway_instance() -> AIPSTNGateway | None:
|
||||
"""Lazy gateway resolver for MCP tools (set on app.state by the lifespan)."""
|
||||
return getattr(app.state, "gateway", None)
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down Hold Slayer Gateway...")
|
||||
await notification_svc.stop()
|
||||
await gateway.stop()
|
||||
await close_db()
|
||||
logger.info("Gateway shut down cleanly. 👋")
|
||||
|
||||
mcp = create_mcp_server(
|
||||
_get_gateway_instance,
|
||||
api_token=get_settings().api_token.get_secret_value(),
|
||||
)
|
||||
mcp_http_app = mcp.http_app(path="/")
|
||||
|
||||
app = FastAPI(
|
||||
title="Hold Slayer Gateway",
|
||||
@@ -171,13 +201,20 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# === API Routes ===
|
||||
app.include_router(calls.router, prefix="/api/calls", tags=["Calls"])
|
||||
app.include_router(call_history.router, prefix="/api/calls", tags=["Call History"])
|
||||
app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"])
|
||||
app.include_router(devices.router, prefix="/api/devices", tags=["Devices"])
|
||||
app.include_router(routing.router, prefix="/api/routing", tags=["Routing"])
|
||||
# call_history must register before calls: both live under /api/calls and
|
||||
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
||||
_auth = [Depends(require_token)]
|
||||
app.include_router(call_history.router, prefix="/api/calls", tags=["Call History"], dependencies=_auth)
|
||||
app.include_router(calls.router, prefix="/api/calls", tags=["Calls"], dependencies=_auth)
|
||||
app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"], dependencies=_auth)
|
||||
app.include_router(devices.router, prefix="/api/devices", tags=["Devices"], dependencies=_auth)
|
||||
app.include_router(routing.router, prefix="/api/routing", tags=["Routing"], dependencies=_auth)
|
||||
# WebSocket endpoints check the token themselves (query param or header)
|
||||
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
||||
app.mount("/mcp", mcp_http_app)
|
||||
|
||||
# === Dashboard (built SvelteKit static) ===
|
||||
import os as _os
|
||||
_dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build")
|
||||
|
||||
Reference in New Issue
Block a user