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

13 KiB

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

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.