2026-10-02 18:01:12 +08:00
|
|
|
/**
|
|
|
|
|
* @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`
|
2026-10-02 21:15:31 +08:00
|
|
|
* 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.
|
2026-10-02 18:01:12 +08:00
|
|
|
*
|
|
|
|
|
* Deliberately conservative:
|
2026-10-02 21:15:31 +08:00
|
|
|
* - 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
|
2026-10-10 03:41:38 +02:00
|
|
|
* `enabled: false`, antigravity `disabled: true`, Copilot's `disabledMcpServers` in its
|
|
|
|
|
* `settings.json`) is not propagated: copying it would
|
2026-10-02 21:15:31 +08:00
|
|
|
* 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.
|
2026-10-02 18:01:12 +08:00
|
|
|
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
|
2026-10-02 21:15:31 +08:00
|
|
|
* - Only one apply runs at a time.
|
2026-10-04 23:43:36 +02:00
|
|
|
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
|
|
|
|
|
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
|
|
|
|
|
* cannot be located safely, so that target is reported `skipped` and never written.
|
2026-10-02 18:01:12 +08:00
|
|
|
*
|
2026-10-04 23:43:36 +02:00
|
|
|
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
|
|
|
|
|
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
|
|
|
|
|
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
|
2026-10-02 18:01:12 +08:00
|
|
|
*
|
|
|
|
|
* @module mcp-sync
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { promises as fs } from 'node:fs';
|
2026-10-02 21:15:31 +08:00
|
|
|
import { randomBytes } from 'node:crypto';
|
2026-10-02 18:01:12 +08:00
|
|
|
import { homedir } from 'node:os';
|
2026-10-11 00:28:44 +08:00
|
|
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
2026-10-04 23:43:36 +02:00
|
|
|
import { parse as parseToml, TomlError } from 'smol-toml';
|
2026-10-02 21:15:31 +08:00
|
|
|
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
2026-10-04 23:43:36 +02:00
|
|
|
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
|
2026-10-02 18:01:12 +08:00
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
export type McpFormat = McpConfigFormat;
|
2026-10-02 18:01:12 +08:00
|
|
|
|
|
|
|
|
export interface McpServer {
|
|
|
|
|
transport: 'stdio' | 'http' | 'sse';
|
|
|
|
|
command?: string;
|
|
|
|
|
args?: string[];
|
|
|
|
|
env?: Record<string, string>;
|
|
|
|
|
cwd?: string;
|
|
|
|
|
url?: string;
|
|
|
|
|
headers?: Record<string, string>;
|
2026-10-02 21:15:31 +08:00
|
|
|
/** Switched off in the CLI that defines it. Never propagated. */
|
|
|
|
|
disabled?: boolean;
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type McpServerMap = Record<string, McpServer>;
|
|
|
|
|
|
|
|
|
|
export interface McpSyncTarget {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
2026-10-04 23:43:36 +02:00
|
|
|
/** Home-relative default location of the config file. */
|
2026-10-02 18:01:12 +08:00
|
|
|
path: string;
|
2026-10-13 18:28:34 +02:00
|
|
|
/**
|
|
|
|
|
* Home-relative fallback file used when `path` is absent: read, and written in place when it
|
|
|
|
|
* parses (a JSONC file with comments fails the parse and is never written).
|
|
|
|
|
*/
|
2026-10-11 00:28:44 +08:00
|
|
|
altPath?: string;
|
2026-10-02 18:01:12 +08:00
|
|
|
format: McpFormat;
|
2026-10-04 23:43:36 +02:00
|
|
|
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
|
|
|
|
|
relocation?: { envVar: string; path: string };
|
2026-10-02 21:15:31 +08:00
|
|
|
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
|
|
|
|
|
installed: boolean;
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
/** 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';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 23:43:36 +02:00
|
|
|
/**
|
|
|
|
|
* An error whose message this module wrote itself. It names keys Codeman chose and server names
|
|
|
|
|
* (which the result reports anyway), never a value from the file, so it may be shown as is.
|
|
|
|
|
*/
|
|
|
|
|
class McpConfigError extends Error {
|
|
|
|
|
constructor(message: string) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = 'McpConfigError';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
|
|
|
|
|
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
|
|
|
|
|
* "Unexpected token" errors quote about ten characters of source. These files hold env values
|
|
|
|
|
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
|
|
|
|
|
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
|
|
|
|
|
* else by a fixed category.
|
|
|
|
|
*/
|
|
|
|
|
function describeMcpSyncError(err: unknown): string {
|
|
|
|
|
if (err instanceof McpConfigError) return err.message;
|
|
|
|
|
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
|
|
|
|
|
if (err instanceof SyntaxError) {
|
|
|
|
|
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
|
|
|
|
|
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
|
|
|
|
|
const pos = /at position (\d+)/.exec(err.message);
|
|
|
|
|
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
|
|
|
|
|
}
|
|
|
|
|
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
|
|
|
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
|
|
|
|
|
return 'unexpected error';
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
|
|
|
|
2026-10-02 18:24:23 +08:00
|
|
|
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
|
|
|
|
|
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
/** 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
function strMap(v: unknown): Record<string, string> | undefined {
|
|
|
|
|
if (!isRecord(v)) return undefined;
|
2026-10-02 21:15:31 +08:00
|
|
|
const out = dict<string>();
|
|
|
|
|
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
|
2026-10-02 18:01:12 +08:00
|
|
|
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;
|
2026-10-02 21:15:31 +08:00
|
|
|
if (s.disabled) out.disabled = true;
|
2026-10-02 18:01:12 +08:00
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
|
|
|
|
|
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
/** 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]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
/** 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);
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
2026-10-02 18:11:13 +08:00
|
|
|
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
|
2026-10-11 00:28:44 +08:00
|
|
|
// today. A bare `url` with no type used to be the legacy SSE form, but gemini-cli v0.63.0 tries
|
|
|
|
|
// it as streamable HTTP first (and logs `httpUrl` as deprecated), so map it to 'http'.
|
2026-10-02 18:01:12 +08:00
|
|
|
if (typeof raw.httpUrl === 'string')
|
|
|
|
|
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
|
2026-10-02 18:11:13 +08:00
|
|
|
if (typeof raw.url === 'string') {
|
2026-10-11 00:28:44 +08:00
|
|
|
return clean({ transport: raw.type === 'sse' ? 'sse' : 'http', url: raw.url, headers: strMap(raw.headers) });
|
2026-10-02 18:11:13 +08:00
|
|
|
}
|
2026-10-02 18:01:12 +08:00
|
|
|
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 } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-10-02 18:11:13 +08:00
|
|
|
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;
|
2026-10-02 21:15:31 +08:00
|
|
|
const disabled = raw.disabled === true;
|
2026-10-02 18:11:13 +08:00
|
|
|
if (typeof raw.serverUrl === 'string')
|
2026-10-02 21:15:31 +08:00
|
|
|
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
|
2026-10-02 18:11:13 +08:00
|
|
|
if (typeof raw.command === 'string') {
|
2026-10-02 21:15:31 +08:00
|
|
|
return clean({
|
|
|
|
|
transport: 'stdio',
|
|
|
|
|
command: raw.command,
|
|
|
|
|
args: strArr(raw.args),
|
|
|
|
|
env: strMap(raw.env),
|
|
|
|
|
disabled,
|
|
|
|
|
});
|
2026-10-02 18:11:13 +08:00
|
|
|
}
|
|
|
|
|
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 };
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fromOpencode(raw: unknown): McpServer | null {
|
|
|
|
|
if (!isRecord(raw)) return null;
|
2026-10-11 00:28:44 +08:00
|
|
|
const disabled = raw.enabled === false || raw.disabled === true;
|
2026-10-02 18:01:12 +08:00
|
|
|
if (raw.type === 'remote' && typeof raw.url === 'string') {
|
2026-10-02 21:15:31 +08:00
|
|
|
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
if (raw.type === 'local') {
|
|
|
|
|
const cmd = strArr(raw.command);
|
|
|
|
|
if (!cmd?.length) return null;
|
2026-10-02 21:15:31 +08:00
|
|
|
return clean({
|
|
|
|
|
transport: 'stdio',
|
|
|
|
|
command: cmd[0],
|
|
|
|
|
args: cmd.slice(1),
|
|
|
|
|
env: strMap(raw.environment),
|
|
|
|
|
disabled,
|
|
|
|
|
});
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 16:13:48 +08:00
|
|
|
/**
|
|
|
|
|
* GitHub Copilot CLI (`copilot mcp add`): `~/.copilot/mcp-config.json`, `mcpServers`. A stdio server is
|
|
|
|
|
* `type: "local"`; every entry carries `tools` (`["*"]` = all). Whether a server is switched off is NOT in
|
|
|
|
|
* this file: `copilot mcp disable` records the name in `settings.json` beside it (`disabledMcpServers`).
|
|
|
|
|
*/
|
|
|
|
|
function fromCopilot(raw: unknown): McpServer | null {
|
|
|
|
|
if (!isRecord(raw)) return null;
|
|
|
|
|
if ((raw.type === 'http' || raw.type === 'sse') && typeof raw.url === 'string') {
|
|
|
|
|
return clean({ transport: raw.type, url: raw.url, headers: strMap(raw.headers) });
|
|
|
|
|
}
|
|
|
|
|
if ((raw.type === undefined || raw.type === 'local' || raw.type === 'stdio') && typeof raw.command === 'string') {
|
|
|
|
|
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toCopilot(s: McpServer): Record<string, unknown> {
|
|
|
|
|
if (s.transport === 'stdio') {
|
|
|
|
|
return { tools: ['*'], type: 'local', command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}) };
|
|
|
|
|
}
|
|
|
|
|
return { tools: ['*'], type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
interface JsonDialect {
|
|
|
|
|
/** Key holding the server table. */
|
|
|
|
|
key: string;
|
2026-10-11 00:28:44 +08:00
|
|
|
/** Servers also read from under `key.nestedKey` (OpenCode V2 nests them under `mcp.servers`). */
|
|
|
|
|
nestedKey?: string;
|
2026-10-02 18:01:12 +08:00
|
|
|
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>;
|
2026-10-09 16:13:48 +08:00
|
|
|
/**
|
|
|
|
|
* A file beside the config that lists the names of servers the user switched off (the switch is
|
|
|
|
|
* not stored on the server entry). Read, never written.
|
|
|
|
|
*/
|
|
|
|
|
disabledIn?: { file: string; key: string };
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 18:11:13 +08:00
|
|
|
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
2026-10-02 18:01:12 +08:00
|
|
|
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
|
|
|
|
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
2026-10-02 18:11:13 +08:00
|
|
|
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
|
2026-10-09 16:13:48 +08:00
|
|
|
'copilot-json': {
|
|
|
|
|
key: 'mcpServers',
|
|
|
|
|
from: fromCopilot,
|
|
|
|
|
to: toCopilot,
|
|
|
|
|
disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
|
|
|
|
|
},
|
2026-10-02 18:01:12 +08:00
|
|
|
'opencode-json': {
|
|
|
|
|
key: 'mcp',
|
2026-10-11 00:28:44 +08:00
|
|
|
nestedKey: 'servers',
|
2026-10-02 18:01:12 +08:00
|
|
|
from: fromOpencode,
|
|
|
|
|
to: toOpencode,
|
|
|
|
|
seed: { $schema: 'https://opencode.ai/config.json' },
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Codex TOML (the `[mcp_servers.*]` tables only)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
function fromCodex(t: Record<string, unknown>): McpServer | null {
|
|
|
|
|
const disabled = t.enabled === false;
|
2026-10-02 18:01:12 +08:00
|
|
|
if (typeof t.url === 'string') {
|
2026-10-02 21:15:31 +08:00
|
|
|
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
if (typeof t.command === 'string') {
|
2026-10-02 21:15:31 +08:00
|
|
|
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
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>();
|
2026-10-02 18:01:12 +08:00
|
|
|
if (format === 'codex-toml') {
|
2026-10-02 21:15:31 +08:00
|
|
|
const doc = parseToml(text);
|
|
|
|
|
const table = doc.mcp_servers;
|
|
|
|
|
if (table === undefined) return dict<unknown>();
|
2026-10-04 23:43:36 +02:00
|
|
|
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
|
2026-10-02 21:15:31 +08:00
|
|
|
return table;
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
const dialect = JSON_DIALECTS[format];
|
|
|
|
|
const doc: unknown = JSON.parse(text);
|
2026-10-04 23:43:36 +02:00
|
|
|
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
|
2026-10-02 18:01:12 +08:00
|
|
|
const table = doc[dialect.key];
|
2026-10-02 21:15:31 +08:00
|
|
|
if (table === undefined) return dict<unknown>();
|
2026-10-04 23:43:36 +02:00
|
|
|
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
|
2026-10-11 00:28:44 +08:00
|
|
|
if (!dialect.nestedKey) return table;
|
|
|
|
|
const nested = table[dialect.nestedKey];
|
2026-10-13 18:28:15 +02:00
|
|
|
// OpenCode reads `mcp.servers` as a V1 server named "servers" when it carries a V1 `type`.
|
|
|
|
|
if (nested === undefined || (isRecord(nested) && (nested.type === 'local' || nested.type === 'remote'))) return table;
|
2026-10-11 00:28:44 +08:00
|
|
|
if (!isRecord(nested)) throw new McpConfigError(`"${dialect.key}.${dialect.nestedKey}" is not an object`);
|
2026-10-13 18:28:15 +02:00
|
|
|
// Merge the direct (V1) table with the nested (V2) one. Like OpenCode, the native V2 entry wins a
|
|
|
|
|
// name collision; sync never writes a name already defined, so this does not affect the read-back.
|
2026-10-11 00:28:44 +08:00
|
|
|
const merged = dict<unknown>();
|
|
|
|
|
for (const k of safeKeys(table)) if (k !== dialect.nestedKey) merged[k] = table[k];
|
2026-10-13 18:28:15 +02:00
|
|
|
for (const k of safeKeys(nested)) merged[k] = nested[k];
|
2026-10-11 00:28:44 +08:00
|
|
|
return merged;
|
2026-10-02 21:15:31 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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;
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
2026-10-02 21:15:31 +08:00
|
|
|
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;
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Whether this dialect can express the server. */
|
2026-10-02 19:06:37 +08:00
|
|
|
function canExpress(format: McpFormat, s: McpServer): boolean {
|
2026-10-02 18:11:13 +08:00
|
|
|
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
|
2026-10-02 18:01:12 +08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-10-02 18:01:12 +08:00
|
|
|
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
|
2026-10-02 21:15:31 +08:00
|
|
|
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;
|
2026-10-02 18:01:12 +08:00
|
|
|
if (format === 'codex-toml') {
|
|
|
|
|
const base = text ?? '';
|
2026-10-02 21:15:31 +08:00
|
|
|
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) {
|
2026-10-04 23:43:36 +02:00
|
|
|
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
for (const n of names) {
|
2026-10-02 21:15:31 +08:00
|
|
|
const got = after.servers[n];
|
|
|
|
|
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
|
2026-10-04 23:43:36 +02:00
|
|
|
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
|
2026-10-02 21:15:31 +08:00
|
|
|
}
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
2026-10-02 21:15:31 +08:00
|
|
|
return out;
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
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);
|
2026-10-04 23:43:36 +02:00
|
|
|
throw new McpConfigError('config path is a dangling symlink');
|
2026-10-02 21:15:31 +08:00
|
|
|
} catch (inner) {
|
|
|
|
|
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
let mode = 0o600;
|
|
|
|
|
try {
|
2026-10-02 21:15:31 +08:00
|
|
|
mode = (await fs.stat(target)).mode & 0o777;
|
|
|
|
|
await fs.copyFile(target, `${target}.codeman-bak`);
|
|
|
|
|
await fs.chmod(`${target}.codeman-bak`, 0o600);
|
2026-10-02 18:01:12 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
|
|
|
}
|
2026-10-02 21:15:31 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface McpSyncOptions {
|
|
|
|
|
/** false = report what would change without writing. */
|
|
|
|
|
apply: boolean;
|
|
|
|
|
home?: string;
|
2026-10-04 23:43:36 +02:00
|
|
|
/**
|
|
|
|
|
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
|
|
|
|
|
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
|
|
|
|
|
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
|
|
|
|
|
* env can never aim a write outside that home.
|
|
|
|
|
*/
|
|
|
|
|
env?: Record<string, string | undefined>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The config file a target means, honouring its relocation env var. `skip` is set when the var
|
|
|
|
|
* holds something that cannot be located safely (a relative path resolves against the CLI's
|
|
|
|
|
* working directory, which differs per session), so the target is neither read nor written.
|
|
|
|
|
*/
|
|
|
|
|
function resolveFile(
|
|
|
|
|
t: McpSyncTarget,
|
|
|
|
|
home: string,
|
|
|
|
|
env: Record<string, string | undefined>
|
|
|
|
|
): { file: string; skip?: string } {
|
|
|
|
|
const rel = t.relocation;
|
|
|
|
|
const dir = rel ? env[rel.envVar] : undefined;
|
|
|
|
|
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
|
|
|
|
|
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
|
|
|
|
|
if (!isAbsolute(dir)) {
|
|
|
|
|
return {
|
|
|
|
|
file: `$${rel.envVar}/${rel.path}`,
|
|
|
|
|
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return { file: join(dir, rel.path) };
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 00:28:44 +08:00
|
|
|
/**
|
|
|
|
|
* The alternate config file for a target (`McpSyncTarget.altPath`), honouring the same relocation
|
|
|
|
|
* as the primary file: the alt lives in the same directory, so the relocated path is the primary's
|
2026-10-13 18:28:34 +02:00
|
|
|
* directory plus the alt filename. Once chosen it is read and written exactly like the primary; a
|
|
|
|
|
* JSONC file with comments fails the parse guard and is never written.
|
2026-10-11 00:28:44 +08:00
|
|
|
*/
|
|
|
|
|
function resolveAltFile(
|
|
|
|
|
t: McpSyncTarget,
|
|
|
|
|
altPath: string,
|
|
|
|
|
home: string,
|
|
|
|
|
env: Record<string, string | undefined>
|
|
|
|
|
): { file: string; skip?: string } {
|
|
|
|
|
const rel = t.relocation;
|
|
|
|
|
const dir = rel ? env[rel.envVar] : undefined;
|
|
|
|
|
if (!rel || dir === undefined || dir === '') return { file: join(home, altPath) };
|
|
|
|
|
if (!isAbsolute(dir)) {
|
|
|
|
|
return {
|
|
|
|
|
file: `$${rel.envVar}/…`,
|
|
|
|
|
skip: `${rel.envVar} is set to a relative path, so the alt file cannot be located safely`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return { file: join(dir, dirname(rel.path), basename(altPath)) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 16:13:48 +08:00
|
|
|
/**
|
|
|
|
|
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
|
|
|
|
|
* disabled, so they are not copied. If that file cannot be read as intended the target is
|
|
|
|
|
* reported unreadable rather than guessing: a guess could switch a server on everywhere.
|
|
|
|
|
*/
|
|
|
|
|
async function applyCompanionDisabled(format: McpFormat, file: string, servers: McpServerMap): Promise<void> {
|
|
|
|
|
if (format === 'codex-toml') return;
|
|
|
|
|
const companion = JSON_DIALECTS[format].disabledIn;
|
|
|
|
|
if (!companion) return;
|
|
|
|
|
const text = await readText(join(dirname(file), companion.file));
|
|
|
|
|
if (text === null || !text.trim()) return;
|
|
|
|
|
let doc: unknown;
|
|
|
|
|
try {
|
|
|
|
|
doc = JSON.parse(text);
|
|
|
|
|
} catch {
|
|
|
|
|
throw new McpConfigError(
|
|
|
|
|
`${companion.file} next to the config is not valid JSON, so which servers are switched off is unknown`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const list = isRecord(doc) ? doc[companion.key] : undefined;
|
|
|
|
|
if (list === undefined) return;
|
|
|
|
|
const names = strArr(list);
|
|
|
|
|
if (!names) throw new McpConfigError(`"${companion.key}" in ${companion.file} is not a list of names`);
|
|
|
|
|
for (const n of names) if (n in servers) servers[n] = { ...servers[n], disabled: true };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
let applying = false;
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
/**
|
|
|
|
|
* 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).
|
2026-10-02 21:15:31 +08:00
|
|
|
* Throws `McpSyncBusyError` if another apply is running.
|
2026-10-02 18:01:12 +08:00
|
|
|
*/
|
2026-10-02 18:11:13 +08:00
|
|
|
export async function syncMcpServers(
|
|
|
|
|
targets: McpSyncTarget[],
|
|
|
|
|
opts: McpSyncOptions,
|
|
|
|
|
unsupported: string[] = []
|
|
|
|
|
): Promise<McpSyncResult> {
|
2026-10-02 21:15:31 +08:00
|
|
|
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> {
|
2026-10-02 18:01:12 +08:00
|
|
|
const home = opts.home ?? homedir();
|
2026-10-04 23:43:36 +02:00
|
|
|
const env = opts.env ?? (opts.home === undefined ? process.env : {});
|
2026-10-02 18:01:12 +08:00
|
|
|
const seen = new Set<string>();
|
2026-10-04 23:43:36 +02:00
|
|
|
const live = targets
|
|
|
|
|
.map((t) => ({ t, ...resolveFile(t, home, env) }))
|
|
|
|
|
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
|
2026-10-02 18:01:12 +08:00
|
|
|
|
2026-10-04 23:43:36 +02:00
|
|
|
const state = live.map(({ t, file, skip }) => {
|
2026-10-02 18:01:12 +08:00
|
|
|
const res: McpSyncTargetResult = {
|
|
|
|
|
id: t.id,
|
|
|
|
|
label: t.label,
|
|
|
|
|
file,
|
2026-10-04 23:43:36 +02:00
|
|
|
status: skip ? 'skipped' : 'ok',
|
|
|
|
|
...(skip ? { error: skip } : {}),
|
2026-10-02 18:01:12 +08:00
|
|
|
servers: [],
|
|
|
|
|
added: [],
|
|
|
|
|
skipped: [],
|
|
|
|
|
};
|
2026-10-02 21:15:31 +08:00
|
|
|
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
|
2026-10-02 18:01:12 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const s of state) {
|
2026-10-04 23:43:36 +02:00
|
|
|
if (s.res.status !== 'ok') continue;
|
2026-10-02 18:01:12 +08:00
|
|
|
try {
|
2026-10-11 00:28:44 +08:00
|
|
|
// A config kept under an alternate name (OpenCode's opencode.jsonc) is read when the primary
|
|
|
|
|
// file is absent, so its servers are synced rather than silently treated as "not installed".
|
|
|
|
|
if (s.t.altPath && !(await exists(s.file))) {
|
|
|
|
|
const alt = resolveAltFile(s.t, s.t.altPath, home, env);
|
|
|
|
|
if (!alt.skip && (await exists(alt.file))) {
|
|
|
|
|
s.file = alt.file;
|
|
|
|
|
s.res.file = alt.file;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-02 21:15:31 +08:00
|
|
|
if (!s.t.installed && !(await exists(s.file))) {
|
|
|
|
|
s.res.status = 'absent';
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const parsed = parseConfig(s.t.format, await readText(s.file));
|
2026-10-09 16:13:48 +08:00
|
|
|
await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
|
2026-10-02 21:15:31 +08:00
|
|
|
s.servers = parsed.servers;
|
|
|
|
|
s.names = parsed.names;
|
|
|
|
|
s.res.servers = [...parsed.names];
|
2026-10-02 18:01:12 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
s.res.status = 'unreadable';
|
2026-10-04 23:43:36 +02:00
|
|
|
s.res.error = describeMcpSyncError(err);
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
|
|
|
|
|
const union = dict<McpServer>();
|
2026-10-02 18:01:12 +08:00
|
|
|
const conflicts = new Set<string>();
|
2026-10-02 21:15:31 +08:00
|
|
|
const switchedOff = new Set<string>();
|
2026-10-02 18:01:12 +08:00
|
|
|
for (const s of state) {
|
|
|
|
|
if (s.res.status !== 'ok') continue;
|
2026-10-02 21:15:31 +08:00
|
|
|
for (const name of Object.keys(s.servers)) {
|
|
|
|
|
const def = s.servers[name];
|
|
|
|
|
if (def.disabled) {
|
|
|
|
|
switchedOff.add(name);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-02 18:01:12 +08:00
|
|
|
if (!(name in union)) union[name] = def;
|
|
|
|
|
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-02 21:15:31 +08:00
|
|
|
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
|
2026-10-02 18:01:12 +08:00
|
|
|
|
|
|
|
|
for (const s of state) {
|
|
|
|
|
if (s.res.status !== 'ok') continue;
|
2026-10-02 21:15:31 +08:00
|
|
|
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];
|
2026-10-02 18:01:12 +08:00
|
|
|
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);
|
2026-10-02 21:15:31 +08:00
|
|
|
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) {
|
2026-10-02 18:01:12 +08:00
|
|
|
s.res.added = [];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-02 21:15:31 +08:00
|
|
|
const subset = dict<McpServer>();
|
|
|
|
|
for (const n of written) subset[n] = add[n];
|
|
|
|
|
await writeAtomic(s.file, out, carriesSecrets(subset));
|
|
|
|
|
s.res.added = written;
|
2026-10-02 18:01:12 +08:00
|
|
|
} catch (err) {
|
2026-10-02 21:15:31 +08:00
|
|
|
s.res.status = 'failed';
|
2026-10-04 23:43:36 +02:00
|
|
|
s.res.error = describeMcpSyncError(err);
|
2026-10-02 18:01:12 +08:00
|
|
|
s.res.added = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
return {
|
|
|
|
|
applied: opts.apply,
|
|
|
|
|
targets: state.map((s) => s.res),
|
|
|
|
|
conflicts: [...conflicts].sort(),
|
|
|
|
|
disabled,
|
|
|
|
|
unsupported,
|
|
|
|
|
};
|
2026-10-02 18:01:12 +08:00
|
|
|
}
|