From fa066f5f53bab839c23574a61e917302579d5541 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 5 Jun 2026 14:06:54 +0200 Subject: [PATCH 1/2] feat(claude): gx claude install auto-registers the gx MCP server in .mcp.json The gx mcp agent radar only helps if agents have it registered; manual `claude mcp add` per machine meant nobody turned it on. Now `gx claude install` wires it into the repo alongside settings/hooks/commands. - installMcpServer merges { mcpServers.gx: { command: gx, args: [mcp, serve] } } into the target .mcp.json without clobbering other servers; idempotent. - --no-mcp opts out. - gx claude check warns when missing; doctor (check --fix) repairs via install. - gx claude uninstall removes the gx server (drops .mcp.json if only ours). - AGENTS.md repo-wiring bullet + usage updated. Tests: 4 new (create/merge/idempotent/dry-run) in claude-install.test.js, 18/18; no-new-failures vs base. --- AGENTS.md | 4 +- .../.openspec.yaml | 2 + .../proposal.md | 32 ++++++++++ .../spec.md | 33 ++++++++++ .../tasks.md | 34 ++++++++++ src/cli/commands/claude.js | 64 ++++++++++++++++++- test/claude-install.test.js | 34 ++++++++++ 7 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/.openspec.yaml create mode 100644 openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/proposal.md create mode 100644 openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/specs/gx-claude-install-auto-registers-gx-mcp-server-in-target-mcp-json/spec.md create mode 100644 openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/tasks.md diff --git a/AGENTS.md b/AGENTS.md index 30c0ef7..fa9c62b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,9 @@ If you are a Claude Code session arriving in this repo for the first time: commit + push + PR + merge + cleanup, still use the non-negotiable `gx branch finish --via-pr --wait-for-merge --cleanup`. 4. **Repo wiring** — `gx claude install` writes `.claude/settings.json`, - hooks, slash commands, and the gitguardex skill into a target repo. + hooks, slash commands, the gitguardex skill, and a `.mcp.json` that registers + the read-only `gx` MCP server (the cross-repo agent radar: `list_agents`, + `who_owns`, `my_context`) into a target repo. Opt out with `--no-mcp`. `gx claude check` diagnoses drift without writing; `gx claude doctor` diagnoses and repairs. diff --git a/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/.openspec.yaml b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/.openspec.yaml new file mode 100644 index 0000000..c53ef21 --- /dev/null +++ b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/proposal.md b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/proposal.md new file mode 100644 index 0000000..8b554cc --- /dev/null +++ b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/proposal.md @@ -0,0 +1,32 @@ +## Why + +`gx mcp` (the read-only cross-repo agent radar) only helps if agents actually +have it registered. Today that means a manual `claude mcp add gx -s user -- gx +mcp serve` per machine — so in practice nobody turns it on and the collision +visibility never reaches the agents that need it. `gx claude install` already +wires the rest of the gitguardex Claude integration into a repo; the MCP server +should ride along. + +## What Changes + +- `gx claude install` now also registers the `gx` MCP server in the target + repo's `.mcp.json` (`{ "mcpServers": { "gx": { "command": "gx", "args": + ["mcp", "serve"] } } }`). It MERGES into an existing `.mcp.json` without + disturbing other servers, and is idempotent. +- `--no-mcp` opts out of the registration. +- `gx claude check` reports a warning when the `gx` server is missing; `gx + claude doctor` (check --fix) repairs it via install. +- `gx claude uninstall` removes the `gx` server (and deletes `.mcp.json` if it + only held ours). + +## Impact + +- **Affected surface**: `src/cli/commands/claude.js` only (install/check/uninstall + + usage). New exports `installMcpServer`, `MCP_REL`, `MCP_SERVER_KEY`. +- **Behavior change**: installing gitguardex into a repo now adds a committed + `.mcp.json`; Claude Code will prompt to approve the project MCP server. Opt out + with `--no-mcp`. Read-only server, no repo mutation at runtime. +- **Portability**: `.mcp.json` references `gx` on PATH; a clone without gx shows + the server as unavailable (soft failure), not an error. +- Verified by `test/claude-install.test.js` (create / merge / idempotent / + dry-run) plus end-to-end smoke of install/merge/--no-mcp/uninstall. diff --git a/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/specs/gx-claude-install-auto-registers-gx-mcp-server-in-target-mcp-json/spec.md b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/specs/gx-claude-install-auto-registers-gx-mcp-server-in-target-mcp-json/spec.md new file mode 100644 index 0000000..51a1482 --- /dev/null +++ b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/specs/gx-claude-install-auto-registers-gx-mcp-server-in-target-mcp-json/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: gx claude install registers the gx MCP server +`gx claude install` SHALL register the read-only `gx` MCP server in the target +repo's `.mcp.json`, unless `--no-mcp` is passed. + +#### Scenario: Fresh repo +- **WHEN** `gx claude install` runs in a repo with no `.mcp.json` +- **THEN** it creates `.mcp.json` containing `mcpServers.gx = { command: "gx", args: ["mcp", "serve"] }`. + +#### Scenario: Merge into existing config +- **WHEN** `.mcp.json` already defines other MCP servers +- **THEN** install adds the `gx` server and leaves the other servers unchanged. + +#### Scenario: Idempotent +- **WHEN** install runs again with the `gx` server already present and correct +- **THEN** the file is unchanged. + +#### Scenario: Opt out +- **WHEN** `gx claude install --no-mcp` runs +- **THEN** no `.mcp.json` is created or modified. + +### Requirement: check and uninstall cover the MCP registration +`gx claude check` SHALL report missing registration, and `gx claude uninstall` +SHALL remove it. + +#### Scenario: Drift detected +- **WHEN** `gx claude check` runs and `.mcp.json` lacks the `gx` server +- **THEN** it reports a warning, and `gx claude doctor` repairs it via install. + +#### Scenario: Clean removal +- **WHEN** `gx claude uninstall --yes` runs +- **THEN** the `gx` server is removed from `.mcp.json`, and the file is deleted if it held only the `gx` server, while any other servers are preserved. diff --git a/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/tasks.md b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/tasks.md new file mode 100644 index 0000000..ce3e4ec --- /dev/null +++ b/openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56`. +- [x] 1.2 Define normative requirements in `specs/gx-claude-install-auto-registers-gx-mcp-server-in-target-mcp-json/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-claude-gx-claude-install-auto-registers-gx-mcp-2026-06-05-13-56 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/src/cli/commands/claude.js b/src/cli/commands/claude.js index 4ef4e0d..c96f183 100644 --- a/src/cli/commands/claude.js +++ b/src/cli/commands/claude.js @@ -20,6 +20,10 @@ const SETTINGS_REL = '.claude/settings.json'; const HOOKS_REL = '.claude/hooks'; const COMMANDS_REL = '.claude/commands'; const SKILLS_REL = '.claude/skills'; +// Repo-scoped MCP registration so any agent in the target repo can see the +// cross-repo agent radar (`gx mcp`). Read-only server; opt out with --no-mcp. +const MCP_REL = '.mcp.json'; +const MCP_SERVER_KEY = SHORT_TOOL_NAME; const MANAGED_HOOK_FILES = [ 'skill_guard.py', @@ -326,6 +330,29 @@ function describeStatus(s) { return '?'; } +function mcpServerSpec() { + return { command: SHORT_TOOL_NAME, args: ['mcp', 'serve'] }; +} + +// Register the read-only `gx mcp` server in the target repo's .mcp.json so any +// agent there can call list_agents / who_owns / my_context. Merges into an +// existing .mcp.json without disturbing other servers; idempotent. +function installMcpServer(repoRoot, { dryRun }) { + const filePath = path.join(repoRoot, MCP_REL); + const fileExisted = fs.existsSync(filePath); + const config = readJsonIfExists(filePath) || {}; + config.mcpServers = config.mcpServers || {}; + const desired = mcpServerSpec(); + const current = config.mcpServers[MCP_SERVER_KEY]; + if (current && JSON.stringify(current) === JSON.stringify(desired)) { + return { status: 'unchanged', dest: filePath }; + } + const status = current ? 'updated' : fileExisted ? 'merged' : 'created'; + config.mcpServers[MCP_SERVER_KEY] = desired; + writeJson(filePath, config, { dryRun }); + return { status, dest: filePath }; +} + function runInstall(rawArgs) { const opts = parseInstallArgs(rawArgs); const repoRoot = resolveRepoRoot(opts.target); @@ -335,6 +362,9 @@ function runInstall(rawArgs) { const hookResults = installHooks(repoRoot, opts); const slashResults = installSlashCommands(repoRoot, opts); const skillResult = installAgentSkill(repoRoot, opts); + const mcpResult = opts.noMcp + ? { status: 'skipped', dest: path.join(repoRoot, MCP_REL) } + : installMcpServer(repoRoot, opts); const symlinkResult = ensureSpeckitMarkers(repoRoot, opts); // Summary @@ -354,6 +384,7 @@ function runInstall(rawArgs) { } else if (skillResult.status === 'source-missing') { logWarn('gitguardex skill source missing in package; skipped.'); } + logInfo(`mcp server (${MCP_REL}): ${mcpResult.status}`); logInfo(`CLAUDE.md symlink: ${symlinkResult.status}${symlinkResult.note ? ` (${symlinkResult.note})` : ''}`); if (opts.json) { @@ -363,6 +394,7 @@ function runInstall(rawArgs) { hooks: hookResults, slashCommands: slashResults, skill: skillResult, + mcp: mcpResult, symlink: symlinkResult, dryRun: opts.dryRun, }, null, 2) + '\n'); @@ -435,6 +467,17 @@ function runCheck(rawArgs) { } } + // MCP registration check + const mcpConfig = readJsonIfExists(path.join(repoRoot, MCP_REL)); + const hasGxMcp = Boolean(mcpConfig && mcpConfig.mcpServers && mcpConfig.mcpServers[MCP_SERVER_KEY]); + if (!hasGxMcp) { + issues.push({ + severity: 'warning', + kind: 'mcp-missing', + message: `${MCP_REL} does not register the '${MCP_SERVER_KEY}' MCP server (run '${SHORT_TOOL_NAME} claude install', or install --no-mcp to skip).`, + }); + } + // Symlink check const symlinkResult = ensureSpeckitMarkers(repoRoot, { dryRun: true }); if (symlinkResult.status === 'would-create-symlink' @@ -518,6 +561,18 @@ function runUninstall(rawArgs) { if (!opts.dryRun) writeJson(settingsPath, settings, { dryRun: false }); removed.push(`${SETTINGS_REL} (managed entries pruned)`); } + // Remove the gx MCP server from .mcp.json (drop the file if it only held ours) + const mcpPath = path.join(repoRoot, MCP_REL); + const mcpConfig = readJsonIfExists(mcpPath); + if (mcpConfig && mcpConfig.mcpServers && mcpConfig.mcpServers[MCP_SERVER_KEY]) { + delete mcpConfig.mcpServers[MCP_SERVER_KEY]; + const onlyOurs = Object.keys(mcpConfig.mcpServers).length === 0 && Object.keys(mcpConfig).length === 1; + if (!opts.dryRun) { + if (onlyOurs) fs.unlinkSync(mcpPath); + else writeJson(mcpPath, mcpConfig, { dryRun: false }); + } + removed.push(`${MCP_REL} (${onlyOurs ? 'removed' : `'${MCP_SERVER_KEY}' server pruned`})`); + } logOk(`Removed ${removed.length} item(s)${opts.dryRun ? ' (dry-run)' : ''}.`); for (const r of removed) console.log(` - ${r}`); @@ -531,6 +586,7 @@ function parseInstallArgs(rawArgs) { json: false, yes: false, fix: false, + noMcp: false, }; for (let index = 0; index < rawArgs.length; index += 1) { const arg = rawArgs[index]; @@ -540,6 +596,7 @@ function parseInstallArgs(rawArgs) { if (arg === '--json') { opts.json = true; continue; } if (arg === '--yes' || arg === '-y') { opts.yes = true; continue; } if (arg === '--fix') { opts.fix = true; continue; } + if (arg === '--no-mcp') { opts.noMcp = true; continue; } } return opts; } @@ -548,7 +605,7 @@ function printUsage() { console.log(`Usage: ${SHORT_TOOL_NAME} claude [flags] Subcommands: - install install/update .claude/settings.json + hooks + slash commands. + install install/update .claude/settings.json + hooks + slash commands + .mcp.json. check diagnose Claude Code wiring (read-only by default). doctor alias: 'check --fix'. uninstall remove gitguardex-managed Claude Code wiring (--yes required). @@ -556,6 +613,7 @@ Subcommands: Flags: --target Operate in a different repo directory. --force Overwrite existing managed entries instead of merging. + --no-mcp Skip registering the gx MCP server in .mcp.json. --dry-run Report what would change without writing. --json Emit JSON output. --yes / -y Required for uninstall. @@ -593,8 +651,12 @@ module.exports = { ensureSpeckitMarkers, installHooks, installSlashCommands, + installMcpServer, + mcpServerSpec, MANAGED_HOOK_FILES, MANAGED_SLASH_COMMANDS, + MCP_REL, + MCP_SERVER_KEY, TEMPLATE_DEFAULT_SETTINGS, EXPECTED_HOOK_MATCHERS, }; diff --git a/test/claude-install.test.js b/test/claude-install.test.js index 6ae151f..5d30279 100644 --- a/test/claude-install.test.js +++ b/test/claude-install.test.js @@ -179,6 +179,40 @@ test('mergeSettings --force ignores existing settings', () => { assert.ok(mergedNonForce.hooks.PreToolUse.some((g) => g.matcher === 'Other')); }); +test('installMcpServer registers the gx server in a fresh .mcp.json', () => { + const repoRoot = makeRepo(); + const result = claudeModule.installMcpServer(repoRoot, { dryRun: false }); + assert.equal(result.status, 'created'); + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, claudeModule.MCP_REL), 'utf8')); + assert.deepEqual(config.mcpServers[claudeModule.MCP_SERVER_KEY], { command: 'gx', args: ['mcp', 'serve'] }); +}); + +test('installMcpServer merges into an existing .mcp.json without clobbering other servers', () => { + const repoRoot = makeRepo(); + fs.writeFileSync( + path.join(repoRoot, claudeModule.MCP_REL), + JSON.stringify({ mcpServers: { other: { command: 'x' } } }, null, 2), + ); + const result = claudeModule.installMcpServer(repoRoot, { dryRun: false }); + assert.equal(result.status, 'merged'); + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, claudeModule.MCP_REL), 'utf8')); + assert.deepEqual(Object.keys(config.mcpServers).sort(), ['gx', 'other']); + assert.deepEqual(config.mcpServers.other, { command: 'x' }, 'existing server preserved'); +}); + +test('installMcpServer is idempotent on a second run', () => { + const repoRoot = makeRepo(); + claudeModule.installMcpServer(repoRoot, { dryRun: false }); + const result = claudeModule.installMcpServer(repoRoot, { dryRun: false }); + assert.equal(result.status, 'unchanged'); +}); + +test('installMcpServer dry-run does not write .mcp.json', () => { + const repoRoot = makeRepo(); + claudeModule.installMcpServer(repoRoot, { dryRun: true }); + assert.equal(fs.existsSync(path.join(repoRoot, claudeModule.MCP_REL)), false); +}); + test('agent_branch_advisor.py is a managed (distributed) hook file', () => { assert.ok( claudeModule.MANAGED_HOOK_FILES.includes('agent_branch_advisor.py'), From 01bab3a71b6d0d1b3eec2a0ee83b0f695fa676b5 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 5 Jun 2026 14:11:57 +0200 Subject: [PATCH 2/2] test(claude): extract uninstallMcpServer + cover the file-deletion edge cases Addresses review note: the uninstall path (the error-prone 'delete file only if it held nothing but ours' guard) had no coverage. Extracted uninstallMcpServer so it is unit-testable; added 4 tests including the {$schema, mcpServers:{gx}} case where an unrelated top-level key must block file deletion. 22/22 pass. --- src/cli/commands/claude.js | 32 ++++++++++++++++++++--------- test/claude-install.test.js | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/claude.js b/src/cli/commands/claude.js index c96f183..bfa09d5 100644 --- a/src/cli/commands/claude.js +++ b/src/cli/commands/claude.js @@ -353,6 +353,24 @@ function installMcpServer(repoRoot, { dryRun }) { return { status, dest: filePath }; } +// Inverse of installMcpServer: drop the gx server. Removes the whole .mcp.json +// only when it held nothing but our server (no other servers AND no other +// top-level keys); otherwise prunes just the gx entry and preserves the rest. +function uninstallMcpServer(repoRoot, { dryRun }) { + const filePath = path.join(repoRoot, MCP_REL); + const config = readJsonIfExists(filePath); + if (!config || !config.mcpServers || !config.mcpServers[MCP_SERVER_KEY]) { + return { status: 'absent', dest: filePath }; + } + delete config.mcpServers[MCP_SERVER_KEY]; + const onlyOurs = Object.keys(config.mcpServers).length === 0 && Object.keys(config).length === 1; + if (!dryRun) { + if (onlyOurs) fs.unlinkSync(filePath); + else writeJson(filePath, config, { dryRun: false }); + } + return { status: onlyOurs ? 'removed' : 'pruned', dest: filePath }; +} + function runInstall(rawArgs) { const opts = parseInstallArgs(rawArgs); const repoRoot = resolveRepoRoot(opts.target); @@ -562,16 +580,9 @@ function runUninstall(rawArgs) { removed.push(`${SETTINGS_REL} (managed entries pruned)`); } // Remove the gx MCP server from .mcp.json (drop the file if it only held ours) - const mcpPath = path.join(repoRoot, MCP_REL); - const mcpConfig = readJsonIfExists(mcpPath); - if (mcpConfig && mcpConfig.mcpServers && mcpConfig.mcpServers[MCP_SERVER_KEY]) { - delete mcpConfig.mcpServers[MCP_SERVER_KEY]; - const onlyOurs = Object.keys(mcpConfig.mcpServers).length === 0 && Object.keys(mcpConfig).length === 1; - if (!opts.dryRun) { - if (onlyOurs) fs.unlinkSync(mcpPath); - else writeJson(mcpPath, mcpConfig, { dryRun: false }); - } - removed.push(`${MCP_REL} (${onlyOurs ? 'removed' : `'${MCP_SERVER_KEY}' server pruned`})`); + const mcpRemoval = uninstallMcpServer(repoRoot, opts); + if (mcpRemoval.status !== 'absent') { + removed.push(`${MCP_REL} (${mcpRemoval.status === 'removed' ? 'removed' : `'${MCP_SERVER_KEY}' server pruned`})`); } logOk(`Removed ${removed.length} item(s)${opts.dryRun ? ' (dry-run)' : ''}.`); @@ -652,6 +663,7 @@ module.exports = { installHooks, installSlashCommands, installMcpServer, + uninstallMcpServer, mcpServerSpec, MANAGED_HOOK_FILES, MANAGED_SLASH_COMMANDS, diff --git a/test/claude-install.test.js b/test/claude-install.test.js index 5d30279..ae2248a 100644 --- a/test/claude-install.test.js +++ b/test/claude-install.test.js @@ -213,6 +213,47 @@ test('installMcpServer dry-run does not write .mcp.json', () => { assert.equal(fs.existsSync(path.join(repoRoot, claudeModule.MCP_REL)), false); }); +test('uninstallMcpServer deletes .mcp.json when it only held the gx server', () => { + const repoRoot = makeRepo(); + claudeModule.installMcpServer(repoRoot, { dryRun: false }); + const result = claudeModule.uninstallMcpServer(repoRoot, { dryRun: false }); + assert.equal(result.status, 'removed'); + assert.equal(fs.existsSync(path.join(repoRoot, claudeModule.MCP_REL)), false); +}); + +test('uninstallMcpServer keeps the file (prunes only gx) when other servers exist', () => { + const repoRoot = makeRepo(); + fs.writeFileSync( + path.join(repoRoot, claudeModule.MCP_REL), + JSON.stringify({ mcpServers: { other: { command: 'x' } } }, null, 2), + ); + claudeModule.installMcpServer(repoRoot, { dryRun: false }); + const result = claudeModule.uninstallMcpServer(repoRoot, { dryRun: false }); + assert.equal(result.status, 'pruned'); + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, claudeModule.MCP_REL), 'utf8')); + assert.deepEqual(Object.keys(config.mcpServers), ['other'], 'gx removed, other kept'); +}); + +test('uninstallMcpServer preserves a file that has other top-level keys (no deletion)', () => { + const repoRoot = makeRepo(); + fs.writeFileSync( + path.join(repoRoot, claudeModule.MCP_REL), + JSON.stringify({ $schema: 'https://example/schema.json', mcpServers: {} }, null, 2), + ); + claudeModule.installMcpServer(repoRoot, { dryRun: false }); // adds gx + const result = claudeModule.uninstallMcpServer(repoRoot, { dryRun: false }); + assert.equal(result.status, 'pruned', 'extra top-level key blocks file deletion'); + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, claudeModule.MCP_REL), 'utf8')); + assert.equal(config.$schema, 'https://example/schema.json', 'unrelated top-level key preserved'); + assert.equal(config.mcpServers[claudeModule.MCP_SERVER_KEY], undefined, 'gx removed'); +}); + +test('uninstallMcpServer is a no-op when no .mcp.json exists', () => { + const repoRoot = makeRepo(); + const result = claudeModule.uninstallMcpServer(repoRoot, { dryRun: false }); + assert.equal(result.status, 'absent'); +}); + test('agent_branch_advisor.py is a managed (distributed) hook file', () => { assert.ok( claudeModule.MANAGED_HOOK_FILES.includes('agent_branch_advisor.py'),