2026-10-02 18:01:12 +08:00
|
|
|
// @vitest-environment node
|
2026-10-02 21:15:31 +08:00
|
|
|
import {
|
|
|
|
|
chmodSync,
|
|
|
|
|
existsSync,
|
|
|
|
|
lstatSync,
|
|
|
|
|
mkdirSync,
|
|
|
|
|
mkdtempSync,
|
|
|
|
|
readdirSync,
|
|
|
|
|
readFileSync,
|
|
|
|
|
rmSync,
|
|
|
|
|
statSync,
|
|
|
|
|
symlinkSync,
|
|
|
|
|
writeFileSync,
|
|
|
|
|
} from 'node:fs';
|
2026-10-02 18:01:12 +08:00
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
2026-10-02 21:15:31 +08:00
|
|
|
import { parse as parseToml } from 'smol-toml';
|
2026-10-02 18:01:12 +08:00
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
2026-10-02 21:15:31 +08:00
|
|
|
import { addServers, McpSyncBusyError, parseServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
|
2026-10-02 18:01:12 +08:00
|
|
|
|
|
|
|
|
const TARGETS: McpSyncTarget[] = [
|
2026-10-02 21:15:31 +08:00
|
|
|
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json', installed: true },
|
|
|
|
|
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json', installed: true },
|
|
|
|
|
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml', installed: true },
|
|
|
|
|
{
|
|
|
|
|
id: 'antigravity',
|
|
|
|
|
label: 'Antigravity',
|
|
|
|
|
path: '.gemini/config/mcp_config.json',
|
|
|
|
|
format: 'antigravity-json',
|
|
|
|
|
installed: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'opencode',
|
|
|
|
|
label: 'OpenCode',
|
|
|
|
|
path: '.config/opencode/opencode.json',
|
|
|
|
|
format: 'opencode-json',
|
|
|
|
|
installed: true,
|
|
|
|
|
},
|
2026-10-02 18:01:12 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let home: string;
|
|
|
|
|
const put = (rel: string, text: string) => {
|
|
|
|
|
const file = join(home, rel);
|
|
|
|
|
mkdirSync(join(file, '..'), { recursive: true });
|
|
|
|
|
writeFileSync(file, text);
|
|
|
|
|
};
|
|
|
|
|
const get = (rel: string) => readFileSync(join(home, rel), 'utf8');
|
2026-10-02 21:15:31 +08:00
|
|
|
const target = (id: string, patch: Partial<McpSyncTarget> = {}) => ({
|
|
|
|
|
...TARGETS.find((t) => t.id === id)!,
|
|
|
|
|
...patch,
|
|
|
|
|
});
|
|
|
|
|
const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id));
|
|
|
|
|
const result = (r: Awaited<ReturnType<typeof syncMcpServers>>, id: string) => r.targets.find((t) => t.id === id)!;
|
2026-10-02 18:01:12 +08:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
home = mkdtempSync(join(tmpdir(), 'mcp-sync-'));
|
|
|
|
|
});
|
|
|
|
|
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
|
|
|
|
|
|
|
|
describe('dialect parsing', () => {
|
|
|
|
|
it('reads codex TOML tables, inline tables and multi-line arrays', () => {
|
|
|
|
|
const servers = parseServers(
|
|
|
|
|
'codex-toml',
|
|
|
|
|
[
|
|
|
|
|
'model = "gpt-5"',
|
|
|
|
|
'',
|
|
|
|
|
'[mcp_servers.fs]',
|
|
|
|
|
'command = "npx"',
|
|
|
|
|
'args = [',
|
|
|
|
|
' "-y", # comment',
|
|
|
|
|
' "@mcp/fs",',
|
|
|
|
|
']',
|
|
|
|
|
'env = { TOKEN = "abc" }',
|
|
|
|
|
'',
|
|
|
|
|
'[mcp_servers."a.b".env]',
|
|
|
|
|
'K = "v"',
|
|
|
|
|
'',
|
|
|
|
|
'[mcp_servers."a.b"]',
|
|
|
|
|
'command = "x"',
|
|
|
|
|
'',
|
|
|
|
|
'[mcp_servers.web]',
|
|
|
|
|
'url = "https://x.test/mcp"',
|
|
|
|
|
'[mcp_servers.web.http_headers]',
|
|
|
|
|
'Authorization = "Bearer t"',
|
|
|
|
|
].join('\n')
|
|
|
|
|
);
|
|
|
|
|
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { TOKEN: 'abc' } });
|
|
|
|
|
expect(servers['a.b']).toEqual({ transport: 'stdio', command: 'x', env: { K: 'v' } });
|
|
|
|
|
expect(servers.web).toEqual({
|
|
|
|
|
transport: 'http',
|
|
|
|
|
url: 'https://x.test/mcp',
|
|
|
|
|
headers: { Authorization: 'Bearer t' },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
it('reads CRLF codex files (the old offset math saw no servers at all)', () => {
|
|
|
|
|
const servers = parseServers('codex-toml', '[mcp_servers.fs]\r\ncommand = "npx"\r\nargs = ["-y"]\r\n');
|
|
|
|
|
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
|
2026-10-02 18:01:12 +08:00
|
|
|
const g = parseServers(
|
|
|
|
|
'gemini-json',
|
|
|
|
|
JSON.stringify({
|
2026-10-02 21:15:31 +08:00
|
|
|
mcpServers: {
|
|
|
|
|
a: { url: 'https://a' },
|
|
|
|
|
b: { httpUrl: 'https://b' },
|
|
|
|
|
c: { command: 'c', args: ['1'] },
|
|
|
|
|
d: { url: 'https://d', type: 'http' },
|
|
|
|
|
},
|
2026-10-02 18:01:12 +08:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
expect(g.a.transport).toBe('sse');
|
|
|
|
|
expect(g.b.transport).toBe('http');
|
|
|
|
|
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(g.d.transport).toBe('http');
|
2026-10-02 18:01:12 +08:00
|
|
|
const o = parseServers(
|
|
|
|
|
'opencode-json',
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
mcp: {
|
|
|
|
|
l: { type: 'local', command: ['npx', '-y', 'x'], environment: { A: '1' } },
|
|
|
|
|
r: { type: 'remote', url: 'https://r' },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
expect(o.l).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'], env: { A: '1' } });
|
|
|
|
|
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
it('throws on unparseable files so they are never written', () => {
|
2026-10-02 18:01:12 +08:00
|
|
|
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();
|
2026-10-02 18:01:12 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 18:11:13 +08:00
|
|
|
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' });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 18:24:23 +08:00
|
|
|
describe('hostile config files', () => {
|
2026-10-02 21:15:31 +08:00
|
|
|
it('never lets a server name or a sub-table key reach Object.prototype', () => {
|
2026-10-02 18:24:23 +08:00
|
|
|
const toml = parseServers(
|
|
|
|
|
'codex-toml',
|
2026-10-02 21:15:31 +08:00
|
|
|
[
|
|
|
|
|
'[mcp_servers.__proto__]',
|
|
|
|
|
'command = "x"',
|
|
|
|
|
'polluted = "yes"',
|
|
|
|
|
'[mcp_servers.fs.__proto__]',
|
|
|
|
|
'polluted = "yes"',
|
|
|
|
|
'[mcp_servers.fs]',
|
|
|
|
|
'command = "y"',
|
|
|
|
|
'[mcp_servers.toString]',
|
|
|
|
|
'command = "t"',
|
|
|
|
|
'call = "x"',
|
|
|
|
|
].join('\n')
|
2026-10-02 18:24:23 +08:00
|
|
|
);
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(Object.keys(toml).sort()).toEqual(['fs', 'toString']);
|
2026-10-02 18:24:23 +08:00
|
|
|
const json = parseServers(
|
|
|
|
|
'claude-json',
|
2026-10-02 21:15:31 +08:00
|
|
|
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"hasOwnProperty":{"command":"h"},"ok":{"command":"y"}}}'
|
2026-10-02 18:24:23 +08:00
|
|
|
);
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(Object.keys(json).sort()).toEqual(['hasOwnProperty', 'ok']);
|
2026-10-02 18:24:23 +08:00
|
|
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
|
|
|
expect(({} as Record<string, unknown>).command).toBeUndefined();
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(typeof Object.prototype.toString.call).toBe('function');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats servers named like Object.prototype members as ordinary names across CLIs', async () => {
|
|
|
|
|
put(
|
|
|
|
|
'.claude.json',
|
|
|
|
|
JSON.stringify({ mcpServers: { toString: { command: 'a' }, hasOwnProperty: { command: 'b' } } })
|
|
|
|
|
);
|
|
|
|
|
put('.gemini/settings.json', JSON.stringify({ mcpServers: {} }));
|
|
|
|
|
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
|
|
|
|
expect(r.conflicts).toEqual([]);
|
|
|
|
|
expect(result(r, 'gemini').added.sort()).toEqual(['hasOwnProperty', 'toString']);
|
|
|
|
|
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual([
|
|
|
|
|
'hasOwnProperty',
|
|
|
|
|
'toString',
|
|
|
|
|
]);
|
2026-10-02 18:24:23 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects a non-object server table instead of overwriting it', () => {
|
|
|
|
|
expect(() => parseServers('claude-json', '{"mcpServers":[]}')).toThrow();
|
|
|
|
|
expect(() => parseServers('claude-json', '[]')).toThrow();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
describe('addServers', () => {
|
2026-10-02 21:15:31 +08:00
|
|
|
it('preserves other keys and existing servers', () => {
|
2026-10-02 18:01:12 +08:00
|
|
|
const out = JSON.parse(
|
|
|
|
|
addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), {
|
|
|
|
|
keep: { transport: 'stdio', command: 'OVERWRITE' },
|
|
|
|
|
n: { transport: 'stdio', command: 'n' },
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
expect(out.theme).toBe('dark');
|
|
|
|
|
expect(out.mcpServers.keep).toEqual({ command: 'k' });
|
|
|
|
|
expect(out.mcpServers.n.command).toBe('n');
|
2026-10-02 21:15:31 +08:00
|
|
|
});
|
2026-10-02 18:01:12 +08:00
|
|
|
|
2026-10-02 21:15:31 +08:00
|
|
|
it('appends codex tables without touching the rest, and quotes odd names', () => {
|
2026-10-02 18:01:12 +08:00
|
|
|
const toml = addServers('codex-toml', 'model = "x"\n', {
|
|
|
|
|
'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } },
|
|
|
|
|
});
|
|
|
|
|
expect(toml.startsWith('model = "x"\n')).toBe(true);
|
|
|
|
|
expect(parseServers('codex-toml', toml)['we ird']).toEqual({
|
|
|
|
|
transport: 'stdio',
|
|
|
|
|
command: 'c',
|
|
|
|
|
args: ['a"b'],
|
|
|
|
|
env: { K: 'v' },
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-02 21:15:31 +08:00
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['CRLF line endings', '[mcp_servers.fs]\r\ncommand = "npx"\r\n'],
|
|
|
|
|
['an [mcp_servers] table with inline tables', '[mcp_servers]\nfs = { command = "npx" }\n'],
|
|
|
|
|
['a table with neither command nor url', '[mcp_servers.fs]\nstartup_timeout_sec = 30\n'],
|
|
|
|
|
])('never appends a second [mcp_servers.fs] to a codex file with %s', (_label, existing) => {
|
|
|
|
|
const out = addServers('codex-toml', existing, {
|
|
|
|
|
fs: { transport: 'stdio', command: 'other' },
|
|
|
|
|
extra: { transport: 'stdio', command: 'e' },
|
|
|
|
|
});
|
|
|
|
|
// Still valid TOML (a duplicate header would throw), fs untouched, extra added.
|
|
|
|
|
const doc = parseToml(out) as { mcp_servers: Record<string, Record<string, unknown>> };
|
|
|
|
|
expect(Object.keys(doc.mcp_servers).sort()).toEqual(['extra', 'fs']);
|
|
|
|
|
expect(doc.mcp_servers.fs.command === 'other').toBe(false);
|
|
|
|
|
expect(out.startsWith(existing)).toBe(true);
|
|
|
|
|
if (existing.includes('\r\n')) expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses to write when the result would not read back as intended', () => {
|
|
|
|
|
// A name TOML cannot carry as a bare key still round-trips (quoted); a duplicate cannot.
|
|
|
|
|
expect(() => addServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\n', {})).toThrow();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('disabled servers are not propagated', () => {
|
|
|
|
|
it('codex enabled=false, opencode enabled:false and antigravity disabled:true stay where they are', async () => {
|
|
|
|
|
put('.codex/config.toml', '[mcp_servers.off_codex]\ncommand = "a"\nenabled = false\n');
|
|
|
|
|
put(
|
|
|
|
|
'.config/opencode/opencode.json',
|
|
|
|
|
JSON.stringify({ mcp: { off_oc: { type: 'local', command: ['b'], enabled: false } } })
|
|
|
|
|
);
|
|
|
|
|
put(
|
|
|
|
|
'.gemini/config/mcp_config.json',
|
|
|
|
|
JSON.stringify({ mcpServers: { off_agy: { command: 'c', disabled: true } } })
|
|
|
|
|
);
|
|
|
|
|
put('.claude.json', JSON.stringify({ mcpServers: { live: { type: 'stdio', command: 'l' } } }));
|
|
|
|
|
mkdirSync(join(home, '.gemini'), { recursive: true });
|
|
|
|
|
put('.gemini/settings.json', '{}');
|
|
|
|
|
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
|
|
|
|
expect(r.disabled).toEqual(['off_agy', 'off_codex', 'off_oc']);
|
|
|
|
|
expect(Object.keys(JSON.parse(get('.claude.json')).mcpServers)).toEqual(['live']);
|
|
|
|
|
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers)).toEqual(['live']);
|
|
|
|
|
// ...and each CLI still gets the live one.
|
|
|
|
|
expect(get('.codex/config.toml')).toContain('[mcp_servers.live]');
|
|
|
|
|
expect(get('.codex/config.toml')).not.toContain('off_oc');
|
|
|
|
|
// The switched-off entry itself is left as it was (still disabled).
|
|
|
|
|
expect(get('.codex/config.toml')).toContain('enabled = false');
|
|
|
|
|
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.off_oc.enabled).toBe(false);
|
|
|
|
|
expect(JSON.parse(get('.gemini/config/mcp_config.json')).mcpServers.off_agy.disabled).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a name switched off in one CLI and live in another is still synced from the live one', async () => {
|
|
|
|
|
put('.codex/config.toml', '[mcp_servers.fs]\ncommand = "a"\nenabled = false\n');
|
|
|
|
|
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'a' } } }));
|
|
|
|
|
put('.gemini/settings.json', '{}');
|
|
|
|
|
const r = await syncMcpServers(only('claude', 'codex', 'gemini'), { apply: true, home });
|
|
|
|
|
expect(r.disabled).toEqual([]);
|
|
|
|
|
expect(result(r, 'gemini').added).toEqual(['fs']);
|
|
|
|
|
});
|
2026-10-02 18:01:12 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('syncMcpServers', () => {
|
|
|
|
|
const claudeFile = JSON.stringify({
|
|
|
|
|
numStartups: 3,
|
|
|
|
|
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
|
|
|
|
|
});
|
2026-10-02 21:15:31 +08:00
|
|
|
const setUpAll = () => {
|
|
|
|
|
for (const d of ['.gemini/config', '.codex', '.config/opencode']) mkdirSync(join(home, d), { recursive: true });
|
|
|
|
|
put('.gemini/settings.json', '{}');
|
|
|
|
|
put('.codex/config.toml', '');
|
|
|
|
|
put('.config/opencode/opencode.json', '{}');
|
|
|
|
|
put('.gemini/config/mcp_config.json', '{}');
|
|
|
|
|
};
|
2026-10-02 18:11:13 +08:00
|
|
|
|
2026-10-02 18:01:12 +08:00
|
|
|
it('previews without writing and never leaks env values', async () => {
|
2026-10-02 21:15:31 +08:00
|
|
|
setUpAll();
|
2026-10-02 18:01:12 +08:00
|
|
|
put('.claude.json', claudeFile);
|
2026-10-02 21:15:31 +08:00
|
|
|
const before = get('.gemini/settings.json');
|
2026-10-02 18:01:12 +08:00
|
|
|
const r = await syncMcpServers(TARGETS, { apply: false, home });
|
|
|
|
|
expect(r.applied).toBe(false);
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(result(r, 'gemini').added).toEqual(['fs']);
|
|
|
|
|
expect(get('.gemini/settings.json')).toBe(before);
|
2026-10-02 18:01:12 +08:00
|
|
|
expect(JSON.stringify(r)).not.toContain('s3cret');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => {
|
2026-10-02 21:15:31 +08:00
|
|
|
setUpAll();
|
2026-10-02 18:01:12 +08:00
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n');
|
|
|
|
|
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(result(r, 'claude').added).toEqual(['web']);
|
|
|
|
|
expect(result(r, 'codex').added).toEqual(['fs']);
|
2026-10-02 18:01:12 +08:00
|
|
|
expect(JSON.parse(get('.claude.json')).numStartups).toBe(3);
|
|
|
|
|
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']);
|
|
|
|
|
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']);
|
|
|
|
|
expect(get('.codex/config.toml')).toContain('model = "gpt-5"');
|
|
|
|
|
expect(existsSync(join(home, '.claude.json.codeman-bak'))).toBe(true);
|
|
|
|
|
|
|
|
|
|
const again = await syncMcpServers(TARGETS, { apply: true, home });
|
|
|
|
|
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => {
|
2026-10-02 21:15:31 +08:00
|
|
|
setUpAll();
|
2026-10-02 18:01:12 +08:00
|
|
|
put(
|
|
|
|
|
'.claude.json',
|
|
|
|
|
JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } })
|
|
|
|
|
);
|
|
|
|
|
put('.gemini/settings.json', JSON.stringify({ mcpServers: { x: { command: 'two' } } }));
|
|
|
|
|
const broken = '{ // jsonc\n "mcp": {} }';
|
|
|
|
|
put('.config/opencode/opencode.json', broken);
|
|
|
|
|
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
|
|
|
|
expect(r.conflicts).toEqual(['x']);
|
|
|
|
|
expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two');
|
2026-10-02 21:15:31 +08:00
|
|
|
expect(result(r, 'codex').skipped).toEqual(['sse']);
|
|
|
|
|
expect(result(r, 'opencode').status).toBe('unreadable');
|
2026-10-02 18:01:12 +08:00
|
|
|
expect(get('.config/opencode/opencode.json')).toBe(broken);
|
|
|
|
|
});
|
2026-10-02 21:15:31 +08:00
|
|
|
|
|
|
|
|
it('passes the unsupported list through to the result', async () => {
|
|
|
|
|
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
|
|
|
|
|
expect(r.unsupported).toEqual(['Pi']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('only CLIs that are installed or already have a config file take part', () => {
|
|
|
|
|
it('never creates config for a CLI that is neither installed nor configured', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
const notInstalled = TARGETS.map((t) => (t.id === 'claude' ? t : { ...t, installed: false }));
|
|
|
|
|
const r = await syncMcpServers(notInstalled, { apply: true, home });
|
|
|
|
|
for (const id of ['gemini', 'codex', 'antigravity', 'opencode']) expect(result(r, id).status).toBe('absent');
|
|
|
|
|
expect(existsSync(join(home, '.codex'))).toBe(false);
|
|
|
|
|
expect(existsSync(join(home, '.gemini'))).toBe(false);
|
|
|
|
|
expect(existsSync(join(home, '.config'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a CLI that is not detected as installed still takes part if its config file exists', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
put('.gemini/settings.json', '{}');
|
|
|
|
|
const r = await syncMcpServers([target('claude'), target('gemini', { installed: false })], { apply: true, home });
|
|
|
|
|
expect(result(r, 'gemini').added).toEqual(['fs']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('an installed CLI with no config yet gets one created', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
const r = await syncMcpServers([target('claude'), target('codex')], { apply: true, home });
|
|
|
|
|
expect(result(r, 'codex').added).toEqual(['fs']);
|
|
|
|
|
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('file safety', () => {
|
|
|
|
|
it('leaves a file that receives env values or headers readable by its owner only', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
put('.gemini/settings.json', '{}');
|
|
|
|
|
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
|
|
|
|
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
|
|
|
|
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o600);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the existing mode when nothing secret is copied', async () => {
|
|
|
|
|
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
|
|
|
|
|
put('.gemini/settings.json', '{}');
|
|
|
|
|
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
|
|
|
|
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
|
|
|
|
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o664);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('writes through a symlinked config instead of replacing the link', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
mkdirSync(join(home, 'dotfiles'), { recursive: true });
|
|
|
|
|
writeFileSync(join(home, 'dotfiles/gemini-settings.json'), '{}');
|
|
|
|
|
mkdirSync(join(home, '.gemini'), { recursive: true });
|
|
|
|
|
symlinkSync(join(home, 'dotfiles/gemini-settings.json'), join(home, '.gemini/settings.json'));
|
|
|
|
|
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
|
|
|
|
expect(lstatSync(join(home, '.gemini/settings.json')).isSymbolicLink()).toBe(true);
|
|
|
|
|
expect(JSON.parse(readFileSync(join(home, 'dotfiles/gemini-settings.json'), 'utf8')).mcpServers.fs.command).toBe(
|
|
|
|
|
'npx'
|
|
|
|
|
);
|
|
|
|
|
expect(existsSync(join(home, 'dotfiles/gemini-settings.json.codeman-bak'))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports a dangling symlink as failed and writes nothing', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
mkdirSync(join(home, '.gemini'), { recursive: true });
|
|
|
|
|
symlinkSync(join(home, 'nowhere.json'), join(home, '.gemini/settings.json'));
|
|
|
|
|
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
|
|
|
|
expect(result(r, 'gemini').status).toBe('failed');
|
|
|
|
|
expect(existsSync(join(home, 'nowhere.json'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses a second apply while one is running, and leaves no temp files behind', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
put('.gemini/settings.json', '{}');
|
|
|
|
|
const first = syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
|
|
|
|
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).rejects.toBeInstanceOf(
|
|
|
|
|
McpSyncBusyError
|
|
|
|
|
);
|
|
|
|
|
await first;
|
|
|
|
|
// A preview is read-only and is never refused.
|
|
|
|
|
await expect(syncMcpServers(only('claude', 'gemini'), { apply: false, home })).resolves.toBeDefined();
|
|
|
|
|
// ...and the lock is released afterwards.
|
|
|
|
|
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).resolves.toBeDefined();
|
|
|
|
|
const leftovers = readdirSync(join(home, '.gemini')).filter((f) => f.includes('codeman-tmp'));
|
|
|
|
|
expect(leftovers).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-02 18:01:12 +08:00
|
|
|
});
|
2026-10-04 23:43:36 +02:00
|
|
|
|
|
|
|
|
describe('error messages never quote the config file (it holds env values and headers)', () => {
|
|
|
|
|
const claudeWithSecret = JSON.stringify({
|
|
|
|
|
mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sk-COPIED-SECRET' } } },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports an unparseable TOML file by line and column only', async () => {
|
|
|
|
|
put('.claude.json', claudeWithSecret);
|
|
|
|
|
put(
|
|
|
|
|
'.codex/config.toml',
|
|
|
|
|
'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n'
|
|
|
|
|
);
|
|
|
|
|
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
|
|
|
|
const codex = result(r, 'codex');
|
|
|
|
|
expect(codex.status).toBe('unreadable');
|
|
|
|
|
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
|
|
|
|
expect(JSON.stringify(r)).not.toContain('lin_SECRET_abc');
|
|
|
|
|
expect(JSON.stringify(r)).not.toContain('LINEAR_API_KEY');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports an unparseable JSON file by position, or by category when V8 quotes source instead', async () => {
|
|
|
|
|
put('.claude.json', claudeWithSecret);
|
|
|
|
|
// V8: `Unexpected token 's', ..."TOKEN":sk-GEMINI-SECRET}"... is not valid JSON` (no position).
|
|
|
|
|
put('.gemini/settings.json', '{"mcpServers":{"g":{"command":"x","env":{"TOKEN":sk-GEMINI-SECRET}}}}');
|
|
|
|
|
// V8: `Expected ',' or '}' after property value in JSON at position N (line 2 column M)`.
|
|
|
|
|
put('.gemini/config/mcp_config.json', '{\n "mcpServers": {"a": {"env": {"K": "sk-AGY-SECRET" "x"}}}\n}');
|
|
|
|
|
const r = await syncMcpServers(only('claude', 'gemini', 'antigravity'), { apply: false, home });
|
|
|
|
|
expect(result(r, 'gemini').status).toBe('unreadable');
|
|
|
|
|
expect(result(r, 'gemini').error).toBe('not valid JSON');
|
|
|
|
|
expect(result(r, 'antigravity').error).toMatch(/^not valid JSON \(line 2, column \d+\)$/);
|
|
|
|
|
const body = JSON.stringify(r);
|
|
|
|
|
for (const secret of ['sk-GEMINI-SECRET', 'sk-AGY-SECRET', 'TOKEN']) expect(body).not.toContain(secret);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a write refused at the re-parse quotes neither the file nor the copied server', async () => {
|
|
|
|
|
put('.claude.json', claudeWithSecret);
|
|
|
|
|
// An inline top-level table parses, but appending `[mcp_servers.fs]` to it does not.
|
|
|
|
|
const inline = 'mcp_servers = { a = { command = "x", env = { K = "sk-FILE-SECRET" } } }\n';
|
|
|
|
|
put('.codex/config.toml', inline);
|
|
|
|
|
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
|
|
|
|
const codex = result(r, 'codex');
|
|
|
|
|
expect(codex.status).toBe('failed');
|
|
|
|
|
expect(codex.error).toMatch(/^not valid TOML \(line \d+, column \d+\)$/);
|
|
|
|
|
expect(get('.codex/config.toml')).toBe(inline);
|
|
|
|
|
const body = JSON.stringify(r);
|
|
|
|
|
expect(body).not.toContain('sk-FILE-SECRET');
|
|
|
|
|
expect(body).not.toContain('sk-COPIED-SECRET');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('relocated config dirs (the CLI reads its file somewhere else)', () => {
|
|
|
|
|
const claudeFile = JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } });
|
|
|
|
|
const CODEX_RELOC = { relocation: { envVar: 'CODEX_HOME', path: 'config.toml' } };
|
|
|
|
|
const CLAUDE_RELOC = { relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' } };
|
|
|
|
|
const OPENCODE_RELOC = { relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' } };
|
|
|
|
|
|
|
|
|
|
it('writes $CODEX_HOME/config.toml, never the default ~/.codex/config.toml', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
const codexHome = join(home, 'elsewhere/codex');
|
|
|
|
|
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
|
|
|
|
apply: true,
|
|
|
|
|
home,
|
|
|
|
|
env: { CODEX_HOME: codexHome },
|
|
|
|
|
});
|
|
|
|
|
expect(result(r, 'codex').file).toBe(join(codexHome, 'config.toml'));
|
|
|
|
|
expect(result(r, 'codex').added).toEqual(['fs']);
|
|
|
|
|
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
|
|
|
|
expect(existsSync(join(home, '.codex'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reads the source from $CLAUDE_CONFIG_DIR and writes $XDG_CONFIG_HOME/opencode', async () => {
|
|
|
|
|
const claudeDir = join(home, 'accounts/work');
|
|
|
|
|
mkdirSync(claudeDir, { recursive: true });
|
|
|
|
|
writeFileSync(join(claudeDir, '.claude.json'), claudeFile);
|
|
|
|
|
// A default-location file that claude does NOT read under CLAUDE_CONFIG_DIR: its server must not spread.
|
|
|
|
|
put('.claude.json', JSON.stringify({ mcpServers: { stray: { type: 'stdio', command: 'nope' } } }));
|
|
|
|
|
const xdg = join(home, 'xdg');
|
|
|
|
|
const r = await syncMcpServers([target('claude', CLAUDE_RELOC), target('opencode', OPENCODE_RELOC)], {
|
|
|
|
|
apply: true,
|
|
|
|
|
home,
|
|
|
|
|
env: { CLAUDE_CONFIG_DIR: claudeDir, XDG_CONFIG_HOME: xdg },
|
|
|
|
|
});
|
|
|
|
|
expect(result(r, 'claude').servers).toEqual(['fs']);
|
|
|
|
|
expect(Object.keys(JSON.parse(readFileSync(join(xdg, 'opencode/opencode.json'), 'utf8')).mcp)).toEqual(['fs']);
|
|
|
|
|
expect(existsSync(join(home, '.config'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports a relative relocation value as skipped and writes nothing anywhere', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
|
|
|
|
apply: true,
|
|
|
|
|
home,
|
|
|
|
|
env: { CODEX_HOME: 'relative/codex' },
|
|
|
|
|
});
|
|
|
|
|
const codex = result(r, 'codex');
|
|
|
|
|
expect(codex.status).toBe('skipped');
|
|
|
|
|
expect(codex.error).toMatch(/CODEX_HOME is set to a relative path/);
|
|
|
|
|
expect(codex.added).toEqual([]);
|
|
|
|
|
expect(existsSync(join(home, '.codex'))).toBe(false);
|
|
|
|
|
expect(existsSync(join(process.cwd(), 'relative'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('an empty value means unset, as it does for the CLI', async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
|
|
|
|
apply: true,
|
|
|
|
|
home,
|
|
|
|
|
env: { CODEX_HOME: '' },
|
|
|
|
|
});
|
|
|
|
|
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
|
|
|
|
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("ignores the caller's own env when home is overridden and no env is passed", async () => {
|
|
|
|
|
put('.claude.json', claudeFile);
|
|
|
|
|
const saved = process.env.CODEX_HOME;
|
|
|
|
|
process.env.CODEX_HOME = join(home, 'from-process-env');
|
|
|
|
|
try {
|
|
|
|
|
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], { apply: true, home });
|
|
|
|
|
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
|
|
|
|
expect(existsSync(join(home, 'from-process-env'))).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
if (saved === undefined) delete process.env.CODEX_HOME;
|
|
|
|
|
else process.env.CODEX_HOME = saved;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-09 16:13:48 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|