Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-05
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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/<your-name>/<branch-slug>`; 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/<your-name>/<branch-slug>`. 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/<your-name>/<branch-slug> --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/<your-name>/<branch-slug> --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).
76 changes: 75 additions & 1 deletion src/cli/commands/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -326,6 +330,47 @@ 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 };
}

// 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);
Expand All @@ -335,6 +380,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
Expand All @@ -354,6 +402,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) {
Expand All @@ -363,6 +412,7 @@ function runInstall(rawArgs) {
hooks: hookResults,
slashCommands: slashResults,
skill: skillResult,
mcp: mcpResult,
symlink: symlinkResult,
dryRun: opts.dryRun,
}, null, 2) + '\n');
Expand Down Expand Up @@ -435,6 +485,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'
Expand Down Expand Up @@ -518,6 +579,11 @@ 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 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)' : ''}.`);
for (const r of removed) console.log(` - ${r}`);
Expand All @@ -531,6 +597,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];
Expand All @@ -540,6 +607,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;
}
Expand All @@ -548,14 +616,15 @@ function printUsage() {
console.log(`Usage: ${SHORT_TOOL_NAME} claude <subcommand> [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).

Flags:
--target <path> 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.
Expand Down Expand Up @@ -593,8 +662,13 @@ module.exports = {
ensureSpeckitMarkers,
installHooks,
installSlashCommands,
installMcpServer,
uninstallMcpServer,
mcpServerSpec,
MANAGED_HOOK_FILES,
MANAGED_SLASH_COMMANDS,
MCP_REL,
MCP_SERVER_KEY,
TEMPLATE_DEFAULT_SETTINGS,
EXPECTED_HOOK_MATCHERS,
};
75 changes: 75 additions & 0 deletions test/claude-install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,81 @@ 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('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'),
Expand Down
Loading