breakpoint-mcp 1.73.4 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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) {
@@ -201,7 +202,28 @@ export class BridgeClient {
201
202
  this.pending.delete(id);
202
203
  clearTimeout(p.timer);
203
204
  if (msg.ok) {
204
- p.resolve(msg.result ?? {});
205
+ // 🔴 THE ONE PLACE A NON-FINITE ENGINE FLOAT CAN ENTER THE HOST. Godot stringifies
206
+ // INF as `1e99999`, which is valid JSON, so `JSON.parse` above hands back a real
207
+ // `Infinity` — accepted by zod 3, refused by zod 4, and turned into `null` by our
208
+ // own re-serialisation to the client either way.
209
+ //
210
+ // 🔴 226 §2: THE POLICY IS PER-METHOD AND IT IS NOT A NULL. Two methods report a
211
+ // partial reading and prune; every other method REFUSES, naming the path and the
212
+ // value. Writing `null` into a `z.number()` slot — which is what the first
213
+ // containment did on 91 tools that never declared it — fails the schema with a
214
+ // message about the shape, and takes the roster that would have explained it down
215
+ // with the parse. See finiteness.ts for the population and the measurement.
216
+ const result = msg.result ?? {};
217
+ if (TOLERANT_METHODS.has(p.method)) {
218
+ p.resolve(tolerate(result));
219
+ }
220
+ else {
221
+ const hits = findNonFinite(result);
222
+ if (hits.length)
223
+ p.reject(new BridgeError("non_finite", describeNonFinite(hits)));
224
+ else
225
+ p.resolve(result);
226
+ }
205
227
  }
206
228
  else {
207
229
  const e = msg.error ?? { code: "unknown", message: "Unknown bridge error" };
@@ -246,7 +268,7 @@ export class BridgeClient {
246
268
  this.ledger.note(id, method, timeoutMs);
247
269
  reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`));
248
270
  }, timeoutMs);
249
- this.pending.set(id, { resolve: resolve, reject, timer });
271
+ this.pending.set(id, { resolve: resolve, reject, timer, method });
250
272
  socket.write(payload, (err) => {
251
273
  if (err) {
252
274
  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
@@ -195,7 +195,7 @@ export function checkCapabilities(config) {
195
195
  status: "ok",
196
196
  severity: "info",
197
197
  detail: dropped.length === 0
198
- ? `${state} — full 291-tool surface`
198
+ ? `${state} — full 292-tool surface`
199
199
  : `${state} (secure default) — ${dropped.length} higher-trust tool(s) dropped from the surface`,
200
200
  hint: dropped.length === 0
201
201
  ? undefined
package/dist/cli/init.js CHANGED
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  import { parseArgs } from "./args.js";
16
16
  import { CLIENT_IDS, clientInfo, mergeClientConfig, serverEntry, snippet } from "./clients.js";
17
17
  import { DEFAULT_REPO, fetchAddonFromGitHub } from "./github.js";
18
- import { CAPABILITY_GROUPS, parsePrivilegedGroups, selectPrivilegedGroups } from "../capabilities.js";
18
+ import { CAPABILITY_GROUPS, PRIVILEGED_TOOL_COUNT, parsePrivilegedGroups, selectPrivilegedGroups, } from "../capabilities.js";
19
19
  /**
20
20
  * Resolve the guided trust preset into a validated BREAKPOINT_PRIVILEGED_GROUPS
21
21
  * value ("" = the safe default — `code-execution` + `network` off). Accepts either
@@ -279,11 +279,11 @@ export async function runInit(argv, deps = {}) {
279
279
  // Trust groups — surface the secure default so even a bare `init` teaches it.
280
280
  say("");
281
281
  if (privilegedGroups) {
282
- say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full 291-tool surface loads.`);
282
+ say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full 292-tool surface loads.`);
283
283
  }
284
284
  else {
285
285
  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.");
286
+ say(`everyday authoring/debug surface and drops the ${PRIVILEGED_TOOL_COUNT} code-execution + network tools (e.g.`);
287
287
  say("godot_run_headless_script, the *_call_method / dbg_evaluate tools, asset-gen, backend_*).");
288
288
  say("Enable them by re-running with `--trust full` (or `--privileged-groups code-execution,network`),");
289
289
  say("or set BREAKPOINT_PRIVILEGED_GROUPS in the server env. The `godot://capabilities` resource and");
package/dist/cli/tools.js CHANGED
@@ -17,8 +17,8 @@
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
  */
@@ -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
@@ -42,7 +42,7 @@ async function main() {
42
42
  // so long jobs (export/import/headless script) support poll/await/cancel.
43
43
  // D3: also advertise resources.subscribe so clients can subscribe to
44
44
  // 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 });
45
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.74.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
46
46
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
47
47
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
48
48
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
@@ -73,14 +73,14 @@ async function main() {
73
73
  // Capability groups — a risk-based axis over the toolsets. `code-execution` is
74
74
  // OFF by default; a disabled group's tools are DROPPED at
75
75
  // registration (omitted from tools/list), so the secure-default surface is
76
- // 291 − 13 = 278 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
76
+ // 292 − 13 = 279 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
77
77
  // server.registerTool AFTER applyOutputSchemas (schema wrapper stays innermost).
78
78
  const privilegedGroups = selectPrivilegedGroups(config.privilegedGroups, (unknown) => log(`ignoring unknown BREAKPOINT_PRIVILEGED_GROUPS token(s): ${unknown.join(", ")}`));
79
79
  applyCapabilities(server, privilegedGroups);
80
80
  // The A/B/C/D planes ARE the grouping. Build the ordered toolset registry
81
81
  // (the single source of truth, shared with the registration tests) and
82
82
  // register only the selected groups. Default (BREAKPOINT_TOOLSETS unset) =
83
- // every group → the full 291-tool surface, byte-identical to before. A filter
83
+ // every group → the full 292-tool surface, byte-identical to before. A filter
84
84
  // lets a client that can't defer tools, or a user who wants a smaller default
85
85
  // menu, load only the planes a project needs (GitHub-MCP `--toolsets` style).
86
86
  let processes;
@@ -120,7 +120,7 @@ async function main() {
120
120
  log(`toolsets enabled: ${[...enabled].sort().join(", ")} (${enabled.size}/${toolsets.length} groups)`);
121
121
  }
122
122
  // 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
123
+ // via prompts/list). Adds NO tools — the 292-tool count is unchanged — and drives
124
124
  // the enforced tools above, so it's a skill-pack layer over typed/undoable tools.
125
125
  registerRecipes(server);
126
126
  // Always-on capability affordance — never behind a toolset or a privileged
@@ -177,6 +177,8 @@ function printUsage() {
177
177
  " --dry-run Print what would change without writing anything.",
178
178
  " --from-github [ref] Fetch the editor addon from GitHub at [ref] (default: this package's version tag) instead of the bundled copy.",
179
179
  " --repo <owner/repo> With --from-github, the source repo (default: jlivingston-Cipher/godot-breakpoint-mcp).",
180
+ " --trust <level> secure | full. `full` enables every higher-trust group (default: secure).",
181
+ " --privileged-groups <a,b> Enable named higher-trust groups instead of all of them.",
180
182
  "",
181
183
  "doctor options:",
182
184
  " --project <dir> Project to check (default: $GODOT_PROJECT or the current directory).",
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.0",
4
4
  "description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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",