@rethunk/mcp-multi-root-git 2.8.0 → 2.9.0

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
@@ -23,7 +23,7 @@ IDEs injecting this as context: do not re-link from rules.
23
23
  | [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT`, **`MAX_ABSOLUTE_GIT_ROOTS`** (256), optional **`absoluteGitRoots`** on workspace pick |
24
24
  | [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
25
25
  | [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `isContentEquivalentlyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `inferRemoteFromUpstream`; `conflictPaths` |
26
- | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
26
+ | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below; `selectToolRegistrars(envValue, registrars)` — pure parse/filter fn for `RETHUNK_GIT_TOOLS` (reads env inside `registerRethunkGitTools`, not at module scope); `TOOL_REGISTRARS` ordered array |
27
27
  | [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
28
28
  | [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
29
29
  | [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
@@ -54,7 +54,7 @@ IDEs injecting this as context: do not re-link from rules.
54
54
 
55
55
  ## Validate + CI
56
56
 
57
- Local: `bun run build` | `bun run check` | `bun run schema:tools:check` | `bun run test`. CI ([`ci.yml`](.github/workflows/ci.yml)) runs same on PRs + `main` after `bun install --frozen-lockfile`, then `bun run test:coverage` + `bun run coverage:check`, and 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)).
57
+ Local: `bun run build` | `bun run lint` | `bun run schema:tools:check` | `bun run test`. CI ([`ci.yml`](.github/workflows/ci.yml)) runs same on PRs + `main` after `bun install --frozen-lockfile`, then `bun run test:coverage` + `bun run coverage:check`, and 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)).
58
58
 
59
59
  Optional [`.githooks/`](.githooks): `bun run setup-hooks` once per clone. pre-commit=`check`; pre-push=frozen install + build + check + test.
60
60
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  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).
4
4
 
5
+ ## [2.9.0] — 2026-06-05
6
+
7
+ Feature release: `RETHUNK_GIT_TOOLS` allowlist env var. Additive — no JSON format-version bump.
8
+
9
+ ### Added
10
+
11
+ - **`RETHUNK_GIT_TOOLS`** — comma-separated allowlist of exact tool names. When set to a non-empty value, only the listed tools are registered; unknown names are warned to stderr and ignored. When unset or empty (default), all 23 tools are registered — zero behavioral change for existing consumers. If every name in the list is unrecognized, zero tools are registered and a loud warning is emitted (the restriction is honored literally rather than falling back to all tools). The presets resource (`rethunk-git://presets`) is always registered regardless of this setting. Cuts MCP token cost for agents that only need a small subset of tools. Example: `RETHUNK_GIT_TOOLS=git_status,git_diff_summary,git_diff,git_log,batch_commit,git_push`.
12
+
5
13
  ## [2.8.0] — 2026-05-29
6
14
 
7
15
  Feature release: three new read-only inspection tools (tool count 20 → 23). Additive — no JSON format-version bump.
package/HUMANS.md CHANGED
@@ -205,7 +205,7 @@ It verifies the `package.json` version has a matching `CHANGELOG.md` section, bo
205
205
 
206
206
  npmjs no longer fits an unattended CI publish flow for this org; **do not** rely on automation to [npmjs](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git). To publish the **same** package name consumers already use (**`@rethunk/mcp-multi-root-git`**):
207
207
 
208
- 1. On a clean checkout at the release commit (usually **`main`** after bumping version), run **`bun run prepublishOnly`** (or `bun run build && bun run check && bun run test`).
208
+ 1. On a clean checkout at the release commit (usually **`main`** after bumping version), run **`bun run prepublishOnly`** (or `bun run build && bun run lint && bun run test`).
209
209
  2. Log in to the public registry once per machine: **`npm login`** (or `npm adduser`) so **`npm whoami`** shows the account that owns **`@rethunk`** on npmjs.
210
210
  3. Ensure **`package.json`** still has **`"name": "@rethunk/mcp-multi-root-git"`** and **`publishConfig.access`** is **`"public"`** (no **`publishConfig.registry`** pointing at GitHub — leave default registry for npmjs).
211
211
  4. Publish: **`npm publish --access public`** (runs **`prepublishOnly`** again unless you pass **`--ignore-scripts`** after you already verified locally).
@@ -24,23 +24,20 @@ const CommitEntrySchema = z.object({
24
24
  files: z
25
25
  .array(FileEntrySchema)
26
26
  .min(1)
27
- .describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging. " +
28
- "Deleted files (missing on disk but tracked in HEAD) are staged as removals via `git rm --cached`. " +
29
- "Combining { path, lines } with a deleted file is an error."),
27
+ .describe("Paths to stage, relative to git root. String or `{ path, lines }` for hunk-level staging. " +
28
+ "Deleted tracked files are staged via `git rm --cached`. Cannot combine `lines` with a deleted file."),
30
29
  });
