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
unavailablea standing declaration that this entry cannot work here, and why

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

unavailable: the breakage Zimmer cannot detect

Section titled “unavailable: the breakage Zimmer cannot detect”

Zimmer works out readiness for itself — see Availability, and what an agent is offered below. unavailable exists for the one class it cannot: an entry whose every ${VAR} resolves and whose endpoint still cannot serve Zimmer. A server written for OAuth whose endpoint accepts only static bearer tokens and publishes no OAuth discovery passes every local check and is unusable anyway, and no amount of probing infers that.

"strad-secrets-oauth": {
"title": "Strad Secrets (OAuth)",
"type": "streamable-http",
"url": "https://secrets.example.com/mcp",
"unavailable": "The endpoint accepts only static bearer tokens and exposes no OAuth discovery."
}
  • Type: string. Non-empty (after trimming) means unavailable, and the string is the reason — reported on the Connectors page and in get_configs’s unavailable roster.
  • It is normalized before it is shown. Whitespace collapses to single spaces and the reason is truncated at 200 characters, because it lands in a markdown list an agent reads as part of a tool response: a newline would split the line it sits on, and a long one would crowd out the roster. Write one sentence.
  • Absent, null, blank, or a non-string means nothing is declared. Note what that is not: it is not a claim that the server works, only that the catalog is silent, so the ordinary readiness checks decide. There is deliberately no "unavailable": true — requiring the reason by construction is the whole point.
  • Remove it when the server is fixed. It is a fact about the world, not a permanent label.

Do not encode availability in description instead. Prose like ⚠️ NOT USABLE YET is invisible to every check, cannot be acted on, and goes stale silently — which is exactly what this field replaced.

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.

The injected servers are Zimmer’s own: streamable-HTTP entries pointing at this instance’s native /mcp endpoint (zimmer-self-session, and zimmer for roots with default_subagent_roots). Zimmer synthesizes them rather than resolving them from the catalog, and retargets any zimmer* entry at the instance preparing the session so a staging session never orchestrates production.

Zimmer’s MCP server for the tool surface, the scoped variants, and auth.

Availability, and what an agent is offered

Section titled “Availability, and what an agent is offered”

A catalog entry that cannot start is not a soft failure. SecretsInterpolator raises MissingVariableError on an unresolved ${VAR} at spawn, and nothing rescues it per entry — so attaching one such server fails the whole session, not just that server.

ConnectorStatusProbe answers “could a session attach this right now?” from local signals only: whether each required ${VAR} resolves, and the state of the stored OAuth credential. Four of its states block a spawn — missing_configuration, needs_authorization, needs_reauth, and declared_unavailable (the unavailable field above). token_expired does not, because RefreshMcpOauthTokensJob renews it unaided.

Both surfaces read that one computation:

  • The Connectors page renders every server with its state and what to do about it.
  • get_configs — what an agent reads as “your options” — lists only the servers that can start, then names the rest in a short Unavailable roster, one line each with a compact reason. The roster is not a second catalog: it exists so an agent can tell this server exists and is broken from this server does not exist, the latter being an invitation to go and register a duplicate. A root default that is currently unavailable is marked (unavailable) in the root’s own listing, since that list is otherwise copied into start_session verbatim.

Two signals are deliberately not on this path. Nothing here contacts an MCP server, so get_configs stays fast and deterministic on a routing session’s critical path, and a Ready badge never claims the remote host answered. And a probe that could not determine an answer — store_unavailable when the Parameter Store did not respond, probe_failed for anything unexpected — leaves the server listed. Those are transient and hit every server at once; emptying the whole option list because Google was slow is a worse failure than offering a server that might not start.

When a server cannot connect, the server is left out — not the session

Section titled “When a server cannot connect, the server is left out — not the session”

A handshake that fails is a lost capability, not a lost session. AgentSessionJob#check_and_handle_mcp_failure classifies the failure and takes one of three routes:

Failure classWhat happens
An OAuth-capable server needs authorizationsession.fail! with failure_reason: oauth_required. The one fatal class, because a human clicking Authorize is the fix.
Anything else, first three timesThe retry ladder: MAX_MCP_CONNECTION_RETRIES = 3, backing off 30s / 60s / 120s. Most connect failures are transient — a server still starting after a deploy, an npx cache race — and self-heal here.
Anything else, definitivelyThe server is left out and the session runs on. Also taken immediately, with no retries, for a static credential the provider rejected: a wrong API token does not become right in 30 seconds.

Leaving a server out means:

  • It is marked failed in mcp_servers_status, so the session page and the JSON consumers show it red.
  • It is recorded in metadata["mcp_degraded_servers"] with its error, and AgentSessionJob#build_prompt_with_goal renders that into an <unavailable-mcp-servers> block on every subsequent prompt — so the agent is told the tools are gone rather than discovering it from a tool call that is not there. The block tells it to stop and say so if it genuinely needs the missing capability, rather than improvising a substitute.
  • The session is resumed with a SYSTEM_RECOVERY nudge, which preserves its scheduled wake-ups. A session whose runtime never started ignores the nudge and runs its original prompt instead.
  • Nothing is rewritten in .mcp.json. The server stays configured, so if whatever broke it is fixed the next spawn reconnects for free. The record exists so the same server failing again is a no-op instead of another terminate-and-resume.

The record is retired by exactly one thing: McpStatusPersisting sees that server report connected again. That is the only signal that is actually true about the outage being over, and it re-arms the ladder if the server fails again later. In particular mcp_degraded_servers is deliberately not in Session::STALE_RETRY_METADATA_KEYS — those keys are cleared by every automatic recovery path (a deploy sweep, an orphan sweep, an auth-outage park lifting), and a write-off that vanished on a deploy would let the still-dead server burn the whole ladder again while the agent silently stopped being told it had lost the capability.

Before this, exhausting the ladder killed the session. A last-resort fallback server the session had never called — and never would have — could orphan two hours of completed work on a stale credential belonging to something else entirely (#521). An agent that genuinely needs the missing capability can now say so and stop, which is a far cheaper failure than losing the transcript.

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. Tracked in #113.
  • NPM_CONFIG_CACHE is set to a clone-local .npm-cache, so npx-based servers in different sessions don’t fight over a shared cache.
  • Within one session they still could, because npx keys its install directory on the package spec alone: two servers running the byte-identical npx -y <pkg>@latest resolve to the same _npx/<hash> and, on a cold clone, race to populate it. NpxCacheIsolator finds those servers at config-write time and gives each its own NPM_CONFIG_CACHE under .npm-cache/isolated/<server>/, so there is nothing to race over. Servers that don’t share a package keep the single shared cache, so tarballs are still downloaded once.
  • 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. It is the repair half; the isolator above is the prevention half, and healing still covers corruption from causes Zimmer can’t see coming.
  • NpxBinExecutableGuard runs on the way into every Claude MCP spawn and restores the execute bit on any _npx/*/node_modules/.bin target that has none. Some packages publish their entrypoint as -rw-r--r-- and rely on npm’s bin-linking to chmod it; when that does not land, the server dies on exec with EACCES identically on every retry, so the server is left out for the life of the clone (#467). Codex sessions are not covered — see Limitations.
  • 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.