fix(claude): stop pre-migration .mcp.json residue outranking a project switch
`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
This commit is contained in:
@@ -5,6 +5,9 @@ import {
|
||||
projectOfEntry,
|
||||
isLegacyMcpctlEntry,
|
||||
activeProjectIn,
|
||||
canonicalProjectIn,
|
||||
legacyEntriesIn,
|
||||
disabledServersFor,
|
||||
} from '../../src/config/claude-mcp.js';
|
||||
|
||||
const bridge = (project: string): Record<string, unknown> => ({
|
||||
@@ -62,6 +65,50 @@ describe('activeProjectIn', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
Reference in New Issue
Block a user