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
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gate } from "../confirm.js";
|
|
3
|
+
/**
|
|
4
|
+
* Group M (second half) — Backend-SDK integration scaffolding (`backend_*`,
|
|
5
|
+
* `*_scaffold`).
|
|
6
|
+
*
|
|
7
|
+
* Principle: host nothing, scaffold everything. Running a leaderboard DB, a
|
|
8
|
+
* save-store or an auth service is a hosted service, outside the scope of editor
|
|
9
|
+
* control — but generating the integration against the game's *installed* SDK is
|
|
10
|
+
* in scope.
|
|
11
|
+
*
|
|
12
|
+
* Detection (read-only, over the editor bridge):
|
|
13
|
+
* - backend_detect — which of the known SDKs (SilentWolf / Nakama /
|
|
14
|
+
* PlayFab / Photon) are installed in this project,
|
|
15
|
+
* and how they were found (autoload / addon / class)
|
|
16
|
+
*
|
|
17
|
+
* Codegen (writes a `res://…gd`, elicitation-gated — the mp_* codegen model):
|
|
18
|
+
* - backend_configure — an SDK config/bootstrap autoload script
|
|
19
|
+
* - leaderboard_scaffold — submit/fetch leaderboard helpers
|
|
20
|
+
* - cloudsave_scaffold — save/load cloud-save helpers
|
|
21
|
+
* - auth_scaffold — login/register/logout helpers
|
|
22
|
+
*
|
|
23
|
+
* Every codegen tool is **feature-detected two ways, and never a dead call**:
|
|
24
|
+
* 1. If the requested SDK provides no such API (e.g. Photon has no
|
|
25
|
+
* leaderboard/cloud-save/auth — it is realtime transport), it degrades to
|
|
26
|
+
* `status:"unsupported_feature"` and writes nothing.
|
|
27
|
+
* 2. If the SDK is not installed in the project, it degrades to
|
|
28
|
+
* `status:"sdk_missing"` ("install <SDK> first") and writes nothing.
|
|
29
|
+
* Only when the SDK is both capable and installed is code generated. The
|
|
30
|
+
* GDScript is built HOST-side (so the templates are unit-tested) and written by
|
|
31
|
+
* the editor's FileAccess through the shared `mp.write_script` bridge method.
|
|
32
|
+
* We host nothing: no leaderboard DB, no save storage, no auth service — bring
|
|
33
|
+
* your own backend, we wire it for you.
|
|
34
|
+
*/
|
|
35
|
+
// ---- result envelopes (mirror tools/netcode.ts) --------------------------
|
|
36
|
+
function ok(obj) {
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
39
|
+
structuredContent: obj,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function fail(err) {
|
|
43
|
+
const be = err;
|
|
44
|
+
const code = be?.code ?? "error";
|
|
45
|
+
const message = be?.message ?? String(err);
|
|
46
|
+
return {
|
|
47
|
+
isError: true,
|
|
48
|
+
content: [{ type: "text", text: `Backend scaffold error [${code}]: ${message}` }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ---- the known SDKs + their capability matrix ----------------------------
|
|
52
|
+
// Capabilities are intrinsic to each SDK (independent of whether it is
|
|
53
|
+
// installed): Photon is realtime multiplayer/relay, so it has no
|
|
54
|
+
// leaderboard / cloud-save / auth surface to scaffold against.
|
|
55
|
+
export const BACKEND_SDKS = ["silentwolf", "nakama", "playfab", "photon"];
|
|
56
|
+
export const SDK_INFO = {
|
|
57
|
+
silentwolf: { label: "SilentWolf", docs: "https://silentwolf.com", auth: true, leaderboard: true, cloudsave: true },
|
|
58
|
+
nakama: { label: "Nakama", docs: "https://heroiclabs.com/docs/nakama/", auth: true, leaderboard: true, cloudsave: true },
|
|
59
|
+
playfab: { label: "PlayFab", docs: "https://learn.microsoft.com/gaming/playfab/", auth: true, leaderboard: true, cloudsave: true },
|
|
60
|
+
photon: { label: "Photon", docs: "https://www.photonengine.com/", auth: false, leaderboard: false, cloudsave: false },
|
|
61
|
+
};
|
|
62
|
+
/** Does this SDK provide the given feature? `configure` is universal. Pure. */
|
|
63
|
+
export function hasCapability(sdk, feature) {
|
|
64
|
+
if (feature === "configure")
|
|
65
|
+
return true;
|
|
66
|
+
const info = SDK_INFO[sdk];
|
|
67
|
+
return feature === "auth" ? info.auth : feature === "leaderboard" ? info.leaderboard : info.cloudsave;
|
|
68
|
+
}
|
|
69
|
+
/** SDK labels that provide `feature` (for the unsupported_feature message). Pure. */
|
|
70
|
+
export function supportersOf(feature) {
|
|
71
|
+
return BACKEND_SDKS.filter((s) => hasCapability(s, feature)).map((s) => SDK_INFO[s].label);
|
|
72
|
+
}
|
|
73
|
+
// ------------------------------------------------------------- codegen ----
|
|
74
|
+
// Pure, exported for unit tests. GDScript is tab-indented (matches operations.gd
|
|
75
|
+
// and tools/netcode.ts); no line is ever space-indented.
|
|
76
|
+
const GEN_HEADER = "## Generated by Breakpoint MCP";
|
|
77
|
+
/** Join GDScript lines (each carries its own leading tabs) + a trailing newline. */
|
|
78
|
+
function gd(lines) {
|
|
79
|
+
return lines.join("\n") + "\n";
|
|
80
|
+
}
|
|
81
|
+
/** A provided value, or a clearly-marked placeholder to fill in. */
|
|
82
|
+
function ph(value, placeholder) {
|
|
83
|
+
return value && value.length > 0 ? value : placeholder;
|
|
84
|
+
}
|
|
85
|
+
export function buildConfigScript(sdk, o = {}) {
|
|
86
|
+
switch (sdk) {
|
|
87
|
+
case "silentwolf":
|
|
88
|
+
return gd([
|
|
89
|
+
"extends Node",
|
|
90
|
+
`${GEN_HEADER} — backend_configure (SilentWolf)`,
|
|
91
|
+
"## Register this script as an autoload so configure() runs at startup.",
|
|
92
|
+
"## Requires the SilentWolf addon (res://addons/silent_wolf). Docs: https://silentwolf.com",
|
|
93
|
+
"",
|
|
94
|
+
`const API_KEY := "${ph(o.apiKey, "YOUR_SILENTWOLF_API_KEY")}"`,
|
|
95
|
+
`const GAME_ID := "${ph(o.gameId, "YOUR_SILENTWOLF_GAME_ID")}"`,
|
|
96
|
+
"",
|
|
97
|
+
"func _ready() -> void:",
|
|
98
|
+
"\tconfigure()",
|
|
99
|
+
"",
|
|
100
|
+
"func configure() -> void:",
|
|
101
|
+
"\tSilentWolf.configure({",
|
|
102
|
+
"\t\t\"api_key\": API_KEY,",
|
|
103
|
+
"\t\t\"game_id\": GAME_ID,",
|
|
104
|
+
"\t\t\"log_level\": 1,",
|
|
105
|
+
"\t})",
|
|
106
|
+
]);
|
|
107
|
+
case "nakama":
|
|
108
|
+
return gd([
|
|
109
|
+
"extends Node",
|
|
110
|
+
`${GEN_HEADER} — backend_configure (Nakama)`,
|
|
111
|
+
"## Register as an autoload so `client` is globally available.",
|
|
112
|
+
"## Requires the Nakama addon (res://addons/com.heroiclabs.nakama). Docs: https://heroiclabs.com/docs/nakama/",
|
|
113
|
+
"",
|
|
114
|
+
"const SCHEME := \"http\"",
|
|
115
|
+
`const HOST := "${ph(o.host, "127.0.0.1")}"`,
|
|
116
|
+
`const PORT := ${o.port ?? 7350}`,
|
|
117
|
+
`const SERVER_KEY := "${ph(o.serverKey, "defaultkey")}"`,
|
|
118
|
+
"",
|
|
119
|
+
"var client # NakamaClient",
|
|
120
|
+
"",
|
|
121
|
+
"func _ready() -> void:",
|
|
122
|
+
"\tconfigure()",
|
|
123
|
+
"",
|
|
124
|
+
"func configure() -> void:",
|
|
125
|
+
"\tclient = Nakama.create_client(SERVER_KEY, HOST, PORT, SCHEME)",
|
|
126
|
+
]);
|
|
127
|
+
case "playfab":
|
|
128
|
+
return gd([
|
|
129
|
+
"extends Node",
|
|
130
|
+
`${GEN_HEADER} — backend_configure (PlayFab)`,
|
|
131
|
+
"## Register as an autoload. Requires the godot-playfab addon.",
|
|
132
|
+
"## Docs: https://learn.microsoft.com/gaming/playfab/",
|
|
133
|
+
"",
|
|
134
|
+
`const TITLE_ID := "${ph(o.titleId, "YOUR_PLAYFAB_TITLE_ID")}"`,
|
|
135
|
+
"",
|
|
136
|
+
"func _ready() -> void:",
|
|
137
|
+
"\tconfigure()",
|
|
138
|
+
"",
|
|
139
|
+
"func configure() -> void:",
|
|
140
|
+
"\tPlayFabManager.settings.title_id = TITLE_ID",
|
|
141
|
+
]);
|
|
142
|
+
case "photon":
|
|
143
|
+
return gd([
|
|
144
|
+
"extends Node",
|
|
145
|
+
`${GEN_HEADER} — backend_configure (Photon)`,
|
|
146
|
+
"## Photon is realtime multiplayer (matchmaking / relay). Set your App ID + region.",
|
|
147
|
+
"## Requires a Photon Godot integration. Docs: https://www.photonengine.com/",
|
|
148
|
+
"",
|
|
149
|
+
`const APP_ID := "${ph(o.appId, "YOUR_PHOTON_APP_ID")}"`,
|
|
150
|
+
`const REGION := "${ph(o.region, "us")}"`,
|
|
151
|
+
"",
|
|
152
|
+
"func _ready() -> void:",
|
|
153
|
+
"\tconfigure()",
|
|
154
|
+
"",
|
|
155
|
+
"func configure() -> void:",
|
|
156
|
+
"\t# Wire APP_ID / REGION into your Photon client's connect settings.",
|
|
157
|
+
"\tprint(\"Photon configured: app=%s region=%s\" % [APP_ID, REGION])",
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function buildAuthScript(sdk) {
|
|
162
|
+
switch (sdk) {
|
|
163
|
+
case "silentwolf":
|
|
164
|
+
return gd([
|
|
165
|
+
"extends Node",
|
|
166
|
+
`${GEN_HEADER} — auth_scaffold (SilentWolf)`,
|
|
167
|
+
"## Requires the SilentWolf addon + a configured SilentWolf (see backend_configure).",
|
|
168
|
+
"",
|
|
169
|
+
"signal registered(success: bool, error: String)",
|
|
170
|
+
"signal logged_in(success: bool, error: String)",
|
|
171
|
+
"",
|
|
172
|
+
"func register(username: String, email: String, password: String, confirm_password: String) -> void:",
|
|
173
|
+
"\tvar res = await SilentWolf.Auth.register_player(username, email, password, confirm_password).sw_registration_complete",
|
|
174
|
+
"\tregistered.emit(bool(res.get(\"success\", false)), str(res.get(\"error\", \"\")))",
|
|
175
|
+
"",
|
|
176
|
+
"func login(username: String, password: String, remember: bool = false) -> void:",
|
|
177
|
+
"\tvar res = await SilentWolf.Auth.login_player(username, password, remember).sw_login_complete",
|
|
178
|
+
"\tlogged_in.emit(bool(res.get(\"success\", false)), str(res.get(\"error\", \"\")))",
|
|
179
|
+
"",
|
|
180
|
+
"func logout() -> void:",
|
|
181
|
+
"\tSilentWolf.Auth.logout_player()",
|
|
182
|
+
]);
|
|
183
|
+
case "nakama":
|
|
184
|
+
return gd([
|
|
185
|
+
"extends Node",
|
|
186
|
+
`${GEN_HEADER} — auth_scaffold (Nakama)`,
|
|
187
|
+
"## Requires a Nakama client (see backend_configure). Uses authenticate_email_async.",
|
|
188
|
+
"",
|
|
189
|
+
"signal authenticated(success: bool, error: String)",
|
|
190
|
+
"",
|
|
191
|
+
"var session # NakamaSession",
|
|
192
|
+
"",
|
|
193
|
+
"func authenticate_email(client, email: String, password: String, create: bool = true) -> void:",
|
|
194
|
+
"\tsession = await client.authenticate_email_async(email, password, null, create)",
|
|
195
|
+
"\tif session.is_exception():",
|
|
196
|
+
"\t\tauthenticated.emit(false, str(session.get_exception().message))",
|
|
197
|
+
"\telse:",
|
|
198
|
+
"\t\tauthenticated.emit(true, \"\")",
|
|
199
|
+
]);
|
|
200
|
+
case "playfab":
|
|
201
|
+
return gd([
|
|
202
|
+
"extends Node",
|
|
203
|
+
`${GEN_HEADER} — auth_scaffold (PlayFab)`,
|
|
204
|
+
"## Requires the godot-playfab addon + a configured title (see backend_configure).",
|
|
205
|
+
"",
|
|
206
|
+
"func login_with_email(email: String, password: String) -> void:",
|
|
207
|
+
"\tvar req := PlayFabManager.client.LoginWithEmailAddress(email, password)",
|
|
208
|
+
"\treq.connect(\"result\", _on_login_result)",
|
|
209
|
+
"\treq.connect(\"error\", _on_login_error)",
|
|
210
|
+
"",
|
|
211
|
+
"func register(email: String, password: String, username: String) -> void:",
|
|
212
|
+
"\tvar req := PlayFabManager.client.RegisterPlayFabUser(email, password, username)",
|
|
213
|
+
"\treq.connect(\"result\", _on_login_result)",
|
|
214
|
+
"\treq.connect(\"error\", _on_login_error)",
|
|
215
|
+
"",
|
|
216
|
+
"func _on_login_result(result) -> void:",
|
|
217
|
+
"\tprint(\"PlayFab login OK: \", result)",
|
|
218
|
+
"",
|
|
219
|
+
"func _on_login_error(error) -> void:",
|
|
220
|
+
"\tpush_error(\"PlayFab login failed: %s\" % error)",
|
|
221
|
+
]);
|
|
222
|
+
case "photon":
|
|
223
|
+
// Guarded by hasCapability before we ever get here; keep the switch total.
|
|
224
|
+
throw new Error("Photon does not provide an auth API");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function buildLeaderboardScript(sdk, opts = { leaderboard: "main" }) {
|
|
228
|
+
const name = opts.leaderboard;
|
|
229
|
+
switch (sdk) {
|
|
230
|
+
case "silentwolf":
|
|
231
|
+
return gd([
|
|
232
|
+
"extends Node",
|
|
233
|
+
`${GEN_HEADER} — leaderboard_scaffold (SilentWolf) — board "${name}"`,
|
|
234
|
+
"## Requires the SilentWolf addon + a configured SilentWolf (see backend_configure).",
|
|
235
|
+
"",
|
|
236
|
+
`const LEADERBOARD := "${name}"`,
|
|
237
|
+
"",
|
|
238
|
+
"func submit_score(player_name: String, score: int) -> void:",
|
|
239
|
+
"\tawait SilentWolf.Scores.save_score(player_name, score, LEADERBOARD).sw_save_score_complete",
|
|
240
|
+
"",
|
|
241
|
+
"func fetch_top(count: int = 10) -> Array:",
|
|
242
|
+
"\tvar res = await SilentWolf.Scores.get_scores(count, LEADERBOARD).sw_get_scores_complete",
|
|
243
|
+
"\treturn res.get(\"scores\", [])",
|
|
244
|
+
]);
|
|
245
|
+
case "nakama":
|
|
246
|
+
return gd([
|
|
247
|
+
"extends Node",
|
|
248
|
+
`${GEN_HEADER} — leaderboard_scaffold (Nakama) — id "${name}"`,
|
|
249
|
+
"## Requires a Nakama client + session (see backend_configure / auth_scaffold).",
|
|
250
|
+
"",
|
|
251
|
+
`const LEADERBOARD_ID := "${name}"`,
|
|
252
|
+
"",
|
|
253
|
+
"func submit_score(client, session, score: int) -> void:",
|
|
254
|
+
"\tawait client.write_leaderboard_record_async(session, LEADERBOARD_ID, score)",
|
|
255
|
+
"",
|
|
256
|
+
"func fetch_top(client, session, count: int = 10) -> Array:",
|
|
257
|
+
"\tvar records = await client.list_leaderboard_records_async(session, LEADERBOARD_ID, [], count)",
|
|
258
|
+
"\treturn records.records",
|
|
259
|
+
]);
|
|
260
|
+
case "playfab":
|
|
261
|
+
return gd([
|
|
262
|
+
"extends Node",
|
|
263
|
+
`${GEN_HEADER} — leaderboard_scaffold (PlayFab) — statistic "${name}"`,
|
|
264
|
+
"## Requires the godot-playfab addon + a configured title (see backend_configure).",
|
|
265
|
+
"",
|
|
266
|
+
`const STATISTIC := "${name}"`,
|
|
267
|
+
"",
|
|
268
|
+
"func submit_score(value: int) -> void:",
|
|
269
|
+
"\tPlayFabManager.client.UpdatePlayerStatistics([{ \"StatisticName\": STATISTIC, \"Value\": value }])",
|
|
270
|
+
"",
|
|
271
|
+
"func fetch_top(count: int = 10) -> void:",
|
|
272
|
+
"\tvar req := PlayFabManager.client.GetLeaderboard(STATISTIC, 0, count)",
|
|
273
|
+
"\treq.connect(\"result\", _on_leaderboard)",
|
|
274
|
+
"",
|
|
275
|
+
"func _on_leaderboard(result) -> void:",
|
|
276
|
+
"\tprint(\"PlayFab leaderboard: \", result)",
|
|
277
|
+
]);
|
|
278
|
+
case "photon":
|
|
279
|
+
throw new Error("Photon does not provide a leaderboard API");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
export function buildCloudSaveScript(sdk) {
|
|
283
|
+
switch (sdk) {
|
|
284
|
+
case "silentwolf":
|
|
285
|
+
return gd([
|
|
286
|
+
"extends Node",
|
|
287
|
+
`${GEN_HEADER} — cloudsave_scaffold (SilentWolf)`,
|
|
288
|
+
"## Requires the SilentWolf addon + a configured SilentWolf (see backend_configure).",
|
|
289
|
+
"",
|
|
290
|
+
"func save_data(player_name: String, data: Dictionary) -> void:",
|
|
291
|
+
"\tawait SilentWolf.Players.save_player_data(player_name, data).sw_save_player_data_complete",
|
|
292
|
+
"",
|
|
293
|
+
"func load_data(player_name: String) -> Dictionary:",
|
|
294
|
+
"\tvar res = await SilentWolf.Players.get_player_data(player_name).sw_get_player_data_complete",
|
|
295
|
+
"\treturn res.get(\"player_data\", {})",
|
|
296
|
+
]);
|
|
297
|
+
case "nakama":
|
|
298
|
+
return gd([
|
|
299
|
+
"extends Node",
|
|
300
|
+
`${GEN_HEADER} — cloudsave_scaffold (Nakama)`,
|
|
301
|
+
"## Requires a Nakama client + session (see backend_configure / auth_scaffold).",
|
|
302
|
+
"",
|
|
303
|
+
"const COLLECTION := \"savegame\"",
|
|
304
|
+
"",
|
|
305
|
+
"func save_data(client, session, key: String, data: Dictionary) -> void:",
|
|
306
|
+
"\tvar write := NakamaWriteStorageObject.new(COLLECTION, key, 1, 1, JSON.stringify(data), \"\")",
|
|
307
|
+
"\tawait client.write_storage_objects_async(session, [write])",
|
|
308
|
+
"",
|
|
309
|
+
"func load_data(client, session, key: String) -> Dictionary:",
|
|
310
|
+
"\tvar ids := [NakamaStorageObjectId.new(COLLECTION, key, session.user_id)]",
|
|
311
|
+
"\tvar objects = await client.read_storage_objects_async(session, ids)",
|
|
312
|
+
"\tif objects.objects.size() == 0:",
|
|
313
|
+
"\t\treturn {}",
|
|
314
|
+
"\treturn JSON.parse_string(objects.objects[0].value)",
|
|
315
|
+
]);
|
|
316
|
+
case "playfab":
|
|
317
|
+
return gd([
|
|
318
|
+
"extends Node",
|
|
319
|
+
`${GEN_HEADER} — cloudsave_scaffold (PlayFab)`,
|
|
320
|
+
"## Requires the godot-playfab addon + a configured title (see backend_configure).",
|
|
321
|
+
"",
|
|
322
|
+
"func save_data(data: Dictionary) -> void:",
|
|
323
|
+
"\tPlayFabManager.client.UpdateUserData(data)",
|
|
324
|
+
"",
|
|
325
|
+
"func load_data() -> void:",
|
|
326
|
+
"\tvar req := PlayFabManager.client.GetUserData([])",
|
|
327
|
+
"\treq.connect(\"result\", _on_user_data)",
|
|
328
|
+
"",
|
|
329
|
+
"func _on_user_data(result) -> void:",
|
|
330
|
+
"\tprint(\"PlayFab user data: \", result)",
|
|
331
|
+
]);
|
|
332
|
+
case "photon":
|
|
333
|
+
throw new Error("Photon does not provide a cloud-save API");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ------------------------------------------------------------- registration ----
|
|
337
|
+
export function registerBackendTools(server, bridge, _config) {
|
|
338
|
+
const call = async (method, params = {}) => bridge.request(method, params);
|
|
339
|
+
async function detect() {
|
|
340
|
+
const r = (await call("backend.detect"));
|
|
341
|
+
return { backends: r.backends ?? [], detected: r.detected ?? [] };
|
|
342
|
+
}
|
|
343
|
+
async function writeScript(toPath, content, overwrite) {
|
|
344
|
+
return (await call("mp.write_script", { to_path: toPath, content, overwrite }));
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The shared scaffolder for the four codegen tools. Two feature-detect gates
|
|
348
|
+
* before anything is written: (1) does this SDK provide the feature at all,
|
|
349
|
+
* (2) is it installed in the project. Either degrades cleanly (nothing
|
|
350
|
+
* written, not an error); only a capable + installed SDK reaches the writer.
|
|
351
|
+
*/
|
|
352
|
+
async function runScaffold(opts) {
|
|
353
|
+
const info = SDK_INFO[opts.sdk];
|
|
354
|
+
if (!opts.toPath.startsWith("res://") || !opts.toPath.endsWith(".gd")) {
|
|
355
|
+
return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
|
|
356
|
+
}
|
|
357
|
+
// (1) capability — intrinsic to the SDK, no bridge needed.
|
|
358
|
+
if (!hasCapability(opts.sdk, opts.feature)) {
|
|
359
|
+
return ok({
|
|
360
|
+
status: "unsupported_feature",
|
|
361
|
+
sdk: opts.sdk,
|
|
362
|
+
kind: opts.kind,
|
|
363
|
+
path: null,
|
|
364
|
+
message: `${info.label} does not provide a ${opts.feature} API — it is realtime transport only. Supported by: ${supportersOf(opts.feature).join(", ")}.`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// (2) installed? — ask the editor which SDKs are present.
|
|
368
|
+
let det;
|
|
369
|
+
try {
|
|
370
|
+
det = await detect();
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
return fail(err);
|
|
374
|
+
}
|
|
375
|
+
if (!det.detected.includes(opts.sdk)) {
|
|
376
|
+
return ok({
|
|
377
|
+
status: "sdk_missing",
|
|
378
|
+
sdk: opts.sdk,
|
|
379
|
+
kind: opts.kind,
|
|
380
|
+
path: null,
|
|
381
|
+
message: `The ${info.label} SDK is not installed in this project — install it first (${info.docs}), then re-run. Nothing was written.`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
// Capable + installed → generate. Writing a .gd is destructive → gated.
|
|
385
|
+
const blocked = await gate(server, opts.confirm, `Write ${opts.kind} scaffold (${info.label}) to ${opts.toPath}`);
|
|
386
|
+
if (blocked)
|
|
387
|
+
return blocked;
|
|
388
|
+
try {
|
|
389
|
+
const content = opts.build();
|
|
390
|
+
const r = await writeScript(opts.toPath, content, opts.overwrite);
|
|
391
|
+
return ok({
|
|
392
|
+
status: String(r.status ?? "written"),
|
|
393
|
+
sdk: opts.sdk,
|
|
394
|
+
kind: opts.kind,
|
|
395
|
+
path: r.path ?? opts.toPath,
|
|
396
|
+
bytes: r.bytes ?? content.length,
|
|
397
|
+
created: r.created ?? true,
|
|
398
|
+
message: `Wrote a ${opts.kind} scaffold for ${info.label} to ${opts.toPath}.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
return fail(err);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// ---------------------------------------------------------- backend_detect ----
|
|
406
|
+
server.registerTool("backend_detect", {
|
|
407
|
+
title: "Detect installed backend SDKs",
|
|
408
|
+
description: "Report which of the known game-backend SDKs (SilentWolf, Nakama, PlayFab, Photon) are installed in this " +
|
|
409
|
+
"project and how each was found (an enabled autoload, an addon directory under res://addons, or a global " +
|
|
410
|
+
"class_name). Read-only — nothing is written. Use it before backend_configure / *_scaffold to see what you " +
|
|
411
|
+
"can generate against.",
|
|
412
|
+
inputSchema: {
|
|
413
|
+
sdk: z.enum(BACKEND_SDKS).optional().describe("Focus the report on a single SDK (default: report all four)"),
|
|
414
|
+
},
|
|
415
|
+
}, async ({ sdk }) => {
|
|
416
|
+
try {
|
|
417
|
+
const det = await detect();
|
|
418
|
+
const backends = sdk ? det.backends.filter((b) => b.sdk === sdk) : det.backends;
|
|
419
|
+
const detected = sdk ? det.detected.filter((s) => s === sdk) : det.detected;
|
|
420
|
+
const message = detected.length
|
|
421
|
+
? `Detected: ${detected.map((s) => SDK_INFO[s]?.label ?? s).join(", ")}.`
|
|
422
|
+
: "No supported backend SDK detected. Install SilentWolf, Nakama, PlayFab or Photon to scaffold against it.";
|
|
423
|
+
return ok({ detected, backends, message });
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
return fail(err);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// ------------------------------------------------------- backend_configure ----
|
|
430
|
+
server.registerTool("backend_configure", {
|
|
431
|
+
title: "Scaffold a backend SDK config",
|
|
432
|
+
description: "Generate a config/bootstrap GDScript for a game-backend SDK (SilentWolf / Nakama / PlayFab / Photon): a Node " +
|
|
433
|
+
"whose configure() wires the API key / game id / host / title id from constants you fill in. Register it as an " +
|
|
434
|
+
"autoload so it runs at startup. FEATURE-DETECTED: if the SDK is not installed, degrades to a clear " +
|
|
435
|
+
"'install <SDK> first' result and writes nothing. DESTRUCTIVE (writes a .gd file) — gated by confirmation.",
|
|
436
|
+
inputSchema: {
|
|
437
|
+
sdk: z.enum(BACKEND_SDKS).describe("Which backend SDK to configure"),
|
|
438
|
+
to_path: z.string().optional().describe("Destination res:// .gd path (default res://backend/<sdk>_config.gd)"),
|
|
439
|
+
api_key: z.string().optional().describe("SilentWolf API key baked into the script"),
|
|
440
|
+
game_id: z.string().optional().describe("SilentWolf game id"),
|
|
441
|
+
title_id: z.string().optional().describe("PlayFab title id"),
|
|
442
|
+
app_id: z.string().optional().describe("Photon app id"),
|
|
443
|
+
host: z.string().optional().describe("Nakama host (default 127.0.0.1)"),
|
|
444
|
+
port: z.number().int().positive().optional().describe("Nakama port (default 7350)"),
|
|
445
|
+
server_key: z.string().optional().describe("Nakama server key (default defaultkey)"),
|
|
446
|
+
region: z.string().optional().describe("Photon region (default us)"),
|
|
447
|
+
overwrite: z.boolean().optional().describe("Overwrite if the file already exists (default false)"),
|
|
448
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
449
|
+
},
|
|
450
|
+
}, async ({ sdk, to_path, api_key, game_id, title_id, app_id, host, port, server_key, region, overwrite, confirm }) => {
|
|
451
|
+
return runScaffold({
|
|
452
|
+
sdk,
|
|
453
|
+
feature: "configure",
|
|
454
|
+
kind: "config",
|
|
455
|
+
toPath: to_path ?? `res://backend/${sdk}_config.gd`,
|
|
456
|
+
overwrite: overwrite ?? false,
|
|
457
|
+
confirm,
|
|
458
|
+
build: () => buildConfigScript(sdk, { apiKey: api_key, gameId: game_id, titleId: title_id, appId: app_id, host, port, serverKey: server_key, region }),
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
// ----------------------------------------------------- leaderboard_scaffold ----
|
|
462
|
+
server.registerTool("leaderboard_scaffold", {
|
|
463
|
+
title: "Scaffold leaderboard integration",
|
|
464
|
+
description: "Generate submit/fetch leaderboard helpers against an installed backend SDK. FEATURE-DETECTED both ways: if the " +
|
|
465
|
+
"SDK provides no leaderboard API (Photon is realtime transport), degrades to 'unsupported_feature'; if the SDK " +
|
|
466
|
+
"is not installed, degrades to 'install <SDK> first'. Neither writes anything. DESTRUCTIVE (writes a .gd) — gated.",
|
|
467
|
+
inputSchema: {
|
|
468
|
+
sdk: z.enum(BACKEND_SDKS).describe("Which backend SDK to target"),
|
|
469
|
+
to_path: z.string().optional().describe("Destination res:// .gd path (default res://backend/leaderboard.gd)"),
|
|
470
|
+
leaderboard_name: z.string().optional().describe("Leaderboard id / statistic name baked into the script (default 'main')"),
|
|
471
|
+
overwrite: z.boolean().optional().describe("Overwrite if the file already exists (default false)"),
|
|
472
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
473
|
+
},
|
|
474
|
+
}, async ({ sdk, to_path, leaderboard_name, overwrite, confirm }) => {
|
|
475
|
+
return runScaffold({
|
|
476
|
+
sdk,
|
|
477
|
+
feature: "leaderboard",
|
|
478
|
+
kind: "leaderboard",
|
|
479
|
+
toPath: to_path ?? "res://backend/leaderboard.gd",
|
|
480
|
+
overwrite: overwrite ?? false,
|
|
481
|
+
confirm,
|
|
482
|
+
build: () => buildLeaderboardScript(sdk, { leaderboard: leaderboard_name ?? "main" }),
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
// ------------------------------------------------------- cloudsave_scaffold ----
|
|
486
|
+
server.registerTool("cloudsave_scaffold", {
|
|
487
|
+
title: "Scaffold cloud-save integration",
|
|
488
|
+
description: "Generate save/load cloud-save helpers against an installed backend SDK. FEATURE-DETECTED both ways: if the SDK " +
|
|
489
|
+
"provides no cloud-save API (Photon is realtime transport), degrades to 'unsupported_feature'; if the SDK is not " +
|
|
490
|
+
"installed, degrades to 'install <SDK> first'. Neither writes anything. DESTRUCTIVE (writes a .gd) — gated.",
|
|
491
|
+
inputSchema: {
|
|
492
|
+
sdk: z.enum(BACKEND_SDKS).describe("Which backend SDK to target"),
|
|
493
|
+
to_path: z.string().optional().describe("Destination res:// .gd path (default res://backend/cloud_save.gd)"),
|
|
494
|
+
overwrite: z.boolean().optional().describe("Overwrite if the file already exists (default false)"),
|
|
495
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
496
|
+
},
|
|
497
|
+
}, async ({ sdk, to_path, overwrite, confirm }) => {
|
|
498
|
+
return runScaffold({
|
|
499
|
+
sdk,
|
|
500
|
+
feature: "cloudsave",
|
|
501
|
+
kind: "cloudsave",
|
|
502
|
+
toPath: to_path ?? "res://backend/cloud_save.gd",
|
|
503
|
+
overwrite: overwrite ?? false,
|
|
504
|
+
confirm,
|
|
505
|
+
build: () => buildCloudSaveScript(sdk),
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
// ------------------------------------------------------------ auth_scaffold ----
|
|
509
|
+
server.registerTool("auth_scaffold", {
|
|
510
|
+
title: "Scaffold auth integration",
|
|
511
|
+
description: "Generate login/register/logout helpers against an installed backend SDK. FEATURE-DETECTED both ways: if the SDK " +
|
|
512
|
+
"provides no auth API (Photon is realtime transport), degrades to 'unsupported_feature'; if the SDK is not " +
|
|
513
|
+
"installed, degrades to 'install <SDK> first'. Neither writes anything. DESTRUCTIVE (writes a .gd) — gated.",
|
|
514
|
+
inputSchema: {
|
|
515
|
+
sdk: z.enum(BACKEND_SDKS).describe("Which backend SDK to target"),
|
|
516
|
+
to_path: z.string().optional().describe("Destination res:// .gd path (default res://backend/auth.gd)"),
|
|
517
|
+
overwrite: z.boolean().optional().describe("Overwrite if the file already exists (default false)"),
|
|
518
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
519
|
+
},
|
|
520
|
+
}, async ({ sdk, to_path, overwrite, confirm }) => {
|
|
521
|
+
return runScaffold({
|
|
522
|
+
sdk,
|
|
523
|
+
feature: "auth",
|
|
524
|
+
kind: "auth",
|
|
525
|
+
toPath: to_path ?? "res://backend/auth.gd",
|
|
526
|
+
overwrite: overwrite ?? false,
|
|
527
|
+
confirm,
|
|
528
|
+
build: () => buildAuthScript(sdk),
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
//# sourceMappingURL=backend.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn, execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
import { registerTaskTool } from "../tasks.js";
|
|
6
|
+
import { ok } from "./lsp-common.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/** Run the Godot binary to completion, capturing stdout/stderr. */
|
|
9
|
+
async function runCaptured(cfg, args, timeoutMs, signal) {
|
|
10
|
+
try {
|
|
11
|
+
const { stdout, stderr } = await execFileAsync(cfg.godotBin, args, {
|
|
12
|
+
cwd: cfg.projectPath,
|
|
13
|
+
timeout: timeoutMs,
|
|
14
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
15
|
+
signal,
|
|
16
|
+
});
|
|
17
|
+
return { code: 0, stdout, stderr, timedOut: false };
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const e = err;
|
|
21
|
+
return {
|
|
22
|
+
code: typeof e.code === "number" ? e.code : null,
|
|
23
|
+
stdout: e.stdout ?? "",
|
|
24
|
+
stderr: e.stderr ?? e.message ?? "",
|
|
25
|
+
timedOut: Boolean(e.killed) && e.signal === "SIGTERM",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Launch the Godot binary detached (for long-lived editor/game processes). */
|
|
30
|
+
function launchDetached(cfg, args) {
|
|
31
|
+
const child = spawn(cfg.godotBin, args, {
|
|
32
|
+
cwd: cfg.projectPath,
|
|
33
|
+
detached: true,
|
|
34
|
+
stdio: "ignore",
|
|
35
|
+
});
|
|
36
|
+
child.unref();
|
|
37
|
+
return child.pid ?? null;
|
|
38
|
+
}
|
|
39
|
+
/** Truncate long output so a single tool result stays reasonable. */
|
|
40
|
+
function tail(s, max = 8000) {
|
|
41
|
+
if (s.length <= max)
|
|
42
|
+
return s;
|
|
43
|
+
return "…(truncated)…\n" + s.slice(s.length - max);
|
|
44
|
+
}
|
|
45
|
+
export function registerCliTools(server, cfg) {
|
|
46
|
+
server.registerTool("godot_version", {
|
|
47
|
+
title: "Godot version",
|
|
48
|
+
description: "Return the version string of the configured Godot binary.",
|
|
49
|
+
inputSchema: {},
|
|
50
|
+
}, async () => {
|
|
51
|
+
const r = await runCaptured(cfg, ["--version"], 15000);
|
|
52
|
+
return ok({ version: r.stdout.trim() || r.stderr.trim(), raw: r });
|
|
53
|
+
});
|
|
54
|
+
server.registerTool("godot_launch_editor", {
|
|
55
|
+
title: "Launch editor",
|
|
56
|
+
description: "Open the Godot editor for the configured project (detached). Needed before any editor_* bridge tool can be used.",
|
|
57
|
+
inputSchema: {},
|
|
58
|
+
}, async () => {
|
|
59
|
+
const pid = launchDetached(cfg, ["-e", "--path", cfg.projectPath]);
|
|
60
|
+
log(`launched editor pid=${pid}`);
|
|
61
|
+
return ok({ launched: true, pid, project: cfg.projectPath });
|
|
62
|
+
});
|
|
63
|
+
server.registerTool("godot_run_project", {
|
|
64
|
+
title: "Run project",
|
|
65
|
+
description: "Run the project (detached). Optionally start from a specific scene path (res://...). Returns the process id.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
scene: z.string().optional().describe("Optional scene to run, e.g. res://levels/test.tscn"),
|
|
68
|
+
},
|
|
69
|
+
}, async ({ scene }) => {
|
|
70
|
+
const args = ["--path", cfg.projectPath];
|
|
71
|
+
if (scene)
|
|
72
|
+
args.push(scene);
|
|
73
|
+
const pid = launchDetached(cfg, args);
|
|
74
|
+
return ok({ running: true, pid, scene: scene ?? null });
|
|
75
|
+
});
|
|
76
|
+
registerTaskTool(server, "godot_export", {
|
|
77
|
+
title: "Export project",
|
|
78
|
+
description: "Headless export using an export preset. Runs to completion and returns exit code + logs. Can be slow — " +
|
|
79
|
+
"exposed as an MCP task, so task-aware clients can poll, await, or cancel it (tasks/get, tasks/result, tasks/cancel).",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
preset: z.string().describe("Export preset name as defined in export_presets.cfg"),
|
|
82
|
+
output_path: z.string().describe("Output file path for the exported build"),
|
|
83
|
+
debug: z.boolean().optional().describe("Export a debug build instead of release (default false)"),
|
|
84
|
+
timeout_ms: z.number().int().positive().optional().describe("Max run time (default 600000)"),
|
|
85
|
+
},
|
|
86
|
+
}, async ({ preset, output_path, debug, timeout_ms }, signal) => {
|
|
87
|
+
const flag = debug ? "--export-debug" : "--export-release";
|
|
88
|
+
const r = await runCaptured(cfg, ["--headless", "--path", cfg.projectPath, flag, preset, output_path], timeout_ms ?? 600000, signal);
|
|
89
|
+
return ok({
|
|
90
|
+
preset,
|
|
91
|
+
output_path,
|
|
92
|
+
exit_code: r.code,
|
|
93
|
+
timed_out: r.timedOut,
|
|
94
|
+
stdout: tail(r.stdout),
|
|
95
|
+
stderr: tail(r.stderr),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
registerTaskTool(server, "godot_import", {
|
|
99
|
+
title: "Import assets",
|
|
100
|
+
description: "Headless (re)import of project assets. Runs to completion and returns exit code + logs. " +
|
|
101
|
+
"Exposed as an MCP task (poll/await/cancel via tasks/get, tasks/result, tasks/cancel).",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
timeout_ms: z.number().int().positive().optional().describe("Max run time (default 600000)"),
|
|
104
|
+
},
|
|
105
|
+
}, async ({ timeout_ms }, signal) => {
|
|
106
|
+
const r = await runCaptured(cfg, ["--headless", "--path", cfg.projectPath, "--import"], timeout_ms ?? 600000, signal);
|
|
107
|
+
return ok({ exit_code: r.code, timed_out: r.timedOut, stdout: tail(r.stdout), stderr: tail(r.stderr) });
|
|
108
|
+
});
|
|
109
|
+
registerTaskTool(server, "godot_run_headless_script", {
|
|
110
|
+
title: "Run headless script",
|
|
111
|
+
description: "Run a GDScript in headless mode (godot --headless -s <script>). Use this to invoke test runners " +
|
|
112
|
+
"(GdUnit4 / GUT) or any batch tool. Returns exit code + logs. Exposed as an MCP task, so a long test " +
|
|
113
|
+
"run can be polled, awaited, or cancelled (tasks/get, tasks/result, tasks/cancel).",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
script_path: z.string().describe("Script to execute, e.g. res://addons/gdUnit4/bin/GdUnitCmdTool.gd"),
|
|
116
|
+
args: z.array(z.string()).optional().describe("Extra CLI args passed after the script"),
|
|
117
|
+
timeout_ms: z.number().int().positive().optional().describe("Max run time (default 600000)"),
|
|
118
|
+
},
|
|
119
|
+
}, async ({ script_path, args, timeout_ms }, signal) => {
|
|
120
|
+
const r = await runCaptured(cfg, ["--headless", "--path", cfg.projectPath, "-s", script_path, ...(args ?? [])], timeout_ms ?? 600000, signal);
|
|
121
|
+
return ok({
|
|
122
|
+
script_path,
|
|
123
|
+
exit_code: r.code,
|
|
124
|
+
timed_out: r.timedOut,
|
|
125
|
+
stdout: tail(r.stdout),
|
|
126
|
+
stderr: tail(r.stderr),
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=cli.js.map
|