Skip to content

feat(mcp): sync MCP servers across enabled CLIs - #521

Merged
Ark0N merged 5 commits into
Ark0N:masterfrom
opticon454:feat/mcp-sync
Oct 4, 2026
Merged

Ark0N merged 5 commits into
Ark0N:masterfrom
opticon454:feat/mcp-sync

Conversation

@opticon454

@opticon454 opticon454 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

What

Sync MCP servers between the agent CLIs. Each CLI keeps its own user-level MCP list in its own file and dialect; this reads every enabled, installed CLI's list and adds any server a CLI is missing from the others.

Opt-in. Settings → Agents & CLIs → MCP servers → "Enable MCP server sync" (mcpSyncEnabled, synced, default OFF). While it is off, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden. Nothing about it runs or writes anything until a user turns it on.

  • Registry: new optional capabilities.mcpConfig ({ path, format }, home-relative, schema-guarded against ../absolute paths). Declared for Claude, Gemini, Codex, OpenCode and Antigravity. No code branches on a CLI id.
  • src/mcp-sync.ts: the adapters and the sync. Codex's config.toml is read with smol-toml (new dependency: BSD-3, zero deps, ~1.9.0).
  • API: GET /api/mcp-sync previews, POST /api/mcp-sync applies. Admin only in multi-user mode. Documented in docs/api-reference.md.
  • UI: Preview / Sync now.

Behaviour

  • Participating CLIs: enabled in the registry and installed or already holding the config file. An enabled CLI that is neither is reported absent and never created.
  • Additive only. A name already defined (in any shape) is never edited or removed. The same name defined differently is reported as a conflict.
  • Disabled servers are not copied (codex enabled = false, opencode enabled: false, antigravity disabled: true), since copying would switch them on elsewhere.
  • Safe writes: other keys preserved; each changed file kept as <file>.codeman-bak (overwritten by each sync); the new text is re-parsed and every added server must read back as intended before the write; unparseable files (OpenCode JSONC, TOML with a duplicate table) are never written; symlinked configs are written through, not replaced; a file that receives env/headers is left 0600; one apply at a time (409); temp files unique and removed on failure.
  • Servers a dialect cannot express (SSE for Codex and Antigravity) are skipped and reported. Enabled agent CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported.
  • Responses carry server names only, never env values or headers. __proto__/constructor/prototype are ignored at every level and untrusted-name tables have no prototype.

Formats

Checked against what the CLI's own mcp add writes (throwaway HOME): Claude, Gemini (url + type), Codex, Antigravity (~/.gemini/config/mcp_config.json, serverUrl). OpenCode follows its documented mcp shape; it was not installed to verify. After a sync, codex mcp list, gemini mcp list and agy mcp list all load the result.

Tests

test/mcp-sync.test.ts, test/mcp-sync-registry.test.ts, test/routes/mcp-sync-routes.test.ts: real CLI output as fixtures; CRLF / inline-table / command-less codex tables; sub-table __proto__ and toString-named servers; disabled servers per dialect; installed gating; permissions; symlinks; concurrency; opt-in 403s; enabled-only; no secrets in responses; multi-user gating; path-traversal rejection. Full CI gate on this branch: typecheck, lint, format, public assets, catalogue and 8561 tests pass.

The first revision was tested by hand in the running app by the author. This push (opt-in setting and review fixes) is covered by the test suite and by running the real codex, gemini and agy CLIs against the synced files in a throwaway HOME; it has not yet been exercised by hand in the Settings UI.

🤖 Generated with Claude Code

opticon454 and others added 4 commits October 2, 2026 18:27
Adds capabilities.mcpConfig to the CLI registry (Claude, Gemini, Codex,
OpenCode), an additive src/mcp-sync.ts, GET/POST /api/mcp-sync and a
Settings > Agents & CLIs control. Never edits or removes an existing
server; backs up each file it changes; reports conflicts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rted CLIs

Formats verified against real agy/gemini/codex mcp add output.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…try tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…xpress

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Thanks @opticon454, this is a well-built feature. It adds a registry capability (capabilities.mcpConfig) and a one-click sync that copies each CLI's user-level MCP servers into the other CLIs' config files, with a preview, backups and no secrets in the response. Using a capability field instead of branching on CLI ids, resolving the registry per request and gating on admin are exactly right, and every check plus your 29 tests pass here.

I tried it against real configs in throwaway HOMEs and found a few things that need fixing before merge.

