breakpoint-mcp 1.77.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/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/index.js CHANGED
@@ -50,7 +50,7 @@ async function main() {
50
50
  // so long jobs (export/import/headless script) support poll/await/cancel.
51
51
  // D3: also advertise resources.subscribe so clients can subscribe to
52
52
  // godot://… resources and receive notifications/resources/updated.
53
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.77.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 });
54
54
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
55
55
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
56
56
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.77.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",