@rethunk/mcp-multi-root-git 2.5.0 → 2.6.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 +3 -3
- package/CHANGELOG.md +24 -1
- package/dist/server/batch-commit-tool.js +25 -4
- package/dist/server/git-diff-summary-tool.js +7 -1
- package/dist/server/git-diff-tool.js +4 -1
- package/dist/server/git-fetch-tool.js +8 -1
- package/dist/server/git-inventory-tool.js +2 -2
- package/dist/server/git-log-tool.js +5 -0
- package/dist/server/git-parity-tool.js +2 -2
- package/dist/server/git-refs.js +12 -5
- package/dist/server/git-show-tool.js +15 -1
- package/dist/server/git-stash-tool.js +15 -14
- package/dist/server/git-tag-tool.js +3 -3
- package/dist/server/git.js +16 -3
- package/dist/server.js +7 -0
- package/docs/mcp-tools.md +16 -7
- package/package.json +3 -2
- package/schemas/git_diff.json +0 -14
- package/schemas/git_diff_summary.json +0 -6
- package/schemas/git_show.json +1 -0
- package/tool-parameters.schema.json +1 -20
package/AGENTS.md
CHANGED
|
@@ -48,7 +48,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
48
48
|
## Changing contracts
|
|
49
49
|
|
|
50
50
|
- **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
|
|
51
|
-
- **JSON format version** (currently `"3"`,
|
|
51
|
+
- **JSON format version** (currently `"3"`, exported as `MCP_JSON_FORMAT_VERSION` in `src/server.ts` and surfaced via the FastMCP `instructions` field — discoverable from the MCP `initialize` response): 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. v3 changes in `git_log`: `sha7` → `sha` (full SHA), `workspace_root` → `workspaceRoot`, `ageRelative` removed, `email` omitted when empty.
|
|
52
52
|
- **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
|
|
53
53
|
- **Public tool surface:** rename/add → update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned), then regenerate the shipped schema artifacts (`tool-parameters.schema.json`, `schemas/index.json`, `schemas/*.json`). Install/client wiring → [docs/install.md](docs/install.md) only.
|
|
54
54
|
|
|
@@ -66,7 +66,7 @@ Rules for LLMs operating in or against this repository.
|
|
|
66
66
|
|
|
67
67
|
**End-user Git/MCP preference** (status/log/diff/commits): **`~/.claude/CLAUDE.md`** § **Git & GitHub** — same policy when dogfooding this server from a harness.
|
|
68
68
|
|
|
69
|
-
**Mutating tools
|
|
69
|
+
**Mutating tools use `workspaceRoot` as trusted operator input** — `git_fetch`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, and `git_stash_apply` operate on roots resolved via `requireGitAndRoots` / `requireSingleRepo`. Pass the target repo as `workspaceRoot` (trusted caller input) or via MCP roots. The `absoluteGitRoots` bulk multi-root parameter is not accepted by mutating tools — passing it is rejected.
|
|
70
70
|
|
|
71
71
|
**`batch_commit` atomic staging — single call per logical change** — Do NOT attempt incremental staging across multiple `batch_commit` calls. Each call is self-contained: it stages all files in all entries, commits them sequentially, and the moment the call completes, all commits have landed. Include all related files (for all related commit entries) in a single `batch_commit` call. A call cannot be resumed or extended by a later call — each is an independent transaction. If entry N fails, entries before N remain committed; entries after N are skipped (not rolled back).
|
|
72
72
|
|
|
@@ -76,7 +76,7 @@ Rules for LLMs operating in or against this repository.
|
|
|
76
76
|
|
|
77
77
|
**Never force-push** — `git_push` has no force-push mode by design. `git_merge` with `strategy: "ff-only"` will fail cleanly rather than force.
|
|
78
78
|
|
|
79
|
-
**Contract bumps need documentation** — if a JSON output shape changes incompatibly, bump `MCP_JSON_FORMAT_VERSION` in `src/server.ts` and document the migration in both this file and [docs/mcp-tools.md](docs/mcp-tools.md).
|
|
79
|
+
**Contract bumps need documentation** — if a JSON output shape changes incompatibly, bump the `MCP_JSON_FORMAT_VERSION` constant in `src/server.ts` (it is surfaced in the server `instructions` field and discoverable via MCP `initialize`) and document the migration in both this file and [docs/mcp-tools.md](docs/mcp-tools.md).
|
|
80
80
|
|
|
81
81
|
**Path confinement** — any tool accepting file paths must use `resolvePathForRepo` / `assertRelativePathUnderTop` from [`src/repo-paths.ts`](src/repo-paths.ts) and include escaping-attempt tests.
|
|
82
82
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,30 @@
|
|
|
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
|
-
## [
|
|
5
|
+
## [2.6.0] — 2026-05-22
|
|
6
|
+
|
|
7
|
+
Security-hardening and correctness release surfaced by a full-repo critical review.
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **Argument-injection hardening** — `git_show`, `git_log`, and `git_fetch` passed caller-supplied ref/branch/remote values straight into git argv. A value beginning with `-` is parsed by git as an option (`--output=<path>` writes an arbitrary file; `--upload-pack=<cmd>` on fetch runs a command). These read tools now validate those tokens against the argv-safe subset before invoking git, returning `unsafe_ref_token`, `unsafe_remote_token`, or `path_escapes_repo` on rejection — the same gate already applied to the mutating tools.
|
|
12
|
+
- **`isProtectedBranch` normalization** — input is now normalized (trim, strip leading `refs/heads/`, lowercase) before matching, so `refs/heads/main`, `Main`, and `MAIN` are all recognized as protected. Previously the exact-name set was case-sensitive and a `refs/heads/` prefix evaded it entirely.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`batch_commit` linked worktrees** — hunk-level staging wrote its scratch patch under `${gitTop}/.git/`, which fails with `ENOTDIR` in a linked worktree (where `.git` is a file). The scratch path is now resolved via `git rev-parse --absolute-git-dir`, correct for both normal repos and worktrees.
|
|
17
|
+
- **`batch_commit` dry run** — preview cleanup ran an unconditional `git reset HEAD --` over every touched path, silently unstaging work the caller had staged before invoking. A pre-staged snapshot is now taken first; cleanup resets only paths the dry run itself staged.
|
|
18
|
+
- **`git_stash_list` index** — `index` was taken from the loop counter, so a malformed entry dropped by the parse guard shifted every later index and `git_stash_apply` could target the wrong stash. The index is now parsed from the canonical `stash@{N}` ref.
|
|
19
|
+
- **`git_diff_summary` rename parsing** — the new path of a rename whose path contained the substring ` b/` was mis-reported because the greedy `diff --git` header regex split at the wrong point; the authoritative `rename to` body line is now used.
|
|
20
|
+
- **`.gitmodules` parser** — `parseGitSubmodulePaths` matched any `path =` line regardless of INI section and ignored comments; it now tracks `[submodule]` sections and strips `;`/`#` comments.
|
|
21
|
+
- **`git_inventory` / `git_parity` v3 JSON** — `workspace_root` field corrected to `workspaceRoot` (camelCase), completing the v3 contract rename already applied to `git_log`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **`git_tag` error codes renamed** — `tag_empty` → `empty_tag_name`, `tag_unsafe` → `unsafe_tag_token`, `ref_unsafe` → `unsafe_ref_token`; consistent with the server-wide naming convention.
|
|
26
|
+
- **`git_diff` multi-root parameters removed** — `absoluteGitRoots` and `allWorkspaceRoots` are no longer accepted by `git_diff`; it is now a pure single-repo tool (identical posture to `git_show`). Use `workspaceRoot` or `rootIndex` to select the target repo. `git_diff_summary` keeps single-entry `absoluteGitRoots` and drops only `allWorkspaceRoots`.
|
|
27
|
+
- **`MCP_JSON_FORMAT_VERSION` constant** — `"3"` is now an exported constant in `src/server.ts` and surfaced in the FastMCP `instructions` field, making the format version discoverable from the MCP `initialize` response.
|
|
28
|
+
- **CI** — the 20 published per-tool `schemas/*.json` artifacts are now drift-checked on every PR via a new `schema:individual:check` step; previously only `tool-parameters.schema.json` was gated.
|
|
6
29
|
|
|
7
30
|
## [2.5.0] — 2026-05-15
|
|
8
31
|
|
|
@@ -162,8 +162,14 @@ async function stageFile(gitTop, filePath, lines) {
|
|
|
162
162
|
if (!partialPatch) {
|
|
163
163
|
return { ok: false, error: "No hunks found in line range" };
|
|
164
164
|
}
|
|
165
|
-
//
|
|
166
|
-
const
|
|
165
|
+
// Resolve the real git dir (works for linked worktrees where .git is a file, not a dir)
|
|
166
|
+
const gitDirResult = await spawnGitAsync(gitTop, ["rev-parse", "--absolute-git-dir"]);
|
|
167
|
+
if (!gitDirResult.ok) {
|
|
168
|
+
return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
|
|
169
|
+
}
|
|
170
|
+
const gitDir = gitDirResult.stdout.trim();
|
|
171
|
+
// Write partial patch to temp file in the git dir and apply it to the index
|
|
172
|
+
const tempPatchFile = `${gitDir}/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
|
|
167
173
|
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
168
174
|
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
169
175
|
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
@@ -239,6 +245,18 @@ export function registerBatchCommitTool(server) {
|
|
|
239
245
|
const gitTop = pre.gitTop;
|
|
240
246
|
const results = [];
|
|
241
247
|
const stagedFilesForCleanup = new Set();
|
|
248
|
+
// Snapshot already-staged paths BEFORE the preview loop so dry-run cleanup
|
|
249
|
+
// doesn't unstage files the caller had staged before invoking.
|
|
250
|
+
let preStagedPaths = new Set();
|
|
251
|
+
if (args.dryRun) {
|
|
252
|
+
const snapResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
|
|
253
|
+
if (snapResult.ok) {
|
|
254
|
+
preStagedPaths = new Set(snapResult.stdout
|
|
255
|
+
.split("\n")
|
|
256
|
+
.map((s) => s.trim())
|
|
257
|
+
.filter(Boolean));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
242
260
|
for (let i = 0; i < args.commits.length; i++) {
|
|
243
261
|
const entry = args.commits[i];
|
|
244
262
|
if (!entry)
|
|
@@ -298,10 +316,13 @@ export function registerBatchCommitTool(server) {
|
|
|
298
316
|
});
|
|
299
317
|
break;
|
|
300
318
|
}
|
|
301
|
-
// Track staged files for cleanup in dry-run
|
|
319
|
+
// Track staged files for cleanup in dry-run, excluding paths that were
|
|
320
|
+
// already staged before this call (so pre-existing staged state survives).
|
|
302
321
|
if (args.dryRun) {
|
|
303
322
|
for (const path of filePaths) {
|
|
304
|
-
|
|
323
|
+
if (!preStagedPaths.has(path)) {
|
|
324
|
+
stagedFilesForCleanup.add(path);
|
|
325
|
+
}
|
|
305
326
|
}
|
|
306
327
|
}
|
|
307
328
|
// --- Dry-run mode: collect preview and unstage ---
|
|
@@ -102,6 +102,12 @@ function extractFileInfo(header, body) {
|
|
|
102
102
|
status = "renamed";
|
|
103
103
|
const fromMatch = /^rename from (.+)$/m.exec(body);
|
|
104
104
|
oldPath = fromMatch?.[1];
|
|
105
|
+
// Prefer the authoritative "rename to <path>" body line over the greedy
|
|
106
|
+
// header regex split, which mis-parses paths containing the literal " b/".
|
|
107
|
+
const toMatch = /^rename to (.+)$/m.exec(body);
|
|
108
|
+
if (toMatch?.[1]) {
|
|
109
|
+
bPath = toMatch[1];
|
|
110
|
+
}
|
|
105
111
|
}
|
|
106
112
|
const path = status === "deleted" ? aPath : bPath;
|
|
107
113
|
return { path, oldPath, status };
|
|
@@ -178,7 +184,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
178
184
|
annotations: {
|
|
179
185
|
readOnlyHint: true,
|
|
180
186
|
},
|
|
181
|
-
parameters: WorkspacePickSchema.extend({
|
|
187
|
+
parameters: WorkspacePickSchema.omit({ allWorkspaceRoots: true }).extend({
|
|
182
188
|
range: z
|
|
183
189
|
.string()
|
|
184
190
|
.optional()
|
|
@@ -61,7 +61,10 @@ export function registerGitDiffTool(server) {
|
|
|
61
61
|
annotations: {
|
|
62
62
|
readOnlyHint: true,
|
|
63
63
|
},
|
|
64
|
-
parameters: WorkspacePickSchema.
|
|
64
|
+
parameters: WorkspacePickSchema.omit({
|
|
65
|
+
absoluteGitRoots: true,
|
|
66
|
+
allWorkspaceRoots: true,
|
|
67
|
+
}).extend({
|
|
65
68
|
base: z
|
|
66
69
|
.string()
|
|
67
70
|
.optional()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { spawnGitAsync } from "./git.js";
|
|
2
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
3
|
import { jsonRespond } from "./json.js";
|
|
4
4
|
import { requireSingleRepo } from "./roots.js";
|
|
5
5
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -78,6 +78,13 @@ export function registerGitFetchTool(server) {
|
|
|
78
78
|
const branch = args.branch?.trim();
|
|
79
79
|
const prune = args.prune === true;
|
|
80
80
|
const tags = args.tags === true;
|
|
81
|
+
// Validate remote and branch to prevent argument injection.
|
|
82
|
+
if (!isSafeGitUpstreamToken(remote)) {
|
|
83
|
+
return jsonRespond({ error: "unsafe_remote_token", remote });
|
|
84
|
+
}
|
|
85
|
+
if (branch && !isSafeGitUpstreamToken(branch)) {
|
|
86
|
+
return jsonRespond({ error: "unsafe_ref_token", branch });
|
|
87
|
+
}
|
|
81
88
|
// Build git fetch command
|
|
82
89
|
const fetchArgs = ["fetch"];
|
|
83
90
|
if (prune) {
|
|
@@ -57,7 +57,7 @@ export function registerGitInventoryTool(server) {
|
|
|
57
57
|
const err = { error: "not_a_git_repository", path: workspaceRoot };
|
|
58
58
|
if (args.format === "json") {
|
|
59
59
|
allJson.push({
|
|
60
|
-
|
|
60
|
+
workspaceRoot: workspaceRoot,
|
|
61
61
|
...(upstream.mode === "fixed" ? { upstream } : {}),
|
|
62
62
|
entries: [
|
|
63
63
|
makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
|
|
@@ -117,7 +117,7 @@ export function registerGitInventoryTool(server) {
|
|
|
117
117
|
}
|
|
118
118
|
if (args.format === "json") {
|
|
119
119
|
allJson.push({
|
|
120
|
-
|
|
120
|
+
workspaceRoot: top,
|
|
121
121
|
...spreadDefined("presetSchemaVersion", presetSchemaVersion),
|
|
122
122
|
...spreadWhen(nestedRootsTruncated, {
|
|
123
123
|
nestedRootsTruncated: true,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
6
|
import { requireGitAndRoots } from "./roots.js";
|
|
6
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -231,6 +232,10 @@ export function registerGitLogTool(server) {
|
|
|
231
232
|
return jsonRespond({ error: "invalid_paths", path: p });
|
|
232
233
|
}
|
|
233
234
|
}
|
|
235
|
+
// Validate branch — reject leading-dash and other injection attempts.
|
|
236
|
+
if (args.branch && !isSafeGitAncestorRef(args.branch)) {
|
|
237
|
+
return jsonRespond({ error: "unsafe_ref_token", branch: args.branch });
|
|
238
|
+
}
|
|
234
239
|
const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
|
|
235
240
|
// Fan out across roots.
|
|
236
241
|
const jobs = pre.roots.map((rootInput) => ({ rootInput }));
|
|
@@ -37,7 +37,7 @@ export function registerGitParityTool(server) {
|
|
|
37
37
|
const err = jsonRespond(errPayload);
|
|
38
38
|
if (args.format === "json") {
|
|
39
39
|
results.push({
|
|
40
|
-
|
|
40
|
+
workspaceRoot: workspaceRoot,
|
|
41
41
|
status: "MISMATCH",
|
|
42
42
|
pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: err }],
|
|
43
43
|
});
|
|
@@ -114,7 +114,7 @@ export function registerGitParityTool(server) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
results.push({
|
|
117
|
-
|
|
117
|
+
workspaceRoot: top,
|
|
118
118
|
...spreadDefined("presetSchemaVersion", parityPresetSchemaVersion),
|
|
119
119
|
status: allOk ? "OK" : "MISMATCH",
|
|
120
120
|
pairs: pairResults,
|
package/dist/server/git-refs.js
CHANGED
|
@@ -25,17 +25,24 @@ const PROTECTED_EXACT = new Set([
|
|
|
25
25
|
"trunk",
|
|
26
26
|
"prod",
|
|
27
27
|
"production",
|
|
28
|
-
"
|
|
28
|
+
"head",
|
|
29
29
|
]);
|
|
30
30
|
const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
|
|
31
31
|
/** True when a branch name is on the protected list and must not be auto-deleted. */
|
|
32
32
|
export function isProtectedBranch(name) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Normalize: trim whitespace, strip leading refs/heads/ prefix, then lowercase.
|
|
34
|
+
const trimmed = name.trim();
|
|
35
|
+
if (trimmed === "")
|
|
35
36
|
return true;
|
|
36
|
-
|
|
37
|
+
const stripped = trimmed.startsWith("refs/heads/")
|
|
38
|
+
? trimmed.slice("refs/heads/".length)
|
|
39
|
+
: trimmed;
|
|
40
|
+
const normalized = stripped.toLowerCase();
|
|
41
|
+
if (normalized === "")
|
|
37
42
|
return true;
|
|
38
|
-
|
|
43
|
+
if (PROTECTED_EXACT.has(normalized))
|
|
44
|
+
return true;
|
|
45
|
+
return PROTECTED_PATTERN.test(normalized);
|
|
39
46
|
}
|
|
40
47
|
// ---------------------------------------------------------------------------
|
|
41
48
|
// Ref/branch name validation (argv-safe subset of git's ref-format rules)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
2
3
|
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
3
5
|
import { jsonRespond } from "./json.js";
|
|
4
6
|
import { requireSingleRepo } from "./roots.js";
|
|
5
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -119,7 +121,10 @@ export function registerGitShowTool(server) {
|
|
|
119
121
|
format: true,
|
|
120
122
|
})
|
|
121
123
|
.extend({
|
|
122
|
-
ref: z
|
|
124
|
+
ref: z
|
|
125
|
+
.string()
|
|
126
|
+
.min(1)
|
|
127
|
+
.describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
|
|
123
128
|
path: z
|
|
124
129
|
.string()
|
|
125
130
|
.optional()
|
|
@@ -130,6 +135,15 @@ export function registerGitShowTool(server) {
|
|
|
130
135
|
if (!pre.ok)
|
|
131
136
|
return jsonRespond(pre.error);
|
|
132
137
|
const top = pre.gitTop;
|
|
138
|
+
if (!isSafeGitAncestorRef(args.ref)) {
|
|
139
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.ref });
|
|
140
|
+
}
|
|
141
|
+
if (args.path !== undefined) {
|
|
142
|
+
const resolved = resolvePathForRepo(args.path, top);
|
|
143
|
+
if (!assertRelativePathUnderTop(args.path, resolved, top)) {
|
|
144
|
+
return jsonRespond({ error: "path_escapes_repo", path: args.path });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
133
147
|
const result = await runGitShow({
|
|
134
148
|
top,
|
|
135
149
|
ref: args.ref,
|
|
@@ -39,21 +39,22 @@ export function registerGitStashListTool(server) {
|
|
|
39
39
|
.split("\n")
|
|
40
40
|
.map((l) => l.trim())
|
|
41
41
|
.filter((l) => l.length > 0);
|
|
42
|
-
for (
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
message,
|
|
53
|
-
sha,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const parts = line.split("|");
|
|
44
|
+
// parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
|
|
45
|
+
const sha = parts[parts.length - 1];
|
|
46
|
+
const message = parts.slice(1, -1).join("|");
|
|
47
|
+
// Parse the real stash index from the canonical stash@{N} ref in parts[0].
|
|
48
|
+
const indexMatch = parts[0] ? /stash@\{(\d+)\}/.exec(parts[0]) : null;
|
|
49
|
+
if (!indexMatch || parts.length < 3 || !message || !sha) {
|
|
50
|
+
// Malformed line — skip without affecting index tracking.
|
|
51
|
+
continue;
|
|
56
52
|
}
|
|
53
|
+
stashes.push({
|
|
54
|
+
index: Number(indexMatch[1]),
|
|
55
|
+
message,
|
|
56
|
+
sha,
|
|
57
|
+
});
|
|
57
58
|
}
|
|
58
59
|
if (args.format === "json") {
|
|
59
60
|
return jsonRespond({ stashes });
|
|
@@ -72,11 +72,11 @@ export function registerGitTagTool(server) {
|
|
|
72
72
|
const gitTop = pre.gitTop;
|
|
73
73
|
const tag = args.tag.trim();
|
|
74
74
|
if (!tag) {
|
|
75
|
-
return jsonRespond({ error: "
|
|
75
|
+
return jsonRespond({ error: "empty_tag_name" });
|
|
76
76
|
}
|
|
77
77
|
// Validate tag name: no shell metacharacters
|
|
78
78
|
if (!isSafeGitUpstreamToken(tag)) {
|
|
79
|
-
return jsonRespond({ error: "
|
|
79
|
+
return jsonRespond({ error: "unsafe_tag_token", tag });
|
|
80
80
|
}
|
|
81
81
|
// Handle deletion
|
|
82
82
|
if (args.delete === true) {
|
|
@@ -99,7 +99,7 @@ export function registerGitTagTool(server) {
|
|
|
99
99
|
// Determine the ref to tag (default HEAD)
|
|
100
100
|
const ref = (args.ref ?? "HEAD").trim();
|
|
101
101
|
if (!isSafeGitUpstreamToken(ref)) {
|
|
102
|
-
return jsonRespond({ error: "
|
|
102
|
+
return jsonRespond({ error: "unsafe_ref_token", ref });
|
|
103
103
|
}
|
|
104
104
|
// Get the SHA of the ref to tag
|
|
105
105
|
const sha = await getRefSha(gitTop, ref);
|
package/dist/server/git.js
CHANGED
|
@@ -94,10 +94,23 @@ export function parseGitSubmodulePaths(gitRoot) {
|
|
|
94
94
|
return [];
|
|
95
95
|
}
|
|
96
96
|
const paths = [];
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
let inSubmoduleSection = false;
|
|
98
|
+
for (const rawLine of text.split("\n")) {
|
|
99
|
+
// Strip inline and whole-line comments (; and #)
|
|
100
|
+
const commentIdx = rawLine.search(/\s*[;#]/);
|
|
101
|
+
const line = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine;
|
|
102
|
+
// Track INI section header
|
|
103
|
+
const sectionMatch = /^\s*\[(.+)\]\s*$/.exec(line);
|
|
104
|
+
if (sectionMatch) {
|
|
105
|
+
inSubmoduleSection = /^submodule\s+"/.test(sectionMatch[1] ?? "");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Only collect path = lines inside a [submodule "..."] section
|
|
109
|
+
if (!inSubmoduleSection)
|
|
110
|
+
continue;
|
|
111
|
+
const m = /^\s*path\s*=\s*(.+?)\s*$/.exec(line);
|
|
99
112
|
if (m?.[1])
|
|
100
|
-
paths.push(m[1]
|
|
113
|
+
paths.push(m[1]);
|
|
101
114
|
}
|
|
102
115
|
return paths;
|
|
103
116
|
}
|
package/dist/server.js
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
import { FastMCP } from "fastmcp";
|
|
3
3
|
import { readMcpServerVersion } from "./server/json.js";
|
|
4
4
|
import { registerRethunkGitTools } from "./server/tools.js";
|
|
5
|
+
/**
|
|
6
|
+
* JSON payload contract version. Bump on incompatible JSON output changes
|
|
7
|
+
* (renamed/nested/omitted fields). Surfaced via the FastMCP `instructions`
|
|
8
|
+
* field below, so it is discoverable in the MCP `initialize` response.
|
|
9
|
+
*/
|
|
10
|
+
export const MCP_JSON_FORMAT_VERSION = "3";
|
|
5
11
|
const server = new FastMCP({
|
|
6
12
|
name: "rethunk-git",
|
|
7
13
|
version: readMcpServerVersion(),
|
|
14
|
+
instructions: `rethunk-git MCP server. JSON payload contract: format version ${MCP_JSON_FORMAT_VERSION} (minified, no envelope; optional fields omitted when empty/null/false).`,
|
|
8
15
|
roots: { enabled: true },
|
|
9
16
|
});
|
|
10
17
|
registerRethunkGitTools(server);
|
package/docs/mcp-tools.md
CHANGED
|
@@ -17,7 +17,7 @@ MCP clients expose tools as `{serverName}_{toolName}`. With the server registere
|
|
|
17
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
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` (`markdown`/`json`/`oneline`). **Read-only.** |
|
|
19
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.** |
|
|
20
|
-
| `git_diff` | `rethunk-git_git_diff` | Raw diff text for a single repo. Supports unstaged, staged, or `base..head` ranges, optionally scoped to one path. Args: `base?`, `head?`, `path?`, `staged
|
|
20
|
+
| `git_diff` | `rethunk-git_git_diff` | Raw diff text for a single repo. Supports unstaged, staged, or `base..head` ranges, optionally scoped to one path. Args: `workspaceRoot`, `rootIndex`, `format`, `base?`, `head?`, `path?`, `staged?`. No `absoluteGitRoots` or `allWorkspaceRoots`. **Read-only.** |
|
|
21
21
|
| `git_show` | `rethunk-git_git_show` | Inspect one commit or ref. Returns commit message plus diff, or file content at `path` for that ref. Args: `ref`, `path?`, plus single-repo workspace pick + `format`. **Read-only.** |
|
|
22
22
|
| `git_worktree_list` | `rethunk-git_git_worktree_list` | List all worktrees (`git worktree list --porcelain`). Workspace pick + `format`. **Read-only.** |
|
|
23
23
|
| `git_stash_list` | `rethunk-git_git_stash_list` | List `git stash` entries for one repo. Args: single-repo workspace pick + `format`. **Read-only.** |
|
|
@@ -259,7 +259,7 @@ Path-escape and `rev-parse` failures are reported inline in `pairs[*].error`, no
|
|
|
259
259
|
|
|
260
260
|
## JSON responses
|
|
261
261
|
|
|
262
|
-
Tool JSON bodies are minified and contain only the payload — no `rethunkGitMcp` envelope. Current `MCP_JSON_FORMAT_VERSION` is **`"3"
|
|
262
|
+
Tool JSON bodies are minified and contain only the payload — no `rethunkGitMcp` envelope. Current `MCP_JSON_FORMAT_VERSION` is **`"3"`** (exported constant in `src/server.ts`); the version string is surfaced in the FastMCP `instructions` field and is therefore discoverable via the MCP `initialize` response. Payload keys (`groups`, `inventories`, `parity`, `roots`) are stable within a given format version. Preset-related responses may include **`presetSchemaVersion`**.
|
|
263
263
|
|
|
264
264
|
The package also ships **`tool-parameters.schema.json`**, generated from the registered Zod parameter schemas via `bun run schema:tools`, plus the published **`schemas/`** directory (`schemas/index.json` + one JSON Schema per tool) via `bun run schema:individual`. Connected MCP clients should still prefer live schema discovery from `initialize` / tool listing; the shipped artifacts are for offline inspection, drift checks, and code generation.
|
|
265
265
|
|
|
@@ -282,7 +282,7 @@ To keep responses compact, **optional fields are omitted when they would be empt
|
|
|
282
282
|
|
|
283
283
|
- 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).
|
|
284
284
|
|
|
285
|
-
**When to bump `MCP_JSON_FORMAT_VERSION` or change payload shape:** [AGENTS.md](../AGENTS.md) — *Changing contracts*.
|
|
285
|
+
**When to bump `MCP_JSON_FORMAT_VERSION` or change payload shape:** [AGENTS.md](../AGENTS.md) — *Changing contracts*. The constant lives in `src/server.ts` and is surfaced via the server `instructions` field (discoverable from the MCP `initialize` response).
|
|
286
286
|
|
|
287
287
|
### `git_log` — parameters
|
|
288
288
|
|
|
@@ -335,6 +335,7 @@ v2 field-omission rules still apply: `filesChanged`, `insertions`, `deletions` o
|
|
|
335
335
|
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
336
336
|
| `invalid_since` | The `since` string contains shell metacharacters and was rejected. |
|
|
337
337
|
| `invalid_paths` | One of the `paths` entries contains shell metacharacters and was rejected. |
|
|
338
|
+
| `unsafe_ref_token` | `branch` contains characters outside the argv-safe subset. |
|
|
338
339
|
| `git_log_failed` | `git log` exited non-zero (e.g. unknown branch ref). |
|
|
339
340
|
| `root_index_out_of_range` | `rootIndex` exceeds the number of MCP file roots. |
|
|
340
341
|
| `absolute_git_roots_exclusive` | `absoluteGitRoots` was combined with `workspaceRoot`, `rootIndex`, or `allWorkspaceRoots: true`. |
|
|
@@ -416,7 +417,11 @@ The response contains one **`parity[]`** entry per resolved git toplevel. `absol
|
|
|
416
417
|
| `head` | string | `HEAD` | Head ref for a revision diff. Used only when `base` is provided. |
|
|
417
418
|
| `path` | string | — | Optional single file path to scope the diff. |
|
|
418
419
|
| `staged` | boolean | `false` | When `true`, runs `git diff --staged`. Ignored when `base` is provided. |
|
|
419
|
-
| `workspaceRoot
|
|
420
|
+
| `workspaceRoot` | string | — | Explicit root; highest priority. |
|
|
421
|
+
| `rootIndex` | int | — | Pick one of several MCP roots (0-based). |
|
|
422
|
+
| `format` | `"markdown"` \| `"json"` | `"markdown"` | Output format. |
|
|
423
|
+
|
|
424
|
+
`git_diff` is a single-repo tool. It does not accept `absoluteGitRoots` or `allWorkspaceRoots`; use `workspaceRoot` or `rootIndex` to select the target repo.
|
|
420
425
|
|
|
421
426
|
### `git_diff` — JSON shape (`format: "json"`)
|
|
422
427
|
|
|
@@ -462,6 +467,8 @@ The response contains one **`parity[]`** entry per resolved git toplevel. `absol
|
|
|
462
467
|
|
|
463
468
|
| Code | Meaning |
|
|
464
469
|
|------|---------|
|
|
470
|
+
| `unsafe_ref_token` | `ref` contains characters outside the argv-safe subset. |
|
|
471
|
+
| `path_escapes_repo` | `path` resolves outside the git toplevel. |
|
|
465
472
|
| `git_show_failed` | `git show` exited non-zero (e.g. unknown ref). |
|
|
466
473
|
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
467
474
|
|
|
@@ -520,6 +527,8 @@ Fetch failures are reported as `ok: false` with the captured git output in `outp
|
|
|
520
527
|
|
|
521
528
|
| Code | Meaning |
|
|
522
529
|
|------|---------|
|
|
530
|
+
| `unsafe_remote_token` | `remote` contains characters outside the argv-safe subset. |
|
|
531
|
+
| `unsafe_ref_token` | `branch` contains characters outside the argv-safe subset. |
|
|
523
532
|
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
524
533
|
|
|
525
534
|
---
|
|
@@ -792,9 +801,9 @@ For deletions, `type` is `"deleted"` and `sha` is an empty string.
|
|
|
792
801
|
|
|
793
802
|
| Code | Meaning |
|
|
794
803
|
|------|---------|
|
|
795
|
-
| `
|
|
796
|
-
| `
|
|
797
|
-
| `
|
|
804
|
+
| `empty_tag_name` | `tag` trimmed to an empty string. |
|
|
805
|
+
| `unsafe_tag_token` | `tag` contains disallowed characters. |
|
|
806
|
+
| `unsafe_ref_token` | `ref` contains disallowed characters. |
|
|
798
807
|
| `ref_not_found` | `ref` did not resolve to a commit. |
|
|
799
808
|
| `tag_create_failed` | `git tag` failed while creating the tag. |
|
|
800
809
|
| `tag_delete_failed` | `git tag -d` failed. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rethunk/mcp-multi-root-git",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
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,
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"schema:tools": "bun scripts/generate-tool-parameters-schema.ts",
|
|
38
38
|
"schema:tools:check": "bun scripts/generate-tool-parameters-schema.ts --check",
|
|
39
39
|
"schema:individual": "bun scripts/generate-individual-schemas.ts",
|
|
40
|
+
"schema:individual:check": "bun scripts/generate-individual-schemas.ts --check",
|
|
40
41
|
"publish:preflight": "bun scripts/publish-preflight.ts",
|
|
41
42
|
"test": "bun test src/",
|
|
42
43
|
"test:coverage": "bun test src/ --coverage",
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
},
|
|
70
71
|
"devDependencies": {
|
|
71
72
|
"@biomejs/biome": "^2.4.15",
|
|
72
|
-
"@types/node": "^25.
|
|
73
|
+
"@types/node": "^25.9.1",
|
|
73
74
|
"rimraf": "^6.1.3",
|
|
74
75
|
"typescript": "^6.0.3"
|
|
75
76
|
},
|
package/schemas/git_diff.json
CHANGED
|
@@ -14,19 +14,6 @@
|
|
|
14
14
|
"minimum": 0,
|
|
15
15
|
"maximum": 9007199254740991
|
|
16
16
|
},
|
|
17
|
-
"allWorkspaceRoots": {
|
|
18
|
-
"default": false,
|
|
19
|
-
"description": "Fan out across all MCP file roots.",
|
|
20
|
-
"type": "boolean"
|
|
21
|
-
},
|
|
22
|
-
"absoluteGitRoots": {
|
|
23
|
-
"description": "Absolute paths to git repo roots. Use for many sibling clones under a non-git parent directory.",
|
|
24
|
-
"maxItems": 256,
|
|
25
|
-
"type": "array",
|
|
26
|
-
"items": {
|
|
27
|
-
"type": "string"
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
17
|
"format": {
|
|
31
18
|
"default": "markdown",
|
|
32
19
|
"type": "string",
|
|
@@ -54,7 +41,6 @@
|
|
|
54
41
|
}
|
|
55
42
|
},
|
|
56
43
|
"required": [
|
|
57
|
-
"allWorkspaceRoots",
|
|
58
44
|
"format",
|
|
59
45
|
"staged"
|
|
60
46
|
],
|
|
@@ -14,11 +14,6 @@
|
|
|
14
14
|
"minimum": 0,
|
|
15
15
|
"maximum": 9007199254740991
|
|
16
16
|
},
|
|
17
|
-
"allWorkspaceRoots": {
|
|
18
|
-
"default": false,
|
|
19
|
-
"description": "Fan out across all MCP file roots.",
|
|
20
|
-
"type": "boolean"
|
|
21
|
-
},
|
|
22
17
|
"absoluteGitRoots": {
|
|
23
18
|
"description": "Absolute paths to git repo roots. Use for many sibling clones under a non-git parent directory.",
|
|
24
19
|
"maxItems": 256,
|
|
@@ -66,7 +61,6 @@
|
|
|
66
61
|
}
|
|
67
62
|
},
|
|
68
63
|
"required": [
|
|
69
|
-
"allWorkspaceRoots",
|
|
70
64
|
"format",
|
|
71
65
|
"maxLinesPerFile",
|
|
72
66
|
"maxFiles"
|
package/schemas/git_show.json
CHANGED
|
@@ -326,11 +326,6 @@
|
|
|
326
326
|
"minimum": 0,
|
|
327
327
|
"maximum": 9007199254740991
|
|
328
328
|
},
|
|
329
|
-
"allWorkspaceRoots": {
|
|
330
|
-
"default": false,
|
|
331
|
-
"description": "Fan out across all MCP file roots.",
|
|
332
|
-
"type": "boolean"
|
|
333
|
-
},
|
|
334
329
|
"absoluteGitRoots": {
|
|
335
330
|
"description": "Absolute paths to git repo roots. Use for many sibling clones under a non-git parent directory.",
|
|
336
331
|
"maxItems": 256,
|
|
@@ -378,7 +373,6 @@
|
|
|
378
373
|
}
|
|
379
374
|
},
|
|
380
375
|
"required": [
|
|
381
|
-
"allWorkspaceRoots",
|
|
382
376
|
"format",
|
|
383
377
|
"maxLinesPerFile",
|
|
384
378
|
"maxFiles"
|
|
@@ -399,19 +393,6 @@
|
|
|
399
393
|
"minimum": 0,
|
|
400
394
|
"maximum": 9007199254740991
|
|
401
395
|
},
|
|
402
|
-
"allWorkspaceRoots": {
|
|
403
|
-
"default": false,
|
|
404
|
-
"description": "Fan out across all MCP file roots.",
|
|
405
|
-
"type": "boolean"
|
|
406
|
-
},
|
|
407
|
-
"absoluteGitRoots": {
|
|
408
|
-
"description": "Absolute paths to git repo roots. Use for many sibling clones under a non-git parent directory.",
|
|
409
|
-
"maxItems": 256,
|
|
410
|
-
"type": "array",
|
|
411
|
-
"items": {
|
|
412
|
-
"type": "string"
|
|
413
|
-
}
|
|
414
|
-
},
|
|
415
396
|
"format": {
|
|
416
397
|
"default": "markdown",
|
|
417
398
|
"type": "string",
|
|
@@ -439,7 +420,6 @@
|
|
|
439
420
|
}
|
|
440
421
|
},
|
|
441
422
|
"required": [
|
|
442
|
-
"allWorkspaceRoots",
|
|
443
423
|
"format",
|
|
444
424
|
"staged"
|
|
445
425
|
],
|
|
@@ -469,6 +449,7 @@
|
|
|
469
449
|
},
|
|
470
450
|
"ref": {
|
|
471
451
|
"type": "string",
|
|
452
|
+
"minLength": 1,
|
|
472
453
|
"description": "Commit reference (SHA, branch, tag, or any git rev-spec)."
|
|
473
454
|
},
|
|
474
455
|
"path": {
|