breakpoint-mcp 1.26.0 → 1.27.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.
@@ -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
@@ -1,6 +1,7 @@
1
1
  import net from "node:net";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { log } from "./logger.js";
4
+ import { OverdueLedger } from "./late-reply.js";
4
5
  export class BridgeError extends Error {
5
6
  code;
6
7
  constructor(code, message) {
@@ -32,6 +33,8 @@ export class BridgeClient {
32
33
  connecting = null;
33
34
  buffer = "";
34
35
  pending = new Map();
36
+ /** Ids whose deadline fired, so a reply arriving later is recognised, not dropped. */
37
+ ledger;
35
38
  eventListeners = new Set();
36
39
  wantConnected = false;
37
40
  reconnectTimer = null;
@@ -43,13 +46,25 @@ export class BridgeClient {
43
46
  * insecure or not-yet-provisioned bridge. Lazy so a secret minted after the
44
47
  * host started (editor launched later) is picked up on the next (re)connect.
45
48
  */
46
- secretProvider) {
49
+ secretProvider,
50
+ /**
51
+ * The env var that widens THIS instance's deadline, and the noun for what
52
+ * answered it. Per-INSTANCE, not per-class: index.ts builds two
53
+ * BridgeClients — the editor bridge and the runtime bridge — and they are
54
+ * configured by different variables (BREAKPOINT_BRIDGE_TIMEOUT_MS vs
55
+ * BREAKPOINT_RUNTIME_TIMEOUT_MS). A late-reply line that names the wrong one
56
+ * sends the operator to a knob that cannot move the deadline they just hit,
57
+ * which is worse than saying nothing. Defaults keep the editor bridge's
58
+ * wording byte-identical to what shipped.
59
+ */
60
+ deadlineKnob = "BREAKPOINT_BRIDGE_TIMEOUT_MS", peerNoun = "the editor") {
47
61
  this.host = host;
48
62
  this.port = port;
49
63
  this.defaultTimeoutMs = defaultTimeoutMs;
50
64
  this.label = label;
51
65
  this.hint = hint;
52
66
  this.secretProvider = secretProvider;
67
+ this.ledger = new OverdueLedger("bridge", peerNoun, deadlineKnob);
53
68
  }
54
69
  /** Register a listener for addon-pushed resource-change events. */
55
70
  onResourceChanged(cb) {
@@ -155,8 +170,16 @@ export class BridgeClient {
155
170
  return;
156
171
  }
157
172
  const id = msg.id;
158
- if (!id || !this.pending.has(id))
173
+ if (!id)
159
174
  return;
175
+ if (!this.pending.has(id)) {
176
+ // Not pending: either a reply we already gave up on (reconcile + log it), or
177
+ // a genuinely unknown id (the addon's auth reply, a stale frame) — ignored
178
+ // exactly as before. Ids are randomUUID() and never reused, so a late reply
179
+ // can only ever be its OWN request's; misattribution is impossible here.
180
+ this.ledger.reconcile(id, msg.ok === true);
181
+ return;
182
+ }
160
183
  const p = this.pending.get(id);
161
184
  this.pending.delete(id);
162
185
  clearTimeout(p.timer);
@@ -168,6 +191,13 @@ export class BridgeClient {
168
191
  p.reject(new BridgeError(e.code, e.message));
169
192
  }
170
193
  }
194
+ /**
195
+ * Snapshot of replies that arrived after their deadline, oldest first.
196
+ * Diagnostics only — nothing in the request path reads this.
197
+ */
198
+ recentLateReplies() {
199
+ return this.ledger.recent();
200
+ }
171
201
  onClose() {
172
202
  this.socket = null;
173
203
  for (const [, p] of this.pending) {
@@ -187,6 +217,11 @@ export class BridgeClient {
187
217
  return new Promise((resolve, reject) => {
188
218
  const timer = setTimeout(() => {
189
219
  this.pending.delete(id);
220
+ // Remember the id BEFORE rejecting, so a reply already in flight is
221
+ // reconciled rather than dropped as anonymous. The message keeps the
222
+ // exact `timed out after <n>ms` phrasing — tools/dap.ts:29 and
223
+ // tools/csdap.ts:31 branch on that substring.
224
+ this.ledger.note(id, method, timeoutMs);
190
225
  reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`));
191
226
  }, timeoutMs);
192
227
  this.pending.set(id, { resolve: resolve, reject, timer });
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import { parsePrivilegedGroups } from "./capabilities.js";
3
+ import { log } from "./logger.js";
3
4
  /**
4
5
  * A TCP port from the environment, falling back to `fallback` when the variable
5
6
  * is absent, empty, non-numeric or out of range.
@@ -25,6 +26,91 @@ function port(raw, fallback) {
25
26
  const n = Number(t);
26
27
  return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : fallback;
27
28
  }
29
+ /**
30
+ * The floor below which a deadline on a FRAME-POLLED bridge is premature *by
31
+ * construction* rather than by luck.
32
+ *
33
+ * Both addons — `bridge_server.gd` and `runtime_bridge.gd` — poll their socket
34
+ * from `_process` and dispatch synchronously, so they cannot answer faster than
35
+ * one frame no matter how trivial the request. Any deadline shorter than a frame
36
+ * period is guaranteed to fire before a reply is possible. The editor throttles
37
+ * its main loop when idle or unfocused, so "one frame" is not the 16 ms a running
38
+ * game would suggest; 250 ms is a frame at any plausible editor framerate, with
39
+ * headroom.
40
+ *
41
+ * Below the floor we FALL BACK to the default rather than clamp up to it,
42
+ * matching how `positiveInt` already treats `0` and negatives. A silently
43
+ * adjusted config value is its own small dishonesty: the operator asked for
44
+ * something unusable, and the honest answer is the documented default, said out
45
+ * loud.
46
+ *
47
+ * **This applies to the two bridge deadlines ONLY, and that boundary is the
48
+ * whole point.** LSP, DAP and the asset-gen backend are ordinary
49
+ * request/response over TCP or stdio — nothing frame-polls them, and they answer
50
+ * in microseconds. A 200 ms DAP deadline is a perfectly reasonable "fail fast",
51
+ * not a broken one; `csdap.test.ts:301` sets exactly that and is right to. The
52
+ * justification for this floor is the frame poll, so its scope is the frame poll.
53
+ *
54
+ * **And it does not replace late-reply reconciliation in `bridge.ts`, nor can
55
+ * it.** A *legitimate* 15000 ms deadline fails identically the moment a frame
56
+ * outlasts it, which `bridge_server.gd:96` documents happening on a `scene.save`
57
+ * that triggers a rescan/reimport. The floor removes the configured cause; the
58
+ * ledger catches the consequence whatever the cause.
59
+ */
60
+ export const MIN_TIMEOUT_MS = 250;
61
+ /**
62
+ * A deadline for a frame-polled bridge: `positiveInt`, then the floor.
63
+ * Used for `bridgeTimeoutMs` and `runtimeTimeoutMs` — the two values that reach
64
+ * a `BridgeClient` — and deliberately not for anything else.
65
+ */
66
+ function bridgeDeadlineMs(raw, fallback) {
67
+ const n = positiveInt(raw, fallback);
68
+ if (n >= MIN_TIMEOUT_MS)
69
+ return n;
70
+ log(`ignoring a ${n}ms bridge timeout: below the ${MIN_TIMEOUT_MS}ms floor, where the addon cannot answer ` +
71
+ `within one frame no matter what it is doing. Using the ${fallback}ms default instead.`);
72
+ return fallback;
73
+ }
74
+ /**
75
+ * A millisecond deadline from the environment, falling back to `fallback` when
76
+ * the variable is absent, empty, non-numeric, or not a usable positive integer.
77
+ *
78
+ * This is `port()`'s sibling, and it exists because the two were split for
79
+ * eleven releases. `port()` was hardened the moment a bad port began *refusing*
80
+ * a launch; every timeout kept the `Number.parseInt(x ?? "15000", 10)` pattern
81
+ * the docstring above condemns. `??` catches only null/undefined, so an
82
+ * exported-but-empty `GODOT_LSP_TIMEOUT_MS=""` — the same shape that motivated
83
+ * `port()` — reached `parseInt` and yielded `NaN`.
84
+ *
85
+ * **`setTimeout(cb, NaN)` does not throw. It fires on the next tick**,
86
+ * measurably sooner than `setTimeout(cb, 1)`, with no Node warning
87
+ * (`TimeoutOverflowWarning` covers only `> 2^31-1`). So the failure is worse
88
+ * than an unusable port, because by then the request is already on the wire:
89
+ * the addon polls its socket from `_process`, once per frame, and cannot answer
90
+ * inside ~1 ms. The deadline wins *deterministically*, the host reports
91
+ * `timed out after NaNms`, the addon still **executes** the mutation, and the
92
+ * real reply is dropped as an unknown id. An agent that retries a reported
93
+ * failure applies it twice.
94
+ *
95
+ * `parseInt` alone is not the fix here either, for the same reason it was not
96
+ * the fix for ports: it stops at the first non-digit, so a plausible `"15s"`
97
+ * silently becomes 15 ms and `"20_000"` becomes 20.
98
+ */
99
+ function positiveInt(raw, fallback) {
100
+ // Same shape as port(), mirroring GDScript's `is_valid_int()`: reject the
101
+ // whole string rather than accept a numeric prefix.
102
+ //
103
+ // Zero and negatives are rejected rather than clamped — a deadline of 0 is
104
+ // not a shorter deadline, it is the NaN failure with a different spelling.
105
+ // The upper bound is setTimeout's own: past 2^31-1 Node warns and silently
106
+ // uses 1 ms, so a fat-fingered BREAKPOINT_ASSETGEN_TIMEOUT_MS would land back
107
+ // in the near-zero failure this guard exists to prevent.
108
+ const t = (raw ?? "").trim();
109
+ if (!/^\+?\d+$/.test(t))
110
+ return fallback;
111
+ const n = Number(t);
112
+ return Number.isSafeInteger(n) && n > 0 && n <= 2_147_483_647 ? n : fallback;
113
+ }
28
114
  export function loadConfig() {
29
115
  const projectPath = process.env.GODOT_PROJECT ?? process.cwd();
30
116
  // The C# project defaults to the main project, but is usually pointed at a
@@ -36,20 +122,20 @@ export function loadConfig() {
36
122
  projectUri: pathToFileURL(projectPath).href,
37
123
  bridgeHost: process.env.BREAKPOINT_BRIDGE_HOST ?? "127.0.0.1",
38
124
  bridgePort: port(process.env.BREAKPOINT_BRIDGE_PORT, 9080),
39
- bridgeTimeoutMs: Number.parseInt(process.env.BREAKPOINT_BRIDGE_TIMEOUT_MS ?? "15000", 10),
125
+ bridgeTimeoutMs: bridgeDeadlineMs(process.env.BREAKPOINT_BRIDGE_TIMEOUT_MS, 15000),
40
126
  lspHost: process.env.GODOT_LSP_HOST ?? "127.0.0.1",
41
127
  lspPort: port(process.env.GODOT_LSP_PORT, 6005),
42
- lspTimeoutMs: Number.parseInt(process.env.GODOT_LSP_TIMEOUT_MS ?? "15000", 10),
128
+ lspTimeoutMs: positiveInt(process.env.GODOT_LSP_TIMEOUT_MS, 15000),
43
129
  csLspCmd: process.env.GODOT_CSLSP_CMD ?? "OmniSharp",
44
130
  csLspArgs: (process.env.GODOT_CSLSP_ARGS ?? "-lsp").split(/\s+/).filter(Boolean),
45
131
  csLspProjectPath,
46
132
  csLspProjectUri: pathToFileURL(csLspProjectPath).href,
47
- csLspTimeoutMs: Number.parseInt(process.env.GODOT_CSLSP_TIMEOUT_MS ?? "30000", 10),
133
+ csLspTimeoutMs: positiveInt(process.env.GODOT_CSLSP_TIMEOUT_MS, 30000),
48
134
  dapHost: process.env.GODOT_DAP_HOST ?? "127.0.0.1",
49
135
  dapPort: port(process.env.GODOT_DAP_PORT, 6006),
50
- dapTimeoutMs: Number.parseInt(process.env.GODOT_DAP_TIMEOUT_MS ?? "20000", 10),
51
- dapSetVarTimeoutMs: Number.parseInt(process.env.GODOT_DAP_SETVAR_TIMEOUT_MS ?? "8000", 10),
52
- dapEvaluateTimeoutMs: Number.parseInt(process.env.GODOT_DAP_EVALUATE_TIMEOUT_MS ?? "8000", 10),
136
+ dapTimeoutMs: positiveInt(process.env.GODOT_DAP_TIMEOUT_MS, 20000),
137
+ dapSetVarTimeoutMs: positiveInt(process.env.GODOT_DAP_SETVAR_TIMEOUT_MS, 8000),
138
+ dapEvaluateTimeoutMs: positiveInt(process.env.GODOT_DAP_EVALUATE_TIMEOUT_MS, 8000),
53
139
  csDapCmd: process.env.GODOT_CSDAP_CMD ?? "netcoredbg",
54
140
  csDapArgs: (process.env.GODOT_CSDAP_ARGS ?? "--interpreter=vscode").split(/\s+/).filter(Boolean),
55
141
  // The default program cs_dbg_launch launches is the Mono/.NET Godot binary. GODOT_CSHARP_BIN
@@ -57,17 +143,17 @@ export function loadConfig() {
57
143
  // caller can also override per-call via cs_dbg_launch's `program` arg.
58
144
  csDapProgram: process.env.GODOT_CSHARP_BIN ?? process.env.GODOT_BIN ?? "godot",
59
145
  csDapProjectPath: csLspProjectPath,
60
- csDapTimeoutMs: Number.parseInt(process.env.GODOT_CSDAP_TIMEOUT_MS ?? "20000", 10),
61
- csDapSetVarTimeoutMs: Number.parseInt(process.env.GODOT_CSDAP_SETVAR_TIMEOUT_MS ?? "8000", 10),
62
- csDapEvaluateTimeoutMs: Number.parseInt(process.env.GODOT_CSDAP_EVALUATE_TIMEOUT_MS ?? "8000", 10),
146
+ csDapTimeoutMs: positiveInt(process.env.GODOT_CSDAP_TIMEOUT_MS, 20000),
147
+ csDapSetVarTimeoutMs: positiveInt(process.env.GODOT_CSDAP_SETVAR_TIMEOUT_MS, 8000),
148
+ csDapEvaluateTimeoutMs: positiveInt(process.env.GODOT_CSDAP_EVALUATE_TIMEOUT_MS, 8000),
63
149
  runtimeHost: process.env.BREAKPOINT_RUNTIME_HOST ?? "127.0.0.1",
64
150
  runtimePort: port(process.env.BREAKPOINT_RUNTIME_PORT, 9081),
65
- runtimeTimeoutMs: Number.parseInt(process.env.BREAKPOINT_RUNTIME_TIMEOUT_MS ?? "15000", 10),
151
+ runtimeTimeoutMs: bridgeDeadlineMs(process.env.BREAKPOINT_RUNTIME_TIMEOUT_MS, 15000),
66
152
  // Group J: asset generation is OFF by default (backend "none" → tools degrade).
67
153
  assetGenBackend: process.env.BREAKPOINT_ASSETGEN_BACKEND ?? "none",
68
154
  assetGenCommand: process.env.BREAKPOINT_ASSETGEN_CMD ?? "",
69
155
  assetGenProvider: process.env.BREAKPOINT_ASSETGEN_PROVIDER ?? "",
70
- assetGenTimeoutMs: Number.parseInt(process.env.BREAKPOINT_ASSETGEN_TIMEOUT_MS ?? "120000", 10),
156
+ assetGenTimeoutMs: positiveInt(process.env.BREAKPOINT_ASSETGEN_TIMEOUT_MS, 120000),
71
157
  toolsets: parseToolsets(process.env.BREAKPOINT_TOOLSETS),
72
158
  privilegedGroups: parsePrivilegedGroups(process.env.BREAKPOINT_PRIVILEGED_GROUPS),
73
159
  };
package/dist/csdap.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { DapError } from "./dap.js";
3
+ import { OverdueLedger } from "./late-reply.js";
3
4
  /**
4
5
  * Minimal DAP client for the C#/.NET debugging plane (D4 C3) — the debugger
5
6
  * analogue of the C2 `CsLspClient`. Transport-agnostic: it drives any
@@ -28,6 +29,12 @@ export class CsDapClient extends EventEmitter {
28
29
  seq = 1;
29
30
  pending = new Map();
30
31
  breakpoints = new Map();
32
+ /**
33
+ * Seqs whose deadline fired, so a response arriving later is recognised, not
34
+ * dropped. `seq` is monotonic and is never reset, so a seq is never reused and
35
+ * a late response can only be its own request's.
36
+ */
37
+ ledger = new OverdueLedger("C# DAP", "the debug adapter", "GODOT_CSDAP_TIMEOUT_MS");
31
38
  configured = false;
32
39
  /** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
33
40
  watches = [];
@@ -58,8 +65,12 @@ export class CsDapClient extends EventEmitter {
58
65
  if (type === "response") {
59
66
  const reqSeq = msg["request_seq"];
60
67
  const p = this.pending.get(reqSeq);
61
- if (!p)
68
+ if (!p) {
69
+ // Not pending: either a response we already gave up on (reconcile + log
70
+ // the overshoot), or a genuinely unknown seq — ignored exactly as before.
71
+ this.ledger.reconcile(reqSeq, msg["success"] === true);
62
72
  return;
73
+ }
63
74
  this.pending.delete(reqSeq);
64
75
  clearTimeout(p.timer);
65
76
  if (msg["success"]) {
@@ -109,11 +120,21 @@ export class CsDapClient extends EventEmitter {
109
120
  break;
110
121
  }
111
122
  }
123
+ /**
124
+ * Snapshot of responses that arrived after their deadline, oldest first.
125
+ * Diagnostics only — nothing in the request path reads this.
126
+ */
127
+ recentLateReplies() {
128
+ return this.ledger.recent();
129
+ }
112
130
  request(command, args = {}, timeoutMs = this.timeoutMs) {
113
131
  const seq = this.seq++;
114
132
  return new Promise((resolve, reject) => {
115
133
  const timer = setTimeout(() => {
116
134
  this.pending.delete(seq);
135
+ // Remember the seq BEFORE rejecting, so a response already in flight is
136
+ // reconciled rather than dropped as anonymous.
137
+ this.ledger.note(seq, command, timeoutMs);
117
138
  reject(new DapError(command, `C# DAP '${command}' timed out after ${timeoutMs}ms`));
118
139
  }, timeoutMs);
119
140
  this.pending.set(seq, { command, resolve: resolve, reject, timer });
package/dist/cslsp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { LspError } from "./lsp.js";
3
+ import { OverdueLedger } from "./late-reply.js";
3
4
  import { packageVersion } from "./version.js";
4
5
  /**
5
6
  * Minimal LSP client for the C#/.NET semantic plane (D4 C2). Transport-agnostic:
@@ -25,6 +26,12 @@ export class CsLspClient {
25
26
  opened = new Set();
26
27
  diagnostics = new Map();
27
28
  diagWaiters = new Map();
29
+ /**
30
+ * Ids whose deadline fired, so a reply arriving later is recognised, not
31
+ * dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
32
+ * so an id is never reused and a late reply can only be its own request's.
33
+ */
34
+ ledger = new OverdueLedger("C# LSP", "the language server", "GODOT_CSLSP_TIMEOUT_MS");
28
35
  /** Absolute project root path (no trailing slash), used to canonicalize URIs. */
29
36
  rootFsPath;
30
37
  constructor(channel, rootUri, timeoutMs) {
@@ -71,8 +78,12 @@ export class CsLspClient {
71
78
  // Response to one of our requests.
72
79
  if (typeof id === "number" && method === undefined) {
73
80
  const p = this.pending.get(id);
74
- if (!p)
81
+ if (!p) {
82
+ // Not pending: either a reply we already gave up on (reconcile + log the
83
+ // overshoot), or a genuinely unknown id — ignored exactly as before.
84
+ this.ledger.reconcile(id, msg["error"] === undefined);
75
85
  return;
86
+ }
76
87
  this.pending.delete(id);
77
88
  clearTimeout(p.timer);
78
89
  if (msg["error"]) {
@@ -126,11 +137,21 @@ export class CsLspClient {
126
137
  this.serverCapabilities = null;
127
138
  this.opened.clear();
128
139
  }
140
+ /**
141
+ * Snapshot of replies that arrived after their deadline, oldest first.
142
+ * Diagnostics only — nothing in the request path reads this.
143
+ */
144
+ recentLateReplies() {
145
+ return this.ledger.recent();
146
+ }
129
147
  rawRequest(method, params, timeoutMs = this.timeoutMs) {
130
148
  const id = this.nextId++;
131
149
  return new Promise((resolve, reject) => {
132
150
  const timer = setTimeout(() => {
133
151
  this.pending.delete(id);
152
+ // Remember the id BEFORE rejecting, so a reply already in flight is
153
+ // reconciled rather than dropped as anonymous.
154
+ this.ledger.note(id, method, timeoutMs);
134
155
  reject(new LspError("timeout", `C# LSP '${method}' timed out after ${timeoutMs}ms`));
135
156
  }, timeoutMs);
136
157
  this.pending.set(id, { resolve: resolve, reject, timer });
package/dist/dap.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { FramedConnection } from "./framing.js";
3
+ import { OverdueLedger } from "./late-reply.js";
3
4
  export class DapError extends Error {
4
5
  command;
5
6
  constructor(command, message) {
@@ -19,6 +20,12 @@ export class DapClient extends EventEmitter {
19
20
  seq = 1;
20
21
  pending = new Map();
21
22
  breakpoints = new Map();
23
+ /**
24
+ * Seqs whose deadline fired, so a response arriving later is recognised, not
25
+ * dropped. `seq` is monotonic and is never reset, so a seq is never reused and
26
+ * a late response can only be its own request's.
27
+ */
28
+ ledger = new OverdueLedger("DAP", "the debug adapter", "GODOT_DAP_TIMEOUT_MS");
22
29
  configured = false;
23
30
  /** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
24
31
  watches = [];
@@ -50,8 +57,12 @@ export class DapClient extends EventEmitter {
50
57
  if (type === "response") {
51
58
  const reqSeq = msg["request_seq"];
52
59
  const p = this.pending.get(reqSeq);
53
- if (!p)
60
+ if (!p) {
61
+ // Not pending: either a response we already gave up on (reconcile + log
62
+ // the overshoot), or a genuinely unknown seq — ignored exactly as before.
63
+ this.ledger.reconcile(reqSeq, msg["success"] === true);
54
64
  return;
65
+ }
55
66
  this.pending.delete(reqSeq);
56
67
  clearTimeout(p.timer);
57
68
  if (msg["success"]) {
@@ -106,6 +117,9 @@ export class DapClient extends EventEmitter {
106
117
  return new Promise((resolve, reject) => {
107
118
  const timer = setTimeout(() => {
108
119
  this.pending.delete(seq);
120
+ // Remember the seq BEFORE rejecting, so a response already in flight is
121
+ // reconciled rather than dropped as anonymous.
122
+ this.ledger.note(seq, command, timeoutMs);
109
123
  reject(new DapError(command, `DAP '${command}' timed out after ${timeoutMs}ms`));
110
124
  }, timeoutMs);
111
125
  this.pending.set(seq, { command, resolve: resolve, reject, timer });
@@ -116,6 +130,13 @@ export class DapClient extends EventEmitter {
116
130
  });
117
131
  });
118
132
  }
133
+ /**
134
+ * Snapshot of responses that arrived after their deadline, oldest first.
135
+ * Diagnostics only — nothing in the request path reads this.
136
+ */
137
+ recentLateReplies() {
138
+ return this.ledger.recent();
139
+ }
119
140
  waitEvent(name, timeoutMs) {
120
141
  return new Promise((resolve) => {
121
142
  const timer = setTimeout(() => {
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { buildToolsets } from "./toolsets.js";
13
13
  import { registerRecipes } from "./recipes.js";
14
14
  import { applyOutputSchemas } from "./schemas.js";
15
15
  import { applyAnnotations } from "./annotations.js";
16
+ import { applyTimeoutCaveat } from "./timeout-caveat.js";
16
17
  import { applyCapabilities, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
17
18
  import { taskStore, TASK_CAPABILITIES } from "./tasks.js";
18
19
  import { RESOURCE_CAPABILITIES, registerResourceSubscriptions } from "./subscriptions.js";
@@ -21,7 +22,11 @@ import { log } from "./logger.js";
21
22
  async function main() {
22
23
  const config = loadConfig();
23
24
  const bridge = new BridgeClient(config.bridgeHost, config.bridgePort, config.bridgeTimeoutMs, "editor bridge", undefined, () => resolveBridgeSecret(config.projectPath, ["BREAKPOINT_BRIDGE_SECRET"]));
24
- const runtime = new BridgeClient(config.runtimeHost, config.runtimePort, config.runtimeTimeoutMs, "runtime bridge", "Is the project running? Launch it (godot_run_project or dbg_launch) with the Breakpoint MCP plugin enabled — it auto-registers the runtime autoload.", () => resolveBridgeSecret(config.projectPath, ["BREAKPOINT_RUNTIME_SECRET", "BREAKPOINT_BRIDGE_SECRET"]));
25
+ const runtime = new BridgeClient(config.runtimeHost, config.runtimePort, config.runtimeTimeoutMs, "runtime bridge", "Is the project running? Launch it (godot_run_project or dbg_launch) with the Breakpoint MCP plugin enabled — it auto-registers the runtime autoload.", () => resolveBridgeSecret(config.projectPath, ["BREAKPOINT_RUNTIME_SECRET", "BREAKPOINT_BRIDGE_SECRET"]),
26
+ // This instance's deadline comes from BREAKPOINT_RUNTIME_TIMEOUT_MS, not the
27
+ // editor bridge's variable — a late reply here must name the knob that can
28
+ // actually move it, and the peer that answered is the running game.
29
+ "BREAKPOINT_RUNTIME_TIMEOUT_MS", "the running game");
25
30
  const lsp = new LspClient(config.lspHost, config.lspPort, config.projectUri, config.lspTimeoutMs);
26
31
  // D4 C2: the C# semantic plane. OmniSharp is spawned over stdio (lazily, on the
27
32
  // first cs_* call) against the C# project root — so a host without OmniSharp
@@ -36,7 +41,7 @@ async function main() {
36
41
  // so long jobs (export/import/headless script) support poll/await/cancel.
37
42
  // D3: also advertise resources.subscribe so clients can subscribe to
38
43
  // godot://… resources and receive notifications/resources/updated.
39
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.26.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
44
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.27.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
40
45
  // B1: enforce frozen output schemas on every structured tool. Must run before
41
46
  // any register*Tools call — it wraps server.registerTool.
42
47
  applyOutputSchemas(server);
@@ -46,6 +51,15 @@ async function main() {
46
51
  // in applyCapabilities (below) + confirm.ts. Wraps registerTool after the
47
52
  // schema wrapper, before any register*Tools call.
48
53
  applyAnnotations(server);
54
+ // A bridge timeout does not mean nothing happened: the request is already on
55
+ // the wire when the deadline fires, and the addon polls once per frame, so the
56
+ // ordinary outcome is that the editor applies the change and answers late.
57
+ // Append a caveat scaled by the annotations just injected above — nothing for
58
+ // the read-only tools, "retrying is safe" for the idempotent ones, and
59
+ // "retrying may apply it a SECOND time" for those that are neither. Wraps the
60
+ // HANDLER (not the config), after applyAnnotations so the hints exist, before
61
+ // applyCapabilities so a dropped tool is never wrapped.
62
+ applyTimeoutCaveat(server);
49
63
  // Capability groups — a risk-based axis over the toolsets. Both `code-execution`
50
64
  // and `network` are OFF by default; a disabled group's tools are DROPPED at
51
65
  // registration (omitted from tools/list), so the secure-default surface is
@@ -0,0 +1,105 @@
1
+ import { log } from "./logger.js";
2
+ /**
3
+ * Bounds on the overdue ledger. Both are belt-and-braces: an id is normally
4
+ * evicted the moment its late reply lands, so the map is empty in steady state.
5
+ * These only matter for deadlines whose reply NEVER arrives (peer killed
6
+ * mid-request), which would otherwise accumulate one small record each.
7
+ */
8
+ export const OVERDUE_MAX = 64;
9
+ export const OVERDUE_MAX_AGE_MS = 5 * 60_000;
10
+ /** How many late replies to retain for `recent()`. Diagnostics only. */
11
+ export const LATE_REPLY_MAX = 32;
12
+ /**
13
+ * Per-client ledger of ids whose deadline fired, plus the late replies that
14
+ * later reconciled against them.
15
+ *
16
+ * Generic in the id type because the clients disagree on it and both are safe:
17
+ * `BridgeClient` correlates on `randomUUID()` strings, while the LSP/DAP family
18
+ * uses a monotonic per-instance counter (`nextId++` / `seq++`) that is never
19
+ * reset — not even by `onClose()`, which clears `pending` but deliberately
20
+ * leaves the counter alone. Either way an id is never reused within the
21
+ * instance that owns the ledger, so a late reply can only ever be its OWN
22
+ * request's and misattribution is impossible.
23
+ */
24
+ export class OverdueLedger {
25
+ kind;
26
+ peer;
27
+ knob;
28
+ overdue = new Map();
29
+ lateReplies = [];
30
+ /**
31
+ * @param kind names the channel in the log line ("bridge", "LSP", "C# DAP").
32
+ * @param peer what answered, as a noun phrase ("the editor", "the debug adapter").
33
+ * @param knob the env var that widens THIS client's deadline. Per-instance,
34
+ * not per-class: the editor and runtime bridges are both
35
+ * `BridgeClient` but are configured by different variables, and
36
+ * naming the wrong one sends the operator to a knob that cannot
37
+ * move the deadline they just hit.
38
+ */
39
+ constructor(kind, peer, knob) {
40
+ this.kind = kind;
41
+ this.peer = peer;
42
+ this.knob = knob;
43
+ }
44
+ /**
45
+ * Record that `id`'s deadline fired, so a reply arriving afterwards is
46
+ * recognisable rather than anonymous. Call ONLY from the timeout path — a
47
+ * request that was never written, or whose connection closed, can never be
48
+ * answered by the peer, so a "late reply" for it is not a thing.
49
+ */
50
+ note(id, method, deadlineMs) {
51
+ this.overdue.set(id, { method, deadlineMs, timedOutAt: Date.now() });
52
+ const cutoff = Date.now() - OVERDUE_MAX_AGE_MS;
53
+ for (const [k, v] of this.overdue) {
54
+ if (v.timedOutAt < cutoff)
55
+ this.overdue.delete(k);
56
+ }
57
+ // Map iterates in insertion order, so the first key is always the oldest.
58
+ while (this.overdue.size > OVERDUE_MAX) {
59
+ const oldest = this.overdue.keys().next();
60
+ if (oldest.done)
61
+ break;
62
+ this.overdue.delete(oldest.value);
63
+ }
64
+ }
65
+ /**
66
+ * A reply landed for an id that is no longer pending. If we timed that id out,
67
+ * this is the proof the deadline was premature — record it and SAY SO. The
68
+ * caller's promise is already settled and cannot be un-rejected; the value
69
+ * here is the overshoot number, which is exactly what the operator needs to
70
+ * fix the deadline, and which the client previously discarded in silence.
71
+ *
72
+ * @returns true if this was a reconciled late reply, false for a genuinely
73
+ * unknown id (a handshake ack, a stale frame) — ignored as ever.
74
+ */
75
+ reconcile(id, ok) {
76
+ const o = this.overdue.get(id);
77
+ if (!o)
78
+ return false;
79
+ this.overdue.delete(id);
80
+ const overshootMs = Date.now() - o.timedOutAt;
81
+ this.lateReplies.push({ method: o.method, deadlineMs: o.deadlineMs, overshootMs, ok });
82
+ if (this.lateReplies.length > LATE_REPLY_MAX)
83
+ this.lateReplies.shift();
84
+ log(`late ${this.kind} reply: '${o.method}' answered ${overshootMs}ms AFTER its ${o.deadlineMs}ms deadline — ` +
85
+ `the call ${ok ? `DID complete in ${this.peer}` : `reached ${this.peer} and failed there`}, ` +
86
+ `so the reported timeout was premature. Raise ${this.knob} above ${o.deadlineMs + overshootMs}ms.`);
87
+ return true;
88
+ }
89
+ /**
90
+ * Snapshot of replies that arrived after their deadline, oldest first.
91
+ * Diagnostics only — nothing in any request path reads this.
92
+ */
93
+ recent() {
94
+ return [...this.lateReplies];
95
+ }
96
+ /**
97
+ * How many timed-out ids are still awaiting a reply. Empty in steady state;
98
+ * exposed so a test can assert the ledger does not leak rather than trusting
99
+ * the bounds by inspection.
100
+ */
101
+ overdueSize() {
102
+ return this.overdue.size;
103
+ }
104
+ }
105
+ //# sourceMappingURL=late-reply.js.map
package/dist/lsp.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { FramedConnection } from "./framing.js";
3
3
  import { packageVersion } from "./version.js";
4
+ import { OverdueLedger } from "./late-reply.js";
4
5
  export class LspError extends Error {
5
6
  code;
6
7
  constructor(code, message) {
@@ -25,6 +26,12 @@ export class LspClient {
25
26
  opened = new Set();
26
27
  diagnostics = new Map();
27
28
  diagWaiters = new Map();
29
+ /**
30
+ * Ids whose deadline fired, so a reply arriving later is recognised, not
31
+ * dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
32
+ * so an id is never reused and a late reply can only be its own request's.
33
+ */
34
+ ledger = new OverdueLedger("LSP", "the language server", "GODOT_LSP_TIMEOUT_MS");
28
35
  /** Absolute project root path (no trailing slash), used to canonicalize URIs. */
29
36
  rootFsPath;
30
37
  constructor(host, port, rootUri, timeoutMs) {
@@ -73,8 +80,12 @@ export class LspClient {
73
80
  // Response to one of our requests.
74
81
  if (typeof id === "number" && method === undefined) {
75
82
  const p = this.pending.get(id);
76
- if (!p)
83
+ if (!p) {
84
+ // Not pending: either a reply we already gave up on (reconcile + log the
85
+ // overshoot), or a genuinely unknown id — ignored exactly as before.
86
+ this.ledger.reconcile(id, msg["error"] === undefined);
77
87
  return;
88
+ }
78
89
  this.pending.delete(id);
79
90
  clearTimeout(p.timer);
80
91
  if (msg["error"]) {
@@ -132,6 +143,9 @@ export class LspClient {
132
143
  return new Promise((resolve, reject) => {
133
144
  const timer = setTimeout(() => {
134
145
  this.pending.delete(id);
146
+ // Remember the id BEFORE rejecting, so a reply already in flight is
147
+ // reconciled rather than dropped as anonymous.
148
+ this.ledger.note(id, method, timeoutMs);
135
149
  reject(new LspError("timeout", `LSP '${method}' timed out after ${timeoutMs}ms`));
136
150
  }, timeoutMs);
137
151
  this.pending.set(id, { resolve: resolve, reject, timer });
@@ -179,6 +193,13 @@ export class LspClient {
179
193
  await this.ensureInitialized();
180
194
  return this.rawRequest(method, params, timeoutMs);
181
195
  }
196
+ /**
197
+ * Snapshot of replies that arrived after their deadline, oldest first.
198
+ * Diagnostics only — nothing in the request path reads this.
199
+ */
200
+ recentLateReplies() {
201
+ return this.ledger.recent();
202
+ }
182
203
  /**
183
204
  * The server's advertised capabilities from the `initialize` handshake (an
184
205
  * empty object if the server advertised none). Lets a tool feature-detect an
package/dist/stdio.js CHANGED
@@ -12,7 +12,10 @@ import { log } from "./logger.js";
12
12
  * connects lazily — so a host with no C# language server installed pays nothing
13
13
  * and never fails at startup; only a cs_* tool call actually launches it. A spawn
14
14
  * failure (e.g. the binary isn't on PATH) rejects the send with an actionable
15
- * hint rather than hanging.
15
+ * hint rather than hanging, and — like the TCP sibling's dropped connection —
16
+ * fires `onClose`, so a client caching an initialize handshake drops it: install
17
+ * the language server afterwards and the next cs_* call picks it up, with no
18
+ * host restart.
16
19
  */
17
20
  export class StdioChannel {
18
21
  command;
@@ -44,13 +47,18 @@ export class StdioChannel {
44
47
  return Promise.resolve(this.child);
45
48
  if (this.starting)
46
49
  return this.starting;
47
- this.starting = new Promise((resolve, reject) => {
50
+ let threwSynchronously = false;
51
+ const starting = new Promise((resolve, reject) => {
48
52
  let child;
49
53
  try {
50
54
  child = spawn(this.command, this.args, { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"] });
51
55
  }
52
56
  catch (err) {
53
- this.starting = null;
57
+ // Nulling `this.starting` here would be undone by the assignment below —
58
+ // the executor runs synchronously, BEFORE that assignment — so a synchronous
59
+ // throw (bad spawn options, not a missing binary) would cache the rejected
60
+ // promise forever. Flag it and let the assignment skip instead.
61
+ threwSynchronously = true;
54
62
  reject(new Error(`${this.label} could not spawn '${this.command}'. ${this.unavailableHint} (${err.message})`));
55
63
  return;
56
64
  }
@@ -75,12 +83,33 @@ export class StdioChannel {
75
83
  });
76
84
  child.on("exit", (code, signal) => {
77
85
  log(`${this.label} exited (code=${code ?? "null"} signal=${signal ?? "null"})`);
86
+ });
87
+ // Reset on `close`, NOT `exit` — the one event name that separated this from
88
+ // its TCP sibling, which hooks `close` and self-heals. A spawn failure (ENOENT:
89
+ // no OmniSharp on PATH) emits `error` + `close` and NEVER `exit`, so an
90
+ // exit-only hook never ran `closeCb()`, and the client's cached `initialized`
91
+ // promise stayed rejected for the process's lifetime: install the language
92
+ // server afterwards and every cs_* call still returned the stale spawn error
93
+ // until the host restarted. `close` is a strict superset — a process that
94
+ // really ran emits `spawn` -> `exit` -> `close` — so the normal path still
95
+ // notifies exactly once and nothing double-fires.
96
+ //
97
+ // Do NOT move this into the `error` handler to notify a turn sooner. `close`
98
+ // lands after the microtask that settles the rejected send, and that lateness
99
+ // is load-bearing: a client's onClose rejects every still-pending request with
100
+ // its own generic "connection closed", so notifying synchronously would
101
+ // overwrite the actionable `${this.unavailableHint}` above with it and the
102
+ // caller would never learn which binary to install. The heal is therefore one
103
+ // turn behind the rejection the caller sees, which no real second tool call
104
+ // can observe.
105
+ child.on("close", () => {
78
106
  this.child = null;
79
107
  this.decoder.reset();
80
108
  this.closeCb();
81
109
  });
82
110
  });
83
- return this.starting;
111
+ this.starting = threwSynchronously ? null : starting;
112
+ return starting;
84
113
  }
85
114
  async send(msg) {
86
115
  const child = await this.start();
@@ -0,0 +1,84 @@
1
+ import { annotationsFor } from "./annotations.js";
2
+ /**
3
+ * The marker identifying a bridge timeout envelope, built by
4
+ * `tools/editor/common.ts`'s `fail()`. Deliberately narrow: LSP and DAP timeouts
5
+ * are a different transport with a different failure mode (no editor-side
6
+ * mutation is implied), and `tools/dap.ts:29` / `tools/csdap.ts:31` branch on
7
+ * their own `/timed out after/` predicate, which this must not disturb.
8
+ */
9
+ const BRIDGE_TIMEOUT_MARK = "Bridge error [timeout]";
10
+ /** Mutating, but repeating it lands in the same state — retrying is safe. */
11
+ export const CAVEAT_IDEMPOTENT = "The request WAS delivered to the editor and may already have been applied. " +
12
+ "This tool is idempotent, so retrying is safe.";
13
+ /** Mutating and NOT idempotent — this is where a blind retry duplicates. */
14
+ export const CAVEAT_NON_IDEMPOTENT = "The request WAS delivered to the editor and may already have been applied. " +
15
+ "Retrying may apply it a SECOND time — verify the editor state before you do.";
16
+ /**
17
+ * The caveat a given tool's timeout envelope should carry, or null for the
18
+ * read-only tools that should carry none.
19
+ */
20
+ export function caveatFor(name) {
21
+ const a = annotationsFor(name);
22
+ if (a.readOnlyHint)
23
+ return null;
24
+ return a.idempotentHint ? CAVEAT_IDEMPOTENT : CAVEAT_NON_IDEMPOTENT;
25
+ }
26
+ /**
27
+ * Append `caveat` to the first text block carrying the bridge-timeout marker.
28
+ *
29
+ * APPENDS — never rewrites. The existing sentence, including its exact
30
+ * `timed out after <n>ms` phrasing, is load-bearing elsewhere. Anything that is
31
+ * not a bridge-timeout error envelope is returned untouched, by identity, so a
32
+ * tool that never speaks to the bridge is unaffected even though it is wrapped.
33
+ */
34
+ export function appendCaveat(result, caveat) {
35
+ const r = result;
36
+ if (!r || r.isError !== true || !Array.isArray(r.content))
37
+ return result;
38
+ let done = false;
39
+ const content = r.content.map((block) => {
40
+ if (done || block?.["type"] !== "text")
41
+ return block;
42
+ const text = block["text"];
43
+ if (typeof text !== "string" || !text.includes(BRIDGE_TIMEOUT_MARK))
44
+ return block;
45
+ done = true;
46
+ return { ...block, text: `${text} ${caveat}` };
47
+ });
48
+ return done ? { ...r, content } : result;
49
+ }
50
+ /**
51
+ * Wrap `server.registerTool` so a bridge-timeout envelope leaves with a caveat
52
+ * matching the tool's own annotations.
53
+ *
54
+ * Mirrors `applyOutputSchemas` / `applyAnnotations` / `applyCapabilities`; call
55
+ * once, AFTER `applyAnnotations` and BEFORE `applyCapabilities`, so a tool
56
+ * dropped by a disabled capability group is never wrapped at all.
57
+ *
58
+ * Read-only tools are not wrapped — not wrapped-and-inert, actually not wrapped —
59
+ * so 92 of the 289 handlers keep their exact previous identity.
60
+ */
61
+ export function applyTimeoutCaveat(server) {
62
+ const inject = (raw) => (name, config, handler) => {
63
+ const caveat = caveatFor(name);
64
+ if (!caveat || typeof handler !== "function")
65
+ return raw(name, config, handler);
66
+ const inner = handler;
67
+ const wrapped = (...args) => {
68
+ const out = inner(...args);
69
+ // Preserve synchronicity: only a promise-returning handler gets a
70
+ // promise back, so nothing's timing changes just by being wrapped.
71
+ return out instanceof Promise ? out.then((v) => appendCaveat(v, caveat)) : appendCaveat(out, caveat);
72
+ };
73
+ return raw(name, config, wrapped);
74
+ };
75
+ const s = server;
76
+ s.registerTool = inject(s.registerTool.bind(server));
77
+ // The D2 task-model tools register through experimental.tasks.registerToolTask,
78
+ // not registerTool — same reason applyAnnotations covers both paths.
79
+ const tasks = s.experimental?.tasks;
80
+ if (tasks?.registerToolTask) {
81
+ tasks.registerToolTask = inject(tasks.registerToolTask.bind(tasks));
82
+ }
83
+ }
84
+ //# sourceMappingURL=timeout-caveat.js.map
@@ -95,6 +95,31 @@ function resourceVariant(cls, path) {
95
95
  }
96
96
  const ALIGN_TO_ENUM = { left: 0, center: 1, right: 2 };
97
97
  // ------------------------------------------------------ column expressions ----
98
+ /**
99
+ * The `{placeholder}` scanner, shared by `resolveColumnExpr` and
100
+ * `columnExprPlaceholders` so the two can never disagree about what counts as a
101
+ * placeholder. Both drive it through `String.replace`, which resets `lastIndex`
102
+ * when it finishes, so the shared `/g` instance carries no state between calls.
103
+ */
104
+ const COLUMN_PLACEHOLDER = /\{([^}]*)\}/g;
105
+ /**
106
+ * The column names an expression references, WITHOUT needing a row to resolve
107
+ * against. `resolveColumnExpr` reports the same set, but only as a side effect of
108
+ * resolving a row that exists — so a deck whose filter selected zero rows would
109
+ * otherwise report every mapped column as unmapped. Malformed placeholders are
110
+ * skipped here and still raised by `resolveColumnExpr` on the first real row.
111
+ * Pure — unit-tested.
112
+ */
113
+ export function columnExprPlaceholders(expr) {
114
+ const columns = [];
115
+ expr.replace(COLUMN_PLACEHOLDER, (_full, rawName) => {
116
+ const name = rawName.trim();
117
+ if (name !== "")
118
+ columns.push(name);
119
+ return "";
120
+ });
121
+ return columns;
122
+ }
98
123
  /**
99
124
  * Resolve a `card_deck_from_table` column expression against one row. A value is
100
125
  * either a bare `{column}` or a composed template like `{name} · {role}`; every
@@ -106,7 +131,7 @@ const ALIGN_TO_ENUM = { left: 0, center: 1, right: 2 };
106
131
  */
107
132
  export function resolveColumnExpr(expr, row) {
108
133
  const columns = [];
109
- const value = expr.replace(/\{([^}]*)\}/g, (_full, rawName) => {
134
+ const value = expr.replace(COLUMN_PLACEHOLDER, (_full, rawName) => {
110
135
  const name = rawName.trim();
111
136
  if (name === "")
112
137
  throw new ComposeError("bad_params", `Empty {} placeholder in column expression ${JSON.stringify(expr)}`);
@@ -534,12 +559,21 @@ export async function emitDeckFromTable(emit, readFile, args) {
534
559
  for (const r of allRows)
535
560
  for (const k of Object.keys(r))
536
561
  header.add(k);
537
- // which columns are actually referenced (by a placeholder, art, or filter)?
562
+ // Which columns are actually referenced (by a placeholder, art, or filter)?
563
+ // All three come off the ARGUMENTS, never off the selected rows. Reading the
564
+ // column_map inside the stamping loop below made this asymmetric: art_column and
565
+ // filter.column counted as referenced whatever the filter matched, while the
566
+ // mapped slots only counted once a row existed to resolve them against — so a
567
+ // filter matching zero rows reported every explicitly mapped column as unmapped.
538
568
  const referenced = new Set();
539
569
  if (args.art_column)
540
570
  referenced.add(args.art_column);
541
571
  if (args.filter)
542
572
  referenced.add(args.filter.column);
573
+ for (const expr of Object.values(args.column_map)) {
574
+ for (const c of columnExprPlaceholders(expr))
575
+ referenced.add(c);
576
+ }
543
577
  // select rows: filter → limit.
544
578
  let selected = args.filter
545
579
  ? allRows.filter((r) => String(r[args.filter.column] ?? "") === String(args.filter.equals))
@@ -552,9 +586,7 @@ export async function emitDeckFromTable(emit, readFile, args) {
552
586
  const row = selected[i];
553
587
  const data = {};
554
588
  for (const [slot, expr] of Object.entries(args.column_map)) {
555
- const { value, columns } = resolveColumnExpr(expr, row);
556
- for (const c of columns)
557
- referenced.add(c);
589
+ const { value } = resolveColumnExpr(expr, row);
558
590
  data[slot] = value;
559
591
  }
560
592
  if (args.art_column && row[args.art_column])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.26.0",
3
+ "version": "1.27.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",