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

@@ -11,7 +11,7 @@ from typing import Optional
from config import Settings, get_settings
from core.call_manager import CallManager
from core.dial_plan import next_extension
from core.dial_plan import is_emergency_number, next_extension
from core.event_bus import EventBus
from core.media_pipeline import MediaPipeline
from core.sip_engine import MockSIPEngine, SIPEngine
@@ -52,7 +52,7 @@ def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine
trunk_host=trunk.host,
trunk_port=trunk.port,
trunk_username=trunk.username,
trunk_password=trunk.password,
trunk_password=trunk.password.get_secret_value(),
trunk_transport=trunk.transport,
domain=gw_sip.domain,
did=trunk.did,
@@ -228,6 +228,19 @@ class AIPSTNGateway:
- hold_slayer: Navigate IVR, wait on hold, transfer when human detected
- ai_assisted: Connect with transcription, recording, noise cancel
"""
if is_emergency_number(number):
raise ValueError(
f"Refusing to dial emergency number '{number}'. Emergency calls "
"must be placed from a phone with E911 location service, not "
"through the gateway API."
)
active = len(self.call_manager.active_calls)
if active >= self.settings.max_concurrent_calls:
raise ValueError(
f"Concurrent-call limit reached ({active}/{self.settings.max_concurrent_calls}). "
"End an active call or raise MAX_CONCURRENT_CALLS."
)
# Create call in manager
call = await self.call_manager.create_call(
remote_number=number,