@rethunk/mcp-multi-root-git 3.1.0 → 4.0.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.
Files changed (66) hide show
  1. package/AGENTS.md +21 -15
  2. package/CHANGELOG.md +114 -39
  3. package/HUMANS.md +20 -39
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +187 -46
  7. package/dist/server/error-codes.js +25 -7
  8. package/dist/server/git-blame-tool.js +6 -3
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +220 -11
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +36 -25
  13. package/dist/server/git-diff-tool.js +55 -27
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +26 -6
  16. package/dist/server/git-log-tool.js +35 -4
  17. package/dist/server/git-merge-tool.js +70 -12
  18. package/dist/server/git-parity-tool.js +13 -4
  19. package/dist/server/git-push-tool.js +3 -1
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-revert-tool.js +160 -0
  22. package/dist/server/git-show-tool.js +7 -3
  23. package/dist/server/git-stash-tool.js +110 -67
  24. package/dist/server/git-tag-tool.js +7 -6
  25. package/dist/server/git-worktree-tool.js +65 -53
  26. package/dist/server/git.js +116 -24
  27. package/dist/server/inventory.js +90 -32
  28. package/dist/server/presets-resource.js +37 -20
  29. package/dist/server/presets.js +87 -15
  30. package/dist/server/roots.js +13 -1
  31. package/dist/server/schemas.js +9 -2
  32. package/dist/server/test-harness.js +11 -4
  33. package/dist/server/tool-parameter-schemas.js +44 -55
  34. package/dist/server/tools.js +36 -17
  35. package/dist/server.js +1 -1
  36. package/docs/install.md +52 -5
  37. package/docs/mcp-tools.md +385 -178
  38. package/package.json +6 -6
  39. package/schemas/batch_commit.json +2 -2
  40. package/schemas/git_blame.json +1 -1
  41. package/schemas/git_branch.json +54 -0
  42. package/schemas/git_cherry_pick.json +12 -2
  43. package/schemas/git_cherry_pick_continue.json +34 -0
  44. package/schemas/{git_reflog.json → git_conflicts.json} +12 -12
  45. package/schemas/git_diff.json +11 -3
  46. package/schemas/git_grep.json +85 -0
  47. package/schemas/git_inventory.json +19 -5
  48. package/schemas/git_log.json +7 -6
  49. package/schemas/git_merge.json +2 -2
  50. package/schemas/git_parity.json +0 -5
  51. package/schemas/git_revert.json +47 -0
  52. package/schemas/git_show.json +1 -1
  53. package/schemas/git_stash_push.json +47 -0
  54. package/schemas/git_status.json +0 -5
  55. package/schemas/git_worktree_add.json +1 -1
  56. package/schemas/git_worktree_remove.json +1 -1
  57. package/schemas/index.json +28 -23
  58. package/schemas/list_presets.json +0 -5
  59. package/tool-parameters.schema.json +326 -167
  60. package/dist/server/git-branch-list-tool.js +0 -132
  61. package/dist/server/git-fetch-tool.js +0 -257
  62. package/dist/server/git-reflog-tool.js +0 -120
  63. package/schemas/git_branch_list.json +0 -30
  64. package/schemas/git_fetch.json +0 -46
  65. package/schemas/git_stash_list.json +0 -24
  66. package/schemas/git_worktree_list.json +0 -24
@@ -7,13 +7,11 @@ import { ERROR_CODES } from "./error-codes.js";
7
7
  * Parallel git subprocesses for inventory rows and git_status submodule rows.
8
8
  * Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
9
9
  */
