breakpoint-mcp 1.27.0 → 1.28.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.
@@ -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.9.1"
10
+ const ADDON_VERSION := "1.9.2"
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.9.1"
6
+ version="1.9.2"
7
7
  script="plugin.gd"
@@ -80,6 +80,7 @@ var _copy_feedback: Label = null
80
80
  var _timer: Timer = null
81
81
  var _pause_toggle: CheckButton = null
82
82
  var _pause_state: Label = null
83
+ var _body: VBoxContainer = null # every Control child lives in here (see _build_ui)
83
84
 
84
85
 
85
86
  ## plugin.gd injects the live bridge server so the editor-bridge plane is read
@@ -111,32 +112,52 @@ func _exit_tree() -> void:
111
112
 
112
113
  func _build_ui() -> void:
113
114
  name = "Breakpoint"
114
- add_theme_constant_override("separation", 6)
115
+
116
+ # #124: a dock slot cannot scroll and cannot be shrunk past its content, so a
117
+ # tall dock is not clipped — it raises the MINIMUM size of the whole editor
118
+ # layout, which the editor can only satisfy by pushing the bottom panel and
119
+ # the lower docks out of the window. A Label with autowrap reports a minimum
120
+ # height measured at its NARROWEST width, a VBoxContainer sums those, and this
121
+ # dock demanded 311x4015 px against ~208x125 for every built-in dock. Putting
122
+ # every Control inside a ScrollContainer decouples the dock's minimum height
123
+ # from its content height, which is what EditorInspector does for the same
124
+ # reason. Verify with get_combined_minimum_size(), not by eye.
125
+ var scroll := ScrollContainer.new()
126
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
127
+ scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
128
+ scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
129
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
130
+ add_child(scroll)
131
+
132
+ _body = VBoxContainer.new()
133
+ _body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
134
+ _body.add_theme_constant_override("separation", 6)
135
+ scroll.add_child(_body)
115
136
 
116
137
  var header := Label.new()
117
138
  header.text = "Breakpoint MCP"
118
139
  header.add_theme_font_size_override("font_size", 15)
119
- add_child(header)
140
+ _body.add_child(header)
120
141
 
121
142
  var sub := Label.new()
122
143
  sub.text = "bridge status · ports · client setup"
123
144
  sub.modulate = Color(1, 1, 1, 0.6)
124
- add_child(sub)
145
+ _body.add_child(sub)
125
146
 
126
- add_child(HSeparator.new())
147
+ _body.add_child(HSeparator.new())
127
148
 
128
149
  var bridges_hdr := Label.new()
129
150
  bridges_hdr.text = "Bridges"
130
151
  bridges_hdr.modulate = Color(1, 1, 1, 0.75)
131
- add_child(bridges_hdr)
152
+ _body.add_child(bridges_hdr)
132
153
 
133
154
  for p in PLANES:
134
155
  var row := Label.new()
135
156
  row.add_theme_font_size_override("font_size", 13)
136
157
  _rows[p["key"]] = row
137
- add_child(row)
158
+ _body.add_child(row)
138
159
  _set_plane(p["key"], "pending", "…")
139
- add_child(HSeparator.new())
160
+ _body.add_child(HSeparator.new())
140
161
 
141
162
  # Control: a one-click "Pause Agent" latch honored by the editor + runtime
142
163
  # bridges (pause_latch.gd). Engaged, those two planes hold new agent commands
@@ -144,55 +165,66 @@ func _build_ui() -> void:
144
165
  var ctrl_hdr := Label.new()
145
166
  ctrl_hdr.text = "Control"
146
167
  ctrl_hdr.modulate = Color(1, 1, 1, 0.75)
147
- add_child(ctrl_hdr)
168
+ _body.add_child(ctrl_hdr)
148
169
 
149
170
  _pause_toggle = CheckButton.new()
150
171
  _pause_toggle.text = "Pause Agent"
151
172
  _pause_toggle.tooltip_text = "Hold new agent commands on the editor + runtime bridges. In-flight ops finish and a bare ping still answers; resume to continue."
152
173
  _pause_toggle.toggled.connect(_on_pause_toggled)
153
- add_child(_pause_toggle)
174
+ _body.add_child(_pause_toggle)
154
175
 
155
176
  _pause_state = Label.new()
156
177
  _pause_state.add_theme_font_size_override("font_size", 12)
