tai speaks MCP in both directions. As a **client** it spawns or dials
third-party MCP servers, bridges their tools into the lazy tool system,
and stores connections in the vault with per-profile enablement. As a
**server** (`tai.py --serve-mcp KEY`) it exposes every native tool plus
`chat` and `completions` over Streamable HTTP. `McpDocTests` fails when
this file drifts from the code. Research: `research/mcp-client-support/`.
## Shared wire subset
Protocol version is `2025-11-25`, sent as `MCP_PROTOCOL_VERSION`.
Client name and server name are both `tai`; server version is `VERSION`.
| Direction | Method | Shape |
|---|---|---|
| C2S | `initialize` | `{protocolVersion, capabilities: {}, clientInfo: {name, version}}`; server answers its version plus `capabilities.tools`, or the client aborts |
| C2S | `notifications/initialized` | no params, no answer |
| both | `ping` | `{}` in, `{}` out |
| C2S | `tools/list` | `{cursor?}` in, `{tools: [...], nextCursor?}` out, followed until no cursor |
| C2S | `tools/call` | `{name, arguments?}` in, `{content: [...], isError?}` out |
| C2S | `notifications/cancelled` | `{requestId, reason?}` on call timeout, then the call fails locally |
JSON-RPC errors (`-32601` unknown, `-32602` bad params, `-32603`
internal) mean protocol failure; a tool that ran and failed arrives
as a normal result with `isError: true`, and tai preserves that split
end to end. Roots, sampling, elicitation, logging display, progress
tokens, and the legacy HTTP+SSE transport are not implemented on
either side; anything asking for them gets `-32601`.
## Caps
| Cap | Value | Meaning |
|---|---|---|
| `MCP_CONNECT_TIMEOUT` | 20s | spawn plus handshake, per server |
| `MCP_CALL_TIMEOUT` | 60s | one `tools/call`, then cancel notification and local failure |
| `MCP_RESULT_MAX` | 64KB | tool result text kept inline; larger spills to a vault record and the model gets the id, never a silent truncation |
| `MCP_HTTP_MAX_BODY` | 10MB | largest request/response body on either HTTP leg |
| `MCP_MAX_SERVERS` | 32 | registered servers; the 33rd registration is refused |
| `MCP_MAX_TOOLS` | 100 | tools bridged per server; extras are listed but not offered |
| `POST /mcp` | one JSON-RPC message; missing/invalid `Authorization: Bearer KEY` gets 401. Answers `initialize` (new `Mcp-Session-Id`), `notifications/initialized` (none), `ping`, `tools/list` (no pagination, all tools fit), `tools/call`. Always a single JSON reply, never SSE. Unknown methods get `-32601`. |
| `GET /mcp` | 405 (no server-initiated streams in v1) |
| `DELETE /mcp` | drops the `Mcp-Session-Id` session, 200 |
Sessions live in memory: `{agent, profile, created, last_seen}`.
Idle sessions past `MCP_SESSION_IDLE` are dropped on next access.
### Served tools
Every native tool schema is served verbatim (name, description,
| `chat` | `message`, `session?`, `profile?`, `max_steps?` | full agent turn on the session's own `Agent` (`run_turn`, capture), history kept in the session; uses the current model with tools |
| `completions` | `messages[]` | one stateless `ChatClient.complete` with the current model, no tools; roles validated (`system`/`user`/`assistant`) |
The key is the authorization: serve mode runs the equivalent of
`--yolo` for tool approvals, documented loudly at startup, because
no user is present to ask. `ask_user` over the server always denies
(no prompter, no tty). Served `chat` agents can themselves use
bridged MCP tools (they are full agents), but bridged foreign tools
are never re-served as MCP tools: transitive serving would launder