breakpoint-mcp 1.76.0 โ†’ 1.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js CHANGED
@@ -120,6 +120,34 @@ export class BridgeClient {
120
120
  this.secretProvider = secretProvider;
121
121
  this.ledger = new OverdueLedger("bridge", peerNoun, deadlineKnob);
122
122
  }
123
+ /**
124
+ * A cause this client cannot see, supplied by whoever CAN, as the remedy on a timeout.
125
+ *
126
+ * ๐Ÿ”ด WHY THIS EXISTS AT ALL โ€” the eighth session of "the host's own failures carry no
127
+ * remedy" (254 ยง4.6 โ†’ 261 ยง4.4), answered for the one case a walk actually produced.
128
+ * `BridgeError.remedy` was documented as "the addon's to give, and a host-raised
129
+ * timeout arrives without one". True for a closed socket. NOT true here, and USER_GUIDE
130
+ * ยง10 B's own step 5 walks the reader straight into it: the addon services `runtime_*`
131
+ * from `_process`, so a breakpoint inside the method being called halts the very frame
132
+ * that owes the reply. Measured โ€” the stop's stack trace is `compute` on top of
133
+ * `_call_method` โ†’ `_dispatch` โ†’ `_handle_line` โ†’ `_drain_lines` โ†’ `_process`, five
134
+ * frames of the addon's own dispatch โ€” and the caller got
135
+ * `Bridge request 'runtime.call_method' timed out after 15000ms` and nothing else,
136
+ * EVERY TIME THE RECIPE WORKED. The deadlock is a fact about the debugger, which lives
137
+ * in the host, three modules away from the socket that gave up.
138
+ *
139
+ * ๐Ÿ”ด A HOOK RATHER THAN A CONSTRUCTOR PARAMETER, and that is not laziness: nine `.mjs`
140
+ * integration probes construct these registrars directly where TypeScript cannot see
141
+ * them, and 257 spent six red CI jobs learning what adding a parameter costs. A probe
142
+ * that never sets a probe gets exactly today's behaviour.
143
+ *
144
+ * Wired on the RUNTIME client only. A game halted at a breakpoint does not hold the
145
+ * editor bridge, and the sentence would be a confident lie on plane A.
146
+ */
147
+ setHoldProbe(probe) {
148
+ this.holdProbe = probe;
149
+ }
150
+ holdProbe = null;
123
151
  /** Register a listener for addon-pushed resource-change events. */
124
152
  onResourceChanged(cb) {
125
153
  this.eventListeners.add(cb);
@@ -325,7 +353,11 @@ export class BridgeClient {
325
353
  // exact `timed out after <n>ms` phrasing โ€” tools/dap.ts:29 and
326
354
  // tools/csdap.ts:31 branch on that substring.
327
355
  this.ledger.note(id, method, timeoutMs);
328
- reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`));
356
+ // ๐Ÿ”ด THE ONE HOST-RAISED FAILURE THAT HAS A KNOWABLE CAUSE (262 ยง2). Everything
357
+ // else this class raises is genuinely opaque from here โ€” a closed socket, a peer
358
+ // that never answered โ€” but a timeout on the RUNTIME bridge has one cause the host
359
+ // can check for nothing: its own debugger holding the game. See `setHoldProbe`.
360
+ reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`, this.holdProbe?.() ?? undefined));
329
361
  }, timeoutMs);
330
362
  this.pending.set(id, { resolve: resolve, reject, timer, method });
