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.
@@ -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
- const ADDON_REL = "addons/breakpoint_mcp";
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 m = /version\s*=\s*"([^"]*)"/.exec(cfgText);
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 ${m ? m[1] : "?"})`,
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
- : "Enable with BREAKPOINT_PRIVILEGED_GROUPS=code-execution,network (or `breakpoint-mcp init --trust full`). See the godot://capabilities resource for the exact tool list.",
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
- return true;
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 (!live) {
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
- function glyph(status) {
330
- return status === "ok" ? "βœ“" : status === "fail" ? "βœ—" : "–";
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.status)} ${c.name.padEnd(width)} ${c.detail}`);
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.ok
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
- requireLive: flags["require-live"] === true,
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 β€” `code-execution` + `network` off). Accepts either
23
- * `--trust safe|full` (friendly presets) or `--privileged-groups <list>`
24
- * (`code-execution`, `network`, `all`, comma-separated). Unknown tokens are dropped
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 = "addons/breakpoint_mcp";
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
- const groups = privilegedGroups ? ` --env BREAKPOINT_PRIVILEGED_GROUPS=${privilegedGroups}` : "";
147
- return `claude mcp add godot --env GODOT_PROJECT=${projectPath}${groups} -- npx -y breakpoint-mcp`;
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
- say(` addon: ${r.action} β†’ ${PLUGIN_REL}/`);
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
- say(`everyday authoring/debug surface and drops the ${PRIVILEGED_TOOL_COUNT} code-execution + network tools (e.g.`);
292
- say("godot_run_headless_script, the *_call_method / dbg_evaluate tools, asset-gen, backend_*).");
293
- say("Enable them by re-running with `--trust full` (or `--privileged-groups code-execution,network`),");
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 Also require the editor/runtime/LSP/DAP bridges to be reachable.",
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.74.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
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
- process.exit(await runDoctor(process.argv.slice(3)));
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
- process.exit(await runTools(process.argv.slice(3)));
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
- process.exit(await runInit(process.argv.slice(3)));
240
+ await exitAfterFlush(await runInit(process.argv.slice(3)));
197
241
  }
198
242
  if (sub === "help" || sub === "--help" || sub === "-h") {
199
243
  printUsage();
200
- process.exit(0);
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
- process.exit(0);
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
- process.exit(2);
258
+ await exitAfterFlush(2);
215
259
  }
216
260
  await main();
217
261
  })().catch((err) => {