@rethunk/mcp-multi-root-git 2.5.0 → 2.8.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 (41) hide show
  1. package/AGENTS.md +4 -4
  2. package/CHANGELOG.md +45 -1
  3. package/README.md +1 -1
  4. package/dist/server/batch-commit-tool.js +32 -10
  5. package/dist/server/error-codes.js +95 -0
  6. package/dist/server/git-blame-tool.js +227 -0
  7. package/dist/server/git-branch-list-tool.js +140 -0
  8. package/dist/server/git-cherry-pick-tool.js +11 -10
  9. package/dist/server/git-diff-summary-tool.js +10 -3
  10. package/dist/server/git-diff-tool.js +60 -13
  11. package/dist/server/git-fetch-tool.js +148 -13
  12. package/dist/server/git-inventory-tool.js +7 -6
  13. package/dist/server/git-log-tool.js +14 -4
  14. package/dist/server/git-merge-tool.js +15 -14
  15. package/dist/server/git-parity-tool.js +5 -4
  16. package/dist/server/git-push-tool.js +9 -5
  17. package/dist/server/git-reflog-tool.js +126 -0
  18. package/dist/server/git-refs.js +12 -5
  19. package/dist/server/git-reset-soft-tool.js +4 -3
  20. package/dist/server/git-show-tool.js +96 -22
  21. package/dist/server/git-stash-tool.js +17 -15
  22. package/dist/server/git-tag-tool.js +8 -7
  23. package/dist/server/git-worktree-tool.js +8 -7
  24. package/dist/server/git.js +86 -7
  25. package/dist/server/list-presets-tool.js +2 -1
  26. package/dist/server/presets-resource.js +3 -2
  27. package/dist/server/presets.js +11 -5
  28. package/dist/server/roots.js +11 -10
  29. package/dist/server/tool-parameter-schemas.js +9 -0
  30. package/dist/server/tools.js +6 -0
  31. package/dist/server.js +7 -0
  32. package/docs/mcp-tools.md +134 -12
  33. package/package.json +4 -3
  34. package/schemas/git_blame.json +52 -0
  35. package/schemas/git_branch_list.json +36 -0
  36. package/schemas/git_diff.json +14 -15
  37. package/schemas/git_diff_summary.json +0 -6
  38. package/schemas/git_reflog.json +44 -0
  39. package/schemas/git_show.json +13 -1
  40. package/schemas/index.json +15 -0
  41. package/tool-parameters.schema.json +153 -22
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { spawnGitAsync } from "./git.js";
3
4
  import { jsonRespond, spreadDefined } from "./json.js";
4
5
  import { requireSingleRepo } from "./roots.js";
@@ -30,7 +31,7 @@ export function registerGitStashListTool(server) {
30
31
  // If there are no stashes, git still returns ok=true with empty output
31
32
  // Only treat as error if git itself failed
32
33
  return jsonRespond({
33
- error: "stash_list_failed",
34
+ error: ERROR_CODES.STASH_LIST_FAILED,
34
35
  detail: (r.stderr || r.stdout).trim(),
35
36
  });
36
37
  }
