Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-sync-copilot.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 4 additions & 2 deletions docs/cli-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Settings-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/config/cli-registry/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/config/cli-registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
74 changes: 74 additions & 0 deletions src/mcp-sync-targets.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 61 additions & 0 deletions src/mcp-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,19 +284,53 @@ 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;
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>;
/**
* 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,
Expand Down Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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];
Expand Down
Loading
Loading