Files
mcpctl/src/cli/tests/config/claude-mcp.test.ts

158 lines
6.6 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import {
MCPCTL_SERVER_NAME,
mergeMcpctlServers,
projectOfEntry,
isLegacyMcpctlEntry,
activeProjectIn,
canonicalProjectIn,
legacyEntriesIn,
disabledServersFor,
} from '../../src/config/claude-mcp.js';
const bridge = (project: string): Record<string, unknown> => ({
command: 'mcpctl',
args: ['mcp', '-p', project],
});
describe('projectOfEntry', () => {
it('reads the project out of the bridge args', () => {
expect(projectOfEntry(bridge('docmost'))).toBe('docmost');
expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '--project', 'sre'] })).toBe('sre');
});
it('ignores anything that is not our stdio bridge', () => {
expect(projectOfEntry({ command: 'echo', args: ['mcp', '-p', 'x'] })).toBeNull();
expect(projectOfEntry({ command: 'mcpctl', args: ['console', '--stdin-mcp'] })).toBeNull();
expect(projectOfEntry({ type: 'remote', url: 'https://x/projects/y/mcp' })).toBeNull();
expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '-p'] })).toBeNull();
expect(projectOfEntry(null)).toBeNull();
expect(projectOfEntry('nope')).toBeNull();
});
});
describe('isLegacyMcpctlEntry', () => {
it('recognises an entry named after the very project it bridges to', () => {
expect(isLegacyMcpctlEntry('docmost', bridge('docmost'))).toBe(true);
});
it('never claims the canonical entry', () => {
expect(isLegacyMcpctlEntry(MCPCTL_SERVER_NAME, bridge('docmost'))).toBe(false);
});
it('leaves a hand-configured server alone', () => {
// Same name, different command — someone else's server.
expect(isLegacyMcpctlEntry('docmost', { command: 'docker', args: ['run', 'docmost'] })).toBe(false);
// Our command, but the name does not match the project: not something this
// CLI ever wrote, so it is the user's to keep.
expect(isLegacyMcpctlEntry('my-shortcut', bridge('docmost'))).toBe(false);
});
});
describe('activeProjectIn', () => {
it('prefers the canonical entry', () => {
expect(activeProjectIn({ mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre') } })).toBe('sre');
});
it('falls back to a legacy entry so pre-migration installs still report', () => {
expect(activeProjectIn({ mcpServers: { docmost: bridge('docmost') } })).toBe('docmost');
});
it('is null when nothing of ours is mounted', () => {
expect(activeProjectIn({ mcpServers: { other: { command: 'echo' } } })).toBeNull();
expect(activeProjectIn(null)).toBeNull();
expect(activeProjectIn({ mcpServers: {} })).toBeNull();
});
});
describe('canonicalProjectIn / legacyEntriesIn', () => {
it('tells a deliberate pin apart from pre-migration residue', () => {
const config = { mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre'), homeautomation: bridge('homeautomation') } };
expect(canonicalProjectIn(config)).toBe('sre');
expect(legacyEntriesIn(config)).toEqual([{ server: 'homeautomation', project: 'homeautomation' }]);
});
it('reports no canonical entry when only legacy ones are present', () => {
const config = { mcpServers: { docmost: bridge('docmost') } };
expect(canonicalProjectIn(config)).toBeNull();
expect(legacyEntriesIn(config)).toEqual([{ server: 'docmost', project: 'docmost' }]);
});
it('leaves servers that are not ours out of both', () => {
const config = { mcpServers: { other: { command: 'echo' } } };
expect(canonicalProjectIn(config)).toBeNull();
expect(legacyEntriesIn(config)).toEqual([]);
expect(legacyEntriesIn(null)).toEqual([]);
});
});
describe('disabledServersFor', () => {
const doc = {
projects: {
'/repo': { disabledMcpServers: ['homeautomation'], disabledMcpjsonServers: ['mcpctl'] },
'/other': { disabledMcpServers: ['sre'] },
},
};
it('unions both of Claude Code\'s disable lists for that directory', () => {
expect([...disabledServersFor(doc, '/repo')].sort()).toEqual(['homeautomation', 'mcpctl']);
});
it('is scoped to the directory asked about', () => {
expect([...disabledServersFor(doc, '/other')]).toEqual(['sre']);
expect([...disabledServersFor(doc, '/unknown')]).toEqual([]);
expect([...disabledServersFor(null, '/repo')]).toEqual([]);
});
it('survives a malformed entry rather than throwing on the status line', () => {
expect([...disabledServersFor({ projects: { '/repo': { disabledMcpServers: 'nope' } } }, '/repo')]).toEqual([]);
});
});
describe('mergeMcpctlServers', () => {
it('writes one constant entry regardless of project', () => {
const { config } = mergeMcpctlServers(null, { project: 'my-fancy-project' });
expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]);
expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('my-fancy-project'));
});
it('re-points rather than stacking on a second project', () => {
const first = mergeMcpctlServers(null, { project: 'a' }).config;
const { config } = mergeMcpctlServers(first, { project: 'b' });
expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]);
expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('b'));
});
it('retires legacy per-project entries and reports them', () => {
const existing = { mcpServers: { homeautomation: bridge('homeautomation'), sre: bridge('sre') } };
const { config, retired } = mergeMcpctlServers(existing, { project: 'docmost' });
expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]);
expect(retired.sort()).toEqual(['homeautomation', 'sre']);
});
it('preserves servers the user configured, and other top-level keys', () => {
const existing = {
mcpServers: { 'my-own': { command: 'echo', args: [] } },
someOtherKey: { keep: true },
};
const { config, retired } = mergeMcpctlServers(existing, { project: 'p' });
expect(config.mcpServers['my-own']).toEqual({ command: 'echo', args: [] });
expect(config['someOtherKey']).toEqual({ keep: true });
expect(retired).toEqual([]);
});
it('--inspect alone does not unmount the project you are working in', () => {
const existing = mergeMcpctlServers(null, { project: 'p' }).config;
const { config, retired } = mergeMcpctlServers(existing, { inspect: true });
expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('p'));
expect(config.mcpServers['mcpctl-inspect']).toEqual({ command: 'mcpctl', args: ['console', '--stdin-mcp'] });
expect(retired).toEqual([]);
});
it('does not mutate the config it was handed', () => {
const existing = { mcpServers: { homeautomation: bridge('homeautomation') } };
mergeMcpctlServers(existing, { project: 'docmost' });
expect(Object.keys(existing.mcpServers)).toEqual(['homeautomation']);
});
});