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.
@@ -0,0 +1,411 @@
1
+ import { z } from "zod";
2
+ import { gate } from "../confirm.js";
3
+ import { toFsPath, readFileText } from "../paths.js";
4
+ /**
5
+ * Group M — Netcode & backend scaffolding (native multiplayer family, `mp_*`).
6
+ *
7
+ * Principle: host nothing, scaffold everything. Running a relay / leaderboard-DB
8
+ * / save-store is a hosted service, outside the scope of editor control — but
9
+ * generating the integration (nodes, scripts, config) is in scope. So this
10
+ * family adds only *authoring*:
11
+ *
12
+ * Node authoring (undoable, over the editor bridge):
13
+ * - mp_add_spawner — a MultiplayerSpawner (spawn_path + spawnable scenes)
14
+ * - mp_add_synchronizer — a MultiplayerSynchronizer (+ a SceneReplicationConfig)
15
+ * - mp_set_authority — set_multiplayer_authority(peer_id, recursive)
16
+ *
17
+ * Codegen (writes a `res://…gd`, elicitation-gated — the resource_create model):
18
+ * - mp_setup_enet_peer — an ENetMultiplayerPeer host/join helper script
19
+ * - mp_setup_webrtc_peer — a WebRTCMultiplayerPeer mesh helper (feature-detected:
20
+ * degrades to "unsupported" when the WebRTC module is
21
+ * absent, never a dead call)
22
+ * - mp_wire_rpc — insert an `@rpc(...)` annotation on a function in an
23
+ * existing script (appends a stub if the func is absent)
24
+ * - mp_scaffold_lobby — a lobby controller script (host/join + peer tracking)
25
+ *
26
+ * The generated GDScript is built HOST-side (so the templates are unit-tested),
27
+ * then written by the editor's FileAccess through the `mp.write_script` bridge
28
+ * method (which also triggers a filesystem rescan). We host nothing: no relay, no
29
+ * leaderboard DB, no save storage — bring your own backend, we wire it for you.
30
+ */
31
+ // ---- result envelopes (mirror tools/editor.ts + tools/assetgen.ts) ----
32
+ function ok(obj) {
33
+ return {
34
+ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
35
+ structuredContent: obj,
36
+ };
37
+ }
38
+ function fail(err) {
39
+ const be = err;
40
+ const code = be?.code ?? "error";
41
+ const message = be?.message ?? String(err);
42
+ return {
43
+ isError: true,
44
+ content: [{ type: "text", text: `Netcode scaffold error [${code}]: ${message}` }],
45
+ };
46
+ }
47
+ // ------------------------------------------------------------- codegen ----
48
+ // Pure, exported for unit tests. GDScript is tab-indented (matches operations.gd).
49
+ const GEN_HEADER = "## Generated by Breakpoint MCP";
50
+ /** Optional `class_name X` line + `extends Base`, then the header comment. */
51
+ function scriptPreamble(extendsType, className, note) {
52
+ const cn = className ? `class_name ${className}\n` : "";
53
+ return `${cn}extends ${extendsType}\n${GEN_HEADER} — ${note}\n`;
54
+ }
55
+ export function buildEnetScript(opts) {
56
+ return (scriptPreamble("Node", opts.className, "mp_setup_enet_peer") +
57
+ "## Attach to an autoload (or any Node), then call host_game() or join_game().\n\n" +
58
+ `const DEFAULT_PORT := ${opts.port}\n` +
59
+ `const MAX_CLIENTS := ${opts.maxClients}\n\n` +
60
+ "func host_game(port: int = DEFAULT_PORT) -> Error:\n" +
61
+ "\tvar peer := ENetMultiplayerPeer.new()\n" +
62
+ "\tvar err := peer.create_server(port, MAX_CLIENTS)\n" +
63
+ "\tif err != OK:\n" +
64
+ "\t\tpush_error(\"create_server failed: %d\" % err)\n" +
65
+ "\t\treturn err\n" +
66
+ "\tmultiplayer.multiplayer_peer = peer\n" +
67
+ "\treturn OK\n\n" +
68
+ "func join_game(address: String = \"127.0.0.1\", port: int = DEFAULT_PORT) -> Error:\n" +
69
+ "\tvar peer := ENetMultiplayerPeer.new()\n" +
70
+ "\tvar err := peer.create_client(address, port)\n" +
71
+ "\tif err != OK:\n" +
72
+ "\t\tpush_error(\"create_client failed: %d\" % err)\n" +
73
+ "\t\treturn err\n" +
74
+ "\tmultiplayer.multiplayer_peer = peer\n" +
75
+ "\treturn OK\n\n" +
76
+ "func close() -> void:\n" +
77
+ "\tmultiplayer.multiplayer_peer = null\n");
78
+ }
79
+ export function buildWebrtcScript(opts) {
80
+ return (scriptPreamble("Node", opts.className, "mp_setup_webrtc_peer") +
81
+ "## Requires the WebRTC module/extension. Signaling (SDP/ICE exchange) is\n" +
82
+ "## application-specific — wire create_offer/set_remote_description in your layer.\n\n" +
83
+ "func create_mesh(peer_id: int) -> WebRTCMultiplayerPeer:\n" +
84
+ "\tvar peer := WebRTCMultiplayerPeer.new()\n" +
85
+ "\tpeer.create_mesh(peer_id)\n" +
86
+ "\tmultiplayer.multiplayer_peer = peer\n" +
87
+ "\treturn peer\n\n" +
88
+ "func add_peer(peer: WebRTCMultiplayerPeer, connection: WebRTCPeerConnection, peer_id: int) -> void:\n" +
89
+ "\tpeer.add_peer(connection, peer_id)\n");
90
+ }
91
+ export function buildLobbyScript(opts) {
92
+ return (scriptPreamble("Node", opts.className, "mp_scaffold_lobby") +
93
+ "## A multiplayer lobby controller — tracks connected peers and exposes host/join.\n" +
94
+ "## Attach to your lobby scene root. Uses Godot's built-in ENet high-level multiplayer.\n\n" +
95
+ "signal player_joined(peer_id: int)\n" +
96
+ "signal player_left(peer_id: int)\n" +
97
+ "signal server_started\n" +
98
+ "signal join_succeeded\n" +
99
+ "signal join_failed\n\n" +
100
+ `const DEFAULT_PORT := ${opts.port}\n` +
101
+ `const MAX_PLAYERS := ${opts.maxPlayers}\n\n` +
102
+ "var players: Dictionary = {}\n\n" +
103
+ "func _ready() -> void:\n" +
104
+ "\tmultiplayer.peer_connected.connect(_on_peer_connected)\n" +
105
+ "\tmultiplayer.peer_disconnected.connect(_on_peer_disconnected)\n" +
106
+ "\tmultiplayer.connected_to_server.connect(func() -> void: join_succeeded.emit())\n" +
107
+ "\tmultiplayer.connection_failed.connect(func() -> void: join_failed.emit())\n\n" +
108
+ "func host_game(port: int = DEFAULT_PORT) -> Error:\n" +
109
+ "\tvar peer := ENetMultiplayerPeer.new()\n" +
110
+ "\tvar err := peer.create_server(port, MAX_PLAYERS)\n" +
111
+ "\tif err != OK:\n" +
112
+ "\t\treturn err\n" +
113
+ "\tmultiplayer.multiplayer_peer = peer\n" +
114
+ "\tplayers[multiplayer.get_unique_id()] = true\n" +
115
+ "\tserver_started.emit()\n" +
116
+ "\treturn OK\n\n" +
117
+ "func join_game(address: String = \"127.0.0.1\", port: int = DEFAULT_PORT) -> Error:\n" +
118
+ "\tvar peer := ENetMultiplayerPeer.new()\n" +
119
+ "\tvar err := peer.create_client(address, port)\n" +
120
+ "\tif err != OK:\n" +
121
+ "\t\treturn err\n" +
122
+ "\tmultiplayer.multiplayer_peer = peer\n" +
123
+ "\treturn OK\n\n" +
124
+ "func get_player_ids() -> Array:\n" +
125
+ "\treturn players.keys()\n\n" +
126
+ "func _on_peer_connected(id: int) -> void:\n" +
127
+ "\tplayers[id] = true\n" +
128
+ "\tplayer_joined.emit(id)\n\n" +
129
+ "func _on_peer_disconnected(id: int) -> void:\n" +
130
+ "\tplayers.erase(id)\n" +
131
+ "\tplayer_left.emit(id)\n");
132
+ }
133
+ const RPC_MODES = ["authority", "any_peer"];
134
+ const RPC_TRANSFER = ["unreliable", "unreliable_ordered", "reliable"];
135
+ /** Build an `@rpc("mode", "call_local|call_remote", "transfer", channel)` annotation. */
136
+ export function rpcAnnotation(opts) {
137
+ const sync = opts.call_local ? "call_local" : "call_remote";
138
+ return `@rpc("${opts.mode}", "${sync}", "${opts.transfer_mode}", ${opts.channel})`;
139
+ }
140
+ function escapeRe(s) {
141
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
+ }
143
+ /**
144
+ * Insert (or replace) an `@rpc(...)` annotation above `func <fn>(`. If the
145
+ * function is not found, append a stub carrying the annotation. Returns the new
146
+ * source and whether a stub was created. Pure — unit-tested.
147
+ */
148
+ export function insertRpc(source, fn, annotation) {
149
+ const lines = source.split("\n");
150
+ const funcRe = new RegExp(`^(\\s*)func\\s+${escapeRe(fn)}\\s*\\(`);
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const m = lines[i].match(funcRe);
153
+ if (!m)
154
+ continue;
155
+ const indent = m[1] ?? "";
156
+ // If the nearest preceding non-blank line is already an @rpc, replace it.
157
+ let prev = i - 1;
158
+ while (prev >= 0 && lines[prev].trim() === "")
159
+ prev--;
160
+ if (prev >= 0 && lines[prev].trim().startsWith("@rpc")) {
161
+ lines[prev] = indent + annotation;
162
+ }
163
+ else {
164
+ lines.splice(i, 0, indent + annotation);
165
+ }
166
+ return { content: lines.join("\n"), stub_created: false };
167
+ }
168
+ let content = source.length === 0 || source.endsWith("\n") ? source : source + "\n";
169
+ content += `\n${annotation}\nfunc ${fn}() -> void:\n\tpass\n`;
170
+ return { content, stub_created: true };
171
+ }
172
+ // ------------------------------------------------------------- registration ----
173
+ export function registerNetcodeTools(server, bridge, config) {
174
+ const call = async (method, params = {}) => {
175
+ try {
176
+ return await bridge.request(method, params);
177
+ }
178
+ catch (err) {
179
+ throw err;
180
+ }
181
+ };
182
+ // Shared writer: pushes host-built GDScript through the editor's FileAccess.
183
+ // `require_class` (webrtc) makes the editor feature-detect before writing; an
184
+ // absent class comes back status:"unsupported" with nothing written.
185
+ async function writeScript(toPath, content, overwrite, requireClass) {
186
+ return (await call("mp.write_script", {
187
+ to_path: toPath,
188
+ content,
189
+ overwrite,
190
+ ...(requireClass ? { require_class: requireClass } : {}),
191
+ }));
192
+ }
193
+ // -------------------------------------------------------- mp_add_spawner ----
194
+ server.registerTool("mp_add_spawner", {
195
+ title: "Add MultiplayerSpawner",
196
+ description: "Add a MultiplayerSpawner node under a parent (undoable). Godot 4 high-level multiplayer replicates " +
197
+ "server-spawned nodes to clients: set spawn_path to the node whose children are auto-replicated, and " +
198
+ "register the scenes it may spawn. Pure node authoring — no service is started.",
199
+ inputSchema: {
200
+ parent_path: z.string().describe("Parent node path relative to the scene root; \".\" for the root"),
201
+ name: z.string().optional().describe("Node name (default MultiplayerSpawner)"),
202
+ spawn_path: z.string().optional().describe("NodePath (relative to the spawner) whose children are auto-replicated"),
203
+ spawnable_scenes: z.array(z.string()).optional().describe("res:// scenes the spawner may instantiate (add_spawnable_scene)"),
204
+ },
205
+ }, async ({ parent_path, name, spawn_path, spawnable_scenes }) => {
206
+ try {
207
+ return ok(await call("mp.add_spawner", {
208
+ parent_path,
209
+ ...(name !== undefined ? { name } : {}),
210
+ ...(spawn_path !== undefined ? { spawn_path } : {}),
211
+ ...(spawnable_scenes !== undefined ? { spawnable_scenes } : {}),
212
+ }));
213
+ }
214
+ catch (err) {
215
+ return fail(err);
216
+ }
217
+ });
218
+ // --------------------------------------------------- mp_add_synchronizer ----
219
+ server.registerTool("mp_add_synchronizer", {
220
+ title: "Add MultiplayerSynchronizer",
221
+ description: "Add a MultiplayerSynchronizer node under a parent (undoable) and, if properties are given, build a " +
222
+ "SceneReplicationConfig that replicates them. root_path is the node the property NodePaths are relative " +
223
+ "to (default \"..\", the synchronizer's parent). Property paths look like \".:position\" or \"Sprite2D:rotation\". " +
224
+ "Pure node authoring — no service is started.",
225
+ inputSchema: {
226
+ parent_path: z.string().describe("Parent node path relative to the scene root; \".\" for the root"),
227
+ name: z.string().optional().describe("Node name (default MultiplayerSynchronizer)"),
228
+ root_path: z.string().optional().describe("NodePath the replicated property paths are relative to (default \"..\")"),
229
+ properties: z.array(z.string()).optional().describe("Property NodePaths to replicate, e.g. [\".:position\", \".:rotation\"]"),
230
+ replication_mode: z.enum(["always", "on_change", "never"]).optional().describe("Replication mode for the properties (default always)"),
231
+ },
232
+ }, async ({ parent_path, name, root_path, properties, replication_mode }) => {
233
+ try {
234
+ return ok(await call("mp.add_synchronizer", {
235
+ parent_path,
236
+ ...(name !== undefined ? { name } : {}),
237
+ ...(root_path !== undefined ? { root_path } : {}),
238
+ ...(properties !== undefined ? { properties } : {}),
239
+ ...(replication_mode !== undefined ? { replication_mode } : {}),
240
+ }));
241
+ }
242
+ catch (err) {
243
+ return fail(err);
244
+ }
245
+ });
246
+ // -------------------------------------------------------- mp_set_authority ----
247
+ server.registerTool("mp_set_authority", {
248
+ title: "Set multiplayer authority",
249
+ description: "Set a node's multiplayer authority (set_multiplayer_authority) to a peer id (undoable). The authority peer " +
250
+ "is the one allowed to push \"authority\"-mode RPCs and MultiplayerSynchronizer state for that node. peer_id 1 " +
251
+ "is the server. Pure node authoring.",
252
+ inputSchema: {
253
+ path: z.string().describe("Node path relative to the scene root"),
254
+ peer_id: z.number().int().describe("Peer id to grant authority (1 = server)"),
255
+ recursive: z.boolean().optional().describe("Also apply to descendants (default true)"),
256
+ },
257
+ }, async ({ path, peer_id, recursive }) => {
258
+ try {
259
+ return ok(await call("mp.set_authority", {
260
+ path,
261
+ peer_id,
262
+ ...(recursive !== undefined ? { recursive } : {}),
263
+ }));
264
+ }
265
+ catch (err) {
266
+ return fail(err);
267
+ }
268
+ });
269
+ // -------------------------------------------------------- mp_setup_enet_peer ----
270
+ server.registerTool("mp_setup_enet_peer", {
271
+ title: "Scaffold an ENet peer script",
272
+ description: "Generate a GDScript helper that sets up an ENetMultiplayerPeer (host_game / join_game / close) and assigns " +
273
+ "multiplayer.multiplayer_peer. Godot's default, always-available transport. DESTRUCTIVE (writes a .gd file) — " +
274
+ "gated by confirmation. No service is started; you call host_game()/join_game() at runtime.",
275
+ inputSchema: {
276
+ to_path: z.string().describe("Destination res:// .gd path, e.g. res://net/enet_peer.gd"),
277
+ port: z.number().int().positive().optional().describe("Default port baked into the script (default 24565)"),
278
+ max_clients: z.number().int().positive().optional().describe("Server max clients (default 32)"),
279
+ class_name: z.string().optional().describe("Optional class_name for the generated script"),
280
+ overwrite: z.boolean().optional().describe("Overwrite if the file already exists (default false)"),
281
+ confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
282
+ },
283
+ }, async ({ to_path, port, max_clients, class_name, overwrite, confirm }) => {
284
+ if (!to_path.startsWith("res://") || !to_path.endsWith(".gd"))
285
+ return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
286
+ const blocked = await gate(server, confirm, `Write ENet peer script to ${to_path}`);
287
+ if (blocked)
288
+ return blocked;
289
+ const content = buildEnetScript({ className: class_name, port: port ?? 24565, maxClients: max_clients ?? 32 });
290
+ try {
291
+ const r = await writeScript(to_path, content, overwrite ?? false);
292
+ return ok({ status: String(r.status ?? "written"), kind: "enet", path: r.path ?? to_path, bytes: r.bytes ?? content.length, created: r.created ?? true, message: `Wrote an ENetMultiplayerPeer helper to ${to_path}.` });
293
+ }
294
+ catch (err) {
295
+ return fail(err);
296
+ }
297
+ });
298
+ // ------------------------------------------------------ mp_setup_webrtc_peer ----
299
+ server.registerTool("mp_setup_webrtc_peer", {
300
+ title: "Scaffold a WebRTC peer script",
301
+ description: "Generate a GDScript helper that sets up a WebRTCMultiplayerPeer mesh and assigns multiplayer.multiplayer_peer. " +
302
+ "FEATURE-DETECTED: if the WebRTC module/extension is absent from this Godot build, the tool degrades to a clear " +
303
+ "\"unsupported\" result and writes nothing (never a dead call). DESTRUCTIVE (writes a .gd file) — gated by confirmation.",
304
+ inputSchema: {
305
+ to_path: z.string().describe("Destination res:// .gd path, e.g. res://net/webrtc_peer.gd"),
306
+ class_name: z.string().optional().describe("Optional class_name for the generated script"),
307
+ overwrite: z.boolean().optional().describe("Overwrite if the file already exists (default false)"),
308
+ confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
309
+ },
310
+ }, async ({ to_path, class_name, overwrite, confirm }) => {
311
+ if (!to_path.startsWith("res://") || !to_path.endsWith(".gd"))
312
+ return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
313
+ const blocked = await gate(server, confirm, `Write WebRTC peer script to ${to_path}`);
314
+ if (blocked)
315
+ return blocked;
316
+ const content = buildWebrtcScript({ className: class_name });
317
+ try {
318
+ const r = await writeScript(to_path, content, overwrite ?? false, "WebRTCMultiplayerPeer");
319
+ if (r.status === "unsupported") {
320
+ return ok({
321
+ status: "unsupported",
322
+ kind: "webrtc",
323
+ path: null,
324
+ message: "WebRTCMultiplayerPeer is not available in this Godot build — enable the WebRTC module/extension, or use mp_setup_enet_peer.",
325
+ });
326
+ }
327
+ return ok({ status: String(r.status ?? "written"), kind: "webrtc", path: r.path ?? to_path, bytes: r.bytes ?? content.length, created: r.created ?? true, message: `Wrote a WebRTCMultiplayerPeer helper to ${to_path}.` });
328
+ }
329
+ catch (err) {
330
+ return fail(err);
331
+ }
332
+ });
333
+ // ------------------------------------------------------------- mp_wire_rpc ----
334
+ server.registerTool("mp_wire_rpc", {
335
+ title: "Wire an @rpc annotation",
336
+ description: "Insert (or replace) an `@rpc(...)` annotation above a function in an existing GDScript, so it can be called " +
337
+ "across the network. If the function does not exist, a stub is appended carrying the annotation. Operates on the " +
338
+ "on-disk file (save the script in the editor first if it has unsaved changes). DESTRUCTIVE (rewrites the .gd file) — gated.",
339
+ inputSchema: {
340
+ path: z.string().describe("res:// .gd script to modify"),
341
+ function: z.string().describe("Function name to annotate (a stub is created if absent)"),
342
+ mode: z.enum(RPC_MODES).optional().describe("Who may call it: 'authority' (default) or 'any_peer'"),
343
+ transfer_mode: z.enum(RPC_TRANSFER).optional().describe("Transfer mode: 'unreliable' (default), 'unreliable_ordered', or 'reliable'"),
344
+ call_local: z.boolean().optional().describe("Also invoke locally on the caller (default false → call_remote)"),
345
+ channel: z.number().int().nonnegative().optional().describe("Transfer channel (default 0)"),
346
+ confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
347
+ },
348
+ }, async ({ path, function: fn, mode, transfer_mode, call_local, channel, confirm }) => {
349
+ if (!path.startsWith("res://") || !path.endsWith(".gd"))
350
+ return fail({ code: "bad_params", message: "'path' must be a res:// .gd path" });
351
+ const source = readFileText(toFsPath(path, config.projectPath));
352
+ if (source === "")
353
+ return fail({ code: "not_found", message: `Cannot read ${path} (does it exist?)` });
354
+ const annotation = rpcAnnotation({
355
+ mode: mode ?? "authority",
356
+ call_local: call_local ?? false,
357
+ transfer_mode: transfer_mode ?? "unreliable",
358
+ channel: channel ?? 0,
359
+ });
360
+ const { content, stub_created } = insertRpc(source, fn, annotation);
361
+ const blocked = await gate(server, confirm, `Wire ${annotation} onto ${fn}() in ${path}`);
362
+ if (blocked)
363
+ return blocked;
364
+ try {
365
+ const r = await writeScript(path, content, true);
366
+ return ok({
367
+ status: String(r.status ?? "written"),
368
+ kind: "rpc",
369
+ path: r.path ?? path,
370
+ function: fn,
371
+ annotation,
372
+ stub_created,
373
+ bytes: r.bytes ?? content.length,
374
+ message: `${stub_created ? "Appended a stub and wired" : "Wired"} ${annotation} onto ${fn}() in ${path}.`,
375
+ });
376
+ }
377
+ catch (err) {
378
+ return fail(err);
379
+ }
380
+ });
381
+ // -------------------------------------------------------- mp_scaffold_lobby ----
382
+ server.registerTool("mp_scaffold_lobby", {
383
+ title: "Scaffold a multiplayer lobby",
384
+ description: "Generate a lobby controller GDScript: host/join over ENet plus peer_connected/peer_disconnected tracking with " +
385
+ "player_joined / player_left / server_started / join_succeeded / join_failed signals. Attach it to your lobby " +
386
+ "scene root. DESTRUCTIVE (writes a .gd file) — gated by confirmation. No service is started.",
387
+ inputSchema: {
388
+ to_path: z.string().describe("Destination res:// .gd path, e.g. res://net/lobby.gd"),
389
+ port: z.number().int().positive().optional().describe("Default port baked into the script (default 24565)"),
390
+ max_players: z.number().int().positive().optional().describe("Server max players (default 8)"),
391
+ class_name: z.string().optional().describe("Optional class_name for the generated script"),
392
+ overwrite: z.boolean().optional().describe("Overwrite if the file already exists (default false)"),
393
+ confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
394
+ },
395
+ }, async ({ to_path, port, max_players, class_name, overwrite, confirm }) => {
396
+ if (!to_path.startsWith("res://") || !to_path.endsWith(".gd"))
397
+ return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
398
+ const blocked = await gate(server, confirm, `Write multiplayer lobby script to ${to_path}`);
399
+ if (blocked)
400
+ return blocked;
401
+ const content = buildLobbyScript({ className: class_name, port: port ?? 24565, maxPlayers: max_players ?? 8 });
402
+ try {
403
+ const r = await writeScript(to_path, content, overwrite ?? false);
404
+ return ok({ status: String(r.status ?? "written"), kind: "lobby", path: r.path ?? to_path, bytes: r.bytes ?? content.length, created: r.created ?? true, message: `Wrote a multiplayer lobby controller to ${to_path}.` });
405
+ }
406
+ catch (err) {
407
+ return fail(err);
408
+ }
409
+ });
410
+ }
411
+ //# sourceMappingURL=netcode.js.map
@@ -0,0 +1,103 @@
1
+ import { spawn } from "node:child_process";
2
+ import { z } from "zod";
3
+ import { log } from "../logger.js";
4
+ import { ok } from "./lsp-common.js";
5
+ const LINE_CAP = 5000;
6
+ /**
7
+ * Runs Godot as a MANAGED child (piped stdio) so the host captures ALL stdout/
8
+ * stderr — including every `print()` and engine error — which the pure-GDScript
9
+ * runtime bridge cannot hook. Complements runtime_get_log with transparent,
10
+ * zero-instrumentation output capture.
11
+ */
12
+ export class ProcessRegistry {
13
+ procs = new Map();
14
+ counter = 0;
15
+ run(cfg, extraArgs) {
16
+ const id = `godot-${++this.counter}`;
17
+ const child = spawn(cfg.godotBin, ["--path", cfg.projectPath, ...extraArgs], {
18
+ cwd: cfg.projectPath,
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ });
21
+ const m = { id, child, lines: [], seq: 0, exited: false, exitCode: null };
22
+ const ingest = (stream) => (buf) => {
23
+ const text = typeof buf === "string" ? buf : buf.toString("utf8");
24
+ for (const line of text.split(/\r?\n/)) {
25
+ if (line.length === 0)
26
+ continue;
27
+ m.seq += 1;
28
+ m.lines.push({ seq: m.seq, stream, text: line });
29
+ if (m.lines.length > LINE_CAP)
30
+ m.lines.shift();
31
+ }
32
+ };
33
+ child.stdout?.on("data", ingest("stdout"));
34
+ child.stderr?.on("data", ingest("stderr"));
35
+ child.on("exit", (code) => {
36
+ m.exited = true;
37
+ m.exitCode = code;
38
+ log(`managed process ${id} exited (${code})`);
39
+ });
40
+ this.procs.set(id, m);
41
+ return m;
42
+ }
43
+ get(id) {
44
+ return this.procs.get(id);
45
+ }
46
+ killAll() {
47
+ for (const m of this.procs.values()) {
48
+ try {
49
+ m.child.kill();
50
+ }
51
+ catch {
52
+ /* ignore */
53
+ }
54
+ }
55
+ }
56
+ }
57
+ export function registerProcessTools(server, cfg) {
58
+ const registry = new ProcessRegistry();
59
+ server.registerTool("godot_run_managed", {
60
+ title: "Run project (managed, captured output)",
61
+ description: "Run the project as a managed child process with captured stdout/stderr, so godot_output can read ALL print()/error output. " +
62
+ "Returns a process id. Use this instead of godot_run_project when you want the game's console log.",
63
+ inputSchema: { scene: z.string().optional().describe("Optional res:// scene to run") },
64
+ }, async ({ scene }) => {
65
+ const m = registry.run(cfg, scene ? [scene] : []);
66
+ return ok({ id: m.id, pid: m.child.pid ?? null, running: true, scene: scene ?? null });
67
+ });
68
+ server.registerTool("godot_output", {
69
+ title: "Read managed process output",
70
+ description: "Read captured console output for a managed process (from godot_run_managed). Use since_seq for incremental reads.",
71
+ inputSchema: {
72
+ id: z.string().describe("Process id from godot_run_managed"),
73
+ since_seq: z.number().int().optional().describe("Only lines with seq greater than this (default 0)"),
74
+ stream: z.enum(["stdout", "stderr", "both"]).optional().describe("Filter by stream (default both)"),
75
+ },
76
+ }, async ({ id, since_seq, stream }) => {
77
+ const m = registry.get(id);
78
+ if (!m)
79
+ return { isError: true, content: [{ type: "text", text: `No managed process with id "${id}"` }] };
80
+ const since = since_seq ?? 0;
81
+ const want = stream ?? "both";
82
+ const lines = m.lines.filter((l) => l.seq > since && (want === "both" || l.stream === want));
83
+ return ok({ id, exited: m.exited, exit_code: m.exitCode, latest_seq: m.seq, lines });
84
+ });
85
+ server.registerTool("godot_stop", {
86
+ title: "Stop managed process",
87
+ description: "Terminate a managed process started by godot_run_managed.",
88
+ inputSchema: { id: z.string().describe("Process id from godot_run_managed") },
89
+ }, async ({ id }) => {
90
+ const m = registry.get(id);
91
+ if (!m)
92
+ return { isError: true, content: [{ type: "text", text: `No managed process with id "${id}"` }] };
93
+ try {
94
+ m.child.kill();
95
+ }
96
+ catch {
97
+ /* ignore */
98
+ }
99
+ return ok({ id, stopped: true });
100
+ });
101
+ return registry;
102
+ }
103
+ //# sourceMappingURL=processes.js.map
@@ -0,0 +1,27 @@
1
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * MCP resources — read-mostly context the assistant can pull on demand (and clients can
4
+ * subscribe to). Each wraps a bridge call and degrades to an informative JSON
5
+ * note when the source (editor/game) isn't currently reachable, rather than
6
+ * failing the read.
7
+ */
8
+ async function jsonResource(uriHref, fetcher) {
9
+ try {
10
+ const data = await fetcher();
11
+ return { contents: [{ uri: uriHref, mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
12
+ }
13
+ catch (err) {
14
+ const message = err?.message ?? String(err);
15
+ return {
16
+ contents: [{ uri: uriHref, mimeType: "application/json", text: JSON.stringify({ available: false, note: message }, null, 2) }],
17
+ };
18
+ }
19
+ }
20
+ export function registerResources(server, editor, runtime) {
21
+ server.registerResource("scene-tree", "godot://scene-tree", { title: "Edited scene tree", description: "Live node tree of the scene open in the editor.", mimeType: "application/json" }, async (uri) => jsonResource(uri.href, () => editor.request("scene.get_tree", {})));
22
+ server.registerResource("editor-state", "godot://editor-state", { title: "Editor state", description: "Currently edited scene, selection, and Godot version.", mimeType: "application/json" }, async (uri) => jsonResource(uri.href, () => editor.request("editor.get_state", {})));
23
+ server.registerResource("runtime-tree", "godot://runtime/tree", { title: "Runtime scene tree", description: "Live SceneTree of the running game.", mimeType: "application/json" }, async (uri) => jsonResource(uri.href, () => runtime.request("runtime.get_tree", {})));
24
+ server.registerResource("runtime-log", "godot://runtime/log", { title: "Runtime log", description: "Log ring buffer from the running game.", mimeType: "application/json" }, async (uri) => jsonResource(uri.href, () => runtime.request("runtime.get_log", { since_seq: 0, levels: [] })));
25
+ server.registerResource("class-doc", new ResourceTemplate("godot://class/{name}", { list: undefined }), { title: "ClassDB documentation", description: "Methods, properties, and signals of an engine class.", mimeType: "application/json" }, async (uri, vars) => jsonResource(uri.href, () => editor.request("classdb.get_class", { class_name: String(vars?.name ?? ""), include_inherited: false })));
26
+ }
27
+ //# sourceMappingURL=resources.js.map