breakpoint-mcp 1.78.3 → 1.80.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.
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { DapError } from "../dap.js";
2
+ import { DapError, DAP_TIMEOUT_CODE, unorderedHandshakeWarning } from "../dap.js";
3
+ import { remedyClause } from "../bridge.js";
3
4
  import { resolveSourceFile } from "../paths.js";
4
5
  import { gate } from "../confirm.js";
5
6
  import { ok } from "./lsp-common.js";
@@ -32,15 +33,28 @@ function fail(err) {
32
33
  content: [{
33
34
  type: "text",
34
35
  text: `C# DAP error [${command}]: the debug adapter reported a failure with no message. ` +
35
- `The request was rejected; nothing was changed by it.`,
36
+ `The request was rejected; nothing was changed by it.${remedyClause(err)}`,
36
37
  }],
37
38
  };
38
39
  }
39
40
  return {
40
41
  isError: true,
41
- content: [{ type: "text", text: `C# DAP error [${command}]: ${message}` }],
42
+ content: [{ type: "text", text: `C# DAP error [${command}]: ${message}${remedyClause(err)}` }],
42
43
  };
43
44
  }
45
+ /**
46
+ * Record whether the adapter announced itself before its breakpoints were applied — the
47
+ * twin of `tools/dap.ts`'s, for the same measured reason (267). Kept per-plane rather than
48
+ * shared because the two planes' result objects are built by different code and a helper
49
+ * imported across them would be the only thing they share.
50
+ */
51
+ function reportHandshakeOrder(result, seen, waitedMs) {
52
+ result.initialized_seen = seen;
53
+ if (seen)
54
+ return;
55
+ const note = unorderedHandshakeWarning(waitedMs);
56
+ result.warning = typeof result.warning === "string" && result.warning.length > 0 ? `${result.warning} ${note}` : note;
57
+ }
44
58
  /** Throw a host refusal — rendered verbatim by `fail()`, never as an adapter error. */
