Merge pull request #521 from opticon454/feat/mcp-sync

feat(mcp): sync MCP servers across enabled CLIs

# Conflicts:
#	src/config/cli-registry/schema.ts
#	src/config/cli-registry/types.ts
#	src/web/public/settings-ui.js
This commit is contained in:
Codeman maintainer
2026-10-04 23:24:26 +02:00
19 changed files with 1597 additions and 0 deletions
+22
View File
@@ -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
@@ -378,6 +379,27 @@ const capabilitiesSchema = z
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).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 ..'),
// 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(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
+5
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.
@@ -800,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.
+10
View File
@@ -93,6 +93,9 @@ export interface CliVariant {
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
/** 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<string, ParamSpec>;
/**
@@ -522,6 +525,13 @@ export interface CliCapabilities {
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
*/
newline?: NewlineSequence;
/**
* 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: 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
+633
View File
@@ -0,0 +1,633 @@
/**
* @fileoverview MCP server sync between the enabled agent CLIs.
*
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
* names the file and the dialect). This module reads every participating CLI's list into one
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
* own user config.
*
* Deliberately conservative:
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
* parsed again and every added server comes back as intended.
* - Only the MCP table is touched; every other key in the file is preserved. Files are
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.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.
*
* @module mcp-sync
*/
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 = McpConfigFormat;
export interface McpServer {
transport: 'stdio' | 'http' | 'sse';
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
/** Switched off in the CLI that defines it. Never propagated. */
disabled?: boolean;
}
export type McpServerMap = Record<string, McpServer>;
export interface McpSyncTarget {
id: string;
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;
/**
* `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). */
added: string[];
/** Missing servers this dialect cannot express. */
skipped: string[];
}
export interface McpSyncResult {
applied: boolean;
targets: McpSyncTargetResult[];
/** 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
// ---------------------------------------------------------------------------
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
function dict<T>(): Record<string, T> {
return Object.create(null) as Record<string, T>;
}
/** Own, safe keys of an untrusted table. */
function safeKeys(table: Record<string, unknown>): string[] {
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
}
function strMap(v: unknown): Record<string, string> | undefined {
if (!isRecord(v)) return undefined;
const out = dict<string>();
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
return Object.keys(out).length ? out : undefined;
}
function strArr(v: unknown): string[] | undefined {
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined;
}
/** Drop undefined/empty fields so equal servers compare equal. */
function clean(s: McpServer): McpServer {
const out: McpServer = { transport: s.transport };
if (s.command) out.command = s.command;
if (s.args?.length) out.args = s.args;
if (s.env && Object.keys(s.env).length) out.env = s.env;
if (s.cwd) out.cwd = s.cwd;
if (s.url) out.url = s.url;
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
if (s.disabled) out.disabled = true;
return out;
}
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
function fingerprint(s: McpServer): string {
const t = s.transport === 'stdio' ? 'stdio' : 'url';
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
}
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
function fullIdentity(s: McpServer): string {
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
}
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
// ---------------------------------------------------------------------------
// JSON dialects
// ---------------------------------------------------------------------------
function fromClaude(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const type = raw.type;
if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') {
return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
}
return null;
}
function toClaude(s: McpServer): Record<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;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
// today, and a bare `url` with no type is the legacy SSE form.
if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') {
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
});
}
return null;
}
function toGemini(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return {
command: s.command,
args: s.args ?? [],
...(s.env ? { env: s.env } : {}),
...(s.cwd ? { cwd: s.cwd } : {}),
};
}
return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) };
}
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
function fromAntigravity(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.disabled === true;
if (typeof raw.serverUrl === 'string')
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
disabled,
});
}
return null;
}
function toAntigravity(s: McpServer): Record<string, unknown> | null {
if (s.transport === 'sse') return null;
if (s.transport === 'stdio') {
return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false };
}
return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false };
}
function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.enabled === false;
if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
}
if (raw.type === 'local') {
const cmd = strArr(raw.command);
if (!cmd?.length) return null;
return clean({
transport: 'stdio',
command: cmd[0],
args: cmd.slice(1),
env: strMap(raw.environment),
disabled,
});
}
return null;
}
function toOpencode(s: McpServer): Record<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<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'opencode-json': {
key: 'mcp',
from: fromOpencode,
to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' },
},
};
// ---------------------------------------------------------------------------
// Codex TOML (the `[mcp_servers.*]` tables only)
// ---------------------------------------------------------------------------
function fromCodex(t: Record<string, unknown>): McpServer | null {
const disabled = t.enabled === false;
if (typeof t.url === 'string') {
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
}
if (typeof t.command === 'string') {
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
}
return null;
}
const tomlStr = (v: string): string => JSON.stringify(v);
const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k));
function toCodexToml(name: string, s: McpServer): string {
const head = `[mcp_servers.${tomlKey(name)}]`;
const lines = [head];
if (s.transport === 'stdio') {
lines.push(`command = ${tomlStr(s.command ?? '')}`);
lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`);
if (s.env) {
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`);
for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
} else {
lines.push(`url = ${tomlStr(s.url ?? '')}`);
if (s.headers) {
lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`);
for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
}
return lines.join('\n') + '\n';
}
// ---------------------------------------------------------------------------
// Dialect entry points
// ---------------------------------------------------------------------------
export interface ParsedConfig {
/** Servers this module understands. */
servers: McpServerMap;
/** Every name defined under the MCP table, in any shape: these are never appended over. */
names: Set<string>;
}
/** 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<string, unknown> {
if (text === null || !text.trim()) return dict<unknown>();
if (format === 'codex-toml') {
const doc = parseToml(text);
const table = doc.mcp_servers;
if (table === undefined) return dict<unknown>();
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 dict<unknown>();
if (!isRecord(table)) throw new Error(`"${dialect.key}" is not an object`);
return table;
}
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
const table = mcpTable(format, text);
const servers = dict<McpServer>();
const names = new Set<string>();
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<McpFormat, 'codex-toml'>].from(raw);
if (s) servers[name] = s;
}
return { servers, names };
}
/** The servers of a config file's text. */
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
return parseConfig(format, text).servers;
}
/** Whether this dialect can express the server. */
function canExpress(format: McpFormat, s: McpServer): boolean {
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
return true;
}
/**
* Add servers to a config file's text and return the new text. A name already defined under the
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
* come back as intended, otherwise this throws and nothing should be written.
*/
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
const before = parseConfig(format, text);
const todo = dict<McpServer>();
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
const names = Object.keys(todo);
if (names.length === 0) return text ?? '';
let out: string;
if (format === 'codex-toml') {
const base = text ?? '';
const eol = base.includes('\r\n') ? '\r\n' : '\n';
const sep =
base.length === 0
? ''
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
? ''
: base.endsWith('\n')
? eol
: eol + eol;
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
out = base + sep + blocks.join(eol);
} else {
const dialect = JSON_DIALECTS[format];
const doc: Record<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) {
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`);
}
for (const n of names) {
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`);
}
}
return out;
}
// ---------------------------------------------------------------------------
// 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 exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
/**
* Write `text` over `file`, keeping the old content as `<file>.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<void> {
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(target)).mode & 0o777;
await fs.copyFile(target, `${target}.codeman-bak`);
await fs.chmod(`${target}.codeman-bak`, 0o600);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
if (secret) mode &= ~0o077;
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
try {
await fs.writeFile(tmp, text, { mode });
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
await fs.chmod(tmp, mode);
await fs.rename(tmp, target);
} catch (err) {
await fs.unlink(tmp).catch(() => undefined);
throw err;
}
}
export interface McpSyncOptions {
/** false = report what would change without writing. */
apply: boolean;
home?: string;
}
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<McpSyncResult> {
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<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: dict<McpServer>(), names: new Set<string>() };
});
for (const s of state) {
try {
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
} catch (err) {
s.res.status = 'unreadable';
s.res.error = err instanceof Error ? err.message : String(err);
}
}
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
const union = dict<McpServer>();
const conflicts = new Set<string>();
const switchedOff = new Set<string>();
for (const s of state) {
if (s.res.status !== 'ok') continue;
for (const name of Object.keys(s.servers)) {
const def = s.servers[name];
if (def.disabled) {
switchedOff.add(name);
continue;
}
if (!(name in union)) union[name] = def;
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
}
}
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
for (const s of state) {
if (s.res.status !== 'ok') continue;
const add = dict<McpServer>();
for (const name of Object.keys(union)) {
if (s.names.has(name)) continue;
if (canExpress(s.t.format, union[name])) add[name] = union[name];
else s.res.skipped.push(name);
}
s.res.added = Object.keys(add);
if (!opts.apply || s.res.added.length === 0) continue;
try {
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
const fresh = await readText(s.file);
const out = addServers(s.t.format, fresh, add);
const current = parseConfig(s.t.format, fresh);
const written = Object.keys(add).filter((n) => !current.names.has(n));
if (written.length === 0) {
s.res.added = [];
continue;
}
const subset = dict<McpServer>();
for (const n of written) subset[n] = add[n];
await writeAtomic(s.file, out, carriesSecrets(subset));
s.res.added = written;
} catch (err) {
s.res.status = 'failed';
s.res.error = err instanceof Error ? err.message : String(err);
s.res.added = [];
}
}
return {
applied: opts.apply,
targets: state.map((s) => s.res),
conflicts: [...conflicts].sort(),
disabled,
unsupported,
};
}
+24
View File
@@ -2490,6 +2490,30 @@
</div>
</div>
</div>
<div class="set-group" id="mcpSyncGroup">
<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 enable claude codex gemini opencode antigravity">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">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.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync).</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 ════════════════════════════════════════════ -->
+54
View File
@@ -416,6 +416,9 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
document.getElementById('appSettingsMcpSync').checked = settings.mcpSyncEnabled === true;
this.applyMcpSyncVisibility();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -1135,6 +1138,56 @@ Object.assign(CodemanApp.prototype, {
log.style.display = 'block';
},
/**
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
* shown disabled, because both endpoints would only answer 403. Called on open and from the
* checkbox's own onchange (assigning .checked fires no change event).
*/
applyMcpSyncVisibility() {
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
const row = document.getElementById('mcpSyncActionRow');
if (row) row.style.display = on ? '' : 'none';
const out = this.$('mcpSyncResult');
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
},
/** 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 installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) 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 === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</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 (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
: '';
const disabled = data.disabled?.length
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
: '';
const unsupported = data.unsupported?.length
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
: '';
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
@@ -2180,6 +2233,7 @@ Object.assign(CodemanApp.prototype, {
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
+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,
+82
View File
@@ -0,0 +1,82 @@
/**
* @fileoverview MCP server sync (src/mcp-sync.ts).
*
* GET /api/mcp-sync — dry run: per participating 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.
*
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
* is admin only. A second apply while one is running answers 409. Responses carry server names
* only, never env values or headers.
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* `absent` and never created.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncResult, type McpSyncTarget } from '../../mcp-sync.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.mcpSyncEnabled === true;
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
export async function mcpSyncTargets(): Promise<McpSyncTarget[]> {
const availability = await probeStockCliAvailability();
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!,
installed: isCliEntryInstalled(e, availability),
}));
}
/** 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);
}
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readMcpSyncEnabled())) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'MCP sync is disabled. Enable it in Settings first.');
}
return null;
}
export function registerMcpSyncRoutes(app: FastifyInstance): void {
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
const denied = await gate(req);
if (denied) {
reply.code(403);
return denied;
}
try {
return { success: true, data: await syncMcpServers(await mcpSyncTargets(), { apply }, mcpUnsupportedLabels()) };
} catch (err) {
if (err instanceof McpSyncBusyError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
};
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
}
+7
View File
@@ -1327,6 +1327,13 @@ export const SettingsUpdateSchema = z
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
*/
cliManagementEnabled: z.boolean().optional(),
/**
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
* the server user's home (including any env values and headers on the servers), so it is
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled: z.boolean().optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
+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);