Files
tai/mcp.md
T
retoor 66ed0f9ea6 MCP both directions: client bridge plus --serve-mcp (1.42.0)
- client: stdio + Streamable HTTP transports, 2025-11-25
  handshake/list/call/ping, vault mcp_servers with sealed headers,
  per-profile enable, description-hash pins, tool cache
- mcp_normalize: pasted JSON/TOML configs (type repair, ${VAR}
  expansion from env then vault secrets, allow/deny lists);
  github URLs resolve via registry/package/README with live
  handshake verification before storing
- bridge: enabled tools enter lazy select_tools as
  mcp_<server>_<tool>, dispatch fallback, approval like non-safe
  shell, drift re-prompt, 64KB spill, per-call audit
- 7 agent tools: mcp_add/list/enable/disable/remove/tools/call
  (native count 42->60 in README, was already stale at 53)
- server: --serve-mcp KEY (+ --mcp-port/--mcp-bind) serves all 60
  native tools plus chat (agent turn, per-session history) and
  completions (stateless, current model) over single-threaded
  HTTP with Bearer auth, sessions, idle expiry; yolo documented
- rotation + plaintext-boot refusal cover sealed headers
- mcp.md contract with drift tests; 47 new tests; live loopback
  verified (client->serve over real HTTP)
2026-10-18 19:46:55 +02:00

10 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

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: registry API by repo path, server.json/mcpName, package.json binaries, README run block, Glama page, then a live initialize + tools/list probe. The resolver is tai itself (the agent loop, usually one mcp_add call after reading): it returns exactly one normalized entry or a loud failure with the evidence gathered. 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.

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)

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, 62 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)

OAuth browser flows, dynamic client registration, token storage and refresh; 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; McpStdioTransport and McpHttpTransport own the two legs behind McpClient; 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.