Skip to content

Commit a1f4e7a

Browse files
committed
refactor(mcp): split tool registration into per-surface modules
Each MCP tool and the presets resource lives in its own file; tools.ts only orchestrates register* calls. Keeps behavior identical while shrinking the largest source file for easier navigation and review.
1 parent ba44745 commit a1f4e7a

File tree

7 files changed

+759
-703
lines changed

7 files changed

+759
-703
lines changed

AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `splitPresetFileRaw`, `loadPresetsFromGitTop`, `getPresetEntry`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod `PresetEntrySchema` / `PresetFileSchema` must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
1818
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
1919
| [`src/server/inventory.ts`](src/server/inventory.ts) | `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` (uses repo-paths + git) |
20-
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — all `addTool` / `addResource` handlers |
20+
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — calls per-surface `register*` below |
21+
| [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `registerGitStatusTool``git_status` |
22+
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `registerGitInventoryTool``git_inventory` |
23+
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `registerGitParityTool``git_parity` |
24+
| [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `registerListPresetsTool``list_presets` |
25+
| [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `registerPresetsResource``rethunk-git://presets` resource |
2126
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop`, `realPathOrSelf` |
2227

2328
## Changing contracts

src/server/git-inventory-tool.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
4+
import {
5+
asyncPool,
6+
GIT_SUBPROCESS_PARALLELISM,
7+
gitRevParseGitDir,
8+
gitTopLevel,
9+
isSafeGitUpstreamToken,
10+
} from "./git.js";
11+
import {
12+
buildInventorySectionMarkdown,
13+
collectInventoryEntry,
14+
type InventoryEntryJson,
15+
makeSkipEntry,
16+
validateRepoPath,
17+
} from "./inventory.js";
18+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
19+
import { applyPresetNestedRoots } from "./presets.js";
20+
import { requireGitAndRoots } from "./roots.js";
21+
import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
22+
23+
export function registerGitInventoryTool(server: FastMCP): void {
24+
server.addTool({
25+
name: "git_inventory",
26+
description:
27+
"Read-only push-prep inventory: status + ahead/behind per root. " +
28+
"Uses each repo's configured upstream (`@{u}`) unless both `remote` and `branch` are set. " +
29+
"Presets from `.rethunk/git-mcp-presets.json`; use `presetMerge` to combine with inline paths.",
30+
parameters: WorkspacePickSchema.extend({
31+
nestedRoots: z
32+
.array(z.string())
33+
.optional()
34+
.describe(
35+
"Paths relative to git toplevel. If empty/omitted, only the repo root is listed. With `presetMerge`, merged with preset paths.",
36+
),
37+
preset: z
38+
.string()
39+
.optional()
40+
.describe("Named preset from .rethunk/git-mcp-presets.json (at git toplevel)."),
41+
presetMerge: z
42+
.boolean()
43+
.optional()
44+
.default(false)
45+
.describe("When true, merge `nestedRoots` with preset nestedRoots instead of replacing."),
46+
remote: z
47+
.string()
48+
.optional()
49+
.describe(
50+
"Fixed upstream remote; must be set together with `branch` to override auto upstream.",
51+
),
52+
branch: z
53+
.string()
54+
.optional()
55+
.describe("Fixed upstream branch name; must be set together with `remote`."),
56+
maxRoots: z
57+
.number()
58+
.int()
59+
.min(1)
60+
.max(256)
61+
.optional()
62+
.default(MAX_INVENTORY_ROOTS_DEFAULT)
63+
.describe("Max nested roots to process (cap)."),
64+
}),
65+
execute: async (args) => {
66+
const pre = requireGitAndRoots(server, args, args.preset);
67+
if (!pre.ok) {
68+
return jsonRespond(pre.error);
69+
}
70+
71+
const fixedRemote = args.remote;
72+
const fixedBranch = args.branch;
73+
const hasRemote = fixedRemote !== undefined && fixedRemote.trim() !== "";
74+
const hasBranch = fixedBranch !== undefined && fixedBranch.trim() !== "";
75+
if (hasRemote !== hasBranch) {
76+
return jsonRespond({
77+
error: "remote_branch_mismatch",
78+
message:
79+
"Set both `remote` and `branch` for fixed upstream, or omit both for auto `@{u}`.",
80+
});
81+
}
82+
const useFixed = hasRemote && hasBranch;
83+
if (useFixed) {
84+
const r = String(fixedRemote).trim();
85+
const b = String(fixedBranch).trim();
86+
if (!isSafeGitUpstreamToken(r) || !isSafeGitUpstreamToken(b)) {
87+
return jsonRespond({
88+
error: "invalid_remote_or_branch",
89+
message:
90+
"remote and branch must be plain tokens: no whitespace, control characters, `@`, `..`, leading `-`, or git rev metacharacters like `^ : ? * [ ] { } ~ \\`.",
91+
});
92+
}
93+
}
94+
95+
const allJson: {
96+
workspace_root: string;
97+
presetSchemaVersion?: string;
98+
upstream: { mode: "auto" | "fixed"; remote?: string; branch?: string };
99+
entries: InventoryEntryJson[];
100+
}[] = [];
101+
102+
const mdChunks: string[] = [];
103+
104+
for (const workspaceRoot of pre.roots) {
105+
const top = gitTopLevel(workspaceRoot);
106+
if (!top) {
107+
const err = { error: "not_a_git_repository", path: workspaceRoot };
108+
if (args.format === "json") {
109+
allJson.push({
110+
workspace_root: workspaceRoot,
111+
upstream: {
112+
mode: useFixed ? "fixed" : "auto",
113+
remote: fixedRemote,
114+
branch: fixedBranch,
115+
},
116+
entries: [
117+
{
118+
label: workspaceRoot,
119+
path: workspaceRoot,
120+
branchStatus: "",
121+
shortStatus: "",
122+
detached: false,
123+
headAbbrev: "",
124+
upstreamMode: useFixed ? "fixed" : "auto",
125+
upstreamRef: null,
126+
ahead: null,
127+
behind: null,
128+
upstreamNote: "",
129+
skipReason: JSON.stringify(err),
130+
},
131+
],
132+
});
133+
} else {
134+
mdChunks.push(`# Git inventory`, "", jsonRespond(err), "");
135+
}
136+
continue;
137+
}
138+
139+
let nestedRoots: string[] | undefined = args.nestedRoots;
140+
let presetSchemaVersion: string | undefined;
141+
142+
if (args.preset) {
143+
const applied = applyPresetNestedRoots(top, args.preset, args.presetMerge, nestedRoots);
144+
if (!applied.ok) {
145+
return jsonRespond(applied.error);
146+
}
147+
nestedRoots = applied.nestedRoots;
148+
presetSchemaVersion = applied.presetSchemaVersion;
149+
}
150+
151+
const maxRoots = args.maxRoots ?? MAX_INVENTORY_ROOTS_DEFAULT;
152+
let nestedRootsTruncated = false;
153+
let nestedRootsOmittedCount = 0;
154+
if (nestedRoots && nestedRoots.length > maxRoots) {
155+
nestedRootsOmittedCount = nestedRoots.length - maxRoots;
156+
nestedRoots = nestedRoots.slice(0, maxRoots);
157+
nestedRootsTruncated = true;
158+
}
159+
160+
const headerNote = useFixed
161+
? `remote/branch (fixed): ${fixedRemote}/${fixedBranch}`
162+
: "upstream: per-repo @{u} (configured upstream)";
163+
164+
const entries: InventoryEntryJson[] = [];
165+
166+
if (nestedRoots?.length) {
167+
const jobs: { label: string; abs: string }[] = [];
168+
for (const rel of nestedRoots) {
169+
const { abs, underTop } = validateRepoPath(rel, top);
170+
if (!underTop) {
171+
entries.push(
172+
makeSkipEntry(
173+
rel,
174+
abs,
175+
useFixed ? "fixed" : "auto",
176+
"(path escapes git toplevel — rejected)",
177+
),
178+
);
179+
continue;
180+
}
181+
if (!gitRevParseGitDir(abs)) {
182+
entries.push(
183+
makeSkipEntry(
184+
rel,
185+
abs,
186+
useFixed ? "fixed" : "auto",
187+
"(not a git work tree — skip)",
188+
),
189+
);
190+
continue;
191+
}
192+
jobs.push({ label: rel, abs });
193+
}
194+
const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) =>
195+
collectInventoryEntry(
196+
j.label,
197+
j.abs,
198+
useFixed ? fixedRemote : undefined,
199+
useFixed ? fixedBranch : undefined,
200+
),
201+
);
202+
entries.push(...computed);
203+
} else if (!gitRevParseGitDir(top)) {
204+
entries.push(
205+
makeSkipEntry(
206+
".",
207+
top,
208+
useFixed ? "fixed" : "auto",
209+
"(not a git work tree — unexpected)",
210+
),
211+
);
212+
} else {
213+
const one = await collectInventoryEntry(
214+
".",
215+
top,
216+
useFixed ? fixedRemote : undefined,
217+
useFixed ? fixedBranch : undefined,
218+
);
219+
entries.push(one);
220+
}
221+
222+
if (args.format === "json") {
223+
allJson.push({
224+
workspace_root: top,
225+
...spreadDefined("presetSchemaVersion", presetSchemaVersion),
226+
...spreadWhen(nestedRootsTruncated, {
227+
nestedRootsTruncated: true,
228+
nestedRootsOmittedCount,
229+
}),
230+
upstream: useFixed
231+
? { mode: "fixed", remote: fixedRemote, branch: fixedBranch }
232+
: { mode: "auto" },
233+
entries,
234+
});
235+
} else {
236+
const sections: string[] = [
237+
"# Git inventory",
238+
"",
239+
`workspace_root: ${top}`,
240+
headerNote,
241+
"",
242+
];
243+
if (nestedRootsTruncated) {
244+
sections.push(
245+
`nested_roots_truncated: ${nestedRootsOmittedCount} path(s) not listed (maxRoots=${maxRoots})`,
246+
"",
247+
);
248+
}
249+
for (const e of entries) {
250+
sections.push(...buildInventorySectionMarkdown(e));
251+
}
252+
mdChunks.push(sections.join("\n"));
253+
}
254+
}
255+
256+
if (args.format === "json") {
257+
return jsonRespond({ inventories: allJson });
258+
}
259+
return mdChunks.join("\n\n---\n\n");
260+
},
261+
});
262+
}

0 commit comments

Comments
 (0)