@rethunk/mcp-multi-root-git 2.9.0 → 3.1.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 (56) hide show
  1. package/AGENTS.md +4 -6
  2. package/CHANGELOG.md +65 -0
  3. package/HUMANS.md +4 -4
  4. package/dist/server/batch-commit-tool.js +24 -15
  5. package/dist/server/coverage.js +5 -1
  6. package/dist/server/error-codes.js +6 -9
  7. package/dist/server/git-blame-tool.js +60 -16
  8. package/dist/server/git-branch-list-tool.js +1 -7
  9. package/dist/server/git-cherry-pick-tool.js +1 -1
  10. package/dist/server/git-diff-summary-tool.js +3 -3
  11. package/dist/server/git-diff-tool.js +1 -4
  12. package/dist/server/git-fetch-tool.js +3 -12
  13. package/dist/server/git-inventory-tool.js +4 -4
  14. package/dist/server/git-log-tool.js +2 -2
  15. package/dist/server/git-merge-tool.js +1 -1
  16. package/dist/server/git-parity-tool.js +2 -2
  17. package/dist/server/git-push-tool.js +1 -1
  18. package/dist/server/git-reflog-tool.js +1 -7
  19. package/dist/server/git-reset-soft-tool.js +1 -1
  20. package/dist/server/git-show-tool.js +2 -11
  21. package/dist/server/git-stash-tool.js +3 -12
  22. package/dist/server/git-status-tool.js +2 -2
  23. package/dist/server/git-tag-tool.js +1 -7
  24. package/dist/server/git-worktree-tool.js +3 -7
  25. package/dist/server/git.js +2 -8
  26. package/dist/server/list-presets-tool.js +2 -8
  27. package/dist/server/roots.js +41 -80
  28. package/dist/server/schemas.js +11 -19
  29. package/dist/server/tool-parameter-schemas.js +3 -3
  30. package/dist/server.js +1 -1
  31. package/docs/mcp-tools.md +117 -136
  32. package/package.json +6 -5
  33. package/schemas/batch_commit.json +7 -19
  34. package/schemas/git_blame.json +12 -10
  35. package/schemas/git_branch_list.json +2 -8
  36. package/schemas/git_cherry_pick.json +5 -17
  37. package/schemas/git_diff.json +7 -13
  38. package/schemas/git_diff_summary.json +2 -16
  39. package/schemas/git_fetch.json +1 -7
  40. package/schemas/git_inventory.json +18 -23
  41. package/schemas/git_log.json +24 -29
  42. package/schemas/git_merge.json +3 -15
  43. package/schemas/git_parity.json +18 -23
  44. package/schemas/git_push.json +1 -13
  45. package/schemas/git_reflog.json +1 -7
  46. package/schemas/git_reset_soft.json +2 -14
  47. package/schemas/git_show.json +1 -7
  48. package/schemas/git_stash_apply.json +3 -9
  49. package/schemas/git_stash_list.json +1 -7
  50. package/schemas/git_status.json +18 -23
  51. package/schemas/git_tag.json +1 -7
  52. package/schemas/git_worktree_add.json +4 -16
  53. package/schemas/git_worktree_list.json +1 -7
  54. package/schemas/git_worktree_remove.json +2 -14
  55. package/schemas/list_presets.json +18 -23
  56. package/tool-parameters.schema.json +122 -297
