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.
@@ -5,7 +5,10 @@ extends Node
5
5
  ## Wire format (both directions), one JSON object per line ("\n" terminated):
6
6
  ## request: {"id": "<string>", "method": "<name>", "params": { ... }}
7
7
  ## response: {"id": "<string>", "ok": true, "result": { ... }}
8
- ## {"id": "<string>", "ok": false, "error": {"code": ..., "message": ...}}
8
+ ## {"id": "<string>", "ok": false, "error": {"code": ..., "message": ..., "remedy": ...}}
9
+ ## `remedy` is the next action for that code (error_remedies.gd) and is omitted
10
+ ## when the plane has nothing to say โ€” the pre-auth denial below carries none by
11
+ ## design, because it must not tell an unauthenticated peer anything at all.
9
12
  ##
10
13
  ## Binds to 127.0.0.1 only. The port comes from the BREAKPOINT_BRIDGE_PORT env
11
14
  ## var (default 9080). The socket is polled from `_process`, so all request
@@ -0,0 +1,113 @@
1
+ @tool
2
+ extends RefCounted
3
+ ## The next action, keyed by the error code the bridge already returns.
4
+ ##
5
+ ## ๐Ÿ”ด WHAT THIS FILE EXISTS TO FIX, MEASURED BEFORE IT WAS WRITTEN (session 254):
6
+ ## 525 `_err()` sites across the two engine-facing planes emitted 216 distinct
7
+ ## message templates, and 451 of those sites named NO next action. The three most
8
+ ## emitted messages in the addon were "No scene is open" (90 sites), "Node not
9
+ ## found: %s" (53) and "ResourceSaver.save() returned %d" (35) โ€” a state report, a
10
+ ## rejection with no way to discover the legal values, and a bare engine return
11
+ ## code. Every one is a true sentence and none of them tells the reader what to do,
12
+ ## which is the whole of what a failing tool owes an agent that cannot see the
13
+ ## editor.
14
+ ##
15
+ ## ๐Ÿ”ด WHY THE REMEDY HANGS OFF THE CODE AND NOT THE MESSAGE. 216 message templates
16
+ ## would be 216 hand-written remedies to keep true; the code vocabulary is 50, it is
17
+ ## already machine-owned (contract_check reads it on both sides of the wire), and it
18
+ ## is the axis the message VARIES within โ€” every "X not found: %s" is one `not_found`
19
+ ## with the same next action. So the remedy is attached where the join is checkable
20
+ ## and the message keeps carrying the particulars.
21
+ ##
22
+ ## ๐Ÿ”ด AND THE TWO PLANES DO NOT SHARE ONE TABLE, DELIBERATELY. `bad_path` on the
23
+ ## editor plane is answered by `scene_get_tree`; on the runtime plane by
24
+ ## `runtime_get_tree`. A single table with a fallback would have to be wrong on one
25
+ ## plane or vague on both, and the exact per-plane join is what lets the gate refuse
26
+ ## a code with no remedy and a remedy no code raises โ€” in both directions, per file.
27
+ ##
28
+ ## Every tool name written here is compared to the live `registerTool(..)` surface by
29
+ ## contract_check check 28: a remedy naming a tool that was renamed or never existed
30
+ ## is a dead instruction, and it is the failure this table would otherwise invite.
31
+
32
+ const EDITOR := "editor"
33
+ const RUNTIME := "runtime"
34
+
35
+ ## operations.gd โ€” the editor plane. Keys are exactly the codes `_err()` raises there.
36
+ const EDITOR_REMEDIES := {
37
+ "bad_params": "Fix the parameter named above and call again โ€” the message carries the accepted shape.",
38
+ "no_scene": "Open a scene first: call `scene_open` with a res:// .tscn path, or `scene_list_open` to see what the editor already has open.",
39
+ "bad_path": "Call `scene_get_tree` for the live node paths โ€” they are relative to the open scene root โ€” or `node_find` to search by name.",
40
+ "not_found": "Check the name or res:// path exists: `filesystem_list` for files, `scene_get_tree` for nodes in the open scene.",
41
+ "bad_type": "Pass a node or resource of the type named above โ€” `scene_get_tree` reports each node's class and `classdb_get_class` describes it.",
42
+ "save_failed": "Check the target path is inside the project and writable, then call again; the number is a Godot Error code.",
43
+ "refused": "Act on a different node โ€” this is refused by design, the message names the invariant, and no flag lifts it.",
44
+ "unsupported": "Check the engine version with `godot_version` โ€” this Godot build does not expose the API the tool needs.",
45
+ "exists": "Choose a different name, or remove the existing one first.",
46
+ "load_failed": "Call `filesystem_scan` to re-import, then call again โ€” the path exists but did not load.",
47
+ "no_signal": "Call `signal_list` for the signals the node actually declares.",
48
+ "bad_track": "Call `anim_get_track_keys` for the animation's tracks and their indices.",
49
+ "bad_root": "Create the right root first โ€” `anim_tree_create` builds a blend tree or a state machine to add nodes to.",
50
+ "no_history": "Make an edit through the bridge first โ€” undo history exists per scope only once something has been recorded into it.",
51
+ "pack_failed": "Call `node_set_owner` on the strays, then call again โ€” every node in the branch must be owned by the scene root.",
52
+ "no_method": "Call `classdb_get_class` for the methods the node's class declares.",
53
+ "duplicate_failed": "Duplicate a node that is saved into the open scene, or call `resource_save` on the resource first.",
54
+ "create_failed": "Check the parent and the name in the message, then call again.",
55
+ "mkdir_failed": "Check the parent directory is inside the project and writable; `filesystem_create_dir` creates intermediate directories, and the number is a Godot Error code.",
56
+ "bad_source": "Create an atlas source first with `tileset_add_source`; the id above is not one.",
57
+ "unknown_method": "Re-run `breakpoint-mcp init --force`, then reopen the project โ€” the addon installed here is older than the host and plain `init` skips an addon that is already present.",
58
+ "bad_request": "Send the request again with the field the message names โ€” it was empty or unparseable.",
59
+ "no_viewport": "Switch the editor to the tab that owns that viewport with `main_screen_set`, then call again.",
60
+ "no_texture": "Call `main_screen_set` to open the matching editor tab and let it draw, then call again.",
61
+ "no_image": "Open the matching editor tab with `main_screen_set` so a frame is drawn, then call again.",
62
+ "instantiate_failed": "Call `classdb_get_class` to check the class is instantiable before constructing it.",
63
+ "bad_index": "Pass an index inside the range the message carries.",
64
+ "not_current": "Call `scene_open` on the scene you mean first โ€” only the current scene can be closed.",
65
+ "bad_class": "Call `classdb_get_class` to check the class exists and is an instantiable Resource.",
66
+ "not_imported": "Call `filesystem_scan` and try again with an asset the editor imported โ€” only imported assets carry .import metadata.",
67
+ "fs_error": "Check the project path is intact, then call `filesystem_scan` โ€” the project filesystem could not be read.",
68
+ "move_failed": "Check the destination directory exists and nothing is already at the target path; the number is a Godot Error code.",
69
+ "bad_key": "Call `anim_get_track_keys` for the keys the track actually has.",
70
+ "bad_texture": "Pass a res:// path that loads as a Texture2D โ€” `resource_load` reports what a path loads as.",
71
+ "no_tile_data": "Create the tile first with `tileset_add_tile`; there is none at that coordinate.",
72
+ "too_large": "Split the call into smaller regions โ€” the message carries the limit.",
73
+ "write_failed": "Check the target path is inside the project and not read-only; the number is a Godot Error code.",
74
+ }
75
+
76
+ ## runtime_bridge.gd โ€” the running game. Keys are exactly the codes `_err()` raises
77
+ ## there; the tools named are the `runtime_*` ones, because the editor's are answered
78
+ ## by a different process.
79
+ const RUNTIME_REMEDIES := {
80
+ "bad_path": "Call `runtime_get_tree` for the live node paths โ€” they are relative to the running scene root.",
81
+ "async_only": "Call this one through the async lane; `runtime_step_frames` and `runtime_await_condition` are dispatched there.",
82
+ "no_viewport": "Call `godot_run_project`, then wait for `runtime_get_tree` to answer before capturing โ€” the game has no viewport yet.",
83
+ "no_texture": "Call `runtime_step_frames` to advance at least one frame, then call again.",
84
+ "no_image": "Call `runtime_step_frames` to advance a frame, then call again โ€” nothing has been rendered to read back.",
85
+ "unknown_method": "Re-run `breakpoint-mcp init --force`, then relaunch the project โ€” the addon running in the game is older than the host and plain `init` skips an addon that is already present.",
86
+ "no_scene": "Set a main scene with `project_set_main_scene`, then run the project again โ€” it is running with no current scene.",
87
+ "no_method": "Call `runtime_get_property` or `godot://class` for what the running node's class declares.",
88
+ "no_signal": "Call `runtime_get_tree` for the node, then `godot://class` for the signals its class declares.",
89
+ "emit_failed": "Check the argument count and types against the signal's declaration, then emit again.",
90
+ "bad_action": "Call `inputmap_list` for the actions the project declares before injecting one.",
91
+ "bad_kind": "Pass one of the kinds the message lists to `runtime_inject_input`.",
92
+ "bad_regex": "Simplify the pattern and call again โ€” it did not compile as a Godot RegEx.",
93
+ "bad_reference": "Pass a reference image the project can load โ€” `runtime_screenshot` writes one you can diff against later.",
94
+ "not_animation_player": "Call `runtime_get_tree` and pass the path of an AnimationPlayer node.",
95
+ "no_animation": "Call `runtime_anim_get_state` for the animations the player actually holds.",
96
+ "bad_scene": "Pass a res:// .tscn path that loads in the running project; `scene_get_dependencies` reports what a scene needs.",
97
+ "bad_args": "Send the arguments the message names and call again.",
98
+ "bad_type": "Pass a node of the type named above โ€” `runtime_get_tree` reports each running node's class.",
99
+ "not_a_node": "Call `runtime_get_tree` for the node paths โ€” the one passed resolves to something that is not a Node.",
100
+ "instantiate_failed": "Check the class or scene loads in the editor first โ€” it would not instantiate in the running game.",
101
+ "cannot_remove_root": "Call `runtime_node_remove` on a child instead, or `godot_stop` to end the run โ€” the running scene root cannot be removed.",
102
+ "no_tree": "Call `godot_run_project` and wait for it to bind, then call again โ€” the game is not running.",
103
+ }
104
+
105
+
106
+ ## The next action for `code` on `plane`, or "" when the plane raises no such code.
107
+ ##
108
+ ## Returning "" rather than a placeholder is deliberate: `_err` omits the field
109
+ ## entirely when there is nothing to say, so a remedy that never arrives is visible
110
+ ## as an absent key rather than as an empty instruction a reader has to judge.
111
+ static func remedy(code: String, plane: String) -> String:
112
+ var table: Dictionary = RUNTIME_REMEDIES if plane == RUNTIME else EDITOR_REMEDIES
113
+ return table.get(code, "")
@@ -7,7 +7,8 @@ extends RefCounted
7
7
  ## wrapped in the EditorUndoRedoManager so a human can Ctrl-Z anything the assistant did.
