breakpoint-mcp 1.74.0 โ 1.75.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 +63 -4
- package/dist/capabilities.js +91 -2
- package/dist/cli/args.js +32 -2
- package/dist/cli/doctor.js +240 -19
- package/dist/cli/init.js +87 -16
- package/dist/cli/tools.js +12 -15
- package/dist/cli/usage.js +106 -0
- package/dist/index.js +84 -43
- 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
|
*/
|
|
@@ -23,10 +35,28 @@ import path from "node:path";
|
|
|
23
35
|
import { spawnSync } from "node:child_process";
|
|
24
36
|
import { loadConfig } from "../config.js";
|
|
25
37
|
import { CAPABILITY_GROUPS, droppedTools, selectPrivilegedGroups } from "../capabilities.js";
|
|
26
|
-
import { parseArgs } from "./args.js";
|
|
38
|
+
import { parseArgs, preflight } from "./args.js";
|
|
39
|
+
import { DOCTOR_FLAGS, DOCTOR_USAGE } from "./usage.js";
|
|
27
40
|
import { BridgeClient } from "../bridge.js";
|
|
28
41
|
import { resolveBridgeSecret } from "../secret.js";
|
|
29
|
-
|
|
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
|
+
}
|
|
30
60
|
const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
|
|
31
61
|
/** Resolve after a TCP connect succeeds (true) or the port is closed/times out (false). */
|
|
32
62
|
function probeTcp(host, port, timeoutMs) {
|
|
@@ -89,6 +119,57 @@ function checkGodotBinary(bin, timeoutMs) {
|
|
|
89
119
|
detail: `${bin} โ ${version}`,
|
|
90
120
|
};
|
|
91
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
|
+
}
|
|
92
173
|
function checkAddon(projectPath) {
|
|
93
174
|
const projText = readText(path.join(projectPath, "project.godot"));
|
|
94
175
|
if (projText === null) {
|
|
@@ -114,13 +195,14 @@ function checkAddon(projectPath) {
|
|
|
114
195
|
});
|
|
115
196
|
}
|
|
116
197
|
else {
|
|
117
|
-
const
|
|
198
|
+
const installed = readAddonVersion(path.join(projectPath, ADDON_REL));
|
|
118
199
|
checks.push({
|
|
119
200
|
name: "addon-installed",
|
|
120
201
|
status: "ok",
|
|
121
202
|
severity: "required",
|
|
122
|
-
detail: `${ADDON_REL} (version ${
|
|
203
|
+
detail: `${ADDON_REL} (version ${installed ?? "?"})`,
|
|
123
204
|
});
|
|
205
|
+
checks.push(addonVersionCheck(installed));
|
|
124
206
|
}
|
|
125
207
|
checks.push(isPluginEnabled(projText)
|
|
126
208
|
? {
|
|
@@ -197,9 +279,12 @@ export function checkCapabilities(config) {
|
|
|
197
279
|
detail: dropped.length === 0
|
|
198
280
|
? `${state} โ full 292-tool surface`
|
|
199
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.
|
|
200
285
|
hint: dropped.length === 0
|
|
201
286
|
? undefined
|
|
202
|
-
:
|
|
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.`,
|
|
203
288
|
},
|
|
204
289
|
];
|
|
205
290
|
const assetGenConfigured = (config.assetGenBackend && config.assetGenBackend !== "none") ||
|
|
@@ -224,20 +309,61 @@ export function checkCapabilities(config) {
|
|
|
224
309
|
* secret that no longer matches the one the addon minted. `probeTcp` can see
|
|
225
310
|
* neither, which is what made "editor-bridge reachable" a check that could pass
|
|
226
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.
|
|
227
321
|
*/
|
|
228
322
|
async function handshakeOk(config, b, timeoutMs) {
|
|
229
323
|
const client = new BridgeClient(b.host, b.port, timeoutMs, "bridge", undefined, () => resolveBridgeSecret(config.projectPath, b.secretEnv ?? []));
|
|
230
324
|
try {
|
|
231
|
-
await client.request("ping", {}, timeoutMs);
|
|
232
|
-
|
|
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 };
|
|
233
328
|
}
|
|
234
329
|
catch {
|
|
235
|
-
return false;
|
|
330
|
+
return { ok: false, addonVersion: null };
|
|
236
331
|
}
|
|
237
332
|
finally {
|
|
238
333
|
client.close();
|
|
239
334
|
}
|
|
240
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
|
+
}
|
|
241
367
|
export async function runDoctorChecks(config, opts) {
|
|
242
368
|
const checks = [];
|
|
243
369
|
// Capability groups โ the secure-default surface + how to widen it (info only).
|
|
@@ -247,21 +373,25 @@ export async function runDoctorChecks(config, opts) {
|
|
|
247
373
|
// Editor addon install + enable.
|
|
248
374
|
checks.push(...checkAddon(config.projectPath));
|
|
249
375
|
// The four bridges.
|
|
250
|
-
const severity = opts.requireLive ? "required" : "info";
|
|
251
376
|
// `secretEnv` marks the two bridges that speak OUR protocol, so a TCP connect
|
|
252
377
|
// is not the end of the story for them (see handshakeOk). The LSP and DAP
|
|
253
378
|
// entries have none: those ports belong to Godot, and anything we could send
|
|
254
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.
|
|
255
383
|
const bridges = [
|
|
256
384
|
{
|
|
257
385
|
name: "editor-bridge",
|
|
386
|
+
tier: "editor",
|
|
258
387
|
host: config.bridgeHost,
|
|
259
388
|
port: config.bridgePort,
|
|
260
|
-
hint: 'Open the editor with the "Breakpoint MCP" plugin enabled.',
|
|
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).',
|
|
261
390
|
secretEnv: ["BREAKPOINT_BRIDGE_SECRET"],
|
|
262
391
|
},
|
|
263
392
|
{
|
|
264
393
|
name: "runtime-bridge",
|
|
394
|
+
tier: "runtime",
|
|
265
395
|
host: config.runtimeHost,
|
|
266
396
|
port: config.runtimePort,
|
|
267
397
|
hint: "Launch the project (godot_run_project / dbg_launch) with the plugin enabled โ it auto-registers the runtime autoload.",
|
|
@@ -269,24 +399,35 @@ export async function runDoctorChecks(config, opts) {
|
|
|
269
399
|
},
|
|
270
400
|
{
|
|
271
401
|
name: "gdscript-lsp",
|
|
402
|
+
tier: "editor",
|
|
272
403
|
host: config.lspHost,
|
|
273
404
|
port: config.lspPort,
|
|
274
405
|
hint: "Godot's language server runs while the editor is open (Editor โ Editor Settings โ Network โ Language Server).",
|
|
275
406
|
},
|
|
276
407
|
{
|
|
277
408
|
name: "gdscript-dap",
|
|
409
|
+
tier: "editor",
|
|
278
410
|
host: config.dapHost,
|
|
279
411
|
port: config.dapPort,
|
|
280
412
|
hint: "Godot's debug adapter runs while the editor is open (Editor โ Editor Settings โ Network โ Debug Adapter).",
|
|
281
413
|
},
|
|
282
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();
|
|
283
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
|
|
284
424
|
const ok = await probeTcp(b.host, b.port, opts.timeoutMs);
|
|
285
425
|
if (!ok) {
|
|
286
426
|
return {
|
|
287
427
|
name: b.name,
|
|
288
428
|
status: "fail",
|
|
289
429
|
severity,
|
|
430
|
+
liveness,
|
|
290
431
|
detail: `${b.host}:${b.port} unreachable`,
|
|
291
432
|
hint: b.hint,
|
|
292
433
|
};
|
|
@@ -299,11 +440,14 @@ export async function runDoctorChecks(config, opts) {
|
|
|
299
440
|
// Both then reported "reachable" here and failed on every real call.
|
|
300
441
|
if (b.secretEnv) {
|
|
301
442
|
const live = await handshakeOk(config, b, opts.timeoutMs);
|
|
302
|
-
if (
|
|
443
|
+
if (live.addonVersion !== null)
|
|
444
|
+
liveAddonVersions.set(b.name, live.addonVersion);
|
|
445
|
+
if (!live.ok) {
|
|
303
446
|
return {
|
|
304
447
|
name: b.name,
|
|
305
448
|
status: "fail",
|
|
306
449
|
severity,
|
|
450
|
+
liveness,
|
|
307
451
|
detail: `${b.host}:${b.port} open, but no Breakpoint bridge answered`,
|
|
308
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.",
|
|
309
453
|
};
|
|
@@ -313,10 +457,19 @@ export async function runDoctorChecks(config, opts) {
|
|
|
313
457
|
name: b.name,
|
|
314
458
|
status: "ok",
|
|
315
459
|
severity,
|
|
460
|
+
liveness,
|
|
316
461
|
detail: `${b.host}:${b.port} reachable${b.secretEnv ? " ยท bridge answered" : ""}`,
|
|
317
462
|
};
|
|
318
463
|
}));
|
|
319
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
|
+
}
|
|
320
473
|
// Optional C# tooling.
|
|
321
474
|
if (opts.includeCsharp) {
|
|
322
475
|
checks.push(checkCsharpTool("csharp-lsp", config.csLspCmd));
|
|
@@ -325,34 +478,102 @@ export async function runDoctorChecks(config, opts) {
|
|
|
325
478
|
const ok = checks.every((c) => c.severity !== "required" || c.status !== "fail");
|
|
326
479
|
return { checks, ok };
|
|
327
480
|
}
|
|
328
|
-
|
|
329
|
-
|
|
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(" ");
|
|
330
525
|
}
|
|
331
526
|
function printReport(report) {
|
|
332
527
|
const width = Math.max(...report.checks.map((c) => c.name.length));
|
|
333
528
|
const out = ["breakpoint-mcp doctor", ""];
|
|
334
529
|
for (const c of report.checks) {
|
|
335
|
-
out.push(` ${glyph(c
|
|
530
|
+
out.push(` ${glyph(c)} ${c.name.padEnd(width)} ${c.detail}`);
|
|
336
531
|
if (c.status === "fail" && c.hint)
|
|
337
532
|
out.push(` โณ ${c.hint}`);
|
|
338
533
|
}
|
|
339
534
|
out.push("");
|
|
340
|
-
out.push(report
|
|
341
|
-
? "All required checks passed."
|
|
342
|
-
: "Some required checks failed โ see the โณ hints above.");
|
|
535
|
+
out.push(summaryLine(report));
|
|
343
536
|
process.stdout.write(out.join("\n") + "\n");
|
|
344
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
|
+
}
|
|
345
557
|
/** Entry point for `breakpoint-mcp doctor`. Returns the process exit code. */
|
|
346
558
|
export async function runDoctor(argv) {
|
|
347
|
-
const
|
|
559
|
+
const parsed = parseArgs(argv, ["json", "require-live", "include-csharp", "help", "h"], DOCTOR_FLAGS);
|
|
560
|
+
const pre = preflight(parsed, "doctor", DOCTOR_USAGE);
|
|
561
|
+
if (pre !== null)
|
|
562
|
+
return pre;
|
|
563
|
+
const { flags } = parsed;
|
|
348
564
|
if (typeof flags.project === "string")
|
|
349
565
|
process.env.GODOT_PROJECT = flags.project;
|
|
350
566
|
const timeoutRaw = typeof flags.timeout === "string" ? Number.parseInt(flags.timeout, 10) : NaN;
|
|
351
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
|
+
}
|
|
352
573
|
const config = loadConfig();
|
|
353
574
|
const report = await runDoctorChecks(config, {
|
|
354
575
|
timeoutMs,
|
|
355
|
-
|
|
576
|
+
liveLevel,
|
|
356
577
|
includeCsharp: flags["include-csharp"] === true,
|
|
357
578
|
});
|
|
358
579
|
if (flags.json === true) {
|
package/dist/cli/init.js
CHANGED
|
@@ -12,16 +12,20 @@ import fs from "node:fs";
|
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { parseArgs } from "./args.js";
|
|
15
|
+
import { parseArgs, preflight } from "./args.js";
|
|
16
|
+
import { INIT_FLAGS, INIT_USAGE } from "./usage.js";
|
|
17
|
+
import { ADDON_REL, ADDON_SKEW_HINT, compareAddonVersions, readAddonVersion, } from "../addon-version.js";
|
|
16
18
|
import { CLIENT_IDS, clientInfo, mergeClientConfig, serverEntry, snippet } from "./clients.js";
|
|
17
19
|
import { DEFAULT_REPO, fetchAddonFromGitHub } from "./github.js";
|
|
18
20
|
import { CAPABILITY_GROUPS, PRIVILEGED_TOOL_COUNT, parsePrivilegedGroups, selectPrivilegedGroups, } from "../capabilities.js";
|
|
19
21
|
/**
|
|
20
22
|
* Resolve the guided trust preset into a validated BREAKPOINT_PRIVILEGED_GROUPS
|
|
21
|
-
* value ("" = the safe default โ
|
|
22
|
-
* `--trust safe|full` (friendly presets) or `--privileged-groups <list>`
|
|
23
|
-
* (
|
|
24
|
-
* 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.
|
|
25
29
|
*/
|
|
26
30
|
export function resolvePrivilegedGroups(flags) {
|
|
27
31
|
const trustAlias = { safe: "", none: "", off: "", full: "all", all: "all" };
|
|
@@ -39,7 +43,7 @@ export function resolvePrivilegedGroups(flags) {
|
|
|
39
43
|
const value = [...enabled].sort().join(",");
|
|
40
44
|
return warn ? { value, warn } : { value };
|
|
41
45
|
}
|
|
42
|
-
const PLUGIN_REL =
|
|
46
|
+
const PLUGIN_REL = ADDON_REL;
|
|
43
47
|
const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
|
|
44
48
|
const SERVER_NAME = "godot";
|
|
45
49
|
/**
|
|
@@ -135,19 +139,52 @@ export function enablePlugin(projectGodotText) {
|
|
|
135
139
|
export function installAddon(addonSource, projectPath, opts) {
|
|
136
140
|
const dest = path.join(projectPath, PLUGIN_REL);
|
|
137
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";
|
|
138
145
|
if (exists && !opts.force)
|
|
139
|
-
return { action: "skipped", dest };
|
|
146
|
+
return { action: "skipped", dest, installed, source, skew };
|
|
140
147
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
141
148
|
fs.cpSync(addonSource, dest, { recursive: true });
|
|
142
|
-
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, `'\\''`)}'`;
|
|
143
171
|
}
|
|
144
172
|
function claudeCodeCommand(projectPath, privilegedGroups) {
|
|
145
|
-
|
|
146
|
-
|
|
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`;
|
|
147
180
|
}
|
|
148
181
|
/** Entry point for `breakpoint-mcp init`. Returns the process exit code. */
|
|
149
182
|
export async function runInit(argv, deps = {}) {
|
|
150
|
-
const
|
|
183
|
+
const parsed = parseArgs(argv, ["force", "dry-run", "help", "h"], INIT_FLAGS);
|
|
184
|
+
const pre = preflight(parsed, "init", INIT_USAGE);
|
|
185
|
+
if (pre !== null)
|
|
186
|
+
return pre;
|
|
187
|
+
const { flags } = parsed;
|
|
151
188
|
const projectPath = typeof flags.project === "string"
|
|
152
189
|
? path.resolve(flags.project)
|
|
153
190
|
: process.env.GODOT_PROJECT ?? process.cwd();
|
|
@@ -220,7 +257,18 @@ export async function runInit(argv, deps = {}) {
|
|
|
220
257
|
const r = installAddon(addonSource, projectPath, { force });
|
|
221
258
|
if (tmpFetchDir)
|
|
222
259
|
fs.rmSync(tmpFetchDir, { recursive: true, force: true });
|
|
223
|
-
|
|
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
|
+
}
|
|
224
272
|
}
|
|
225
273
|
// 2. Enable it in project.godot.
|
|
226
274
|
const before = fs.readFileSync(projGodot, "utf8");
|
|
@@ -283,16 +331,39 @@ export async function runInit(argv, deps = {}) {
|
|
|
283
331
|
}
|
|
284
332
|
else {
|
|
285
333
|
say("Higher-trust tool groups are OFF by default (the secure default). The assistant loads the");
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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(",")}\`),`);
|
|
289
346
|
say("or set BREAKPOINT_PRIVILEGED_GROUPS in the server env. The `godot://capabilities` resource and");
|
|
290
347
|
say("`breakpoint-mcp doctor` list exactly what each group gates.");
|
|
291
348
|
}
|
|
292
349
|
if (privilegedWarn)
|
|
293
350
|
say(` note: ${privilegedWarn}`);
|
|
294
351
|
say("");
|
|
295
|
-
|
|
352
|
+
// ๐ด THE REOPEN TRAP. Godot reads project.godot's [editor_plugins] at project load and
|
|
353
|
+
// does not reload it, so an editor that was already running when we wrote the section
|
|
354
|
+
// keeps the plugin DISABLED โ the bridge never starts and every editor-plane tool is
|
|
355
|
+
// dark, with nothing on screen saying why. The line is conditioned on `en.changed`
|
|
356
|
+
// because a project whose plugin was already enabled has nothing to reopen for, and a
|
|
357
|
+
// warning printed on every run is a warning nobody reads.
|
|
358
|
+
if (!dryRun && en.changed) {
|
|
359
|
+
say("Next: open the project in Godot โ and if it is ALREADY OPEN, close and reopen it.");
|
|
360
|
+
say(" Godot reads project.godot's [editor_plugins] at project load and does not reload");
|
|
361
|
+
say(" it, so an editor that was already running keeps the plugin disabled.");
|
|
362
|
+
say("Then run `breakpoint-mcp doctor --require-live` to verify.");
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
say("Next: open the project in Godot, then run `breakpoint-mcp doctor --require-live` to verify.");
|
|
366
|
+
}
|
|
296
367
|
process.stdout.write(out.join("\n") + "\n");
|
|
297
368
|
return 0;
|
|
298
369
|
}
|
package/dist/cli/tools.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* `--surface` picks which one populates `tools[]`; both counts are always in the
|
|
23
23
|
* header so a consumer cannot report one as the other by accident.
|
|
24
24
|
*/
|
|
25
|
-
import { parseArgs } from "./args.js";
|
|
25
|
+
import { parseArgs, preflight } from "./args.js";
|
|
26
|
+
import { TOOLS_FLAGS, TOOLS_USAGE } from "./usage.js";
|
|
26
27
|
import { buildToolsets } from "../toolsets.js";
|
|
27
28
|
import { applyOutputSchemas } from "../schemas.js";
|
|
28
29
|
import { applyAnnotations, annotationsFor } from "../annotations.js";
|
|
@@ -148,20 +149,16 @@ function renderText(report) {
|
|
|
148
149
|
return lines.join("\n");
|
|
149
150
|
}
|
|
150
151
|
export async function runTools(argv) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"",
|
|
162
|
-
].join("\n"));
|
|
163
|
-
return 0;
|
|
164
|
-
}
|
|
152
|
+
const parsed = parseArgs(argv, ["json", "help", "h"], TOOLS_FLAGS);
|
|
153
|
+
const pre = preflight(parsed, "tools", [
|
|
154
|
+
...TOOLS_USAGE,
|
|
155
|
+
"",
|
|
156
|
+
"Reads no Godot install and starts no server โ the registry is built statically.",
|
|
157
|
+
"",
|
|
158
|
+
]);
|
|
159
|
+
if (pre !== null)
|
|
160
|
+
return pre;
|
|
161
|
+
const { flags } = parsed;
|
|
165
162
|
const raw = typeof flags.surface === "string" ? flags.surface : "secure-default";
|
|
166
163
|
if (raw !== "full" && raw !== "secure-default") {
|
|
167
164
|
process.stderr.write(`unknown --surface "${raw}" (expected: full | secure-default)\n`);
|