breakpoint-mcp 1.0.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/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/bridge.js +189 -0
- package/dist/config.js +71 -0
- package/dist/confirm.js +45 -0
- package/dist/csdap.js +331 -0
- package/dist/cslsp.js +217 -0
- package/dist/dap.js +341 -0
- package/dist/framing.js +125 -0
- package/dist/index.js +110 -0
- package/dist/logger.js +11 -0
- package/dist/lsp.js +219 -0
- package/dist/paths.js +28 -0
- package/dist/schemas.js +587 -0
- package/dist/stdio.js +101 -0
- package/dist/subscriptions.js +141 -0
- package/dist/tasks.js +146 -0
- package/dist/tools/assetgen.js +360 -0
- package/dist/tools/backend.js +532 -0
- package/dist/tools/cli.js +130 -0
- package/dist/tools/csdap.js +377 -0
- package/dist/tools/cslsp.js +285 -0
- package/dist/tools/dap.js +504 -0
- package/dist/tools/editor.js +1919 -0
- package/dist/tools/knowledge.js +517 -0
- package/dist/tools/lsp-common.js +121 -0
- package/dist/tools/lsp.js +576 -0
- package/dist/tools/netcode.js +411 -0
- package/dist/tools/processes.js +103 -0
- package/dist/tools/resources.js +27 -0
- package/dist/tools/runtime.js +125 -0
- package/package.json +57 -0
package/dist/dap.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { FramedConnection } from "./framing.js";
|
|
3
|
+
export class DapError extends Error {
|
|
4
|
+
command;
|
|
5
|
+
constructor(command, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.command = command;
|
|
8
|
+
this.name = "DapError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Minimal DAP client for Godot's Debug Adapter (raw TCP + DAP framing). Runs the
|
|
13
|
+
* initialize → (breakpoints) → configurationDone → launch/attach handshake, and
|
|
14
|
+
* tracks execution state from `stopped`/`terminated` events. One session.
|
|
15
|
+
*/
|
|
16
|
+
export class DapClient extends EventEmitter {
|
|
17
|
+
timeoutMs;
|
|
18
|
+
conn;
|
|
19
|
+
seq = 1;
|
|
20
|
+
pending = new Map();
|
|
21
|
+
breakpoints = new Map();
|
|
22
|
+
configured = false;
|
|
23
|
+
/** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
|
|
24
|
+
watches = [];
|
|
25
|
+
/** The mode + args of the last start(), so restart() can reuse/override them. */
|
|
26
|
+
lastStartMode = null;
|
|
27
|
+
lastStartArgs = null;
|
|
28
|
+
capabilities = null;
|
|
29
|
+
state = "disconnected";
|
|
30
|
+
lastStoppedThreadId = null;
|
|
31
|
+
lastStoppedReason = null;
|
|
32
|
+
constructor(host, port, timeoutMs) {
|
|
33
|
+
super();
|
|
34
|
+
this.timeoutMs = timeoutMs;
|
|
35
|
+
this.conn = new FramedConnection(host, port, "DAP", "Is the editor running with the Debug Adapter enabled (Editor Settings → Network → Debug Adapter, port 6006)?");
|
|
36
|
+
this.conn.onMessage((m) => this.onMessage(m));
|
|
37
|
+
this.conn.onClose(() => {
|
|
38
|
+
this.state = "terminated";
|
|
39
|
+
this.configured = false;
|
|
40
|
+
for (const [, p] of this.pending) {
|
|
41
|
+
clearTimeout(p.timer);
|
|
42
|
+
p.reject(new DapError(p.command, "DAP connection closed"));
|
|
43
|
+
}
|
|
44
|
+
this.pending.clear();
|
|
45
|
+
this.emit("closed");
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
onMessage(msg) {
|
|
49
|
+
const type = msg["type"];
|
|
50
|
+
if (type === "response") {
|
|
51
|
+
const reqSeq = msg["request_seq"];
|
|
52
|
+
const p = this.pending.get(reqSeq);
|
|
53
|
+
if (!p)
|
|
54
|
+
return;
|
|
55
|
+
this.pending.delete(reqSeq);
|
|
56
|
+
clearTimeout(p.timer);
|
|
57
|
+
if (msg["success"]) {
|
|
58
|
+
p.resolve((msg["body"] ?? {}));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
p.reject(new DapError(String(msg["command"] ?? p.command), String(msg["message"] ?? "DAP request failed")));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (type === "event") {
|
|
65
|
+
this.onEvent(String(msg["event"]), (msg["body"] ?? {}));
|
|
66
|
+
}
|
|
67
|
+
else if (type === "request") {
|
|
68
|
+
// Reverse request (e.g. runInTerminal). Ack success so we don't stall.
|
|
69
|
+
void this.conn.send({
|
|
70
|
+
seq: this.seq++,
|
|
71
|
+
type: "response",
|
|
72
|
+
request_seq: msg["seq"],
|
|
73
|
+
success: true,
|
|
74
|
+
command: msg["command"],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
onEvent(event, body) {
|
|
79
|
+
switch (event) {
|
|
80
|
+
case "initialized":
|
|
81
|
+
this.emit("initialized");
|
|
82
|
+
break;
|
|
83
|
+
case "stopped":
|
|
84
|
+
this.state = "stopped";
|
|
85
|
+
this.lastStoppedThreadId = body["threadId"] ?? this.lastStoppedThreadId ?? 1;
|
|
86
|
+
this.lastStoppedReason = body["reason"] ?? null;
|
|
87
|
+
this.emit("stopped", body);
|
|
88
|
+
break;
|
|
89
|
+
case "continued":
|
|
90
|
+
this.state = "running";
|
|
91
|
+
break;
|
|
92
|
+
case "terminated":
|
|
93
|
+
case "exited":
|
|
94
|
+
this.state = "terminated";
|
|
95
|
+
this.emit("terminated", body);
|
|
96
|
+
break;
|
|
97
|
+
case "output":
|
|
98
|
+
this.emit("output", body);
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
request(command, args = {}, timeoutMs = this.timeoutMs) {
|
|
105
|
+
const seq = this.seq++;
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
this.pending.delete(seq);
|
|
109
|
+
reject(new DapError(command, `DAP '${command}' timed out after ${timeoutMs}ms`));
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
this.pending.set(seq, { command, resolve: resolve, reject, timer });
|
|
112
|
+
this.conn.send({ seq, type: "request", command, arguments: args }).catch((err) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
this.pending.delete(seq);
|
|
115
|
+
reject(err);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
waitEvent(name, timeoutMs) {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
this.removeListener(name, onEvent);
|
|
123
|
+
resolve();
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
const onEvent = () => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
resolve();
|
|
128
|
+
};
|
|
129
|
+
this.once(name, onEvent);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Store breakpoints; apply immediately if the session is already configured. */
|
|
133
|
+
async setBreakpoints(path, lines, conditions, hitConditions, logMessages) {
|
|
134
|
+
this.breakpoints.set(path, { path, lines, conditions, hitConditions, logMessages });
|
|
135
|
+
if (this.configured) {
|
|
136
|
+
return this.applyBreakpoints(path);
|
|
137
|
+
}
|
|
138
|
+
return { buffered: true, path, lines };
|
|
139
|
+
}
|
|
140
|
+
applyBreakpoints(path) {
|
|
141
|
+
const bp = this.breakpoints.get(path);
|
|
142
|
+
if (!bp)
|
|
143
|
+
return Promise.resolve({});
|
|
144
|
+
return this.request("setBreakpoints", {
|
|
145
|
+
source: { path },
|
|
146
|
+
// DAP SourceBreakpoint: line + optional condition / hitCondition / logMessage.
|
|
147
|
+
// A logMessage turns the breakpoint into a logpoint (adapter logs, doesn't halt).
|
|
148
|
+
breakpoints: bp.lines.map((line, i) => {
|
|
149
|
+
const b = { line };
|
|
150
|
+
const condition = bp.conditions?.[i];
|
|
151
|
+
const hit = bp.hitConditions?.[i];
|
|
152
|
+
const log = bp.logMessages?.[i];
|
|
153
|
+
if (condition)
|
|
154
|
+
b.condition = condition;
|
|
155
|
+
if (hit)
|
|
156
|
+
b.hitCondition = hit;
|
|
157
|
+
if (log)
|
|
158
|
+
b.logMessage = log;
|
|
159
|
+
return b;
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// ---- Watch expressions ---------------------------------------------------
|
|
164
|
+
/** Add expressions to the persistent watch set (deduped, order-preserving). */
|
|
165
|
+
addWatches(expressions) {
|
|
166
|
+
for (const e of expressions)
|
|
167
|
+
if (e && !this.watches.includes(e))
|
|
168
|
+
this.watches.push(e);
|
|
169
|
+
}
|
|
170
|
+
/** Remove specific expressions from the watch set. */
|
|
171
|
+
removeWatches(expressions) {
|
|
172
|
+
const drop = new Set(expressions);
|
|
173
|
+
this.watches = this.watches.filter((e) => !drop.has(e));
|
|
174
|
+
}
|
|
175
|
+
/** Clear all watch expressions. */
|
|
176
|
+
clearWatches() {
|
|
177
|
+
this.watches = [];
|
|
178
|
+
}
|
|
179
|
+
/** The current watch set (a copy). */
|
|
180
|
+
listWatches() {
|
|
181
|
+
return [...this.watches];
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Evaluate every watch expression in the context of a stopped frame and return
|
|
185
|
+
* the results. Each expression is evaluated with DAP `context: "watch"` (the
|
|
186
|
+
* side-effect-free evaluation context IDEs use for watch panels). A single bad
|
|
187
|
+
* expression yields an `error` on that entry instead of failing the whole call.
|
|
188
|
+
*
|
|
189
|
+
* `timeoutMs` bounds each individual `evaluate` request (defaults to the client's full
|
|
190
|
+
* `timeoutMs`). Callers pass the shorter `dapEvaluateTimeoutMs` so a watch expression the
|
|
191
|
+
* adapter never answers fails fast on that entry — mirroring `dbg_evaluate` — instead of
|
|
192
|
+
* hanging the full 20 s DAP timeout per stalling expression at every stop.
|
|
193
|
+
*/
|
|
194
|
+
async evaluateWatches(frameId, timeoutMs = this.timeoutMs) {
|
|
195
|
+
const results = [];
|
|
196
|
+
for (const expression of this.watches) {
|
|
197
|
+
try {
|
|
198
|
+
const body = await this.request("evaluate", { expression, frameId, context: "watch" }, timeoutMs);
|
|
199
|
+
results.push({
|
|
200
|
+
expression,
|
|
201
|
+
value: String(body["result"] ?? ""),
|
|
202
|
+
type: String(body["type"] ?? ""),
|
|
203
|
+
error: null,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const e = err;
|
|
208
|
+
results.push({ expression, value: "", type: "", error: e.message ?? String(err) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
async applyAllBreakpoints() {
|
|
214
|
+
for (const path of this.breakpoints.keys()) {
|
|
215
|
+
await this.applyBreakpoints(path).catch(() => undefined);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Full handshake: initialize → launch/attach → (breakpoints) → configurationDone. */
|
|
219
|
+
async start(mode, args) {
|
|
220
|
+
// Remember how we started so restart() can reuse (or override) these params.
|
|
221
|
+
this.lastStartMode = mode;
|
|
222
|
+
this.lastStartArgs = args;
|
|
223
|
+
// Listen for `initialized` before we ask, so we cannot miss it.
|
|
224
|
+
const onInit = this.waitEvent("initialized", Math.min(this.timeoutMs, 5000));
|
|
225
|
+
this.capabilities = await this.request("initialize", {
|
|
226
|
+
clientID: "breakpoint-mcp",
|
|
227
|
+
clientName: "Godot Breakpoint MCP",
|
|
228
|
+
adapterID: "godot",
|
|
229
|
+
pathFormat: "path",
|
|
230
|
+
linesStartAt1: true,
|
|
231
|
+
columnsStartAt1: true,
|
|
232
|
+
supportsRunInTerminalRequest: false,
|
|
233
|
+
});
|
|
234
|
+
this.state = "initialized";
|
|
235
|
+
// Send launch/attach but don't await it yet — many adapters only resolve it
|
|
236
|
+
// after configurationDone.
|
|
237
|
+
const startReq = this.request(mode, args);
|
|
238
|
+
await onInit;
|
|
239
|
+
await this.applyAllBreakpoints();
|
|
240
|
+
await this.request("configurationDone", {}).catch(() => undefined);
|
|
241
|
+
this.configured = true;
|
|
242
|
+
this.state = "running";
|
|
243
|
+
// Surface a launch/attach failure if one occurs, but don't hang on success.
|
|
244
|
+
startReq.catch((err) => this.emit("error", err));
|
|
245
|
+
}
|
|
246
|
+
threadId() {
|
|
247
|
+
return this.lastStoppedThreadId ?? 1;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Issue a resume command (continue / next / stepIn / stepOut) and wait for the
|
|
251
|
+
* program to settle again — i.e. the next `stopped` (hit a breakpoint / step
|
|
252
|
+
* landed) or `terminated` event — before returning. Without this, step/continue
|
|
253
|
+
* returned instantly with a stale "running"/"stopped" state and no location.
|
|
254
|
+
*
|
|
255
|
+
* The stop listener is armed BEFORE the command is sent so a fast stop can't be
|
|
256
|
+
* missed. If nothing settles within `waitMs` (e.g. `continue` runs on with no
|
|
257
|
+
* further breakpoint), it resolves with the current state ("running").
|
|
258
|
+
*/
|
|
259
|
+
async resume(command, args, waitMs) {
|
|
260
|
+
const settled = new Promise((resolve) => {
|
|
261
|
+
const finish = () => {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
this.removeListener("stopped", onStop);
|
|
264
|
+
this.removeListener("terminated", onTerm);
|
|
265
|
+
resolve({ state: this.state, reason: this.lastStoppedReason });
|
|
266
|
+
};
|
|
267
|
+
const onStop = () => finish();
|
|
268
|
+
const onTerm = () => finish();
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
|
+
this.removeListener("stopped", onStop);
|
|
271
|
+
this.removeListener("terminated", onTerm);
|
|
272
|
+
resolve({ state: this.state, reason: this.lastStoppedReason });
|
|
273
|
+
}, waitMs);
|
|
274
|
+
this.once("stopped", onStop);
|
|
275
|
+
this.once("terminated", onTerm);
|
|
276
|
+
});
|
|
277
|
+
// Optimistically mark running so a stale "stopped" isn't reported back.
|
|
278
|
+
this.state = "running";
|
|
279
|
+
await this.request(command, args);
|
|
280
|
+
return settled;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Resolve when the program next settles (a `stopped` or `terminated` event) or
|
|
284
|
+
* `waitMs` elapses, whichever comes first — the shared wait used after a resume
|
|
285
|
+
* or a restart. Listeners are armed by the caller BEFORE the triggering request
|
|
286
|
+
* is sent so a fast settle can't be missed.
|
|
287
|
+
*/
|
|
288
|
+
settle(waitMs) {
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
const finish = () => {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
this.removeListener("stopped", onStop);
|
|
293
|
+
this.removeListener("terminated", onTerm);
|
|
294
|
+
resolve({ state: this.state, reason: this.lastStoppedReason });
|
|
295
|
+
};
|
|
296
|
+
const onStop = () => finish();
|
|
297
|
+
const onTerm = () => finish();
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
this.removeListener("stopped", onStop);
|
|
300
|
+
this.removeListener("terminated", onTerm);
|
|
301
|
+
resolve({ state: this.state, reason: this.lastStoppedReason });
|
|
302
|
+
}, waitMs);
|
|
303
|
+
this.once("stopped", onStop);
|
|
304
|
+
this.once("terminated", onTerm);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Restart the debug session. If the adapter advertises `supportsRestartRequest`,
|
|
309
|
+
* issue a single DAP `restart` (carrying the launch/attach args); otherwise fall
|
|
310
|
+
* back to `terminate` + a fresh handshake, so restart works on every adapter.
|
|
311
|
+
* Reuses the last dbg_launch/dbg_attach params; `overrideArgs` (e.g. a new scene
|
|
312
|
+
* or stopOnEntry) are merged over them. `method` tells the caller which path ran.
|
|
313
|
+
*/
|
|
314
|
+
async restart(overrideArgs = {}, waitMs = 15000) {
|
|
315
|
+
if (!this.lastStartMode || !this.lastStartArgs) {
|
|
316
|
+
throw new DapError("restart", "no debug session to restart — call dbg_launch or dbg_attach first");
|
|
317
|
+
}
|
|
318
|
+
const args = { ...this.lastStartArgs, ...overrideArgs };
|
|
319
|
+
const scene = typeof args["scene"] === "string" ? args["scene"] : null;
|
|
320
|
+
if (this.capabilities?.["supportsRestartRequest"] === true) {
|
|
321
|
+
// Arm the settle listener before issuing restart so a fast stop isn't missed.
|
|
322
|
+
const settled = this.settle(waitMs);
|
|
323
|
+
this.state = "running";
|
|
324
|
+
await this.request("restart", { arguments: args });
|
|
325
|
+
this.lastStartArgs = args;
|
|
326
|
+
const r = await settled;
|
|
327
|
+
return { method: "restart", state: r.state, reason: r.reason, scene };
|
|
328
|
+
}
|
|
329
|
+
// Fallback: ask the debuggee to terminate (best-effort), then re-run the full
|
|
330
|
+
// initialize → launch/attach → configurationDone handshake with the same args.
|
|
331
|
+
await this.request("terminate", {}).catch(() => undefined);
|
|
332
|
+
await this.start(this.lastStartMode, args);
|
|
333
|
+
return { method: "relaunch", state: this.state, reason: this.lastStoppedReason, scene };
|
|
334
|
+
}
|
|
335
|
+
close() {
|
|
336
|
+
this.conn.close();
|
|
337
|
+
this.state = "disconnected";
|
|
338
|
+
this.configured = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
//# sourceMappingURL=dap.js.map
|
package/dist/framing.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { log } from "./logger.js";
|
|
3
|
+
/** Serialize a JSON message as an LSP/DAP `Content-Length` frame. */
|
|
4
|
+
export function encodeFrame(msg) {
|
|
5
|
+
const body = Buffer.from(JSON.stringify(msg), "utf8");
|
|
6
|
+
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii");
|
|
7
|
+
return Buffer.concat([header, body]);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Incremental `Content-Length` frame decoder. Feed it chunks (from a socket or a
|
|
11
|
+
* child process's stdout) with `push`; it invokes `onMessage` for every complete
|
|
12
|
+
* JSON body and buffers the remainder. A malformed header block is skipped so the
|
|
13
|
+
* stream resyncs rather than wedging. Factored out of `FramedConnection` so the
|
|
14
|
+
* stdio transport reuses the exact same, tested, framing.
|
|
15
|
+
*/
|
|
16
|
+
export class FrameDecoder {
|
|
17
|
+
onMessage;
|
|
18
|
+
label;
|
|
19
|
+
buffer = Buffer.alloc(0);
|
|
20
|
+
constructor(onMessage, label) {
|
|
21
|
+
this.onMessage = onMessage;
|
|
22
|
+
this.label = label;
|
|
23
|
+
}
|
|
24
|
+
push(chunk) {
|
|
25
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
26
|
+
this.buffer = Buffer.concat([this.buffer, buf]);
|
|
27
|
+
for (;;) {
|
|
28
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
29
|
+
if (headerEnd === -1)
|
|
30
|
+
return;
|
|
31
|
+
const header = this.buffer.subarray(0, headerEnd).toString("ascii");
|
|
32
|
+
const match = /Content-Length:\s*(\d+)/i.exec(header);
|
|
33
|
+
if (!match) {
|
|
34
|
+
// Malformed header block — skip it and resync.
|
|
35
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const length = Number.parseInt(match[1], 10);
|
|
39
|
+
const bodyStart = headerEnd + 4;
|
|
40
|
+
if (this.buffer.length < bodyStart + length)
|
|
41
|
+
return; // wait for the rest
|
|
42
|
+
const body = this.buffer.subarray(bodyStart, bodyStart + length).toString("utf8");
|
|
43
|
+
this.buffer = this.buffer.subarray(bodyStart + length);
|
|
44
|
+
try {
|
|
45
|
+
this.onMessage(JSON.parse(body));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
log(`${this.label} received unparseable frame (${length} bytes)`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
reset() {
|
|
53
|
+
this.buffer = Buffer.alloc(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Raw-TCP transport with LSP/DAP `Content-Length` framing:
|
|
58
|
+
*
|
|
59
|
+
* Content-Length: <n>\r\n\r\n<n bytes of UTF-8 JSON>
|
|
60
|
+
*
|
|
61
|
+
* Godot serves BOTH its GDScript language server (LSP, JSON-RPC 2.0) and its
|
|
62
|
+
* Debug Adapter (DAP, seq/type/command) this way, so this one class underpins
|
|
63
|
+
* both protocol clients. Connects lazily on first send; reconnects after drop.
|
|
64
|
+
*/
|
|
65
|
+
export class FramedConnection {
|
|
66
|
+
host;
|
|
67
|
+
port;
|
|
68
|
+
label;
|
|
69
|
+
unavailableHint;
|
|
70
|
+
socket = null;
|
|
71
|
+
connecting = null;
|
|
72
|
+
decoder;
|
|
73
|
+
messageCb = () => { };
|
|
74
|
+
closeCb = () => { };
|
|
75
|
+
constructor(host, port, label, unavailableHint) {
|
|
76
|
+
this.host = host;
|
|
77
|
+
this.port = port;
|
|
78
|
+
this.label = label;
|
|
79
|
+
this.unavailableHint = unavailableHint;
|
|
80
|
+
this.decoder = new FrameDecoder((m) => this.messageCb(m), label);
|
|
81
|
+
}
|
|
82
|
+
onMessage(cb) {
|
|
83
|
+
this.messageCb = cb;
|
|
84
|
+
}
|
|
85
|
+
onClose(cb) {
|
|
86
|
+
this.closeCb = cb;
|
|
87
|
+
}
|
|
88
|
+
connect() {
|
|
89
|
+
if (this.socket && !this.socket.destroyed)
|
|
90
|
+
return Promise.resolve(this.socket);
|
|
91
|
+
if (this.connecting)
|
|
92
|
+
return this.connecting;
|
|
93
|
+
this.connecting = new Promise((resolve, reject) => {
|
|
94
|
+
const socket = net.createConnection({ host: this.host, port: this.port });
|
|
95
|
+
socket.setNoDelay(true);
|
|
96
|
+
socket.once("connect", () => {
|
|
97
|
+
this.socket = socket;
|
|
98
|
+
this.connecting = null;
|
|
99
|
+
log(`${this.label} connected to ${this.host}:${this.port}`);
|
|
100
|
+
resolve(socket);
|
|
101
|
+
});
|
|
102
|
+
socket.once("error", (err) => {
|
|
103
|
+
this.connecting = null;
|
|
104
|
+
reject(new Error(`${this.label} unavailable at ${this.host}:${this.port}. ${this.unavailableHint} (${err.message})`));
|
|
105
|
+
});
|
|
106
|
+
socket.on("data", (chunk) => this.decoder.push(chunk));
|
|
107
|
+
socket.on("close", () => {
|
|
108
|
+
this.socket = null;
|
|
109
|
+
this.decoder.reset();
|
|
110
|
+
this.closeCb();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
return this.connecting;
|
|
114
|
+
}
|
|
115
|
+
async send(msg) {
|
|
116
|
+
const socket = await this.connect();
|
|
117
|
+
socket.write(encodeFrame(msg));
|
|
118
|
+
}
|
|
119
|
+
close() {
|
|
120
|
+
if (this.socket)
|
|
121
|
+
this.socket.destroy();
|
|
122
|
+
this.socket = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=framing.js.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { envCompat, loadConfig } from "./config.js";
|
|
5
|
+
import { BridgeClient } from "./bridge.js";
|
|
6
|
+
import { LspClient } from "./lsp.js";
|
|
7
|
+
import { CsLspClient } from "./cslsp.js";
|
|
8
|
+
import { CsDapClient } from "./csdap.js";
|
|
9
|
+
import { StdioChannel } from "./stdio.js";
|
|
10
|
+
import { DapClient } from "./dap.js";
|
|
11
|
+
import { registerCliTools } from "./tools/cli.js";
|
|
12
|
+
import { registerEditorTools } from "./tools/editor.js";
|
|
13
|
+
import { registerLspTools } from "./tools/lsp.js";
|
|
14
|
+
import { registerCsLspTools } from "./tools/cslsp.js";
|
|
15
|
+
import { registerDapTools } from "./tools/dap.js";
|
|
16
|
+
import { registerCsDapTools } from "./tools/csdap.js";
|
|
17
|
+
import { registerRuntimeTools } from "./tools/runtime.js";
|
|
18
|
+
import { registerProcessTools } from "./tools/processes.js";
|
|
19
|
+
import { registerKnowledgeTools } from "./tools/knowledge.js";
|
|
20
|
+
import { registerAssetGenTools } from "./tools/assetgen.js";
|
|
21
|
+
import { registerNetcodeTools } from "./tools/netcode.js";
|
|
22
|
+
import { registerBackendTools } from "./tools/backend.js";
|
|
23
|
+
import { registerResources } from "./tools/resources.js";
|
|
24
|
+
import { applyOutputSchemas } from "./schemas.js";
|
|
25
|
+
import { taskStore, TASK_CAPABILITIES } from "./tasks.js";
|
|
26
|
+
import { RESOURCE_CAPABILITIES, registerResourceSubscriptions } from "./subscriptions.js";
|
|
27
|
+
import { log } from "./logger.js";
|
|
28
|
+
async function main() {
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
const bridge = new BridgeClient(config.bridgeHost, config.bridgePort, config.bridgeTimeoutMs);
|
|
31
|
+
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.");
|
|
32
|
+
const lsp = new LspClient(config.lspHost, config.lspPort, config.projectUri, config.lspTimeoutMs);
|
|
33
|
+
// D4 C2: the C# semantic plane. OmniSharp is spawned over stdio (lazily, on the
|
|
34
|
+
// first cs_* call) against the C# project root — so a host without OmniSharp
|
|
35
|
+
// installed starts and runs the other planes unaffected.
|
|
36
|
+
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);
|
|
37
|
+
const dap = new DapClient(config.dapHost, config.dapPort, config.dapTimeoutMs);
|
|
38
|
+
// D4 C3: the C# debugging plane. netcoredbg is spawned over stdio (lazily, on
|
|
39
|
+
// the first cs_dbg_* call) — so a host without netcoredbg installed starts and
|
|
40
|
+
// runs every other plane unaffected.
|
|
41
|
+
const csDap = new CsDapClient(new StdioChannel(config.csDapCmd, config.csDapArgs, config.csDapProjectPath, "C# DAP (netcoredbg)", "Is netcoredbg installed and on PATH (or set GODOT_CSDAP_CMD to its binary), and GODOT_CSHARP_PROJECT pointed at a C# project?"), config.csDapTimeoutMs);
|
|
42
|
+
// D2: advertise the MCP task-execution model and hand the SDK a task store,
|
|
43
|
+
// so long jobs (export/import/headless script) support poll/await/cancel.
|
|
44
|
+
// D3: also advertise resources.subscribe so clients can subscribe to
|
|
45
|
+
// godot://… resources and receive notifications/resources/updated.
|
|
46
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.0.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
|
|
47
|
+
// B1: enforce frozen output schemas on every structured tool. Must run before
|
|
48
|
+
// the register*Tools calls below — it wraps server.registerTool.
|
|
49
|
+
applyOutputSchemas(server);
|
|
50
|
+
// Plane B (headless CLI): works without the editor running.
|
|
51
|
+
registerCliTools(server, config);
|
|
52
|
+
// Plane A (live editor): requires the editor open with the Breakpoint MCP plugin.
|
|
53
|
+
registerEditorTools(server, bridge);
|
|
54
|
+
// Plane D (semantic): connects to Godot's GDScript language server (LSP, 6005).
|
|
55
|
+
registerLspTools(server, lsp, config);
|
|
56
|
+
// Plane D (C# semantic, D4 C2): drives OmniSharp over stdio for the cs_* tools.
|
|
57
|
+
registerCsLspTools(server, csLsp, config);
|
|
58
|
+
// Plane D (debugging): connects to Godot's Debug Adapter (DAP, 6006).
|
|
59
|
+
registerDapTools(server, dap, config);
|
|
60
|
+
// Plane D (C# debugging, D4 C3): drives netcoredbg over stdio for the cs_dbg_* tools.
|
|
61
|
+
registerCsDapTools(server, csDap, config);
|
|
62
|
+
// Plane C (runtime): connects to the in-game runtime autoload (9081).
|
|
63
|
+
registerRuntimeTools(server, runtime);
|
|
64
|
+
// Phase 4: managed run + captured console output (transparent print() logs).
|
|
65
|
+
const processes = registerProcessTools(server, config);
|
|
66
|
+
// Group K: host-side knowledge & search (project grep, symbol/usage index, idiom lookup).
|
|
67
|
+
registerKnowledgeTools(server, config);
|
|
68
|
+
// Group J: AI asset generation (delegated backend / connected client; degrades
|
|
69
|
+
// to a request spec when no backend is configured). Writes + imports via the bridge.
|
|
70
|
+
registerAssetGenTools(server, bridge, config);
|
|
71
|
+
// Group M: native multiplayer & backend scaffolding (mp_*). Pure authoring —
|
|
72
|
+
// undoable node ops (spawner/synchronizer/authority) + gated GDScript codegen
|
|
73
|
+
// (enet/webrtc peer, @rpc wiring, lobby). Hosts nothing; scaffolds everything.
|
|
74
|
+
registerNetcodeTools(server, bridge, config);
|
|
75
|
+
// Group M (second half): backend-SDK integration scaffolding (backend_* / *_scaffold).
|
|
76
|
+
// Detects the installed SDK (SilentWolf/Nakama/PlayFab/Photon) and generates gated
|
|
77
|
+
// GDScript against it; degrades cleanly when the SDK is absent or lacks the feature.
|
|
78
|
+
registerBackendTools(server, bridge, config);
|
|
79
|
+
// Phase 4: MCP resources (scene tree, editor state, runtime tree/log, ClassDB docs).
|
|
80
|
+
registerResources(server, bridge, runtime);
|
|
81
|
+
// D3: resource subscriptions — push notifications/resources/updated when a
|
|
82
|
+
// subscribed godot://… resource changes (editor selection / edited scene, or
|
|
83
|
+
// the running game's live SceneTree). Rapid changes are coalesced per-URI; the
|
|
84
|
+
// trailing window is overridable via BREAKPOINT_RESOURCE_COALESCE_MS.
|
|
85
|
+
const coalesceRaw = envCompat("BREAKPOINT_RESOURCE_COALESCE_MS", "CLAUDE_RESOURCE_COALESCE_MS");
|
|
86
|
+
const coalesceMs = coalesceRaw ? Number.parseInt(coalesceRaw, 10) : undefined;
|
|
87
|
+
registerResourceSubscriptions(server, bridge, runtime, undefined, { coalesceMs });
|
|
88
|
+
const transport = new StdioServerTransport();
|
|
89
|
+
await server.connect(transport);
|
|
90
|
+
log(`ready · godotBin=${config.godotBin} · project=${config.projectPath} · ` +
|
|
91
|
+
`bridge=${config.bridgeHost}:${config.bridgePort} · runtime=${config.runtimeHost}:${config.runtimePort} · ` +
|
|
92
|
+
`lsp=${config.lspHost}:${config.lspPort} · dap=${config.dapHost}:${config.dapPort}`);
|
|
93
|
+
const shutdown = () => {
|
|
94
|
+
bridge.close();
|
|
95
|
+
runtime.close();
|
|
96
|
+
lsp.close();
|
|
97
|
+
csLsp.close();
|
|
98
|
+
dap.close();
|
|
99
|
+
csDap.close();
|
|
100
|
+
processes.killAll();
|
|
101
|
+
process.exit(0);
|
|
102
|
+
};
|
|
103
|
+
process.on("SIGINT", shutdown);
|
|
104
|
+
process.on("SIGTERM", shutdown);
|
|
105
|
+
}
|
|
106
|
+
main().catch((err) => {
|
|
107
|
+
log("fatal:", err instanceof Error ? err.stack ?? err.message : String(err));
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|
|
110
|
+
//# sourceMappingURL=index.js.map
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All diagnostic logging MUST go to stderr. stdout is reserved for the MCP
|
|
3
|
+
* stdio transport (JSON-RPC frames); writing anything else there corrupts it.
|
|
4
|
+
*/
|
|
5
|
+
export function log(...args) {
|
|
6
|
+
const line = args
|
|
7
|
+
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
8
|
+
.join(" ");
|
|
9
|
+
process.stderr.write(`[breakpoint-mcp] ${line}\n`);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=logger.js.map
|