8
8
 
9
9
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
10
- const ADDON_VERSION := "1.9.9"
10
+ const Remedies := preload("res://addons/breakpoint_mcp/error_remedies.gd")
11
+ const ADDON_VERSION := "1.11.0"
11
12
 
12
13
  var _plugin: EditorPlugin
13
14
 
@@ -340,7 +341,14 @@ func _ok(result: Variant) -> Dictionary:
340
341
 
341
342
 
342
343
  func _err(code: String, message: String) -> Dictionary:
343
- return {"ok": false, "error": {"code": code, "message": message}}
344
+ # 254: the message says what went wrong; the remedy says what to do about it. It is
345
+ # attached HERE, at the one place every editor-plane failure passes through, so no
346
+ # call site can forget it and none has to repeat it.
347
+ var e := {"code": code, "message": message}
348
+ var r := Remedies.remedy(code, Remedies.EDITOR)
349
+ if r != "":
350
+ e["remedy"] = r
351
+ return {"ok": false, "error": e}
344
352
 
345
353
 
346
354
  func _edited_root() -> Node:
@@ -3,5 +3,5 @@
3
3
  name="Breakpoint MCP"
4
4
  description="Exposes the live Godot editor and the running game to an MCP host so an AI assistant can drive them: scene/node/resource CRUD with full undo/redo, project settings, ClassDB, editor + in-game screenshots, and an in-game runtime bridge (live SceneTree, property get/set, method calls, signals, input injection, Performance monitors). Pairs with the breakpoint-mcp MCP host."
