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

@@ -15,19 +15,40 @@ Example from an AI assistant:
import json
import logging
from typing import Optional
from typing import Callable, Optional
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from core.gateway import AIPSTNGateway
logger = logging.getLogger(__name__)
def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
"""Create and configure the MCP server with all tools and resources."""
def create_mcp_server(
get_gateway: Callable[[], Optional[AIPSTNGateway]],
api_token: str = "",
) -> FastMCP:
"""
Create and configure the MCP server with all tools and resources.
mcp = FastMCP("Hold Slayer Gateway")
The gateway is resolved lazily per request via `get_gateway` so the
server can be mounted at app construction, before the lifespan has
started the gateway.
"""
auth = None
if api_token:
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
auth = StaticTokenVerifier(tokens={api_token: {"client_id": "hold-slayer"}})
mcp = FastMCP("Hold Slayer Gateway", auth=auth)
def require_gateway() -> AIPSTNGateway:
gateway = get_gateway()
if gateway is None:
raise ToolError("Gateway is still starting up — try again shortly.")
return gateway
# ================================================================
# Tools
@@ -42,7 +63,10 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
device: str = "",
) -> str:
"""
Place an outbound phone call.
Place a REAL outbound phone call over the PSTN. The remote party's
phone actually rings and the call may incur telephony charges —
only use this when the user has asked for a call to be placed.
Emergency numbers (911/112) are always refused.
Args:
number: Phone number to call (E.164 format, e.g., +18005551234)
@@ -56,19 +80,23 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
"""
from models.call import CallMode
gateway = require_gateway()
mode_map = {
"direct": CallMode.DIRECT,
"hold_slayer": CallMode.HOLD_SLAYER,
"ai_assisted": CallMode.AI_ASSISTED,
}
call = await gateway.make_call(
number=number,
mode=mode_map.get(mode, CallMode.DIRECT),
intent=intent or None,
call_flow_id=call_flow_id or None,
device=device or None,
)
try:
call = await gateway.make_call(
number=number,
mode=mode_map.get(mode, CallMode.DIRECT),
intent=intent or None,
call_flow_id=call_flow_id or None,
device=device or None,
)
except ValueError as e:
raise ToolError(str(e))
return (
f"Call {call.id} initiated.\n"
f" Number: {number}\n"
@@ -85,6 +113,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
Shows: status, duration, hold time, current audio type, recent transcript.
"""
gateway = require_gateway()
call = gateway.get_call(call_id)
if not call:
return f"Call {call_id} not found. It may have already ended."
@@ -113,6 +142,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
call_id: The call to transfer
device: Target device ID (e.g., "sip_phone", "cell")
"""
gateway = require_gateway()
try:
await gateway.transfer_call(call_id, device)
return f"Call {call_id} transferred to {device}."
@@ -122,6 +152,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
@mcp.tool()
async def hangup(call_id: str) -> str:
"""Hang up a call."""
gateway = require_gateway()
try:
await gateway.hangup_call(call_id)
return f"Call {call_id} hung up."
@@ -131,6 +162,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
@mcp.tool()
async def list_active_calls() -> str:
"""List all currently active calls with their status."""
gateway = require_gateway()
calls = gateway.call_manager.active_calls
if not calls:
return "No active calls."
@@ -223,7 +255,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
id=flow_id,
name=name,
phone_number=phone_number,
description=f"Created by AI assistant",
description="Created by AI assistant",
steps=steps,
notes=notes or None,
tags=["ai-created"],
@@ -246,6 +278,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
call_id: The call to send tones on
digits: DTMF digits to send (e.g., "1", "2", "123#")
"""
gateway = require_gateway()
call = gateway.get_call(call_id)
if not call:
return f"Call {call_id} not found."
@@ -264,6 +297,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
Returns the complete transcript text.
"""
gateway = require_gateway()
call = gateway.get_call(call_id)
if not call:
return f"Call {call_id} not found."
@@ -402,38 +436,10 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
except Exception as e:
return f"Error searching call history: {e}"
@mcp.tool()
async def learn_call_flow(call_id: str, name: str = "") -> str:
"""
Learn a call flow from a completed call's event history.
Analyzes the IVR navigation events from a call to build a
reusable call flow for next time.
Args:
call_id: The call to learn from
name: Optional name for the flow (auto-generated if empty)
"""
from services.call_flow_learner import CallFlowLearner
try:
learner = CallFlowLearner(gateway.event_bus, gateway.settings)
flow = await learner.learn_from_call(call_id, name or None)
if flow:
return (
f"Learned call flow '{flow.name}' from call {call_id}:\n"
f" Phone: {flow.phone_number}\n"
f" Steps: {len(flow.steps)}\n"
f" Flow ID: {flow.id}"
)
return f"Could not learn a call flow from call {call_id}. Not enough IVR navigation data."
except Exception as e:
return f"Error learning call flow: {e}"
@mcp.tool()
async def list_devices() -> str:
"""List all registered devices and their online/offline status."""
devices = gateway.devices
devices = require_gateway().devices
if not devices:
return "No devices registered."
@@ -446,7 +452,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
@mcp.tool()
async def gateway_status() -> str:
"""Get full gateway status — trunk, devices, active calls, uptime."""
status = await gateway.status()
status = await require_gateway().status()
trunk = status["trunk"]
lines = [
@@ -470,7 +476,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
@mcp.resource("gateway://status")
async def resource_gateway_status() -> str:
"""Current gateway status — trunk, devices, active calls."""
status = await gateway.status()
status = await require_gateway().status()
return json.dumps(status, default=str, indent=2)
@mcp.resource("gateway://call-flows")
@@ -502,7 +508,7 @@ def create_mcp_server(gateway: AIPSTNGateway) -> FastMCP:
@mcp.resource("gateway://active-calls")
async def resource_active_calls() -> str:
"""All currently active calls."""
calls = gateway.call_manager.active_calls
calls = require_gateway().call_manager.active_calls
return json.dumps(
[c.summary() for c in calls.values()],
default=str,