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:
@@ -46,6 +46,8 @@ async def make_call(
|
||||
number=request.number,
|
||||
mode=request.mode.value,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -86,6 +88,8 @@ async def hold_slayer(
|
||||
mode="hold_slayer",
|
||||
message="Hold Slayer activated. I'll ring you when a human picks up. ☕",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
28
api/deps.py
28
api/deps.py
@@ -2,11 +2,12 @@
|
||||
API Dependencies — Shared dependency injection for all routes.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import secrets
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
from config import get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import get_db
|
||||
|
||||
|
||||
def get_gateway(request: Request) -> AIPSTNGateway:
|
||||
@@ -15,3 +16,24 @@ def get_gateway(request: Request) -> AIPSTNGateway:
|
||||
if gateway is None:
|
||||
raise HTTPException(status_code=503, detail="Gateway not initialized")
|
||||
return gateway
|
||||
|
||||
|
||||
def require_token(authorization: str | None = Header(default=None)) -> None:
|
||||
"""
|
||||
Enforce the static bearer token (API_TOKEN) on REST routes.
|
||||
|
||||
An empty configured token disables auth; startup refuses that
|
||||
combination unless the server is bound to loopback.
|
||||
"""
|
||||
token = get_settings().api_token.get_secret_value()
|
||||
if not token:
|
||||
return
|
||||
supplied = ""
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
supplied = authorization[7:]
|
||||
if not secrets.compare_digest(supplied, token):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing or invalid bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user