breakpoint-mcp 1.27.1 → 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.
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.1" }, { 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.1",
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",