diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index f9719fc..6e00b52 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -119,10 +119,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 92b6c96..38665a4 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -290,6 +290,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d ' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Print what would change without writing or syncing' # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' @@ -297,6 +299,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l ou complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l dry-run -d 'Print what would change without writing or syncing' # config pi options complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index a57e359..2f78a35 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -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 }>; +/** + * 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, 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] ?? '', + retiredLegacyEntries: retired, + marker: opts.skipMarker === true || opts.project === undefined + ? '' + : join(dirname(outputPath), '.mcpctl-project'), + skills: opts.skipSkills === true ? '' : '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) { diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts new file mode 100644 index 0000000..6feaa0f --- /dev/null +++ b/src/cli/src/config/claude-mcp.ts @@ -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; + [key: string]: unknown; +} + +export interface McpJson { + mcpServers: Record; + [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 | 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 | null | undefined, + opts: { project?: string; inspect?: boolean }, +): MergeResult { + const servers: Record = { ...(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; + delete rest.mcpServers; + return { config: { ...rest, mcpServers: servers }, retired }; +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9eb0053..4ebc6fd 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -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); }); }); diff --git a/src/cli/tests/config/claude-mcp.test.ts b/src/cli/tests/config/claude-mcp.test.ts new file mode 100644 index 0000000..45a1a0a --- /dev/null +++ b/src/cli/tests/config/claude-mcp.test.ts @@ -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 => ({ + 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']); + }); +});