157
178
  _pause_state.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
158
- add_child(_pause_state)
179
+ _body.add_child(_pause_state)
159
180
 
160
- add_child(HSeparator.new())
181
+ _body.add_child(HSeparator.new())
161
182
 
162
183
  var cfg_hdr := Label.new()
163
184
  cfg_hdr.text = "Config"
164
185
  cfg_hdr.modulate = Color(1, 1, 1, 0.75)
165
- add_child(cfg_hdr)
186
+ _body.add_child(cfg_hdr)
166
187
 
167
188
  _config_label = Label.new()
168
189
  _config_label.add_theme_font_size_override("font_size", 12)
169
190
  _config_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
170
- add_child(_config_label)
191
+ _body.add_child(_config_label)
171
192
 
172
- add_child(HSeparator.new())
193
+ _body.add_child(HSeparator.new())
173
194
 
174
195
  var copy_btn := Button.new()
175
196
  copy_btn.text = "Copy MCP-client config"
176
197
  copy_btn.tooltip_text = "Copy the mcpServers snippet for this project to the clipboard."
177
198
  copy_btn.pressed.connect(_on_copy_pressed)
178
- add_child(copy_btn)
199
+ _body.add_child(copy_btn)
179
200
 
180
201
  var refresh_btn := Button.new()
181
202
  refresh_btn.text = "Refresh"
182
203
  refresh_btn.pressed.connect(_refresh)
183
- add_child(refresh_btn)
204
+ _body.add_child(refresh_btn)
184
205
 
185
206
  _copy_feedback = Label.new()
186
207
  _copy_feedback.add_theme_font_size_override("font_size", 12)
187
208
  _copy_feedback.modulate = Color(1, 1, 1, 0.7)
188
209
  _copy_feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
189
- add_child(_copy_feedback)
210
+ _body.add_child(_copy_feedback)
190
211
 
191
212
  var foot := Label.new()
192
213
  foot.text = "Status/config only — the assistant runs in your MCP client, not here."
193
214
  foot.modulate = Color(1, 1, 1, 0.5)
194
215
  foot.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
195
- add_child(foot)
216
+ _body.add_child(foot)
217
+
218
+ # The height half of #124 is the ScrollContainer above; this is the width half.
219
+ # A non-wrapping Label reports its FULL text width as a minimum, so one long
220
+ # status line (311 px) forced the dock wider than a default dock slot (280 px).
221
+ # Trimming with an ellipsis lets the dock shrink; _set_plane keeps the whole
222
+ # line reachable as a tooltip. Applies to every such Label, including any
223
+ # added later.
224
+ for child in _body.get_children():
225
+ var lbl := child as Label
226
+ if lbl != null and lbl.autowrap_mode == TextServer.AUTOWRAP_OFF:
227
+ lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
196
228
 
197
229
 
198
230
  func _display_name(key: String) -> String:
@@ -207,6 +239,7 @@ func _set_plane(key: String, status: String, detail: String) -> void:
207
239
  if row == null:
208
240
  return
209
241
  row.text = plane_line(_display_name(key), status, detail)
242
+ row.tooltip_text = row.text # the row trims with an ellipsis (see _build_ui)
210
243
  var col := COLOR_PENDING
211
244
  if status == "ok":
212
245
  col = COLOR_OK