331
363
  socket.write(payload, (err) => {
package/dist/confirm.js CHANGED
@@ -50,14 +50,53 @@ export async function gate(server, confirm, summary) {
50
50
  content: [{ type: "text", text: `Cancelled โ€” user did not approve: ${summary}` }],
51
51
  };
52
52
  }
53
- catch {
53
+ catch (err) {
54
+ // ๐Ÿ”ด 261 โ€” ONE `catch` WAS COVERING THREE DIFFERENT CAUSES AND NAMING THE THIRD.
55
+ //
56
+ // Measured at 261 against the published 1.76.0: a client that DECLARES elicitation
57
+ // and then answers with content that does not satisfy `requestedSchema` gets
58
+ // "interactive confirmation isn't available on this client" โ€” a sentence about the
59
+ // client's CAPABILITIES, printed because of a bad ANSWER โ€” followed by the one
60
+ // remedy that skips the confirmation the user was in the middle of giving. That is
61
+ // 260's standing rule ("a diagnostic that names a state is not a diagnosis of its
62
+ // cause") landing on the safety control itself, and the misdiagnosis points at the
63
+ // bypass, which is the wrong direction for a gate to fail in.
64
+ //
65
+ // The capability is knowable, so ask instead of inferring it from a throw. Only the
66
+ // genuinely unsupported client is told to re-run with `confirm: true`; a client that
67
+ // CAN ask and failed is told the attempt failed and why, because the next action
68
+ // there is to fix the answer, not to bypass the prompt.
69
+ const supportsElicitation = (() => {
70
+ try {
71
+ return !!server.server.getClientCapabilities()?.elicitation;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ })();
77
+ if (!supportsElicitation) {
78
+ return {
79
+ isError: true,
80
+ content: [
81
+ {
82
+ type: "text",
83
+ text: `This is a destructive action (${summary}) and interactive confirmation ` +
84
+ `isn't available on this client. Re-run the tool with confirm: true to proceed.`,
85
+ },
86
+ ],
87
+ };
88
+ }
89
+ const detail = err instanceof Error ? err.message : String(err);
54
90
  return {
55
91
  isError: true,
56
92
  content: [
57
93
  {
58
94
  type: "text",
59
- text: `This is a destructive action (${summary}) and interactive confirmation ` +
60
- `isn't available on this client. Re-run the tool with confirm: true to proceed.`,
95
+ text: `This is a destructive action (${summary}) and NOTHING WAS DONE. This client ` +
96
+ `declares elicitation support, so the confirmation prompt was sent and the ` +
97
+ `attempt failed: ${detail}. Fix the client's elicitation response (it must be ` +
98
+ `{"action":"accept","content":{"proceed":true}}) and retry โ€” or, if you mean to ` +
99
+ `skip the prompt deliberately, re-run with confirm: true.`,
61
100
  },
62
101
  ],
63
102
  };
package/dist/csdap.js CHANGED
@@ -53,10 +53,61 @@ export class CsDapClient extends EventEmitter {
53
53
  * being emitted as an unlistened `error` event (which throws).
54
54
  */
55
55
  startFailure = null;
56
+ /**
57
+ * Whether a launch/attach this client sent was accepted. Tracked SEPARATELY from
58
+ * `state` for the reason `DapClient` tracks it separately, measured here on this
59
+ * plane against a real netcoredbg: `cs_dbg_continue` on a client that had never
60
+ * launched anything left `state = "running"` behind โ€” `resume()` assigns it
61
+ * optimistically before it waits โ€” so every call after it read as a live session.
62
+ * A guard built on `state` alone would be poisoned by the very call it exists to
63
+ * refuse.
64
+ */
65
+ sessionStarted = false;
56
66
  capabilities = null;
57
67
  state = "disconnected";
58
68
  lastStoppedThreadId = null;
59
69
  lastStoppedReason = null;
70
+ /**
71
+ * Whether a launch/attach the adapter accepted is still in force. The `cs_dbg_*`
72
+ * tools that can only answer from a live session consult this instead of asking
73
+ * netcoredbg and reporting whatever came back.
74
+ *
75
+ * ๐Ÿ”ด Measured against a real netcoredbg 3.2.0-1092 on a client that had never
76
+ * launched anything: seven of the nine readers answered with a raw hex code โ€”
77
+ * `Failed command 'stackTrace' : 0x80004005` (E_FAIL), `0x80070057` (E_INVALIDARG)
78
+ * โ€” `cs_dbg_set_variable` reported "the debug adapter reported a failure with no
79
+ * message", and `cs_dbg_watch` answered `isError:false` with every entry carrying
80
+ * `error: "error: 0x80004005"`. Only `cs_dbg_restart` refused, and only because it
81
+ * happens to read `lastStartMode`. That is the same single exception session 259
82
+ * measured one plane over, for the same accidental reason.
83
+ */
84
+ get hasSession() {
85
+ return this.sessionStarted && this.state !== "terminated" && this.state !== "disconnected";
86
+ }
87
+ /**
88
+ * Whether the adapter has reported a stop, read through a call so TypeScript's
89
+ * control-flow narrowing does not collapse it โ€” `state` is mutated by the event
90
+ * handler across every `await` in `start()`, so a literal comparison after an
91
+ * assignment is (wrongly) an impossible one to tsc. `DapClient.stoppedNow()`
92
+ * carries the same note for the same reason.
93
+ */
94
+ stoppedNow() {
95
+ return this.state === "stopped";
96
+ }
97
+ /**
98
+ * Whether there is a FRAME to answer from โ€” the public half of `stoppedNow()`.
99
+ *
100
+ * ๐Ÿ”ด `hasSession` IS NOT THIS, and session 262 spent a whole session on the
101
+ * difference on the GDScript plane. Measured here with a session live and the
102
+ * program merely RUNNING: `stackTrace` 0x80070057, `scopes` / `variables` /
103
+ * `step` 0x80004005, `evaluate` 0x80070057, `set_variable` blaming the adapter,
104
+ * `watch` an `isError:false` answer whose every entry carried a hex code, and
105
+ * `cs_dbg_continue` waiting the full 15 s to answer `{"state":"running"}` โ€”
106
+ * about 45 s of adapter round trips for a question answerable here in none.
107
+ */
108
+ get isStopped() {
109
+ return this.stoppedNow();
110
+ }
60
111
  constructor(channel, timeoutMs) {
61
112
  super();
62
113
  this.channel = channel;
@@ -65,6 +116,7 @@ export class CsDapClient extends EventEmitter {
65
116
  this.channel.onClose((cause) => {
66
117
  this.state = "terminated";
67
118
  this.configured = false;
119
+ this.sessionStarted = false;
68
120
  const detail = cause ? ` (${cause.message})` : "";
69
121
  for (const [, p] of this.pending) {
70
122
  clearTimeout(p.timer);
@@ -262,6 +314,7 @@ export class CsDapClient extends EventEmitter {
262
314
  async start(mode, args) {
263
315
  this.lastStartMode = mode;
264
316
  this.lastStartArgs = args;
317
+ this.sessionStarted = false;
265
318
  // Listen for `initialized` before we ask, so we cannot miss it.
266
319
  const onInit = this.waitEvent("initialized", Math.min(this.timeoutMs, 5000));
267
320
  this.capabilities = await this.request("initialize", {
@@ -287,6 +340,9 @@ export class CsDapClient extends EventEmitter {
287
340
  const startReq = this.request(mode, args).then(() => undefined, (err) => {
288
341
  this.startFailure = err;
289
342
  this.configured = false;
343
+ // A rejection arriving after `start()` returned ends the session, so the
344
+ // tools' session guard refuses from then on rather than reading `running`.
345
+ this.sessionStarted = false;
290
346
  this.state = "terminated";
291
347
  // A DISTINCT event name, deliberately: `error` is special-cased by
292
348
  // EventEmitter and throws when unlistened. This one is safe to ignore.
@@ -313,6 +369,7 @@ export class CsDapClient extends EventEmitter {
313
369
  const fatal = this.startFailure ?? (configureDoneAdvertised ? configureFailure : null);
314
370
  if (fatal) {
315
371
  this.configured = false;
372
+ this.sessionStarted = false;
316
373
  this.state = "terminated";
317
374
  void startReq;
318
375
  const detail = fatal?.message ?? String(fatal);
@@ -321,6 +378,7 @@ export class CsDapClient extends EventEmitter {
321
378
  `${JSON.stringify(args).slice(0, 200)}).`);
322
379
  }
323
380
  this.configured = true;
381
+ this.sessionStarted = true;
324
382
  // ๐Ÿ”ด ONLY from `initialized`, never unconditionally. The adapter can emit `stopped`
325
383
  // (or `terminated`) between the configurationDone RESPONSE and this line, and the
326
384
  // event handler has already moved the state โ€” a blind `= "running"` clobbers it and
@@ -430,6 +488,7 @@ export class CsDapClient extends EventEmitter {
430
488
  this.channel.close();
431
489
  this.state = "disconnected";
432
490
  this.configured = false;
491
+ this.sessionStarted = false;
433
492
  }
434
493
  }
435
494
  //# sourceMappingURL=csdap.js.map
package/dist/dap.js CHANGED
@@ -113,6 +113,23 @@ export class DapClient extends EventEmitter {
113
113
  stoppedNow() {
114
114
  return this.state === "stopped";
115
115
  }
116
+ /**
117
+ * Whether there is a FRAME to answer from โ€” the public half of `stoppedNow()`.
118
+ *
119
+ * ๐Ÿ”ด `hasSession` IS NOT THIS, AND THE DIFFERENCE IS A WHOLE FAMILY OF DEFECTS (262 ยง1).
120
+ * The `dbg_*` tools consulted `hasSession` โ€” *did a launch succeed* โ€” and then asked the
121
+ * adapter for a stack, a scope, a variable or a step. With a session live and the program
122
+ * RUNNING, measured against a real 4.7 adapter, that produced eight different answers to
123
+ * one question: `{"frames":[]}` and `{"scopes":[]}` (empty successes), a `dbg_watch` entry
124
+ * carrying a fabricated `error:"timeout"` after 5 s, a 15 s wait per `dbg_step` /
125
+ * `dbg_continue` ending in `{"state":"running"}`, and a `dbg_set_variable` refusal blaming
126
+ * the user's Godot build. ~48 s of adapter round trips for a question answerable here in
127
+ * none. A session existing is a PROXY for a frame existing, and the two part company the
128
+ * moment the program is not at a stop โ€” 261's rule, one plane over.
129
+ */
130
+ get isStopped() {
131
+ return this.stoppedNow();
132
+ }
116
133
  constructor(host, port, timeoutMs) {
117
134
  super();
118
135
  this.timeoutMs = timeoutMs;
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { CsLspClient } from "./cslsp.js";
9
9
  import { CsDapClient } from "./csdap.js";
10
10
  import { StdioChannel } from "./stdio.js";
11
11
  import { DapClient } from "./dap.js";
12
+ import { DEBUGGER_HOLD_REMEDY } from "./remedies.js";
12
13
  import { fullUsage } from "./cli/usage.js";
13
14
  import { buildToolsets } from "./toolsets.js";
14
15
  import { registerRecipes } from "./recipes.js";
@@ -36,6 +37,11 @@ async function main() {
36
37
  // installed starts and runs the other planes unaffected.
37
38
  const csLsp = new CsLspClient(new StdioChannel(config.csLspCmd, config.csLspArgs, config.csLspProjectPath, "C# LSP (OmniSharp)", "Is OmniSharp installed and on PATH (or set GODOT_CSLSP_CMD to its binary), and GODOT_CSHARP_PROJECT pointed at a restored C# project?"), config.csLspProjectUri, config.csLspTimeoutMs);
38
39
  const dap = new DapClient(config.dapHost, config.dapPort, config.dapTimeoutMs);
40
+ // ๐Ÿ”ด THE ONE PLACE BOTH HALVES OF THE DEADLOCK ARE IN SCOPE (262 ยง2). The runtime
41
+ // bridge cannot see the debugger and the debugger cannot see the bridge; index.ts holds
42
+ // both, so the join is made here and nowhere else. Wired on `runtime` alone โ€” the game
43
+ // being stopped says nothing about the editor bridge on plane A.
44
+ runtime.setHoldProbe(() => (dap.isStopped ? DEBUGGER_HOLD_REMEDY : undefined));
39
45
  // D4 C3: the C# debugging plane. netcoredbg is spawned over stdio (lazily, on
40
46
  // the first cs_dbg_* call) โ€” so a host without netcoredbg installed starts and
41
47
  // runs every other plane unaffected.
@@ -44,7 +50,7 @@ async function main() {
44
50
  // so long jobs (export/import/headless script) support poll/await/cancel.
45
51
  // D3: also advertise resources.subscribe so clients can subscribe to
46
52
  // godot://โ€ฆ resources and receive notifications/resources/updated.
47
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.76.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
53
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
48
54
  // 208 ยง7.1 โ€” delete from the wire what the protocol already says: the SDK's
49
55
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
50
56
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
package/dist/remedies.js CHANGED
@@ -60,4 +60,22 @@ export function remedyForWireError(code, fromWire) {
60
60
  return fromWire;
61
61
  return HOST_FALLBACK_REMEDIES[code];
62
62
  }
63
+ /**
64
+ * The next action when the runtime bridge went quiet because OUR debugger stopped the game.
65
+ *
66
+ * ๐Ÿ”ด NOT A ROW IN THE TABLE ABOVE, and the distinction is the whole reason this file's
67
+ * one-row ceiling can survive a second sentence being added to the module. That table is
68
+ * keyed by a CODE THE ADDON RAISES โ€” check 28 joins every key to an `_err(..)` site on an
69
+ * engine plane, and a `timeout` key would have no such site to join to, because nothing in
70
+ * the engine raised it. This is the opposite population: a failure the HOST invents when a
71
+ * peer says nothing, keyed on a piece of state only the host holds. Keyed on a code it
72
+ * would be wrong โ€” most `timeout`s have nothing to do with the debugger โ€” so it is emitted
73
+ * only when `dap.isStopped` is true at the moment the deadline fires.
74
+ *
75
+ * Written to the same grammar check 28 enforces next door, because it reaches the same
76
+ * reader through the same clause: one imperative at the head, backticked names that resolve,
77
+ * a full stop at the end.
78
+ */
79
+ export const DEBUGGER_HOLD_REMEDY = "Release the game with `dbg_continue` โ€” it is stopped at a breakpoint, and the addon answers `runtime_*` calls from `_process`, " +
80
+ "so the frame that owes this reply cannot run until execution resumes.";
63
81
  //# sourceMappingURL=remedies.js.map
@@ -105,6 +105,67 @@ const CS_DAP_PATHS = {
105
105
  };
106
106
  export function registerCsDapTools(server, dap, cfg) {
107
107
  const root = cfg.csDapProjectPath;
108
+ /**
109
+ * Refuse when no launch/attach the adapter accepted is in force.
110
+ *
111
+ * ๐Ÿ”ด Measured against a real netcoredbg 3.2.0-1092 on a client that had never
112
+ * launched anything. Nine readers, and the only one that refused did so by
113
+ * accident:
114
+ *
115
+ * ```
116
+ * cs_dbg_stack_trace 11ms Failed command 'stackTrace' : 0x80004005
117
+ * cs_dbg_scopes 1ms Failed command 'scopes' : 0x80004005
118
+ * cs_dbg_variables 1ms Failed command 'variables' : 0x80004005
119
+ * cs_dbg_evaluate 1ms error: 0x80004005
120
+ * cs_dbg_watch 1ms isError:FALSE, every entry error "error: 0x80004005"
121
+ * cs_dbg_set_variable 1ms "the debug adapter reported a failure with no message"
122
+ * cs_dbg_step 1ms Failed command 'next' : 0x80004005
123
+ * cs_dbg_continue 0ms Failed command 'continue' : 0x80004005
124
+ * cs_dbg_restart 1ms refuses โ€” because it reads `lastStartMode`
125
+ * ```
126
+ *
127
+ * `0x80004005` is E_FAIL and `0x80070057` is E_INVALIDARG: hex for "no", naming
128
+ * nothing the caller can act on and pointing at the adapter for a state the host
129
+ * already knew. The refusal names the two ways to open a session instead.
130
+ */
131
+ const requireSession = (tool) => {
132
+ if (dap.hasSession)
133
+ return;
134
+ refuse(`${tool} needs a C# debug session: call cs_dbg_launch or cs_dbg_attach first. ` +
135
+ `There is none, so any state, frame or scope reported here would be invented โ€” ` +
136
+ `this call previously answered with the adapter's raw failure code.`, "no_session");
137
+ };
138
+ /**
139
+ * Refuse when the session is live but the program is not AT A STOP.
140
+ *
141
+ * ๐Ÿ”ด THE SAME PROXY, ONE PLANE OVER โ€” session 262 ยง1's whole finding, ported after
142
+ * measuring it here rather than assumed. A session existing is a proxy for a frame
143
+ * existing, and the two part company the moment the program runs on. Measured with
144
+ * a launched session and nothing stopped:
145
+ *
146
+ * ```
147
+ * cs_dbg_stack_trace 2ms Failed command 'stackTrace' : 0x80070057
148
+ * cs_dbg_scopes 1ms Failed command 'scopes' : 0x80004005
149
+ * cs_dbg_variables 1ms Failed command 'variables' : 0x80004005
150
+ * cs_dbg_evaluate 1ms error: 0x80070057
151
+ * cs_dbg_set_variable 1ms "the debug adapter reported a failure with no message"
152
+ * cs_dbg_step 1ms Failed command 'next' : 0x80004005
153
+ * cs_dbg_continue 15,000ms isError:FALSE {"state":"running"}
154
+ * ```
155
+ *
156
+ * ๐Ÿ”ด RAISED BEFORE THE ADAPTER ROUND TRIP, which is most of the point: that 15 s โ€”
157
+ * with `cs_dbg_restart`'s 30 s beside it โ€” is time the caller spent being told
158
+ * nothing, for a question `dap.isStopped` answers in none. The refusal names the
159
+ * state it READ rather than the state it assumed, and names both ways to reach a
160
+ * stop, because "not stopped" is not itself an instruction.
161
+ */
162
+ const requireStopped = (tool) => {
163
+ if (dap.isStopped)
164
+ return;
165
+ refuse(`${tool} needs the program stopped at a breakpoint: the C# debug session is live but the program is ${dap.state}. ` +
166
+ `Arm a line with cs_dbg_set_breakpoints and trigger that code path, or step from a stop you already have โ€” ` +
167
+ `nothing here can be read from a program that is still running.`, "not_stopped");
168
+ };
108
169
  /**
109
170
  * Resolve a breakpoint source path and REFUSE one that cannot carry a breakpoint.
110
171
  *
@@ -272,6 +333,8 @@ export function registerCsDapTools(server, dap, cfg) {
272
333
  inputSchema: {},
273
334
  }, async () => {
274
335
  try {
336
+ requireSession("cs_dbg_continue");
337
+ requireStopped("cs_dbg_continue");
275
338
  const r = await dap.resume("continue", { threadId: dap.threadId() }, RESUME_WAIT_MS);
276
339
  return ok({ state: r.state, stopped_reason: r.reason });
277
340
  }
@@ -286,6 +349,8 @@ export function registerCsDapTools(server, dap, cfg) {
286
349
  inputSchema: { kind: z.enum(["in", "over", "out"]).describe("Step kind") },
287
350
  }, async ({ kind }) => {
288
351
  try {
352
+ requireSession("cs_dbg_step");
353
+ requireStopped("cs_dbg_step");
289
354
  const command = kind === "in" ? "stepIn" : kind === "out" ? "stepOut" : "next";
290
355
  const r = await dap.resume(command, { threadId: dap.threadId() }, RESUME_WAIT_MS);
291
356
  return ok({ state: r.state, stopped_reason: r.reason });
@@ -300,6 +365,8 @@ export function registerCsDapTools(server, dap, cfg) {
300
365
  inputSchema: { levels: z.number().int().positive().optional().describe("Max frames (default 20)") },
301
366
  }, async ({ levels }) => {
302
367
  try {
368
+ requireSession("cs_dbg_stack_trace");
369
+ requireStopped("cs_dbg_stack_trace");
303
370
  const body = await dap.request("stackTrace", { threadId: dap.threadId(), startFrame: 0, levels: levels ?? 20 });
304
371
  const frames = Array.isArray(body["stackFrames"])
305
372
  ? body["stackFrames"].map((f) => ({
@@ -318,6 +385,8 @@ export function registerCsDapTools(server, dap, cfg) {
318
385
  inputSchema: { frame_id: z.number().int().describe("Frame id from cs_dbg_stack_trace") },
319
386
  }, async ({ frame_id }) => {
320
387
  try {
388
+ requireSession("cs_dbg_scopes");
389
+ requireStopped("cs_dbg_scopes");
321
390
  const body = await dap.request("scopes", { frameId: frame_id });
322
391
  const scopes = Array.isArray(body["scopes"])
323
392
  ? body["scopes"].map((s) => ({ name: s.name ?? "", variables_ref: s.variablesReference ?? 0 }))
@@ -334,6 +403,8 @@ export function registerCsDapTools(server, dap, cfg) {
334
403
  inputSchema: { variables_ref: z.number().int().describe("variablesReference from cs_dbg_scopes or a parent variable") },
335
404
  }, async ({ variables_ref }) => {
336
405
  try {
406
+ requireSession("cs_dbg_variables");
407
+ requireStopped("cs_dbg_variables");
337
408
  const body = await dap.request("variables", { variablesReference: variables_ref });
338
409
  const variables = Array.isArray(body["variables"])
339
410
  ? body["variables"].map((v) => ({
@@ -357,6 +428,11 @@ export function registerCsDapTools(server, dap, cfg) {
357
428
  },
358
429
  }, async ({ expression, frame_id, confirm }) => {
359
430
  try {
431
+ // ๐Ÿ”ด BEFORE the confirmation prompt, not after. Asking the operator to approve
432
+ // an evaluate that cannot run โ€” and that previously answered `0x80070057` when
433
+ // they approved it โ€” spends their attention on a call already known to fail.
434
+ requireSession("cs_dbg_evaluate");
435
+ requireStopped("cs_dbg_evaluate");
360
436
  const blocked = await gate(server, confirm, `Evaluate C# expression in the running game: ${expression}`);
361
437
  if (blocked)
362
438
  return blocked;
@@ -393,6 +469,11 @@ export function registerCsDapTools(server, dap, cfg) {
393
469
  },
394
470
  }, async ({ variables_ref, name, value, confirm }) => {
395
471
  try {
472
+ // Before the capability read as well as before the prompt: `capabilities` is
473
+ // null until `initialize`, so with no session the check below is a question
474
+ // asked of nobody.
475
+ requireSession("cs_dbg_set_variable");
476
+ requireStopped("cs_dbg_set_variable");
396
477
  if (dap.capabilities && dap.capabilities["supportsSetVariable"] === false) {
397
478
  return {
398
479
  isError: true,
@@ -436,12 +517,37 @@ export function registerCsDapTools(server, dap, cfg) {
436
517
  },
437
518
  }, async ({ add, remove, clear, frame_id }) => {
438
519
  try {
520
+ requireSession("cs_dbg_watch");
521
+ // ๐Ÿ”ด THE ONE READER HERE NOT REFUSED WHILE THE PROGRAM RUNS, ON PURPOSE โ€” the
522
+ // same deliberate divergence session 262 made on the GDScript plane, for the
523
+ // same reason. `cs_dbg_watch` is two things at once: it MANAGES a persistent
524
+ // set and it evaluates that set, and this tool's own description teaches
525
+ // arming the set and re-reading it "after a step/continue". Refusing the
526
+ // mutation while the program runs would refuse the documented workflow.
527
+ // The set change is applied; only the VALUES are unavailable, and the entry's
528
+ // own `error` field says which.
439
529
  if (clear)
440
530
  dap.clearWatches();
441
531
  if (remove && remove.length)
442
532
  dap.removeWatches(remove);
443
533
  if (add && add.length)
444
534
  dap.addWatches(add);
535
+ if (!dap.isStopped) {
536
+ // ๐Ÿ”ด What this replaces was worse than empty. Measured against a real
537
+ // netcoredbg with no session and again with the program running: an
538
+ // `isError:false` answer whose every entry carried `error: "error:
539
+ // 0x80004005"` โ€” a hex code offered to the caller as the reason their watch
540
+ // has no value. The reason is that the program is not stopped, the host
541
+ // knows it, and it costs no round trip to say so.
542
+ return ok({
543
+ watches: dap.listWatches().map((expression) => ({
544
+ expression,
545
+ value: "",
546
+ type: "",
547
+ error: `not stopped (${dap.state}) โ€” a watch is evaluated in a stopped frame, so values arrive at the next stop`,
548
+ })),
549
+ });
550
+ }
445
551
  // Bound each watch's evaluate to the short deadline (mirrors cs_dbg_evaluate): a watch
446
552
  // the adapter never answers fails fast on that entry instead of hanging the full DAP
447
553
  // timeout at every stop, while other watches still resolve normally.
@@ -468,6 +574,21 @@ export function registerCsDapTools(server, dap, cfg) {
468
574
  // Per the DAP spec a client should only send setExceptionBreakpoints when the adapter
469
575
  // advertised at least one exception filter. Short-circuit with a clear "unsupported"
470
576
  // message otherwise, matching the GDScript dbg_set_exception_breakpoints discipline.
577
+ // ๐Ÿ”ด "THE CONNECTED ADAPTER" MUST FIRST BE ONE. `capabilities` is null until
578
+ // `initialize`, so with no session this read found nothing and the tool told
579
+ // the caller their debugger advertises no exception filters. Measured against
580
+ // a real netcoredbg 3.2.0-1092: with no session it answered "unsupported by
581
+ // the connected C# debug adapter (it advertises no exceptionBreakpointFilters)"
582
+ // โ€” while that same adapter advertises `all` and `user-unhandled`, which the
583
+ // cs-dap cohort asserts on every CI leg. A capability read is a question for
584
+ // somebody, and there was nobody. Session 262 ยง2 fixed this shape one plane
585
+ // over, where the message blamed the user's engine BUILD; here it blamed their
586
+ // debugger for a handshake that never happened.
587
+ //
588
+ // NOT `requireStopped`: exception filters are armed for the FUTURE, so a live
589
+ // running session is a legitimate caller โ€” the same call `dbg_data_breakpoints`
590
+ // gets on the GDScript plane, and for the same reason.
591
+ requireSession("cs_dbg_set_exception_breakpoints");
471
592
  const advertised = dap.capabilities?.["exceptionBreakpointFilters"];
472
593
  const available_filters = Array.isArray(advertised)
473
594
  ? advertised.map((f) => ({ filter: f.filter ?? "", label: f.label ?? "" }))
package/dist/tools/dap.js CHANGED
@@ -166,6 +166,41 @@ export function registerDapTools(server, dap, cfg) {
166
166
  `There is none, so any state, frame or scope reported here would be invented โ€” ` +
167
167
  `this call previously answered as if the program were running.`, "no_session");
168
168
  };
169
+ /**
170
+ * Refuse when the session is live but the program is not AT A STOP.
171
+ *
172
+ * ๐Ÿ”ด THE HALF `requireSession` COULD NOT SEE, AND IT IS THE SAME MISTAKE ONE LAYER IN.
173
+ * That guard asks whether a launch succeeded; every tool below then asked the adapter
174
+ * for a frame. A session existing is a proxy for a frame existing, and the proxy stops
175
+ * holding the instant the program runs on. Measured against a live 4.7 adapter with a
176
+ * launched session and nothing stopped โ€” nine tools, eight different answers, none of
177
+ * them "the program is running":
178
+ *
179
+ * ```
180
+ * dbg_stack_trace 4ms {"frames":[]} empty success
181
+ * dbg_scopes 8ms {"scopes":[]} empty success
182
+ * dbg_variables 10ms DAP error [variables]: unknown the adapter's word for it
183
+ * dbg_watch 5,016ms error:"timeout" per expression a fabricated cause
184
+ * dbg_evaluate 5,019ms DAP error [evaluate]: timeout a fabricated cause
185
+ * dbg_set_variable 8,004ms "this Godot build (e.g. 4.3)โ€ฆ" blames the user's engine
186
+ * dbg_step 15,004ms {"state":"running"} a success that waited
187
+ * dbg_continue 15,003ms {"state":"running"} a success that waited
188
+ * ```
189
+ *
190
+ * ๐Ÿ”ด RAISED BEFORE THE ADAPTER ROUND TRIP, which is most of the point: that column of
191
+ * timeouts is ~48 s the caller waited to be told nothing, for a question `dap.isStopped`
192
+ * answers in none. `dbg_launch`'s scene guard is placed the same way and for the same
193
+ * reason. The refusal names the state it read rather than the state it assumed โ€” 260's
194
+ * rule โ€” and names the two ways to reach a stop, because "not stopped" is not itself an
195
+ * instruction.
196
+ */
197
+ const requireStopped = (tool) => {
198
+ if (dap.isStopped)
199
+ return;
200
+ throw new DapRefusal(`${tool} needs the program stopped at a breakpoint: the debug session is live but the program is ${dap.state}. ` +
201
+ `Arm a line with dbg_set_breakpoints and trigger that code path, or step from a stop you already have โ€” ` +
202
+ `nothing here can be read from a program that is still running.`, "not_stopped");
203
+ };
169
204
  /**
170
205
  * Report, on a launch/attach result, any breakpoint modifier the handshake dropped
171
206
  * when it applied the buffered breakpoints.
@@ -376,11 +411,13 @@ export function registerDapTools(server, dap, cfg) {
376
411
  server.registerTool("dbg_continue", {
377
412
  title: "Continue",
378
413
  description: "Resume execution and wait for the program to settle again (next breakpoint or termination). " +
379
- "Returns the resulting state; if it runs on with no further breakpoint, reports state 'running'.",
414
+ "Returns the resulting state; if it runs on with no further breakpoint, reports state 'running'. " +
415
+ "Requires a stop; a running program is refused (`not_stopped`).",
380
416
  inputSchema: {},
381
417
  }, async () => {
382
418
  try {
383
419
  requireSession("dbg_continue");
420
+ requireStopped("dbg_continue");
384
421
  const r = await dap.resume("continue", { threadId: dap.threadId() }, RESUME_WAIT_MS);
385
422
  return ok({ state: r.state, stopped_reason: r.reason });
386
423
  }
@@ -391,11 +428,13 @@ export function registerDapTools(server, dap, cfg) {
391
428
  server.registerTool("dbg_step", {
392
429
  title: "Step",
393
430
  description: "Step execution: 'over' (next), 'in' (stepIn), or 'out' (stepOut), then wait for the step to land. " +
394
- "Returns the resulting state and stop reason. Note: stepOut may be unsupported on older Godot builds.",
431
+ "Returns the resulting state and stop reason. Note: stepOut may be unsupported on older Godot builds. " +
432
+ "Requires a stop; a running program is refused (`not_stopped`).",
395
433
  inputSchema: { kind: z.enum(["in", "over", "out"]).describe("Step kind") },
396
434
  }, async ({ kind }) => {
397
435
  try {
398
436
  requireSession("dbg_step");
437
+ requireStopped("dbg_step");
399
438
  const command = kind === "in" ? "stepIn" : kind === "out" ? "stepOut" : "next";
400
439
  const r = await dap.resume(command, { threadId: dap.threadId() }, RESUME_WAIT_MS);
401
440
  return ok({ state: r.state, stopped_reason: r.reason });
@@ -406,11 +445,13 @@ export function registerDapTools(server, dap, cfg) {
406
445
  });
407
446
  server.registerTool("dbg_stack_trace", {
408
447
  title: "Stack trace",
409
- description: "Return the current call stack (only meaningful while stopped at a breakpoint).",
448
+ description: "Return the current call stack. Requires a stop; a running program is refused (`not_stopped`) rather than " +
449
+ "answered with the empty frame list it used to return.",
410
450
  inputSchema: { levels: z.number().int().positive().optional().describe("Max frames (default 20)") },
411
451
  }, async ({ levels }) => {
412
452
  try {
413
453
  requireSession("dbg_stack_trace");
454
+ requireStopped("dbg_stack_trace");
414
455
  const body = await dap.request("stackTrace", { threadId: dap.threadId(), startFrame: 0, levels: levels ?? 20 });
415
456
  const frames = Array.isArray(body["stackFrames"])
416
457
  ? body["stackFrames"].map((f) => ({
@@ -425,11 +466,13 @@ export function registerDapTools(server, dap, cfg) {
425
466
  });
426
467
  server.registerTool("dbg_scopes", {
427
468
  title: "Scopes",
428
- description: "Return the variable scopes (Locals, Members, Globals) for a stack frame.",
469
+ description: "Return the variable scopes (Locals, Members, Globals) for a stack frame. Requires a stop; a running program is " +
470
+ "refused (`not_stopped`) rather than answered with an empty list.",
429
471
  inputSchema: { frame_id: z.number().int().describe("Frame id from dbg_stack_trace") },
430
472
  }, async ({ frame_id }) => {
431
473
  try {
432
474
  requireSession("dbg_scopes");
475
+ requireStopped("dbg_scopes");
433
476
  const body = await dap.request("scopes", { frameId: frame_id });
434
477
  const scopes = Array.isArray(body["scopes"])
435
478
  ? body["scopes"].map((s) => ({ name: s.name ?? "", variables_ref: s.variablesReference ?? 0 }))
@@ -442,11 +485,13 @@ export function registerDapTools(server, dap, cfg) {
442
485
  });
443
486
  server.registerTool("dbg_variables", {
444
487
  title: "Variables",
445
- description: "List variables under a scope or a complex value (via its variables_ref).",
488
+ description: "List variables under a scope or a complex value (via its variables_ref). Requires a stop (`not_stopped`) โ€” a " +
489
+ "reference is only valid within the stop that issued it.",
446
490
  inputSchema: { variables_ref: z.number().int().describe("variablesReference from dbg_scopes or a parent variable") },
447
491
  }, async ({ variables_ref }) => {
448
492
  try {
449
493
  requireSession("dbg_variables");
494
+ requireStopped("dbg_variables");
450
495
  const body = await dap.request("variables", { variablesReference: variables_ref });
451
496
  const variables = Array.isArray(body["variables"])
452
497
  ? body["variables"].map((v) => ({
@@ -461,7 +506,8 @@ export function registerDapTools(server, dap, cfg) {
461
506
  });
462
507
  server.registerTool("dbg_evaluate", {
463
508
  title: "Evaluate expression",
464
- description: "Evaluate a GDScript expression in the context of a stopped frame. DESTRUCTIVE: arbitrary code execution โ€” confirm with the user and keep this capability gated.",
509
+ description: "Evaluate a GDScript expression in the context of a stopped frame. DESTRUCTIVE: arbitrary code execution โ€” confirm with the user and keep this capability gated. " +
510
+ "Requires a stop (`not_stopped`), checked before the confirmation prompt.",
465
511
  inputSchema: {
466
512
  expression: z.string().describe("GDScript expression to evaluate"),
467
513
  frame_id: z.number().int().optional().describe("Frame id (from dbg_stack_trace); omit for the top frame"),
@@ -471,7 +517,11 @@ export function registerDapTools(server, dap, cfg) {
471
517
  try {
472
518
  // Before the gate on purpose: the old order prompted the operator to approve
473
519
  // arbitrary code execution against a session that had never been started.
520
+ // ๐Ÿ”ด And before it for the second reason too โ€” a program that is running has no
521
+ // frame to evaluate in, so the prompt was asking the operator to approve code
522
+ // execution that could only end in the adapter's 5 s silence.
474
523
  requireSession("dbg_evaluate");
524
+ requireStopped("dbg_evaluate");
475
525
  const blocked = await gate(server, confirm, `Evaluate expression in the running game: ${expression}`);
476
526
  if (blocked)
477
527
  return blocked;
@@ -513,12 +563,34 @@ export function registerDapTools(server, dap, cfg) {
513
563
  }, async ({ add, remove, clear, frame_id }) => {
514
564
  try {
515
565
  requireSession("dbg_watch");
566
+ // ๐Ÿ”ด THE ONE TOOL HERE THAT IS NOT REFUSED WHEN THE PROGRAM RUNS, ON PURPOSE (262 ยง1).
567
+ // `dbg_watch` is two things at once: it MANAGES a persistent set and it evaluates that
568
+ // set. ยง10 B step 6's documented use โ€” "dbg_watch for an expression across stops" โ€” is
569
+ // to arm the set once and re-read it at each stop, so refusing the mutation while the
570
+ // program runs would refuse the workflow the guide teaches. The set change is applied;
571
+ // only the VALUES are unavailable, and the entry's own `error` field says which โ€” the
572
+ // shape already carries a per-expression reason, so this needs no new key and, unlike
573
+ // what it replaces, costs no adapter round trip.
516
574
  if (clear)
517
575
  dap.clearWatches();
518
576
  if (remove && remove.length)
519
577
  dap.removeWatches(remove);
520
578
  if (add && add.length)
521
579
  dap.addWatches(add);
580
+ if (!dap.isStopped) {
581
+ // ๐Ÿ”ด What this replaces was worse than empty: each expression came back with
582
+ // `error: "timeout"` after the adapter sat on it for the full evaluate deadline โ€”
583
+ // 5 s of waiting for a fabricated cause. The program was never stopped; the adapter
584
+ // had nothing to answer from and said so by saying nothing.
585
+ return ok({
586
+ watches: dap.listWatches().map((expression) => ({
587
+ expression,
588
+ value: "",
589
+ type: "",
590
+ error: `not stopped (${dap.state}) โ€” a watch is evaluated in a stopped frame, so values arrive at the next stop`,
591
+ })),
592
+ });
593
+ }
522
594
  // Bound each watch's evaluate to the short deadline (mirrors dbg_evaluate): a watch
523
595
  // expression the adapter never answers fails fast on that entry instead of hanging the
524
596
  // full dapTimeoutMs (20 s) at every stop, while other watches still resolve normally.
@@ -582,6 +654,7 @@ export function registerDapTools(server, dap, cfg) {
582
654
  try {
583
655
  // Before the gate and the caps check on purpose โ€” see dbg_evaluate.
584
656
  requireSession("dbg_set_variable");
657
+ requireStopped("dbg_set_variable");
585
658
  // Feature-detect: some debug adapters don't implement setVariable. If the
586
659
  // adapter explicitly advertised it as unsupported, say so plainly instead
587
660
  // of prompting for a confirmation and then failing.
@@ -594,10 +667,18 @@ export function registerDapTools(server, dap, cfg) {
594
667
  const blocked = await gate(server, confirm, `Set variable ${name} = ${value} in the running game`);
595
668
  if (blocked)
596
669
  return blocked;
597
- // Godot 4.3 advertises supportsSetVariable=true (so the caps short-circuit above does
598
- // not fire) but never answers the setVariable request. Caps can't detect that โ€” 4.3
599
- // lies โ€” so bound the request to a short deadline and, on timeout, say plainly that the
600
- // build does not implement setVariable rather than emitting the generic 20 s DAP timeout.
670
+ // Godot advertises supportsSetVariable=true (so the caps short-circuit above does not
671
+ // fire) and never answers the setVariable request. Caps can't detect that โ€” the adapter
672
+ // lies โ€” so bound the request to a short deadline and, on timeout, say plainly that this
673
+ // adapter does not implement setVariable rather than emitting the generic 20 s DAP timeout.
674
+ //
675
+ // ๐Ÿ”ด THE SENTENCE USED TO BLAME THE READER'S ENGINE, AND IT WAS MEASURABLY WRONG (262 ยง3).
676
+ // It read "this Godot build (e.g. 4.3) does not implement setVariable", written when 4.3
677
+ // was the build in hand. Measured twice at a REAL stop on **4.7** โ€” the newest build there
678
+ // is โ€” the request is advertised and still unanswered, so the only sentence this tool has
679
+ // ever emitted told a current-build user they were behind. The naming is now the adapter,
680
+ // not the reader's version: no `dbg_*` caller can act on "upgrade", because there is
681
+ // nothing to upgrade to.
601
682
  let body;
602
683
  try {
603
684
  body = await dap.request("setVariable", { variablesReference: variables_ref, name, value }, cfg.dapSetVarTimeoutMs);
@@ -606,7 +687,7 @@ export function registerDapTools(server, dap, cfg) {
606
687
  if (isDapTimeout(err)) {
607
688
  return {
608
689
  isError: true,
609
- content: [{ type: "text", text: `The debug adapter advertises supportsSetVariable but did not answer the setVariable request within ${cfg.dapSetVarTimeoutMs}ms โ€” this Godot build (e.g. 4.3) does not implement setVariable. No change was made; the variable is unchanged. Read-only inspection (dbg_variables) still works.` }],
690
+ content: [{ type: "text", text: `The debug adapter advertises supportsSetVariable but did not answer the setVariable request within ${cfg.dapSetVarTimeoutMs}ms โ€” Godot's GDScript debug adapter does not implement it (measured unanswered on 4.3 and on 4.7, at a real stop). No change was made; the variable is unchanged. Read-only inspection (dbg_variables) still works, and dbg_evaluate can read the value.` }],
610
691
  };
611
692
  }
612
693
  throw err;
@@ -678,6 +759,13 @@ export function registerDapTools(server, dap, cfg) {
678
759
  },
679
760
  }, async ({ path, line, target_id, confirm }) => {
680
761
  try {
762
+ // Before the caps check, the same order `dbg_set_variable` uses: a guard that
763
+ // holds on every adapter outranks one that reports this adapter's answer. 262
764
+ // added the pair here โ€” `dbg_goto` had NEITHER, and "unreachable behind the
765
+ // capability gate" is the same argument the escape guard above was written
766
+ // against three sessions earlier.
767
+ requireSession("dbg_goto");
768
+ requireStopped("dbg_goto");
681
769
  if (!dap.capabilities || dap.capabilities["supportsGotoTargetsRequest"] !== true) {
682
770
  return {
683
771
  isError: true,
@@ -742,6 +830,14 @@ export function registerDapTools(server, dap, cfg) {
742
830
  },
743
831
  }, async ({ watch }) => {
744
832
  try {
833
+ // ๐Ÿ”ด SESSION ONLY, NOT STOPPED โ€” and the asymmetry with `dbg_goto` above is the
834
+ // point rather than an oversight. `goto` moves the program counter *within the
835
+ // current stopped frame*, which is meaningless without one. A data breakpoint is
836
+ // ARMED for the future: a `variables_ref` naming a live scope does need a stop,
837
+ // but a bare global `name` resolves without one, and refusing the whole tool
838
+ // would refuse the case that works. Each entry already reports its own
839
+ // `unresolved` reason, which is where a ref that needed a frame says so.
840
+ requireSession("dbg_data_breakpoints");
745
841
  if (!dap.capabilities || dap.capabilities["supportsDataBreakpoints"] !== true) {
746
842
  return {
747
843
  isError: true,
@@ -28,6 +28,41 @@ import { fail } from "./common.js";
28
28
  * frame; the tab tools only give the caller somewhere to go when it bites.
29
29
  */
30
30
  const MIN_RENDERED_VIEWPORT_PX = 8;
31
+ /**
32
+ * ๐Ÿ”ด 261 โ€” THE SIZE GUARD ABOVE FIRES ONCE PER EDITOR SESSION AND THEN NEVER AGAIN,
33
+ * AND WHAT IT STOPS PROTECTING AGAINST IS WORSE THAN WHAT IT CATCHES.
34
+ *
35
+ * Measured at 261 on Godot 4.7, driving the published `breakpoint-mcp@1.76.0` as a
36
+ * client against a live editor. Godot collapses a main-screen tab's viewport to 2x2
37
+ * only while that tab has **never been activated**. Visit it once โ€” which is step 5
38
+ * of the User Guide's own scene-authoring recipe โ€” and the viewport keeps its full
39
+ * size for the rest of the session while it is hidden. It also stops updating:
40
+ *
41
+ * tab 2D, capture 2d -> 2214x1809 md5 4dcacd9ac2ce
42
+ * tab 3D, capture 2d -> 2214x1809 md5 4dcacd9ac2ce (same frame, now hidden)
43
+ * add a 900x700 magenta ColorRect to the scene, still on the 3D tab
44
+ * tab 3D, capture 2d -> 2214x1809 md5 4dcacd9ac2ce ๐Ÿ”ด UNCHANGED โ€” frozen
45
+ * tab 2D, capture 2d -> 2214x1809 md5 b2075a8606e7 the rect is there
46
+ *
47
+ * So after the first tab switch every capture of the inactive viewport is a
48
+ * SUCCESS carrying a stale frame, and `MIN_RENDERED_VIEWPORT_PX` cannot see it,
49
+ * because the size is the one thing that did not change. An assistant reads that
50
+ * image as the current scene and edits against a picture of the past.
51
+ *
52
+ * The size threshold is a proxy for a question the editor can answer directly โ€”
53
+ * *is the viewport I am capturing the one that is on screen?* โ€” so ask that
54
+ * instead, and keep the size check underneath it as the second line for the
55
+ * never-visited case that reaches this code with a matching tab.
56
+ *
57
+ * ๐Ÿ”ด THE UNIT TEST THAT PINS THE SIZE GUARD FEEDS A MOCKED 2x2 THROUGH A FAKE
58
+ * BRIDGE. It is a true test of the branch and it is structurally unable to observe
59
+ * the engine behaviour the branch is a model of โ€” which is why this survived from
60
+ * 1.30.0 to 1.76.0 with a green suite over it the whole way.
61
+ *
62
+ * Best-effort by construction: an addon too old to answer `main_screen.get`
63
+ * (pre-1.9.3) degrades to the size check alone rather than losing the capture.
64
+ */
65
+ const VIEWPORT_TAB = { "2d": "2D", "3d": "3D" };
31
66
  /** Editor selection, ClassDB / docs lookups, and viewport screenshot. */
32
67
  export function registerIntrospectionTools(server, call, bridge) {
33
68
  server.registerTool("selection_get", { title: "Get selection", description: "Return the paths of the nodes currently selected in the editor.", inputSchema: {} }, async () => call("selection.get"));
@@ -98,26 +133,46 @@ export function registerIntrospectionTools(server, call, bridge) {
98
133
  server.registerTool("screenshot_editor", {
99
134
  title: "Screenshot editor viewport",
100
135
  description: "Capture the 2D or 3D editor viewport as a PNG and return it as image content so the assistant can see the scene. " +
101
- "Requires the matching editor tab (2D/3D) to be active: Godot collapses the inactive tab's viewport to a few " +
102
- "pixels, and this tool returns a viewport_not_rendered error rather than that placeholder frame. " +
103
- "A fresh editor is on the 3D tab and opening a scene does NOT switch it, so to capture 2d reliably call " +
104
- 'main_screen_set {"name":"2D"} first โ€” or main_screen_get to see which tab is active.',
136
+ "The requested viewport's tab must be the ACTIVE main-screen tab, and this tool refuses with " +
137
+ "viewport_not_active when it is not: a hidden tab's viewport either collapses to a few pixels (before its " +
138
+ "first visit) or keeps rendering the frame it had when it was hidden (after), and the second case is a " +
139
+ "full-size stale image that looks exactly like a good capture. " +
140
+ 'A fresh editor is on the 3D tab and opening a scene does NOT switch it, so to capture 2d call ' +
141
+ 'main_screen_set {"name":"2D"} first AND pass {"viewport":"2d"} โ€” the default is 3d, so switching the tab ' +
142
+ "alone captures the tab you just left. main_screen_get reports which tab is active.",
105
143
  inputSchema: { viewport: z.enum(["2d", "3d"]).optional().describe("Which viewport (default 3d)") },
106
144
  }, async ({ viewport }) => {
107
145
  try {
108
- const r = (await bridge.request("screenshot.editor_viewport", { viewport: viewport ?? "3d" }));
146
+ const want = viewport ?? "3d";
147
+ // ๐Ÿ”ด 261 โ€” ASK THE EDITOR WHICH TAB IS ON SCREEN BEFORE READING A TEXTURE.
148
+ // The size check below cannot separate "hidden and frozen" from "visible and
149
+ // unchanged", because after a tab's first visit those two states have the same
150
+ // dimensions. This one can, and it is the question the caller actually means.
151
+ // Degrades on an addon too old to answer, rather than refusing the capture.
152
+ let activeTab;
153
+ try {
154
+ const s = (await bridge.request("main_screen.get", {}));
155
+ if (s?.active)
156
+ activeTab = s.active;
157
+ }
158
+ catch { /* pre-1.9.3 addon โ€” fall through to the size check alone */ }
159
+ if (activeTab && VIEWPORT_TAB[want] && activeTab.toUpperCase() !== VIEWPORT_TAB[want]) {
160
+ return fail({
161
+ code: "viewport_not_active",
162
+ message: `The ${want} editor viewport is not on screen โ€” the editor is on the "${activeTab}" tab. Godot keeps a ` +
163
+ `hidden tab's viewport alive but stops drawing to it, so capturing it now returns the frame it held when ` +
164
+ `it was hidden, at full size and with no sign that it is stale. Call main_screen_set ` +
165
+ `{"name":"${VIEWPORT_TAB[want]}"} and retry, or capture the active tab with ` +
166
+ `screenshot_editor {"viewport":"${activeTab.toLowerCase()}"}.`,
167
+ });
168
+ }
169
+ const r = (await bridge.request("screenshot.editor_viewport", { viewport: want }));
109
170
  if (r.width < MIN_RENDERED_VIEWPORT_PX || r.height < MIN_RENDERED_VIEWPORT_PX) {
110
171
  // Name the tab that is ACTUALLY active rather than leaving the caller to infer
111
- // it from a 2x2. Best-effort: this error must survive an addon too old to answer
112
- // (main_screen.* is 1.9.3+), so a failure here degrades the message instead of
113
- // replacing a useful "not rendered" error with an unrelated bridge error.
114
- let active;
115
- try {
116
- const s = (await bridge.request("main_screen.get", {}));
117
- if (s?.active)
118
- active = s.active;
119
- }
120
- catch { /* older addon, or no main-screen container โ€” fall through */ }
172
+ // it from a 2x2. 261: the read now happens ABOVE, before the capture, so this
173
+ // arm reuses it โ€” an addon too old to answer leaves it undefined and the message
174
+ // degrades exactly as it did before.
175
+ const active = activeTab;
121
176
  return fail({
122
177
  code: "viewport_not_rendered",
123
178
  message: `The ${r.viewport} editor viewport measured ${r.width}x${r.height} โ€” a collapsed viewport, not a rendered ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.76.0",
3
+ "version": "1.78.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",