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 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-02 18:27:06 +08:00
co-authored by Claude Sonnet 5.5
parent 9240493c43
commit e6b258fc44
12 changed files with 843 additions and 0 deletions
+5
View File
@@ -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.
+2
View File
@@ -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)
+13
View File
@@ -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({
+4
View File
@@ -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.
+7
View File
@@ -511,6 +511,13 @@ export interface CliCapabilities {
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** 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
+555
View File
@@ -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 `<file>.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<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
}
export type McpServerMap = Record<string, McpServer>;
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<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
function strMap(v: unknown): Record<string, string> | undefined {
if (!isRecord(v)) return undefined;
const out: Record<string, string> = {};
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
}
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<string, string> | 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<string, string> = { 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<string, string> = {};
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.<name>` table as `{ ...keys, env?: {...}, http_headers?: {...} }`. */
function parseCodexTables(text: string): Record<string, Record<string, TomlValue>> {
const out: Record<string, Record<string, TomlValue>> = {};
let current: Record<string, TomlValue> | 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<string, string>)[key] = typeof value === 'string' ? value : '';
else current[key] = value;
}
return out;
}
function fromCodex(t: Record<string, TomlValue>): 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<string, unknown> =
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
const existing = doc[dialect.key];
const table: Record<string, unknown> = 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<string | null> {
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<void> {
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<McpSyncResult> {
const home = opts.home ?? homedir();
const seen = new Set<string>();
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<string>();
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() };
}
+17
View File
@@ -2474,6 +2474,23 @@
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync claude codex gemini opencode">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">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 <code>.codeman-bak</code>.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
<button class="btn-toolbar btn-sm btn-primary" id="mcpSyncApplyBtn" onclick="app.mcpSync(true)">Sync now</button>
</span>
</div>
<div id="mcpSyncResult" class="set-note" style="display:none"></div>
</div>
</div>
</section>
<!-- ══ Notifications ════════════════════════════════════════════ -->
+29
View File
@@ -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 `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'unreadable')})</li>`;
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 `<li><b>${escapeHtml(t.label)}</b> (${t.servers.length} servers): ${parts.join('; ')}</li>`;
});
const conflicts = data.conflicts.length
? `<p>Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}</p>`
: '';
show(`<ul>${rows.join('')}</ul>${conflicts}`);
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
+1
View File
@@ -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,
+45
View File
@@ -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<never> | 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<ApiResponse<McpSyncResult>> => {
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));
}
+2
View File
@@ -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);
+163
View File
@@ -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);
});
});