31
30
  const PushModeSchema = z
32
31
  .enum(["never", "after"])
33
32
  .optional()
34
33
  .default("never")
35
- .describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
36
- "fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
37
- "Enum reserved for future modes such as `force-with-lease`.");
34
+ .describe("`never` (default): no push. `after`: push current branch to upstream after all commits succeed; " +
35
+ "fails with `push_no_upstream` if no upstream (commits are NOT rolled back).");
38
36
  const DryRunSchema = z
39
37
  .boolean()
40
38
  .optional()
41
39
  .default(false)
42
- .describe("When true, stage files, collect preview (files staged, commit messages), return preview response without writing commits. " +
43
- "Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
40
+ .describe("Stage files and return a preview without writing commits; unstages afterwards. Response is marked DRY RUN.");
44
41
  /**
45
42
  * Parses a unified diff to extract hunks that overlap with a given line range.
46
43
  * Returns a partial patch containing only the overlapping hunks, including header lines.
@@ -220,11 +217,9 @@ export async function runPushAfter(gitTop) {
220
217
  export function registerBatchCommitTool(server) {
221
218
  server.addTool({
222
219
  name: "batch_commit",
223
- description: "Create multiple sequential git commits in a single call. " +
224
- "Each entry stages the listed files then commits with the given message. " +
225
- 'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
226
- "to its upstream once all commits succeed. " +
227
- "Optional `dryRun: true` previews what would be staged/committed without writing commits.",
220
+ description: "Create multiple sequential git commits in one call. " +
221
+ "Each entry stages its files then commits. Stops on first failure. " +
222
+ 'Optional `push: "after"` pushes after all commits succeed. `dryRun: true` previews without writing.',
228
223
  annotations: {
229
224
  readOnlyHint: false,
230
225
  destructiveHint: false,
@@ -235,7 +230,7 @@ export function registerBatchCommitTool(server) {
235
230
  .array(CommitEntrySchema)
236
231
  .min(1)
237
232
  .max(50)
238
- .describe("Commits to create, applied in order."),
233
+ .describe("Ordered list of commits to create."),
239
234
  push: PushModeSchema,
240
235
  dryRun: DryRunSchema,
241
236
  }),
@@ -100,9 +100,7 @@ function renderBranchListMarkdown(result) {
100
100
  export function registerGitBranchListTool(server) {
101
101
  server.addTool({
102
102
  name: "git_branch_list",
103
- description: "List local branches (and optionally remote-tracking branches) for a single git repository. " +
104
- "Returns `{ branches: [{ name, sha, current, upstream? }], remotes?: [{ name, sha }] }`. " +
105
- "The current branch is marked with `current: true`. Remote branches are included when `includeRemotes: true`.",
103
+ description: "List local (and optionally remote-tracking) branches. Current branch marked `current: true`. Set `includeRemotes: true` for remotes.",
106
104
  annotations: {
107
105
  readOnlyHint: true,
108
106
  },
@@ -117,7 +115,7 @@ export function registerGitBranchListTool(server) {
117
115
  .boolean()
118
116
  .optional()
119
117
  .default(false)
120
- .describe("When true, also return remote-tracking branches from refs/remotes (excluding symbolic origin/HEAD)."),
118
+ .describe("Include remote-tracking branches from refs/remotes (symbolic origin/HEAD excluded)."),
121
119
  }),
122
120
  execute: async (args) => {
123
121
  const pre = requireSingleRepo(server, args);
@@ -94,14 +94,11 @@ async function filterAndDedupe(gitTop, onto, resolved) {
94
94
  export function registerGitCherryPickTool(server) {
95
95
  server.addTool({
96
96
  name: "git_cherry_pick",
97
- description: "Play commits from one or more sources onto a destination. Sources may be SHAs, " +
98
- "`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
99
- "Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
100
- "stops on the first conflict and reports paths. Optional flags auto-delete source " +
101
- "branches and worktrees after success; deletion uses patch-id equivalence by default " +
102
- "(content-identical commits with different SHAs are treated as merged, which is the " +
103
- "normal cherry-pick outcome). Pass `strictMergedRefEquality: true` for strict `git branch -d` " +
104
- "ancestry semantics. Protected branch names always skipped.",
97
+ description: "Cherry-pick commits from one or more sources onto a destination. Sources: SHAs, `A..B` ranges, " +
98
+ "or branch names (expanded to `onto..<branch>`, oldest-first). Already-reachable commits skipped. " +
99
+ "Refuses on dirty tree; stops on first conflict. Optional flags delete source branches/worktrees " +
100
+ "after success using patch-id equivalence (set `strictMergedRefEquality: true` for strict ancestry). " +
101
+ "Protected names always skipped.",
105
102
  annotations: {
106
103
  readOnlyHint: false,
107
104
  destructiveHint: false,
@@ -112,8 +109,7 @@ export function registerGitCherryPickTool(server) {
112
109
  .array(z.string().min(1))
113
110
  .min(1)
114
111
  .max(50)
115
- .describe("Sources to cherry-pick: SHA, `A..B` range, or branch name. Branch sources " +
116
- "resolve to `onto..<branch>` (only commits missing from destination)."),
112
+ .describe("Sources: SHA, `A..B` range, or branch name (resolves to `onto..<branch>`)."),
117
113
  onto: z
118
114
  .string()
119
115
  .optional()
@@ -122,24 +118,18 @@ export function registerGitCherryPickTool(server) {
122
118
  .boolean()
123
119
  .optional()
124
120
  .default(false)
125
- .describe("After all commits apply, delete each branch-kind source locally " +
126
- "(`git branch -d`) when it is fully merged into the destination. " +
127
- "Protected names always skipped; never touches remote refs."),
121
+ .describe("Delete branch-kind sources locally after success. Protected names and remote refs unaffected."),
128
122
  deleteMergedWorktrees: z
129
123
  .boolean()
130
124
  .optional()
131
125
  .default(false)
132
- .describe("After success, remove any local worktree attached to a branch-kind source " +
133
- "(`git worktree remove`). Protected tails always skipped."),
126
+ .describe("Remove local worktrees on branch-kind sources after success. Protected tails skipped."),
134
127
  strictMergedRefEquality: z
135
128
  .boolean()
136
129
  .optional()
137
130
  .default(false)
138
- .describe("When false (default), branch deletion uses patch-id equivalence: a source branch " +
139
- "is deleted when every commit it contains has a content-equivalent commit on the " +
140
- "destination (same diff, different SHA — the normal cherry-pick outcome). " +
141
- "Set to true to require strict ref ancestry (`git branch -d` semantics), which " +
142
- "will refuse deletion after a cherry-pick because the SHA differs."),
131
+ .describe("false (default): delete branch when every commit is content-equivalent on destination (patch-id, normal cherry-pick outcome). " +
132
+ "true: require strict ref ancestry (`git branch -d` semantics — will refuse after cherry-pick due to SHA mismatch)."),
143
133
  }),
144
134
  execute: async (args) => {
145
135
  const pre = requireSingleRepo(server, args);
@@ -218,7 +208,7 @@ export function registerGitCherryPickTool(server) {
218
208
  if (allOk) {
219
209
  for (let i = 0; i < perSourceReport.length; i++) {
220
210
  const src = perSourceReport[i];
221
- if (!src || src.kind !== "branch")
211
+ if (src?.kind !== "branch")
222
212
  continue;
223
213
  if (isProtectedBranch(src.raw))
224
214
  continue;
@@ -179,9 +179,8 @@ function rangeLabel(range, diffArgs) {
179
179
  export function registerGitDiffSummaryTool(server) {
180
180
  server.addTool({
181
181
  name: "git_diff_summary",
182
- description: "Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions, " +
183
- "truncated to configurable line limits, with noise files (lock files, dist, etc.) excluded by default. " +
184
- "Use `range` to target staged, HEAD, or any revision range.",
182
+ description: "Structured diff viewer: per-file diffs with counts, truncated to configurable limits. " +
183
+ "Noise files (lock files, dist, etc.) excluded by default. Use `range` to target staged, HEAD, or a revision range.",
185
184
  annotations: {
186
185
  readOnlyHint: true,
187
186
  },
@@ -214,7 +213,7 @@ export function registerGitDiffSummaryTool(server) {
214
213
  excludePatterns: z
215
214
  .array(z.string())
216
215
  .optional()
217
- .describe("Glob patterns to exclude. Defaults to common noise: lock files, dist, vendor, etc."),
216
+ .describe("Glob patterns to exclude. Default: lock files, dist, vendor, etc."),
218
217
  }),
219
218
  execute: async (args) => {
220
219
  const pre = requireSingleRepo(server, args);
@@ -61,10 +61,8 @@ function rangeLabel(opts) {
61
61
  export function registerGitDiffTool(server) {
62
62
  server.addTool({
63
63
  name: "git_diff",
64
- description: "Get diff text for scoped file(s) or range. Returns the raw diff output. " +
65
- "Use `staged: true` for staged changes, `base`/`head` for revision ranges, " +
66
- "`path` to scope to a single file, `paths` to scope to multiple files, " +
67
- "and `unified` to control the number of context lines.",
64
+ description: "Raw diff text for scoped file(s) or range. `staged: true` for staged changes, " +
65
+ "`base`/`head` for revision ranges, `path`/`paths` to scope, `unified` for context lines.",
68
66
  annotations: {
69
67
  readOnlyHint: true,
70
68
  },
@@ -75,37 +73,31 @@ export function registerGitDiffTool(server) {
75
73
  base: z
76
74
  .string()
77
75
  .optional()
78
- .describe('Base ref (e.g. "main", "HEAD~3"). Required for range diffs. ' +
79
- "If omitted and `staged: false`, shows unstaged changes."),
76
+ .describe('Base ref (e.g. "main", "HEAD~3"). Omit for unstaged changes.'),
80
77
  head: z
81
78
  .string()
82
79
  .optional()
83
- .describe('Head ref (e.g. "feature-branch"). If omitted, defaults to HEAD. ' +
84
- "Only used if `base` is provided."),
80
+ .describe('Head ref (e.g. "feature-branch"). Defaults to HEAD. Used only when `base` is set.'),
85
81
  path: z
86
82
  .string()
87
83
  .optional()
88
- .describe('Scope diff to a single file path (e.g. "src/main.ts"). ' +
89
- "For multiple files, prefer `paths`. If both `path` and `paths` are given, they are unioned."),
84
+ .describe("Scope to a single file. Unioned with `paths` if both given."),
90
85
  paths: z
91
86
  .array(z.string())
92
87
  .optional()
93
- .describe('Scope diff to multiple file paths (e.g. ["src/a.ts", "src/b.ts"]). ' +
94
- "Each path is validated and must lie within the repository root. " +
95
- "If both `path` and `paths` are given, they are unioned."),
88
+ .describe("Scope to multiple files (must be within repo root). Unioned with `path` if both given."),
96
89
  staged: z
97
90
  .boolean()
98
91
  .optional()
99
92
  .default(false)
100
- .describe("If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided."),
93
+ .describe("Show staged changes (`git diff --staged`). Ignored if `base` is set."),
101
94
  unified: z
102
95
  .number()
103
96
  .int()
104
97
  .min(0)
105
98
  .max(100)
106
99
  .optional()
107
- .describe("Number of context lines to show around each change (passed as -U<n> to git diff). " +
108
- "Defaults to git's built-in default (3). Use 0 for no context."),
100
+ .describe("Context lines around each change (`-U<n>`). Default: 3. Use 0 for no context."),
109
101
  }),
110
102
  execute: async (args) => {
111
103
  const pre = requireSingleRepo(server, args);
@@ -176,8 +176,7 @@ function renderLogMarkdown(group, filterSummary) {
176
176
  export function registerGitLogTool(server) {
177
177
  server.addTool({
178
178
  name: "git_log",
179
- description: "Path-filtered, time-windowed read-only `git log` across one or more workspace roots. " +
180
- "Returns structured commit history with author, date, subject, and optional diff stats.",
179
+ description: "Read-only `git log` across one or more roots. Returns author, date, subject, optional diff stats.",
181
180
  annotations: {
182
181
  readOnlyHint: true,
183
182
  },
@@ -186,26 +185,21 @@ export function registerGitLogTool(server) {
186
185
  .enum(["markdown", "json", "oneline"])
187
186
  .optional()
188
187
  .default("markdown")
189
- .describe("`markdown` (default): headed sections per root. " +
190
- "`json`: structured groups array. " +
191
- "`oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification."),
188
+ .describe("`markdown` (default): headed sections per root. `json`: groups array. " +
189
+ "`oneline`: `<sha7> <subject>` per line; lowest-token option for post-commit verification."),
192
190
  since: z
193
191
  .string()
194
192
  .optional()
195
- .describe("Passed to `git log --since=`. Accepts ISO timestamps or git relative forms like " +
196
- "`48.hours` or `2.weeks.ago`. Default: `7.days`."),
193
+ .describe("Passed to `--since=`. ISO timestamp or git relative form (`48.hours`, `2.weeks.ago`). Default: `7.days`."),
197
194
  paths: z
198
195
  .array(z.string())
199
196
  .optional()
200
- .describe("Limit to commits touching these paths (passed as `-- <paths>`)."),
197
+ .describe("Limit to commits touching these paths (`-- <paths>`)."),
201
198
  grep: z
202
199
  .string()
203
200
  .optional()
204
- .describe("Filter commits whose message matches this regex (git `--grep`, case-insensitive)."),
205
- author: z
206
- .string()
207
- .optional()
208
- .describe("Filter by author name or email (passed as `--author=`)."),
201
+ .describe("Filter by commit message regex (git `--grep`, case-insensitive)."),
202
+ author: z.string().optional().describe("Filter by author name or email (`--author=`)."),
209
203
  maxCommits: z
210
204
  .number()
211
205
  .int()
@@ -213,7 +207,7 @@ export function registerGitLogTool(server) {
213
207
  .max(MAX_COMMITS_HARD_CAP)
214
208
  .optional()
215
209
  .default(DEFAULT_MAX_COMMITS)
216
- .describe(`Maximum commits to return per root (hard cap ${MAX_COMMITS_HARD_CAP}). Default ${DEFAULT_MAX_COMMITS}.`),
210
+ .describe(`Max commits per root (hard cap ${MAX_COMMITS_HARD_CAP}, default ${DEFAULT_MAX_COMMITS}).`),
217
211
  branch: z.string().optional().describe("Ref/branch to log from. Default: HEAD."),
218
212
  }),
219
213
  execute: async (args) => {
@@ -207,12 +207,10 @@ async function maybeDeleteBranch(gitTop, source, enabled, into) {
207
207
  export function registerGitMergeTool(server) {
208
208
  server.addTool({
209
209
  name: "git_merge",
210
- description: "Merge one or more source branches into a destination. Default strategy `auto` " +
211
- "cascades fast-forward → rebase → merge-commit per source, preferring linear history. " +
212
- "Refuses if the working tree is dirty. Stops on the first conflict and reports " +
213
- "the affected paths. Optional flags auto-delete merged branches and worktrees, " +
214
- "skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
215
- "production, release/*, hotfix/*).",
210
+ description: "Merge one or more source branches into a destination. `auto` cascades " +
211
+ "fast-forward → rebase → merge-commit, preferring linear history. " +
212
+ "Refuses on dirty tree; stops on first conflict. Optional flags delete merged branches/worktrees " +
213
+ "(protected names skipped: main, master, dev, develop, stable, trunk, prod, production, release/*, hotfix/*).",
216
214
  annotations: {
217
215
  readOnlyHint: false,
218
216
  destructiveHint: false,
@@ -237,14 +235,12 @@ export function registerGitMergeTool(server) {
237
235
  .boolean()
238
236
  .optional()
239
237
  .default(false)
240
- .describe("After all sources merge cleanly, delete each source branch locally (`git branch -d`). " +
241
- "Protected names always skipped. Never affects remote branches."),
238
+ .describe("Delete each source branch locally after clean merge (`git branch -d`). Protected names and remote refs unaffected."),
242
239
  deleteMergedWorktrees: z
243
240
  .boolean()
244
241
  .optional()
245
242
  .default(false)
246
- .describe("After all sources merge cleanly, remove any local worktree currently checked out " +
247
- "on a source branch (`git worktree remove`). Protected tails always skipped."),
243
+ .describe("Remove local worktrees on source branches after clean merge (`git worktree remove`). Protected tails skipped."),
248
244
  }),
249
245
  execute: async (args) => {
250
246
  const pre = requireSingleRepo(server, args);
@@ -8,10 +8,8 @@ import { WorkspacePickSchema } from "./schemas.js";
8
8
  export function registerGitResetSoftTool(server) {
9
9
  server.addTool({
10
10
  name: "git_reset_soft",
11
- description: "Soft-reset the current branch to a reference (`git reset --soft <ref>`). " +
12
- "Moves the branch pointer back while keeping all changes from the rewound commits " +
13
- "in the staging index — use this to re-split an already-committed chunk. " +
14
- "Refuses when the working tree has any uncommitted or unstaged changes (run on a clean tree).",
11
+ description: "`git reset --soft <ref>`: moves HEAD back while keeping rewound changes staged. " +
12
+ "Use to re-split committed work. Refuses on a dirty tree.",
15
13
  annotations: {
16
14
  readOnlyHint: false,
17
15
  destructiveHint: false,
@@ -21,8 +19,7 @@ export function registerGitResetSoftTool(server) {
21
19
  ref: z
22
20
  .string()
23
21
  .min(1)
24
- .describe("Commit to reset to. Accepts ancestor notation (`HEAD~1`, `HEAD~3`), " +
25
- "branch names, or full SHAs."),
22
+ .describe("Commit to reset to: ancestor notation (`HEAD~1`, `HEAD~3`), branch name, or SHA."),
26
23
  }),
27
24
  execute: async (args) => {
28
25
  const pre = requireSingleRepo(server, args);
@@ -10,7 +10,7 @@ import { WorkspacePickSchema } from "./schemas.js";
10
10
  export function registerGitStashListTool(server) {
11
11
  server.addTool({
12
12
  name: "git_stash_list",
13
- description: "List all git stashes. Returns array of `{ index: number, message: string, sha: string }`.",
13
+ description: "List all git stashes.",
14
14
  annotations: {
15
15
  readOnlyHint: true,
16
16
  },
@@ -77,9 +77,7 @@ export function registerGitStashListTool(server) {
77
77
  export function registerGitStashApplyTool(server) {
78
78
  server.addTool({
79
79
  name: "git_stash_apply",
80
- description: "Apply or pop a git stash. `index` defaults to 0 (stash@{0}). " +
81
- "Set `pop: true` to run `git stash pop` instead of `git stash apply` (removes stash after applying). " +
82
- "Returns `{ applied: boolean, stashIndex: number, popped: boolean, output: string }`.",
80
+ description: "Apply or pop a git stash. `index` defaults to 0. `pop: true` removes stash after applying.",
83
81
  annotations: {
84
82
  readOnlyHint: false,
85
83
  destructiveHint: false,
@@ -103,7 +101,7 @@ export function registerGitStashApplyTool(server) {
103
101
  .boolean()
104
102
  .optional()
105
103
  .default(false)
106
- .describe("If true, runs `git stash pop` instead of `git stash apply` (removes stash after applying)."),
104
+ .describe("Run `git stash pop` instead of `git stash apply` (removes stash after applying)."),
107
105
  }),
108
106
  execute: async (args) => {
109
107
  const pre = requireSingleRepo(server, args);
@@ -49,8 +49,7 @@ export function registerGitWorktreeListTool(server) {
49
49
  export function registerGitWorktreeAddTool(server) {
50
50
  server.addTool({
51
51
  name: "git_worktree_add",
52
- description: "Add a new git worktree. If `branch` does not exist it is created from `baseRef` " +
53
- "(defaults to HEAD). Refuses to create on a protected branch name.",
52
+ description: "Add a new git worktree. Creates `branch` from `baseRef` (default: HEAD) if it doesn't exist. Refuses protected branch names.",
54
53
  annotations: {
55
54
  readOnlyHint: false,
56
55
  destructiveHint: false,
@@ -60,15 +59,15 @@ export function registerGitWorktreeAddTool(server) {
60
59
  path: z
61
60
  .string()
62
61
  .min(1)
63
- .describe("Filesystem path for the new worktree. Relative paths are resolved from the git toplevel."),
62
+ .describe("Filesystem path for the new worktree (relative paths resolved from git toplevel)."),
64
63
  branch: z
65
64
  .string()
66
65
  .min(1)
67
- .describe("Branch to check out in the new worktree. Created from `baseRef` if absent."),
66
+ .describe("Branch to check out; created from `baseRef` if it doesn't exist."),
68
67
  baseRef: z
69
68
  .string()
70
69
  .optional()
71
- .describe("Commit-ish to base the new branch on when it does not yet exist. Default: HEAD."),
70
+ .describe("Commit-ish to base the new branch on. Default: HEAD."),
72
71
  }),
73
72
  execute: async (args) => {
74
73
  const pre = requireSingleRepo(server, args);
@@ -132,9 +131,7 @@ export function registerGitWorktreeAddTool(server) {
132
131
  export function registerGitWorktreeRemoveTool(server) {
133
132
  server.addTool({
134
133
  name: "git_worktree_remove",
135
- description: "Remove a git worktree (`git worktree remove`). " +
136
- "Pass `force: true` to remove a worktree with uncommitted changes. " +
137
- "Refuses to remove the main worktree.",
134
+ description: "Remove a git worktree. Pass `force: true` to remove with uncommitted changes. Refuses to remove the main worktree.",
138
135
  annotations: {
139
136
  readOnlyHint: false,
140
137
  destructiveHint: true,
@@ -149,7 +146,7 @@ export function registerGitWorktreeRemoveTool(server) {
149
146
  .boolean()
150
147
  .optional()
151
148
  .default(false)
152
- .describe("Pass `--force` to allow removal of worktrees with uncommitted changes."),
149
+ .describe("Allow removal of worktrees with uncommitted changes (`--force`)."),
153
150
  }),
154
151
  execute: async (args) => {
155
152
  const pre = requireSingleRepo(server, args);
@@ -4,24 +4,24 @@ const FormatSchema = z.enum(["markdown", "json"]).optional().default("markdown")
4
4
  /** Max paths in `absoluteGitRoots` (matches `git_inventory` `maxRoots` hard cap). */
5
5
  export const MAX_ABSOLUTE_GIT_ROOTS = 256;
6
6
  export const WorkspacePickSchema = z.object({
7
- workspaceRoot: z.string().optional().describe("Highest-priority override."),
7
+ workspaceRoot: z.string().optional().describe("Highest-priority root override."),
8
8
  rootIndex: z
9
9
  .number()
10
10
  .int()
11
11
  .min(0)
12
12
  .optional()
13
- .describe("0-based index into the MCP file roots list; ignored when workspaceRoot is set."),
13
+ .describe("0-based index into MCP roots; ignored if workspaceRoot set."),
14
14
  allWorkspaceRoots: z
15
15
  .boolean()
16
16
  .optional()
17
17
  .default(false)
18
- .describe("Fan out across all MCP file roots."),
18
+ .describe("Fan out across all MCP roots."),
19
19
  /** Independent git worktrees (sibling clones). Mutually exclusive with workspaceRoot, rootIndex, allWorkspaceRoots, and (git_inventory) preset/nestedRoots. */
20
20
  absoluteGitRoots: z
21
21
  .array(z.string())
22
22
  .max(MAX_ABSOLUTE_GIT_ROOTS)
23
23
  .optional()
24
- .describe("Absolute paths to git repo roots. Use for many sibling clones under a non-git parent directory."),
24
+ .describe("Absolute paths to git repo roots; use for sibling clones under a non-git parent."),
25
25
  format: FormatSchema,
26
26
  });
27
27
  export { MAX_INVENTORY_ROOTS_DEFAULT };
@@ -19,32 +19,73 @@ import { registerGitTagTool } from "./git-tag-tool.js";
19
19
  import { registerGitWorktreeAddTool, registerGitWorktreeListTool, registerGitWorktreeRemoveTool, } from "./git-worktree-tool.js";
20
20
  import { registerListPresetsTool } from "./list-presets-tool.js";
21
21
  import { registerPresetsResource } from "./presets-resource.js";
22
- export function registerRethunkGitTools(server) {
22
+ /**
23
+ * Ordered registry of all 23 MCP tools. Registration order is preserved for
24
+ * both full and filtered (RETHUNK_GIT_TOOLS) subsets.
25
+ */
26
+ const TOOL_REGISTRARS = [
23
27
  // Read-only tools
24
- registerGitStatusTool(server);
25
- registerGitInventoryTool(server);
26
- registerGitParityTool(server);
27
- registerListPresetsTool(server);
28
- registerGitLogTool(server);
29
- registerGitDiffSummaryTool(server);
30
- registerGitDiffTool(server);
31
- registerGitShowTool(server);
32
- registerGitWorktreeListTool(server);
33
- registerGitStashListTool(server);
34
- registerGitFetchTool(server);
35
- registerGitBlameTool(server);
36
- registerGitBranchListTool(server);
37
- registerGitReflogTool(server);
28
+ { name: "git_status", register: registerGitStatusTool },
29
+ { name: "git_inventory", register: registerGitInventoryTool },
30
+ { name: "git_parity", register: registerGitParityTool },
31
+ { name: "list_presets", register: registerListPresetsTool },
32
+ { name: "git_log", register: registerGitLogTool },
33
+ { name: "git_diff_summary", register: registerGitDiffSummaryTool },
34
+ { name: "git_diff", register: registerGitDiffTool },
35
+ { name: "git_show", register: registerGitShowTool },
36
+ { name: "git_worktree_list", register: registerGitWorktreeListTool },
37
+ { name: "git_stash_list", register: registerGitStashListTool },
38
+ { name: "git_fetch", register: registerGitFetchTool },
39
+ { name: "git_blame", register: registerGitBlameTool },
40
+ { name: "git_branch_list", register: registerGitBranchListTool },
41
+ { name: "git_reflog", register: registerGitReflogTool },
38
42
  // Mutating tools
39
- registerBatchCommitTool(server);
40
- registerGitPushTool(server);
41
- registerGitMergeTool(server);
42
- registerGitCherryPickTool(server);
43
- registerGitResetSoftTool(server);
44
- registerGitTagTool(server);
45
- registerGitWorktreeAddTool(server);
46
- registerGitWorktreeRemoveTool(server);
47
- registerGitStashApplyTool(server);
48
- // Resources
43
+ { name: "batch_commit", register: registerBatchCommitTool },
44
+ { name: "git_push", register: registerGitPushTool },
45
+ { name: "git_merge", register: registerGitMergeTool },
46
+ { name: "git_cherry_pick", register: registerGitCherryPickTool },
47
+ { name: "git_reset_soft", register: registerGitResetSoftTool },
48
+ { name: "git_tag", register: registerGitTagTool },
49
+ { name: "git_worktree_add", register: registerGitWorktreeAddTool },
50
+ { name: "git_worktree_remove", register: registerGitWorktreeRemoveTool },
51
+ { name: "git_stash_apply", register: registerGitStashApplyTool },
52
+ ];
53
+ /**
54
+ * Parse the RETHUNK_GIT_TOOLS env var and return the matching subset of
55
+ * registrars plus any unrecognized token names.
56
+ *
57
+ * @param envValue Raw value of process.env.RETHUNK_GIT_TOOLS (may be undefined).
58
+ * @param registrars Full ordered registrar list (injectable for tests).
59
+ */
60
+ export function selectToolRegistrars(envValue, registrars) {
61
+ const tokens = (envValue ?? "")
62
+ .split(",")
63
+ .map((t) => t.trim())
64
+ .filter((t) => t.length > 0);
65
+ // Unset, empty, or whitespace-only → register all tools.
66
+ if (tokens.length === 0) {
67
+ return { selected: registrars, unknown: [] };
68
+ }
69
+ const knownNames = new Set(registrars.map((r) => r.name));
70
+ const requested = new Set(tokens);
71
+ const unknown = tokens.filter((t) => !knownNames.has(t));
72
+ // Preserve canonical order; deduplicate duplicate tokens automatically.
73
+ const selected = registrars.filter((r) => requested.has(r.name));
74
+ return { selected, unknown };
75
+ }
76
+ export function registerRethunkGitTools(server) {
77
+ const env = process.env.RETHUNK_GIT_TOOLS;
78
+ const { selected, unknown } = selectToolRegistrars(env, TOOL_REGISTRARS);
79
+ if (unknown.length > 0) {
80
+ process.stderr.write(`[rethunk-git] RETHUNK_GIT_TOOLS: unknown tool name(s) ignored: ${unknown.map((n) => JSON.stringify(n)).join(", ")}\n`);
81
+ }
82
+ if (selected.length === 0 && (env ?? "").trim().length > 0) {
83
+ process.stderr.write(`[rethunk-git] RETHUNK_GIT_TOOLS: every listed name was unrecognized — registering NO tools. ` +
84
+ `Set RETHUNK_GIT_TOOLS to a comma-separated list of valid tool names, or unset it to register all tools.\n`);
85
+ }
86
+ for (const { register } of selected) {
87
+ register(server);
88
+ }
89
+ // The presets RESOURCE is always registered, regardless of the tool allowlist.
49
90
  registerPresetsResource(server);
50
91
  }
package/docs/install.md CHANGED
@@ -92,6 +92,7 @@ Omit any `cwd` / `workingDirectory` field unless your client requires it for unr
92
92
  | Variable | Default | Purpose |
93
93
  |----------|---------|---------|
94
94
  | `GIT_SUBPROCESS_PARALLELISM` | `4` | Number of parallel git subprocesses for inventory operations and `git_status` submodule rows. Valid range: 1 to 2×CPU count (auto-clamped to prevent runaway spawning). Increase on high-core machines to accelerate large fleet scans; decrease if system resources are constrained. |
95
+ | `RETHUNK_GIT_TOOLS` | _(unset)_ | Comma-separated list of tool names to register. When unset or empty, all 23 tools are registered (default). When set, only the listed tools are exposed — unknown names are warned and ignored. If every name is unknown, **zero** tools are registered and a loud warning is emitted (the restriction is honored literally). The presets resource (`rethunk-git://presets`) is always registered regardless of this setting. Example: `RETHUNK_GIT_TOOLS=git_status,git_diff_summary,git_diff,git_log,batch_commit,git_push`. Full tool-name list: `git_status`, `git_inventory`, `git_parity`, `list_presets`, `git_log`, `git_diff_summary`, `git_diff`, `git_show`, `git_worktree_list`, `git_stash_list`, `git_fetch`, `git_blame`, `git_branch_list`, `git_reflog`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, `git_worktree_add`, `git_worktree_remove`, `git_stash_apply`. |
95
96
 
96
97
  Set these in the environment where the MCP client launches the server (e.g. in your shell, in the MCP client config as `env`, or in a startup script).
97
98