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] 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); + }); +});