From 57d5c7b1c23b547bfc20c8005367192b8314ca02 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:13:48 +0800 Subject: [PATCH] feat(mcp-sync): sync GitHub Copilot CLI's MCP servers too Copilot CLI keeps its user MCP list in ~/.copilot/mcp-config.json (COPILOT_HOME moves it) but is not a Codeman run mode, so it has no registry entry. Add the copilot-json dialect (mcpServers, tools ["*"], type local/http/sse, checked against `copilot mcp add` 1.0.94) and declare Copilot as a sync-only target in src/mcp-sync-targets.ts, listed after the registry CLIs. A server switched off with `copilot mcp disable` is recorded in settings.json (disabledMcpServers), not on the entry: sync reads that list so it is not copied, and reports the target unreadable if the file is not valid JSON instead of guessing. --- .changeset/mcp-sync-copilot.md | 5 + docs/api-reference.md | 2 +- docs/cli-registry.md | 6 +- docs/wiki/Settings-Reference.md | 2 +- src/config/cli-registry/schema.ts | 1 + src/config/cli-registry/types.ts | 8 +- src/mcp-sync-targets.ts | 74 +++++++++++++++ src/mcp-sync.ts | 61 ++++++++++++ src/web/public/index.html | 6 +- src/web/routes/mcp-sync-routes.ts | 13 ++- test/mcp-sync-targets.test.ts | 54 +++++++++++ test/mcp-sync.test.ts | 151 ++++++++++++++++++++++++++++++ 12 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 .changeset/mcp-sync-copilot.md create mode 100644 src/mcp-sync-targets.ts create mode 100644 test/mcp-sync-targets.test.ts diff --git a/.changeset/mcp-sync-copilot.md b/.changeset/mcp-sync-copilot.md new file mode 100644 index 00000000..e531f06b --- /dev/null +++ b/.changeset/mcp-sync-copilot.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +MCP server sync now includes GitHub Copilot CLI. If `copilot` is installed (or `~/.copilot/mcp-config.json` exists), its MCP servers are copied into your other CLIs and theirs into it, with the same rules as the others: only missing servers are added, nothing is edited or removed, a server you switched off with `copilot mcp disable` is not copied, and a file that receives env values or headers stays readable by you only. `COPILOT_HOME` is followed. Copilot is not a Codeman run mode, so it is declared as a sync-only target rather than a CLI-registry entry. diff --git a/docs/api-reference.md b/docs/api-reference.md index 47c22e0b..1bac2642 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -812,7 +812,7 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl 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). +- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `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); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `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). - `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file. - `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 10e49c6b..b2407c87 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -265,9 +265,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C ## MCP server sync -`capabilities.mcpConfig` (`{ path, format, relocation? }`, `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`. +`capabilities.mcpConfig` (`{ path, format, relocation? }`, `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 (plus GitHub Copilot CLI as a sync-only target, below); 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`. -`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `/`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment. +**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing. + +`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `/`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment. 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, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index ff31d1d3..81d9112a 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -142,7 +142,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E | Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. | | Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. | | Animated status effects | Cosmetic. | -| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. | +| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. | ### Notifications diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5159a203..093e5c79 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -449,6 +449,7 @@ const capabilitiesSchema = z 'codex-toml', 'opencode-json', 'antigravity-json', + 'copilot-json', ] as const satisfies readonly McpConfigFormat[]), // The env var the CLI reads to move the file, and the path under it (same no-traversal // rule: sync writes there too). Resolved from the server env at call time, never here. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 09cc3136..22d70928 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -105,7 +105,13 @@ export type ModelConfigResolverName = 'deepseek-route'; export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort'; /** 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 type McpConfigFormat = + | 'claude-json' + | 'gemini-json' + | 'codex-toml' + | 'opencode-json' + | 'antigravity-json' + | 'copilot-json'; export interface CliLaunch { params: Record; diff --git a/src/mcp-sync-targets.ts b/src/mcp-sync-targets.ts new file mode 100644 index 00000000..a2292f6e --- /dev/null +++ b/src/mcp-sync-targets.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview MCP sync targets that are not Codeman run modes. + * + * `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an + * `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not + * something Codeman launches, so they have no registry entry (and no id to branch on): GitHub + * Copilot CLI is the first. They are plain data here, take part only when installed or when their + * config file already exists (an absent tool is reported `absent`, never created), and sort after + * the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied. + * + * @module mcp-sync-targets + */ + +import { accessSync, constants as fsConstants } from 'node:fs'; +import { homedir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import type { McpConfigFormat } from './config/cli-registry/types.js'; +import type { McpSyncTarget } from './mcp-sync.js'; + +export interface McpSyncOnlyTool { + id: string; + label: string; + /** Home-relative default location of the MCP config file. */ + path: string; + format: McpConfigFormat; + /** The env var the tool reads to move its home, and the file under it. */ + relocation?: { envVar: string; path: string }; + /** The executable whose presence on this machine means the tool is installed. */ + binary: string; +} + +export const MCP_SYNC_ONLY_TOOLS: readonly McpSyncOnlyTool[] = [ + { + id: 'copilot', + label: 'GitHub Copilot CLI', + path: '.copilot/mcp-config.json', + format: 'copilot-json', + // COPILOT_HOME replaces ~/.copilot (checked: `COPILOT_HOME= copilot mcp list` reads ). + relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' }, + binary: 'copilot', + }, +]; + +/** `name` is an executable file in the server's PATH, `~/.local/bin` or `/usr/local/bin`. */ +export function binaryOnPath(name: string, env: Record = process.env): boolean { + const dirs = [ + ...(env.PATH ?? '').split(delimiter).filter(Boolean), + join(homedir(), '.local', 'bin'), + '/usr/local/bin', + ]; + return dirs.some((dir) => { + try { + accessSync(join(dir, name), fsConstants.X_OK); + return true; + } catch { + return false; + } + }); +} + +/** The sync-only tools as sync targets, skipping any id the registry already provides. */ +export function mcpSyncOnlyTargets( + taken: ReadonlySet, + isInstalled: (binary: string) => boolean = binaryOnPath +): McpSyncTarget[] { + return MCP_SYNC_ONLY_TOOLS.filter((t) => !taken.has(t.id)).map((t) => ({ + id: t.id, + label: t.label, + path: t.path, + format: t.format, + ...(t.relocation ? { relocation: t.relocation } : {}), + installed: isInstalled(t.binary), + })); +} diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index 1e092ad0..14a9f67a 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -284,6 +284,29 @@ function toOpencode(s: McpServer): Record { return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true }; } +/** + * 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 { + 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 } : {}) }; +} + interface JsonDialect { /** Key holding the server table. */ key: string; @@ -291,12 +314,23 @@ interface JsonDialect { to(s: McpServer): Record | null; /** Top-level keys to seed when creating the file from nothing. */ seed?: Record; + /** + * 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 }; } const JSON_DIALECTS: Record, JsonDialect> = { 'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude }, 'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini }, 'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity }, + 'copilot-json': { + key: 'mcpServers', + from: fromCopilot, + to: toCopilot, + disabledIn: { file: 'settings.json', key: 'disabledMcpServers' }, + }, 'opencode-json': { key: 'mcp', from: fromOpencode, @@ -558,6 +592,32 @@ function resolveFile( return { file: join(dir, rel.path) }; } +/** + * 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 { + 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 }; +} + let applying = false; /** @@ -611,6 +671,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: continue; } const parsed = parseConfig(s.t.format, await readText(s.file)); + await applyCompanionDisabled(s.t.format, s.file, parsed.servers); s.servers = parsed.servers; s.names = parsed.names; s.res.servers = [...parsed.names]; diff --git a/src/web/public/index.html b/src/web/public/index.html index 335d3785..e486a02c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2644,17 +2644,17 @@

MCP servers

synced
-
+
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. + Adds a control that copies MCP servers between your enabled CLIs, and GitHub Copilot CLI if it is installed, by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.