From 41a10b159e7eb3a33af930d344a2c7f089e7a8e8 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:11:13 +0800 Subject: [PATCH] feat(mcp): add Antigravity, fix Gemini http/sse shape, report unsupported CLIs Formats verified against real agy/gemini/codex mcp add output. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mcp-sync-clis.md | 2 +- src/config/cli-registry/schema.ts | 2 +- src/config/cli-registry/stock.ts | 1 + src/config/cli-registry/types.ts | 5 +++- src/mcp-sync.ts | 44 ++++++++++++++++++++++----- src/web/public/settings-ui.js | 5 +++- src/web/routes/mcp-sync-routes.ts | 9 +++++- test/mcp-sync.test.ts | 50 +++++++++++++++++++++++++++++++ 8 files changed, 106 insertions(+), 12 deletions(-) diff --git a/.changeset/mcp-sync-clis.md b/.changeset/mcp-sync-clis.md index 20217ffe..e93ca423 100644 --- a/.changeset/mcp-sync-clis.md +++ b/.changeset/mcp-sync-clis.md @@ -2,4 +2,4 @@ "aicodeman": minor --- -MCP server sync between CLIs: Settings → Agents & CLIs → "Sync MCP servers across CLIs" (and `GET`/`POST /api/mcp-sync`) copies each enabled CLI's MCP servers into the others' config files (Claude, Gemini, Codex, OpenCode). It only adds missing servers, never edits or removes one, keeps a `.codeman-bak` of every file it changes, and reports same-name conflicts instead of overwriting. +MCP server sync between CLIs: Settings → Agents & CLIs → "Sync MCP servers across CLIs" (and `GET`/`POST /api/mcp-sync`) copies each enabled CLI's MCP servers into the others' config files (Claude, Gemini, Codex, OpenCode, Antigravity; enabled CLIs without a known MCP config are listed as unsupported). It only adds missing servers, never edits or removes one, keeps a `.codeman-bak` of every file it changes, and reports same-name conflicts instead of overwriting. diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index eca0d8ca..1017a9b6 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -386,7 +386,7 @@ const capabilitiesSchema = z .max(100) .regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/) .refine((v) => !v.split('/').includes('..'), 'must not contain ..'), - format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json']), + format: z.enum(['claude-json', 'gemini-json', 'codex-toml', 'opencode-json', 'antigravity-json']), }) .strict() .optional(), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index fc2dc2bb..47832e01 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -804,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. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 6d3bc393..9447bae5 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -517,7 +517,10 @@ export interface CliCapabilities { * 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: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' }; + mcpConfig?: { + path: string; + format: 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; + }; /** * 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 diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index 08216ae4..523fd1ea 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -25,7 +25,7 @@ import { promises as fs } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json'; +export type McpFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; export interface McpServer { transport: 'stdio' | 'http' | 'sse'; @@ -64,6 +64,8 @@ export interface McpSyncResult { targets: McpSyncTargetResult[]; /** Names defined differently by different CLIs; left untouched. */ conflicts: string[]; + /** Enabled agent CLIs with no known MCP config file, so sync cannot touch them. */ + unsupported: string[]; } // --------------------------------------------------------------------------- @@ -124,9 +126,13 @@ function toClaude(s: McpServer): Record { function fromGemini(raw: unknown): McpServer | null { if (!isRecord(raw)) return null; + // `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes + // today, and a bare `url` with no type is the legacy SSE form. if (typeof raw.httpUrl === 'string') return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) }); - if (typeof raw.url === 'string') return clean({ transport: 'sse', url: raw.url, headers: strMap(raw.headers) }); + if (typeof raw.url === 'string') { + return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) }); + } if (typeof raw.command === 'string') { return clean({ transport: 'stdio', @@ -148,7 +154,26 @@ function toGemini(s: McpServer): Record { ...(s.cwd ? { cwd: s.cwd } : {}), }; } - return { [s.transport === 'http' ? 'httpUrl' : 'url']: s.url, ...(s.headers ? { headers: s.headers } : {}) }; + 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; + if (typeof raw.serverUrl === 'string') + return clean({ transport: 'http', url: raw.serverUrl, 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 toAntigravity(s: McpServer): Record | 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 }; } function fromOpencode(raw: unknown): McpServer | null { @@ -185,9 +210,10 @@ interface JsonDialect { seed?: Record; } -const JSON_DIALECTS: Record<'claude-json' | 'gemini-json' | 'opencode-json', JsonDialect> = { +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 }, 'opencode-json': { key: 'mcp', from: fromOpencode, @@ -418,7 +444,7 @@ export function parseServers(format: McpFormat, text: string | null): McpServerM /** Whether this dialect can express the server. */ export function canExpress(format: McpFormat, s: McpServer): boolean { - if (format === 'codex-toml') return s.transport !== 'sse'; + if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse'; return true; } @@ -482,7 +508,11 @@ export interface McpSyncOptions { * 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). */ -export async function syncMcpServers(targets: McpSyncTarget[], opts: McpSyncOptions): Promise { +export async function syncMcpServers( + targets: McpSyncTarget[], + opts: McpSyncOptions, + unsupported: string[] = [] +): Promise { const home = opts.home ?? homedir(); const seen = new Set(); const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true))); @@ -551,5 +581,5 @@ export async function syncMcpServers(targets: McpSyncTarget[], opts: McpSyncOpti } } - return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort() }; + return { applied: opts.apply, targets: state.map((s) => s.res), conflicts: [...conflicts].sort(), unsupported }; } diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 167ae6b3..267cd19d 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1140,7 +1140,10 @@ Object.assign(CodemanApp.prototype, { const conflicts = data.conflicts.length ? `

Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}

