breakpoint-mcp 1.74.1 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { log } from "../logger.js";
4
4
  import { ok, failPath } from "./lsp-common.js";
5
5
  import { resolveInsideProject } from "../paths.js";
6
6
  import { portFree, portConflictMessage } from "../ports.js";
7
+ import { waitForRuntimeBridge, notReadyRemedy } from "../readiness.js";
7
8
  const LINE_CAP = 5000;
8
9
  /**
9
10
  * Runs Godot as a MANAGED child (piped stdio) so the host captures ALL stdout/
@@ -86,6 +87,8 @@ export function registerProcessTools(server, cfg) {
86
87
  title: "Run project (managed, captured output)",
87
88
  description: "Run the project as a managed child process with captured stdout/stderr, so godot_output can read ALL print()/error output. " +
88
89
  "Returns a process id. Use this instead of godot_run_project when you want the game's console log. " +
90
+ "WAITS until the game's runtime bridge answers ping and reports bridge_ready, exactly as godot_run_project " +
91
+ "does — no runtime_* tool is reachable before it does. " +
89
92
  "Refuses if the runtime bridge port is already bound — the new game could not host the bridge, and every " +
90
93
  "runtime_* call would address the process already holding the port. Use runtime_spawn_peers to drive more " +
91
94
  "than one game at once.",
@@ -97,12 +100,24 @@ export function registerProcessTools(server, cfg) {
97
100
  .describe("Start even though the runtime bridge port is already bound (default false). The new game's runtime " +
98
101
  "bridge will NOT be reachable — use only when you want the process for its console output or side " +
99
102
  "effects and will not call any runtime_* tool against it."),
103
+ wait_timeout_ms: z
104
+ .number()
105
+ .int()
106
+ .min(0)
107
+ .optional()
108
+ .describe("How long to wait for the runtime bridge to answer ping, in ms (default 15000, the runtime bridge's own " +
109
+ "deadline). 0 returns as soon as the process is spawned, reporting bridge_ready false and " +
110
+ "bridge_wait_ms 0 — waited-not-at-all, which a caller can tell apart from waited-and-lost."),
100
111
  },
101
112
  },
102
113
  // 🔴 `godot_run_project`'s TWIN, in a different file, with the same defect —
103
114
  // measured the same way and launching the same escaping argv. 1.42.0's lesson
104
115
  // ("the second call site is the interesting one") applied before the fact.
105
- async ({ scene, allow_port_conflict }) => {
116
+ //
117
+ // 🔴 AND THE SAME LESSON AGAIN AT 257 FOR THE READINESS RACE. The queue row
118
+ // `run-project-returns-before-bridge` names only `godot_run_project`; this
119
+ // returned `running: true` at the same false moment and no row said so.
120
+ async ({ scene, allow_port_conflict, wait_timeout_ms }) => {
106
121
  try {
107
122
  if (scene !== undefined)
108
123
  resolveInsideProject(scene, cfg.projectPath, "scene");
@@ -114,7 +129,16 @@ export function registerProcessTools(server, cfg) {
114
129
  return { isError: true, content: [{ type: "text", text: portConflictMessage(cfg.runtimeHost, cfg.runtimePort) }] };
115
130
  }
116
131
  const m = registry.run(cfg, scene ? [scene] : []);
117
- return ok({ id: m.id, pid: m.child.pid ?? null, running: true, scene: scene ?? null });
132
+ const readiness = await waitForRuntimeBridge(cfg, wait_timeout_ms ?? cfg.runtimeTimeoutMs);
133
+ return ok({
134
+ id: m.id,
135
+ pid: m.child.pid ?? null,
136
+ running: true,
137
+ scene: scene ?? null,
138
+ bridge_ready: readiness.ready,
139
+ bridge_wait_ms: readiness.waited_ms,
140
+ bridge_note: readiness.ready ? null : notReadyRemedy(cfg, readiness.waited_ms),
141
+ });
118
142
  });
119
143
  server.registerTool("godot_output", {
120
144
  title: "Read managed process output",
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { isTransportUnavailable, remedyClause } from "../bridge.js";
2
3
  import { MAX_PEERS } from "../peers.js";
3
4
  import { gate } from "../confirm.js";
4
5
  import { ok, failPath } from "./lsp-common.js";
@@ -26,7 +27,7 @@ function fail(err) {
26
27
  const be = err;
27
28
  return {
28
29
  isError: true,
29
- content: [{ type: "text", text: `Runtime error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}` }],
30
+ content: [{ type: "text", text: `Runtime error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}${remedyClause(err)}` }],
30
31
  };
31
32
  }
32
33
  /**
@@ -259,14 +260,21 @@ export function registerRuntimeTools(server, runtime, peers, config) {
259
260
  "Capture the baseline earlier with runtime_get_monitors and pass it back inline. Pass direction is inferred " +
260
261
  "(time/fps is higher-better; every other monitor is lower-better) unless overridden per key.",
261
262
  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.
263
+ // 🔴 226 §2 — `1e999` MUST NOT REACH `float(baseline[key])` IN runtime_bridge.gd,
264
+ // where it arrives as `inf` and comes back as `1e99999`: the one non-finite path in
265
+ // this tree that needs no engine edge case at all, only a well-formed call.
266
+ //
267
+ // 🆕 255 — AND THE `.finite()` THAT CLOSED IT IS GONE, BECAUSE THE BASE TYPE CLOSED
268
+ // IT. Under the major this tree left, `z.number()` accepted ±Infinity and the
269
+ // refinement was the whole guard; under the installed one `z.number()` refuses it
270
+ // and `.finite()` contributes NO check and NO bag entry — measured, not assumed:
271
+ // `wire_invisible_gate.mjs` found the roster row it had been pinned by standing over
272
+ // nothing. A call that adds no rule is a sentence wearing a rule's clothes, and this
273
+ // repository has spent three sessions deleting those. What holds the door now is
274
+ // asserted in `finiteness.test.ts` against the INSTALLED zod, so a future major that
275
+ // relaxes `z.number()` reddens there rather than silently re-opening this line.
268
276
  baseline: z
269
- .record(z.string(), z.number().finite())
277
+ .record(z.string(), z.number())
270
278
  .describe("Monitor key -> baseline value (capture earlier via runtime_get_monitors)"),
271
279
  tolerance: z
272
280
  .number()
@@ -357,7 +365,9 @@ export function registerRuntimeTools(server, runtime, peers, config) {
357
365
  title: "Runtime await condition",
358
366
  description: "Poll a property on a LIVE node until it satisfies a comparison, or a timeout elapses (read-only verification). " +
359
367
  "op is eq | ne | gt | ge | lt | le (the ordered operators require numeric values). Use this to wait for the " +
360
- "running game to reach a state before asserting on it — e.g. wait for hp le 0, then runtime_assert_screen_text.",
368
+ "running game to reach a state before asserting on it — e.g. wait for hp le 0, then runtime_assert_screen_text. " +
369
+ "An unreachable runtime bridge is retried until the timeout rather than failing on the first poll, so this " +
370
+ "tool can also be used to wait for a game that is still booting.",
361
371
  inputSchema: {
362
372
  path: z.string().describe("Node path (relative to the current scene; '/root/...' absolute allowed)"),
363
373
  property: z.string().describe("Property to read on each poll"),
@@ -388,7 +398,21 @@ export function registerRuntimeTools(server, runtime, peers, config) {
388
398
  res = (await client.request("runtime.get_property", { path, property }));
389
399
  }
390
400
  catch (err) {
391
- return fail(err);
401
+ // 🔴 THE ONE WAITING TOOL DID NOT WAIT FOR THE ONE THING WORTH WAITING
402
+ // FOR (249, closed 257). Every error ended the loop here, so a caller
403
+ // that set timeout_ms 15000 against a game that was still booting got
404
+ // an answer in 2 ms with polls 1 — `deadline` and `sleep(interval)`
405
+ // are both downstream of this catch and were never reached.
406
+ //
407
+ // `bridge_unavailable` is the transport saying *not yet*; every other
408
+ // code is the game saying *no* — a missing node, an unknown property,
409
+ // a refused method — and retrying those would turn a precise failure
410
+ // into a slow one. So the retry is scoped to the one code, and it is
411
+ // still bounded by the caller's own deadline rather than by a new one.
412
+ if (!isTransportUnavailable(err) || Date.now() >= deadline)
413
+ return fail(err);
414
+ await sleep(interval);
415
+ continue;
392
416
  }
393
417
  last = res?.value ?? null;
394
418
  if (compareValues(last, value, operator)) {
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { z } from "zod";
3
+ import { remedyClause } from "../bridge.js";
3
4
  import { gate } from "../confirm.js";
4
5
  import { readFileText, resolveExistingFile, resolveInsideProject, resolveWriteTarget } from "../paths.js";
5
6
  /**
@@ -36,7 +37,7 @@ function fail(err) {
36
37
  const message = be?.message ?? String(err);
37
38
  return {
38
39
  isError: true,
39
- content: [{ type: "text", text: `Tabletop compose error [${code}]: ${message}` }],
40
+ content: [{ type: "text", text: `Tabletop compose error [${code}]: ${message}${remedyClause(err)}` }],
40
41
  };
41
42
  }
42
43
  /** A bad-input failure that reads like a bridge error but never reaches the bridge. */
