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:
90
tests/test_api_security.py
Normal file
90
tests/test_api_security.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
API surface tests — bearer-token enforcement and route registration order.
|
||||
|
||||
The app is exercised without its lifespan: auth runs before any handler,
|
||||
so a 503 ("Gateway not initialized") proves the token was accepted.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from starlette.routing import Match
|
||||
|
||||
import main
|
||||
from config import get_settings
|
||||
|
||||
TOKEN = "test-token-for-suite"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_enabled(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestBearerToken:
|
||||
async def test_missing_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get("/api/calls/active")
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
async def test_wrong_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/api/calls/active", headers={"Authorization": "Bearer wrong"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/api/calls/active", headers={"Authorization": f"Bearer {TOKEN}"}
|
||||
)
|
||||
# No lifespan ran, so the handler itself 503s — auth was accepted
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_empty_token_disables_auth(self, monkeypatch, client):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
||||
resp = await client.get("/api/calls/active")
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_all_api_routers_protected(self, token_enabled, client):
|
||||
for path in ("/api/calls/active", "/api/call-flows/", "/api/devices/",
|
||||
"/api/routing/rules", "/api/calls/history"):
|
||||
resp = await client.get(path)
|
||||
assert resp.status_code == 401, path
|
||||
|
||||
|
||||
class TestRouteOrder:
|
||||
def _resolve(self, path: str):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": path,
|
||||
"root_path": "",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
}
|
||||
for route in main.app.router.routes:
|
||||
match, _ = route.matches(scope)
|
||||
if match == Match.FULL:
|
||||
return route
|
||||
return None
|
||||
|
||||
def test_history_not_shadowed_by_call_id(self):
|
||||
route = self._resolve("/api/calls/history")
|
||||
assert route is not None
|
||||
assert route.endpoint.__name__ == "list_history"
|
||||
|
||||
def test_call_id_still_matches(self):
|
||||
route = self._resolve("/api/calls/call_abc123")
|
||||
assert route is not None
|
||||
assert route.endpoint.__name__ == "get_call"
|
||||
|
||||
def test_mcp_mounted(self):
|
||||
mounted = [getattr(r, "path", "") for r in main.app.router.routes]
|
||||
assert "/mcp" in mounted
|
||||
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")
|
||||
@@ -3,7 +3,6 @@ Tests for the intelligence layer services:
|
||||
- LLMClient
|
||||
- NotificationService
|
||||
- RecordingService
|
||||
- CallAnalytics
|
||||
- CallFlowLearner
|
||||
"""
|
||||
|
||||
@@ -286,47 +285,6 @@ class TestRecordingService:
|
||||
await svc.stop_recording("call_abc123")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Call Analytics Tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCallAnalytics:
|
||||
"""Test analytics tracking."""
|
||||
|
||||
def _make_service(self):
|
||||
from services.call_analytics import CallAnalytics
|
||||
|
||||
return CallAnalytics(max_history=1000)
|
||||
|
||||
def test_init(self):
|
||||
svc = self._make_service()
|
||||
assert svc._call_records == []
|
||||
assert svc.total_calls_recorded == 0
|
||||
|
||||
def test_get_summary_empty(self):
|
||||
svc = self._make_service()
|
||||
summary = svc.get_summary(hours=24)
|
||||
assert summary["total_calls"] == 0
|
||||
assert summary["success_rate"] == 0.0
|
||||
|
||||
def test_get_company_stats_unknown(self):
|
||||
svc = self._make_service()
|
||||
stats = svc.get_company_stats("+18005551234")
|
||||
assert stats["total_calls"] == 0
|
||||
|
||||
def test_get_top_numbers_empty(self):
|
||||
svc = self._make_service()
|
||||
top = svc.get_top_numbers(limit=5)
|
||||
assert top == []
|
||||
|
||||
def test_get_hold_time_trend(self):
|
||||
svc = self._make_service()
|
||||
trend = svc.get_hold_time_trend(days=7)
|
||||
assert len(trend) == 7
|
||||
assert all(t["call_count"] == 0 for t in trend)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Call Flow Learner Tests
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user