@@ -39,21 +40,22 @@ export function registerGitStashListTool(server) {
39
40
  .split("\n")
40
41
  .map((l) => l.trim())
41
42
  .filter((l) => l.length > 0);
42
- for (let i = 0; i < lines.length; i++) {
43
- const line = lines[i];
44
- if (line) {
45
- const parts = line.split("|");
46
- // parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
47
- const sha = parts[parts.length - 1];
48
- const message = parts.slice(1, -1).join("|");
49
- if (parts.length >= 3 && message && sha) {
50
- stashes.push({
51
- index: i,
52
- message,
53
- sha,
54
- });
55
- }
43
+ for (const line of lines) {
44
+ const parts = line.split("|");
45
+ // parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
46
+ const sha = parts[parts.length - 1];
47
+ const message = parts.slice(1, -1).join("|");
48
+ // Parse the real stash index from the canonical stash@{N} ref in parts[0].
49
+ const indexMatch = parts[0] ? /stash@\{(\d+)\}/.exec(parts[0]) : null;
50
+ if (!indexMatch || parts.length < 3 || !message || !sha) {
51
+ // Malformed line — skip without affecting index tracking.
52
+ continue;
56
53
  }
54
+ stashes.push({
55
+ index: Number(indexMatch[1]),
56
+ message,
57
+ sha,
58
+ });
57
59
  }
58
60
  if (args.format === "json") {
59
61
  return jsonRespond({ stashes });
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
4
  import { jsonRespond } from "./json.js";
4
5
  import { requireSingleRepo } from "./roots.js";
@@ -72,18 +73,18 @@ export function registerGitTagTool(server) {
72
73
  const gitTop = pre.gitTop;
73
74
  const tag = args.tag.trim();
74
75
  if (!tag) {
75
- return jsonRespond({ error: "tag_empty" });
76
+ return jsonRespond({ error: ERROR_CODES.EMPTY_TAG_NAME });
76
77
  }
77
78
  // Validate tag name: no shell metacharacters
78
79
  if (!isSafeGitUpstreamToken(tag)) {
79
- return jsonRespond({ error: "tag_unsafe", tag });
80
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_TAG_TOKEN, tag });
80
81
  }
81
82
  // Handle deletion
82
83
  if (args.delete === true) {
83
84
  const delResult = await spawnGitAsync(gitTop, ["tag", "-d", tag]);
84
85
  if (!delResult.ok) {
85
86
  return jsonRespond({
86
- error: "tag_delete_failed",
87
+ error: ERROR_CODES.TAG_DELETE_FAILED,
87
88
  detail: (delResult.stderr || delResult.stdout).trim(),
88
89
  });
89
90
  }
@@ -99,13 +100,13 @@ export function registerGitTagTool(server) {
99
100
  // Determine the ref to tag (default HEAD)
100
101
  const ref = (args.ref ?? "HEAD").trim();
101
102
  if (!isSafeGitUpstreamToken(ref)) {
102
- return jsonRespond({ error: "ref_unsafe", ref });
103
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
103
104
  }
104
105
  // Get the SHA of the ref to tag
105
106
  const sha = await getRefSha(gitTop, ref);
106
107
  if (!sha) {
107
108
  return jsonRespond({
108
- error: "ref_not_found",
109
+ error: ERROR_CODES.REF_NOT_FOUND,
109
110
  ref,
110
111
  });
111
112
  }
@@ -122,7 +123,7 @@ export function registerGitTagTool(server) {
122
123
  const createResult = await spawnGitAsync(gitTop, tagArgs);
123
124
  if (!createResult.ok) {
124
125
  return jsonRespond({
125
- error: "tag_create_failed",
126
+ error: ERROR_CODES.TAG_CREATE_FAILED,
126
127
  detail: (createResult.stderr || createResult.stdout).trim(),
127
128
  });
128
129
  }
@@ -130,7 +131,7 @@ export function registerGitTagTool(server) {
130
131
  const tagType = await getTagType(gitTop, tag);
131
132
  if (!tagType) {
132
133
  return jsonRespond({
133
- error: "tag_verification_failed",
134
+ error: ERROR_CODES.TAG_VERIFICATION_FAILED,
134
135
  tag,
135
136
  });
136
137
  }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { z } from "zod";
3
+ import { ERROR_CODES } from "./error-codes.js";
3
4
  import { spawnGitAsync } from "./git.js";
4
5
  import { isProtectedBranch, isSafeGitRefToken, listWorktrees, } from "./git-refs.js";
5
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
@@ -76,13 +77,13 @@ export function registerGitWorktreeAddTool(server) {
76
77
  const { gitTop } = pre;
77
78
  // Validate branch
78
79
  if (!isSafeGitRefToken(args.branch)) {
79
- return jsonRespond({ error: "unsafe_ref_token", ref: args.branch });
80
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
80
81
  }
81
82
  if (isProtectedBranch(args.branch)) {
82
- return jsonRespond({ error: "protected_branch", branch: args.branch });
83
+ return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: args.branch });
83
84
  }
84
85
  if (args.baseRef !== undefined && !isSafeGitRefToken(args.baseRef)) {
85
- return jsonRespond({ error: "unsafe_ref_token", ref: args.baseRef });
86
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
86
87
  }
87
88
  // Resolve path
88
89
  const wtPath = resolve(gitTop, args.path);
@@ -107,7 +108,7 @@ export function registerGitWorktreeAddTool(server) {
107
108
  if (!r.ok) {
108
109
  return jsonRespond({
109
110
  ok: false,
110
- error: "worktree_add_failed",
111
+ error: ERROR_CODES.WORKTREE_ADD_FAILED,
111
112
  detail: (r.stderr || r.stdout).trim(),
112
113
  });
113
114
  }
@@ -158,13 +159,13 @@ export function registerGitWorktreeRemoveTool(server) {
158
159
  // Refuse to remove the main worktree
159
160
  const wtPath = resolve(gitTop, args.path);
160
161
  if (wtPath === gitTop) {
161
- return jsonRespond({ error: "cannot_remove_main_worktree", path: wtPath });
162
+ return jsonRespond({ error: ERROR_CODES.CANNOT_REMOVE_MAIN_WORKTREE, path: wtPath });
162
163
  }
163
164
  // Verify it exists in the worktree list
164
165
  const trees = await listWorktrees(gitTop);
165
166
  const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
166
167
  if (!isRegistered) {
167
- return jsonRespond({ error: "worktree_not_found", path: args.path });
168
+ return jsonRespond({ error: ERROR_CODES.WORKTREE_NOT_FOUND, path: args.path });
168
169
  }
169
170
  const removeArgs = ["worktree", "remove", wtPath];
170
171
  if (args.force)
@@ -173,7 +174,7 @@ export function registerGitWorktreeRemoveTool(server) {
173
174
  if (!r.ok) {
174
175
  return jsonRespond({
175
176
  ok: false,
176
- error: "worktree_remove_failed",
177
+ error: ERROR_CODES.WORKTREE_REMOVE_FAILED,
177
178
  detail: (r.stderr || r.stdout).trim(),
178
179
  ...spreadWhen((r.stderr || r.stdout).includes("contains modified") ||
179
180
  (r.stderr || r.stdout).includes("is not empty"), { hint: "Pass force: true to remove a worktree with uncommitted changes." }),
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, lstatSync, readFileSync } from "node:fs";
3
3
  import { cpus } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { ERROR_CODES } from "./error-codes.js";
5
6
  /**
6
7
  * Parallel git subprocesses for inventory rows and git_status submodule rows.
7
8
  * Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
@@ -19,9 +20,28 @@ function resolveGitSubprocessParallelism() {
19
20
  return 4;
20
21
  }
21
22
  export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
23
+ /**
24
+ * Default timeout for git subprocesses spawned by spawnGitAsync.
25
+ * Reads from GIT_SUBPROCESS_TIMEOUT_MS env var (default 120000 ms = 2 min).
26
+ * A value of 0 (or negative/NaN) disables the timeout — use for operations
27
+ * like large clones where unbounded wait is intentional.
28
+ */
29
+ function resolveGitSubprocessTimeoutMs() {
30
+ const env = process.env.GIT_SUBPROCESS_TIMEOUT_MS;
31
+ if (env) {
32
+ const n = Number.parseInt(env, 10);
33
+ if (!Number.isNaN(n) && n > 0)
34
+ return n;
35
+ // 0 or negative → disabled
36
+ if (!Number.isNaN(n))
37
+ return 0;
38
+ }
39
+ return 120_000;
40
+ }
41
+ export const GIT_SUBPROCESS_TIMEOUT_MS = resolveGitSubprocessTimeoutMs();
22
42
  let gitPathState = "unknown";
23
43
  const GIT_NOT_FOUND_BODY = {
24
- error: "git_not_found",
44
+ error: ERROR_CODES.GIT_NOT_FOUND,
25
45
  };
26
46
  export function gateGit() {
27
47
  if (gitPathState === "ok") {
@@ -94,10 +114,23 @@ export function parseGitSubmodulePaths(gitRoot) {
94
114
  return [];
95
115
  }
96
116
  const paths = [];
97
- for (const line of text.split("\n")) {
98
- const m = /^\s*path\s*=\s*(.+)\s*$/.exec(line);
117
+ let inSubmoduleSection = false;
118
+ for (const rawLine of text.split("\n")) {
119
+ // Strip inline and whole-line comments (; and #)
120
+ const commentIdx = rawLine.search(/\s*[;#]/);
121
+ const line = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine;
122
+ // Track INI section header
123
+ const sectionMatch = /^\s*\[(.+)\]\s*$/.exec(line);
124
+ if (sectionMatch) {
125
+ inSubmoduleSection = /^submodule\s+"/.test(sectionMatch[1] ?? "");
126
+ continue;
127
+ }
128
+ // Only collect path = lines inside a [submodule "..."] section
129
+ if (!inSubmoduleSection)
130
+ continue;
131
+ const m = /^\s*path\s*=\s*(.+?)\s*$/.exec(line);
99
132
  if (m?.[1])
100
- paths.push(m[1].trim());
133
+ paths.push(m[1]);
101
134
  }
102
135
  return paths;
103
136
  }
@@ -134,11 +167,12 @@ export async function asyncPool(items, concurrency, fn) {
134
167
  await Promise.all(Array.from({ length: n }, () => worker()));
135
168
  return results;
136
169
  }
137
- export function spawnGitAsync(cwd, args) {
170
+ export function spawnGitAsync(cwd, args, opts) {
138
171
  return new Promise((resolveP) => {
139
172
  const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
140
173
  let stdout = "";
141
174
  let stderr = "";
175
+ let settled = false;
142
176
  child.stdout?.setEncoding("utf8");
143
177
  child.stderr?.setEncoding("utf8");
144
178
  child.stdout?.on("data", (c) => {
@@ -147,8 +181,53 @@ export function spawnGitAsync(cwd, args) {
147
181
  child.stderr?.on("data", (c) => {
148
182
  stderr += c;
149
183
  });
150
- child.on("error", () => resolveP({ ok: false, stdout, stderr }));
151
- child.on("close", (code) => resolveP({ ok: code === 0, stdout, stderr }));
184
+ const effectiveTimeout = opts?.timeoutMs ?? GIT_SUBPROCESS_TIMEOUT_MS;
185
+ let timer;
186
+ let abortListener;
187
+ function cleanup() {
188
+ if (timer !== undefined) {
189
+ clearTimeout(timer);
190
+ timer = undefined;
191
+ }
192
+ if (abortListener !== undefined && opts?.signal) {
193
+ opts.signal.removeEventListener("abort", abortListener);
194
+ abortListener = undefined;
195
+ }
196
+ }
197
+ function settle(result) {
198
+ if (settled)
199
+ return;
200
+ settled = true;
201
+ cleanup();
202
+ resolveP(result);
203
+ }
204
+ // AbortSignal: kill immediately if already aborted, else listen
205
+ if (opts?.signal) {
206
+ if (opts.signal.aborted) {
207
+ child.kill("SIGTERM");
208
+ settle({ ok: false, stdout, stderr, aborted: true });
209
+ return;
210
+ }
211
+ abortListener = () => {
212
+ child.kill("SIGTERM");
213
+ settle({ ok: false, stdout, stderr, aborted: true });
214
+ };
215
+ opts.signal.addEventListener("abort", abortListener, { once: true });
216
+ }
217
+ // Timeout: set timer if effectiveTimeout > 0
218
+ if (effectiveTimeout > 0) {
219
+ timer = setTimeout(() => {
220
+ child.kill("SIGTERM");
221
+ settle({
222
+ ok: false,
223
+ stdout,
224
+ stderr: `${stderr}\n<git timed out after ${effectiveTimeout}ms>`,
225
+ timedOut: true,
226
+ });
227
+ }, effectiveTimeout);
228
+ }
229
+ child.on("error", () => settle({ ok: false, stdout, stderr }));
230
+ child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
152
231
  });
153
232
  }
154
233
  function gitStatusFailText(r) {
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { gitTopLevel } from "./git.js";
3
4
  import { jsonRespond, spreadDefined } from "./json.js";
4
5
  import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
@@ -34,7 +35,7 @@ export function registerListPresetsTool(server) {
34
35
  presetFile,
35
36
  fileExists: false,
36
37
  presets: [],
37
- error: { error: "not_a_git_repository", path: ws },
38
+ error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws },
38
39
  });
39
40
  continue;
40
41
  }
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { gitTopLevel } from "./git.js";
3
4
  import { jsonRespond, spreadDefined } from "./json.js";
4
5
  import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
@@ -15,11 +16,11 @@ export function registerPresetsResource(server) {
15
16
  }
16
17
  const ws = pre.roots[0];
17
18
  if (!ws) {
18
- return { text: jsonRespond({ error: "no_workspace_root" }) };
19
+ return { text: jsonRespond({ error: ERROR_CODES.NO_WORKSPACE_ROOT }) };
19
20
  }
20
21
  const top = gitTopLevel(ws);
21
22
  if (!top) {
22
- return { text: jsonRespond({ error: "not_a_git_repository", path: ws }) };
23
+ return { text: jsonRespond({ error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws }) };
23
24
  }
24
25
  const loaded = loadPresetsFromGitTop(top);
25
26
  if (!loaded.ok) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
+ import { ERROR_CODES } from "./error-codes.js";
4
5
  /**
5
6
  * Schema for `.rethunk/git-mcp-presets.json` at the workspace root.
6
7
  * Each named entry defines roots for `git_inventory` and/or pairs for `git_parity`.
@@ -85,16 +86,21 @@ export function presetLoadErrorPayload(gitTop, fail) {
85
86
  const presetFile = join(gitTop, PRESET_FILE_PATH);
86
87
  if (fail.reason === "invalid_json") {
87
88
  return {
88
- error: "preset_file_invalid",
89
+ error: ERROR_CODES.PRESET_FILE_INVALID,
89
90
  kind: "invalid_json",
90
91
  presetFile,
91
92
  message: fail.message,
92
93
  };
93
94
  }
94
95
  if (fail.reason === "schema") {
95
- return { error: "preset_file_invalid", kind: "schema", presetFile, issues: fail.issues };
96
+ return {
97
+ error: ERROR_CODES.PRESET_FILE_INVALID,
98
+ kind: "schema",
99
+ presetFile,
100
+ issues: fail.issues,
101
+ };
96
102
  }
97
- return { error: "preset_file_invalid", presetFile };
103
+ return { error: ERROR_CODES.PRESET_FILE_INVALID, presetFile };
98
104
  }
99
105
  function getPresetEntry(gitTop, presetName) {
100
106
  const loaded = loadPresetsFromGitTop(gitTop);
@@ -103,7 +109,7 @@ function getPresetEntry(gitTop, presetName) {
103
109
  return {
104
110
  ok: false,
105
111
  error: {
106
- error: "preset_not_found",
112
+ error: ERROR_CODES.PRESET_NOT_FOUND,
107
113
  preset: presetName,
108
114
  presetFile: join(gitTop, PRESET_FILE_PATH),
109
115
  },
@@ -116,7 +122,7 @@ function getPresetEntry(gitTop, presetName) {
116
122
  return {
117
123
  ok: false,
118
124
  error: {
119
- error: "preset_not_found",
125
+ error: ERROR_CODES.PRESET_NOT_FOUND,
120
126
  preset: presetName,
121
127
  presetFile: join(gitTop, PRESET_FILE_PATH),
122
128
  },
@@ -1,5 +1,6 @@
1
1
  import { basename, resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { ERROR_CODES } from "./error-codes.js";
3
4
  import { gateGit, gitTopLevel } from "./git.js";
4
5
  import { loadPresetsFromGitTop, presetLoadErrorPayload } from "./presets.js";
5
6
  import { MAX_ABSOLUTE_GIT_ROOTS } from "./schemas.js";
@@ -61,7 +62,7 @@ export function resolveAbsoluteGitRootsList(raw) {
61
62
  return {
62
63
  ok: false,
63
64
  error: {
64
- error: "absolute_git_roots_too_many",
65
+ error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_TOO_MANY,
65
66
  max: MAX_ABSOLUTE_GIT_ROOTS,
66
67
  count: raw.length,
67
68
  },
@@ -72,12 +73,12 @@ export function resolveAbsoluteGitRootsList(raw) {
72
73
  for (const item of raw) {
73
74
  const trimmed = item.trim();
74
75
  if (trimmed.length === 0) {
75
- return { ok: false, error: { error: "invalid_absolute_git_root", path: item } };
76
+ return { ok: false, error: { error: ERROR_CODES.INVALID_ABSOLUTE_GIT_ROOT, path: item } };
76
77
  }
77
78
  const abs = resolve(trimmed);
78
79
  const top = gitTopLevel(abs);
79
80
  if (!top) {
80
- return { ok: false, error: { error: "invalid_absolute_git_root", path: abs } };
81
+ return { ok: false, error: { error: ERROR_CODES.INVALID_ABSOLUTE_GIT_ROOT, path: abs } };
81
82
  }
82
83
  if (seen.has(top))
83
84
  continue;
@@ -85,7 +86,7 @@ export function resolveAbsoluteGitRootsList(raw) {
85
86
  tops.push(top);
86
87
  }
87
88
  if (tops.length === 0) {
88
- return { ok: false, error: { error: "absolute_git_roots_empty" } };
89
+ return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_EMPTY } };
89
90
  }
90
91
  return { ok: true, roots: tops };
91
92
  }
@@ -104,7 +105,7 @@ function resolveWorkspaceRoots(server, args) {
104
105
  return {
105
106
  ok: false,
106
107
  error: {
107
- error: "root_index_out_of_range",
108
+ error: ERROR_CODES.ROOT_INDEX_OUT_OF_RANGE,
108
109
  rootIndex: args.rootIndex,
109
110
  rootCount: fileRoots.length,
110
111
  },
@@ -163,10 +164,10 @@ export function requireGitAndRoots(server, args, presetName) {
163
164
  const abs = args.absoluteGitRoots;
164
165
  if (abs != null && abs.length > 0) {
165
166
  if (presetName) {
166
- return { ok: false, error: { error: "absolute_git_roots_preset_conflict" } };
167
+ return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_PRESET_CONFLICT } };
167
168
  }
168
169
  if (hasExclusiveWorkspacePick(args)) {
169
- return { ok: false, error: { error: "absolute_git_roots_exclusive" } };
170
+ return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_EXCLUSIVE } };
170
171
  }
171
172
  return resolveAbsoluteGitRootsList(abs);
172
173
  }
@@ -191,16 +192,16 @@ export function requireSingleRepo(server, args, presetName = undefined) {
191
192
  return {
192
193
  ok: false,
193
194
  error: {
194
- error: "absolute_git_roots_single_repo_only",
195
+ error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_SINGLE_REPO_ONLY,
195
196
  rootCount: pre.roots.length,
196
197
  },
197
198
  };
198
199
  }
199
200
  const rootInput = pre.roots[0];
200
201
  if (!rootInput)
201
- return { ok: false, error: { error: "no_workspace_root" } };
202
+ return { ok: false, error: { error: ERROR_CODES.NO_WORKSPACE_ROOT } };
202
203
  const top = gitTopLevel(rootInput);
203
204
  if (!top)
204
- return { ok: false, error: { error: "not_a_git_repository", path: rootInput } };
205
+ return { ok: false, error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: rootInput } };
205
206
  return { ok: true, gitTop: top };
206
207
  }
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { registerBatchCommitTool } from "./batch-commit-tool.js";
3
+ import { registerGitBlameTool } from "./git-blame-tool.js";
4
+ import { registerGitBranchListTool } from "./git-branch-list-tool.js";
3
5
  import { registerGitCherryPickTool } from "./git-cherry-pick-tool.js";
4
6
  import { registerGitDiffSummaryTool } from "./git-diff-summary-tool.js";
5
7
  import { registerGitDiffTool } from "./git-diff-tool.js";
@@ -9,6 +11,7 @@ import { registerGitLogTool } from "./git-log-tool.js";
9
11
  import { registerGitMergeTool } from "./git-merge-tool.js";
10
12
  import { registerGitParityTool } from "./git-parity-tool.js";
11
13
  import { registerGitPushTool } from "./git-push-tool.js";
14
+ import { registerGitReflogTool } from "./git-reflog-tool.js";
12
15
  import { registerGitResetSoftTool } from "./git-reset-soft-tool.js";
13
16
  import { registerGitShowTool } from "./git-show-tool.js";
14
17
  import { registerGitStashApplyTool, registerGitStashListTool } from "./git-stash-tool.js";
@@ -29,6 +32,9 @@ export const READ_ONLY_SINGLE_REPO_TOOLS = [
29
32
  "git_show",
30
33
  "git_worktree_list",
31
34
  "git_stash_list",
35
+ "git_blame",
36
+ "git_branch_list",
37
+ "git_reflog",
32
38
  ];
33
39
  export const MUTATING_TOOLS = [
34
40
  "git_fetch",
@@ -71,6 +77,9 @@ export function captureToolParameterSchemas() {
71
77
  registerGitWorktreeListTool(server);
72
78
  registerGitStashListTool(server);
73
79
  registerGitFetchTool(server);
80
+ registerGitBlameTool(server);
81
+ registerGitBranchListTool(server);
82
+ registerGitReflogTool(server);
74
83
  registerBatchCommitTool(server);
75
84
  registerGitPushTool(server);
76
85
  registerGitMergeTool(server);
@@ -1,4 +1,6 @@
1
1
  import { registerBatchCommitTool } from "./batch-commit-tool.js";
2
+ import { registerGitBlameTool } from "./git-blame-tool.js";
3
+ import { registerGitBranchListTool } from "./git-branch-list-tool.js";
2
4
  import { registerGitCherryPickTool } from "./git-cherry-pick-tool.js";
3
5
  import { registerGitDiffSummaryTool } from "./git-diff-summary-tool.js";
4
6
  import { registerGitDiffTool } from "./git-diff-tool.js";
@@ -8,6 +10,7 @@ import { registerGitLogTool } from "./git-log-tool.js";
8
10
  import { registerGitMergeTool } from "./git-merge-tool.js";
9
11
  import { registerGitParityTool } from "./git-parity-tool.js";
10
12
  import { registerGitPushTool } from "./git-push-tool.js";
13
+ import { registerGitReflogTool } from "./git-reflog-tool.js";
11
14
  import { registerGitResetSoftTool } from "./git-reset-soft-tool.js";
12
15
  import { registerGitShowTool } from "./git-show-tool.js";
13
16
  import { registerGitStashApplyTool, registerGitStashListTool } from "./git-stash-tool.js";
@@ -29,6 +32,9 @@ export function registerRethunkGitTools(server) {
29
32
  registerGitWorktreeListTool(server);
30
33
  registerGitStashListTool(server);
31
34
  registerGitFetchTool(server);
35
+ registerGitBlameTool(server);
36
+ registerGitBranchListTool(server);
37
+ registerGitReflogTool(server);
32
38
  // Mutating tools
33
39
  registerBatchCommitTool(server);
34
40
  registerGitPushTool(server);
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);