Must fix

  1. Codex: the sync can leave ~/.codex/config.toml unloadable (src/mcp-sync.ts:460-463, :374). The TOML branch of addServers appends [mcp_servers.<name>] for every name the reader did not return, and parseServers('codex-toml', ...) never throws, so nothing stops a duplicate. Three valid configs trigger it: CRLF line endings (the offset math adds l.length + 1 after splitting on \r?\n, so the reader sees no servers at all), an [mcp_servers] table with inline tables (fs = { command = "npx" }), and a table without command/url. With fs defined in both Claude and Codex, the sync appends a second [mcp_servers.fs] and codex mcp list (codex-cli 0.147.0) fails with duplicate key. Please (a) compute offsets from the real text or normalise line endings, (b) refuse to append a name the raw text already defines anywhere under mcp_servers, and (c) re-parse the new text before writing and refuse unless every appended server comes back with no repeated header. A small real TOML parser for the read and validate steps is welcome. Please add tests for all three shapes.

Should fix

  1. The __proto__ guard misses sub-tables (src/mcp-sync.ts:364-366). [mcp_servers.fs.__proto__] makes current[sub] Object.prototype, so the following keys land on it: after parsing polluted = "yes" under that header, ({}).polluted === 'yes' in the server process. A server named toString binds current to Object.prototype.toString (call = "x" then breaks every Object.prototype.toString.call), and the in checks at :556/:565 report JSON servers named toString or hasOwnProperty as conflicts. Please use Object.create(null) for out, union, the table records and sub-tables, test membership with Object.hasOwn, and apply UNSAFE_NAMES to the sub-table and key positions too. Please extend the hostile-config test.
  2. Disabled servers come out enabled (src/mcp-sync.ts:164-204, :387-395). Codex enabled = false, OpenCode enabled: false and Antigravity disabled: true are dropped on read, and the writers emit enabled: true / disabled: false. An OpenCode server the user turned off ends up live in ~/.claude.json. Please carry a disabled flag and leave disabled servers out of the union (or write them disabled where the dialect supports it), with a test per dialect.
  3. "Enabled" covers every stock CLI on a default install (src/web/routes/mcp-sync-routes.ts:19-24). Every stock entry ships enabled: true, and the only way to disable one is the opt-in CLI management list, so on a Claude-only machine Sync now creates ~/.codex/config.toml, ~/.gemini/settings.json, ~/.gemini/config/mcp_config.json and ~/.config/opencode/opencode.json, each with copies of the env values and headers. Please target only CLIs whose config file exists or which are installed (isCliEntryInstalled() in src/utils/cli-installed-probes.ts, as the CLI list uses), and report the rest as not installed.

Smaller items, fine in the same round

  1. writeAtomic (src/mcp-sync.ts:492-504) keeps the target's mode, so a token from a 0600 config.toml can land in a 0664 ~/.claude.json. When the added servers carry env or headers, write with mode & ~0o077, and say in the Settings text that env values and headers are copied.
  2. rename(tmp, file) replaces a symlinked config with a plain file, which silently detaches dotfiles setups. Resolve with fs.realpath and write next to the real target, or skip symlinks and report them.
  3. Two overlapping POSTs share ${file}.codeman-tmp-${pid}. Please add a module-level in-flight guard that answers 409 CONFLICT to a second apply, use a unique tmp suffix, and unlink the tmp on failure.
  4. Please add a short /api/mcp-sync section to docs/api-reference.md (result shape, statuses, the 403 for non-admins in multi-user mode), since /api/v1 paths are public surface.

Optional: the UI says conflicting names are "left unchanged", but the first CLI's definition is still copied to the CLIs that lack the name (settings-ui.js:1141). A write failure is reported as unreadable. The format list is declared three times (schema, types, McpFormat). .codeman-bak is overwritten on every write.

Separately, I still need to decide on the product side whether Codeman writes other CLIs' own user config at all (it has avoided that so far) and whether this sits behind an opt-in setting. I will post that decision here before you start on the fixes, so nothing is wasted. After that, a push covering items 1 to 4 with tests gets another review.

Opt-in (mcpSyncEnabled, default OFF; routes 403 until on). Review fixes:
- codex TOML read/validated with smol-toml: CRLF, inline tables and
  command-less tables no longer yield a duplicate [mcp_servers.x]; the new
  text is re-parsed before writing
- null-prototype tables and own-key checks; unsafe names ignored at every level
- servers switched off in their own CLI (codex/opencode/antigravity) are not copied
- only CLIs that are installed or already have a config file take part
- files receiving env/headers are left 0600; symlinked configs are written through
- one apply at a time (409), unique tmp files cleaned on failure, failed status
- routes set real HTTP status codes; api-reference section; format type single-sourced

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Pushed 4398dbfa; everything below is in it, with tests.

Product decision: it is now opt-in. mcpSyncEnabled (synced, default OFF). While off, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden. Happy to adjust if you'd prefer a different default or home for the toggle.