package/dist/bridge.js CHANGED
@@ -70,6 +70,19 @@ export class BridgeClient {
70
70
  onResourceChanged(cb) {
71
71
  this.eventListeners.add(cb);
72
72
  }
73
+ /**
74
+ * The last socket-level error, held so the close path can name it.
75
+ *
76
+ * `socket.once("error")` below only rejects the CONNECT promise. Once the
77
+ * connection is up that handler is still armed, so a mid-flight ECONNRESET /
78
+ * EPIPE fires it, calls reject() on an already-settled promise — a silent
79
+ * no-op — and then `close` rejects every pending request with a generic
80
+ * "connection closed". The specific errno never reached the caller, which is
81
+ * the difference between "the editor crashed" and "something else is on that
82
+ * port". Cleared on a successful connect so a stale errno cannot be blamed
83
+ * for a later, unrelated drop.
84
+ */
85
+ lastSocketError = null;
73
86
  connect() {
74
87
  if (this.socket && !this.socket.destroyed)
75
88
  return Promise.resolve(this.socket);
@@ -81,6 +94,7 @@ export class BridgeClient {
81
94
  socket.once("connect", () => {
82
95
  this.socket = socket;
83
96
  this.connecting = null;
97
+ this.lastSocketError = null;
84
98
  this.clearReconnect();
85
99
  // Loopback-auth handshake: if a secret is available it MUST be the first
86
100
  // line on the connection. TCP preserves order and the addon drains lines
@@ -98,6 +112,9 @@ export class BridgeClient {
98
112
  });
99
113
  socket.once("error", (err) => {
100
114
  this.connecting = null;
115
+ // Recorded for onClose(): if the connection was already up, this reject()
116
+ // is a no-op on a settled promise and the errno would otherwise be lost.
117
+ this.lastSocketError = err;
101
118
  reject(new BridgeError("bridge_unavailable", `Cannot reach the Godot ${this.label} at ${this.host}:${this.port}. ${this.hint} (${err.message})`));
102
119
  });
103
120
  socket.on("data", (chunk) => this.onData(chunk));
@@ -200,9 +217,14 @@ export class BridgeClient {
200
217
  }
201
218
  onClose() {
202
219
  this.socket = null;
220
+ // Name the transport error when there was one. The CODE stays `bridge_closed`
221
+ // — callers and tests branch on it — but the message carries the errno.
222
+ const cause = this.lastSocketError;
223
+ this.lastSocketError = null;
224
+ const detail = cause ? ` (${cause.message})` : "";
203
225
  for (const [, p] of this.pending) {
204
226
  clearTimeout(p.timer);
205
- p.reject(new BridgeError("bridge_closed", "Bridge connection closed before a response arrived"));
227
+ p.reject(new BridgeError("bridge_closed", `Bridge connection closed before a response arrived${detail}`));
206
228
  }
207
229
  this.pending.clear();
208
230
  // Keep the push channel alive across editor restarts while subscriptions want it.
@@ -1,9 +1,8 @@
1
- /** The two groups, in display order. */
2
- export const CAPABILITY_GROUPS = ["code-execution", "network"];
1
+ /** The groups, in display order. */
2
+ export const CAPABILITY_GROUPS = ["code-execution"];
3
3
  /** One-line human description per group (shown by `doctor` and the resource). */
4
4
  export const GROUP_DESCRIBE = {
5
5
  "code-execution": "Run arbitrary GDScript, invoke arbitrary methods, evaluate an expression in a paused debug frame, spawn headless child processes of the project, or run a local asset-gen command backend.",
6
- network: "Egress beyond loopback — the Group M backend SDK.",
7
6
  };
8
7
  /**
9
8
  * Tool → capability group(s). A tool absent from this map is unprivileged and
@@ -32,9 +31,11 @@ export const TOOL_CAPABILITIES = {
32
31
  asset_gen_texture: ["code-execution"],
33
32
  asset_gen_model: ["code-execution"],
34
33
  asset_gen_audio_sfx: ["code-execution"],
35
- // network — Group M backend SDK (egress to a backend provider)
36
- backend_configure: ["network"],
37
- backend_detect: ["network"],
34
+ // backend_detect / backend_configure are deliberately ABSENT: neither leaves
35
+ // this machine (see the header). They are unprivileged, which also puts
36
+ // backend_configure back in line with the three sibling codegen tools —
37
+ // leaderboard_scaffold / cloudsave_scaffold / auth_scaffold — that write the
38
+ // same kind of generated GDScript and were never privileged.
38
39
  };
39
40
  /**
40
41
  * Parse the raw BREAKPOINT_PRIVILEGED_GROUPS env into a normalized token list
@@ -24,6 +24,8 @@ import { spawnSync } from "node:child_process";
24
24
  import { loadConfig } from "../config.js";
25
25
  import { CAPABILITY_GROUPS, droppedTools, selectPrivilegedGroups } from "../capabilities.js";
26
26
  import { parseArgs } from "./args.js";
27
+ import { BridgeClient } from "../bridge.js";
28
+ import { resolveBridgeSecret } from "../secret.js";
27
29
  const ADDON_REL = "addons/breakpoint_mcp";
28
30
  const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
29
31
  /** Resolve after a TCP connect succeeds (true) or the port is closed/times out (false). */
@@ -214,6 +216,28 @@ export function checkCapabilities(config) {
214
216
  }
215
217
  return checks;
216
218
  }
219
+ /**
220
+ * Prove the thing on the far end is OUR bridge, not merely a listening socket.
221
+ *
222
+ * Sends an authenticated `ping` with the same secret resolution index.ts uses,
223
+ * so this fails exactly when a real call would: wrong process on the port, or a
224
+ * secret that no longer matches the one the addon minted. `probeTcp` can see
225
+ * neither, which is what made "editor-bridge reachable" a check that could pass
226
+ * while every tool failed.
227
+ */
228
+ async function handshakeOk(config, b, timeoutMs) {
229
+ const client = new BridgeClient(b.host, b.port, timeoutMs, "bridge", undefined, () => resolveBridgeSecret(config.projectPath, b.secretEnv ?? []));
230
+ try {
231
+ await client.request("ping", {}, timeoutMs);
232
+ return true;
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ finally {
238
+ client.close();
239
+ }
240
+ }
217
241
  export async function runDoctorChecks(config, opts) {
218
242
  const checks = [];
219
243
  // Capability groups — the secure-default surface + how to widen it (info only).
@@ -224,18 +248,24 @@ export async function runDoctorChecks(config, opts) {
224
248
  checks.push(...checkAddon(config.projectPath));
225
249
  // The four bridges.
226
250
  const severity = opts.requireLive ? "required" : "info";
251
+ // `secretEnv` marks the two bridges that speak OUR protocol, so a TCP connect
252
+ // is not the end of the story for them (see handshakeOk). The LSP and DAP
253
+ // entries have none: those ports belong to Godot, and anything we could send
254
+ // to prove liveness would be us implementing a foreign protocol inside doctor.
227
255
  const bridges = [
228
256
  {
229
257
  name: "editor-bridge",
230
258
  host: config.bridgeHost,
231
259
  port: config.bridgePort,
232
260
  hint: 'Open the editor with the "Breakpoint MCP" plugin enabled.',
261
+ secretEnv: ["BREAKPOINT_BRIDGE_SECRET"],
233
262
  },
234
263
  {
235
264
  name: "runtime-bridge",
236
265
  host: config.runtimeHost,
237
266
  port: config.runtimePort,
238
267
  hint: "Launch the project (godot_run_project / dbg_launch) with the plugin enabled — it auto-registers the runtime autoload.",
268
+ secretEnv: ["BREAKPOINT_RUNTIME_SECRET", "BREAKPOINT_BRIDGE_SECRET"],
239
269
  },
240
270
  {
241
271
  name: "gdscript-lsp",
@@ -252,12 +282,38 @@ export async function runDoctorChecks(config, opts) {
252
282
  ];
253
283
  const bridgeChecks = await Promise.all(bridges.map(async (b) => {
254
284
  const ok = await probeTcp(b.host, b.port, opts.timeoutMs);
285
+ if (!ok) {
286
+ return {
287
+ name: b.name,
288
+ status: "fail",
289
+ severity,
290
+ detail: `${b.host}:${b.port} unreachable`,
291
+ hint: b.hint,
292
+ };
293
+ }
294
+ // An open port is not a working bridge. A TCP connect succeeds for ANY
295
+ // process holding it, and never touches the shared secret — so the two
296
+ // failures that look most like success stayed invisible: a foreign
297
+ // process on 9080, and a stale secret (the addon regenerates it into
298
+ // res://.godot/, so a copied or long-lived config goes quietly wrong).
299
+ // Both then reported "reachable" here and failed on every real call.
300
+ if (b.secretEnv) {
301
+ const live = await handshakeOk(config, b, opts.timeoutMs);
302
+ if (!live) {
303
+ return {
304
+ name: b.name,
305
+ status: "fail",
306
+ severity,
307
+ detail: `${b.host}:${b.port} open, but no Breakpoint bridge answered`,
308
+ hint: "Something holds that port without speaking the bridge protocol, or the shared secret is stale. Close other Godot instances, or delete res://.godot/breakpoint_mcp.secret and reopen the editor to remint it.",
309
+ };
310
+ }
311
+ }
255
312
  return {
256
313
  name: b.name,
257
- status: ok ? "ok" : "fail",
314
+ status: "ok",
258
315
  severity,
259
- detail: `${b.host}:${b.port} ${ok ? "reachable" : "unreachable"}`,
260
- hint: ok ? undefined : b.hint,
316
+ detail: `${b.host}:${b.port} reachable${b.secretEnv ? " · bridge answered" : ""}`,
261
317
  };
262
318
  }));
263
319
  checks.push(...bridgeChecks);
package/dist/cli/tools.js CHANGED
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Two surfaces are reported, because the difference is the security story:
20
20
  * • `full` — all 289 tools, what loads with BREAKPOINT_PRIVILEGED_GROUPS=all
21
- * • `secure-default` — 274, what an untouched install actually advertises
21
+ * • `secure-default` — 276, what an untouched install actually advertises
22
22
  * `--surface` picks which one populates `tools[]`; both counts are always in the
23
23
  * header so a consumer cannot report one as the other by accident.
24
24
  */
package/dist/csdap.js CHANGED
@@ -49,12 +49,13 @@ export class CsDapClient extends EventEmitter {
49
49
  this.channel = channel;
50
50
  this.timeoutMs = timeoutMs;
51
51
  this.channel.onMessage((m) => this.onMessage(m));
52
- this.channel.onClose(() => {
52
+ this.channel.onClose((cause) => {
53
53
  this.state = "terminated";
54
54
  this.configured = false;
55
+ const detail = cause ? ` (${cause.message})` : "";
55
56
  for (const [, p] of this.pending) {
56
57
  clearTimeout(p.timer);
57
- p.reject(new DapError(p.command, "C# DAP connection closed"));
58
+ p.reject(new DapError(p.command, `C# DAP connection closed${detail}`));
58
59
  }
59
60
  this.pending.clear();
60
61
  this.emit("closed");
package/dist/cslsp.js CHANGED
@@ -47,7 +47,7 @@ export class CsLspClient {
47
47
  }
48
48
  this.rootFsPath = root.replace(/[\\/]+$/, "");
49
49
  this.channel.onMessage((m) => this.onMessage(m));
50
- this.channel.onClose(() => this.onClose());
50
+ this.channel.onClose((cause) => this.onClose(cause));
51
51
  }
52
52
  /**
53
53
  * Reduce any document URI to a stable, project-relative key (e.g. "Player.cs")
@@ -127,10 +127,10 @@ export class CsLspClient {
127
127
  void this.channel.send({ jsonrpc: "2.0", id, result: null });
128
128
  }
129
129
  }
130
- onClose() {
130
+ onClose(cause) {
131
131
  for (const [, p] of this.pending) {
132
132
  clearTimeout(p.timer);
133
- p.reject(new LspError("closed", "C# LSP connection closed"));
133
+ p.reject(new LspError("closed", `C# LSP connection closed${cause ? ` (${cause.message})` : ""}`));
134
134
  }
135
135
  this.pending.clear();
136
136
  this.initialized = null;
package/dist/dap.js CHANGED
@@ -41,12 +41,16 @@ export class DapClient extends EventEmitter {
41
41
  this.timeoutMs = timeoutMs;
42
42
  this.conn = new FramedConnection(host, port, "DAP", "Is the editor running with the Debug Adapter enabled (Editor Settings → Network → Debug Adapter, port 6006)?");
43
43
  this.conn.onMessage((m) => this.onMessage(m));
44
- this.conn.onClose(() => {
44
+ this.conn.onClose((cause) => {
45
45
  this.state = "terminated";
46
46
  this.configured = false;
47
+ // `cause` is the socket-level error when the drop had one (ECONNRESET, EPIPE).
48
+ // Without it every pending request reported a generic close and the operator
49
+ // could not tell a crashed server from something else holding the port.
50
+ const detail = cause ? ` (${cause.message})` : "";
47
51
  for (const [, p] of this.pending) {
48
52
  clearTimeout(p.timer);
49
- p.reject(new DapError(p.command, "DAP connection closed"));
53
+ p.reject(new DapError(p.command, `DAP connection closed${detail}`));
50
54
  }
51
55
  this.pending.clear();
52
56
  this.emit("closed");
package/dist/framing.js CHANGED
@@ -72,6 +72,8 @@ export class FramedConnection {
72
72
  decoder;
73
73
  messageCb = () => { };
74
74
  closeCb = () => { };
75
+ /** Last socket error, so the close path can name the errno (see connect()). */
76
+ lastError = null;
75
77
  constructor(host, port, label, unavailableHint) {
76
78
  this.host = host;
77
79
  this.port = port;
@@ -96,18 +98,25 @@ export class FramedConnection {
96
98
  socket.once("connect", () => {
97
99
  this.socket = socket;
98
100
  this.connecting = null;
101
+ this.lastError = null;
99
102
  log(`${this.label} connected to ${this.host}:${this.port}`);
100
103
  resolve(socket);
101
104
  });
102
105
  socket.once("error", (err) => {
103
106
  this.connecting = null;
107
+ // Held for the close path: once the connection is up this reject() is a
108
+ // no-op on a settled promise, so without recording it the errno is lost
109
+ // and every pending request gets a generic "connection closed".
110
+ this.lastError = err;
104
111
  reject(new Error(`${this.label} unavailable at ${this.host}:${this.port}. ${this.unavailableHint} (${err.message})`));
105
112
  });
106
113
  socket.on("data", (chunk) => this.decoder.push(chunk));
107
114
  socket.on("close", () => {
108
115
  this.socket = null;
109
116
  this.decoder.reset();
110
- this.closeCb();
117
+ const cause = this.lastError;
118
+ this.lastError = null;
119
+ this.closeCb(cause ?? undefined);
111
120
  });
112
121
  });
113
122
  return this.connecting;
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ async function main() {
41
41
  // so long jobs (export/import/headless script) support poll/await/cancel.
42
42
  // D3: also advertise resources.subscribe so clients can subscribe to
43
43
  // godot://… resources and receive notifications/resources/updated.
44
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.27.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
44
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.28.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
45
45
  // B1: enforce frozen output schemas on every structured tool. Must run before
46
46
  // any register*Tools call — it wraps server.registerTool.
47
47
  applyOutputSchemas(server);
@@ -60,10 +60,10 @@ async function main() {
60
60
  // HANDLER (not the config), after applyAnnotations so the hints exist, before
61
61
  // applyCapabilities so a dropped tool is never wrapped.
62
62
  applyTimeoutCaveat(server);
63
- // Capability groups — a risk-based axis over the toolsets. Both `code-execution`
64
- // and `network` are OFF by default; a disabled group's tools are DROPPED at
63
+ // Capability groups — a risk-based axis over the toolsets. `code-execution` is
64
+ // OFF by default; a disabled group's tools are DROPPED at
65
65
  // registration (omitted from tools/list), so the secure-default surface is
66
- // 289 − 15 = 274 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
66
+ // 289 − 13 = 276 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
67
67
  // server.registerTool AFTER applyOutputSchemas (schema wrapper stays innermost).
68
68
  const privilegedGroups = selectPrivilegedGroups(config.privilegedGroups, (unknown) => log(`ignoring unknown BREAKPOINT_PRIVILEGED_GROUPS token(s): ${unknown.join(", ")}`));
69
69
  applyCapabilities(server, privilegedGroups);
package/dist/lsp.js CHANGED
@@ -47,7 +47,7 @@ export class LspClient {
47
47
  this.rootFsPath = root.replace(/[\\/]+$/, "");
48
48
  this.conn = new FramedConnection(host, port, "LSP", "Is the editor running with the GDScript language server enabled (Editor Settings → Network → Language Server, port 6005)?");
49
49
  this.conn.onMessage((m) => this.onMessage(m));
50
- this.conn.onClose(() => this.onClose());
50
+ this.conn.onClose((cause) => this.onClose(cause));
51
51
  }
52
52
  /**
53
53
  * Reduce any document URI to a stable, project-relative key (e.g. "player.gd")
@@ -128,10 +128,14 @@ export class LspClient {
128
128
  void this.conn.send({ jsonrpc: "2.0", id, result: null });
129
129
  }
130
130
  }
131
- onClose() {
131
+ onClose(cause) {
132
+ // `cause` is the socket-level error when the drop had one (ECONNRESET, EPIPE).
133
+ // Without it every pending request reported a generic close and the operator
134
+ // could not tell a crashed server from something else holding the port.
135
+ const detail = cause ? ` (${cause.message})` : "";
132
136
  for (const [, p] of this.pending) {
133
137
  clearTimeout(p.timer);
134
- p.reject(new LspError("closed", "LSP connection closed"));
138
+ p.reject(new LspError("closed", `LSP connection closed${detail}`));
135
139
  }
136
140
  this.pending.clear();
137
141
  this.initialized = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",