breakpoint-mcp 1.74.0 โ 1.75.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 +63 -4
- package/dist/capabilities.js +91 -2
- package/dist/cli/args.js +32 -2
- package/dist/cli/doctor.js +240 -19
- package/dist/cli/init.js +87 -16
- package/dist/cli/tools.js +12 -15
- package/dist/cli/usage.js +106 -0
- package/dist/index.js +84 -43
- 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
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
package/dist/annotations.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
@@ -39,7 +82,17 @@ export class BridgeClient {
|
|
|
39
82
|
eventListeners = new Set();
|
|
40
83
|
wantConnected = false;
|
|
41
84
|
reconnectTimer = null;
|
|
42
|
-
constructor(host, port, defaultTimeoutMs, label = "editor bridge",
|
|
85
|
+
constructor(host, port, defaultTimeoutMs, label = "editor bridge",
|
|
86
|
+
// ๐ด THE SECOND SENTENCE IS THE ONE THAT EARNS ITS PLACE. The first asks a
|
|
87
|
+
// question whose answer, for the user most likely to be reading this line,
|
|
88
|
+
// is YES: the editor IS open and the plugin IS listed in project.godot.
|
|
89
|
+
// Godot reads `[editor_plugins]` at project load and never re-reads it, so
|
|
90
|
+
// an editor that was already running when `init` wrote the section keeps
|
|
91
|
+
// the plugin disabled โ the bridge never starts, every editor-plane tool is
|
|
92
|
+
// dark, and the remedy agrees with everything the user can see. `init` now
|
|
93
|
+
// warns at the point it writes; this is the same fact told to the person
|
|
94
|
+
// who did not read that line, or who read it a week ago.
|
|
95
|
+
hint = 'Is the editor open with the "Breakpoint MCP" plugin enabled? If it was ALREADY open when you ran `breakpoint-mcp init`, close and reopen the project โ Godot reads the enabled-plugin list only at project load.',
|
|
43
96
|
/**
|
|
44
97
|
* Loopback-auth secret provider, read lazily on each connect. When it
|
|
45
98
|
* returns a non-empty string the client sends it as the FIRST line on the
|
|
@@ -116,7 +169,7 @@ export class BridgeClient {
|
|
|
116
169
|
// Recorded for onClose(): if the connection was already up, this reject()
|
|
117
170
|
// is a no-op on a settled promise and the errno would otherwise be lost.
|
|
118
171
|
this.lastSocketError = err;
|
|
119
|
-
reject(new BridgeError(
|
|
172
|
+
reject(new BridgeError(BRIDGE_UNAVAILABLE, `Cannot reach the Godot ${this.label} at ${this.host}:${this.port}. ${this.hint} (${err.message})`));
|
|
120
173
|
});
|
|
121
174
|
socket.on("data", (chunk) => this.onData(chunk));
|
|
122
175
|
socket.on("close", () => this.onClose());
|
|
@@ -227,7 +280,13 @@ export class BridgeClient {
|
|
|
227
280
|
}
|
|
228
281
|
else {
|
|
229
282
|
const e = msg.error ?? { code: "unknown", message: "Unknown bridge error" };
|
|
230
|
-
|
|
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)));
|
|
231
290
|
}
|
|
232
291
|
}
|
|
233
292
|
/**
|
package/dist/capabilities.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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 {
|
package/dist/cli/args.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function parseArgs(argv, booleanFlags = []) {
|
|
1
|
+
export function parseArgs(argv, booleanFlags = [], knownFlags) {
|
|
2
2
|
const bool = new Set(booleanFlags);
|
|
3
3
|
const positionals = [];
|
|
4
4
|
const flags = {};
|
|
@@ -35,6 +35,36 @@ export function parseArgs(argv, booleanFlags = []) {
|
|
|
35
35
|
}
|
|
36
36
|
positionals.push(a);
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// Computed AFTER the loop rather than inside it, so `--flag=value`,
|
|
39
|
+
// `--flag value` and the bare boolean forms are all judged by the same rule โ
|
|
40
|
+
// the key that landed in `flags`, however it got there.
|
|
41
|
+
const known = knownFlags === undefined ? null : new Set([...knownFlags, ...booleanFlags]);
|
|
42
|
+
const unknown = known === null ? [] : Object.keys(flags).filter((k) => !known.has(k));
|
|
43
|
+
return { positionals, flags, unknown };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The two questions every subcommand must answer before it does any work: was
|
|
47
|
+
* `--help` asked for, and was it handed something it does not accept. Returns
|
|
48
|
+
* the exit code to return, or `null` to carry on.
|
|
49
|
+
*
|
|
50
|
+
* Shared rather than written per subcommand, because written per subcommand it
|
|
51
|
+
* was answered by exactly ONE of the three. `tools` handled `--help` and kept
|
|
52
|
+
* its own copy of the text; `doctor` ignored it and ran the checks; `init`
|
|
53
|
+
* ignored it and fell through to the project check, reporting a missing
|
|
54
|
+
* `project.godot` โ an error about the user's directory in answer to a
|
|
55
|
+
* question about flags.
|
|
56
|
+
*/
|
|
57
|
+
export function preflight(parsed, subcommand, usage) {
|
|
58
|
+
if (parsed.flags.help === true || parsed.flags.h === true) {
|
|
59
|
+
process.stdout.write(usage.join("\n") + "\n");
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
if (parsed.unknown.length > 0) {
|
|
63
|
+
const named = parsed.unknown.map((u) => (u.length === 1 ? `-${u}` : `--${u}`)).join(", ");
|
|
64
|
+
process.stderr.write(`${subcommand}: unknown ${parsed.unknown.length === 1 ? "option" : "options"} ${named}.\n` +
|
|
65
|
+
` Run \`breakpoint-mcp ${subcommand} --help\` for the options this subcommand accepts.\n`);
|
|
66
|
+
return 2;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
39
69
|
}
|
|
40
70
|
//# sourceMappingURL=args.js.map
|