5
5
  author="James Livingston"
6
- version="1.9.9"
6
+ version="1.11.0"
7
7
  script="plugin.gd"
@@ -11,7 +11,18 @@ extends Node
11
11
  ## NOTE: this script is intentionally NOT @tool โ€” it must run in the game, not
12
12
  ## the editor. All handlers run on the main thread (socket polled from _process).
13
13
 
14
+ ## The addon version this GAME is running, answered on `ping`.
15
+ ##
16
+ ## ๐Ÿ”ด THE EDITOR PLANE HAS ANSWERED WITH ONE SINCE IT HAD ONE AND THIS PLANE NEVER DID
17
+ ## (258 ยง2). The runtime table's own `unknown_method` remedy says *the addon running in
18
+ ## the game is older than the host* โ€” a claim about a version the game could not report,
19
+ ## on the one plane where disk and memory diverge most: `init --force` rewrites the files
20
+ ## and the process that is already running keeps the addon it loaded. Held in lockstep
21
+ ## with `plugin.cfg` and with `operations.gd`'s copy by contract_check check 14, which
22
+ ## exists because two of those literals disagreed for two releases.
23
+ const ADDON_VERSION := "1.11.0"
14
24
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
25
+ const Remedies := preload("res://addons/breakpoint_mcp/error_remedies.gd")
15
26
  const DEFAULT_PORT := 9081
