mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
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:
co-authored by
Claude Sonnet 5.5
parent
9240493c43
commit
e6b258fc44
+555
@@ -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() };
|
||||
}
|
||||
Reference in New Issue
Block a user