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 +33 -1
- package/dist/confirm.js +42 -3
- package/dist/dap.js +17 -0
- package/dist/index.js +7 -1
- package/dist/remedies.js +18 -0
- package/dist/tools/dap.js +107 -11
- package/dist/tools/editor/introspection.js +70 -15
- package/package.json +1 -1
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
|
-
|
|
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
|
|
60
|
-
`
|
|
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.
|
|
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
|
|
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
|
|
598
|
-
//
|
|
599
|
-
// lies โ so bound the request to a short deadline and, on timeout, say plainly that
|
|
600
|
-
//
|
|
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 โ
|
|
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
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
|
|
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
|
|
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.
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
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.
|
|
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",
|