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-clis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---

Opt-in MCP server sync between CLIs. Turn on Settings → Agents & CLIs → MCP servers → "Enable MCP server sync" (`mcpSyncEnabled`, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on), then Preview or Sync now to copy each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. Enabled CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
20 changes: 20 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,26 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |

## MCP server sync

Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.

| Method | Path | Body | Notes |
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |

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).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `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).
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).

The result carries server **names** only, never `env` values or `headers`. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.

## Voice dictation

Browser dictation transcribed through this server's Claude Code login, i.e. the
Expand Down
6 changes: 6 additions & 0 deletions docs/cli-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.

## MCP server sync

`capabilities.mcpConfig` (`{ path, format }`, `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`.

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. The schema restricts `path` to a home-relative path without `..`, since sync writes to it.

## See also

- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
Expand Down
2 changes: 2 additions & 0 deletions docs/wiki/HTTP-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
curl -s "$API/api/subagents" | jq # background agents
curl -s "$API/api/search?q=deploy" | jq # cross-session search
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove

# with ID set to a session id:
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"smol-toml": "^1.9.0",
"undici": "^6.28.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
Expand Down
22 changes: 22 additions & 0 deletions src/config/cli-registry/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat } from './types.js';

/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
Expand Down Expand Up @@ -377,6 +378,27 @@ const capabilitiesSchema = z
privilegedEnvKeys: z.array(envName).max(8),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
mcpConfig: z
.object({
// Home-relative, no traversal: sync writes to this path.
path: z
.string()
.min(1)
.max(100)
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
.refine((v) => !v.split('/').includes('..'), 'must not contain ..'),
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
'claude-json',
'gemini-json',
'codex-toml',
'opencode-json',
'antigravity-json',
] as const satisfies readonly McpConfigFormat[]),
})
.strict()
.optional(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
Expand Down
5 changes: 5 additions & 0 deletions src/config/cli-registry/stock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ const CLAUDE: CliEntry = {
'CLAUDE_CONFIG_DIR',
],
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
mcpConfig: { path: '.claude.json', format: 'claude-json' },
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
// llama.cpp server. Claude reads these at process start only, so switching requires a
// respawn, never a live hot-swap.
Expand Down Expand Up @@ -486,6 +487,7 @@ const OPENCODE: CliEntry = {
...agentDefaults(),
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
mcpConfig: { path: '.config/opencode/opencode.json', format: 'opencode-json' },
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
Expand Down Expand Up @@ -620,6 +622,7 @@ const CODEX: CliEntry = {
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
mcpConfig: { path: '.codex/config.toml', format: 'codex-toml' },
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
// so the user's real ~/.codex/config.toml is never touched.
customModelInjection: {
Expand Down Expand Up @@ -721,6 +724,7 @@ const GEMINI: CliEntry = {
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
// sends no geminiConfig at all would still get yolo for free.
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
mcpConfig: { path: '.gemini/settings.json', format: 'gemini-json' },
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
// start). Confirm the exact model-override env var name against the installed
// gemini-cli version before shipping.
Expand Down Expand Up @@ -800,6 +804,7 @@ const ANTIGRAVITY: CliEntry = {
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
// SENT config needs the flag forced off — nothing is materialized.
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
// custom-endpoint setting and explicitly say it "cannot currently" become the core
// reasoning model. Toolbar entry stays disabled for this mode.
Expand Down
10 changes: 10 additions & 0 deletions src/config/cli-registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export interface CliVariant {
args: ArgSpec[];
}

/** 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 interface CliLaunch {
params: Record<string, ParamSpec>;
/**
Expand Down Expand Up @@ -511,6 +514,13 @@ export interface CliCapabilities {
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
/**
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
* `path` is relative to the home directory. `format` names the file dialect the sync
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
* skipped by sync rather than guessed at.
*/
mcpConfig?: { path: string; format: McpConfigFormat };
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
Expand Down
Loading
Loading