breakpoint-mcp 1.76.0 โ†’ 1.77.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/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.77.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
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.77.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",