` : ''; - show(`
    ${rows.join('')}
${conflicts}`); + const unsupported = data.unsupported?.length + ? `

No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}

` + : ''; + show(`
    ${rows.join('')}
${conflicts}${unsupported}`); }, _setUpdateResult(html) { diff --git a/src/web/routes/mcp-sync-routes.ts b/src/web/routes/mcp-sync-routes.ts index 2f53c839..1abcd128 100644 --- a/src/web/routes/mcp-sync-routes.ts +++ b/src/web/routes/mcp-sync-routes.ts @@ -30,12 +30,19 @@ function gate(req: FastifyRequest): ApiResponse | null { return null; } +/** Enabled agent CLIs with no known MCP config file (sync cannot touch them). */ +export function mcpUnsupportedLabels(): string[] { + return enabledClis() + .filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig) + .map((e) => e.label); +} + export function registerMcpSyncRoutes(app: FastifyInstance): void { const run = async (req: FastifyRequest, apply: boolean): Promise> => { const denied = gate(req); if (denied) return denied; try { - return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }) }; + return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }, mcpUnsupportedLabels()) }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } diff --git a/test/mcp-sync.test.ts b/test/mcp-sync.test.ts index bcc898ae..dc9d494c 100644 --- a/test/mcp-sync.test.ts +++ b/test/mcp-sync.test.ts @@ -9,6 +9,7 @@ const TARGETS: McpSyncTarget[] = [ { id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json' }, { id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json' }, { id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml' }, + { id: 'antigravity', label: 'Antigravity', path: '.gemini/config/mcp_config.json', format: 'antigravity-json' }, { id: 'opencode', label: 'OpenCode', path: '.config/opencode/opencode.json', format: 'opencode-json' }, ]; @@ -89,6 +90,50 @@ describe('dialect parsing', () => { }); }); +describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => { + it('reads and writes the antigravity dialect', () => { + const real = JSON.stringify({ + mcpServers: { + fs: { args: ['-y', '@mcp/fs'], command: 'npx', disabled: false, env: { K: 'v' } }, + web: { disabled: false, headers: { Authorization: 'Bearer T' }, serverUrl: 'https://x.test/mcp' }, + }, + }); + const servers = parseServers('antigravity-json', real); + expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } }); + expect(servers.web).toEqual({ + transport: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer T' }, + }); + const out = JSON.parse( + addServers('antigravity-json', null, { ...servers, s: { transport: 'sse', url: 'https://s' } }) + ); + expect(out.mcpServers.web.serverUrl).toBe('https://x.test/mcp'); + expect(out.mcpServers.fs.disabled).toBe(false); + expect(out.mcpServers.s).toBeUndefined(); + }); + + it('writes gemini http/sse as url + type, as `gemini mcp add` does', () => { + const out = JSON.parse( + addServers('gemini-json', null, { + web: { transport: 'http', url: 'https://x.test/mcp', headers: { A: 'b' } }, + s: { transport: 'sse', url: 'https://x.test/sse' }, + }) + ); + expect(out.mcpServers.web).toEqual({ url: 'https://x.test/mcp', type: 'http', headers: { A: 'b' } }); + expect(out.mcpServers.s).toEqual({ url: 'https://x.test/sse', type: 'sse' }); + expect(parseServers('gemini-json', JSON.stringify(out)).web.transport).toBe('http'); + }); + + it('reads codex output as written by `codex mcp add`', () => { + const real = + '[mcp_servers.fs]\ncommand = "npx"\nargs = ["-y", "@mcp/fs"]\n\n[mcp_servers.fs.env]\nK = "v"\n\n[mcp_servers.web]\nurl = "https://x.test/mcp"\n'; + const servers = parseServers('codex-toml', real); + expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } }); + expect(servers.web).toEqual({ transport: 'http', url: 'https://x.test/mcp' }); + }); +}); + describe('addServers', () => { it('preserves other keys and existing servers, appends codex tables without touching the rest', () => { const out = JSON.parse( @@ -120,6 +165,11 @@ describe('syncMcpServers', () => { mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } }, }); + it('passes the unsupported list through to the result', async () => { + const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']); + expect(r.unsupported).toEqual(['Pi']); + }); + it('previews without writing and never leaks env values', async () => { put('.claude.json', claudeFile); const r = await syncMcpServers(TARGETS, { apply: false, home });