Merge pull request #581: feat(mcp-sync): sync GitHub Copilot CLI's MCP servers too

Conflict in docs/wiki/Settings-Reference.md resolved by keeping #565's Apply
wording and adding Copilot (and COPILOT_HOME, which the bot's review found
missing from this list).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 03:40:20 +02:00
12 changed files with 372 additions and 11 deletions
+5
View File
@@ -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.
+1 -1
View File
@@ -833,7 +833,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`.
+4 -2
View File
@@ -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 `<value>/<relocation.path>`, 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 `<value>/<relocation.path>`, 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.
+1 -1
View File
@@ -152,7 +152,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 **Apply** or **Save** (Apply keeps Settings open), 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 **Apply** or **Save** (Apply keeps Settings open), 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`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
### Notifications
+1
View File
@@ -451,6 +451,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.
+7 -1
View File
@@ -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<string, ParamSpec>;
+74
View File
@@ -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=<dir> copilot mcp list` reads <dir>).
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<string, string | undefined> = 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<string>,
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),
}));
}
+61
View File
@@ -284,6 +284,29 @@ function toOpencode(s: McpServer): Record<string, unknown> {
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<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 } : {}) };
}
interface JsonDialect {
/** Key holding the server table. */
key: string;
@@ -291,12 +314,23 @@ interface JsonDialect {
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
/**
* 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<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'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<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 };
}
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];
+3 -3
View File
@@ -2705,17 +2705,17 @@
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity copilot github">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
<span class="set-row-desc">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.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers (and GitHub Copilot CLI's) into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>, <code>COPILOT_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
+10 -3
View File
@@ -10,7 +10,8 @@
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
* or when its config file exists); one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
@@ -28,6 +29,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
import { mcpSyncOnlyTargets } from '../../mcp-sync-targets.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
@@ -35,9 +37,13 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
return settings.mcpSyncEnabled === true;
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
/**
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
* definition wins a same-name difference.
*/
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
return enabledClis()
const registry = enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
@@ -46,6 +52,7 @@ export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTa
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
return [...registry, ...mcpSyncOnlyTargets(new Set(registry.map((t) => t.id)))];
}
/**
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment node
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { binaryOnPath, MCP_SYNC_ONLY_TOOLS, mcpSyncOnlyTargets } from '../src/mcp-sync-targets.js';
import { mcpSyncTargets } from '../src/web/routes/mcp-sync-routes.js';
describe('sync-only MCP targets (tools that are not Codeman run modes)', () => {
it('declares GitHub Copilot CLI with its config file, dialect and relocation var', () => {
const t = mcpSyncOnlyTargets(new Set(), () => true).find((x) => x.id === 'copilot')!;
expect(t).toMatchObject({
label: 'GitHub Copilot CLI',
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
installed: true,
});
});
it('asks about the declared binary, and reports it not installed when it is missing', () => {
const asked: string[] = [];
const t = mcpSyncOnlyTargets(new Set(), (b) => (asked.push(b), false));
expect(asked).toEqual(MCP_SYNC_ONLY_TOOLS.map((x) => x.binary));
expect(t.every((x) => x.installed === false)).toBe(true);
});
it('yields to a registry CLI that already uses the id', () => {
expect(mcpSyncOnlyTargets(new Set(['copilot']), () => true)).toEqual([]);
});
it('comes after the registry CLIs in the targets the route syncs', () => {
const ids = mcpSyncTargets({}).map((t) => t.id);
expect(ids).toContain('copilot');
expect(ids.indexOf('copilot')).toBe(ids.length - 1);
});
});
describe('binaryOnPath', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'bin-on-path-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('finds an executable in PATH and ignores a non-executable file of the same name', () => {
writeFileSync(join(dir, 'fake-tool'), '#!/bin/sh\n');
chmodSync(join(dir, 'fake-tool'), 0o644);
expect(binaryOnPath('fake-tool', { PATH: dir })).toBe(false);
chmodSync(join(dir, 'fake-tool'), 0o755);
expect(binaryOnPath('fake-tool', { PATH: dir })).toBe(true);
expect(binaryOnPath('no-such-tool-xyz', { PATH: dir })).toBe(false);
});
});
+151
View File
@@ -585,3 +585,154 @@ describe('relocated config dirs (the CLI reads its file somewhere else)', () =>
}
});
});
describe('GitHub Copilot CLI (copilot-json)', () => {
const COPILOT: McpSyncTarget = {
id: 'copilot',
label: 'GitHub Copilot CLI',
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
installed: true,
};
const withCopilot = (...ids: string[]) => [...only(...ids), COPILOT];
// Captured from `copilot mcp add` (1.0.94) into a throwaway COPILOT_HOME.
const REAL = JSON.stringify({
mcpServers: {
h1: {
tools: ['*'],
type: 'http',
url: 'https://example.invalid/mcp',
headers: { Authorization: 'Bearer x', 'X-A': 'b' },
},
s1: { tools: ['*'], type: 'sse', url: 'https://example.invalid/sse' },
l1: { tools: ['*'], type: 'local', command: 'node', args: ['server.js', '--flag'], env: { K: 'V', K2: 'V2' } },
l2: { tools: ['a', 'b'], type: 'local', command: 'npx', args: ['-y', 'pkg'] },
},
});
it('reads what `copilot mcp add` writes: local, http and sse, ignoring the tools filter', () => {
const servers = parseServers('copilot-json', REAL);
expect(servers.h1).toEqual({
transport: 'http',
url: 'https://example.invalid/mcp',
headers: { Authorization: 'Bearer x', 'X-A': 'b' },
});
expect(servers.s1).toEqual({ transport: 'sse', url: 'https://example.invalid/sse' });
expect(servers.l1).toEqual({
transport: 'stdio',
command: 'node',
args: ['server.js', '--flag'],
env: { K: 'V', K2: 'V2' },
});
expect(servers.l2).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'pkg'] });
});
it('writes servers the way `copilot mcp add` does: tools ["*"], type local/http/sse', () => {
const out = JSON.parse(
addServers('copilot-json', null, {
fs: { transport: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: '1' } },
web: { transport: 'http', url: 'https://w.test/mcp', headers: { A: 'b' } },
live: { transport: 'sse', url: 'https://l.test/sse' },
})
);
expect(out.mcpServers.fs).toEqual({
tools: ['*'],
type: 'local',
command: 'npx',
args: ['-y', 'fs'],
env: { T: '1' },
});
expect(out.mcpServers.web).toEqual({ tools: ['*'], type: 'http', url: 'https://w.test/mcp', headers: { A: 'b' } });
expect(out.mcpServers.live).toEqual({ tools: ['*'], type: 'sse', url: 'https://l.test/sse' });
});
it('keeps an existing server, its tools filter and every other key when adding', () => {
const out = JSON.parse(
addServers('copilot-json', JSON.stringify({ other: 1, mcpServers: JSON.parse(REAL).mcpServers }), {
l2: { transport: 'stdio', command: 'changed' },
n: { transport: 'stdio', command: 'x' },
})
);
expect(out.other).toBe(1);
expect(out.mcpServers.l2.tools).toEqual(['a', 'b']);
expect(out.mcpServers.l2.command).toBe('npx');
expect(out.mcpServers.n.type).toBe('local');
});
it('copies between Copilot and another CLI in both directions, with sse expressed', async () => {
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
put('.copilot/mcp-config.json', REAL);
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
expect(result(r, 'copilot').added).toEqual(['fs']);
expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 'l2', 's1']);
expect(JSON.parse(get('.copilot/mcp-config.json')).mcpServers.fs).toMatchObject({
type: 'local',
command: 'npx',
tools: ['*'],
});
expect(JSON.parse(get('.claude.json')).mcpServers.s1).toMatchObject({ type: 'sse' });
// second run: nothing left to do
const again = await syncMcpServers(withCopilot('claude'), { apply: true, home });
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
});
it('does not copy a server switched off with `copilot mcp disable` (it lives in settings.json)', async () => {
put('.copilot/mcp-config.json', REAL);
put('.copilot/settings.json', JSON.stringify({ disabledMcpServers: ['l2'], theme: 'dark' }));
put('.claude.json', JSON.stringify({ mcpServers: {} }));
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 's1']);
expect(JSON.parse(get('.claude.json')).mcpServers.l2).toBeUndefined();
expect(r.disabled).toEqual(['l2']);
// settings.json is only ever read
expect(JSON.parse(get('.copilot/settings.json'))).toEqual({ disabledMcpServers: ['l2'], theme: 'dark' });
});
it('a name switched off in Copilot but live in another CLI is still synced from the live one', async () => {
put('.copilot/mcp-config.json', REAL);
put('.copilot/settings.json', JSON.stringify({ disabledMcpServers: ['l2'] }));
put('.claude.json', JSON.stringify({ mcpServers: { l2: { type: 'stdio', command: 'npx', args: ['-y', 'pkg'] } } }));
const r = await syncMcpServers(withCopilot('claude'), { apply: false, home });
expect(r.disabled).toEqual([]);
expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 's1']);
});
it('reports the target unreadable, and writes nothing, when settings.json is not valid JSON', async () => {
put('.copilot/mcp-config.json', REAL);
put('.copilot/settings.json', '{ "disabledMcpServers": ["l2", "SECRET-NAME" ');
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
expect(result(r, 'copilot').status).toBe('unreadable');
expect(result(r, 'copilot').error).toContain('settings.json');
expect(result(r, 'copilot').error).not.toContain('SECRET-NAME');
expect(get('.copilot/mcp-config.json')).toBe(REAL);
});
it('follows COPILOT_HOME and never touches ~/.copilot then', async () => {
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
const copilotHome = join(home, 'elsewhere/copilot');
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home, env: { COPILOT_HOME: copilotHome } });
expect(result(r, 'copilot').file).toBe(join(copilotHome, 'mcp-config.json'));
expect(JSON.parse(readFileSync(join(copilotHome, 'mcp-config.json'), 'utf8')).mcpServers.fs.type).toBe('local');
expect(existsSync(join(home, '.copilot'))).toBe(false);
});
it('is left alone when it is neither installed nor configured', async () => {
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
const r = await syncMcpServers([...only('claude'), { ...COPILOT, installed: false }], { apply: true, home });
expect(result(r, 'copilot').status).toBe('absent');
expect(existsSync(join(home, '.copilot'))).toBe(false);
});
it('keeps the env values of a copied server out of the result and the file private', async () => {
put(
'.claude.json',
JSON.stringify({ mcpServers: { s: { type: 'stdio', command: 'x', env: { TOKEN: 'hunter2' } } } })
);
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
expect(JSON.stringify(r)).not.toContain('hunter2');
expect(statSync(join(home, '.copilot/mcp-config.json')).mode & 0o077).toBe(0);
});
});