Files
tai/mcp.md
T
retoor 58786386cf v1.43.0: automatic MCP GitHub resolver agent plus stdlib-only OAuth login
- mcp_resolve_agent: bounded fork subagent fallback when the
  deterministic GitHub pass finds no run contract (MCP_RESOLVER_*).
- McpOAuth/McpOAuthCallback: discovery, DCR, PKCE loopback, token
  exchange, sealed per-profile token storage, one-shot refresh.
- McpHttpError carries status plus WWW-Authenticate through the
  client; bridge retries once after refresh, else names mcp_login.
- mcp_add stores 401-gated servers disabled; mcp_login approves,
  verifies, enables; mcp_remove drops tokens.
- Docs, conclusions, 17 new tests (1209 + 16 green).
2026-10-18 20:38:52 +02:00

255 lines
13 KiB
Markdown

# mcp.md
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 |
| `MCP_SESSION_IDLE` | 3600s | idle server-side MCP sessions are dropped |
| `MCP_CHAT_TIMEOUT` | 600s | deadline per served `chat` turn |
| `MCP_RESOLVER_TIMEOUT` | 300s | deadline for the fork-based GitHub resolver subagent |
| `MCP_RESOLVER_STEPS` | 12 | max agent steps for one GitHub resolution |
| `MCP_OAUTH_TIMEOUT` | 300s | how long `mcp_login` waits for the browser redirect |
## Client
### Transports
stdio: the server command spawns via subprocess with piped stdio,
newline-delimited JSON-RPC, stderr drained to a capped buffer (never
parsed). Shutdown order is close-stdin, wait, terminate, kill. The
exact argv is shown at registration consent and re-shown whenever it
changes. stdio reads need POSIX `select`; the HTTP leg covers the
rest.
Streamable HTTP: one `POST <url>` per request with
`Accept: application/json, text/event-stream`, the stored headers,
and `Mcp-Session-Id` after `initialize`. The reply is a single JSON
message or a `text/event-stream` body that the client parses by hand
(`data:` lines, blank-line frames, `event: message` only). `GET` and
`DELETE` are never sent; sessions end by dropping the id.
### Vault tables
```sql
mcp_servers(name PK, transport, command, args_json, env_json, url,
headers_json, cwd, allow_json, deny_json, source, created, updated)
mcp_server_profiles(server, profile, enabled) PK(server, profile)
mcp_tool_pins(server, tool, hash, updated) PK(server, tool)
mcp_server_tools(server, tool, description, schema_json, updated) PK(server, tool)
```
`headers_json` is sealed at rest whenever the vault seal is enabled.
`source` records what was pasted (`config-json`, `config-toml`,
`github:<owner>/<repo>`, `explicit`). A server with no profile row
defaults to disabled everywhere; enabling is always explicit and per
profile.
### Normalizer (`mcp_normalize`)
Accepted input: a JSON object with a `mcpServers` or `servers`
wrapper (or one bare entry), a TOML document with `[mcp_servers.*]`
tables (3.11+, clean error below), or a bare server name already
stored. Repair rules: an entry with `url` and no `type` gains
`type: http`; command entries keep `command`/`args`/`env`/`cwd`.
`${VAR}` and `${VAR:-default}` expand in `command`, `args`, `env`,
`url`, and `headers` at use time from process env first, then vault
secrets; Cursor `${env:NAME}` rewrites to `${NAME}`; VS Code
`${input:id}` resolves against vault secrets by id and fails the
spawn when missing. Unresolved names warn and keep the literal for
display, but the spawn is refused. `npx` on Windows wraps in
`cmd /c`; `uvx` never does. TOML-only keys map as: `env`/`env_vars`
to env, `url` plus `http_headers`/`env_http_headers` to the HTTP
leg, `enabled` to the registering profile, `enabled_tools`/
`disabled_tools` to a tool allow/deny stored beside the server row.
`timeout`/`startup_timeout` are ignored with a note: tai's connect/call
caps govern.
GitHub URLs (`github.com/<owner>/<repo>` plus anything after) are
not normalizable; they go to the resolver in two passes. The
deterministic pass reads `mcp.json`, `.mcp.json`, `server.json`,
then `package.json` (bare `npx -y <name>`), then the README for an
`npx`/`uvx` run block, all through the contents API. When that
finds nothing, `mcp_resolve_agent` spawns a bounded fork subagent
(same profile, `default_runner`, `MCP_RESOLVER_STEPS` steps,
`MCP_RESOLVER_TIMEOUT` seconds) with a strict brief: read the same
files plus the raw README and return exactly one fenced JSON
`command` or `url` entry, or `{"error", "evidence"}`. Anything
else is a loud failure naming both passes. Every resolved entry is
verified by a live handshake before it is stored.
### Bridge
Enabled servers' tools enter the lazy tool system as
`mcp_<server>_<tool>`, sanitized to `[a-z0-9_]` (anything else
becomes `_`, empties gain a numeric suffix). `select_tools` also
iterates the bridge schemas, so description-word matching works
unchanged; `Tools.dispatch` falls back to the bridge for `mcp_*`
names. Schemas pass the server's `inputSchema` through as
`parameters`, defaulting to `{type: object, properties: {}}`.
Calls ask approval exactly like non-safe shell commands (always in
interactive mode, auto in `--auto`, silent in `--yolo`), and the
consent screen names server, tool, and argument digest.
Trust: tool names, descriptions, and annotations are untrusted text.
At registration tai hashes name plus description plus schema per
tool into `mcp_tool_pins`; added or redefined tools on a later
`tools/list` re-prompt for approval and are withheld until granted,
while pure removals apply silently.
Results over `MCP_RESULT_MAX` spill to a vault record. Every call
lands in the audit trail (`mcp-call`, `server/tool`, argument
digest, outcome, duration, size). `${VAR}` secret values resolve at
spawn/call time and `Store.redact` scrubs known values from results.
HTTP failures arrive as `McpHttpError` (an `McpError` carrying the
status code and the `WWW-Authenticate` header) so the bridge and
the OAuth layer can tell 401 from anything else.
### OAuth (remote servers)
Servers that answer 401 get the OAuth treatment, all stdlib
(`urllib`, `http.server`, `secrets`, `webbrowser`), in `McpOAuth`:
1. `mcp_add` on a 401 stores the server **disabled** (no tools
cached) and points at `mcp_login`; servers with a static
`Authorization` header that 401 instead fail with a
check-your-credentials error.
2. `mcp_login` probes anonymously (an open server is refused with
"needs no login"), then discovers: `resource_metadata` from the
401 header, else the origin's
`/.well-known/oauth-authorization-server`, keeping scope from the
protected-resource document. Servers without a
`registration_endpoint` are refused: only dynamic clients.
3. Dynamic client registration (`tai-mcp`, public, loopback
redirect), then PKCE (`S256`) plus state, then the browser opens
(the URL is also printed, for headless terminals). The loopback
listener (`McpOAuthCallback`, `127.0.0.1`, ephemeral port,
`MCP_OAUTH_TIMEOUT` seconds) takes the code, which is exchanged
for tokens.
4. Tokens live in vault `secrets` as
`mcp-oauth-<server>-access`/`-refresh` (name-clamped to
`SECRET_RE`, sealed at rest, redacted from output), with the
`client_id` in the access secret's meta. `mcp_remove` drops them
for every known profile plus the current one.
5. Every HTTP connect injects `Authorization: Bearer <access>`
unless the entry configures its own. A 401 mid-call refreshes
once (re-discover, `refresh_token` grant, rotation honored) and
retries; when that fails the error names `mcp_login`.
Login is interactive-local by nature: run it in a REPL, not over
a served session, since only the local terminal shows the URL.
### Agent tools
| Tool | Args | Effect |
|---|---|---|
| `mcp_add` | `source`, `name?`, `profile?` | normalize or resolve, consent the spawn, live-verify, store, return the tool list |
| `mcp_list` | - | servers with transport, enabled profiles, tool counts, pin state |
| `mcp_enable` | `server`, `profile?` | enable for a profile (current when omitted) |
| `mcp_disable` | `server`, `profile?` | disable for a profile |
| `mcp_remove` | `server` | delete server, profile rows, and pins; asks first |
| `mcp_tools` | `server?` | list bridged tools with descriptions and pin state |
| `mcp_call` | `server`, `tool`, `args?` | call one tool explicitly (same path as bridged dispatch) |
| `mcp_login` | `server`, `profile?` | OAuth browser login, store sealed tokens, verify, enable; asks first |
## Server (`tai.py --serve-mcp KEY`)
One process, one `Config`, one `Store`, single-threaded `HTTPServer`
(no thread pool: sequential requests fit MCP clients and the
no-internal-threads rule). Flags: `--serve-mcp KEY` (required; the
Bearer token clients must send), `--mcp-port` (default from
`TAI_MCP_PORT` else `MCP_DEFAULT_PORT`, 8765), `--mcp-bind`
(default `TAI_MCP_BIND` else `127.0.0.1`).
| Endpoint | Behavior |
|---|---|
| `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,
`parameters` as `inputSchema`), plus two extras, 63 total:
| Tool | Args | Effect |
|---|---|---|
| `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
untrusted servers through tai's identity.
### Exclusions (both sides)
Confidential or pre-registered OAuth clients (public DCR only),
device flow, and CIMD; roots/sampling/elicitation serving; legacy
HTTP+SSE; `GET /mcp` streams; file-attachment content beyond text
passthrough (images/audio arrive as their JSON, unchanged); batch
JSON-RPC arrays (one message per POST).
## Code map
`MCP_PROTOCOL_VERSION` pins the wire; `mcp_normalize` plus
`mcp_expand_vars` own pasted-config intake; `mcp_resolve_agent`
owns the fork-based GitHub fallback; `McpStdioTransport` and
`McpHttpTransport` own the two legs behind `McpClient`;
`McpHttpError` carries HTTP status to the bridge; `McpOAuth` plus
`McpOAuthCallback` own discovery, DCR, PKCE loopback, refresh, and
sealed token storage; `McpBridge` owns enablement, drift pins,
approval, caps, spill, and audit; `McpServeContext` plus
`mcp_serve_handler` own the HTTP server; `mcp_serve_main` owns
flags and boot.
## Test hooks
`McpDocTests` asserts this file names every `MCP_*` cap, every
method in the wire table, every vault table, every agent tool, and
both server extras. Transport tests drive a fake MCP server (a
`python3 -c` NDJSON stub for stdio, `http.server` in-test for the
HTTP leg); serve tests drive the real `POST /mcp` with `urllib`
against `ChatClient` fakes, never a live model; `McpOAuthTests`
covers discovery, PKCE, the loopback round trip, refresh, the
401 paths, and token cleanup.