@rethunk/mcp-multi-root-git 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,53 +1,56 @@
1
- # AGENTS.md — LLM and developer onboarding
1
+ # AGENTS.md — LLM + dev onboarding
2
2
 
3
- **Scope:** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) is an MCP **stdio** server: entry [`src/server.ts`](src/server.ts) (FastMCP + `registerRethunkGitTools`), supporting modules under [`src/server/`](src/server/), build output [`dist/server.js`](dist/server.js) (see `package.json` `bin` / `exports`; publish ships the full `dist/` tree).
3
+ IDEs injecting this as context: do not re-link from rules.
4
4
 
5
- **Operators and integrators:** **[docs/install.md](docs/install.md)** is the **only** place for prerequisites, how to launch the server, and per-client MCP configuration — do not duplicate that in README, HUMANS, rules, or here. Preset file, dev workflow, CI, and publishing: **[HUMANS.md](HUMANS.md)**.
5
+ **Package:** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git). MCP **stdio** server. Entry [`src/server.ts`](src/server.ts) FastMCP + `registerRethunkGitTools`. Build output [`dist/server.js`](dist/server.js) (publish ships full `dist/`).
6
6
 
7
- **Tool ids, client naming, `format` / JSON envelopes, resource URI, workspace root order:** **[docs/mcp-tools.md](docs/mcp-tools.md)**canonical; do not duplicate those tables in this file or HUMANS.
7
+ **Canonical docs — do not duplicate:**
8
+ - Install + per-client wiring → [docs/install.md](docs/install.md)
9
+ - Tools, JSON shape, resources, root resolution → [docs/mcp-tools.md](docs/mcp-tools.md)
10
+ - Presets, dev, CI, publish → [HUMANS.md](HUMANS.md)
8
11
 
9
12
  ## Implementation map
10
13
 
