`mcpctl config claude --project X` writes user scope and never rewrites a checkout's `.mcp.json`. The status line, however, preferred that file unconditionally — so a legacy project-named entry an older mcpctl left behind (`homeautomation` -> `mcpctl mcp -p homeautomation`) kept naming the old project for good, and every switch looked like it had done nothing. Reproduced live: with user scope on `sre`, `mcpctl statusline --directory ~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a project Claude Code also had in `disabledMcpServers` for that directory, so the line named a server that was not even mounted. Rank the sources by how deliberate each one is instead: a canonical `mcpctl` pin, then user scope, then legacy residue, then the marker. A pin is a decision and still wins; residue is not and no longer does. At every step, skip a server Claude Code has switched off for that directory. `config claude` now also warns when the working directory's `.mcp.json` contradicts the switch, naming the file — the two scopes are merged rather than chosen between, so nothing else would tell you. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
158 lines
6.6 KiB
TypeScript
158 lines
6.6 KiB
TypeScript
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']);
|
|
});
|
|
});
|