mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
e6b258fc44
commit
41a10b159e
@@ -2,4 +2,4 @@
|
|||||||
"aicodeman": minor
|
"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.
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ const capabilitiesSchema = z
|
|||||||
.max(100)
|
.max(100)
|
||||||
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
|
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
|
||||||
.refine((v) => !v.split('/').includes('..'), 'must not contain ..'),
|
.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()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -804,6 +804,7 @@ const ANTIGRAVITY: CliEntry = {
|
|||||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||||
// SENT config needs the flag forced off — nothing is materialized.
|
// SENT config needs the flag forced off — nothing is materialized.
|
||||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
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
|
// 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
|
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
||||||
// reasoning model. Toolbar entry stays disabled for this mode.
|
// reasoning model. Toolbar entry stays disabled for this mode.
|
||||||
|
|||||||
@@ -517,7 +517,10 @@ export interface CliCapabilities {
|
|||||||
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
|
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
|
||||||
* skipped by sync rather than guessed at.
|
* 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
|
* 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
|
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||||
|
|||||||
+37
-7
@@ -25,7 +25,7 @@ import { promises as fs } from 'node:fs';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
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 {
|
export interface McpServer {
|
||||||
transport: 'stdio' | 'http' | 'sse';
|
transport: 'stdio' | 'http' | 'sse';
|
||||||
@@ -64,6 +64,8 @@ export interface McpSyncResult {
|
|||||||
targets: McpSyncTargetResult[];
|
targets: McpSyncTargetResult[];
|
||||||
/** Names defined differently by different CLIs; left untouched. */
|
/** Names defined differently by different CLIs; left untouched. */
|
||||||
conflicts: string[];
|
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<string, unknown> {
|
|||||||
|
|
||||||
function fromGemini(raw: unknown): McpServer | null {
|
function fromGemini(raw: unknown): McpServer | null {
|
||||||
if (!isRecord(raw)) return 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')
|
if (typeof raw.httpUrl === 'string')
|
||||||
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
|
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') {
|
if (typeof raw.command === 'string') {
|
||||||
return clean({
|
return clean({
|
||||||
transport: 'stdio',
|
transport: 'stdio',
|
||||||
@@ -148,7 +154,26 @@ function toGemini(s: McpServer): Record<string, unknown> {
|
|||||||
...(s.cwd ? { cwd: s.cwd } : {}),
|
...(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<string, unknown> | 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 {
|
function fromOpencode(raw: unknown): McpServer | null {
|
||||||
@@ -185,9 +210,10 @@ interface JsonDialect {
|
|||||||
seed?: Record<string, unknown>;
|
seed?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JSON_DIALECTS: Record<'claude-json' | 'gemini-json' | 'opencode-json', JsonDialect> = {
|
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||||
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
||||||
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
||||||
|
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
|
||||||
'opencode-json': {
|
'opencode-json': {
|
||||||
key: 'mcp',
|
key: 'mcp',
|
||||||
from: fromOpencode,
|
from: fromOpencode,
|
||||||
@@ -418,7 +444,7 @@ export function parseServers(format: McpFormat, text: string | null): McpServerM
|
|||||||
|
|
||||||
/** Whether this dialect can express the server. */
|
/** Whether this dialect can express the server. */
|
||||||
export function canExpress(format: McpFormat, s: McpServer): boolean {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +508,11 @@ export interface McpSyncOptions {
|
|||||||
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
|
* 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).
|
* 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<McpSyncResult> {
|
export async function syncMcpServers(
|
||||||
|
targets: McpSyncTarget[],
|
||||||
|
opts: McpSyncOptions,
|
||||||
|
unsupported: string[] = []
|
||||||
|
): Promise<McpSyncResult> {
|
||||||
const home = opts.home ?? homedir();
|
const home = opts.home ?? homedir();
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true)));
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1140,7 +1140,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const conflicts = data.conflicts.length
|
const conflicts = data.conflicts.length
|
||||||
? `<p>Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}</p>`
|
? `<p>Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}</p>`
|
||||||
: '';
|
: '';
|
||||||
show(`<ul>${rows.join('')}</ul>${conflicts}`);
|
const unsupported = data.unsupported?.length
|
||||||
|
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
|
||||||
|
: '';
|
||||||
|
show(`<ul>${rows.join('')}</ul>${conflicts}${unsupported}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
_setUpdateResult(html) {
|
_setUpdateResult(html) {
|
||||||
|
|||||||
@@ -30,12 +30,19 @@ function gate(req: FastifyRequest): ApiResponse<never> | null {
|
|||||||
return 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 {
|
export function registerMcpSyncRoutes(app: FastifyInstance): void {
|
||||||
const run = async (req: FastifyRequest, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
|
const run = async (req: FastifyRequest, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
|
||||||
const denied = gate(req);
|
const denied = gate(req);
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
try {
|
try {
|
||||||
return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }) };
|
return { success: true, data: await syncMcpServers(mcpSyncTargets(), { apply }, mcpUnsupportedLabels()) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const TARGETS: McpSyncTarget[] = [
|
|||||||
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json' },
|
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json' },
|
||||||
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json' },
|
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json' },
|
||||||
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml' },
|
{ 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' },
|
{ 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', () => {
|
describe('addServers', () => {
|
||||||
it('preserves other keys and existing servers, appends codex tables without touching the rest', () => {
|
it('preserves other keys and existing servers, appends codex tables without touching the rest', () => {
|
||||||
const out = JSON.parse(
|
const out = JSON.parse(
|
||||||
@@ -120,6 +165,11 @@ describe('syncMcpServers', () => {
|
|||||||
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
|
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 () => {
|
it('previews without writing and never leaks env values', async () => {
|
||||||
put('.claude.json', claudeFile);
|
put('.claude.json', claudeFile);
|
||||||
const r = await syncMcpServers(TARGETS, { apply: false, home });
|
const r = await syncMcpServers(TARGETS, { apply: false, home });
|
||||||
|
|||||||
Reference in New Issue
Block a user