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:
2026-07-09 15:20:24 -04:00
parent 9a84987796
commit 94fb6cd79d
16 changed files with 498 additions and 383 deletions

View File

@@ -2,10 +2,12 @@
import asyncio
import logging
import secrets
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.deps import get_gateway
from config import get_settings
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
@@ -13,6 +15,26 @@ logger = logging.getLogger(__name__)
router = APIRouter()
async def _authorize(websocket: WebSocket) -> bool:
"""
Check the static bearer token before accepting the socket.
Browsers can't set headers on WebSocket connects, so a `token`
query parameter is accepted alongside the Authorization header.
"""
token = get_settings().api_token.get_secret_value()
if not token:
return True
supplied = websocket.query_params.get("token", "")
auth = websocket.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
supplied = auth[7:]
if secrets.compare_digest(supplied, token):
return True
await websocket.close(code=4401, reason="Missing or invalid bearer token")
return False
async def _send_trunk_status(websocket: WebSocket, gateway) -> None:
"""Send current SIP trunk status as a synthetic event to a newly connected client."""
try:
@@ -58,6 +80,8 @@ async def event_stream(websocket: WebSocket):
"message": "🚨 Human detected!"
}
"""
if not await _authorize(websocket):
return
await websocket.accept()
logger.info("WebSocket client connected")
@@ -90,6 +114,8 @@ async def call_event_stream(websocket: WebSocket, call_id: str):
Same format as /events but only sends events for the specified call.
"""
if not await _authorize(websocket):
return
await websocket.accept()
logger.info(f"WebSocket client connected for call {call_id}")