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

@@ -34,6 +34,12 @@ import {
isMcpctlToken,
} from '../config/prime-agent.js';
import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js';
import {
MCPCTL_SERVER_NAME,
mergeMcpctlServers,
activeProjectIn,
type McpJson,
} from '../config/claude-mcp.js';
import {
opencodeConfigDir,
opencodeStatePath,
@@ -57,8 +63,18 @@ const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent';
/** Same, for the tokens `config opencode` mints. */
const OPENCODE_TOKEN_PREFIX = 'opencode';
interface McpConfig {
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
/**
* Read an existing `.mcp.json`. A missing or unparseable file yields null, and
* the caller starts fresh — the same behaviour as before, kept because a
* half-written file must not stop you re-provisioning.
*/
function readMcpJson(path: string): McpJson | null {
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as McpJson;
} catch {
return null;
}
}
export interface ConfigCommandDeps {
@@ -261,63 +277,70 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring')
.option('--stdout', 'Print to stdout instead of writing a file')
.option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)')
.action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean }) => {
.option('--skip-marker', 'Do not write a .mcpctl-project marker next to the output file')
.option('--dry-run', 'Print what would change without writing or syncing')
.action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; dryRun?: boolean }) => {
if (!opts.project && !opts.inspect) {
log('Error: at least one of --project or --inspect is required');
process.exitCode = 1;
return;
}
const servers: McpConfig['mcpServers'] = {};
if (opts.project) {
servers[opts.project] = {
command: 'mcpctl',
args: ['mcp', '-p', opts.project],
};
}
if (opts.inspect) {
servers['mcpctl-inspect'] = {
command: 'mcpctl',
args: ['console', '--stdin-mcp'],
};
}
const outputPath = resolve(opts.output);
const existing = readMcpJson(outputPath);
const { config: finalConfig, retired } = mergeMcpctlServers(existing, {
...(opts.project !== undefined ? { project: opts.project } : {}),
...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}),
});
if (opts.stdout) {
log(JSON.stringify({ mcpServers: servers }, null, 2));
if (opts.stdout === true) {
log(JSON.stringify(finalConfig, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig: McpConfig = { mcpServers: servers };
// Always merge with existing .mcp.json — never overwrite other servers
if (existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...servers,
},
};
} catch {
// If existing file is invalid, start fresh
}
if (opts.dryRun === true) {
log(JSON.stringify({
claude: {
output: outputPath,
previousProject: activeProjectIn(existing),
server: MCPCTL_SERVER_NAME,
entry: finalConfig.mcpServers[MCPCTL_SERVER_NAME] ?? '<unchanged>',
retiredLegacyEntries: retired,
marker: opts.skipMarker === true || opts.project === undefined
? '<skipped>'
: join(dirname(outputPath), '.mcpctl-project'),
skills: opts.skipSkills === true ? '<skipped>' : 'sync + SessionStart hook',
},
action: 'merge .mcp.json (one `mcpctl` entry, project behind it) + marker + skills sync + hook',
}, null, 2));
return;
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
log(`Wrote ${outputPath} (${serverCount} server(s))`);
if (retired.length > 0) {
// Before the constant name, every project you configured stayed
// mounted alongside the new one.
log(`Retired legacy per-project entr${retired.length === 1 ? 'y' : 'ies'}: ${retired.join(', ')}`);
}
if (opts.project !== undefined) {
log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`);
}
// PR-5: write project marker, run initial skills sync, install
// SessionStart hook. Skipped when --inspect-only or --skip-skills.
if (opts.project && !opts.skipSkills) {
const projectDir = dirname(outputPath);
try {
const markerPath = await writeProjectMarker(projectDir, opts.project);
log(`Wrote ${markerPath}`);
} catch (err: unknown) {
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
if (opts.skipMarker === true) {
log('Skipped .mcpctl-project marker (--skip-marker)');
} else {
try {
const markerPath = await writeProjectMarker(projectDir, opts.project);
log(`Wrote ${markerPath}`);
} catch (err: unknown) {
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (skillsClient) {

View File

@@ -0,0 +1,133 @@
/**
* `.mcp.json` shaping for `mcpctl config claude`.
*
* WHY A CONSTANT SERVER NAME
*
* The entry used to be named after the project (`homeautomation`,
* `docmost`, …). Because `.mcp.json` is *merged* rather than rewritten, running
* `config claude` for a second project left the first one mounted too: every
* project you had ever configured stayed connected, with duplicate tool names
* and no way to tell which one was "active".
*
* The entry is now always called `mcpctl`, and switching projects rewrites what
* is behind that name. Claude Code can reconnect an existing MCP server from
* inside a session (`/mcp`), so a switch takes effect without restarting the
* app — and the tool prefix (`mcpctl__*`) stays stable across switches, so the
* model never sees a tool namespace disappear.
*
* Legacy project-named entries this CLI wrote are retired on the next run; see
* `isLegacyMcpctlEntry` for what counts as ours.
*/
/** The one MCP server name mcpctl owns in `.mcp.json`. */
export const MCPCTL_SERVER_NAME = 'mcpctl';
/** Name of the optional traffic-inspection server (`--inspect`). */
export const MCPCTL_INSPECT_SERVER_NAME = 'mcpctl-inspect';
export interface McpServerEntry {
command?: string;
args?: string[];
url?: string;
env?: Record<string, string>;
[key: string]: unknown;
}
export interface McpJson {
mcpServers: Record<string, McpServerEntry>;
[key: string]: unknown;
}
/** The stdio-bridge entry that mounts `project`. */
export function mcpctlStdioServer(project: string): McpServerEntry {
return { command: 'mcpctl', args: ['mcp', '-p', project] };
}
/** The `--inspect` traffic monitor entry. */
export function mcpctlInspectServer(): McpServerEntry {
return { command: 'mcpctl', args: ['console', '--stdin-mcp'] };
}
/**
* The project an entry bridges to, or null if it is not an mcpctl stdio bridge.
*
* Reads it straight out of `args` rather than a bookkeeping key, so nothing
* non-standard is written into a file Claude Code owns.
*/
export function projectOfEntry(entry: unknown): string | null {
if (entry === null || typeof entry !== 'object') return null;
const rec = entry as McpServerEntry;
if (rec.command !== 'mcpctl' || !Array.isArray(rec.args)) return null;
const args = rec.args;
if (args[0] !== 'mcp') return null;
const flag = args.indexOf('-p') >= 0 ? args.indexOf('-p') : args.indexOf('--project');
if (flag < 0) return null;
const project = args[flag + 1];
return typeof project === 'string' && project !== '' ? project : null;
}
/**
* Is `name` an entry an older mcpctl wrote — i.e. named after the very project
* its command bridges to?
*
* That pairing is what makes retiring it safe. A server someone configured by
* hand would have to be named exactly after the project it bridges to *and* run
* our command to be mistaken for one of ours, at which point it is functionally
* the same entry anyway.
*/
export function isLegacyMcpctlEntry(name: string, entry: unknown): boolean {
if (name === MCPCTL_SERVER_NAME) return false;
return projectOfEntry(entry) === name;
}
/** The project currently mounted by `.mcp.json`, preferring the canonical entry. */
export function activeProjectIn(config: Pick<McpJson, 'mcpServers'> | null | undefined): string | null {
const servers = config?.mcpServers;
if (!servers) return null;
const canonical = projectOfEntry(servers[MCPCTL_SERVER_NAME]);
if (canonical !== null) return canonical;
for (const [name, entry] of Object.entries(servers)) {
if (isLegacyMcpctlEntry(name, entry)) return name;
}
return null;
}
export interface MergeResult {
config: McpJson;
/** Legacy project-named entries dropped by this merge. */
retired: string[];
}
/**
* Merge mcpctl's entries into an existing `.mcp.json`.
*
* Every server the user configured is preserved; only our own legacy
* project-named entries are dropped, and only once the canonical entry replaces
* them. Passing no project leaves any existing mount alone (`--inspect` on its
* own must not unmount the project you are working in).
*/
export function mergeMcpctlServers(
existing: Partial<McpJson> | null | undefined,
opts: { project?: string; inspect?: boolean },
): MergeResult {
const servers: Record<string, McpServerEntry> = { ...(existing?.mcpServers ?? {}) };
const retired: string[] = [];
if (opts.project !== undefined && opts.project !== '') {
for (const name of Object.keys(servers)) {
if (isLegacyMcpctlEntry(name, servers[name])) {
delete servers[name];
retired.push(name);
}
}
servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(opts.project);
}
if (opts.inspect === true) {
servers[MCPCTL_INSPECT_SERVER_NAME] = mcpctlInspectServer();
}
// Preserve any sibling top-level keys the file carried.
const rest = { ...(existing ?? {}) } as Partial<McpJson>;
delete rest.mcpServers;
return { config: { ...rest, mcpServers: servers }, retired };
}

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']);
});
});