@@ -156,17 +156,8 @@ export function registerGitShowTool(server) {
156
156
  annotations: {
157
157
  readOnlyHint: true,
158
158
  },
159
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
160
- .pick({
161
- workspaceRoot: true,
162
- rootIndex: true,
163
- format: true,
164
- })
165
- .extend({
166
- ref: z
167
- .string()
168
- .min(1)
169
- .describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
159
+ parameters: WorkspacePickSchema.extend({
160
+ ref: z.string().min(1).describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
170
161
  path: z
171
162
  .string()
172
163
  .optional()
@@ -14,11 +14,7 @@ export function registerGitStashListTool(server) {
14
14
  annotations: {
15
15
  readOnlyHint: true,
16
16
  },
17
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true }).pick({
18
- workspaceRoot: true,
19
- rootIndex: true,
20
- format: true,
21
- }),
17
+ parameters: WorkspacePickSchema,
22
18
  execute: async (args) => {
23
19
  const pre = requireSingleRepo(server, args);
24
20
  if (!pre.ok)
@@ -83,17 +79,12 @@ export function registerGitStashApplyTool(server) {
83
79
  destructiveHint: false,
84
80
  idempotentHint: false,
85
81
  },
86
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
87
- .pick({
88
- workspaceRoot: true,
89
- rootIndex: true,
90
- format: true,
91
- })
92
- .extend({
82
+ parameters: WorkspacePickSchema.extend({
93
83
  index: z
94
84
  .number()
95
85
  .int()
96
86
  .min(0)
87
+ .max(10000)
97
88
  .optional()
98
89
  .default(0)
99
90
  .describe("Stash index (defaults to 0 for stash@{0})."),
@@ -4,7 +4,7 @@ import { isStrictlyUnderGitTop } from "../repo-paths.js";
4
4
  import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitStatusShortBranchAsync, gitTopLevel, hasGitMetadata, parseGitSubmodulePaths, } from "./git.js";
5
5
  import { jsonRespond } from "./json.js";
6
6
  import { requireGitAndRoots } from "./roots.js";
7
- import { WorkspacePickSchema } from "./schemas.js";
7
+ import { RootPickSchema } from "./schemas.js";
8
8
  export function registerGitStatusTool(server) {
9
9
  server.addTool({
10
10
  name: "git_status",
@@ -12,7 +12,7 @@ export function registerGitStatusTool(server) {
12
12
  annotations: {
13
13
  readOnlyHint: true,
14
14
  },
15
- parameters: WorkspacePickSchema.extend({
15
+ parameters: RootPickSchema.extend({
16
16
  includeSubmodules: z.boolean().optional().default(true),
17
17
  }),
18
18
  execute: async (args) => {
@@ -44,13 +44,7 @@ export function registerGitTagTool(server) {
44
44
  readOnlyHint: false,
45
45
  destructiveHint: true,
46
46
  },
47
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
48
- .pick({
49
- workspaceRoot: true,
50
- rootIndex: true,
51
- format: true,
52
- })
53
- .extend({
47
+ parameters: WorkspacePickSchema.extend({
54
48
  tag: z.string().min(1).describe("Tag name (e.g. 'v1.2.3')."),
55
49
  message: z
56
50
  .string()
@@ -16,11 +16,7 @@ export function registerGitWorktreeListTool(server) {
16
16
  annotations: {
17
17
  readOnlyHint: true,
18
18
  },
19
- parameters: WorkspacePickSchema.pick({
20
- workspaceRoot: true,
21
- rootIndex: true,
22
- format: true,
23
- }),
19
+ parameters: WorkspacePickSchema,
24
20
  execute: async (args) => {
25
21
  const pre = requireSingleRepo(server, args);
26
22
  if (!pre.ok)
@@ -55,7 +51,7 @@ export function registerGitWorktreeAddTool(server) {
55
51
  destructiveHint: false,
56
52
  idempotentHint: false,
57
53
  },
58
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
54
+ parameters: WorkspacePickSchema.extend({
59
55
  path: z
60
56
  .string()
61
57
  .min(1)
@@ -137,7 +133,7 @@ export function registerGitWorktreeRemoveTool(server) {
137
133
  destructiveHint: true,
138
134
  idempotentHint: false,
139
135
  },
140
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
136
+ parameters: WorkspacePickSchema.extend({
141
137
  path: z
142
138
  .string()
143
139
  .min(1)
@@ -95,19 +95,13 @@ export function gitRevParseHead(cwd) {
95
95
  }
96
96
  export function parseGitSubmodulePaths(gitRoot) {
97
97
  const f = join(gitRoot, ".gitmodules");
98
- if (!existsSync(f))
99
- return [];
100
98
  // Skip non-regular files (character devices, sockets, etc.) — common in
101
99
  // Claude Code sandbox environments where stub device files shadow paths.
100
+ // Use a single try/catch to avoid TOCTOU between existence check and open.
101
+ let text;
102
102
  try {
103
103
  if (!lstatSync(f).isFile())
104
104
  return [];
105
- }
106
- catch {
107
- return [];
108
- }
109
- let text;
110
- try {
111
105
  text = readFileSync(f, "utf8");
112
106
  }
113
107
  catch {
@@ -4,7 +4,7 @@ import { gitTopLevel } from "./git.js";
4
4
  import { jsonRespond, spreadDefined } from "./json.js";
5
5
  import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
6
6
  import { requireGitAndRoots } from "./roots.js";
7
- import { WorkspacePickSchema } from "./schemas.js";
7
+ import { RootPickSchema } from "./schemas.js";
8
8
  export function registerListPresetsTool(server) {
9
9
  server.addTool({
10
10
  name: "list_presets",
@@ -12,13 +12,7 @@ export function registerListPresetsTool(server) {
12
12
  annotations: {
13
13
  readOnlyHint: true,
14
14
  },
15
- parameters: WorkspacePickSchema.pick({
16
- workspaceRoot: true,
17
- rootIndex: true,
18
- allWorkspaceRoots: true,
19
- absoluteGitRoots: true,
20
- format: true,
21
- }),
15
+ parameters: RootPickSchema,
22
16
  execute: async (args) => {
23
17
  const pre = requireGitAndRoots(server, args, undefined);
24
18
  if (!pre.ok) {
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { ERROR_CODES } from "./error-codes.js";
4
4
  import { gateGit, gitTopLevel } from "./git.js";
5
5
  import { loadPresetsFromGitTop, presetLoadErrorPayload } from "./presets.js";
6
- import { MAX_ABSOLUTE_GIT_ROOTS } from "./schemas.js";
6
+ import { MAX_ROOT_PATHS } from "./schemas.js";
7
7
  function uriToPath(uri) {
8
8
  if (!uri.startsWith("file://"))
9
9
  return null;
@@ -45,25 +45,17 @@ function pathMatchesWorkspaceRootHint(rootPath, hint) {
45
45
  return true;
46
46
  return basename(rootPath) === h;
47
47
  }
48
- function hasExclusiveWorkspacePick(args) {
49
- if (args.workspaceRoot?.trim())
50
- return true;
51
- if (args.rootIndex != null)
52
- return true;
53
- if (args.allWorkspaceRoots === true)
54
- return true;
55
- return false;
56
- }
57
48
  /**
58
- * Resolve `absoluteGitRoots` to unique git toplevels (stable order, first occurrence wins).
49
+ * Resolve an explicit `root` path array to unique git toplevels
50
+ * (stable order, first occurrence wins).
59
51
  */
60
- export function resolveAbsoluteGitRootsList(raw) {
61
- if (raw.length > MAX_ABSOLUTE_GIT_ROOTS) {
52
+ export function resolveRootPathList(raw) {
53
+ if (raw.length > MAX_ROOT_PATHS) {
62
54
  return {
63
55
  ok: false,
64
56
  error: {
65
- error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_TOO_MANY,
66
- max: MAX_ABSOLUTE_GIT_ROOTS,
57
+ error: ERROR_CODES.ROOT_LIST_TOO_MANY,
58
+ max: MAX_ROOT_PATHS,
67
59
  count: raw.length,
68
60
  },
69
61
  };
@@ -73,12 +65,12 @@ export function resolveAbsoluteGitRootsList(raw) {
73
65
  for (const item of raw) {
74
66
  const trimmed = item.trim();
75
67
  if (trimmed.length === 0) {
76
- return { ok: false, error: { error: ERROR_CODES.INVALID_ABSOLUTE_GIT_ROOT, path: item } };
68
+ return { ok: false, error: { error: ERROR_CODES.INVALID_ROOT_PATH, path: item } };
77
69
  }
78
70
  const abs = resolve(trimmed);
79
71
  const top = gitTopLevel(abs);
80
72
  if (!top) {
81
- return { ok: false, error: { error: ERROR_CODES.INVALID_ABSOLUTE_GIT_ROOT, path: abs } };
73
+ return { ok: false, error: { error: ERROR_CODES.INVALID_ROOT_PATH, path: abs } };
82
74
  }
83
75
  if (seen.has(top))
84
76
  continue;
@@ -86,47 +78,23 @@ export function resolveAbsoluteGitRootsList(raw) {
86
78
  tops.push(top);
87
79
  }
88
80
  if (tops.length === 0) {
89
- return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_EMPTY } };
81
+ return { ok: false, error: { error: ERROR_CODES.ROOT_LIST_EMPTY } };
90
82
  }
91
83
  return { ok: true, roots: tops };
92
84
  }
93
- function resolveWorkspaceRoots(server, args) {
94
- if (args.workspaceRoot?.trim()) {
95
- return { ok: true, roots: [resolve(args.workspaceRoot.trim())] };
96
- }
97
- const fileRoots = listFileRoots(server);
98
- const fallback = { ok: true, roots: [process.cwd()] };
99
- if (args.allWorkspaceRoots) {
100
- return fileRoots.length === 0 ? fallback : { ok: true, roots: fileRoots };
101
- }
102
- if (args.rootIndex != null) {
103
- const r = fileRoots[args.rootIndex];
104
- if (!r) {
105
- return {
106
- ok: false,
107
- error: {
108
- error: ERROR_CODES.ROOT_INDEX_OUT_OF_RANGE,
109
- rootIndex: args.rootIndex,
110
- rootCount: fileRoots.length,
111
- },
112
- };
113
- }
114
- return { ok: true, roots: [r] };
115
- }
85
+ /** Default when `root` is omitted: first MCP file root, else cwd. */
86
+ function defaultRoots(fileRoots) {
116
87
  const primary = fileRoots[0];
117
- return primary !== undefined ? { ok: true, roots: [primary] } : fallback;
88
+ return { ok: true, roots: [primary ?? process.cwd()] };
118
89
  }
119
90
  /**
120
91
  * When a preset name is requested and multiple MCP roots exist, pick the first root
121
92
  * whose git toplevel loads a preset file containing that name.
122
93
  */
123
- function resolveRootsForPreset(server, args, presetName) {
124
- if (args.workspaceRoot?.trim() || args.allWorkspaceRoots || args.rootIndex != null) {
125
- return resolveWorkspaceRoots(server, args);
126
- }
94
+ function resolveRootsForPreset(server, presetName) {
127
95
  const fileRoots = listFileRoots(server);
128
96
  if (fileRoots.length <= 1) {
129
- return resolveWorkspaceRoots(server, args);
97
+ return defaultRoots(fileRoots);
130
98
  }
131
99
  const matches = [];
132
100
  for (const r of fileRoots) {
@@ -153,53 +121,46 @@ function resolveRootsForPreset(server, args, presetName) {
153
121
  if (pick !== undefined) {
154
122
  return { ok: true, roots: [pick] };
155
123
  }
156
- return resolveWorkspaceRoots(server, args);
124
+ return defaultRoots(fileRoots);
157
125
  }
158
- /** `gateGit` plus workspace / preset root resolution; shared tool and resource prelude. */
126
+ /** `gateGit` plus `root` resolution; shared fan-out tool and resource prelude. */
159
127
  export function requireGitAndRoots(server, args, presetName) {
160
128
  const gg = gateGit();
161
129
  if (!gg.ok) {
162
130
  return { ok: false, error: gg.body };
163
131
  }
164
- const abs = args.absoluteGitRoots;
165
- if (abs != null && abs.length > 0) {
132
+ const root = args.root;
133
+ if (Array.isArray(root)) {
166
134
  if (presetName) {
167
- return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_PRESET_CONFLICT } };
168
- }
169
- if (hasExclusiveWorkspacePick(args)) {
170
- return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_EXCLUSIVE } };
135
+ return { ok: false, error: { error: ERROR_CODES.ROOT_LIST_PRESET_CONFLICT } };
171
136
  }
172
- return resolveAbsoluteGitRootsList(abs);
137
+ return resolveRootPathList(root);
173
138
  }
174
- const rootsRes = presetName
175
- ? resolveRootsForPreset(server, args, presetName)
176
- : resolveWorkspaceRoots(server, args);
177
- if (!rootsRes.ok) {
178
- return { ok: false, error: rootsRes.error };
139
+ const trimmed = root?.trim();
140
+ if (trimmed === "*") {
141
+ const fileRoots = listFileRoots(server);
142
+ return fileRoots.length === 0 ? defaultRoots(fileRoots) : { ok: true, roots: fileRoots };
179
143
  }
180
- return { ok: true, roots: rootsRes.roots };
144
+ if (trimmed) {
145
+ return { ok: true, roots: [resolve(trimmed)] };
146
+ }
147
+ if (presetName) {
148
+ return resolveRootsForPreset(server, presetName);
149
+ }
150
+ return defaultRoots(listFileRoots(server));
181
151
  }
182
152
  /**
183
- * Convenience wrapper for single-repo tools: gate git, resolve roots, pick the first
184
- * root, and resolve its git toplevel. Returns `{ ok: true, gitTop }` or a structured
185
- * error payload ready for `jsonRespond`.
153
+ * Prelude for single-repo tools: gate git, resolve `workspaceRoot` (or the first
154
+ * MCP root / cwd), and resolve its git toplevel. Returns `{ ok: true, gitTop }`
155
+ * or a structured error payload ready for `jsonRespond`.
186
156
  */
187
- export function requireSingleRepo(server, args, presetName = undefined) {
188
- const pre = requireGitAndRoots(server, args, presetName);
189
- if (!pre.ok)
190
- return pre;
191
- if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0 && pre.roots.length !== 1) {
192
- return {
193
- ok: false,
194
- error: {
195
- error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_SINGLE_REPO_ONLY,
196
- rootCount: pre.roots.length,
197
- },
198
- };
157
+ export function requireSingleRepo(server, args) {
158
+ const gg = gateGit();
159
+ if (!gg.ok) {
160
+ return { ok: false, error: gg.body };
199
161
  }
200
- const rootInput = pre.roots[0];
201
- if (!rootInput)
202
- return { ok: false, error: { error: ERROR_CODES.NO_WORKSPACE_ROOT } };
162
+ const ws = args.workspaceRoot?.trim();
163
+ const rootInput = ws ? resolve(ws) : (listFileRoots(server)[0] ?? process.cwd());
203
164
  const top = gitTopLevel(rootInput);
204
165
  if (!top)
205
166
  return { ok: false, error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: rootInput } };
@@ -1,27 +1,19 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_INVENTORY_ROOTS_DEFAULT } from "./inventory.js";
3
3
  const FormatSchema = z.enum(["markdown", "json"]).optional().default("markdown");
4
- /** Max paths in `absoluteGitRoots` (matches `git_inventory` `maxRoots` hard cap). */
5
- export const MAX_ABSOLUTE_GIT_ROOTS = 256;
4
+ /** Max entries when `root` is an array (matches `git_inventory` `maxRoots` hard cap). */
5
+ export const MAX_ROOT_PATHS = 256;
6
+ /** Single-repo tools: one optional repo-path override plus output format. */
6
7
  export const WorkspacePickSchema = z.object({
7
- workspaceRoot: z.string().optional().describe("Highest-priority root override."),
8
- rootIndex: z
9
- .number()
10
- .int()
11
- .min(0)
12
- .optional()
13
- .describe("0-based index into MCP roots; ignored if workspaceRoot set."),
14
- allWorkspaceRoots: z
15
- .boolean()
16
- .optional()
17
- .default(false)
18
- .describe("Fan out across all MCP roots."),
19
- /** Independent git worktrees (sibling clones). Mutually exclusive with workspaceRoot, rootIndex, allWorkspaceRoots, and (git_inventory) preset/nestedRoots. */
20
- absoluteGitRoots: z
21
- .array(z.string())
22
- .max(MAX_ABSOLUTE_GIT_ROOTS)
8
+ workspaceRoot: z.string().optional().describe("Repo path. Default: first MCP root / cwd."),
9
+ format: FormatSchema,
10
+ });
11
+ /** Fan-out tools: one polymorphic routing param plus output format. */
12
+ export const RootPickSchema = z.object({
13
+ root: z
14
+ .union([z.string(), z.array(z.string()).max(MAX_ROOT_PATHS), z.literal("*")])
23
15
  .optional()
24
- .describe("Absolute paths to git repo roots; use for sibling clones under a non-git parent."),
16
+ .describe('Repo path, array of paths, or "*" for all MCP roots.'),
25
17
  format: FormatSchema,
26
18
  });
27
19
  export { MAX_INVENTORY_ROOTS_DEFAULT };
@@ -19,15 +19,15 @@ import { registerGitStatusTool } from "./git-status-tool.js";
19
19
  import { registerGitTagTool } from "./git-tag-tool.js";
20
20
  import { registerGitWorktreeAddTool, registerGitWorktreeListTool, registerGitWorktreeRemoveTool, } from "./git-worktree-tool.js";
21
21
  import { registerListPresetsTool } from "./list-presets-tool.js";
22
- export const READ_ONLY_ABSOLUTE_ROOT_TOOLS = [
22
+ export const FAN_OUT_ROOT_TOOLS = [
23
23
  "git_status",
24
24
  "git_inventory",
25
25
  "git_parity",
26
26
  "list_presets",
27
27
  "git_log",
28
- "git_diff_summary",
29
28
  ];
30
29
  export const READ_ONLY_SINGLE_REPO_TOOLS = [
30
+ "git_diff_summary",
31
31
  "git_diff",
32
32
  "git_show",
33
33
  "git_worktree_list",
@@ -49,7 +49,7 @@ export const MUTATING_TOOLS = [
49
49
  "git_stash_apply",
50
50
  ];
51
51
  export const ALL_PARAMETER_SCHEMA_TOOLS = [
52
- ...READ_ONLY_ABSOLUTE_ROOT_TOOLS,
52
+ ...FAN_OUT_ROOT_TOOLS,
53
53
  ...READ_ONLY_SINGLE_REPO_TOOLS,
54
54
  ...MUTATING_TOOLS,
55
55
  ];
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ import { registerRethunkGitTools } from "./server/tools.js";
7
7
  * (renamed/nested/omitted fields). Surfaced via the FastMCP `instructions`
8
8
  * field below, so it is discoverable in the MCP `initialize` response.
9
9
  */
10
- export const MCP_JSON_FORMAT_VERSION = "3";
10
+ export const MCP_JSON_FORMAT_VERSION = "5";
11
11
  const server = new FastMCP({
12
12
  name: "rethunk-git",
13
13
  version: readMcpServerVersion(),