From 4a2c3fcfd92d5653d4c7619c05fef2cf62f187ef Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 5 Jun 2026 12:49:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp):=20gx=20mcp=20=E2=80=94=20cross-r?= =?UTF-8?q?epo=20read-only=20multi-agent=20observability=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents running in parallel couldn't see who was on which branch/PR or who had claimed a file, so they collided (and edited the primary checkout, which auto-stashed). gitguardex already knows all of this; this exposes it to agents. - src/mcp/server.js: dependency-free stdio JSON-RPC MCP server (no SDK; gx stays at 2 deps). Tools: list_agents, repo_state, who_owns, my_context. - src/mcp/collect.js: read-only collector over git worktrees + per-worktree lock files + pr.findOpenPrForBranch + cockpit projects-finder. who_owns aggregates locks across ALL worktrees (they're per-worktree on disk); surfaces a warning when a lane edits the primary checkout. - src/cli/commands/mcp.js: gx mcp serve | list-agents | who-owns | register. - Wired into src/cli/main.js dispatch. - Tests: mcp-collect (lanes, cross-worktree who_owns, dedupe, primary warning) + mcp-server (JSON-RPC protocol). 12/12 pass; no-new-failures vs base. Read-only; never mutates a repo. Registration is opt-in (gx mcp register). --- .../.openspec.yaml | 2 + .../proposal.md | 42 +++ .../spec.md | 38 +++ .../tasks.md | 34 +++ src/cli/commands/mcp.js | 118 ++++++++ src/cli/main.js | 2 + src/mcp/collect.js | 282 ++++++++++++++++++ src/mcp/server.js | 151 ++++++++++ test/mcp-collect.test.js | 125 ++++++++ test/mcp-server.test.js | 67 +++++ 10 files changed, 861 insertions(+) create mode 100644 openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/.openspec.yaml create mode 100644 openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/proposal.md create mode 100644 openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md create mode 100644 openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/tasks.md create mode 100644 src/cli/commands/mcp.js create mode 100644 src/mcp/collect.js create mode 100644 src/mcp/server.js create mode 100644 test/mcp-collect.test.js create mode 100644 test/mcp-server.test.js diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/.openspec.yaml b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/.openspec.yaml new file mode 100644 index 0000000..c53ef21 --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/proposal.md b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/proposal.md new file mode 100644 index 0000000..bf83bcf --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/proposal.md @@ -0,0 +1,42 @@ +## Why + +Multiple agents (Claude, Codex) run in parallel across repos and step on each +other: they don't know who is on which branch, which PR is in flight, or who +already claimed a file. Two concrete failure modes: an agent edits the PRIMARY +checkout (not an isolated worktree) and a later branch switch auto-stashes the +work; and two agents edit the same file because lock ownership isn't visible +across worktrees. gitguardex already KNOWS all of this (worktrees, branches, +locks, PRs) but only exposes it to a human via `gx cockpit`. Agents need it +programmatically. + +## What Changes + +- New `gx mcp` command with a hand-rolled, dependency-free stdio JSON-RPC MCP + server (no `@modelcontextprotocol/sdk` — keeps gx at 2 deps). +- Four READ-ONLY tools, derived automatically from git/worktree/lock/PR state + (no manual bookkeeping; complements, not replaces, Colony): + - `list_agents` — every active agent lane across all discovered repos: + repo, branch, worktree, task, the PR it's shipping, held locks, last + commit, and a warning when a lane is editing the primary checkout. + - `repo_state(repo)` — the same for a single repo. + - `who_owns(file)` — which agent/branch holds the lock on a path, aggregated + across ALL worktrees (lock files are per-worktree on disk), for a real + cross-agent collision check before editing. + - `my_context` — the current session's repo, branch, worktree, whether it's + the protected primary checkout, held locks, and PR. +- `gx mcp list-agents` / `who-owns` CLI views (human/debug) and `gx mcp register` + (prints the `claude mcp add` one-liner + `.mcp.json` snippet). + +## Impact + +- **New surfaces**: `src/mcp/collect.js`, `src/mcp/server.js`, + `src/cli/commands/mcp.js`; one dispatch line in `src/cli/main.js`. No new + dependency. No changes to any guard or mutating path. +- **Read-only**: the server never writes to a repo. PR lookups are best-effort + (skip un-pushed branches; gh missing/unauthed → null, never throws). +- **Registration is opt-in**: the server isn't wired into any harness + automatically; users run `claude mcp add gx -s user -- gx mcp serve`. +- **Does not by itself prevent collisions** — it gives the visibility + a + `who_owns` pre-check. The primary-checkout-edit root cause still needs the + separate worktree-discipline work; the `warning` field surfaces it. +- Verified by `test/mcp-collect.test.js` and `test/mcp-server.test.js`. diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md new file mode 100644 index 0000000..b015c3d --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Read-only cross-repo agent observability over MCP +The system SHALL provide a `gx mcp serve` stdio MCP server that exposes +read-only tools reflecting current git, worktree, file-lock, and PR state. The +server SHALL NOT mutate any repository. + +#### Scenario: Protocol handshake and tool discovery +- **WHEN** an MCP client sends `initialize` then `tools/list` +- **THEN** the server returns its `serverInfo` and tools capability +- **AND** `tools/list` returns exactly `list_agents`, `repo_state`, `who_owns`, and `my_context`, each with a description and an object input schema. + +#### Scenario: List active agent lanes across repos +- **WHEN** `list_agents` is called +- **THEN** it returns one record per active agent lane (a worktree on a non-protected branch) across all discovered repos +- **AND** each record carries repo, branch, worktree, task, held locks, last commit, and (best-effort) the open PR for the branch +- **AND** a repo and its linked worktrees are counted as a single repo (deduped by main root). + +#### Scenario: Cross-worktree lock ownership +- **WHEN** `who_owns(file)` is called for a path locked by another agent in a different worktree +- **THEN** it returns that branch/agent as the owner, aggregating per-worktree lock files across the whole repo +- **AND** returns `owner: null` for an unclaimed path. + +#### Scenario: Surface unsafe primary-checkout editing +- **WHEN** a lane is the primary checkout sitting on a non-protected branch +- **THEN** its record includes a warning that edits there risk auto-stash/revert. + +#### Scenario: Best-effort, never-throwing PR lookup +- **WHEN** a branch has no upstream, or `gh` is missing/unauthenticated +- **THEN** the PR field is `null` and the call still succeeds (no throw, no partial failure). + +### Requirement: Dependency-free server and opt-in registration +The MCP server SHALL be implemented without adding a third-party MCP SDK +dependency, and SHALL NOT be wired into any agent harness automatically. + +#### Scenario: Registration guidance +- **WHEN** the user runs `gx mcp register` +- **THEN** the command prints how to register the server (`claude mcp add` and a `.mcp.json` snippet) without modifying any configuration file. diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/tasks.md b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/tasks.md new file mode 100644 index 0000000..2d6cb45 --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/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-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/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-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33`. +- [x] 1.2 Define normative requirements in `specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/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-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33 --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/mcp.js b/src/cli/commands/mcp.js new file mode 100644 index 0000000..3fc86d5 --- /dev/null +++ b/src/cli/commands/mcp.js @@ -0,0 +1,118 @@ +'use strict'; + +// `gx mcp` — cross-repo, read-only multi-agent observability over MCP. +// gx mcp serve run the stdio MCP server (for agent harnesses) +// gx mcp list-agents one-shot human/debug view of all agent lanes +// gx mcp who-owns who holds the lock on a file +// gx mcp register print how to wire the server into Claude Code / Codex + +const collect = require('../../mcp/collect'); +const server = require('../../mcp/server'); +const { SHORT_TOOL_NAME } = require('../../context'); + +function printUsage() { + process.stdout.write( + [ + `Usage: ${SHORT_TOOL_NAME} mcp `, + '', + ' serve Run the read-only MCP server over stdio.', + ' list-agents [--json] [--no-prs]', + ' Show every active agent lane across all repos.', + ' who-owns [--json]', + ' Which agent/branch holds the lock on .', + ' register Print how to register the server with an agent.', + '', + ].join('\n') + '\n', + ); +} + +function fmtAgent(a) { + const pr = a.pr ? `PR #${a.pr.number} (${a.pr.state}${a.pr.isDraft ? ', draft' : ''})` : a.pushed ? 'pushed, no open PR' : 'local only'; + const locks = a.locks && a.locks.length ? `${a.locks.length} lock(s)` : 'no locks'; + const when = a.lastCommit && a.lastCommit.date ? a.lastCommit.date.replace('T', ' ').replace(/\..*$/, '') : '?'; + const warn = a.warning ? ' ⚠ ON PRIMARY CHECKOUT' : ''; + return [ + `• ${a.repo} ${a.branch}${warn}`, + ` agent=${a.agent || '?'} task=${a.task}`, + ` ${pr} ${locks} last=${when}`, + ` worktree=${a.worktree}`, + ].join('\n'); +} + +function listAgents(rest) { + const includePrs = !rest.includes('--no-prs'); + const data = collect.collectAllAgents({ includePrs }); + if (rest.includes('--json')) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const header = `gx agents — ${data.agents.length} active lane(s) across ${data.scannedRepos} repo(s)`; + const body = data.agents.length ? data.agents.map(fmtAgent).join('\n') : ' (no active agent lanes found)'; + process.stdout.write(`${header}\n\n${body}\n`); + if (data.errors && data.errors.length) { + process.stdout.write(`\n${data.errors.length} repo(s) errored during scan (run with --json for detail)\n`); + } +} + +function whoOwns(rest) { + const file = rest.find((a) => !a.startsWith('--')); + const result = collect.whoOwns(file, {}); + if (rest.includes('--json')) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + if (!result.owner) { + process.stdout.write(`${result.file || file}: unclaimed${result.error ? ` (${result.error})` : ''}\n`); + return; + } + process.stdout.write(`${result.file}: locked by ${result.owner.agent || result.owner.branch} (branch ${result.owner.branch})\n`); +} + +function register() { + process.stdout.write( + [ + 'Register the read-only gx agent-observability MCP with your harness:', + '', + 'Claude Code (user scope — available in every repo):', + ` claude mcp add gx -s user -- ${SHORT_TOOL_NAME} mcp serve`, + '', + 'Or add to a repo-root .mcp.json:', + ' {', + ' "mcpServers": {', + ` "gx": { "command": "${SHORT_TOOL_NAME}", "args": ["mcp", "serve"] }`, + ' }', + ' }', + '', + 'Codex / other MCP clients: run `gx mcp serve` as a stdio MCP server.', + 'Tools exposed (all read-only): list_agents, repo_state, who_owns, my_context.', + '', + ].join('\n') + '\n', + ); +} + +function mcp(rawArgs = []) { + const [subcommand, ...rest] = rawArgs; + if (subcommand === 'serve') { + server.serve(); // long-running: readline keeps the process alive + return; + } + if (subcommand === 'list-agents' || subcommand === 'list' || subcommand === 'agents') { + listAgents(rest); + process.exitCode = 0; + return; + } + if (subcommand === 'who-owns' || subcommand === 'who') { + whoOwns(rest); + process.exitCode = 0; + return; + } + if (subcommand === 'register' || subcommand === 'print-config') { + register(); + process.exitCode = 0; + return; + } + printUsage(); + process.exitCode = subcommand ? 1 : 0; +} + +module.exports = { mcp }; diff --git a/src/cli/main.js b/src/cli/main.js index 85d1114..28d436e 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -38,6 +38,7 @@ const { review, prReview } = require('./commands/review'); const { pr: prCommand } = require('./commands/pr'); const { claude: claudeCommand } = require('./commands/claude'); const { agents } = require('./commands/agents'); +const { mcp } = require('./commands/mcp'); const { report } = require('./commands/report'); const { release } = require('./commands/release'); const { watch } = require('./commands/watch'); @@ -222,6 +223,7 @@ async function main() { if (command === 'install-agent-skills') return installAgentSkills(rest); if (command === 'internal') return internal(rest); if (command === 'agents') return agents(rest); + if (command === 'mcp') return mcp(rest); if (command === 'cockpit') return cockpit(rest); if (command === 'merge') return merge(rest); if (command === 'finish') return finish(rest); diff --git a/src/mcp/collect.js b/src/mcp/collect.js new file mode 100644 index 0000000..5f97c02 --- /dev/null +++ b/src/mcp/collect.js @@ -0,0 +1,282 @@ +'use strict'; + +// Read-only collector for the gx MCP server. Assembles a cross-repo picture of +// "which agent is on which branch / worktree / PR, and what file locks they +// hold" purely from git + gitguardex on-disk state — no manual bookkeeping. +// +// Sources (all already maintained by gitguardex): +// - repo discovery : cockpit/projects-finder.findProjects() +// - branches/worktrees: `git worktree list --porcelain` +// - file locks : .omx/state/agent-file-locks.json +// - PR state : pr.findOpenPrForBranch() (gh, best-effort) + +const cp = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { findProjects } = require('../cockpit/projects-finder'); +const { findOpenPrForBranch } = require('../pr'); + +const PROTECTED_BRANCHES = new Set(['main', 'master', 'dev']); +const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); + +function git(repoRoot, args) { + const res = cp.spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' }); + if (!res || res.status !== 0) return null; + return (res.stdout || '').trim(); +} + +function isProtectedBranch(branch) { + return !branch || branch === 'HEAD' || PROTECTED_BRANCHES.has(branch); +} + +function parseAgentName(branch) { + // agent// -> name + const parts = String(branch || '').split('/'); + if (parts.length >= 3 && parts[0] === 'agent') return parts[1]; + return null; +} + +function humanizeSlug(branch) { + const parts = String(branch || '').split('/'); + const slug = (parts.length >= 3 ? parts.slice(2).join('/') : parts.slice(1).join('/')) || branch; + return slug.replace(/-\d{4}-\d{2}-\d{2}.*$/, '').replace(/-/g, ' ').trim() || branch; +} + +function repoName(repoPath) { + return path.basename(repoPath || ''); +} + +function listWorktrees(repoRoot) { + const out = git(repoRoot, ['worktree', 'list', '--porcelain']); + if (out == null) return []; + const worktrees = []; + out.split(/\n\n+/).forEach((block, idx) => { + let wtPath = null; + let branch = null; + let head = null; + let detached = false; + for (const line of block.split('\n')) { + if (line.startsWith('worktree ')) wtPath = line.slice(9).trim(); + else if (line.startsWith('branch ')) branch = line.slice(7).trim().replace(/^refs\/heads\//, ''); + else if (line.startsWith('HEAD ')) head = line.slice(5).trim(); + else if (line.trim() === 'detached') detached = true; + } + if (wtPath) worktrees.push({ path: wtPath, branch: detached ? null : branch, head, isPrimary: idx === 0 }); + }); + return worktrees; +} + +function readLockMap(repoRoot) { + const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE); + let raw; + try { + raw = fs.readFileSync(lockPath, 'utf8'); + } catch { + return {}; + } + try { + const data = JSON.parse(raw); + return (data && data.locks) || {}; + } catch { + return {}; + } +} + +function locksByBranch(repoRoot) { + const map = readLockMap(repoRoot); + const byBranch = {}; + for (const [file, meta] of Object.entries(map)) { + const b = meta && meta.branch; + if (!b) continue; + (byBranch[b] = byBranch[b] || []).push(file); + } + return byBranch; +} + +// Resolve the MAIN repository root from any path inside it (a linked agent +// worktree resolves up to the primary checkout). Worktrees share one ref store +// via --git-common-dir, so all git ref ops below run against the main root. +function mainRepoRoot(somePath) { + const top = git(somePath, ['rev-parse', '--show-toplevel']); + if (!top) return null; + const common = git(somePath, ['rev-parse', '--git-common-dir']); + if (!common) return top; + const commonAbs = path.isAbsolute(common) ? common : path.resolve(top, common); + return path.basename(commonAbs) === '.git' ? path.dirname(commonAbs) : top; +} + +function branchHasUpstream(repoRoot, branch) { + return Boolean(git(repoRoot, ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`])); +} + +function lastCommit(repoRoot, branch) { + const out = git(repoRoot, ['log', '-1', '--format=%cI%x09%s', branch]); + if (!out) return null; + const tab = out.indexOf('\t'); + if (tab === -1) return { date: out, subject: '' }; + return { date: out.slice(0, tab), subject: out.slice(tab + 1) }; +} + +// Best-effort PR lookup. Skips gh entirely for un-pushed branches, and never +// throws (gh missing / unauthed / offline -> null). +function safePr(repoRoot, branch) { + if (!branchHasUpstream(repoRoot, branch)) return null; + try { + const pr = findOpenPrForBranch(repoRoot, branch); + if (!pr) return null; + return { + number: pr.number, + url: pr.url, + state: pr.state, + isDraft: pr.isDraft, + title: pr.title, + baseRefName: pr.baseRefName, + reviewDecision: pr.reviewDecision || null, + mergeable: pr.mergeable || null, + mergeStateStatus: pr.mergeStateStatus || null, + }; + } catch { + return null; + } +} + +function buildAgentRecord(mainRoot, wt, locks, includePrs) { + const branch = wt.branch; + const record = { + repo: repoName(mainRoot), + repoPath: mainRoot, + branch, + agent: parseAgentName(branch), + task: humanizeSlug(branch), + worktree: wt.path, + onPrimaryCheckout: Boolean(wt.isPrimary), + pushed: branchHasUpstream(mainRoot, branch), + locks, + lastCommit: lastCommit(mainRoot, branch), + pr: includePrs ? safePr(mainRoot, branch) : null, + }; + if (wt.isPrimary) { + record.warning = + 'on the PRIMARY checkout, not an isolated worktree — edits here risk auto-stash/revert when another lane switches branches. Use `gx branch start`.'; + } + return record; +} + +function collectRepoAgents(repoPath, { includePrs = true } = {}) { + const mainRoot = mainRepoRoot(repoPath) || repoPath; + const worktrees = listWorktrees(mainRoot); + if (worktrees.length === 0) return []; + const agents = []; + for (const wt of worktrees) { + // Skip the safe/normal states: primary on a protected base, detached + // worktrees, and the rare protected-branch linked worktree. What remains + // is an active agent lane (or an agent editing on primary, surfaced with a + // warning). + if (!wt.branch) continue; + if (isProtectedBranch(wt.branch) && !wt.isPrimary) continue; + if (wt.isPrimary && isProtectedBranch(wt.branch)) continue; + // Each worktree owns its OWN lock file; a lane's locks are the entries in + // its own worktree keyed to its branch. + const locks = locksByBranch(wt.path)[wt.branch] || []; + agents.push(buildAgentRecord(mainRoot, wt, locks, includePrs)); + } + return agents; +} + +function collectAllAgents({ roots, includePrs = true, limit } = {}) { + const found = findProjects(roots && roots.length ? { roots } : {}); + const projects = Array.isArray(found.projects) ? found.projects : []; + // Collapse discovered paths to unique MAIN repo roots — a repo and its linked + // worktrees must not be counted as separate "repos". + const seen = new Set(); + const mainRoots = []; + for (const project of projects) { + const root = mainRepoRoot(project.path) || project.path; + if (seen.has(root)) continue; + seen.add(root); + mainRoots.push(root); + if (limit && mainRoots.length >= limit) break; + } + const agents = []; + const errors = []; + for (const root of mainRoots) { + try { + agents.push(...collectRepoAgents(root, { includePrs })); + } catch (err) { + errors.push({ repo: root, error: String((err && err.message) || err) }); + } + } + agents.sort((a, b) => { + const da = (a.lastCommit && a.lastCommit.date) || ''; + const db = (b.lastCommit && b.lastCommit.date) || ''; + return db.localeCompare(da); // most recent activity first + }); + return { agents, scannedRepos: mainRoots.length, roots: found.roots || [], errors }; +} + +function repoState(repoOrCwd, { includePrs = true } = {}) { + const root = mainRepoRoot(repoOrCwd) || repoOrCwd; + return { repo: repoName(root), repoPath: root, agents: collectRepoAgents(root, { includePrs }) }; +} + +// Aggregate locks across ALL worktrees of the repo. Lock files are per-worktree +// on disk, so a single worktree's file only shows its own claims — the +// collision view requires the union. +function whoOwns(file, { cwd = process.cwd(), repoPath } = {}) { + if (!file) return { file: null, owner: null, error: 'no file given' }; + const mainRoot = mainRepoRoot(repoPath || cwd); + if (!mainRoot) return { file, owner: null, error: 'not a git repo' }; + const rel = path.isAbsolute(file) ? path.relative(mainRoot, file) : file; + const owners = []; + const seenBranch = new Set(); + for (const wt of listWorktrees(mainRoot)) { + const map = readLockMap(wt.path); + const entry = map[rel] || map[file]; + if (entry && entry.branch && !seenBranch.has(entry.branch)) { + seenBranch.add(entry.branch); + owners.push({ + branch: entry.branch, + agent: parseAgentName(entry.branch), + claimed_at: entry.claimed_at || null, + worktree: wt.path, + }); + } + } + if (owners.length === 0) return { file: rel, owner: null }; + return { file: rel, owner: owners.length === 1 ? owners[0] : null, owners, conflict: owners.length > 1 }; +} + +function myContext({ cwd = process.cwd(), includePr = true } = {}) { + const here = git(cwd, ['rev-parse', '--show-toplevel']); + if (!here) return { error: 'not a git repo', cwd }; + const mainRoot = mainRepoRoot(cwd) || here; + const branch = git(here, ['rev-parse', '--abbrev-ref', 'HEAD']); + const self = listWorktrees(mainRoot).find((w) => path.resolve(w.path) === path.resolve(here)); + return { + repo: repoName(mainRoot), + repoPath: mainRoot, + worktree: here, + branch, + agent: parseAgentName(branch), + onPrimaryCheckout: self ? Boolean(self.isPrimary) : null, + protected: isProtectedBranch(branch), + locks: branch ? locksByBranch(here)[branch] || [] : [], // this lane's own claims + pr: includePr && branch ? safePr(mainRoot, branch) : null, + lastCommit: branch ? lastCommit(mainRoot, branch) : null, + }; +} + +module.exports = { + collectAllAgents, + collectRepoAgents, + repoState, + whoOwns, + myContext, + listWorktrees, + locksByBranch, + parseAgentName, + humanizeSlug, + isProtectedBranch, + LOCK_FILE_RELATIVE, +}; diff --git a/src/mcp/server.js b/src/mcp/server.js new file mode 100644 index 0000000..8ef2c01 --- /dev/null +++ b/src/mcp/server.js @@ -0,0 +1,151 @@ +'use strict'; + +// Minimal Model Context Protocol server over stdio, hand-rolled to keep +// gitguardex dependency-light (no @modelcontextprotocol/sdk). MCP stdio is +// newline-delimited JSON-RPC 2.0; we implement the small surface an agent +// needs: initialize, tools/list, tools/call, ping. +// +// All tools are READ-ONLY — the server only reflects git/worktree/lock/PR +// state, it never mutates a repo. + +const readline = require('node:readline'); + +const collect = require('./collect'); +const { packageJson } = require('../context'); + +const PROTOCOL_VERSION = '2024-11-05'; + +const TOOLS = [ + { + name: 'list_agents', + description: + 'List every active agent lane across all discovered repos: repo, branch, worktree, task, the PR it is shipping, held file locks, last commit, and warnings (e.g. an agent editing on the primary checkout). Use this to see who is working on what before you start. Read-only.', + inputSchema: { + type: 'object', + properties: { + include_prs: { + type: 'boolean', + description: 'Fetch PR state per pushed branch via gh (slower, network). Default true.', + }, + roots: { + type: 'array', + items: { type: 'string' }, + description: 'Override repo search roots. Default: ~/Documents, ~/code, ~/src, ~/projects.', + }, + limit: { type: 'number', description: 'Max number of repos to scan.' }, + }, + }, + }, + { + name: 'repo_state', + description: + 'Agent lanes for a single repository (branches, worktrees, file locks, PRs). Pass a repo path; defaults to the current working repo.', + inputSchema: { + type: 'object', + properties: { + repo: { type: 'string', description: 'Path inside the target repo. Defaults to cwd.' }, + include_prs: { type: 'boolean', description: 'Fetch PR state. Default true.' }, + }, + }, + }, + { + name: 'who_owns', + description: + 'Check which agent/branch holds the gitguardex file lock on a path BEFORE you edit it, to avoid colliding with another agent. Returns owner=null when the file is unclaimed.', + inputSchema: { + type: 'object', + properties: { + file: { type: 'string', description: 'Repo-relative or absolute path to check.' }, + repo: { type: 'string', description: 'Path inside the target repo. Defaults to cwd.' }, + }, + required: ['file'], + }, + }, + { + name: 'my_context', + description: + 'Report THIS session: current repo, branch, worktree, whether it is the protected primary checkout (where edits are unsafe), held locks, and the PR for the branch.', + inputSchema: { type: 'object', properties: {} }, + }, +]; + +function callTool(name, args = {}) { + switch (name) { + case 'list_agents': + return collect.collectAllAgents({ + roots: args.roots, + includePrs: args.include_prs !== false, + limit: args.limit, + }); + case 'repo_state': + return collect.repoState(args.repo || process.cwd(), { includePrs: args.include_prs !== false }); + case 'who_owns': + return collect.whoOwns(args.file, { repoPath: args.repo }); + case 'my_context': + return collect.myContext({}); + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +function ok(id, result) { + return id === undefined || id === null ? null : { jsonrpc: '2.0', id, result }; +} + +function rpcError(id, code, message) { + return id === undefined || id === null ? null : { jsonrpc: '2.0', id, error: { code, message } }; +} + +// Pure request handler: returns a JSON-RPC response object, or null for +// notifications (no `id`). Kept side-effect-free so it is unit-testable. +function dispatch(msg) { + const { id, method, params } = msg || {}; + const isNotification = id === undefined || id === null; + try { + if (method === 'initialize') { + return ok(id, { + protocolVersion: (params && params.protocolVersion) || PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'gx', version: (packageJson && packageJson.version) || '0.0.0' }, + }); + } + if (method === 'tools/list') return ok(id, { tools: TOOLS }); + if (method === 'tools/call') { + const name = params && params.name; + const args = (params && params.arguments) || {}; + const result = callTool(name, args); + return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }); + } + if (method === 'ping') return ok(id, {}); + if (isNotification) return null; // e.g. notifications/initialized + return rpcError(id, -32601, `Method not found: ${method}`); + } catch (err) { + const message = String((err && err.message) || err); + // A failing tool call is reported as a tool result (isError), per MCP, so + // the agent sees the error instead of the whole call rejecting. + if (method === 'tools/call' && !isNotification) { + return ok(id, { content: [{ type: 'text', text: `Error: ${message}` }], isError: true }); + } + if (isNotification) return null; + return rpcError(id, -32603, message); + } +} + +function serve({ input = process.stdin, output = process.stdout } = {}) { + const rl = readline.createInterface({ input, terminal: false }); + rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + return; // ignore malformed line + } + const res = dispatch(msg); + if (res) output.write(`${JSON.stringify(res)}\n`); + }); + return rl; +} + +module.exports = { serve, dispatch, callTool, TOOLS, PROTOCOL_VERSION }; diff --git a/test/mcp-collect.test.js b/test/mcp-collect.test.js new file mode 100644 index 0000000..bf835cd --- /dev/null +++ b/test/mcp-collect.test.js @@ -0,0 +1,125 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const cp = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const collect = require('../src/mcp/collect'); + +function git(dir, args) { + const r = cp.spawnSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd: dir, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr || r.stdout}`); + return (r.stdout || '').trim(); +} + +function writeLock(worktreePath, locks) { + const dir = path.join(worktreePath, '.omx', 'state'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'agent-file-locks.json'), `${JSON.stringify({ locks }, null, 2)}\n`); +} + +// A main repo on `main` plus two linked agent worktrees, each with its OWN +// per-worktree lock file (mirrors how gitguardex stores locks on disk). +function makeRepoWithLanes() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gxmcp-')); + const main = path.join(root, 'mainrepo'); + fs.mkdirSync(main); + git(main, ['init', '-q', '-b', 'main']); + git(main, ['config', 'user.email', 't@e.com']); + git(main, ['config', 'user.name', 'T']); + git(main, ['config', 'commit.gpgsign', 'false']); + fs.writeFileSync(path.join(main, 'README.md'), 'hi\n'); + git(main, ['add', '.']); + git(main, ['commit', '-q', '-m', 'seed']); + + const wtA = path.join(root, 'wt-alice'); + git(main, ['worktree', 'add', '-q', '-b', 'agent/alice/feature-x', wtA]); + writeLock(wtA, { 'src/x.js': { branch: 'agent/alice/feature-x', claimed_at: '2026-06-05T10:00:00+00:00' } }); + + const wtB = path.join(root, 'wt-bob'); + git(main, ['worktree', 'add', '-q', '-b', 'agent/bob/fix-y', wtB]); + writeLock(wtB, { 'src/y.js': { branch: 'agent/bob/fix-y', claimed_at: '2026-06-05T11:00:00+00:00' } }); + + return { root, main, wtA, wtB }; +} + +test('collectRepoAgents lists each agent lane (not the protected primary) with its own locks', () => { + const { root, main } = makeRepoWithLanes(); + try { + const agents = collect.collectRepoAgents(main, { includePrs: false }); + const branches = agents.map((a) => a.branch).sort(); + assert.deepEqual(branches, ['agent/alice/feature-x', 'agent/bob/fix-y'], 'two lanes, no main'); + const alice = agents.find((a) => a.agent === 'alice'); + assert.equal(alice.repo, 'mainrepo'); + assert.deepEqual(alice.locks, ['src/x.js']); + assert.equal(alice.onPrimaryCheckout, false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('whoOwns aggregates locks across ALL worktrees (per-worktree lock files)', () => { + const { root, main, wtA } = makeRepoWithLanes(); + try { + // Query from main: finds alice's lock that lives in wt-alice's file. + const x = collect.whoOwns('src/x.js', { repoPath: main }); + assert.equal(x.owner.branch, 'agent/alice/feature-x'); + assert.equal(x.owner.agent, 'alice'); + + // Query from ALICE's worktree: still finds BOB's lock (cross-worktree union). + const y = collect.whoOwns('src/y.js', { repoPath: wtA }); + assert.equal(y.owner.agent, 'bob', 'cross-worktree lock visibility is the whole point'); + + const free = collect.whoOwns('README.md', { repoPath: main }); + assert.equal(free.owner, null, 'unclaimed file has no owner'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('myContext resolves the real repo name + this lane from inside a worktree', () => { + const { root, wtA } = makeRepoWithLanes(); + try { + const ctx = collect.myContext({ cwd: wtA, includePr: false }); + assert.equal(ctx.repo, 'mainrepo', 'repo name is the MAIN repo, not the worktree dir'); + assert.equal(ctx.branch, 'agent/alice/feature-x'); + assert.equal(ctx.agent, 'alice'); + assert.equal(ctx.onPrimaryCheckout, false); + assert.equal(ctx.protected, false); + assert.deepEqual(ctx.locks, ['src/x.js']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('collectAllAgents dedupes a repo + its worktrees into one main root', () => { + const { root } = makeRepoWithLanes(); + try { + const data = collect.collectAllAgents({ roots: [root], includePrs: false }); + assert.equal(data.scannedRepos, 1, 'main repo + 2 worktrees collapse to one repo'); + assert.equal(data.agents.filter((a) => a.branch === 'agent/alice/feature-x').length, 1, 'no duplicate lanes'); + assert.equal(data.agents.length, 2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('an agent editing on the PRIMARY checkout is surfaced with a warning', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gxmcp-primary-')); + try { + git(root, ['init', '-q', '-b', 'feature/on-primary']); + git(root, ['config', 'user.email', 't@e.com']); + git(root, ['config', 'user.name', 'T']); + git(root, ['config', 'commit.gpgsign', 'false']); + fs.writeFileSync(path.join(root, 'f.txt'), 'x\n'); + git(root, ['add', '.']); + git(root, ['commit', '-q', '-m', 'seed']); + const agents = collect.collectRepoAgents(root, { includePrs: false }); + assert.equal(agents.length, 1); + assert.equal(agents[0].onPrimaryCheckout, true); + assert.match(agents[0].warning, /PRIMARY checkout/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js new file mode 100644 index 0000000..03ecb2a --- /dev/null +++ b/test/mcp-server.test.js @@ -0,0 +1,67 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { PassThrough } = require('node:stream'); + +const server = require('../src/mcp/server'); + +test('initialize returns serverInfo and echoes the protocol version', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } }); + assert.equal(r.id, 1); + assert.equal(r.result.serverInfo.name, 'gx'); + assert.equal(r.result.protocolVersion, '2024-11-05'); + assert.ok(r.result.capabilities.tools, 'declares tools capability'); +}); + +test('tools/list returns the four read-only tools, each with a schema', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const names = r.result.tools.map((t) => t.name).sort(); + assert.deepEqual(names, ['list_agents', 'my_context', 'repo_state', 'who_owns']); + for (const t of r.result.tools) { + assert.ok(t.description, `${t.name} has a description`); + assert.equal(t.inputSchema.type, 'object', `${t.name} has an object input schema`); + } +}); + +test('notifications (no id) produce no response', () => { + assert.equal(server.dispatch({ jsonrpc: '2.0', method: 'notifications/initialized' }), null); +}); + +test('unknown method returns JSON-RPC method-not-found (-32601)', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 9, method: 'bogus/method' }); + assert.equal(r.error.code, -32601); +}); + +test('tools/call wraps the tool result as JSON text content', () => { + const r = server.dispatch({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'who_owns', arguments: { file: 'definitely-unclaimed-xyz.txt' } }, + }); + assert.equal(r.id, 3); + assert.ok(Array.isArray(r.result.content)); + const parsed = JSON.parse(r.result.content[0].text); + assert.ok('owner' in parsed, 'who_owns result has an owner field'); +}); + +test('a failing tool call comes back as isError, not a thrown rejection', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'nope', arguments: {} } }); + assert.equal(r.result.isError, true); + assert.match(r.result.content[0].text, /Unknown tool/); +}); + +test('serve() reads newline-delimited JSON from stdin and writes responses to stdout', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let out = ''; + output.on('data', (c) => { out += c; }); + const rl = server.serve({ input, output }); + input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })}\n`); + input.end(); + await new Promise((resolve) => { output.on('end', resolve); rl.on('close', resolve); setTimeout(resolve, 100); }); + rl.close(); + const first = out.trim().split('\n')[0]; + const msg = JSON.parse(first); + assert.equal(msg.id, 1); + assert.equal(msg.result.tools.length, 4); +}); From 7d9248e34d08dc9e96d5451feaa0e2d5c43709b8 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 5 Jun 2026 13:10:36 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp):=20address=20adversarial=20review?= =?UTF-8?q?=20=E2=80=94=20pin=20protocol=20version,=20bound=20git=20calls,?= =?UTF-8?q?=20add=20live=20dirty=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review verdict SHIP_WITH_FIXES. Applied: - HIGH #5: initialize pins server PROTOCOL_VERSION instead of echoing the client's requested version (correct MCP version negotiation). - HIGH #10: bound every git() call with a 7s timeout + maxBuffer, and make list_agents PR fetch opt-in (default off) so a hung gh can't exceed the MCP client timeout. repo_state/my_context keep PRs on (narrow scope). - Value gap #16: add per-lane 'dirty' (git status --porcelain) — the files an agent is editing RIGHT NOW, independent of commit-time locks. Filters .omx/.omc runtime churn. Caught+fixed a porcelain parse bug (git() .trim() ate the first line's leading status column). - Cheap: parse errors -> JSON-RPC -32700 (id null); stderr warn on corrupt lock. Tests: 15/15 (added protocol-pin, parse-error, and live-dirty cases). --- .../spec.md | 9 ++++- src/cli/commands/mcp.js | 3 +- src/mcp/collect.js | 40 ++++++++++++++++++- src/mcp/server.js | 16 +++++--- test/mcp-collect.test.js | 15 +++++++ test/mcp-server.test.js | 20 ++++++++++ 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md index b015c3d..71a0ca2 100644 --- a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md @@ -13,8 +13,13 @@ server SHALL NOT mutate any repository. #### Scenario: List active agent lanes across repos - **WHEN** `list_agents` is called - **THEN** it returns one record per active agent lane (a worktree on a non-protected branch) across all discovered repos -- **AND** each record carries repo, branch, worktree, task, held locks, last commit, and (best-effort) the open PR for the branch -- **AND** a repo and its linked worktrees are counted as a single repo (deduped by main root). +- **AND** each record carries repo, branch, worktree, task, the files it is editing right now (`dirty`), held locks, last commit, and the open PR for the branch +- **AND** a repo and its linked worktrees are counted as a single repo (deduped by main root) +- **AND** PR lookups are opt-in for `list_agents` (default off) to bound the cross-repo `gh` fan-out, while single-repo `repo_state`/`my_context` include PRs by default. + +#### Scenario: Live in-progress edits independent of locks +- **WHEN** a lane has uncommitted changes that have not been lock-claimed (locks materialize at commit time) +- **THEN** the lane's `dirty` field lists those changed files (excluding gitguardex runtime state under `.omx/`/`.omc/`), giving a live "currently editing" signal. #### Scenario: Cross-worktree lock ownership - **WHEN** `who_owns(file)` is called for a path locked by another agent in a different worktree diff --git a/src/cli/commands/mcp.js b/src/cli/commands/mcp.js index 3fc86d5..9716c30 100644 --- a/src/cli/commands/mcp.js +++ b/src/cli/commands/mcp.js @@ -29,12 +29,13 @@ function printUsage() { function fmtAgent(a) { const pr = a.pr ? `PR #${a.pr.number} (${a.pr.state}${a.pr.isDraft ? ', draft' : ''})` : a.pushed ? 'pushed, no open PR' : 'local only'; const locks = a.locks && a.locks.length ? `${a.locks.length} lock(s)` : 'no locks'; + const dirty = a.dirty && a.dirty.length ? `editing ${a.dirty.length} file(s)` : 'clean'; const when = a.lastCommit && a.lastCommit.date ? a.lastCommit.date.replace('T', ' ').replace(/\..*$/, '') : '?'; const warn = a.warning ? ' ⚠ ON PRIMARY CHECKOUT' : ''; return [ `• ${a.repo} ${a.branch}${warn}`, ` agent=${a.agent || '?'} task=${a.task}`, - ` ${pr} ${locks} last=${when}`, + ` ${dirty} ${locks} ${pr} last=${when}`, ` worktree=${a.worktree}`, ].join('\n'); } diff --git a/src/mcp/collect.js b/src/mcp/collect.js index 5f97c02..9a469f2 100644 --- a/src/mcp/collect.js +++ b/src/mcp/collect.js @@ -21,11 +21,44 @@ const PROTECTED_BRANCHES = new Set(['main', 'master', 'dev']); const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); function git(repoRoot, args) { - const res = cp.spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' }); + // Bounded: a hung git call must not stall the whole MCP request past the + // client timeout. On timeout spawnSync sets status=null -> we return null. + const res = cp.spawnSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + timeout: 7000, + maxBuffer: 8 * 1024 * 1024, + }); if (!res || res.status !== 0) return null; return (res.stdout || '').trim(); } +// Files an agent is changing RIGHT NOW in a worktree (uncommitted). Unlike +// locks (written at commit time), this reflects in-progress edits — the most +// direct "who is working on what" signal. +function dirtyFiles(worktreePath, cap = 25) { + // NB: parse RAW stdout (not the trimmed git() helper) — porcelain is + // column-sensitive ("XY PATH"); trimming eats the first line's leading + // status space and shifts the path by one. + const res = cp.spawnSync('git', ['status', '--porcelain'], { + cwd: worktreePath, + encoding: 'utf8', + timeout: 7000, + maxBuffer: 8 * 1024 * 1024, + }); + if (!res || res.status !== 0 || !res.stdout) return []; + const files = res.stdout + .split('\n') + .filter((line) => line.length > 3) + .map((line) => line.slice(3)) + .filter(Boolean) + // Exclude gitguardex runtime state — it's bookkeeping churn, not the + // agent's work (and is gitignored in real repos anyway). + .filter((f) => !f.startsWith('.omx/') && !f.startsWith('.omc/')); + if (files.length <= cap) return files; + return files.slice(0, cap).concat([`…(+${files.length - cap} more)`]); +} + function isProtectedBranch(branch) { return !branch || branch === 'HEAD' || PROTECTED_BRANCHES.has(branch); } @@ -79,6 +112,9 @@ function readLockMap(repoRoot) { const data = JSON.parse(raw); return (data && data.locks) || {}; } catch { + // stdout is reserved for JSON-RPC; surface the problem on stderr so a + // poisoned lock file doesn't silently hide claims. + process.stderr.write(`[gx mcp] warning: ignoring corrupt lock file ${lockPath}\n`); return {}; } } @@ -152,6 +188,7 @@ function buildAgentRecord(mainRoot, wt, locks, includePrs) { worktree: wt.path, onPrimaryCheckout: Boolean(wt.isPrimary), pushed: branchHasUpstream(mainRoot, branch), + dirty: dirtyFiles(wt.path), locks, lastCommit: lastCommit(mainRoot, branch), pr: includePrs ? safePr(mainRoot, branch) : null, @@ -261,6 +298,7 @@ function myContext({ cwd = process.cwd(), includePr = true } = {}) { agent: parseAgentName(branch), onPrimaryCheckout: self ? Boolean(self.isPrimary) : null, protected: isProtectedBranch(branch), + dirty: dirtyFiles(here), locks: branch ? locksByBranch(here)[branch] || [] : [], // this lane's own claims pr: includePr && branch ? safePr(mainRoot, branch) : null, lastCommit: branch ? lastCommit(mainRoot, branch) : null, diff --git a/src/mcp/server.js b/src/mcp/server.js index 8ef2c01..0fe0d41 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -19,13 +19,13 @@ const TOOLS = [ { name: 'list_agents', description: - 'List every active agent lane across all discovered repos: repo, branch, worktree, task, the PR it is shipping, held file locks, last commit, and warnings (e.g. an agent editing on the primary checkout). Use this to see who is working on what before you start. Read-only.', + 'List every active agent lane across all discovered repos: repo, branch, worktree, task, dirty (files changed RIGHT NOW), held file locks, last commit, the PR it is shipping (opt-in), and warnings — e.g. a lane editing the primary checkout (the harness should act on that warning by moving to `gx branch start`). Use this to see who is working on what before you start. Read-only.', inputSchema: { type: 'object', properties: { include_prs: { type: 'boolean', - description: 'Fetch PR state per pushed branch via gh (slower, network). Default true.', + description: 'Fetch PR state per pushed branch via gh (slower, network fan-out). Default FALSE here — pass true when you need PRs, or use repo_state for one repo.', }, roots: { type: 'array', @@ -72,9 +72,11 @@ const TOOLS = [ function callTool(name, args = {}) { switch (name) { case 'list_agents': + // PRs are opt-in here: a cross-repo gh fan-out can exceed the client + // timeout. repo_state/my_context (narrow scope) keep PRs on by default. return collect.collectAllAgents({ roots: args.roots, - includePrs: args.include_prs !== false, + includePrs: args.include_prs === true, limit: args.limit, }); case 'repo_state': @@ -103,8 +105,10 @@ function dispatch(msg) { const isNotification = id === undefined || id === null; try { if (method === 'initialize') { + // Pin the version WE support, regardless of what the client requested — + // echoing an unknown/future version back defeats MCP version negotiation. return ok(id, { - protocolVersion: (params && params.protocolVersion) || PROTOCOL_VERSION, + protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: 'gx', version: (packageJson && packageJson.version) || '0.0.0' }, }); @@ -140,7 +144,9 @@ function serve({ input = process.stdin, output = process.stdout } = {}) { try { msg = JSON.parse(trimmed); } catch { - return; // ignore malformed line + // JSON-RPC 2.0: a parse error is reported with a null id. + output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); + return; } const res = dispatch(msg); if (res) output.write(`${JSON.stringify(res)}\n`); diff --git a/test/mcp-collect.test.js b/test/mcp-collect.test.js index bf835cd..1424fdf 100644 --- a/test/mcp-collect.test.js +++ b/test/mcp-collect.test.js @@ -105,6 +105,21 @@ test('collectAllAgents dedupes a repo + its worktrees into one main root', () => } }); +test('a lane reports the files it is editing RIGHT NOW (uncommitted), independent of locks', () => { + const { root, main, wtA } = makeRepoWithLanes(); + try { + // alice has no committed lock for README, but is editing it uncommitted. + fs.writeFileSync(path.join(wtA, 'README.md'), 'work in progress\n'); + const agents = collect.collectRepoAgents(main, { includePrs: false }); + const alice = agents.find((a) => a.agent === 'alice'); + assert.ok(alice.dirty.includes('README.md'), 'in-progress edit shows up in dirty'); + const bob = agents.find((a) => a.agent === 'bob'); + assert.deepEqual(bob.dirty, [], 'a clean lane reports no dirty files'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('an agent editing on the PRIMARY checkout is surfaced with a warning', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gxmcp-primary-')); try { diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js index 03ecb2a..7f382c9 100644 --- a/test/mcp-server.test.js +++ b/test/mcp-server.test.js @@ -12,6 +12,11 @@ test('initialize returns serverInfo and echoes the protocol version', () => { assert.ok(r.result.capabilities.tools, 'declares tools capability'); }); +test('initialize pins the server protocol version even when the client requests another', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2099-01-01' } }); + assert.equal(r.result.protocolVersion, server.PROTOCOL_VERSION, 'server pins its own supported version'); +}); + test('tools/list returns the four read-only tools, each with a schema', () => { const r = server.dispatch({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); const names = r.result.tools.map((t) => t.name).sort(); @@ -65,3 +70,18 @@ test('serve() reads newline-delimited JSON from stdin and writes responses to st assert.equal(msg.id, 1); assert.equal(msg.result.tools.length, 4); }); + +test('serve() reports a malformed line as a JSON-RPC parse error (-32700, id null)', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let out = ''; + output.on('data', (c) => { out += c; }); + const rl = server.serve({ input, output }); + input.write('this is not json\n'); + input.end(); + await new Promise((resolve) => { rl.on('close', resolve); setTimeout(resolve, 100); }); + rl.close(); + const msg = JSON.parse(out.trim().split('\n')[0]); + assert.equal(msg.id, null); + assert.equal(msg.error.code, -32700); +});