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:
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user