@@ -6,10 +6,19 @@ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
6
  * conforming peer does not already assume. Both are removed on the way out.
7
7
  *
8
8
  * 1. 🔴 `"$schema": "http://json-schema.org/draft-07/schema#"`, inside EVERY input and
9
- * output schema. Nobody in this repo writes it: the SDK converts our Zod through
10
- * `zod-to-json-schema`, which defaults to draft-07, and offers no target knob on the
11
- * Zod-v3 path (`server/zod-json-schema-compat.js` — the `target` option is read only
12
- * by the Zod-v4 branch). MCP fixes the DEFAULT dialect at JSON Schema 2020-12 and
9
+ * output schema. Nobody in this repo writes it: the SDK converts our Zod and asks for
10
+ * draft-07 while doing it.
11
+ *
12
+ * 🆕 255 — AND MOVING TO THE ZOD-v4 BRANCH DID NOT CHANGE THAT, WHICH THE PARAGRAPH
13
+ * THIS REPLACES IMPLIED IT WOULD. The old text read "the `target` option is read only
14
+ * by the Zod-v4 branch", which is true and useless: `server/mcp.js` calls
15
+ * `toJsonSchemaCompat` with no `target` at either version, and the v4 branch maps an
16
+ * absent target to `'draft-7'` before handing it to zod's own converter. So the
17
+ * declaration on the wire is the SDK's choice and not the library's, it survived a
18
+ * major bump unchanged, and this strip stays necessary — the same conclusion the
19
+ * migration spike reached from the outside, now measured from inside the tree.
20
+ *
21
+ * MCP fixes the DEFAULT dialect at JSON Schema 2020-12 and
13
22
  * requires every implementation to support it; a `$schema` field is an EXPLICIT
