breakpoint-mcp 1.74.1 β 1.76.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/addon/breakpoint_mcp/bridge_server.gd +4 -1
- package/addon/breakpoint_mcp/error_remedies.gd +113 -0
- package/addon/breakpoint_mcp/operations.gd +10 -2
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +20 -2
- package/dist/addon-version.js +113 -0
- package/dist/annotations.js +10 -1
- package/dist/bridge.js +52 -3
- package/dist/capabilities.js +91 -2
- package/dist/cli/doctor.js +232 -16
- package/dist/cli/init.js +65 -13
- package/dist/cli/usage.js +5 -1
- package/dist/index.js +52 -8
- package/dist/peers.js +17 -18
- package/dist/readiness.js +108 -0
- package/dist/remedies.js +63 -0
- package/dist/result-cost.js +90 -0
- package/dist/schemas.js +23 -4
- package/dist/tools/backend.js +2 -1
- package/dist/tools/cli.js +43 -6
- package/dist/tools/cslsp.js +19 -3
- package/dist/tools/editor/common.js +2 -1
- package/dist/tools/lsp-common.js +23 -0
- package/dist/tools/lsp.js +18 -3
- package/dist/tools/netcode.js +2 -1
- package/dist/tools/processes.js +26 -2
- package/dist/tools/runtime.js +34 -10
- package/dist/tools/tabletop.js +2 -1
- package/dist/wire-defaults.js +80 -8
- package/package.json +2 -2
package/dist/cli/doctor.js
CHANGED
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
* may legitimately not be running when a user checks their install; pass
|
|
15
15
|
* `--require-live` when you expect them up (e.g. after opening the editor).
|
|
16
16
|
*
|
|
17
|
+
* π΄ THE FOUR BRIDGES DO NOT COME UP TOGETHER, AND `--require-live` USED TO
|
|
18
|
+
* PRETEND THEY DID. Opening the editor starts three of them β the addon's
|
|
19
|
+
* bridge on 9080 and Godot's own LSP/DAP. The runtime bridge on 9081 lives
|
|
20
|
+
* INSIDE THE GAME and binds only while the project is running. So the one
|
|
21
|
+
* instruction `docs/USER_GUIDE.md` Β§3.0, `README.md` and `init`'s own closing
|
|
22
|
+
* line all give a new user β open the editor, then run `doctor --require-live`
|
|
23
|
+
* β exited 1 on a completely correct install, every time, because it demanded a
|
|
24
|
+
* game nobody had been told to launch. `LIVE_LEVELS` is that distinction made
|
|
25
|
+
* nameable: bare `--require-live` asserts what opening the editor makes true,
|
|
26
|
+
* and `--require-live=all` is the old four-bridge contract, still reachable and
|
|
27
|
+
* now something a caller asks for on purpose.
|
|
28
|
+
*
|
|
17
29
|
* Configuration is read via loadConfig(), so the same env overrides the server
|
|
18
30
|
* honours (GODOT_BIN, GODOT_PROJECT, and the BREAKPOINT_ / GODOT_ ports) apply here.
|
|
19
31
|
*/
|
|
@@ -27,7 +39,24 @@ import { parseArgs, preflight } from "./args.js";
|
|
|
27
39
|
import { DOCTOR_FLAGS, DOCTOR_USAGE } from "./usage.js";
|
|
28
40
|
import { BridgeClient } from "../bridge.js";
|
|
29
41
|
import { resolveBridgeSecret } from "../secret.js";
|
|
30
|
-
|
|
42
|
+
import { ADDON_REL, ADDON_SKEW_HINT, compareAddonVersions, readAddonVersion, } from "../addon-version.js";
|
|
43
|
+
import { resolveBundledAddon } from "./init.js";
|
|
44
|
+
/**
|
|
45
|
+
* Which bridges a run insists on.
|
|
46
|
+
*
|
|
47
|
+
* none β every bridge is informational (the default: a user checking an
|
|
48
|
+
* install has neither the editor nor the game up, necessarily)
|
|
49
|
+
* editor β the three the EDITOR brings up: editor bridge, GDScript LSP, DAP
|
|
50
|
+
* runtime β the one the GAME brings up: the runtime bridge on 9081
|
|
51
|
+
* all β all four
|
|
52
|
+
*
|
|
53
|
+
* The order matters to nothing; the tier a bridge belongs to is declared on the
|
|
54
|
+
* bridge, so adding a fifth is one field and no new branch.
|
|
55
|
+
*/
|
|
56
|
+
export const LIVE_LEVELS = ["none", "editor", "runtime", "all"];
|
|
57
|
+
export function severityFor(level, tier) {
|
|
58
|
+
return level === "all" || level === tier ? "required" : "info";
|
|
59
|
+
}
|
|
31
60
|
const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
|
|
32
61
|
/** Resolve after a TCP connect succeeds (true) or the port is closed/times out (false). */
|
|
33
62
|
function probeTcp(host, port, timeoutMs) {
|
|
@@ -90,6 +119,57 @@ function checkGodotBinary(bin, timeoutMs) {
|
|
|
90
119
|
detail: `${bin} β ${version}`,
|
|
91
120
|
};
|
|
92
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The pair nothing compared until 258 Β§2: the addon a project has INSTALLED
|
|
124
|
+
* against the addon this host SHIPS.
|
|
125
|
+
*
|
|
126
|
+
* π΄ WHY `info` AND NOT `required`, ON THE RECORD. `doctor`'s exit code is a
|
|
127
|
+
* pre-flight contract, and 252 spent a whole row fixing `--require-live` for
|
|
128
|
+
* exactly one reason: it exited 1 on a correct install. An addon a release or two
|
|
129
|
+
* behind answers almost every method the host calls β an addon at 1.9.1 answers
|
|
130
|
+
* 260 of the current 265 β so a red exit would be wrong for most of the population
|
|
131
|
+
* that has one. The skew is REPORTED, loudly and with the command that clears it;
|
|
132
|
+
* it does not fail the run. Promoting it is one word if that turns out to be the
|
|
133
|
+
* wrong call.
|
|
134
|
+
*
|
|
135
|
+
* `newer` is not folded into `older`. An installed addon ahead of the host is a
|
|
136
|
+
* developer running the repo copy against an older published host, and telling
|
|
137
|
+
* that person to overwrite it with `--force` would destroy the newer one.
|
|
138
|
+
*/
|
|
139
|
+
function addonVersionCheck(installed) {
|
|
140
|
+
const bundledDir = resolveBundledAddon();
|
|
141
|
+
const bundled = bundledDir === null ? null : readAddonVersion(bundledDir);
|
|
142
|
+
const skew = compareAddonVersions(installed, bundled);
|
|
143
|
+
const pair = `installed ${installed ?? "?"} Β· this host ships ${bundled ?? "?"}`;
|
|
144
|
+
if (skew === "same") {
|
|
145
|
+
return { name: "addon-version", status: "ok", severity: "info", detail: `matches this host (${installed})` };
|
|
146
|
+
}
|
|
147
|
+
if (skew === "unknown") {
|
|
148
|
+
return {
|
|
149
|
+
name: "addon-version",
|
|
150
|
+
status: "skip",
|
|
151
|
+
severity: "info",
|
|
152
|
+
detail: `not comparable (${pair})`,
|
|
153
|
+
hint: "Neither version parsed as a dotted number, so no direction can be reported without guessing at it.",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (skew === "newer") {
|
|
157
|
+
return {
|
|
158
|
+
name: "addon-version",
|
|
159
|
+
status: "ok",
|
|
160
|
+
severity: "info",
|
|
161
|
+
detail: `ahead of this host (${pair})`,
|
|
162
|
+
hint: "The project's addon is newer than the one bundled here. Upgrade the host ('npm i -g breakpoint-mcp@latest') rather than overwriting the addon.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
name: "addon-version",
|
|
167
|
+
status: "fail",
|
|
168
|
+
severity: "info",
|
|
169
|
+
detail: `older than this host (${pair})`,
|
|
170
|
+
hint: ADDON_SKEW_HINT,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
93
173
|
function checkAddon(projectPath) {
|
|
94
174
|
const projText = readText(path.join(projectPath, "project.godot"));
|
|
95
175
|
if (projText === null) {
|
|
@@ -115,13 +195,14 @@ function checkAddon(projectPath) {
|
|
|
115
195
|
});
|
|
116
196
|
}
|
|
117
197
|
else {
|
|
118
|
-
const
|
|
198
|
+
const installed = readAddonVersion(path.join(projectPath, ADDON_REL));
|
|
119
199
|
checks.push({
|
|
120
200
|
name: "addon-installed",
|
|
121
201
|
status: "ok",
|
|
122
202
|
severity: "required",
|
|
123
|
-
detail: `${ADDON_REL} (version ${
|
|
203
|
+
detail: `${ADDON_REL} (version ${installed ?? "?"})`,
|
|
124
204
|
});
|
|
205
|
+
checks.push(addonVersionCheck(installed));
|
|
125
206
|
}
|
|
126
207
|
checks.push(isPluginEnabled(projText)
|
|
127
208
|
? {
|
|
@@ -198,9 +279,12 @@ export function checkCapabilities(config) {
|
|
|
198
279
|
detail: dropped.length === 0
|
|
199
280
|
? `${state} β full 292-tool surface`
|
|
200
281
|
: `${state} (secure default) β ${dropped.length} higher-trust tool(s) dropped from the surface`,
|
|
282
|
+
// The token list is DERIVED β `code-execution,network` was printed here
|
|
283
|
+
// for as long as the `network` group has been deleted, so the remedy
|
|
284
|
+
// named a token `selectPrivilegedGroups` reports as unknown and drops.
|
|
201
285
|
hint: dropped.length === 0
|
|
202
286
|
? undefined
|
|
203
|
-
:
|
|
287
|
+
: `Enable with BREAKPOINT_PRIVILEGED_GROUPS=${CAPABILITY_GROUPS.join(",")} (or \`breakpoint-mcp init --trust full\`). See the godot://capabilities resource for the exact tool list.`,
|
|
204
288
|
},
|
|
205
289
|
];
|
|
206
290
|
const assetGenConfigured = (config.assetGenBackend && config.assetGenBackend !== "none") ||
|
|
@@ -225,20 +309,61 @@ export function checkCapabilities(config) {
|
|
|
225
309
|
* secret that no longer matches the one the addon minted. `probeTcp` can see
|
|
226
310
|
* neither, which is what made "editor-bridge reachable" a check that could pass
|
|
227
311
|
* while every tool failed.
|
|
312
|
+
*
|
|
313
|
+
* π΄ AND IT NOW KEEPS THE ANSWER. `ping` has replied with `addon_version` since the
|
|
314
|
+
* addon had one, and all four of its consumers threw it away β this one returned
|
|
315
|
+
* `boolean`, `readiness.ts` returns `boolean`, and the two tool paths hand it to the
|
|
316
|
+
* model and forget it. That is the third of 258 Β§2's four compounding parts: the
|
|
317
|
+
* version of the addon ACTUALLY LOADED never reached anything that could compare it.
|
|
318
|
+
* Files on disk are not what is running β Godot reads the addon at project load, so
|
|
319
|
+
* `init --force` fixes the disk and changes nothing until the editor restarts, and
|
|
320
|
+
* that gap is exactly where a user concludes the fix did not work.
|
|
228
321
|
*/
|
|
229
322
|
async function handshakeOk(config, b, timeoutMs) {
|
|
230
323
|
const client = new BridgeClient(b.host, b.port, timeoutMs, "bridge", undefined, () => resolveBridgeSecret(config.projectPath, b.secretEnv ?? []));
|
|
231
324
|
try {
|
|
232
|
-
await client.request("ping", {}, timeoutMs);
|
|
233
|
-
|
|
325
|
+
const reply = (await client.request("ping", {}, timeoutMs));
|
|
326
|
+
const v = reply?.addon_version;
|
|
327
|
+
return { ok: true, addonVersion: typeof v === "string" && v !== "" ? v : null };
|
|
234
328
|
}
|
|
235
329
|
catch {
|
|
236
|
-
return false;
|
|
330
|
+
return { ok: false, addonVersion: null };
|
|
237
331
|
}
|
|
238
332
|
finally {
|
|
239
333
|
client.close();
|
|
240
334
|
}
|
|
241
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* The live half of the pair: the addon a bridge REPORTS against the one this host
|
|
338
|
+
* ships. Emitted only when a bridge actually answered with a version, so a closed
|
|
339
|
+
* editor produces no row rather than a row about nothing.
|
|
340
|
+
*/
|
|
341
|
+
export function addonRunningCheck(bridge, running, bundled) {
|
|
342
|
+
if (running === null)
|
|
343
|
+
return null;
|
|
344
|
+
// One row per answering bridge, named after it: a stale editor and a stale game are
|
|
345
|
+
// two different states with two different next actions β reopen the project, versus
|
|
346
|
+
// relaunch it β and collapsing them into one `addon-running` row would report
|
|
347
|
+
// whichever process happened to be asked first.
|
|
348
|
+
const name = `addon-running-${bridge.replace(/-bridge$/, "")}`;
|
|
349
|
+
const skew = compareAddonVersions(running, bundled);
|
|
350
|
+
const pair = `${bridge} reports ${running} Β· this host ships ${bundled ?? "?"}`;
|
|
351
|
+
if (skew === "older") {
|
|
352
|
+
return {
|
|
353
|
+
name,
|
|
354
|
+
status: "fail",
|
|
355
|
+
severity: "info",
|
|
356
|
+
detail: `older than this host (${pair})`,
|
|
357
|
+
hint: ADDON_SKEW_HINT,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
name,
|
|
362
|
+
status: "ok",
|
|
363
|
+
severity: "info",
|
|
364
|
+
detail: skew === "same" ? `matches this host (${running}, live on ${bridge})` : `(${pair})`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
242
367
|
export async function runDoctorChecks(config, opts) {
|
|
243
368
|
const checks = [];
|
|
244
369
|
// Capability groups β the secure-default surface + how to widen it (info only).
|
|
@@ -248,14 +373,17 @@ export async function runDoctorChecks(config, opts) {
|
|
|
248
373
|
// Editor addon install + enable.
|
|
249
374
|
checks.push(...checkAddon(config.projectPath));
|
|
250
375
|
// The four bridges.
|
|
251
|
-
const severity = opts.requireLive ? "required" : "info";
|
|
252
376
|
// `secretEnv` marks the two bridges that speak OUR protocol, so a TCP connect
|
|
253
377
|
// is not the end of the story for them (see handshakeOk). The LSP and DAP
|
|
254
378
|
// entries have none: those ports belong to Godot, and anything we could send
|
|
255
379
|
// to prove liveness would be us implementing a foreign protocol inside doctor.
|
|
380
|
+
//
|
|
381
|
+
// `tier` is what the bridge NEEDS RUNNING, not what it talks to β the one
|
|
382
|
+
// fact `--require-live` was missing.
|
|
256
383
|
const bridges = [
|
|
257
384
|
{
|
|
258
385
|
name: "editor-bridge",
|
|
386
|
+
tier: "editor",
|
|
259
387
|
host: config.bridgeHost,
|
|
260
388
|
port: config.bridgePort,
|
|
261
389
|
hint: 'Open the editor with the "Breakpoint MCP" plugin enabled β and if it was already open when you ran `breakpoint-mcp init`, close and reopen the project (Godot reads the enabled-plugin list only at project load).',
|
|
@@ -263,6 +391,7 @@ export async function runDoctorChecks(config, opts) {
|
|
|
263
391
|
},
|
|
264
392
|
{
|
|
265
393
|
name: "runtime-bridge",
|
|
394
|
+
tier: "runtime",
|
|
266
395
|
host: config.runtimeHost,
|
|
267
396
|
port: config.runtimePort,
|
|
268
397
|
hint: "Launch the project (godot_run_project / dbg_launch) with the plugin enabled β it auto-registers the runtime autoload.",
|
|
@@ -270,24 +399,35 @@ export async function runDoctorChecks(config, opts) {
|
|
|
270
399
|
},
|
|
271
400
|
{
|
|
272
401
|
name: "gdscript-lsp",
|
|
402
|
+
tier: "editor",
|
|
273
403
|
host: config.lspHost,
|
|
274
404
|
port: config.lspPort,
|
|
275
405
|
hint: "Godot's language server runs while the editor is open (Editor β Editor Settings β Network β Language Server).",
|
|
276
406
|
},
|
|
277
407
|
{
|
|
278
408
|
name: "gdscript-dap",
|
|
409
|
+
tier: "editor",
|
|
279
410
|
host: config.dapHost,
|
|
280
411
|
port: config.dapPort,
|
|
281
412
|
hint: "Godot's debug adapter runs while the editor is open (Editor β Editor Settings β Network β Debug Adapter).",
|
|
282
413
|
},
|
|
283
414
|
];
|
|
415
|
+
// Filled by the handshakes below, read once after them: which bridge reported which
|
|
416
|
+
// addon version. A map and not a single variable because both the editor and the
|
|
417
|
+
// runtime plane answer `ping` with one, and they are two different processes that
|
|
418
|
+
// can legitimately disagree β the editor holds the addon it loaded at project open,
|
|
419
|
+
// the game holds the one on disk when it launched.
|
|
420
|
+
const liveAddonVersions = new Map();
|
|
284
421
|
const bridgeChecks = await Promise.all(bridges.map(async (b) => {
|
|
422
|
+
const severity = severityFor(opts.liveLevel, b.tier);
|
|
423
|
+
const liveness = true; // every row in this map is one
|
|
285
424
|
const ok = await probeTcp(b.host, b.port, opts.timeoutMs);
|
|
286
425
|
if (!ok) {
|
|
287
426
|
return {
|
|
288
427
|
name: b.name,
|
|
289
428
|
status: "fail",
|
|
290
429
|
severity,
|
|
430
|
+
liveness,
|
|
291
431
|
detail: `${b.host}:${b.port} unreachable`,
|
|
292
432
|
hint: b.hint,
|
|
293
433
|
};
|
|
@@ -300,11 +440,14 @@ export async function runDoctorChecks(config, opts) {
|
|
|
300
440
|
// Both then reported "reachable" here and failed on every real call.
|
|
301
441
|
if (b.secretEnv) {
|
|
302
442
|
const live = await handshakeOk(config, b, opts.timeoutMs);
|
|
303
|
-
if (
|
|
443
|
+
if (live.addonVersion !== null)
|
|
444
|
+
liveAddonVersions.set(b.name, live.addonVersion);
|
|
445
|
+
if (!live.ok) {
|
|
304
446
|
return {
|
|
305
447
|
name: b.name,
|
|
306
448
|
status: "fail",
|
|
307
449
|
severity,
|
|
450
|
+
liveness,
|
|
308
451
|
detail: `${b.host}:${b.port} open, but no Breakpoint bridge answered`,
|
|
309
452
|
hint: "Something holds that port without speaking the bridge protocol, or the shared secret is stale. Close other Godot instances, or delete res://.godot/breakpoint_mcp.secret and reopen the editor to remint it.",
|
|
310
453
|
};
|
|
@@ -314,10 +457,19 @@ export async function runDoctorChecks(config, opts) {
|
|
|
314
457
|
name: b.name,
|
|
315
458
|
status: "ok",
|
|
316
459
|
severity,
|
|
460
|
+
liveness,
|
|
317
461
|
detail: `${b.host}:${b.port} reachable${b.secretEnv ? " Β· bridge answered" : ""}`,
|
|
318
462
|
};
|
|
319
463
|
}));
|
|
320
464
|
checks.push(...bridgeChecks);
|
|
465
|
+
// The running half of the pair, one row per bridge that answered.
|
|
466
|
+
const bundledDir = resolveBundledAddon();
|
|
467
|
+
const bundledVersion = bundledDir === null ? null : readAddonVersion(bundledDir);
|
|
468
|
+
for (const [bridge, running] of liveAddonVersions) {
|
|
469
|
+
const c = addonRunningCheck(bridge, running, bundledVersion);
|
|
470
|
+
if (c)
|
|
471
|
+
checks.push(c);
|
|
472
|
+
}
|
|
321
473
|
// Optional C# tooling.
|
|
322
474
|
if (opts.includeCsharp) {
|
|
323
475
|
checks.push(checkCsharpTool("csharp-lsp", config.csLspCmd));
|
|
@@ -326,23 +478,82 @@ export async function runDoctorChecks(config, opts) {
|
|
|
326
478
|
const ok = checks.every((c) => c.severity !== "required" || c.status !== "fail");
|
|
327
479
|
return { checks, ok };
|
|
328
480
|
}
|
|
329
|
-
|
|
330
|
-
|
|
481
|
+
/**
|
|
482
|
+
* π΄ `β` IS RESERVED FOR A CHECK THAT FAILED THE RUN. An informational check
|
|
483
|
+
* that did not pass gets `!`, because the alternative was the contradiction this
|
|
484
|
+
* function used to print: four `β` glyphs, and then `All required checks
|
|
485
|
+
* passed.` Both halves were true and defensible on their own β the bridges were
|
|
486
|
+
* genuinely down, and no REQUIRED check had genuinely failed β and a tool whose
|
|
487
|
+
* whole job is telling a user whether they are okay cannot say those two things
|
|
488
|
+
* in one breath and leave them to work out which one is the answer.
|
|
489
|
+
*/
|
|
490
|
+
function glyph(c) {
|
|
491
|
+
if (c.status === "ok")
|
|
492
|
+
return "β";
|
|
493
|
+
if (c.status === "skip")
|
|
494
|
+
return "β";
|
|
495
|
+
return c.severity === "required" ? "β" : "!";
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The last line, which is the only line many readers read. It says what failed
|
|
499
|
+
* AND what that means for the exit code, because those are two facts and the
|
|
500
|
+
* summary that named only one of them was the defect.
|
|
501
|
+
*/
|
|
502
|
+
export function summaryLine(report) {
|
|
503
|
+
const failed = report.checks.filter((c) => c.status === "fail");
|
|
504
|
+
const noted = failed.filter((c) => c.severity === "info");
|
|
505
|
+
if (!report.ok)
|
|
506
|
+
return "Some required checks failed β see the β³ hints above.";
|
|
507
|
+
if (noted.length === 0)
|
|
508
|
+
return "All checks passed.";
|
|
509
|
+
const names = noted.map((c) => c.name).join(", ");
|
|
510
|
+
const head = `All required checks passed. ${noted.length} informational check(s) did not β ${names} (marked !, not counted against the exit code).`;
|
|
511
|
+
const live = noted.filter((c) => c.liveness);
|
|
512
|
+
const standing = noted.filter((c) => !c.liveness);
|
|
513
|
+
// Only the liveness rows get the "expected when nothing is running" sentence, and
|
|
514
|
+
// only the standing rows get "this will not clear on its own". Saying either of
|
|
515
|
+
// those about the whole set is how the line contradicted the glyphs the first time.
|
|
516
|
+
const parts = [head];
|
|
517
|
+
if (live.length) {
|
|
518
|
+
parts.push(`${live.map((c) => c.name).join(", ")} ${live.length === 1 ? "is" : "are"} expected when the editor ` +
|
|
519
|
+
`or the game is not running; re-run with --require-live once the project is open in Godot.`);
|
|
520
|
+
}
|
|
521
|
+
if (standing.length) {
|
|
522
|
+
parts.push(`${standing.map((c) => c.name).join(", ")} will NOT clear by starting anything β see the β³ hint${standing.length === 1 ? "" : "s"} above.`);
|
|
523
|
+
}
|
|
524
|
+
return parts.join(" ");
|
|
331
525
|
}
|
|
332
526
|
function printReport(report) {
|
|
333
527
|
const width = Math.max(...report.checks.map((c) => c.name.length));
|
|
334
528
|
const out = ["breakpoint-mcp doctor", ""];
|
|
335
529
|
for (const c of report.checks) {
|
|
336
|
-
out.push(` ${glyph(c
|
|
530
|
+
out.push(` ${glyph(c)} ${c.name.padEnd(width)} ${c.detail}`);
|
|
337
531
|
if (c.status === "fail" && c.hint)
|
|
338
532
|
out.push(` β³ ${c.hint}`);
|
|
339
533
|
}
|
|
340
534
|
out.push("");
|
|
341
|
-
out.push(report
|
|
342
|
-
? "All required checks passed."
|
|
343
|
-
: "Some required checks failed β see the β³ hints above.");
|
|
535
|
+
out.push(summaryLine(report));
|
|
344
536
|
process.stdout.write(out.join("\n") + "\n");
|
|
345
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Read `--require-live` in both its forms. Bare (`true`) is `editor`; a value is
|
|
540
|
+
* taken literally. An unrecognised value is an ERROR, never a silent default β
|
|
541
|
+
* `--require-live=yes` quietly meaning "editor" would be a flag that agrees with
|
|
542
|
+
* whatever you typed and asserts something else.
|
|
543
|
+
*/
|
|
544
|
+
export function parseLiveLevel(raw) {
|
|
545
|
+
if (raw === undefined || raw === false)
|
|
546
|
+
return "none";
|
|
547
|
+
if (raw === true)
|
|
548
|
+
return "editor";
|
|
549
|
+
const v = raw.trim().toLowerCase();
|
|
550
|
+
if (LIVE_LEVELS.includes(v) && v !== "none")
|
|
551
|
+
return v;
|
|
552
|
+
return {
|
|
553
|
+
error: `doctor: --require-live=${raw} is not a level. Use --require-live (the editor's three bridges), ` +
|
|
554
|
+
`--require-live=runtime (the game's), or --require-live=all.`,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
346
557
|
/** Entry point for `breakpoint-mcp doctor`. Returns the process exit code. */
|
|
347
558
|
export async function runDoctor(argv) {
|
|
348
559
|
const parsed = parseArgs(argv, ["json", "require-live", "include-csharp", "help", "h"], DOCTOR_FLAGS);
|
|
@@ -354,10 +565,15 @@ export async function runDoctor(argv) {
|
|
|
354
565
|
process.env.GODOT_PROJECT = flags.project;
|
|
355
566
|
const timeoutRaw = typeof flags.timeout === "string" ? Number.parseInt(flags.timeout, 10) : NaN;
|
|
356
567
|
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 1500;
|
|
568
|
+
const liveLevel = parseLiveLevel(flags["require-live"]);
|
|
569
|
+
if (typeof liveLevel !== "string") {
|
|
570
|
+
process.stderr.write(liveLevel.error + "\n");
|
|
571
|
+
return 2;
|
|
572
|
+
}
|
|
357
573
|
const config = loadConfig();
|
|
358
574
|
const report = await runDoctorChecks(config, {
|
|
359
575
|
timeoutMs,
|
|
360
|
-
|
|
576
|
+
liveLevel,
|
|
361
577
|
includeCsharp: flags["include-csharp"] === true,
|
|
362
578
|
});
|
|
363
579
|
if (flags.json === true) {
|
package/dist/cli/init.js
CHANGED
|
@@ -14,15 +14,18 @@ import path from "node:path";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { parseArgs, preflight } from "./args.js";
|
|
16
16
|
import { INIT_FLAGS, INIT_USAGE } from "./usage.js";
|
|
17
|
+
import { ADDON_REL, ADDON_SKEW_HINT, compareAddonVersions, readAddonVersion, } from "../addon-version.js";
|
|
17
18
|
import { CLIENT_IDS, clientInfo, mergeClientConfig, serverEntry, snippet } from "./clients.js";
|
|
18
19
|
import { DEFAULT_REPO, fetchAddonFromGitHub } from "./github.js";
|
|
19
20
|
import { CAPABILITY_GROUPS, PRIVILEGED_TOOL_COUNT, parsePrivilegedGroups, selectPrivilegedGroups, } from "../capabilities.js";
|
|
20
21
|
/**
|
|
21
22
|
* Resolve the guided trust preset into a validated BREAKPOINT_PRIVILEGED_GROUPS
|
|
22
|
-
* value ("" = the safe default β
|
|
23
|
-
* `--trust safe|full` (friendly presets) or `--privileged-groups <list>`
|
|
24
|
-
* (
|
|
25
|
-
* with a warning so a typo never silently enables a group
|
|
23
|
+
* value ("" = the safe default β every group in `CAPABILITY_GROUPS` off). Accepts
|
|
24
|
+
* either `--trust safe|full` (friendly presets) or `--privileged-groups <list>`
|
|
25
|
+
* (any of `CAPABILITY_GROUPS`, or `all`, comma-separated). Unknown tokens are
|
|
26
|
+
* dropped with a warning so a typo never silently enables a group β which is why
|
|
27
|
+
* every printed example of this flag is derived from `CAPABILITY_GROUPS` rather
|
|
28
|
+
* than restated: a restated one named `network` long after it was deleted.
|
|
26
29
|
*/
|
|
27
30
|
export function resolvePrivilegedGroups(flags) {
|
|
28
31
|
const trustAlias = { safe: "", none: "", off: "", full: "all", all: "all" };
|
|
@@ -40,7 +43,7 @@ export function resolvePrivilegedGroups(flags) {
|
|
|
40
43
|
const value = [...enabled].sort().join(",");
|
|
41
44
|
return warn ? { value, warn } : { value };
|
|
42
45
|
}
|
|
43
|
-
const PLUGIN_REL =
|
|
46
|
+
const PLUGIN_REL = ADDON_REL;
|
|
44
47
|
const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
|
|
45
48
|
const SERVER_NAME = "godot";
|
|
46
49
|
/**
|
|
@@ -136,15 +139,44 @@ export function enablePlugin(projectGodotText) {
|
|
|
136
139
|
export function installAddon(addonSource, projectPath, opts) {
|
|
137
140
|
const dest = path.join(projectPath, PLUGIN_REL);
|
|
138
141
|
const exists = fs.existsSync(path.join(dest, "plugin.cfg"));
|
|
142
|
+
const installed = exists ? readAddonVersion(dest) : null;
|
|
143
|
+
const source = readAddonVersion(addonSource);
|
|
144
|
+
const skew = exists ? compareAddonVersions(installed, source) : "unknown";
|
|
139
145
|
if (exists && !opts.force)
|
|
140
|
-
return { action: "skipped", dest };
|
|
146
|
+
return { action: "skipped", dest, installed, source, skew };
|
|
141
147
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
142
148
|
fs.cpSync(addonSource, dest, { recursive: true });
|
|
143
|
-
return { action: exists ? "overwritten" : "installed", dest };
|
|
149
|
+
return { action: exists ? "overwritten" : "installed", dest, installed, source, skew };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* POSIX-quote one word of a command line this CLI prints for a human to paste.
|
|
153
|
+
*
|
|
154
|
+
* π΄ A GENERATED COMMAND LINE IS AN INPUT YOU WROTE. The JSON snippet beside this
|
|
155
|
+
* one has been correct since it shipped, and for a reason that has nothing to do
|
|
156
|
+
* with care: JSON quotes for you. The shell line was assembled by interpolation,
|
|
157
|
+
* so `~/Godot Projects/My Game` became four words β `claude mcp add` read
|
|
158
|
+
* `--env GODOT_PROJECT=/Users/x/Godot` and then `Projects/My`, `Game` as stray
|
|
159
|
+
* positionals, and the server it configured pointed at a directory that does not
|
|
160
|
+
* exist. Nothing failed loudly; the path was simply truncated at the first space.
|
|
161
|
+
*
|
|
162
|
+
* The safe set is `shlex.quote`'s: characters no shell reinterprets, so an
|
|
163
|
+
* ordinary path stays readable and only a path that needs quoting gets it. The
|
|
164
|
+
* `'\''` idiom closes the quote, escapes a literal apostrophe, and reopens β
|
|
165
|
+
* correct in every POSIX shell, and the only way to carry a `'` inside `'β¦'`.
|
|
166
|
+
*/
|
|
167
|
+
export function shellQuote(word) {
|
|
168
|
+
if (word.length > 0 && /^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
|
|
169
|
+
return word;
|
|
170
|
+
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
144
171
|
}
|
|
145
172
|
function claudeCodeCommand(projectPath, privilegedGroups) {
|
|
146
|
-
|
|
147
|
-
|
|
173
|
+
// Each `--env` value is ONE word to the shell, so the whole `KEY=value` token is
|
|
174
|
+
// quoted rather than the path alone: `GODOT_PROJECT='/a b'` would reach the
|
|
175
|
+
// process as the literal string it looks like, quotes and all.
|
|
176
|
+
const groups = privilegedGroups
|
|
177
|
+
? ` --env ${shellQuote(`BREAKPOINT_PRIVILEGED_GROUPS=${privilegedGroups}`)}`
|
|
178
|
+
: "";
|
|
179
|
+
return `claude mcp add godot --env ${shellQuote(`GODOT_PROJECT=${projectPath}`)}${groups} -- npx -y breakpoint-mcp`;
|
|
148
180
|
}
|
|
149
181
|
/** Entry point for `breakpoint-mcp init`. Returns the process exit code. */
|
|
150
182
|
export async function runInit(argv, deps = {}) {
|
|
@@ -225,7 +257,18 @@ export async function runInit(argv, deps = {}) {
|
|
|
225
257
|
const r = installAddon(addonSource, projectPath, { force });
|
|
226
258
|
if (tmpFetchDir)
|
|
227
259
|
fs.rmSync(tmpFetchDir, { recursive: true, force: true });
|
|
228
|
-
|
|
260
|
+
const versionNote = r.action === "skipped" ? ` (kept ${r.installed ?? "?"})` : r.source ? ` (${r.source})` : "";
|
|
261
|
+
say(` addon: ${r.action} β ${PLUGIN_REL}/${versionNote}`);
|
|
262
|
+
// π΄ THE LINE THAT MAKES THE SKIP HONEST. A skip over an addon that is already
|
|
263
|
+
// current is correct and silent; a skip over an OLDER one is the moment the skew
|
|
264
|
+
// becomes permanent, and until now the two read identically. It goes to stderr
|
|
265
|
+
// because it is not part of the install transcript a user pastes β it is the thing
|
|
266
|
+
// that went wrong inside a command that exits 0.
|
|
267
|
+
if (r.action === "skipped" && r.skew === "older") {
|
|
268
|
+
process.stderr.write(`init: the addon already in this project is OLDER than the one this host ships ` +
|
|
269
|
+
`(${r.installed ?? "?"} vs ${r.source ?? "?"}), and it was left in place.\n` +
|
|
270
|
+
` ${ADDON_SKEW_HINT}\n`);
|
|
271
|
+
}
|
|
229
272
|
}
|
|
230
273
|
// 2. Enable it in project.godot.
|
|
231
274
|
const before = fs.readFileSync(projGodot, "utf8");
|
|
@@ -288,9 +331,18 @@ export async function runInit(argv, deps = {}) {
|
|
|
288
331
|
}
|
|
289
332
|
else {
|
|
290
333
|
say("Higher-trust tool groups are OFF by default (the secure default). The assistant loads the");
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
334
|
+
// π΄ THE GROUP NAME AND THE EXAMPLES ARE BOTH CORRECTIONS, NOT POLISH.
|
|
335
|
+
// This said "code-execution + network tools" and named `backend_*` among
|
|
336
|
+
// them: `network` has been deleted from capabilities.ts, and
|
|
337
|
+
// backend_detect / backend_configure are DELIBERATELY unprivileged β so the
|
|
338
|
+
// first thing a new user reads named a group that does not exist and two
|
|
339
|
+
// tools that are not dropped. The suggested flag below carried the same
|
|
340
|
+
// dead token, which `init`'s own resolver answers with
|
|
341
|
+
// `ignoring unknown trust group(s): network`: the tool warned about the
|
|
342
|
+
// command the tool told them to run.
|
|
343
|
+
say(`everyday authoring/debug surface and drops the ${PRIVILEGED_TOOL_COUNT} ${CAPABILITY_GROUPS.join(" + ")} tools (e.g.`);
|
|
344
|
+
say("godot_run_headless_script, the *_call_method / dbg_evaluate tools, asset-gen).");
|
|
345
|
+
say(`Enable them by re-running with \`--trust full\` (or \`--privileged-groups ${CAPABILITY_GROUPS.join(",")}\`),`);
|
|
294
346
|
say("or set BREAKPOINT_PRIVILEGED_GROUPS in the server env. The `godot://capabilities` resource and");
|
|
295
347
|
say("`breakpoint-mcp doctor` list exactly what each group gates.");
|
|
296
348
|
}
|
package/dist/cli/usage.js
CHANGED
|
@@ -51,7 +51,11 @@ export const DOCTOR_USAGE = [
|
|
|
51
51
|
"",
|
|
52
52
|
"doctor options:",
|
|
53
53
|
" --project <dir> Project to check (default: $GODOT_PROJECT or the current directory).",
|
|
54
|
-
" --require-live
|
|
54
|
+
" --require-live[=L] Require bridges to be reachable rather than just report them.",
|
|
55
|
+
" Bare, it means the three OPENING THE EDITOR brings up: the editor",
|
|
56
|
+
" bridge, the GDScript LSP and the DAP. L is editor | runtime | all β",
|
|
57
|
+
" the runtime bridge lives inside the running game, so it is required",
|
|
58
|
+
" only by =runtime and =all.",
|
|
55
59
|
" --include-csharp Also probe OmniSharp / netcoredbg on PATH (the C# planes).",
|
|
56
60
|
" --timeout <ms> Per-bridge connect timeout (default 1500).",
|
|
57
61
|
" --json Emit the report as JSON.",
|
package/dist/index.js
CHANGED
|
@@ -16,10 +16,11 @@ import { applyWireDefaults } from "./wire-defaults.js";
|
|
|
16
16
|
import { applyOutputSchemas } from "./schemas.js";
|
|
17
17
|
import { applyAnnotations } from "./annotations.js";
|
|
18
18
|
import { applyTimeoutCaveat } from "./timeout-caveat.js";
|
|
19
|
-
import { applyCapabilities, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
|
|
19
|
+
import { applyCapabilities, applyDroppedToolRefusal, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
|
|
20
20
|
import { taskStore, TASK_CAPABILITIES } from "./tasks.js";
|
|
21
21
|
import { RESOURCE_CAPABILITIES, registerResourceSubscriptions } from "./subscriptions.js";
|
|
22
22
|
import { pauseLatch, installPauseSignalHandlers } from "./pause.js";
|
|
23
|
+
import { applyResultCost } from "./result-cost.js";
|
|
23
24
|
import { log } from "./logger.js";
|
|
24
25
|
async function main() {
|
|
25
26
|
const config = loadConfig();
|
|
@@ -43,7 +44,7 @@ async function main() {
|
|
|
43
44
|
// so long jobs (export/import/headless script) support poll/await/cancel.
|
|
44
45
|
// D3: also advertise resources.subscribe so clients can subscribe to
|
|
45
46
|
// godot://β¦ resources and receive notifications/resources/updated.
|
|
46
|
-
const server = new McpServer({ name: "breakpoint-mcp", version: "1.
|
|
47
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.76.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
|
|
47
48
|
// 208 Β§7.1 β delete from the wire what the protocol already says: the SDK's
|
|
48
49
|
// draft-07 `$schema` inside every input/output schema (MCP's default dialect is
|
|
49
50
|
// 2020-12, and declaring another is an explicit switch a peer may reject), and
|
|
@@ -129,6 +130,17 @@ async function main() {
|
|
|
129
130
|
// read godot://capabilities to see what exists-but-is-disabled and how to
|
|
130
131
|
// enable it. Registered directly (not via the `resources` toolset).
|
|
131
132
|
registerCapabilitiesResource(server, privilegedGroups);
|
|
133
|
+
// β¦and the other half of "never a silent gap", which the resource alone could
|
|
134
|
+
// not deliver. A dropped tool is not registered, so calling it by name reaches
|
|
135
|
+
// the SDK's `Tool <name> not found` β grammatical, accurate about the lookup,
|
|
136
|
+
// and wrong about the world. Must run AFTER every ts.run() above: the CallTool
|
|
137
|
+
// dispatcher does not exist until the first registerTool, and this wraps it.
|
|
138
|
+
// Advertises nothing new β tools/list is untouched at 279.
|
|
139
|
+
applyDroppedToolRefusal(server, privilegedGroups);
|
|
140
|
+
// 257 β the result-cost meter, off unless BREAKPOINT_RESULT_COST names a file.
|
|
141
|
+
// AFTER the refusal wrapper so a dropped tool's refusal is measured as the result
|
|
142
|
+
// it actually is: a client pays for a refusal exactly as it pays for an answer.
|
|
143
|
+
applyResultCost(server);
|
|
132
144
|
if (config.privilegedGroups) {
|
|
133
145
|
const on = [...privilegedGroups].sort().join(", ") || "(none)";
|
|
134
146
|
log(`privileged groups enabled: ${on}; dropped ${droppedTools(privilegedGroups).length} tool(s) from the surface`);
|
|
@@ -181,23 +193,55 @@ function printUsage() {
|
|
|
181
193
|
// * An unrecognized token is a TYPO, and the only useful thing to do with one
|
|
182
194
|
// is name it. Falling through spends the user's next twenty minutes on the
|
|
183
195
|
// wrong question.
|
|
196
|
+
// π΄ EXIT ONLY AFTER stdout HAS DRAINED, AND IT IS THE PIPE THAT MAKES THIS A
|
|
197
|
+
// DEFECT RATHER THAN A STYLE NOTE. `process.stdout` is SYNCHRONOUS to a file
|
|
198
|
+
// and to a TTY and ASYNCHRONOUS to a pipe. `process.exit()` does not flush what
|
|
199
|
+
// is still queued, so every subcommand below was correct when a human watched
|
|
200
|
+
// it, correct when a shell redirected it to a file, and lost everything past
|
|
201
|
+
// the first 64 KiB the moment anybody piped it.
|
|
202
|
+
//
|
|
203
|
+
// Measured on 1.75.0 and on the published 1.74.1 β same command, same build:
|
|
204
|
+
//
|
|
205
|
+
// breakpoint-mcp tools --json > file 202,960 bytes Β· valid JSON
|
|
206
|
+
// breakpoint-mcp tools --json | cat 65,536 bytes Β· unterminated string
|
|
207
|
+
//
|
|
208
|
+
// 65,536 is one pipe buffer, and that is the whole tell: nothing failed and no
|
|
209
|
+
// error was raised, the process simply stopped existing with the rest of the
|
|
210
|
+
// answer still inside it. The flag's own usage text is `so releases can be
|
|
211
|
+
// diffed`, and diffing is a pipe.
|
|
212
|
+
//
|
|
213
|
+
// π΄ THE POPULATION IS A PREDICATE, NOT A LIST OF COMMANDS (257). `tools --json`
|
|
214
|
+
// is the only subcommand that exceeds a buffer TODAY β `doctor --json` measures
|
|
215
|
+
// 2,138 bytes and `init --dry-run` 1,186 β so a fix inside `runTools` would be
|
|
216
|
+
// correct and would leave the next command to cross 64 KiB to rediscover this
|
|
217
|
+
// from a user's bug report. The drain is attached at the dispatch every
|
|
218
|
+
// subcommand passes through instead (254).
|
|
219
|
+
//
|
|
220
|
+
// The empty write IS the flush: its callback runs once everything already
|
|
221
|
+
// queued on the stream has been handed to the OS.
|
|
222
|
+
async function exitAfterFlush(code) {
|
|
223
|
+
await new Promise((resolve) => {
|
|
224
|
+
process.stdout.write("", () => resolve());
|
|
225
|
+
});
|
|
226
|
+
process.exit(code);
|
|
227
|
+
}
|
|
184
228
|
void (async () => {
|
|
185
229
|
const sub = process.argv[2];
|
|
186
230
|
if (sub === "doctor") {
|
|
187
231
|
const { runDoctor } = await import("./cli/doctor.js");
|
|
188
|
-
|
|
232
|
+
await exitAfterFlush(await runDoctor(process.argv.slice(3)));
|
|
189
233
|
}
|
|
190
234
|
if (sub === "tools") {
|
|
191
235
|
const { runTools } = await import("./cli/tools.js");
|
|
192
|
-
|
|
236
|
+
await exitAfterFlush(await runTools(process.argv.slice(3)));
|
|
193
237
|
}
|
|
194
238
|
if (sub === "init") {
|
|
195
239
|
const { runInit } = await import("./cli/init.js");
|
|
196
|
-
|
|
240
|
+
await exitAfterFlush(await runInit(process.argv.slice(3)));
|
|
197
241
|
}
|
|
198
242
|
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
199
243
|
printUsage();
|
|
200
|
-
|
|
244
|
+
await exitAfterFlush(0);
|
|
201
245
|
}
|
|
202
246
|
if (sub === "version" || sub === "--version" || sub === "-v" || sub === "-V") {
|
|
203
247
|
// Bare, so `breakpoint-mcp --version` is usable in a shell substitution.
|
|
@@ -205,13 +249,13 @@ void (async () => {
|
|
|
205
249
|
// twenty releases that announced 0.2.0 to every LSP server they met.
|
|
206
250
|
const { packageVersion } = await import("./version.js");
|
|
207
251
|
process.stdout.write(`${packageVersion()}\n`);
|
|
208
|
-
|
|
252
|
+
await exitAfterFlush(0);
|
|
209
253
|
}
|
|
210
254
|
if (sub !== undefined) {
|
|
211
255
|
process.stderr.write(`breakpoint-mcp: unknown argument '${sub}'.\n` +
|
|
212
256
|
" Run `breakpoint-mcp --help` for the subcommands and their options.\n" +
|
|
213
257
|
" To start the MCP server, run it with no arguments β that is how MCP clients launch it.\n");
|
|
214
|
-
|
|
258
|
+
await exitAfterFlush(2);
|
|
215
259
|
}
|
|
216
260
|
await main();
|
|
217
261
|
})().catch((err) => {
|