diff --git a/.changeset/mcp-sync-clis.md b/.changeset/mcp-sync-clis.md index e93ca423..7b44fae7 100644 --- a/.changeset/mcp-sync-clis.md +++ b/.changeset/mcp-sync-clis.md @@ -2,4 +2,4 @@ "aicodeman": minor --- -MCP server sync between CLIs: Settings → Agents & CLIs → "Sync MCP servers across CLIs" (and `GET`/`POST /api/mcp-sync`) copies each enabled CLI's MCP servers into the others' config files (Claude, Gemini, Codex, OpenCode, Antigravity; enabled CLIs without a known MCP config are listed as unsupported). It only adds missing servers, never edits or removes one, keeps a `.codeman-bak` of every file it changes, and reports same-name conflicts instead of overwriting. +Opt-in MCP server sync between CLIs. Turn on Settings → Agents & CLIs → MCP servers → "Enable MCP server sync" (`mcpSyncEnabled`, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on), then Preview or Sync now to copy each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. Enabled CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely. diff --git a/docs/api-reference.md b/docs/api-reference.md index c0f7235d..322b614d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -713,6 +713,26 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route | `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | | `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | +## MCP server sync + +Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`. + +| Method | Path | Body | Notes | +| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. | +| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. | + +Result (`data`): + +- `applied` — `false` for the dry run. +- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity). + - `status`: `ok`; `absent` (not installed and no config file, so not read or created); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged). +- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing. +- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`). +- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed). + +The result carries server **names** only, never `env` values or `headers`. Each changed file keeps its previous content as `.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`. + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the diff --git a/docs/cli-registry.md b/docs/cli-registry.md index b30ad767..27d3b71f 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -251,9 +251,9 @@ A module-level const freezes at first import, and the failure is asymmetric: a C ## MCP server sync -`capabilities.mcpConfig` (`{ path, format }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one and adds any server a CLI is missing from the others; `GET`/`POST /api/mcp-sync` and Settings → Agents & CLIs → MCP servers drive it. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`. +`capabilities.mcpConfig` (`{ path, format }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`. -Three rules the module keeps and the tests pin: it only ADDS (an existing server is never edited or removed, a same-name difference is reported as a conflict), it never writes a file it could not parse (opencode JSONC with comments), and its result carries server names only, never env values or headers. The schema restricts `path` to a home-relative path without `..`, since sync writes to it. +The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers. The schema restricts `path` to a home-relative path without `..`, since sync writes to it. ## See also diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index f321780a..7cd94f11 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -144,7 +144,7 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions curl -s "$API/api/sessions/unified" | jq # live + historical, deduped curl -s "$API/api/subagents" | jq # background agents curl -s "$API/api/search?q=deploy" | jq # cross-session search -curl -s "$API/api/mcp-sync" | jq # preview MCP server sync across enabled CLIs (names only) +curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on) curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove # with ID set to a session id: diff --git a/package-lock.json b/package-lock.json index 271350e4..af1a0198 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "smol-toml": "^1.9.0", "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", @@ -10114,6 +10115,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", diff --git a/package.json b/package.json index da2a097b..1398bab0 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "smol-toml": "^1.9.0", "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 1017a9b6..508a4a2d 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -15,6 +15,7 @@ import { z } from 'zod'; import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; +import type { McpConfigFormat } from './types.js'; /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ const cliId = z @@ -386,7 +387,15 @@ const capabilitiesSchema = z .max(100) .regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/) .refine((v) => !v.split('/').includes('..'), 'must not contain ..'), - format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json', 'antigravity-json']), + // Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is + // keyed by the same type, so an adapter-less format fails to compile there. + format: z.enum([ + 'claude-json', + 'gemini-json', + 'codex-toml', + 'opencode-json', + 'antigravity-json', + ] as const satisfies readonly McpConfigFormat[]), }) .strict() .optional(), diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 9447bae5..a7431535 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -90,6 +90,9 @@ export interface CliVariant { args: ArgSpec[]; } +/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */ +export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; + export interface CliLaunch { params: Record; /** @@ -517,10 +520,7 @@ export interface CliCapabilities { * adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is * skipped by sync rather than guessed at. */ - mcpConfig?: { - path: string; - format: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; - }; + mcpConfig?: { path: string; format: McpConfigFormat }; /** * How this CLI is pointed at a user-supplied custom OpenAI-compatible * endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index f168eef4..4072ff9d 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -2,18 +2,29 @@ * @fileoverview MCP server sync between the enabled agent CLIs. * * Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig` - * names the file and the dialect). This module reads every enabled CLI's list into one neutral - * shape, and adds any server a CLI is missing from the others. + * names the file and the dialect). This module reads every participating CLI's list into one + * neutral shape, and adds any server a CLI is missing from the others. The whole feature is + * opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools' + * own user config. * * Deliberately conservative: - * - ADDITIVE only. A server already present under a name is never rewritten and nothing is - * ever removed, so a sync cannot lose a hand-tuned entry. Same name with a different - * definition is reported as a conflict and left alone. - * - A file that does not parse (e.g. opencode JSONC with comments) is never written. - * - Only the MCP table is touched; every other key in the file is preserved. JSON files are - * re-read immediately before the write, and written via tmp+rename with the old file kept - * as `.codeman-bak`. + * - ADDITIVE only. A server already present under a name (in ANY shape, even one this module + * does not understand) is never rewritten and nothing is ever removed. Same name with a + * different definition is reported as a conflict and left alone. + * - A server the user has switched off in its own CLI (codex `enabled = false`, opencode + * `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would + * switch it on in every other CLI. + * - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a + * duplicate table) is never written, and a write is only made after the NEW text has been + * parsed again and every added server comes back as intended. + * - Only the MCP table is touched; every other key in the file is preserved. Files are + * re-read immediately before the write and replaced via tmp+rename next to the REAL target + * (a symlinked dotfile stays a symlink), with the old file kept as `.codeman-bak` + * (overwritten by each sync). + * - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left + * readable by its owner only. * - Servers a dialect cannot express (SSE for codex) are skipped and reported. + * - Only one apply runs at a time. * * The result types never carry env values or headers: those commonly hold secrets and the * result is returned over HTTP. @@ -22,10 +33,13 @@ */ import { promises as fs } from 'node:fs'; +import { randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import { parse as parseToml } from 'smol-toml'; +import type { McpConfigFormat } from './config/cli-registry/types.js'; -export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; +export type McpFormat = McpConfigFormat; export interface McpServer { transport: 'stdio' | 'http' | 'sse'; @@ -35,6 +49,8 @@ export interface McpServer { cwd?: string; url?: string; headers?: Record; + /** Switched off in the CLI that defines it. Never propagated. */ + disabled?: boolean; } export type McpServerMap = Record; @@ -44,13 +60,20 @@ export interface McpSyncTarget { label: string; path: string; format: McpFormat; + /** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */ + installed: boolean; } export interface McpSyncTargetResult { id: string; label: string; file: string; - status: 'ok' | 'unreadable'; + /** + * `absent`: not installed and no config file, so neither read nor created. + * `unreadable`: the file exists but cannot be parsed safely, so it is not written. + * `failed`: a read or write error (the file may be unchanged). + */ + status: 'ok' | 'absent' | 'unreadable' | 'failed'; error?: string; servers: string[]; /** Servers added (apply) or that would be added (plan). */ @@ -62,12 +85,22 @@ export interface McpSyncTargetResult { export interface McpSyncResult { applied: boolean; targets: McpSyncTargetResult[]; - /** Names defined differently by different CLIs; left untouched. */ + /** Names defined differently by different CLIs; existing definitions are left untouched. */ conflicts: string[]; + /** Names left out because the only definitions are switched off in their own CLI. */ + disabled: string[]; /** Enabled agent CLIs with no known MCP config file, so sync cannot touch them. */ unsupported: string[]; } +/** A second apply was requested while one was running. */ +export class McpSyncBusyError extends Error { + constructor() { + super('An MCP sync is already running'); + this.name = 'McpSyncBusyError'; + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -77,10 +110,20 @@ const isRecord = (v: unknown): v is Record => typeof v === 'obj /** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */ const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']); +/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */ +function dict(): Record { + return Object.create(null) as Record; +} + +/** Own, safe keys of an untrusted table. */ +function safeKeys(table: Record): string[] { + return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k)); +} + function strMap(v: unknown): Record | undefined { if (!isRecord(v)) return undefined; - const out: Record = {}; - for (const [k, val] of Object.entries(v)) if (typeof val === 'string') out[k] = val; + const out = dict(); + for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string; return Object.keys(out).length ? out : undefined; } @@ -97,15 +140,26 @@ function clean(s: McpServer): McpServer { if (s.cwd) out.cwd = s.cwd; if (s.url) out.url = s.url; if (s.headers && Object.keys(s.headers).length) out.headers = s.headers; + if (s.disabled) out.disabled = true; return out; } +const sortedEntries = (m: Record | undefined): [string, string][] => + Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + /** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */ function fingerprint(s: McpServer): string { const t = s.transport === 'stdio' ? 'stdio' : 'url'; return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]); } +/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */ +function fullIdentity(s: McpServer): string { + return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]); +} + +const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers); + // --------------------------------------------------------------------------- // JSON dialects // --------------------------------------------------------------------------- @@ -163,10 +217,17 @@ function toGemini(s: McpServer): Record { /** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */ function fromAntigravity(raw: unknown): McpServer | null { if (!isRecord(raw)) return null; + const disabled = raw.disabled === true; if (typeof raw.serverUrl === 'string') - return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers) }); + return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled }); if (typeof raw.command === 'string') { - return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) }); + return clean({ + transport: 'stdio', + command: raw.command, + args: strArr(raw.args), + env: strMap(raw.env), + disabled, + }); } return null; } @@ -181,13 +242,20 @@ function toAntigravity(s: McpServer): Record | null { function fromOpencode(raw: unknown): McpServer | null { if (!isRecord(raw)) return null; + const disabled = raw.enabled === false; if (raw.type === 'remote' && typeof raw.url === 'string') { - return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers) }); + return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled }); } if (raw.type === 'local') { const cmd = strArr(raw.command); if (!cmd?.length) return null; - return clean({ transport: 'stdio', command: cmd[0], args: cmd.slice(1), env: strMap(raw.environment) }); + return clean({ + transport: 'stdio', + command: cmd[0], + args: cmd.slice(1), + env: strMap(raw.environment), + disabled, + }); } return null; } @@ -229,168 +297,13 @@ const JSON_DIALECTS: Record, JsonDialect> = { // Codex TOML (the `[mcp_servers.*]` tables only) // --------------------------------------------------------------------------- -type TomlValue = string | string[] | Record | boolean | number | null; - -/** Parse one TOML value starting at `i`; returns the value and the index after it. */ -function parseTomlValue(src: string, start: number): [TomlValue, number] { - let i = start; - const ws = () => { - while (i < src.length && /[ \t\r\n]/.test(src[i])) i++; - }; - ws(); - const c = src[i]; - if (c === '"') { - if (src.startsWith('"""', i)) { - const end = src.indexOf('"""', i + 3); - return [src.slice(i + 3, end < 0 ? src.length : end).replace(/^\n/, ''), end < 0 ? src.length : end + 3]; - } - let out = ''; - i++; - while (i < src.length && src[i] !== '"') { - if (src[i] === '\\') { - const n = src[i + 1]; - const map: Record = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\' }; - if (n === 'u') { - out += String.fromCodePoint(parseInt(src.slice(i + 2, i + 6), 16)); - i += 6; - continue; - } - out += map[n] ?? n; - i += 2; - } else out += src[i++]; - } - return [out, i + 1]; - } - if (c === "'") { - const end = src.indexOf("'", i + 1); - return [src.slice(i + 1, end < 0 ? src.length : end), end < 0 ? src.length : end + 1]; - } - if (c === '[') { - const arr: string[] = []; - i++; - for (;;) { - ws(); - if (src[i] === '#') { - while (i < src.length && src[i] !== '\n') i++; - continue; - } - if (src[i] === ']' || i >= src.length) return [arr, i + 1]; - if (src[i] === ',') { - i++; - continue; - } - const [v, next] = parseTomlValue(src, i); - if (typeof v === 'string') arr.push(v); - i = next; - } - } - if (c === '{') { - const obj: Record = {}; - i++; - for (;;) { - ws(); - if (src[i] === '}' || i >= src.length) return [obj, i + 1]; - if (src[i] === ',') { - i++; - continue; - } - const [k, afterKey] = parseTomlKey(src, i); - i = afterKey; - ws(); - if (src[i] === '=') i++; - const [v, next] = parseTomlValue(src, i); - if (typeof v === 'string') obj[k] = v; - i = next; - } - } - const m = /^[^\s,\]}#]+/.exec(src.slice(i)); - const tok = m ? m[0] : ''; - const after = i + tok.length; - if (tok === 'true') return [true, after]; - if (tok === 'false') return [false, after]; - const num = Number(tok); - return [Number.isNaN(num) ? null : num, Math.max(after, i + 1)]; -} - -function parseTomlKey(src: string, start: number): [string, number] { - let i = start; - while (src[i] === ' ' || src[i] === '\t') i++; - if (src[i] === '"' || src[i] === "'") { - const [v, next] = parseTomlValue(src, i); - return [String(v), next]; - } - const m = /^[A-Za-z0-9_-]+/.exec(src.slice(i)); - const key = m ? m[0] : ''; - return [key, i + Math.max(key.length, 1)]; -} - -/** Split a table header like `mcp_servers."my.srv".env` into dotted key parts. */ -function parseTomlHeader(line: string): string[] | null { - const m = /^\[([^[\]].*)\]\s*(#.*)?$/.exec(line.trim()); - if (!m) return null; - const body = m[1]; - const parts: string[] = []; - let i = 0; - while (i < body.length) { - while (body[i] === ' ') i++; - const [k, next] = parseTomlKey(body, i); - if (!k) return null; - parts.push(k); - i = next; - while (body[i] === ' ') i++; - if (body[i] === '.') i++; - else if (i < body.length) return null; - } - return parts; -} - -/** Returns each `mcp_servers.` table as `{ ...keys, env?: {...}, http_headers?: {...} }`. */ -function parseCodexTables(text: string): Record> { - const out: Record> = {}; - let current: Record | null = null; - let sub: string | null = null; - const lines = text.split(/\r?\n/); - for (let n = 0; n < lines.length; n++) { - const line = lines[n]; - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - if (trimmed.startsWith('[')) { - current = null; - sub = null; - if (trimmed.startsWith('[[')) continue; - const parts = parseTomlHeader(trimmed); - if (parts && parts[0] === 'mcp_servers' && (parts.length === 2 || parts.length === 3)) { - if (UNSAFE_NAMES.has(parts[1])) continue; - current = out[parts[1]] ??= {}; - sub = parts.length === 3 ? parts[2] : null; - if (sub && !isRecord(current[sub])) current[sub] = {}; - } - continue; - } - if (!current) continue; - // key = value; a value may span lines (arrays), so feed the parser the remainder of the file. - const eq = line.indexOf('='); - if (eq < 0) continue; - const offset = lines.slice(0, n).reduce((a, l) => a + l.length + 1, 0); - const [key, afterKey] = parseTomlKey(text, offset + (line.length - line.trimStart().length)); - const valueStart = text.indexOf('=', afterKey) + 1; - const [value, end] = parseTomlValue(text, valueStart); - // Skip the lines the value consumed. - const consumed = text.slice(valueStart, end).split('\n').length - 1; - n += consumed; - if (sub) (current[sub] as Record)[key] = typeof value === 'string' ? value : ''; - else current[key] = value; - } - return out; -} - -function fromCodex(t: Record): McpServer | null { +function fromCodex(t: Record): McpServer | null { + const disabled = t.enabled === false; if (typeof t.url === 'string') { - const headers = strMap(t.http_headers); - return clean({ transport: 'http', url: t.url, headers }); + return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled }); } if (typeof t.command === 'string') { - return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env) }); + return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled }); } return null; } @@ -422,30 +335,54 @@ function toCodexToml(name: string, s: McpServer): string { // Dialect entry points // --------------------------------------------------------------------------- -/** Parse a config file's text (null = file absent) into servers. Throws if it cannot be read safely. */ -export function parseServers(format: McpFormat, text: string | null): McpServerMap { - const out: McpServerMap = {}; - if (text === null || !text.trim()) return out; +export interface ParsedConfig { + /** Servers this module understands. */ + servers: McpServerMap; + /** Every name defined under the MCP table, in any shape: these are never appended over. */ + names: Set; +} + +/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */ +function mcpTable(format: McpFormat, text: string | null): Record { + if (text === null || !text.trim()) return dict(); if (format === 'codex-toml') { - for (const [name, table] of Object.entries(parseCodexTables(text))) { - if (UNSAFE_NAMES.has(name)) continue; - const s = fromCodex(table); - if (s) out[name] = s; - } - return out; + const doc = parseToml(text); + const table = doc.mcp_servers; + if (table === undefined) return dict(); + if (!isRecord(table)) throw new Error('"mcp_servers" is not a table'); + return table; } const dialect = JSON_DIALECTS[format]; const doc: unknown = JSON.parse(text); if (!isRecord(doc)) throw new Error('top level is not a JSON object'); const table = doc[dialect.key]; - if (table === undefined) return out; + if (table === undefined) return dict(); if (!isRecord(table)) throw new Error(`"${dialect.key}" is not an object`); - for (const [name, raw] of Object.entries(table)) { - if (UNSAFE_NAMES.has(name)) continue; - const s = dialect.from(raw); - if (s) out[name] = s; + return table; +} + +/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */ +export function parseConfig(format: McpFormat, text: string | null): ParsedConfig { + const table = mcpTable(format, text); + const servers = dict(); + const names = new Set(); + for (const name of safeKeys(table)) { + names.add(name); + const raw = table[name]; + const s = + format === 'codex-toml' + ? isRecord(raw) + ? fromCodex(raw) + : null + : JSON_DIALECTS[format as Exclude].from(raw); + if (s) servers[name] = s; } - return out; + return { servers, names }; +} + +/** The servers of a config file's text. */ +export function parseServers(format: McpFormat, text: string | null): McpServerMap { + return parseConfig(format, text).servers; } /** Whether this dialect can express the server. */ @@ -454,26 +391,58 @@ function canExpress(format: McpFormat, s: McpServer): boolean { return true; } -/** Add servers to a config file's text and return the new text. Existing names are never touched. */ +/** + * Add servers to a config file's text and return the new text. A name already defined under the + * MCP table (in any shape) is skipped; the new text is parsed again and every added server must + * come back as intended, otherwise this throws and nothing should be written. + */ export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string { - const names = Object.keys(add); + const before = parseConfig(format, text); + const todo = dict(); + for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n]; + const names = Object.keys(todo); + if (names.length === 0) return text ?? ''; + + let out: string; if (format === 'codex-toml') { const base = text ?? ''; - const sep = base.length === 0 ? '' : base.endsWith('\n\n') ? '' : base.endsWith('\n') ? '\n' : '\n\n'; - return base + sep + names.map((n) => toCodexToml(n, add[n])).join('\n'); + const eol = base.includes('\r\n') ? '\r\n' : '\n'; + const sep = + base.length === 0 + ? '' + : base.endsWith('\n\n') || base.endsWith('\r\n\r\n') + ? '' + : base.endsWith('\n') + ? eol + : eol + eol; + const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol)); + out = base + sep + blocks.join(eol); + } else { + const dialect = JSON_DIALECTS[format]; + const doc: Record = + text && text.trim() ? (JSON.parse(text) as Record) : { ...dialect.seed }; + const existing = doc[dialect.key]; + const table: Record = isRecord(existing) ? existing : {}; + for (const n of names) { + const entry = dialect.to(todo[n]); + if (entry) table[n] = entry; + } + doc[dialect.key] = table; + out = JSON.stringify(doc, null, 2) + '\n'; + } + + // Re-read what we are about to write. + const after = parseConfig(format, out); + for (const n of before.names) { + if (!after.names.has(n)) throw new Error(`refusing to write: "${n}" would be lost`); } - const dialect = JSON_DIALECTS[format]; - const doc: Record = - text && text.trim() ? (JSON.parse(text) as Record) : { ...dialect.seed }; - const existing = doc[dialect.key]; - const table: Record = isRecord(existing) ? existing : {}; for (const n of names) { - if (n in table) continue; - const entry = dialect.to(add[n]); - if (entry) table[n] = entry; + const got = after.servers[n]; + if (!got || fullIdentity(got) !== fullIdentity(todo[n])) { + throw new Error(`refusing to write: "${n}" does not read back as written`); + } } - doc[dialect.key] = table; - return JSON.stringify(doc, null, 2) + '\n'; + return out; } // --------------------------------------------------------------------------- @@ -489,19 +458,56 @@ async function readText(file: string): Promise { } } -async function writeAtomic(file: string, text: string): Promise { +async function exists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +/** + * Write `text` over `file`, keeping the old content as `.codeman-bak`. Follows a symlink + * to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is + * readable by its owner only. + */ +async function writeAtomic(file: string, text: string, secret: boolean): Promise { + let target = file; + try { + if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + // ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart. + try { + await fs.lstat(file); + throw new Error('config path is a dangling symlink'); + } catch (inner) { + if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner; + } + } + let mode = 0o600; try { - mode = (await fs.stat(file)).mode & 0o777; - await fs.copyFile(file, `${file}.codeman-bak`); - await fs.chmod(`${file}.codeman-bak`, 0o600); + mode = (await fs.stat(target)).mode & 0o777; + await fs.copyFile(target, `${target}.codeman-bak`); + await fs.chmod(`${target}.codeman-bak`, 0o600); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } - await fs.mkdir(dirname(file), { recursive: true }); - const tmp = `${file}.codeman-tmp-${process.pid}`; - await fs.writeFile(tmp, text, { mode }); - await fs.rename(tmp, file); + if (secret) mode &= ~0o077; + + await fs.mkdir(dirname(target), { recursive: true }); + const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`; + try { + await fs.writeFile(tmp, text, { mode }); + // writeFile's mode is masked by the umask; the mode we computed is the one we mean. + await fs.chmod(tmp, mode); + await fs.rename(tmp, target); + } catch (err) { + await fs.unlink(tmp).catch(() => undefined); + throw err; + } } export interface McpSyncOptions { @@ -510,15 +516,30 @@ export interface McpSyncOptions { home?: string; } +let applying = false; + /** * Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority * order: when two CLIs define a name differently, the first one's definition is the one copied). + * Throws `McpSyncBusyError` if another apply is running. */ export async function syncMcpServers( targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[] = [] ): Promise { + if (opts.apply) { + if (applying) throw new McpSyncBusyError(); + applying = true; + } + try { + return await run(targets, opts, unsupported); + } finally { + if (opts.apply) applying = false; + } +} + +async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise { const home = opts.home ?? homedir(); const seen = new Set(); const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true))); @@ -534,36 +555,49 @@ export async function syncMcpServers( added: [], skipped: [], }; - return { t, file, res, servers: {} as McpServerMap }; + return { t, file, res, servers: dict(), names: new Set() }; }); for (const s of state) { try { - s.servers = parseServers(s.t.format, await readText(s.file)); - s.res.servers = Object.keys(s.servers); + if (!s.t.installed && !(await exists(s.file))) { + s.res.status = 'absent'; + continue; + } + const parsed = parseConfig(s.t.format, await readText(s.file)); + s.servers = parsed.servers; + s.names = parsed.names; + s.res.servers = [...parsed.names]; } catch (err) { s.res.status = 'unreadable'; s.res.error = err instanceof Error ? err.message : String(err); } } - // Union, first definition wins; a later, different definition of the same name is a conflict. - const union: McpServerMap = {}; + // Union, first enabled definition wins; a later, different definition of the same name is a conflict. + const union = dict(); const conflicts = new Set(); + const switchedOff = new Set(); for (const s of state) { if (s.res.status !== 'ok') continue; - for (const [name, def] of Object.entries(s.servers)) { + for (const name of Object.keys(s.servers)) { + const def = s.servers[name]; + if (def.disabled) { + switchedOff.add(name); + continue; + } if (!(name in union)) union[name] = def; else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name); } } + const disabled = [...switchedOff].filter((n) => !(n in union)).sort(); for (const s of state) { if (s.res.status !== 'ok') continue; - const add: McpServerMap = {}; - for (const [name, def] of Object.entries(union)) { - if (name in s.servers) continue; - if (canExpress(s.t.format, def)) add[name] = def; + const add = dict(); + for (const name of Object.keys(union)) { + if (s.names.has(name)) continue; + if (canExpress(s.t.format, union[name])) add[name] = union[name]; else s.res.skipped.push(name); } s.res.added = Object.keys(add); @@ -571,21 +605,29 @@ export async function syncMcpServers( try { // Re-read right before writing: claude rewrites ~/.claude.json constantly. const fresh = await readText(s.file); - const stillMissing: McpServerMap = {}; - const current = parseServers(s.t.format, fresh); - for (const [n, d] of Object.entries(add)) if (!(n in current)) stillMissing[n] = d; - if (Object.keys(stillMissing).length === 0) { + const out = addServers(s.t.format, fresh, add); + const current = parseConfig(s.t.format, fresh); + const written = Object.keys(add).filter((n) => !current.names.has(n)); + if (written.length === 0) { s.res.added = []; continue; } - await writeAtomic(s.file, addServers(s.t.format, fresh, stillMissing)); - s.res.added = Object.keys(stillMissing); + const subset = dict(); + for (const n of written) subset[n] = add[n]; + await writeAtomic(s.file, out, carriesSecrets(subset)); + s.res.added = written; } catch (err) { - s.res.status = 'unreadable'; + s.res.status = 'failed'; s.res.error = err instanceof Error ? err.message : String(err); s.res.added = []; } } - return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort(), unsupported }; + return { + applied: opts.apply, + targets: state.map((s) => s.res), + conflicts: [...conflicts].sort(), + disabled, + unsupported, + }; } diff --git a/src/web/public/index.html b/src/web/public/index.html index 3a98c358..37739108 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2475,13 +2475,20 @@ -
+

MCP servers

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