@rethunk/mcp-multi-root-git 2.3.4 → 2.4.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 +29 -3
- package/CHANGELOG.md +43 -0
- package/HUMANS.md +54 -5
- package/README.md +15 -1
- package/dist/server/batch-commit-tool.js +244 -19
- package/dist/server/git-diff-tool.js +132 -0
- package/dist/server/git-fetch-tool.js +131 -0
- package/dist/server/git-push-tool.js +8 -1
- package/dist/server/git-show-tool.js +148 -0
- package/dist/server/git-stash-tool.js +131 -0
- package/dist/server/git-tag-tool.js +162 -0
- package/dist/server/git.js +18 -2
- package/dist/server/roots.js +8 -4
- package/dist/server/test-harness.js +68 -5
- package/dist/server/tool-parameter-schemas.js +21 -1
- package/dist/server/tools.js +11 -0
- package/docs/install.md +19 -2
- package/docs/mcp-tools.md +214 -5
- package/package.json +7 -5
- package/schemas/batch_commit.json +125 -0
- package/schemas/git_cherry_pick.json +63 -0
- package/schemas/git_diff.json +62 -0
- package/schemas/git_diff_summary.json +75 -0
- package/schemas/git_fetch.json +52 -0
- package/schemas/git_inventory.json +74 -0
- package/schemas/git_log.json +75 -0
- package/schemas/git_merge.json +79 -0
- package/schemas/git_parity.json +74 -0
- package/schemas/git_push.json +50 -0
- package/schemas/git_reset_soft.json +42 -0
- package/schemas/git_show.json +39 -0
- package/schemas/git_stash_apply.json +44 -0
- package/schemas/git_stash_list.json +30 -0
- package/schemas/git_status.json +49 -0
- package/schemas/git_tag.json +50 -0
- package/schemas/git_worktree_add.json +52 -0
- package/schemas/git_worktree_list.json +30 -0
- package/schemas/git_worktree_remove.json +48 -0
- package/schemas/index.json +108 -0
- package/schemas/list_presets.json +44 -0
- package/tool-parameters.schema.json +317 -4
package/AGENTS.md
CHANGED
|
@@ -9,6 +9,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
9
9
|
- Tools, JSON shape, resources, root resolution → [docs/mcp-tools.md](docs/mcp-tools.md)
|
|
10
10
|
- Dev setup, CI, commit conventions → [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
11
11
|
- Presets, auth, publish → [HUMANS.md](HUMANS.md)
|
|
12
|
+
- Spec layout for repo planning → [specs/README.md](specs/README.md)
|
|
12
13
|
|
|
13
14
|
## Implementation map
|
|
14
15
|
|
|
@@ -29,6 +30,11 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
29
30
|
| [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `list_presets` |
|
|
30
31
|
| [`src/server/git-log-tool.ts`](src/server/git-log-tool.ts) | `git_log` — v3 JSON shape: `sha` (full), `workspaceRoot`, no `sha7`/`ageRelative`, optional `email` |
|
|
31
32
|
| [`src/server/git-diff-summary-tool.ts`](src/server/git-diff-summary-tool.ts) | `git_diff_summary` — structured token-efficient diff viewer; read-only |
|
|
33
|
+
| [`src/server/git-diff-tool.ts`](src/server/git-diff-tool.ts) | `git_diff` — raw scoped diff text; read-only |
|
|
34
|
+
| [`src/server/git-show-tool.ts`](src/server/git-show-tool.ts) | `git_show` — inspect commit message + diff or file content at a ref; read-only |
|
|
35
|
+
| [`src/server/git-stash-tool.ts`](src/server/git-stash-tool.ts) | `git_stash_list`, `git_stash_apply` |
|
|
36
|
+
| [`src/server/git-fetch-tool.ts`](src/server/git-fetch-tool.ts) | `git_fetch` — fetch remote refs without touching the working tree |
|
|
37
|
+
| [`src/server/git-tag-tool.ts`](src/server/git-tag-tool.ts) | `git_tag` — create/delete annotated or lightweight tags |
|
|
32
38
|
| [`src/server/git-worktree-tool.ts`](src/server/git-worktree-tool.ts) | `git_worktree_list`, `git_worktree_add`, `git_worktree_remove` |
|
|
33
39
|
| [`src/server/batch-commit-tool.ts`](src/server/batch-commit-tool.ts) | `batch_commit` — sequential multi-commit; mutating; exports `PushReport`, `runPushAfter` |
|
|
34
40
|
| [`src/server/git-push-tool.ts`](src/server/git-push-tool.ts) | `git_push` — standalone push with optional upstream tracking |
|
|
@@ -36,7 +42,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
36
42
|
| [`src/server/git-cherry-pick-tool.ts`](src/server/git-cherry-pick-tool.ts) | `git_cherry_pick` — mutating |
|
|
37
43
|
| [`src/server/git-reset-soft-tool.ts`](src/server/git-reset-soft-tool.ts) | `git_reset_soft` — soft-reset; mutating |
|
|
38
44
|
| [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
|
|
39
|
-
| [`src/server/tool-parameter-schemas.ts`](src/server/tool-parameter-schemas.ts) | `buildToolParameterSchemaDocument`, `captureToolParameterSchemas`; backs `tool-parameters.schema.json` |
|
|
45
|
+
| [`src/server/tool-parameter-schemas.ts`](src/server/tool-parameter-schemas.ts) | `buildToolParameterSchemaDocument`, `captureToolParameterSchemas`; backs `tool-parameters.schema.json` and published `schemas/*.json` snapshots |
|
|
40
46
|
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop` |
|
|
41
47
|
|
|
42
48
|
## Changing contracts
|
|
@@ -44,7 +50,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
44
50
|
- **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
|
|
45
51
|
- **JSON format version** (currently `"3"`, discoverable via MCP `initialize`): 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.
|
|
46
52
|
- **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
|
|
47
|
-
- **Public tool surface:** rename/add → update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned). Install/client wiring → [docs/install.md](docs/install.md) only.
|
|
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.
|
|
48
54
|
|
|
49
55
|
## Validate + CI
|
|
50
56
|
|
|
@@ -54,10 +60,30 @@ Optional [`.githooks/`](.githooks): `bun run setup-hooks` once per clone. pre-co
|
|
|
54
60
|
|
|
55
61
|
Path confinement: [`src/repo-paths.ts`](src/repo-paths.ts) — extend tests when changing.
|
|
56
62
|
|
|
63
|
+
## AI constraints
|
|
64
|
+
|
|
65
|
+
Rules for LLMs operating in or against this repository.
|
|
66
|
+
|
|
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
|
+
|
|
69
|
+
**Mutating tools require workspace-root confirmation** — `git_fetch`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, and `git_stash_apply` operate only on roots confirmed by `requireGitAndRoots` / `requireSingleRepo`. Never pass caller-supplied absolute paths to mutating tools; use `workspaceRoot` or MCP roots.
|
|
70
|
+
|
|
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
|
+
|
|
73
|
+
**`absoluteGitRoots` is read-only** — pass it only on read tools (`git_status`, `git_inventory`, `git_parity`, `git_log`, `git_diff_summary`, `list_presets`). Mutating tools reject this parameter.
|
|
74
|
+
|
|
75
|
+
**Protected branches are enforced by the server** — do not attempt `git_worktree_add` with a branch name matching `main`, `master`, `dev`, `develop`, `stable`, `trunk`, `prod`, `production`, `release*`, or `hotfix*`. The server rejects such calls.
|
|
76
|
+
|
|
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
|
+
|
|
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).
|
|
80
|
+
|
|
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
|
+
|
|
57
83
|
## Repo MCP entry (contributors)
|
|
58
84
|
|
|
59
85
|
Dogfood from clone: [docs/install.md](docs/install.md) — *From source*.
|
|
60
86
|
|
|
61
|
-
|
|
87
|
+
Client Git policy is still **`~/.claude/CLAUDE.md`** § Git & GitHub (see **End-user Git/MCP preference** above).
|
|
62
88
|
|
|
63
89
|
User-level skills may mention README for discovery. Canonical refs: tools/JSON → [docs/mcp-tools.md](docs/mcp-tools.md); install → [docs/install.md](docs/install.md); presets → [HUMANS.md](HUMANS.md).
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
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
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [2.4.0] — 2026-05-07
|
|
8
|
+
|
|
9
|
+
New git MCP tools, better `batch_commit` ergonomics, published schema coverage for the full tool surface, and a broad docs/test refresh since `v2.3.4`.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **New tools:** `git_fetch`, `git_diff`, `git_show`, `git_tag`, `git_stash_list`, and `git_stash_apply`.
|
|
14
|
+
- **`batch_commit` enhancements:** `dryRun: true` preview mode plus hunk-level staging via `{ path, lines: { from, to } }`.
|
|
15
|
+
- **Published per-tool schema artifacts:** `schemas/index.json` plus one JSON Schema file per tool alongside `tool-parameters.schema.json`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`GIT_SUBPROCESS_PARALLELISM`** is now configurable via environment and clamps to a safe `2×CPU` maximum.
|
|
20
|
+
- **`git_show`**, **`git_fetch`**, and **`git_tag`** now use the standard single-repo workspace pick (`workspaceRoot` / `rootIndex`) and omit multi-root-only parameters.
|
|
21
|
+
- **Build / release tooling** now aligns on Bun `1.3.13`, updated dev dependencies, and the current prerelease tarball flow.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **MCP roots** — workspace root collection now scans active MCP sessions and dedupes `file://` roots instead of relying on a fixed server `cwd`.
|
|
26
|
+
- **`git_push`** and **`batch_commit`** now surface raw git stdout/stderr on failure for easier recovery.
|
|
27
|
+
- **Published schema snapshots** are now complete and in sync with the registered tool surface, including `schema:tools:check`.
|
|
28
|
+
- **`publish:preflight`** now writes temporary coverage output under the platform temp directory instead of a hard-coded `/tmp` path.
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- **README / HUMANS / AGENTS / CONTRIBUTING / install docs** refreshed for the current tool surface, shipped schema artifacts, and contributor workflow.
|
|
33
|
+
- **`SECURITY.md`** added with repository access, git-operation risk, and disclosure guidance.
|
|
34
|
+
- **`docs/mcp-tools.md`** now documents the full tool surface, `batch_commit` atomic staging semantics, and the shipped schema artifacts.
|
|
35
|
+
- **`TODO.md`** backlog entries now reflect genuine remaining gaps instead of listing already-implemented tools as missing.
|
|
36
|
+
- **`specs/` scaffold** added with standard `active`, `done`, and `parked` layout for repo planning.
|
|
37
|
+
- **CHANGELOG references** for `v2.3.2`–`v2.3.4` were restored.
|
|
38
|
+
|
|
39
|
+
### Tests
|
|
40
|
+
|
|
41
|
+
- Coverage expanded across `list_presets`, `git_parity`, `git_cherry_pick`, `git_merge`, `git_show`, schema generation, and roots handling.
|
|
42
|
+
- The shared git test harness now reuses repo-init / commit helpers and speeds up fixture setup.
|
|
43
|
+
|
|
5
44
|
## [2.3.4] — 2026-04-26
|
|
6
45
|
|
|
7
46
|
Publication-prep patch for the `absoluteGitRoots` line.
|
|
@@ -138,6 +177,10 @@ Mutating git operations: merge, cherry-pick, and optional push-after for `batch_
|
|
|
138
177
|
|
|
139
178
|
- Initial release: `git_status`, `git_inventory`, `git_parity`, `list_presets`.
|
|
140
179
|
|
|
180
|
+
[2.4.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.4.0
|
|
181
|
+
[2.3.4]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.4
|
|
182
|
+
[2.3.3]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.3
|
|
183
|
+
[2.3.2]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.2
|
|
141
184
|
[2.3.1]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.1
|
|
142
185
|
[2.3.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.0
|
|
143
186
|
[2.2.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.2.0
|
package/HUMANS.md
CHANGED
|
@@ -55,19 +55,21 @@ If you installed from **GitHub Packages**, use **`./node_modules/@rethunk-ai/mcp
|
|
|
55
55
|
|
|
56
56
|
## Tool parameter schema artifact
|
|
57
57
|
|
|
58
|
-
The package ships **`tool-parameters.schema.json`**, a generated JSON Schema snapshot of every registered tool's parameter schema. Maintainers regenerate
|
|
58
|
+
The package ships **`tool-parameters.schema.json`**, a generated JSON Schema snapshot of every registered tool's parameter schema, plus the published **`schemas/`** directory with **`schemas/index.json`** and one per-tool JSON Schema file. Maintainers regenerate them with:
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
bun run schema:tools
|
|
62
|
+
bun run schema:individual
|
|
62
63
|
```
|
|
63
64
|
|
|
64
65
|
CI and `prepublishOnly` use:
|
|
65
66
|
|
|
66
67
|
```bash
|
|
67
68
|
bun run schema:tools:check
|
|
69
|
+
bun run schema:individual
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
These artifacts are for inspection, drift checks, code generation, and clients that want offline schema snapshots. Runtime MCP schema discovery remains the source of truth for connected clients.
|
|
71
73
|
|
|
72
74
|
**Layouts:**
|
|
73
75
|
|
|
@@ -121,13 +123,60 @@ Example `git_parity` batch using the same pair in each sibling clone:
|
|
|
121
123
|
|
|
122
124
|
`absoluteGitRoots` is read-only by design. Mutating tools such as **`batch_commit`**, **`git_push`**, **`git_merge`**, and **`git_cherry_pick`** omit it from their schema; use `workspaceRoot` or MCP roots for writes. Full parameter rules and error codes live in **[docs/mcp-tools.md](docs/mcp-tools.md#absoluteGitRoots-sibling-clones)**.
|
|
123
125
|
|
|
126
|
+
## Prerequisites
|
|
127
|
+
|
|
128
|
+
- **Git** on `PATH` (`git --version`). Missing git → all tools return `git_not_found`.
|
|
129
|
+
- **Node.js ≥ 22** (for `npx`) or **Bun** (for `bunx`). See `engines` in `package.json`.
|
|
130
|
+
- No other runtime dependencies; the server is a self-contained MCP stdio process.
|
|
131
|
+
|
|
132
|
+
## Running the server
|
|
133
|
+
|
|
134
|
+
The server is a **stdio** MCP process — your MCP client starts it. You do not run it directly. Quick start:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# npmjs (public, may lag tags)
|
|
138
|
+
npx -y @rethunk/mcp-multi-root-git
|
|
139
|
+
|
|
140
|
+
# GitHub Packages (CI-aligned, every tag)
|
|
141
|
+
npx -y @rethunk-ai/mcp-multi-root-git # requires ~/.npmrc with @rethunk-ai registry token
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Add the server to your MCP client config under a stable name (e.g. `rethunk-git`). Full per-client config (Cursor, VS Code, Claude Desktop, Zed): **[docs/install.md](docs/install.md)**.
|
|
145
|
+
|
|
146
|
+
## Common operations
|
|
147
|
+
|
|
148
|
+
Call tools by their registered id (prefix depends on client config name):
|
|
149
|
+
|
|
150
|
+
| Operation | Tool |
|
|
151
|
+
|-----------|------|
|
|
152
|
+
| Check status across workspace roots | `git_status` |
|
|
153
|
+
| Status + ahead/behind for submodules | `git_inventory` |
|
|
154
|
+
| Compare HEAD between path pairs | `git_parity` |
|
|
155
|
+
| List preset names | `list_presets` |
|
|
156
|
+
| View commit log | `git_log` |
|
|
157
|
+
| View structured diff | `git_diff_summary` |
|
|
158
|
+
| View raw diff text | `git_diff` |
|
|
159
|
+
| Inspect commit content | `git_show` |
|
|
160
|
+
| List stashes | `git_stash_list` |
|
|
161
|
+
| Apply or pop a stash | `git_stash_apply` |
|
|
162
|
+
| Fetch remote refs | `git_fetch` |
|
|
163
|
+
| Create commits | `batch_commit` |
|
|
164
|
+
| Push a branch | `git_push` |
|
|
165
|
+
| Create or delete tags | `git_tag` |
|
|
166
|
+
| Merge branches | `git_merge` |
|
|
167
|
+
| Cherry-pick commits | `git_cherry_pick` |
|
|
168
|
+
| Soft-reset HEAD | `git_reset_soft` |
|
|
169
|
+
| Manage worktrees | `git_worktree_list` / `git_worktree_add` / `git_worktree_remove` |
|
|
170
|
+
|
|
171
|
+
Full parameter tables and JSON shapes: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
|
|
172
|
+
|
|
124
173
|
## `git_not_found`
|
|
125
174
|
|
|
126
|
-
If **`git`** is missing or not runnable, tools and the presets resource respond with **`git_not_found`**.
|
|
175
|
+
If **`git`** is missing or not runnable, tools and the presets resource respond with **`git_not_found`**. Ensure `git` is on `PATH` in the environment that launches the MCP server.
|
|
127
176
|
|
|
128
177
|
## Installation
|
|
129
178
|
|
|
130
|
-
**
|
|
179
|
+
**Full install instructions and MCP client wiring:** **[docs/install.md](docs/install.md)**.
|
|
131
180
|
|
|
132
181
|
## Development
|
|
133
182
|
|
|
@@ -150,7 +199,7 @@ Before tagging, run the clean-tree preflight from the release commit:
|
|
|
150
199
|
bun run publish:preflight
|
|
151
200
|
```
|
|
152
201
|
|
|
153
|
-
It verifies the `package.json` version has a matching `CHANGELOG.md` section,
|
|
202
|
+
It verifies the `package.json` version has a matching `CHANGELOG.md` section, both schema artifacts are current, build/check/coverage pass, and `npm pack --dry-run` includes the expected package files.
|
|
154
203
|
|
|
155
204
|
### npmjs (manual) — maintainers only
|
|
156
205
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
**git** tools over MCP:
|
|
7
|
+
**git** tools over MCP: status, multi-root inventory, `HEAD` parity, presets, logs, structured and raw diff views, commit inspection, fetch, stash, tag, batch commit, push, merge, cherry-pick, worktrees, and soft-reset. **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
|
|
8
8
|
|
|
9
9
|
**Repository:** [github.com/Rethunk-AI/mcp-multi-root-git](https://github.com/Rethunk-AI/mcp-multi-root-git) · **npmjs (manual releases):** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) · **GitHub Packages (CI on each tag):** [`@rethunk-ai/mcp-multi-root-git`](https://github.com/Rethunk-AI/mcp-multi-root-git/packages) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
|
|
10
10
|
|
|
@@ -14,7 +14,21 @@
|
|
|
14
14
|
|-----|----------|
|
|
15
15
|
| **[docs/install.md](docs/install.md)** | Single source for prerequisites, running the package, and every supported MCP client (plus from-source and troubleshooting) |
|
|
16
16
|
| **[docs/mcp-tools.md](docs/mcp-tools.md)** | Tool ids, client naming, `format` / JSON, resource URI, workspace root resolution (canonical reference) |
|
|
17
|
+
| **[schemas/index.json](schemas/index.json)** | Published JSON schemas for all MCP tool parameters (JSON Schema draft 2020-12 format) |
|
|
17
18
|
| **[HUMANS.md](HUMANS.md)** | Preset file, dev commands, CI, publishing |
|
|
18
19
|
| **[AGENTS.md](AGENTS.md)** | Contributors: implementation map ([`src/server/`](src/server/) + entry [`src/server.ts`](src/server.ts)), contract bumps, CI |
|
|
20
|
+
| **[CONTRIBUTING.md](CONTRIBUTING.md)** | Dev setup, hooks, commit conventions, release checks, and how to add tools |
|
|
21
|
+
| **[SECURITY.md](SECURITY.md)** | Disclosure policy, threat model, and repository safety guidance |
|
|
22
|
+
| **[specs/README.md](specs/README.md)** | Active / done / parked specification layout used for repo planning |
|
|
19
23
|
|
|
20
24
|
**Tools at a glance:** see the table in **[docs/mcp-tools.md](docs/mcp-tools.md)**.
|
|
25
|
+
|
|
26
|
+
## JSON Schemas
|
|
27
|
+
|
|
28
|
+
All MCP tool parameters are published as JSON Schema (draft 2020-12) documents in the **[`schemas/`](schemas/)** directory. Tools and validators can use these schemas to validate tool input arguments without reading source code.
|
|
29
|
+
|
|
30
|
+
**Schema files:**
|
|
31
|
+
- `schemas/index.json` — Index of all 20 tool schemas with file paths
|
|
32
|
+
- `schemas/{tool_name}.json` — Individual schema for each tool (e.g., `schemas/batch_commit.json`, `schemas/git_status.json`)
|
|
33
|
+
|
|
34
|
+
For programmatic use, read `schemas/index.json` to discover available tools and their schema files.
|
|
@@ -5,9 +5,24 @@ import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
|
|
|
5
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
6
|
import { requireSingleRepo } from "./roots.js";
|
|
7
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
const FileEntrySchema = z.union([
|
|
9
|
+
z.string().min(1),
|
|
10
|
+
z.object({
|
|
11
|
+
path: z.string().min(1).describe("File path relative to git root."),
|
|
12
|
+
lines: z
|
|
13
|
+
.object({
|
|
14
|
+
from: z.number().int().min(1).describe("Start line number (1-indexed)."),
|
|
15
|
+
to: z.number().int().min(1).describe("End line number (1-indexed, inclusive)."),
|
|
16
|
+
})
|
|
17
|
+
.describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
|
|
18
|
+
}),
|
|
19
|
+
]);
|
|
8
20
|
const CommitEntrySchema = z.object({
|
|
9
21
|
message: z.string().min(1).describe("Commit message."),
|
|
10
|
-
files: z
|
|
22
|
+
files: z
|
|
23
|
+
.array(FileEntrySchema)
|
|
24
|
+
.min(1)
|
|
25
|
+
.describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."),
|
|
11
26
|
});
|
|
12
27
|
const PushModeSchema = z
|
|
13
28
|
.enum(["never", "after"])
|
|
@@ -16,6 +31,132 @@ const PushModeSchema = z
|
|
|
16
31
|
.describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
|
|
17
32
|
"fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
|
|
18
33
|
"Enum reserved for future modes such as `force-with-lease`.");
|
|
34
|
+
const DryRunSchema = z
|
|
35
|
+
.boolean()
|
|
36
|
+
.optional()
|
|
37
|
+
.default(false)
|
|
38
|
+
.describe("When true, stage files, collect preview (files staged, commit messages), return preview response without writing commits. " +
|
|
39
|
+
"Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
|
|
40
|
+
/**
|
|
41
|
+
* Parses a unified diff to extract hunks that overlap with a given line range.
|
|
42
|
+
* Returns a partial patch containing only the overlapping hunks, including header lines.
|
|
43
|
+
* Uses new file line numbers (after @@) to determine overlap.
|
|
44
|
+
*/
|
|
45
|
+
function extractOverlappingHunks(diffContent, fromLine, toLine) {
|
|
46
|
+
const lines = diffContent.split("\n");
|
|
47
|
+
// Find file header lines (index, ---, +++)
|
|
48
|
+
const fileHeaderLines = [];
|
|
49
|
+
let firstHunkIdx = -1;
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i];
|
|
52
|
+
if (line === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
if (line.startsWith("@@")) {
|
|
55
|
+
firstHunkIdx = i;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
fileHeaderLines.push(line);
|
|
59
|
+
}
|
|
60
|
+
if (firstHunkIdx === -1) {
|
|
61
|
+
// No hunks found
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const result = [...fileHeaderLines];
|
|
65
|
+
let i = firstHunkIdx;
|
|
66
|
+
while (i < lines.length) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
if (line === undefined) {
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Match hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
73
|
+
const hunkMatch = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
|
|
74
|
+
if (hunkMatch) {
|
|
75
|
+
const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
|
|
76
|
+
const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
|
|
77
|
+
const hunkEnd = newStart + newCount - 1;
|
|
78
|
+
// Check if hunk overlaps with requested line range
|
|
79
|
+
const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
|
|
80
|
+
if (hasOverlap) {
|
|
81
|
+
// Add hunk header
|
|
82
|
+
result.push(line);
|
|
83
|
+
i++;
|
|
84
|
+
// Add hunk content until next hunk or EOF
|
|
85
|
+
while (i < lines.length) {
|
|
86
|
+
const contentLine = lines[i];
|
|
87
|
+
if (contentLine === undefined) {
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Stop at next hunk header
|
|
92
|
+
if (contentLine.startsWith("@@")) {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
result.push(contentLine);
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Skip hunk
|
|
101
|
+
i++;
|
|
102
|
+
while (i < lines.length) {
|
|
103
|
+
const contentLine = lines[i];
|
|
104
|
+
if (contentLine === undefined) {
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (contentLine.startsWith("@@")) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return result.length > fileHeaderLines.length ? result.join("\n") : null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Stages a file with optional line range. If lines are provided, only hunks
|
|
123
|
+
* overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
|
|
124
|
+
*/
|
|
125
|
+
async function stageFile(gitTop, filePath, lines) {
|
|
126
|
+
if (!lines) {
|
|
127
|
+
// Simple case: stage the whole file
|
|
128
|
+
const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
|
|
129
|
+
return {
|
|
130
|
+
ok: addResult.ok,
|
|
131
|
+
error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Line range case: extract overlapping hunks and apply patch
|
|
135
|
+
const diffResult = await spawnGitAsync(gitTop, ["diff", filePath]);
|
|
136
|
+
if (!diffResult.ok) {
|
|
137
|
+
return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
|
|
138
|
+
}
|
|
139
|
+
const partialPatch = extractOverlappingHunks(diffResult.stdout, lines.from, lines.to);
|
|
140
|
+
if (!partialPatch) {
|
|
141
|
+
return { ok: false, error: "No hunks found in line range" };
|
|
142
|
+
}
|
|
143
|
+
// Write partial patch to temp file in the git repo and apply it to the index
|
|
144
|
+
const tempPatchFile = `${gitTop}/.git/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
|
|
145
|
+
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
146
|
+
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
147
|
+
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
148
|
+
// Clean up temp file
|
|
149
|
+
try {
|
|
150
|
+
unlinkSync(tempPatchFile);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Ignore cleanup errors
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: applyResult.ok,
|
|
157
|
+
error: applyResult.ok ? undefined : (applyResult.stderr || applyResult.stdout).trim(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
19
160
|
/**
|
|
20
161
|
* After all commits succeed, push the current branch to its upstream.
|
|
21
162
|
* Commits are already applied at this point — do NOT attempt rollback on push failure.
|
|
@@ -39,7 +180,13 @@ export async function runPushAfter(gitTop) {
|
|
|
39
180
|
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
40
181
|
};
|
|
41
182
|
}
|
|
42
|
-
|
|
183
|
+
const gitOutput = (pushResult.stdout || pushResult.stderr).trim();
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
branch,
|
|
187
|
+
upstream: t.upstream,
|
|
188
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
189
|
+
};
|
|
43
190
|
}
|
|
44
191
|
export function registerBatchCommitTool(server) {
|
|
45
192
|
server.addTool({
|
|
@@ -47,7 +194,8 @@ export function registerBatchCommitTool(server) {
|
|
|
47
194
|
description: "Create multiple sequential git commits in a single call. " +
|
|
48
195
|
"Each entry stages the listed files then commits with the given message. " +
|
|
49
196
|
'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
|
|
50
|
-
"to its upstream once all commits succeed."
|
|
197
|
+
"to its upstream once all commits succeed. " +
|
|
198
|
+
"Optional `dryRun: true` previews what would be staged/committed without writing commits.",
|
|
51
199
|
annotations: {
|
|
52
200
|
readOnlyHint: false,
|
|
53
201
|
destructiveHint: false,
|
|
@@ -60,6 +208,7 @@ export function registerBatchCommitTool(server) {
|
|
|
60
208
|
.max(50)
|
|
61
209
|
.describe("Commits to create, applied in order."),
|
|
62
210
|
push: PushModeSchema,
|
|
211
|
+
dryRun: DryRunSchema,
|
|
63
212
|
}),
|
|
64
213
|
execute: async (args) => {
|
|
65
214
|
const pre = requireSingleRepo(server, args);
|
|
@@ -67,16 +216,30 @@ export function registerBatchCommitTool(server) {
|
|
|
67
216
|
return jsonRespond(pre.error);
|
|
68
217
|
const gitTop = pre.gitTop;
|
|
69
218
|
const results = [];
|
|
219
|
+
const stagedFilesForCleanup = new Set();
|
|
70
220
|
for (let i = 0; i < args.commits.length; i++) {
|
|
71
221
|
const entry = args.commits[i];
|
|
72
222
|
if (!entry)
|
|
73
223
|
break;
|
|
224
|
+
// Normalize file entries to { path, lines? } format
|
|
225
|
+
const fileEntries = [];
|
|
226
|
+
const filePaths = [];
|
|
227
|
+
for (const fileEntry of entry.files) {
|
|
228
|
+
if (typeof fileEntry === "string") {
|
|
229
|
+
fileEntries.push({ path: fileEntry });
|
|
230
|
+
filePaths.push(fileEntry);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
fileEntries.push(fileEntry);
|
|
234
|
+
filePaths.push(fileEntry.path);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
74
237
|
// --- Validate all paths are under the git toplevel ---
|
|
75
238
|
const escapedPaths = [];
|
|
76
|
-
for (const
|
|
77
|
-
const abs = resolvePathForRepo(
|
|
239
|
+
for (const path of filePaths) {
|
|
240
|
+
const abs = resolvePathForRepo(path, gitTop);
|
|
78
241
|
if (!isStrictlyUnderGitTop(abs, gitTop)) {
|
|
79
|
-
escapedPaths.push(
|
|
242
|
+
escapedPaths.push(path);
|
|
80
243
|
}
|
|
81
244
|
}
|
|
82
245
|
if (escapedPaths.length > 0) {
|
|
@@ -84,53 +247,94 @@ export function registerBatchCommitTool(server) {
|
|
|
84
247
|
index: i,
|
|
85
248
|
ok: false,
|
|
86
249
|
message: entry.message,
|
|
87
|
-
files:
|
|
250
|
+
files: filePaths,
|
|
88
251
|
error: "path_escapes_repository",
|
|
89
252
|
detail: escapedPaths.join(", "),
|
|
90
253
|
});
|
|
91
254
|
break;
|
|
92
255
|
}
|
|
93
|
-
// --- Stage files ---
|
|
94
|
-
|
|
95
|
-
|
|
256
|
+
// --- Stage files (with optional line ranges) ---
|
|
257
|
+
let stagingFailed = false;
|
|
258
|
+
let stagingError = "";
|
|
259
|
+
for (const fileEntry of fileEntries) {
|
|
260
|
+
const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
|
|
261
|
+
if (!stageResult.ok) {
|
|
262
|
+
stagingFailed = true;
|
|
263
|
+
stagingError = stageResult.error || "Unknown error";
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (stagingFailed) {
|
|
96
268
|
results.push({
|
|
97
269
|
index: i,
|
|
98
270
|
ok: false,
|
|
99
271
|
message: entry.message,
|
|
100
|
-
files:
|
|
272
|
+
files: filePaths,
|
|
101
273
|
error: "stage_failed",
|
|
102
|
-
detail:
|
|
274
|
+
detail: stagingError,
|
|
275
|
+
...spreadDefined("output", stagingError || undefined),
|
|
103
276
|
});
|
|
104
277
|
break;
|
|
105
278
|
}
|
|
279
|
+
// Track staged files for cleanup in dry-run
|
|
280
|
+
if (args.dryRun) {
|
|
281
|
+
for (const path of filePaths) {
|
|
282
|
+
stagedFilesForCleanup.add(path);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// --- Dry-run mode: collect preview and unstage ---
|
|
286
|
+
if (args.dryRun) {
|
|
287
|
+
// Get diff stat for this staged entry
|
|
288
|
+
const diffStatResult = await spawnGitAsync(gitTop, ["diff", "--staged", "--stat"]);
|
|
289
|
+
const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
|
|
290
|
+
results.push({
|
|
291
|
+
index: i,
|
|
292
|
+
ok: true,
|
|
293
|
+
message: entry.message,
|
|
294
|
+
files: filePaths,
|
|
295
|
+
staged: filePaths,
|
|
296
|
+
...spreadDefined("diffStat", diffStat || undefined),
|
|
297
|
+
});
|
|
298
|
+
continue; // Skip actual commit in dry-run mode
|
|
299
|
+
}
|
|
106
300
|
// --- Commit ---
|
|
107
301
|
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
|
|
108
302
|
if (!commitResult.ok) {
|
|
303
|
+
const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
|
|
109
304
|
results.push({
|
|
110
305
|
index: i,
|
|
111
306
|
ok: false,
|
|
112
307
|
message: entry.message,
|
|
113
|
-
files:
|
|
308
|
+
files: filePaths,
|
|
114
309
|
error: "commit_failed",
|
|
115
|
-
detail:
|
|
310
|
+
detail: gitOutput,
|
|
311
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
116
312
|
});
|
|
117
313
|
break;
|
|
118
314
|
}
|
|
119
315
|
// --- Extract SHA from commit output ---
|
|
120
316
|
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
|
|
317
|
+
const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
|
|
121
318
|
results.push({
|
|
122
319
|
index: i,
|
|
123
320
|
ok: true,
|
|
124
321
|
sha: shaMatch?.[1],
|
|
125
322
|
message: entry.message,
|
|
126
|
-
files:
|
|
323
|
+
files: filePaths,
|
|
324
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
127
325
|
});
|
|
128
326
|
}
|
|
327
|
+
// --- In dry-run mode, unstage all files ---
|
|
328
|
+
if (args.dryRun && stagedFilesForCleanup.size > 0) {
|
|
329
|
+
const filesToReset = Array.from(stagedFilesForCleanup);
|
|
330
|
+
await spawnGitAsync(gitTop, ["reset", "HEAD", "--", ...filesToReset]);
|
|
331
|
+
}
|
|
129
332
|
const allOk = results.length === args.commits.length && results.every((r) => r.ok);
|
|
130
|
-
// --- Optional push after all commits succeed ---
|
|
131
|
-
const push = allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
|
|
333
|
+
// --- Optional push after all commits succeed (not in dry-run mode) ---
|
|
334
|
+
const push = !args.dryRun && allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
|
|
132
335
|
if (args.format === "json") {
|
|
133
336
|
return jsonRespond({
|
|
337
|
+
...spreadWhen(args.dryRun, { dryRun: true }),
|
|
134
338
|
ok: allOk,
|
|
135
339
|
committed: results.filter((r) => r.ok).length,
|
|
136
340
|
total: args.commits.length,
|
|
@@ -140,8 +344,11 @@ export function registerBatchCommitTool(server) {
|
|
|
140
344
|
...spreadDefined("sha", r.sha),
|
|
141
345
|
message: r.message,
|
|
142
346
|
files: r.files,
|
|
347
|
+
...spreadDefined("staged", r.staged),
|
|
348
|
+
...spreadDefined("diffStat", r.diffStat),
|
|
143
349
|
...spreadDefined("error", r.error),
|
|
144
350
|
...spreadDefined("detail", r.detail),
|
|
351
|
+
...spreadDefined("output", r.output),
|
|
145
352
|
})),
|
|
146
353
|
...spreadWhen(push !== undefined, {
|
|
147
354
|
push: {
|
|
@@ -150,15 +357,17 @@ export function registerBatchCommitTool(server) {
|
|
|
150
357
|
...spreadDefined("upstream", push?.upstream),
|
|
151
358
|
...spreadDefined("error", push?.error),
|
|
152
359
|
...spreadDefined("detail", push?.detail),
|
|
360
|
+
...spreadDefined("output", push?.output),
|
|
153
361
|
},
|
|
154
362
|
}),
|
|
155
363
|
});
|
|
156
364
|
}
|
|
157
365
|
// --- Markdown ---
|
|
158
366
|
const lines = [];
|
|
367
|
+
const dryRunPrefix = args.dryRun ? "DRY RUN — " : "";
|
|
159
368
|
const header = allOk
|
|
160
|
-
? `# Batch commit: ${results.length}/${args.commits.length} committed`
|
|
161
|
-
: `# Batch commit: ${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
|
|
369
|
+
? `# Batch commit: ${dryRunPrefix}${results.length}/${args.commits.length} committed`
|
|
370
|
+
: `# Batch commit: ${dryRunPrefix}${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
|
|
162
371
|
lines.push(header, "");
|
|
163
372
|
for (const r of results) {
|
|
164
373
|
const icon = r.ok ? "✓" : "✗";
|
|
@@ -167,11 +376,24 @@ export function registerBatchCommitTool(server) {
|
|
|
167
376
|
if (!r.ok && r.detail) {
|
|
168
377
|
lines.push(` Error: ${r.error} — ${r.detail}`);
|
|
169
378
|
}
|
|
379
|
+
if (args.dryRun && r.staged) {
|
|
380
|
+
lines.push(` Staged: ${r.staged.join(", ")}`);
|
|
381
|
+
}
|
|
382
|
+
if (args.dryRun && r.diffStat) {
|
|
383
|
+
lines.push(` Diff stat:`);
|
|
384
|
+
lines.push(` ${r.diffStat.replace(/\n/g, "\n ")}`);
|
|
385
|
+
}
|
|
386
|
+
if (r.output) {
|
|
387
|
+
lines.push(` Output: ${r.output.replace(/\n/g, "\n ")}`);
|
|
388
|
+
}
|
|
170
389
|
}
|
|
171
390
|
if (!allOk && results.length < args.commits.length) {
|
|
172
391
|
const skipped = args.commits.length - results.length;
|
|
173
392
|
lines.push("", `${skipped} remaining commit(s) skipped.`);
|
|
174
393
|
}
|
|
394
|
+
if (args.dryRun) {
|
|
395
|
+
lines.push("", "**DRY RUN — no commits written. All staged files have been unstaged.**");
|
|
396
|
+
}
|
|
175
397
|
if (push) {
|
|
176
398
|
lines.push("");
|
|
177
399
|
if (push.ok) {
|
|
@@ -180,6 +402,9 @@ export function registerBatchCommitTool(server) {
|
|
|
180
402
|
else {
|
|
181
403
|
lines.push(`Push: ✗ ${push.error}${push.detail ? ` — ${push.detail}` : ""}`);
|
|
182
404
|
}
|
|
405
|
+
if (push.output) {
|
|
406
|
+
lines.push(` Output: ${push.output.replace(/\n/g, "\n ")}`);
|
|
407
|
+
}
|
|
183
408
|
}
|
|
184
409
|
return lines.join("\n");
|
|
185
410
|
},
|