14
23
  * SWITCH to a dialect nobody is obliged to support and which a peer MUST reject
15
24
  * gracefully if it does not. So this is not decoration and it is not a trim:
@@ -49,12 +58,65 @@ export const DIALECT_SENSITIVE = [
49
58
  ["exclusiveMinimum", (v) => typeof v === "boolean"], // draft-04 form
50
59
  ["exclusiveMaximum", (v) => typeof v === "boolean"], // draft-04 form
51
60
  ["dependencies", () => true], // split into dependentSchemas/dependentRequired
52
- ["definitions", () => true], // $defs in 2020-12
53
- ["$defs", () => true], // 2020-12 only — the label would be wrong already
54
61
  ["prefixItems", () => true], // 2020-12 only
55
62
  ["unevaluatedProperties", () => true], // 2020-12 only
56
63
  ["unevaluatedItems", () => true], // 2020-12 only
57
64
  ];
65
+ /**
66
+ * The definition CONTAINERS, which are the one entry above that could not be decided from
67
+ * its own value.
68
+ *
69
+ * 🔴 255 — AND THEY WERE ON THE LIST FOR A SPELLING DIFFERENCE THAT DOES NOT REACH A
70
+ * REFERENCE. `definitions` is draft-07's container and `$defs` is 2020-12's, so each is an
71
+ * unknown keyword under the other dialect — and an unknown keyword is one both specs
72
+ * require a validator to IGNORE. What actually resolves a reference is `$ref`, and
73
+ * `"#/definitions/X"` is a URI-reference resolved as a JSON Pointer into the same schema
74
+ * resource under BOTH dialects: the pointer walks the document by key, and it does not
75
+ * care whether the key it walks through is vocabulary or not.
76
+ *
77
+ * So the container is dialect-bound only when something inside it is REACHED BY A NAME
78
+ * rather than by a path — `$anchor` (2020-12 only) or a nested `$id` (a new base URI in
79
+ * both, resolved differently) — or when a `$ref` anywhere in the document is not a plain
80
+ * local pointer. That is the predicate below, and it is a property of the WHOLE document,
81
+ * which is why it could not live in the table above.
82
+ *
83
+ * 🔴 THIS WAS MEASURED BEFORE IT WAS BELIEVED, AND IT IS MEASURED AGAIN ON EVERY RUN.
84
+ * `wire_defaults.test.ts` compiles the STRIPPED schema under a strict 2020-12 validator
85
+ * and the declared one under draft-07, and compares their verdicts on probe instances —
86
+ * Ajv is a second opinion with no stake in this argument. The fail-safe is not weakened:
87
+ * a container that is reached by name, or a reference that leaves the document, still
88
+ * keeps its declaration.
89
+ */
90
+ const CONTAINER_KEYS = ["definitions", "$defs"];
91
+ /** Reached by NAME rather than by path — the two ways a container stops being inert. */
92
+ const NAME_REACHED = new Set(["$id", "$anchor", "$dynamicAnchor", "$dynamicRef", "$recursiveAnchor", "$recursiveRef"]);
93
+ /**
94
+ * Can a definition container be renamed between dialects without changing meaning?
95
+ * A property of the whole document: every `$ref` in it must be a plain local pointer, and
96
+ * nothing anywhere may be reached by name.
97
+ */
98
+ export function containersInert(doc) {
99
+ let inert = true;
100
+ const walk = (node) => {
101
+ if (!inert || node === null || typeof node !== "object")
102
+ return;
103
+ if (Array.isArray(node))
104
+ return node.forEach(walk);
105
+ for (const [k, v] of Object.entries(node)) {
106
+ if (NAME_REACHED.has(k)) {
107
+ inert = false;
108
+ return;
109
+ }
110
+ if (k === "$ref" && !(typeof v === "string" && v.startsWith("#/"))) {
111
+ inert = false;
112
+ return;
113
+ }
114
+ walk(v);
115
+ }
116
+ };
117
+ walk(doc);
118
+ return inert;
119
+ }
58
120
  /** Keys under these keywords are AUTHOR-CHOSEN NAMES, not schema vocabulary. */
