breakpoint-mcp 1.73.4 → 1.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The MCP **host** for [Breakpoint MCP](https://github.com/jlivingston-Cipher/godot-breakpoint-mcp) —
4
4
  a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the
5
5
  Godot game engine to AI coding assistants across four planes: headless CLI, the live
6
- editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **291 tools
6
+ editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **292 tools
7
7
  + 6 MCP resources**, built against the stable `@modelcontextprotocol/sdk` 1.x API.
8
8
  Developed and tested with **Claude**; because MCP is an open protocol, other clients can
9
9
  connect too (currently untested — reports welcome).
@@ -1,6 +1,6 @@
1
1
  /** Tools that mutate nothing. */
2
2
  const READ_ONLY = [
3
- "anim_get_track_keys", "anim_list", "backend_detect", "class_reference", "classdb_get_class", "cs_code_action",
3
+ "anim_get_track_keys", "anim_list", "backend_detect", "breakpoint_doctor", "class_reference", "classdb_get_class", "cs_code_action",
4
4
  "cs_completion", "cs_dbg_scopes", "cs_dbg_stack_trace", "cs_dbg_variables", "cs_definition", "cs_diagnostics",
5
5
  "cs_document_symbols", "cs_hover", "cs_references", "cs_signature_help", "cs_workspace_symbols", "dbg_scopes",
6
6
  "dbg_stack_trace", "dbg_variables", "docs_search", "editor_get_state", "editor_ping", "example_snippet",
@@ -47,7 +47,7 @@ const IDEMPOTENT = [
47
47
  "area_set_gravity", "area_set_monitoring", "asset_gen_configure", "asset_gen_placeholder",
48
48
  "audio_bus_set_volume", "audio_set_bus_layout", "audio_set_stream", "auth_scaffold", "backend_configure",
49
49
  "backend_detect", "board_create", "board_place", "board_tile_create", "board_tile_place",
50
- "body_set_collision_layer", "body_set_collision_mask", "body_set_physics_material", "card_template_create",
50
+ "body_set_collision_layer", "body_set_collision_mask", "body_set_physics_material", "breakpoint_doctor", "card_template_create",
51
51
  "class_reference", "classdb_get_class", "cloudsave_scaffold", "control_set_anchors", "control_set_layout_preset",
52
52
  "control_set_size_flags", "control_set_theme", "cs_code_action", "cs_completion", "cs_dbg_attach",
53
53
  "cs_dbg_scopes", "cs_dbg_set_breakpoints", "cs_dbg_set_exception_breakpoints", "cs_dbg_set_variable",
@@ -111,7 +111,7 @@ export const ALL_ANNOTATED = [
111
111
  "audio_bus_set_volume", "audio_player_create", "audio_set_bus_layout", "audio_set_stream", "auth_scaffold",
112
112
  "backend_configure", "backend_detect", "board_create", "board_place", "board_tile_create", "board_tile_place",
113
113
  "body_create", "body_set_collision_layer", "body_set_collision_mask", "body_set_physics_material",
114
- "camera_create", "card_deck_from_table", "card_hand_layout", "card_instance", "card_set_face",
114
+ "breakpoint_doctor", "camera_create", "card_deck_from_table", "card_hand_layout", "card_instance", "card_set_face",
115
115
  "card_template_create", "class_reference", "classdb_get_class", "cloudsave_scaffold", "collisionpolygon_add",
116
116
  "collisionshape_add", "container_add_child", "control_create", "control_set_anchors",
117
117
  "control_set_layout_preset", "control_set_size_flags", "control_set_theme", "cs_code_action", "cs_completion",
package/dist/bridge.js CHANGED
@@ -2,6 +2,7 @@ import net from "node:net";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { log } from "./logger.js";
4
4
  import { OverdueLedger } from "./late-reply.js";
5
+ import { findNonFinite, describeNonFinite, tolerate, TOLERANT_METHODS } from "./finiteness.js";
5
6
  export class BridgeError extends Error {
6
7
  code;
7
8
  constructor(code, message) {
@@ -38,7 +39,17 @@ export class BridgeClient {
38
39
  eventListeners = new Set();
39
40
  wantConnected = false;
40
41
  reconnectTimer = null;
41
- constructor(host, port, defaultTimeoutMs, label = "editor bridge", hint = 'Is the editor open with the "Breakpoint MCP" plugin enabled?',
42
+ constructor(host, port, defaultTimeoutMs, label = "editor bridge",
43
+ // 🔴 THE SECOND SENTENCE IS THE ONE THAT EARNS ITS PLACE. The first asks a
44
+ // question whose answer, for the user most likely to be reading this line,
45
+ // is YES: the editor IS open and the plugin IS listed in project.godot.
46
+ // Godot reads `[editor_plugins]` at project load and never re-reads it, so
47
+ // an editor that was already running when `init` wrote the section keeps
48
+ // the plugin disabled — the bridge never starts, every editor-plane tool is
49
+ // dark, and the remedy agrees with everything the user can see. `init` now
50
+ // warns at the point it writes; this is the same fact told to the person
51
+ // who did not read that line, or who read it a week ago.
52
+ hint = 'Is the editor open with the "Breakpoint MCP" plugin enabled? 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.',
42
53
  /**
43
54
  * Loopback-auth secret provider, read lazily on each connect. When it
44
55
  * returns a non-empty string the client sends it as the FIRST line on the
@@ -201,7 +212,28 @@ export class BridgeClient {
201
212
  this.pending.delete(id);
202
213
  clearTimeout(p.timer);
203
214
  if (msg.ok) {
204
- p.resolve(msg.result ?? {});
215
+ // 🔴 THE ONE PLACE A NON-FINITE ENGINE FLOAT CAN ENTER THE HOST. Godot stringifies
216
+ // INF as `1e99999`, which is valid JSON, so `JSON.parse` above hands back a real
217
+ // `Infinity` — accepted by zod 3, refused by zod 4, and turned into `null` by our
218
+ // own re-serialisation to the client either way.
219
+ //
220
+ // 🔴 226 §2: THE POLICY IS PER-METHOD AND IT IS NOT A NULL. Two methods report a
221
+ // partial reading and prune; every other method REFUSES, naming the path and the
222
+ // value. Writing `null` into a `z.number()` slot — which is what the first
223
+ // containment did on 91 tools that never declared it — fails the schema with a
224
+ // message about the shape, and takes the roster that would have explained it down
225
+ // with the parse. See finiteness.ts for the population and the measurement.
226
+ const result = msg.result ?? {};
227
+ if (TOLERANT_METHODS.has(p.method)) {
228
+ p.resolve(tolerate(result));
229
+ }
230
+ else {
231
+ const hits = findNonFinite(result);
232
+ if (hits.length)
233
+ p.reject(new BridgeError("non_finite", describeNonFinite(hits)));
234
+ else
235
+ p.resolve(result);
236
+ }
205
237
  }
206
238
  else {
207
239
  const e = msg.error ?? { code: "unknown", message: "Unknown bridge error" };
@@ -246,7 +278,7 @@ export class BridgeClient {
246
278
  this.ledger.note(id, method, timeoutMs);
247
279
  reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`));
248
280
  }, timeoutMs);
249
- this.pending.set(id, { resolve: resolve, reject, timer });
281
+ this.pending.set(id, { resolve: resolve, reject, timer, method });
250
282
  socket.write(payload, (err) => {
251
283
  if (err) {
252
284
  clearTimeout(timer);
@@ -37,6 +37,17 @@ export const TOOL_CAPABILITIES = {
37
37
  // leaderboard_scaffold / cloudsave_scaffold / auth_scaffold — that write the
38
38
  // same kind of generated GDScript and were never privileged.
39
39
  };
40
+ /**
41
+ * How many tools the secure default drops, DERIVED rather than restated.
42
+ *
43
+ * 🔴 `breakpoint-mcp init` printed "drops the 14 code-execution + network tools"
44
+ * for as long as this group has had thirteen members — a wrong number in the first
45
+ * thing a new user reads, on the subject the secure default exists to explain.
46
+ * Check 25 reads three-digit numerals and this one is two digits; check 11 claims
47
+ * tool TOTALS and this is a subset of one. NO READER OWNED IT, which is 222 §2's
48
+ * finding arriving one instrument over, in a string we print to a terminal.
49
+ */
50
+ export const PRIVILEGED_TOOL_COUNT = Object.keys(TOOL_CAPABILITIES).length;
40
51
  /**
41
52
  * Parse the raw BREAKPOINT_PRIVILEGED_GROUPS env into a normalized token list
42
53
  * (or null for "unset" → no groups → the safe-default surface). Comma/whitespace
package/dist/cli/args.js CHANGED
@@ -1,4 +1,4 @@
1
- export function parseArgs(argv, booleanFlags = []) {
1
+ export function parseArgs(argv, booleanFlags = [], knownFlags) {
2
2
  const bool = new Set(booleanFlags);
3
3
  const positionals = [];
4
4
  const flags = {};
@@ -35,6 +35,36 @@ export function parseArgs(argv, booleanFlags = []) {
35
35
  }
36
36
  positionals.push(a);
37
37
  }
38
- return { positionals, flags };
38
+ // Computed AFTER the loop rather than inside it, so `--flag=value`,
39
+ // `--flag value` and the bare boolean forms are all judged by the same rule —
40
+ // the key that landed in `flags`, however it got there.
41
+ const known = knownFlags === undefined ? null : new Set([...knownFlags, ...booleanFlags]);
42
+ const unknown = known === null ? [] : Object.keys(flags).filter((k) => !known.has(k));
43
+ return { positionals, flags, unknown };
44
+ }
45
+ /**
46
+ * The two questions every subcommand must answer before it does any work: was
47
+ * `--help` asked for, and was it handed something it does not accept. Returns
48
+ * the exit code to return, or `null` to carry on.
49
+ *
50
+ * Shared rather than written per subcommand, because written per subcommand it
51
+ * was answered by exactly ONE of the three. `tools` handled `--help` and kept
52
+ * its own copy of the text; `doctor` ignored it and ran the checks; `init`
53
+ * ignored it and fell through to the project check, reporting a missing
54
+ * `project.godot` — an error about the user's directory in answer to a
55
+ * question about flags.
56
+ */
57
+ export function preflight(parsed, subcommand, usage) {
58
+ if (parsed.flags.help === true || parsed.flags.h === true) {
59
+ process.stdout.write(usage.join("\n") + "\n");
60
+ return 0;
61
+ }
62
+ if (parsed.unknown.length > 0) {
63
+ const named = parsed.unknown.map((u) => (u.length === 1 ? `-${u}` : `--${u}`)).join(", ");
64
+ process.stderr.write(`${subcommand}: unknown ${parsed.unknown.length === 1 ? "option" : "options"} ${named}.\n` +
65
+ ` Run \`breakpoint-mcp ${subcommand} --help\` for the options this subcommand accepts.\n`);
66
+ return 2;
67
+ }
68
+ return null;
39
69
  }
40
70
  //# sourceMappingURL=args.js.map
@@ -23,7 +23,8 @@ import path from "node:path";
23
23
  import { spawnSync } from "node:child_process";
24
24
  import { loadConfig } from "../config.js";
25
25
  import { CAPABILITY_GROUPS, droppedTools, selectPrivilegedGroups } from "../capabilities.js";
26
- import { parseArgs } from "./args.js";
26
+ import { parseArgs, preflight } from "./args.js";
27
+ import { DOCTOR_FLAGS, DOCTOR_USAGE } from "./usage.js";
27
28
  import { BridgeClient } from "../bridge.js";
28
29
  import { resolveBridgeSecret } from "../secret.js";
29
30
  const ADDON_REL = "addons/breakpoint_mcp";
@@ -195,7 +196,7 @@ export function checkCapabilities(config) {
195
196
  status: "ok",
196
197
  severity: "info",
197
198
  detail: dropped.length === 0
198
- ? `${state} — full 291-tool surface`
199
+ ? `${state} — full 292-tool surface`
199
200
  : `${state} (secure default) — ${dropped.length} higher-trust tool(s) dropped from the surface`,
200
201
  hint: dropped.length === 0
201
202
  ? undefined
@@ -257,7 +258,7 @@ export async function runDoctorChecks(config, opts) {
257
258
  name: "editor-bridge",
258
259
  host: config.bridgeHost,
259
260
  port: config.bridgePort,
260
- hint: 'Open the editor with the "Breakpoint MCP" plugin enabled.',
261
+ 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
262
  secretEnv: ["BREAKPOINT_BRIDGE_SECRET"],
262
263
  },
263
264
  {
@@ -344,7 +345,11 @@ function printReport(report) {
344
345
  }
345
346
  /** Entry point for `breakpoint-mcp doctor`. Returns the process exit code. */
346
347
  export async function runDoctor(argv) {
347
- const { flags } = parseArgs(argv, ["json", "require-live", "include-csharp"]);
348
+ const parsed = parseArgs(argv, ["json", "require-live", "include-csharp", "help", "h"], DOCTOR_FLAGS);
349
+ const pre = preflight(parsed, "doctor", DOCTOR_USAGE);
350
+ if (pre !== null)
351
+ return pre;
352
+ const { flags } = parsed;
348
353
  if (typeof flags.project === "string")
349
354
  process.env.GODOT_PROJECT = flags.project;
350
355
  const timeoutRaw = typeof flags.timeout === "string" ? Number.parseInt(flags.timeout, 10) : NaN;
package/dist/cli/init.js CHANGED
@@ -12,10 +12,11 @@ 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";
16
17
  import { CLIENT_IDS, clientInfo, mergeClientConfig, serverEntry, snippet } from "./clients.js";
17
18
  import { DEFAULT_REPO, fetchAddonFromGitHub } from "./github.js";
18
- import { CAPABILITY_GROUPS, parsePrivilegedGroups, selectPrivilegedGroups } from "../capabilities.js";
19
+ import { CAPABILITY_GROUPS, PRIVILEGED_TOOL_COUNT, parsePrivilegedGroups, selectPrivilegedGroups, } from "../capabilities.js";
19
20
  /**
20
21
  * Resolve the guided trust preset into a validated BREAKPOINT_PRIVILEGED_GROUPS
21
22
  * value ("" = the safe default — `code-execution` + `network` off). Accepts either
@@ -147,7 +148,11 @@ function claudeCodeCommand(projectPath, privilegedGroups) {
147
148
  }
148
149
  /** Entry point for `breakpoint-mcp init`. Returns the process exit code. */
149
150
  export async function runInit(argv, deps = {}) {
150
- const { flags } = parseArgs(argv, ["force", "dry-run"]);
151
+ const parsed = parseArgs(argv, ["force", "dry-run", "help", "h"], INIT_FLAGS);
152
+ const pre = preflight(parsed, "init", INIT_USAGE);
153
+ if (pre !== null)
154
+ return pre;
155
+ const { flags } = parsed;
151
156
  const projectPath = typeof flags.project === "string"
152
157
  ? path.resolve(flags.project)
153
158
  : process.env.GODOT_PROJECT ?? process.cwd();
@@ -279,11 +284,11 @@ export async function runInit(argv, deps = {}) {
279
284
  // Trust groups — surface the secure default so even a bare `init` teaches it.
280
285
  say("");
281
286
  if (privilegedGroups) {
282
- say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full 291-tool surface loads.`);
287
+ say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full 292-tool surface loads.`);
283
288
  }
284
289
  else {
285
290
  say("Higher-trust tool groups are OFF by default (the secure default). The assistant loads the");
286
- say("everyday authoring/debug surface and drops the 14 code-execution + network tools (e.g.");
291
+ say(`everyday authoring/debug surface and drops the ${PRIVILEGED_TOOL_COUNT} code-execution + network tools (e.g.`);
287
292
  say("godot_run_headless_script, the *_call_method / dbg_evaluate tools, asset-gen, backend_*).");
288
293
  say("Enable them by re-running with `--trust full` (or `--privileged-groups code-execution,network`),");
289
294
  say("or set BREAKPOINT_PRIVILEGED_GROUPS in the server env. The `godot://capabilities` resource and");
@@ -292,7 +297,21 @@ export async function runInit(argv, deps = {}) {
292
297
  if (privilegedWarn)
293
298
  say(` note: ${privilegedWarn}`);
294
299
  say("");
295
- say("Next: open the project in Godot, then run `breakpoint-mcp doctor --require-live` to verify.");
300
+ // 🔴 THE REOPEN TRAP. Godot reads project.godot's [editor_plugins] at project load and
301
+ // does not reload it, so an editor that was already running when we wrote the section
302
+ // keeps the plugin DISABLED — the bridge never starts and every editor-plane tool is
303
+ // dark, with nothing on screen saying why. The line is conditioned on `en.changed`
304
+ // because a project whose plugin was already enabled has nothing to reopen for, and a
305
+ // warning printed on every run is a warning nobody reads.
306
+ if (!dryRun && en.changed) {
307
+ say("Next: open the project in Godot — and if it is ALREADY OPEN, close and reopen it.");
308
+ say(" Godot reads project.godot's [editor_plugins] at project load and does not reload");
309
+ say(" it, so an editor that was already running keeps the plugin disabled.");
310
+ say("Then run `breakpoint-mcp doctor --require-live` to verify.");
311
+ }
312
+ else {
313
+ say("Next: open the project in Godot, then run `breakpoint-mcp doctor --require-live` to verify.");
314
+ }
296
315
  process.stdout.write(out.join("\n") + "\n");
297
316
  return 0;
298
317
  }
package/dist/cli/tools.js CHANGED
@@ -17,12 +17,13 @@
17
17
  * container, and on a machine with no Godot installed.
18
18
  *
19
19
  * Two surfaces are reported, because the difference is the security story:
20
- * • `full` — all 291 tools, what loads with BREAKPOINT_PRIVILEGED_GROUPS=all
21
- * • `secure-default` — 278, what an untouched install actually advertises
20
+ * • `full` — all 292 tools, what loads with BREAKPOINT_PRIVILEGED_GROUPS=all
21
+ * • `secure-default` — 279, what an untouched install actually advertises
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 { flags } = parseArgs(argv, ["json", "help", "h"]);
152
- if (flags.help || flags.h) {
153
- process.stdout.write([
154
- "breakpoint-mcp tools — export the tool surface (name, risk annotations, capability group).",
155
- "",
156
- " --json Emit JSON (stable, no timestamp — safe to diff between releases).",
157
- " --surface <which> full | secure-default (default: secure-default, what an",
158
- " untouched install actually advertises).",
159
- "",
160
- "Reads no Godot install and starts no server — the registry is built statically.",
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`);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The CLI's help text, in ONE place, split by subcommand.
3
+ *
4
+ * It lived in `index.ts`'s `printUsage` as a single flat array, which was fine
5
+ * while `--help` was the only thing that printed it. It stopped being fine the
6
+ * moment `breakpoint-mcp init --help` had to print the init section and nothing
7
+ * else: the alternative to this module is the same twelve lines written twice,
8
+ * and a help text that disagrees with itself is worse than one that is merely
9
+ * long. `index.ts` composes the whole document from these parts, so a flag
10
+ * documented for a subcommand is documented in the top-level help by
11
+ * construction.
12
+ *
13
+ * 🔴 THE ROSTERS BELOW ARE NOT DOCUMENTATION — THEY ARE THE PARSER'S INPUT.
14
+ * `parseArgs` takes them as `knownFlags` and reports anything else as unknown,
15
+ * so a flag added to a subcommand and not added here is refused at the command
16
+ * line rather than silently ignored. That is the whole point of keeping the
17
+ * prose and the roster in the same file: the pressure to update both is the
18
+ * same edit.
19
+ */
20
+ export const HEADLINE = "breakpoint-mcp — MCP server exposing Godot to AI coding assistants.";
21
+ export const SYNOPSIS = [
22
+ "Usage:",
23
+ " breakpoint-mcp Start the MCP server on stdio (default; how MCP clients launch it).",
24
+ " breakpoint-mcp init Install + enable the editor addon in a project and wire the MCP client.",
25
+ " breakpoint-mcp doctor Check the Godot binary, the editor addon, and the four bridges.",
26
+ " breakpoint-mcp tools Export the tool surface + risk annotations (for catalogs / reviews).",
27
+ " breakpoint-mcp --help Show this help.",
28
+ " breakpoint-mcp --version Print the installed version and exit.",
29
+ "",
30
+ "Every subcommand also accepts --help.",
31
+ ];
32
+ export const INIT_USAGE = [
33
+ "Usage: breakpoint-mcp init [options]",
34
+ "",
35
+ "Install + enable the editor addon in a Godot project and wire the MCP client.",
36
+ "",
37
+ "init options:",
38
+ " --project <dir> Target Godot project (default: $GODOT_PROJECT or the current directory).",
39
+ " --client <id> Write the MCP config for a client: claude-code | claude-desktop | cursor | windsurf | vscode.",
40
+ " --force Overwrite an addon that is already installed.",
41
+ " --dry-run Print what would change without writing anything.",
42
+ " --from-github [ref] Fetch the editor addon from GitHub at [ref] (default: this package's version tag) instead of the bundled copy.",
43
+ " --repo <owner/repo> With --from-github, the source repo (default: jlivingston-Cipher/godot-breakpoint-mcp).",
44
+ " --trust <level> secure | full. `full` enables every higher-trust group (default: secure).",
45
+ " --privileged-groups <a,b> Enable named higher-trust groups instead of all of them.",
46
+ ];
47
+ export const DOCTOR_USAGE = [
48
+ "Usage: breakpoint-mcp doctor [options]",
49
+ "",
50
+ "Check the Godot binary, the editor addon, and the four bridges.",
51
+ "",
52
+ "doctor options:",
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.",
55
+ " --include-csharp Also probe OmniSharp / netcoredbg on PATH (the C# planes).",
56
+ " --timeout <ms> Per-bridge connect timeout (default 1500).",
57
+ " --json Emit the report as JSON.",
58
+ ];
59
+ export const TOOLS_USAGE = [
60
+ "Usage: breakpoint-mcp tools [options]",
61
+ "",
62
+ "Export the tool surface + risk annotations (for catalogs / reviews).",
63
+ "",
64
+ "tools options:",
65
+ " --surface <which> full | secure-default (default: secure-default, what an untouched install advertises).",
66
+ " --json Emit the surface as JSON — stable and timestamp-free, so releases can be diffed.",
67
+ ];
68
+ /** Every flag `init` accepts. Booleans are declared at the call site. */
69
+ export const INIT_FLAGS = [
70
+ "project",
71
+ "client",
72
+ "from-github",
73
+ "repo",
74
+ "trust",
75
+ "privileged-groups",
76
+ ];
77
+ /** Every flag `doctor` accepts. */
78
+ export const DOCTOR_FLAGS = ["project", "timeout"];
79
+ /** Every flag `tools` accepts. */
80
+ export const TOOLS_FLAGS = ["surface"];
81
+ /** The whole document, composed — what `breakpoint-mcp --help` prints. */
82
+ export function fullUsage() {
83
+ // Each subcommand block contributes only its own `<name> options:` tail; the
84
+ // per-subcommand synopsis above it would read as four competing headlines in
85
+ // a single document.
86
+ const tail = (block) => block.slice(block.findIndex((l) => l.endsWith("options:")));
87
+ return [
88
+ HEADLINE,
89
+ "",
90
+ ...SYNOPSIS,
91
+ "",
92
+ ...tail(INIT_USAGE),
93
+ "",
94
+ ...tail(DOCTOR_USAGE),
95
+ "",
96
+ ...tail(TOOLS_USAGE),
97
+ "",
98
+ "All runtime configuration is via environment variables; see the README.",
99
+ "",
100
+ ];
101
+ }
102
+ //# sourceMappingURL=usage.js.map
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Non-finite engine floats, contained at the one place they enter the host.
3
+ *
4
+ * 🔴 224 §7.3 ASKED WHETHER GODOT'S PERFORMANCE MONITORS CAN EMIT `INF` THROUGH
5
+ * `runtime_get_monitors`, and called it "the only item that can break a working tool
6
+ * silently in production rather than loudly in CI". Measured on Godot 4.7 headless in
7
+ * session 225, every hop:
8
+ *
9
+ * Godot `JSON.stringify(INF)` -> `1e99999` — VALID JSON, so `JSON.parse` gives Infinity
10
+ * Godot `JSON.stringify(NAN)` -> `null` — plus a one-shot engine warning
11
+ * zod 3 `z.number()` -> ACCEPTS ±Infinity, REJECTS NaN
12
+ * zod 4 `z.number()` -> REJECTS both
13
+ * host -> MCP client -> `JSON.stringify(Infinity)` is `null`
14
+ *
15
+ * The answer is yes, and it needs no engine edge case at all: a client sending
16
+ * `"baseline": 1e999` to `runtime_assert_perf` round-trips through the addon as `inf`.
17
+ *
18
+ * 🔴 226 §2 — AND THE FIRST CONTAINMENT WAS SCOPED TO A ROSTER OF TWO. Session 225 put
19
+ * this walk at the single `JSON.parse` in `bridge.ts`, which is the right place, and then
20
+ * widened exactly the two schemas it had been thinking about. Measured off the shipped
21
+ * wire rather than the roster:
22
+ *
23
+ * bridge-routed tools carrying >=1 non-nullable number field 93
24
+ * their non-nullable number field paths 243
25
+ * of those, declaring `non_finite` 2
26
+ *
27
+ * Replacing a non-finite reading with `null` on the other 91 turns a silent wrong value
28
+ * into a HARD SCHEMA REFUSAL whose message blames the shape — `current: Expected number,
29
+ * received null` — and the `non_finite` roster that would have explained it is destroyed
30
+ * by the very parse that fails, because those 91 schemas do not declare the key. That is
31
+ * 225 §5's own finding one layer out: a coherent, specific message about the wrong cause.
32
+ *
33
+ * SO THE POLICY IS SPLIT, AND BOTH HALVES ARE DERIVED RATHER THAN CHOSEN:
34
+ *
35
+ * - DEFAULT — REFUSE, NAMING THE PATH AND THE VALUE. A non-finite number anywhere in a
36
+ * bridge reply rejects the call with code `non_finite` and a message that says which
37
+ * dotted path carried it and what it was. Loud, accurate, and — because no schema
38
+ * moves — invisible to `tools/list`. This is a strictly better failure than either
39
+ * 1.73.4 (silent `null` on the wire) or the first containment (a shape complaint).
40
+ *
41
+ * - TOLERANT — PRUNE AND ROSTER, for the tools whose whole job is a partial reading.
42
+ * `NON_FINITE_TOLERANT` below is the only place that set is written down, and
43
+ * `finiteness.test.ts` asserts it equals the set of shipped schemas declaring
44
+ * `non_finite`, in BOTH directions. A tool cannot join one without joining the other.
45
+ *
46
+ * WHAT THIS DELIBERATELY DOES NOT DO: widen a number to `number | null`. 225's note said
47
+ * "the numeric contract is not redefined … older clients are unaffected"; `wire_diff.mjs`
48
+ * classified that same change MAJOR at four field paths. The prose and the classifier
49
+ * disagreed and the classifier was right. Nothing here moves a declared type — every
50
+ * shipped `outputSchema` is byte-identical to v1.73.4 except for the OPTIONAL
51
+ * `non_finite` roster added to the two tolerant tools, which is what took the cut's
52
+ * classification from MAJOR back to MINOR.
53
+ */
54
+ /** The key a tolerant reply carries when something was pruned. Optional in the schemas. */
55
+ export const NON_FINITE_KEY = "non_finite";
56
+ /**
57
+ * The tools that report a PARTIAL reading rather than refusing, keyed to the addon method
58
+ * `bridge.ts` matches on.
59
+ *
60
+ * 🔴 THIS IS THE ONLY WRITTEN-DOWN COPY OF THAT SET. `finiteness.test.ts` asserts, both
61
+ * ways, that its keys are exactly the shipped schemas declaring `non_finite` and that
62
+ * every method value is actually called by `tools/runtime.ts` — so the set cannot drift
63
+ * from the wire, and a rename cannot orphan an entry. 225 §9's carried finding is that
64
+ * every scope is prose until something derives it; this one is asserted against two
65
+ * independent readers.
66
+ */
67
+ export const NON_FINITE_TOLERANT = new Map([
68
+ ["runtime_get_monitors", "runtime.get_monitors"],
69
+ ["runtime_assert_perf", "runtime.assert_perf"],
70
+ ]);
71
+ /** The addon methods whose replies are pruned instead of refused. */
72
+ export const TOLERANT_METHODS = new Set(NON_FINITE_TOLERANT.values());
73
+ /** How deep the walk goes. Bridge replies are shallow; the cap is a runaway guard, and it
74
+ * is asserted rather than assumed — see `finiteness.test.ts`'s depth case. */
75
+ const MAX_DEPTH = 64;
76
+ /** `Infinity` / `-Infinity` / `NaN` as a word. `String(NaN)` is already "NaN". */
77
+ function word(n) {
78
+ return Number.isNaN(n) ? "NaN" : n > 0 ? "Infinity" : "-Infinity";
79
+ }
80
+ /**
81
+ * Every non-finite number in `value`, in encounter order. Pure: allocates one array and
82
+ * never copies the input, so the normal path — which is every real reply — costs one walk
83
+ * and returns the empty list.
84
+ */
85
+ export function findNonFinite(value) {
86
+ const hits = [];
87
+ const walk = (v, path, depth) => {
88
+ if (typeof v === "number") {
89
+ if (!Number.isFinite(v))
90
+ hits.push({ path: path || "(root)", value: word(v) });
91
+ return;
92
+ }
93
+ if (v === null || typeof v !== "object" || depth >= MAX_DEPTH)
94
+ return;
95
+ if (Array.isArray(v)) {
96
+ v.forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1));
97
+ return;
98
+ }
99
+ for (const [k, item] of Object.entries(v)) {
100
+ walk(item, path ? `${path}.${k}` : k, depth + 1);
101
+ }
102
+ };
103
+ walk(value, "", 0);
104
+ return hits;
105
+ }
106
+ /**
107
+ * The refusal message. It names the paths and the values, because the whole point of
108
+ * refusing here rather than at the schema is that the schema cannot say either.
109
+ * Capped so a pathological reply cannot produce an unbounded error string; the cap is
110
+ * asserted in `finiteness.test.ts` rather than trusted.
111
+ */
112
+ export const MESSAGE_CAP = 8;
113
+ export function describeNonFinite(hits) {
114
+ const shown = hits.slice(0, MESSAGE_CAP).map((h) => `${h.path}=${h.value}`).join(", ");
115
+ const rest = hits.length > MESSAGE_CAP ? ` (+${hits.length - MESSAGE_CAP} more)` : "";
116
+ return (`The engine returned ${hits.length} non-finite number(s) this reply: ${shown}${rest}. ` +
117
+ `JSON cannot carry them — Godot stringifies INF as 1e99999 and NAN as null — so the ` +
118
+ `call is refused rather than reported as a success with the value silently missing.`);
119
+ }
120
+ /**
121
+ * Drop every non-finite value from a flat monitor record, returning what survived and the
122
+ * keys that did not. The declared type of the record does not move: a reading that is not
123
+ * a number is ABSENT, not `null`, and `non_finite` is where it says so.
124
+ */
125
+ export function pruneRecord(rec) {
126
+ const kept = {};
127
+ const dropped = [];
128
+ if (rec === null || typeof rec !== "object" || Array.isArray(rec))
129
+ return { kept, dropped };
130
+ for (const [k, v] of Object.entries(rec)) {
131
+ if (typeof v === "number" && Number.isFinite(v))
132
+ kept[k] = v;
133
+ else
134
+ dropped.push(k);
135
+ }
136
+ return { kept, dropped };
137
+ }
138
+ /**
139
+ * The tolerant policy for the two partial-reading tools: prune `monitors`, drop any
140
+ * comparison row that cannot be compared, and roster every key that left.
141
+ *
142
+ * 🔴 A DROPPED COMPARISON IS NOT A PASSED ONE. `ok` and `checked` are left exactly as the
143
+ * addon computed them — this host does not re-decide the assertion — but a key that could
144
+ * not be read appears in `non_finite`, so a caller reading `ok: true` alongside a
145
+ * non-empty roster can see that the two statements are about different key sets.
146
+ */
147
+ export function tolerate(result) {
148
+ if (result === null || typeof result !== "object" || Array.isArray(result))
149
+ return result;
150
+ const src = result;
151
+ const roster = new Set();
152
+ const out = { ...src };
153
+ if ("monitors" in src) {
154
+ const { kept, dropped } = pruneRecord(src.monitors);
155
+ if (dropped.length) {
156
+ out.monitors = kept;
157
+ dropped.forEach((k) => roster.add(k));
158
+ }
159
+ }
160
+ if (Array.isArray(src.regressions)) {
161
+ const rows = src.regressions;
162
+ const survivors = rows.filter((row) => {
163
+ const bad = ["baseline", "current"].some((f) => typeof row?.[f] === "number" && !Number.isFinite(row[f]));
164
+ if (bad)
165
+ roster.add(typeof row?.key === "string" ? row.key : "(unnamed)");
166
+ return !bad;
167
+ });
168
+ if (survivors.length !== rows.length)
169
+ out.regressions = survivors;
170
+ }
171
+ if (roster.size === 0)
172
+ return result;
173
+ return { ...out, [NON_FINITE_KEY]: [...roster] };
174
+ }
175
+ //# sourceMappingURL=finiteness.js.map
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { CsLspClient } from "./cslsp.js";
9
9
  import { CsDapClient } from "./csdap.js";
10
10
  import { StdioChannel } from "./stdio.js";
11
11
  import { DapClient } from "./dap.js";
12
+ import { fullUsage } from "./cli/usage.js";
12
13
  import { buildToolsets } from "./toolsets.js";
13
14
  import { registerRecipes } from "./recipes.js";
14
15
  import { applyWireDefaults } from "./wire-defaults.js";
@@ -42,7 +43,7 @@ async function main() {
42
43
  // so long jobs (export/import/headless script) support poll/await/cancel.
43
44
  // D3: also advertise resources.subscribe so clients can subscribe to
44
45
  // godot://… resources and receive notifications/resources/updated.
45
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.73.4" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
46
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.74.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
46
47
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
47
48
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
48
49
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
@@ -73,14 +74,14 @@ async function main() {
73
74
  // Capability groups — a risk-based axis over the toolsets. `code-execution` is
74
75
  // OFF by default; a disabled group's tools are DROPPED at
75
76
  // registration (omitted from tools/list), so the secure-default surface is
76
- // 291 − 13 = 278 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
77
+ // 292 − 13 = 279 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
77
78
  // server.registerTool AFTER applyOutputSchemas (schema wrapper stays innermost).
78
79
  const privilegedGroups = selectPrivilegedGroups(config.privilegedGroups, (unknown) => log(`ignoring unknown BREAKPOINT_PRIVILEGED_GROUPS token(s): ${unknown.join(", ")}`));
79
80
  applyCapabilities(server, privilegedGroups);
80
81
  // The A/B/C/D planes ARE the grouping. Build the ordered toolset registry
81
82
  // (the single source of truth, shared with the registration tests) and
82
83
  // register only the selected groups. Default (BREAKPOINT_TOOLSETS unset) =
83
- // every group → the full 291-tool surface, byte-identical to before. A filter
84
+ // every group → the full 292-tool surface, byte-identical to before. A filter
84
85
  // lets a client that can't defer tools, or a user who wants a smaller default
85
86
  // menu, load only the planes a project needs (GitHub-MCP `--toolsets` style).
86
87
  let processes;
@@ -120,7 +121,7 @@ async function main() {
120
121
  log(`toolsets enabled: ${[...enabled].sort().join(", ")} (${enabled.size}/${toolsets.length} groups)`);
121
122
  }
122
123
  // Recipes: a free, curated task-recipe layer exposed as MCP prompts (discoverable
123
- // via prompts/list). Adds NO tools — the 291-tool count is unchanged — and drives
124
+ // via prompts/list). Adds NO tools — the 292-tool count is unchanged — and drives
124
125
  // the enforced tools above, so it's a skill-pack layer over typed/undoable tools.
125
126
  registerRecipes(server);
126
127
  // Always-on capability affordance — never behind a toolset or a privileged
@@ -160,42 +161,26 @@ async function main() {
160
161
  process.on("SIGTERM", shutdown);
161
162
  }
162
163
  function printUsage() {
163
- process.stdout.write([
164
- "breakpoint-mcp — MCP server exposing Godot to AI coding assistants.",
165
- "",
166
- "Usage:",
167
- " breakpoint-mcp Start the MCP server on stdio (default; how MCP clients launch it).",
168
- " breakpoint-mcp init Install + enable the editor addon in a project and wire the MCP client.",
169
- " breakpoint-mcp doctor Check the Godot binary, the editor addon, and the four bridges.",
170
- " breakpoint-mcp tools Export the tool surface + risk annotations (for catalogs / reviews).",
171
- " breakpoint-mcp --help Show this help.",
172
- "",
173
- "init options:",
174
- " --project <dir> Target Godot project (default: $GODOT_PROJECT or the current directory).",
175
- " --client <id> Write the MCP config for a client: claude-code | claude-desktop | cursor | windsurf | vscode.",
176
- " --force Overwrite an addon that is already installed.",
177
- " --dry-run Print what would change without writing anything.",
178
- " --from-github [ref] Fetch the editor addon from GitHub at [ref] (default: this package's version tag) instead of the bundled copy.",
179
- " --repo <owner/repo> With --from-github, the source repo (default: jlivingston-Cipher/godot-breakpoint-mcp).",
180
- "",
181
- "doctor options:",
182
- " --project <dir> Project to check (default: $GODOT_PROJECT or the current directory).",
183
- " --require-live Also require the editor/runtime/LSP/DAP bridges to be reachable.",
184
- " --include-csharp Also probe OmniSharp / netcoredbg on PATH (the C# planes).",
185
- " --timeout <ms> Per-bridge connect timeout (default 1500).",
186
- " --json Emit the report as JSON.",
187
- "",
188
- "tools options:",
189
- " --surface <which> full | secure-default (default: secure-default, what an untouched install advertises).",
190
- " --json Emit the surface as JSON — stable and timestamp-free, so releases can be diffed.",
191
- "",
192
- "All runtime configuration is via environment variables; see the README.",
193
- "",
194
- ].join("\n"));
164
+ process.stdout.write(fullUsage().join("\n"));
195
165
  }
196
- // Subcommand dispatch. Anything that isn't a recognized subcommand — including
197
- // no arguments at all, which is how every MCP client launches this — falls
198
- // through to the stdio server, so the server's launch contract is unchanged.
166
+ // Subcommand dispatch. NO arguments at all is how every MCP client launches
167
+ // this — every invocation in the README, the user guide and `init`'s own
168
+ // emitted config passes the binary an empty argv — so that case, and only that
169
+ // case, starts the stdio server. The launch contract is unchanged.
170
+ //
171
+ // 🔴 WHAT CHANGED IS THE FALL-THROUGH, WHICH USED TO SWALLOW EVERYTHING ELSE.
172
+ // `breakpoint-mcp --version` started a server. So did `-v`, `version`, and any
173
+ // mistyped flag: the process printed one ready line and then sat on a stdin
174
+ // nobody was going to speak MCP into, which in a terminal is a hang. The two
175
+ // halves of that are separate defects and both are user-facing:
176
+ //
177
+ // * There was NO WAY TO ASK THIS BINARY ITS VERSION. `--version` is the most
178
+ // universal convention a CLI has, and the first thing anyone filing a bug
179
+ // report is asked for. A project with 0 issues against ~3,700 downloads
180
+ // cannot afford a version query that hangs the terminal instead.
181
+ // * An unrecognized token is a TYPO, and the only useful thing to do with one
182
+ // is name it. Falling through spends the user's next twenty minutes on the
183
+ // wrong question.
199
184
  void (async () => {
200
185
  const sub = process.argv[2];
201
186
  if (sub === "doctor") {
@@ -214,6 +199,20 @@ void (async () => {
214
199
  printUsage();
215
200
  process.exit(0);
216
201
  }
202
+ if (sub === "version" || sub === "--version" || sub === "-v" || sub === "-V") {
203
+ // Bare, so `breakpoint-mcp --version` is usable in a shell substitution.
204
+ // Read from package.json rather than written here: see version.ts on the
205
+ // twenty releases that announced 0.2.0 to every LSP server they met.
206
+ const { packageVersion } = await import("./version.js");
207
+ process.stdout.write(`${packageVersion()}\n`);
208
+ process.exit(0);
209
+ }
210
+ if (sub !== undefined) {
211
+ process.stderr.write(`breakpoint-mcp: unknown argument '${sub}'.\n` +
212
+ " Run `breakpoint-mcp --help` for the subcommands and their options.\n" +
213
+ " To start the MCP server, run it with no arguments — that is how MCP clients launch it.\n");
214
+ process.exit(2);
215
+ }
217
216
  await main();
218
217
  })().catch((err) => {
219
218
  log("fatal:", err instanceof Error ? err.stack ?? err.message : String(err));
package/dist/peers.js CHANGED
@@ -6,7 +6,7 @@ import { portFree } from "./ports.js";
6
6
  /**
7
7
  * Hard ceiling on simultaneously live peers.
8
8
  *
9
- * Four, not the eight a rival offers. Four headless Godot instances is already a
9
+ * Four, not the eight an alternative offers. Four headless Godot instances is already a
10
10
  * heavy CI runner, the convergence cases that matter are covered at four, and
11
11
  * every extra instance multiplies the flake surface of the one feature whose
12
12
  * entire selling point is that it does NOT flake. Raise it on evidence, never
package/dist/recipes.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * it's **free** (MIT, shipped in the server), and it sits **over typed,
8
8
  * schema-validated, undoable tools** — so the contract is executed by the
9
9
  * server, not merely described in prose the model might misapply. Recipes are
10
- * discoverable via MCP `prompts/list`; they add **no tools** (the 291-tool
10
+ * discoverable via MCP `prompts/list`; they add **no tools** (the 292-tool
11
11
  * count is unchanged) and cost nothing until a client pulls one.
12
12
  */
13
13
  import { z } from "zod";
package/dist/schemas.js CHANGED
@@ -71,6 +71,17 @@ const engineLog = {
71
71
  /** name -> ZodRawShape describing that tool's structuredContent. */
72
72
  export const outputSchemas = {
73
73
  // ---- Plane B: headless CLI (tools/cli.ts) ----
74
+ breakpoint_doctor: {
75
+ ok: z.boolean(),
76
+ failed: z.number(),
77
+ checks: z.array(z.object({
78
+ name: z.string(),
79
+ status: z.string(),
80
+ severity: z.string(),
81
+ detail: z.string(),
82
+ hint: z.string().optional(),
83
+ })),
84
+ },
74
85
  godot_version: { version: z.string(), raw: capturedRaw },
75
86
  godot_launch_editor: { launched: z.boolean(), pid: z.number().nullable(), project: z.string() },
76
87
  godot_run_project: { running: z.boolean(), pid: z.number().nullable(), scene: z.string().nullable() },
@@ -203,7 +214,7 @@ export const outputSchemas = {
203
214
  resource_duplicate: { duplicated: z.string(), from: z.string(), deep: z.boolean() },
204
215
  resource_get_property: { path: z.string(), property: z.string(), value: encodedValue },
205
216
  resource_set_property: { path: z.string(), property: z.string(), value: encodedValue },
206
- resource_get_import_settings: { path: z.string(), imported: z.boolean(), importer: z.string(), settings: z.record(encodedValue) },
217
+ resource_get_import_settings: { path: z.string(), imported: z.boolean(), importer: z.string(), settings: z.record(z.string(), encodedValue) },
207
218
  // `changed` (1.46.0) is narrower than `settings`: `settings` lists every key the call
208
219
  // SET, `changed` only those whose stored value actually moved. Setting a key to the
209
220
  // value it already held reported `reimported: true` with the sidecar's bytes unmoved
@@ -506,7 +517,15 @@ export const outputSchemas = {
506
517
  runtime_call_method: { ...engineLog, return: encodedValue },
507
518
  runtime_emit_signal: { ...engineLog, emitted: z.boolean() },
508
519
  runtime_inject_input: { ...engineLog, injected: z.boolean(), kind: z.string() },
509
- runtime_get_monitors: { ...engineLog, monitors: z.record(z.number()) },
520
+ // `monitors` values are `Performance.get_monitor()` readings, so they are engine floats
521
+ // and can be non-finite. 🔴 226 §2: a reading that is not a number is ABSENT from
522
+ // `monitors` and named in `non_finite` — it is NOT `null`. The value type does not move,
523
+ // so this schema is byte-identical to v1.73.4 apart from the optional roster, and
524
+ // arithmetic over `Object.values(monitors)` keeps working on every key it can see.
525
+ runtime_get_monitors: { ...engineLog,
526
+ monitors: z.record(z.string(), z.number()),
527
+ non_finite: z.array(z.string()).optional(),
528
+ },
510
529
  runtime_get_log: { ...engineLog,
511
530
  entries: z.array(z.object({ seq: z.number(), level: z.string(), message: z.string() })),
512
531
  latest_seq: z.number(),
@@ -530,6 +549,14 @@ export const outputSchemas = {
530
549
  actual: encodedValue.optional(),
531
550
  })),
532
551
  },
552
+ // Same source as runtime_get_monitors for `current`/`monitors`. 🔴 `baseline` was the
553
+ // CLIENT's own number echoed back through the addon (`float(baseline[key])` in
554
+ // runtime_bridge.gd), and `1e999` in a request parses to `inf` in GDScript — the field
555
+ // reachable from a well-formed call with no engine edge case at all. 226 §2 closes that
556
+ // at the DOOR instead: the input schema now takes `z.number().finite()`, which refuses
557
+ // `1e999` on the way in and emits an identical JSON Schema, so neither side of this
558
+ // tool's wire moves. A row that cannot be compared is DROPPED and its key named in
559
+ // `non_finite`; `ok` and `checked` stay exactly as the addon computed them.
533
560
  runtime_assert_perf: { ...engineLog,
534
561
  ok: z.boolean(),
535
562
  checked: z.number(),
@@ -539,7 +566,8 @@ export const outputSchemas = {
539
566
  current: z.number(),
540
567
  direction: z.string(),
541
568
  })),
542
- monitors: z.record(z.number()),
569
+ monitors: z.record(z.string(), z.number()),
570
+ non_finite: z.array(z.string()).optional(),
543
571
  },
544
572
  runtime_assert_screen_text: { ...engineLog,
545
573
  ok: z.boolean(),
@@ -574,7 +602,7 @@ export const outputSchemas = {
574
602
  // F4 additions: deterministic playtesting (time control / frame stepping / state digest / RNG seed).
575
603
  runtime_time_scale: { ...engineLog, previous: z.number(), current: z.number() },
576
604
  runtime_step_frames: { ...engineLog, frames_advanced: z.number(), frame_index: z.number() },
577
- runtime_state_digest: { ...engineLog, digest: z.record(z.record(encodedValue)), node_count: z.number() },
605
+ runtime_state_digest: { ...engineLog, digest: z.record(z.string(), z.record(z.string(), encodedValue)), node_count: z.number() },
578
606
  runtime_seed_rng: { ...engineLog, seed: z.number() },
579
607
  // F6 additions: multi-peer deterministic playtesting (spawn / stop / converge).
580
608
  runtime_spawn_peers: {
@@ -589,7 +617,7 @@ export const outputSchemas = {
589
617
  },
590
618
  runtime_peer_stop: { stopped: z.array(z.string()) },
591
619
  runtime_peers_digest: {
592
- digests: z.array(z.object({ id: z.string(), digest: z.record(z.record(encodedValue)), node_count: z.number() })),
620
+ digests: z.array(z.object({ id: z.string(), digest: z.record(z.string(), z.record(z.string(), encodedValue)), node_count: z.number() })),
593
621
  converged: z.boolean(),
594
622
  diverged_at: z.array(z.string()).nullable(),
595
623
  },
package/dist/tasks.js CHANGED
@@ -8,7 +8,7 @@ import { log } from "./logger.js";
8
8
  * Long, run-to-completion tools (headless export / import / script runs) used to
9
9
  * emit ad-hoc `notifications/progress`. They now register under the spec's task
10
10
  * model: a call creates a task, returns a handle immediately, and the client
11
- * drives it with tasks/get (poll), tasks/result (await), and tasks/cancel
11
+ * drives it with tasks/get (poll) and tasks/cancel
12
12
  * (stop). Plain, non-task clients are unaffected — the SDK auto-creates a task,
13
13
  * polls it to completion, and returns the result synchronously.
14
14
  */
@@ -20,6 +20,18 @@ export const TASK_POLL_INTERVAL_MS = 500;
20
20
  * Server capabilities advertising the task-execution model for tools/call.
21
21
  * Passed to the McpServer constructor so the SDK installs the tasks/get,
22
22
  * tasks/result, tasks/list and tasks/cancel request handlers.
23
+ *
24
+ * Revision 2026-07-28 removes all four from CORE: tasks/result and tasks/list have
25
+ * no successor anywhere, while tasks/get and tasks/cancel survive only inside the
26
+ * io.modelcontextprotocol/tasks extension. The handlers stay while we negotiate
27
+ * 2025-11-25 — they are the SDK's, and this comment is not shipped to a client.
28
+ *
29
+ * 🔴 THIS COMMENT USED TO ASSERT "no shipped tool description advertises them".
30
+ * Nothing checked that, and it was ALREADY FALSE for tasks/get and tasks/cancel on
31
+ * the day it was written — three descriptions in tools/cli.ts named both. An
32
+ * exemption a gate cannot verify is a promise. scripts/spec_conformance.py now
33
+ * measures the claim instead of restating it, and refuses on a shipped string that
34
+ * names a method the current revision removed.
23
35
  */
24
36
  export const TASK_CAPABILITIES = {
25
37
  tasks: { requests: { tools: { call: {} } } },
@@ -70,7 +82,8 @@ function errorResult(message) {
70
82
  * ToolTaskHandler. createTask registers the task, launches the work in the
71
83
  * background, and returns the handle immediately; the background settle stores
72
84
  * the result — which the SDK turns into a notifications/tasks/status update and
73
- * uses to satisfy any pending tasks/result long-poll.
85
+ * uses to satisfy any pending tasks/result long-poll. Both names are 2025-11-25's;
86
+ * see TASK_CAPABILITIES above for what 2026-07-28 does to them.
74
87
  */
75
88
  function makeTaskHandler(name, worker) {
76
89
  const shape = outputSchemas[name];
package/dist/tools/cli.js CHANGED
@@ -6,6 +6,7 @@ import { registerTaskTool } from "../tasks.js";
6
6
  import { ok, failPath } from "./lsp-common.js";
7
7
  import { resolveInsideProject } from "../paths.js";
8
8
  import { portFree, portConflictMessage } from "../ports.js";
9
+ import { runDoctorChecks } from "../cli/doctor.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  /** Run the Godot binary to completion, capturing stdout/stderr. */
11
12
  async function runCaptured(cfg, args, timeoutMs, signal) {
@@ -102,7 +103,7 @@ export function registerCliTools(server, cfg) {
102
103
  registerTaskTool(server, "godot_export", {
103
104
  title: "Export project",
104
105
  description: "Headless export using an export preset. Runs to completion and returns exit code + logs. Can be slow — " +
105
- "exposed as an MCP task, so task-aware clients can poll, await, or cancel it (tasks/get, tasks/result, tasks/cancel).",
106
+ "exposed as an MCP task, so a task-aware client can poll it or cancel it while it runs.",
106
107
  inputSchema: {
107
108
  preset: z.string().describe("Export preset name as defined in export_presets.cfg"),
108
109
  output_path: z.string().describe("Output file path for the exported build"),
@@ -137,7 +138,7 @@ export function registerCliTools(server, cfg) {
137
138
  registerTaskTool(server, "godot_import", {
138
139
  title: "Import assets",
139
140
  description: "Headless (re)import of project assets. Runs to completion and returns exit code + logs. " +
140
- "Exposed as an MCP task (poll/await/cancel via tasks/get, tasks/result, tasks/cancel).",
141
+ "Exposed as an MCP task, so a task-aware client can poll it or cancel it while it runs.",
141
142
  inputSchema: {
142
143
  timeout_ms: z.number().int().positive().optional().describe("Max run time (default 600000)"),
143
144
  },
@@ -149,7 +150,7 @@ export function registerCliTools(server, cfg) {
149
150
  title: "Run headless script",
150
151
  description: "Run a GDScript in headless mode (godot --headless -s <script>). Use this to invoke test runners " +
151
152
  "(GdUnit4 / GUT) or any batch tool. Returns exit code + logs. Exposed as an MCP task, so a long test " +
152
- "run can be polled, awaited, or cancelled (tasks/get, tasks/result, tasks/cancel).",
153
+ "run can be polled or cancelled while it is in flight.",
153
154
  inputSchema: {
154
155
  script_path: z.string().describe("Script to execute, e.g. res://addons/gdUnit4/bin/GdUnitCmdTool.gd"),
155
156
  args: z.array(z.string()).optional().describe("Extra CLI args passed after the script"),
@@ -176,5 +177,45 @@ export function registerCliTools(server, cfg) {
176
177
  stderr: tail(r.stderr),
177
178
  });
178
179
  });
180
+ // 🔴 D4, CARRIED SINCE 205 — THE DIAGNOSTIC THE PERSON WHO NEEDS IT CANNOT REACH.
181
+ // `breakpoint-mcp doctor` has shipped for many versions and answers exactly this
182
+ // question. It is a TERMINAL command, and a user whose bridge is not running is
183
+ // not in a terminal — they are in a conversation, watching an editor_* tool fail
184
+ // with a connect error and no idea which of the addon, the editor, the port or
185
+ // the secret is the one that is wrong. The assistant could not look. Now it can.
186
+ //
187
+ // Same `runDoctorChecks` the CLI drives, so the two can never disagree. It takes
188
+ // the ALREADY-LOADED server Config rather than calling loadConfig() — `runDoctor`
189
+ // mutates process.env.GODOT_PROJECT before loading, and doing that inside a tool
190
+ // handler would reconfigure the live server out from under every other tool.
191
+ server.registerTool("breakpoint_doctor", {
192
+ title: "Diagnose the setup",
193
+ description: "Check this Breakpoint MCP setup end to end: the Godot binary, the editor addon's install and " +
194
+ "enable state, the capability groups in force, and the editor/runtime/LSP/DAP bridges. Returns a " +
195
+ "per-check status with a hint for anything wrong. Read-only — run it first when an editor_*, " +
196
+ "runtime_*, gd_* or dbg_* tool fails to connect.",
197
+ inputSchema: {
198
+ require_live: z
199
+ .boolean()
200
+ .optional()
201
+ .describe("Treat an unreachable bridge as a failure rather than information (default false)"),
202
+ include_csharp: z
203
+ .boolean()
204
+ .optional()
205
+ .describe("Also probe OmniSharp / netcoredbg on PATH (the C# planes)"),
206
+ timeout_ms: z.number().int().positive().optional().describe("Per-bridge connect timeout (default 1500)"),
207
+ },
208
+ }, async ({ require_live, include_csharp, timeout_ms }) => {
209
+ const report = await runDoctorChecks(cfg, {
210
+ timeoutMs: timeout_ms ?? 1500,
211
+ requireLive: require_live ?? false,
212
+ includeCsharp: include_csharp ?? false,
213
+ });
214
+ return ok({
215
+ ok: report.ok,
216
+ failed: report.checks.filter((c) => c.status === "fail").length,
217
+ checks: report.checks,
218
+ });
219
+ });
179
220
  }
180
221
  //# sourceMappingURL=cli.js.map
@@ -14,7 +14,7 @@ export function registerResourceTools(server, call, guard) {
14
14
  inputSchema: {
15
15
  class_name: z.string().describe("Resource subclass to instantiate, e.g. StyleBoxFlat, Theme, GDScript"),
16
16
  to_path: z.string().describe("Destination res:// path, e.g. res://styles/panel.tres"),
17
- properties: z.record(z.any()).optional().describe("Initial property values (JSON scalars or __type__-tagged Variants)"),
17
+ properties: z.record(z.string(), z.any()).optional().describe("Initial property values (JSON scalars or __type__-tagged Variants)"),
18
18
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
19
19
  },
20
20
  }, async ({ class_name, to_path, properties, confirm }) => {
@@ -130,7 +130,7 @@ export function registerResourceTools(server, call, guard) {
130
130
  description: "Update import parameters in an asset's .import metadata and trigger a reimport. DESTRUCTIVE (rewrites metadata + reimports) — gated by confirmation. Errors not_found when the path is not a file, not_imported when the file has no .import sidecar. `settings` echoes every key set; `changed` lists only those whose stored value actually moved, so a no-op is distinguishable from a real edit.",
131
131
  inputSchema: {
132
132
  path: z.string().describe("Asset res:// path"),
133
- settings: z.record(z.any()).describe("Import params to set (name -> JSON scalar or __type__-tagged Variant)"),
133
+ settings: z.record(z.string(), z.any()).describe("Import params to set (name -> JSON scalar or __type__-tagged Variant)"),
134
134
  reimport: z.boolean().optional().describe("Reimport after writing (default true)"),
135
135
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
136
136
  },
@@ -228,7 +228,7 @@ export function registerRuntimeTools(server, runtime, peers, config) {
228
228
  inputSchema: {
229
229
  path: z.string().describe("Node path (relative to the current scene; '/root/...' absolute allowed)"),
230
230
  expect: z
231
- .record(z.any())
231
+ .record(z.string(), z.any())
232
232
  .describe("Map of property name -> expected value (JSON; use the tagged-Variant form for complex types like Vector2/Color)"),
233
233
  tolerance: z
234
234
  .number()
@@ -259,8 +259,14 @@ export function registerRuntimeTools(server, runtime, peers, config) {
259
259
  "Capture the baseline earlier with runtime_get_monitors and pass it back inline. Pass direction is inferred " +
260
260
  "(time/fps is higher-better; every other monitor is lower-better) unless overridden per key.",
261
261
  inputSchema: {
262
+ // 🔴 226 §2 — `.finite()` REFUSES `1e999` AT THE DOOR AND MOVES NO BYTE ON THE
263
+ // WIRE. `zodToJsonSchema` emits `{"type":"number"}` for both forms, measured, so
264
+ // this narrows what the host accepts without changing what it advertises. Before
265
+ // it, a well-formed call carrying `1e999` reached `float(baseline[key])` in
266
+ // runtime_bridge.gd as `inf` and came back as `1e99999` — the one non-finite path
267
+ // in this tree that needed no engine edge case at all.
262
268
  baseline: z
263
- .record(z.number())
269
+ .record(z.string(), z.number().finite())
264
270
  .describe("Monitor key -> baseline value (capture earlier via runtime_get_monitors)"),
265
271
  tolerance: z
266
272
  .number()
@@ -268,7 +274,7 @@ export function registerRuntimeTools(server, runtime, peers, config) {
268
274
  .optional()
269
275
  .describe("Fractional tolerance applied to each comparison (default 0 = exact)"),
270
276
  direction: z
271
- .record(z.enum(["higher_better", "lower_better"]))
277
+ .record(z.string(), z.enum(["higher_better", "lower_better"]))
272
278
  .optional()
273
279
  .describe("Per-key override of the pass direction (defaults: time/fps higher_better, else lower_better)"),
274
280
  ...peerField,
@@ -1526,7 +1526,7 @@ export function registerTabletopTools(server, bridge, config) {
1526
1526
  inputSchema: {
1527
1527
  template_path: z.string().describe("Card template scene, e.g. res://ui/cards/Card.tscn"),
1528
1528
  parent: z.string().describe("Node path to parent the instance under (in the open scene); \".\" for the root"),
1529
- data: z.record(z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value; a texture slot takes a res:// path"),
1529
+ data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value; a texture slot takes a res:// path"),
1530
1530
  position: z.object({ x: z.number(), y: z.number() }).optional().describe("Local position of the instance"),
1531
1531
  face_up: z.boolean().optional().describe("Show the face (default true); false shows the back on two-sided cards"),
1532
1532
  name: z.string().optional().describe("Optional node name for the instance"),
@@ -1556,7 +1556,7 @@ export function registerTabletopTools(server, bridge, config) {
1556
1556
  template_path: z.string().describe("Card template scene, e.g. res://ui/cards/Card.tscn"),
1557
1557
  parent: z.string().describe("Container node path the cards are instanced under; \".\" for the root"),
1558
1558
  cards: z.array(z.object({
1559
- data: z.record(z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value for this card"),
1559
+ data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value for this card"),
1560
1560
  face_up: z.boolean().optional().describe("Show the face (default true)"),
1561
1561
  })).min(1).describe("One entry per card to instance"),
1562
1562
  mode: z.enum(["row", "fan", "stack", "grid"]).describe("Arrangement mode"),
@@ -1589,7 +1589,7 @@ export function registerTabletopTools(server, bridge, config) {
1589
1589
  parent: z.string().describe("Container node path the cards are instanced under; \".\" for the root"),
1590
1590
  table_path: z.string().describe("CSV or JSON table on disk (res:// or absolute); format auto-detected by extension unless `format` set"),
1591
1591
  format: z.enum(["csv", "json"]).optional().describe("Override the table format"),
1592
- column_map: z.record(z.string()).describe("Slot name → column expression (a bare {column} or a composed \"{a} · {b}\")"),
1592
+ column_map: z.record(z.string(), z.string()).describe("Slot name → column expression (a bare {column} or a composed \"{a} · {b}\")"),
1593
1593
  filter: z.object({
1594
1594
  column: z.string(),
1595
1595
  equals: z.union([z.string(), z.number(), z.boolean()]),
@@ -1869,7 +1869,7 @@ export function registerTabletopTools(server, bridge, config) {
1869
1869
  inputSchema: {
1870
1870
  template_path: z.string().describe("Piece template scene, e.g. res://ui/pieces/Piece.tscn"),
1871
1871
  parent: z.string().describe("Node path to parent the instance under (in the open scene); \".\" for the root"),
1872
- data: z.record(z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value (art takes a res:// path; color a #RRGGBB; label a string)"),
1872
+ data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value (art takes a res:// path; color a #RRGGBB; label a string)"),
1873
1873
  position: z.object({ x: z.number(), y: z.number() }).optional().describe("Local position of the instance (ignored when place_on snaps it to a cell)"),
1874
1874
  face_up: z.boolean().optional().describe("Show the face (default true); false shows the back on two-sided pieces"),
1875
1875
  name: z.string().optional().describe("Optional node name for the instance"),
@@ -1933,7 +1933,7 @@ export function registerTabletopTools(server, bridge, config) {
1933
1933
  node: z.string().describe("Node path (in the open scene) to make draggable; \".\" for the root"),
1934
1934
  script_path: z.string().describe("Where to save the generated drag script, e.g. res://ui/interact/Draggable.gd"),
1935
1935
  mode: z.enum(["control", "node2d"]).describe("control→Control _get_drag_data DnD; node2d→Area2D hit region + pointer handler"),
1936
- payload: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().describe("Neutral data the drag carries (bound into the script as a Dictionary); default empty"),
1936
+ payload: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("Neutral data the drag carries (bound into the script as a Dictionary); default empty"),
1937
1937
  preview: z.boolean().optional().describe("control mode: show a translucent drag preview (default false)"),
1938
1938
  button: z.number().int().nonnegative().optional().describe("node2d mode: mouse button index that begins the drag (default 1 = left)"),
1939
1939
  action: z.string().optional().describe("node2d mode: input action name registered for the drag button (default \"drag\")"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.73.4",
3
+ "version": "1.74.1",
4
4
  "description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,8 +55,8 @@
55
55
  "node": ">=18"
56
56
  },
57
57
  "dependencies": {
58
- "@modelcontextprotocol/sdk": "^1.17.0",
59
- "zod": "^3.23.8"
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
59
+ "zod": "^3.25.76"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^20.14.0",