Stage 1: mount MCP server, bearer auth, outbound-call guards #1

Merged
r merged 1 commits from feature/stage1-agent-surface into main 2026-07-10 11:07:54 +00:00
Owner

First stage of the architecture-review roadmap: make the agent surface real and safe.

What was broken

  • The MCP server was never mounted. main.py created it and stashed it on app.state — no MCP client could ever connect, despite docs describing an SSE endpoint at /mcp.
  • learn_call_flow crashed on invocation — wrong constructor args (CallFlowLearner(event_bus, settings) vs __init__(self, llm_client=None)) and a nonexistent learn_from_call() method. No MCP tests existed to catch it.
  • Zero auth anywhere — REST, WebSocket (streams all transcripts), and MCP make_call (real PSTN dialing) were all open.
  • GET /api/calls/{call_id} shadowed GET /api/calls/history (registration order).
  • Insecure defaults: debug=True (with SQL echo), credential baked into the default DATABASE_URL, secrets as plain str.

Changes

  • Mount MCP at /mcp/ (streamable HTTP, FastMCP 3.x) with combined lifespan; gateway resolved lazily per request so mounting happens at app construction.
  • One static API_TOKEN across all three surfaces: REST dependency (compare_digest), WS check before accept() (query param or header, close 4401), MCP StaticTokenVerifier. Startup refuses a tokenless non-loopback bind.
  • Emergency-number guard (911/9911/112, all forms) + MAX_CONCURRENT_CALLS cap in gateway.make_call; surfaces as HTTP 400 / MCP ToolError. README 911 caution rewritten to match reality.
  • Deleted the broken learn_call_flow tool and the never-fed CallAnalytics service (analytics will be recomputed from DB call records later); call_flow_learner.py kept for proper wiring in stage 6.
  • dial_plan.py trimmed to what's actually referenced (emergency set + extension allocation); the unreferenced matcher/E.164 normaliser deleted.
  • call_history router registered before calls; regression test included.
  • SecretStr for SIP/LLM/TTS credentials and the API token; debug=False; DATABASE_URL has no default and is validated at startup with clear guidance.
  • README: real MCP endpoint + claude mcp add example, actual tool table (14 tools + 3 resources), auth setup, pjsua2 as a system dependency, port 8100 typo fixed.

Tests & verification

  • tests/test_mcp.py (8): in-memory FastMCP client — tool surface, lazy-gateway error, make_call happy path, emergency refusal (unit + gateway + tool), call cap.
  • tests/test_api_security.py (8): 401 paths on all routers, valid-token pass-through, tokenless-loopback mode, route order, mount presence.
  • Suite: 104 passed; the 2 failures (test_complex_tone_as_music, test_trunk_status) are pre-existing on main.
  • Live verification on 127.0.0.1:8199 (mock SIP engine): REST 401/200 paths, /api/calls/history returns 200, MCP 401 without token, and a real fastmcp client over streamable HTTP listed all 14 tools, called gateway_status, and had make_call("911") refused. Tokenless HOST=0.0.0.0 startup refused with guidance.

Notes for reviewer

  • Installed fastmcp is 3.3.1, so the pin is >=3.0 (plan drafted >=2.3; wrote against the live version).
  • A generated API_TOKEN was appended to the local .env (gitignored) so the existing HOST=0.0.0.0 dev setup keeps booting.
  • Spotted while verifying: the nested SIP_TRUNK_*/TTS_* settings classes don't read .env (no env_file on the sub-settings, and the parent doesn't export) — they only see real process env vars. Likely why the engine silently runs in mock mode. Not touched here; it belongs with stage 4's explicit engine-mode work.

🤖 Generated with Claude Code

First stage of the architecture-review roadmap: make the agent surface real and safe. ## What was broken - **The MCP server was never mounted.** `main.py` created it and stashed it on `app.state` — no MCP client could ever connect, despite docs describing an SSE endpoint at `/mcp`. - **`learn_call_flow` crashed on invocation** — wrong constructor args (`CallFlowLearner(event_bus, settings)` vs `__init__(self, llm_client=None)`) and a nonexistent `learn_from_call()` method. No MCP tests existed to catch it. - **Zero auth anywhere** — REST, WebSocket (streams all transcripts), and MCP `make_call` (real PSTN dialing) were all open. - `GET /api/calls/{call_id}` shadowed `GET /api/calls/history` (registration order). - Insecure defaults: `debug=True` (with SQL echo), credential baked into the default `DATABASE_URL`, secrets as plain `str`. ## Changes - Mount MCP at **`/mcp/` (streamable HTTP, FastMCP 3.x)** with combined lifespan; gateway resolved lazily per request so mounting happens at app construction. - **One static `API_TOKEN`** across all three surfaces: REST dependency (`compare_digest`), WS check before `accept()` (query param or header, close 4401), MCP `StaticTokenVerifier`. Startup refuses a tokenless non-loopback bind. - **Emergency-number guard** (911/9911/112, all forms) + `MAX_CONCURRENT_CALLS` cap in `gateway.make_call`; surfaces as HTTP 400 / MCP ToolError. README 911 caution rewritten to match reality. - Deleted the broken `learn_call_flow` tool and the never-fed `CallAnalytics` service (analytics will be recomputed from DB call records later); `call_flow_learner.py` kept for proper wiring in stage 6. - `dial_plan.py` trimmed to what's actually referenced (emergency set + extension allocation); the unreferenced matcher/E.164 normaliser deleted. - `call_history` router registered before `calls`; regression test included. - `SecretStr` for SIP/LLM/TTS credentials and the API token; `debug=False`; `DATABASE_URL` has no default and is validated at startup with clear guidance. - README: real MCP endpoint + `claude mcp add` example, actual tool table (14 tools + 3 resources), auth setup, pjsua2 as a system dependency, port 8100 typo fixed. ## Tests & verification - `tests/test_mcp.py` (8): in-memory FastMCP client — tool surface, lazy-gateway error, make_call happy path, emergency refusal (unit + gateway + tool), call cap. - `tests/test_api_security.py` (8): 401 paths on all routers, valid-token pass-through, tokenless-loopback mode, route order, mount presence. - Suite: **104 passed**; the 2 failures (`test_complex_tone_as_music`, `test_trunk_status`) are pre-existing on `main`. - Live verification on 127.0.0.1:8199 (mock SIP engine): REST 401/200 paths, `/api/calls/history` returns 200, MCP 401 without token, and a real `fastmcp` client over streamable HTTP listed all 14 tools, called `gateway_status`, and had `make_call("911")` refused. Tokenless `HOST=0.0.0.0` startup refused with guidance. ## Notes for reviewer - Installed `fastmcp` is 3.3.1, so the pin is `>=3.0` (plan drafted `>=2.3`; wrote against the live version). - A generated `API_TOKEN` was appended to the local `.env` (gitignored) so the existing `HOST=0.0.0.0` dev setup keeps booting. - Spotted while verifying: the nested `SIP_TRUNK_*`/`TTS_*` settings classes don't read `.env` (no `env_file` on the sub-settings, and the parent doesn't export) — they only see real process env vars. Likely why the engine silently runs in mock mode. Not touched here; it belongs with stage 4's explicit engine-mode work. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
r added 1 commit 2026-07-09 19:20:54 +00:00
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>
r merged commit fd88543fb1 into main 2026-07-10 11:07:54 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: r/hold-slayer#1