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>
49 lines
1.5 KiB
Python
49 lines
1.5 KiB
Python
"""
|
|
Dial Plan — Emergency-number guard and extension allocation.
|
|
|
|
Emergency numbers are never dialable through the gateway's API/MCP
|
|
surfaces: an outbound emergency call must come from a human on a real
|
|
phone whose trunk provider has E911 location data, not from an AI agent
|
|
or a REST request. See the README caution.
|
|
"""
|
|
|
|
|
|
# Dialled forms and their E.164 mappings — both sides are refused.
|
|
EMERGENCY_NUMBERS: dict[str, str] = {
|
|
"911": "+1911", # North American emergency
|
|
"9911": "+1911", # Mis-dial with phantom '9' prefix
|
|
"112": "+112", # International GSM emergency
|
|
}
|
|
|
|
_BLOCKED = frozenset(EMERGENCY_NUMBERS) | frozenset(EMERGENCY_NUMBERS.values())
|
|
|
|
|
|
def is_emergency_number(number: str) -> bool:
|
|
"""True if the dialled string is an emergency number (any known form)."""
|
|
cleaned = number.strip().replace(" ", "").replace("-", "").replace(".", "")
|
|
return cleaned in _BLOCKED
|
|
|
|
|
|
# ================================================================
|
|
# Extension allocation (2XX range)
|
|
# ================================================================
|
|
|
|
EXTENSION_FIRST = 221
|
|
EXTENSION_LAST = 299
|
|
|
|
|
|
def next_extension(used: set[int]) -> int | None:
|
|
"""
|
|
Return the lowest available extension in the 2XX range.
|
|
|
|
Args:
|
|
used: Set of already-assigned extension numbers.
|
|
|
|
Returns:
|
|
Next free extension, or None if the range is exhausted.
|
|
"""
|
|
for ext in range(EXTENSION_FIRST, EXTENSION_LAST + 1):
|
|
if ext not in used:
|
|
return ext
|
|
return None
|