16
27
  const LOG_CAP := 1000
17
28
  # D1a: how many error/warning entries one response may carry. A cap and not the
@@ -369,7 +380,7 @@ func _attach_engine_log(response: Dictionary, seq_before: int) -> void:
369
380
  func _dispatch(method: String, params: Dictionary) -> Dictionary:
370
381
  match method:
371
382
  "ping":
372
- return _ok({"pong": true, "runtime": true, "godot": Engine.get_version_info().get("string", ""), "log_capture": _log_capture != null})
383
+ return _ok({"pong": true, "runtime": true, "addon_version": ADDON_VERSION, "godot": Engine.get_version_info().get("string", ""), "log_capture": _log_capture != null})
373
384
  "runtime.get_tree":
374
385
  return _get_tree(params)
375
386
  "runtime.get_property":
@@ -428,7 +439,14 @@ func _ok(result: Variant) -> Dictionary:
428
439
 
429
440
 
430
441
  func _err(code: String, message: String) -> Dictionary:
431
- return {"ok": false, "error": {"code": code, "message": message}}
442
+ # 254: the message says what went wrong; the remedy says what to do about it. It is
443
+ # attached HERE, at the one place every runtime-plane failure passes through, so no
444
+ # call site can forget it and none has to repeat it.
445
+ var e := {"code": code, "message": message}
446
+ var r := Remedies.remedy(code, Remedies.RUNTIME)
447
+ if r != "":
448
+ e["remedy"] = r
449
+ return {"ok": false, "error": e}
432
450
 
433
451
 
