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:
94
tests/test_mcp.py
Normal file
94
tests/test_mcp.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
MCP server tests — tool surface, lazy gateway resolution, call safety.
|
||||
|
||||
Uses the FastMCP in-memory client (no network, no mounted app).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from config import Settings
|
||||
from core.dial_plan import is_emergency_number
|
||||
from core.gateway import AIPSTNGateway
|
||||
from mcp_server.server import create_mcp_server
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"make_call",
|
||||
"get_call_status",
|
||||
"transfer_call",
|
||||
"hangup",
|
||||
"list_active_calls",
|
||||
"get_call_flow",
|
||||
"create_call_flow",
|
||||
"send_dtmf",
|
||||
"get_call_transcript",
|
||||
"get_call_recording",
|
||||
"get_call_summary",
|
||||
"search_call_history",
|
||||
"list_devices",
|
||||
"gateway_status",
|
||||
}
|
||||
|
||||
|
||||
def _make_gateway(max_calls: int = 4) -> AIPSTNGateway:
|
||||
"""Unstarted gateway on the in-memory MockSIPEngine — no network, no DB."""
|
||||
return AIPSTNGateway(settings=Settings(max_concurrent_calls=max_calls))
|
||||
|
||||
|
||||
class TestToolSurface:
|
||||
async def test_tool_listing_matches_expected(self):
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
tools = {t.name for t in await client.list_tools()}
|
||||
assert tools == EXPECTED_TOOLS
|
||||
|
||||
async def test_auth_configured_when_token_given(self):
|
||||
assert create_mcp_server(lambda: None, api_token="sekrit").auth is not None
|
||||
assert create_mcp_server(lambda: None).auth is None
|
||||
|
||||
|
||||
class TestGatewayResolution:
|
||||
async def test_tool_errors_cleanly_before_gateway_ready(self):
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(Exception, match="starting up"):
|
||||
await client.call_tool("list_active_calls", {})
|
||||
|
||||
async def test_make_call_happy_path(self):
|
||||
gateway = _make_gateway()
|
||||
mcp = create_mcp_server(lambda: gateway)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"make_call", {"number": "+15551234567", "mode": "direct"}
|
||||
)
|
||||
text = result.content[0].text
|
||||
assert "initiated" in text
|
||||
assert "+15551234567" in text
|
||||
assert len(gateway.call_manager.active_calls) == 1
|
||||
|
||||
|
||||
class TestCallSafety:
|
||||
def test_emergency_number_detection(self):
|
||||
for number in ("911", "9911", "112", "+1911", "+112", " 911 ", "9-1-1"):
|
||||
assert is_emergency_number(number), number
|
||||
for number in ("+19115551234", "+18005551234", "211", "999"):
|
||||
assert not is_emergency_number(number), number
|
||||
|
||||
async def test_gateway_refuses_emergency_numbers(self):
|
||||
gateway = _make_gateway()
|
||||
with pytest.raises(ValueError, match="emergency"):
|
||||
await gateway.make_call("911")
|
||||
assert gateway.call_manager.active_calls == {}
|
||||
|
||||
async def test_mcp_make_call_refuses_emergency(self):
|
||||
gateway = _make_gateway()
|
||||
mcp = create_mcp_server(lambda: gateway)
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(Exception, match="[Ee]mergency"):
|
||||
await client.call_tool("make_call", {"number": "911"})
|
||||
|
||||
async def test_concurrent_call_cap(self):
|
||||
gateway = _make_gateway(max_calls=1)
|
||||
await gateway.make_call("+15551234567")
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
await gateway.make_call("+15557654321")
|
||||
Reference in New Issue
Block a user