--- description: MCP tools return formatted strings; ToolError-vs-return-string convention; lazy gateway resolution; resources are JSON paths: - "mcp_server/server.py" --- # MCP tools & resources The MCP server ([mcp_server/server.py](../../mcp_server/server.py)) is the AI-assistant control surface. It's built by `create_mcp_server(get_gateway)` and mounted at `/mcp/` before the lifespan runs, so everything is resolved lazily. - **Auth is the ASGI `_owner_only_mcp` wrapper in [main.py](../../main.py), not FastMCP.** `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper resolves the `Authorization` bearer (Casdoor JWT or owner-minted PAT) via the shared `resolve_from_header_or_query` + `is_owner` and returns 401/403 before the inner app runs. Don't reintroduce a FastMCP verifier here — one resolver gates all four surfaces (see the [auth-surfaces rule](auth-surfaces.md)). MCP clients use a PAT (`hs_pat_…`) minted from the dashboard's Tokens modal. - **Tools return plain formatted strings; resources return JSON strings.** Tools produce human-readable text an assistant reads back to a user (`"Call abc123 initiated. …"`). Resources (`gateway://status`, `gateway://call-flows`, `gateway://active-calls`) return `json.dumps(...)`. Don't blur these — a tool that returns raw JSON, or a resource that returns prose, breaks the contract. - **Error convention — match the two existing patterns:** - **Raise `ToolError`** when the request is invalid or unsafe: emergency number, bad mode, concurrency cap hit, or "gateway still starting" (`require_gateway`). The assistant should treat these as errors. - **Return an error string** for a lookup that simply found nothing or hit a recoverable snag: `"Call {id} not found."`, `"No stored call flow for …"`, `"Error looking up …: {e}"`. The assistant reads these as content. - Rule of thumb: *"you asked for something invalid/unsafe" → raise; "I looked, here's the (maybe empty/failed) answer" → return.* - **`require_gateway()` gates every tool that needs the live gateway.** It raises `ToolError("Gateway is still starting up …")` when `get_gateway()` returns `None`. This is why the MCP app can mount before the lifespan builds the gateway. Call it at the top of any tool that touches the gateway; never assume the gateway exists. - **`make_call` is the one tool that dials.** It maps the string `mode` to `CallMode`, defaults unknown modes to `DIRECT`, and lets `gateway.make_call`'s refusals (`ValueError`) surface as `ToolError`. The emergency guard and concurrency cap live in the gateway, **not** here — don't reimplement or skip them at the tool layer (see the call-safety rule). - **DB-backed tools use `session_scope()`** and read from `call_persistence`. Completed-call history, summaries, recordings, and stored flows come from the database, not from `active_calls` (those are live only). Keep the `async with session_scope() as session:` pattern; don't open ad-hoc sessions. - **Keep the tool count and README table in sync.** There are 15 tools + 3 resources. If you add/remove one, update the README's MCP table and the `docs/mcp-server.md` reference — a drifting tool list is a documented lie.