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.
- package/addon/breakpoint_mcp/operations.gd +1 -1
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/status_dock.gd +51 -18
- 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
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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",
|
|
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",
|