434
452
  func _base() -> Node:
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The addon's version, read from the artifact rather than believed.
3
+ *
4
+ * ๐Ÿ”ด WHY THIS FILE EXISTS, MEASURED BEFORE IT WAS WRITTEN (session 258 ยง2). The
5
+ * addon is the half of this product npm cannot version โ€” `registry_bytes.py` says
6
+ * so in its own comment: *the only name npm has for that artifact is the HOST
7
+ * version*. It ships inside the host tarball, it moves on its own cadence, and
8
+ * until this file nothing anywhere compared the copy a user is RUNNING to the copy
9
+ * their host SHIPPED. Four things compounded:
10
+ *
11
+ * 1. `installAddon` tested `fs.existsSync(dest/plugin.cfg)` and skipped. The
12
+ * destination's `version=` was never opened, so upgrading the host and
13
+ * re-running `init` printed `addon: skipped` and pinned the old addon
14
+ * FOREVER. Walked live at 258: install 1.74.1 โ†’ addon 1.9.9; upgrade the
15
+ * host to 1.75.0, whose bundled addon is 1.10.0; `init` โ†’ still 1.9.9.
16
+ * 2. `doctor` regexed the version out of `plugin.cfg`, interpolated it into a
17
+ * display string and hardcoded `status: "ok"` โ€” a green line carrying the
18
+ * number that proves it should be red.
19
+ * 3. Nothing crossed the wire. `operations.gd`'s `ping` put `addon_version` in
20
+ * its reply and all four consumers discarded it; the runtime plane's `ping`
21
+ * did not carry one at all.
22
+ * 4. The remedy that names this failure shipped only in the addon that is not
23
+ * stale โ€” `error_remedies.gd` was ADDED in 1.10.0.
24
+ *
25
+ * So the comparison is spelled ONCE, here, and the three readers that need it โ€”
26
+ * `init` on skip, `doctor` on both pairs, and the host's own fallback remedy โ€”
27
+ * import it rather than each growing a regex. A version comparison written three
28
+ * times is two of them wrong the first time the format changes.
29
+ */
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ /** Where the addon lives inside a Godot project, relative to the project root. */
33
+ export const ADDON_REL = "addons/breakpoint_mcp";
34
+ /**
35
+ * The one sentence that turns a skew into an instruction, spelled once because
36
+ * four places print it: `doctor`'s on-disk check, `doctor`'s running-addon check,
37
+ * `init`'s warning when it skips, and the host's fallback remedy for a stale
38
+ * addon's `unknown_method`.
39
+ *
40
+ * ๐Ÿ”ด IT NAMES `--force` AND THAT IS THE WHOLE POINT. `installAddon` skips a
41
+ * destination that already has a `plugin.cfg`, so a remedy that says re-run
42
+ * `breakpoint-mcp init` is an instruction that does nothing in the one state it
43
+ * is ever read in โ€” the addon is already there, which is why it is stale. Two
44
+ * rows in `error_remedies.gd` said exactly that from the day the table shipped:
45
+ * 254's rule joined every backticked TOOL NAME in a remedy to the live registry,
46
+ * and nobody joined `init` to what `init` does.
47
+ *
48
+ * ๐Ÿ”ด AND IT SAYS RESTART. Godot reads the enabled-plugin list and the addon
49
+ * scripts at project load, so overwriting the files changes nothing in a session
50
+ * that is already open โ€” a remedy stopping at `--force` sends a user to a command
51
+ * that appears not to have worked.
52
+ */
53
+ export const ADDON_SKEW_HINT = "Run 'breakpoint-mcp init --force' to overwrite it with the addon this host ships, then close and reopen the project in Godot. Plain 'init' skips an addon that is already installed, which is why the old one persisted.";
54
+ /**
55
+ * The `version="โ€ฆ"` line of a `plugin.cfg`, or null if the file or the key is
56
+ * unreadable.
57
+ *
58
+ * Null and not `"unknown"`: a caller comparing two versions must be able to tell
59
+ * *I could not read this* from *this is a version that happens to differ*, and a
60
+ * sentinel string collapses those into a false skew report.
61
+ */
62
+ export function readAddonVersion(addonDir) {
63
+ try {
64
+ const text = fs.readFileSync(path.join(addonDir, "plugin.cfg"), "utf8");
65
+ const m = /^\s*version\s*=\s*"([^"]*)"/m.exec(text);
66
+ return m && m[1] !== "" ? m[1] : null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /** The addon installed in `projectPath`, or null if there isn't one. */
73
+ export function installedAddonVersion(projectPath) {
74
+ return readAddonVersion(path.join(projectPath, ADDON_REL));
75
+ }
76
+ /**
77
+ * Compare two addon versions the way a user would need them compared.
78
+ *
79
+ * Dotted numeric segments, compared left to right, missing segments read as 0 โ€”
80
+ * enough for a `MAJOR.MINOR.PATCH` line that has never carried a pre-release
81
+ * suffix. Anything that does not parse as digits is `"unknown"` rather than a
82
+ * guess: 254's rule is that a remedy is an instruction somebody will execute, and
83
+ * *your addon is older* is an instruction. Being wrong about the direction sends
84
+ * a user to overwrite a newer addon with an older one.
85
+ */
86
+ export function compareAddonVersions(installed, bundled) {
87
+ if (installed === null || bundled === null)
88
+ return "unknown";
89
+ const parse = (v) => {
90
+ const parts = v.split(".");
91
+ const out = [];
92
+ for (const p of parts) {
93
+ if (!/^\d+$/.test(p))
94
+ return null;
95
+ out.push(Number(p));
96
+ }
97
+ return out.length > 0 ? out : null;
98
+ };
99
+ const a = parse(installed);
100
+ const b = parse(bundled);
101
+ if (a === null || b === null)
102
+ return "unknown";
103
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
104
+ const x = a[i] ?? 0;
105
+ const y = b[i] ?? 0;
106
+ if (x < y)
107
+ return "older";
108
+ if (x > y)
109
+ return "newer";
110
+ }
111
+ return "same";
112
+ }
113
+ //# sourceMappingURL=addon-version.js.map
@@ -38,6 +38,15 @@ const DESTRUCTIVE = [
38
38
  "shader_set_code", "shadermaterial_create", "shadermaterial_set_param", "shadermaterial_set_shader",
39
39
  "signal_disconnect", "signal_emit", "theme_create", "theme_set_color", "theme_set_constant", "theme_set_font",
40
40
  "theme_set_stylebox", "tilemap_clear", "tilemap_set_cell", "tilemap_set_cells_rect", "tileset_create",
41
+ // ๐Ÿ†• 252 โ€” the one row of 251's twenty-one where the DOCUMENT was right and this
42
+ // list was wrong. 251 gated the catalog's โœ” against this roster and found eight
43
+ // tools carrying โœ” that are not here; seven were plainly additive and the โœ” came
44
+ // off them. `tileset_set_tile_collision` REPLACES a tile's existing collision
45
+ // shape, which is this list's own sentence โ€” *may overwrite or discard state the
46
+ // caller did not supply*. It was split out and decided on its own rather than
47
+ // moved inside a copy fix, because this is what a client shows a human BEFORE it
48
+ // runs the tool, and that is a behaviour change, not a wording one.
49
+ "tileset_set_tile_collision",
41
50
  "vcs_restore", "vcs_stash",
42
51
  ];
