Skip to content

Commit 2b17b49

Browse files
committed
feat(roots): support absolute git root picks
Allow read-only tools to inspect sibling clones without relying on MCP workspace roots.
1 parent ed2182b commit 2b17b49

15 files changed

Lines changed: 202 additions & 16 deletions

.cursor/rules/rethunk-git-mcp.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Dogfood and all client wiring: **[docs/install.md](../../docs/install.md)** (*Fr
1111

1212
## When to use it
1313

14-
Prefer **`rethunk-git_git_*`** and **`rethunk-git_list_presets`** over shell **`git status`** / ad-hoc loops for multi-root or preset-driven git state. Use resource **`rethunk-git://presets`** when the client supports MCP resources.
14+
Prefer **`rethunk-git_git_*`** and **`rethunk-git_list_presets`** over shell **`git status`** / ad-hoc loops for multi-root or preset-driven git state. For sibling clones under a non-git directory, use **`absoluteGitRoots`** (see **[docs/mcp-tools.md](../../docs/mcp-tools.md)** — *Workspace root resolution*). Use resource **`rethunk-git://presets`** when the client supports MCP resources.
1515

1616
## When shell git is still fine
1717

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ IDEs injecting this as context: do not re-link from rules.
1717
| [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
1818
| [`src/server/json.ts`](src/server/json.ts) | `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
1919
| [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
20-
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots`, `requireSingleRepo` — shared tool preludes; session root resolution |
20+
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots`, `requireSingleRepo`, `resolveAbsoluteGitRootsList`, `GitRootPickArgs` — shared tool preludes; session root resolution; optional `absoluteGitRoots` bulk pick |
2121
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `loadPresetsFromGitTop`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
22-
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
22+
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT`, **`MAX_ABSOLUTE_GIT_ROOTS`** (256), optional **`absoluteGitRoots`** on workspace pick |
2323
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
2424
| [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `conflictPaths` |
2525
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to `@rethunk/mcp-multi-root-git` are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com); the project uses [Semantic Versioning](https://semver.org).
44

5+
## [2.3.3] — 2026-04-21
6+
7+
### Added
8+
9+
- **`absoluteGitRoots`** on the workspace pick schema: pass absolute paths to many independent git clones in one MCP call for **`git_status`**, **`git_inventory`**, **`git_log`**, **`git_parity`**, **`git_diff_summary`** (single distinct toplevel only), and **`list_presets`**. Mutating tools omit this parameter from their Zod surface. See **`docs/mcp-tools.md`** (*Workspace root resolution*).
10+
11+
### Changed
12+
13+
- **`requireGitAndRoots`** / **`requireSingleRepo`**: new prelude **`resolveAbsoluteGitRootsList`** with dedupe, fail-fast on invalid paths, and mutual exclusion with `workspaceRoot` / `rootIndex` / `allWorkspaceRoots` / `preset` (and `nestedRoots`+`preset` guarded in **`git_inventory`**).
14+
515
## [2.3.2] — 2026-04-21
616

717
### CI

docs/mcp-tools.md

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ MCP clients expose tools as `{serverName}_{toolName}`. With the server registere
1111

1212
| Short id | Client id (server `rethunk-git`) | Purpose |
1313
|----------|-----------------------------------|---------|
14-
| `git_status` | `rethunk-git_git_status` | `git status --short -b` per MCP root and optional submodules (`includeSubmodules`); parallel submodule status. Args include `allWorkspaceRoots`, `rootIndex`, `workspaceRoot`, `format`. **Read-only.** |
15-
| `git_inventory` | `rethunk-git_git_inventory` | Status + ahead/behind per path; default upstream each repo’s `@{u}`; pass **both** `remote` and `branch` for fixed tracking. `nestedRoots`, `preset`, `presetMerge`, `maxRoots`, `format`, plus workspace pick args. **Read-only.** |
14+
| `git_status` | `rethunk-git_git_status` | `git status --short -b` per MCP root and optional submodules (`includeSubmodules`); parallel submodule status. Args include `absoluteGitRoots`, `allWorkspaceRoots`, `rootIndex`, `workspaceRoot`, `format`. **Read-only.** |
15+
| `git_inventory` | `rethunk-git_git_inventory` | Status + ahead/behind per path; default upstream each repo’s `@{u}`; pass **both** `remote` and `branch` for fixed tracking. `nestedRoots`, `preset`, `presetMerge`, `maxRoots`, `format`, plus workspace pick args (`absoluteGitRoots` cannot combine with `preset`/`nestedRoots`). **Read-only.** |
1616
| `git_parity` | `rethunk-git_git_parity` | Compare `git rev-parse HEAD` for path pairs. `pairs`, `preset`, `presetMerge`, `format`, plus workspace pick args. **Read-only.** |
17-
| `list_presets` | `rethunk-git_list_presets` | List preset names/counts from `.rethunk/git-mcp-presets.json`; invalid JSON/schema surface as errors. Workspace pick + `format` only. **Read-only.** |
18-
| `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args + `format`. **Read-only.** |
19-
| `git_diff_summary` | `rethunk-git_git_diff_summary` | Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions counts, truncated to configurable line limits, with lock files/dist/vendor excluded by default. Args: `range`, `fileFilter`, `maxLinesPerFile`, `maxFiles`, `excludePatterns`, plus workspace pick args + `format`. **Read-only.** |
17+
| `list_presets` | `rethunk-git_list_presets` | List preset names/counts from `.rethunk/git-mcp-presets.json`; invalid JSON/schema surface as errors. Workspace pick + `format` only (includes `absoluteGitRoots`). **Read-only.** |
18+
| `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args (`absoluteGitRoots` for sibling clones) + `format`. **Read-only.** |
19+
| `git_diff_summary` | `rethunk-git_git_diff_summary` | Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions counts, truncated to configurable line limits, with lock files/dist/vendor excluded by default. Args: `range`, `fileFilter`, `maxLinesPerFile`, `maxFiles`, `excludePatterns`, plus workspace pick args (optional single-entry `absoluteGitRoots`) + `format`. **Read-only.** |
2020
| `git_worktree_list` | `rethunk-git_git_worktree_list` | List all worktrees (`git worktree list --porcelain`). Workspace pick + `format`. **Read-only.** |
2121
| `git_push` | `rethunk-git_git_push` | Push the current branch to its upstream. Optional `remote`, `branch`, `setUpstream` (passes `-u`). Refuses on detached HEAD; never force-pushes. Workspace pick + `format`. **Mutating.** |
2222
| `git_worktree_add` | `rethunk-git_git_worktree_add` | Create a new linked worktree, creating the branch from `baseRef` if it does not yet exist. Refuses on protected branch names. Args: `path`, `branch`, `baseRef?`, plus workspace pick + `format`. **Mutating.** |
@@ -106,6 +106,12 @@ v2 field-omission rules still apply: `filesChanged`, `insertions`, `deletions` o
106106
| `invalid_paths` | One of the `paths` entries contains shell metacharacters and was rejected. |
107107
| `git_log_failed` | `git log` exited non-zero (e.g. unknown branch ref). |
108108
| `root_index_out_of_range` | `rootIndex` exceeds the number of MCP file roots. |
109+
| `absolute_git_roots_exclusive` | `absoluteGitRoots` was combined with `workspaceRoot`, `rootIndex`, or `allWorkspaceRoots: true`. |
110+
| `absolute_git_roots_preset_conflict` | `absoluteGitRoots` was combined with a `preset` argument (root resolution). |
111+
| `invalid_absolute_git_root` | An `absoluteGitRoots` entry is empty, not inside a git worktree, or not a directory git recognizes. |
112+
| `absolute_git_roots_too_many` | More than 256 entries in `absoluteGitRoots`. |
113+
| `absolute_git_roots_empty` | `absoluteGitRoots` produced zero git toplevels after resolution. |
114+
| `absolute_git_roots_single_repo_only` | A single-repo tool received `absoluteGitRoots` resolving to more than one distinct git toplevel. |
109115

110116
### `git_diff_summary` — parameters
111117

@@ -456,6 +462,32 @@ Repo state is cleaned (`git cherry-pick --abort`) before returning — no partia
456462

457463
## Workspace root resolution
458464

465+
### `absoluteGitRoots` (sibling clones)
466+
467+
When **`absoluteGitRoots`** is a **non-empty** string array, it **replaces** the normal workspace pick for that tool call:
468+
469+
- Each entry is passed through `path.resolve`, then resolved to a **git toplevel** via the same logic as `workspaceRoot`. Duplicate toplevels are dropped (stable order, first wins).
470+
- **Maximum** **256** paths (same cap as `git_inventory` `maxRoots` upper bound).
471+
- **Mutually exclusive** with **`workspaceRoot`**, **`rootIndex`**, and **`allWorkspaceRoots: true`**. Combining them returns `{ "error": "absolute_git_roots_exclusive" }`.
472+
- **Mutually exclusive** with a **`preset`** argument on root resolution (`absolute_git_roots_preset_conflict`).
473+
- **`git_inventory` only:** also mutually exclusive with **`nestedRoots`** or **`preset`** on the same call (`absolute_git_roots_nested_or_preset_conflict`).
474+
- **Mutating** tools (`batch_commit`, `git_push`, `git_merge`, …) **omit** this parameter from their schema; callers must use `workspaceRoot` / MCP roots for writes.
475+
- **Read tools** that use **`requireSingleRepo`** (`git_diff_summary`, …) accept at most **one** distinct toplevel from `absoluteGitRoots`; more than one returns `absolute_git_roots_single_repo_only`.
476+
477+
Example — two sibling repos in one `git_status` call:
478+
479+
```json
480+
{
481+
"format": "json",
482+
"absoluteGitRoots": [
483+
"/usr/local/src/com.github/Rethunk-AI/mcp-multi-root-git",
484+
"/usr/local/src/com.github/Rethunk-AI/rethunk-github-mcp"
485+
]
486+
}
487+
```
488+
489+
### Default order (when `absoluteGitRoots` is absent or empty)
490+
459491
Order applied when resolving which directory(ies) tools run against:
460492

461493
1. Explicit **`workspaceRoot`** on the tool call (highest priority).

src/server/batch-commit-tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export function registerBatchCommitTool(server: FastMCP): void {
8282
destructiveHint: false,
8383
idempotentHint: false,
8484
},
85-
parameters: WorkspacePickSchema.extend({
85+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
8686
commits: z
8787
.array(CommitEntrySchema)
8888
.min(1)

src/server/git-cherry-pick-tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function registerGitCherryPickTool(server: FastMCP): void {
157157
destructiveHint: false,
158158
idempotentHint: false,
159159
},
160-
parameters: WorkspacePickSchema.extend({
160+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
161161
sources: z
162162
.array(z.string().min(1))
163163
.min(1)

src/server/git-inventory-tool.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export function registerGitInventoryTool(server: FastMCP): void {
4040
maxRoots: z.number().int().min(1).max(256).optional().default(MAX_INVENTORY_ROOTS_DEFAULT),
4141
}),
4242
execute: async (args) => {
43+
if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0) {
44+
if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
45+
return jsonRespond({ error: "absolute_git_roots_nested_or_preset_conflict" });
46+
}
47+
}
4348
const pre = requireGitAndRoots(server, args, args.preset);
4449
if (!pre.ok) {
4550
return jsonRespond(pre.error);

src/server/git-merge-tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ export function registerGitMergeTool(server: FastMCP): void {
297297
destructiveHint: false,
298298
idempotentHint: false,
299299
},
300-
parameters: WorkspacePickSchema.extend({
300+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
301301
sources: z
302302
.array(z.string().min(1))
303303
.min(1)

src/server/git-push-tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function registerGitPushTool(server: FastMCP): void {
1919
destructiveHint: false,
2020
idempotentHint: false,
2121
},
22-
parameters: WorkspacePickSchema.extend({
22+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
2323
remote: z
2424
.string()
2525
.optional()

src/server/git-reset-soft-tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function registerGitResetSoftTool(server: FastMCP): void {
2020
destructiveHint: false,
2121
idempotentHint: false,
2222
},
23-
parameters: WorkspacePickSchema.extend({
23+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
2424
ref: z
2525
.string()
2626
.min(1)

0 commit comments

Comments
 (0)