- 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)
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.