45
59
  function refuse(message, code) {
46
60
  throw Object.assign(new Error(message), { refusal: true, code });
@@ -50,9 +64,13 @@ function refuse(message, code) {
50
64
  * adapter-reported failure or a dropped connection). Used to turn a
51
65
  * setVariable / evaluate non-response into a clear message rather than the
52
66
  * generic timeout — the same F1 discipline the GDScript DAP plane uses.
67
+ *
68
+ * 🔴 A CODE SINCE 268, A REGEX OVER ENGLISH BEFORE IT — see `tools/dap.ts`'s twin for
69
+ * the whole argument. Both planes carried the identical prose predicate, which is why
70
+ * the row named two files and not one.
53
71
  */
54
72
  function isDapTimeout(err) {
55
- return err instanceof DapError && /timed out after/.test(err.message);
73
+ return err instanceof DapError && err.code === DAP_TIMEOUT_CODE;
56
74
  }
57
75
  /**
58
76
  * D4 C3 — the C#/.NET debugging plane. `cs_dbg_*` tools mirroring the read/inspect
@@ -239,14 +257,16 @@ export function registerCsDapTools(server, dap, cfg) {
239
257
  };
240
258
  }
241
259
  try {
242
- await dap.start("launch", {
260
+ const { initializedSeen, initializedWaitMs } = await dap.start("launch", {
243
261
  program: resolvedProgram,
244
262
  args: resolvedArgs,
245
263
  cwd: cfg.csDapProjectPath,
246
264
  stopAtEntry: stop_on_entry ?? false,
247
265
  justMyCode: just_my_code ?? true,
248
- });
249
- return ok({ session_id: "csharp", state: dap.state });
266
+ }, cfg.dapRequireInitialized);
267
+ const result = { session_id: "csharp", state: dap.state };
268
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
269
+ return ok(result);
250
270
  }
251
271
  catch (err) {
252
272
  return fail(err);
@@ -280,8 +300,10 @@ export function registerCsDapTools(server, dap, cfg) {
280
300
  `was previously reported as "running" against a process that does not exist.`, "no_such_process");
281
301
  }
282
302
  }
283
- await dap.start("attach", { processId: process_id });
284
- return ok({ session_id: "csharp", state: dap.state });
303
+ const { initializedSeen, initializedWaitMs } = await dap.start("attach", { processId: process_id }, cfg.dapRequireInitialized);
304
+ const result = { session_id: "csharp", state: dap.state };
305
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
306
+ return ok(result);
285
307
  }
286
308
  catch (err) {
287
309
  return fail(err);
package/dist/tools/dap.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { DapError } from "../dap.js";
2
+ import { DapError, DAP_TIMEOUT_CODE, unorderedHandshakeWarning } from "../dap.js";
3
+ import { remedyClause } from "../bridge.js";
3
4
  import { resolveSourceFile } from "../paths.js";
4
5
  import { gate } from "../confirm.js";
5
6
  import { ok } from "./lsp-common.js";
@@ -116,24 +117,38 @@ function fail(err) {
116
117
  // asked. Same distinction lsp-common.fail() already draws for the LSP planes.
117
118
  const e = err;
118
119
  if (err instanceof DapRefusal || e?.refusal) {
119
- return { isError: true, content: [{ type: "text", text: e.message ?? String(err) }] };
120
+ return { isError: true, content: [{ type: "text", text: `${e.message ?? String(err)}${remedyClause(err)}` }] };
120
121
  }
121
122
  // An adapter can answer a failure with an EMPTY message (netcoredbg did, on
122
123
  // setVariable). `?? String(err)` only covers an ABSENT one, so the whole answer
123
124
  // became a label and a colon.
124
125
  const detail = e.message && e.message.trim() !== "" ? e.message : String(err);
126
+ // 🆕 267 — `remedyClause(err)` reads the `remedy` FIELD, which `DapError` gained this
127
+ // release. Before it, this plane's next actions were pasted inside the message at the
128
+ // raise site and this renderer had nothing to append; check 28 could not see them and a
129
+ // reword dropped one silently. Empty string when the site has no answer, so a rejection
130
+ // relaying the adapter's own words is byte-identical to what it was.
125
131
  return {
126
132
  isError: true,
127
- content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${detail}` }],
133
+ content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${detail}${remedyClause(err)}` }],
128
134
  };
129
135
  }
130
136
  /**
131
137
  * True when `err` is a DAP request that hit its own request deadline (as opposed to an
132
138
  * adapter-reported failure or a dropped connection). Used to turn the setVariable /
133
139
  * evaluate hangs on advertised-but-unimplemented Godot builds into a clear message.
140
+ *
141
+ * 🔴 **THIS READ THE MESSAGE BODY UNTIL 268, AND TWO SHIPPED TOOLS BRANCHED ON IT.** The
142
+ * predicate was `/timed out after/.test(err.message)`, so `dbg_evaluate`'s and
143
+ * `dbg_set_variable`'s specific answers were selected by a regex over English — a copy
144
+ * edit at the raise site would have silently returned every caller to a bare timeout,
145
+ * with every test in the suite still green. 267 declined to reword those messages for
146
+ * exactly that reason and asserted the wording instead, which is a test defending a
147
+ * defect rather than a behaviour. The raise site now sets `DAP_TIMEOUT_CODE` and this
148
+ * reads it; check 32 refuses the regex coming back.
134
149
  */
135
150
  function isDapTimeout(err) {
136
- return err instanceof DapError && /timed out after/.test(err.message);
151
+ return err instanceof DapError && err.code === DAP_TIMEOUT_CODE;
137
152
  }
138
153
  /**
139
154
  * 🔴 The modifier feature-detection lives in `dap.ts` (the client), NOT here, and that
@@ -144,6 +159,23 @@ function isDapTimeout(err) {
144
159
  * it, the breakpoint halted every frame, and the result carried no warning at all.
145
160
  * Detection now happens where the modifiers go on the wire, so every path gets it.
146
161
  */
162
+ /**
163
+ * Record whether the adapter announced itself before its breakpoints were applied.
164
+ *
165
+ * 🔴 THE KEY IS ALWAYS PRESENT, THE WARNING ONLY WHEN IT IS FALSE (267). A field that
166
+ * appears only on the bad path is a field a caller cannot branch on — it cannot tell
167
+ * *this adapter was conformant* from *this host is too old to say* — which is the shape
168
+ * `required-any-reachability` is open about one layer up. The warning is the human half
169
+ * and merges rather than clobbers, for the same reason `reportDroppedModifiers` does:
170
+ * an out-of-order handshake and an unhonoured stop-on-entry can both be true at once.
171
+ */
172
+ function reportHandshakeOrder(result, seen, waitedMs) {
173
+ result.initialized_seen = seen;
174
+ if (seen)
175
+ return;
176
+ const note = unorderedHandshakeWarning(waitedMs);
177
+ result.warning = typeof result.warning === "string" && result.warning.length > 0 ? `${result.warning} ${note}` : note;
178
+ }
147
179
  const warnDropped = (dropped) => `The connected Godot debug adapter does not support ${dropped.join(", ")} on breakpoints (it advertises ` +
148
180
  `${dropped.length > 1 ? "them" : "it"} unsupported), so ${dropped.length > 1 ? "they were" : "it was"} dropped — ` +
149
181
  `the affected breakpoint(s) will halt unconditionally.`;
@@ -312,8 +344,9 @@ export function registerDapTools(server, dap, cfg) {
312
344
  };
313
345
  }
