From e6b258fc44ecfd0f099e163b1bbb0486341c4a1f Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:01:12 +0800 Subject: [PATCH 1/5] feat(mcp): sync MCP servers across enabled CLIs 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 --- .changeset/mcp-sync-clis.md | 5 + docs/wiki/HTTP-API.md | 2 + src/config/cli-registry/schema.ts | 13 + src/config/cli-registry/stock.ts | 4 + src/config/cli-registry/types.ts | 7 + src/mcp-sync.ts | 555 ++++++++++++++++++++++++++++++ src/web/public/index.html | 17 + src/web/public/settings-ui.js | 29 ++ src/web/routes/index.ts | 1 + src/web/routes/mcp-sync-routes.ts | 45 +++ src/web/server.ts | 2 + test/mcp-sync.test.ts | 163 +++++++++ 12 files changed, 843 insertions(+) create mode 100644 .changeset/mcp-sync-clis.md create mode 100644 src/mcp-sync.ts create mode 100644 src/web/routes/mcp-sync-routes.ts create mode 100644 test/mcp-sync.test.ts diff --git a/.changeset/mcp-sync-clis.md b/.changeset/mcp-sync-clis.md new file mode 100644 index 00000000..20217ffe --- /dev/null +++ b/.changeset/mcp-sync-clis.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +MCP server sync between CLIs: Settings → Agents & CLIs → "Sync MCP servers across CLIs" (and `GET`/`POST /api/mcp-sync`) copies each enabled CLI's MCP servers into the others' config files (Claude, Gemini, Codex, OpenCode). It only adds missing servers, never edits or removes one, keeps a `.codeman-bak` of every file it changes, and reports same-name conflicts instead of overwriting. diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index e31c0dc8..f321780a 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -144,6 +144,8 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions curl -s "$API/api/sessions/unified" | jq # live + historical, deduped curl -s "$API/api/subagents" | jq # background agents curl -s "$API/api/search?q=deploy" | jq # cross-session search +curl -s "$API/api/mcp-sync" | jq # preview MCP server sync across enabled CLIs (names only) +curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove # with ID set to a session id: curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek) diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index b72c58fd..eca0d8ca 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -377,6 +377,19 @@ const capabilitiesSchema = z privilegedEnvKeys: z.array(envName).max(8), gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()), maxFrameBytes: z.number().int().positive().optional(), + mcpConfig: z + .object({ + // Home-relative, no traversal: sync writes to this path. + path: z + .string() + .min(1) + .max(100) + .regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/) + .refine((v) => !v.split('/').includes('..'), 'must not contain ..'), + format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json']), + }) + .strict() + .optional(), customModelInjection: z.discriminatedUnion('kind', [ z .object({ diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 465da26a..fc2dc2bb 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -306,6 +306,7 @@ const CLAUDE: CliEntry = { 'CLAUDE_CONFIG_DIR', ], gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, + mcpConfig: { path: '.claude.json', format: 'claude-json' }, // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real // llama.cpp server. Claude reads these at process start only, so switching requires a // respawn, never a live hot-swap. @@ -486,6 +487,7 @@ const OPENCODE: CliEntry = { ...agentDefaults(), altScreen: 'strip-mux-only', echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, + mcpConfig: { path: '.config/opencode/opencode.json', format: 'opencode-json' }, // Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's // own `env.configContentVar` already declares — the builder in custom-model-injection.ts // must merge into whatever opencode config Codeman would otherwise send, not clobber it. @@ -620,6 +622,7 @@ const CODEX: CliEntry = { // `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a // regression; `schema.ts` now rejects a name that is not a declared param. privilegedParams: [{ param: 'bypassApprovals', clampTo: false }], + mcpConfig: { path: '.codex/config.toml', format: 'codex-toml' }, // Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME // so the user's real ~/.codex/config.toml is never touched. customModelInjection: { @@ -721,6 +724,7 @@ const GEMINI: CliEntry = { // MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who // sends no geminiConfig at all would still get yolo for free. privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }], + mcpConfig: { path: '.gemini/settings.json', format: 'gemini-json' }, // Web-researched, unverified — needs a restart to pick up (CLI reads these at process // start). Confirm the exact model-override env var name against the installed // gemini-cli version before shipping. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index c39007cd..6d3bc393 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -511,6 +511,13 @@ export interface CliCapabilities { gates: Record; /** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */ maxFrameBytes?: number; + /** + * Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`). + * `path` is relative to the home directory. `format` names the file dialect the sync + * adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is + * skipped by sync rather than guessed at. + */ + mcpConfig?: { path: string; format: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' }; /** * How this CLI is pointed at a user-supplied custom OpenAI-compatible * endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts new file mode 100644 index 00000000..08216ae4 --- /dev/null +++ b/src/mcp-sync.ts @@ -0,0 +1,555 @@ +/** + * @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 enabled CLI's list into one neutral + * shape, and adds any server a CLI is missing from the others. + * + * Deliberately conservative: + * - ADDITIVE only. A server already present under a name is never rewritten and nothing is + * ever removed, so a sync cannot lose a hand-tuned entry. Same name with a different + * definition is reported as a conflict and left alone. + * - A file that does not parse (e.g. opencode JSONC with comments) is never written. + * - Only the MCP table is touched; every other key in the file is preserved. JSON files are + * re-read immediately before the write, and written via tmp+rename with the old file kept + * as `.codeman-bak`. + * - Servers a dialect cannot express (SSE for codex) are skipped and reported. + * + * The result types never carry env values or headers: those commonly hold secrets and the + * result is returned over HTTP. + * + * @module mcp-sync + */ + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json'; + +export interface McpServer { + transport: 'stdio' | 'http' | 'sse'; + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + headers?: Record; +} + +export type McpServerMap = Record; + +export interface McpSyncTarget { + id: string; + label: string; + path: string; + format: McpFormat; +} + +export interface McpSyncTargetResult { + id: string; + label: string; + file: string; + status: 'ok' | 'unreadable'; + error?: string; + servers: string[]; + /** Servers added (apply) or that would be added (plan). */ + added: string[]; + /** Missing servers this dialect cannot express. */ + skipped: string[]; +} + +export interface McpSyncResult { + applied: boolean; + targets: McpSyncTargetResult[]; + /** Names defined differently by different CLIs; left untouched. */ + conflicts: string[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); + +function strMap(v: unknown): Record | undefined { + if (!isRecord(v)) return undefined; + const out: Record = {}; + for (const [k, val] of Object.entries(v)) if (typeof val === 'string') out[k] = val; + 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; + return out; +} + +/** 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]); +} + +// --------------------------------------------------------------------------- +// 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; + if (typeof raw.httpUrl === 'string') + return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) }); + if (typeof raw.url === 'string') return clean({ transport: '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 { [s.transport === 'http' ? 'httpUrl' : 'url']: s.url, ...(s.headers ? { headers: s.headers } : {}) }; +} + +function fromOpencode(raw: unknown): McpServer | null { + if (!isRecord(raw)) return null; + if (raw.type === 'remote' && typeof raw.url === 'string') { + return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers) }); + } + 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) }); + } + 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<'claude-json' | 'gemini-json' | 'opencode-json', JsonDialect> = { + 'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude }, + 'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini }, + 'opencode-json': { + key: 'mcp', + from: fromOpencode, + to: toOpencode, + seed: { $schema: 'https://opencode.ai/config.json' }, + }, +}; + +// --------------------------------------------------------------------------- +// Codex TOML (the `[mcp_servers.*]` tables only) +// --------------------------------------------------------------------------- + +type TomlValue = string | string[] | Record | boolean | number | null; + +/** Parse one TOML value starting at `i`; returns the value and the index after it. */ +function parseTomlValue(src: string, start: number): [TomlValue, number] { + let i = start; + const ws = () => { + while (i < src.length && /[ \t\r\n]/.test(src[i])) i++; + }; + ws(); + const c = src[i]; + if (c === '"') { + if (src.startsWith('"""', i)) { + const end = src.indexOf('"""', i + 3); + return [src.slice(i + 3, end < 0 ? src.length : end).replace(/^\n/, ''), end < 0 ? src.length : end + 3]; + } + let out = ''; + i++; + while (i < src.length && src[i] !== '"') { + if (src[i] === '\\') { + const n = src[i + 1]; + const map: Record = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\' }; + if (n === 'u') { + out += String.fromCodePoint(parseInt(src.slice(i + 2, i + 6), 16)); + i += 6; + continue; + } + out += map[n] ?? n; + i += 2; + } else out += src[i++]; + } + return [out, i + 1]; + } + if (c === "'") { + const end = src.indexOf("'", i + 1); + return [src.slice(i + 1, end < 0 ? src.length : end), end < 0 ? src.length : end + 1]; + } + if (c === '[') { + const arr: string[] = []; + i++; + for (;;) { + ws(); + if (src[i] === '#') { + while (i < src.length && src[i] !== '\n') i++; + continue; + } + if (src[i] === ']' || i >= src.length) return [arr, i + 1]; + if (src[i] === ',') { + i++; + continue; + } + const [v, next] = parseTomlValue(src, i); + if (typeof v === 'string') arr.push(v); + i = next; + } + } + if (c === '{') { + const obj: Record = {}; + i++; + for (;;) { + ws(); + if (src[i] === '}' || i >= src.length) return [obj, i + 1]; + if (src[i] === ',') { + i++; + continue; + } + const [k, afterKey] = parseTomlKey(src, i); + i = afterKey; + ws(); + if (src[i] === '=') i++; + const [v, next] = parseTomlValue(src, i); + if (typeof v === 'string') obj[k] = v; + i = next; + } + } + const m = /^[^\s,\]}#]+/.exec(src.slice(i)); + const tok = m ? m[0] : ''; + const after = i + tok.length; + if (tok === 'true') return [true, after]; + if (tok === 'false') return [false, after]; + const num = Number(tok); + return [Number.isNaN(num) ? null : num, Math.max(after, i + 1)]; +} + +function parseTomlKey(src: string, start: number): [string, number] { + let i = start; + while (src[i] === ' ' || src[i] === '\t') i++; + if (src[i] === '"' || src[i] === "'") { + const [v, next] = parseTomlValue(src, i); + return [String(v), next]; + } + const m = /^[A-Za-z0-9_-]+/.exec(src.slice(i)); + const key = m ? m[0] : ''; + return [key, i + Math.max(key.length, 1)]; +} + +/** Split a table header like `mcp_servers."my.srv".env` into dotted key parts. */ +function parseTomlHeader(line: string): string[] | null { + const m = /^\[([^\]\[].*)\]\s*(#.*)?$/.exec(line.trim()); + if (!m) return null; + const body = m[1]; + const parts: string[] = []; + let i = 0; + while (i < body.length) { + while (body[i] === ' ') i++; + const [k, next] = parseTomlKey(body, i); + if (!k) return null; + parts.push(k); + i = next; + while (body[i] === ' ') i++; + if (body[i] === '.') i++; + else if (i < body.length) return null; + } + return parts; +} + +/** Returns each `mcp_servers.` table as `{ ...keys, env?: {...}, http_headers?: {...} }`. */ +function parseCodexTables(text: string): Record> { + const out: Record> = {}; + let current: Record | null = null; + let sub: string | null = null; + const lines = text.split(/\r?\n/); + for (let n = 0; n < lines.length; n++) { + const line = lines[n]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (trimmed.startsWith('[')) { + current = null; + sub = null; + if (trimmed.startsWith('[[')) continue; + const parts = parseTomlHeader(trimmed); + if (parts && parts[0] === 'mcp_servers' && (parts.length === 2 || parts.length === 3)) { + current = out[parts[1]] ??= {}; + sub = parts.length === 3 ? parts[2] : null; + if (sub && !isRecord(current[sub])) current[sub] = {}; + } + continue; + } + if (!current) continue; + // key = value; a value may span lines (arrays), so feed the parser the remainder of the file. + const eq = line.indexOf('='); + if (eq < 0) continue; + const offset = lines.slice(0, n).reduce((a, l) => a + l.length + 1, 0); + const [key, afterKey] = parseTomlKey(text, offset + (line.length - line.trimStart().length)); + const valueStart = text.indexOf('=', afterKey) + 1; + const [value, end] = parseTomlValue(text, valueStart); + // Skip the lines the value consumed. + const consumed = text.slice(valueStart, end).split('\n').length - 1; + n += consumed; + if (sub) (current[sub] as Record)[key] = typeof value === 'string' ? value : ''; + else current[key] = value; + } + return out; +} + +function fromCodex(t: Record): McpServer | null { + if (typeof t.url === 'string') { + const headers = strMap(t.http_headers); + return clean({ transport: 'http', url: t.url, headers }); + } + if (typeof t.command === 'string') { + return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env) }); + } + 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 +// --------------------------------------------------------------------------- + +/** Parse a config file's text (null = file absent) into servers. Throws if it cannot be read safely. */ +export function parseServers(format: McpFormat, text: string | null): McpServerMap { + const out: McpServerMap = {}; + if (text === null || !text.trim()) return out; + if (format === 'codex-toml') { + for (const [name, table] of Object.entries(parseCodexTables(text))) { + const s = fromCodex(table); + if (s) out[name] = s; + } + return out; + } + const dialect = JSON_DIALECTS[format]; + const doc: unknown = JSON.parse(text); + if (!isRecord(doc)) throw new Error('top level is not a JSON object'); + const table = doc[dialect.key]; + if (table === undefined) return out; + if (!isRecord(table)) throw new Error(`"${dialect.key}" is not an object`); + for (const [name, raw] of Object.entries(table)) { + const s = dialect.from(raw); + if (s) out[name] = s; + } + return out; +} + +/** Whether this dialect can express the server. */ +export function canExpress(format: McpFormat, s: McpServer): boolean { + if (format === 'codex-toml') return s.transport !== 'sse'; + return true; +} + +/** Add servers to a config file's text and return the new text. Existing names are never touched. */ +export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string { + const names = Object.keys(add); + if (format === 'codex-toml') { + const base = text ?? ''; + const sep = base.length === 0 ? '' : base.endsWith('\n\n') ? '' : base.endsWith('\n') ? '\n' : '\n\n'; + return base + sep + names.map((n) => toCodexToml(n, add[n])).join('\n'); + } + 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) { + if (n in table) continue; + const entry = dialect.to(add[n]); + if (entry) table[n] = entry; + } + doc[dialect.key] = table; + return JSON.stringify(doc, null, 2) + '\n'; +} + +// --------------------------------------------------------------------------- +// 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 writeAtomic(file: string, text: string): Promise { + let mode = 0o600; + try { + mode = (await fs.stat(file)).mode & 0o777; + await fs.copyFile(file, `${file}.codeman-bak`); + await fs.chmod(`${file}.codeman-bak`, 0o600); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + await fs.mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.codeman-tmp-${process.pid}`; + await fs.writeFile(tmp, text, { mode }); + await fs.rename(tmp, file); +} + +export interface McpSyncOptions { + /** false = report what would change without writing. */ + apply: boolean; + home?: string; +} + +/** + * 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). + */ +export async function syncMcpServers(targets: McpSyncTarget[], opts: McpSyncOptions): Promise { + const home = opts.home ?? homedir(); + const seen = new Set(); + const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true))); + + const state = live.map((t) => { + const file = join(home, t.path); + const res: McpSyncTargetResult = { + id: t.id, + label: t.label, + file, + status: 'ok', + servers: [], + added: [], + skipped: [], + }; + return { t, file, res, servers: {} as McpServerMap }; + }); + + for (const s of state) { + try { + s.servers = parseServers(s.t.format, await readText(s.file)); + s.res.servers = Object.keys(s.servers); + } catch (err) { + s.res.status = 'unreadable'; + s.res.error = err instanceof Error ? err.message : String(err); + } + } + + // Union, first definition wins; a later, different definition of the same name is a conflict. + const union: McpServerMap = {}; + const conflicts = new Set(); + for (const s of state) { + if (s.res.status !== 'ok') continue; + for (const [name, def] of Object.entries(s.servers)) { + if (!(name in union)) union[name] = def; + else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name); + } + } + + for (const s of state) { + if (s.res.status !== 'ok') continue; + const add: McpServerMap = {}; + for (const [name, def] of Object.entries(union)) { + if (name in s.servers) continue; + if (canExpress(s.t.format, def)) add[name] = def; + 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 stillMissing: McpServerMap = {}; + const current = parseServers(s.t.format, fresh); + for (const [n, d] of Object.entries(add)) if (!(n in current)) stillMissing[n] = d; + if (Object.keys(stillMissing).length === 0) { + s.res.added = []; + continue; + } + await writeAtomic(s.file, addServers(s.t.format, fresh, stillMissing)); + s.res.added = Object.keys(stillMissing); + } catch (err) { + s.res.status = 'unreadable'; + s.res.error = err instanceof Error ? err.message : String(err); + s.res.added = []; + } + } + + return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort() }; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index 40d19750..3a98c358 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2474,6 +2474,23 @@ + +
+

