Skip to content

MCP servers

An MCP server is a tool provider the agent can call. The set of MCP servers on a session is the session’s blast radius — the complete list of things the agent can do outside its own clone.

MCP is the one artifact type with no separate body — the index entry is the connection config. From mcp.json:

"playwright-custom": {
"title": "Playwright Custom",
"description": "Playwright MCP server for browser automation and screenshots.",
"type": "stdio",
"command": "npx",
"args": ["-y", "playwright-stealth-mcp-server@latest"],
"env": { "STEALTH_MODE": "false", "HEADLESS": "true" },
"default_in_roots": ["zimmer"]
}
FieldNotes
typestdio | sse | streamable-http (http)
command / args / envstdio servers
url / headersremote servers
oauthremote servers that need an OAuth flow
default_in_rootswhich roots get it by default

env and headers values may contain ${VAR} placeholders.

The formal schema is published by AIR at pulsemcp.github.io/air/schemas/mcp.schema.json — which is what mcp.json’s own $schema key points at. A snapshot is also served from this site at /mcp.schema.json.

The catalog carries the placeholder. The environment carries the value. The transform joins them at prepare time, and AIR then validates that no ${VAR} survived and fails if any did.

That validation is the good part: a typo’d secret name fails loudly at prepare, before the agent ever gets a server that 401s on every call.

Zimmer’s SecretsLoader resolves values in this order: XOauthTokenVendor (for X/Twitter tokens) → Rails encrypted credentials (mcp_secrets) → ENV.

A session’s server list is seeded from the agent root’s defaults and then owned by the session. The UI and the API (PATCH /api/v1/sessions/:id/mcp_servers, max 50) mutate it directly, and air prepare runs with --without-defaults so AIR won’t re-add what you removed.

Beyond the ones you pick, a session also gets auto-injected servers — most notably the self-session server (SelfSessionInjector), which is how an agent can archive itself, set its own title, or schedule its own wake-up. session_json exposes three fields for this: mcp_servers (what you chose), injected_mcp_servers, and all_mcp_servers.

A remote server (http / streamable-http / sse) with no static Authorization header is assumed to possibly need OAuth. Before spawn, McpOauthCredentialInjector checks each one; if any lacks a valid credential, the session is parked in failed with failure_reason: oauth_required, and the UI renders Authorize buttons.

MCP server OAuth for the full flow.

MCP connection status is inferred from logs

Section titled “MCP connection status is inferred from logs”

There is no protocol-level “did this server connect” signal that Zimmer consumes. Instead:

  • Claude: McpLogPollerService scrapes the CLI’s MCP log files.
  • Codex: CodexMcpStatusDetector string-matches tool names against codex-rs’s MCP_TOOL_NAME_DELIMITER = "__", and reimplements Codex’s internal sanitize_responses_api_tool_name character rules in Ruby.
  • MCP_TIMEOUT = 180000 (3 minutes) — a flat startup timeout for every MCP server.
  • NPM_CONFIG_CACHE is set to a clone-local .npm-cache, so npx-based servers don’t fight over a shared cache.
  • NpxCacheHealService exists to detect and delete a corrupted _npx cache — by matching npm’s error text (ENOTEMPTY, ERR_UNSUPPORTED_DIR_IMPORT). An entire service that self-heals a filesystem bug by regexing stderr.
  • MCP_PACKAGE_REINSTALL and Dockerfile.base’s bin/preinstall-mcp-packages pre-warm the npm and python packages listed in mcp.json, so a cold session doesn’t pay the download.

playwright-custom (the only one default-on, for the zimmer root), context7, linear, and eleven others. Read mcp.json for the current list — it changes more often than this page will.