314
346
  try {
315
- const { entryStopSeen } = await dap.start("launch", { project: cfg.projectPath, scene: scene ?? "main", stopOnEntry: stop_on_entry ?? false }, stop_on_entry ? ENTRY_STOP_WAIT_MS : 0);
347
+ const { entryStopSeen, initializedSeen, initializedWaitMs } = await dap.start("launch", { project: cfg.projectPath, scene: scene ?? "main", stopOnEntry: stop_on_entry ?? false }, stop_on_entry ? ENTRY_STOP_WAIT_MS : 0, cfg.dapRequireInitialized);
316
348
  const result = { session_id: "godot", state: dap.state, scene: scene ?? "main" };
349
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
317
350
  if (stop_on_entry) {
318
351
  // 🔴 Measured on Godot 4.7: stopOnEntry is ignored outright — the game ran to
319
352
  // completion and printed its _ready() line while the tool answered a bare
@@ -348,8 +381,9 @@ export function registerDapTools(server, dap, cfg) {
348
381
  },
349
382
  }, async ({ address, port }) => {
350
383
  try {
351
- await dap.start("attach", { address: address ?? "127.0.0.1", port });
384
+ const { initializedSeen, initializedWaitMs } = await dap.start("attach", { address: address ?? "127.0.0.1", port }, 0, cfg.dapRequireInitialized);
352
385
  const result = { session_id: "godot", state: dap.state };
386
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
353
387
  reportDroppedModifiers(result);
354
388
  return ok(result);
355
389
  }
@@ -1,10 +1,15 @@
1
1
  import { ok, failPath } from "../lsp-common.js";
2
2
  import { resolveInsideProject } from "../../paths.js";
3
- import { remedyClause } from "../../bridge.js";
3
+ import { remedyClause, bridgeErrorLabel } from "../../bridge.js";
4
4
  /**
5
5
  * MCP error envelope for a failed editor-bridge call (never throws to the
6
6
  * caller). Distinct from lsp-common's `fail` (which labels errors "LSP error");
7
7
  * this one labels them "Bridge error".
8
+ *
9
+ * 🔴 THE LABEL IS BUILT BY `bridgeErrorLabel` AND NOT WRITTEN HERE (268), because
10
+ * `timeout-caveat.ts` matches against it to decide whether a mutating tool warns that
11
+ * a timed-out change may already have landed. Inlining the template again would put the
12
+ * two spellings back in two files with nothing joining them.
8
13
  */
9
14
  export function fail(err) {
10
15
  const be = err;
@@ -12,7 +17,7 @@ export function fail(err) {
12
17
  const message = be?.message ?? String(err);
13
18
  return {
14
19
  isError: true,
15
- content: [{ type: "text", text: `Bridge error [${code}]: ${message}${remedyClause(err)}` }],
20
+ content: [{ type: "text", text: `${bridgeErrorLabel(code)}: ${message}${remedyClause(err)}` }],
16
21
  };
17
22
  }
18
23
  /**
@@ -4,6 +4,7 @@
4
4
  // MarkupContent), so these live once here rather than being duplicated per plane.
5
5
  // The generic MCP success-envelope helper `ok()` is the single shared envelope
6
6
  // shape and is imported by the other tool modules too, not only the LSP planes.
7
+ import { remedyClause } from "../bridge.js";
7
8
  // LSP CompletionItemKind numeric -> readable name.
8
9
  export const COMPLETION_KIND = {
9
10
  1: "text", 2: "method", 3: "function", 4: "constructor", 5: "field", 6: "variable",
@@ -57,12 +58,14 @@ export function fail(err) {
57
58
  if (e?.refusal) {
58
59
  return {
59
60
  isError: true,
60
- content: [{ type: "text", text: e.message ?? String(err) }],
61
+ content: [{ type: "text", text: `${e.message ?? String(err)}${remedyClause(err)}` }],
61
62
  };
62
63
  }
64
+ // 🆕 267 — see `tools/dap.ts`. `LspError` gained a `remedy` field this release; this is
65
+ // the one place both LSP planes render, so both get the clause from one edit.
63
66
  return {
64
67
  isError: true,
65
- content: [{ type: "text", text: `LSP error [${e.code ?? "error"}]: ${e.message ?? String(err)}` }],
68
+ content: [{ type: "text", text: `LSP error [${e.code ?? "error"}]: ${e.message ?? String(err)}${remedyClause(err)}` }],
66
69
  };
67
70
  }
68
71
  /**
@@ -82,7 +85,7 @@ export function failPath(err) {
82
85
  const be = err;
83
86
  return {
84
87
  isError: true,
85
- content: [{ type: "text", text: `Path error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}` }],
88
+ content: [{ type: "text", text: `Path error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}${remedyClause(err)}` }],
86
89
  };
87
90
  }
88
91
  /**
@@ -157,7 +157,12 @@ export function registerProcessTools(server, cfg) {
157
157
  const since = since_seq ?? 0;
158
158
  const want = stream ?? "both";
159
159
  const lines = m.lines.filter((l) => l.seq > since && (want === "both" || l.stream === want));
160
- return ok({ id, exited: m.exited, exit_code: m.exitCode, latest_seq: m.seq, lines });
160
+ // 🆕 267 — `signal` on the wire. `exit_code` alone could not distinguish a child
161
+ // that chose to exit 0 from one the OS killed: both answered `exited: true` with a
162
+ // null-or-number code, and a SIGKILLed child answered `exit_code: null`, which reads
163
+ // exactly like a process that has not finished. The registry has captured the signal
164
+ // since 266; this is the key that lets a caller read it.
165
+ return ok({ id, exited: m.exited, exit_code: m.exitCode, signal: m.exitSignal, latest_seq: m.seq, lines });
161
166
  });
162
167
  server.registerTool("godot_stop", {
163
168
  title: "Stop managed process",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.78.3",
3
+ "version": "1.80.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",
@@ -43,7 +43,8 @@
43
43
  "start": "node dist/index.js",
44
44
  "dev": "tsc && node dist/index.js",
45
45
  "stage-addon": "node scripts/stage-addon.mjs",
46
- "prepublishOnly": "npm run build && npm run stage-addon",
46
+ "prepublishOnly": "npm run build && npm run stage-addon && node scripts/publish_guard.mjs",
47
+ "publish-guard": "node scripts/publish_guard.mjs --selftest && node scripts/publish_guard.mjs",
47
48
  "path-cohort": "node scripts/path-cohort.mjs",
48
49
  "tautology-gate": "node scripts/tautology_gate.selftest.mjs && node scripts/tautology_gate.mjs",
49
50
  "scope-gate": "python3 ../scripts/scope_gate.py",