Must fix

  1. Codex TOML. Replaced my hand-rolled reader with smol-toml (new dependency, BSD-3, zero deps) for read and validate. CRLF, [mcp_servers] with inline tables, and tables without command/url no longer produce a duplicate [mcp_servers.fs]: any name defined under mcp_servers in any shape is never appended over, and the new text is re-parsed before writing (it refuses unless every added server reads back as intended and nothing existing is lost). Tests for all three shapes, and I ran codex mcp list against the results. (Note a table with no command/url is rejected by Codex itself before any sync; it is left untouched.)

Should fix
2. __proto__ / prototype names. Tables keyed by untrusted names are null-prototype, membership is by own key, and unsafe names are ignored at the server-name and sub-table positions. Tests cover [mcp_servers.fs.__proto__], a server named toString, and JSON servers named toString/hasOwnProperty (no spurious conflicts).
3. Disabled servers. Codex enabled = false, OpenCode enabled: false and Antigravity disabled: true are read and not propagated; the result lists them under disabled. A name disabled in one CLI and live in another still syncs from the live one. Test per dialect.
4. Installed only. A CLI takes part only if it is enabled, declares an mcpConfig, and is installed (isCliEntryInstalled) or already has its config file. Others are reported absent and never created.

Smaller items
5. A file that receives servers carrying env/headers is left 0600 (existing mode otherwise kept; also fixed the umask masking the preserved mode). The Settings text says env values and headers are copied.
6. Symlinked configs are written through to the real file; a dangling link is reported failed and nothing is written.
7. Module-level in-flight guard: a second apply answers 409 CONFLICT; tmp names are unique and unlinked on failure; write errors are failed, distinct from unreadable.
8. docs/api-reference.md has an /api/mcp-sync section (result shape, statuses, 403/409).

Optional items: the conflict wording in Settings now says the first CLI's definition is copied where the name is missing; the format list is a single McpConfigFormat type (the schema enum is checked against it, and mcp-sync.ts's dialect table is keyed by it); .codeman-bak is still overwritten each sync, which is now stated in the Settings text and docs.

While here: the routes previously returned error bodies with HTTP 200; they now set real status codes (403/409/500).

CI gate on the branch: typecheck, lint, format, public assets, catalogue and 8561 tests pass.

@opticon454

Copy link
Copy Markdown
Contributor Author

Heads-up on merge order, so nothing surprises you. I checked my open PRs against each other with trial merges. Every overlap is a textual conflict in a shared registry or list (adjacent insertions), none is a behavioural interaction, but whichever lands first, the others will need a rebase:

I'm not touching this PR for it. As each of #520, #522 and #523 merges I will rebase the others onto master, resolve the conflicts, re-run the full gate and push, so you should not have to deal with any of it. If you would rather take them in a particular order, say so and I will keep to it. #521 is the largest, so landing it first means the smaller PRs rebase once onto it.

@Ark0N
Ark0N merged commit 9649b50 into Ark0N:master Oct 4, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 4, 2026
…docs and Settings polish (#521 review)

Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).

M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.

M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.

M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.

Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
  settings to turn MCP sync on first" instead of calling the routes; the 403 message also
  says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
  from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
  pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
  the route imported them, so no churn.

Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 4, 2026
…#520, #521, #522, #523, #524, #530, #531)

One consolidated minor changeset with the Thanks block first; the four contributor changesets (#520, #521, #522, #523) are folded into it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Merged and shipped in 1.34.0. Thanks @opticon454!

On the product question I left open in my first review: making it opt-in (mcpSyncEnabled, default OFF) answered it, so it ships exactly that way. Switching to smol-toml for both the read and the re-parse validation is what made writing other CLIs' config files acceptable, since it cannot leave a config.toml that Codex refuses to load.

I applied the review items while landing (b451b38):

  • A config file that fails to parse is now reported by position only ("not valid TOML (line 3, column 21)"), never by quoting the file, because that line can hold an API key. Tests put a secret on the broken line and check it reaches neither the result nor the HTTP response.
  • Relocated config dirs: a new optional capabilities.mcpConfig.relocation { envVar, path } makes the sync follow CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME (opencode) and GEMINI_CLI_HOME from the server's environment. A value that is not absolute gives a new skipped status and writes nothing, rather than writing a file the CLI never reads. Antigravity declares none, since agy only follows $HOME.
  • Docs: a CLAUDE.md Key Patterns paragraph, a Settings Reference row, and docs/cli-registry.md / docs/api-reference.md updates.
  • UI: Preview before saving now says to save first, the group is hidden from non-admins in multi-user mode, the scope chip reads "synced", and the copy says "(1 server)". The unsupported list names only installed CLIs, and the result types moved to src/types/mcp-sync.ts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants