diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f39120498..835cb8b40 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -894,6 +894,17 @@ Tokens are omitted from durable workspace metadata. Launch configuration avoids
Sources: [HTTP host](src/mcp/runtime/BuiltInMcpHttpHost.ts), [tool registrar](src/mcp/runtime/createBuiltInMcpServer.ts), [launch configuration](src/providers/shared/runtime/builtInMcpLaunch.ts).
+**User MCP servers (#1143).** Users can also attach their own MCP servers to Claude and Codex agents.
+
+- **Storage.** Main owns them in `mcp-servers.json`, stored in the de facto `mcpServers` entry shape. Secret values live in separate `safeStorage` blobs, and entries refer to them as `${input:id}`.
+- **Launch.** The renderer sends only a pane's explicit per-agent choices, as `user:` keys in the same override map as built-in domains. At launch, main applies the per-provider defaults, secret readiness, transport support, Codex name collisions and Claude's enterprise MCP policy. It passes the result to the providers.
+- **Claude.** User entries are added to the same private config file as the built-in server. Secrets are `${VAR}` references expanded from the process environment.
+- **Codex.** User servers become `--config mcp_servers..*` overrides. Header values go through `env_http_headers`, and stdio secrets through `env_vars`.
+- **Failure handling.** A requested server that cannot attach is reported (`user-mcp-unavailable`) and never fails the launch. The attached ids are an observed backend fact (`userMcpServerIds`), like `builtInMcpDomains`.
+- **Config files.** Provider config files are never written. The user-scope servers each CLI loads itself are only read, to list them and to detect collisions.
+
+Sources: [service](src/main/userMcp/service.ts), [translators](src/providers/shared/runtime/userMcpLaunch.ts), [model and import](src/shared/userMcp), [design](docs/superpowers/specs/2026-09-22-user-mcp-servers-design.md).
+
#### 5.6.2 Domains and scope
| Domain | Tools/responsibility | Scope notes |
diff --git a/README.md b/README.md
index 130f7cbc4..f41f929de 100644
--- a/README.md
+++ b/README.md
@@ -91,23 +91,31 @@ a running session can move mid-task among Claude Code, Codex, and OpenCode.
-- **TLDR peek** — enable **TLDR MCP** for an agent, then hold **Cmd+L** to
+- **MCP servers** — add any MCP server (stdio, HTTP or SSE) by pasting the
+ config from its README, for example Beeper Desktop's, and choose per provider
+ which ones new Claude and Codex agents get. **Settings → MCP** shows Agent
+ Code's own MCP servers and yours in one grid with a column per provider, and
+ **Agent MCP Servers…** changes one agent's set with a single reload. Tokens
+ are stored encrypted and reach the server through environment variables,
+ never a config file or the command line. Servers the CLIs already load from
+ their own config are listed read-only and can be copied in.
+- **TLDR peek** — turn on **TLDR** for an agent, then hold **Cmd+L** to
see each visible agent’s latest short status centered over its pane.
A small footer shows **Last active** and **Note written** independently, using
relative times and calendar dates for older activity. Release to return. The
**TLDR** palette command also opens the preview; Escape dismisses it. Reporting
is off by default. MCP settings apply to new agents and existing agents on their
next reload, including the managed reporting skill. Explicit per-agent choices
- take priority; **Use Global MCP Settings** clears those choices and reloads the
+ in **Agent MCP Servers…** take priority; its Reset clears them and reloads the
agent. Claude and Codex agents with TLDR are asked to set their goal on the
first prompt (through Goal instead when Goal MCP is also on), and at turn end to update after work that used tools without a
report; the footer notes when that check is not running. **View TLDR History**
shows how an agent's status evolved. The editor keeps Cmd+L Select Line.
-- **Goal peek** — enable **Goal MCP** for an agent, then hold **Cmd+G** to see
+- **Goal peek** — turn on **Goal** for an agent, then hold **Cmd+G** to see
what each visible agent’s work is for, next to the TLDR’s where-it-is status.
Agents set a goal once they understand a task and change it only when the
direction changes, so it stays meaningful while the TLDR moves. Goal has its
- own MCP setting, off by default, and works with or without TLDR; only the agent
+ own row in Settings → MCP, and works with or without TLDR; only the agent
writes it. Claude and Codex agents with Goal are asked for one at the first
prompt and at turn end if it is still missing. **View TLDR History** shows goal
changes alongside status updates. The editor keeps Cmd+G Find Next.
diff --git a/docs/superpowers/plans/2026-09-22-user-mcp-servers.md b/docs/superpowers/plans/2026-09-22-user-mcp-servers.md
new file mode 100644
index 000000000..422abbd1e
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-22-user-mcp-servers.md
@@ -0,0 +1,119 @@
+# MCP Servers: Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** One MCP interface for every MCP server. Users can:
+- add any MCP server by pasting its README snippet;
+- keep its secrets encrypted;
+- choose per provider which servers new agents get, for Agent Code's built-in servers and their own alike;
+- override those choices per agent with one staged reload;
+- see and copy in the servers the CLIs already load directly.
+
+Agents receive user servers when they launch, through the path the built-in MCP servers already use. Provider config files are never written.
+
+**Spec:** `docs/superpowers/specs/2026-09-22-user-mcp-servers-design.md`. Its **Revision 2** section overrides anything in the rest of the spec that conflicts with it. Read Evidence, Decisions and Revision 2 before starting any task.
+
+**Issue:** #1143 (Refs #244). **Status:** user-approved 2026-09-22 (plan auto-approved); Tasks 1–8 implemented on this branch. Verification notes are in the PR.
+
+**Working tree:** `.worktrees/user-mcp-servers`, branch `feat/user-mcp-servers`, based on `origin/main` `672d0941`. Submodules are initialized and `node_modules` is symlinked. Use Node 24 for vitest, because Node 25 breaks happy-dom.
+
+**Conventions:**
+- Write thick WHY comments at every code site that enforces a decision.
+- Use Conventional Commits with scope `mcp`.
+- Test fixtures are the real published Beeper snippets.
+- Verify with `npx tsc -b` and vitest once at the end.
+- Never launch the app.
+
+---
+
+### Task 1: Shared model (`src/shared/userMcp/`)
+- [x] `types.ts`:
+ - `UserMcpServer`, `UserMcpServerEntry`, `UserMcpInput` and `UserMcpDocument`.
+ - The view and problem types.
+ - `USER_MCP_PROVIDERS` (`claude`, `codex`) and the reserved names.
+ - `userMcpOverrideKey(id)` and `userMcpOverridesFrom(map)`.
+- [x] `validate.ts`:
+ - Name rules, entry validation and type normalization.
+ - The rule that `${input:…}` may appear only in `env` and `headers` values.
+ - The support matrix (SSE is Claude-only).
+ - `coerceUserMcpDocument`, which keeps unknown keys and flags malformed servers instead of dropping them.
+- [x] `inputs.ts`: scan and substitute `${input:id}`.
+- [x] `importConfig.ts`:
+ - Accepts the `mcpServers`, VS Code `servers`/`inputs`, bare-map and bare-entry forms.
+ - Moves every literal env and header value into a secret input, returned as `pendingSecrets`.
+- [x] Tests for each of the above, using the Beeper fixtures.
+
+### Task 2: Launch translators (`src/providers/shared/runtime/userMcpLaunch.ts`)
+- [x] `userMcpSecretVariable`: deterministic variable names (needed so Claude's OAuth key stays stable).
+- [x] `claudeUserMcpEntries`: builds Claude's config entries.
+- [x] `addCodexUserMcpLaunchConfig`: builds the Codex arguments, and drops a server whose `env_vars` collide with another's.
+- [x] Widen `createPrivateClaudeMcpConfig(builtIns, userEntries)`.
+- [x] Golden tests, including one asserting that no secret appears in argv.
+
+### Task 3: Main service (`src/main/userMcp/`)
+- [x] `store.ts`: an atomic write of `STATE_DIR/mcp-servers.json` with mode 0600. A corrupt file is preserved rather than overwritten.
+- [x] `secrets.ts`: `safeStorage` blobs, with only a hint ever returned.
+- [x] `nativeServers.ts`:
+ - Lists the CLIs' own user-scope servers for Claude and Codex.
+ - Collects the Codex names used for the collision check.
+ - Detects Claude's managed-policy lock.
+- [x] `service.ts`:
+ - Snapshot and mutations, run through a serialized queue.
+ - `resolveForLaunch(provider, overrides, cwd)`, which returns `{ servers, attachedIds, dropped }`.
+ - A change emitter.
+- [x] IPC in `src/main/ipc/userMcp.ts`, the preload API in `src/preload/api/userMcp.ts`, and wiring in `src/main/index.ts`.
+- [x] Tests for the store, secrets, resolution and native parsing.
+
+### Task 4: Session wiring (main and providers)
+- [x] Add `userMcpOverrides` to the spawn and recover options.
+- [x] Add `userMcpServerIds` to the snapshot, spawn result and recover result.
+- [x] `SessionManager` resolves user servers beside `builtInMcpServers` and records the attached ids per session.
+ - The Codex replacement restore reuses the recorded overrides.
+ - It emits `user-mcp-unavailable`, which the forwarder broadcasts.
+- [x] Claude and Codex sessions accept `userMcpServers`.
+- [x] Tests: a missing secret still spawns; the token is absent from argv.
+
+### Task 5: Renderer model
+- [x] Per-provider `defaultBuiltInMcpDomains`, covering:
+ - type and coercion;
+ - the resolver picking the provider's list;
+ - the refs input type;
+ - the `store.ts` comment.
+- [x] `normalizeBuiltInMcpOverrides` keeps `user:` keys.
+- [x] `clonedMcpOverrides` keeps them too.
+- [x] Every renderer spawn and recover call site sends `userMcpOverrides`, and the pane meta stores `userMcpServerIds`.
+- [x] `features/mcp/store.ts`: a mirror of main's snapshot plus a sync hook, mounted in `App.tsx`.
+- [x] Global toast for `user-mcp-unavailable`.
+
+### Task 6: Settings → MCP
+- [x] Add the `mcp` category.
+- [x] Add the `mcp-servers` marker row, which replaces the eight built-in default toggle rows.
+- [x] Move `external-control` into the new category.
+- [x] `McpServersRow`:
+ - The grid of built-in and user servers, with a column per enabled provider.
+ - Master switches, problem chips, and the ⋯ actions.
+ - The native section with Copy in.
+- [x] `McpServerDialog`:
+ - Add and edit, with paste import and a JSON editor.
+ - Masked secret fields.
+ - Sign in help shows a copyable `codex mcp login -c 'mcp_servers..url=…'` command and Claude `/mcp` guidance. Changed during implementation: the command is shown rather than run in a new terminal pane, because login opens a browser and waits on the user, and that belongs in a terminal the user controls. Codex login reads the effective config including `-c`, so the stored token is reused by every later launch.
+- [x] `ui.openSettings(category?)`.
+
+### Task 7: Commands and the per-agent modal
+- [x] `AgentMcpServersModal` and its surface: staged toggles, one reload, a reset row, and Root Management going through its confirmation dialog.
+- [x] New commands: `mcp-servers`, `add-mcp-server` and `agent-mcp-servers`.
+- [x] Retire `use-global-mcp-settings` and the eight `enable-*-mcp` toggles.
+- [x] Update the control references, `catalog.test.ts`, `taxonomy.test.ts` and the affected renderer tests.
+
+### Task 8: Documentation, verification and PR
+- [x] Update README and ARCHITECTURE.
+- [x] Run `npx tsc -b` and vitest.
+- [x] Real-binary check of the generated Codex `-c` arguments with `codex mcp list`.
+- [ ] Open the PR `feat(mcp): manage MCP servers per provider and per agent` with `Fixes #1143` and `Refs #244`. Run two orchestrated reviewers, fix the valid findings, wait for CI, and do not merge.
+
+## Out of scope (follow-up issues)
+- Add from MCP Registry.
+- OpenCode and Grok user servers.
+- User servers for workflow subagents.
+- Project-scope native server listing.
+- #244 hosted extension servers.
diff --git a/docs/superpowers/specs/2026-09-22-user-mcp-servers-design.md b/docs/superpowers/specs/2026-09-22-user-mcp-servers-design.md
new file mode 100644
index 000000000..ef4d501ec
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-22-user-mcp-servers-design.md
@@ -0,0 +1,518 @@
+# User MCP servers — design
+
+Status: draft for review · Date: 2026-09-22 · Branch: `feat/user-mcp-servers`
+Issue: #1143 (Refs #244)
+
+## Revision 2: one interface for every MCP server (user-approved 2026-09-22)
+
+The user reviewed ASCII mockups and approved a unified interface. It also
+covers Agent Code's own built-in MCP servers. Where this section conflicts
+with anything below, **this section wins**.
+
+1. **One grid for all servers.** Settings → MCP lists Agent Code's built-in
+ servers (TLDR, Goal, Goal Loop, Orchestration, Agent Transcripts, Agent
+ Management, AI Workspace, Workflows) and the user's servers in **one grid**
+ with the same per-provider columns.
+ - The rule for every cell is: *new agents of this provider get it*.
+ - `—` marks a server that provider can't use, with the reason shown.
+ - Root Management is listed as per-agent only; it is never a default.
+ - A provider column appears only when that provider is enabled in
+ Settings → Providers.
+ - Built-in cells cover all four providers, because every launcher carries
+ built-ins. User-server cells for OpenCode and Grok show `—` "Not
+ supported yet", which is honest and keeps the grid rectangular.
+2. **Per-provider built-in defaults.** `settings.defaultBuiltInMcpDomains`
+ changes from one flat list to
+ `Record`.
+ - Coercion copies a legacy flat list to every provider, so nobody's
+ current behavior changes on upgrade.
+ - The resolver still accepts a flat list, meaning "every provider". That
+ keeps orchestration, control and test callers that pass an explicit list
+ valid without a parallel API.
+3. **One per-agent override map.** Per-agent choices for user servers live in
+ the **same** `builtInMcpOverrides` map, under namespaced keys
+ `user:`.
+ - That map already travels through spawn, replace, reload, recovery,
+ undo-close, provider switch, duplicate and the control API. Giving user
+ servers a second parallel map would mean re-threading every one of those
+ paths, and missing one silently drops a choice.
+ - The field name stays as it is, because renaming a persisted workspace
+ field needs a migration.
+ - The type is widened, and a WHY comment at the type explains it.
+4. **Main decides which user servers attach.** The renderer sends only the
+ `user:` subset of the pane's overrides (`userMcpOverrides`) with
+ spawn/recover. Main applies its own store's defaults (the master switch
+ and per-provider flags), overrides, readiness and support.
+ - This replaces the earlier "renderer resolves ids" contract. Main owns
+ the store and the secrets, so letting a stale renderer snapshot decide
+ would add a second authority for no benefit.
+ - Main records the attached ids per session and reports them as
+ `userMcpServerIds` on the session snapshot, spawn result and recover
+ result, exactly like `builtInMcpDomains`.
+5. **One `Agent MCP Servers…` modal.** It lists built-in and user servers for
+ the focused agent, **stages** the toggles, and applies them with **one**
+ reload. "Reset to MCP settings defaults" clears every override.
+ - Root Management in this modal still goes through its confirmation
+ dialog. Applying a staged Root Management grant opens that dialog, and
+ the dialog performs the reload with all the staged choices.
+ - It **retires** `use-global-mcp-settings` and the eight
+ `enable-*-mcp` toggles: AI Workspace, Orchestration, Agent Transcripts,
+ Agent Management, TLDR, Goal, Goal Loop and Workflow.
+ - `enable-root-agent-code-management` (a confirmation-gated command whose
+ id other code refers to) and the debug-only `enable-built-in-mcp-ping`
+ stay.
+6. **Other commands.**
+ - `MCP Servers` opens Settings on the MCP category. `ui.openSettings`
+ gains an optional category.
+ - `Add MCP Server…` opens the add dialog.
+7. **Read-only list of servers the CLIs load directly, now in v1.** It shows
+ user-scope `mcpServers` from `~/.claude.json` (honoring
+ `CLAUDE_CONFIG_DIR`) and `[mcp_servers]` from
+ `${CODEX_HOME:-~/.codex}/config.toml`, each with a **Copy in** action
+ that imports it as a managed server.
+ - Project-scope files depend on the cwd, so Settings (which has no cwd)
+ doesn't list them.
+ - The Codex collision check still reads both the user file and
+ `/.codex/config.toml` at spawn.
+8. **Unavailable notice.** A user server that can't attach is broadcast as
+ `user-mcp-unavailable { servers: [{ name, reason }] }` and shown by the
+ global toast, with the same repeat window as `managed-skills-unavailable`.
+9. **External operator MCP** moves unchanged into the MCP category.
+
+## Problem
+
+Agent Code cannot add, configure, or switch off a third-party MCP server. The
+only MCP servers it knows about are its own: the `agent_code` built-in host
+(TLDR, Goal, orchestration, …) and the reserved `agent-code-control` external
+operator server. A user who wants, say, the Beeper Desktop MCP server attached to
+their agents today has to leave the app and hand-edit `~/.claude.json` or
+`~/.codex/config.toml` (or run `claude mcp add` / `codex mcp add`). They then
+get it on *every* agent of that provider, can't switch it off for one agent,
+can't see it anywhere in Agent Code, and have to repeat the work per provider.
+
+Wanted: a single place in Agent Code to add **any** MCP server (stdio,
+Streamable HTTP, SSE; with secrets; OAuth-capable), choose which providers it
+attaches to, override it per agent, and flip it on/off from Settings and the
+command palette. The scope is **Claude Code and Codex**. Beeper Desktop is the
+worked example and acceptance fixture, not a special case.
+
+This complements the provider enablement feature (#1126, Settings →
+Providers). Enablement decides which providers exist in the app, and this
+feature decides which user MCP servers each of them gets.
+
+## Evidence this design rests on
+
+The research ran on 2026-09-22 and is summarized here so the next session
+doesn't have to redo it.
+
+### What Agent Code does today
+
+- **Built-in MCP.** One loopback Streamable-HTTP host
+ (`src/mcp/runtime/BuiltInMcpHttpHost.ts`) mints a per-session bearer and
+ returns one `BuiltInMcpServerConfig` named `agent_code`.
+ `SessionManager.spawn` (`src/main/sessionManager.ts:2875`) passes
+ `builtInMcpServers` into the provider's `createSession`.
+- **Per-launch injection, no durable writes**
+ (`src/providers/shared/runtime/builtInMcpLaunch.ts`):
+ - **Claude** gets a mode-0600 private temp `mcp.json`, passed as
+ `--mcp-config ` and deleted on stop or rollback
+ (`claudeSession.ts:1189-1202`).
+ - **Codex** gets `--config mcp_servers..url=…` plus
+ `env_http_headers.="AGENT_CODE_MCP_i_j"`. Values go in the child env,
+ never in argv.
+ - **OpenCode** gets `OPENCODE_CONFIG_CONTENT` with `{env:…}` references.
+- **User-native servers already load.** `--strict-mcp-config` is deliberately
+ *not* used. Claude gets a targeted `deniedMcpServers` instead, inside the
+ **single** `--settings` value built by `excludeExternalControlFromClaude`
+ (`externalControlExclusion.ts:47-60`). Codex keeps the user's `CODEX_HOME`.
+ So whatever the user put in `~/.claude.json`, `.mcp.json` or
+ `config.toml` still loads, but Agent Code never reads, shows or edits it.
+- **Settings model for built-ins.**
+ - Global `settings.defaultBuiltInMcpDomains` lives in renderer
+ localStorage.
+ - Per-pane `builtInMcpOverrides` (absent means inherit, `false` means
+ explicit off) is resolved by `resolveSessionBuiltInMcpDomains`
+ (`src/renderer/src/workspace/mcpDomains.ts`).
+ - The pane's `builtInMcpDomains` is the *observed* launched set, not a
+ choice.
+ - Changes apply on the next spawn or reload through
+ `reloadSessionWithBuiltInMcpOverrides`.
+- **Codex `config.toml` writer precedent.** The only writer into provider-owned
+ config is `src/main/settings/externalCodexConfig.ts`: a hash-stamped managed
+ block, deep-equal proof that nothing else changed, refusal to touch a
+ same-name unmanaged server, and observed-compare atomic replace. It is about
+ 130 lines of safety for **one** server. That cost is why this design does
+ not write provider config.
+- **Provider enablement (#1126)** is the store/IPC/broadcast pattern to copy:
+ - main-owned state with a coerce-on-load setter;
+ - `provider-enablement:get|set|reset` IPC plus a
+ `provider-enablement:changed` broadcast;
+ - a non-persisted zustand mirror in the renderer
+ (`features/providers/store.ts`) with a snapshot getter and a hook, synced
+ once from `App.tsx`;
+ - a self-subscribing settings "marker row" (`ProviderEnablementRow.tsx`).
+- **Secrets precedent.** `src/main/dictation/apiKeyStore.ts` uses Electron
+ `safeStorage` per-file blobs with no auth prompt; the renderer only sees a
+ last-4 hint. The key vault (`src/main/keyVault`) gates every read behind
+ Touch ID, which is wrong for secrets resolved during automatic restore.
+- **Open issue #244** ("Host user-authored MCP servers as Agent Code
+ extensions") is broader: Agent Code *hosting* author-written servers through
+ a manifest. This design is the configuration layer #244 needs anyway. A
+ hosted extension server can later appear as one more entry in the same list.
+
+### How the CLIs load MCP servers (vendor source + installed claude 2.1.280 / codex-cli 0.155.1)
+
+| | Claude Code | Codex |
+|---|---|---|
+| Per-launch injection | `--mcp-config …` (variadic, later wins, overrides all file scopes) | `-c mcp_servers..*=` (TOML-valued, deep-merged SessionFlags layer, precedence 30) |
+| Launch vs user entry, same name | Whole entry replaced | **Deep-merged key by key.** A user `command` plus our `url` becomes an invalid mixed-transport config, and the launch fails |
+| Transports | stdio, http, sse (deprecated), ws | stdio, streamable HTTP. **No SSE** |
+| Secret indirection | `${VAR}` / `${VAR:-d}` expanded in command, args, env, url, headers | No expansion. Use `env_vars=[names]` (stdio), `bearer_token_env_var`, `env_http_headers` |
+| stdio child env | Inherits Claude's full env plus `env` | Fixed allowlist (HOME, PATH, …) plus `env_vars` plus literal `env` |
+| Name charset | `[A-Za-z0-9_-]` | `[A-Za-z0-9_\-:@/.]`, but `-c` splits paths on `.` naively and never unquotes |
+| OAuth | Keychain `mcpOAuth`, key `name\|sha256({type,url,headers})[:16]`. `/mcp` login works for `--mcp-config` servers | Keyring "Codex MCP Credentials", key `name + url`. `codex mcp login` searches the *effective* config, so `-c` servers work |
+| Persistent per-server off | `disabledMcpServers` in `~/.claude.json` `projects[gitRoot]`, checked **by name for every scope including `--mcp-config`** | `enabled=false` in config.toml |
+| Invalid entry | Process exits 1 | Config load fails, so the launch fails |
+| Enterprise lock | `managed-mcp.json` present: non-sdk `--mcp-config` entries are rejected and the process exits | `requirements.toml` allowlist can disable |
+| Live reload in TUI | Only on `/clear` or `/reload-plugins` | Not reachable from the TUI |
+
+### Beeper Desktop MCP (worked example)
+
+- **Built in.** Beeper Desktop has its own MCP server (enable it under
+ Settings → Developers / Integrations). It runs as Streamable HTTP at
+ `http://localhost:23373/v0/mcp`.
+- **Auth.** OAuth 2.0 + PKCE is the default. Alternatively
+ `Authorization: Bearer ` with a token from Settings → Integrations
+ → Approved connections, which bypasses OAuth.
+- **Official snippets:** `claude mcp add beeper http://localhost:23373/v0/mcp -t http`,
+ `codex mcp add beeper --url http://localhost:23373/v0/mcp [--bearer-token-env-var X]`,
+ and the generic `{"mcpServers":{"beeper":{"url":…,"headers":{"Authorization":"Bearer …"}}}}`.
+- **stdio alternatives:** `npx -y @beeper/mcp-remote` or `@beeper/desktop-mcp`
+ (env `BEEPER_ACCESS_TOKEN`).
+
+### Open-source prior art (what to build on)
+
+- **De facto config shape.** Most server READMEs publish
+ `{"mcpServers": {"": {command,args,env} | {type,url,headers}}}`.
+ Claude Code, Claude Desktop and Cursor use it. VS Code differs: it uses
+ `servers` and has `inputs` with `password: true`, referenced as
+ `${input:id}`, prompted once and stored in a secret store. Claude Desktop's
+ MCPB manifest converged on the same idea (`user_config` with
+ `sensitive: true`).
+- **`add-mcp` (Apache-2.0, neon-solutions).** Its per-client
+ `transformConfig(name, cfg)` is the right shape, and ~30 lines per client.
+ It isn't worth depending on: it has CLI deps, and its Codex transform is
+ wrong (SSE, `type` key, no env indirection). We copy the idea, not the
+ package.
+- **Aggregators (MetaMCP, 1MCP, MCPHub, ToolHive, Docker gateway): rejected.**
+ - A supervised runtime to bundle.
+ - Breaks each CLI's native OAuth.
+ - Rewrites tool names (breaking permission rules and our transcript
+ rendering).
+ - A single point of failure for all servers.
+ - Their one advantage, hot-swap without restart, doesn't outweigh these.
+- **MCP Registry** (registry.modelcontextprotocol.io, v0.1 API frozen, still
+ *preview*). `server.json` `packages[]` / `remotes[]` with
+ `isSecret`-flagged env and headers maps directly onto our inputs model. It
+ is a good optional "Add from registry" source later, but not v1. Beeper
+ isn't listed.
+
+## Decisions
+
+| Question | Decision | Why |
+| --- | --- | --- |
+| Deliver by writing provider config, or at launch? | **At launch only.** Never write `~/.claude.json`, `.mcp.json` or `config.toml`. | Reuses the proven built-in path. There's no clobber race with live Claude processes (which rewrite `~/.claude.json` constantly), no `codex mcp add` table rewrite, and a per-agent subset comes free. The price, "needs an agent reload", already holds for built-in MCP and for both CLIs' own config. |
+| Canonical storage format | **The de facto `mcpServers` entry shape**, plus VS Code-style `${input:id}` secret references and a little Agent Code metadata. | Users paste README snippets unchanged. The format is already understood by every MCP author, so there is no bespoke schema to learn or document. |
+| Where state lives | **Main-owned** `STATE_DIR/mcp-servers.json` (0600), not renderer localStorage. | Main resolves secrets and builds launch material. Multiple windows need one owner. This matches provider enablement. |
+| Secrets | `safeStorage` per-file blobs under `STATE_DIR/mcp-secrets/`, no auth prompt, never sent to the renderer (hint only). | A Touch ID-gated vault would prompt during automatic restore or reload. This is the dictation key precedent. |
+| Where may `${input:id}` appear? | **Only in `env` values and `headers` values.** Rejected in `command`, `args` and `url`. | Codex doesn't expand variables, so a secret in args or url lands in argv / `ps`. Servers that need a token on their command line (e.g. `mcp-remote --header "…${X}"`) already expand their *own* env, so the user writes `${X}` in args and puts the secret in `env.X`. |
+| Toggle semantics | Three levels. **`enabled`** is a master switch ("off" means off everywhere, even with a per-agent on). **`providers.{claude,codex}`** is the default attachment for agents of that provider. **Per-agent overrides** add or remove a server for one agent. | Mirrors the built-in MCP defaults-plus-overrides model users already know, plus Cursor's "toggle without deleting". |
+| Provider scope | Claude and Codex. The data model is keyed by provider, so OpenCode or Grok is an additive task later. | User scope. OpenCode's launcher already has an inline-config path to extend. |
+| Transport support matrix | stdio: Claude + Codex. http: Claude + Codex. sse: **Claude only** (the Codex column is disabled with a reason). | Codex has no SSE. |
+| Name rules | Must match `^[A-Za-z0-9_-]{1,64}$`. `agent_code` and `agent-code-control` are rejected (case-insensitive). No automatic prefixing. | The charset is the intersection of both CLIs and `-c` path parsing. A prefix would uglify every tool name (`mcp__ac-beeper__send_message`) and break the permission rules users copy from docs. |
+| Same name as a user-native server | **Codex: skip injecting that server for this spawn and warn.** Claude: inject (launch replaces the native entry), and show a notice in Settings. | The Codex deep merge can create an invalid mixed-transport entry that kills the whole launch. Claude replacement is well-defined. |
+| OAuth | **Delegated to each CLI.** Settings offers "Sign in…" for HTTP servers. For Codex it opens a terminal pane running `codex mcp login ` with the same `-c` overrides. For Claude it tells the user to run `/mcp` in an agent that has the server attached. | Both CLIs already store OAuth tokens securely and key them stably. Building our own OAuth client would duplicate that and break the CLIs' own refresh logic. |
+| Aggregator/proxy | Rejected. | See prior art above. |
+| Registry / catalog | Out of v1. Follow-up issue. | Preview API; paste already covers "any server". |
+| Workflows (Codex SDK subagents) | Out of scope. They keep their private `CODEX_HOME` without user servers. | Replay safety (`inheritedMcpServers: 'unknown'`). |
+| Settings location | New **MCP** category holding the user-servers row, **plus** the existing built-in MCP default rows and the External operator MCP row moved from Agents. | One place for MCP. The move is a category change on existing registry entries (opportunistic cleanup within blast radius). |
+| Editing UI | **One JSON editor per server** (the standard entry shape), plus masked secret fields generated from the `${input:*}` references it contains. No field-by-field form builder. | "Any MCP server" means any shape a README publishes. A form either restricts that or grows forever. The user explicitly asked for a lean UI. |
+
+## Contracts
+
+### Stored document (`STATE_DIR/mcp-servers.json`, mode 0600)
+
+```ts
+// src/shared/types/userMcp.ts
+export const USER_MCP_PROVIDERS = ['claude', 'codex'] as const
+export type UserMcpProvider = (typeof USER_MCP_PROVIDERS)[number]
+
+/** The de facto `mcpServers` entry. Kept structurally identical to what
+ * READMEs publish so paste → store → re-export is lossless. */
+export type UserMcpServerEntry =
+ | { type?: 'stdio'; command: string; args?: string[]; env?: Record; cwd?: string }
+ | { type: 'http' | 'sse'; url: string; headers?: Record }
+
+export type UserMcpInput = {
+ id: string // referenced as ${input:}; ^[A-Za-z0-9_-]{1,64}$
+ description: string // shown next to the masked field
+}
+
+export type UserMcpServer = {
+ id: string // stable uuid; overrides key on this, never on name
+ name: string // provider-visible name, see name rules
+ enabled: boolean // master switch
+ providers: Record // default attachment per provider
+ entry: UserMcpServerEntry
+ inputs: UserMcpInput[] // secret definitions only; values live in safeStorage
+}
+
+export type UserMcpDocument = { version: 1; servers: UserMcpServer[] }
+```
+
+A pasted entry that has no `type` but does have `url` is normalized to
+`type: 'http'`. That is Cursor/Claude Desktop behavior, and Streamable HTTP is
+the current transport. Unknown extra keys in `entry` are **preserved**, not
+dropped (see "What would make this wrong").
+
+### Renderer-visible snapshot (IPC, no secret values)
+
+```ts
+export type UserMcpServerView = UserMcpServer & {
+ secrets: Record
+ problems: UserMcpProblem[] // validation + readiness, computed in main
+ support: Record
+}
+export type UserMcpProblem =
+ | { kind: 'invalid-name' | 'reserved-name' | 'invalid-entry'; message: string }
+ | { kind: 'secret-in-forbidden-field'; field: string }
+ | { kind: 'unknown-input'; inputId: string } // ${input:x} with no definition
+ | { kind: 'secret-missing'; inputId: string }
+ | { kind: 'duplicate-name'; otherId: string }
+```
+
+IPC channels are `user-mcp:get`, `user-mcp:save-server` (upsert by id),
+`user-mcp:delete-server`, `user-mcp:set-enabled`, `user-mcp:set-provider`,
+`user-mcp:set-secret`, `user-mcp:clear-secret` and `user-mcp:import` (parse
+only, returns candidate servers), plus the broadcast `user-mcp:changed`. Every
+mutation returns the new full snapshot, as provider enablement does.
+
+### Per-agent state (renderer pane metadata, beside `builtInMcpOverrides`)
+
+```ts
+userMcpOverrides?: Record // absent = inherit
+userMcpServerIds?: string[] // OBSERVED: what the running process was launched with
+```
+
+`resolveSessionUserMcpServerIds({ provider, servers, overrides })` returns
+enabled servers whose provider default is on, plus overrides set to `true`,
+minus overrides set to `false`. It then filters out servers whose `enabled` is
+false and servers that don't support that provider. It is a pure function in
+`src/renderer/src/workspace/userMcp.ts`.
+
+`clonedMcpOverrides` copies `userMcpOverrides` too, because a duplicate
+should keep its tools. `Use Global MCP Settings` clears both override maps.
+
+### Spawn contract (renderer → main)
+
+`SessionSpawnOptions` / `SessionRecoverOptions` gain
+`userMcpServerIds?: string[]`. Main is the authority:
+
+1. It re-reads its store and drops unknown ids, disabled servers, servers
+ unsupported on this provider, servers with problems, and servers with a
+ missing secret.
+2. It resolves secrets and builds `ResolvedUserMcpServer[]`.
+3. It passes that list into `createSession({ …, userMcpServers })`.
+4. `SessionInfo.userMcpServerIds` reports what was actually attached.
+
+For every dropped id, main emits `user-mcp-unavailable
+{ sessionId, servers: [{ name, reason }] }`, shown the same way as
+`managed-skills-unavailable` (`sessionManager.ts:220, 2724`). **A bad user
+server never fails an agent launch.**
+
+The renderer sends ids, not configs, because secrets never leave main, and
+because a stale renderer snapshot must not be able to inject a server the user
+has since deleted.
+
+### Launch material (main, per provider)
+
+`src/providers/shared/runtime/userMcpLaunch.ts`. Each translator is a pure
+function (`servers → { claudeEntries | codexArgs, env }`), in the spirit of
+add-mcp's `transformConfig`.
+
+**Secret variable naming.** Each `env`/`headers` value that contains
+`${input:…}` is fully substituted in main. The result goes in a generated
+variable named
+`AGENT_CODE_USER_MCP__`: uppercased, non-alphanumerics mapped to
+`_`, and made collision-free with a numeric suffix. The name is
+**deterministic across spawns**. That is required because Claude keys stored
+OAuth tokens on `hash(type,url,headers)`, and headers carry these variable
+names, so an index-based name that shifted when another server was toggled
+would silently discard the user's OAuth login.
+
+**Claude.** User entries merge into the **same** private 0600 file that
+`createPrivateClaudeMcpConfig` already writes, so there's still one
+`--mcp-config`, still last among the flags:
+
+- stdio: `{type:'stdio', command, args, env:{K: '${AGENT_CODE_USER_MCP_…}' | literal}}`.
+- http/sse: `{type, url, headers:{H: '${AGENT_CODE_USER_MCP_…}' | literal}}`.
+- The generated variables go into the Claude process env, and Claude expands
+ them.
+- The file is created even when no built-in servers are enabled. Today the
+ function returns `null` for an empty list, so the signature widens.
+
+**Codex.** Extend beside `addCodexBuiltInMcpLaunchConfig`:
+
+- stdio:
+ - `mcp_servers..command="…"`
+ - `mcp_servers..args=[…]` (TOML array via JSON-compatible literal)
+ - `mcp_servers..cwd="…"`
+ - `mcp_servers..env_vars=["K1","K2"]`, where each `K` is set to its
+ substituted value in the Codex process env. Codex passes env to stdio
+ children only through its allowlist plus `env_vars`, and a literal `env`
+ would put values in argv.
+ - **Collision:** if two attached stdio servers need the same env key with
+ different values, the second is dropped with a reason. `env_vars` can't
+ rename.
+- http:
+ - `mcp_servers..url="…"`.
+ - Every header goes through `env_http_headers.=""`,
+ exactly like the built-ins. That includes `Authorization`: Codex rejects
+ literal `bearer_token`, and `env_http_headers` covers it without special
+ casing.
+- Name collision with a user-native Codex server: before building args, main
+ parses `${CODEX_HOME:-~/.codex}/config.toml` `mcp_servers` keys with
+ `@iarna/toml` (already a dependency). On a hit, it skips and warns. A
+ project `.codex/config.toml` in the cwd is checked the same way.
+
+**Claude enterprise lock.** If `managed-mcp.json` exists (macOS:
+`/Library/Application Support/ClaudeCode/managed-mcp.json`), user servers are
+not injected for Claude, and each gets a `managed-policy` reason. Injecting
+them would make the process exit.
+
+### Import parser (`user-mcp:import`)
+
+It accepts, in order:
+
+1. `{"mcpServers": {…}}`, the Claude, Cursor and Claude Desktop form.
+2. `{"servers": {…}, "inputs": [...]}`, the VS Code form. `inputs` with
+ `password: true` become our inputs, and non-password `promptString` inputs
+ become literal placeholders the user must fill.
+3. A bare `{"": {command|url…}}` map.
+4. A single bare entry `{command|url…}`, for which the user supplies a name.
+
+The import rule is that every literal `env` value and `headers` value becomes
+a generated secret input (`-`), with its value pre-filled from the
+paste and stored in safeStorage. The JSON keeps only `${input:…}`. The user can
+edit a value back to a literal if it isn't sensitive. We deliberately don't
+guess sensitivity from key names (same reasoning as the built-in launcher's
+`env_http_headers` WHY comment): a missed guess leaks a token into a
+plaintext file.
+
+## UX
+
+### Settings → MCP (new category)
+
+1. **Your MCP servers** (marker row `user-mcp-servers`). Each server shows:
+ - name, and a transport summary (`http · localhost:23373/v0/mcp`,
+ `stdio · npx -y @beeper/mcp-remote`);
+ - a master switch;
+ - one checkbox per provider that is **enabled in Settings → Providers**,
+ so a disabled provider's column is hidden (tying into #1126);
+ - status chips from `problems` and support reasons (for example
+ "Claude only: Codex has no SSE", "Secret not set", "Name collides with
+ your Codex config");
+ - Edit, Delete and, for HTTP servers, Sign in….
+ - Footer: **Add server…** and "Changes apply to new agents and on agent
+ reload."
+2. **Add / Edit dialog.** A name field, a JSON editor holding the entry (paste
+ anything from a README), and a live list of masked secret fields, one per
+ `${input:id}` found. Validation errors show inline and come from the same
+ main-side validator. "Paste config" imports multiple servers at once.
+3. The built-in MCP default rows and External operator MCP move here
+ unchanged.
+
+### Command palette
+
+Per `docs/command-style.md`:
+
+| id | Title | Surface | Behavior |
+|---|---|---|---|
+| `user-mcp-servers` | `MCP Servers…` | app | Picker of all user servers with toggle state, plus "Add server…" and "Open MCP settings" rows. Selecting a server flips its master switch; `keepPaletteOpen`. |
+| `add-user-mcp-server` | `Add MCP Server…` | app | Opens the Add dialog with the paste box focused. |
+| `agent-user-mcp-servers` | `Agent MCP Servers…` | session | Picker for the focused Claude or Codex agent. Each row shows attached, inherited or overridden state. Selecting one writes the override and reloads the agent through the existing reload path, one reload per toggle, like the built-in MCP commands. |
+| `use-global-mcp-settings` | (existing) | session | Also clears `userMcpOverrides`. |
+
+The catalog is context-free, so it can't generate one command per server.
+Hence pickers. `ui.openSettings()` gains an optional category argument so
+"Open MCP settings" deep-links.
+
+## What would make this wrong (invariants)
+
+- **Secrets never reach** argv, the renderer, `mcp-servers.json`, logs or
+ incident bundles. Secrets *do* reach the agent's own process env and the
+ private 0600 Claude file, so the agent's shell tools can read them. That is
+ equally true of the existing built-in bearer and of the CLIs' own config,
+ and it is accepted and stated in the UI copy.
+- **A bad user server never fails an agent launch.** Main validates and
+ drops. The CLIs exit on invalid config (see evidence), so passing something
+ unvalidated through is a fleet-wide outage.
+- **Only one `--settings` for Claude.** This feature adds no Claude settings
+ fragment. If it ever needs one (for example to hide a native server), it
+ must merge into `excludeExternalControlFromClaude`.
+- **Unknown entry keys survive a round trip.** READMEs use client-specific
+ keys (`oauth`, `headersHelper`, `timeout`). For Claude they pass through
+ untouched. For Codex, only the known keys are translated, and the rest are
+ ignored with a visible "Ignored by Codex: …" note, never silently dropped
+ from storage.
+- **The renderer never decides what gets attached.** It proposes ids, and main
+ disposes. The launched set on the pane is observed (`userMcpServerIds`), and
+ recovery adopts the running process's set, exactly like
+ `builtInMcpDomains`.
+- **The master switch really means off.** No per-agent override may resurrect
+ a server whose `enabled` is false.
+
+## Known limitations (stated, not solved)
+
+- A config change needs an agent reload. Neither TUI reloads MCP config
+ (Claude only on `/clear` or `/reload-plugins`).
+- Claude's persistent `disabledMcpServers` (set via `/mcp` in a repo) applies
+ by name to injected servers too. Toggling an injected server inside `/mcp`
+ writes that state back into `~/.claude.json`. We document this and don't
+ fight it.
+- Native servers (from `~/.claude.json`, `.mcp.json` or `config.toml`) still
+ load and aren't shown. The read-only "Also loaded natively" list is a
+ follow-up.
+- Codex can't use SSE-only servers.
+- Workflow subagents don't get user servers.
+
+## Follow-ups (separate issues after v1)
+
+1. Read-only "Also loaded natively" list, with "Copy into Agent Code".
+2. "Add from registry" via the MCP Registry v0.1 API (preview).
+3. OpenCode and Grok support (the data model and translators are additive).
+4. #244: Agent Code-hosted extension servers register into the same list.
+
+## Acceptance (Beeper as the fixture)
+
+1. Paste Beeper's official token snippet →
+ - the result is one server, `beeper`, of type http with a
+ `beeper-authorization` secret;
+ - the JSON on disk contains no token.
+2. With Claude and Codex both ticked, a new Claude agent's private
+ `mcp.json` has `beeper` with an `${AGENT_CODE_USER_MCP_BEEPER_AUTHORIZATION}`
+ header.
+3. A new Codex agent's argv has `mcp_servers.beeper.url` and
+ `env_http_headers.Authorization="AGENT_CODE_USER_MCP_BEEPER_AUTHORIZATION"`,
+ and no token appears in argv.
+4. `Agent MCP Servers…` → Beeper off → after the reload the agent has no
+ beeper tools, and `userMcpServerIds` excludes it.
+5. The master switch off → no new agent gets it, even one with a per-agent
+ on override.
+6. A stdio server (`npx -y @beeper/mcp-remote`) works on both providers.
+7. An SSE server shows the Codex column disabled with a reason.
+8. A server named `agent_code` is rejected. A server colliding with a
+ `[mcp_servers.X]` in the user's `config.toml` is skipped for Codex with a
+ visible reason, and the Claude launch is unaffected.
+9. A deleted secret → the agent still launches, without that server, and
+ shows the unavailable notice.
diff --git a/src/main/index.ts b/src/main/index.ts
index 4d69ab6f7..b84aa9b09 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -138,6 +138,8 @@ import { RemoteController } from '@main/remote/RemoteController.js'
import { CaffeinateController } from '@main/caffeinate/CaffeinateController.js'
import { createFileVaultStore } from '@main/keyVault/vaultStore.js'
import { createSafeStorageCodec } from '@main/keyVault/safeStorageCodec.js'
+import { UserMcpService } from '@main/userMcp/service.js'
+import { sweepStalePrivateMcpConfigs } from '@providers/shared/runtime/builtInMcpLaunch.js'
import { VaultService } from '@main/keyVault/VaultService.js'
import { buildAppMenu } from '@main/menu/appMenu.js'
import { UpdateService } from '@main/updates/UpdateService.js'
@@ -1026,6 +1028,16 @@ async function startApp(): Promise {
const agentCodeConventionsService = new AgentCodeManagedSkillsService()
await agentCodeConventionsService.initialize()
assertStartupOpen()
+ // User MCP servers (#1143). Loaded before the manager so the first restored
+ // agent already launches with them; initialize() never throws (a corrupt
+ // document is moved aside and reported in Settings instead).
+ const userMcpService = new UserMcpService({ stateDir: STATE_DIR, codec: createSafeStorageCodec() })
+ await userMcpService.initialize()
+ // Private MCP config files now carry user secrets; a crash must not leave
+ // them in the temp dir. Before any agent can launch, so nothing live is hit.
+ void sweepStalePrivateMcpConfigs().then(removed => {
+ if (removed > 0) appRunJournal?.record({ area: 'mcp.user', name: 'private_config.swept', data: { removed } })
+ })
manager = new SessionManager(
tmuxAvailable ? tmuxRegistry : null,
builtInMcpHost,
@@ -1042,6 +1054,7 @@ async function startApp(): Promise {
)
},
)
+ manager.setUserMcpResolver(params => userMcpService.resolveForLaunch(params))
// Adapters seal streams a sleep severed (#963); the manager fans each
// suspension out to the live agent runtimes.
systemSuspension.on('suspension', (suspension: import('@shared/types/systemSuspension.js').SystemSuspension) => {
@@ -1218,6 +1231,14 @@ async function startApp(): Promise {
},
sessionManager: manager,
appRunJournal,
+ // #1143: the mcp_servers domain edits the same document Settings → MCP
+ // does, through the same service. Every agent-made change is broadcast so
+ // the user always learns that their MCP configuration changed.
+ userMcpService,
+ onUserMcpChangedByAgent: event => {
+ appRunJournal?.record({ area: 'mcp.user', name: 'user_mcp.agent_change', ids: { sessionId: event.sessionId }, data: { message: event.message } })
+ broadcastToWindows('user-mcp:agent-change', { message: event.message })
+ },
workflowService: activeWorkflowService,
workflowBridge: activeWorkflowBridge,
// Root Agent Code Management (#906): the SAME operator catalog the external
@@ -1415,6 +1436,7 @@ async function startApp(): Promise {
const conversationService = createConversationService({ ledger: conversationLedger, listWorktrees: listWorktreesForCwd })
registerAllIpc({
manager,
+ userMcpService,
remoteController,
lspManager,
ghostJournals,
diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts
index b8f96047e..3c35df518 100644
--- a/src/main/ipc/index.ts
+++ b/src/main/ipc/index.ts
@@ -47,6 +47,8 @@ import type { AppRunJournal } from '@main/incident/AppRunJournal.js'
import { registerIncidentIpc } from '@main/ipc/incident.js'
import { registerLifecycleIpc } from '@main/ipc/lifecycle.js'
import { registerProviderEnablementIpc } from '@main/ipc/providerEnablement.js'
+import { registerUserMcpIpc } from '@main/ipc/userMcp.js'
+import type { UserMcpService } from '@main/userMcp/service.js'
import { registerUsageIpc } from '@main/ipc/usage.js'
import { registerCliUpdatesIpc } from '@main/ipc/cliUpdates.js'
import type { CliUpdateOrchestrator } from '@main/setup/cliUpdateOrchestrator.js'
@@ -73,6 +75,7 @@ import type { SystemSuspensionTracker } from '@main/systemSuspension/SystemSuspe
export type IpcDeps = {
manager: SessionManager
+ userMcpService: UserMcpService
lspManager: LspManager
ghostJournals: GhostJournalRegistry
dictationDebugJournals: DictationDebugJournalRegistry
@@ -140,6 +143,7 @@ export function registerAllIpc(deps: IpcDeps): void {
)
registerDebugIpc(deps.appRunJournal, lifecycleDiagnostics)
registerProviderEnablementIpc()
+ registerUserMcpIpc(deps.userMcpService)
registerUsageIpc()
registerCliUpdatesIpc(deps.cliUpdateOrchestrator)
registerWorkflowIpc(deps.workflowBridge)
diff --git a/src/main/ipc/userMcp.ts b/src/main/ipc/userMcp.ts
new file mode 100644
index 000000000..a8f8479f0
--- /dev/null
+++ b/src/main/ipc/userMcp.ts
@@ -0,0 +1,85 @@
+import { ipcMain } from 'electron'
+
+import type { UserMcpService } from '@main/userMcp/service.js'
+import { broadcastToWindows } from '@main/window/windowRegistry.js'
+import { isUserMcpProvider, isUserMcpServerId, type UserMcpSaveInput } from '@shared/userMcp/types.js'
+import { isPlainObject } from '@shared/userMcp/validate.js'
+
+export const USER_MCP_CHANGED_CHANNEL = 'user-mcp:changed'
+export const USER_MCP_UNAVAILABLE_CHANNEL = 'user-mcp:unavailable'
+
+// Pasted configs are small; anything larger is not an MCP snippet and would
+// only cost a JSON parse in main.
+const MAX_IMPORT_CHARS = 256 * 1024
+const MAX_SECRET_CHARS = 64 * 1024
+
+/**
+ * User MCP servers (#1143). Same contract as provider enablement: every
+ * mutation returns the fresh snapshot AND broadcasts it, so every window's
+ * Settings grid and per-agent picker update even though only one was touched.
+ *
+ * Arguments are untrusted renderer input. The shapes are checked here; the
+ * semantic rules (names, transports, secret placement) are enforced again in
+ * UserMcpService.save, which is the single place that decides what is valid.
+ */
+export function registerUserMcpIpc(service: UserMcpService): void {
+ ipcMain.handle('user-mcp:get', () => service.snapshot())
+
+ ipcMain.handle('user-mcp:save', (_evt, input: unknown) => {
+ if (!isSaveInput(input)) throw new Error('user-mcp:save: invalid arguments')
+ return service.save(input)
+ })
+
+ ipcMain.handle('user-mcp:delete', (_evt, id: unknown) => {
+ if (!isUserMcpServerId(id)) throw new Error('user-mcp:delete: invalid id')
+ return service.delete(id)
+ })
+
+ ipcMain.handle('user-mcp:set-enabled', (_evt, id: unknown, enabled: unknown) => {
+ if (!isUserMcpServerId(id) || typeof enabled !== 'boolean') throw new Error('user-mcp:set-enabled: invalid arguments')
+ return service.setEnabled(id, enabled)
+ })
+
+ ipcMain.handle('user-mcp:set-provider', (_evt, id: unknown, provider: unknown, enabled: unknown) => {
+ if (!isUserMcpServerId(id) || !isUserMcpProvider(provider) || typeof enabled !== 'boolean') {
+ throw new Error('user-mcp:set-provider: invalid arguments')
+ }
+ return service.setProvider(id, provider, enabled)
+ })
+
+ ipcMain.handle('user-mcp:set-secret', (_evt, id: unknown, inputId: unknown, value: unknown) => {
+ if (
+ !isUserMcpServerId(id) || typeof inputId !== 'string' ||
+ typeof value !== 'string' || value.length > MAX_SECRET_CHARS
+ ) {
+ throw new Error('user-mcp:set-secret: invalid arguments')
+ }
+ return service.setSecret(id, inputId, value)
+ })
+
+ ipcMain.handle('user-mcp:import', (_evt, text: unknown, fallbackName: unknown) => {
+ if (typeof text !== 'string' || text.length > MAX_IMPORT_CHARS) throw new Error('user-mcp:import: invalid text')
+ return service.importConfig(text, typeof fallbackName === 'string' ? fallbackName : undefined)
+ })
+
+ ipcMain.handle('user-mcp:copy-native', (_evt, provider: unknown, name: unknown) => {
+ if (!isUserMcpProvider(provider) || typeof name !== 'string') throw new Error('user-mcp:copy-native: invalid arguments')
+ return service.copyNative(provider, name)
+ })
+
+ service.onChange(snapshot => broadcastToWindows(USER_MCP_CHANGED_CHANNEL, snapshot))
+}
+
+function isSaveInput(value: unknown): value is UserMcpSaveInput {
+ if (!isPlainObject(value)) return false
+ if (value.id !== undefined && !isUserMcpServerId(value.id)) return false
+ if (typeof value.name !== 'string' || typeof value.enabled !== 'boolean') return false
+ if (!isPlainObject(value.providers) || !isPlainObject(value.entry) || !Array.isArray(value.inputs)) return false
+ if (value.secrets !== undefined) {
+ if (!isPlainObject(value.secrets)) return false
+ for (const secret of Object.values(value.secrets)) {
+ if (typeof secret !== 'string' || secret.length > MAX_SECRET_CHARS) return false
+ }
+ }
+ return true
+}
diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts
index 7f4841c34..d494db241 100644
--- a/src/main/sessionManager.ts
+++ b/src/main/sessionManager.ts
@@ -69,6 +69,24 @@ import type {
} from '@shared/types/providerKind.js'
import type { BuiltInMcpDomain, BuiltInMcpServerConfig } from '@mcp/shared/types.js'
import type { BuiltInMcpHttpHost } from '@mcp/runtime/BuiltInMcpHttpHost.js'
+import {
+ normalizeUserMcpOverrides,
+ type ResolvedUserMcpServer,
+ type UserMcpDroppedServer,
+} from '@shared/userMcp/types.js'
+
+/** Structural so SessionManager does not depend on the service class; see
+ * UserMcpService.resolveForLaunch for the rules it applies. */
+export type UserMcpResolver = (params: {
+ provider: string
+ overrides: Readonly>
+ cwd: string
+}) => Promise<{
+ servers: ResolvedUserMcpServer[]
+ attachedIds: string[]
+ dropped: UserMcpDroppedServer[]
+ codexShellPolicy?: { style: 'filters' } | { style: 'legacy'; exclude: readonly string[] }
+}>
import type { AppRunJournal } from '@main/incident/AppRunJournal.js'
import { SessionLifecycleJournal } from '@main/lifecycle/SessionLifecycleJournal.js'
import type { PromptGateState } from '@shared/types/session.js'
@@ -218,6 +236,10 @@ type ManagerEvents = {
* reconcile could not prepare, and it is being started without them (#1133).
* Carries domain names only, never the error: see the pre-spawn gate. */
'managed-skills-unavailable': [{ sessionId: string; skills: ReportingDomain[] }]
+ // #1143. A user MCP server the agent asked for could not be attached to this
+ // launch (missing secret, unsupported transport, name collision…). The
+ // launch itself proceeded without it.
+ 'user-mcp-unavailable': [{ sessionId: string; servers: UserMcpDroppedServer[] }]
exit: [{ sessionId: string; exitCode: number; signal?: number }]
}
@@ -719,6 +741,25 @@ export class SessionManager extends EventEmitter {
this.lifecycle = new SessionLifecycleJournal(journal)
}
+ /**
+ * User MCP servers (#1143). A setter rather than another positional
+ * constructor argument because the service is optional (tests and the
+ * headless harnesses construct managers without one) and the constructor's
+ * positional list is already long enough that one more slot invites
+ * argument-order bugs at every call site.
+ */
+ setUserMcpResolver(resolver: UserMcpResolver | null): void {
+ this.userMcpResolver = resolver
+ }
+
+ private userMcpResolver: UserMcpResolver | null = null
+ // What each live agent was actually launched with, and the per-agent
+ // choices it was launched from. The first feeds the backend snapshot the
+ // same way builtInMcpHost.sessionDomains does; the second lets a same-rollout
+ // Codex restore relaunch with the predecessor's exact choices.
+ private readonly userMcpAttached = new Map()
+ private readonly userMcpOverridesBySession = new Map>()
+
private readonly lifecycle: SessionLifecycleJournal
// Terminal attach/replay state.
@@ -1035,6 +1076,12 @@ export class SessionManager extends EventEmitter {
this.agentPtyAttachCounts.delete(sessionId)
this.agentPtyRestoreSizes.delete(sessionId)
if (revokeAgentMcp) this.builtInMcpHost?.revokeSession(sessionId)
+ // Once the backend is gone its user-MCP launch facts must not outlive it
+ // and be reported for a successor. Unconditional (review round 2):
+ // `revokeAgentMcp` is about the built-in host bearer and is false when a
+ // launch had no built-in domains, which leaked these entries.
+ this.userMcpAttached.delete(sessionId)
+ this.userMcpOverridesBySession.delete(sessionId)
}
forgetFeedDebugSession(sessionId)
return true
@@ -2350,6 +2397,7 @@ export class SessionManager extends EventEmitter {
dangerousMode: predecessorInfo.dangerousMode,
useProxy: predecessorInfo.useProxy,
builtInMcpDomains: effectiveDomains,
+ userMcpOverrides: this.userMcpOverridesBySession.get(predecessorSessionId) ?? {},
tldrIdentity: this.builtInMcpHost?.sessionTldrIdentity?.(predecessorSessionId),
}
reservation.restoreOptions = restoreOptions
@@ -2721,6 +2769,56 @@ export class SessionManager extends EventEmitter {
* The toast would also have claimed "agents started" off a launch that did
* not happen.
*/
+ /**
+ * Decide which user MCP servers this launch gets (#1143).
+ *
+ * Never throws: a broken MCP document, an unreadable keyring or a bad server
+ * must cost the user that server, not the agent. Every server the agent
+ * asked for but did not get is reported through `user-mcp-unavailable`, so
+ * the absence is visible instead of looking like an attached server whose
+ * tools never appear.
+ */
+ private async resolveUserMcpServers(
+ sessionId: string,
+ kind: SessionKind,
+ options: SessionSpawnOptions,
+ ): Promise<{
+ servers: ResolvedUserMcpServer[]
+ codexShellPolicy?: { style: 'filters' } | { style: 'legacy'; exclude: readonly string[] }
+ }> {
+ if (!this.userMcpResolver || !isAgentProviderKind(kind)) return { servers: [] }
+ const overrides = normalizeUserMcpOverrides(options.userMcpOverrides)
+ this.userMcpOverridesBySession.set(sessionId, overrides)
+ try {
+ const resolution = await this.userMcpResolver({ provider: kind, overrides, cwd: options.cwd })
+ this.userMcpAttached.set(sessionId, resolution.attachedIds)
+ if (resolution.dropped.length > 0) {
+ this.journal?.record({
+ area: 'mcp.user',
+ name: 'user_mcp.unavailable',
+ severity: 'warn',
+ ids: { sessionId },
+ // Names and reasons only; reasons are fixed strings built by the
+ // service and never contain a secret value.
+ data: { servers: resolution.dropped.map(server => `${server.name}: ${server.reason}`) },
+ })
+ this.emit('user-mcp-unavailable', { sessionId, servers: resolution.dropped })
+ }
+ return {
+ servers: resolution.servers,
+ ...(resolution.codexShellPolicy ? { codexShellPolicy: resolution.codexShellPolicy } : {}),
+ }
+ } catch (error) {
+ this.userMcpAttached.set(sessionId, [])
+ this.journal?.recordError('user_mcp.resolve_failed', error, undefined, { sessionId })
+ this.emit('user-mcp-unavailable', {
+ sessionId,
+ servers: [{ name: 'MCP servers', reason: 'Your MCP server settings could not be read' }],
+ })
+ return { servers: [] }
+ }
+ }
+
private reportSkillsUnavailable(sessionId: string, unavailable: readonly ReportingDomain[]): void {
if (unavailable.length === 0) return
// Pairs with the `.error` rows (same ids.sessionId): "this session is
@@ -2886,6 +2984,8 @@ export class SessionManager extends EventEmitter {
})
mcpRegistered = true
}
+ const { servers: userMcpServers, codexShellPolicy: userMcpCodexShellPolicy } =
+ await this.resolveUserMcpServers(sessionId, kind, options)
this.throwIfSpawnCancelled(recoveryClaim, codexReplacementHandoff)
if (this.beforeAgentSessionStart) {
const unavailableSkills = await this.runPreSpawnSkillReconcile(sessionId, options)
@@ -2943,6 +3043,8 @@ export class SessionManager extends EventEmitter {
// `openai_base_url`.
useProxy: options.useProxy,
builtInMcpServers,
+ userMcpServers,
+ ...(userMcpCodexShellPolicy ? { userMcpCodexShellPolicy } : {}),
...(kind === 'codex' && codexReplacementHandoff
? {
// WHY the provider receives execution timing, not policy: Codex
@@ -3282,6 +3384,7 @@ export class SessionManager extends EventEmitter {
return {
sessionId,
...(providerSessionId ? { providerSessionId } : {}),
+ userMcpServerIds: this.userMcpAttached.get(sessionId) ?? [],
}
}
@@ -5459,6 +5562,7 @@ export class SessionManager extends EventEmitter {
? {
builtInMcpDomains:
this.builtInMcpHost?.sessionDomains?.(sessionId) ?? [],
+ userMcpServerIds: this.userMcpAttached.get(sessionId) ?? [],
tldrIdentity: this.builtInMcpHost?.sessionTldrIdentity?.(sessionId),
}
: {}),
diff --git a/src/main/sessionManager.userMcp.test.ts b/src/main/sessionManager.userMcp.test.ts
new file mode 100644
index 000000000..1af9cdb2a
--- /dev/null
+++ b/src/main/sessionManager.userMcp.test.ts
@@ -0,0 +1,100 @@
+import { EventEmitter } from 'node:events'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ResolvedUserMcpServer, UserMcpDroppedServer } from '@shared/userMcp/types.js'
+
+// Harness mirrors sessionManager.recover.test.ts: the provider registry is
+// mocked so these tests observe exactly what SessionManager hands a provider,
+// which is the contract under test (#1143).
+const { createSession } = vi.hoisted(() => ({ createSession: vi.fn() }))
+
+vi.mock('@main/workspaceDirectory.js', () => ({
+ MissingWorkspaceDirectoryError: class extends Error {},
+ assertWorkspaceDirectoryExists: vi.fn(async () => {}),
+}))
+vi.mock('@providers/registry.main.js', () => ({
+ getMainProvider: () => ({ createSession, deliverPrompt: vi.fn() }),
+}))
+vi.mock('@main/setup/toolchain.js', () => ({ getToolPath: () => '/usr/bin/true' }))
+vi.mock('@main/performance/PerformanceService.js', () => ({
+ performanceService: { mark: vi.fn(), record: vi.fn(), error: vi.fn() },
+}))
+vi.mock('@main/storage/feedDebugLog.js', () => ({ forgetFeedDebugSession: vi.fn() }))
+
+class FakeAgentSession extends EventEmitter {
+ readonly start = vi.fn(async (): Promise => {
+ this.emit('started', { projectDir: '/tmp/project' })
+ })
+ readonly stop = vi.fn(async (): Promise => {})
+ readonly write = vi.fn()
+ readonly resize = vi.fn()
+}
+
+const beeper: ResolvedUserMcpServer = {
+ id: 'srv-beeper',
+ name: 'beeper',
+ entry: { type: 'http', url: 'http://localhost:23373/v0/mcp' },
+ secrets: {},
+}
+
+describe('SessionManager user MCP servers', () => {
+ beforeEach(() => {
+ createSession.mockReset()
+ createSession.mockImplementation(() => new FakeAgentSession())
+ })
+
+ it('hands the provider exactly the servers main resolved and reports them on the snapshot', async () => {
+ const { SessionManager } = await import('./sessionManager')
+ const manager = new SessionManager()
+ const resolver = vi.fn(async () => ({ servers: [beeper], attachedIds: [beeper.id], dropped: [] }))
+ manager.setUserMcpResolver(resolver)
+
+ const result = await manager.spawn({ kind: 'claude', cwd: '/tmp/project', userMcpOverrides: { 'srv-beeper': true, 'bad id!': true } })
+
+ // The renderer's override map is untrusted: malformed ids never reach the resolver.
+ expect(resolver).toHaveBeenCalledWith({ provider: 'claude', overrides: { 'srv-beeper': true }, cwd: '/tmp/project' })
+ expect(createSession.mock.calls[0]![0].userMcpServers).toEqual([beeper])
+ expect(result.userMcpServerIds).toEqual(['srv-beeper'])
+ expect(manager.getBackendSnapshot(result.sessionId)?.userMcpServerIds).toEqual(['srv-beeper'])
+ })
+
+ it('still launches the agent when a server is dropped, and says why', async () => {
+ const { SessionManager } = await import('./sessionManager')
+ const manager = new SessionManager()
+ const dropped: UserMcpDroppedServer[] = [{ name: 'beeper', reason: 'Secret "beeper-authorization" is not set' }]
+ manager.setUserMcpResolver(async () => ({ servers: [], attachedIds: [], dropped }))
+ const events: unknown[] = []
+ manager.on('user-mcp-unavailable', event => events.push(event))
+
+ const result = await manager.spawn({ kind: 'codex', cwd: '/tmp/project' })
+
+ expect(createSession).toHaveBeenCalledTimes(1)
+ expect(createSession.mock.calls[0]![0].userMcpServers).toEqual([])
+ expect(events).toEqual([{ sessionId: result.sessionId, servers: dropped }])
+ })
+
+ it('treats a resolver failure as "no user servers", never as a failed launch', async () => {
+ const { SessionManager } = await import('./sessionManager')
+ const manager = new SessionManager()
+ manager.setUserMcpResolver(async () => { throw new Error('keyring locked') })
+ const events: unknown[] = []
+ manager.on('user-mcp-unavailable', event => events.push(event))
+
+ const result = await manager.spawn({ kind: 'claude', cwd: '/tmp/project' })
+
+ expect(manager.getBackendSnapshot(result.sessionId)?.lifecycle).toBe('live')
+ expect(result.userMcpServerIds).toEqual([])
+ expect(events).toHaveLength(1)
+ })
+
+ it('never consults the resolver for terminal sessions', async () => {
+ const { SessionManager } = await import('./sessionManager')
+ const manager = new SessionManager()
+ const resolver = vi.fn()
+ manager.setUserMcpResolver(resolver)
+ // Terminal spawn goes through TerminalSession, which this harness does not
+ // fake; only the absence of a resolver call matters here.
+ await manager.spawn({ kind: 'terminal', cwd: '/tmp/project' }).catch(() => {})
+ expect(resolver).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/sessions/forwarder.ts b/src/main/sessions/forwarder.ts
index 1d09578e7..9782b5f1f 100644
--- a/src/main/sessions/forwarder.ts
+++ b/src/main/sessions/forwarder.ts
@@ -2,6 +2,8 @@ import type { SessionManager } from '@main/sessionManager.js'
import { aliasScreenSnapshotForWire } from '@shared/types/session.js'
import type { AgentScreenSnapshot } from '@shared/types/session.js'
import type { LspManager } from '@main/lspManager.js'
+import { USER_MCP_UNAVAILABLE_CHANNEL } from '@main/ipc/userMcp.js'
+import type { UserMcpUnavailableEvent } from '@shared/userMcp/types.js'
import {
MANAGED_SKILLS_UNAVAILABLE_CHANNEL,
type ManagedSkillsUnavailableEvent,
@@ -192,6 +194,14 @@ export function wireSessionForwarder(
manager.on('managed-skills-unavailable', ({ skills }) => {
const event: ManagedSkillsUnavailableEvent = { skills }
broadcastToWindows(MANAGED_SKILLS_UNAVAILABLE_CHANNEL, event)
+ })
+ // #1143. Broadcast for the same routing reason as managed skills above: the
+ // launch can be main-initiated (orchestration child, restore) before any
+ // window has claimed the id. Only server names and fixed reason strings
+ // cross; nothing here can carry a secret value.
+ manager.on('user-mcp-unavailable', ({ servers }) => {
+ const event: UserMcpUnavailableEvent = { servers }
+ broadcastToWindows(USER_MCP_UNAVAILABLE_CHANNEL, event)
})
// Diagnostics are keyed by file, not by session: two windows can have the
// same file open in their editors and both need them.
diff --git a/src/main/userMcp/nativeServers.ts b/src/main/userMcp/nativeServers.ts
new file mode 100644
index 000000000..4d02555ae
--- /dev/null
+++ b/src/main/userMcp/nativeServers.ts
@@ -0,0 +1,262 @@
+import { access, readFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+import TOML from '@iarna/toml'
+
+import type { NativeMcpServerSource, UserMcpInput, UserMcpServerEntry } from '@shared/userMcp/types.js'
+import { isPlainObject, isStringArray, isStringRecord, summarizeEntry, transportOf } from '@shared/userMcp/validate.js'
+
+/**
+ * Read-only view of the MCP servers each CLI loads from its OWN config
+ * (spec Revision 2 §7). Agent Code never writes these files; it only reads
+ * them to (a) show the user servers they would otherwise not know are loaded,
+ * (b) offer Copy in, and (c) detect Codex name collisions before launch.
+ *
+ * Only user-scope files are listed. Project-scope files (`.mcp.json`,
+ * `.codex/config.toml`) depend on an agent's cwd, which Settings does not have.
+ */
+
+export type NativeMcpPaths = {
+ home: string
+ claudeConfigDir?: string
+ codexHome?: string
+ platform: NodeJS.Platform
+}
+
+export function defaultNativeMcpPaths(): NativeMcpPaths {
+ return {
+ home: homedir(),
+ claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || undefined,
+ codexHome: process.env.CODEX_HOME || undefined,
+ platform: process.platform,
+ }
+}
+
+// Mirrors Claude's getGlobalClaudeFile (vendor utils/env.ts): the user-scope
+// file lives in CLAUDE_CONFIG_DIR when set, otherwise the home directory.
+function claudeUserConfigFile(paths: NativeMcpPaths): string {
+ return join(paths.claudeConfigDir || paths.home, '.claude.json')
+}
+
+function codexHome(paths: NativeMcpPaths): string {
+ return paths.codexHome || join(paths.home, '.codex')
+}
+
+// Mirrors Claude's getManagedFilePath (vendor utils/settings/managedPath.ts).
+function claudeManagedMcpFile(paths: NativeMcpPaths): string {
+ const dir = paths.platform === 'darwin'
+ ? '/Library/Application Support/ClaudeCode'
+ : paths.platform === 'win32'
+ ? 'C:\\Program Files\\ClaudeCode'
+ : '/etc/claude-code'
+ return join(dir, 'managed-mcp.json')
+}
+
+/**
+ * When an enterprise managed-mcp.json exists, Claude takes exclusive control
+ * of MCP and rejects any non-SDK `--mcp-config` server by exiting (vendor
+ * main.tsx "enterprise MCP config"). User servers must then be held back for
+ * Claude, or every Claude agent would fail to start.
+ */
+export async function claudeManagedMcpPolicyPresent(paths = defaultNativeMcpPaths()): Promise {
+ try {
+ await access(claudeManagedMcpFile(paths))
+ return true
+ } catch {
+ return false
+ }
+}
+
+export async function readNativeMcpServers(paths = defaultNativeMcpPaths()): Promise {
+ const [claude, codex] = await Promise.all([readClaudeNative(paths), readCodexNative(paths)])
+ return [...claude, ...codex]
+}
+
+/**
+ * Names Codex will already have in `mcp_servers` for an agent in `cwd`.
+ *
+ * WHY launch refuses these names for Codex: `-c mcp_servers..*` deep-merges
+ * key by key into an existing table (codex-rs config/src/merge.rs). Our `url`
+ * merged into a user's stdio table yields a mixed-transport entry that fails
+ * config load and takes the WHOLE Codex launch down with it.
+ */
+export async function codexNativeServerNames(cwd: string, paths = defaultNativeMcpPaths()): Promise> {
+ const names = new Set()
+ for (const file of await codexConfigLayers(cwd, paths)) {
+ const table = await readCodexMcpTable(file)
+ for (const name of Object.keys(table)) names.add(name)
+ }
+ return names
+}
+
+/**
+ * The config.toml files Codex merges for an agent in `cwd`, lowest precedence
+ * first: system, user (CODEX_HOME), then every `.codex/config.toml` from the
+ * project root down to `cwd`.
+ *
+ * WHY the ancestor walk (review round 2): Codex's project discovery reads
+ * every ancestor `.codex/config.toml` up to the project root marker (`.git`;
+ * vendor codex-rs config/src/loader discover_project_layers), not just the
+ * cwd's. Reading only `/.codex` missed a repo-root server or legacy
+ * shell policy for any agent started in a subdirectory or nested worktree —
+ * and either one could make our `-c` overrides fail Codex's config load.
+ * Managed and profile layers are not modelled; they are rare, and a miss
+ * there degrades to the launch-time notice, not a silent change.
+ */
+export async function codexConfigLayers(cwd: string, paths = defaultNativeMcpPaths()): Promise {
+ const system = paths.platform === 'win32' ? [] : ['/etc/codex/config.toml']
+ const ancestors: string[] = []
+ let dir = cwd
+ for (let depth = 0; depth < 64; depth++) {
+ ancestors.unshift(join(dir, '.codex', 'config.toml'))
+ if (await exists(join(dir, '.git'))) break
+ const parent = dirname(dir)
+ if (parent === dir) break
+ dir = parent
+ }
+ return [...system, join(codexHome(paths), 'config.toml'), ...ancestors]
+}
+
+async function exists(path: string): Promise {
+ try {
+ await access(path)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Which spelling the user's Codex config uses for shell-environment
+ * exclusions, across the user and project config.toml (see
+ * CodexShellPolicyStyle for why it decides how our exclusions are sent).
+ * Legacy `exclude` entries are returned so they can be re-sent with ours,
+ * because a `-c` array replaces the configured one instead of merging.
+ */
+export async function codexShellPolicyStyle(
+ cwd: string,
+ paths = defaultNativeMcpPaths(),
+): Promise<{ style: 'filters' } | { style: 'legacy'; exclude: string[] }> {
+ let legacy = false
+ const exclude: string[] = []
+ for (const file of await codexConfigLayers(cwd, paths)) {
+ const policy = (await readCodexToml(file)).shell_environment_policy
+ if (!isPlainObject(policy)) continue
+ if (policy.exclude !== undefined || policy.include_only !== undefined) legacy = true
+ // A later (project) layer replaces the array; keep the last one seen.
+ if (isStringArray(policy.exclude)) exclude.splice(0, exclude.length, ...policy.exclude)
+ }
+ return legacy ? { style: 'legacy', exclude } : { style: 'filters' }
+}
+
+async function readClaudeNative(paths: NativeMcpPaths): Promise {
+ const file = claudeUserConfigFile(paths)
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(await readFile(file, 'utf8'))
+ } catch {
+ return []
+ }
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.mcpServers)) return []
+ return Object.entries(parsed.mcpServers).map(([name, raw]) =>
+ nativeServer('claude', name, displayPath(file, paths.home), isPlainObject(raw) ? raw : null))
+}
+
+async function readCodexNative(paths: NativeMcpPaths): Promise {
+ const file = join(codexHome(paths), 'config.toml')
+ const table = await readCodexMcpTable(file)
+ return Object.entries(table).map(([name, raw]) =>
+ nativeServer('codex', name, displayPath(file, paths.home), isPlainObject(raw) ? codexToMcpServersShape(raw) : null))
+}
+
+async function readCodexMcpTable(file: string): Promise> {
+ const parsed = await readCodexToml(file)
+ return isPlainObject(parsed.mcp_servers) ? parsed.mcp_servers : {}
+}
+
+async function readCodexToml(file: string): Promise> {
+ let text: string
+ try {
+ text = await readFile(file, 'utf8')
+ } catch {
+ return {}
+ }
+ try {
+ return TOML.parse(text) as Record
+ } catch {
+ // An unparseable config.toml also fails Codex itself; we have nothing
+ // useful to add, so it contributes nothing rather than blocking launch.
+ return {}
+ }
+}
+
+/** Codex table → `mcpServers` entry, so Copy in can reuse the import path. */
+function codexToMcpServersShape(raw: Record): Record {
+ if (typeof raw.command === 'string') {
+ const env: Record = isStringRecord(raw.env) ? { ...raw.env } : {}
+ // `env_vars` names pass through from Codex's own environment; in an
+ // mcpServers entry that becomes an env value the user must supply.
+ if (isStringArray(raw.env_vars)) for (const name of raw.env_vars) env[name] ??= ''
+ return {
+ command: raw.command,
+ ...(isStringArray(raw.args) ? { args: raw.args } : {}),
+ ...(Object.keys(env).length > 0 ? { env } : {}),
+ ...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}),
+ }
+ }
+ if (typeof raw.url === 'string') {
+ const headers: Record = isStringRecord(raw.http_headers) ? { ...raw.http_headers } : {}
+ if (isStringRecord(raw.env_http_headers)) for (const header of Object.keys(raw.env_http_headers)) headers[header] ??= ''
+ if (typeof raw.bearer_token_env_var === 'string') headers.Authorization ??= 'Bearer '
+ return { type: 'http', url: raw.url, ...(Object.keys(headers).length > 0 ? { headers } : {}) }
+ }
+ return raw
+}
+
+function nativeServer(
+ provider: 'claude' | 'codex',
+ name: string,
+ source: string,
+ raw: Record | null,
+): NativeMcpServerSource {
+ const transport = raw ? transportOf(raw) : null
+ if (!raw || !transport) {
+ return { provider, name, source, transport: null, summary: '', copyable: false, entry: null, inputs: [] }
+ }
+ const inputs: UserMcpInput[] = []
+ const entry: Record = { ...raw }
+ // Withhold every value: native configs often store tokens in plaintext and
+ // this object is sent to the renderer.
+ for (const field of ['env', 'headers'] as const) {
+ if (!isStringRecord(entry[field])) {
+ delete entry[field]
+ continue
+ }
+ const next: Record = {}
+ for (const [key, value] of Object.entries(entry[field] as Record)) {
+ const id = `${name}-${key}`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 64)
+ // The auth scheme word is not secret and keeping it means the user pastes
+ // just the token. Codex's bearer_token_env_var arrives as "Bearer ".
+ const scheme = /^(Bearer|Token|Basic)\s/i.exec(value)
+ next[key] = scheme ? `${scheme[1]} \${input:${id}}` : `\${input:${id}}`
+ inputs.push({ id, description: `${field === 'env' ? 'Environment variable' : 'Header'} ${key}` })
+ }
+ entry[field] = next
+ }
+ if (transport !== 'stdio') entry.type = transport
+ return {
+ provider,
+ name,
+ source,
+ transport,
+ summary: summarizeEntry(raw),
+ copyable: true,
+ entry: entry as UserMcpServerEntry,
+ inputs,
+ }
+}
+
+function displayPath(file: string, home: string): string {
+ return file.startsWith(home) ? `~${file.slice(home.length)}` : file
+}
diff --git a/src/main/userMcp/secrets.ts b/src/main/userMcp/secrets.ts
new file mode 100644
index 000000000..16de0ca24
--- /dev/null
+++ b/src/main/userMcp/secrets.ts
@@ -0,0 +1,115 @@
+import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+
+import type { SecretCodec } from '@main/keyVault/vaultStore.js'
+import type { UserMcpSecretState } from '@shared/userMcp/types.js'
+
+/*
+ * Encryption uses the API Key Vault's safeStorage codec (the same OS-derived
+ * key, injectable so tests do not need Electron), but NOT the vault itself.
+ *
+ * WHY not store MCP secrets in the vault: the vault gates every read behind
+ * Touch ID / the login password once per app run. MCP secrets are read at
+ * agent LAUNCH, which includes automatic restore of a whole workspace at
+ * startup — an OS prompt before any window is usable, or a restore that
+ * silently drops every server until the user unlocks something, are both
+ * worse than the dictation-key precedent (src/main/dictation/apiKeyStore.ts):
+ * encrypted at rest with an OS-derived key, readable without a prompt.
+ */
+
+/**
+ * One encrypted blob per secret at `//.bin`.
+ *
+ * WHY one file per secret instead of one encrypted document: a blob that stops
+ * decrypting (Keychain reset, copied profile) then costs exactly one secret —
+ * shown as "not set" — instead of every server's credentials at once. Server
+ * ids and input ids are both validated to `[A-Za-z0-9_-]`, so they are safe
+ * path segments by construction.
+ */
+export class UserMcpSecretStore {
+ constructor(
+ private readonly dir: string,
+ private readonly codec: SecretCodec,
+ ) {}
+
+ available(): boolean {
+ try {
+ return this.codec.isEncryptionAvailable()
+ } catch {
+ return false
+ }
+ }
+
+ async get(serverId: string, inputId: string): Promise {
+ if (!this.available()) return null
+ let ciphertext: Buffer
+ try {
+ ciphertext = await readFile(this.path(serverId, inputId))
+ } catch {
+ return null
+ }
+ try {
+ const value = this.codec.decrypt(ciphertext)
+ return value === '' ? null : value
+ } catch {
+ // Left in place on purpose: if the keyring comes back, so does the value.
+ return null
+ }
+ }
+
+ async set(serverId: string, inputId: string, value: string): Promise {
+ if (value === '') {
+ await this.clear(serverId, inputId)
+ return
+ }
+ if (!this.available()) {
+ throw new Error('Secure storage is not available on this system, so the secret cannot be saved.')
+ }
+ const directory = join(this.dir, serverId)
+ await mkdir(directory, { recursive: true, mode: 0o700 })
+ const target = this.path(serverId, inputId)
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`
+ await writeFile(temporary, this.codec.encrypt(value), { mode: 0o600 })
+ await rename(temporary, target)
+ }
+
+ async clear(serverId: string, inputId: string): Promise {
+ await rm(this.path(serverId, inputId), { force: true })
+ }
+
+ async clearServer(serverId: string): Promise {
+ await rm(join(this.dir, serverId), { recursive: true, force: true })
+ }
+
+ /** Drop blobs for inputs the server no longer defines, so a renamed or
+ * removed secret does not linger as an orphaned credential on disk. */
+ async prune(serverId: string, keepInputIds: readonly string[]): Promise {
+ let files: string[]
+ try {
+ files = await readdir(join(this.dir, serverId))
+ } catch {
+ return
+ }
+ const keep = new Set(keepInputIds.map(id => `${id}.bin`))
+ await Promise.all(files.filter(file => !keep.has(file)).map(file =>
+ rm(join(this.dir, serverId, file), { force: true })))
+ }
+
+ /** Presence and a last-4 hint only. The renderer never receives a value. */
+ async state(serverId: string, inputIds: readonly string[]): Promise> {
+ const entries = await Promise.all(inputIds.map(async id => {
+ const value = await this.get(serverId, id)
+ // No hint for short values: the last four characters of a six-character
+ // PIN are most of the secret.
+ const state: UserMcpSecretState = value === null
+ ? { set: false }
+ : { set: true, ...(value.length >= 12 ? { hint: value.slice(-4) } : {}) }
+ return [id, state] as const
+ }))
+ return Object.fromEntries(entries)
+ }
+
+ private path(serverId: string, inputId: string): string {
+ return join(this.dir, serverId, `${inputId}.bin`)
+ }
+}
diff --git a/src/main/userMcp/service.test.ts b/src/main/userMcp/service.test.ts
new file mode 100644
index 000000000..245c8ddc5
--- /dev/null
+++ b/src/main/userMcp/service.test.ts
@@ -0,0 +1,328 @@
+import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import type { SecretCodec } from '@main/keyVault/vaultStore.js'
+import type { NativeMcpServerSource, UserMcpSaveInput } from '@shared/userMcp/types.js'
+
+import { readNativeMcpServers, codexNativeServerNames } from './nativeServers.js'
+import { UserMcpService } from './service.js'
+
+// Reversible stand-in for safeStorage: the tests are about WHERE values go,
+// not about the OS cipher. The prefix makes an accidental plaintext write
+// (a value that skipped encrypt) visible as a mismatch.
+const codec: SecretCodec = {
+ isEncryptionAvailable: () => true,
+ encrypt: plain => Buffer.from(`enc:${plain}`, 'utf8'),
+ decrypt: cipher => {
+ const text = cipher.toString('utf8')
+ if (!text.startsWith('enc:')) throw new Error('not ours')
+ return text.slice(4)
+ },
+}
+
+const TOKEN = 'bpr_live_9f3a1c7d'
+
+let dir: string
+let native: NativeMcpServerSource[]
+let codexNames: Set
+let managed: boolean
+
+function service(): UserMcpService {
+ return new UserMcpService({
+ stateDir: dir,
+ codec,
+ native: {
+ list: async () => native,
+ codexNames: async () => codexNames,
+ claudeManagedPolicy: async () => managed,
+ },
+ })
+}
+
+const beeper = (overrides: Partial = {}): UserMcpSaveInput => ({
+ name: 'beeper',
+ enabled: true,
+ providers: { claude: true, codex: true },
+ entry: { type: 'http', url: 'http://localhost:23373/v0/mcp', headers: { Authorization: 'Bearer ${input:beeper-authorization}' } },
+ inputs: [{ id: 'beeper-authorization', description: 'Header Authorization' }],
+ secrets: { 'beeper-authorization': TOKEN },
+ ...overrides,
+})
+
+beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'user-mcp-'))
+ native = []
+ codexNames = new Set()
+ managed = false
+})
+afterEach(async () => {
+ await rm(dir, { recursive: true, force: true })
+})
+
+describe('UserMcpService storage', () => {
+ it('persists the server without its secret, and the snapshot shows only a hint', async () => {
+ const result = await service().save(beeper())
+ expect(result.ok).toBe(true)
+ const onDisk = await readFile(join(dir, 'mcp-servers.json'), 'utf8')
+ expect(onDisk).toContain('beeper')
+ expect(onDisk).not.toContain(TOKEN)
+ expect((await stat(join(dir, 'mcp-servers.json'))).mode & 0o777).toBe(0o600)
+ if (!result.ok) return
+ expect(JSON.stringify(result.snapshot)).not.toContain(TOKEN)
+ expect(result.snapshot.servers[0]!.secrets['beeper-authorization']).toEqual({ set: true, hint: '1c7d' })
+ })
+
+ it('refuses structurally invalid servers but accepts a server whose secret is not set yet', async () => {
+ const svc = service()
+ expect(await svc.save(beeper({ name: 'agent_code' }))).toMatchObject({ ok: false })
+ const pending = await svc.save(beeper({ secrets: {} }))
+ expect(pending.ok).toBe(true)
+ if (!pending.ok) return
+ expect(pending.snapshot.servers[0]!.problems.map(problem => problem.kind)).toEqual(['secret-missing'])
+ })
+
+ it('moves an unreadable document aside instead of erasing it', async () => {
+ await writeFile(join(dir, 'mcp-servers.json'), '{ not json')
+ const snapshot = await service().snapshot()
+ expect(snapshot.servers).toEqual([])
+ expect(snapshot.storeProblem).toMatch(/moved to/)
+ expect((await readdir(dir)).some(file => file.startsWith('mcp-servers.json.corrupt-'))).toBe(true)
+ })
+
+ it('deletes a server together with its secrets', async () => {
+ const svc = service()
+ const saved = await svc.save(beeper())
+ if (!saved.ok) throw new Error(saved.error)
+ await svc.delete(saved.id!)
+ expect(await readdir(join(dir, 'mcp-secrets'))).toEqual([])
+ })
+
+ it('copies a CLI-native server in, attached only to the other provider, with secrets unset', async () => {
+ native = [{
+ provider: 'codex', name: 'sentry', source: '~/.codex/config.toml', transport: 'http', summary: 'mcp.sentry.dev/mcp', copyable: true,
+ entry: { type: 'http', url: 'https://mcp.sentry.dev/mcp', headers: { Authorization: 'Bearer ${input:sentry-authorization}' } },
+ inputs: [{ id: 'sentry-authorization', description: 'Header Authorization' }],
+ }]
+ const result = await service().copyNative('codex', 'sentry')
+ if (!result.ok) throw new Error(result.error)
+ const copy = result.snapshot.servers[0]!
+ expect(copy.providers).toEqual({ claude: true, codex: false })
+ expect(copy.secrets['sentry-authorization']).toEqual({ set: false })
+ })
+})
+
+describe('UserMcpService.resolveForLaunch', () => {
+ async function saved(input: UserMcpSaveInput = beeper()) {
+ const svc = service()
+ const result = await svc.save(input)
+ if (!result.ok) throw new Error(result.error)
+ return { svc, id: result.id! }
+ }
+
+ it('attaches a default-on server with its secret resolved', async () => {
+ const { svc, id } = await saved()
+ const resolution = await svc.resolveForLaunch({ provider: 'codex', overrides: {}, cwd: dir })
+ expect(resolution.attachedIds).toEqual([id])
+ expect(resolution.servers[0]!.secrets).toEqual({ 'beeper-authorization': TOKEN })
+ expect(resolution.dropped).toEqual([])
+ })
+
+ it('lets a per-agent override add or remove a server', async () => {
+ const { svc, id } = await saved(beeper({ providers: { claude: false, codex: false } }))
+ expect((await svc.resolveForLaunch({ provider: 'claude', overrides: {}, cwd: dir })).attachedIds).toEqual([])
+ expect((await svc.resolveForLaunch({ provider: 'claude', overrides: { [id]: true }, cwd: dir })).attachedIds).toEqual([id])
+ const { svc: svc2, id: id2 } = await saved(beeper({ name: 'beeper2' }))
+ expect((await svc2.resolveForLaunch({ provider: 'claude', overrides: { [id2]: false }, cwd: dir })).attachedIds)
+ .not.toContain(id2)
+ })
+
+ it('never attaches a server whose master switch is off, even with a per-agent on', async () => {
+ const { svc, id } = await saved(beeper({ enabled: false }))
+ const resolution = await svc.resolveForLaunch({ provider: 'claude', overrides: { [id]: true }, cwd: dir })
+ expect(resolution.attachedIds).toEqual([])
+ // Silent: the user turned it off everywhere, so there is nothing to warn about.
+ expect(resolution.dropped).toEqual([])
+ })
+
+ it('drops a requested server with a reason when its secret is missing', async () => {
+ const { svc } = await saved(beeper({ secrets: {} }))
+ const resolution = await svc.resolveForLaunch({ provider: 'claude', overrides: {}, cwd: dir })
+ expect(resolution.attachedIds).toEqual([])
+ expect(resolution.dropped).toEqual([{ name: 'beeper', reason: 'Secret "beeper-authorization" is not set' }])
+ })
+
+ it('refuses a Codex name that is already in the user\'s Codex config, but not for Claude', async () => {
+ const { svc, id } = await saved()
+ codexNames = new Set(['beeper'])
+ expect((await svc.resolveForLaunch({ provider: 'codex', overrides: {}, cwd: dir })).dropped[0]?.reason)
+ .toMatch(/already in your Codex config/)
+ expect((await svc.resolveForLaunch({ provider: 'claude', overrides: {}, cwd: dir })).attachedIds).toEqual([id])
+ })
+
+ it('holds user servers back for Claude under an enterprise MCP policy', async () => {
+ const { svc } = await saved()
+ managed = true
+ const resolution = await svc.resolveForLaunch({ provider: 'claude', overrides: {}, cwd: dir })
+ expect(resolution.attachedIds).toEqual([])
+ expect(resolution.dropped[0]?.reason).toMatch(/policy/)
+ })
+
+ it('keeps SSE servers off Codex with a reason', async () => {
+ const { svc } = await saved(beeper({
+ name: 'linear', entry: { type: 'sse', url: 'https://mcp.linear.app/sse' }, inputs: [], secrets: {},
+ }))
+ const resolution = await svc.resolveForLaunch({ provider: 'codex', overrides: {}, cwd: dir })
+ expect(resolution.dropped).toEqual([{ name: 'linear', reason: 'Codex does not support SSE servers' }])
+ })
+
+ it('gives providers without user MCP support nothing', async () => {
+ const { svc } = await saved()
+ expect(await svc.resolveForLaunch({ provider: 'opencode', overrides: {}, cwd: dir }))
+ .toEqual({ servers: [], attachedIds: [], dropped: [] })
+ })
+})
+
+describe('native server discovery', () => {
+ it('reads user-scope servers from both CLIs and never forwards their values', async () => {
+ await writeFile(join(dir, '.claude.json'), JSON.stringify({
+ mcpServers: { context7: { type: 'http', url: 'https://mcp.context7.com/mcp', headers: { CONTEXT7_API_KEY: 'ctx7_secret' } } },
+ }))
+ const codexHome = join(dir, 'codex')
+ await import('node:fs/promises').then(fs => fs.mkdir(codexHome))
+ await writeFile(join(codexHome, 'config.toml'), [
+ '[mcp_servers.sentry]',
+ 'url = "https://mcp.sentry.dev/mcp"',
+ 'bearer_token_env_var = "SENTRY_TOKEN"',
+ '',
+ '[mcp_servers.fs]',
+ 'command = "npx"',
+ 'args = ["-y", "@modelcontextprotocol/server-filesystem"]',
+ 'env = { ROOT_TOKEN = "fs_secret" }',
+ ].join('\n'))
+ const servers = await readNativeMcpServers({ home: dir, codexHome, platform: 'darwin' })
+ expect(servers.map(server => `${server.provider}:${server.name}`)).toEqual(['claude:context7', 'codex:sentry', 'codex:fs'])
+ expect(JSON.stringify(servers)).not.toMatch(/ctx7_secret|fs_secret/)
+ expect(servers.find(server => server.name === 'sentry')!.entry).toEqual({
+ type: 'http', url: 'https://mcp.sentry.dev/mcp', headers: { Authorization: 'Bearer ${input:sentry-authorization}' },
+ })
+ expect(await codexNativeServerNames(join(dir, 'project'), { home: dir, codexHome, platform: 'darwin' }))
+ .toEqual(new Set(['sentry', 'fs']))
+ })
+})
+
+describe('UserMcpService unreadable document (review round 1)', () => {
+ it('refuses writes while the document cannot be read, instead of replacing it with an empty list', async () => {
+ const file = join(dir, 'mcp-servers.json')
+ await writeFile(file, JSON.stringify({ version: 1, servers: [{ id: 'keep-me', name: 'kept', enabled: true, providers: { claude: true, codex: true }, entry: { command: 'x' }, inputs: [] }] }))
+ const { chmod } = await import('node:fs/promises')
+ await chmod(file, 0o000)
+ try {
+ const svc = service()
+ const result = await svc.save(beeper())
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error).toMatch(/Nothing was changed/)
+ } finally {
+ await chmod(file, 0o600)
+ }
+ expect(await readFile(file, 'utf8')).toContain('keep-me')
+ })
+
+ it('recovers once the document becomes readable again', async () => {
+ const file = join(dir, 'mcp-servers.json')
+ await writeFile(file, JSON.stringify({ version: 1, servers: [{ id: 'keep-me', name: 'kept', enabled: true, providers: { claude: true, codex: true }, entry: { command: 'x' }, inputs: [] }] }))
+ const { chmod } = await import('node:fs/promises')
+ await chmod(file, 0o000)
+ const svc = service()
+ await svc.initialize()
+ await chmod(file, 0o600)
+ const result = await svc.save(beeper())
+ expect(result.ok).toBe(true)
+ if (result.ok) expect(result.snapshot.servers.map(server => server.name).sort()).toEqual(['beeper', 'kept'])
+ })
+})
+
+describe('UserMcpService secret redirection (review round 1)', () => {
+ it('forgets stored secrets when a server is pointed somewhere else', async () => {
+ const svc = service()
+ const saved = await svc.save(beeper())
+ if (!saved.ok) throw new Error(saved.error)
+ const moved = await svc.save({ ...beeper(), id: saved.id, secrets: undefined, entry: { type: 'http', url: 'https://evil.example/mcp', headers: { Authorization: 'Bearer ${input:beeper-authorization}' } } })
+ expect(moved).toMatchObject({ ok: true, secretsCleared: true })
+ const launch = await svc.resolveForLaunch({ provider: 'claude', overrides: {}, cwd: dir })
+ // The token never reaches the new host: the server is dropped instead.
+ expect(launch.servers).toEqual([])
+ expect(launch.dropped[0]?.reason).toMatch(/not set/)
+ })
+
+ it('keeps secrets when only the name or the providers change', async () => {
+ const svc = service()
+ const saved = await svc.save(beeper())
+ if (!saved.ok) throw new Error(saved.error)
+ const edited = await svc.save({ ...beeper(), id: saved.id, name: 'beeper-desktop', secrets: undefined, providers: { claude: true, codex: false } })
+ expect(edited).toMatchObject({ ok: true })
+ expect(edited).not.toHaveProperty('secretsCleared')
+ expect((await svc.resolveForLaunch({ provider: 'claude', overrides: {}, cwd: dir })).servers[0]?.secrets)
+ .toEqual({ 'beeper-authorization': TOKEN })
+ })
+})
+
+
+describe('UserMcpService review round 2', () => {
+ const stdio = (env: Record): UserMcpSaveInput => ({
+ name: 'gh', enabled: true, providers: { claude: true, codex: true },
+ entry: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'], env },
+ inputs: [{ id: 'pat', description: '' }], secrets: { pat: TOKEN },
+ })
+
+ it('forgets secrets when a literal env value is added, not only when the command moves', async () => {
+ // NODE_OPTIONS=--require ./evil.js runs attacker code with the token in
+ // its env even though command/args/url are unchanged.
+ const svc = service()
+ const saved = await svc.save(stdio({ GITHUB_PERSONAL_ACCESS_TOKEN: '${input:pat}' }))
+ if (!saved.ok) throw new Error(saved.error)
+ const edited = await svc.save({ ...stdio({ GITHUB_PERSONAL_ACCESS_TOKEN: '${input:pat}', NODE_OPTIONS: '--require /tmp/evil.js' }), id: saved.id, secrets: undefined })
+ expect(edited).toMatchObject({ ok: true, secretsCleared: true })
+ })
+
+ it('stores a server an agent adds switched off and flagged for review', async () => {
+ const svc = service()
+ const added = await svc.save(beeper(), 'agent')
+ if (!added.ok) throw new Error(added.error)
+ const view = added.snapshot.servers[0]!
+ expect(view.enabled).toBe(false)
+ expect(view.pendingReview).toBe(true)
+ expect(view.problems[0]?.kind).toBe('pending-review')
+ expect((await svc.resolveForLaunch({ provider: 'claude', overrides: { [view.id]: true }, cwd: dir })).servers).toEqual([])
+ })
+
+ it('never lets an agent turn a server on, and lets the user approve it', async () => {
+ const svc = service()
+ const added = await svc.save(beeper(), 'agent')
+ if (!added.ok) throw new Error(added.error)
+ const id = added.id!
+ expect(await svc.setEnabled(id, true, 'agent')).toMatchObject({ ok: false })
+ expect(await svc.save({ ...beeper(), id, enabled: true }, 'agent')).toMatchObject({ ok: true })
+ expect((await svc.snapshot()).servers[0]!.enabled).toBe(false)
+ const approved = await svc.setEnabled(id, true)
+ if (!approved.ok) throw new Error(approved.error)
+ expect(approved.snapshot.servers[0]!.pendingReview).toBeUndefined()
+ expect((await svc.resolveForLaunch({ provider: 'claude', overrides: {}, cwd: dir })).attachedIds).toEqual([id])
+ })
+
+ it('switches an approved server back off when an agent points it somewhere new', async () => {
+ const svc = service()
+ const saved = await svc.save(beeper())
+ if (!saved.ok) throw new Error(saved.error)
+ const moved = await svc.save({ ...beeper(), id: saved.id, secrets: undefined, entry: { type: 'http', url: 'https://evil.example/mcp' }, inputs: [] }, 'agent')
+ expect(moved).toMatchObject({ ok: true, pendingReview: true })
+ expect((await svc.snapshot()).servers[0]!.enabled).toBe(false)
+ })
+
+ it('refuses a secret value that Claude would expand from its own environment', async () => {
+ const svc = service()
+ expect(await svc.save(beeper({ secrets: { 'beeper-authorization': '${GITHUB_TOKEN}' } }))).toMatchObject({ ok: false })
+ })
+})
diff --git a/src/main/userMcp/service.ts b/src/main/userMcp/service.ts
new file mode 100644
index 000000000..cc9a4ef57
--- /dev/null
+++ b/src/main/userMcp/service.ts
@@ -0,0 +1,492 @@
+import { randomUUID } from 'node:crypto'
+import { join } from 'node:path'
+
+import type { SecretCodec } from '@main/keyVault/vaultStore.js'
+import {
+ addCodexUserMcpLaunchConfig,
+ claudeUserMcpEntries,
+ type CodexShellPolicyStyle,
+ type ResolvedUserMcpServer,
+} from '@providers/shared/runtime/userMcpLaunch.js'
+import { importUserMcpConfig } from '@shared/userMcp/importConfig.js'
+import {
+ isUserMcpProvider,
+ type NativeMcpServer,
+ type NativeMcpServerSource,
+ type UserMcpActor,
+ type UserMcpDocument,
+ type UserMcpDroppedServer,
+ type UserMcpImportResult,
+ type UserMcpMutationResult,
+ type UserMcpProblem,
+ type UserMcpProvider,
+ type UserMcpSaveInput,
+ type UserMcpServer,
+ type UserMcpServerView,
+ type UserMcpSnapshot,
+} from '@shared/userMcp/types.js'
+import {
+ coerceInputs,
+ normalizeEntry,
+ secretValueProblem,
+ userMcpDestination,
+ providerSupportForEntry,
+ referencedInputIds,
+ summarizeEntry,
+ transportOf,
+ validateServer,
+} from '@shared/userMcp/validate.js'
+
+import {
+ claudeManagedMcpPolicyPresent,
+ codexNativeServerNames,
+ codexShellPolicyStyle,
+ readNativeMcpServers,
+} from './nativeServers.js'
+import { UserMcpSecretStore } from './secrets.js'
+import { loadUserMcpDocument, saveUserMcpDocument } from './store.js'
+
+export type UserMcpLaunchResolution = {
+ servers: ResolvedUserMcpServer[]
+ attachedIds: string[]
+ dropped: UserMcpDroppedServer[]
+ /** Codex only: how to send the shell exclusions for the secrets it carries. */
+ codexShellPolicy?: CodexShellPolicyStyle
+}
+
+export type UserMcpServiceDeps = {
+ stateDir: string
+ codec: SecretCodec
+ /** Injectable for tests; production reads the real CLI config files. */
+ native?: {
+ list(): Promise
+ codexNames(cwd: string): Promise>
+ claudeManagedPolicy(): Promise
+ codexShellPolicy?(cwd: string): Promise
+ }
+}
+
+/**
+ * Single owner of user MCP servers (#1143): the document, the secrets, and the
+ * launch-time decision of what attaches to which agent.
+ *
+ * WHY main decides what attaches, not the renderer (spec Revision 2 §4): main
+ * owns the document and the only copy of the secrets, and a renderer holding a
+ * stale snapshot must not be able to attach a server the user has since
+ * deleted or switched off. The renderer contributes only the pane's explicit
+ * per-agent choices; everything else is read here at launch.
+ */
+export class UserMcpService {
+ private document: UserMcpDocument = { version: 1, servers: [] }
+ private storeProblem: string | undefined
+ private readonly file: string
+ private readonly secrets: UserMcpSecretStore
+ private readonly native: NonNullable
+ private readonly listeners = new Set<(snapshot: UserMcpSnapshot) => void>()
+ // Every mutation runs after the previous one settles. Two windows toggling
+ // at once would otherwise both read-modify-write the same document and the
+ // later write would silently discard the earlier change.
+ private tail: Promise = Promise.resolve()
+ private initialized: Promise | null = null
+ // Set while the document on disk could not be read (see
+ // LoadedUserMcpDocument.readFailed). Writing then would atomically replace
+ // every server the user configured with the empty in-memory list, so
+ // mutations are refused until a re-read succeeds.
+ private readFailed = false
+
+ constructor(deps: UserMcpServiceDeps) {
+ this.file = join(deps.stateDir, 'mcp-servers.json')
+ this.secrets = new UserMcpSecretStore(join(deps.stateDir, 'mcp-secrets'), deps.codec)
+ this.native = deps.native ?? {
+ list: () => readNativeMcpServers(),
+ codexNames: cwd => codexNativeServerNames(cwd),
+ claudeManagedPolicy: () => claudeManagedMcpPolicyPresent(),
+ codexShellPolicy: cwd => codexShellPolicyStyle(cwd),
+ }
+ }
+
+ initialize(): Promise {
+ this.initialized ??= (async () => {
+ const loaded = await loadUserMcpDocument(this.file)
+ this.document = loaded.document
+ this.storeProblem = loaded.problem
+ this.readFailed = loaded.readFailed === true
+ })()
+ return this.initialized
+ }
+
+ onChange(listener: (snapshot: UserMcpSnapshot) => void): () => void {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ }
+
+ async snapshot(): Promise {
+ await this.initialize()
+ const [native, claudeManagedPolicy] = await Promise.all([
+ this.native.list().catch(() => [] as NativeMcpServerSource[]),
+ this.native.claudeManagedPolicy().catch(() => false),
+ ])
+ // Names already in the user's own Codex config.toml collide at every Codex
+ // launch (see codexNativeServerNames), so the view says so up front.
+ // Project-scope files depend on the agent's cwd and are only caught at
+ // launch, with a notice.
+ const codexNativeNames = new Set(native.filter(entry => entry.provider === 'codex').map(entry => entry.name))
+ const claudeNativeNames = new Set(native.filter(entry => entry.provider === 'claude').map(entry => entry.name))
+ const servers = await Promise.all(this.document.servers.map(server =>
+ this.view(server, claudeManagedPolicy, codexNativeNames, claudeNativeNames)))
+ return {
+ servers,
+ // Strip Copy-in material: see NativeMcpServer.copyable.
+ native: native.map(({ entry: _entry, inputs: _inputs, ...view }): NativeMcpServer => view),
+ claudeManagedPolicy,
+ ...(this.storeProblem ? { storeProblem: this.storeProblem } : {}),
+ }
+ }
+
+ importConfig(text: string, fallbackName?: string): UserMcpImportResult {
+ return importUserMcpConfig(text, fallbackName)
+ }
+
+ save(input: UserMcpSaveInput, actor: UserMcpActor = 'user'): Promise {
+ return this.mutate(async () => {
+ const existing = input.id ? this.document.servers.find(server => server.id === input.id) : undefined
+ if (input.id && !existing) return { ok: false, error: 'That server no longer exists.' }
+ for (const value of Object.values(input.secrets ?? {})) {
+ const problem = value === '' ? null : secretValueProblem(value)
+ if (problem) return { ok: false, error: problem }
+ }
+ const entry = normalizeEntry(input.entry)
+ const destinationChanged = existing !== undefined && userMcpDestination(existing.entry) !== userMcpDestination(entry)
+ // An agent proposes, the user approves (review round 2): a server an
+ // agent adds, or points somewhere new, is stored OFF and flagged for
+ // review, and an agent can never turn one on. Otherwise a single
+ // prompt-injected agent could install a command that every future agent
+ // runs. A user save of an existing server clears the flag only by
+ // turning it on (setEnabled); saving it off keeps the flag visible.
+ const agentNeedsReview = actor === 'agent' && (existing === undefined || destinationChanged || existing.pendingReview === true)
+ const enabled = actor === 'agent'
+ ? (agentNeedsReview ? false : existing!.enabled && input.enabled)
+ : input.enabled
+ const pendingReview = agentNeedsReview || (actor === 'user' && existing?.pendingReview === true && !enabled)
+ const server: UserMcpServer = {
+ id: existing?.id ?? randomUUID(),
+ name: input.name.trim(),
+ enabled,
+ providers: { claude: input.providers.claude === true, codex: input.providers.codex === true },
+ entry,
+ inputs: coerceInputs(input.inputs),
+ ...(pendingReview ? { pendingReview: true as const } : {}),
+ }
+ const others = this.document.servers.filter(other => other.id !== server.id)
+ // Structural problems block the save. A missing secret does not: it is a
+ // normal intermediate state (paste config now, fetch the token later),
+ // and launch already refuses to attach the server until it is set.
+ const problems = validateServer(server, others)
+ if (problems.length > 0) return { ok: false, error: problems[0]!.message, problems }
+ // Changing WHERE a server connects forgets its stored secrets (review
+ // round 1). Otherwise an edit — or an agent's mcp_servers_update after a
+ // prompt injection — could keep `${input:token}` and point the entry at
+ // another host or command, and the next launch would hand the token to
+ // it: exfiltration without ever reading a secret. Secrets supplied in
+ // this same save are set afterwards, so an intentional move that
+ // re-enters the token still works in one step.
+ this.document = {
+ version: 1,
+ servers: existing
+ ? this.document.servers.map(candidate => candidate.id === server.id ? server : candidate)
+ : [...this.document.servers, server],
+ }
+ // Document first, secret blobs after (review round 2): a failed persist
+ // rolls the document back in mutate(), and blobs cleared before it could
+ // not be rolled back, so a failed destination edit used to lose the
+ // server's token for good.
+ await this.persist()
+ if (destinationChanged) await this.secrets.clearServer(server.id)
+ for (const [inputId, value] of Object.entries(input.secrets ?? {})) {
+ if (server.inputs.some(candidate => candidate.id === inputId)) {
+ await this.secrets.set(server.id, inputId, value)
+ }
+ }
+ await this.secrets.prune(server.id, server.inputs.map(candidate => candidate.id))
+ return {
+ ok: true,
+ id: server.id,
+ ...(destinationChanged ? { secretsCleared: true } : {}),
+ ...(pendingReview ? { pendingReview: true } : {}),
+ }
+ })
+ }
+
+ delete(id: string): Promise {
+ return this.mutate(async () => {
+ if (!this.document.servers.some(server => server.id === id)) return { ok: false, error: 'That server no longer exists.' }
+ this.document = { version: 1, servers: this.document.servers.filter(server => server.id !== id) }
+ await this.persist()
+ await this.secrets.clearServer(id)
+ return { ok: true }
+ })
+ }
+
+ /** Agents may only turn a server OFF; turning one on is the user's review
+ * decision, and doing so clears pendingReview. */
+ setEnabled(id: string, enabled: boolean, actor: UserMcpActor = 'user'): Promise {
+ if (actor === 'agent' && enabled) {
+ return Promise.resolve({ ok: false, error: 'Only the user can turn an MCP server on (Settings → MCP).' })
+ }
+ return this.update(id, server => {
+ const { pendingReview: _pending, ...rest } = server
+ return enabled ? { ...rest, enabled } : { ...server, enabled }
+ })
+ }
+
+ setProvider(id: string, provider: UserMcpProvider, enabled: boolean): Promise {
+ return this.update(id, server => ({ ...server, providers: { ...server.providers, [provider]: enabled } }))
+ }
+
+ setSecret(id: string, inputId: string, value: string): Promise {
+ return this.mutate(async () => {
+ const problem = value === '' ? null : secretValueProblem(value)
+ if (problem) return { ok: false, error: problem }
+ const server = this.document.servers.find(candidate => candidate.id === id)
+ if (!server) return { ok: false, error: 'That server no longer exists.' }
+ if (!server.inputs.some(input => input.id === inputId)) return { ok: false, error: `No secret named "${inputId}".` }
+ await this.secrets.set(id, inputId, value)
+ return { ok: true }
+ })
+ }
+
+ /**
+ * Copy a CLI-native server into Agent Code.
+ *
+ * The copy starts attached only to the OTHER provider. WHY: the source CLI
+ * keeps loading its own entry, so attaching the copy there too duplicates it
+ * — and for Codex, a same-name launch entry is refused at launch (see
+ * codexNativeServerNames). Sharing a server the user set up in one CLI with
+ * the other is the main reason to copy it in.
+ */
+ copyNative(provider: UserMcpProvider, name: string): Promise {
+ return this.mutate(async () => {
+ const native = (await this.native.list()).find(server => server.provider === provider && server.name === name)
+ if (!native?.entry) return { ok: false, error: 'That server can no longer be read from its config file.' }
+ const others = this.document.servers
+ const server: UserMcpServer = {
+ id: randomUUID(),
+ name: native.name,
+ enabled: true,
+ providers: { claude: provider !== 'claude', codex: provider !== 'codex' },
+ entry: normalizeEntry(native.entry),
+ inputs: native.inputs,
+ }
+ const problems = validateServer(server, others)
+ if (problems.length > 0) return { ok: false, error: problems[0]!.message, problems }
+ this.document = { version: 1, servers: [...others, server] }
+ await this.persist()
+ return { ok: true, id: server.id }
+ })
+ }
+
+ /**
+ * Decide and materialize the user servers for one agent launch.
+ *
+ * `overrides` are the pane's explicit per-agent choices (bare server ids).
+ * Order of rules, and why:
+ * 1. master switch off → skip silently. The user turned it off everywhere,
+ * so there is nothing to warn about, even for a per-agent "on".
+ * 2. not requested (no override and provider default off) → skip silently.
+ * 3. requested but unusable (invalid, unsupported transport, enterprise
+ * policy, native name collision, missing secret, translator refusal) →
+ * drop WITH a reason. The user asked for it, so silence would read as
+ * "it's attached" while the agent has no such tools.
+ * A dropped server never fails the launch.
+ */
+ async resolveForLaunch(params: {
+ provider: string
+ overrides: Readonly>
+ cwd: string
+ }): Promise {
+ await this.initialize()
+ const empty: UserMcpLaunchResolution = { servers: [], attachedIds: [], dropped: [] }
+ if (!isUserMcpProvider(params.provider)) return empty
+ const provider = params.provider
+ const requested = this.document.servers.filter(server =>
+ server.enabled && (params.overrides[server.id] ?? server.providers[provider]))
+ if (requested.length === 0) return empty
+
+ const dropped: UserMcpDroppedServer[] = []
+ const candidates: ResolvedUserMcpServer[] = []
+ const claudeManaged = provider === 'claude' && await this.native.claudeManagedPolicy().catch(() => false)
+ const codexNames = provider === 'codex'
+ ? await this.native.codexNames(params.cwd).catch(() => new Set())
+ : new Set()
+ for (const server of requested) {
+ const others = this.document.servers.filter(other => other.id !== server.id)
+ const problem = validateServer(server, others)[0]
+ if (problem) {
+ dropped.push({ name: server.name, reason: problem.message })
+ continue
+ }
+ const support = providerSupportForEntry(server.entry)[provider]
+ if (!support.ok) {
+ dropped.push({ name: server.name, reason: support.reason })
+ continue
+ }
+ if (claudeManaged) {
+ dropped.push({ name: server.name, reason: "Your organization's Claude MCP policy only allows its own servers" })
+ continue
+ }
+ if (server.pendingReview) {
+ dropped.push({ name: server.name, reason: 'Waiting for your review in Settings → MCP' })
+ continue
+ }
+ if (codexNames.has(server.name)) {
+ dropped.push({ name: server.name, reason: 'A server with this name is already in your Codex config.toml' })
+ continue
+ }
+ const secrets: Record = {}
+ let missing: string | null = null
+ for (const inputId of referencedInputIds(server.entry)) {
+ const value = await this.secrets.get(server.id, inputId)
+ if (value === null) {
+ missing = inputId
+ break
+ }
+ secrets[inputId] = value
+ }
+ if (missing) {
+ dropped.push({ name: server.name, reason: `Secret "${missing}" is not set` })
+ continue
+ }
+ candidates.push({ id: server.id, name: server.name, entry: server.entry, secrets })
+ }
+
+ // Dry-run the provider translator here, where drops can be reported, so
+ // the provider session never has to silently omit a server it was handed.
+ // Both translators are pure and deterministic over the same input.
+ const codexShellPolicy = provider === 'codex'
+ ? await (this.native.codexShellPolicy?.(params.cwd) ?? Promise.resolve({ style: 'filters' } as const))
+ .catch(() => ({ style: 'filters' } as const))
+ : undefined
+ const translatorDrops = provider === 'claude'
+ ? claudeUserMcpEntries(candidates).dropped
+ // The dry run sees the same inherited environment the Codex process will
+ // (Codex inherits main's), so its "your environment already sets X"
+ // refusal is reported here rather than silently applied in the session.
+ : addCodexUserMcpLaunchConfig(candidates, [], inheritedEnvironment(), codexShellPolicy)
+ const refused = new Set(translatorDrops.map(server => server.name))
+ dropped.push(...translatorDrops)
+ const servers = candidates.filter(server => !refused.has(server.name))
+ return {
+ servers,
+ attachedIds: servers.map(server => server.id),
+ dropped,
+ ...(codexShellPolicy ? { codexShellPolicy } : {}),
+ }
+ }
+
+ private update(id: string, change: (server: UserMcpServer) => UserMcpServer): Promise {
+ return this.mutate(async () => {
+ const server = this.document.servers.find(candidate => candidate.id === id)
+ if (!server) return { ok: false, error: 'That server no longer exists.' }
+ this.document = {
+ version: 1,
+ servers: this.document.servers.map(candidate => candidate.id === id ? change(candidate) : candidate),
+ }
+ await this.persist()
+ return { ok: true }
+ })
+ }
+
+ private mutate(
+ operation: () => Promise<{ ok: true; id?: string; secretsCleared?: boolean; pendingReview?: boolean } | { ok: false; error: string; problems?: UserMcpProblem[] }>,
+ ): Promise {
+ const run = this.tail.then(async (): Promise => {
+ await this.initialize()
+ const before = this.document
+ if (this.readFailed) {
+ // A transient failure (a restore holding the file, EMFILE while many
+ // agents start) usually clears; retry before refusing.
+ const loaded = await loadUserMcpDocument(this.file)
+ if (loaded.readFailed) {
+ return { ok: false, error: `${loaded.problem ?? 'MCP settings could not be read'}. Nothing was changed.` }
+ }
+ this.document = loaded.document
+ this.storeProblem = loaded.problem
+ this.readFailed = false
+ }
+ try {
+ const outcome = await operation()
+ if (!outcome.ok) return outcome
+ const snapshot = await this.snapshot()
+ for (const listener of this.listeners) listener(snapshot)
+ return {
+ ok: true,
+ snapshot,
+ ...(outcome.id ? { id: outcome.id } : {}),
+ ...(outcome.secretsCleared ? { secretsCleared: true } : {}),
+ ...(outcome.pendingReview ? { pendingReview: true } : {}),
+ }
+ } catch (error) {
+ // Review round 1: a failed persist must not leave memory ahead of
+ // disk, or the snapshot shows a server that a restart will lose and a
+ // retry is refused as a duplicate. (Secrets written before the failure
+ // are orphaned blobs at worst; the next save of that server prunes them.)
+ this.document = before
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ }
+ })
+ this.tail = run.catch(() => {})
+ return run
+ }
+
+ private async persist(): Promise {
+ await saveUserMcpDocument(this.file, this.document)
+ // A successful write supersedes whatever made the old file unreadable.
+ this.storeProblem = undefined
+ }
+
+ private async view(
+ server: UserMcpServer,
+ claudeManagedPolicy: boolean,
+ codexNativeNames: ReadonlySet,
+ claudeNativeNames: ReadonlySet,
+ ): Promise {
+ const transport = transportOf(server.entry)
+ const others = this.document.servers.filter(other => other.id !== server.id)
+ const secrets = await this.secrets.state(server.id, server.inputs.map(input => input.id))
+ const problems = validateServer(server, others)
+ for (const inputId of referencedInputIds(server.entry)) {
+ if (secrets[inputId] && !secrets[inputId]!.set) {
+ problems.push({ kind: 'secret-missing', message: `Secret "${inputId}" is not set` })
+ }
+ }
+ if (server.pendingReview) {
+ problems.unshift({ kind: 'pending-review', message: 'Added or changed by an agent — review the config, then turn it on' })
+ }
+ // Spec Decisions: Claude replaces a same-name native server with ours for
+ // Agent Code launches (whole-entry --mcp-config precedence), while the
+ // user's own `claude` runs keep theirs. Say so, or a rotated native token
+ // looks ignored (review round 2).
+ if (claudeNativeNames.has(server.name) && server.providers.claude) {
+ problems.push({ kind: 'claude-native-name', message: 'Also in your Claude config; Agent Code agents use this one instead' })
+ }
+ const support = { ...providerSupportForEntry(server.entry) }
+ if (claudeManagedPolicy) {
+ support.claude = { ok: false, reason: "Your organization's Claude MCP policy only allows its own servers" }
+ }
+ if (support.codex.ok && codexNativeNames.has(server.name)) {
+ support.codex = { ok: false, reason: 'A server with this name is already in your Codex config.toml' }
+ }
+ return {
+ ...server,
+ transport,
+ summary: summarizeEntry(server.entry),
+ secrets,
+ problems,
+ support,
+ }
+ }
+}
+
+function inheritedEnvironment(): Record {
+ return Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
+}
diff --git a/src/main/userMcp/store.ts b/src/main/userMcp/store.ts
new file mode 100644
index 000000000..153c5e362
--- /dev/null
+++ b/src/main/userMcp/store.ts
@@ -0,0 +1,60 @@
+import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
+import { dirname } from 'node:path'
+
+import type { UserMcpDocument } from '@shared/userMcp/types.js'
+import { coerceUserMcpDocument } from '@shared/userMcp/validate.js'
+
+export type LoadedUserMcpDocument = {
+ document: UserMcpDocument
+ /** Human-readable notice when the file existed but could not be used. */
+ problem?: string
+ /**
+ * The file exists but could not be READ (EACCES, EIO, EMFILE…), as opposed
+ * to read-but-unparseable. Its contents are unknown and still on disk, so a
+ * write would replace them with the empty document: callers must not write
+ * until a later read succeeds.
+ */
+ readFailed?: boolean
+}
+
+/**
+ * Read `mcp-servers.json`.
+ *
+ * WHY a corrupt file is moved aside instead of overwritten or left in place:
+ * leaving it would make every later save either fail or clobber it, and
+ * silently resetting would erase every server the user configured. Renaming
+ * it keeps the bytes recoverable (`mcp-servers.json.corrupt-