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.
The entry format
Section titled “The entry format”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"]}| Field | Notes |
|---|---|
type | stdio | sse | streamable-http (http) |
command / args / env | stdio servers |
url / headers | remote servers |
oauth | remote servers that need an OAuth flow |
default_in_roots | which roots get it by default |
unavailable | a standing declaration that this entry cannot work here, and why |
startup_timeout_sec | how long a runtime should wait for this server to start |
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.
startup_timeout_sec: how long this server gets to start
Section titled “startup_timeout_sec: how long this server gets to start”Every MCP server gets three minutes to start and answer initialize. That
one number is simultaneously too generous and too strict: a fast local server that hangs holds a
session for three minutes before anything says so, while a big npx cold start or a remote server
doing OAuth may need more. An entry can name its own budget instead
(#113).
"acme-fast": { "type": "stdio", "command": "node", "args": ["/opt/acme/server.js"], "startup_timeout_sec": 20}- Type: integer seconds, between 5 and 600 inclusive. The bounds are Claude Code’s own
startupTimeoutSecbounds, reused rather than invented. A value outside them, a string ("20"), or a fraction is ignored and warned about in the application log, and the server falls back to the default. - Absent means the default, which is what every entry in the catalog means today: nothing declares one yet. Declaring actual values is the catalog’s own follow-up, not Zimmer’s.
- Check what took effect without a shell.
startup_timeout_secis carried on every server inGET /api/v1/mcp_serversandGET /api/v1/configs— the value Zimmer read, so an entry whose number was refused reportsnullthere rather than the number the catalog wrote. - A longer budget reaches all three runtimes; a shorter one reaches Codex alone. Codex writes it
into that server’s
[mcp_servers.*]table, wherestartup_timeout_secis startup-scoped and the tool budget is a separatetool_timeout_sec— so a fast server fails fast beside a slow one. Pi only ever lengthens: its one key,requestTimeoutMs, is the budget for every request on the connection, so honoring a declared 15 there would cap that server’s tool calls at 15 seconds rather than making its startup fail fast, and a declared value below the default is left on the floor. Claude cannot shorten one server’s at all:MCP_TIMEOUTis a property of the agent process, so Zimmer hands it the largest budget any of the session’s servers asks for, never less than the default. See Timeouts and caching. - Default only. A timeout a repo already wrote into its own checked-in
.codex/config.tomlor.mcp.jsonwins over the catalog’s, the same way every other local value does. So does one Zimmer itself wrote on an earlier prepare — a clone keeps the budget it was prepared with, and picks up a newly declared one when its config is regenerated rather than in place. - It reaches a runtime only because Zimmer writes it. AIR validates the entry (its server
schema sets no
additionalProperties: false) andair resolvereturns the field verbatim, but neither the Claude nor the Codex adapter copies it into a runtime config — both translate a fixed field list. The field is Zimmer’s to read, exactly likeunavailable.
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.
Secrets never touch the catalog
Section titled “Secrets never touch the catalog”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.
Selection is per session
Section titled “Selection is per session”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.
The catalog also carries zimmer* entries you attach deliberately, each scoped to a tool group:
zimmer-sessions, zimmer-fleet, and zimmer-gate-decisions (the gate decision
ledger — separate from zimmer-sessions on purpose, so that carrying
session orchestration does not carry the ability to write gate ratings). gate_decisions is an
opt-in tool group, so zimmer-gate-decisions is the only entry that reaches the ledger tools:
the unscoped zimmer entry does not carry them. zimmer-work-backlog is the same shape for the
work backlog: work_backlog is opt-in, and that entry is the only one
that can append to the queue or pull from it.
→ 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.
Every surface that offers a server reads 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 intostart_sessionverbatim.- The MCP-server pickers on the new-session form, the trigger form and the session detail page
show an unavailable server with an Unavailable badge and its reason, sorted below the ones
that work.
McpServerOptionsbuilds that payload. GET /api/v1/configsandGET /api/v1/mcp_serverscarryunavailable(a boolean) andunavailable_reason(a short string,nullwhen startable) on every server.
Why the pickers flag and get_configs omits
Section titled “Why the pickers flag and get_configs omits”The two surfaces express the same fact in the idiom of their reader, and the difference is deliberate.
An agent’s list is a menu of things it may pass to start_session, and an agent can fix none of the
reasons a server is unavailable — it cannot seed a secret or complete an OAuth consent. Leaving an
unusable option in that menu only invites the failure, so get_configs takes it out and compensates
with the roster.
A human can fix most of them: “OAuth authorization not completed” is one click away at
/connectors, and “FOO_TOKEN unresolved” names the variable to set. For that reader a silent
absence is worse than a flagged entry — a server that vanishes from the picker reads as a broken
catalog, not as a credential to go and seed. So the picker keeps the entry, says why, and sorts it
last.
The picker does not refuse the pick. This is the read path: the list’s job is to say. What the write paths do with a selection that names an unavailable server is the next section.
Neither surface ever filters on store_unavailable or probe_failed — see below.
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.
Creating a session with a server that cannot start: it warns, it does not refuse
Section titled “Creating a session with a server that cannot start: it warns, it does not refuse”Every path that creates a session — Mcp::Tools::StartSession, POST /api/v1/sessions, and the
new-session form — checks the servers the session ends up with against the same readiness answer and
warns. The session is created, the agent job is queued, and the caller is told which server
cannot start and why. McpServerReadiness is the one implementation; the three surfaces differ only
in where they put the sentence.
| Surface | Where the warning lands |
|---|---|
start_session | a ⚠️ line at the end of the tool result, under the session’s id and job id |
POST /api/v1/sessions | a warnings array on the 201 body, beside session. Absent entirely when every server starts |
| The new-session form | a flash alert next to the “Session created successfully” notice |
All three also write the warning to the session’s own log, at warning level. That is the copy
that survives: a flash fades, a tool result scrolls out of an agent’s context, and the log sits on
the session page next to the failure it predicted.
The check reads the session’s resolved server list, not the argument the caller passed. A spawn
that names no servers at all inherits the agent root’s default_mcp_servers, and one of those can
be broken — that caller is the one with the least idea it is happening.
What it costs the session is not the same for all four states
Section titled “What it costs the session is not the same for all four states”The warning names the consequence, and the four blocking states do not share one. Saying they did would contradict two flows this page describes elsewhere, so the sentence branches on the worst state present:
| State | What it actually costs | What the warning says |
|---|---|---|
missing_configuration | The whole session. air prepare exits 1 on the unresolved ${VAR}, before the agent runs | ”preparing it is expected to fail outright until the missing value is set” |
needs_authorization / needs_reauth | Preparation succeeds; AgentSessionJob’s pre-spawn OAuth gate parks the session with Authorize buttons on its page | ”it will park for OAuth authorization before the agent runs” |
declared_unavailable | Only the server’s tools. Nothing local stops the session — see the next section | ”the session will run without those tools” |
Only the first is the expensive case the warning exists for. The worst state present is the one named, because it is the one the caller has to act on and three clauses do not fit a flash toast.
Why not reject
Section titled “Why not reject”Rejecting is the more satisfying answer and it is the wrong one, for two reasons.
Readiness is a local view of a moment. ConnectorStatusProbe is deliberately generous about the
states that mean “Zimmer could not find out” — store_unavailable and probe_failed report as
usable, because a Parameter Store blip hits every server at once. That generosity is affordable on a
badge. On a gate it is not the same bet: making the local view authoritative on the spawn path turns
a slow answer from Google into an outage that blocks spawning.
A restricted connection has no legal way to comply. A connection scoped by allowed_agent_roots
must pass its root’s default_mcp_servers exactly — it can neither add nor drop one. If one of
those defaults is unavailable, that caller is compelled to name a server that cannot start.
Rejecting there would make the root unspawnable until an operator fixed the secret, and would do it
at the moment some other piece of work needed the root. So it warns, the spawn goes through, and the
warning names the server and points at /connectors. This is a decision, not a side effect, and
a restricted connection compelled to pass an unavailable default is warned, not refused pins it.
The warning is advice, and advice must never be able to break a spawn: if the readiness computation
raises, McpServerReadiness says nothing and the session is created exactly as it would have been.
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 class | What happens |
|---|---|
| An OAuth-capable server needs authorization | session.fail! with failure_reason: oauth_required. The one fatal class, because a human clicking Authorize is the fix. |
| Anything else, first three times | The retry ladder: RetryBudget::MCP_CONNECTION (3 attempts), 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, definitively | The 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. |
The route is chosen per server, not per handshake. One handshake can fail several servers for
several different reasons, and the verdict belongs to the server. A session whose Slack token was
rejected while a second server crashed on a corrupt npx cache degrades the first and retries the
second in the same pass: the rejected one is recorded in mcp_degraded_servers with its own
reason, the other rides the ladder, and the _npx heal below runs over both. Classifying the whole
set by its worst member wrote off servers that would have connected on the second attempt, skipped
the heal for them, and told the operator and the agent that a working server’s credentials had been
rejected (#689). Each entry in
mcp_degraded_servers carries its own reason, which is what the <unavailable-mcp-servers> block
renders.
The npx heal runs on both routes, not only before a retry. A degraded server stays in the
runtime config precisely so it reconnects for free once whatever broke it is fixed — and a corrupt
_npx/<hash> tree it left behind is exactly what makes the next spawn crash identically instead.
“A static credential the provider rejected” is read from two places, because a server can name the
rejection in words the transport’s own error never carries. A stdio server that runs a credential
health check at startup and exits when it fails hands the runtime nothing but Connection closed;
what the provider actually said is only in the text the server printed on its own stderr, which
McpLogPollerService folds into the same error blob, joining every entry it saw with " | ":
Server stderr: BrightData: Invalid API key - authentication failed | Connection failed after 3941ms (CONNECTION_CLOSED): Connection closedSo the classifier reads that blob twice. The broad AUTH_ERROR_PATTERN (401, unauthorized,
oauth, invalid_token) covers what the transport says. A much narrower check covers what the
child process said: a Server stderr: marker and a phrase whose whole meaning is “the
credential was refused” (invalid api key, authentication failed, bad credentials), both in the
same joined segment — otherwise a transport-level Connection failed: authentication failed
sitting beside an unrelated stderr line would read as something the server never reported.
Both narrowings are deliberate, because a false positive here fails silently: it stops retrying a
server that would have connected, and nothing errors. So the stderr check never fires on the broad
pattern’s words, and it never routes to the fatal oauth_required branch — an OAuth-capable server
keeps the ladder (#645).
Leaving a server out means:
- It is marked
failedinmcp_servers_status, so the session page and the JSON consumers show it red. - It is recorded in
metadata["mcp_degraded_servers"]with its error, andAgentSessionJob#build_prompt_with_goalrenders 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_RECOVERYnudge, 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.
Remote servers and OAuth
Section titled “Remote servers and OAuth”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:
McpLogPollerServicescrapes the CLI’s MCP log files. - Codex:
CodexMcpStatusDetectorstring-matches tool names againstcodex-rs’sMCP_TOOL_NAME_DELIMITER = "__", and reimplements Codex’s internalsanitize_responses_api_tool_namecharacter rules in Ruby.
Timeouts and caching
Section titled “Timeouts and caching”- Three minutes to start by default, for every MCP server, on all three runtimes — unless the
catalog entry declares its own
startup_timeout_sec. The default is one number —McpStartupTimeout::SECONDS— written in each runtime’s own idiom, because they share no mechanism. Claude readsMCP_TIMEOUT=180000off the agent process’s environment (ClaudeSpawnEnv#configure_mcp_env), which reaches every server it spawns. Codex has no such variable: it readsstartup_timeout_secout of each[mcp_servers.*]table, soCodexConfigTomlPostProcessorwritesstartup_timeout_sec = 180onto every stdio entry that declares nothing of its own. Pi has no such variable either, and no MCP of its own — its client is thepi-mcp-adapterextension, whose knob is per-entryrequestTimeoutMs, soPiMcpConfigPostProcessorwrites"requestTimeoutMs": 180000onto every stdio entry of the.mcp.jsonit seeds that declares nothing longer. The adapter also has a globalsettings.requestTimeoutMs, which Zimmer does not use: it would widen HTTP entries too, and those are exactly the ones left out. On both of those two, HTTP entries get none — they reach a server that is already running, and a longer budget there would only delay reporting a URL that is simply unreachable. - Codex’s own default is 30 seconds, measured against the pinned
@openai/codex@0.146.0binary: a stdio server that never answers delays the first model request by 29.9s over the no-server baseline, andstartup_timeout_sec = 5moves the same measurement to 5.1s. That is the whole exposure — the cold clone below is guaranteed by the cache pinning, and installing all npx servers at once into one fresh clone cache — nine of them when this was measured — takes 18s for the slowest on an idle production droplet. Under 2x margin, on the runtime where running out means the server is dropped rather than merely slow (#702). - The wider budget has a cost, and it is the one Claude already pays: a server that hangs holds the handshake for three minutes instead of thirty seconds, on every launch, since Zimmer respawns stdio servers per run. A slow start is recoverable and a dropped server is not, so that is the trade taken deliberately.
- Pi’s own default is the MCP SDK’s 60 seconds, measured against the pinned
pi-mcp-adapter@2.32.1the same way: aneagerstdio server pointed at anodeprocess that accepts the connection and never answersinitializeis given up on at 63.4s with nothing set, at 33.6s withrequestTimeoutMs: 30000, at 93.6s with90000, and at 183.2s with the180000Zimmer writes — a flat ~3.4s of adapter startup on top of an exactly honored budget (#844). - On an
npxentry, Pi’s budget does not cover the whole cold start. The adapter intercepts acommandofnpxornpmand resolves the package to a concrete bin path itself, before any transport exists (resolveNpxBinaryin itsnpx-resolver.ts) — and it reads the npm cache of the Pi process, not the entry’s ownenv, so the clone pinning below does not reach that lookup. A miss there runsnpm execunder the adapter’s own hard, non-configurable 30-second cap, and on timeout it kills the install and falls back to plainnpx. What Zimmer’s budget covers is that fallback — plus every non-npx stdio server. See the cold-clone section of Limitations. - Pi’s spelling covers more than a startup, and that is deliberate rather than incidental.
requestTimeoutMsis the budget for every request on the connection, so a tool call on a Pi MCP server gets three minutes rather than the SDK’s sixty seconds too. The adapter has no connect-only key to write instead —buildRequestOptionsin itsserver-manager.tsbuilds oneRequestOptionsand hands it toclient.connectand to every call after it. The trade goes the same way as the startup one: a slow tool is recoverable, a tool killed mid-flight is not. - A config entry that already carries
startup_timeout_sec(or the deprecatedstartup_timeout_msCodex folds into the same field), orrequestTimeoutMson Pi, keeps its own value — a timeout a repo wrote into its own checked-in.codex/config.tomlor.mcp.json, which AIR merges around rather than replaces. That is a different source from the catalog field below, and the local file wins over it. - A catalog entry can declare its own budget:
"startup_timeout_sec": 20on the entry. Codex honors it in both directions, because its key is startup-scoped and its tool budget is the separatetool_timeout_sec. Pi honors only a longer one:requestTimeoutMscovers every request on the connection, so a shorter value would cap that server’s tool calls rather than its startup. Claude cannot shorten one server’s at all, and the reason is measured rather than read off a doc: against CLI 2.1.268, a.mcp.jsonentry carrying Claude’s ownstartupTimeoutSeckey times out atMCP_TIMEOUT(60.6s withMCP_TIMEOUT=60000andstartupTimeoutSec: 6), and amcpServerstable in project or usersettings.jsonregisters no server at all. So on Claude, Zimmer setsMCP_TIMEOUTto the largest budget any of the session’s servers asks for, floored at the default: no server is given less room than its entry asks for, and none of them fails fast (#113). - The default still reaches stdio entries only, on Codex and Pi. A declared value is written to remote entries too — that argument is about what Zimmer should assume for a server it knows nothing about, and an entry naming a number has stopped leaving it to Zimmer. A remote server whose OAuth leg is slow is the case that pays for.
- Every server whose
commandisnpxgetsNPM_CONFIG_CACHEwritten into its ownenvtable byRuntimeConfigPostProcessor, pointing at the clone’s.npm-cache. SonpxMCP servers in different sessions never fight over a shared cache. The match is exact:sh -c "npx …", an absolute/usr/bin/npx,npm exec,bunxandpnpm dlxare out of scope and keep whatever cache they inherit. Every catalog entry uses the bare form. - It is written per entry rather than inherited from the agent process on purpose. Codex never sets
the variable —
CodexRuntimeAdapter’s spawn env has none, and Codex builds each stdio server’s environment from a fixed whitelist plus exactly what the entry’s ownenv/env_varsname — so before this, every npx MCP server under Codex resolved against npm’s user-level~/.npm/_npx, shared by every session on the host and outside every clone-scoped mechanism below.ENOTEMPTY … renameon/home/rails/.npm/_npx/<hash>/node_modules/playwrightis what two concurrent sessions installing into one host-wide tree looks like (#595). Claude does export the variable, but a config generator relying on inheritance is the wrong shape either way. - Within one session two servers can still collide, because
npxkeys its install directory on the package spec alone: two servers running the byte-identicalnpx -y <pkg>@latestresolve to the same_npx/<hash>and, on a cold clone, race to populate it.NpxCacheIsolatorfinds those servers at config-write time and gives each its ownNPM_CONFIG_CACHEunder.npm-cache/isolated/<server>/, so there is nothing to race over. Servers that don’t share a package share the clone’s.npm-cache, so tarballs are still downloaded once. Both answers come fromNpxCacheLayout, the one place that knows where a clone’s npm caches live — the isolator writes those paths, the heal, clear and bin-permission mechanisms below walk them, and none of them can drift about which roots exist. - A catalog entry that sets
NPM_CONFIG_CACHEitself keeps its value — that is the operator’s call. NpxCacheHealServiceexists to detect and delete a corrupted_npxcache — 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.NpxBinExecutableGuardruns on the way into every Claude MCP spawn and restores the execute bit on any_npx/*/node_modules/.bintarget that has none. Some packages publish their entrypoint as-rw-r--r--and rely on npm’s bin-linking tochmodit; when that does not land, the server dies onexecwithEACCESidentically on every retry, so the server is left out for the life of the clone (#467). It sweeps every cache root the layout knows about — the shared one and each isolated root — because the two servers that get isolated in practice both runonepassword-mcp-server, the package whose published tarball ships its entrypoint-rw-r--r--(#498). Each root is its own containment boundary, checked twice: a root that resolves outside the clone is not walked at all, and within a root a shim that resolves outside it is refused rather than chmod’ed. Codex sessions are not covered — see Limitations.MCP_PACKAGE_REINSTALLandDockerfile.base’sbin/preinstall-mcp-packagespre-warm the python packages listed inmcp.json, andnpm install -gthe npm ones. The npm half no longer helps an MCP server:NPM_CONFIG_CACHEmoves the whole npm cache into the clone,_cacacheincluded, so a cold clone pays the registry download for every npx server, on every runtime. The startup budget above is the headroom that absorbs it — see Limitations.
The sixteen that ship
Section titled “The sixteen that ship”playwright-custom (the only one default-on, for the zimmer root), context7, linear, and
thirteen others. Read mcp.json for the current list — it changes more often than this page will.