Skip to content

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

Open
opticon454 wants to merge 5 commits into
Ark0N:masterfrom
opticon454:feat/mcp-sync
Open

opticon454 wants to merge 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.

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