11
- | File | Symbols / notes |
12
- |------|-------------------|
14
+ | File | Symbols |
15
+ |------|---------|
13
16
  | [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
14
- | [`src/server/json.ts`](src/server/json.ts) | `MCP_JSON_FORMAT_VERSION`, `jsonRespond()`, `spreadWhen`, `spreadDefined` — every tool JSON body ends with `rethunkGitMcp` |
15
- | [`src/server/git.ts`](src/server/git.ts) | `gateGit()` — lazy `git --version`; `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`; `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`; `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
16
- | [`src/server/roots.ts`](src/server/roots.ts) | `uriToPath`, `listFileRoots`, `pathMatchesWorkspaceRootHint`, `resolveWorkspaceRoots`, `resolveRootsForPreset`, `requireGitAndRoots` — session roots only (client wiring: [docs/install.md](docs/install.md)) |
17
- | [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `splitPresetFileRaw`, `loadPresetsFromGitTop`, `getPresetEntry`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod `PresetEntrySchema` / `PresetFileSchema` must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
17
+ | [`src/server/json.ts`](src/server/json.ts) | `MCP_JSON_FORMAT_VERSION="2"`, `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
18
+ | [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
19
+ | [`src/server/roots.ts`](src/server/roots.ts) | `uriToPath`, `listFileRoots`, `pathMatchesWorkspaceRootHint`, `resolveWorkspaceRoots`, `resolveRootsForPreset`, `requireGitAndRoots` — session roots only |
20
+ | [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `splitPresetFileRaw`, `loadPresetsFromGitTop`, `getPresetEntry`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
18
21
  | [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
19
- | [`src/server/inventory.ts`](src/server/inventory.ts) | `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` (uses repo-paths + git) |
20
- | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — calls per-surface `register*` below |
21
- | [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `registerGitStatusTool` — `git_status` |
22
- | [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `registerGitInventoryTool` — `git_inventory` |
23
- | [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `registerGitParityTool` — `git_parity` |
24
- | [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `registerListPresetsTool` — `list_presets` |
25
- | [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `registerPresetsResource` — `rethunk-git://presets` resource |
22
+ | [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
23
+ | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
24
+ | [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
25
+ | [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
26
+ | [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
27
+ | [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `list_presets` |
28
+ | [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
26
29
  | [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop`, `realPathOrSelf` |
27
30
 
28
31
  ## Changing contracts
29
32
 
30
- - **Documentation layout:** do not add top-of-file **banner** paragraphs (bold blocks such as “Canonical doc for… / link here only”) to `docs/install.md` or other shipped docs. Use normal titles, TOC, and cross-links from README / this file / HUMANS.
31
- - **`rethunkGitMcp.jsonFormatVersion`:** bump **`MCP_JSON_FORMAT_VERSION`** and document the migration in this file and in [docs/mcp-tools.md](docs/mcp-tools.md) when JSON field names or nesting change incompatibly.
32
- - **Preset file:** keep **`splitPresetFileRaw`** + Zod parsing aligned with **`git-mcp-presets.schema.json`**; update the schema when adding keys or shapes.
33
- - **Public tool surface:** if you add/rename tools, update [docs/mcp-tools.md](docs/mcp-tools.md) and [README.md](README.md) if the landing page still mentions tools; update [docs/install.md](docs/install.md) if install or client-specific wiring changes — **never** copy install steps into other docs; update [.cursor/rules/rethunk-git-mcp.mdc](.cursor/rules/rethunk-git-mcp.mdc) only if *when-to-use MCP vs shell* wording must change (that rule links `docs/install.md`, [HUMANS.md](HUMANS.md), and `docs/mcp-tools.md` without duplicating them).
33
+ - **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
34
+ - **`MCP_JSON_FORMAT_VERSION`** (currently `"2"`): bump on incompatible JSON changes (renamed/nested/omitted fields). Document migration here + [docs/mcp-tools.md](docs/mcp-tools.md). v2 removed the `rethunkGitMcp` envelope; payloads are minified; optional fields omitted when empty/null/false.
35
+ - **Preset file:** keep `splitPresetFileRaw` + Zod aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
36
+ - **Public tool surface:** rename/add update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned). Install/client wiring [docs/install.md](docs/install.md) only. `.cursor/rules/rethunk-git-mcp.mdc` only when *MCP-vs-shell* guidance changes.
34
37
 
35
- ## Validation and CI
38
+ ## Validate + CI
36
39
 
37
- Local: `bun run build` (`rimraf dist && tsc`), `bun run check` (Biome), `bun run test` (`bun test` for [`src/repo-paths.test.ts`](src/repo-paths.test.ts)). GitHub Actions runs the same after `bun install --frozen-lockfile` on PRs and `main` ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)), then uploads a **prerelease `npm pack` artifact**. Pushes of tag **`v*.*.*`** matching `package.json` `version` run [`.github/workflows/release.yml`](.github/workflows/release.yml) (**GitHub Packages** npm publish under **`@rethunk-ai/mcp-multi-root-git`** + **GitHub Release** with tarball); **npmjs** is manual only — see [HUMANS.md](HUMANS.md) Publishing.
40
+ Local: `bun run build` | `bun run check` | `bun run test`. CI ([`ci.yml`](.github/workflows/ci.yml)) runs same on PRs + `main` after `bun install --frozen-lockfile`, uploads prerelease `npm pack` artifact. Tag `v*.*.*` matching `package.json` version [`release.yml`](.github/workflows/release.yml) publishes to GitHub Packages as `@rethunk-ai/mcp-multi-root-git` + cuts GitHub Release. npmjs publish is manual (see [HUMANS.md](HUMANS.md)).
38
41
 
39
- Optional [`.githooks/`](.githooks): run **`bun run setup-hooks`** once per clone (`core.hooksPath` → `.githooks`). **pre-commit** = `check`; **pre-push** = frozen install + build + check. See [HUMANS.md](HUMANS.md) Development.
42
+ Optional [`.githooks/`](.githooks): `bun run setup-hooks` once per clone. pre-commit=`check`; pre-push=frozen install + build + check.
40
43
 
41
- Path confinement helpers live in [`src/repo-paths.ts`](src/repo-paths.ts); extend tests when changing that logic.
44
+ Path confinement: [`src/repo-paths.ts`](src/repo-paths.ts) extend tests when changing.
42
45
 
43
- ## Repository MCP entry (contributors)
46
+ ## Repo MCP entry (contributors)
44
47
 
45
- Dogfooding from a clone: **[docs/install.md](docs/install.md)** — *From source (this repository)* only.
48
+ Dogfood from clone: [docs/install.md](docs/install.md) — *From source*.
46
49
 
47
- This repo may ship **`.cursor/`** config (example MCP entry + **alwaysApply** rule [`.cursor/rules/rethunk-git-mcp.mdc`](.cursor/rules/rethunk-git-mcp.mdc)). The rule covers *when* to call these tools vs shell git and links **`docs/install.md`**, **[HUMANS.md](HUMANS.md)**, and **`docs/mcp-tools.md`** without duplicating their bodies; it does not re-link this file when it is already injected as project context.
50
+ Repo ships `.cursor/` with alwaysApply rule [`.cursor/rules/rethunk-git-mcp.mdc`](.cursor/rules/rethunk-git-mcp.mdc) covering MCP-vs-shell usage. Rule does not re-link this file (already injected).
48
51
 
49
- User-level skills may still mention the GitHub **README** for discovery. Canonical references: **tools / JSON** **[docs/mcp-tools.md](docs/mcp-tools.md)**; **install / client wiring** — **[docs/install.md](docs/install.md)** (link it; do not paste per-client JSON into skills); **preset file** — **[HUMANS.md](HUMANS.md)**.
52
+ User-level skills may mention README for discovery. Canonical refs: tools/JSON [docs/mcp-tools.md](docs/mcp-tools.md); install [docs/install.md](docs/install.md); presets [HUMANS.md](HUMANS.md).
50
53
 
51
54
  ## Commits
52
55
 
53
- Use the team’s **conventional commits + batching** skill (or equivalent): small themed commits, why-focused messages, `git add` + `git commit` in one shell invocation per batch.
56
+ Conventional Commits. Small themed commits. Why-focused messages. Stage + commit in one invocation per batch.
package/HUMANS.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Read-only MCP git tools for any workspace. **How the server is installed and wired to clients:** **[docs/install.md](docs/install.md)** only (do not restate that material here).
4
4
 
5
+ ## Badges
6
+
7
+ [![Release](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml)
8
+ [![GitHub release](https://img.shields.io/github/v/release/Rethunk-AI/mcp-multi-root-git?logo=github&label=release)](https://github.com/Rethunk-AI/mcp-multi-root-git/releases/latest)
9
+ [![npm downloads](https://img.shields.io/npm/dm/%40rethunk%2Fmcp-multi-root-git.svg?label=npm%20downloads)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
10
+ [![GitHub Packages](https://img.shields.io/badge/github%20packages-%40rethunk--ai%2Fmcp--multi--root--git-24292f?logo=github)](https://github.com/Rethunk-AI/mcp-multi-root-git/packages)
11
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D22-339933.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/blob/main/package.json)
12
+
5
13
  **Implementation map (modules under `src/server/`, entry `src/server.ts`), symbols, and contract bumps** live in **`AGENTS.md`** at the repository root. This guide does not repeat those sections.
6
14
 
7
15
  **Registered tool ids, client naming (`rethunk-git_*`), `format` / JSON envelopes, resource URI, workspace root resolution:** **[docs/mcp-tools.md](docs/mcp-tools.md)** — canonical; not duplicated here.
package/README.md CHANGED
@@ -1,17 +1,12 @@
1
1
  # @rethunk/mcp-multi-root-git
2
2
 
3
3
  [![CI](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/ci.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/ci.yml)
4
- [![Release](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml)
5
- [![GitHub release](https://img.shields.io/github/v/release/Rethunk-AI/mcp-multi-root-git?logo=github&label=release)](https://github.com/Rethunk-AI/mcp-multi-root-git/releases/latest)
6
4
  [![npm version](https://img.shields.io/npm/v/%40rethunk%2Fmcp-multi-root-git.svg)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
7
- [![npm downloads](https://img.shields.io/npm/dm/%40rethunk%2Fmcp-multi-root-git.svg?label=npm%20downloads)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
8
- [![GitHub Packages](https://img.shields.io/badge/github%20packages-%40rethunk--ai%2Fmcp--multi--root--git-24292f?logo=github)](https://github.com/Rethunk-AI/mcp-multi-root-git/pkgs/npm/mcp-multi-root-git)
9
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
- [![Node.js](https://img.shields.io/badge/node-%3E%3D22-339933.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/blob/main/package.json)
11
6
 
12
7
  Read-only **git** tools over MCP (status, multi-root inventory, `HEAD` parity, presets). **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
13
8
 
14
- **Repository:** [github.com/Rethunk-AI/mcp-multi-root-git](https://github.com/Rethunk-AI/mcp-multi-root-git) · **npmjs (manual releases):** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) · **GitHub Packages (CI on each tag):** [`@rethunk-ai/mcp-multi-root-git`](https://github.com/Rethunk-AI/mcp-multi-root-git/pkgs/npm/mcp-multi-root-git) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
9
+ **Repository:** [github.com/Rethunk-AI/mcp-multi-root-git](https://github.com/Rethunk-AI/mcp-multi-root-git) · **npmjs (manual releases):** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) · **GitHub Packages (CI on each tag):** [`@rethunk-ai/mcp-multi-root-git`](https://github.com/Rethunk-AI/mcp-multi-root-git/packages) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
15
10
 
16
11
  ## Documentation
17
12
 
@@ -20,6 +15,6 @@ Read-only **git** tools over MCP (status, multi-root inventory, `HEAD` parity, p
20
15
  | **[docs/install.md](docs/install.md)** | Single source for prerequisites, running the package, and every supported MCP client (plus from-source and troubleshooting) |
21
16
  | **[docs/mcp-tools.md](docs/mcp-tools.md)** | Tool ids, client naming, `format` / JSON, resource URI, workspace root resolution (canonical reference) |
22
17
  | **[HUMANS.md](HUMANS.md)** | Preset file, dev commands, CI, publishing |
23
- | **[AGENTS.md](AGENTS.md)** | Contributors: implementation map ([`src/server/`](src/server/) + entry [`src/server.ts`](src/server.ts)), contract bumps, CI (IDEs that inject this file as project context should not re-link it from rules) |
18
+ | **[AGENTS.md](AGENTS.md)** | Contributors: implementation map ([`src/server/`](src/server/) + entry [`src/server.ts`](src/server.ts)), contract bumps, CI |
24
19
 
25
20
  **Tools at a glance:** see the table in **[docs/mcp-tools.md](docs/mcp-tools.md)**.
@@ -8,66 +8,39 @@ import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
8
8
  export function registerGitInventoryTool(server) {
9
9
  server.addTool({
10
10
  name: "git_inventory",
11
- description: "Read-only push-prep inventory: status + ahead/behind per root. " +
12
- "Uses each repo's configured upstream (`@{u}`) unless both `remote` and `branch` are set. " +
13
- "Presets from `.rethunk/git-mcp-presets.json`; use `presetMerge` to combine with inline paths.",
11
+ description: "Read-only status + ahead/behind per root. See docs/mcp-tools.md.",
14
12
  parameters: WorkspacePickSchema.extend({
15
- nestedRoots: z
16
- .array(z.string())
17
- .optional()
18
- .describe("Paths relative to git toplevel. If empty/omitted, only the repo root is listed. With `presetMerge`, merged with preset paths."),
19
- preset: z
20
- .string()
21
- .optional()
22
- .describe("Named preset from .rethunk/git-mcp-presets.json (at git toplevel)."),
13
+ nestedRoots: z.array(z.string()).optional(),
14
+ preset: z.string().optional(),
23
15
  presetMerge: z
24
16
  .boolean()
25
17
  .optional()
26
18
  .default(false)
27
- .describe("When true, merge `nestedRoots` with preset nestedRoots instead of replacing."),
28
- remote: z
29
- .string()
30
- .optional()
31
- .describe("Fixed upstream remote; must be set together with `branch` to override auto upstream."),
32
- branch: z
33
- .string()
34
- .optional()
35
- .describe("Fixed upstream branch name; must be set together with `remote`."),
36
- maxRoots: z
37
- .number()
38
- .int()
39
- .min(1)
40
- .max(256)
41
- .optional()
42
- .default(MAX_INVENTORY_ROOTS_DEFAULT)
43
- .describe("Max nested roots to process (cap)."),
19
+ .describe("Merge with preset instead of replacing."),
20
+ remote: z.string().optional().describe("Pair with `branch`."),
21
+ branch: z.string().optional().describe("Pair with `remote`."),
22
+ maxRoots: z.number().int().min(1).max(256).optional().default(MAX_INVENTORY_ROOTS_DEFAULT),
44
23
  }),
45
24
  execute: async (args) => {
46
25
  const pre = requireGitAndRoots(server, args, args.preset);
47
26
  if (!pre.ok) {
48
27
  return jsonRespond(pre.error);
49
28
  }
50
- const fixedRemote = args.remote;
51
- const fixedBranch = args.branch;
52
- const hasRemote = fixedRemote !== undefined && fixedRemote.trim() !== "";
53
- const hasBranch = fixedBranch !== undefined && fixedBranch.trim() !== "";
29
+ const rawRemote = args.remote?.trim();
30
+ const rawBranch = args.branch?.trim();
31
+ const hasRemote = rawRemote !== undefined && rawRemote !== "";
32
+ const hasBranch = rawBranch !== undefined && rawBranch !== "";
54
33
  if (hasRemote !== hasBranch) {
55
- return jsonRespond({
56
- error: "remote_branch_mismatch",
57
- message: "Set both `remote` and `branch` for fixed upstream, or omit both for auto `@{u}`.",
58
- });
34
+ return jsonRespond({ error: "remote_branch_mismatch" });
59
35
  }
60
- const useFixed = hasRemote && hasBranch;
61
- if (useFixed) {
62
- const r = String(fixedRemote).trim();
63
- const b = String(fixedBranch).trim();
64
- if (!isSafeGitUpstreamToken(r) || !isSafeGitUpstreamToken(b)) {
65
- return jsonRespond({
66
- error: "invalid_remote_or_branch",
67
- message: "remote and branch must be plain tokens: no whitespace, control characters, `@`, `..`, leading `-`, or git rev metacharacters like `^ : ? * [ ] { } ~ \\`.",
68
- });
36
+ let upstream = { mode: "auto" };
37
+ if (hasRemote && hasBranch && rawRemote && rawBranch) {
38
+ if (!isSafeGitUpstreamToken(rawRemote) || !isSafeGitUpstreamToken(rawBranch)) {
39
+ return jsonRespond({ error: "invalid_remote_or_branch" });
69
40
  }
41
+ upstream = { mode: "fixed", remote: rawRemote, branch: rawBranch };
70
42
  }
43
+ const useFixed = upstream.mode === "fixed";
71
44
  const allJson = [];
72
45
  const mdChunks = [];
73
46
  for (const workspaceRoot of pre.roots) {
@@ -77,31 +50,14 @@ export function registerGitInventoryTool(server) {
77
50
  if (args.format === "json") {
78
51
  allJson.push({
79
52
  workspace_root: workspaceRoot,
80
- upstream: {
81
- mode: useFixed ? "fixed" : "auto",
82
- remote: fixedRemote,
83
- branch: fixedBranch,
84
- },
53
+ ...(upstream.mode === "fixed" ? { upstream } : {}),
85
54
  entries: [
86
- {
87
- label: workspaceRoot,
88
- path: workspaceRoot,
89
- branchStatus: "",
90
- shortStatus: "",
91
- detached: false,
92
- headAbbrev: "",
93
- upstreamMode: useFixed ? "fixed" : "auto",
94
- upstreamRef: null,
95
- ahead: null,
96
- behind: null,
97
- upstreamNote: "",
98
- skipReason: JSON.stringify(err),
99
- },
55
+ makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
100
56
  ],
101
57
  });
102
58
  }
103
59
  else {
104
- mdChunks.push(`# Git inventory`, "", jsonRespond(err), "");
60
+ mdChunks.push(`### ${workspaceRoot}\n${jsonRespond(err)}`);
105
61
  }
106
62
  continue;
107
63
  }
@@ -124,31 +80,31 @@ export function registerGitInventoryTool(server) {
124
80
  nestedRootsTruncated = true;
125
81
  }
126
82
  const headerNote = useFixed
127
- ? `remote/branch (fixed): ${fixedRemote}/${fixedBranch}`
128
- : "upstream: per-repo @{u} (configured upstream)";
83
+ ? `upstream (fixed): ${upstream.remote}/${upstream.branch}`
84
+ : "upstream: @{u}";
129
85
  const entries = [];
130
86
  if (nestedRoots?.length) {
131
87
  const jobs = [];
132
88
  for (const rel of nestedRoots) {
133
89
  const { abs, underTop } = validateRepoPath(rel, top);
134
90
  if (!underTop) {
135
- entries.push(makeSkipEntry(rel, abs, useFixed ? "fixed" : "auto", "(path escapes git toplevel — rejected)"));
91
+ entries.push(makeSkipEntry(rel, abs, upstream.mode, "(path escapes git toplevel — rejected)"));
136
92
  continue;
137
93
  }
138
94
  if (!gitRevParseGitDir(abs)) {
139
- entries.push(makeSkipEntry(rel, abs, useFixed ? "fixed" : "auto", "(not a git work tree — skip)"));
95
+ entries.push(makeSkipEntry(rel, abs, upstream.mode, "(not a git work tree — skip)"));
140
96
  continue;
141
97
  }
142
98
  jobs.push({ label: rel, abs });
143
99
  }
144
- const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, useFixed ? fixedRemote : undefined, useFixed ? fixedBranch : undefined));
100
+ const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, upstream.remote, upstream.branch));
145
101
  entries.push(...computed);
146
102
  }
147
103
  else if (!gitRevParseGitDir(top)) {
148
- entries.push(makeSkipEntry(".", top, useFixed ? "fixed" : "auto", "(not a git work tree — unexpected)"));
104
+ entries.push(makeSkipEntry(".", top, upstream.mode, "(not a git work tree — unexpected)"));
149
105
  }
150
106
  else {
151
- const one = await collectInventoryEntry(".", top, useFixed ? fixedRemote : undefined, useFixed ? fixedBranch : undefined);
107
+ const one = await collectInventoryEntry(".", top, upstream.remote, upstream.branch);
152
108
  entries.push(one);
153
109
  }
154
110
  if (args.format === "json") {
@@ -159,22 +115,14 @@ export function registerGitInventoryTool(server) {
159
115
  nestedRootsTruncated: true,
160
116
  nestedRootsOmittedCount,
161
117
  }),
162
- upstream: useFixed
163
- ? { mode: "fixed", remote: fixedRemote, branch: fixedBranch }
164
- : { mode: "auto" },
118
+ ...(upstream.mode === "fixed" ? { upstream } : {}),
165
119
  entries,
166
120
  });
167
121
  }
168
122
  else {
169
- const sections = [
170
- "# Git inventory",
171
- "",
172
- `workspace_root: ${top}`,
173
- headerNote,
174
- "",
175
- ];
123
+ const sections = [`### ${top}`, headerNote];
176
124
  if (nestedRootsTruncated) {
177
- sections.push(`nested_roots_truncated: ${nestedRootsOmittedCount} path(s) not listed (maxRoots=${maxRoots})`, "");
125
+ sections.push(`nested_roots_truncated: ${nestedRootsOmittedCount} path(s) not listed (maxRoots=${maxRoots})`);
178
126
  }
179
127
  for (const e of entries) {
180
128
  sections.push(...buildInventorySectionMarkdown(e));
@@ -185,7 +133,7 @@ export function registerGitInventoryTool(server) {
185
133
  if (args.format === "json") {
186
134
  return jsonRespond({ inventories: allJson });
187
135
  }
188
- return mdChunks.join("\n\n---\n\n");
136
+ return ["# Git inventory", ...mdChunks].join("\n\n");
189
137
  },
190
138
  });
191
139
  }
@@ -8,7 +8,7 @@ import { WorkspacePickSchema } from "./schemas.js";
8
8
  export function registerGitParityTool(server) {
9
9
  server.addTool({
10
10
  name: "git_parity",
11
- description: "Read-only: compare `git rev-parse HEAD` for path pairs. Presets or inline `pairs`; `presetMerge` merges with inline pairs.",
11
+ description: "Read-only HEAD parity for path pairs. See docs/mcp-tools.md.",
12
12
  parameters: WorkspacePickSchema.extend({
13
13
  pairs: z
14
14
  .array(z.object({
@@ -55,10 +55,7 @@ export function registerGitParityTool(server) {
55
55
  parityPresetSchemaVersion = applied.presetSchemaVersion;
56
56
  }
57
57
  if (!pairs?.length) {
58
- return jsonRespond({
59
- error: "no_pairs",
60
- message: "Pass `pairs` directly or a `preset` with parityPairs (or presetMerge).",
61
- });
58
+ return jsonRespond({ error: "no_pairs" });
62
59
  }
63
60
  let allOk = true;
64
61
  const pairResults = [];
@@ -8,14 +8,9 @@ import { WorkspacePickSchema } from "./schemas.js";
8
8
  export function registerGitStatusTool(server) {
9
9
  server.addTool({
10
10
  name: "git_status",
11
- description: "Run `git status --short -b` in the workspace root and (optionally) each path from `.gitmodules`. " +
12
- "Read-only. Supports multi-root MCP workspaces via `allWorkspaceRoots` or `rootIndex`.",
11
+ description: "Read-only `git status --short -b` per root + submodules. See docs/mcp-tools.md.",
13
12
  parameters: WorkspacePickSchema.extend({
14
- includeSubmodules: z
15
- .boolean()
16
- .optional()
17
- .default(true)
18
- .describe("When true (default), include submodule paths listed in .gitmodules."),
13
+ includeSubmodules: z.boolean().optional().default(true),
19
14
  }),
20
15
  execute: async (args) => {
21
16
  const pre = requireGitAndRoots(server, args, undefined);
@@ -69,13 +64,19 @@ export function registerGitStatusTool(server) {
69
64
  if (args.format === "json") {
70
65
  return jsonRespond({ groups });
71
66
  }
72
- const sections = ["# Multi-root git status", ""];
67
+ const sections = [groups.length > 1 ? "# Multi-root git status" : "# Git status"];
73
68
  for (const g of groups) {
74
69
  if (groups.length > 1) {
75
- sections.push(`### MCP root: ${g.mcpRoot}`, "");
70
+ sections.push("", `### MCP root: ${g.mcpRoot}`);
76
71
  }
77
72
  for (const row of g.repos) {
78
- sections.push(`## ${row.label}`, `path: ${row.path}`, "```text", row.statusText || "(empty)", "```", ``);
73
+ const body = row.statusText || "(clean)";
74
+ if (body.includes("\n")) {
75
+ sections.push("", `## ${row.label} — ${row.path}`, "```text", body, "```");
76
+ }
77
+ else {
78
+ sections.push("", `## ${row.label} — ${row.path}`, body);
79
+ }
79
80
  }
80
81
  }
81
82
  return sections.join("\n");
@@ -6,7 +6,6 @@ export const GIT_SUBPROCESS_PARALLELISM = 4;
6
6
  let gitPathState = "unknown";
7
7
  const GIT_NOT_FOUND_BODY = {
8
8
  error: "git_not_found",
9
- message: "The `git` binary was not found on PATH or failed `git --version`. Install Git and ensure it is available to the MCP server process.",
10
9
  };
11
10
  export function gateGit() {
12
11
  if (gitPathState === "ok") {
@@ -81,22 +80,7 @@ export function isSafeGitUpstreamToken(s) {
81
80
  return false;
82
81
  if (t.includes(".."))
83
82
  return false;
84
- if (t.startsWith("-"))
85
- return false;
86
- if (t.includes("@"))
87
- return false;
88
- const forbidden = new Set("{}[]~^:?*\\".split(""));
89
- for (let i = 0; i < t.length; i++) {
90
- const ch = t.charAt(i);
91
- const c = ch.charCodeAt(0);
92
- if (c < 32 || c === 127)
93
- return false;
94
- if (/\s/.test(ch))
95
- return false;
96
- if (forbidden.has(ch))
97
- return false;
98
- }
99
- return true;
83
+ return /^(?!-)[A-Za-z0-9_./+-]+$/.test(t);
100
84
  }
101
85
  // ---------------------------------------------------------------------------
102
86
  // Async pool for parallel git (inventory)
@@ -140,16 +124,11 @@ function gitStatusFailText(r) {
140
124
  return (r.stderr || r.stdout || "git status failed").trim();
141
125
  }
142
126
  export async function gitStatusSnapshotAsync(cwd) {
143
- const [br, sh] = await Promise.all([
144
- spawnGitAsync(cwd, ["status", "--short", "-b"]),
145
- spawnGitAsync(cwd, ["status", "--short"]),
146
- ]);
147
- return {
148
- branchOk: br.ok,
149
- shortOk: sh.ok,
150
- branchLine: br.ok ? br.stdout.trimEnd() : gitStatusFailText(br),
151
- shortLine: sh.ok ? sh.stdout.trimEnd() : gitStatusFailText(sh),
152
- };
127
+ const r = await spawnGitAsync(cwd, ["status", "--short", "-b"]);
128
+ if (!r.ok) {
129
+ return { branchOk: false, branchLine: gitStatusFailText(r) };
130
+ }
131
+ return { branchOk: true, branchLine: r.stdout.trimEnd() };
153
132
  }
154
133
  export async function gitStatusShortBranchAsync(cwd) {
155
134
  const s = await gitStatusSnapshotAsync(cwd);
@@ -6,48 +6,52 @@ export function validateRepoPath(rel, gitTop) {
6
6
  return { abs, underTop: assertRelativePathUnderTop(rel, abs, gitTop) };
7
7
  }
8
8
  export function makeSkipEntry(label, abs, upstreamMode, skipReason) {
9
- return {
10
- label,
11
- path: abs,
12
- branchStatus: "",
13
- shortStatus: "",
14
- detached: false,
15
- headAbbrev: "",
16
- upstreamMode,
17
- upstreamRef: null,
18
- ahead: null,
19
- behind: null,
20
- upstreamNote: "",
21
- skipReason,
22
- };
9
+ return { label, path: abs, upstreamMode, skipReason };
23
10
  }
24
11
  export function buildInventorySectionMarkdown(e) {
12
+ const header = `## ${e.label} — ${e.path}`;
25
13
  if (e.skipReason) {
26
- return [`## ${e.label}`, `path: ${e.path}`, "```text", e.skipReason, "```", ``];
27
- }
28
- const lines = [];
29
- lines.push(e.branchStatus);
30
- lines.push("");
31
- lines.push("short:");
32
- lines.push(e.shortStatus || "(clean)");
33
- lines.push("");
34
- if (e.detached) {
35
- lines.push("branch: (detached HEAD)");
36
- lines.push("");
14
+ return ["", header, e.skipReason];
37
15
  }
38
- if (e.ahead != null && e.behind != null && e.upstreamRef) {
39
- lines.push(`ahead_of_${e.upstreamRef.replace(/\//g, "_")}: ${e.ahead}`);
40
- lines.push(`behind_${e.upstreamRef.replace(/\//g, "_")}: ${e.behind}`);
16
+ const lines = [e.branchStatus || "(clean)"];
17
+ if (e.detached)
18
+ lines.push("detached HEAD");
19
+ if (e.ahead !== undefined && e.behind !== undefined && e.upstreamRef) {
20
+ lines.push(`${e.upstreamRef}: ahead ${e.ahead}, behind ${e.behind}`);
41
21
  }
42
- else {
22
+ else if (e.upstreamNote) {
43
23
  lines.push(`upstream: ${e.upstreamNote}`);
44
24
  }
45
- return [`## ${e.label}`, `path: ${e.path}`, "```text", lines.join("\n"), "```", ``];
25
+ const single = lines.length === 1 ? lines[0] : undefined;
26
+ if (single !== undefined && !single.includes("\n")) {
27
+ return ["", header, single];
28
+ }
29
+ return ["", header, "```text", lines.join("\n"), "```"];
46
30
  }
47
- function upstreamNoteFor(ref, ahead, behind) {
48
- return ahead != null && behind != null
49
- ? `tracking ${ref}`
50
- : `upstream ${ref} (counts unreadable)`;
31
+ function upstreamNoteFor(ref, hasCounts) {
32
+ return hasCounts ? `tracking ${ref}` : `upstream ${ref} (counts unreadable)`;
33
+ }
34
+ function buildEntry(params) {
35
+ const out = {
36
+ label: params.label,
37
+ path: params.absPath,
38
+ upstreamMode: params.upstreamMode,
39
+ };
40
+ if (params.branchStatus)
41
+ out.branchStatus = params.branchStatus;
42
+ if (params.detached)
43
+ out.detached = true;
44
+ if (params.headAbbrev)
45
+ out.headAbbrev = params.headAbbrev;
46
+ if (params.upstreamRef !== null)
47
+ out.upstreamRef = params.upstreamRef;
48
+ if (params.ahead !== null)
49
+ out.ahead = params.ahead;
50
+ if (params.behind !== null)
51
+ out.behind = params.behind;
52
+ if (params.upstreamNote)
53
+ out.upstreamNote = params.upstreamNote;
54
+ return out;
51
55
  }
52
56
  export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch) {
53
57
  const [snap, headR] = await Promise.all([
@@ -55,79 +59,52 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
55
59
  spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
56
60
  ]);
57
61
  const branchStatus = snap.branchLine;
58
- const shortStatus = snap.shortLine;
59
62
  const headAbbrev = headR.ok ? headR.stdout.trim() : "";
60
63
  const detached = !headR.ok || headAbbrev === "HEAD" || headAbbrev.endsWith("/HEAD");
61
- const useFixed = fixedRemote !== undefined && fixedBranch !== undefined;
62
- if (useFixed) {
63
- const remote = fixedRemote;
64
- const branch = fixedBranch;
65
- const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", `${remote}/${branch}`]);
64
+ const base = { label, absPath, branchStatus, detached, headAbbrev };
65
+ if (fixedRemote !== undefined && fixedBranch !== undefined) {
66
+ const ref = `${fixedRemote}/${fixedBranch}`;
67
+ const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", ref]);
66
68
  if (!verify.ok) {
67
- return {
68
- label,
69
- path: absPath,
70
- branchStatus,
71
- shortStatus,
72
- detached,
73
- headAbbrev: headAbbrev || "(unknown)",
69
+ return buildEntry({
70
+ ...base,
74
71
  upstreamMode: "fixed",
75
- upstreamRef: `${remote}/${branch}`,
72
+ upstreamRef: ref,
76
73
  ahead: null,
77
74
  behind: null,
78
- upstreamNote: `(no local ref ${remote}/${branch} or unreadable)`,
79
- };
75
+ upstreamNote: `(no local ref ${ref} or unreadable)`,
76
+ });
80
77
  }
81
- const ref = `${remote}/${branch}`;
82
78
  const { ahead, behind } = await fetchAheadBehind(absPath, ref);
83
- return {
84
- label,
85
- path: absPath,
86
- branchStatus,
87
- shortStatus,
88
- detached,
89
- headAbbrev: headAbbrev || "(unknown)",
79
+ return buildEntry({
80
+ ...base,
90
81
  upstreamMode: "fixed",
91
82
  upstreamRef: ref,
92
83
  ahead,
93
84
  behind,
94
- upstreamNote: upstreamNoteFor(ref, ahead, behind),
95
- };
85
+ upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
86
+ });
96
87
  }
97
88
  const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
98
89
  if (!upVerify.ok) {
99
- let note = "no upstream configured";
100
- if (detached) {
101
- note = "detached HEAD — no upstream";
102
- }
103
- return {
104
- label,
105
- path: absPath,
106
- branchStatus,
107
- shortStatus,
108
- detached,
109
- headAbbrev: headAbbrev || "(unknown)",
90
+ return buildEntry({
91
+ ...base,
110
92
  upstreamMode: "auto",
111
93
  upstreamRef: null,
112
94
  ahead: null,
113
95
  behind: null,
114
- upstreamNote: note,
115
- };
96
+ upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
97
+ });
116
98
  }
117
99
  const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
118
100
  const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
119
101
  const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
120
- return {
121
- label,
122
- path: absPath,
123
- branchStatus,
124
- shortStatus,
125
- detached,
126
- headAbbrev: headAbbrev || "(unknown)",
102
+ return buildEntry({
103
+ ...base,
127
104
  upstreamMode: "auto",
128
105
  upstreamRef,
129
106
  ahead,
130
107
  behind,
131
- upstreamNote: upstreamNoteFor(upstreamRef, ahead, behind),
132
- };
108
+ upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
109
+ });
133
110
  }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- export const MCP_JSON_FORMAT_VERSION = "1";
4
+ export const MCP_JSON_FORMAT_VERSION = "2";
5
5
  export function readPackageVersion() {
6
6
  const here = dirname(fileURLToPath(import.meta.url));
7
7
  const pkgPath = join(here, "..", "..", "package.json");
@@ -23,13 +23,7 @@ export function readMcpServerVersion() {
23
23
  return "0.0.0";
24
24
  }
25
25
  export function jsonRespond(body) {
26
- return JSON.stringify({
27
- ...body,
28
- rethunkGitMcp: {
29
- jsonFormatVersion: MCP_JSON_FORMAT_VERSION,
30
- packageVersion: readPackageVersion(),
31
- },
32
- }, null, 2);
26
+ return JSON.stringify(body);
33
27
  }
34
28
  /** Spread into an object literal only when `cond` is true; otherwise `{}`. */
35
29
  export function spreadWhen(cond, fields) {
@@ -7,7 +7,7 @@ import { WorkspacePickSchema } from "./schemas.js";
7
7
  export function registerListPresetsTool(server) {
8
8
  server.addTool({
9
9
  name: "list_presets",
10
- description: "List named entries from `.rethunk/git-mcp-presets.json` at the git toplevel for the resolved workspace root.",
10
+ description: "List presets from .rethunk/git-mcp-presets.json. See docs/mcp-tools.md.",
11
11
  parameters: WorkspacePickSchema.pick({
12
12
  workspaceRoot: true,
13
13
  rootIndex: true,
@@ -101,19 +101,20 @@ export function getPresetEntry(gitTop, presetName) {
101
101
  if (!loaded.ok) {
102
102
  if (loaded.reason === "missing") {
103
103
  return {
104
+ ok: false,
104
105
  error: {
105
106
  error: "preset_not_found",
106
107
  preset: presetName,
107
108
  presetFile: join(gitTop, PRESET_FILE_PATH),
108
- message: "Preset file missing",
109
109
  },
110
110
  };
111
111
  }
112
- return { error: presetLoadErrorPayload(gitTop, loaded) };
112
+ return { ok: false, error: presetLoadErrorPayload(gitTop, loaded) };
113
113
  }
114
114
  const entry = loaded.data[presetName];
115
115
  if (!entry) {
116
116
  return {
117
+ ok: false,
117
118
  error: {
118
119
  error: "preset_not_found",
119
120
  preset: presetName,
@@ -121,7 +122,7 @@ export function getPresetEntry(gitTop, presetName) {
121
122
  },
122
123
  };
123
124
  }
124
- return { entry, presetSchemaVersion: loaded.schemaVersion };
125
+ return { ok: true, entry, presetSchemaVersion: loaded.schemaVersion };
125
126
  }
126
127
  export function mergeNestedRoots(preset, inline) {
127
128
  const a = preset ?? [];
@@ -147,7 +148,7 @@ export function mergePairs(preset, inline) {
147
148
  }
148
149
  export function applyPresetNestedRoots(gitTop, presetName, presetMerge, inlineNestedRoots) {
149
150
  const got = getPresetEntry(gitTop, presetName);
150
- if ("error" in got)
151
+ if (!got.ok)
151
152
  return { ok: false, error: got.error };
152
153
  const fromPreset = got.entry.nestedRoots;
153
154
  let nestedRoots = inlineNestedRoots;
@@ -161,7 +162,7 @@ export function applyPresetNestedRoots(gitTop, presetName, presetMerge, inlineNe
161
162
  }
162
163
  export function applyPresetParityPairs(gitTop, presetName, presetMerge, inlinePairs) {
163
164
  const got = getPresetEntry(gitTop, presetName);
164
- if ("error" in got)
165
+ if (!got.ok)
165
166
  return { ok: false, error: got.error };
166
167
  const fromPreset = got.entry.parityPairs;
167
168
  let pairs = inlinePairs;
@@ -1,4 +1,4 @@
1
- import { basename, isAbsolute, resolve } from "node:path";
1
+ import { basename, resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { gateGit, gitTopLevel } from "./git.js";
4
4
  import { loadPresetsFromGitTop, presetLoadErrorPayload } from "./presets.js";
@@ -28,36 +28,25 @@ export function pathMatchesWorkspaceRootHint(rootPath, hint) {
28
28
  const h = hint.trim();
29
29
  if (!h)
30
30
  return true;
31
- if (basename(rootPath) === h)
32
- return true;
33
31
  const absRoot = resolve(rootPath);
34
- if (absRoot === h)
35
- return true;
36
- try {
37
- if (isAbsolute(h) && resolve(h) === absRoot)
38
- return true;
39
- }
40
- catch {
41
- /* invalid absolute hint */
42
- }
43
32
  const normRoot = absRoot.replace(/\\/g, "/").replace(/\/+$/, "");
44
33
  const normHint = h.replace(/\\/g, "/").replace(/\/+$/, "").replace(/^\/+/, "");
45
34
  if (!normHint)
46
35
  return true;
47
36
  if (normRoot === normHint)
48
37
  return true;
49
- return normRoot.endsWith(`/${normHint}`);
38
+ if (normRoot.endsWith(`/${normHint}`))
39
+ return true;
40
+ return basename(rootPath) === h;
50
41
  }
51
42
  export function resolveWorkspaceRoots(server, args) {
52
43
  if (args.workspaceRoot?.trim()) {
53
44
  return { ok: true, roots: [resolve(args.workspaceRoot.trim())] };
54
45
  }
55
46
  const fileRoots = listFileRoots(server);
47
+ const fallback = { ok: true, roots: [process.cwd()] };
56
48
  if (args.allWorkspaceRoots) {
57
- if (fileRoots.length === 0) {
58
- return { ok: true, roots: [process.cwd()] };
59
- }
60
- return { ok: true, roots: fileRoots };
49
+ return fileRoots.length === 0 ? fallback : { ok: true, roots: fileRoots };
61
50
  }
62
51
  if (args.rootIndex != null) {
63
52
  const r = fileRoots[args.rootIndex];
@@ -74,10 +63,7 @@ export function resolveWorkspaceRoots(server, args) {
74
63
  return { ok: true, roots: [r] };
75
64
  }
76
65
  const primary = fileRoots[0];
77
- if (primary !== undefined) {
78
- return { ok: true, roots: [primary] };
79
- }
80
- return { ok: true, roots: [process.cwd()] };
66
+ return primary !== undefined ? { ok: true, roots: [primary] } : fallback;
81
67
  }
82
68
  /**
83
69
  * When a preset name is requested and multiple MCP roots exist, pick the first root
@@ -2,21 +2,9 @@ import { z } from "zod";
2
2
  import { MAX_INVENTORY_ROOTS_DEFAULT } from "./inventory.js";
3
3
  const FormatSchema = z.enum(["markdown", "json"]).optional().default("markdown");
4
4
  export const WorkspacePickSchema = z.object({
5
- workspaceRoot: z
6
- .string()
7
- .optional()
8
- .describe("Override workspace path. Wins over MCP roots, rootIndex, and allWorkspaceRoots."),
9
- rootIndex: z
10
- .number()
11
- .int()
12
- .min(0)
13
- .optional()
14
- .describe("Use the Nth file:// MCP root (0-based) when multiple workspace roots exist."),
15
- allWorkspaceRoots: z
16
- .boolean()
17
- .optional()
18
- .default(false)
19
- .describe("Run against every file:// MCP root and aggregate results."),
20
- format: FormatSchema.describe('Return "markdown" (default) or structured "json".'),
5
+ workspaceRoot: z.string().optional().describe("Highest-priority override."),
6
+ rootIndex: z.number().int().min(0).optional(),
7
+ allWorkspaceRoots: z.boolean().optional().default(false),
8
+ format: FormatSchema,
21
9
  });
22
10
  export { MAX_INVENTORY_ROOTS_DEFAULT };
package/docs/mcp-tools.md CHANGED
@@ -20,22 +20,34 @@ Pass **`format: "json"`** on any tool for structured JSON instead of markdown (d
20
20
 
21
21
  ## JSON responses
22
22
 
23
- Every JSON string returned by a tool (including errors) ends with a **`rethunkGitMcp`** object:
23
+ Tool JSON bodies are minified and contain only the payload — no `rethunkGitMcp` envelope. Current `MCP_JSON_FORMAT_VERSION` is **`"2"`**; server + format version are discoverable via MCP `initialize`. Payload keys (`groups`, `inventories`, `parity`, `roots`) are stable within a given format version. Preset-related responses may include **`presetSchemaVersion`**.
24
24
 
25
- - **`jsonFormatVersion`**: `"1"` for the current response shape.
26
- - **`packageVersion`**: from the running server’s `package.json`.
25
+ ### v2 field omission (consumer contract)
27
26
 
28
- Payload keys (e.g. `groups`, `inventories`, `parity`, `roots`) are stable within a given `jsonFormatVersion`. Preset-related responses may include **`presetSchemaVersion`**.
27
+ To keep responses compact, **optional fields are omitted when they would be empty, `null`, or `false`** — they are not emitted as `null`. Consumers must test for *presence*, not compare to `null`.
29
28
 
30
- For **`git_inventory`** with `format: "json"`, each object inside **`inventories`** may include **`nestedRootsTruncated`** (boolean) and **`nestedRootsOmittedCount`** (number) when **`nestedRoots`** was longer than **`maxRoots`**.
29
+ **`git_inventory` `inventories[*]`**
31
30
 
32
- **When to bump `jsonFormatVersion` or change payload shape:** [AGENTS.md](../AGENTS.md) — *Changing contracts*.
31
+ - Always present: `workspace_root`, `entries`.
32
+ - Omitted when not applicable: `presetSchemaVersion`, `nestedRootsTruncated`, `nestedRootsOmittedCount`, and the whole `upstream` object (emitted only when a fixed `remote`/`branch` pair was supplied; in `auto` mode it is absent).
33
+
34
+ **`git_inventory` → `entries[*]` (`InventoryEntryJson`)**
35
+
36
+ - Always present: `label`, `path`, `upstreamMode` (`"auto"` or `"fixed"`).
37
+ - Optional (omitted when empty/absent): `branchStatus`, `headAbbrev`, `upstreamRef`, `ahead`, `behind`, `upstreamNote`, `detached` (only emitted as `true`), `skipReason` (only on skipped entries).
38
+ - **Removed in v2:** `shortStatus`. The porcelain entries now live inside `branchStatus` (the full `git status --short -b` body — branch header line followed by porcelain lines).
39
+
40
+ **Errors** (any tool)
41
+
42
+ - Error payloads carry an `error` code string and any structured context (e.g. `preset`, `presetFile`). The old free-text `message` field is **removed** for self-describing codes (`git_not_found`, `remote_branch_mismatch`, `invalid_remote_or_branch`, `no_pairs`, `preset_not_found` *missing* case). It is retained only where it carries parse output (the `invalid_json` preset branch).
43
+
44
+ **When to bump `MCP_JSON_FORMAT_VERSION` or change payload shape:** [AGENTS.md](../AGENTS.md) — *Changing contracts*.
33
45
 
34
46
  ## Resource
35
47
 
36
48
  | URI | Purpose |
37
49
  |-----|---------|
38
- | `rethunk-git://presets` | JSON snapshot of `.rethunk/git-mcp-presets.json` at the resolved git toplevel (or structured errors; same `rethunkGitMcp` envelope pattern as tools). |
50
+ | `rethunk-git://presets` | JSON snapshot of `.rethunk/git-mcp-presets.json` at the resolved git toplevel (or structured errors). |
39
51
 
40
52
  ## Workspace root resolution
41
53
 
@@ -43,7 +55,7 @@ Order applied when resolving which directory(ies) tools run against:
43
55
 
44
56
  1. Explicit **`workspaceRoot`** on the tool call (highest priority).
45
57
  2. **`rootIndex`** (0-based) — one `file://` MCP root when several exist.
46
- 3. **`allWorkspaceRoots`: true** — every `file://` root; markdown sections separated by `---`, or combined JSON.
58
+ 3. **`allWorkspaceRoots`: true** — every `file://` root; markdown output emits one `# {tool}` header with per-root subsections (`git_inventory` uses `### {gitTop}`; `git_status` uses `### MCP root: ...`), or combined JSON.
47
59
  4. **`preset`** set and multiple roots — first root whose git toplevel defines that preset (respecting **`workspaceRootHint`** on the preset entry when present).
48
60
  5. Otherwise the first `file://` root from MCP **`initialize`** / **`roots/list_changed`**.
49
61
  6. **`process.cwd()`** if no file roots (e.g. CI with explicit `workspaceRoot`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rethunk/mcp-multi-root-git",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "MCP stdio server: multi-root git status, inventory, and HEAD parity checks. Generic tools usable by any workspace.",
5
5
  "type": "module",
6
6
  "private": false,