59
121
  const NAME_BEARING = new Set(["properties", "patternProperties", "definitions", "$defs"]);
60
122
  /**
@@ -65,12 +127,20 @@ const NAME_BEARING = new Set(["properties", "patternProperties", "definitions",
65
127
  * have kept the declaration on the whole surface; the hit was `scene_get_dependencies`'s
66
128
  * output field, a property NAMED dependencies. A walker that cannot tell a keyword from a
67
129
  * key reads an author's vocabulary as the protocol's.
130
+ *
131
+ * 🔴 THE CONTAINER ANSWER IS COMPUTED ONCE, AT THE DOCUMENT, AND CARRIED DOWN. Whether
132
+ * `definitions` is load-bearing is not readable from `definitions` — see `containersInert`
133
+ * — so the recursion threads the document's answer rather than re-deriving a different one
134
+ * at each level, which is how the same schema would get two verdicts from one walk.
68
135
  */
69
136
  export function dialectSensitive(node, inNamePosition = false) {
137
+ return walkSensitive(node, inNamePosition, containersInert(node));
138
+ }
139
+ function walkSensitive(node, inNamePosition, inert) {
70
140
  if (node === null || typeof node !== "object")
71
141
  return false;
72
142
  if (Array.isArray(node))
73
- return node.some((v) => dialectSensitive(v, false));
143
+ return node.some((v) => walkSensitive(v, false, inert));
74
144
  const obj = node;
75
145
  // draft-07 ignores keywords adjacent to $ref; 2020-12 applies them.
76
146
  if (obj.$ref !== undefined && Object.keys(obj).length > 1)
@@ -80,8 +150,10 @@ export function dialectSensitive(node, inNamePosition = false) {
80
150
  for (const [key, matches] of DIALECT_SENSITIVE)
81
151
  if (k === key && matches(v))
82
152
  return true;
153
+ if (!inert && CONTAINER_KEYS.includes(k))
154
+ return true;
83
155
  }
84
- if (dialectSensitive(v, NAME_BEARING.has(k)))
156
+ if (walkSensitive(v, NAME_BEARING.has(k), inert))
85
157
  return true;
86
158
  }
87
159
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.74.1",
3
+ "version": "1.76.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",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
- "zod": "^3.25.76"
59
+ "zod": "^4.4.3"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^20.14.0",