fix(claude): one constant mcpctl MCP entry instead of one per project

`config claude` named the `.mcp.json` entry after the project, and the file is
merged rather than rewritten — so configuring a second project left the first
one mounted alongside it. Every project you had ever configured stayed
connected, with duplicate tool names and nothing saying which was active.

The entry is now always `mcpctl`, and switching rewrites what sits behind that
name. Claude Code can reconnect an existing MCP server from inside a session, so
a switch lands without restarting the app, and the tool prefix stays stable
across switches. Entries an older CLI wrote are retired on the next run —
recognised by the pairing that makes retiring them safe: our command, named
after the very project it bridges to. A hand-configured server is never touched.

The shaping lives in config/claude-mcp.ts as pure functions so the merge,
migration and active-project detection are unit-tested rather than inferred from
a command's side effects.

Also brings two parity gaps in line with `config opencode` / `config prime-agent`:
--dry-run, and --skip-marker for when the caller must not re-scope the directory
it runs in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
This commit is contained in:
Michal
2026-08-09 18:51:31 +01:00
parent c5ea39e959
commit d7055a0953
6 changed files with 363 additions and 53 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
@@ -46,7 +46,7 @@ describe('config claude', () => {
expect(client.get).not.toHaveBeenCalled();
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['homeautomation']).toEqual({
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'homeautomation'],
});
@@ -61,7 +61,7 @@ describe('config claude', () => {
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
const parsed = JSON.parse(output[0]);
expect(parsed.mcpServers['myproj']).toEqual({
expect(parsed.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'myproj'],
});
@@ -81,7 +81,7 @@ describe('config claude', () => {
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['existing--server']).toBeDefined();
expect(written.mcpServers['proj-1']).toEqual({
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
@@ -113,7 +113,7 @@ describe('config claude', () => {
await cmd.parseAsync(['claude', '--project', 'ha', '--inspect', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['ha']).toBeDefined();
expect(written.mcpServers['mcpctl']).toBeDefined();
expect(written.mcpServers['mcpctl-inspect']).toBeDefined();
expect(output.join('\n')).toContain('2 server(s)');
});
@@ -127,21 +127,61 @@ describe('config claude', () => {
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['proj-1']).toEqual({
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
});
it('uses project name as the server key', async () => {
it('uses one constant server key, whatever the project is called', async () => {
// The key used to be the project name, so `config claude` for a second
// project left the first one mounted too — every project ever configured
// stayed connected, with duplicate tool names.
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' });
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']);
expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'my-fancy-project']);
});
it('switching projects replaces the mount instead of stacking a second one', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'first', '-o', outPath, '--skip-skills'], { from: 'user' });
await cmd.parseAsync(['claude', '--project', 'second', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']);
expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'second']);
});
it('retires a legacy project-named entry left by an older CLI', async () => {
const outPath = join(tmpDir, '.mcp.json');
writeFileSync(outPath, JSON.stringify({
mcpServers: {
homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] },
'my-own-server': { command: 'echo', args: [] },
},
}));
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'docmost', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers).sort()).toEqual(['mcpctl', 'my-own-server']);
expect(output.join('\n')).toContain('Retired legacy per-project entry: homeautomation');
});
it('--dry-run reports the plan and writes nothing', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'p', '-o', outPath, '--dry-run'], { from: 'user' });
const plan = JSON.parse(output.join('\n'));
expect(plan.claude.server).toBe('mcpctl');
expect(plan.claude.entry.args).toEqual(['mcp', '-p', 'p']);
expect(existsSync(outPath)).toBe(false);
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import {
MCPCTL_SERVER_NAME,
mergeMcpctlServers,
projectOfEntry,
isLegacyMcpctlEntry,
activeProjectIn,
} 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('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']);
});
});