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 +23 -1
- package/dist/capabilities.js +7 -6
- package/dist/cli/doctor.js +59 -3
- package/dist/cli/tools.js +1 -1
- package/dist/csdap.js +3 -2
- package/dist/cslsp.js +3 -3
- package/dist/dap.js +6 -2
- package/dist/framing.js +10 -1
- package/dist/index.js +4 -4
- package/dist/lsp.js +7 -3
- package/package.json +1 -1
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",
|
|
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.
|
package/dist/capabilities.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
/** The
|
|
2
|
-
export const CAPABILITY_GROUPS = ["code-execution"
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
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
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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:
|
|
314
|
+
status: "ok",
|
|
258
315
|
severity,
|
|
259
|
-
detail: `${b.host}:${b.port} ${
|
|
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` —
|
|
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,
|
|
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",
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
//
|
|
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 −
|
|
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",
|
|
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.
|
|
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",
|