/** * @fileoverview MCP server sync between the enabled agent CLIs. * * Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig` * names the file and the dialect). This module reads every participating CLI's list into one * neutral shape, and adds any server a CLI is missing from the others. The whole feature is * opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools' * own user config. * * Deliberately conservative: * - ADDITIVE only. A server already present under a name (in ANY shape, even one this module * does not understand) is never rewritten and nothing is ever removed. Same name with a * different definition is reported as a conflict and left alone. * - A server the user has switched off in its own CLI (codex `enabled = false`, opencode * `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would * switch it on in every other CLI. * - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a * duplicate table) is never written, and a write is only made after the NEW text has been * parsed again and every added server comes back as intended. * - Only the MCP table is touched; every other key in the file is preserved. Files are * re-read immediately before the write and replaced via tmp+rename next to the REAL target * (a symlinked dotfile stays a symlink), with the old file kept as `.codeman-bak` * (overwritten by each sync). * - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left * readable by its owner only. * - Servers a dialect cannot express (SSE for codex) are skipped and reported. * - Only one apply runs at a time. * - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`, * `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value * cannot be located safely, so that target is reported `skipped` and never written. * * The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly * hold secrets and the result is returned over HTTP. For the same reason a parse failure is * reported by position only (`describeMcpSyncError`): parsers quote the offending source. * * @module mcp-sync */ import { promises as fs } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; import { parse as parseToml, TomlError } from 'smol-toml'; import type { McpConfigFormat } from './config/cli-registry/types.js'; import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js'; export type McpFormat = McpConfigFormat; export interface McpServer { transport: 'stdio' | 'http' | 'sse'; command?: string; args?: string[]; env?: Record; cwd?: string; url?: string; headers?: Record; /** Switched off in the CLI that defines it. Never propagated. */ disabled?: boolean; } export type McpServerMap = Record; export interface McpSyncTarget { id: string; label: string; /** Home-relative default location of the config file. */ path: string; format: McpFormat; /** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */ relocation?: { envVar: string; path: string }; /** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */ installed: boolean; } /** A second apply was requested while one was running. */ export class McpSyncBusyError extends Error { constructor() { super('An MCP sync is already running'); this.name = 'McpSyncBusyError'; } } /** * An error whose message this module wrote itself. It names keys Codeman chose and server names * (which the result reports anyway), never a value from the file, so it may be shown as is. */ class McpConfigError extends Error { constructor(message: string) { super(message); this.name = 'McpConfigError'; } } /** * What a target's `error` may say. A parser's own message can quote the file: smol-toml's * `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON * "Unexpected token" errors quote about ten characters of source. These files hold env values * and headers and the result goes over HTTP, so a parse failure is reported by position only, * an errno failure by Node's own message (code, syscall and path: no file content), and anything * else by a fixed category. */ function describeMcpSyncError(err: unknown): string { if (err instanceof McpConfigError) return err.message; if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`; if (err instanceof SyntaxError) { const lc = /\(line (\d+) column (\d+)\)/.exec(err.message); if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`; const pos = /at position (\d+)/.exec(err.message); return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON'; } const code = (err as NodeJS.ErrnoException | null)?.code; if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message; return 'unexpected error'; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); /** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */ const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']); /** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */ function dict(): Record { return Object.create(null) as Record; } /** Own, safe keys of an untrusted table. */ function safeKeys(table: Record): string[] { return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k)); } function strMap(v: unknown): Record | undefined { if (!isRecord(v)) return undefined; const out = dict(); for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string; return Object.keys(out).length ? out : undefined; } function strArr(v: unknown): string[] | undefined { return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined; } /** Drop undefined/empty fields so equal servers compare equal. */ function clean(s: McpServer): McpServer { const out: McpServer = { transport: s.transport }; if (s.command) out.command = s.command; if (s.args?.length) out.args = s.args; if (s.env && Object.keys(s.env).length) out.env = s.env; if (s.cwd) out.cwd = s.cwd; if (s.url) out.url = s.url; if (s.headers && Object.keys(s.headers).length) out.headers = s.headers; if (s.disabled) out.disabled = true; return out; } const sortedEntries = (m: Record | undefined): [string, string][] => Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); /** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */ function fingerprint(s: McpServer): string { const t = s.transport === 'stdio' ? 'stdio' : 'url'; return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]); } /** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */ function fullIdentity(s: McpServer): string { return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]); } const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers); // --------------------------------------------------------------------------- // JSON dialects // --------------------------------------------------------------------------- function fromClaude(raw: unknown): McpServer | null { if (!isRecord(raw)) return null; const type = raw.type; if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') { return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) }); } if (typeof raw.command === 'string') { return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) }); } return null; } function toClaude(s: McpServer): Record { if (s.transport === 'stdio') return { type: 'stdio', command: s.command, args: s.args ?? [], env: s.env ?? {} }; return { type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) }; } function fromGemini(raw: unknown): McpServer | null { if (!isRecord(raw)) return null; // `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes // today, and a bare `url` with no type is the legacy SSE form. if (typeof raw.httpUrl === 'string') return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) }); if (typeof raw.url === 'string') { return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) }); } if (typeof raw.command === 'string') { return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env), cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined, }); } return null; } function toGemini(s: McpServer): Record { if (s.transport === 'stdio') { return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), ...(s.cwd ? { cwd: s.cwd } : {}), }; } return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) }; } /** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */ function fromAntigravity(raw: unknown): McpServer | null { if (!isRecord(raw)) return null; const disabled = raw.disabled === true; if (typeof raw.serverUrl === 'string') return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled }); if (typeof raw.command === 'string') { return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env), disabled, }); } return null; } function toAntigravity(s: McpServer): Record | null { if (s.transport === 'sse') return null; if (s.transport === 'stdio') { return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false }; } return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false }; } function fromOpencode(raw: unknown): McpServer | null { if (!isRecord(raw)) return null; const disabled = raw.enabled === false; if (raw.type === 'remote' && typeof raw.url === 'string') { return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled }); } if (raw.type === 'local') { const cmd = strArr(raw.command); if (!cmd?.length) return null; return clean({ transport: 'stdio', command: cmd[0], args: cmd.slice(1), env: strMap(raw.environment), disabled, }); } return null; } function toOpencode(s: McpServer): Record { if (s.transport === 'stdio') { return { type: 'local', command: [s.command, ...(s.args ?? [])], ...(s.env ? { environment: s.env } : {}), enabled: true, }; } return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true }; } interface JsonDialect { /** Key holding the server table. */ key: string; from(raw: unknown): McpServer | null; to(s: McpServer): Record | null; /** Top-level keys to seed when creating the file from nothing. */ seed?: Record; } const JSON_DIALECTS: Record, JsonDialect> = { 'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude }, 'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini }, 'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity }, 'opencode-json': { key: 'mcp', from: fromOpencode, to: toOpencode, seed: { $schema: 'https://opencode.ai/config.json' }, }, }; // --------------------------------------------------------------------------- // Codex TOML (the `[mcp_servers.*]` tables only) // --------------------------------------------------------------------------- function fromCodex(t: Record): McpServer | null { const disabled = t.enabled === false; if (typeof t.url === 'string') { return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled }); } if (typeof t.command === 'string') { return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled }); } return null; } const tomlStr = (v: string): string => JSON.stringify(v); const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k)); function toCodexToml(name: string, s: McpServer): string { const head = `[mcp_servers.${tomlKey(name)}]`; const lines = [head]; if (s.transport === 'stdio') { lines.push(`command = ${tomlStr(s.command ?? '')}`); lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`); if (s.env) { lines.push('', `[mcp_servers.${tomlKey(name)}.env]`); for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`); } } else { lines.push(`url = ${tomlStr(s.url ?? '')}`); if (s.headers) { lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`); for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`); } } return lines.join('\n') + '\n'; } // --------------------------------------------------------------------------- // Dialect entry points // --------------------------------------------------------------------------- export interface ParsedConfig { /** Servers this module understands. */ servers: McpServerMap; /** Every name defined under the MCP table, in any shape: these are never appended over. */ names: Set; } /** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */ function mcpTable(format: McpFormat, text: string | null): Record { if (text === null || !text.trim()) return dict(); if (format === 'codex-toml') { const doc = parseToml(text); const table = doc.mcp_servers; if (table === undefined) return dict(); if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table'); return table; } const dialect = JSON_DIALECTS[format]; const doc: unknown = JSON.parse(text); if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object'); const table = doc[dialect.key]; if (table === undefined) return dict(); if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`); return table; } /** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */ export function parseConfig(format: McpFormat, text: string | null): ParsedConfig { const table = mcpTable(format, text); const servers = dict(); const names = new Set(); for (const name of safeKeys(table)) { names.add(name); const raw = table[name]; const s = format === 'codex-toml' ? isRecord(raw) ? fromCodex(raw) : null : JSON_DIALECTS[format as Exclude].from(raw); if (s) servers[name] = s; } return { servers, names }; } /** The servers of a config file's text. */ export function parseServers(format: McpFormat, text: string | null): McpServerMap { return parseConfig(format, text).servers; } /** Whether this dialect can express the server. */ function canExpress(format: McpFormat, s: McpServer): boolean { if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse'; return true; } /** * Add servers to a config file's text and return the new text. A name already defined under the * MCP table (in any shape) is skipped; the new text is parsed again and every added server must * come back as intended, otherwise this throws and nothing should be written. */ export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string { const before = parseConfig(format, text); const todo = dict(); for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n]; const names = Object.keys(todo); if (names.length === 0) return text ?? ''; let out: string; if (format === 'codex-toml') { const base = text ?? ''; const eol = base.includes('\r\n') ? '\r\n' : '\n'; const sep = base.length === 0 ? '' : base.endsWith('\n\n') || base.endsWith('\r\n\r\n') ? '' : base.endsWith('\n') ? eol : eol + eol; const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol)); out = base + sep + blocks.join(eol); } else { const dialect = JSON_DIALECTS[format]; const doc: Record = text && text.trim() ? (JSON.parse(text) as Record) : { ...dialect.seed }; const existing = doc[dialect.key]; const table: Record = isRecord(existing) ? existing : {}; for (const n of names) { const entry = dialect.to(todo[n]); if (entry) table[n] = entry; } doc[dialect.key] = table; out = JSON.stringify(doc, null, 2) + '\n'; } // Re-read what we are about to write. const after = parseConfig(format, out); for (const n of before.names) { if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`); } for (const n of names) { const got = after.servers[n]; if (!got || fullIdentity(got) !== fullIdentity(todo[n])) { throw new McpConfigError(`refusing to write: "${n}" does not read back as written`); } } return out; } // --------------------------------------------------------------------------- // Orchestration // --------------------------------------------------------------------------- async function readText(file: string): Promise { try { return await fs.readFile(file, 'utf8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err; } } async function exists(file: string): Promise { try { await fs.access(file); return true; } catch { return false; } } /** * Write `text` over `file`, keeping the old content as `.codeman-bak`. Follows a symlink * to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is * readable by its owner only. */ async function writeAtomic(file: string, text: string, secret: boolean): Promise { let target = file; try { if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; // ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart. try { await fs.lstat(file); throw new McpConfigError('config path is a dangling symlink'); } catch (inner) { if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner; } } let mode = 0o600; try { mode = (await fs.stat(target)).mode & 0o777; await fs.copyFile(target, `${target}.codeman-bak`); await fs.chmod(`${target}.codeman-bak`, 0o600); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } if (secret) mode &= ~0o077; await fs.mkdir(dirname(target), { recursive: true }); const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`; try { await fs.writeFile(tmp, text, { mode }); // writeFile's mode is masked by the umask; the mode we computed is the one we mean. await fs.chmod(tmp, mode); await fs.rename(tmp, target); } catch (err) { await fs.unlink(tmp).catch(() => undefined); throw err; } } export interface McpSyncOptions { /** false = report what would change without writing. */ apply: boolean; home?: string; /** * Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs * Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden * (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own * env can never aim a write outside that home. */ env?: Record; } /** * The config file a target means, honouring its relocation env var. `skip` is set when the var * holds something that cannot be located safely (a relative path resolves against the CLI's * working directory, which differs per session), so the target is neither read nor written. */ function resolveFile( t: McpSyncTarget, home: string, env: Record ): { file: string; skip?: string } { const rel = t.relocation; const dir = rel ? env[rel.envVar] : undefined; // Every CLI declared today treats an empty value as unset (`||` / a non-empty filter). if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) }; if (!isAbsolute(dir)) { return { file: `$${rel.envVar}/${rel.path}`, skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`, }; } return { file: join(dir, rel.path) }; } let applying = false; /** * Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority * order: when two CLIs define a name differently, the first one's definition is the one copied). * Throws `McpSyncBusyError` if another apply is running. */ export async function syncMcpServers( targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[] = [] ): Promise { if (opts.apply) { if (applying) throw new McpSyncBusyError(); applying = true; } try { return await run(targets, opts, unsupported); } finally { if (opts.apply) applying = false; } } async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise { const home = opts.home ?? homedir(); const env = opts.env ?? (opts.home === undefined ? process.env : {}); const seen = new Set(); const live = targets .map((t) => ({ t, ...resolveFile(t, home, env) })) .filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true))); const state = live.map(({ t, file, skip }) => { const res: McpSyncTargetResult = { id: t.id, label: t.label, file, status: skip ? 'skipped' : 'ok', ...(skip ? { error: skip } : {}), servers: [], added: [], skipped: [], }; return { t, file, res, servers: dict(), names: new Set() }; }); for (const s of state) { if (s.res.status !== 'ok') continue; try { if (!s.t.installed && !(await exists(s.file))) { s.res.status = 'absent'; continue; } const parsed = parseConfig(s.t.format, await readText(s.file)); s.servers = parsed.servers; s.names = parsed.names; s.res.servers = [...parsed.names]; } catch (err) { s.res.status = 'unreadable'; s.res.error = describeMcpSyncError(err); } } // Union, first enabled definition wins; a later, different definition of the same name is a conflict. const union = dict(); const conflicts = new Set(); const switchedOff = new Set(); for (const s of state) { if (s.res.status !== 'ok') continue; for (const name of Object.keys(s.servers)) { const def = s.servers[name]; if (def.disabled) { switchedOff.add(name); continue; } if (!(name in union)) union[name] = def; else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name); } } const disabled = [...switchedOff].filter((n) => !(n in union)).sort(); for (const s of state) { if (s.res.status !== 'ok') continue; const add = dict(); for (const name of Object.keys(union)) { if (s.names.has(name)) continue; if (canExpress(s.t.format, union[name])) add[name] = union[name]; else s.res.skipped.push(name); } s.res.added = Object.keys(add); if (!opts.apply || s.res.added.length === 0) continue; try { // Re-read right before writing: claude rewrites ~/.claude.json constantly. const fresh = await readText(s.file); const out = addServers(s.t.format, fresh, add); const current = parseConfig(s.t.format, fresh); const written = Object.keys(add).filter((n) => !current.names.has(n)); if (written.length === 0) { s.res.added = []; continue; } const subset = dict(); for (const n of written) subset[n] = add[n]; await writeAtomic(s.file, out, carriesSecrets(subset)); s.res.added = written; } catch (err) { s.res.status = 'failed'; s.res.error = describeMcpSyncError(err); s.res.added = []; } } return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort(), disabled, unsupported, }; }