breakpoint-mcp 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,9 @@ npx breakpoint-mcp doctor
30
30
 
31
31
  `init` copies the addon into `addons/breakpoint_mcp/`, enables it in `project.godot`, and
32
32
  prints — or, with `--client claude-code|claude-desktop|cursor|windsurf|vscode`, writes —
33
- the client config. `doctor` checks the Godot binary, the addon, and the four bridges
33
+ the client config. By default it uses the addon bundled in this package; add
34
+ `--from-github [ref]` to fetch the addon from GitHub instead (e.g. `--from-github main`).
35
+ `doctor` checks the Godot binary, the addon, and the four bridges
34
36
  (add `--require-live` once the editor is open; `--json` for a machine-readable report).
35
37
 
36
38
  To install the `breakpoint-mcp` command globally instead of invoking it via `npx`:
@@ -9,9 +9,16 @@ Loopback TCP/JSON bridge that exposes the live Godot editor (and, via the runtim
9
9
 
10
10
  Requires **Godot 4.2+** (uses the `EditorInterface` singleton; 4.4+ recommended).
11
11
 
12
+ ## Status dock
13
+ On enable, a **Breakpoint MCP** panel is added to the editor dock. It reports the health of
14
+ the editor / runtime / GDScript-LSP / DAP bridges, shows the configured ports and project
15
+ path, and offers a one-click **Copy MCP-client config**. Connection / status / config only —
16
+ not a chat UI; the AI assistant runs in your MCP client.
17
+
12
18
  ## Files
13
- - `plugin.gd` — `EditorPlugin` entry point; starts/stops the server.
19
+ - `plugin.gd` — `EditorPlugin` entry point; starts/stops the server and adds the status dock.
14
20
  - `bridge_server.gd` — `TCPServer` polled from `_process`; newline-delimited JSON framing.
21
+ - `status_dock.gd` — in-editor status/config dock (bridge health, ports, copy client config).
15
22
  - `operations.gd` — request handlers; every mutation is wrapped in `EditorUndoRedoManager`.
16
23
  - `variant_json.gd` — Variant ⇄ JSON codec (tagged rich types).
17
24
 
@@ -48,6 +48,19 @@ func shutdown() -> void:
48
48
  _server = null
49
49
 
50
50
 
51
+ ## Read-only snapshot for the in-editor status dock. Pure — never mutates state.
52
+ ## `listening` reflects the live TCPServer; `clients` is the current peer count.
53
+ ## The dock lives inside this same process, so this is authoritative for the
54
+ ## editor-bridge plane (no TCP self-probe needed).
55
+ func get_status() -> Dictionary:
56
+ return {
57
+ "listening": _server != null and _server.is_listening(),
58
+ "host": "127.0.0.1",
59
+ "port": _port,
60
+ "clients": _clients.size(),
61
+ }
62
+
63
+
51
64
  func _process(_delta: float) -> void:
52
65
  if _server == null:
53
66
  return
@@ -7,7 +7,7 @@ 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.0.0"
10
+ const ADDON_VERSION := "1.4.0"
11
11
 
12
12
  var _plugin: EditorPlugin
13
13
 
@@ -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.3.0"
6
+ version="1.4.0"
7
7
  script="plugin.gd"
@@ -12,10 +12,12 @@ extends EditorPlugin
12
12
  ## subscribed MCP host can emit notifications/resources/updated.
13
13
 
14
14
  const BridgeServer := preload("res://addons/breakpoint_mcp/bridge_server.gd")
15
+ const StatusDock := preload("res://addons/breakpoint_mcp/status_dock.gd")
15
16
  const RUNTIME_AUTOLOAD := "BreakpointRuntimeBridge"
16
17
  const RUNTIME_SCRIPT := "res://addons/breakpoint_mcp/runtime_bridge.gd"
17
18
 
18
19
  var _server: Node = null
20
+ var _dock: Control = null
19
21
  var _selection: EditorSelection = null
20
22
 
21
23
 
@@ -24,6 +26,13 @@ func _enter_tree() -> void:
24
26
  _server.name = "BreakpointBridgeServer"
25
27
  _server.setup(self)
26
28
  add_child(_server)
29
+ # Phase-4 status/config dock: a thin panel that reports bridge health across
30
+ # the editor/runtime/LSP/DAP planes and offers a one-click MCP-client config.
31
+ # Connection/status/config only — not a chat UI. Reads the server in-process.
32
+ var dock := StatusDock.new()
33
+ dock.set_server(_server)
34
+ add_control_to_dock(DOCK_SLOT_RIGHT_UL, dock)
35
+ _dock = dock
27
36
  # Inject the runtime bridge into every run of the project so the runtime_*
28
37
  # tools work as soon as the game starts. Removed cleanly on disable.
29
38
  add_autoload_singleton(RUNTIME_AUTOLOAD, RUNTIME_SCRIPT)
@@ -38,6 +47,10 @@ func _enter_tree() -> void:
38
47
 
39
48
 
40
49
  func _exit_tree() -> void:
50
+ if _dock:
51
+ remove_control_from_docks(_dock)
52
+ _dock.queue_free()
53
+ _dock = null
41
54
  if _selection and _selection.selection_changed.is_connected(_on_selection_changed):
42
55
  _selection.selection_changed.disconnect(_on_selection_changed)
43
56
  _selection = null
@@ -0,0 +1,312 @@
1
+ @tool
2
+ extends VBoxContainer
3
+ ## Breakpoint MCP — in-editor status/config dock (Phase-4 adoption item).
4
+ ##
5
+ ## The GUI twin of `breakpoint-mcp doctor` + `init`: it surfaces the four bridge
6
+ ## planes' health, the configured ports, the project path, and a one-click
7
+ ## "Copy MCP-client config" yielding the exact snippet `init` prints. Scope is
8
+ ## connection / status / config ONLY — it is NOT a chat UI. The AI assistant
9
+ ## still lives in the user's MCP client; this dock only wires it up and reports.
10
+ ##
11
+ ## The editor-bridge plane is read directly from the in-process bridge server
12
+ ## (server.get_status()); the runtime / LSP / DAP planes are checked with short,
13
+ ## non-blocking StreamPeerTCP probes ticked from _process, so the editor main
14
+ ## thread never stalls. LSP/DAP ports are read from EditorSettings, so the dock
15
+ ## reflects the user's actual configuration, not just the defaults.
16
+
17
+ const DEFAULT_BRIDGE_PORT := 9080
18
+ const DEFAULT_RUNTIME_PORT := 9081
19
+ const DEFAULT_LSP_PORT := 6005
20
+ const DEFAULT_DAP_PORT := 6006
21
+ const SERVER_NAME := "godot"
22
+ const REFRESH_INTERVAL_SEC := 2.0
23
+ const PROBE_DEADLINE_MSEC := 1500
24
+
25
+ const COLOR_OK := Color(0.38, 0.85, 0.45)
26
+ const COLOR_FAIL := Color(0.92, 0.47, 0.40)
27
+ const COLOR_PENDING := Color(0.62, 0.62, 0.62)
28
+
29
+ const PLANES := [
30
+ {"key": "editor", "name": "editor-bridge"},
31
+ {"key": "runtime", "name": "runtime-bridge"},
32
+ {"key": "lsp", "name": "gdscript-lsp"},
33
+ {"key": "dap", "name": "gdscript-dap"},
34
+ ]
35
+
36
+
37
+ # --- pure helpers: editor-free, socket-free, unit-tested --------------------
38
+
39
+ ## The stdio server entry an MCP client config needs, matching the host's
40
+ ## clients.ts serverEntry() default (GODOT_BIN omitted when it is the default).
41
+ static func server_entry(project_path: String) -> Dictionary:
42
+ return {
43
+ "command": "npx",
44
+ "args": ["-y", "breakpoint-mcp"],
45
+ "env": {"GODOT_PROJECT": project_path},
46
+ }
47
+
48
+ ## Copy-pasteable single-server snippet (wrapper key "mcpServers"), pretty JSON
49
+ ## with 2-space indent and insertion order preserved (sort_keys=false) so it is
50
+ ## the same shape `breakpoint-mcp init` prints — dock and CLI never drift.
51
+ static func client_snippet(project_path: String) -> String:
52
+ var obj := {"mcpServers": {SERVER_NAME: server_entry(project_path)}}
53
+ return JSON.stringify(obj, " ", false)
54
+
55
+ ## Status glyph, mirroring doctor's check / cross / dash vocabulary.
56
+ static func plane_glyph(status: String) -> String:
57
+ match status:
58
+ "ok":
59
+ return "✓"
60
+ "fail":
61
+ return "✗"
62
+ _:
63
+ return "–"
64
+
65
+ ## One plane row's text: "<glyph> <name> <detail>".
66
+ static func plane_line(pname: String, status: String, detail: String) -> String:
67
+ return "%s %s %s" % [plane_glyph(status), pname, detail]
68
+
69
+
70
+ # --- state -----------------------------------------------------------------
71
+
72
+ var _server: Node = null # bridge_server.gd instance, injected by plugin.gd
73
+ var _rows := {} # plane key -> Label
74
+ var _probes := {} # plane key -> {peer, deadline, port}
75
+ var _config_label: Label = null
76
+ var _copy_feedback: Label = null
77
+ var _timer: Timer = null
78
+
79
+
80
+ ## plugin.gd injects the live bridge server so the editor-bridge plane is read
81
+ ## in-process (authoritative) instead of self-probing the loopback port.
82
+ func set_server(server: Node) -> void:
83
+ _server = server
84
+
85
+
86
+ func _ready() -> void:
87
+ _build_ui()
88
+ _timer = Timer.new()
89
+ _timer.wait_time = REFRESH_INTERVAL_SEC
90
+ _timer.autostart = true
91
+ _timer.timeout.connect(_refresh)
92
+ add_child(_timer)
93
+ set_process(true)
94
+ _refresh()
95
+
96
+
97
+ func _exit_tree() -> void:
98
+ for key in _probes:
99
+ var pr: Dictionary = _probes[key]
100
+ if pr["peer"] != null:
101
+ pr["peer"].disconnect_from_host()
102
+ _probes.clear()
103
+
104
+
105
+ # --- UI --------------------------------------------------------------------
106
+
107
+ func _build_ui() -> void:
108
+ name = "Breakpoint"
109
+ add_theme_constant_override("separation", 6)
110
+
111
+ var header := Label.new()
112
+ header.text = "Breakpoint MCP"
113
+ header.add_theme_font_size_override("font_size", 15)
114
+ add_child(header)
115
+
116
+ var sub := Label.new()
117
+ sub.text = "bridge status · ports · client setup"
118
+ sub.modulate = Color(1, 1, 1, 0.6)
119
+ add_child(sub)
120
+
121
+ add_child(HSeparator.new())
122
+
123
+ var bridges_hdr := Label.new()
124
+ bridges_hdr.text = "Bridges"
125
+ bridges_hdr.modulate = Color(1, 1, 1, 0.75)
126
+ add_child(bridges_hdr)
127
+
128
+ for p in PLANES:
129
+ var row := Label.new()
130
+ row.add_theme_font_size_override("font_size", 13)
131
+ _rows[p["key"]] = row
132
+ add_child(row)
133
+ _set_plane(p["key"], "pending", "…")
134
+ add_child(HSeparator.new())
135
+
136
+ var cfg_hdr := Label.new()
137
+ cfg_hdr.text = "Config"
138
+ cfg_hdr.modulate = Color(1, 1, 1, 0.75)
139
+ add_child(cfg_hdr)
140
+
141
+ _config_label = Label.new()
142
+ _config_label.add_theme_font_size_override("font_size", 12)
143
+ _config_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
144
+ add_child(_config_label)
145
+
146
+ add_child(HSeparator.new())
147
+
148
+ var copy_btn := Button.new()
149
+ copy_btn.text = "Copy MCP-client config"
150
+ copy_btn.tooltip_text = "Copy the mcpServers snippet for this project to the clipboard."
151
+ copy_btn.pressed.connect(_on_copy_pressed)
152
+ add_child(copy_btn)
153
+
154
+ var refresh_btn := Button.new()
155
+ refresh_btn.text = "Refresh"
156
+ refresh_btn.pressed.connect(_refresh)
157
+ add_child(refresh_btn)
158
+
159
+ _copy_feedback = Label.new()
160
+ _copy_feedback.add_theme_font_size_override("font_size", 12)
161
+ _copy_feedback.modulate = Color(1, 1, 1, 0.7)
162
+ _copy_feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
163
+ add_child(_copy_feedback)
164
+
165
+ var foot := Label.new()
166
+ foot.text = "Status/config only — the assistant runs in your MCP client, not here."
167
+ foot.modulate = Color(1, 1, 1, 0.5)
168
+ foot.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
169
+ add_child(foot)
170
+
171
+
172
+ func _display_name(key: String) -> String:
173
+ for p in PLANES:
174
+ if p["key"] == key:
175
+ return String(p["name"])
176
+ return key
177
+
178
+
179
+ func _set_plane(key: String, status: String, detail: String) -> void:
180
+ var row: Label = _rows.get(key)
181
+ if row == null:
182
+ return
183
+ row.text = plane_line(_display_name(key), status, detail)
184
+ var col := COLOR_PENDING
185
+ if status == "ok":
186
+ col = COLOR_OK
187
+ elif status == "fail":
188
+ col = COLOR_FAIL
189
+ row.add_theme_color_override("font_color", col)
190
+
191
+
192
+ # --- refresh ---------------------------------------------------------------
193
+
194
+ func _refresh() -> void:
195
+ _update_editor_plane()
196
+ _start_probe("runtime", _runtime_port())
197
+ _start_probe("lsp", _lsp_port())
198
+ _start_probe("dap", _dap_port())
199
+ _update_config_line()
200
+
201
+
202
+ func _update_editor_plane() -> void:
203
+ if _server != null and _server.has_method("get_status"):
204
+ var s: Dictionary = _server.get_status()
205
+ if bool(s.get("listening", false)):
206
+ var n := int(s.get("clients", 0))
207
+ var host := String(s.get("host", "127.0.0.1"))
208
+ var suffix := "client" if n == 1 else "clients"
209
+ _set_plane("editor", "ok", "%s:%d · %d %s" % [host, int(s.get("port", DEFAULT_BRIDGE_PORT)), n, suffix])
210
+ else:
211
+ _set_plane("editor", "fail", "not listening")
212
+ else:
213
+ _set_plane("editor", "fail", "server unavailable")
214
+
215
+
216
+ func _update_config_line() -> void:
217
+ if _config_label == null:
218
+ return
219
+ _config_label.text = "project %s\neditor %d · runtime %d · lsp %d · dap %d" % [
220
+ _project_path(), _bridge_port(), _runtime_port(), _lsp_port(), _dap_port(),
221
+ ]
222
+
223
+
224
+ func _on_copy_pressed() -> void:
225
+ DisplayServer.clipboard_set(client_snippet(_project_path()))
226
+ if _copy_feedback != null:
227
+ _copy_feedback.text = "Copied the mcpServers snippet to the clipboard."
228
+
229
+
230
+ # --- ports -----------------------------------------------------------------
231
+
232
+ func _project_path() -> String:
233
+ return ProjectSettings.globalize_path("res://").trim_suffix("/")
234
+
235
+
236
+ func _bridge_port() -> int:
237
+ if _server != null and _server.has_method("get_status"):
238
+ var s: Dictionary = _server.get_status()
239
+ return int(s.get("port", DEFAULT_BRIDGE_PORT))
240
+ return _env_port("BREAKPOINT_BRIDGE_PORT", DEFAULT_BRIDGE_PORT)
241
+
242
+
243
+ func _runtime_port() -> int:
244
+ return _env_port("BREAKPOINT_RUNTIME_PORT", DEFAULT_RUNTIME_PORT)
245
+
246
+
247
+ func _env_port(var_name: String, fallback: int) -> int:
248
+ var env := OS.get_environment(var_name)
249
+ if env != "" and env.is_valid_int():
250
+ return int(env)
251
+ return fallback
252
+
253
+
254
+ func _editor_setting_int(key: String, fallback: int) -> int:
255
+ var es := EditorInterface.get_editor_settings()
256
+ if es != null and es.has_setting(key):
257
+ var v: Variant = es.get_setting(key)
258
+ if typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT:
259
+ return int(v)
260
+ return fallback
261
+
262
+
263
+ func _lsp_port() -> int:
264
+ return _editor_setting_int("network/language_server/remote_port", DEFAULT_LSP_PORT)
265
+
266
+
267
+ func _dap_port() -> int:
268
+ return _editor_setting_int("network/debug_adapter/remote_port", DEFAULT_DAP_PORT)
269
+
270
+
271
+ # --- non-blocking TCP probes -----------------------------------------------
272
+ # The editor-bridge plane is read in-process (see _update_editor_plane); only
273
+ # runtime / LSP / DAP are probed. connect_to_host is non-blocking; the socket is
274
+ # polled from _process across frames, so a slow/closed port never stalls the UI.
275
+
276
+ func _start_probe(key: String, port: int) -> void:
277
+ if _probes.has(key):
278
+ var old: Dictionary = _probes[key]
279
+ if old["peer"] != null:
280
+ old["peer"].disconnect_from_host()
281
+ _probes.erase(key)
282
+ var peer := StreamPeerTCP.new()
283
+ var err := peer.connect_to_host("127.0.0.1", port)
284
+ if err != OK:
285
+ _set_plane(key, "fail", "127.0.0.1:%d unreachable" % port)
286
+ return
287
+ _set_plane(key, "pending", "127.0.0.1:%d checking…" % port)
288
+ _probes[key] = {"peer": peer, "deadline": Time.get_ticks_msec() + PROBE_DEADLINE_MSEC, "port": port}
289
+
290
+
291
+ func _process(_delta: float) -> void:
292
+ if _probes.is_empty():
293
+ return
294
+ var done: Array = []
295
+ for key in _probes:
296
+ var pr: Dictionary = _probes[key]
297
+ var peer: StreamPeerTCP = pr["peer"]
298
+ peer.poll()
299
+ var st := peer.get_status()
300
+ if st == StreamPeerTCP.STATUS_CONNECTED:
301
+ _set_plane(key, "ok", "127.0.0.1:%d reachable" % int(pr["port"]))
302
+ peer.disconnect_from_host()
303
+ done.append(key)
304
+ elif st == StreamPeerTCP.STATUS_ERROR:
305
+ _set_plane(key, "fail", "127.0.0.1:%d unreachable" % int(pr["port"]))
306
+ done.append(key)
307
+ elif Time.get_ticks_msec() > int(pr["deadline"]):
308
+ _set_plane(key, "fail", "127.0.0.1:%d no response" % int(pr["port"]))
309
+ peer.disconnect_from_host()
310
+ done.append(key)
311
+ for key in done:
312
+ _probes.erase(key)
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `--from-github` escape hatch for `breakpoint-mcp init`: fetch the editor addon
3
+ * (addons/breakpoint_mcp/**) straight from the GitHub repo at a chosen ref, as an
4
+ * alternative to the copy bundled in the npm tarball. Used when the bundled addon
5
+ * is missing/corrupt, or to install a different ref (e.g. `main`, or an older tag)
6
+ * than the one that shipped with the installed package.
7
+ *
8
+ * Dependency-free: one GitHub git/trees API call lists the addon's blobs at the ref,
9
+ * then each file is downloaded from raw.githubusercontent.com (a CDN that does not
10
+ * count against the REST rate limit). The fetch-shaped dependency is injectable, so
11
+ * the whole path is unit-testable offline.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ export const DEFAULT_REPO = "jlivingston-Cipher/godot-breakpoint-mcp";
16
+ const ADDON_PREFIX = "addons/breakpoint_mcp/";
17
+ const USER_AGENT = "breakpoint-mcp-cli";
18
+ function defaultFetch() {
19
+ const f = globalThis.fetch;
20
+ if (typeof f !== "function") {
21
+ throw new Error("global fetch is unavailable — Node 18+ is required for --from-github.");
22
+ }
23
+ return f;
24
+ }
25
+ /** Validate and split an `owner/repo` slug; throws on anything else. */
26
+ export function parseRepo(repo) {
27
+ const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(repo.trim());
28
+ if (!m)
29
+ throw new Error(`invalid --repo '${repo}' (expected owner/repo).`);
30
+ return { owner: m[1], name: m[2] };
31
+ }
32
+ function authHeaders() {
33
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
34
+ const h = { "User-Agent": USER_AGENT };
35
+ if (token)
36
+ h.Authorization = `Bearer ${token}`;
37
+ return h;
38
+ }
39
+ /**
40
+ * Fetch addons/breakpoint_mcp/** from `repo` at `ref` into `dest` (created if
41
+ * needed). Returns the written relative paths. Throws a clear Error on a bad
42
+ * repo/ref, a missing addon, rate-limiting, or a network failure.
43
+ */
44
+ export async function fetchAddonFromGitHub(opts, fetchFn) {
45
+ const doFetch = fetchFn ?? defaultFetch();
46
+ const { owner, name } = parseRepo(opts.repo);
47
+ const ref = opts.ref;
48
+ const treeUrl = `https://api.github.com/repos/${owner}/${name}/git/trees/${ref}?recursive=1`;
49
+ let treeRes;
50
+ try {
51
+ treeRes = await doFetch(treeUrl, {
52
+ headers: { ...authHeaders(), Accept: "application/vnd.github+json" },
53
+ });
54
+ }
55
+ catch (err) {
56
+ throw new Error(`could not reach GitHub: ${err instanceof Error ? err.message : String(err)}`);
57
+ }
58
+ if (treeRes.status === 404) {
59
+ throw new Error(`GitHub returned 404 for ${owner}/${name}@${ref} — check the repo and ref.`);
60
+ }
61
+ if (treeRes.status === 403) {
62
+ throw new Error("GitHub returned 403 (rate-limited or forbidden). Set GITHUB_TOKEN to raise the limit, or retry later.");
63
+ }
64
+ if (!treeRes.ok) {
65
+ throw new Error(`GitHub git/trees request failed (HTTP ${treeRes.status}).`);
66
+ }
67
+ const body = (await treeRes.json());
68
+ if (body.truncated) {
69
+ throw new Error(`the repository tree at ${ref} was too large to list in one request; --from-github can't be used for this ref.`);
70
+ }
71
+ const entries = Array.isArray(body.tree) ? body.tree : [];
72
+ const files = entries.filter((e) => e.type === "blob" && e.path.startsWith(ADDON_PREFIX));
73
+ if (files.length === 0) {
74
+ throw new Error(`no ${ADDON_PREFIX} found in ${owner}/${name}@${ref}.`);
75
+ }
76
+ const written = [];
77
+ for (const f of files) {
78
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${name}/${ref}/${f.path}`;
79
+ let res;
80
+ try {
81
+ res = await doFetch(rawUrl, { headers: { "User-Agent": USER_AGENT } });
82
+ }
83
+ catch (err) {
84
+ throw new Error(`could not download ${f.path}: ${err instanceof Error ? err.message : String(err)}`);
85
+ }
86
+ if (!res.ok) {
87
+ throw new Error(`could not download ${f.path} (HTTP ${res.status}).`);
88
+ }
89
+ const rel = f.path.slice(ADDON_PREFIX.length);
90
+ const outPath = path.join(opts.dest, rel);
91
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
92
+ fs.writeFileSync(outPath, Buffer.from(await res.arrayBuffer()));
93
+ written.push(rel);
94
+ }
95
+ return written;
96
+ }
97
+ //# sourceMappingURL=github.js.map
package/dist/cli/init.js CHANGED
@@ -9,10 +9,12 @@
9
9
  * it into that client's config file (see clients.ts for the id list + paths).
10
10
  */
11
11
  import fs from "node:fs";
12
+ import os from "node:os";
12
13
  import path from "node:path";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { parseArgs } from "./args.js";
15
16
  import { CLIENT_IDS, clientInfo, mergeClientConfig, serverEntry, snippet } from "./clients.js";
17
+ import { DEFAULT_REPO, fetchAddonFromGitHub } from "./github.js";
16
18
  const PLUGIN_REL = "addons/breakpoint_mcp";
17
19
  const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
18
20
  const SERVER_NAME = "godot";
@@ -39,6 +41,23 @@ export function resolveBundledAddon() {
39
41
  }
40
42
  return null;
41
43
  }
44
+ /** This package's own version, read from its package.json (null if unreadable). */
45
+ function packageVersion() {
46
+ try {
47
+ const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); // dist/cli -> pkg
48
+ const v = JSON.parse(fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8"))
49
+ .version;
50
+ return typeof v === "string" ? v : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** Default ref for --from-github: this package's version tag (vX.Y.Z), else main. */
57
+ function defaultGitHubRef() {
58
+ const v = packageVersion();
59
+ return v ? `v${v}` : "main";
60
+ }
42
61
  /**
43
62
  * Add `res://addons/breakpoint_mcp/plugin.cfg` to project.godot's
44
63
  * `[editor_plugins] enabled=PackedStringArray(...)`, creating the section or the
@@ -102,7 +121,7 @@ function claudeCodeCommand(projectPath) {
102
121
  return `claude mcp add godot --env GODOT_PROJECT=${projectPath} -- npx -y breakpoint-mcp`;
103
122
  }
104
123
  /** Entry point for `breakpoint-mcp init`. Returns the process exit code. */
105
- export async function runInit(argv) {
124
+ export async function runInit(argv, deps = {}) {
106
125
  const { flags } = parseArgs(argv, ["force", "dry-run"]);
107
126
  const projectPath = typeof flags.project === "string"
108
127
  ? path.resolve(flags.project)
@@ -111,30 +130,69 @@ export async function runInit(argv) {
111
130
  const force = flags.force === true;
112
131
  const client = typeof flags.client === "string" ? flags.client : "none";
113
132
  const godotBin = process.env.GODOT_BIN ?? "godot";
133
+ // --from-github [ref]: source the addon from GitHub instead of the bundled copy.
134
+ const fromGitHub = "from-github" in flags;
135
+ const fgVal = flags["from-github"];
136
+ const ref = typeof fgVal === "string" && fgVal.length > 0 ? fgVal : defaultGitHubRef();
137
+ const repo = typeof flags.repo === "string" && flags.repo.length > 0 ? flags.repo : DEFAULT_REPO;
114
138
  const projGodot = path.join(projectPath, "project.godot");
115
139
  if (!fs.existsSync(projGodot)) {
116
140
  process.stderr.write(`init: no project.godot at ${projectPath}\n` +
117
141
  " Pass --project <dir> pointing at your Godot project (the folder with project.godot).\n");
118
142
  return 1;
119
143
  }
120
- const addonSource = resolveBundledAddon();
121
- if (!addonSource) {
122
- process.stderr.write("init: could not locate the bundled editor addon inside the package.\n");
123
- return 1;
124
- }
125
144
  const out = [];
126
145
  const say = (s = "") => {
127
146
  out.push(s);
128
147
  };
129
148
  say(`Project: ${projectPath}${dryRun ? " (dry run — no changes written)" : ""}`);
149
+ // Resolve the addon source: the bundled copy, or (--from-github) a temp dir
150
+ // fetched from GitHub. The temp dir is removed once the addon is installed.
151
+ let addonSource = null;
152
+ let tmpFetchDir = null;
153
+ if (fromGitHub) {
154
+ if (dryRun) {
155
+ say(` addon source: would fetch ${repo}@${ref} from GitHub`);
156
+ }
157
+ else {
158
+ say(` addon source: fetching ${repo}@${ref} from GitHub…`);
159
+ tmpFetchDir = fs.mkdtempSync(path.join(os.tmpdir(), "bpmcp-gh-"));
160
+ try {
161
+ const written = await fetchAddonFromGitHub({ repo, ref, dest: tmpFetchDir }, deps.fetchFn);
162
+ say(` addon source: fetched ${written.length} file(s) from ${repo}@${ref}`);
163
+ addonSource = tmpFetchDir;
164
+ }
165
+ catch (err) {
166
+ fs.rmSync(tmpFetchDir, { recursive: true, force: true });
167
+ process.stderr.write(`init: --from-github failed: ${err instanceof Error ? err.message : String(err)}\n` +
168
+ " Omit --from-github to install the addon bundled with this package.\n");
169
+ return 1;
170
+ }
171
+ }
172
+ }
173
+ else {
174
+ addonSource = resolveBundledAddon();
175
+ if (!addonSource) {
176
+ process.stderr.write("init: could not locate the bundled editor addon inside the package.\n" +
177
+ " Reinstall breakpoint-mcp, or pass --from-github to fetch the addon from GitHub.\n");
178
+ return 1;
179
+ }
180
+ }
130
181
  // 1. Install the addon.
131
182
  const destHasAddon = fs.existsSync(path.join(projectPath, PLUGIN_REL, "plugin.cfg"));
132
183
  if (dryRun) {
133
184
  const verb = destHasAddon ? (force ? "overwrite" : "skip (already present; --force to overwrite)") : "install";
134
- say(` addon: would ${verb} → ${PLUGIN_REL}/`);
185
+ const srcNote = fromGitHub ? ` (from ${repo}@${ref})` : "";
186
+ say(` addon: would ${verb} → ${PLUGIN_REL}/${srcNote}`);
135
187
  }
136
188
  else {
189
+ if (!addonSource) {
190
+ process.stderr.write("init: internal error — no addon source resolved.\n");
191
+ return 1;
192
+ }
137
193
  const r = installAddon(addonSource, projectPath, { force });
194
+ if (tmpFetchDir)
195
+ fs.rmSync(tmpFetchDir, { recursive: true, force: true });
138
196
  say(` addon: ${r.action} → ${PLUGIN_REL}/`);
139
197
  }
140
198
  // 2. Enable it in project.godot.
package/dist/index.js CHANGED
@@ -43,7 +43,7 @@ async function main() {
43
43
  // so long jobs (export/import/headless script) support poll/await/cancel.
44
44
  // D3: also advertise resources.subscribe so clients can subscribe to
45
45
  // godot://… resources and receive notifications/resources/updated.
46
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.3.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
46
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.4.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
47
47
  // B1: enforce frozen output schemas on every structured tool. Must run before
48
48
  // the register*Tools calls below — it wraps server.registerTool.
49
49
  applyOutputSchemas(server);
@@ -118,6 +118,8 @@ function printUsage() {
118
118
  " --client <id> Write the MCP config for a client: claude-code | claude-desktop | cursor | windsurf | vscode.",
119
119
  " --force Overwrite an addon that is already installed.",
120
120
  " --dry-run Print what would change without writing anything.",
121
+ " --from-github [ref] Fetch the editor addon from GitHub at [ref] (default: this package's version tag) instead of the bundled copy.",
122
+ " --repo <owner/repo> With --from-github, the source repo (default: jlivingston-Cipher/godot-breakpoint-mcp).",
121
123
  "",
122
124
  "doctor options:",
123
125
  " --project <dir> Project to check (default: $GODOT_PROJECT or the current directory).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
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",