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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user