MCP servers

server
+
+
+
+ Sync MCP servers across CLIs + Copies each enabled CLI's MCP servers into the others' config files. Only adds missing servers; never edits or removes one. The previous file is kept as .codeman-bak. +
+ + + + +
+ +
+
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 6c5208e8..167ae6b3 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1114,6 +1114,35 @@ Object.assign(CodemanApp.prototype, { this._updateCheck = null; }, + /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ + async mcpSync(apply) { + const out = this.$('mcpSyncResult'); + const show = (html) => { + if (out) { out.style.display = 'block'; out.innerHTML = html; } + }; + if (apply && !confirm('Add missing MCP servers to every enabled CLI\'s config file?')) return; + show('Working…'); + const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync'); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + show(escapeHtml(body?.error || 'MCP sync failed.')); + return; + } + const data = body.data; + const rows = data.targets.map((t) => { + if (t.status !== 'ok') return `
  • ${escapeHtml(t.label)}: not touched (${escapeHtml(t.error || 'unreadable')})
  • `; + const verb = data.applied ? 'added' : 'would add'; + const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date']; + if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`); + return `
  • ${escapeHtml(t.label)} (${t.servers.length} servers): ${parts.join('; ')}
  • `; + }); + const conflicts = data.conflicts.length + ? `

    Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}

    ` + : ''; + show(`
      ${rows.join('')}
    ${conflicts}`); + }, + _setUpdateResult(html) { const el = this.$('updateResult'); if (el) { el.style.display = 'block'; el.innerHTML = html; } diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 2a7e41b5..583fb112 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -28,6 +28,7 @@ export { registerWsRoutes } from './ws-routes.js'; export { registerVoiceRoutes } from './voice-routes.js'; export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js'; export { registerTabLayoutRoutes } from './tab-layout-routes.js'; +export { registerMcpSyncRoutes } from './mcp-sync-routes.js'; export { registerCustomModelRoutes, refreshAllCustomModelHosts, diff --git a/src/web/routes/mcp-sync-routes.ts b/src/web/routes/mcp-sync-routes.ts new file mode 100644 index 00000000..2f53c839 --- /dev/null +++ b/src/web/routes/mcp-sync-routes.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview MCP server sync (src/mcp-sync.ts). + * + * GET /api/mcp-sync — dry run: per enabled CLI, which servers it has and which it would gain. + * POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file. + * + * Writes files in the SERVER user's home, so in multi-user mode it is admin only. Responses + * carry server names only, never env values or headers. + */ + +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { isAdmin } from '../route-helpers.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { enabledClis } from '../../config/cli-registry/registry.js'; +import { syncMcpServers, type McpSyncResult, type McpSyncTarget } from '../../mcp-sync.js'; + +/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */ +export function mcpSyncTargets(): McpSyncTarget[] { + return enabledClis() + .filter((e) => e.capabilities.mcpConfig) + .sort((a, b) => a.order - b.order) + .map((e) => ({ id: e.id, label: e.label, ...e.capabilities.mcpConfig! })); +} + +function gate(req: FastifyRequest): ApiResponse | null { + if (isMultiUserMode() && !isAdmin(req)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + return null; +} + +export function registerMcpSyncRoutes(app: FastifyInstance): void { + const run = async (req: FastifyRequest, apply: boolean): Promise> => { + const denied = gate(req); + if (denied) return denied; + try { + return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }) }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }; + app.get('/api/mcp-sync', (req) => run(req, false)); + app.post('/api/mcp-sync', (req) => run(req, true)); +} diff --git a/src/web/server.ts b/src/web/server.ts index d70577e6..31034563 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -197,6 +197,7 @@ import { registerVoiceRoutes, registerWebviewRoutes, registerTabLayoutRoutes, + registerMcpSyncRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, readCustomModelEndpointsEnabled, @@ -1130,6 +1131,7 @@ export class WebServer extends EventEmitter { registerOrchestratorRoutes(this.app, ctx); registerWebviewRoutes(this.app, ctx, this.basePath); registerTabLayoutRoutes(this.app, ctx); + registerMcpSyncRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); diff --git a/test/mcp-sync.test.ts b/test/mcp-sync.test.ts new file mode 100644 index 00000000..bcc898ae --- /dev/null +++ b/test/mcp-sync.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment node +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseServers, addServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js'; + +const TARGETS: McpSyncTarget[] = [ + { id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json' }, + { id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json' }, + { id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml' }, + { id: 'opencode', label: 'OpenCode', path: '.config/opencode/opencode.json', format: 'opencode-json' }, +]; + +let home: string; +const put = (rel: string, text: string) => { + const file = join(home, rel); + mkdirSync(join(file, '..'), { recursive: true }); + writeFileSync(file, text); +}; +const get = (rel: string) => readFileSync(join(home, rel), 'utf8'); + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mcp-sync-')); +}); +afterEach(() => rmSync(home, { recursive: true, force: true })); + +describe('dialect parsing', () => { + it('reads codex TOML tables, inline tables and multi-line arrays', () => { + const servers = parseServers( + 'codex-toml', + [ + 'model = "gpt-5"', + '', + '[mcp_servers.fs]', + 'command = "npx"', + 'args = [', + ' "-y", # comment', + ' "@mcp/fs",', + ']', + 'env = { TOKEN = "abc" }', + '', + '[mcp_servers."a.b".env]', + 'K = "v"', + '', + '[mcp_servers."a.b"]', + 'command = "x"', + '', + '[mcp_servers.web]', + 'url = "https://x.test/mcp"', + '[mcp_servers.web.http_headers]', + 'Authorization = "Bearer t"', + ].join('\n') + ); + expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { TOKEN: 'abc' } }); + expect(servers['a.b']).toEqual({ transport: 'stdio', command: 'x', env: { K: 'v' } }); + expect(servers.web).toEqual({ + transport: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer t' }, + }); + }); + + it('reads gemini url (sse) vs httpUrl (http) and opencode local/remote', () => { + const g = parseServers( + 'gemini-json', + JSON.stringify({ + mcpServers: { a: { url: 'https://a' }, b: { httpUrl: 'https://b' }, c: { command: 'c', args: ['1'] } }, + }) + ); + expect(g.a.transport).toBe('sse'); + expect(g.b.transport).toBe('http'); + expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] }); + const o = parseServers( + 'opencode-json', + JSON.stringify({ + mcp: { + l: { type: 'local', command: ['npx', '-y', 'x'], environment: { A: '1' } }, + r: { type: 'remote', url: 'https://r' }, + }, + }) + ); + expect(o.l).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'], env: { A: '1' } }); + expect(o.r).toEqual({ transport: 'http', url: 'https://r' }); + }); + + it('throws on unparseable JSON so the file is never written', () => { + expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow(); + }); +}); + +describe('addServers', () => { + it('preserves other keys and existing servers, appends codex tables without touching the rest', () => { + const out = JSON.parse( + addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), { + keep: { transport: 'stdio', command: 'OVERWRITE' }, + n: { transport: 'stdio', command: 'n' }, + }) + ); + expect(out.theme).toBe('dark'); + expect(out.mcpServers.keep).toEqual({ command: 'k' }); + expect(out.mcpServers.n.command).toBe('n'); + + const toml = addServers('codex-toml', 'model = "x"\n', { + 'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } }, + }); + expect(toml.startsWith('model = "x"\n')).toBe(true); + expect(parseServers('codex-toml', toml)['we ird']).toEqual({ + transport: 'stdio', + command: 'c', + args: ['a"b'], + env: { K: 'v' }, + }); + }); +}); + +describe('syncMcpServers', () => { + const claudeFile = JSON.stringify({ + numStartups: 3, + mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } }, + }); + + it('previews without writing and never leaks env values', async () => { + put('.claude.json', claudeFile); + const r = await syncMcpServers(TARGETS, { apply: false, home }); + expect(r.applied).toBe(false); + expect(r.targets.find((t) => t.id === 'gemini')!.added).toEqual(['fs']); + expect(existsSync(join(home, '.gemini/settings.json'))).toBe(false); + expect(JSON.stringify(r)).not.toContain('s3cret'); + }); + + it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => { + put('.claude.json', claudeFile); + put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n'); + const r = await syncMcpServers(TARGETS, { apply: true, home }); + expect(r.targets.find((t) => t.id === 'claude')!.added).toEqual(['web']); + expect(r.targets.find((t) => t.id === 'codex')!.added).toEqual(['fs']); + expect(JSON.parse(get('.claude.json')).numStartups).toBe(3); + expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']); + expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']); + expect(get('.codex/config.toml')).toContain('model = "gpt-5"'); + expect(existsSync(join(home, '.claude.json.codeman-bak'))).toBe(true); + + const again = await syncMcpServers(TARGETS, { apply: true, home }); + expect(again.targets.every((t) => t.added.length === 0)).toBe(true); + }); + + it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => { + put( + '.claude.json', + JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } }) + ); + put('.gemini/settings.json', JSON.stringify({ mcpServers: { x: { command: 'two' } } })); + const broken = '{ // jsonc\n "mcp": {} }'; + put('.config/opencode/opencode.json', broken); + const r = await syncMcpServers(TARGETS, { apply: true, home }); + expect(r.conflicts).toEqual(['x']); + expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two'); + expect(r.targets.find((t) => t.id === 'codex')!.skipped).toEqual(['sse']); + expect(r.targets.find((t) => t.id === 'opencode')!.status).toBe('unreadable'); + expect(get('.config/opencode/opencode.json')).toBe(broken); + }); +}); From 41a10b159e7eb3a33af930d344a2c7f089e7a8e8 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:11:13 +0800 Subject: [PATCH 2/5] feat(mcp): add Antigravity, fix Gemini http/sse shape, report unsupported CLIs Formats verified against real agy/gemini/codex mcp add output. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mcp-sync-clis.md | 2 +- src/config/cli-registry/schema.ts | 2 +- src/config/cli-registry/stock.ts | 1 + src/config/cli-registry/types.ts | 5 +++- src/mcp-sync.ts | 44 ++++++++++++++++++++++----- src/web/public/settings-ui.js | 5 +++- src/web/routes/mcp-sync-routes.ts | 9 +++++- test/mcp-sync.test.ts | 50 +++++++++++++++++++++++++++++++ 8 files changed, 106 insertions(+), 12 deletions(-) diff --git a/.changeset/mcp-sync-clis.md b/.changeset/mcp-sync-clis.md index 20217ffe..e93ca423 100644 --- a/.changeset/mcp-sync-clis.md +++ b/.changeset/mcp-sync-clis.md @@ -2,4 +2,4 @@ "aicodeman": minor --- -MCP server sync between CLIs: Settings → Agents & CLIs → "Sync MCP servers across CLIs" (and `GET`/`POST /api/mcp-sync`) copies each enabled CLI's MCP servers into the others' config files (Claude, Gemini, Codex, OpenCode). It only adds missing servers, never edits or removes one, keeps a `.codeman-bak` of every file it changes, and reports same-name conflicts instead of overwriting. +MCP server sync between CLIs: Settings → Agents & CLIs → "Sync MCP servers across CLIs" (and `GET`/`POST /api/mcp-sync`) copies each enabled CLI's MCP servers into the others' config files (Claude, Gemini, Codex, OpenCode, Antigravity; enabled CLIs without a known MCP config are listed as unsupported). It only adds missing servers, never edits or removes one, keeps a `.codeman-bak` of every file it changes, and reports same-name conflicts instead of overwriting. diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index eca0d8ca..1017a9b6 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -386,7 +386,7 @@ const capabilitiesSchema = z .max(100) .regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/) .refine((v) => !v.split('/').includes('..'), 'must not contain ..'), - format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json']), + format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json', 'antigravity-json']), }) .strict() .optional(), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index fc2dc2bb..47832e01 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -804,6 +804,7 @@ const ANTIGRAVITY: CliEntry = { // Like codex: an ABSENT config already defaults safe (no bypass flag), so only a // SENT config needs the flag forced off — nothing is materialized. privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }], + mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' }, // No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only // custom-endpoint setting and explicitly say it "cannot currently" become the core // reasoning model. Toolbar entry stays disabled for this mode. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 6d3bc393..9447bae5 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -517,7 +517,10 @@ export interface CliCapabilities { * adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is * skipped by sync rather than guessed at. */ - mcpConfig?: { path: string; format: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' }; + mcpConfig?: { + path: string; + format: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; + }; /** * How this CLI is pointed at a user-supplied custom OpenAI-compatible * endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index 08216ae4..523fd1ea 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -25,7 +25,7 @@ import { promises as fs } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json'; +export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; export interface McpServer { transport: 'stdio' | 'http' | 'sse'; @@ -64,6 +64,8 @@ export interface McpSyncResult { targets: McpSyncTargetResult[]; /** Names defined differently by different CLIs; left untouched. */ conflicts: string[]; + /** Enabled agent CLIs with no known MCP config file, so sync cannot touch them. */ + unsupported: string[]; } // --------------------------------------------------------------------------- @@ -124,9 +126,13 @@ function toClaude(s: McpServer): Record { 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: 'sse', url: raw.url, 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', @@ -148,7 +154,26 @@ function toGemini(s: McpServer): Record { ...(s.cwd ? { cwd: s.cwd } : {}), }; } - return { [s.transport === 'http' ? 'httpUrl' : 'url']: s.url, ...(s.headers ? { headers: s.headers } : {}) }; + 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; + if (typeof raw.serverUrl === 'string') + return clean({ transport: 'http', url: raw.serverUrl, 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 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 { @@ -185,9 +210,10 @@ interface JsonDialect { seed?: Record; } -const JSON_DIALECTS: Record<'claude-json' | 'gemini-json' | 'opencode-json', JsonDialect> = { +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, @@ -418,7 +444,7 @@ export function parseServers(format: McpFormat, text: string | null): McpServerM /** Whether this dialect can express the server. */ export function canExpress(format: McpFormat, s: McpServer): boolean { - if (format === 'codex-toml') return s.transport !== 'sse'; + if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse'; return true; } @@ -482,7 +508,11 @@ export interface McpSyncOptions { * 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). */ -export async function syncMcpServers(targets: McpSyncTarget[], opts: McpSyncOptions): Promise { +export async function syncMcpServers( + targets: McpSyncTarget[], + opts: McpSyncOptions, + unsupported: string[] = [] +): Promise { const home = opts.home ?? homedir(); const seen = new Set(); const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true))); @@ -551,5 +581,5 @@ export async function syncMcpServers(targets: McpSyncTarget[], opts: McpSyncOpti } } - return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort() }; + return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort(), unsupported }; } diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 167ae6b3..267cd19d 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1140,7 +1140,10 @@ Object.assign(CodemanApp.prototype, { const conflicts = data.conflicts.length ? `

    Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}

    ` : ''; - show(`
      ${rows.join('')}
    ${conflicts}`); + const unsupported = data.unsupported?.length + ? `

    No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}

    ` + : ''; + show(`
      ${rows.join('')}
    ${conflicts}${unsupported}`); }, _setUpdateResult(html) { diff --git a/src/web/routes/mcp-sync-routes.ts b/src/web/routes/mcp-sync-routes.ts index 2f53c839..1abcd128 100644 --- a/src/web/routes/mcp-sync-routes.ts +++ b/src/web/routes/mcp-sync-routes.ts @@ -30,12 +30,19 @@ function gate(req: FastifyRequest): ApiResponse | null { return null; } +/** Enabled agent CLIs with no known MCP config file (sync cannot touch them). */ +export function mcpUnsupportedLabels(): string[] { + return enabledClis() + .filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig) + .map((e) => e.label); +} + export function registerMcpSyncRoutes(app: FastifyInstance): void { const run = async (req: FastifyRequest, apply: boolean): Promise> => { const denied = gate(req); if (denied) return denied; try { - return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }) }; + return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }, mcpUnsupportedLabels()) }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } diff --git a/test/mcp-sync.test.ts b/test/mcp-sync.test.ts index bcc898ae..dc9d494c 100644 --- a/test/mcp-sync.test.ts +++ b/test/mcp-sync.test.ts @@ -9,6 +9,7 @@ const TARGETS: McpSyncTarget[] = [ { id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json' }, { id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json' }, { id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml' }, + { id: 'antigravity', label: 'Antigravity', path: '.gemini/config/mcp_config.json', format: 'antigravity-json' }, { id: 'opencode', label: 'OpenCode', path: '.config/opencode/opencode.json', format: 'opencode-json' }, ]; @@ -89,6 +90,50 @@ describe('dialect parsing', () => { }); }); +describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => { + it('reads and writes the antigravity dialect', () => { + const real = JSON.stringify({ + mcpServers: { + fs: { args: ['-y', '@mcp/fs'], command: 'npx', disabled: false, env: { K: 'v' } }, + web: { disabled: false, headers: { Authorization: 'Bearer T' }, serverUrl: 'https://x.test/mcp' }, + }, + }); + const servers = parseServers('antigravity-json', real); + expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } }); + expect(servers.web).toEqual({ + transport: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer T' }, + }); + const out = JSON.parse( + addServers('antigravity-json', null, { ...servers, s: { transport: 'sse', url: 'https://s' } }) + ); + expect(out.mcpServers.web.serverUrl).toBe('https://x.test/mcp'); + expect(out.mcpServers.fs.disabled).toBe(false); + expect(out.mcpServers.s).toBeUndefined(); + }); + + it('writes gemini http/sse as url + type, as `gemini mcp add` does', () => { + const out = JSON.parse( + addServers('gemini-json', null, { + web: { transport: 'http', url: 'https://x.test/mcp', headers: { A: 'b' } }, + s: { transport: 'sse', url: 'https://x.test/sse' }, + }) + ); + expect(out.mcpServers.web).toEqual({ url: 'https://x.test/mcp', type: 'http', headers: { A: 'b' } }); + expect(out.mcpServers.s).toEqual({ url: 'https://x.test/sse', type: 'sse' }); + expect(parseServers('gemini-json', JSON.stringify(out)).web.transport).toBe('http'); + }); + + it('reads codex output as written by `codex mcp add`', () => { + const real = + '[mcp_servers.fs]\ncommand = "npx"\nargs = ["-y", "@mcp/fs"]\n\n[mcp_servers.fs.env]\nK = "v"\n\n[mcp_servers.web]\nurl = "https://x.test/mcp"\n'; + const servers = parseServers('codex-toml', real); + expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } }); + expect(servers.web).toEqual({ transport: 'http', url: 'https://x.test/mcp' }); + }); +}); + describe('addServers', () => { it('preserves other keys and existing servers, appends codex tables without touching the rest', () => { const out = JSON.parse( @@ -120,6 +165,11 @@ describe('syncMcpServers', () => { mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } }, }); + it('passes the unsupported list through to the result', async () => { + const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']); + expect(r.unsupported).toEqual(['Pi']); + }); + it('previews without writing and never leaks env values', async () => { put('.claude.json', claudeFile); const r = await syncMcpServers(TARGETS, { apply: false, home }); From af032fc81a9086442d8d973f974c3acce89b30c2 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:24:23 +0800 Subject: [PATCH 3/5] fix(mcp): block __proto__ server names, fix lint; add route and registry tests Co-Authored-By: Claude Sonnet 5.5 --- src/mcp-sync.ts | 8 +- test/mcp-sync-registry.test.ts | 44 +++++++++++ test/mcp-sync.test.ts | 22 ++++++ test/routes/mcp-sync-routes.test.ts | 116 ++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 test/mcp-sync-registry.test.ts create mode 100644 test/routes/mcp-sync-routes.test.ts diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index 523fd1ea..b0b9dadb 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -74,6 +74,9 @@ export interface McpSyncResult { 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']); + function strMap(v: unknown): Record | undefined { if (!isRecord(v)) return undefined; const out: Record = {}; @@ -323,7 +326,7 @@ function parseTomlKey(src: string, start: number): [string, number] { /** Split a table header like `mcp_servers."my.srv".env` into dotted key parts. */ function parseTomlHeader(line: string): string[] | null { - const m = /^\[([^\]\[].*)\]\s*(#.*)?$/.exec(line.trim()); + const m = /^\[([^[\]].*)\]\s*(#.*)?$/.exec(line.trim()); if (!m) return null; const body = m[1]; const parts: string[] = []; @@ -357,6 +360,7 @@ function parseCodexTables(text: string): Record structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry; + +function withMcp(mcpConfig: unknown) { + const e = claude(); + (e.capabilities as Record).mcpConfig = mcpConfig; + return CliEntrySchema.safeParse(e); +} + +describe('capabilities.mcpConfig', () => { + it('is declared by exactly the CLIs whose format is verified', () => { + const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string); + expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']); + }); + + it('every stock declaration passes the schema, with a distinct file per CLI', () => { + for (const e of STOCK_CLIS) expect(CliEntrySchema.safeParse(e).success, e.id as string).toBe(true); + const paths = STOCK_CLIS.flatMap((e) => (e.capabilities.mcpConfig ? [e.capabilities.mcpConfig.path] : [])); + expect(new Set(paths).size).toBe(paths.length); + }); + + it('accepts a home-relative path with a known format', () => { + expect(withMcp({ path: '.tool/mcp.json', format: 'claude-json' }).success).toBe(true); + }); + + it.each([ + ['parent traversal', { path: '../evil.json', format: 'claude-json' }], + ['nested traversal', { path: '.a/../../evil.json', format: 'claude-json' }], + ['absolute path', { path: '/etc/cron.d/x', format: 'claude-json' }], + ['shell metacharacters', { path: '.a;rm -rf', format: 'claude-json' }], + ['unknown format', { path: '.a/mcp.json', format: 'yaml' }], + ['extra key', { path: '.a/mcp.json', format: 'claude-json', mode: 'rw' }], + ])('rejects %s', (_label, value) => { + expect(withMcp(value).success).toBe(false); + }); +}); diff --git a/test/mcp-sync.test.ts b/test/mcp-sync.test.ts index dc9d494c..da1059cd 100644 --- a/test/mcp-sync.test.ts +++ b/test/mcp-sync.test.ts @@ -134,6 +134,28 @@ describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => }); }); +describe('hostile config files', () => { + it('never lets a server name reach Object.prototype (toml and json)', () => { + const toml = parseServers( + 'codex-toml', + '[mcp_servers.__proto__]\ncommand = "x"\npolluted = "yes"\n[mcp_servers.ok]\ncommand = "y"\n' + ); + expect(Object.keys(toml)).toEqual(['ok']); + const json = parseServers( + 'claude-json', + '{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"ok":{"command":"y"}}}' + ); + expect(Object.keys(json)).toEqual(['ok']); + expect(({} as Record).polluted).toBeUndefined(); + expect(({} as Record).command).toBeUndefined(); + }); + + it('rejects a non-object server table instead of overwriting it', () => { + expect(() => parseServers('claude-json', '{"mcpServers":[]}')).toThrow(); + expect(() => parseServers('claude-json', '[]')).toThrow(); + }); +}); + describe('addServers', () => { it('preserves other keys and existing servers, appends codex tables without touching the rest', () => { const out = JSON.parse( diff --git a/test/routes/mcp-sync-routes.test.ts b/test/routes/mcp-sync-routes.test.ts new file mode 100644 index 00000000..9845d00f --- /dev/null +++ b/test/routes/mcp-sync-routes.test.ts @@ -0,0 +1,116 @@ +/** + * @fileoverview Route tests for /api/mcp-sync. Only CLIs that are ENABLED in the registry take + * part; enabled agent CLIs with no known MCP config are reported as unsupported. + * + * ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it + * creates. Port: N/A (app.inject()). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js'; +import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js'; + +const home = () => homedir(); +const write = (rel: string, text: string) => { + const f = join(home(), rel); + mkdirSync(dirname(f), { recursive: true }); + writeFileSync(f, text); +}; +const disable = (...ids: string[]) => { + const file = registryFilePath(); + mkdirSync(dirname(file), { recursive: true }); + const clis = Object.fromEntries(ids.map((id) => [id, { enabled: false }])); + writeFileSync(file, JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 }); + reloadCliRegistry(); +}; + +const CLAUDE = '.claude.json'; +const CODEX = '.codex/config.toml'; +const GEMINI = '.gemini/settings.json'; + +beforeEach(() => { + rmSync(registryFilePath(), { force: true }); + reloadCliRegistry(); + for (const d of ['.claude.json', '.codex', '.gemini', '.config']) + rmSync(join(home(), d), { recursive: true, force: true }); + write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } })); +}); +afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + rmSync(registryFilePath(), { force: true }); + reloadCliRegistry(); +}); + +describe('/api/mcp-sync', () => { + it('GET previews without writing', async () => { + const { app } = await createRouteTestHarness(registerMcpSyncRoutes); + const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data.applied).toBe(false); + expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']); + expect(existsSync(join(home(), CODEX))).toBe(false); + }); + + it('POST adds the server to every enabled CLI', async () => { + const { app } = await createRouteTestHarness(registerMcpSyncRoutes); + const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' }); + expect(res.json().data.applied).toBe(true); + expect(readFileSync(join(home(), CODEX), 'utf8')).toContain('[mcp_servers.fs]'); + expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx'); + }); + + it('never touches a CLI that is disabled in the registry', async () => { + disable('codex'); + const { app } = await createRouteTestHarness(registerMcpSyncRoutes); + const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' }); + const ids = res.json().data.targets.map((t: { id: string }) => t.id); + expect(ids).not.toContain('codex'); + expect(ids).toContain('gemini'); + expect(existsSync(join(home(), '.codex'))).toBe(false); + expect(existsSync(join(home(), GEMINI))).toBe(true); + }); + + it('lists enabled agent CLIs without MCP support, and omits disabled ones and the shell', async () => { + disable('pi'); + const { app } = await createRouteTestHarness(registerMcpSyncRoutes); + const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data; + expect(unsupported).toContain('Grok'); + expect(unsupported).not.toContain('Pi'); + expect(unsupported.some((l: string) => /shell|terminal/i.test(l))).toBe(false); + }); + + it('never returns env values or headers', async () => { + write( + CLAUDE, + JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sekrit-value' } } } }) + ); + const { app } = await createRouteTestHarness(registerMcpSyncRoutes); + const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' }); + expect(res.body).not.toContain('sekrit-value'); + }); + + it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerMcpSyncRoutes, { + authUser: { username: 'bob', role: 'user' }, + }); + for (const method of ['GET', 'POST'] as const) { + const res = await app.inject({ method, url: '/api/mcp-sync' }); + expect(res.json().success, method).toBe(false); + } + expect(existsSync(join(home(), CODEX))).toBe(false); + }); + + it('multi-user: an admin is allowed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerMcpSyncRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); + expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().success).toBe(true); + }); +}); From 7616de13de8baf1975a5413a362b9bb619988887 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:06:37 +0800 Subject: [PATCH 4/5] docs(mcp): document MCP sync in the CLI registry guide; unexport canExpress Co-Authored-By: Claude Sonnet 5.5 --- docs/cli-registry.md | 6 ++++++ src/mcp-sync.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 351d6e49..b30ad767 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -249,6 +249,12 @@ A module-level const freezes at first import, and the failure is asymmetric: a C 4. Only if it cannot install with a plain `npm install -g `: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place. 5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code. +## MCP server sync + +`capabilities.mcpConfig` (`{ path, format }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one and adds any server a CLI is missing from the others; `GET`/`POST /api/mcp-sync` and Settings → Agents & CLIs → MCP servers drive it. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`. + +Three rules the module keeps and the tests pin: it only ADDS (an existing server is never edited or removed, a same-name difference is reported as a conflict), it never writes a file it could not parse (opencode JSONC with comments), and its result carries server names only, never env values or headers. The schema restricts `path` to a home-relative path without `..`, since sync writes to it. + ## See also - [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide. diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index b0b9dadb..f168eef4 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -449,7 +449,7 @@ export function parseServers(format: McpFormat, text: string | null): McpServerM } /** Whether this dialect can express the server. */ -export function canExpress(format: McpFormat, s: McpServer): boolean { +function canExpress(format: McpFormat, s: McpServer): boolean { if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse'; return true; } From 4398dbfad0f0ec6e39b8b1dedc89bfb1e631b148 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:15:31 +0800 Subject: [PATCH 5/5] feat(mcp): make sync opt-in and address 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 --- .changeset/mcp-sync-clis.md | 2 +- docs/api-reference.md | 20 ++ docs/cli-registry.md | 4 +- docs/wiki/HTTP-API.md | 2 +- package-lock.json | 13 + package.json | 1 + src/config/cli-registry/schema.ts | 11 +- src/config/cli-registry/types.ts | 8 +- src/mcp-sync.ts | 508 +++++++++++++++------------- src/web/public/index.html | 13 +- src/web/public/settings-ui.js | 30 +- src/web/routes/mcp-sync-routes.ts | 72 ++-- src/web/schemas.ts | 7 + test/mcp-sync.test.ts | 279 +++++++++++++-- test/routes/mcp-sync-routes.test.ts | 108 +++++- 15 files changed, 772 insertions(+), 306 deletions(-) diff --git a/.changeset/mcp-sync-clis.md b/.changeset/mcp-sync-clis.md index e93ca423..7b44fae7 100644 --- a/.changeset/mcp-sync-clis.md +++ b/.changeset/mcp-sync-clis.md @@ -2,4 +2,4 @@ "aicodeman": minor --- -MCP server sync between CLIs: Settings → Agents & CLIs → "Sync MCP servers across CLIs" (and `GET`/`POST /api/mcp-sync`) copies each enabled CLI's MCP servers into the others' config files (Claude, Gemini, Codex, OpenCode, Antigravity; enabled CLIs without a known MCP config are listed as unsupported). It only adds missing servers, never edits or removes one, keeps a `.codeman-bak` of every file it changes, and reports same-name conflicts instead of overwriting. +Opt-in MCP server sync between CLIs. Turn on Settings → Agents & CLIs → MCP servers → "Enable MCP server sync" (`mcpSyncEnabled`, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on), then Preview or Sync now to copy each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. Enabled CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely. diff --git a/docs/api-reference.md b/docs/api-reference.md index c0f7235d..322b614d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -713,6 +713,26 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route | `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | | `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | +## MCP server sync + +Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`. + +| Method | Path | Body | Notes | +| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. | +| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. | + +Result (`data`): + +- `applied` — `false` for the dry run. +- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity). + - `status`: `ok`; `absent` (not installed and no config file, so not read or created); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged). +- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing. +- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`). +- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed). + +The result carries server **names** only, never `env` values or `headers`. Each changed file keeps its previous content as `.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`. + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the diff --git a/docs/cli-registry.md b/docs/cli-registry.md index b30ad767..27d3b71f 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -251,9 +251,9 @@ A module-level const freezes at first import, and the failure is asymmetric: a C ## MCP server sync -`capabilities.mcpConfig` (`{ path, format }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one and adds any server a CLI is missing from the others; `GET`/`POST /api/mcp-sync` and Settings → Agents & CLIs → MCP servers drive it. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`. +`capabilities.mcpConfig` (`{ path, format }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`. -Three rules the module keeps and the tests pin: it only ADDS (an existing server is never edited or removed, a same-name difference is reported as a conflict), it never writes a file it could not parse (opencode JSONC with comments), and its result carries server names only, never env values or headers. The schema restricts `path` to a home-relative path without `..`, since sync writes to it. +The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers. The schema restricts `path` to a home-relative path without `..`, since sync writes to it. ## See also diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index f321780a..7cd94f11 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -144,7 +144,7 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions curl -s "$API/api/sessions/unified" | jq # live + historical, deduped curl -s "$API/api/subagents" | jq # background agents curl -s "$API/api/search?q=deploy" | jq # cross-session search -curl -s "$API/api/mcp-sync" | jq # preview MCP server sync across enabled CLIs (names only) +curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on) curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove # with ID set to a session id: diff --git a/package-lock.json b/package-lock.json index 271350e4..af1a0198 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "smol-toml": "^1.9.0", "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", @@ -10114,6 +10115,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", diff --git a/package.json b/package.json index da2a097b..1398bab0 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "smol-toml": "^1.9.0", "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 1017a9b6..508a4a2d 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -15,6 +15,7 @@ import { z } from 'zod'; import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; +import type { McpConfigFormat } from './types.js'; /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ const cliId = z @@ -386,7 +387,15 @@ const capabilitiesSchema = z .max(100) .regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/) .refine((v) => !v.split('/').includes('..'), 'must not contain ..'), - format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json', 'antigravity-json']), + // Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is + // keyed by the same type, so an adapter-less format fails to compile there. + format: z.enum([ + 'claude-json', + 'gemini-json', + 'codex-toml', + 'opencode-json', + 'antigravity-json', + ] as const satisfies readonly McpConfigFormat[]), }) .strict() .optional(), diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 9447bae5..a7431535 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -90,6 +90,9 @@ export interface CliVariant { args: ArgSpec[]; } +/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */ +export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; + export interface CliLaunch { params: Record; /** @@ -517,10 +520,7 @@ export interface CliCapabilities { * adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is * skipped by sync rather than guessed at. */ - mcpConfig?: { - path: string; - format: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; - }; + mcpConfig?: { path: string; format: McpConfigFormat }; /** * How this CLI is pointed at a user-supplied custom OpenAI-compatible * endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index f168eef4..4072ff9d 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -2,18 +2,29 @@ * @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 enabled CLI's list into one neutral - * shape, and adds any server a CLI is missing from the others. + * 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 is never rewritten and nothing is - * ever removed, so a sync cannot lose a hand-tuned entry. Same name with a different - * definition is reported as a conflict and left alone. - * - A file that does not parse (e.g. opencode JSONC with comments) is never written. - * - Only the MCP table is touched; every other key in the file is preserved. JSON files are - * re-read immediately before the write, and written via tmp+rename with the old file kept - * as `.codeman-bak`. + * - 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. * * The result types never carry env values or headers: those commonly hold secrets and the * result is returned over HTTP. @@ -22,10 +33,13 @@ */ import { promises as fs } from 'node:fs'; +import { randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import { parse as parseToml } from 'smol-toml'; +import type { McpConfigFormat } from './config/cli-registry/types.js'; -export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; +export type McpFormat = McpConfigFormat; export interface McpServer { transport: 'stdio' | 'http' | 'sse'; @@ -35,6 +49,8 @@ export interface McpServer { cwd?: string; url?: string; headers?: Record; + /** Switched off in the CLI that defines it. Never propagated. */ + disabled?: boolean; } export type McpServerMap = Record; @@ -44,13 +60,20 @@ export interface McpSyncTarget { label: string; path: string; format: McpFormat; + /** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */ + installed: boolean; } export interface McpSyncTargetResult { id: string; label: string; file: string; - status: 'ok' | 'unreadable'; + /** + * `absent`: not installed and no config file, so neither read nor created. + * `unreadable`: the file exists but cannot be parsed safely, so it is not written. + * `failed`: a read or write error (the file may be unchanged). + */ + status: 'ok' | 'absent' | 'unreadable' | 'failed'; error?: string; servers: string[]; /** Servers added (apply) or that would be added (plan). */ @@ -62,12 +85,22 @@ export interface McpSyncTargetResult { export interface McpSyncResult { applied: boolean; targets: McpSyncTargetResult[]; - /** Names defined differently by different CLIs; left untouched. */ + /** Names defined differently by different CLIs; existing definitions are left untouched. */ conflicts: string[]; + /** Names left out because the only definitions are switched off in their own CLI. */ + disabled: string[]; /** Enabled agent CLIs with no known MCP config file, so sync cannot touch them. */ unsupported: string[]; } +/** 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'; + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -77,10 +110,20 @@ const isRecord = (v: unknown): v is Record => typeof v === 'obj /** 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: Record = {}; - for (const [k, val] of Object.entries(v)) if (typeof val === 'string') out[k] = val; + 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; } @@ -97,15 +140,26 @@ function clean(s: McpServer): McpServer { 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 // --------------------------------------------------------------------------- @@ -163,10 +217,17 @@ function toGemini(s: McpServer): Record { /** 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) }); + 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) }); + return clean({ + transport: 'stdio', + command: raw.command, + args: strArr(raw.args), + env: strMap(raw.env), + disabled, + }); } return null; } @@ -181,13 +242,20 @@ function toAntigravity(s: McpServer): Record | null { 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) }); + 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) }); + return clean({ + transport: 'stdio', + command: cmd[0], + args: cmd.slice(1), + env: strMap(raw.environment), + disabled, + }); } return null; } @@ -229,168 +297,13 @@ const JSON_DIALECTS: Record, JsonDialect> = { // Codex TOML (the `[mcp_servers.*]` tables only) // --------------------------------------------------------------------------- -type TomlValue = string | string[] | Record | boolean | number | null; - -/** Parse one TOML value starting at `i`; returns the value and the index after it. */ -function parseTomlValue(src: string, start: number): [TomlValue, number] { - let i = start; - const ws = () => { - while (i < src.length && /[ \t\r\n]/.test(src[i])) i++; - }; - ws(); - const c = src[i]; - if (c === '"') { - if (src.startsWith('"""', i)) { - const end = src.indexOf('"""', i + 3); - return [src.slice(i + 3, end < 0 ? src.length : end).replace(/^\n/, ''), end < 0 ? src.length : end + 3]; - } - let out = ''; - i++; - while (i < src.length && src[i] !== '"') { - if (src[i] === '\\') { - const n = src[i + 1]; - const map: Record = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\' }; - if (n === 'u') { - out += String.fromCodePoint(parseInt(src.slice(i + 2, i + 6), 16)); - i += 6; - continue; - } - out += map[n] ?? n; - i += 2; - } else out += src[i++]; - } - return [out, i + 1]; - } - if (c === "'") { - const end = src.indexOf("'", i + 1); - return [src.slice(i + 1, end < 0 ? src.length : end), end < 0 ? src.length : end + 1]; - } - if (c === '[') { - const arr: string[] = []; - i++; - for (;;) { - ws(); - if (src[i] === '#') { - while (i < src.length && src[i] !== '\n') i++; - continue; - } - if (src[i] === ']' || i >= src.length) return [arr, i + 1]; - if (src[i] === ',') { - i++; - continue; - } - const [v, next] = parseTomlValue(src, i); - if (typeof v === 'string') arr.push(v); - i = next; - } - } - if (c === '{') { - const obj: Record = {}; - i++; - for (;;) { - ws(); - if (src[i] === '}' || i >= src.length) return [obj, i + 1]; - if (src[i] === ',') { - i++; - continue; - } - const [k, afterKey] = parseTomlKey(src, i); - i = afterKey; - ws(); - if (src[i] === '=') i++; - const [v, next] = parseTomlValue(src, i); - if (typeof v === 'string') obj[k] = v; - i = next; - } - } - const m = /^[^\s,\]}#]+/.exec(src.slice(i)); - const tok = m ? m[0] : ''; - const after = i + tok.length; - if (tok === 'true') return [true, after]; - if (tok === 'false') return [false, after]; - const num = Number(tok); - return [Number.isNaN(num) ? null : num, Math.max(after, i + 1)]; -} - -function parseTomlKey(src: string, start: number): [string, number] { - let i = start; - while (src[i] === ' ' || src[i] === '\t') i++; - if (src[i] === '"' || src[i] === "'") { - const [v, next] = parseTomlValue(src, i); - return [String(v), next]; - } - const m = /^[A-Za-z0-9_-]+/.exec(src.slice(i)); - const key = m ? m[0] : ''; - return [key, i + Math.max(key.length, 1)]; -} - -/** Split a table header like `mcp_servers."my.srv".env` into dotted key parts. */ -function parseTomlHeader(line: string): string[] | null { - const m = /^\[([^[\]].*)\]\s*(#.*)?$/.exec(line.trim()); - if (!m) return null; - const body = m[1]; - const parts: string[] = []; - let i = 0; - while (i < body.length) { - while (body[i] === ' ') i++; - const [k, next] = parseTomlKey(body, i); - if (!k) return null; - parts.push(k); - i = next; - while (body[i] === ' ') i++; - if (body[i] === '.') i++; - else if (i < body.length) return null; - } - return parts; -} - -/** Returns each `mcp_servers.` table as `{ ...keys, env?: {...}, http_headers?: {...} }`. */ -function parseCodexTables(text: string): Record> { - const out: Record> = {}; - let current: Record | null = null; - let sub: string | null = null; - const lines = text.split(/\r?\n/); - for (let n = 0; n < lines.length; n++) { - const line = lines[n]; - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - if (trimmed.startsWith('[')) { - current = null; - sub = null; - if (trimmed.startsWith('[[')) continue; - const parts = parseTomlHeader(trimmed); - if (parts && parts[0] === 'mcp_servers' && (parts.length === 2 || parts.length === 3)) { - if (UNSAFE_NAMES.has(parts[1])) continue; - current = out[parts[1]] ??= {}; - sub = parts.length === 3 ? parts[2] : null; - if (sub && !isRecord(current[sub])) current[sub] = {}; - } - continue; - } - if (!current) continue; - // key = value; a value may span lines (arrays), so feed the parser the remainder of the file. - const eq = line.indexOf('='); - if (eq < 0) continue; - const offset = lines.slice(0, n).reduce((a, l) => a + l.length + 1, 0); - const [key, afterKey] = parseTomlKey(text, offset + (line.length - line.trimStart().length)); - const valueStart = text.indexOf('=', afterKey) + 1; - const [value, end] = parseTomlValue(text, valueStart); - // Skip the lines the value consumed. - const consumed = text.slice(valueStart, end).split('\n').length - 1; - n += consumed; - if (sub) (current[sub] as Record)[key] = typeof value === 'string' ? value : ''; - else current[key] = value; - } - return out; -} - -function fromCodex(t: Record): McpServer | null { +function fromCodex(t: Record): McpServer | null { + const disabled = t.enabled === false; if (typeof t.url === 'string') { - const headers = strMap(t.http_headers); - return clean({ transport: 'http', url: t.url, headers }); + 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) }); + return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled }); } return null; } @@ -422,30 +335,54 @@ function toCodexToml(name: string, s: McpServer): string { // Dialect entry points // --------------------------------------------------------------------------- -/** Parse a config file's text (null = file absent) into servers. Throws if it cannot be read safely. */ -export function parseServers(format: McpFormat, text: string | null): McpServerMap { - const out: McpServerMap = {}; - if (text === null || !text.trim()) return out; +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') { - for (const [name, table] of Object.entries(parseCodexTables(text))) { - if (UNSAFE_NAMES.has(name)) continue; - const s = fromCodex(table); - if (s) out[name] = s; - } - return out; + const doc = parseToml(text); + const table = doc.mcp_servers; + if (table === undefined) return dict(); + if (!isRecord(table)) throw new Error('"mcp_servers" is not a table'); + return table; } const dialect = JSON_DIALECTS[format]; const doc: unknown = JSON.parse(text); if (!isRecord(doc)) throw new Error('top level is not a JSON object'); const table = doc[dialect.key]; - if (table === undefined) return out; + if (table === undefined) return dict(); if (!isRecord(table)) throw new Error(`"${dialect.key}" is not an object`); - for (const [name, raw] of Object.entries(table)) { - if (UNSAFE_NAMES.has(name)) continue; - const s = dialect.from(raw); - if (s) out[name] = s; + 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 out; + 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. */ @@ -454,26 +391,58 @@ function canExpress(format: McpFormat, s: McpServer): boolean { return true; } -/** Add servers to a config file's text and return the new text. Existing names are never touched. */ +/** + * 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 names = Object.keys(add); + 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 sep = base.length === 0 ? '' : base.endsWith('\n\n') ? '' : base.endsWith('\n') ? '\n' : '\n\n'; - return base + sep + names.map((n) => toCodexToml(n, add[n])).join('\n'); + 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 Error(`refusing to write: "${n}" would be lost`); } - 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) { - if (n in table) continue; - const entry = dialect.to(add[n]); - if (entry) table[n] = entry; + const got = after.servers[n]; + if (!got || fullIdentity(got) !== fullIdentity(todo[n])) { + throw new Error(`refusing to write: "${n}" does not read back as written`); + } } - doc[dialect.key] = table; - return JSON.stringify(doc, null, 2) + '\n'; + return out; } // --------------------------------------------------------------------------- @@ -489,19 +458,56 @@ async function readText(file: string): Promise { } } -async function writeAtomic(file: string, text: string): Promise { +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 Error('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(file)).mode & 0o777; - await fs.copyFile(file, `${file}.codeman-bak`); - await fs.chmod(`${file}.codeman-bak`, 0o600); + 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; } - await fs.mkdir(dirname(file), { recursive: true }); - const tmp = `${file}.codeman-tmp-${process.pid}`; - await fs.writeFile(tmp, text, { mode }); - await fs.rename(tmp, file); + 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 { @@ -510,15 +516,30 @@ export interface McpSyncOptions { home?: string; } +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 seen = new Set(); const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true))); @@ -534,36 +555,49 @@ export async function syncMcpServers( added: [], skipped: [], }; - return { t, file, res, servers: {} as McpServerMap }; + return { t, file, res, servers: dict(), names: new Set() }; }); for (const s of state) { try { - s.servers = parseServers(s.t.format, await readText(s.file)); - s.res.servers = Object.keys(s.servers); + 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 = err instanceof Error ? err.message : String(err); } } - // Union, first definition wins; a later, different definition of the same name is a conflict. - const union: McpServerMap = {}; + // 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, def] of Object.entries(s.servers)) { + 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: McpServerMap = {}; - for (const [name, def] of Object.entries(union)) { - if (name in s.servers) continue; - if (canExpress(s.t.format, def)) add[name] = def; + 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); @@ -571,21 +605,29 @@ export async function syncMcpServers( try { // Re-read right before writing: claude rewrites ~/.claude.json constantly. const fresh = await readText(s.file); - const stillMissing: McpServerMap = {}; - const current = parseServers(s.t.format, fresh); - for (const [n, d] of Object.entries(add)) if (!(n in current)) stillMissing[n] = d; - if (Object.keys(stillMissing).length === 0) { + 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; } - await writeAtomic(s.file, addServers(s.t.format, fresh, stillMissing)); - s.res.added = Object.keys(stillMissing); + 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 = 'unreadable'; + s.res.status = 'failed'; s.res.error = err instanceof Error ? err.message : String(err); s.res.added = []; } } - return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort(), unsupported }; + return { + applied: opts.apply, + targets: state.map((s) => s.res), + conflicts: [...conflicts].sort(), + disabled, + unsupported, + }; } diff --git a/src/web/public/index.html b/src/web/public/index.html index 3a98c358..37739108 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2475,13 +2475,20 @@ -
    +

    MCP servers

    server
    -
    +
    +
    + Enable MCP server sync + Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's. +
    + +
    +