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.
- package/addon/breakpoint_mcp/bridge_server.gd +4 -1
- package/addon/breakpoint_mcp/error_remedies.gd +113 -0
- package/addon/breakpoint_mcp/operations.gd +10 -2
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +20 -2
- package/dist/addon-version.js +113 -0
- package/dist/annotations.js +10 -1
- package/dist/bridge.js +52 -3
- package/dist/capabilities.js +91 -2
- package/dist/cli/doctor.js +232 -16
- package/dist/cli/init.js +65 -13
- package/dist/cli/usage.js +5 -1
- package/dist/index.js +52 -8
- package/dist/peers.js +17 -18
- package/dist/readiness.js +108 -0
- package/dist/remedies.js +63 -0
- package/dist/result-cost.js +90 -0
- package/dist/schemas.js +23 -4
- package/dist/tools/backend.js +2 -1
- package/dist/tools/cli.js +43 -6
- package/dist/tools/cslsp.js +19 -3
- package/dist/tools/editor/common.js +2 -1
- package/dist/tools/lsp-common.js +23 -0
- package/dist/tools/lsp.js +18 -3
- package/dist/tools/netcode.js +2 -1
- package/dist/tools/processes.js +26 -2
- package/dist/tools/runtime.js +34 -10
- package/dist/tools/tabletop.js +2 -1
- package/dist/wire-defaults.js +80 -8
- package/package.json +2 -2
package/dist/tools/processes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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",
|
package/dist/tools/runtime.js
CHANGED
|
@@ -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 —
|
|
263
|
-
//
|
|
264
|
-
// this
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
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()
|
|
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
|
-
|
|
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)) {
|
package/dist/tools/tabletop.js
CHANGED
|
@@ -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. */
|
package/dist/wire-defaults.js
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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) =>
|
|
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 (
|
|
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.
|
|
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": "^
|
|
59
|
+
"zod": "^4.4.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "^20.14.0",
|