breakpoint-mcp 1.26.0 → 1.27.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 +37 -2
- package/dist/config.js +97 -11
- package/dist/csdap.js +22 -1
- package/dist/cslsp.js +22 -1
- package/dist/dap.js +22 -1
- package/dist/index.js +16 -2
- package/dist/late-reply.js +105 -0
- package/dist/lsp.js +22 -1
- package/dist/stdio.js +33 -4
- package/dist/timeout-caveat.js +84 -0
- package/dist/tools/tabletop.js +37 -5
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { log } from "./logger.js";
|
|
4
|
+
import { OverdueLedger } from "./late-reply.js";
|
|
4
5
|
export class BridgeError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
constructor(code, message) {
|
|
@@ -32,6 +33,8 @@ export class BridgeClient {
|
|
|
32
33
|
connecting = null;
|
|
33
34
|
buffer = "";
|
|
34
35
|
pending = new Map();
|
|
36
|
+
/** Ids whose deadline fired, so a reply arriving later is recognised, not dropped. */
|
|
37
|
+
ledger;
|
|
35
38
|
eventListeners = new Set();
|
|
36
39
|
wantConnected = false;
|
|
37
40
|
reconnectTimer = null;
|
|
@@ -43,13 +46,25 @@ export class BridgeClient {
|
|
|
43
46
|
* insecure or not-yet-provisioned bridge. Lazy so a secret minted after the
|
|
44
47
|
* host started (editor launched later) is picked up on the next (re)connect.
|
|
45
48
|
*/
|
|
46
|
-
secretProvider
|
|
49
|
+
secretProvider,
|
|
50
|
+
/**
|
|
51
|
+
* The env var that widens THIS instance's deadline, and the noun for what
|
|
52
|
+
* answered it. Per-INSTANCE, not per-class: index.ts builds two
|
|
53
|
+
* BridgeClients — the editor bridge and the runtime bridge — and they are
|
|
54
|
+
* configured by different variables (BREAKPOINT_BRIDGE_TIMEOUT_MS vs
|
|
55
|
+
* BREAKPOINT_RUNTIME_TIMEOUT_MS). A late-reply line that names the wrong one
|
|
56
|
+
* sends the operator to a knob that cannot move the deadline they just hit,
|
|
57
|
+
* which is worse than saying nothing. Defaults keep the editor bridge's
|
|
58
|
+
* wording byte-identical to what shipped.
|
|
59
|
+
*/
|
|
60
|
+
deadlineKnob = "BREAKPOINT_BRIDGE_TIMEOUT_MS", peerNoun = "the editor") {
|
|
47
61
|
this.host = host;
|
|
48
62
|
this.port = port;
|
|
49
63
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
50
64
|
this.label = label;
|
|
51
65
|
this.hint = hint;
|
|
52
66
|
this.secretProvider = secretProvider;
|
|
67
|
+
this.ledger = new OverdueLedger("bridge", peerNoun, deadlineKnob);
|
|
53
68
|
}
|
|
54
69
|
/** Register a listener for addon-pushed resource-change events. */
|
|
55
70
|
onResourceChanged(cb) {
|
|
@@ -155,8 +170,16 @@ export class BridgeClient {
|
|
|
155
170
|
return;
|
|
156
171
|
}
|
|
157
172
|
const id = msg.id;
|
|
158
|
-
if (!id
|
|
173
|
+
if (!id)
|
|
159
174
|
return;
|
|
175
|
+
if (!this.pending.has(id)) {
|
|
176
|
+
// Not pending: either a reply we already gave up on (reconcile + log it), or
|
|
177
|
+
// a genuinely unknown id (the addon's auth reply, a stale frame) — ignored
|
|
178
|
+
// exactly as before. Ids are randomUUID() and never reused, so a late reply
|
|
179
|
+
// can only ever be its OWN request's; misattribution is impossible here.
|
|
180
|
+
this.ledger.reconcile(id, msg.ok === true);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
160
183
|
const p = this.pending.get(id);
|
|
161
184
|
this.pending.delete(id);
|
|
162
185
|
clearTimeout(p.timer);
|
|
@@ -168,6 +191,13 @@ export class BridgeClient {
|
|
|
168
191
|
p.reject(new BridgeError(e.code, e.message));
|
|
169
192
|
}
|
|
170
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Snapshot of replies that arrived after their deadline, oldest first.
|
|
196
|
+
* Diagnostics only — nothing in the request path reads this.
|
|
197
|
+
*/
|
|
198
|
+
recentLateReplies() {
|
|
199
|
+
return this.ledger.recent();
|
|
200
|
+
}
|
|
171
201
|
onClose() {
|
|
172
202
|
this.socket = null;
|
|
173
203
|
for (const [, p] of this.pending) {
|
|
@@ -187,6 +217,11 @@ export class BridgeClient {
|
|
|
187
217
|
return new Promise((resolve, reject) => {
|
|
188
218
|
const timer = setTimeout(() => {
|
|
189
219
|
this.pending.delete(id);
|
|
220
|
+
// Remember the id BEFORE rejecting, so a reply already in flight is
|
|
221
|
+
// reconciled rather than dropped as anonymous. The message keeps the
|
|
222
|
+
// exact `timed out after <n>ms` phrasing — tools/dap.ts:29 and
|
|
223
|
+
// tools/csdap.ts:31 branch on that substring.
|
|
224
|
+
this.ledger.note(id, method, timeoutMs);
|
|
190
225
|
reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`));
|
|
191
226
|
}, timeoutMs);
|
|
192
227
|
this.pending.set(id, { resolve: resolve, reject, timer });
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
2
|
import { parsePrivilegedGroups } from "./capabilities.js";
|
|
3
|
+
import { log } from "./logger.js";
|
|
3
4
|
/**
|
|
4
5
|
* A TCP port from the environment, falling back to `fallback` when the variable
|
|
5
6
|
* is absent, empty, non-numeric or out of range.
|
|
@@ -25,6 +26,91 @@ function port(raw, fallback) {
|
|
|
25
26
|
const n = Number(t);
|
|
26
27
|
return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : fallback;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The floor below which a deadline on a FRAME-POLLED bridge is premature *by
|
|
31
|
+
* construction* rather than by luck.
|
|
32
|
+
*
|
|
33
|
+
* Both addons — `bridge_server.gd` and `runtime_bridge.gd` — poll their socket
|
|
34
|
+
* from `_process` and dispatch synchronously, so they cannot answer faster than
|
|
35
|
+
* one frame no matter how trivial the request. Any deadline shorter than a frame
|
|
36
|
+
* period is guaranteed to fire before a reply is possible. The editor throttles
|
|
37
|
+
* its main loop when idle or unfocused, so "one frame" is not the 16 ms a running
|
|
38
|
+
* game would suggest; 250 ms is a frame at any plausible editor framerate, with
|
|
39
|
+
* headroom.
|
|
40
|
+
*
|
|
41
|
+
* Below the floor we FALL BACK to the default rather than clamp up to it,
|
|
42
|
+
* matching how `positiveInt` already treats `0` and negatives. A silently
|
|
43
|
+
* adjusted config value is its own small dishonesty: the operator asked for
|
|
44
|
+
* something unusable, and the honest answer is the documented default, said out
|
|
45
|
+
* loud.
|
|
46
|
+
*
|
|
47
|
+
* **This applies to the two bridge deadlines ONLY, and that boundary is the
|
|
48
|
+
* whole point.** LSP, DAP and the asset-gen backend are ordinary
|
|
49
|
+
* request/response over TCP or stdio — nothing frame-polls them, and they answer
|
|
50
|
+
* in microseconds. A 200 ms DAP deadline is a perfectly reasonable "fail fast",
|
|
51
|
+
* not a broken one; `csdap.test.ts:301` sets exactly that and is right to. The
|
|
52
|
+
* justification for this floor is the frame poll, so its scope is the frame poll.
|
|
53
|
+
*
|
|
54
|
+
* **And it does not replace late-reply reconciliation in `bridge.ts`, nor can
|
|
55
|
+
* it.** A *legitimate* 15000 ms deadline fails identically the moment a frame
|
|
56
|
+
* outlasts it, which `bridge_server.gd:96` documents happening on a `scene.save`
|
|
57
|
+
* that triggers a rescan/reimport. The floor removes the configured cause; the
|
|
58
|
+
* ledger catches the consequence whatever the cause.
|
|
59
|
+
*/
|
|
60
|
+
export const MIN_TIMEOUT_MS = 250;
|
|
61
|
+
/**
|
|
62
|
+
* A deadline for a frame-polled bridge: `positiveInt`, then the floor.
|
|
63
|
+
* Used for `bridgeTimeoutMs` and `runtimeTimeoutMs` — the two values that reach
|
|
64
|
+
* a `BridgeClient` — and deliberately not for anything else.
|
|
65
|
+
*/
|
|
66
|
+
function bridgeDeadlineMs(raw, fallback) {
|
|
67
|
+
const n = positiveInt(raw, fallback);
|
|
68
|
+
if (n >= MIN_TIMEOUT_MS)
|
|
69
|
+
return n;
|
|
70
|
+
log(`ignoring a ${n}ms bridge timeout: below the ${MIN_TIMEOUT_MS}ms floor, where the addon cannot answer ` +
|
|
71
|
+
`within one frame no matter what it is doing. Using the ${fallback}ms default instead.`);
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A millisecond deadline from the environment, falling back to `fallback` when
|
|
76
|
+
* the variable is absent, empty, non-numeric, or not a usable positive integer.
|
|
77
|
+
*
|
|
78
|
+
* This is `port()`'s sibling, and it exists because the two were split for
|
|
79
|
+
* eleven releases. `port()` was hardened the moment a bad port began *refusing*
|
|
80
|
+
* a launch; every timeout kept the `Number.parseInt(x ?? "15000", 10)` pattern
|
|
81
|
+
* the docstring above condemns. `??` catches only null/undefined, so an
|
|
82
|
+
* exported-but-empty `GODOT_LSP_TIMEOUT_MS=""` — the same shape that motivated
|
|
83
|
+
* `port()` — reached `parseInt` and yielded `NaN`.
|
|
84
|
+
*
|
|
85
|
+
* **`setTimeout(cb, NaN)` does not throw. It fires on the next tick**,
|
|
86
|
+
* measurably sooner than `setTimeout(cb, 1)`, with no Node warning
|
|
87
|
+
* (`TimeoutOverflowWarning` covers only `> 2^31-1`). So the failure is worse
|
|
88
|
+
* than an unusable port, because by then the request is already on the wire:
|
|
89
|
+
* the addon polls its socket from `_process`, once per frame, and cannot answer
|
|
90
|
+
* inside ~1 ms. The deadline wins *deterministically*, the host reports
|
|
91
|
+
* `timed out after NaNms`, the addon still **executes** the mutation, and the
|
|
92
|
+
* real reply is dropped as an unknown id. An agent that retries a reported
|
|
93
|
+
* failure applies it twice.
|
|
94
|
+
*
|
|
95
|
+
* `parseInt` alone is not the fix here either, for the same reason it was not
|
|
96
|
+
* the fix for ports: it stops at the first non-digit, so a plausible `"15s"`
|
|
97
|
+
* silently becomes 15 ms and `"20_000"` becomes 20.
|
|
98
|
+
*/
|
|
99
|
+
function positiveInt(raw, fallback) {
|
|
100
|
+
// Same shape as port(), mirroring GDScript's `is_valid_int()`: reject the
|
|
101
|
+
// whole string rather than accept a numeric prefix.
|
|
102
|
+
//
|
|
103
|
+
// Zero and negatives are rejected rather than clamped — a deadline of 0 is
|
|
104
|
+
// not a shorter deadline, it is the NaN failure with a different spelling.
|
|
105
|
+
// The upper bound is setTimeout's own: past 2^31-1 Node warns and silently
|
|
106
|
+
// uses 1 ms, so a fat-fingered BREAKPOINT_ASSETGEN_TIMEOUT_MS would land back
|
|
107
|
+
// in the near-zero failure this guard exists to prevent.
|
|
108
|
+
const t = (raw ?? "").trim();
|
|
109
|
+
if (!/^\+?\d+$/.test(t))
|
|
110
|
+
return fallback;
|
|
111
|
+
const n = Number(t);
|
|
112
|
+
return Number.isSafeInteger(n) && n > 0 && n <= 2_147_483_647 ? n : fallback;
|
|
113
|
+
}
|
|
28
114
|
export function loadConfig() {
|
|
29
115
|
const projectPath = process.env.GODOT_PROJECT ?? process.cwd();
|
|
30
116
|
// The C# project defaults to the main project, but is usually pointed at a
|
|
@@ -36,20 +122,20 @@ export function loadConfig() {
|
|
|
36
122
|
projectUri: pathToFileURL(projectPath).href,
|
|
37
123
|
bridgeHost: process.env.BREAKPOINT_BRIDGE_HOST ?? "127.0.0.1",
|
|
38
124
|
bridgePort: port(process.env.BREAKPOINT_BRIDGE_PORT, 9080),
|
|
39
|
-
bridgeTimeoutMs:
|
|
125
|
+
bridgeTimeoutMs: bridgeDeadlineMs(process.env.BREAKPOINT_BRIDGE_TIMEOUT_MS, 15000),
|
|
40
126
|
lspHost: process.env.GODOT_LSP_HOST ?? "127.0.0.1",
|
|
41
127
|
lspPort: port(process.env.GODOT_LSP_PORT, 6005),
|
|
42
|
-
lspTimeoutMs:
|
|
128
|
+
lspTimeoutMs: positiveInt(process.env.GODOT_LSP_TIMEOUT_MS, 15000),
|
|
43
129
|
csLspCmd: process.env.GODOT_CSLSP_CMD ?? "OmniSharp",
|
|
44
130
|
csLspArgs: (process.env.GODOT_CSLSP_ARGS ?? "-lsp").split(/\s+/).filter(Boolean),
|
|
45
131
|
csLspProjectPath,
|
|
46
132
|
csLspProjectUri: pathToFileURL(csLspProjectPath).href,
|
|
47
|
-
csLspTimeoutMs:
|
|
133
|
+
csLspTimeoutMs: positiveInt(process.env.GODOT_CSLSP_TIMEOUT_MS, 30000),
|
|
48
134
|
dapHost: process.env.GODOT_DAP_HOST ?? "127.0.0.1",
|
|
49
135
|
dapPort: port(process.env.GODOT_DAP_PORT, 6006),
|
|
50
|
-
dapTimeoutMs:
|
|
51
|
-
dapSetVarTimeoutMs:
|
|
52
|
-
dapEvaluateTimeoutMs:
|
|
136
|
+
dapTimeoutMs: positiveInt(process.env.GODOT_DAP_TIMEOUT_MS, 20000),
|
|
137
|
+
dapSetVarTimeoutMs: positiveInt(process.env.GODOT_DAP_SETVAR_TIMEOUT_MS, 8000),
|
|
138
|
+
dapEvaluateTimeoutMs: positiveInt(process.env.GODOT_DAP_EVALUATE_TIMEOUT_MS, 8000),
|
|
53
139
|
csDapCmd: process.env.GODOT_CSDAP_CMD ?? "netcoredbg",
|
|
54
140
|
csDapArgs: (process.env.GODOT_CSDAP_ARGS ?? "--interpreter=vscode").split(/\s+/).filter(Boolean),
|
|
55
141
|
// The default program cs_dbg_launch launches is the Mono/.NET Godot binary. GODOT_CSHARP_BIN
|
|
@@ -57,17 +143,17 @@ export function loadConfig() {
|
|
|
57
143
|
// caller can also override per-call via cs_dbg_launch's `program` arg.
|
|
58
144
|
csDapProgram: process.env.GODOT_CSHARP_BIN ?? process.env.GODOT_BIN ?? "godot",
|
|
59
145
|
csDapProjectPath: csLspProjectPath,
|
|
60
|
-
csDapTimeoutMs:
|
|
61
|
-
csDapSetVarTimeoutMs:
|
|
62
|
-
csDapEvaluateTimeoutMs:
|
|
146
|
+
csDapTimeoutMs: positiveInt(process.env.GODOT_CSDAP_TIMEOUT_MS, 20000),
|
|
147
|
+
csDapSetVarTimeoutMs: positiveInt(process.env.GODOT_CSDAP_SETVAR_TIMEOUT_MS, 8000),
|
|
148
|
+
csDapEvaluateTimeoutMs: positiveInt(process.env.GODOT_CSDAP_EVALUATE_TIMEOUT_MS, 8000),
|
|
63
149
|
runtimeHost: process.env.BREAKPOINT_RUNTIME_HOST ?? "127.0.0.1",
|
|
64
150
|
runtimePort: port(process.env.BREAKPOINT_RUNTIME_PORT, 9081),
|
|
65
|
-
runtimeTimeoutMs:
|
|
151
|
+
runtimeTimeoutMs: bridgeDeadlineMs(process.env.BREAKPOINT_RUNTIME_TIMEOUT_MS, 15000),
|
|
66
152
|
// Group J: asset generation is OFF by default (backend "none" → tools degrade).
|
|
67
153
|
assetGenBackend: process.env.BREAKPOINT_ASSETGEN_BACKEND ?? "none",
|
|
68
154
|
assetGenCommand: process.env.BREAKPOINT_ASSETGEN_CMD ?? "",
|
|
69
155
|
assetGenProvider: process.env.BREAKPOINT_ASSETGEN_PROVIDER ?? "",
|
|
70
|
-
assetGenTimeoutMs:
|
|
156
|
+
assetGenTimeoutMs: positiveInt(process.env.BREAKPOINT_ASSETGEN_TIMEOUT_MS, 120000),
|
|
71
157
|
toolsets: parseToolsets(process.env.BREAKPOINT_TOOLSETS),
|
|
72
158
|
privilegedGroups: parsePrivilegedGroups(process.env.BREAKPOINT_PRIVILEGED_GROUPS),
|
|
73
159
|
};
|
package/dist/csdap.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { DapError } from "./dap.js";
|
|
3
|
+
import { OverdueLedger } from "./late-reply.js";
|
|
3
4
|
/**
|
|
4
5
|
* Minimal DAP client for the C#/.NET debugging plane (D4 C3) — the debugger
|
|
5
6
|
* analogue of the C2 `CsLspClient`. Transport-agnostic: it drives any
|
|
@@ -28,6 +29,12 @@ export class CsDapClient extends EventEmitter {
|
|
|
28
29
|
seq = 1;
|
|
29
30
|
pending = new Map();
|
|
30
31
|
breakpoints = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Seqs whose deadline fired, so a response arriving later is recognised, not
|
|
34
|
+
* dropped. `seq` is monotonic and is never reset, so a seq is never reused and
|
|
35
|
+
* a late response can only be its own request's.
|
|
36
|
+
*/
|
|
37
|
+
ledger = new OverdueLedger("C# DAP", "the debug adapter", "GODOT_CSDAP_TIMEOUT_MS");
|
|
31
38
|
configured = false;
|
|
32
39
|
/** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
|
|
33
40
|
watches = [];
|
|
@@ -58,8 +65,12 @@ export class CsDapClient extends EventEmitter {
|
|
|
58
65
|
if (type === "response") {
|
|
59
66
|
const reqSeq = msg["request_seq"];
|
|
60
67
|
const p = this.pending.get(reqSeq);
|
|
61
|
-
if (!p)
|
|
68
|
+
if (!p) {
|
|
69
|
+
// Not pending: either a response we already gave up on (reconcile + log
|
|
70
|
+
// the overshoot), or a genuinely unknown seq — ignored exactly as before.
|
|
71
|
+
this.ledger.reconcile(reqSeq, msg["success"] === true);
|
|
62
72
|
return;
|
|
73
|
+
}
|
|
63
74
|
this.pending.delete(reqSeq);
|
|
64
75
|
clearTimeout(p.timer);
|
|
65
76
|
if (msg["success"]) {
|
|
@@ -109,11 +120,21 @@ export class CsDapClient extends EventEmitter {
|
|
|
109
120
|
break;
|
|
110
121
|
}
|
|
111
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Snapshot of responses that arrived after their deadline, oldest first.
|
|
125
|
+
* Diagnostics only — nothing in the request path reads this.
|
|
126
|
+
*/
|
|
127
|
+
recentLateReplies() {
|
|
128
|
+
return this.ledger.recent();
|
|
129
|
+
}
|
|
112
130
|
request(command, args = {}, timeoutMs = this.timeoutMs) {
|
|
113
131
|
const seq = this.seq++;
|
|
114
132
|
return new Promise((resolve, reject) => {
|
|
115
133
|
const timer = setTimeout(() => {
|
|
116
134
|
this.pending.delete(seq);
|
|
135
|
+
// Remember the seq BEFORE rejecting, so a response already in flight is
|
|
136
|
+
// reconciled rather than dropped as anonymous.
|
|
137
|
+
this.ledger.note(seq, command, timeoutMs);
|
|
117
138
|
reject(new DapError(command, `C# DAP '${command}' timed out after ${timeoutMs}ms`));
|
|
118
139
|
}, timeoutMs);
|
|
119
140
|
this.pending.set(seq, { command, resolve: resolve, reject, timer });
|
package/dist/cslsp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { LspError } from "./lsp.js";
|
|
3
|
+
import { OverdueLedger } from "./late-reply.js";
|
|
3
4
|
import { packageVersion } from "./version.js";
|
|
4
5
|
/**
|
|
5
6
|
* Minimal LSP client for the C#/.NET semantic plane (D4 C2). Transport-agnostic:
|
|
@@ -25,6 +26,12 @@ export class CsLspClient {
|
|
|
25
26
|
opened = new Set();
|
|
26
27
|
diagnostics = new Map();
|
|
27
28
|
diagWaiters = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* Ids whose deadline fired, so a reply arriving later is recognised, not
|
|
31
|
+
* dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
|
|
32
|
+
* so an id is never reused and a late reply can only be its own request's.
|
|
33
|
+
*/
|
|
34
|
+
ledger = new OverdueLedger("C# LSP", "the language server", "GODOT_CSLSP_TIMEOUT_MS");
|
|
28
35
|
/** Absolute project root path (no trailing slash), used to canonicalize URIs. */
|
|
29
36
|
rootFsPath;
|
|
30
37
|
constructor(channel, rootUri, timeoutMs) {
|
|
@@ -71,8 +78,12 @@ export class CsLspClient {
|
|
|
71
78
|
// Response to one of our requests.
|
|
72
79
|
if (typeof id === "number" && method === undefined) {
|
|
73
80
|
const p = this.pending.get(id);
|
|
74
|
-
if (!p)
|
|
81
|
+
if (!p) {
|
|
82
|
+
// Not pending: either a reply we already gave up on (reconcile + log the
|
|
83
|
+
// overshoot), or a genuinely unknown id — ignored exactly as before.
|
|
84
|
+
this.ledger.reconcile(id, msg["error"] === undefined);
|
|
75
85
|
return;
|
|
86
|
+
}
|
|
76
87
|
this.pending.delete(id);
|
|
77
88
|
clearTimeout(p.timer);
|
|
78
89
|
if (msg["error"]) {
|
|
@@ -126,11 +137,21 @@ export class CsLspClient {
|
|
|
126
137
|
this.serverCapabilities = null;
|
|
127
138
|
this.opened.clear();
|
|
128
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Snapshot of replies that arrived after their deadline, oldest first.
|
|
142
|
+
* Diagnostics only — nothing in the request path reads this.
|
|
143
|
+
*/
|
|
144
|
+
recentLateReplies() {
|
|
145
|
+
return this.ledger.recent();
|
|
146
|
+
}
|
|
129
147
|
rawRequest(method, params, timeoutMs = this.timeoutMs) {
|
|
130
148
|
const id = this.nextId++;
|
|
131
149
|
return new Promise((resolve, reject) => {
|
|
132
150
|
const timer = setTimeout(() => {
|
|
133
151
|
this.pending.delete(id);
|
|
152
|
+
// Remember the id BEFORE rejecting, so a reply already in flight is
|
|
153
|
+
// reconciled rather than dropped as anonymous.
|
|
154
|
+
this.ledger.note(id, method, timeoutMs);
|
|
134
155
|
reject(new LspError("timeout", `C# LSP '${method}' timed out after ${timeoutMs}ms`));
|
|
135
156
|
}, timeoutMs);
|
|
136
157
|
this.pending.set(id, { resolve: resolve, reject, timer });
|
package/dist/dap.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { FramedConnection } from "./framing.js";
|
|
3
|
+
import { OverdueLedger } from "./late-reply.js";
|
|
3
4
|
export class DapError extends Error {
|
|
4
5
|
command;
|
|
5
6
|
constructor(command, message) {
|
|
@@ -19,6 +20,12 @@ export class DapClient extends EventEmitter {
|
|
|
19
20
|
seq = 1;
|
|
20
21
|
pending = new Map();
|
|
21
22
|
breakpoints = new Map();
|
|
23
|
+
/**
|
|
24
|
+
* Seqs whose deadline fired, so a response arriving later is recognised, not
|
|
25
|
+
* dropped. `seq` is monotonic and is never reset, so a seq is never reused and
|
|
26
|
+
* a late response can only be its own request's.
|
|
27
|
+
*/
|
|
28
|
+
ledger = new OverdueLedger("DAP", "the debug adapter", "GODOT_DAP_TIMEOUT_MS");
|
|
22
29
|
configured = false;
|
|
23
30
|
/** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
|
|
24
31
|
watches = [];
|
|
@@ -50,8 +57,12 @@ export class DapClient extends EventEmitter {
|
|
|
50
57
|
if (type === "response") {
|
|
51
58
|
const reqSeq = msg["request_seq"];
|
|
52
59
|
const p = this.pending.get(reqSeq);
|
|
53
|
-
if (!p)
|
|
60
|
+
if (!p) {
|
|
61
|
+
// Not pending: either a response we already gave up on (reconcile + log
|
|
62
|
+
// the overshoot), or a genuinely unknown seq — ignored exactly as before.
|
|
63
|
+
this.ledger.reconcile(reqSeq, msg["success"] === true);
|
|
54
64
|
return;
|
|
65
|
+
}
|
|
55
66
|
this.pending.delete(reqSeq);
|
|
56
67
|
clearTimeout(p.timer);
|
|
57
68
|
if (msg["success"]) {
|
|
@@ -106,6 +117,9 @@ export class DapClient extends EventEmitter {
|
|
|
106
117
|
return new Promise((resolve, reject) => {
|
|
107
118
|
const timer = setTimeout(() => {
|
|
108
119
|
this.pending.delete(seq);
|
|
120
|
+
// Remember the seq BEFORE rejecting, so a response already in flight is
|
|
121
|
+
// reconciled rather than dropped as anonymous.
|
|
122
|
+
this.ledger.note(seq, command, timeoutMs);
|
|
109
123
|
reject(new DapError(command, `DAP '${command}' timed out after ${timeoutMs}ms`));
|
|
110
124
|
}, timeoutMs);
|
|
111
125
|
this.pending.set(seq, { command, resolve: resolve, reject, timer });
|
|
@@ -116,6 +130,13 @@ export class DapClient extends EventEmitter {
|
|
|
116
130
|
});
|
|
117
131
|
});
|
|
118
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Snapshot of responses that arrived after their deadline, oldest first.
|
|
135
|
+
* Diagnostics only — nothing in the request path reads this.
|
|
136
|
+
*/
|
|
137
|
+
recentLateReplies() {
|
|
138
|
+
return this.ledger.recent();
|
|
139
|
+
}
|
|
119
140
|
waitEvent(name, timeoutMs) {
|
|
120
141
|
return new Promise((resolve) => {
|
|
121
142
|
const timer = setTimeout(() => {
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { buildToolsets } from "./toolsets.js";
|
|
|
13
13
|
import { registerRecipes } from "./recipes.js";
|
|
14
14
|
import { applyOutputSchemas } from "./schemas.js";
|
|
15
15
|
import { applyAnnotations } from "./annotations.js";
|
|
16
|
+
import { applyTimeoutCaveat } from "./timeout-caveat.js";
|
|
16
17
|
import { applyCapabilities, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
|
|
17
18
|
import { taskStore, TASK_CAPABILITIES } from "./tasks.js";
|
|
18
19
|
import { RESOURCE_CAPABILITIES, registerResourceSubscriptions } from "./subscriptions.js";
|
|
@@ -21,7 +22,11 @@ import { log } from "./logger.js";
|
|
|
21
22
|
async function main() {
|
|
22
23
|
const config = loadConfig();
|
|
23
24
|
const bridge = new BridgeClient(config.bridgeHost, config.bridgePort, config.bridgeTimeoutMs, "editor bridge", undefined, () => resolveBridgeSecret(config.projectPath, ["BREAKPOINT_BRIDGE_SECRET"]));
|
|
24
|
-
const runtime = new BridgeClient(config.runtimeHost, config.runtimePort, config.runtimeTimeoutMs, "runtime bridge", "Is the project running? Launch it (godot_run_project or dbg_launch) with the Breakpoint MCP plugin enabled — it auto-registers the runtime autoload.", () => resolveBridgeSecret(config.projectPath, ["BREAKPOINT_RUNTIME_SECRET", "BREAKPOINT_BRIDGE_SECRET"])
|
|
25
|
+
const runtime = new BridgeClient(config.runtimeHost, config.runtimePort, config.runtimeTimeoutMs, "runtime bridge", "Is the project running? Launch it (godot_run_project or dbg_launch) with the Breakpoint MCP plugin enabled — it auto-registers the runtime autoload.", () => resolveBridgeSecret(config.projectPath, ["BREAKPOINT_RUNTIME_SECRET", "BREAKPOINT_BRIDGE_SECRET"]),
|
|
26
|
+
// This instance's deadline comes from BREAKPOINT_RUNTIME_TIMEOUT_MS, not the
|
|
27
|
+
// editor bridge's variable — a late reply here must name the knob that can
|
|
28
|
+
// actually move it, and the peer that answered is the running game.
|
|
29
|
+
"BREAKPOINT_RUNTIME_TIMEOUT_MS", "the running game");
|
|
25
30
|
const lsp = new LspClient(config.lspHost, config.lspPort, config.projectUri, config.lspTimeoutMs);
|
|
26
31
|
// D4 C2: the C# semantic plane. OmniSharp is spawned over stdio (lazily, on the
|
|
27
32
|
// first cs_* call) against the C# project root — so a host without OmniSharp
|
|
@@ -36,7 +41,7 @@ async function main() {
|
|
|
36
41
|
// so long jobs (export/import/headless script) support poll/await/cancel.
|
|
37
42
|
// D3: also advertise resources.subscribe so clients can subscribe to
|
|
38
43
|
// godot://… resources and receive notifications/resources/updated.
|
|
39
|
-
const server = new McpServer({ name: "breakpoint-mcp", version: "1.
|
|
44
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.27.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
|
|
40
45
|
// B1: enforce frozen output schemas on every structured tool. Must run before
|
|
41
46
|
// any register*Tools call — it wraps server.registerTool.
|
|
42
47
|
applyOutputSchemas(server);
|
|
@@ -46,6 +51,15 @@ async function main() {
|
|
|
46
51
|
// in applyCapabilities (below) + confirm.ts. Wraps registerTool after the
|
|
47
52
|
// schema wrapper, before any register*Tools call.
|
|
48
53
|
applyAnnotations(server);
|
|
54
|
+
// A bridge timeout does not mean nothing happened: the request is already on
|
|
55
|
+
// the wire when the deadline fires, and the addon polls once per frame, so the
|
|
56
|
+
// ordinary outcome is that the editor applies the change and answers late.
|
|
57
|
+
// Append a caveat scaled by the annotations just injected above — nothing for
|
|
58
|
+
// the read-only tools, "retrying is safe" for the idempotent ones, and
|
|
59
|
+
// "retrying may apply it a SECOND time" for those that are neither. Wraps the
|
|
60
|
+
// HANDLER (not the config), after applyAnnotations so the hints exist, before
|
|
61
|
+
// applyCapabilities so a dropped tool is never wrapped.
|
|
62
|
+
applyTimeoutCaveat(server);
|
|
49
63
|
// Capability groups — a risk-based axis over the toolsets. Both `code-execution`
|
|
50
64
|
// and `network` are OFF by default; a disabled group's tools are DROPPED at
|
|
51
65
|
// registration (omitted from tools/list), so the secure-default surface is
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
/**
|
|
3
|
+
* Bounds on the overdue ledger. Both are belt-and-braces: an id is normally
|
|
4
|
+
* evicted the moment its late reply lands, so the map is empty in steady state.
|
|
5
|
+
* These only matter for deadlines whose reply NEVER arrives (peer killed
|
|
6
|
+
* mid-request), which would otherwise accumulate one small record each.
|
|
7
|
+
*/
|
|
8
|
+
export const OVERDUE_MAX = 64;
|
|
9
|
+
export const OVERDUE_MAX_AGE_MS = 5 * 60_000;
|
|
10
|
+
/** How many late replies to retain for `recent()`. Diagnostics only. */
|
|
11
|
+
export const LATE_REPLY_MAX = 32;
|
|
12
|
+
/**
|
|
13
|
+
* Per-client ledger of ids whose deadline fired, plus the late replies that
|
|
14
|
+
* later reconciled against them.
|
|
15
|
+
*
|
|
16
|
+
* Generic in the id type because the clients disagree on it and both are safe:
|
|
17
|
+
* `BridgeClient` correlates on `randomUUID()` strings, while the LSP/DAP family
|
|
18
|
+
* uses a monotonic per-instance counter (`nextId++` / `seq++`) that is never
|
|
19
|
+
* reset — not even by `onClose()`, which clears `pending` but deliberately
|
|
20
|
+
* leaves the counter alone. Either way an id is never reused within the
|
|
21
|
+
* instance that owns the ledger, so a late reply can only ever be its OWN
|
|
22
|
+
* request's and misattribution is impossible.
|
|
23
|
+
*/
|
|
24
|
+
export class OverdueLedger {
|
|
25
|
+
kind;
|
|
26
|
+
peer;
|
|
27
|
+
knob;
|
|
28
|
+
overdue = new Map();
|
|
29
|
+
lateReplies = [];
|
|
30
|
+
/**
|
|
31
|
+
* @param kind names the channel in the log line ("bridge", "LSP", "C# DAP").
|
|
32
|
+
* @param peer what answered, as a noun phrase ("the editor", "the debug adapter").
|
|
33
|
+
* @param knob the env var that widens THIS client's deadline. Per-instance,
|
|
34
|
+
* not per-class: the editor and runtime bridges are both
|
|
35
|
+
* `BridgeClient` but are configured by different variables, and
|
|
36
|
+
* naming the wrong one sends the operator to a knob that cannot
|
|
37
|
+
* move the deadline they just hit.
|
|
38
|
+
*/
|
|
39
|
+
constructor(kind, peer, knob) {
|
|
40
|
+
this.kind = kind;
|
|
41
|
+
this.peer = peer;
|
|
42
|
+
this.knob = knob;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Record that `id`'s deadline fired, so a reply arriving afterwards is
|
|
46
|
+
* recognisable rather than anonymous. Call ONLY from the timeout path — a
|
|
47
|
+
* request that was never written, or whose connection closed, can never be
|
|
48
|
+
* answered by the peer, so a "late reply" for it is not a thing.
|
|
49
|
+
*/
|
|
50
|
+
note(id, method, deadlineMs) {
|
|
51
|
+
this.overdue.set(id, { method, deadlineMs, timedOutAt: Date.now() });
|
|
52
|
+
const cutoff = Date.now() - OVERDUE_MAX_AGE_MS;
|
|
53
|
+
for (const [k, v] of this.overdue) {
|
|
54
|
+
if (v.timedOutAt < cutoff)
|
|
55
|
+
this.overdue.delete(k);
|
|
56
|
+
}
|
|
57
|
+
// Map iterates in insertion order, so the first key is always the oldest.
|
|
58
|
+
while (this.overdue.size > OVERDUE_MAX) {
|
|
59
|
+
const oldest = this.overdue.keys().next();
|
|
60
|
+
if (oldest.done)
|
|
61
|
+
break;
|
|
62
|
+
this.overdue.delete(oldest.value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A reply landed for an id that is no longer pending. If we timed that id out,
|
|
67
|
+
* this is the proof the deadline was premature — record it and SAY SO. The
|
|
68
|
+
* caller's promise is already settled and cannot be un-rejected; the value
|
|
69
|
+
* here is the overshoot number, which is exactly what the operator needs to
|
|
70
|
+
* fix the deadline, and which the client previously discarded in silence.
|
|
71
|
+
*
|
|
72
|
+
* @returns true if this was a reconciled late reply, false for a genuinely
|
|
73
|
+
* unknown id (a handshake ack, a stale frame) — ignored as ever.
|
|
74
|
+
*/
|
|
75
|
+
reconcile(id, ok) {
|
|
76
|
+
const o = this.overdue.get(id);
|
|
77
|
+
if (!o)
|
|
78
|
+
return false;
|
|
79
|
+
this.overdue.delete(id);
|
|
80
|
+
const overshootMs = Date.now() - o.timedOutAt;
|
|
81
|
+
this.lateReplies.push({ method: o.method, deadlineMs: o.deadlineMs, overshootMs, ok });
|
|
82
|
+
if (this.lateReplies.length > LATE_REPLY_MAX)
|
|
83
|
+
this.lateReplies.shift();
|
|
84
|
+
log(`late ${this.kind} reply: '${o.method}' answered ${overshootMs}ms AFTER its ${o.deadlineMs}ms deadline — ` +
|
|
85
|
+
`the call ${ok ? `DID complete in ${this.peer}` : `reached ${this.peer} and failed there`}, ` +
|
|
86
|
+
`so the reported timeout was premature. Raise ${this.knob} above ${o.deadlineMs + overshootMs}ms.`);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Snapshot of replies that arrived after their deadline, oldest first.
|
|
91
|
+
* Diagnostics only — nothing in any request path reads this.
|
|
92
|
+
*/
|
|
93
|
+
recent() {
|
|
94
|
+
return [...this.lateReplies];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* How many timed-out ids are still awaiting a reply. Empty in steady state;
|
|
98
|
+
* exposed so a test can assert the ledger does not leak rather than trusting
|
|
99
|
+
* the bounds by inspection.
|
|
100
|
+
*/
|
|
101
|
+
overdueSize() {
|
|
102
|
+
return this.overdue.size;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=late-reply.js.map
|
package/dist/lsp.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { FramedConnection } from "./framing.js";
|
|
3
3
|
import { packageVersion } from "./version.js";
|
|
4
|
+
import { OverdueLedger } from "./late-reply.js";
|
|
4
5
|
export class LspError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
constructor(code, message) {
|
|
@@ -25,6 +26,12 @@ export class LspClient {
|
|
|
25
26
|
opened = new Set();
|
|
26
27
|
diagnostics = new Map();
|
|
27
28
|
diagWaiters = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* Ids whose deadline fired, so a reply arriving later is recognised, not
|
|
31
|
+
* dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
|
|
32
|
+
* so an id is never reused and a late reply can only be its own request's.
|
|
33
|
+
*/
|
|
34
|
+
ledger = new OverdueLedger("LSP", "the language server", "GODOT_LSP_TIMEOUT_MS");
|
|
28
35
|
/** Absolute project root path (no trailing slash), used to canonicalize URIs. */
|
|
29
36
|
rootFsPath;
|
|
30
37
|
constructor(host, port, rootUri, timeoutMs) {
|
|
@@ -73,8 +80,12 @@ export class LspClient {
|
|
|
73
80
|
// Response to one of our requests.
|
|
74
81
|
if (typeof id === "number" && method === undefined) {
|
|
75
82
|
const p = this.pending.get(id);
|
|
76
|
-
if (!p)
|
|
83
|
+
if (!p) {
|
|
84
|
+
// Not pending: either a reply we already gave up on (reconcile + log the
|
|
85
|
+
// overshoot), or a genuinely unknown id — ignored exactly as before.
|
|
86
|
+
this.ledger.reconcile(id, msg["error"] === undefined);
|
|
77
87
|
return;
|
|
88
|
+
}
|
|
78
89
|
this.pending.delete(id);
|
|
79
90
|
clearTimeout(p.timer);
|
|
80
91
|
if (msg["error"]) {
|
|
@@ -132,6 +143,9 @@ export class LspClient {
|
|
|
132
143
|
return new Promise((resolve, reject) => {
|
|
133
144
|
const timer = setTimeout(() => {
|
|
134
145
|
this.pending.delete(id);
|
|
146
|
+
// Remember the id BEFORE rejecting, so a reply already in flight is
|
|
147
|
+
// reconciled rather than dropped as anonymous.
|
|
148
|
+
this.ledger.note(id, method, timeoutMs);
|
|
135
149
|
reject(new LspError("timeout", `LSP '${method}' timed out after ${timeoutMs}ms`));
|
|
136
150
|
}, timeoutMs);
|
|
137
151
|
this.pending.set(id, { resolve: resolve, reject, timer });
|
|
@@ -179,6 +193,13 @@ export class LspClient {
|
|
|
179
193
|
await this.ensureInitialized();
|
|
180
194
|
return this.rawRequest(method, params, timeoutMs);
|
|
181
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Snapshot of replies that arrived after their deadline, oldest first.
|
|
198
|
+
* Diagnostics only — nothing in the request path reads this.
|
|
199
|
+
*/
|
|
200
|
+
recentLateReplies() {
|
|
201
|
+
return this.ledger.recent();
|
|
202
|
+
}
|
|
182
203
|
/**
|
|
183
204
|
* The server's advertised capabilities from the `initialize` handshake (an
|
|
184
205
|
* empty object if the server advertised none). Lets a tool feature-detect an
|
package/dist/stdio.js
CHANGED
|
@@ -12,7 +12,10 @@ import { log } from "./logger.js";
|
|
|
12
12
|
* connects lazily — so a host with no C# language server installed pays nothing
|
|
13
13
|
* and never fails at startup; only a cs_* tool call actually launches it. A spawn
|
|
14
14
|
* failure (e.g. the binary isn't on PATH) rejects the send with an actionable
|
|
15
|
-
* hint rather than hanging
|
|
15
|
+
* hint rather than hanging, and — like the TCP sibling's dropped connection —
|
|
16
|
+
* fires `onClose`, so a client caching an initialize handshake drops it: install
|
|
17
|
+
* the language server afterwards and the next cs_* call picks it up, with no
|
|
18
|
+
* host restart.
|
|
16
19
|
*/
|
|
17
20
|
export class StdioChannel {
|
|
18
21
|
command;
|
|
@@ -44,13 +47,18 @@ export class StdioChannel {
|
|
|
44
47
|
return Promise.resolve(this.child);
|
|
45
48
|
if (this.starting)
|
|
46
49
|
return this.starting;
|
|
47
|
-
|
|
50
|
+
let threwSynchronously = false;
|
|
51
|
+
const starting = new Promise((resolve, reject) => {
|
|
48
52
|
let child;
|
|
49
53
|
try {
|
|
50
54
|
child = spawn(this.command, this.args, { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
51
55
|
}
|
|
52
56
|
catch (err) {
|
|
53
|
-
this.starting
|
|
57
|
+
// Nulling `this.starting` here would be undone by the assignment below —
|
|
58
|
+
// the executor runs synchronously, BEFORE that assignment — so a synchronous
|
|
59
|
+
// throw (bad spawn options, not a missing binary) would cache the rejected
|
|
60
|
+
// promise forever. Flag it and let the assignment skip instead.
|
|
61
|
+
threwSynchronously = true;
|
|
54
62
|
reject(new Error(`${this.label} could not spawn '${this.command}'. ${this.unavailableHint} (${err.message})`));
|
|
55
63
|
return;
|
|
56
64
|
}
|
|
@@ -75,12 +83,33 @@ export class StdioChannel {
|
|
|
75
83
|
});
|
|
76
84
|
child.on("exit", (code, signal) => {
|
|
77
85
|
log(`${this.label} exited (code=${code ?? "null"} signal=${signal ?? "null"})`);
|
|
86
|
+
});
|
|
87
|
+
// Reset on `close`, NOT `exit` — the one event name that separated this from
|
|
88
|
+
// its TCP sibling, which hooks `close` and self-heals. A spawn failure (ENOENT:
|
|
89
|
+
// no OmniSharp on PATH) emits `error` + `close` and NEVER `exit`, so an
|
|
90
|
+
// exit-only hook never ran `closeCb()`, and the client's cached `initialized`
|
|
91
|
+
// promise stayed rejected for the process's lifetime: install the language
|
|
92
|
+
// server afterwards and every cs_* call still returned the stale spawn error
|
|
93
|
+
// until the host restarted. `close` is a strict superset — a process that
|
|
94
|
+
// really ran emits `spawn` -> `exit` -> `close` — so the normal path still
|
|
95
|
+
// notifies exactly once and nothing double-fires.
|
|
96
|
+
//
|
|
97
|
+
// Do NOT move this into the `error` handler to notify a turn sooner. `close`
|
|
98
|
+
// lands after the microtask that settles the rejected send, and that lateness
|
|
99
|
+
// is load-bearing: a client's onClose rejects every still-pending request with
|
|
100
|
+
// its own generic "connection closed", so notifying synchronously would
|
|
101
|
+
// overwrite the actionable `${this.unavailableHint}` above with it and the
|
|
102
|
+
// caller would never learn which binary to install. The heal is therefore one
|
|
103
|
+
// turn behind the rejection the caller sees, which no real second tool call
|
|
104
|
+
// can observe.
|
|
105
|
+
child.on("close", () => {
|
|
78
106
|
this.child = null;
|
|
79
107
|
this.decoder.reset();
|
|
80
108
|
this.closeCb();
|
|
81
109
|
});
|
|
82
110
|
});
|
|
83
|
-
|
|
111
|
+
this.starting = threwSynchronously ? null : starting;
|
|
112
|
+
return starting;
|
|
84
113
|
}
|
|
85
114
|
async send(msg) {
|
|
86
115
|
const child = await this.start();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { annotationsFor } from "./annotations.js";
|
|
2
|
+
/**
|
|
3
|
+
* The marker identifying a bridge timeout envelope, built by
|
|
4
|
+
* `tools/editor/common.ts`'s `fail()`. Deliberately narrow: LSP and DAP timeouts
|
|
5
|
+
* are a different transport with a different failure mode (no editor-side
|
|
6
|
+
* mutation is implied), and `tools/dap.ts:29` / `tools/csdap.ts:31` branch on
|
|
7
|
+
* their own `/timed out after/` predicate, which this must not disturb.
|
|
8
|
+
*/
|
|
9
|
+
const BRIDGE_TIMEOUT_MARK = "Bridge error [timeout]";
|
|
10
|
+
/** Mutating, but repeating it lands in the same state — retrying is safe. */
|
|
11
|
+
export const CAVEAT_IDEMPOTENT = "The request WAS delivered to the editor and may already have been applied. " +
|
|
12
|
+
"This tool is idempotent, so retrying is safe.";
|
|
13
|
+
/** Mutating and NOT idempotent — this is where a blind retry duplicates. */
|
|
14
|
+
export const CAVEAT_NON_IDEMPOTENT = "The request WAS delivered to the editor and may already have been applied. " +
|
|
15
|
+
"Retrying may apply it a SECOND time — verify the editor state before you do.";
|
|
16
|
+
/**
|
|
17
|
+
* The caveat a given tool's timeout envelope should carry, or null for the
|
|
18
|
+
* read-only tools that should carry none.
|
|
19
|
+
*/
|
|
20
|
+
export function caveatFor(name) {
|
|
21
|
+
const a = annotationsFor(name);
|
|
22
|
+
if (a.readOnlyHint)
|
|
23
|
+
return null;
|
|
24
|
+
return a.idempotentHint ? CAVEAT_IDEMPOTENT : CAVEAT_NON_IDEMPOTENT;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Append `caveat` to the first text block carrying the bridge-timeout marker.
|
|
28
|
+
*
|
|
29
|
+
* APPENDS — never rewrites. The existing sentence, including its exact
|
|
30
|
+
* `timed out after <n>ms` phrasing, is load-bearing elsewhere. Anything that is
|
|
31
|
+
* not a bridge-timeout error envelope is returned untouched, by identity, so a
|
|
32
|
+
* tool that never speaks to the bridge is unaffected even though it is wrapped.
|
|
33
|
+
*/
|
|
34
|
+
export function appendCaveat(result, caveat) {
|
|
35
|
+
const r = result;
|
|
36
|
+
if (!r || r.isError !== true || !Array.isArray(r.content))
|
|
37
|
+
return result;
|
|
38
|
+
let done = false;
|
|
39
|
+
const content = r.content.map((block) => {
|
|
40
|
+
if (done || block?.["type"] !== "text")
|
|
41
|
+
return block;
|
|
42
|
+
const text = block["text"];
|
|
43
|
+
if (typeof text !== "string" || !text.includes(BRIDGE_TIMEOUT_MARK))
|
|
44
|
+
return block;
|
|
45
|
+
done = true;
|
|
46
|
+
return { ...block, text: `${text} ${caveat}` };
|
|
47
|
+
});
|
|
48
|
+
return done ? { ...r, content } : result;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wrap `server.registerTool` so a bridge-timeout envelope leaves with a caveat
|
|
52
|
+
* matching the tool's own annotations.
|
|
53
|
+
*
|
|
54
|
+
* Mirrors `applyOutputSchemas` / `applyAnnotations` / `applyCapabilities`; call
|
|
55
|
+
* once, AFTER `applyAnnotations` and BEFORE `applyCapabilities`, so a tool
|
|
56
|
+
* dropped by a disabled capability group is never wrapped at all.
|
|
57
|
+
*
|
|
58
|
+
* Read-only tools are not wrapped — not wrapped-and-inert, actually not wrapped —
|
|
59
|
+
* so 92 of the 289 handlers keep their exact previous identity.
|
|
60
|
+
*/
|
|
61
|
+
export function applyTimeoutCaveat(server) {
|
|
62
|
+
const inject = (raw) => (name, config, handler) => {
|
|
63
|
+
const caveat = caveatFor(name);
|
|
64
|
+
if (!caveat || typeof handler !== "function")
|
|
65
|
+
return raw(name, config, handler);
|
|
66
|
+
const inner = handler;
|
|
67
|
+
const wrapped = (...args) => {
|
|
68
|
+
const out = inner(...args);
|
|
69
|
+
// Preserve synchronicity: only a promise-returning handler gets a
|
|
70
|
+
// promise back, so nothing's timing changes just by being wrapped.
|
|
71
|
+
return out instanceof Promise ? out.then((v) => appendCaveat(v, caveat)) : appendCaveat(out, caveat);
|
|
72
|
+
};
|
|
73
|
+
return raw(name, config, wrapped);
|
|
74
|
+
};
|
|
75
|
+
const s = server;
|
|
76
|
+
s.registerTool = inject(s.registerTool.bind(server));
|
|
77
|
+
// The D2 task-model tools register through experimental.tasks.registerToolTask,
|
|
78
|
+
// not registerTool — same reason applyAnnotations covers both paths.
|
|
79
|
+
const tasks = s.experimental?.tasks;
|
|
80
|
+
if (tasks?.registerToolTask) {
|
|
81
|
+
tasks.registerToolTask = inject(tasks.registerToolTask.bind(tasks));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=timeout-caveat.js.map
|
package/dist/tools/tabletop.js
CHANGED
|
@@ -95,6 +95,31 @@ function resourceVariant(cls, path) {
|
|
|
95
95
|
}
|
|
96
96
|
const ALIGN_TO_ENUM = { left: 0, center: 1, right: 2 };
|
|
97
97
|
// ------------------------------------------------------ column expressions ----
|
|
98
|
+
/**
|
|
99
|
+
* The `{placeholder}` scanner, shared by `resolveColumnExpr` and
|
|
100
|
+
* `columnExprPlaceholders` so the two can never disagree about what counts as a
|
|
101
|
+
* placeholder. Both drive it through `String.replace`, which resets `lastIndex`
|
|
102
|
+
* when it finishes, so the shared `/g` instance carries no state between calls.
|
|
103
|
+
*/
|
|
104
|
+
const COLUMN_PLACEHOLDER = /\{([^}]*)\}/g;
|
|
105
|
+
/**
|
|
106
|
+
* The column names an expression references, WITHOUT needing a row to resolve
|
|
107
|
+
* against. `resolveColumnExpr` reports the same set, but only as a side effect of
|
|
108
|
+
* resolving a row that exists — so a deck whose filter selected zero rows would
|
|
109
|
+
* otherwise report every mapped column as unmapped. Malformed placeholders are
|
|
110
|
+
* skipped here and still raised by `resolveColumnExpr` on the first real row.
|
|
111
|
+
* Pure — unit-tested.
|
|
112
|
+
*/
|
|
113
|
+
export function columnExprPlaceholders(expr) {
|
|
114
|
+
const columns = [];
|
|
115
|
+
expr.replace(COLUMN_PLACEHOLDER, (_full, rawName) => {
|
|
116
|
+
const name = rawName.trim();
|
|
117
|
+
if (name !== "")
|
|
118
|
+
columns.push(name);
|
|
119
|
+
return "";
|
|
120
|
+
});
|
|
121
|
+
return columns;
|
|
122
|
+
}
|
|
98
123
|
/**
|
|
99
124
|
* Resolve a `card_deck_from_table` column expression against one row. A value is
|
|
100
125
|
* either a bare `{column}` or a composed template like `{name} · {role}`; every
|
|
@@ -106,7 +131,7 @@ const ALIGN_TO_ENUM = { left: 0, center: 1, right: 2 };
|
|
|
106
131
|
*/
|
|
107
132
|
export function resolveColumnExpr(expr, row) {
|
|
108
133
|
const columns = [];
|
|
109
|
-
const value = expr.replace(
|
|
134
|
+
const value = expr.replace(COLUMN_PLACEHOLDER, (_full, rawName) => {
|
|
110
135
|
const name = rawName.trim();
|
|
111
136
|
if (name === "")
|
|
112
137
|
throw new ComposeError("bad_params", `Empty {} placeholder in column expression ${JSON.stringify(expr)}`);
|
|
@@ -534,12 +559,21 @@ export async function emitDeckFromTable(emit, readFile, args) {
|
|
|
534
559
|
for (const r of allRows)
|
|
535
560
|
for (const k of Object.keys(r))
|
|
536
561
|
header.add(k);
|
|
537
|
-
//
|
|
562
|
+
// Which columns are actually referenced (by a placeholder, art, or filter)?
|
|
563
|
+
// All three come off the ARGUMENTS, never off the selected rows. Reading the
|
|
564
|
+
// column_map inside the stamping loop below made this asymmetric: art_column and
|
|
565
|
+
// filter.column counted as referenced whatever the filter matched, while the
|
|
566
|
+
// mapped slots only counted once a row existed to resolve them against — so a
|
|
567
|
+
// filter matching zero rows reported every explicitly mapped column as unmapped.
|
|
538
568
|
const referenced = new Set();
|
|
539
569
|
if (args.art_column)
|
|
540
570
|
referenced.add(args.art_column);
|
|
541
571
|
if (args.filter)
|
|
542
572
|
referenced.add(args.filter.column);
|
|
573
|
+
for (const expr of Object.values(args.column_map)) {
|
|
574
|
+
for (const c of columnExprPlaceholders(expr))
|
|
575
|
+
referenced.add(c);
|
|
576
|
+
}
|
|
543
577
|
// select rows: filter → limit.
|
|
544
578
|
let selected = args.filter
|
|
545
579
|
? allRows.filter((r) => String(r[args.filter.column] ?? "") === String(args.filter.equals))
|
|
@@ -552,9 +586,7 @@ export async function emitDeckFromTable(emit, readFile, args) {
|
|
|
552
586
|
const row = selected[i];
|
|
553
587
|
const data = {};
|
|
554
588
|
for (const [slot, expr] of Object.entries(args.column_map)) {
|
|
555
|
-
const { value
|
|
556
|
-
for (const c of columns)
|
|
557
|
-
referenced.add(c);
|
|
589
|
+
const { value } = resolveColumnExpr(expr, row);
|
|
558
590
|
data[slot] = value;
|
|
559
591
|
}
|
|
560
592
|
if (args.art_column && row[args.art_column])
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakpoint-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.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",
|