10
- function resolveGitSubprocessParallelism() {
11
- const env = process.env.GIT_SUBPROCESS_PARALLELISM;
12
- if (env) {
13
- const n = Number.parseInt(env, 10);
10
+ export function resolveGitSubprocessParallelism(envValue = process.env.GIT_SUBPROCESS_PARALLELISM, cpuCount = cpus().length) {
11
+ if (envValue) {
12
+ const n = Number.parseInt(envValue, 10);
14
13
  if (!Number.isNaN(n) && n >= 1) {
15
- const cpuCount = cpus().length;
16
- const maxParallel = cpuCount * 2;
14
+ const maxParallel = Math.max(1, cpuCount * 2);
17
15
  return Math.min(n, maxParallel);
18
16
  }
19
17
  }
@@ -26,10 +24,9 @@ export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
26
24
  * A value of 0 (or negative/NaN) disables the timeout — use for operations
27
25
  * like large clones where unbounded wait is intentional.
28
26
  */
29
- function resolveGitSubprocessTimeoutMs() {
30
- const env = process.env.GIT_SUBPROCESS_TIMEOUT_MS;
31
- if (env) {
32
- const n = Number.parseInt(env, 10);
27
+ export function resolveGitSubprocessTimeoutMs(envValue = process.env.GIT_SUBPROCESS_TIMEOUT_MS) {
28
+ if (envValue) {
29
+ const n = Number.parseInt(envValue, 10);
33
30
  if (!Number.isNaN(n) && n > 0)
34
31
  return n;
35
32
  // 0 or negative → disabled
@@ -39,10 +36,31 @@ function resolveGitSubprocessTimeoutMs() {
39
36
  return 120_000;
40
37
  }
41
38
  export const GIT_SUBPROCESS_TIMEOUT_MS = resolveGitSubprocessTimeoutMs();
39
+ /**
40
+ * Max combined stdout+stderr bytes retained from spawnGitAsync.
41
+ * Env: GIT_SUBPROCESS_MAX_BUFFER_BYTES (default 16 MiB). Exceeding kills the child.
42
+ */
43
+ export function resolveGitSubprocessMaxBufferBytes(envValue = process.env.GIT_SUBPROCESS_MAX_BUFFER_BYTES) {
44
+ if (envValue) {
45
+ const n = Number.parseInt(envValue, 10);
46
+ if (!Number.isNaN(n) && n >= 1024)
47
+ return n;
48
+ }
49
+ return 16 * 1024 * 1024;
50
+ }
51
+ export const GIT_SUBPROCESS_MAX_BUFFER_BYTES = resolveGitSubprocessMaxBufferBytes();
52
+ /** Delay after SIGTERM before escalating to SIGKILL (spawnGitAsync timeout/abort/overflow). */
53
+ export const GIT_SUBPROCESS_SIGKILL_ESCALATION_MS = 2_000;
54
+ /** Timeout for sync spawnSync helpers (gateGit, rev-parse). */
55
+ export const GIT_SYNC_TIMEOUT_MS = 30_000;
42
56
  let gitPathState = "unknown";
43
57
  const GIT_NOT_FOUND_BODY = {
44
58
  error: ERROR_CODES.GIT_NOT_FOUND,
45
59
  };
60
+ /** Test-only: reset the cached git-on-PATH probe. */
61
+ export function resetGitPathStateForTests() {
62
+ gitPathState = "unknown";
63
+ }
46
64
  export function gateGit() {
47
65
  if (gitPathState === "ok") {
48
66
  return { ok: true };
@@ -53,9 +71,16 @@ export function gateGit() {
53
71
  body: GIT_NOT_FOUND_BODY,
54
72
  };
55
73
  }
56
- const r = spawnSync("git", ["--version"], { encoding: "utf8" });
57
- if (r.status !== 0) {
58
- gitPathState = "missing";
74
+ const r = spawnSync("git", ["--version"], {
75
+ encoding: "utf8",
76
+ timeout: GIT_SYNC_TIMEOUT_MS,
77
+ });
78
+ if (r.error || r.status !== 0) {
79
+ // Do not cache "missing" on timeout — a wedged git may recover.
80
+ const timedOut = r.error !== undefined && r.error.code === "ETIMEDOUT";
81
+ if (!timedOut) {
82
+ gitPathState = "missing";
83
+ }
59
84
  return {
60
85
  ok: false,
61
86
  body: GIT_NOT_FOUND_BODY,
@@ -71,8 +96,9 @@ export function gitTopLevel(cwd) {
71
96
  const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
72
97
  cwd,
73
98
  encoding: "utf8",
99
+ timeout: GIT_SYNC_TIMEOUT_MS,
74
100
  });
75
- if (r.status !== 0)
101
+ if (r.error || r.status !== 0)
76
102
  return null;
77
103
  return r.stdout.trim();
78
104
  }
@@ -80,15 +106,17 @@ export function gitRevParseGitDir(cwd) {
80
106
  const r = spawnSync("git", ["rev-parse", "--git-dir"], {
81
107
  cwd,
82
108
  encoding: "utf8",
109
+ timeout: GIT_SYNC_TIMEOUT_MS,
83
110
  });
84
- return r.status === 0;
111
+ return !r.error && r.status === 0;
85
112
  }
86
113
  export function gitRevParseHead(cwd) {
87
114
  const r = spawnSync("git", ["rev-parse", "HEAD"], {
88
115
  cwd,
89
116
  encoding: "utf8",
117
+ timeout: GIT_SYNC_TIMEOUT_MS,
90
118
  });
91
- if (r.status !== 0) {
119
+ if (r.error || r.status !== 0) {
92
120
  return { ok: false, text: (r.stderr || r.stdout || "git rev-parse HEAD failed").trim() };
93
121
  }
94
122
  return { ok: true, sha: r.stdout.trim(), text: r.stdout.trim() };
@@ -163,17 +191,70 @@ export async function asyncPool(items, concurrency, fn) {
163
191
  }
164
192
  export function spawnGitAsync(cwd, args, opts) {
165
193
  return new Promise((resolveP) => {
166
- const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
194
+ const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
167
195
  let stdout = "";
168
196
  let stderr = "";
169
197
  let settled = false;
198
+ let stdoutBytes = 0;
199
+ let stderrBytes = 0;
200
+ let sigkillTimer;
201
+ const maxBuffer = opts?.maxBufferBytes ?? GIT_SUBPROCESS_MAX_BUFFER_BYTES;
202
+ const sigkillAfter = opts?.sigkillAfterMs ?? GIT_SUBPROCESS_SIGKILL_ESCALATION_MS;
203
+ if (!opts?.holdStdin) {
204
+ child.stdin?.end();
205
+ }
170
206
  child.stdout?.setEncoding("utf8");
171
207
  child.stderr?.setEncoding("utf8");
208
+ function escalateKill() {
209
+ try {
210
+ child.kill("SIGTERM");
211
+ }
212
+ catch {
213
+ /* already dead */
214
+ }
215
+ if (sigkillTimer !== undefined)
216
+ return;
217
+ sigkillTimer = setTimeout(() => {
218
+ sigkillTimer = undefined;
219
+ try {
220
+ if (!settled && child.exitCode === null && child.signalCode === null) {
221
+ child.kill("SIGKILL");
222
+ }
223
+ }
224
+ catch {
225
+ /* already dead */
226
+ }
227
+ }, sigkillAfter);
228
+ // Do not keep the process alive solely for SIGKILL escalation.
229
+ if (typeof sigkillTimer === "object" && "unref" in sigkillTimer) {
230
+ sigkillTimer.unref();
231
+ }
232
+ }
233
+ function onChunk(stream, chunk) {
234
+ const byteLen = Buffer.byteLength(chunk, "utf8");
235
+ if (stream === "stdout") {
236
+ stdoutBytes += byteLen;
237
+ stdout += chunk;
238
+ }
239
+ else {
240
+ stderrBytes += byteLen;
241
+ stderr += chunk;
242
+ }
243
+ if (stdoutBytes + stderrBytes > maxBuffer) {
244
+ escalateKill();
245
+ settle({
246
+ ok: false,
247
+ stdout,
248
+ stderr: `${stderr}\n<git output exceeded ${maxBuffer} bytes>`,
249
+ truncated: true,
250
+ });
251
+ }
252
+ }
172
253
  child.stdout?.on("data", (c) => {
173
- stdout += c;
254
+ onChunk("stdout", c);
174
255
  });
175
256
  child.stderr?.on("data", (c) => {
176
- stderr += c;
257
+ onChunk("stderr", c);
177
258
  });
178
259
  const effectiveTimeout = opts?.timeoutMs ?? GIT_SUBPROCESS_TIMEOUT_MS;
179
260
  let timer;
@@ -183,10 +264,20 @@ export function spawnGitAsync(cwd, args, opts) {
183
264
  clearTimeout(timer);
184
265
  timer = undefined;
185
266
  }
267
+ if (sigkillTimer !== undefined) {
268
+ clearTimeout(sigkillTimer);
269
+ sigkillTimer = undefined;
270
+ }
186
271
  if (abortListener !== undefined && opts?.signal) {
187
272
  opts.signal.removeEventListener("abort", abortListener);
188
273
  abortListener = undefined;
189
274
  }
275
+ try {
276
+ child.stdin?.destroy();
277
+ }
278
+ catch {
279
+ /* ignore */
280
+ }
190
281
  }
191
282
  function settle(result) {
192
283
  if (settled)
@@ -195,15 +286,18 @@ export function spawnGitAsync(cwd, args, opts) {
195
286
  cleanup();
196
287
  resolveP(result);
197
288
  }
289
+ // Register lifecycle handlers before any early kill so close/error are observed.
290
+ child.on("error", () => settle({ ok: false, stdout, stderr }));
291
+ child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
198
292
  // AbortSignal: kill immediately if already aborted, else listen
199
293
  if (opts?.signal) {
200
294
  if (opts.signal.aborted) {
201
- child.kill("SIGTERM");
295
+ escalateKill();
202
296
  settle({ ok: false, stdout, stderr, aborted: true });
203
297
  return;
204
298
  }
205
299
  abortListener = () => {
206
- child.kill("SIGTERM");
300
+ escalateKill();
207
301
  settle({ ok: false, stdout, stderr, aborted: true });
208
302
  };
209
303
  opts.signal.addEventListener("abort", abortListener, { once: true });
@@ -211,7 +305,7 @@ export function spawnGitAsync(cwd, args, opts) {
211
305
  // Timeout: set timer if effectiveTimeout > 0
212
306
  if (effectiveTimeout > 0) {
213
307
  timer = setTimeout(() => {
214
- child.kill("SIGTERM");
308
+ escalateKill();
215
309
  settle({
216
310
  ok: false,
217
311
  stdout,
@@ -220,8 +314,6 @@ export function spawnGitAsync(cwd, args, opts) {
220
314
  });
221
315
  }, effectiveTimeout);
222
316
  }
223
- child.on("error", () => settle({ ok: false, stdout, stderr }));
224
- child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
225
317
  });
226
318
  }
227
319
  function gitStatusFailText(r) {
@@ -22,6 +22,15 @@ export function buildInventorySectionMarkdown(e) {
22
22
  else if (e.upstreamNote) {
23
23
  lines.push(`upstream: ${e.upstreamNote}`);
24
24
  }
25
+ if (e.compareRefs) {
26
+ const cr = e.compareRefs;
27
+ if (cr.ahead !== undefined && cr.behind !== undefined) {
28
+ lines.push(`${cr.left}...${cr.right}: ahead ${cr.ahead}, behind ${cr.behind}`);
29
+ }
30
+ else if (cr.note) {
31
+ lines.push(`compareRefs ${cr.left}...${cr.right}: ${cr.note}`);
32
+ }
33
+ }
25
34
  const single = lines.length === 1 ? lines[0] : undefined;
26
35
  if (single !== undefined && !single.includes("\n")) {
27
36
  return ["", header, single];
@@ -53,7 +62,48 @@ function buildEntry(params) {
53
62
  out.upstreamNote = params.upstreamNote;
54
63
  return out;
55
64
  }
56
- export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch) {
65
+ /**
66
+ * Ahead = commits reachable from `right` but not `left` (`left..right`).
67
+ * Behind = commits reachable from `left` but not `right` (`right..left`).
68
+ */
69
+ async function fetchCompareAheadBehind(absPath, left, right) {
70
+ const [aheadR, behindR] = await Promise.all([
71
+ spawnGitAsync(absPath, ["rev-list", "--count", `${left}..${right}`]),
72
+ spawnGitAsync(absPath, ["rev-list", "--count", `${right}..${left}`]),
73
+ ]);
74
+ return {
75
+ ahead: aheadR.ok ? aheadR.stdout.trim() : null,
76
+ behind: behindR.ok ? behindR.stdout.trim() : null,
77
+ };
78
+ }
79
+ async function attachCompareRefs(entry, absPath, compareRefs) {
80
+ if (!compareRefs)
81
+ return entry;
82
+ const left = compareRefs.left;
83
+ const right = compareRefs.right;
84
+ const [leftOk, rightOk] = await Promise.all([
85
+ spawnGitAsync(absPath, ["rev-parse", "--verify", left]),
86
+ spawnGitAsync(absPath, ["rev-parse", "--verify", right]),
87
+ ]);
88
+ if (!leftOk.ok || !rightOk.ok) {
89
+ entry.compareRefs = {
90
+ left,
91
+ right,
92
+ note: `(ref unreadable: ${[!leftOk.ok ? left : "", !rightOk.ok ? right : ""].filter(Boolean).join(", ")})`,
93
+ };
94
+ return entry;
95
+ }
96
+ const { ahead, behind } = await fetchCompareAheadBehind(absPath, left, right);
97
+ entry.compareRefs = {
98
+ left,
99
+ right,
100
+ ...(ahead != null ? { ahead } : {}),
101
+ ...(behind != null ? { behind } : {}),
102
+ ...(ahead == null || behind == null ? { note: "(counts unreadable)" } : {}),
103
+ };
104
+ return entry;
105
+ }
106
+ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch, compareRefs) {
57
107
  const [snap, headR] = await Promise.all([
58
108
  gitStatusSnapshotAsync(absPath),
59
109
  spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
@@ -62,11 +112,12 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
62
112
  const headAbbrev = headR.ok ? headR.stdout.trim() : "";
63
113
  const detached = !headR.ok || headAbbrev === "HEAD" || headAbbrev.endsWith("/HEAD");
64
114
  const base = { label, absPath, branchStatus, detached, headAbbrev };
115
+ let entry;
65
116
  if (fixedRemote !== undefined && fixedBranch !== undefined) {
66
117
  const ref = `${fixedRemote}/${fixedBranch}`;
67
118
  const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", ref]);
68
119
  if (!verify.ok) {
69
- return buildEntry({
120
+ entry = buildEntry({
70
121
  ...base,
71
122
  upstreamMode: "fixed",
72
123
  upstreamRef: ref,
@@ -75,36 +126,43 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
75
126
  upstreamNote: `(no local ref ${ref} or unreadable)`,
76
127
  });
77
128
  }
78
- const { ahead, behind } = await fetchAheadBehind(absPath, ref);
79
- return buildEntry({
80
- ...base,
81
- upstreamMode: "fixed",
82
- upstreamRef: ref,
83
- ahead,
84
- behind,
85
- upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
86
- });
129
+ else {
130
+ const { ahead, behind } = await fetchAheadBehind(absPath, ref);
131
+ entry = buildEntry({
132
+ ...base,
133
+ upstreamMode: "fixed",
134
+ upstreamRef: ref,
135
+ ahead,
136
+ behind,
137
+ upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
138
+ });
139
+ }
87
140
  }
88
- const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
89
- if (!upVerify.ok) {
90
- return buildEntry({
91
- ...base,
92
- upstreamMode: "auto",
93
- upstreamRef: null,
94
- ahead: null,
95
- behind: null,
96
- upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
97
- });
141
+ else {
142
+ const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
143
+ if (!upVerify.ok) {
144
+ entry = buildEntry({
145
+ ...base,
146
+ upstreamMode: "auto",
147
+ upstreamRef: null,
148
+ ahead: null,
149
+ behind: null,
150
+ upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
151
+ });
152
+ }
153
+ else {
154
+ const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
155
+ const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
156
+ const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
157
+ entry = buildEntry({
158
+ ...base,
159
+ upstreamMode: "auto",
160
+ upstreamRef,
161
+ ahead,
162
+ behind,
163
+ upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
164
+ });
165
+ }
98
166
  }
99
- const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
100
- const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
101
- const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
102
- return buildEntry({
103
- ...base,
104
- upstreamMode: "auto",
105
- upstreamRef,
106
- ahead,
107
- behind,
108
- upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
109
- });
167
+ return attachCompareRefs(entry, absPath, compareRefs);
110
168
  }
@@ -10,39 +10,56 @@ export function registerPresetsResource(server) {
10
10
  name: "git-mcp-presets",
11
11
  mimeType: "application/json",
12
12
  async load() {
13
- const pre = requireGitAndRoots(server, {}, undefined);
13
+ const pre = requireGitAndRoots(server, { root: "*" }, undefined);
14
14
  if (!pre.ok) {
15
15
  return { text: jsonRespond(pre.error) };
16
16
  }
17
- const ws = pre.roots[0];
18
- if (!ws) {
17
+ if (pre.roots.length === 0) {
19
18
  return { text: jsonRespond({ error: ERROR_CODES.NO_WORKSPACE_ROOT }) };
20
19
  }
21
- const top = gitTopLevel(ws);
22
- if (!top) {
23
- return { text: jsonRespond({ error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws }) };
24
- }
25
- const loaded = loadPresetsFromGitTop(top);
26
- if (!loaded.ok) {
27
- if (loaded.reason === "missing") {
20
+ const roots = pre.roots.map((ws) => {
21
+ const top = gitTopLevel(ws);
22
+ const presetFile = top ? join(top, PRESET_FILE_PATH) : join(ws, PRESET_FILE_PATH);
23
+ if (!top) {
28
24
  return {
29
- text: jsonRespond({
30
- presetFile: join(top, PRESET_FILE_PATH),
25
+ workspaceRoot: ws,
26
+ gitTop: null,
27
+ presetFile,
28
+ fileExists: false,
29
+ presets: {},
30
+ error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws },
31
+ };
32
+ }
33
+ const loaded = loadPresetsFromGitTop(top);
34
+ if (!loaded.ok) {
35
+ if (loaded.reason === "missing") {
36
+ return {
37
+ workspaceRoot: ws,
38
+ gitTop: top,
39
+ presetFile,
31
40
  fileExists: false,
32
41
  presets: {},
33
- }),
42
+ };
43
+ }
44
+ return {
45
+ workspaceRoot: ws,
46
+ gitTop: top,
47
+ presetFile,
48
+ fileExists: true,
49
+ presets: {},
50
+ error: presetLoadErrorPayload(top, loaded),
34
51
  };
35
52
  }
36
- return { text: jsonRespond(presetLoadErrorPayload(top, loaded)) };
37
- }
38
- return {
39
- text: jsonRespond({
40
- presetFile: join(top, PRESET_FILE_PATH),
53
+ return {
54
+ workspaceRoot: ws,
55
+ gitTop: top,
56
+ presetFile,
41
57
  fileExists: true,
42
58
  ...spreadDefined("presetSchemaVersion", loaded.schemaVersion),
43
59
  presets: loaded.data,
44
- }),
45
- };
60
+ };
61
+ });
62
+ return { text: jsonRespond({ roots }) };
46
63
  },
47
64
  });
48
65
  }
@@ -5,21 +5,56 @@ import { ERROR_CODES } from "./error-codes.js";
5
5
  /**
6
6
  * Schema for `.rethunk/git-mcp-presets.json` at the workspace root.
7
7
  * Each named entry defines roots for `git_inventory` and/or pairs for `git_parity`.
8
+ * Must stay aligned with `git-mcp-presets.schema.json`.
8
9
  */
9
- const PresetEntrySchema = z.object({
10
+ const ParityPairSchema = z
11
+ .object({
12
+ left: z.string(),
13
+ right: z.string(),
14
+ label: z.string().optional(),
15
+ })
16
+ .strict();
17
+ const PresetEntrySchema = z
18
+ .object({
10
19
  nestedRoots: z.array(z.string()).optional(),
11
- parityPairs: z
12
- .array(z.object({
13
- left: z.string(),
14
- right: z.string(),
15
- label: z.string().optional(),
16
- }))
17
- .optional(),
20
+ parityPairs: z.array(ParityPairSchema).optional(),
18
21
  /** When multiple MCP file roots exist, prefer one whose path basename or suffix matches this hint. */
19
22
  workspaceRootHint: z.string().optional(),
20
- });
23
+ })
24
+ .strict();
21
25
  const PresetFileSchema = z.record(z.string(), PresetEntrySchema);
22
26
  export const PRESET_FILE_PATH = ".rethunk/git-mcp-presets.json";
27
+ const PRESET_ENTRY_FIELD_NAMES = new Set(["nestedRoots", "parityPairs", "workspaceRootHint"]);
28
+ const WRAPPED_META_KEYS = new Set(["$schema", "schemaVersion", "presets"]);
29
+ const PRESET_SCHEMA_VERSION = "1";
30
+ function looksLikePresetEntryObject(obj) {
31
+ const keys = Object.keys(obj);
32
+ if (keys.length === 0)
33
+ return true;
34
+ return keys.every((k) => PRESET_ENTRY_FIELD_NAMES.has(k));
35
+ }
36
+ function isWrappedLayout(o) {
37
+ if (!("presets" in o))
38
+ return false;
39
+ const inner = o.presets;
40
+ if (inner === null || typeof inner !== "object" || Array.isArray(inner)) {
41
+ return false;
42
+ }
43
+ const innerObj = inner;
44
+ // Non-empty inner with only PresetEntry field names → legacy preset named "presets".
45
+ if (looksLikePresetEntryObject(innerObj) && Object.keys(innerObj).length > 0) {
46
+ return false;
47
+ }
48
+ // Empty inner: wrapped when top level has only metadata + presets.
49
+ if (Object.keys(innerObj).length === 0) {
50
+ const topKeys = Object.keys(o);
51
+ return topKeys.every((k) => WRAPPED_META_KEYS.has(k));
52
+ }
53
+ return true;
54
+ }
55
+ function schemaIssue(message, path = []) {
56
+ return { code: "custom", path, message };
57
+ }
23
58
  /**
24
59
  * Supports:
25
60
  * - Wrapped: `{ "schemaVersion": "1", "presets": { "name": { ... } } }`
@@ -30,10 +65,12 @@ function splitPresetFileRaw(raw) {
30
65
  throw new Error("invalid_root");
31
66
  }
32
67
  const o = raw;
33
- if ("presets" in o &&
34
- o.presets !== null &&
35
- typeof o.presets === "object" &&
36
- !Array.isArray(o.presets)) {
68
+ if (isWrappedLayout(o)) {
69
+ for (const key of Object.keys(o)) {
70
+ if (!WRAPPED_META_KEYS.has(key)) {
71
+ throw new Error("wrapped_extra_keys");
72
+ }
73
+ }
37
74
  const sv = o.schemaVersion;
38
75
  return {
39
76
  mapRaw: o.presets,
@@ -69,13 +106,39 @@ export function loadPresetsFromGitTop(gitTop) {
69
106
  mapRaw = s.mapRaw;
70
107
  schemaVersion = s.schemaVersion;
71
108
  }
72
- catch {
109
+ catch (e) {
110
+ if (e instanceof Error && e.message === "wrapped_extra_keys") {
111
+ return {
112
+ ok: false,
113
+ reason: "schema",
114
+ issues: [
115
+ schemaIssue("Wrapped preset files allow only $schema, schemaVersion, and presets"),
116
+ ],
117
+ };
118
+ }
73
119
  return {
74
120
  ok: false,
75
121
  reason: "invalid_json",
76
122
  message: "Preset file root must be a JSON object",
77
123
  };
78
124
  }
125
+ if (schemaVersion !== undefined && schemaVersion !== PRESET_SCHEMA_VERSION) {
126
+ return {
127
+ ok: false,
128
+ reason: "schema",
129
+ issues: [schemaIssue(`schemaVersion must be "${PRESET_SCHEMA_VERSION}"`, ["schemaVersion"])],
130
+ };
131
+ }
132
+ if (mapRaw === null ||
133
+ typeof mapRaw !== "object" ||
134
+ Array.isArray(mapRaw) ||
135
+ Object.keys(mapRaw).length === 0) {
136
+ return {
137
+ ok: false,
138
+ reason: "schema",
139
+ issues: [schemaIssue("Preset file must contain at least one preset")],
140
+ };
141
+ }
79
142
  const parsed = PresetFileSchema.safeParse(mapRaw);
80
143
  if (!parsed.success) {
81
144
  return { ok: false, reason: "schema", issues: parsed.error.issues };
@@ -150,7 +213,16 @@ function mergePairs(preset, inline) {
150
213
  const b = inline ?? [];
151
214
  if (a.length === 0 && b.length === 0)
152
215
  return undefined;
153
- return [...a, ...b];
216
+ const seen = new Set();
217
+ const out = [];
218
+ for (const pair of [...a, ...b]) {
219
+ const key = `${pair.left}\0${pair.right}`;
220
+ if (!seen.has(key)) {
221
+ seen.add(key);
222
+ out.push(pair);
223
+ }
224
+ }
225
+ return out;
154
226
  }
155
227
  export function applyPresetNestedRoots(gitTop, presetName, presetMerge, inlineNestedRoots) {
156
228
  const got = getPresetEntry(gitTop, presetName);
@@ -139,7 +139,19 @@ export function requireGitAndRoots(server, args, presetName) {
139
139
  const trimmed = root?.trim();
140
140
  if (trimmed === "*") {
141
141
  const fileRoots = listFileRoots(server);
142
- return fileRoots.length === 0 ? defaultRoots(fileRoots) : { ok: true, roots: fileRoots };
142
+ if (fileRoots.length === 0)
143
+ return defaultRoots(fileRoots);
144
+ if (fileRoots.length > MAX_ROOT_PATHS) {
145
+ return {
146
+ ok: false,
147
+ error: {
148
+ error: ERROR_CODES.ROOT_LIST_TOO_MANY,
149
+ max: MAX_ROOT_PATHS,
150
+ count: fileRoots.length,
151
+ },
152
+ };
153
+ }
154
+ return { ok: true, roots: fileRoots };
143
155
  }
144
156
  if (trimmed) {
145
157
  return { ok: true, roots: [resolve(trimmed)] };
@@ -8,10 +8,17 @@ export const WorkspacePickSchema = z.object({
8
8
  workspaceRoot: z.string().optional().describe("Repo path. Default: first MCP root / cwd."),
9
9
  format: FormatSchema,
10
10
  });
11
- /** Fan-out tools: one polymorphic routing param plus output format. */
11
+ /**
12
+ * Fan-out tools: one polymorphic routing param plus output format.
13
+ *
14
+ * Array length is intentionally uncapped here so `resolveRootPathList` can
15
+ * return the structured `{ error: root_list_too_many, max, count }` JSON
16
+ * payload. Zod `.max(MAX_ROOT_PATHS)` would reject with `too_big` before execute.
17
+ * The `"*"` sentinel is a plain string (no redundant `z.literal("*")`).
18
+ */
12
19
  export const RootPickSchema = z.object({
13
20
  root: z
14
- .union([z.string(), z.array(z.string()).max(MAX_ROOT_PATHS), z.literal("*")])
21
+ .union([z.string(), z.array(z.string())])
15
22
  .optional()
16
23
  .describe('Repo path, array of paths, or "*" for all MCP roots.'),
17
24
  format: FormatSchema,