43
52
  /** Tools where a repeat call with identical arguments adds no further effect. */
@@ -95,7 +104,7 @@ const OPEN_WORLD = [];
95
104
  /**
96
105
  * The complete annotated roster โ€” every registered tool, listed explicitly.
97
106
  *
98
- * This CANNOT be derived as the union of the four lists above: 52 tools are
107
+ * This CANNOT be derived as the union of the four lists above: 51 tools are
99
108
  * all-false (mutating, non-destructive, non-idempotent, local โ€” e.g. `node_add`,
100
109
  * `dbg_step`, `vcs_commit`), so a derived union would silently omit them and the
101
110
  * totality check below would pass while they shipped unannotated. Listing the
package/dist/bridge.js CHANGED
@@ -3,14 +3,57 @@ import { randomUUID } from "node:crypto";
3
3
  import { log } from "./logger.js";
4
4
  import { OverdueLedger } from "./late-reply.js";
5
5
  import { findNonFinite, describeNonFinite, tolerate, TOLERANT_METHODS } from "./finiteness.js";
6
+ import { remedyForWireError } from "./remedies.js";
6
7
  export class BridgeError extends Error {
7
8
  code;
8
- constructor(code, message) {
9
+ /**
10
+ * The next action the addon attached to this code (254). Optional by
11
+ * construction: the addon omits it for codes it has nothing to say about, and
12
+ * every failure the host raises itself โ€” timeout, closed socket, non-finite โ€”
13
+ * arrives without one, because the remedy for those is not the addon's to give.
14
+ */
15
+ remedy;
16
+ constructor(code, message, remedy) {
9
17
  super(message);
10
18
  this.name = "BridgeError";
11
19
  this.code = code;
20
+ if (remedy)
21
+ this.remedy = remedy;
12
22
  }
13
23
  }
24
+ /**
25
+ * The code `connect()` raises when nothing is listening on the bridge port.
26
+ *
27
+ * Spelled once, because two callers now branch on it โ€” `connect()` which raises
28
+ * it and `runtime_await_condition` which retries past it โ€” and a string literal
29
+ * in two files is a rename away from a waiting tool that silently stops waiting.
30
+ */
31
+ export const BRIDGE_UNAVAILABLE = "bridge_unavailable";
32
+ /**
33
+ * Is this the transport saying *not yet*, rather than the peer saying *no*?
34
+ *
35
+ * ๐Ÿ”ด THE DISTINCTION IS THE WHOLE OF 249's REMEDY C. A caller that asked to wait
36
+ * should keep waiting through an unbound port, and should NOT keep waiting
37
+ * through a missing node or an unknown property โ€” those are answers, and
38
+ * retrying an answer only makes it slower.
39
+ */
40
+ export function isTransportUnavailable(err) {
41
+ return err?.code === BRIDGE_UNAVAILABLE;
42
+ }
43
+ /**
44
+ * The remedy clause every plane's `fail()` appends, rendered once.
45
+ *
46
+ * ๐Ÿ”ด FIVE RENDERERS, ONE CLAUSE. `tools/editor/common.ts`, `tools/runtime.ts`,
47
+ * `tools/tabletop.ts`, `tools/netcode.ts` and `tools/backend.ts` each turn a
48
+ * `Partial<BridgeError>` into MCP text under their own label. A remedy pasted into
49
+ * five templates is five places to drift; this is the one place it is spelled, and
50
+ * contract_check check 26 asserts every renderer of a `Partial<BridgeError>` calls
51
+ * it โ€” a sixth plane added without it would ship a silent failure message again.
52
+ */
53
+ export function remedyClause(err) {
54
+ const r = err?.remedy;
55
+ return typeof r === "string" && r !== "" ? ` โ€” ${r}` : "";
56
+ }
14
57
  /**
15
58
  * TCP client for the in-editor Breakpoint MCP addon. Speaks newline-delimited
16
59
  * JSON. Requests are correlated to responses by `id`. Connects lazily and
@@ -126,7 +169,7 @@ export class BridgeClient {
126
169
  // Recorded for onClose(): if the connection was already up, this reject()
127
170
  // is a no-op on a settled promise and the errno would otherwise be lost.
128
171
  this.lastSocketError = err;
129
- reject(new BridgeError("bridge_unavailable", `Cannot reach the Godot ${this.label} at ${this.host}:${this.port}. ${this.hint} (${err.message})`));
172
+ reject(new BridgeError(BRIDGE_UNAVAILABLE, `Cannot reach the Godot ${this.label} at ${this.host}:${this.port}. ${this.hint} (${err.message})`));
130
173
  });
131
174
  socket.on("data", (chunk) => this.onData(chunk));
132
175
  socket.on("close", () => this.onClose());
@@ -237,7 +280,13 @@ export class BridgeClient {
237
280
  }
238
281
  else {
239
282
  const e = msg.error ?? { code: "unknown", message: "Unknown bridge error" };
240
- p.reject(new BridgeError(e.code, e.message));
283
+ // ๐Ÿ”ด THE FALLBACK IS FOR THE ADDON THAT CANNOT ANSWER FOR ITSELF (258 ยง2).
284
+ // A current addon attaches its own remedy and wins outright; an addon old
285
+ // enough to raise `unknown_method` predates `error_remedies.gd` entirely,
286
+ // so the only side that can name its next action is the host it is skewed
287
+ // against. See remedies.ts for why that table has exactly one row.
288
+ const wire = typeof e.remedy === "string" ? e.remedy : undefined;
289
+ p.reject(new BridgeError(e.code, e.message, remedyForWireError(e.code, wire)));
241
290
  }
242
291
  }
243
292
  /**
@@ -1,3 +1,4 @@
1
+ import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
1
2
  /** The groups, in display order. */
