breakpoint-mcp 1.4.1 → 1.12.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/README.md +1 -1
- package/addon/breakpoint_mcp/LICENSE +21 -0
- package/addon/breakpoint_mcp/bridge_server.gd +16 -0
- package/addon/breakpoint_mcp/operations.gd +1 -1
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/dist/index.js +13 -1
- package/dist/schemas.js +215 -0
- package/dist/tools/tabletop.js +1775 -0
- package/dist/tools/vcs.js +458 -0
- package/package.json +1 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ok } from "./lsp-common.js";
|
|
5
|
+
import { gate } from "../confirm.js";
|
|
6
|
+
// Group L — version control (git), host-side (Plane B). These read the project's
|
|
7
|
+
// git repository directly by spawning the `git` binary with an explicit argv (no
|
|
8
|
+
// shell), rooted at the configured project path via `git -C <projectPath>`. They
|
|
9
|
+
// need neither the Godot editor nor a language server, so they answer whenever the
|
|
10
|
+
// project is a git work tree — exactly the "cloud-verifiable end-to-end" lane the
|
|
11
|
+
// backlog flags. This file is the READ-ONLY core (status/log/diff/show/branches/
|
|
12
|
+
// blame); none mutate the index or working tree, so none are undoable or gated.
|
|
13
|
+
// The mutating half (stage/commit/restore/…) is intentionally deferred pending a
|
|
14
|
+
// scope steer and, when added, reuses the elicitation `gate()` in ../confirm.ts.
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const GIT_TIMEOUT_MS = 20000;
|
|
17
|
+
const MAX_BUFFER = 32 * 1024 * 1024;
|
|
18
|
+
const UNIT = "\x1f"; // ASCII unit separator — safe field delimiter for --pretty.
|
|
19
|
+
/** Run git with an explicit argv rooted at the project path. Never throws. */
|
|
20
|
+
async function git(cfg, args, timeoutMs = GIT_TIMEOUT_MS) {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout, stderr } = await execFileAsync("git", ["-C", cfg.projectPath, ...args], {
|
|
23
|
+
timeout: timeoutMs,
|
|
24
|
+
maxBuffer: MAX_BUFFER,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
});
|
|
27
|
+
return { ok: true, code: 0, stdout, stderr, missing: false };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const e = err;
|
|
31
|
+
const missing = e.code === "ENOENT" || e.errno === "ENOENT";
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
code: typeof e.code === "number" ? e.code : null,
|
|
35
|
+
stdout: e.stdout ?? "",
|
|
36
|
+
stderr: e.stderr ?? e.message ?? "",
|
|
37
|
+
missing,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** MCP error envelope for a failed git call (never throws to the caller). */
|
|
42
|
+
function gitFail(r) {
|
|
43
|
+
if (r.missing) {
|
|
44
|
+
return {
|
|
45
|
+
isError: true,
|
|
46
|
+
content: [{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: "git is not installed or not on PATH. Install git to use the vcs_* tools.",
|
|
49
|
+
}],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const msg = (r.stderr || r.stdout || "git command failed").trim();
|
|
53
|
+
const notRepo = /not a git repository/i.test(msg);
|
|
54
|
+
const hint = notRepo ? " (the configured project path is not inside a git work tree)" : "";
|
|
55
|
+
return {
|
|
56
|
+
isError: true,
|
|
57
|
+
content: [{ type: "text", text: `git error${r.code != null ? ` [${r.code}]` : ""}: ${msg}${hint}` }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Truncate long text so a single tool result stays reasonable; report whether it
|
|
62
|
+
* was cut. Keeps the HEAD (not the tail): for a patch the first changed files and
|
|
63
|
+
* their hunks are the useful part, and for file content the start is; the caller
|
|
64
|
+
* narrows with `path`/`ref`/line range when `truncated` is true.
|
|
65
|
+
*/
|
|
66
|
+
function clip(s, max = 12000) {
|
|
67
|
+
if (s.length <= max)
|
|
68
|
+
return { text: s, truncated: false };
|
|
69
|
+
return { text: s.slice(0, max) + "\n…(truncated)…", truncated: true };
|
|
70
|
+
}
|
|
71
|
+
/** Accept a res:// path (project-relative) or a plain repo-relative/absolute path. */
|
|
72
|
+
function toRepoPath(p) {
|
|
73
|
+
return p.startsWith("res://") ? p.slice("res://".length) : p;
|
|
74
|
+
}
|
|
75
|
+
function parseStatusV2(stdout) {
|
|
76
|
+
const s = {
|
|
77
|
+
branch: null, oid: null, upstream: null, ahead: 0, behind: 0,
|
|
78
|
+
staged: [], unstaged: [], untracked: [], unmerged: [], clean: true,
|
|
79
|
+
};
|
|
80
|
+
for (const line of stdout.split("\n")) {
|
|
81
|
+
if (!line)
|
|
82
|
+
continue;
|
|
83
|
+
if (line.startsWith("# branch.oid ")) {
|
|
84
|
+
const v = line.slice(13).trim();
|
|
85
|
+
s.oid = v === "(initial)" ? null : v;
|
|
86
|
+
}
|
|
87
|
+
else if (line.startsWith("# branch.head ")) {
|
|
88
|
+
const v = line.slice(14).trim();
|
|
89
|
+
s.branch = v === "(detached)" ? null : v;
|
|
90
|
+
}
|
|
91
|
+
else if (line.startsWith("# branch.upstream "))
|
|
92
|
+
s.upstream = line.slice(18).trim();
|
|
93
|
+
else if (line.startsWith("# branch.ab ")) {
|
|
94
|
+
const m = line.slice(12).trim().match(/\+(-?\d+)\s+-(-?\d+)/);
|
|
95
|
+
if (m) {
|
|
96
|
+
s.ahead = Number(m[1]);
|
|
97
|
+
s.behind = Number(m[2]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
101
|
+
const parts = line.split(" ");
|
|
102
|
+
const xy = parts[1];
|
|
103
|
+
const rest = line.startsWith("2 ")
|
|
104
|
+
? parts.slice(9).join(" ").split("\t")[0] // renamed: path before the \t<orig>
|
|
105
|
+
: parts.slice(8).join(" ");
|
|
106
|
+
const x = xy[0], y = xy[1];
|
|
107
|
+
if (x !== ".")
|
|
108
|
+
s.staged.push({ path: rest, status: x });
|
|
109
|
+
if (y !== ".")
|
|
110
|
+
s.unstaged.push({ path: rest, status: y });
|
|
111
|
+
}
|
|
112
|
+
else if (line.startsWith("u ")) {
|
|
113
|
+
const parts = line.split(" ");
|
|
114
|
+
s.unmerged.push(parts.slice(10).join(" "));
|
|
115
|
+
}
|
|
116
|
+
else if (line.startsWith("? ")) {
|
|
117
|
+
s.untracked.push(line.slice(2));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
s.clean = s.staged.length === 0 && s.unstaged.length === 0 && s.untracked.length === 0 && s.unmerged.length === 0;
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
export function registerVcsTools(server, cfg) {
|
|
124
|
+
// ---- vcs_status ----------------------------------------------------------
|
|
125
|
+
server.registerTool("vcs_status", {
|
|
126
|
+
title: "Git status",
|
|
127
|
+
description: "Working-tree status of the project's git repository: current branch, upstream ahead/behind, " +
|
|
128
|
+
"and the staged / unstaged / untracked / unmerged file lists. Read-only. Reports clean=true when " +
|
|
129
|
+
"nothing is pending. Errors clearly if the project path is not a git work tree.",
|
|
130
|
+
inputSchema: {},
|
|
131
|
+
}, async () => {
|
|
132
|
+
const r = await git(cfg, ["status", "--porcelain=v2", "--branch"]);
|
|
133
|
+
if (!r.ok)
|
|
134
|
+
return gitFail(r);
|
|
135
|
+
return ok(parseStatusV2(r.stdout));
|
|
136
|
+
});
|
|
137
|
+
// ---- vcs_log -------------------------------------------------------------
|
|
138
|
+
server.registerTool("vcs_log", {
|
|
139
|
+
title: "Git log",
|
|
140
|
+
description: "Recent commits, newest first: full and short hash, author, ISO author date, and subject. " +
|
|
141
|
+
"Optionally limit to commits touching a path (accepts res:// or repo-relative). Read-only.",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
max_count: z.number().int().positive().max(1000).optional().describe("Max commits to return (default 20)"),
|
|
144
|
+
path: z.string().optional().describe("Only commits touching this path (res:// or repo-relative)"),
|
|
145
|
+
},
|
|
146
|
+
}, async ({ max_count, path }) => {
|
|
147
|
+
const fmt = ["%H", "%h", "%an", "%aI", "%s"].join(UNIT);
|
|
148
|
+
const args = ["log", `--max-count=${max_count ?? 20}`, `--pretty=format:${fmt}`];
|
|
149
|
+
if (path)
|
|
150
|
+
args.push("--", toRepoPath(path));
|
|
151
|
+
const r = await git(cfg, args);
|
|
152
|
+
if (!r.ok)
|
|
153
|
+
return gitFail(r);
|
|
154
|
+
const commits = r.stdout
|
|
155
|
+
.split("\n")
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.map((line) => {
|
|
158
|
+
const [hash, short, author, date, subject] = line.split(UNIT);
|
|
159
|
+
return { hash, short, author, date, subject };
|
|
160
|
+
});
|
|
161
|
+
return ok({ commits, count: commits.length });
|
|
162
|
+
});
|
|
163
|
+
// ---- vcs_diff ------------------------------------------------------------
|
|
164
|
+
server.registerTool("vcs_diff", {
|
|
165
|
+
title: "Git diff",
|
|
166
|
+
description: "Unified diff of the working tree (default) or the staged index (staged=true), optionally scoped to a " +
|
|
167
|
+
"single path (res:// or repo-relative). Returns the patch text (tail-truncated for large diffs) plus the " +
|
|
168
|
+
"list of changed files parsed from it. Read-only.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
staged: z.boolean().optional().describe("Diff the staged index vs HEAD instead of the working tree (default false)"),
|
|
171
|
+
path: z.string().optional().describe("Restrict the diff to this path (res:// or repo-relative)"),
|
|
172
|
+
},
|
|
173
|
+
}, async ({ staged, path }) => {
|
|
174
|
+
const args = ["diff", "--no-color"];
|
|
175
|
+
if (staged)
|
|
176
|
+
args.push("--cached");
|
|
177
|
+
if (path)
|
|
178
|
+
args.push("--", toRepoPath(path));
|
|
179
|
+
const r = await git(cfg, args);
|
|
180
|
+
if (!r.ok)
|
|
181
|
+
return gitFail(r);
|
|
182
|
+
const files = [...r.stdout.matchAll(/^diff --git a\/(.+?) b\//gm)].map((m) => m[1]);
|
|
183
|
+
const { text, truncated } = clip(r.stdout);
|
|
184
|
+
return ok({ staged: Boolean(staged), path: path ?? null, files, patch: text, truncated });
|
|
185
|
+
});
|
|
186
|
+
// ---- vcs_show ------------------------------------------------------------
|
|
187
|
+
server.registerTool("vcs_show", {
|
|
188
|
+
title: "Git show",
|
|
189
|
+
description: "Inspect a commit or a file at a revision. With no path: commit metadata (hash, author, date, subject, " +
|
|
190
|
+
"body) plus its patch (tail-truncated). With a path: the file's full content at that ref. `ref` defaults " +
|
|
191
|
+
"to HEAD and accepts any revision (branch, tag, sha, HEAD~2). Read-only.",
|
|
192
|
+
inputSchema: {
|
|
193
|
+
ref: z.string().optional().describe("Revision to show (default HEAD): branch, tag, sha, or HEAD~n"),
|
|
194
|
+
path: z.string().optional().describe("If set, return this file's content at <ref> instead of the commit"),
|
|
195
|
+
},
|
|
196
|
+
}, async ({ ref, path }) => {
|
|
197
|
+
const rev = ref ?? "HEAD";
|
|
198
|
+
if (path) {
|
|
199
|
+
const r = await git(cfg, ["show", `${rev}:${toRepoPath(path)}`]);
|
|
200
|
+
if (!r.ok)
|
|
201
|
+
return gitFail(r);
|
|
202
|
+
const { text, truncated } = clip(r.stdout, 20000);
|
|
203
|
+
return ok({ ref: rev, path, content: text, truncated });
|
|
204
|
+
}
|
|
205
|
+
const meta = await git(cfg, ["show", "-s", `--pretty=format:${["%H", "%h", "%an", "%aI", "%s", "%b"].join(UNIT)}`, rev]);
|
|
206
|
+
if (!meta.ok)
|
|
207
|
+
return gitFail(meta);
|
|
208
|
+
const [hash, short, author, date, subject, body] = meta.stdout.split(UNIT);
|
|
209
|
+
const patchRes = await git(cfg, ["show", "--no-color", "--format=", rev]);
|
|
210
|
+
if (!patchRes.ok)
|
|
211
|
+
return gitFail(patchRes);
|
|
212
|
+
const { text, truncated } = clip(patchRes.stdout);
|
|
213
|
+
return ok({
|
|
214
|
+
ref: rev, hash, short, author, date, subject,
|
|
215
|
+
body: (body ?? "").trim(), patch: text, truncated,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
// ---- vcs_branch_list -----------------------------------------------------
|
|
219
|
+
server.registerTool("vcs_branch_list", {
|
|
220
|
+
title: "Git branches",
|
|
221
|
+
description: "List branches with their short object name and a flag for the current branch. Local only by default; " +
|
|
222
|
+
"set remotes=true to include remote-tracking branches. Read-only.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
remotes: z.boolean().optional().describe("Include remote-tracking branches (default false)"),
|
|
225
|
+
},
|
|
226
|
+
}, async ({ remotes }) => {
|
|
227
|
+
const args = ["branch", "--no-color", `--format=%(refname:short)${UNIT}%(objectname:short)${UNIT}%(HEAD)`];
|
|
228
|
+
if (remotes)
|
|
229
|
+
args.push("--all");
|
|
230
|
+
const r = await git(cfg, args);
|
|
231
|
+
if (!r.ok)
|
|
232
|
+
return gitFail(r);
|
|
233
|
+
let current = null;
|
|
234
|
+
const branches = r.stdout
|
|
235
|
+
.split("\n")
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.map((line) => {
|
|
238
|
+
const [name, short_sha, head] = line.split(UNIT);
|
|
239
|
+
const isCurrent = head.trim() === "*";
|
|
240
|
+
if (isCurrent)
|
|
241
|
+
current = name;
|
|
242
|
+
return { name, short_sha, current: isCurrent, remote: name.startsWith("remotes/") };
|
|
243
|
+
});
|
|
244
|
+
return ok({ current, branches, count: branches.length });
|
|
245
|
+
});
|
|
246
|
+
// ---- vcs_blame -----------------------------------------------------------
|
|
247
|
+
server.registerTool("vcs_blame", {
|
|
248
|
+
title: "Git blame",
|
|
249
|
+
description: "Per-line last-change attribution for a file: for each line, the short commit, author, ISO date, and the " +
|
|
250
|
+
"line text. Optionally restrict to a [start,end] line range (1-based, inclusive). Read-only.",
|
|
251
|
+
inputSchema: {
|
|
252
|
+
path: z.string().describe("File to blame (res:// or repo-relative)"),
|
|
253
|
+
start: z.number().int().positive().optional().describe("First line (1-based, inclusive)"),
|
|
254
|
+
end: z.number().int().positive().optional().describe("Last line (1-based, inclusive)"),
|
|
255
|
+
},
|
|
256
|
+
}, async ({ path, start, end }) => {
|
|
257
|
+
const args = ["blame", "--line-porcelain"];
|
|
258
|
+
if (start != null || end != null)
|
|
259
|
+
args.push("-L", `${start ?? 1},${end ?? "$"}`);
|
|
260
|
+
args.push("--", toRepoPath(path));
|
|
261
|
+
const r = await git(cfg, args);
|
|
262
|
+
if (!r.ok)
|
|
263
|
+
return gitFail(r);
|
|
264
|
+
const lines = parseBlamePorcelain(r.stdout);
|
|
265
|
+
const { list, truncated } = capLines(lines);
|
|
266
|
+
return ok({ path, lines: list, count: list.length, truncated });
|
|
267
|
+
});
|
|
268
|
+
// ==== mutating tools (Tier A — safe local, no network) ====================
|
|
269
|
+
// Posture: gate what can LOSE work or REWRITE history (vcs_restore, vcs_stash
|
|
270
|
+
// op=drop); leave the reversible ops (add / commit / branch_create / switch)
|
|
271
|
+
// ungated. Gating reuses the shared elicitation `gate()` — honors confirm:true,
|
|
272
|
+
// and BLOCKS (never proceeds silently) on a client that can't elicit.
|
|
273
|
+
// ---- vcs_add (stage) — ungated (reversible via `git restore --staged`) ----
|
|
274
|
+
server.registerTool("vcs_add", {
|
|
275
|
+
title: "Git add (stage)",
|
|
276
|
+
description: "Stage changes for the next commit. With `paths`, stages exactly those (res:// or repo-relative); " +
|
|
277
|
+
"omit `paths` to stage everything (git add -A). Returns the resulting staged file list. Reversible " +
|
|
278
|
+
"with vcs_restore-staged / `git restore --staged`, so not gated.",
|
|
279
|
+
inputSchema: {
|
|
280
|
+
paths: z.array(z.string()).optional().describe("Paths to stage (res:// or repo-relative). Omit to stage all."),
|
|
281
|
+
},
|
|
282
|
+
}, async ({ paths }) => {
|
|
283
|
+
const addArgs = paths && paths.length > 0 ? ["add", "--", ...paths.map(toRepoPath)] : ["add", "-A"];
|
|
284
|
+
const r = await git(cfg, addArgs);
|
|
285
|
+
if (!r.ok)
|
|
286
|
+
return gitFail(r);
|
|
287
|
+
const st = await git(cfg, ["status", "--porcelain=v2", "--branch"]);
|
|
288
|
+
if (!st.ok)
|
|
289
|
+
return gitFail(st);
|
|
290
|
+
const parsed = parseStatusV2(st.stdout);
|
|
291
|
+
return ok({ staged: parsed.staged, count: parsed.staged.length });
|
|
292
|
+
});
|
|
293
|
+
// ---- vcs_commit — ungated (reversible via `git reset --soft HEAD~1`) ------
|
|
294
|
+
server.registerTool("vcs_commit", {
|
|
295
|
+
title: "Git commit",
|
|
296
|
+
description: "Commit the currently staged changes with a message. Reversible (`git reset --soft HEAD~1`) and " +
|
|
297
|
+
"loses nothing, so not gated. Errors clearly if nothing is staged. Commit signing is disabled for " +
|
|
298
|
+
"this call so it can never block on a passphrase prompt.",
|
|
299
|
+
inputSchema: {
|
|
300
|
+
message: z.string().min(1).describe("Commit message"),
|
|
301
|
+
},
|
|
302
|
+
}, async ({ message }) => {
|
|
303
|
+
const r = await git(cfg, ["-c", "commit.gpgsign=false", "commit", "-m", message]);
|
|
304
|
+
if (!r.ok) {
|
|
305
|
+
const blob = `${r.stdout}\n${r.stderr}`;
|
|
306
|
+
if (/nothing to commit|no changes added|nothing added to commit/i.test(blob)) {
|
|
307
|
+
return {
|
|
308
|
+
isError: true,
|
|
309
|
+
content: [{ type: "text", text: "Nothing to commit — stage changes first with vcs_add." }],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return gitFail(r);
|
|
313
|
+
}
|
|
314
|
+
const meta = await git(cfg, ["log", "-1", `--pretty=format:${["%H", "%h", "%s"].join(UNIT)}`]);
|
|
315
|
+
if (!meta.ok)
|
|
316
|
+
return gitFail(meta);
|
|
317
|
+
const [hash, short, subject] = meta.stdout.split(UNIT);
|
|
318
|
+
return ok({ committed: true, hash, short, summary: subject });
|
|
319
|
+
});
|
|
320
|
+
// ---- vcs_restore — GATED (discards uncommitted working-tree edits) --------
|
|
321
|
+
server.registerTool("vcs_restore", {
|
|
322
|
+
title: "Git restore (discard changes)",
|
|
323
|
+
description: "Discard uncommitted working-tree changes to the given paths, restoring them from the index/HEAD " +
|
|
324
|
+
"(`git restore -- <paths>`). DESTRUCTIVE — the discarded edits are unrecoverable — so it is " +
|
|
325
|
+
"elicitation-gated: pass confirm:true to bypass the prompt on clients that can't elicit.",
|
|
326
|
+
inputSchema: {
|
|
327
|
+
paths: z.array(z.string()).min(1).describe("Paths to discard changes for (res:// or repo-relative)"),
|
|
328
|
+
confirm: z.boolean().optional().describe("Skip the confirmation prompt"),
|
|
329
|
+
},
|
|
330
|
+
}, async ({ paths, confirm }) => {
|
|
331
|
+
const rels = paths.map(toRepoPath);
|
|
332
|
+
const blocked = await gate(server, confirm, `Discard working-tree changes to: ${rels.join(", ")}`);
|
|
333
|
+
if (blocked)
|
|
334
|
+
return blocked;
|
|
335
|
+
const r = await git(cfg, ["restore", "--", ...rels]);
|
|
336
|
+
if (!r.ok)
|
|
337
|
+
return gitFail(r);
|
|
338
|
+
return ok({ restored: rels, count: rels.length });
|
|
339
|
+
});
|
|
340
|
+
// ---- vcs_stash — push/pop/list ungated; drop GATED (destroys an entry) ----
|
|
341
|
+
server.registerTool("vcs_stash", {
|
|
342
|
+
title: "Git stash",
|
|
343
|
+
description: "Manage stashes: op='push' saves and reverts your working changes (optional message); 'pop' " +
|
|
344
|
+
"re-applies the latest stash; 'list' returns the stash entries; 'drop' deletes a stash entry. " +
|
|
345
|
+
"Only 'drop' is destructive and elicitation-gated (confirm:true bypasses); push/pop/list are not.",
|
|
346
|
+
inputSchema: {
|
|
347
|
+
op: z.enum(["push", "pop", "list", "drop"]).describe("Stash operation"),
|
|
348
|
+
message: z.string().optional().describe("Message for op='push'"),
|
|
349
|
+
ref: z.string().optional().describe("Stash ref for op='drop'/'pop', e.g. stash@{1} (default latest)"),
|
|
350
|
+
confirm: z.boolean().optional().describe("Skip the confirmation prompt (op='drop')"),
|
|
351
|
+
},
|
|
352
|
+
}, async ({ op, message, ref, confirm }) => {
|
|
353
|
+
if (op === "list") {
|
|
354
|
+
const r = await git(cfg, ["stash", "list", `--pretty=format:%gd${UNIT}%s`]);
|
|
355
|
+
if (!r.ok)
|
|
356
|
+
return gitFail(r);
|
|
357
|
+
const stashes = r.stdout.split("\n").filter(Boolean).map((line) => {
|
|
358
|
+
const [refName, description] = line.split(UNIT);
|
|
359
|
+
return { ref: refName, description };
|
|
360
|
+
});
|
|
361
|
+
return ok({ op, message: `${stashes.length} stash entr${stashes.length === 1 ? "y" : "ies"}`, stashes });
|
|
362
|
+
}
|
|
363
|
+
if (op === "drop") {
|
|
364
|
+
const target = ref ?? "the latest stash";
|
|
365
|
+
const blocked = await gate(server, confirm, `Delete stash entry (${target}) — its contents are unrecoverable`);
|
|
366
|
+
if (blocked)
|
|
367
|
+
return blocked;
|
|
368
|
+
const r = await git(cfg, ["stash", "drop", ...(ref ? [ref] : [])]);
|
|
369
|
+
if (!r.ok)
|
|
370
|
+
return gitFail(r);
|
|
371
|
+
return ok({ op, message: r.stdout.trim() || "dropped", stashes: [] });
|
|
372
|
+
}
|
|
373
|
+
// push / pop
|
|
374
|
+
const args = op === "push"
|
|
375
|
+
? ["stash", "push", ...(message ? ["-m", message] : [])]
|
|
376
|
+
: ["stash", "pop", ...(ref ? [ref] : [])];
|
|
377
|
+
const r = await git(cfg, args);
|
|
378
|
+
if (!r.ok)
|
|
379
|
+
return gitFail(r);
|
|
380
|
+
return ok({ op, message: r.stdout.trim() || `${op} ok`, stashes: [] });
|
|
381
|
+
});
|
|
382
|
+
// ---- vcs_branch_create — ungated (reversible) ----------------------------
|
|
383
|
+
server.registerTool("vcs_branch_create", {
|
|
384
|
+
title: "Git branch (create)",
|
|
385
|
+
description: "Create a new branch, optionally starting from a given ref (default HEAD), and optionally switch to " +
|
|
386
|
+
"it. Reversible (`git branch -d`), so not gated. Errors clearly if the branch already exists.",
|
|
387
|
+
inputSchema: {
|
|
388
|
+
name: z.string().min(1).describe("New branch name"),
|
|
389
|
+
from: z.string().optional().describe("Start point (branch, tag, or sha; default HEAD)"),
|
|
390
|
+
switch: z.boolean().optional().describe("Switch to the new branch after creating it (default false)"),
|
|
391
|
+
},
|
|
392
|
+
}, async ({ name, from, switch: doSwitch }) => {
|
|
393
|
+
const r = await git(cfg, ["branch", name, ...(from ? [from] : [])]);
|
|
394
|
+
if (!r.ok)
|
|
395
|
+
return gitFail(r);
|
|
396
|
+
let switched = false;
|
|
397
|
+
if (doSwitch) {
|
|
398
|
+
const sw = await git(cfg, ["switch", name]);
|
|
399
|
+
if (!sw.ok)
|
|
400
|
+
return gitFail(sw);
|
|
401
|
+
switched = true;
|
|
402
|
+
}
|
|
403
|
+
return ok({ created: true, name, from: from ?? null, switched });
|
|
404
|
+
});
|
|
405
|
+
// ---- vcs_switch — ungated (git refuses on a dirty conflict; no --force) ---
|
|
406
|
+
server.registerTool("vcs_switch", {
|
|
407
|
+
title: "Git switch (branch)",
|
|
408
|
+
description: "Switch to an existing branch (`git switch <branch>`). No --force: if local changes would be " +
|
|
409
|
+
"overwritten, git refuses and its message is returned unchanged — nothing is clobbered — so this is " +
|
|
410
|
+
"not gated.",
|
|
411
|
+
inputSchema: {
|
|
412
|
+
branch: z.string().min(1).describe("Existing branch to switch to"),
|
|
413
|
+
},
|
|
414
|
+
}, async ({ branch }) => {
|
|
415
|
+
const r = await git(cfg, ["switch", branch]);
|
|
416
|
+
if (!r.ok)
|
|
417
|
+
return gitFail(r);
|
|
418
|
+
return ok({ switched: true, branch });
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function parseBlamePorcelain(stdout) {
|
|
422
|
+
const out = [];
|
|
423
|
+
const rows = stdout.split("\n");
|
|
424
|
+
let sha = "", author = "", epoch = 0, finalLine = 0;
|
|
425
|
+
for (const row of rows) {
|
|
426
|
+
const head = row.match(/^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/);
|
|
427
|
+
if (head) {
|
|
428
|
+
sha = head[1];
|
|
429
|
+
finalLine = Number(head[2]);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (row.startsWith("author ")) {
|
|
433
|
+
author = row.slice(7);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (row.startsWith("author-time ")) {
|
|
437
|
+
epoch = Number(row.slice(12));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (row.startsWith("\t")) {
|
|
441
|
+
out.push({
|
|
442
|
+
line: finalLine,
|
|
443
|
+
commit: sha.slice(0, 7),
|
|
444
|
+
author,
|
|
445
|
+
date: epoch ? new Date(epoch * 1000).toISOString() : "",
|
|
446
|
+
text: row.slice(1),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
/** Cap a blame result so a huge file can't blow the response size. */
|
|
453
|
+
function capLines(lines, max = 5000) {
|
|
454
|
+
if (lines.length <= max)
|
|
455
|
+
return { list: lines, truncated: false };
|
|
456
|
+
return { list: lines.slice(0, max), truncated: true };
|
|
457
|
+
}
|
|
458
|
+
//# sourceMappingURL=vcs.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakpoint-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|