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:
Michal
2026-08-10 12:15:46 +01:00
parent 2b87cfdbf1
commit 2a7bba11ea
7 changed files with 414 additions and 31 deletions

View File

@@ -359,4 +359,46 @@ describe('config claude — user scope', () => {
process.exitCode = prevExit;
expect(output.join('\n')).toContain("unknown --scope 'global'");
});
// A user-scope switch never rewrites a directory's .mcp.json, so anything of
// ours left in one keeps answering in that directory. Saying so is the only
// way the user finds out — the switch otherwise reports plain success.
describe('warns when the working directory contradicts the switch', () => {
const switchTo = async (project: string): Promise<string> => {
await createConfigCommand({ configDeps: {}, log, cwd: () => tmpDir })
.parseAsync(['claude', '--project', project, '--skip-skills', '--skip-ui'], { from: 'user' });
return output.join('\n');
};
it('names a legacy entry that stays mounted alongside the new project', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: { homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] } },
}));
const out = await switchTo('sre');
expect(out).toContain(join(tmpDir, '.mcp.json'));
expect(out).toContain("'homeautomation'");
expect(out).toContain('mounted alongside');
});
it('says a canonical pin overrides the switch in that directory', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: { mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'docmost'] } },
}));
expect(await switchTo('sre')).toContain('overrides the switch here');
});
it('stays quiet when the directory already agrees, or wires nothing of ours', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: {
mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'sre'] },
'their-server': { command: 'docker', args: ['run', 'x'] },
},
}));
expect(await switchTo('sre')).not.toContain('Warning:');
});
it('stays quiet when there is no .mcp.json at all', async () => {
expect(await switchTo('sre')).not.toContain('Warning:');
});
});
});