2
3
  export const CAPABILITY_GROUPS = ["code-execution"];
3
4
  /** One-line human description per group (shown by `doctor` and the resource). */
@@ -128,6 +129,83 @@ export function applyCapabilities(server, enabled) {
128
129
  tasks.registerToolTask = gate(tasks.registerToolTask.bind(tasks));
129
130
  }
130
131
  }
132
+ /**
133
+ * The JSON-RPC method `McpServer` registers its tool dispatcher under. Written as
134
+ * a literal because the SDK does not export the method string, and PINNED by
135
+ * `capabilities.test.ts`, which builds a real server and asserts the handler map
136
+ * carries exactly this key โ€” so an SDK that renamed the method reddens a test
137
+ * rather than silently un-installing the refusal below.
138
+ */
139
+ export const CALL_TOOL_METHOD = "tools/call";
140
+ /**
141
+ * The message a withheld tool answers with, in place of the SDK's `not found`.
142
+ *
143
+ * ๐Ÿ”ด THE READER OF THIS SENTENCE IS USUALLY NOT A HUMAN. `mcp.js` throws
144
+ * `Tool <name> not found` for any name absent from its registry, and a dropped
145
+ * tool is absent for exactly that reason โ€” so a deliberate configuration reads,
146
+ * to the assistant relaying it, as a feature this package does not have. It will
147
+ * report that absence to the user as fact. Every remedy is therefore named here
148
+ * and named *derived*: the group, the env var, the `init` preset and the resource
149
+ * that lists the rest. `not a missing feature` is in the first line because it is
150
+ * the sentence the reader is about to get wrong.
151
+ */
152
+ export function droppedToolMessage(name) {
153
+ const groups = TOOL_CAPABILITIES[name] ?? [];
154
+ const list = groups.join(", ");
155
+ return (`Tool ${name} exists in this server but is WITHHELD BY POLICY โ€” it is not a missing feature. ` +
156
+ `It belongs to the higher-trust capability group${groups.length === 1 ? "" : "s"} ` +
157
+ `'${list}', which ${groups.length === 1 ? "is" : "are"} OFF by default. ` +
158
+ `Enable it by setting BREAKPOINT_PRIVILEGED_GROUPS=${list} in this server's env, ` +
159
+ `or by re-running \`npx breakpoint-mcp init --trust full\`. ` +
160
+ `Read the godot://capabilities resource for every group, its state, and the full withheld list.`);
161
+ }
162
+ /**
163
+ * Make a call to a DROPPED tool answer with the policy instead of the SDK's
164
+ * `Tool <name> not found`. Call ONCE, after every `register*Tools()` โ€” the
165
+ * dispatcher does not exist until the first `registerTool`, and wrapping it
166
+ * earlier would wrap nothing.
167
+ *
168
+ * ๐Ÿ”ด THIS DELIBERATELY DOES NOT REGISTER THE TOOL. Least-privilege by
169
+ * construction is the point of the drop: `tools/list` stays at 279 and no
170
+ * withheld schema crosses the wire, so `wire_diff`, `token-cost` and every floor
171
+ * over the advertised surface are unmoved. The ONLY thing that changes is the
172
+ * sentence a caller gets back when it asks for one by name โ€” which is the whole
173
+ * defect, because the tool was never the problem.
174
+ *
175
+ * Wrapping the map entry rather than calling `setRequestHandler` is not a
176
+ * shortcut: the SDK stores an already-parsed wrapper there, so re-registering
177
+ * would mean re-implementing dispatch. This preserves it and adds one branch in
178
+ * front, the same monkeypatch idiom `applyCapabilities` uses one function up.
179
+ */
180
+ export function applyDroppedToolRefusal(server, enabled) {
181
+ const dropped = new Set(droppedTools(enabled));
182
+ if (dropped.size === 0)
183
+ return;
184
+ const proto = server.server;
185
+ const handlers = proto?._requestHandlers;
186
+ const inner = handlers?.get(CALL_TOOL_METHOD);
187
+ // No dispatcher means no tool was ever registered โ€” nothing to guard, and
188
+ // installing a handler here would answer for a surface that does not exist.
189
+ if (!handlers || !inner)
190
+ return;
191
+ handlers.set(CALL_TOOL_METHOD, async (request, extra) => {
192
+ const name = request?.params?.name;
193
+ if (typeof name === "string" && dropped.has(name)) {
194
+ // ๐Ÿ”ด THE SAME SHAPE `not found` ALREADY HAD, AND ONLY THE SENTENCE CHANGED.
195
+ // `mcp.js` catches its own `McpError` and answers with an isError
196
+ // CallToolResult โ€” it does NOT throw โ€” so a refusal that threw would be a
197
+ // protocol error where every other failure on this surface is a tool
198
+ // error, and clients treat the two differently. The defect was one
199
+ // sentence; the fix is one sentence. The code is carried for the client
200
+ // that reads it, and it is InvalidParams for the same reason the SDK's is.
201
+ return {
202
+ content: [{ type: "text", text: `MCP error ${ErrorCode.InvalidParams}: ${droppedToolMessage(name)}` }],
203
+ isError: true,
204
+ };
205
+ }
206
+ return inner(request, extra);
207
+ });
208
+ }
131
209
  /**
132
210
  * Register the always-on `godot://capabilities` resource: a read-only listing of
133
211
  * the capability groups, their enabled/disabled state, exactly which tools each
@@ -150,12 +228,23 @@ export function registerCapabilitiesResource(server, enabled) {
150
228
  .filter((name) => (TOOL_CAPABILITIES[name] ?? []).includes(g))
151
229
  .sort(),
152
230
  }));
231
+ // ๐Ÿ”ด BOTH OF THESE ARE DERIVED FROM `CAPABILITY_GROUPS`, AND THAT IS THE
232
+ // FIX RATHER THAN A TIDY-UP. Until 250 this resource announced "Two
233
+ // higher-trust tool groups" and offered `'network'` โ€” a group deleted from
234
+ // this file's own header, whose token `selectPrivilegedGroups` reports as
235
+ // unknown and ignores. The always-on affordance that exists so a withheld
236
+ // tool is never a silent gap was telling its reader to type something the
237
+ // parser refuses. A restated count goes stale in silence; a derived one
238
+ // cannot, which is 198 ยง36's rule applied to a sentence instead of a floor.
239
+ const groupList = CAPABILITY_GROUPS.map((g) => `'${g}'`).join(", ");
153
240
  const payload = {
154
- summary: "Two higher-trust tool groups are OFF by default. A disabled group's tools are not registered (omitted from tools/list). Enable a group to load its tools.",
241
+ summary: `${CAPABILITY_GROUPS.length} higher-trust tool group(s) โ€” ${CAPABILITY_GROUPS.join(", ")} โ€” are OFF by default. ` +
242
+ "A disabled group's tools are not registered (omitted from tools/list), and calling one by name is refused with a message naming this resource โ€” never with `not found`. Enable a group to load its tools.",
155
243
  default_secure: enabled.size === 0,
156
244
  enabled_groups: [...enabled].sort(),
157
245
  dropped_tools: droppedTools(enabled),
158
- how_to_enable: "Set BREAKPOINT_PRIVILEGED_GROUPS in the MCP server env (comma-separated): 'code-execution', 'network', 'code-execution,network', or 'all'. Or re-run `npx breakpoint-mcp init` for a guided setup.",
246
+ how_to_enable: `Set BREAKPOINT_PRIVILEGED_GROUPS in the MCP server env (comma-separated), from: ${groupList}, or 'all'. ` +
247
+ "Or re-run `npx breakpoint-mcp init --trust full` for a guided setup.",
159
248
  groups,
160
249
  };
161
250
  return {