godot-daedalus_backend 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.
Files changed (67) hide show
  1. package/README.md +101 -0
  2. package/bin/godot-daedalus-backend.js +4 -0
  3. package/bin/godot-daedalus-mcp.js +4 -0
  4. package/bin/godot-daedalus-terminal-mcp.js +4 -0
  5. package/bin/run-tsx-entry.js +26 -0
  6. package/package.json +54 -0
  7. package/scripts/deepseek-tokenizer-server.py +54 -0
  8. package/src/app-paths.ts +36 -0
  9. package/src/main.ts +21 -0
  10. package/src/mcp/content-length-protocol.ts +68 -0
  11. package/src/mcp/custom-mcp-config-store.ts +397 -0
  12. package/src/mcp/godot-diagnostics-bridge.ts +1298 -0
  13. package/src/mcp/godot-editor-bridge.ts +307 -0
  14. package/src/mcp/godot-mcp-server.ts +3484 -0
  15. package/src/mcp/godot-paths.ts +151 -0
  16. package/src/mcp/godot-project-settings.ts +233 -0
  17. package/src/mcp/godot-tool-registration.ts +46 -0
  18. package/src/mcp/mcp-config.ts +48 -0
  19. package/src/mcp/mcp-host.ts +393 -0
  20. package/src/mcp/mcp-session.ts +81 -0
  21. package/src/mcp/terminal-mcp-server.ts +576 -0
  22. package/src/mcp/tscn-tools.ts +302 -0
  23. package/src/mcp/types.ts +12 -0
  24. package/src/ping-client.ts +24 -0
  25. package/src/prompts/registry.ts +97 -0
  26. package/src/prompts/templates/backend-helper.md +25 -0
  27. package/src/prompts/templates/gdscript-reviewer.md +19 -0
  28. package/src/prompts/templates/godot-assistant.md +225 -0
  29. package/src/prompts/templates/scene-architect.md +15 -0
  30. package/src/prompts/templates/session-compressor.md +33 -0
  31. package/src/protocol/schema.ts +486 -0
  32. package/src/protocol/types.ts +77 -0
  33. package/src/providers/deepseek-agent.ts +1014 -0
  34. package/src/providers/deepseek-client.ts +114 -0
  35. package/src/providers/deepseek-dsml-tools.ts +90 -0
  36. package/src/providers/deepseek-loose-tools.ts +450 -0
  37. package/src/providers/provider-config-store.ts +164 -0
  38. package/src/server/client-session.ts +93 -0
  39. package/src/server/request-dispatcher.ts +74 -0
  40. package/src/server/response-helpers.ts +33 -0
  41. package/src/server/send-json.ts +8 -0
  42. package/src/server/websocket-server.ts +3997 -0
  43. package/src/session/session-compressor.ts +68 -0
  44. package/src/session/session-store.ts +669 -0
  45. package/src/skills/registry.ts +180 -0
  46. package/src/skills/templates/backend-helper.md +12 -0
  47. package/src/skills/templates/file-creator.md +14 -0
  48. package/src/skills/templates/gdscript-review.md +12 -0
  49. package/src/skills/templates/godot-project-init.md +29 -0
  50. package/src/skills/templates/scene-builder.md +12 -0
  51. package/src/tokens/deepseek-tokenizer-counter.ts +233 -0
  52. package/src/tokens/model-profiles.ts +38 -0
  53. package/src/tokens/token-counter-factory.ts +52 -0
  54. package/src/tokens/token-counter.ts +22 -0
  55. package/src/tools/approval-gateway.ts +111 -0
  56. package/src/tools/llm-tools.ts +1415 -0
  57. package/src/tools/tool-dispatcher.ts +147 -0
  58. package/src/tools/tool-event-describer.ts +387 -0
  59. package/src/tools/tool-idempotency.ts +373 -0
  60. package/src/tools/tool-policy-table.ts +61 -0
  61. package/src/tools/tool-policy.ts +73 -0
  62. package/src/workflow/llm-planner.ts +407 -0
  63. package/src/workflow/planner.ts +201 -0
  64. package/src/workflow/runner.ts +141 -0
  65. package/src/workflow/types.ts +69 -0
  66. package/src/workspace/registry.ts +104 -0
  67. package/src/workspace/types.ts +7 -0
@@ -0,0 +1,3484 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import type { Dirent } from "node:fs";
4
+ import * as fs from "node:fs/promises";
5
+ import * as path from "node:path";
6
+ import { z } from "zod";
7
+
8
+ const MAX_TEXT_FILE_BYTES: number = 512 * 1024;
9
+ const MAX_NEW_FILE_BYTES: number = 64 * 1024;
10
+ const MAX_PROJECT_LOG_BYTES: number = 256 * 1024;
11
+ const DEFAULT_PROJECT_LOG_LINES: number = 200;
12
+ const MAX_PROJECT_LOG_LINES: number = 1000;
13
+ const MAX_PROJECT_SETTING_VALUE_CHARS: number = 16 * 1024;
14
+ const MAX_PROJECT_SETTING_VALUE_LINES: number = 240;
15
+ const MAX_PROJECT_SETTINGS_RESULT: number = 500;
16
+ const MAX_EDITOR_CONFIG_FILE_BYTES: number = 256 * 1024;
17
+ const MAX_EDITOR_CONFIG_FILES: number = 500;
18
+ const MAX_EDITOR_SETTINGS_RESULT: number = 500;
19
+ const MAX_RECENT_PROJECTS_RESULT: number = 100;
20
+
21
+ const PROJECT_CONFIG_FILE_NAME: string = "project.godot";
22
+ const DEFAULT_GODOT_LOG_PATH: string = "user://logs/godot.log";
23
+ const DEFAULT_GODOT_LOG_MAX_FILES: number = 5;
24
+ const GODOT_CONFIG_DIR_NAME: string = "Godot";
25
+
26
+ const WRITABLE_EXTENSIONS: Set<string> = new Set([
27
+ ".gd",
28
+ ".tres",
29
+ ".tscn",
30
+ ".json",
31
+ ".md",
32
+ ".txt"
33
+ ]);
34
+
35
+ const MAX_TSCN_FILE_BYTES: number = 256 * 1024;
36
+
37
+ const DEFAULT_IGNORED_DIRECTORIES: Set<string> = new Set([
38
+ ".git",
39
+ ".godot",
40
+ ".vscode",
41
+ ".idea",
42
+ "android",
43
+ "node_modules"
44
+ ]);
45
+
46
+ const TEXT_EXTENSIONS: Set<string> = new Set([
47
+ ".cfg",
48
+ ".cs",
49
+ ".gd",
50
+ ".gdshader",
51
+ ".godot",
52
+ ".json",
53
+ ".md",
54
+ ".res",
55
+ ".tres",
56
+ ".tscn",
57
+ ".txt",
58
+ ".uid"
59
+ ]);
60
+
61
+ type ProjectSummary = {
62
+ path: string;
63
+ name: string;
64
+ mainScene: string;
65
+ features: string;
66
+ addons: string[];
67
+ sceneCount: number;
68
+ scriptCount: number;
69
+ };
70
+
71
+ type ProjectSettingEntry = {
72
+ section: string;
73
+ name: string;
74
+ fullKey: string;
75
+ valueExpression: string;
76
+ lineStart: number;
77
+ lineEnd: number;
78
+ };
79
+
80
+ type ProjectSettingsDocument = {
81
+ content: string;
82
+ lines: string[];
83
+ entries: ProjectSettingEntry[];
84
+ sectionLineIndexes: Map<string, number>;
85
+ };
86
+
87
+ type ResolvedGodotPath = {
88
+ originalPath: string;
89
+ absolutePath: string;
90
+ rootPath: string;
91
+ kind: "user" | "res" | "absolute" | "relative_user";
92
+ };
93
+
94
+ type ProjectLogConfig = {
95
+ projectName: string;
96
+ userDataDir: string;
97
+ logPath: string;
98
+ logPathSource: "project" | "default";
99
+ absolutePath: string;
100
+ logDirectory: string;
101
+ fileLoggingEnabled: boolean;
102
+ fileLoggingEnabledSource: "project" | "default_pc";
103
+ maxLogFiles: number;
104
+ maxLogFilesSource: "project" | "default";
105
+ pathKind: ResolvedGodotPath["kind"];
106
+ resolutionNote: string;
107
+ };
108
+
109
+ type ProjectLogFile = {
110
+ fileName: string;
111
+ absolutePath: string;
112
+ size: number;
113
+ modifiedAt: string;
114
+ };
115
+
116
+ type EditorSettingsFile = {
117
+ fileName: string;
118
+ absolutePath: string;
119
+ version: string;
120
+ major: number;
121
+ minor: number;
122
+ size: number;
123
+ modifiedAt: string;
124
+ };
125
+
126
+ type EditorConfigFileScope = "global_config" | "project_editor";
127
+
128
+ type EditorConfigFile = {
129
+ fileId: string;
130
+ scope: EditorConfigFileScope;
131
+ relativePath: string;
132
+ absolutePath: string;
133
+ size: number;
134
+ modifiedAt: string;
135
+ };
136
+
137
+ type EditorConfigPaths = {
138
+ configDir: string;
139
+ projectEditorDir: string;
140
+ settingsFile: EditorSettingsFile | null;
141
+ settingsFiles: EditorSettingsFile[];
142
+ };
143
+
144
+ type ScriptEditorState = {
145
+ resourcePath: string;
146
+ line: number | null;
147
+ column: number | null;
148
+ row: number | null;
149
+ storedColumn: number | null;
150
+ breakpoints: number[];
151
+ bookmarks: number[];
152
+ selection: boolean | null;
153
+ syntaxHighlighter: string | null;
154
+ };
155
+
156
+ const projectPathText: string | undefined = process.env.GODOT_PROJECT_PATH;
157
+
158
+ if (projectPathText === undefined || projectPathText.trim().length === 0) {
159
+ console.error("GODOT_PROJECT_PATH environment variable is required");
160
+ process.exit(1);
161
+ }
162
+
163
+ const projectRoot: string = path.resolve(projectPathText);
164
+
165
+ function toProjectRelativePath(absolutePath: string): string {
166
+ return path.relative(projectRoot, absolutePath).replaceAll(path.sep, "/");
167
+ }
168
+
169
+ function isPathInsideProject(absolutePath: string): boolean {
170
+ const relativePath: string = path.relative(projectRoot, absolutePath);
171
+ return relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
172
+ }
173
+
174
+ async function resolveProjectPath(relativePath: string): Promise<string> {
175
+ const cleanedPath: string = relativePath.trim();
176
+ const resolvedPath: string = path.resolve(projectRoot, cleanedPath.length > 0 ? cleanedPath : ".");
177
+
178
+ if (!isPathInsideProject(resolvedPath)) {
179
+ throw new Error(`Path traversal denied: ${relativePath}`);
180
+ }
181
+
182
+ return resolvedPath;
183
+ }
184
+
185
+ function shouldSkipDirectory(name: string): boolean {
186
+ return DEFAULT_IGNORED_DIRECTORIES.has(name);
187
+ }
188
+
189
+ async function assertProjectExists(): Promise<void> {
190
+ const stat = await fs.stat(projectRoot);
191
+ if (!stat.isDirectory()) {
192
+ throw new Error(`GODOT_PROJECT_PATH is not a directory: ${projectRoot}`);
193
+ }
194
+
195
+ await fs.access(path.join(projectRoot, PROJECT_CONFIG_FILE_NAME));
196
+ }
197
+
198
+ async function walkProjectFiles(options?: {
199
+ subdir?: string | undefined;
200
+ extensions?: string[] | undefined;
201
+ includeAddons?: boolean | undefined;
202
+ }): Promise<string[]> {
203
+ const startPath: string = options?.subdir !== undefined
204
+ ? await resolveProjectPath(options.subdir)
205
+ : projectRoot;
206
+ const extensions: Set<string> | undefined = options?.extensions !== undefined && options.extensions.length > 0
207
+ ? new Set(options.extensions.map((extension: string): string => extension.startsWith(".") ? extension : `.${extension}`))
208
+ : undefined;
209
+ const results: string[] = [];
210
+
211
+ async function walk(directoryPath: string): Promise<void> {
212
+ const entries: Dirent[] = await fs.readdir(directoryPath, { withFileTypes: true });
213
+
214
+ for (const entry of entries) {
215
+ if (entry.isDirectory() && shouldSkipDirectory(entry.name)) {
216
+ continue;
217
+ }
218
+
219
+ if (entry.isDirectory() && entry.name === "addons" && options?.includeAddons !== true) {
220
+ continue;
221
+ }
222
+
223
+ const fullPath: string = path.join(directoryPath, entry.name);
224
+ if (entry.isDirectory()) {
225
+ await walk(fullPath);
226
+ continue;
227
+ }
228
+
229
+ if (!entry.isFile()) {
230
+ continue;
231
+ }
232
+
233
+ const extension: string = path.extname(entry.name);
234
+ if (extensions !== undefined && !extensions.has(extension)) {
235
+ continue;
236
+ }
237
+
238
+ results.push(toProjectRelativePath(fullPath));
239
+ }
240
+ }
241
+
242
+ await walk(startPath);
243
+ results.sort();
244
+ return results;
245
+ }
246
+
247
+ function getProjectConfigPath(): string {
248
+ return path.join(projectRoot, PROJECT_CONFIG_FILE_NAME);
249
+ }
250
+
251
+ function normalizeConfigContent(content: string): string {
252
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
253
+ }
254
+
255
+ function getExpressionBalance(text: string): number {
256
+ let balance: number = 0;
257
+ let quote: string = "";
258
+ let escaped: boolean = false;
259
+
260
+ for (const char of text) {
261
+ if (quote.length > 0) {
262
+ if (escaped) {
263
+ escaped = false;
264
+ continue;
265
+ }
266
+
267
+ if (char === "\\") {
268
+ escaped = true;
269
+ continue;
270
+ }
271
+
272
+ if (char === quote) {
273
+ quote = "";
274
+ }
275
+ continue;
276
+ }
277
+
278
+ if (char === "\"" || char === "'") {
279
+ quote = char;
280
+ continue;
281
+ }
282
+
283
+ if (char === "{" || char === "[" || char === "(") {
284
+ balance += 1;
285
+ } else if (char === "}" || char === "]" || char === ")") {
286
+ balance -= 1;
287
+ }
288
+ }
289
+
290
+ return balance;
291
+ }
292
+
293
+ function makeProjectSettingFullKey(section: string, name: string): string {
294
+ return section.length > 0 ? `${section}/${name}` : name;
295
+ }
296
+
297
+ function parseProjectSettings(content: string): ProjectSettingsDocument {
298
+ const normalizedContent: string = normalizeConfigContent(content);
299
+ const lines: string[] = normalizedContent.split("\n");
300
+ const entries: ProjectSettingEntry[] = [];
301
+ const sectionLineIndexes: Map<string, number> = new Map();
302
+ let currentSection: string = "";
303
+ let index: number = 0;
304
+
305
+ while (index < lines.length) {
306
+ const line: string = lines[index]!;
307
+ const trimmedLine: string = line.trim();
308
+ const sectionMatch: RegExpMatchArray | null = trimmedLine.match(/^\[([^\]]+)\]$/);
309
+
310
+ if (sectionMatch !== null) {
311
+ currentSection = sectionMatch[1]!.trim();
312
+ sectionLineIndexes.set(currentSection, index);
313
+ index += 1;
314
+ continue;
315
+ }
316
+
317
+ if (trimmedLine.length === 0 || trimmedLine.startsWith(";")) {
318
+ index += 1;
319
+ continue;
320
+ }
321
+
322
+ const equalsIndex: number = line.indexOf("=");
323
+ if (equalsIndex === -1) {
324
+ index += 1;
325
+ continue;
326
+ }
327
+
328
+ const name: string = line.slice(0, equalsIndex).trim();
329
+ if (name.length === 0) {
330
+ index += 1;
331
+ continue;
332
+ }
333
+
334
+ const valueLines: string[] = [line.slice(equalsIndex + 1)];
335
+ let balance: number = getExpressionBalance(valueLines[0]!);
336
+ let lineEnd: number = index;
337
+
338
+ while (balance > 0 && lineEnd + 1 < lines.length) {
339
+ lineEnd += 1;
340
+ const nextLine: string = lines[lineEnd]!;
341
+ valueLines.push(nextLine);
342
+ balance += getExpressionBalance(nextLine);
343
+ }
344
+
345
+ entries.push({
346
+ section: currentSection,
347
+ name,
348
+ fullKey: makeProjectSettingFullKey(currentSection, name),
349
+ valueExpression: valueLines.join("\n").trimEnd(),
350
+ lineStart: index,
351
+ lineEnd
352
+ });
353
+
354
+ index = lineEnd + 1;
355
+ }
356
+
357
+ return {
358
+ content: normalizedContent,
359
+ lines,
360
+ entries,
361
+ sectionLineIndexes
362
+ };
363
+ }
364
+
365
+ async function readProjectSettingsDocument(): Promise<ProjectSettingsDocument> {
366
+ const content: string = await fs.readFile(getProjectConfigPath(), "utf8");
367
+ return parseProjectSettings(content);
368
+ }
369
+
370
+ async function readProjectConfig(): Promise<Record<string, string>> {
371
+ const document: ProjectSettingsDocument = await readProjectSettingsDocument();
372
+ const config: Record<string, string> = {};
373
+
374
+ for (const entry of document.entries) {
375
+ config[entry.fullKey] = entry.valueExpression;
376
+ }
377
+
378
+ return config;
379
+ }
380
+
381
+ function parseProjectSettingString(valueExpression: string | undefined): string | undefined {
382
+ if (valueExpression === undefined) {
383
+ return undefined;
384
+ }
385
+
386
+ const trimmedValue: string = valueExpression.trim();
387
+ if (trimmedValue.startsWith("\"") && trimmedValue.endsWith("\"")) {
388
+ try {
389
+ return JSON.parse(trimmedValue) as string;
390
+ } catch {
391
+ return trimmedValue.slice(1, -1);
392
+ }
393
+ }
394
+
395
+ return trimmedValue;
396
+ }
397
+
398
+ function parseProjectSettingBoolean(valueExpression: string | undefined, fallback: boolean): boolean {
399
+ const trimmedValue: string | undefined = valueExpression?.trim();
400
+ if (trimmedValue === "true") {
401
+ return true;
402
+ }
403
+
404
+ if (trimmedValue === "false") {
405
+ return false;
406
+ }
407
+
408
+ return fallback;
409
+ }
410
+
411
+ function parseProjectSettingInteger(valueExpression: string | undefined, fallback: number): number {
412
+ const trimmedValue: string | undefined = valueExpression?.trim();
413
+ if (trimmedValue === undefined || !/^-?\d+$/.test(trimmedValue)) {
414
+ return fallback;
415
+ }
416
+
417
+ return Number.parseInt(trimmedValue, 10);
418
+ }
419
+
420
+ function parseProjectFeatureVersion(config: Record<string, string>): string | undefined {
421
+ const featuresValue: string | undefined = config["application/config/features"] ?? config["config/features"];
422
+ if (featuresValue === undefined) {
423
+ return undefined;
424
+ }
425
+
426
+ const match: RegExpMatchArray | null = featuresValue.match(/"(\d+\.\d+)"/);
427
+ return match?.[1];
428
+ }
429
+
430
+ function parseEditorSettingsFileName(fileName: string): { version: string; major: number; minor: number } | null {
431
+ const match: RegExpMatchArray | null = fileName.match(/^editor_settings-(\d+)(?:\.(\d+))?\.tres$/);
432
+ if (match === null) {
433
+ return null;
434
+ }
435
+
436
+ const major: number = Number.parseInt(match[1]!, 10);
437
+ const minor: number = match[2] === undefined ? -1 : Number.parseInt(match[2], 10);
438
+ return {
439
+ version: minor < 0 ? `${major}` : `${major}.${minor}`,
440
+ major,
441
+ minor
442
+ };
443
+ }
444
+
445
+ async function listEditorSettingsFiles(): Promise<EditorSettingsFile[]> {
446
+ const configDir: string = getGodotConfigDir();
447
+ let entries: Dirent[];
448
+ try {
449
+ entries = await fs.readdir(configDir, { withFileTypes: true });
450
+ } catch {
451
+ return [];
452
+ }
453
+
454
+ const files: EditorSettingsFile[] = [];
455
+ for (const entry of entries) {
456
+ if (!entry.isFile()) {
457
+ continue;
458
+ }
459
+
460
+ const versionInfo = parseEditorSettingsFileName(entry.name);
461
+ if (versionInfo === null) {
462
+ continue;
463
+ }
464
+
465
+ const absolutePath: string = path.join(configDir, entry.name);
466
+ const stat = await fs.stat(absolutePath);
467
+ files.push({
468
+ fileName: entry.name,
469
+ absolutePath,
470
+ version: versionInfo.version,
471
+ major: versionInfo.major,
472
+ minor: versionInfo.minor,
473
+ size: stat.size,
474
+ modifiedAt: stat.mtime.toISOString()
475
+ });
476
+ }
477
+
478
+ files.sort((left: EditorSettingsFile, right: EditorSettingsFile): number => {
479
+ if (right.major !== left.major) {
480
+ return right.major - left.major;
481
+ }
482
+
483
+ return right.minor - left.minor;
484
+ });
485
+ return files;
486
+ }
487
+
488
+ async function getEditorConfigPaths(): Promise<EditorConfigPaths> {
489
+ const config: Record<string, string> = await readProjectConfig();
490
+ const preferredVersion: string | undefined = parseProjectFeatureVersion(config);
491
+ const settingsFiles: EditorSettingsFile[] = await listEditorSettingsFiles();
492
+ const settingsFile: EditorSettingsFile | null = settingsFiles.find((file: EditorSettingsFile): boolean => file.version === preferredVersion)
493
+ ?? settingsFiles[0]
494
+ ?? null;
495
+
496
+ return {
497
+ configDir: getGodotConfigDir(),
498
+ projectEditorDir: getProjectEditorDir(),
499
+ settingsFile,
500
+ settingsFiles
501
+ };
502
+ }
503
+
504
+ async function readEditorSettingsDocument(): Promise<{ paths: EditorConfigPaths; document: ProjectSettingsDocument | null }> {
505
+ const paths: EditorConfigPaths = await getEditorConfigPaths();
506
+ if (paths.settingsFile === null) {
507
+ return { paths, document: null };
508
+ }
509
+
510
+ const content: string = await fs.readFile(paths.settingsFile.absolutePath, "utf8");
511
+ return {
512
+ paths,
513
+ document: parseProjectSettings(content)
514
+ };
515
+ }
516
+
517
+ function getEditorSettingKey(entry: ProjectSettingEntry): string {
518
+ return entry.section === "resource" ? entry.name : entry.fullKey;
519
+ }
520
+
521
+ function findEditorSettingEntry(document: ProjectSettingsDocument, fullKey: string): ProjectSettingEntry | undefined {
522
+ return document.entries.find((entry: ProjectSettingEntry): boolean => getEditorSettingKey(entry) === fullKey);
523
+ }
524
+
525
+ function formatEditorSettingEntry(entry: ProjectSettingEntry, raw: boolean): Record<string, unknown> {
526
+ return {
527
+ key: getEditorSettingKey(entry),
528
+ section: entry.section,
529
+ name: entry.name,
530
+ valueExpression: redactSensitivePaths(entry.valueExpression, raw),
531
+ lineStart: entry.lineStart + 1,
532
+ lineEnd: entry.lineEnd + 1
533
+ };
534
+ }
535
+
536
+ function decodeGodotQuotedString(quotedText: string): string {
537
+ try {
538
+ return JSON.parse(quotedText) as string;
539
+ } catch {
540
+ return quotedText.slice(1, -1);
541
+ }
542
+ }
543
+
544
+ function parseGodotStringList(valueExpression: string | undefined): string[] {
545
+ if (valueExpression === undefined) {
546
+ return [];
547
+ }
548
+
549
+ const values: string[] = [];
550
+ const stringPattern: RegExp = /"((?:[^"\\]|\\.)*)"/g;
551
+ let match: RegExpExecArray | null;
552
+ while ((match = stringPattern.exec(valueExpression)) !== null) {
553
+ values.push(decodeGodotQuotedString(`"${match[1] ?? ""}"`));
554
+ }
555
+
556
+ return values;
557
+ }
558
+
559
+ function parseGodotIntArray(valueExpression: string | undefined): number[] {
560
+ if (valueExpression === undefined) {
561
+ return [];
562
+ }
563
+
564
+ const values: number[] = [];
565
+ const match: RegExpMatchArray | null = valueExpression.match(/PackedInt32Array\(([^)]*)\)/);
566
+ if (match === null || match[1] === undefined) {
567
+ return values;
568
+ }
569
+
570
+ for (const rawValue of match[1].split(",")) {
571
+ const trimmedValue: string = rawValue.trim();
572
+ if (/^-?\d+$/.test(trimmedValue)) {
573
+ values.push(Number.parseInt(trimmedValue, 10));
574
+ }
575
+ }
576
+
577
+ return values;
578
+ }
579
+
580
+ function parseStateDictionaryNumber(valueExpression: string, key: string): number | null {
581
+ const match: RegExpMatchArray | null = valueExpression.match(new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`));
582
+ return match === null ? null : Number(match[1]);
583
+ }
584
+
585
+ function parseStateDictionaryBoolean(valueExpression: string, key: string): boolean | null {
586
+ const match: RegExpMatchArray | null = valueExpression.match(new RegExp(`"${key}"\\s*:\\s*(true|false)`));
587
+ return match === null ? null : match[1] === "true";
588
+ }
589
+
590
+ function parseStateDictionaryString(valueExpression: string, key: string): string | null {
591
+ const match: RegExpMatchArray | null = valueExpression.match(new RegExp(`"${key}"\\s*:\\s*("(?:[^"\\\\]|\\\\.)*")`));
592
+ return match === null || match[1] === undefined ? null : decodeGodotQuotedString(match[1]);
593
+ }
594
+
595
+ function createScriptEditorState(resourcePath: string, valueExpression: string): ScriptEditorState {
596
+ const row: number | null = parseStateDictionaryNumber(valueExpression, "row");
597
+ const storedColumn: number | null = parseStateDictionaryNumber(valueExpression, "column");
598
+ return {
599
+ resourcePath,
600
+ line: row === null ? null : row + 1,
601
+ column: storedColumn === null ? null : storedColumn + 1,
602
+ row,
603
+ storedColumn,
604
+ breakpoints: parseGodotIntArray(valueExpression.match(/"breakpoints"\s*:\s*(PackedInt32Array\([^)]*\))/)?.[1]),
605
+ bookmarks: parseGodotIntArray(valueExpression.match(/"bookmarks"\s*:\s*(PackedInt32Array\([^)]*\))/)?.[1]),
606
+ selection: parseStateDictionaryBoolean(valueExpression, "selection"),
607
+ syntaxHighlighter: parseStateDictionaryString(valueExpression, "syntax_highlighter")
608
+ };
609
+ }
610
+
611
+ function sanitizePathList(values: readonly string[], raw: boolean): string[] {
612
+ return values.map((value: string): string => redactOnePath(value, raw));
613
+ }
614
+
615
+ async function readConfigDocumentIfExists(absolutePath: string): Promise<ProjectSettingsDocument | null> {
616
+ try {
617
+ const content: string = await fs.readFile(absolutePath, "utf8");
618
+ return parseProjectSettings(content);
619
+ } catch {
620
+ return null;
621
+ }
622
+ }
623
+
624
+ function getDocumentValue(document: ProjectSettingsDocument | null, key: string): string | undefined {
625
+ if (document === null) {
626
+ return undefined;
627
+ }
628
+
629
+ return findProjectSettingEntry(document, key)?.valueExpression;
630
+ }
631
+
632
+ async function getEditorSettings(keys: string[] | undefined, prefix: string | undefined, raw: boolean | undefined): Promise<Record<string, unknown>> {
633
+ const includeRaw: boolean = raw === true;
634
+ const { paths, document } = await readEditorSettingsDocument();
635
+ if (document === null) {
636
+ return {
637
+ ok: false,
638
+ message: "No Godot editor_settings-*.tres file found.",
639
+ configDir: redactOnePath(paths.configDir, includeRaw)
640
+ };
641
+ }
642
+
643
+ const trimmedKeys: string[] = (keys ?? []).map((key: string): string => key.trim()).filter((key: string): boolean => key.length > 0);
644
+ const trimmedPrefix: string | undefined = prefix?.trim();
645
+ let entries: ProjectSettingEntry[];
646
+
647
+ if (trimmedKeys.length > 0) {
648
+ entries = trimmedKeys
649
+ .map((key: string): ProjectSettingEntry | undefined => findEditorSettingEntry(document, key))
650
+ .filter((entry: ProjectSettingEntry | undefined): entry is ProjectSettingEntry => entry !== undefined);
651
+ } else if (trimmedPrefix !== undefined && trimmedPrefix.length > 0) {
652
+ entries = document.entries.filter((entry: ProjectSettingEntry): boolean => getEditorSettingKey(entry).startsWith(trimmedPrefix));
653
+ } else {
654
+ entries = document.entries;
655
+ }
656
+
657
+ const clippedEntries: ProjectSettingEntry[] = entries.slice(0, MAX_EDITOR_SETTINGS_RESULT);
658
+ const missingKeys: string[] = trimmedKeys.filter((key: string): boolean => findEditorSettingEntry(document, key) === undefined);
659
+ const settingsFile: EditorSettingsFile | null = paths.settingsFile;
660
+ if (settingsFile === null) {
661
+ return {
662
+ ok: false,
663
+ message: "No Godot editor_settings-*.tres file found.",
664
+ configDir: redactOnePath(paths.configDir, includeRaw)
665
+ };
666
+ }
667
+
668
+ return {
669
+ settingsFile: settingsFile.fileName,
670
+ settingsPath: redactOnePath(settingsFile.absolutePath, includeRaw),
671
+ settings: clippedEntries.map((entry: ProjectSettingEntry): Record<string, unknown> => formatEditorSettingEntry(entry, includeRaw)),
672
+ missingKeys,
673
+ totalMatched: entries.length,
674
+ truncated: entries.length > clippedEntries.length
675
+ };
676
+ }
677
+
678
+ async function maybeAddEditorConfigFile(files: EditorConfigFile[], scope: EditorConfigFileScope, rootPath: string, relativePath: string): Promise<void> {
679
+ if (files.length >= MAX_EDITOR_CONFIG_FILES) {
680
+ return;
681
+ }
682
+
683
+ const normalizedRelativePath: string = relativePath.replaceAll("\\", "/").replace(/^\/+/, "");
684
+ const absolutePath: string = path.resolve(rootPath, normalizedRelativePath);
685
+ if (!isPathInsideRoot(absolutePath, rootPath)) {
686
+ return;
687
+ }
688
+
689
+ try {
690
+ const stat = await fs.stat(absolutePath);
691
+ if (!stat.isFile()) {
692
+ return;
693
+ }
694
+
695
+ files.push({
696
+ fileId: `${scope}:${normalizedRelativePath}`,
697
+ scope,
698
+ relativePath: normalizedRelativePath,
699
+ absolutePath,
700
+ size: stat.size,
701
+ modifiedAt: stat.mtime.toISOString()
702
+ });
703
+ } catch {
704
+ // Missing optional editor file.
705
+ }
706
+ }
707
+
708
+ async function walkAllowedEditorSubdir(files: EditorConfigFile[], scope: EditorConfigFileScope, rootPath: string, subdir: string, allowedExtensions: ReadonlySet<string>): Promise<void> {
709
+ const startPath: string = path.resolve(rootPath, subdir);
710
+ if (!isPathInsideRoot(startPath, rootPath)) {
711
+ return;
712
+ }
713
+
714
+ let entries: Dirent[];
715
+ try {
716
+ entries = await fs.readdir(startPath, { withFileTypes: true });
717
+ } catch {
718
+ return;
719
+ }
720
+
721
+ for (const entry of entries) {
722
+ if (files.length >= MAX_EDITOR_CONFIG_FILES) {
723
+ return;
724
+ }
725
+
726
+ const relativePath: string = `${subdir}/${entry.name}`.replaceAll("\\", "/");
727
+ const absolutePath: string = path.resolve(rootPath, relativePath);
728
+ if (entry.isDirectory()) {
729
+ await walkAllowedEditorSubdir(files, scope, rootPath, relativePath, allowedExtensions);
730
+ continue;
731
+ }
732
+
733
+ if (!entry.isFile() || !allowedExtensions.has(path.extname(entry.name).toLowerCase())) {
734
+ continue;
735
+ }
736
+
737
+ await maybeAddEditorConfigFile(files, scope, rootPath, relativePath);
738
+ }
739
+ }
740
+
741
+ async function listEditorConfigFiles(raw: boolean | undefined): Promise<Record<string, unknown>> {
742
+ const includeRaw: boolean = raw === true;
743
+ const paths: EditorConfigPaths = await getEditorConfigPaths();
744
+ const files: EditorConfigFile[] = [];
745
+
746
+ for (const settingsFile of paths.settingsFiles) {
747
+ await maybeAddEditorConfigFile(files, "global_config", paths.configDir, settingsFile.fileName);
748
+ }
749
+
750
+ for (const fileName of ["projects.cfg", "recent_dirs", "favorite_dirs", "favorite_properties"]) {
751
+ await maybeAddEditorConfigFile(files, "global_config", paths.configDir, fileName);
752
+ }
753
+
754
+ await walkAllowedEditorSubdir(files, "global_config", paths.configDir, "text_editor_themes", new Set([".tet"]));
755
+ await walkAllowedEditorSubdir(files, "global_config", paths.configDir, "script_templates", new Set([".gd", ".cs", ".txt", ".md", ".cfg", ".json"]));
756
+
757
+ let projectEditorEntries: Dirent[] = [];
758
+ try {
759
+ projectEditorEntries = await fs.readdir(paths.projectEditorDir, { withFileTypes: true });
760
+ } catch {
761
+ projectEditorEntries = [];
762
+ }
763
+
764
+ for (const entry of projectEditorEntries) {
765
+ if (files.length >= MAX_EDITOR_CONFIG_FILES) {
766
+ break;
767
+ }
768
+
769
+ if (!entry.isFile()) {
770
+ continue;
771
+ }
772
+
773
+ const extension: string = path.extname(entry.name).toLowerCase();
774
+ const allowed: boolean = extension === ".cfg"
775
+ || entry.name.startsWith("favorites")
776
+ || entry.name.startsWith("create_recent.");
777
+ if (!allowed) {
778
+ continue;
779
+ }
780
+
781
+ await maybeAddEditorConfigFile(files, "project_editor", paths.projectEditorDir, entry.name);
782
+ }
783
+
784
+ const visibleFiles: EditorConfigFile[] = files.slice(0, MAX_EDITOR_CONFIG_FILES);
785
+ return {
786
+ configDir: redactOnePath(paths.configDir, includeRaw),
787
+ projectEditorDir: redactOnePath(paths.projectEditorDir, includeRaw),
788
+ files: visibleFiles.map((file: EditorConfigFile): Record<string, unknown> => ({
789
+ fileId: file.fileId,
790
+ scope: file.scope,
791
+ relativePath: file.relativePath,
792
+ displayPath: file.scope === "global_config"
793
+ ? `Godot/${file.relativePath}`
794
+ : `.godot/editor/${file.relativePath}`,
795
+ absolutePath: includeRaw ? normalizeDisplayPath(file.absolutePath) : undefined,
796
+ size: file.size,
797
+ modifiedAt: file.modifiedAt
798
+ })),
799
+ totalMatched: files.length,
800
+ truncated: files.length >= MAX_EDITOR_CONFIG_FILES
801
+ };
802
+ }
803
+
804
+ function resolveEditorConfigFileIdentifier(fileId: string | undefined, filePath: string | undefined): { scope: EditorConfigFileScope; relativePath: string } {
805
+ const value: string | undefined = fileId?.trim().length ? fileId.trim() : filePath?.trim();
806
+ if (value === undefined || value.length === 0) {
807
+ throw new Error("fileId or filePath is required");
808
+ }
809
+
810
+ const separatorIndex: number = value.indexOf(":");
811
+ if (separatorIndex > 0) {
812
+ const scopeText: string = value.slice(0, separatorIndex);
813
+ if (scopeText === "global_config" || scopeText === "project_editor") {
814
+ return {
815
+ scope: scopeText,
816
+ relativePath: value.slice(separatorIndex + 1).replaceAll("\\", "/").replace(/^\/+/, "")
817
+ };
818
+ }
819
+ }
820
+
821
+ if (value.startsWith(".godot/editor/")) {
822
+ return {
823
+ scope: "project_editor",
824
+ relativePath: value.slice(".godot/editor/".length)
825
+ };
826
+ }
827
+
828
+ if (value.startsWith("Godot/")) {
829
+ return {
830
+ scope: "global_config",
831
+ relativePath: value.slice("Godot/".length)
832
+ };
833
+ }
834
+
835
+ return {
836
+ scope: "global_config",
837
+ relativePath: value.replaceAll("\\", "/").replace(/^\/+/, "")
838
+ };
839
+ }
840
+
841
+ function isAllowedGlobalEditorConfigPath(relativePath: string): boolean {
842
+ const normalizedRelativePath: string = relativePath.replaceAll("\\", "/");
843
+ const fileName: string = path.basename(normalizedRelativePath);
844
+ if (/^editor_settings-\d+(?:\.\d+)?\.tres$/.test(fileName) && !normalizedRelativePath.includes("/")) {
845
+ return true;
846
+ }
847
+
848
+ if (["projects.cfg", "recent_dirs", "favorite_dirs", "favorite_properties"].includes(normalizedRelativePath)) {
849
+ return true;
850
+ }
851
+
852
+ if (normalizedRelativePath.startsWith("text_editor_themes/") && path.extname(normalizedRelativePath).toLowerCase() === ".tet") {
853
+ return true;
854
+ }
855
+
856
+ if (normalizedRelativePath.startsWith("script_templates/")) {
857
+ return [".gd", ".cs", ".txt", ".md", ".cfg", ".json"].includes(path.extname(normalizedRelativePath).toLowerCase());
858
+ }
859
+
860
+ return false;
861
+ }
862
+
863
+ function isAllowedProjectEditorConfigPath(relativePath: string): boolean {
864
+ const normalizedRelativePath: string = relativePath.replaceAll("\\", "/");
865
+ if (normalizedRelativePath.includes("/")) {
866
+ return false;
867
+ }
868
+
869
+ const fileName: string = path.basename(normalizedRelativePath);
870
+ return path.extname(fileName).toLowerCase() === ".cfg"
871
+ || fileName.startsWith("favorites")
872
+ || fileName.startsWith("create_recent.");
873
+ }
874
+
875
+ async function readEditorConfigFile(fileId: string | undefined, filePath: string | undefined, raw: boolean | undefined): Promise<Record<string, unknown>> {
876
+ const includeRaw: boolean = raw === true;
877
+ const paths: EditorConfigPaths = await getEditorConfigPaths();
878
+ const identifier = resolveEditorConfigFileIdentifier(fileId, filePath);
879
+ const rootPath: string = identifier.scope === "global_config" ? paths.configDir : paths.projectEditorDir;
880
+ const relativePath: string = identifier.relativePath;
881
+
882
+ if (relativePath.length === 0 || relativePath.includes("..")) {
883
+ throw new Error(`Invalid editor config file path: ${relativePath}`);
884
+ }
885
+
886
+ const allowed: boolean = identifier.scope === "global_config"
887
+ ? isAllowedGlobalEditorConfigPath(relativePath)
888
+ : isAllowedProjectEditorConfigPath(relativePath);
889
+ if (!allowed) {
890
+ throw new Error(`Editor config file is outside the read-only whitelist: ${identifier.scope}:${relativePath}`);
891
+ }
892
+
893
+ const absolutePath: string = path.resolve(rootPath, relativePath);
894
+ if (!isPathInsideRoot(absolutePath, rootPath)) {
895
+ throw new Error(`Editor config path traversal denied: ${relativePath}`);
896
+ }
897
+
898
+ const stat = await fs.stat(absolutePath);
899
+ if (!stat.isFile()) {
900
+ throw new Error(`Not an editor config file: ${relativePath}`);
901
+ }
902
+
903
+ if (stat.size > MAX_EDITOR_CONFIG_FILE_BYTES) {
904
+ throw new Error(`Editor config file too large: ${relativePath} (${stat.size} bytes)`);
905
+ }
906
+
907
+ const content: string = await fs.readFile(absolutePath, "utf8");
908
+ return {
909
+ fileId: `${identifier.scope}:${relativePath}`,
910
+ scope: identifier.scope,
911
+ relativePath,
912
+ displayPath: identifier.scope === "global_config"
913
+ ? `Godot/${relativePath}`
914
+ : `.godot/editor/${relativePath}`,
915
+ absolutePath: includeRaw ? normalizeDisplayPath(absolutePath) : undefined,
916
+ size: stat.size,
917
+ modifiedAt: stat.mtime.toISOString(),
918
+ raw: includeRaw,
919
+ content: redactSensitivePaths(content, includeRaw)
920
+ };
921
+ }
922
+
923
+ function createRecentProjectSummary(sectionPath: string, favoriteValue: string | undefined, raw: boolean): Record<string, unknown> {
924
+ const favorite: boolean = parseProjectSettingBoolean(favoriteValue, false);
925
+ return {
926
+ name: path.basename(sectionPath),
927
+ path: redactOnePath(sectionPath, raw),
928
+ absolutePath: raw ? normalizeDisplayPath(sectionPath) : undefined,
929
+ isCurrentProject: isCurrentProjectPath(sectionPath),
930
+ favorite
931
+ };
932
+ }
933
+
934
+ async function getRecentProjects(raw: boolean | undefined): Promise<Record<string, unknown>> {
935
+ const includeRaw: boolean = raw === true;
936
+ const configDir: string = getGodotConfigDir();
937
+ const projectsPath: string = path.join(configDir, "projects.cfg");
938
+ const recentDirsPath: string = path.join(configDir, "recent_dirs");
939
+ const projectsDocument: ProjectSettingsDocument | null = await readConfigDocumentIfExists(projectsPath);
940
+ let recentProjects: Array<Record<string, unknown>> = [];
941
+
942
+ if (projectsDocument !== null) {
943
+ const sections: string[] = Array.from(new Set(projectsDocument.entries.map((entry: ProjectSettingEntry): string => entry.section)));
944
+ recentProjects = sections.slice(0, MAX_RECENT_PROJECTS_RESULT).map((section: string): Record<string, unknown> =>
945
+ createRecentProjectSummary(section, findProjectSettingEntry(projectsDocument, `${section}/favorite`)?.valueExpression, includeRaw)
946
+ );
947
+ }
948
+
949
+ let recentDirs: string[] = [];
950
+ try {
951
+ const recentDirsContent: string = await fs.readFile(recentDirsPath, "utf8");
952
+ recentDirs = recentDirsContent
953
+ .split(/\r?\n/)
954
+ .map((line: string): string => line.trim())
955
+ .filter((line: string): boolean => line.length > 0)
956
+ .slice(0, MAX_RECENT_PROJECTS_RESULT)
957
+ .map((line: string): string => redactOnePath(line, includeRaw));
958
+ } catch {
959
+ recentDirs = [];
960
+ }
961
+
962
+ return {
963
+ configDir: redactOnePath(configDir, includeRaw),
964
+ projectsFile: redactOnePath(projectsPath, includeRaw),
965
+ recentDirsFile: redactOnePath(recentDirsPath, includeRaw),
966
+ projects: recentProjects,
967
+ recentDirs,
968
+ raw: includeRaw
969
+ };
970
+ }
971
+
972
+ async function getEditorProjectState(raw: boolean | undefined): Promise<Record<string, unknown>> {
973
+ const includeRaw: boolean = raw === true;
974
+ const projectEditorDir: string = getProjectEditorDir();
975
+ const layoutDocument: ProjectSettingsDocument | null = await readConfigDocumentIfExists(path.join(projectEditorDir, "editor_layout.cfg"));
976
+ const scriptCacheDocument: ProjectSettingsDocument | null = await readConfigDocumentIfExists(path.join(projectEditorDir, "script_editor_cache.cfg"));
977
+ const openScenes: string[] = parseGodotStringList(getDocumentValue(layoutDocument, "EditorNode/open_scenes"));
978
+ const currentScene: string | undefined = parseProjectSettingString(getDocumentValue(layoutDocument, "EditorNode/current_scene"));
979
+ const fileSystemSelectedPaths: string[] = parseGodotStringList(getDocumentValue(layoutDocument, "docks/FileSystem/selected_paths"));
980
+ const fileSystemUncollapsedPaths: string[] = parseGodotStringList(getDocumentValue(layoutDocument, "docks/FileSystem/uncollapsed_paths"));
981
+ const openScripts: string[] = parseGodotStringList(getDocumentValue(layoutDocument, "ScriptEditor/open_scripts"));
982
+ const selectedScript: string | undefined = parseProjectSettingString(getDocumentValue(layoutDocument, "ScriptEditor/selected_script"));
983
+ const openHelp: string[] = parseGodotStringList(getDocumentValue(layoutDocument, "ScriptEditor/open_help"));
984
+ const scriptStates: ScriptEditorState[] = scriptCacheDocument === null
985
+ ? []
986
+ : scriptCacheDocument.entries
987
+ .filter((entry: ProjectSettingEntry): boolean => entry.name === "state")
988
+ .map((entry: ProjectSettingEntry): ScriptEditorState => createScriptEditorState(entry.section, entry.valueExpression));
989
+ const selectedScriptState: ScriptEditorState | undefined = selectedScript === undefined
990
+ ? undefined
991
+ : scriptStates.find((state: ScriptEditorState): boolean => state.resourcePath === selectedScript);
992
+
993
+ return {
994
+ projectEditorDir: redactOnePath(projectEditorDir, includeRaw),
995
+ layoutFileExists: layoutDocument !== null,
996
+ scriptCacheFileExists: scriptCacheDocument !== null,
997
+ openScenes: sanitizePathList(openScenes, includeRaw),
998
+ currentScene: currentScene === undefined ? null : redactOnePath(currentScene, includeRaw),
999
+ fileSystemSelectedPaths: sanitizePathList(fileSystemSelectedPaths, includeRaw),
1000
+ fileSystemUncollapsedPathCount: fileSystemUncollapsedPaths.length,
1001
+ openScripts: sanitizePathList(openScripts, includeRaw),
1002
+ selectedScript: selectedScript === undefined ? null : redactOnePath(selectedScript, includeRaw),
1003
+ selectedScriptState: selectedScriptState === undefined ? null : {
1004
+ ...selectedScriptState,
1005
+ resourcePath: redactOnePath(selectedScriptState.resourcePath, includeRaw)
1006
+ },
1007
+ openHelp,
1008
+ scriptStates: scriptStates.slice(0, 50).map((state: ScriptEditorState): ScriptEditorState => ({
1009
+ ...state,
1010
+ resourcePath: redactOnePath(state.resourcePath, includeRaw)
1011
+ })),
1012
+ scriptStateCount: scriptStates.length,
1013
+ raw: includeRaw
1014
+ };
1015
+ }
1016
+
1017
+ async function getEditorConfigSummary(raw: boolean | undefined): Promise<Record<string, unknown>> {
1018
+ const includeRaw: boolean = raw === true;
1019
+ const { paths, document } = await readEditorSettingsDocument();
1020
+ const recentProjects: Record<string, unknown> = await getRecentProjects(includeRaw);
1021
+ const projectState: Record<string, unknown> = await getEditorProjectState(includeRaw);
1022
+ const settingValue = (key: string): string | undefined => document === null ? undefined : findEditorSettingEntry(document, key)?.valueExpression;
1023
+ const openScenes: unknown = projectState["openScenes"];
1024
+ const openScripts: unknown = projectState["openScripts"];
1025
+ const recentProjectsValue: unknown = recentProjects["projects"];
1026
+
1027
+ return {
1028
+ configDir: redactOnePath(paths.configDir, includeRaw),
1029
+ projectEditorDir: redactOnePath(paths.projectEditorDir, includeRaw),
1030
+ settingsFile: paths.settingsFile === null ? null : {
1031
+ fileName: paths.settingsFile.fileName,
1032
+ version: paths.settingsFile.version,
1033
+ path: redactOnePath(paths.settingsFile.absolutePath, includeRaw),
1034
+ size: paths.settingsFile.size,
1035
+ modifiedAt: paths.settingsFile.modifiedAt
1036
+ },
1037
+ availableSettingsFiles: paths.settingsFiles.map((file: EditorSettingsFile): Record<string, unknown> => ({
1038
+ fileName: file.fileName,
1039
+ version: file.version,
1040
+ path: redactOnePath(file.absolutePath, includeRaw),
1041
+ modifiedAt: file.modifiedAt
1042
+ })),
1043
+ interface: {
1044
+ language: parseProjectSettingString(settingValue("interface/editor/localization/editor_language")) ?? null,
1045
+ displayScale: settingValue("interface/editor/appearance/display_scale") ?? null,
1046
+ customDisplayScale: settingValue("interface/editor/appearance/custom_display_scale") ?? null,
1047
+ mainFontSize: settingValue("interface/editor/fonts/main_font_size") ?? null,
1048
+ codeFontSize: settingValue("interface/editor/fonts/code_font_size") ?? null,
1049
+ mainFont: redactSensitivePaths(parseProjectSettingString(settingValue("interface/editor/fonts/main_font")) ?? "", includeRaw),
1050
+ codeFont: redactSensitivePaths(parseProjectSettingString(settingValue("interface/editor/fonts/code_font")) ?? "", includeRaw)
1051
+ },
1052
+ theme: {
1053
+ style: parseProjectSettingString(settingValue("interface/theme/style")) ?? null,
1054
+ colorPreset: parseProjectSettingString(settingValue("interface/theme/color_preset")) ?? null,
1055
+ baseColor: settingValue("interface/theme/base_color") ?? null,
1056
+ accentColor: settingValue("interface/theme/accent_color") ?? null,
1057
+ cornerRadius: settingValue("interface/theme/corner_radius") ?? null
1058
+ },
1059
+ recentProjectCount: Array.isArray(recentProjectsValue) ? recentProjectsValue.length : 0,
1060
+ projectState: {
1061
+ currentScene: projectState["currentScene"],
1062
+ openSceneCount: Array.isArray(openScenes) ? openScenes.length : 0,
1063
+ selectedScript: projectState["selectedScript"],
1064
+ openScriptCount: Array.isArray(openScripts) ? openScripts.length : 0,
1065
+ fileSystemSelectedPaths: projectState["fileSystemSelectedPaths"]
1066
+ },
1067
+ raw: includeRaw,
1068
+ privacyNote: includeRaw
1069
+ ? "raw=true:工具结果包含原始本机路径。"
1070
+ : "默认已脱敏:非当前项目绝对路径会被隐藏,Godot 配置目录会显示为 %APPDATA%/Godot。"
1071
+ };
1072
+ }
1073
+
1074
+ async function listAddons(): Promise<string[]> {
1075
+ const addonsPath: string = path.join(projectRoot, "addons");
1076
+ try {
1077
+ const entries: Dirent[] = await fs.readdir(addonsPath, { withFileTypes: true });
1078
+ return entries
1079
+ .filter((entry: Dirent): boolean => entry.isDirectory())
1080
+ .map((entry: Dirent): string => entry.name)
1081
+ .sort();
1082
+ } catch {
1083
+ return [];
1084
+ }
1085
+ }
1086
+
1087
+ async function getProjectSummary(): Promise<ProjectSummary> {
1088
+ const config: Record<string, string> = await readProjectConfig();
1089
+ const scenes: string[] = await walkProjectFiles({ extensions: [".tscn"] });
1090
+ const scripts: string[] = await walkProjectFiles({ extensions: [".gd"] });
1091
+ const addons: string[] = await listAddons();
1092
+
1093
+ return {
1094
+ path: projectRoot,
1095
+ name: parseProjectSettingString(config["application/config/name"] ?? config["config/name"]) ?? "unknown",
1096
+ mainScene: parseProjectSettingString(config["application/run/main_scene"] ?? config["run/main_scene"]) ?? "",
1097
+ features: config["application/config/features"] ?? config["config/features"] ?? "",
1098
+ addons,
1099
+ sceneCount: scenes.length,
1100
+ scriptCount: scripts.length
1101
+ };
1102
+ }
1103
+
1104
+ async function readTextFile(relativePath: string): Promise<string> {
1105
+ const fullPath: string = await resolveProjectPath(relativePath);
1106
+ const stat = await fs.stat(fullPath);
1107
+
1108
+ if (!stat.isFile()) {
1109
+ throw new Error(`Not a file: ${relativePath}`);
1110
+ }
1111
+
1112
+ if (stat.size > MAX_TEXT_FILE_BYTES) {
1113
+ throw new Error(`File too large: ${relativePath} (${stat.size} bytes)`);
1114
+ }
1115
+
1116
+ const extension: string = path.extname(fullPath);
1117
+ const fileName: string = path.basename(fullPath);
1118
+ if (fileName !== "project.godot" && !TEXT_EXTENSIONS.has(extension)) {
1119
+ throw new Error(`Unsupported text file extension: ${extension || "(none)"}`);
1120
+ }
1121
+
1122
+ return fs.readFile(fullPath, "utf8");
1123
+ }
1124
+
1125
+ function isPathInsideRoot(absolutePath: string, rootPath: string): boolean {
1126
+ const relativePath: string = path.relative(rootPath, absolutePath);
1127
+ return relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
1128
+ }
1129
+
1130
+ function getWindowsAppDataPath(): string {
1131
+ const appDataPath: string | undefined = process.env.APPDATA;
1132
+ if (appDataPath === undefined || appDataPath.trim().length === 0) {
1133
+ throw new Error("APPDATA is not configured; cannot resolve Godot user:// paths");
1134
+ }
1135
+
1136
+ return appDataPath;
1137
+ }
1138
+
1139
+ function getUserProfilePath(): string | undefined {
1140
+ const userProfilePath: string | undefined = process.env.USERPROFILE;
1141
+ return userProfilePath !== undefined && userProfilePath.trim().length > 0
1142
+ ? path.resolve(userProfilePath)
1143
+ : undefined;
1144
+ }
1145
+
1146
+ function getGodotConfigDir(): string {
1147
+ return path.join(getWindowsAppDataPath(), GODOT_CONFIG_DIR_NAME);
1148
+ }
1149
+
1150
+ function getProjectEditorDir(): string {
1151
+ return path.join(projectRoot, ".godot", "editor");
1152
+ }
1153
+
1154
+ function normalizeDisplayPath(value: string): string {
1155
+ return value.replaceAll("\\", "/");
1156
+ }
1157
+
1158
+ function isWindowsAbsolutePath(value: string): boolean {
1159
+ return /^[A-Za-z]:[\\/]/.test(value);
1160
+ }
1161
+
1162
+ function isCurrentProjectPath(value: string): boolean {
1163
+ if (!isWindowsAbsolutePath(value) && !path.isAbsolute(value)) {
1164
+ return false;
1165
+ }
1166
+
1167
+ return isPathInsideRoot(path.resolve(value), projectRoot);
1168
+ }
1169
+
1170
+ function redactOnePath(value: string, raw: boolean): string {
1171
+ if (raw || value.startsWith("res://") || value.startsWith("uid://") || value.startsWith("user://")) {
1172
+ return normalizeDisplayPath(value);
1173
+ }
1174
+
1175
+ const normalizedValue: string = normalizeDisplayPath(value);
1176
+ const userProfilePath: string | undefined = getUserProfilePath();
1177
+ const appDataPath: string = path.resolve(getWindowsAppDataPath());
1178
+ const godotConfigDir: string = path.resolve(getGodotConfigDir());
1179
+
1180
+ if (isCurrentProjectPath(normalizedValue)) {
1181
+ return normalizeDisplayPath(path.resolve(normalizedValue));
1182
+ }
1183
+
1184
+ if (isPathInsideRoot(path.resolve(normalizedValue), godotConfigDir)) {
1185
+ const relativePath: string = path.relative(godotConfigDir, path.resolve(normalizedValue)).replaceAll(path.sep, "/");
1186
+ return `%APPDATA%/Godot/${relativePath}`;
1187
+ }
1188
+
1189
+ if (isPathInsideRoot(path.resolve(normalizedValue), appDataPath)) {
1190
+ const relativePath: string = path.relative(appDataPath, path.resolve(normalizedValue)).replaceAll(path.sep, "/");
1191
+ return `%APPDATA%/${relativePath}`;
1192
+ }
1193
+
1194
+ if (userProfilePath !== undefined && isPathInsideRoot(path.resolve(normalizedValue), userProfilePath)) {
1195
+ const relativePath: string = path.relative(userProfilePath, path.resolve(normalizedValue)).replaceAll(path.sep, "/");
1196
+ return `%USERPROFILE%/${relativePath}`;
1197
+ }
1198
+
1199
+ if (isWindowsAbsolutePath(normalizedValue) || path.isAbsolute(normalizedValue)) {
1200
+ return `[redacted]/${path.basename(normalizedValue)}`;
1201
+ }
1202
+
1203
+ return normalizedValue;
1204
+ }
1205
+
1206
+ function redactSensitivePaths(text: string, raw: boolean): string {
1207
+ if (raw) {
1208
+ return normalizeDisplayPath(text);
1209
+ }
1210
+
1211
+ let redactedText: string = normalizeDisplayPath(text);
1212
+ const userProfilePath: string | undefined = getUserProfilePath();
1213
+ const replacements: Array<[string, string]> = [
1214
+ [normalizeDisplayPath(getGodotConfigDir()), "%APPDATA%/Godot"],
1215
+ [normalizeDisplayPath(getWindowsAppDataPath()), "%APPDATA%"],
1216
+ [normalizeDisplayPath(projectRoot), normalizeDisplayPath(projectRoot)]
1217
+ ];
1218
+
1219
+ if (userProfilePath !== undefined) {
1220
+ replacements.push([normalizeDisplayPath(userProfilePath), "%USERPROFILE%"]);
1221
+ }
1222
+
1223
+ for (const [fromText, toText] of replacements) {
1224
+ if (fromText.length > 0) {
1225
+ redactedText = redactedText.replaceAll(fromText, toText);
1226
+ }
1227
+ }
1228
+
1229
+ redactedText = redactedText.replace(/[A-Za-z]:\/[^"\s,)]+/g, (matchedPath: string): string => {
1230
+ if (isCurrentProjectPath(matchedPath) || matchedPath.startsWith("%APPDATA%") || matchedPath.startsWith("%USERPROFILE%")) {
1231
+ return matchedPath;
1232
+ }
1233
+
1234
+ return `[redacted]/${path.basename(matchedPath)}`;
1235
+ });
1236
+
1237
+ return redactedText;
1238
+ }
1239
+
1240
+ function sanitizeGodotUserDirName(value: string): string {
1241
+ const sanitized: string = value.trim()
1242
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
1243
+ .replace(/[. ]+$/g, "")
1244
+ .trim();
1245
+
1246
+ return sanitized.length > 0 ? sanitized : "[unnamed project]";
1247
+ }
1248
+
1249
+ function getProjectNameForUserData(config: Record<string, string>): string {
1250
+ const projectName: string | undefined = parseProjectSettingString(config["application/config/name"]);
1251
+ return sanitizeGodotUserDirName(projectName ?? "[unnamed project]");
1252
+ }
1253
+
1254
+ function getGodotUserDataDir(config: Record<string, string>): string {
1255
+ const appDataPath: string = getWindowsAppDataPath();
1256
+ const projectName: string = getProjectNameForUserData(config);
1257
+ const useCustomUserDir: boolean = parseProjectSettingBoolean(config["application/config/use_custom_user_dir"], false);
1258
+
1259
+ if (useCustomUserDir) {
1260
+ const customUserDirName: string | undefined = parseProjectSettingString(config["application/config/custom_user_dir_name"]);
1261
+ return path.join(appDataPath, sanitizeGodotUserDirName(customUserDirName ?? projectName));
1262
+ }
1263
+
1264
+ return path.join(appDataPath, "Godot", "app_userdata", projectName);
1265
+ }
1266
+
1267
+ function resolveGodotPath(resourcePath: string, config: Record<string, string>): ResolvedGodotPath {
1268
+ const trimmedPath: string = resourcePath.trim();
1269
+ const userDataDir: string = getGodotUserDataDir(config);
1270
+
1271
+ if (trimmedPath.startsWith("user://")) {
1272
+ const relativePath: string = trimmedPath.slice("user://".length).replace(/^[/\\]+/, "");
1273
+ const absolutePath: string = path.resolve(userDataDir, relativePath);
1274
+ if (!isPathInsideRoot(absolutePath, userDataDir)) {
1275
+ throw new Error(`user:// path traversal denied: ${resourcePath}`);
1276
+ }
1277
+
1278
+ return {
1279
+ originalPath: resourcePath,
1280
+ absolutePath,
1281
+ rootPath: userDataDir,
1282
+ kind: "user"
1283
+ };
1284
+ }
1285
+
1286
+ if (trimmedPath.startsWith("res://")) {
1287
+ const relativePath: string = trimmedPath.slice("res://".length).replace(/^[/\\]+/, "");
1288
+ const absolutePath: string = path.resolve(projectRoot, relativePath);
1289
+ if (!isPathInsideRoot(absolutePath, projectRoot)) {
1290
+ throw new Error(`res:// path traversal denied: ${resourcePath}`);
1291
+ }
1292
+
1293
+ return {
1294
+ originalPath: resourcePath,
1295
+ absolutePath,
1296
+ rootPath: projectRoot,
1297
+ kind: "res"
1298
+ };
1299
+ }
1300
+
1301
+ if (!path.isAbsolute(trimmedPath)) {
1302
+ const absolutePath: string = path.resolve(userDataDir, trimmedPath);
1303
+ if (!isPathInsideRoot(absolutePath, userDataDir)) {
1304
+ throw new Error(`Relative user data path traversal denied: ${resourcePath}`);
1305
+ }
1306
+
1307
+ return {
1308
+ originalPath: resourcePath,
1309
+ absolutePath,
1310
+ rootPath: userDataDir,
1311
+ kind: "relative_user"
1312
+ };
1313
+ }
1314
+
1315
+ const absolutePath: string = path.resolve(trimmedPath);
1316
+ if (!isPathInsideRoot(absolutePath, userDataDir) && !isPathInsideRoot(absolutePath, projectRoot)) {
1317
+ throw new Error(`Absolute path is outside allowed Godot project/user data roots: ${resourcePath}`);
1318
+ }
1319
+
1320
+ return {
1321
+ originalPath: resourcePath,
1322
+ absolutePath,
1323
+ rootPath: isPathInsideRoot(absolutePath, userDataDir) ? userDataDir : projectRoot,
1324
+ kind: "absolute"
1325
+ };
1326
+ }
1327
+
1328
+ async function getProjectLogConfig(): Promise<ProjectLogConfig> {
1329
+ const config: Record<string, string> = await readProjectConfig();
1330
+ const projectName: string = getProjectNameForUserData(config);
1331
+ const userDataDir: string = getGodotUserDataDir(config);
1332
+ const configuredLogPath: string | undefined = parseProjectSettingString(config["debug/file_logging/log_path"]);
1333
+ const logPath: string = configuredLogPath ?? DEFAULT_GODOT_LOG_PATH;
1334
+ const resolvedPath: ResolvedGodotPath = resolveGodotPath(logPath, config);
1335
+ const hasExplicitFileLogging: boolean = config["debug/file_logging/enable_file_logging"] !== undefined
1336
+ || config["debug/file_logging/enable_file_logging.pc"] !== undefined;
1337
+ const fileLoggingEnabled: boolean = parseProjectSettingBoolean(
1338
+ config["debug/file_logging/enable_file_logging.pc"] ?? config["debug/file_logging/enable_file_logging"],
1339
+ true
1340
+ );
1341
+ const configuredMaxLogFiles: string | undefined = config["debug/file_logging/max_log_files"];
1342
+ const maxLogFiles: number = Math.max(0, parseProjectSettingInteger(configuredMaxLogFiles, DEFAULT_GODOT_LOG_MAX_FILES));
1343
+
1344
+ return {
1345
+ projectName,
1346
+ userDataDir,
1347
+ logPath,
1348
+ logPathSource: configuredLogPath === undefined ? "default" : "project",
1349
+ absolutePath: resolvedPath.absolutePath,
1350
+ logDirectory: path.dirname(resolvedPath.absolutePath),
1351
+ fileLoggingEnabled,
1352
+ fileLoggingEnabledSource: hasExplicitFileLogging ? "project" : "default_pc",
1353
+ maxLogFiles,
1354
+ maxLogFilesSource: configuredMaxLogFiles === undefined ? "default" : "project",
1355
+ pathKind: resolvedPath.kind,
1356
+ resolutionNote: "Godot 的 user:// 会解析到 OS.get_user_data_dir();当前 Windows 项目默认位于 %APPDATA%/Godot/app_userdata/<application/config/name>。"
1357
+ };
1358
+ }
1359
+
1360
+ async function listProjectLogFiles(): Promise<{ config: ProjectLogConfig; logs: ProjectLogFile[] }> {
1361
+ const config: ProjectLogConfig = await getProjectLogConfig();
1362
+ const baseName: string = path.basename(config.absolutePath);
1363
+ const extension: string = path.extname(baseName) || ".log";
1364
+ const stem: string = baseName.endsWith(extension) ? baseName.slice(0, -extension.length) : baseName;
1365
+
1366
+ let entries: Dirent[];
1367
+ try {
1368
+ entries = await fs.readdir(config.logDirectory, { withFileTypes: true });
1369
+ } catch {
1370
+ return { config, logs: [] };
1371
+ }
1372
+
1373
+ const logs: ProjectLogFile[] = [];
1374
+ for (const entry of entries) {
1375
+ if (!entry.isFile()) {
1376
+ continue;
1377
+ }
1378
+
1379
+ const fileName: string = entry.name;
1380
+ if (fileName !== baseName && (!fileName.startsWith(stem) || !fileName.endsWith(extension))) {
1381
+ continue;
1382
+ }
1383
+
1384
+ const absolutePath: string = path.resolve(config.logDirectory, fileName);
1385
+ if (!isPathInsideRoot(absolutePath, config.logDirectory)) {
1386
+ continue;
1387
+ }
1388
+
1389
+ const stat = await fs.stat(absolutePath);
1390
+ logs.push({
1391
+ fileName,
1392
+ absolutePath,
1393
+ size: stat.size,
1394
+ modifiedAt: stat.mtime.toISOString()
1395
+ });
1396
+ }
1397
+
1398
+ logs.sort((left: ProjectLogFile, right: ProjectLogFile): number =>
1399
+ Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt)
1400
+ );
1401
+ return { config, logs };
1402
+ }
1403
+
1404
+ async function readFileTail(absolutePath: string, maxBytes: number): Promise<{ text: string; bytesRead: number; truncatedBytes: boolean }> {
1405
+ const stat = await fs.stat(absolutePath);
1406
+ const bytesToRead: number = Math.min(stat.size, maxBytes);
1407
+ const start: number = Math.max(0, stat.size - bytesToRead);
1408
+ const handle = await fs.open(absolutePath, "r");
1409
+
1410
+ try {
1411
+ const buffer: Buffer = Buffer.alloc(bytesToRead);
1412
+ await handle.read(buffer, 0, bytesToRead, start);
1413
+ let text: string = buffer.toString("utf8");
1414
+ if (start > 0) {
1415
+ const firstNewlineIndex: number = text.indexOf("\n");
1416
+ if (firstNewlineIndex >= 0) {
1417
+ text = text.slice(firstNewlineIndex + 1);
1418
+ }
1419
+ }
1420
+
1421
+ return {
1422
+ text,
1423
+ bytesRead: bytesToRead,
1424
+ truncatedBytes: stat.size > bytesToRead
1425
+ };
1426
+ } finally {
1427
+ await handle.close();
1428
+ }
1429
+ }
1430
+
1431
+ async function readProjectLog(fileName: string | undefined, lines: number | undefined): Promise<Record<string, unknown>> {
1432
+ const { config, logs } = await listProjectLogFiles();
1433
+ const requestedLines: number = Math.max(1, Math.min(MAX_PROJECT_LOG_LINES, Math.floor(lines ?? DEFAULT_PROJECT_LOG_LINES)));
1434
+ const baseName: string = path.basename(config.absolutePath);
1435
+ let selectedLog: ProjectLogFile | undefined;
1436
+
1437
+ if (fileName !== undefined && fileName.trim().length > 0) {
1438
+ const normalizedFileName: string = path.basename(fileName.trim());
1439
+ if (normalizedFileName !== fileName.trim() || normalizedFileName.includes("/") || normalizedFileName.includes("\\")) {
1440
+ throw new Error("fileName must be a plain log file name from mcp_godot_list_project_logs");
1441
+ }
1442
+
1443
+ selectedLog = logs.find((log: ProjectLogFile): boolean => log.fileName === normalizedFileName);
1444
+ } else {
1445
+ selectedLog = logs.find((log: ProjectLogFile): boolean => log.fileName === baseName) ?? logs[0];
1446
+ }
1447
+
1448
+ if (selectedLog === undefined) {
1449
+ return {
1450
+ ok: false,
1451
+ message: "No Godot project log file found.",
1452
+ config,
1453
+ logs
1454
+ };
1455
+ }
1456
+
1457
+ const absolutePath: string = path.resolve(config.logDirectory, selectedLog.fileName);
1458
+ if (!isPathInsideRoot(absolutePath, config.logDirectory)) {
1459
+ throw new Error(`Log path traversal denied: ${selectedLog.fileName}`);
1460
+ }
1461
+
1462
+ const tail = await readFileTail(absolutePath, MAX_PROJECT_LOG_BYTES);
1463
+ const allLines: string[] = tail.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1464
+ const selectedLines: string[] = allLines.slice(-requestedLines);
1465
+
1466
+ return {
1467
+ ok: true,
1468
+ fileName: selectedLog.fileName,
1469
+ absolutePath,
1470
+ logPath: config.logPath,
1471
+ lines: selectedLines,
1472
+ lineCount: selectedLines.length,
1473
+ requestedLines,
1474
+ bytesRead: tail.bytesRead,
1475
+ truncatedBytes: tail.truncatedBytes,
1476
+ config,
1477
+ resolutionNote: config.resolutionNote
1478
+ };
1479
+ }
1480
+
1481
+ function splitProjectSettingKey(fullKey: string): { section: string; name: string } {
1482
+ const trimmedKey: string = fullKey.trim();
1483
+ const separatorIndex: number = trimmedKey.indexOf("/");
1484
+ if (
1485
+ trimmedKey.length === 0
1486
+ || separatorIndex <= 0
1487
+ || separatorIndex === trimmedKey.length - 1
1488
+ || trimmedKey.includes("\n")
1489
+ || trimmedKey.includes("\r")
1490
+ || /[\[\]=]/.test(trimmedKey)
1491
+ || !/^[A-Za-z0-9_./-]+$/.test(trimmedKey)
1492
+ ) {
1493
+ throw new Error(`Invalid project setting key: ${fullKey}`);
1494
+ }
1495
+
1496
+ return {
1497
+ section: trimmedKey.slice(0, separatorIndex),
1498
+ name: trimmedKey.slice(separatorIndex + 1)
1499
+ };
1500
+ }
1501
+
1502
+ function normalizeProjectSettingValueExpression(valueExpression: string): string {
1503
+ const normalizedValue: string = normalizeConfigContent(valueExpression).trimEnd();
1504
+ const valueLines: string[] = normalizedValue.split("\n");
1505
+
1506
+ if (normalizedValue.trim().length === 0) {
1507
+ throw new Error("valueExpression must not be empty");
1508
+ }
1509
+
1510
+ if (normalizedValue.length > MAX_PROJECT_SETTING_VALUE_CHARS) {
1511
+ throw new Error(`valueExpression too large: ${normalizedValue.length} chars (max ${MAX_PROJECT_SETTING_VALUE_CHARS})`);
1512
+ }
1513
+
1514
+ if (valueLines.length > MAX_PROJECT_SETTING_VALUE_LINES) {
1515
+ throw new Error(`valueExpression has too many lines: ${valueLines.length} (max ${MAX_PROJECT_SETTING_VALUE_LINES})`);
1516
+ }
1517
+
1518
+ for (let index: number = 1; index < valueLines.length; index += 1) {
1519
+ if (/^\s*\[[^\]]+\]\s*$/.test(valueLines[index]!)) {
1520
+ throw new Error("valueExpression must not contain project.godot section headers");
1521
+ }
1522
+ }
1523
+
1524
+ const balance: number = valueLines.reduce((sum: number, line: string): number => sum + getExpressionBalance(line), 0);
1525
+ if (balance !== 0) {
1526
+ throw new Error("valueExpression has unbalanced braces, brackets, or parentheses");
1527
+ }
1528
+
1529
+ return normalizedValue;
1530
+ }
1531
+
1532
+ function createProjectSettingAssignmentLines(name: string, valueExpression: string): string[] {
1533
+ const valueLines: string[] = valueExpression.split("\n");
1534
+ return [`${name}=${valueLines[0] ?? ""}`, ...valueLines.slice(1)];
1535
+ }
1536
+
1537
+ function findProjectSettingEntry(document: ProjectSettingsDocument, fullKey: string): ProjectSettingEntry | undefined {
1538
+ return document.entries.find((entry: ProjectSettingEntry): boolean => entry.fullKey === fullKey);
1539
+ }
1540
+
1541
+ function findProjectSettingInsertIndex(document: ProjectSettingsDocument, section: string): number {
1542
+ const sectionLineIndex: number | undefined = document.sectionLineIndexes.get(section);
1543
+ if (sectionLineIndex === undefined) {
1544
+ return -1;
1545
+ }
1546
+
1547
+ let nextSectionIndex: number = document.lines.length;
1548
+ for (let index: number = sectionLineIndex + 1; index < document.lines.length; index += 1) {
1549
+ if (/^\s*\[[^\]]+\]\s*$/.test(document.lines[index]!)) {
1550
+ nextSectionIndex = index;
1551
+ break;
1552
+ }
1553
+ }
1554
+
1555
+ let insertIndex: number = nextSectionIndex;
1556
+ while (insertIndex > sectionLineIndex + 1 && document.lines[insertIndex - 1]!.trim().length === 0) {
1557
+ insertIndex -= 1;
1558
+ }
1559
+
1560
+ return insertIndex;
1561
+ }
1562
+
1563
+ function finalizeProjectConfigContent(lines: string[]): string {
1564
+ return `${lines.join("\n").replace(/\n*$/g, "")}\n`;
1565
+ }
1566
+
1567
+ function applyProjectSettingSetToContent(
1568
+ document: ProjectSettingsDocument,
1569
+ fullKey: string,
1570
+ valueExpression: string
1571
+ ): {
1572
+ content: string;
1573
+ action: "add" | "update";
1574
+ oldValueExpression: string | null;
1575
+ lineStart: number | null;
1576
+ lineEnd: number | null;
1577
+ } {
1578
+ const { section, name } = splitProjectSettingKey(fullKey);
1579
+ const normalizedValue: string = normalizeProjectSettingValueExpression(valueExpression);
1580
+ const assignmentLines: string[] = createProjectSettingAssignmentLines(name, normalizedValue);
1581
+ const lines: string[] = [...document.lines];
1582
+ const existingEntry: ProjectSettingEntry | undefined = findProjectSettingEntry(document, fullKey);
1583
+
1584
+ if (existingEntry !== undefined) {
1585
+ lines.splice(existingEntry.lineStart, existingEntry.lineEnd - existingEntry.lineStart + 1, ...assignmentLines);
1586
+ return {
1587
+ content: finalizeProjectConfigContent(lines),
1588
+ action: "update",
1589
+ oldValueExpression: existingEntry.valueExpression,
1590
+ lineStart: existingEntry.lineStart + 1,
1591
+ lineEnd: existingEntry.lineEnd + 1
1592
+ };
1593
+ }
1594
+
1595
+ const sectionInsertIndex: number = findProjectSettingInsertIndex(document, section);
1596
+ if (sectionInsertIndex >= 0) {
1597
+ lines.splice(sectionInsertIndex, 0, ...assignmentLines);
1598
+ return {
1599
+ content: finalizeProjectConfigContent(lines),
1600
+ action: "add",
1601
+ oldValueExpression: null,
1602
+ lineStart: sectionInsertIndex + 1,
1603
+ lineEnd: sectionInsertIndex + assignmentLines.length
1604
+ };
1605
+ }
1606
+
1607
+ let insertIndex: number = lines.length;
1608
+ if (insertIndex > 0 && lines[insertIndex - 1] === "") {
1609
+ insertIndex -= 1;
1610
+ }
1611
+
1612
+ const insertedLines: string[] = [];
1613
+ if (insertIndex > 0 && lines[insertIndex - 1]!.trim().length > 0) {
1614
+ insertedLines.push("");
1615
+ }
1616
+ insertedLines.push(`[${section}]`, "", ...assignmentLines);
1617
+ lines.splice(insertIndex, 0, ...insertedLines);
1618
+
1619
+ return {
1620
+ content: finalizeProjectConfigContent(lines),
1621
+ action: "add",
1622
+ oldValueExpression: null,
1623
+ lineStart: insertIndex + insertedLines.length - assignmentLines.length + 1,
1624
+ lineEnd: insertIndex + insertedLines.length
1625
+ };
1626
+ }
1627
+
1628
+ function applyProjectSettingUnsetToContent(
1629
+ document: ProjectSettingsDocument,
1630
+ fullKey: string
1631
+ ): {
1632
+ content: string;
1633
+ action: "remove" | "noop";
1634
+ oldValueExpression: string | null;
1635
+ lineStart: number | null;
1636
+ lineEnd: number | null;
1637
+ } {
1638
+ splitProjectSettingKey(fullKey);
1639
+ const lines: string[] = [...document.lines];
1640
+ const existingEntry: ProjectSettingEntry | undefined = findProjectSettingEntry(document, fullKey);
1641
+
1642
+ if (existingEntry === undefined) {
1643
+ return {
1644
+ content: document.content,
1645
+ action: "noop",
1646
+ oldValueExpression: null,
1647
+ lineStart: null,
1648
+ lineEnd: null
1649
+ };
1650
+ }
1651
+
1652
+ lines.splice(existingEntry.lineStart, existingEntry.lineEnd - existingEntry.lineStart + 1);
1653
+ return {
1654
+ content: finalizeProjectConfigContent(lines),
1655
+ action: "remove",
1656
+ oldValueExpression: existingEntry.valueExpression,
1657
+ lineStart: existingEntry.lineStart + 1,
1658
+ lineEnd: existingEntry.lineEnd + 1
1659
+ };
1660
+ }
1661
+
1662
+ function formatProjectSettingEntry(entry: ProjectSettingEntry): Record<string, unknown> {
1663
+ return {
1664
+ key: entry.fullKey,
1665
+ section: entry.section,
1666
+ name: entry.name,
1667
+ valueExpression: entry.valueExpression,
1668
+ lineStart: entry.lineStart + 1,
1669
+ lineEnd: entry.lineEnd + 1
1670
+ };
1671
+ }
1672
+
1673
+ async function getProjectSettings(keys: string[] | undefined, prefix: string | undefined): Promise<Record<string, unknown>> {
1674
+ const document: ProjectSettingsDocument = await readProjectSettingsDocument();
1675
+ const trimmedKeys: string[] = (keys ?? []).map((key: string): string => key.trim()).filter((key: string): boolean => key.length > 0);
1676
+ const trimmedPrefix: string | undefined = prefix?.trim();
1677
+ let entries: ProjectSettingEntry[];
1678
+
1679
+ if (trimmedKeys.length > 0) {
1680
+ entries = trimmedKeys
1681
+ .map((key: string): ProjectSettingEntry | undefined => findProjectSettingEntry(document, key))
1682
+ .filter((entry: ProjectSettingEntry | undefined): entry is ProjectSettingEntry => entry !== undefined);
1683
+ } else if (trimmedPrefix !== undefined && trimmedPrefix.length > 0) {
1684
+ entries = document.entries.filter((entry: ProjectSettingEntry): boolean => entry.fullKey.startsWith(trimmedPrefix));
1685
+ } else {
1686
+ entries = document.entries;
1687
+ }
1688
+
1689
+ const clippedEntries: ProjectSettingEntry[] = entries.slice(0, MAX_PROJECT_SETTINGS_RESULT);
1690
+ const missingKeys: string[] = trimmedKeys.filter((key: string): boolean => findProjectSettingEntry(document, key) === undefined);
1691
+
1692
+ return {
1693
+ projectConfigPath: getProjectConfigPath(),
1694
+ settings: clippedEntries.map(formatProjectSettingEntry),
1695
+ missingKeys,
1696
+ totalMatched: entries.length,
1697
+ truncated: entries.length > clippedEntries.length
1698
+ };
1699
+ }
1700
+
1701
+ async function proposeSetProjectSetting(fullKey: string, valueExpression: string): Promise<Record<string, unknown>> {
1702
+ const document: ProjectSettingsDocument = await readProjectSettingsDocument();
1703
+ const result = applyProjectSettingSetToContent(document, fullKey, valueExpression);
1704
+
1705
+ return {
1706
+ valid: true,
1707
+ key: fullKey.trim(),
1708
+ action: result.action,
1709
+ oldValueExpression: result.oldValueExpression,
1710
+ newValueExpression: normalizeProjectSettingValueExpression(valueExpression),
1711
+ lineStart: result.lineStart,
1712
+ lineEnd: result.lineEnd,
1713
+ projectConfigPath: getProjectConfigPath(),
1714
+ preview: result.content.slice(0, 1200) + (result.content.length > 1200 ? "\n..." : "")
1715
+ };
1716
+ }
1717
+
1718
+ async function setProjectSetting(fullKey: string, valueExpression: string): Promise<Record<string, unknown>> {
1719
+ const document: ProjectSettingsDocument = await readProjectSettingsDocument();
1720
+ const result = applyProjectSettingSetToContent(document, fullKey, valueExpression);
1721
+ await fs.writeFile(getProjectConfigPath(), result.content, "utf8");
1722
+
1723
+ return {
1724
+ modified: true,
1725
+ key: fullKey.trim(),
1726
+ action: result.action,
1727
+ oldValueExpression: result.oldValueExpression,
1728
+ newValueExpression: normalizeProjectSettingValueExpression(valueExpression),
1729
+ lineStart: result.lineStart,
1730
+ lineEnd: result.lineEnd,
1731
+ projectConfigPath: getProjectConfigPath()
1732
+ };
1733
+ }
1734
+
1735
+ async function proposeUnsetProjectSetting(fullKey: string): Promise<Record<string, unknown>> {
1736
+ const document: ProjectSettingsDocument = await readProjectSettingsDocument();
1737
+ const result = applyProjectSettingUnsetToContent(document, fullKey);
1738
+
1739
+ return {
1740
+ valid: true,
1741
+ key: fullKey.trim(),
1742
+ action: result.action,
1743
+ oldValueExpression: result.oldValueExpression,
1744
+ lineStart: result.lineStart,
1745
+ lineEnd: result.lineEnd,
1746
+ projectConfigPath: getProjectConfigPath(),
1747
+ preview: result.content.slice(0, 1200) + (result.content.length > 1200 ? "\n..." : "")
1748
+ };
1749
+ }
1750
+
1751
+ async function unsetProjectSetting(fullKey: string): Promise<Record<string, unknown>> {
1752
+ const document: ProjectSettingsDocument = await readProjectSettingsDocument();
1753
+ const result = applyProjectSettingUnsetToContent(document, fullKey);
1754
+ if (result.action === "remove") {
1755
+ await fs.writeFile(getProjectConfigPath(), result.content, "utf8");
1756
+ }
1757
+
1758
+ return {
1759
+ modified: result.action === "remove",
1760
+ key: fullKey.trim(),
1761
+ action: result.action,
1762
+ oldValueExpression: result.oldValueExpression,
1763
+ lineStart: result.lineStart,
1764
+ lineEnd: result.lineEnd,
1765
+ projectConfigPath: getProjectConfigPath()
1766
+ };
1767
+ }
1768
+
1769
+ function asTextResult(text: string): { content: Array<{ type: "text"; text: string }> } {
1770
+ return {
1771
+ content: [{ type: "text", text }]
1772
+ };
1773
+ }
1774
+
1775
+ function asJsonTextResult(value: unknown): { content: Array<{ type: "text"; text: string }> } {
1776
+ return asTextResult(JSON.stringify(value, null, 2));
1777
+ }
1778
+
1779
+ const PROHIBITED_PREFIXES: string[] = [".godot", "addons"];
1780
+
1781
+ type TscnSection = {
1782
+ name: string;
1783
+ attrs: Record<string, string>;
1784
+ };
1785
+
1786
+ type TscnExtResource = {
1787
+ id: string;
1788
+ type: string;
1789
+ path: string | undefined;
1790
+ uid: string | undefined;
1791
+ };
1792
+
1793
+ type TscnSubResource = {
1794
+ id: string;
1795
+ type: string;
1796
+ properties: Record<string, string>;
1797
+ };
1798
+
1799
+ type TscnNode = {
1800
+ name: string;
1801
+ type: string;
1802
+ parent: string | null;
1803
+ properties: Record<string, string>;
1804
+ script: string | null;
1805
+ instance: string | null;
1806
+ };
1807
+
1808
+ type TscnConnection = {
1809
+ signal: string;
1810
+ from: string;
1811
+ to: string;
1812
+ method: string;
1813
+ flags: number | null;
1814
+ binds: string | null;
1815
+ };
1816
+
1817
+ type TscnData = {
1818
+ format: number;
1819
+ loadSteps: number;
1820
+ uid: string | null;
1821
+ extResources: TscnExtResource[];
1822
+ subResources: TscnSubResource[];
1823
+ nodes: TscnNode[];
1824
+ connections: TscnConnection[];
1825
+ };
1826
+
1827
+ type ScenePatchOperation =
1828
+ | {
1829
+ type: "add_node";
1830
+ parentPath: string;
1831
+ nodeType: string;
1832
+ nodeName: string;
1833
+ properties?: Record<string, string>;
1834
+ }
1835
+ | {
1836
+ type: "attach_script";
1837
+ nodePath: string;
1838
+ scriptPath: string;
1839
+ }
1840
+ | {
1841
+ type: "connect_signal";
1842
+ signal: string;
1843
+ fromNode: string;
1844
+ toNode: string;
1845
+ method: string;
1846
+ flags?: number;
1847
+ binds?: string;
1848
+ };
1849
+
1850
+ function parseSectionHeader(line: string): TscnSection | null {
1851
+ const match = line.match(/^\[([^\]]+)\](.*)$/);
1852
+ if (match === null) return null;
1853
+ const sectionContent: string = match[1]!.trim();
1854
+ const firstWhitespaceIndex: number = sectionContent.search(/\s/);
1855
+ const name: string = firstWhitespaceIndex === -1
1856
+ ? sectionContent
1857
+ : sectionContent.slice(0, firstWhitespaceIndex);
1858
+ const attrs: Record<string, string> = {};
1859
+ const attrStr: string = firstWhitespaceIndex === -1
1860
+ ? match[2]!.trim()
1861
+ : sectionContent.slice(firstWhitespaceIndex + 1).trim();
1862
+ if (attrStr.length > 0) {
1863
+ const attrRegex = /(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g;
1864
+ let attrMatch;
1865
+ while ((attrMatch = attrRegex.exec(attrStr)) !== null) {
1866
+ let value = attrMatch[2]!;
1867
+ if (value.startsWith('"') && value.endsWith('"')) {
1868
+ value = value.slice(1, -1);
1869
+ }
1870
+ attrs[attrMatch[1]!] = value;
1871
+ }
1872
+ }
1873
+ return { name, attrs };
1874
+ }
1875
+
1876
+ function parseTscn(content: string): TscnData {
1877
+ const lines = content.split("\n");
1878
+ const data: TscnData = {
1879
+ format: 0,
1880
+ loadSteps: 0,
1881
+ uid: null,
1882
+ extResources: [],
1883
+ subResources: [],
1884
+ nodes: [],
1885
+ connections: []
1886
+ };
1887
+
1888
+ let currentSection: string | null = null;
1889
+ let currentSubResourceProps: Record<string, string> = {};
1890
+ let currentSubResourceId = "";
1891
+ let currentSubResourceType = "";
1892
+
1893
+ for (const rawLine of lines) {
1894
+ const line = rawLine.trimEnd();
1895
+ if (line.length === 0 || line.startsWith(";")) continue;
1896
+
1897
+ const section = parseSectionHeader(line);
1898
+ if (section !== null) {
1899
+ // Flush any pending sub-resource
1900
+ if (currentSection === "sub_resource" && currentSubResourceId.length > 0) {
1901
+ data.subResources.push({
1902
+ id: currentSubResourceId,
1903
+ type: currentSubResourceType,
1904
+ properties: { ...currentSubResourceProps }
1905
+ });
1906
+ currentSubResourceProps = {};
1907
+ currentSubResourceId = "";
1908
+ currentSubResourceType = "";
1909
+ }
1910
+
1911
+ currentSection = section.name;
1912
+
1913
+ if (section.name === "gd_scene") {
1914
+ data.format = parseInt(section.attrs["format"] ?? "0", 10);
1915
+ data.loadSteps = parseInt(section.attrs["load_steps"] ?? "0", 10);
1916
+ data.uid = section.attrs["uid"] ?? null;
1917
+ } else if (section.name === "ext_resource") {
1918
+ data.extResources.push({
1919
+ id: section.attrs["id"] ?? "",
1920
+ type: section.attrs["type"] ?? "",
1921
+ path: section.attrs["path"],
1922
+ uid: section.attrs["uid"]
1923
+ });
1924
+ } else if (section.name === "sub_resource") {
1925
+ currentSubResourceId = section.attrs["id"] ?? "";
1926
+ currentSubResourceType = section.attrs["type"] ?? "";
1927
+ currentSubResourceProps = {};
1928
+ } else if (section.name === "node") {
1929
+ data.nodes.push({
1930
+ name: section.attrs["name"] ?? "",
1931
+ type: section.attrs["type"] ?? "",
1932
+ parent: section.attrs["parent"] ?? null,
1933
+ properties: {},
1934
+ script: null,
1935
+ instance: section.attrs["instance"] ?? null
1936
+ });
1937
+ } else if (section.name === "connection") {
1938
+ data.connections.push({
1939
+ signal: section.attrs["signal"] ?? "",
1940
+ from: section.attrs["from"] ?? "",
1941
+ to: section.attrs["to"] ?? "",
1942
+ method: section.attrs["method"] ?? "",
1943
+ flags: section.attrs["flags"] !== undefined ? parseInt(section.attrs["flags"], 10) : null,
1944
+ binds: section.attrs["binds"] ?? null
1945
+ });
1946
+ }
1947
+ continue;
1948
+ }
1949
+
1950
+ // Property line
1951
+ const eqIdx = line.indexOf("=");
1952
+ if (eqIdx === -1) continue;
1953
+
1954
+ const key = line.slice(0, eqIdx).trim();
1955
+ const value = line.slice(eqIdx + 1).trim();
1956
+
1957
+ if (currentSection === "node" && data.nodes.length > 0) {
1958
+ const lastNode = data.nodes[data.nodes.length - 1]!;
1959
+ if (key === "script" && value.startsWith('ExtResource(')) {
1960
+ lastNode.script = value;
1961
+ } else {
1962
+ lastNode.properties[key] = value;
1963
+ }
1964
+ } else if (currentSection === "sub_resource") {
1965
+ currentSubResourceProps[key] = value;
1966
+ }
1967
+ }
1968
+
1969
+ // Flush final sub-resource
1970
+ if (currentSection === "sub_resource" && currentSubResourceId.length > 0) {
1971
+ data.subResources.push({
1972
+ id: currentSubResourceId,
1973
+ type: currentSubResourceType,
1974
+ properties: { ...currentSubResourceProps }
1975
+ });
1976
+ }
1977
+
1978
+ return data;
1979
+ }
1980
+
1981
+ function quoteTscnString(value: string): string {
1982
+ return value
1983
+ .replaceAll("\\", "\\\\")
1984
+ .replaceAll("\"", "\\\"");
1985
+ }
1986
+
1987
+ function createNodePathMap(nodes: TscnNode[]): Map<string, TscnNode> {
1988
+ const pathMap: Map<string, TscnNode> = new Map();
1989
+ const rootNode: TscnNode | undefined = nodes.find((node: TscnNode): boolean => node.parent === null);
1990
+ const rootName: string | undefined = rootNode?.name;
1991
+
1992
+ if (rootNode !== undefined) {
1993
+ pathMap.set(".", rootNode);
1994
+ pathMap.set(rootNode.name, rootNode);
1995
+ }
1996
+
1997
+ for (const node of nodes) {
1998
+ if (node.parent === null) {
1999
+ continue;
2000
+ }
2001
+
2002
+ const parentPath: string = node.parent === "." ? (rootName ?? ".") : node.parent;
2003
+ const fullPath: string = parentPath.length > 0 ? `${parentPath}/${node.name}` : node.name;
2004
+ pathMap.set(fullPath, node);
2005
+
2006
+ if (node.parent === ".") {
2007
+ pathMap.set(node.name, node);
2008
+ }
2009
+
2010
+ if (rootName !== undefined && !fullPath.startsWith(`${rootName}/`) && fullPath !== rootName) {
2011
+ pathMap.set(`${rootName}/${fullPath}`, node);
2012
+ }
2013
+ }
2014
+
2015
+ return pathMap;
2016
+ }
2017
+
2018
+ function toSceneRelativeNodePath(data: TscnData, nodePath: string): string {
2019
+ const normalizedPath: string = nodePath.trim().replace(/^\//, "");
2020
+ const rootNode: TscnNode | undefined = data.nodes.find((node: TscnNode): boolean => node.parent === null);
2021
+ const rootName: string | undefined = rootNode?.name;
2022
+
2023
+ if (normalizedPath.length === 0 || normalizedPath === ".") {
2024
+ return ".";
2025
+ }
2026
+
2027
+ if (rootName !== undefined) {
2028
+ if (normalizedPath === rootName) {
2029
+ return ".";
2030
+ }
2031
+
2032
+ if (normalizedPath.startsWith(`${rootName}/`)) {
2033
+ return normalizedPath.slice(rootName.length + 1);
2034
+ }
2035
+ }
2036
+
2037
+ return normalizedPath;
2038
+ }
2039
+
2040
+ function getNodeSectionIndex(lines: string[], targetNode: TscnNode): number {
2041
+ for (let index: number = 0; index < lines.length; index += 1) {
2042
+ const section: TscnSection | null = parseSectionHeader(lines[index]!);
2043
+ if (section === null || section.name !== "node") {
2044
+ continue;
2045
+ }
2046
+
2047
+ const name: string = section.attrs["name"] ?? "";
2048
+ const type: string = section.attrs["type"] ?? "";
2049
+ const parent: string | null = section.attrs["parent"] ?? null;
2050
+
2051
+ if (name === targetNode.name && type === targetNode.type && parent === targetNode.parent) {
2052
+ return index;
2053
+ }
2054
+ }
2055
+
2056
+ return -1;
2057
+ }
2058
+
2059
+ function getNextSectionIndex(lines: string[], startIndex: number): number {
2060
+ let index: number = startIndex + 1;
2061
+ while (index < lines.length) {
2062
+ const line: string = lines[index]!.trim();
2063
+ if (line.startsWith("[")) {
2064
+ break;
2065
+ }
2066
+ index += 1;
2067
+ }
2068
+
2069
+ return index;
2070
+ }
2071
+
2072
+ function generateSceneTscn(rootNodeType: string, rootNodeName: string): string {
2073
+ return `[gd_scene load_steps=2 format=3]
2074
+
2075
+ [node name="${quoteTscnString(rootNodeName)}" type="${quoteTscnString(rootNodeType)}"]
2076
+ `;
2077
+ }
2078
+
2079
+ function findNodeInTscn(data: TscnData, targetPath: string): TscnNode | null {
2080
+ const normalizedTargetPath: string = targetPath.trim().replace(/^\//, "");
2081
+ if (normalizedTargetPath.length === 0 || normalizedTargetPath === ".") {
2082
+ return data.nodes.find(n => n.parent === null) ?? null;
2083
+ }
2084
+
2085
+ return createNodePathMap(data.nodes).get(normalizedTargetPath) ?? null;
2086
+ }
2087
+
2088
+ function getNodeFullPath(node: TscnNode, allNodes: TscnNode[]): string {
2089
+ if (node.parent === null || node.parent === ".") return node.name;
2090
+
2091
+ // Find parent
2092
+ const parent = allNodes.find(n => {
2093
+ const parentPath = n.parent === "." || n.parent === null ? "" : n.parent;
2094
+ const nodePath = parentPath.length > 0 ? `${parentPath}/${n.name}` : n.name;
2095
+ return nodePath === node.parent;
2096
+ });
2097
+
2098
+ if (parent === undefined) return node.name;
2099
+ return `${getNodeFullPath(parent, allNodes)}/${node.name}`;
2100
+ }
2101
+
2102
+ function addNodeToSceneTscn(content: string, parentPath: string, nodeType: string, nodeName: string, properties: Record<string, string>): string {
2103
+ const data: TscnData = parseTscn(content);
2104
+ const parentNode: TscnNode | null = findNodeInTscn(data, parentPath);
2105
+
2106
+ if (parentNode === null) {
2107
+ throw new Error(`Parent node not found in scene: ${parentPath}`);
2108
+ }
2109
+
2110
+ const resolvedParent: string = toSceneRelativeNodePath(data, parentPath);
2111
+ const rootNode: TscnNode | undefined = data.nodes.find((node: TscnNode): boolean => node.parent === null);
2112
+ const candidateScenePath: string = resolvedParent === "." ? nodeName : `${resolvedParent}/${nodeName}`;
2113
+ const candidateFullPath: string = rootNode === undefined ? candidateScenePath : `${rootNode.name}/${candidateScenePath}`;
2114
+ const nodePathMap: Map<string, TscnNode> = createNodePathMap(data.nodes);
2115
+
2116
+ if (nodePathMap.has(candidateScenePath) || nodePathMap.has(candidateFullPath)) {
2117
+ throw new Error(`Node already exists in scene: ${candidateScenePath}`);
2118
+ }
2119
+
2120
+ let nodeLine = `[node name="${quoteTscnString(nodeName)}" type="${quoteTscnString(nodeType)}" parent="${quoteTscnString(resolvedParent)}"]`;
2121
+ if (Object.keys(properties).length > 0) {
2122
+ for (const [key, value] of Object.entries(properties)) {
2123
+ nodeLine += `\n${key} = ${value}`;
2124
+ }
2125
+ }
2126
+ nodeLine += "\n";
2127
+
2128
+ // Insert before the last section (connections or end of file)
2129
+ const lines = content.split("\n");
2130
+ const insertIdx = findLastNodeInsertIndex(lines);
2131
+ lines.splice(insertIdx, 0, nodeLine);
2132
+ return lines.join("\n");
2133
+ }
2134
+
2135
+ function findLastNodeInsertIndex(lines: string[]): number {
2136
+ // Find where to insert a new node: after the last [node ...] section's properties
2137
+ let lastNodeLine = -1;
2138
+ for (let i = 0; i < lines.length; i++) {
2139
+ if (lines[i]!.startsWith("[node ")) {
2140
+ lastNodeLine = i;
2141
+ }
2142
+ }
2143
+
2144
+ if (lastNodeLine === -1) {
2145
+ // No nodes yet, find where [gd_scene] header ends
2146
+ for (let i = 0; i < lines.length; i++) {
2147
+ if (lines[i]!.startsWith("[gd_scene ")) {
2148
+ // Find next non-empty, non-property line after header
2149
+ let j = i + 1;
2150
+ while (j < lines.length && lines[j]!.trim().length === 0) j++;
2151
+ return j;
2152
+ }
2153
+ }
2154
+ return lines.length;
2155
+ }
2156
+
2157
+ // Skip the [node ...] line and its properties
2158
+ let i = lastNodeLine + 1;
2159
+ while (i < lines.length) {
2160
+ const line = lines[i]!.trim();
2161
+ if (line.length === 0 || line.startsWith(";")) {
2162
+ i++;
2163
+ continue;
2164
+ }
2165
+ if (line.startsWith("[")) break;
2166
+ i++;
2167
+ }
2168
+ return i;
2169
+ }
2170
+
2171
+ function attachScriptToSceneTscn(content: string, nodePath: string, scriptPath: string): string {
2172
+ const data = parseTscn(content);
2173
+ const targetNode = findNodeInTscn(data, nodePath);
2174
+
2175
+ if (targetNode === null) {
2176
+ throw new Error(`Node not found in scene: ${nodePath}`);
2177
+ }
2178
+
2179
+ const extResMatch = scriptPath.match(/^ExtResource\("([^"]+)"\)$/);
2180
+ const lines = content.split("\n");
2181
+ const nodeSectionIndex: number = getNodeSectionIndex(lines, targetNode);
2182
+
2183
+ if (nodeSectionIndex === -1) {
2184
+ throw new Error(`Node section not found in scene: ${nodePath}`);
2185
+ }
2186
+
2187
+ const nodeSectionEndIndex: number = getNextSectionIndex(lines, nodeSectionIndex);
2188
+ for (let index: number = nodeSectionIndex + 1; index < nodeSectionEndIndex; index += 1) {
2189
+ if (lines[index]!.trim().startsWith("script =")) {
2190
+ throw new Error(`Node already has a script: ${nodePath}`);
2191
+ }
2192
+ }
2193
+
2194
+ let scriptValue: string;
2195
+ if (extResMatch !== null) {
2196
+ scriptValue = scriptPath;
2197
+ } else {
2198
+ if (!scriptPath.startsWith("res://") || !scriptPath.endsWith(".gd")) {
2199
+ throw new Error("scriptPath must be a res:// path ending with .gd or an ExtResource(\"id\") reference");
2200
+ }
2201
+
2202
+ const existingResource: TscnExtResource | undefined = data.extResources.find(
2203
+ (resource: TscnExtResource): boolean => resource.path === scriptPath
2204
+ );
2205
+ let resourceId: string;
2206
+
2207
+ if (existingResource !== undefined) {
2208
+ resourceId = existingResource.id;
2209
+ } else {
2210
+ const usedIds: Set<string> = new Set(data.extResources.map((resource: TscnExtResource): string => resource.id));
2211
+ let nextIndex: number = data.extResources.length + 1;
2212
+ do {
2213
+ resourceId = `${nextIndex}_script`;
2214
+ nextIndex += 1;
2215
+ } while (usedIds.has(resourceId));
2216
+
2217
+ const gdSceneIndex: number = lines.findIndex((line: string): boolean => line.startsWith("[gd_scene "));
2218
+ if (gdSceneIndex === -1) {
2219
+ throw new Error("Missing [gd_scene ...] header");
2220
+ }
2221
+
2222
+ lines[gdSceneIndex] = lines[gdSceneIndex]!.replace(
2223
+ /load_steps=(\d+)/,
2224
+ (_match: string, value: string): string => `load_steps=${Number.parseInt(value, 10) + 1}`
2225
+ );
2226
+ lines.splice(gdSceneIndex + 1, 0, `[ext_resource type="Script" path="${quoteTscnString(scriptPath)}" id="${resourceId}"]`);
2227
+ }
2228
+
2229
+ scriptValue = `ExtResource("${resourceId}")`;
2230
+ }
2231
+
2232
+ const refreshedData: TscnData = parseTscn(lines.join("\n"));
2233
+ const refreshedNode: TscnNode | null = findNodeInTscn(refreshedData, nodePath);
2234
+ if (refreshedNode === null) {
2235
+ throw new Error(`Node not found after script resource update: ${nodePath}`);
2236
+ }
2237
+
2238
+ const refreshedNodeSectionIndex: number = getNodeSectionIndex(lines, refreshedNode);
2239
+ lines.splice(refreshedNodeSectionIndex + 1, 0, `script = ${scriptValue}`);
2240
+ return lines.join("\n");
2241
+ }
2242
+
2243
+ function connectSignalInSceneTscn(content: string, signal: string, fromNode: string, toNode: string, method: string, flags?: number, binds?: string): string {
2244
+ const data: TscnData = parseTscn(content);
2245
+
2246
+ if (findNodeInTscn(data, fromNode) === null) {
2247
+ throw new Error(`Signal source node not found in scene: ${fromNode}`);
2248
+ }
2249
+
2250
+ if (findNodeInTscn(data, toNode) === null) {
2251
+ throw new Error(`Signal target node not found in scene: ${toNode}`);
2252
+ }
2253
+
2254
+ const resolvedFromNode: string = toSceneRelativeNodePath(data, fromNode);
2255
+ const resolvedToNode: string = toSceneRelativeNodePath(data, toNode);
2256
+ const connExists: boolean = data.connections.some(
2257
+ (connection: TscnConnection): boolean =>
2258
+ connection.signal === signal
2259
+ && toSceneRelativeNodePath(data, connection.from) === resolvedFromNode
2260
+ && toSceneRelativeNodePath(data, connection.to) === resolvedToNode
2261
+ && connection.method === method
2262
+ );
2263
+
2264
+ if (connExists) {
2265
+ throw new Error("This signal connection already exists in the scene");
2266
+ }
2267
+
2268
+ let connLine = `[connection signal="${quoteTscnString(signal)}" from="${quoteTscnString(resolvedFromNode)}" to="${quoteTscnString(resolvedToNode)}" method="${quoteTscnString(method)}"`;
2269
+ if (flags !== undefined) {
2270
+ connLine += ` flags=${flags}`;
2271
+ }
2272
+ if (binds !== undefined && binds.length > 0) {
2273
+ connLine += ` binds= ${binds}`;
2274
+ }
2275
+ connLine += "]\n";
2276
+
2277
+ // Find the last [connection ...] line or the end
2278
+ const lines = content.split("\n");
2279
+ let lastConnIdx = -1;
2280
+ for (let i = 0; i < lines.length; i++) {
2281
+ if (lines[i]!.startsWith("[connection ")) {
2282
+ lastConnIdx = i;
2283
+ }
2284
+ }
2285
+
2286
+ if (lastConnIdx >= 0) {
2287
+ lines.splice(lastConnIdx + 1, 0, connLine);
2288
+ } else {
2289
+ lines.push(connLine);
2290
+ }
2291
+
2292
+ return lines.join("\n").replace(/\n\n+$/, "\n");
2293
+ }
2294
+
2295
+ function applyScenePatchToTscn(content: string, operations: ScenePatchOperation[]): {
2296
+ content: string;
2297
+ applied: Array<Record<string, unknown>>;
2298
+ } {
2299
+ let nextContent: string = content;
2300
+ const applied: Array<Record<string, unknown>> = [];
2301
+
2302
+ for (const operation of operations) {
2303
+ if (operation.type === "add_node") {
2304
+ nextContent = addNodeToSceneTscn(
2305
+ nextContent,
2306
+ operation.parentPath,
2307
+ operation.nodeType,
2308
+ operation.nodeName,
2309
+ operation.properties ?? {}
2310
+ );
2311
+ applied.push({
2312
+ type: operation.type,
2313
+ parentPath: operation.parentPath,
2314
+ nodeType: operation.nodeType,
2315
+ nodeName: operation.nodeName
2316
+ });
2317
+ } else if (operation.type === "attach_script") {
2318
+ nextContent = attachScriptToSceneTscn(nextContent, operation.nodePath, operation.scriptPath);
2319
+ applied.push({
2320
+ type: operation.type,
2321
+ nodePath: operation.nodePath,
2322
+ scriptPath: operation.scriptPath
2323
+ });
2324
+ } else if (operation.type === "connect_signal") {
2325
+ nextContent = connectSignalInSceneTscn(
2326
+ nextContent,
2327
+ operation.signal,
2328
+ operation.fromNode,
2329
+ operation.toNode,
2330
+ operation.method,
2331
+ operation.flags,
2332
+ operation.binds
2333
+ );
2334
+ applied.push({
2335
+ type: operation.type,
2336
+ signal: operation.signal,
2337
+ fromNode: operation.fromNode,
2338
+ toNode: operation.toNode,
2339
+ method: operation.method
2340
+ });
2341
+ } else {
2342
+ const unreachable: never = operation;
2343
+ throw new Error(`Unsupported scene patch operation: ${JSON.stringify(unreachable)}`);
2344
+ }
2345
+ }
2346
+
2347
+ return { content: nextContent, applied };
2348
+ }
2349
+
2350
+ async function assertWritablePath(relativePath: string): Promise<string> {
2351
+ const cleanedPath: string = relativePath.trim().replaceAll("\\", "/");
2352
+ const resolvedPath: string = await resolveProjectPath(cleanedPath);
2353
+ const normalized: string = path.relative(projectRoot, resolvedPath).replaceAll(path.sep, "/");
2354
+
2355
+ const segments: string[] = normalized.split("/");
2356
+
2357
+ for (const segment of segments) {
2358
+ if (segment.startsWith(".") && segment !== "..") {
2359
+ throw new Error(`Path contains hidden directory: ${segment}`);
2360
+ }
2361
+ }
2362
+
2363
+ for (const prefix of PROHIBITED_PREFIXES) {
2364
+ if (normalized.startsWith(prefix + "/") || normalized === prefix) {
2365
+ throw new Error(`Writing to ${prefix}/ is not allowed`);
2366
+ }
2367
+ }
2368
+
2369
+ const extension: string = path.extname(resolvedPath);
2370
+ if (!WRITABLE_EXTENSIONS.has(extension)) {
2371
+ throw new Error(`Unsupported writable extension: ${extension || "(none)"}. Allowed: ${Array.from(WRITABLE_EXTENSIONS).join(", ")}`);
2372
+ }
2373
+
2374
+ return resolvedPath;
2375
+ }
2376
+
2377
+ function validateTscnContent(content: string): string[] {
2378
+ const errors: string[] = [];
2379
+ const trimmedContent: string = content.trimStart();
2380
+
2381
+ if (!/^\[gd_scene\s/.test(trimmedContent)) {
2382
+ errors.push("TSCN file must start with [gd_scene ...] header");
2383
+ }
2384
+
2385
+ const nodeMatches: RegExpMatchArray | null = trimmedContent.match(/^\[node\s/gm);
2386
+ if (nodeMatches === null || nodeMatches.length === 0) {
2387
+ errors.push("TSCN file must contain at least one [node ...] section (root node)");
2388
+ }
2389
+
2390
+ return errors;
2391
+ }
2392
+
2393
+ async function validateNewTextFile(relativePath: string, content: string): Promise<{
2394
+ valid: boolean;
2395
+ resolvedPath?: string;
2396
+ normalizedPath: string;
2397
+ errors: string[];
2398
+ }> {
2399
+ const errors: string[] = [];
2400
+ let resolvedPath: string;
2401
+
2402
+ if (content.length === 0) {
2403
+ errors.push("File content is empty");
2404
+ }
2405
+
2406
+ if (relativePath.endsWith(".tscn")) {
2407
+ if (content.length > MAX_TSCN_FILE_BYTES) {
2408
+ errors.push(`Content too large: ${content.length} bytes (max ${MAX_TSCN_FILE_BYTES})`);
2409
+ }
2410
+ } else if (content.length > MAX_NEW_FILE_BYTES) {
2411
+ errors.push(`Content too large: ${content.length} bytes (max ${MAX_NEW_FILE_BYTES})`);
2412
+ }
2413
+
2414
+ if (relativePath.endsWith(".tscn") && content.length > 0) {
2415
+ errors.push(...validateTscnContent(content));
2416
+ }
2417
+
2418
+ try {
2419
+ resolvedPath = await assertWritablePath(relativePath);
2420
+ } catch (error: unknown) {
2421
+ return {
2422
+ valid: false,
2423
+ normalizedPath: relativePath,
2424
+ errors: [error instanceof Error ? error.message : "Path validation failed"]
2425
+ };
2426
+ }
2427
+
2428
+ const normalizedPath: string = path.relative(projectRoot, resolvedPath).replaceAll(path.sep, "/");
2429
+
2430
+ try {
2431
+ await fs.access(resolvedPath);
2432
+ errors.push(`File already exists: ${normalizedPath}`);
2433
+ } catch {
2434
+ // File does not exist — this is required for create.
2435
+ }
2436
+
2437
+ return {
2438
+ valid: errors.length === 0,
2439
+ resolvedPath,
2440
+ normalizedPath,
2441
+ errors
2442
+ };
2443
+ }
2444
+
2445
+ async function createTextFile(relativePath: string, content: string): Promise<{
2446
+ created: true;
2447
+ path: string;
2448
+ size: number;
2449
+ }> {
2450
+ const validation = await validateNewTextFile(relativePath, content);
2451
+
2452
+ if (!validation.valid || validation.resolvedPath === undefined) {
2453
+ throw new Error(validation.errors.join("; "));
2454
+ }
2455
+
2456
+ await fs.mkdir(path.dirname(validation.resolvedPath), { recursive: true });
2457
+ await fs.writeFile(validation.resolvedPath, content, "utf8");
2458
+
2459
+ return {
2460
+ created: true,
2461
+ path: validation.normalizedPath,
2462
+ size: content.length
2463
+ };
2464
+ }
2465
+
2466
+ async function overwriteTextFile(relativePath: string, content: string): Promise<{
2467
+ overwritten: true;
2468
+ path: string;
2469
+ size: number;
2470
+ oldSize: number;
2471
+ }> {
2472
+ if (content.length === 0) {
2473
+ throw new Error("File content is empty");
2474
+ }
2475
+
2476
+ const maxBytes: number = relativePath.endsWith(".tscn") ? MAX_TSCN_FILE_BYTES : MAX_TEXT_FILE_BYTES;
2477
+ if (content.length > maxBytes) {
2478
+ throw new Error(`Content too large: ${content.length} bytes (max ${maxBytes})`);
2479
+ }
2480
+
2481
+ if (relativePath.endsWith(".tscn")) {
2482
+ const tscnErrors: string[] = validateTscnContent(content);
2483
+ if (tscnErrors.length > 0) {
2484
+ throw new Error(`TSCN validation failed: ${tscnErrors.join("; ")}`);
2485
+ }
2486
+ }
2487
+
2488
+ const resolvedPath: string = await assertWritablePath(relativePath);
2489
+ const oldContent: string = await fs.readFile(resolvedPath, "utf8");
2490
+ await fs.writeFile(resolvedPath, content, "utf8");
2491
+
2492
+ return {
2493
+ overwritten: true,
2494
+ path: path.relative(projectRoot, resolvedPath).replaceAll(path.sep, "/"),
2495
+ size: content.length,
2496
+ oldSize: oldContent.length
2497
+ };
2498
+ }
2499
+
2500
+ async function replaceTextInFile(relativePath: string, oldText: string, newText: string): Promise<{
2501
+ replaced: true;
2502
+ path: string;
2503
+ occurrences: number;
2504
+ size: number;
2505
+ oldSize: number;
2506
+ }> {
2507
+ if (oldText.length === 0) {
2508
+ throw new Error("oldText must not be empty");
2509
+ }
2510
+
2511
+ const resolvedPath: string = await assertWritablePath(relativePath);
2512
+ const oldContent: string = await fs.readFile(resolvedPath, "utf8");
2513
+
2514
+ if (!oldContent.includes(oldText)) {
2515
+ throw new Error("oldText was not found in file");
2516
+ }
2517
+
2518
+ const occurrenceCount: number = oldContent.split(oldText).length - 1;
2519
+ const newContent: string = oldContent.replace(oldText, newText);
2520
+
2521
+ if (newContent.length > MAX_TEXT_FILE_BYTES) {
2522
+ throw new Error(`Content too large after replacement: ${newContent.length} bytes (max ${MAX_TEXT_FILE_BYTES})`);
2523
+ }
2524
+
2525
+ await fs.writeFile(resolvedPath, newContent, "utf8");
2526
+
2527
+ return {
2528
+ replaced: true,
2529
+ path: path.relative(projectRoot, resolvedPath).replaceAll(path.sep, "/"),
2530
+ occurrences: occurrenceCount,
2531
+ size: newContent.length,
2532
+ oldSize: oldContent.length
2533
+ };
2534
+ }
2535
+
2536
+ async function main(): Promise<void> {
2537
+ await assertProjectExists();
2538
+
2539
+ const server: McpServer = new McpServer({
2540
+ name: "godot-project-server",
2541
+ version: "1.0.0"
2542
+ });
2543
+
2544
+ server.registerTool(
2545
+ "get_project_summary",
2546
+ {
2547
+ title: "Get Godot Project Summary",
2548
+ description: "返回当前 Godot 项目的名称、主场景、插件列表和文件数量",
2549
+ inputSchema: z.object({})
2550
+ },
2551
+ async () => asJsonTextResult(await getProjectSummary())
2552
+ );
2553
+
2554
+ server.registerTool(
2555
+ "list_project_files",
2556
+ {
2557
+ title: "List Godot Project Files",
2558
+ description: "递归列出 Godot 项目文件,可按子目录和扩展名过滤",
2559
+ inputSchema: z.object({
2560
+ subdir: z.string().optional().describe("相对于项目根目录的子目录"),
2561
+ extensions: z.array(z.string()).optional().describe("扩展名过滤,例如 ['.gd', '.tscn']"),
2562
+ includeAddons: z.boolean().optional().describe("是否包含 addons 目录")
2563
+ })
2564
+ },
2565
+ async ({ subdir, extensions, includeAddons }) => {
2566
+ const files: string[] = await walkProjectFiles({ subdir, extensions, includeAddons });
2567
+ return asJsonTextResult({ files });
2568
+ }
2569
+ );
2570
+
2571
+ server.registerTool(
2572
+ "list_scenes",
2573
+ {
2574
+ title: "List Godot Scenes",
2575
+ description: "列出 Godot 项目中所有 .tscn 场景文件",
2576
+ inputSchema: z.object({
2577
+ includeAddons: z.boolean().optional().describe("是否包含 addons 目录")
2578
+ })
2579
+ },
2580
+ async ({ includeAddons }) => {
2581
+ const scenes: string[] = await walkProjectFiles({ extensions: [".tscn"], includeAddons });
2582
+ return asJsonTextResult({ scenes });
2583
+ }
2584
+ );
2585
+
2586
+ server.registerTool(
2587
+ "list_scripts",
2588
+ {
2589
+ title: "List GDScript Files",
2590
+ description: "列出 Godot 项目中所有 .gd 脚本文件",
2591
+ inputSchema: z.object({
2592
+ includeAddons: z.boolean().optional().describe("是否包含 addons 目录")
2593
+ })
2594
+ },
2595
+ async ({ includeAddons }) => {
2596
+ const scripts: string[] = await walkProjectFiles({ extensions: [".gd"], includeAddons });
2597
+ return asJsonTextResult({ scripts });
2598
+ }
2599
+ );
2600
+
2601
+ server.registerTool(
2602
+ "read_text_file",
2603
+ {
2604
+ title: "Read Text File",
2605
+ description: "读取 Godot 项目中的文本文件,带路径越界和大小限制",
2606
+ inputSchema: z.object({
2607
+ relativePath: z.string().min(1).describe("相对于项目根目录的文件路径")
2608
+ })
2609
+ },
2610
+ async ({ relativePath }) => asTextResult(await readTextFile(relativePath))
2611
+ );
2612
+
2613
+ server.registerTool(
2614
+ "search_text",
2615
+ {
2616
+ title: "Search Text",
2617
+ description: "在项目文本文件中搜索关键词,返回匹配文件和行号",
2618
+ inputSchema: z.object({
2619
+ query: z.string().min(1).describe("要搜索的文本"),
2620
+ extensions: z.array(z.string()).optional().describe("扩展名过滤,例如 ['.gd']"),
2621
+ limit: z.number().int().positive().max(200).optional().describe("最多返回多少条匹配")
2622
+ })
2623
+ },
2624
+ async ({ query, extensions, limit }) => {
2625
+ const maxMatches: number = limit ?? 50;
2626
+ const files: string[] = await walkProjectFiles({
2627
+ extensions: extensions ?? Array.from(TEXT_EXTENSIONS)
2628
+ });
2629
+ const matches: Array<{ file: string; line: number; text: string }> = [];
2630
+
2631
+ for (const file of files) {
2632
+ if (matches.length >= maxMatches) {
2633
+ break;
2634
+ }
2635
+
2636
+ let content: string;
2637
+ try {
2638
+ content = await readTextFile(file);
2639
+ } catch {
2640
+ continue;
2641
+ }
2642
+
2643
+ const lines: string[] = content.split("\n");
2644
+ for (let index: number = 0; index < lines.length; index += 1) {
2645
+ const lineText: string | undefined = lines[index];
2646
+ if (lineText === undefined || !lineText.includes(query)) {
2647
+ continue;
2648
+ }
2649
+
2650
+ matches.push({
2651
+ file,
2652
+ line: index + 1,
2653
+ text: lineText.trim()
2654
+ });
2655
+
2656
+ if (matches.length >= maxMatches) {
2657
+ break;
2658
+ }
2659
+ }
2660
+ }
2661
+
2662
+ return asJsonTextResult({ matches });
2663
+ }
2664
+ );
2665
+
2666
+ server.registerTool(
2667
+ "get_project_log_config",
2668
+ {
2669
+ title: "Get Godot Project Log Config",
2670
+ description: "读取 Godot 项目日志配置,解析 debug/file_logging/log_path。缺省为 user://logs/godot.log,并返回 user:// 对应的真实系统路径。",
2671
+ inputSchema: z.object({})
2672
+ },
2673
+ async () => asJsonTextResult(await getProjectLogConfig())
2674
+ );
2675
+
2676
+ server.registerTool(
2677
+ "list_project_logs",
2678
+ {
2679
+ title: "List Godot Project Logs",
2680
+ description: "列出当前 Godot 项目日志目录中的 godot.log 和轮转日志。遇到 user:// 路径时会先按 Godot 规则解析到真实系统路径。",
2681
+ inputSchema: z.object({})
2682
+ },
2683
+ async () => asJsonTextResult(await listProjectLogFiles())
2684
+ );
2685
+
2686
+ server.registerTool(
2687
+ "read_project_log",
2688
+ {
2689
+ title: "Read Godot Project Log",
2690
+ description: "读取当前 Godot 项目日志尾部。默认读取 godot.log;如果不存在则读取最新轮转日志。只允许读取日志目录内的 .log 文件。",
2691
+ inputSchema: z.object({
2692
+ fileName: z.string().optional().describe("可选,来自 list_project_logs 的纯文件名,例如 godot.log"),
2693
+ lines: z.number().int().positive().max(MAX_PROJECT_LOG_LINES).optional().describe(`读取尾部行数,默认 ${DEFAULT_PROJECT_LOG_LINES},最多 ${MAX_PROJECT_LOG_LINES}`)
2694
+ })
2695
+ },
2696
+ async ({ fileName, lines }) => asJsonTextResult(await readProjectLog(fileName, lines))
2697
+ );
2698
+
2699
+ server.registerTool(
2700
+ "get_project_settings",
2701
+ {
2702
+ title: "Get Godot Project Settings",
2703
+ description: "结构化读取 project.godot 中显式写出的项目设置。key 使用 Godot 完整路径,例如 application/config/name 或 debug/file_logging/log_path。",
2704
+ inputSchema: z.object({
2705
+ keys: z.array(z.string().min(1)).max(64).optional().describe("可选,按完整 key 精确读取"),
2706
+ prefix: z.string().optional().describe("可选,按完整 key 前缀过滤,例如 debug/file_logging/")
2707
+ })
2708
+ },
2709
+ async ({ keys, prefix }) => asJsonTextResult(await getProjectSettings(keys, prefix))
2710
+ );
2711
+
2712
+ server.registerTool(
2713
+ "get_editor_config_summary",
2714
+ {
2715
+ title: "Get Godot Editor Config Summary",
2716
+ description: "读取 Godot 编辑器全局设置和当前项目 .godot/editor 状态摘要。默认脱敏本机路径;只有 raw=true 时返回原始路径。",
2717
+ inputSchema: z.object({
2718
+ raw: z.boolean().optional().describe("是否返回原始本机路径。默认 false,会脱敏用户名和非当前项目绝对路径。")
2719
+ })
2720
+ },
2721
+ async ({ raw }) => asJsonTextResult(await getEditorConfigSummary(raw))
2722
+ );
2723
+
2724
+ server.registerTool(
2725
+ "get_editor_settings",
2726
+ {
2727
+ title: "Get Godot Editor Settings",
2728
+ description: "按 key 或 prefix 读取 editor_settings-*.tres 中的编辑器设置,例如 interface/theme/ 或 text_editor/。默认脱敏路径值。",
2729
+ inputSchema: z.object({
2730
+ keys: z.array(z.string().min(1)).max(64).optional().describe("可选,按完整 EditorSettings key 精确读取"),
2731
+ prefix: z.string().optional().describe("可选,按 key 前缀过滤,例如 interface/theme/"),
2732
+ raw: z.boolean().optional().describe("是否返回原始路径值。默认 false。")
2733
+ })
2734
+ },
2735
+ async ({ keys, prefix, raw }) => asJsonTextResult(await getEditorSettings(keys, prefix, raw))
2736
+ );
2737
+
2738
+ server.registerTool(
2739
+ "list_editor_config_files",
2740
+ {
2741
+ title: "List Godot Editor Config Files",
2742
+ description: "列出只读白名单中的 Godot 编辑器配置文件,包括 editor_settings、projects.cfg、recent_dirs、text_editor_themes、script_templates 和当前项目 .godot/editor/*.cfg。",
2743
+ inputSchema: z.object({
2744
+ raw: z.boolean().optional().describe("是否返回原始绝对路径。默认 false。")
2745
+ })
2746
+ },
2747
+ async ({ raw }) => asJsonTextResult(await listEditorConfigFiles(raw))
2748
+ );
2749
+
2750
+ server.registerTool(
2751
+ "read_editor_config_file",
2752
+ {
2753
+ title: "Read Godot Editor Config File",
2754
+ description: "读取 list_editor_config_files 返回的白名单编辑器配置文件。默认脱敏内容中的本机路径;只有 raw=true 时返回原文。",
2755
+ inputSchema: z.object({
2756
+ fileId: z.string().optional().describe("来自 list_editor_config_files 的 fileId,例如 global_config:editor_settings-4.7.tres"),
2757
+ filePath: z.string().optional().describe("可选路径写法;推荐优先使用 fileId"),
2758
+ raw: z.boolean().optional().describe("是否返回原始内容。默认 false。")
2759
+ })
2760
+ },
2761
+ async ({ fileId, filePath, raw }) => asJsonTextResult(await readEditorConfigFile(fileId, filePath, raw))
2762
+ );
2763
+
2764
+ server.registerTool(
2765
+ "get_editor_project_state",
2766
+ {
2767
+ title: "Get Godot Editor Project State",
2768
+ description: "结构化读取当前项目 .godot/editor/editor_layout.cfg 与 script_editor_cache.cfg,返回打开场景、脚本、FileSystem 选中项和光标状态。默认脱敏路径。",
2769
+ inputSchema: z.object({
2770
+ raw: z.boolean().optional().describe("是否返回原始路径。默认 false。")
2771
+ })
2772
+ },
2773
+ async ({ raw }) => asJsonTextResult(await getEditorProjectState(raw))
2774
+ );
2775
+
2776
+ server.registerTool(
2777
+ "get_recent_projects",
2778
+ {
2779
+ title: "Get Godot Recent Projects",
2780
+ description: "读取 Godot projects.cfg 和 recent_dirs,返回最近项目与最近目录。默认脱敏非当前项目路径;raw=true 时返回原始路径。",
2781
+ inputSchema: z.object({
2782
+ raw: z.boolean().optional().describe("是否返回原始路径。默认 false。")
2783
+ })
2784
+ },
2785
+ async ({ raw }) => asJsonTextResult(await getRecentProjects(raw))
2786
+ );
2787
+
2788
+ server.registerTool(
2789
+ "propose_set_project_setting",
2790
+ {
2791
+ title: "Propose Set Godot Project Setting",
2792
+ description: "预览设置 project.godot 中的某个项目设置,不会写入磁盘。valueExpression 必须是 Godot project.godot 右侧原始表达式,例如 \"\\\"Daedalus\\\"\"、true、PackedStringArray(...)。",
2793
+ inputSchema: z.object({
2794
+ key: z.string().min(1).describe("完整项目设置 key,例如 debug/file_logging/log_path"),
2795
+ valueExpression: z.string().min(1).describe("project.godot 右侧原始表达式")
2796
+ })
2797
+ },
2798
+ async ({ key, valueExpression }) => asJsonTextResult(await proposeSetProjectSetting(key, valueExpression))
2799
+ );
2800
+
2801
+ server.registerTool(
2802
+ "set_project_setting",
2803
+ {
2804
+ title: "Set Godot Project Setting",
2805
+ description: "修改 project.godot 中的某个项目设置,会实际写入磁盘并需要用户审批。修改前应先读取当前值并调用 propose_set_project_setting 预览。",
2806
+ inputSchema: z.object({
2807
+ key: z.string().min(1).describe("完整项目设置 key,例如 debug/file_logging/log_path"),
2808
+ valueExpression: z.string().min(1).describe("project.godot 右侧原始表达式")
2809
+ })
2810
+ },
2811
+ async ({ key, valueExpression }) => asJsonTextResult(await setProjectSetting(key, valueExpression))
2812
+ );
2813
+
2814
+ server.registerTool(
2815
+ "propose_unset_project_setting",
2816
+ {
2817
+ title: "Propose Unset Godot Project Setting",
2818
+ description: "预览移除 project.godot 中的某个显式项目设置,不会写入磁盘。移除后 Godot 会回退到引擎默认值或平台默认值。",
2819
+ inputSchema: z.object({
2820
+ key: z.string().min(1).describe("完整项目设置 key,例如 debug/file_logging/log_path")
2821
+ })
2822
+ },
2823
+ async ({ key }) => asJsonTextResult(await proposeUnsetProjectSetting(key))
2824
+ );
2825
+
2826
+ server.registerTool(
2827
+ "unset_project_setting",
2828
+ {
2829
+ title: "Unset Godot Project Setting",
2830
+ description: "移除 project.godot 中的某个显式项目设置,会实际写入磁盘并需要用户审批。移除后 Godot 会回退到默认值。",
2831
+ inputSchema: z.object({
2832
+ key: z.string().min(1).describe("完整项目设置 key,例如 debug/file_logging/log_path")
2833
+ })
2834
+ },
2835
+ async ({ key }) => asJsonTextResult(await unsetProjectSetting(key))
2836
+ );
2837
+
2838
+ server.registerTool(
2839
+ "propose_create_text_file",
2840
+ {
2841
+ title: "Propose Create Text File",
2842
+ description: "提出新建一个文本文件的提案。不会实际写入磁盘,仅返回校验结果和预览。支持 .gd/.tres/.tscn/.json/.md/.txt 文件。.tscn 文件必须包含 [gd_scene ...] 头部和至少一个 [node ...] 根节点。不允许覆盖已有文件,不允许写入 .godot/ 或 addons/ 目录。",
2843
+ inputSchema: z.object({
2844
+ relativePath: z.string().min(1).describe("相对于项目根目录的新文件路径"),
2845
+ content: z.string().describe("文件内容")
2846
+ })
2847
+ },
2848
+ async ({ relativePath, content }) => {
2849
+ const validation = await validateNewTextFile(relativePath, content);
2850
+
2851
+ if (!validation.valid) {
2852
+ return asJsonTextResult({
2853
+ valid: false,
2854
+ path: validation.normalizedPath,
2855
+ errors: validation.errors
2856
+ });
2857
+ }
2858
+
2859
+ const previewLength: number = Math.min(content.length, 500);
2860
+ const preview: string = content.slice(0, previewLength) + (content.length > previewLength ? "\n..." : "");
2861
+
2862
+ return asJsonTextResult({
2863
+ valid: true,
2864
+ path: validation.normalizedPath,
2865
+ size: content.length,
2866
+ preview
2867
+ });
2868
+ }
2869
+ );
2870
+
2871
+ server.registerTool(
2872
+ "create_text_file",
2873
+ {
2874
+ title: "Create Text File",
2875
+ description: "创建一个新的文本文件,会实际写入磁盘。支持 .gd/.tres/.tscn/.json/.md/.txt 文件。.tscn 文件必须包含 [gd_scene ...] 头部和至少一个 [node ...] 根节点。不允许覆盖已有文件,不允许写入 .godot/ 或 addons/ 目录。写入后建议运行 godot.check_only 验证。",
2876
+ inputSchema: z.object({
2877
+ relativePath: z.string().min(1).describe("相对于项目根目录的新文件路径"),
2878
+ content: z.string().describe("文件内容")
2879
+ })
2880
+ },
2881
+ async ({ relativePath, content }) => asJsonTextResult(await createTextFile(relativePath, content))
2882
+ );
2883
+
2884
+ server.registerTool(
2885
+ "propose_overwrite_text_file",
2886
+ {
2887
+ title: "Propose Overwrite Text File",
2888
+ description: "提出覆盖已有文件的提案。不会实际写入,仅校验并返回新旧内容对比。支持 .gd/.tres/.tscn/.json/.md/.txt 文件。.tscn 文件必须包含 [gd_scene ...] 头部和至少一个 [node ...] 根节点。文件必须已存在,不允许写入 .godot/。",
2889
+ inputSchema: z.object({
2890
+ relativePath: z.string().min(1).describe("相对于项目根目录的已有文件路径"),
2891
+ content: z.string().describe("新的完整文件内容")
2892
+ })
2893
+ },
2894
+ async ({ relativePath, content }) => {
2895
+ const errors: string[] = [];
2896
+ let resolvedPath: string;
2897
+
2898
+ try {
2899
+ resolvedPath = await assertWritablePath(relativePath);
2900
+ } catch (error: unknown) {
2901
+ return asJsonTextResult({
2902
+ valid: false,
2903
+ path: relativePath,
2904
+ errors: [error instanceof Error ? error.message : "Path validation failed"]
2905
+ });
2906
+ }
2907
+
2908
+ if (content.length === 0) {
2909
+ errors.push("File content is empty");
2910
+ }
2911
+
2912
+ const overwriteMaxBytes: number = relativePath.endsWith(".tscn") ? MAX_TSCN_FILE_BYTES : MAX_TEXT_FILE_BYTES;
2913
+ if (content.length > overwriteMaxBytes) {
2914
+ errors.push(`Content too large: ${content.length} bytes (max ${overwriteMaxBytes})`);
2915
+ }
2916
+
2917
+ if (relativePath.endsWith(".tscn") && content.length > 0) {
2918
+ errors.push(...validateTscnContent(content));
2919
+ }
2920
+ let oldContent: string;
2921
+ try {
2922
+ oldContent = await fs.readFile(resolvedPath, "utf8");
2923
+ } catch {
2924
+ errors.push(`File does not exist: ${relativePath}`);
2925
+ return asJsonTextResult({ valid: false, path: relativePath, errors });
2926
+ }
2927
+
2928
+ if (errors.length > 0) {
2929
+ return asJsonTextResult({ valid: false, path: relativePath, errors });
2930
+ }
2931
+
2932
+ const previewLength: number = Math.min(content.length, 500);
2933
+ const normalizedPath: string = path.relative(projectRoot, resolvedPath).replaceAll(path.sep, "/");
2934
+
2935
+ return asJsonTextResult({
2936
+ valid: true,
2937
+ path: normalizedPath,
2938
+ size: content.length,
2939
+ oldSize: oldContent.length,
2940
+ preview: content.slice(0, previewLength) + (content.length > previewLength ? "\n..." : "")
2941
+ });
2942
+ }
2943
+ );
2944
+
2945
+ server.registerTool(
2946
+ "overwrite_text_file",
2947
+ {
2948
+ title: "Overwrite Text File",
2949
+ description: "覆盖已有文本文件,会实际写入磁盘。支持 .gd/.tres/.tscn/.json/.md/.txt 文件。.tscn 文件必须包含 [gd_scene ...] 头部和至少一个 [node ...] 根节点。不允许写入 .godot/、addons/ 或隐藏目录。写入后建议运行 godot.check_only 验证。",
2950
+ inputSchema: z.object({
2951
+ relativePath: z.string().min(1).describe("相对于项目根目录的已有文件路径"),
2952
+ content: z.string().describe("新的完整文件内容")
2953
+ })
2954
+ },
2955
+ async ({ relativePath, content }) => asJsonTextResult(await overwriteTextFile(relativePath, content))
2956
+ );
2957
+
2958
+ server.registerTool(
2959
+ "propose_replace_text_in_file",
2960
+ {
2961
+ title: "Propose Replace Text In File",
2962
+ description: "提出替换文件中指定文本的提案。不会实际写入,仅校验并返回 diff 预览。文件必须已存在。",
2963
+ inputSchema: z.object({
2964
+ relativePath: z.string().min(1).describe("相对于项目根目录的已有文件路径"),
2965
+ oldText: z.string().min(1).describe("要被替换的原文本(必须精确匹配)"),
2966
+ newText: z.string().describe("替换后的新文本")
2967
+ })
2968
+ },
2969
+ async ({ relativePath, oldText, newText }) => {
2970
+ const errors: string[] = [];
2971
+ let resolvedPath: string;
2972
+
2973
+ try {
2974
+ resolvedPath = await assertWritablePath(relativePath);
2975
+ } catch (error: unknown) {
2976
+ return asJsonTextResult({
2977
+ valid: false,
2978
+ path: relativePath,
2979
+ errors: [error instanceof Error ? error.message : "Path validation failed"]
2980
+ });
2981
+ }
2982
+
2983
+ let oldContent: string;
2984
+ try {
2985
+ oldContent = await fs.readFile(resolvedPath, "utf8");
2986
+ } catch {
2987
+ errors.push(`File does not exist: ${relativePath}`);
2988
+ return asJsonTextResult({ valid: false, path: relativePath, errors });
2989
+ }
2990
+
2991
+ if (!oldContent.includes(oldText)) {
2992
+ errors.push("oldText not found in file. Ensure exact match including whitespace and indentation.");
2993
+ return asJsonTextResult({ valid: false, path: relativePath, errors });
2994
+ }
2995
+
2996
+ const newContent: string = oldContent.replace(oldText, newText);
2997
+ const occurrenceCount: number = oldContent.split(oldText).length - 1;
2998
+ const normalizedPath: string = path.relative(projectRoot, resolvedPath).replaceAll(path.sep, "/");
2999
+
3000
+ return asJsonTextResult({
3001
+ valid: true,
3002
+ path: normalizedPath,
3003
+ occurrences: occurrenceCount,
3004
+ oldLength: oldContent.length,
3005
+ newLength: newContent.length,
3006
+ preview: newContent.slice(0, 500) + (newContent.length > 500 ? "\n..." : "")
3007
+ });
3008
+ }
3009
+ );
3010
+
3011
+ server.registerTool(
3012
+ "replace_text_in_file",
3013
+ {
3014
+ title: "Replace Text In File",
3015
+ description: "替换已有文件中首次出现的指定文本,会实际写入磁盘。oldText 必须精确匹配。",
3016
+ inputSchema: z.object({
3017
+ relativePath: z.string().min(1).describe("相对于项目根目录的已有文件路径"),
3018
+ oldText: z.string().min(1).describe("要被替换的原文本(必须精确匹配)"),
3019
+ newText: z.string().describe("替换后的新文本")
3020
+ })
3021
+ },
3022
+ async ({ relativePath, oldText, newText }) => asJsonTextResult(await replaceTextInFile(relativePath, oldText, newText))
3023
+ );
3024
+
3025
+ server.registerTool(
3026
+ "delete_file",
3027
+ {
3028
+ title: "Delete File",
3029
+ description: "删除项目中的文件。文件必须存在,不允许删除 .godot/ 中的文件。",
3030
+ inputSchema: z.object({
3031
+ relativePath: z.string().min(1).describe("相对于项目根目录的已有文件路径")
3032
+ })
3033
+ },
3034
+ async ({ relativePath }) => {
3035
+ const errors: string[] = [];
3036
+ let resolvedPath: string;
3037
+
3038
+ try {
3039
+ resolvedPath = await assertWritablePath(relativePath);
3040
+ } catch (error: unknown) {
3041
+ return asJsonTextResult({
3042
+ valid: false,
3043
+ path: relativePath,
3044
+ errors: [error instanceof Error ? error.message : "Path validation failed"]
3045
+ });
3046
+ }
3047
+
3048
+ const normalizedPath: string = path.relative(projectRoot, resolvedPath).replaceAll(path.sep, "/");
3049
+
3050
+ if (normalizedPath.startsWith(".godot/") || normalizedPath === ".godot") {
3051
+ return asJsonTextResult({
3052
+ valid: false,
3053
+ path: normalizedPath,
3054
+ errors: ["Cannot delete files in .godot/"]
3055
+ });
3056
+ }
3057
+
3058
+ try {
3059
+ const stat = await fs.stat(resolvedPath);
3060
+ if (!stat.isFile()) {
3061
+ errors.push(`Not a file: ${normalizedPath}`);
3062
+ }
3063
+ } catch {
3064
+ errors.push(`File does not exist: ${normalizedPath}`);
3065
+ }
3066
+
3067
+ if (errors.length > 0) {
3068
+ return asJsonTextResult({ valid: false, path: normalizedPath, errors });
3069
+ }
3070
+
3071
+ try {
3072
+ await fs.unlink(resolvedPath);
3073
+ return asJsonTextResult({ deleted: true, path: normalizedPath });
3074
+ } catch (error: unknown) {
3075
+ return asJsonTextResult({
3076
+ valid: false,
3077
+ path: normalizedPath,
3078
+ errors: [error instanceof Error ? error.message : "Failed to delete file"]
3079
+ });
3080
+ }
3081
+ }
3082
+ );
3083
+
3084
+ server.registerResource(
3085
+ "project",
3086
+ "godot://project",
3087
+ {
3088
+ title: "Godot Project Summary",
3089
+ description: "当前 Godot 项目的摘要信息",
3090
+ mimeType: "application/json"
3091
+ },
3092
+ async (uri: URL) => ({
3093
+ contents: [{
3094
+ uri: uri.href,
3095
+ mimeType: "application/json",
3096
+ text: JSON.stringify(await getProjectSummary(), null, 2)
3097
+ }]
3098
+ })
3099
+ );
3100
+
3101
+ server.registerResource(
3102
+ "scenes",
3103
+ "godot://scenes",
3104
+ {
3105
+ title: "Godot Scenes",
3106
+ description: "当前 Godot 项目的场景文件列表",
3107
+ mimeType: "application/json"
3108
+ },
3109
+ async (uri: URL) => ({
3110
+ contents: [{
3111
+ uri: uri.href,
3112
+ mimeType: "application/json",
3113
+ text: JSON.stringify({ scenes: await walkProjectFiles({ extensions: [".tscn"] }) }, null, 2)
3114
+ }]
3115
+ })
3116
+ );
3117
+
3118
+ server.registerResource(
3119
+ "scripts",
3120
+ "godot://scripts",
3121
+ {
3122
+ title: "GDScript Files",
3123
+ description: "当前 Godot 项目的 GDScript 文件列表",
3124
+ mimeType: "application/json"
3125
+ },
3126
+ async (uri: URL) => ({
3127
+ contents: [{
3128
+ uri: uri.href,
3129
+ mimeType: "application/json",
3130
+ text: JSON.stringify({ scripts: await walkProjectFiles({ extensions: [".gd"] }) }, null, 2)
3131
+ }]
3132
+ })
3133
+ );
3134
+
3135
+ // Scene semantic tools
3136
+ server.registerTool(
3137
+ "inspect_scene_tree",
3138
+ {
3139
+ title: "Inspect Scene Tree",
3140
+ description: "解析 .tscn 场景文件,返回节点树、脚本引用和信号连接的完整结构化信息。",
3141
+ inputSchema: z.object({
3142
+ relativePath: z.string().min(1).describe("场景文件的相对路径,例如 'scenes/main.tscn'")
3143
+ })
3144
+ },
3145
+ async ({ relativePath }) => {
3146
+ try {
3147
+ const fullPath = await resolveProjectPath(relativePath);
3148
+ const ext = path.extname(fullPath);
3149
+ if (ext !== ".tscn") {
3150
+ return asJsonTextResult({ valid: false, path: relativePath, errors: ["File is not a .tscn scene file"] });
3151
+ }
3152
+ const content = await fs.readFile(fullPath, "utf8");
3153
+ const data = parseTscn(content);
3154
+ return asJsonTextResult({ valid: true, path: relativePath, data });
3155
+ } catch (error: unknown) {
3156
+ return asJsonTextResult({ valid: false, path: relativePath, errors: [error instanceof Error ? error.message : "Failed to inspect scene"] });
3157
+ }
3158
+ }
3159
+ );
3160
+
3161
+ server.registerTool(
3162
+ "propose_create_scene",
3163
+ {
3164
+ title: "Propose Create Scene",
3165
+ description: "提出创建一个新的 Godot 场景文件(.tscn)的提案。不会实际写入磁盘,仅返回校验结果和预览。参数包含相对路径、根节点类型和根节点名称。",
3166
+ inputSchema: z.object({
3167
+ relativePath: z.string().min(1).describe("新场景文件的相对路径,必须以 .tscn 结尾"),
3168
+ rootNodeType: z.string().min(1).describe("根节点类型,例如 Node2D、Node3D、Control"),
3169
+ rootNodeName: z.string().min(1).describe("根节点名称,例如 Main、Game")
3170
+ })
3171
+ },
3172
+ async ({ relativePath, rootNodeType, rootNodeName }) => {
3173
+ const content = generateSceneTscn(rootNodeType, rootNodeName);
3174
+ const validation = await validateNewTextFile(relativePath, content);
3175
+ if (!validation.valid) {
3176
+ return asJsonTextResult({ valid: false, path: validation.normalizedPath, errors: validation.errors });
3177
+ }
3178
+ return asJsonTextResult({
3179
+ valid: true,
3180
+ path: validation.normalizedPath,
3181
+ rootNodeType,
3182
+ rootNodeName,
3183
+ size: content.length,
3184
+ preview: content
3185
+ });
3186
+ }
3187
+ );
3188
+
3189
+ server.registerTool(
3190
+ "create_scene",
3191
+ {
3192
+ title: "Create Scene",
3193
+ description: "创建一个新的 Godot 场景 .tscn 文件,会实际写入磁盘。需要用户审批。参数包含相对路径、根节点类型和根节点名称。写入后建议运行 godot.check_only 验证。",
3194
+ inputSchema: z.object({
3195
+ relativePath: z.string().min(1).describe("新场景文件的相对路径,必须以 .tscn 结尾"),
3196
+ rootNodeType: z.string().min(1).describe("根节点类型,例如 Node2D、Node3D、Control"),
3197
+ rootNodeName: z.string().min(1).describe("根节点名称,例如 Main、Game")
3198
+ })
3199
+ },
3200
+ async ({ relativePath, rootNodeType, rootNodeName }) => {
3201
+ const content = generateSceneTscn(rootNodeType, rootNodeName);
3202
+ const result = await createTextFile(relativePath, content);
3203
+ return asJsonTextResult({ ...result, rootNodeType, rootNodeName });
3204
+ }
3205
+ );
3206
+
3207
+ server.registerTool(
3208
+ "propose_add_node_to_scene",
3209
+ {
3210
+ title: "Propose Add Node To Scene",
3211
+ description: "提出向场景添加节点的提案。不会实际写入磁盘,仅校验并返回修改后的场景预览。参数包含场景路径、父节点路径、节点类型、节点名称和属性。",
3212
+ inputSchema: z.object({
3213
+ scenePath: z.string().min(1).describe("已有场景文件的相对路径"),
3214
+ parentPath: z.string().min(1).describe("父节点的路径,根节点用 . 表示"),
3215
+ nodeType: z.string().min(1).describe("节点类型,例如 Label、Button、CollisionShape2D"),
3216
+ nodeName: z.string().min(1).describe("节点名称,例如 HealthLabel"),
3217
+ properties: z.record(z.string(), z.string()).optional().describe("节点属性,例如 { text: 'Hello', position: 'Vector2(100, 200)' }")
3218
+ })
3219
+ },
3220
+ async ({ scenePath, parentPath, nodeType, nodeName, properties }) => {
3221
+ try {
3222
+ const fullPath = await resolveProjectPath(scenePath);
3223
+ const oldContent = await fs.readFile(fullPath, "utf8");
3224
+ const data = parseTscn(oldContent);
3225
+ const targetParent = findNodeInTscn(data, parentPath);
3226
+ if (targetParent === null) {
3227
+ return asJsonTextResult({ valid: false, scenePath, errors: [`Parent node not found: ${parentPath}`] });
3228
+ }
3229
+ const newContent = addNodeToSceneTscn(oldContent, parentPath, nodeType, nodeName, properties ?? {});
3230
+ const previewStart = newContent.indexOf(`[node name="${quoteTscnString(nodeName)}"`);
3231
+ const preview = previewStart >= 0 ? newContent.slice(Math.max(0, previewStart - 50), previewStart + 200) : newContent.slice(0, 500);
3232
+ return asJsonTextResult({
3233
+ valid: true,
3234
+ scenePath,
3235
+ nodeType,
3236
+ nodeName,
3237
+ parentPath,
3238
+ preview: preview + (newContent.length > preview.length ? "\n..." : "")
3239
+ });
3240
+ } catch (error: unknown) {
3241
+ return asJsonTextResult({ valid: false, scenePath, errors: [error instanceof Error ? error.message : "Failed to preview node addition"] });
3242
+ }
3243
+ }
3244
+ );
3245
+
3246
+ server.registerTool(
3247
+ "add_node_to_scene",
3248
+ {
3249
+ title: "Add Node To Scene",
3250
+ description: "向已有场景添加一个节点,会实际写入磁盘。需要用户审批。参数包含场景路径、父节点路径、节点类型、节点名称和属性。",
3251
+ inputSchema: z.object({
3252
+ scenePath: z.string().min(1).describe("已有场景文件的相对路径"),
3253
+ parentPath: z.string().min(1).describe("父节点的路径,根节点用 . 表示"),
3254
+ nodeType: z.string().min(1).describe("节点类型"),
3255
+ nodeName: z.string().min(1).describe("节点名称"),
3256
+ properties: z.record(z.string(), z.string()).optional().describe("节点属性")
3257
+ })
3258
+ },
3259
+ async ({ scenePath, parentPath, nodeType, nodeName, properties }) => {
3260
+ const fullPath = await resolveProjectPath(scenePath);
3261
+ const oldContent = await fs.readFile(fullPath, "utf8");
3262
+ const newContent = addNodeToSceneTscn(oldContent, parentPath, nodeType, nodeName, properties ?? {});
3263
+ await fs.writeFile(fullPath, newContent, "utf8");
3264
+ return asJsonTextResult({ modified: true, scenePath, nodeType, nodeName, parentPath });
3265
+ }
3266
+ );
3267
+
3268
+ server.registerTool(
3269
+ "propose_attach_script_to_node",
3270
+ {
3271
+ title: "Propose Attach Script To Node",
3272
+ description: "提出给场景中的节点挂载脚本的提案。不会实际写入,仅校验并返回预览。参数包含场景路径、节点路径和脚本路径。",
3273
+ inputSchema: z.object({
3274
+ scenePath: z.string().min(1).describe("场景文件的相对路径"),
3275
+ nodePath: z.string().min(1).describe("目标节点的路径,例如 Main/Player"),
3276
+ scriptPath: z.string().min(1).describe("脚本资源路径,例如 res://scripts/player.gd 或 ExtResource('1_abc')")
3277
+ })
3278
+ },
3279
+ async ({ scenePath, nodePath, scriptPath }) => {
3280
+ try {
3281
+ const fullPath = await resolveProjectPath(scenePath);
3282
+ const oldContent = await fs.readFile(fullPath, "utf8");
3283
+ const data = parseTscn(oldContent);
3284
+ const targetNode = findNodeInTscn(data, nodePath);
3285
+ if (targetNode === null) {
3286
+ return asJsonTextResult({ valid: false, scenePath, errors: [`Node not found: ${nodePath}`] });
3287
+ }
3288
+ if (targetNode.script !== null) {
3289
+ return asJsonTextResult({ valid: false, scenePath, errors: [`Node already has a script: ${targetNode.script}`] });
3290
+ }
3291
+ const newContent = attachScriptToSceneTscn(oldContent, nodePath, scriptPath);
3292
+ const nodeIdx = newContent.indexOf(`[node name="${quoteTscnString(targetNode.name)}"`);
3293
+ const preview = nodeIdx >= 0 ? newContent.slice(nodeIdx, nodeIdx + 300) : newContent.slice(0, 500);
3294
+ return asJsonTextResult({ valid: true, scenePath, nodePath, scriptPath, preview: preview + "\n..." });
3295
+ } catch (error: unknown) {
3296
+ return asJsonTextResult({ valid: false, scenePath, errors: [error instanceof Error ? error.message : "Failed to preview script attachment"] });
3297
+ }
3298
+ }
3299
+ );
3300
+
3301
+ server.registerTool(
3302
+ "attach_script_to_node",
3303
+ {
3304
+ title: "Attach Script To Node",
3305
+ description: "给场景中的节点挂载脚本,会实际写入磁盘。需要用户审批。参数包含场景路径、节点路径和脚本路径。",
3306
+ inputSchema: z.object({
3307
+ scenePath: z.string().min(1).describe("场景文件的相对路径"),
3308
+ nodePath: z.string().min(1).describe("目标节点的路径"),
3309
+ scriptPath: z.string().min(1).describe("脚本资源路径")
3310
+ })
3311
+ },
3312
+ async ({ scenePath, nodePath, scriptPath }) => {
3313
+ const fullPath = await resolveProjectPath(scenePath);
3314
+ const oldContent = await fs.readFile(fullPath, "utf8");
3315
+ const newContent = attachScriptToSceneTscn(oldContent, nodePath, scriptPath);
3316
+ await fs.writeFile(fullPath, newContent, "utf8");
3317
+ return asJsonTextResult({ modified: true, scenePath, nodePath, scriptPath });
3318
+ }
3319
+ );
3320
+
3321
+ server.registerTool(
3322
+ "propose_connect_signal_in_scene",
3323
+ {
3324
+ title: "Propose Connect Signal In Scene",
3325
+ description: "提出在场景中连接信号的提案。不会实际写入,仅校验并返回预览。参数包含场景路径、信号名、发送节点、接收节点和方法名。",
3326
+ inputSchema: z.object({
3327
+ scenePath: z.string().min(1).describe("场景文件的相对路径"),
3328
+ signal: z.string().min(1).describe("信号名称,例如 pressed、body_entered"),
3329
+ fromNode: z.string().min(1).describe("发送信号的节点路径"),
3330
+ toNode: z.string().min(1).describe("接收信号的节点路径,方法所在节点用 . 表示"),
3331
+ method: z.string().min(1).describe("回调方法名称,例如 _on_button_pressed"),
3332
+ flags: z.number().int().optional().describe("连接标志,默认 0"),
3333
+ binds: z.string().optional().describe("绑定的参数,例如 [] 或 [1, 2]")
3334
+ })
3335
+ },
3336
+ async ({ scenePath, signal, fromNode, toNode, method, flags, binds }) => {
3337
+ try {
3338
+ const fullPath = await resolveProjectPath(scenePath);
3339
+ const oldContent = await fs.readFile(fullPath, "utf8");
3340
+ const data = parseTscn(oldContent);
3341
+ const connExists = data.connections.some(c => c.signal === signal && c.from === fromNode && c.to === toNode && c.method === method);
3342
+ if (connExists) {
3343
+ return asJsonTextResult({ valid: false, scenePath, errors: ["This signal connection already exists in the scene"] });
3344
+ }
3345
+ const newContent = connectSignalInSceneTscn(oldContent, signal, fromNode, toNode, method, flags, binds);
3346
+ const connLine = newContent.lastIndexOf("[connection ");
3347
+ const preview = connLine >= 0 ? newContent.slice(connLine, connLine + 200) : newContent.slice(-500);
3348
+ return asJsonTextResult({ valid: true, scenePath, signal, fromNode, toNode, method, preview });
3349
+ } catch (error: unknown) {
3350
+ return asJsonTextResult({ valid: false, scenePath, errors: [error instanceof Error ? error.message : "Failed to preview signal connection"] });
3351
+ }
3352
+ }
3353
+ );
3354
+
3355
+ server.registerTool(
3356
+ "connect_signal_in_scene",
3357
+ {
3358
+ title: "Connect Signal In Scene",
3359
+ description: "在场景中连接一个信号,会实际写入磁盘。需要用户审批。参数包含场景路径、信号名、发送节点、接收节点和方法名。",
3360
+ inputSchema: z.object({
3361
+ scenePath: z.string().min(1).describe("场景文件的相对路径"),
3362
+ signal: z.string().min(1).describe("信号名称"),
3363
+ fromNode: z.string().min(1).describe("发送信号的节点路径"),
3364
+ toNode: z.string().min(1).describe("接收信号的节点路径"),
3365
+ method: z.string().min(1).describe("回调方法名称"),
3366
+ flags: z.number().int().optional().describe("连接标志"),
3367
+ binds: z.string().optional().describe("绑定的参数")
3368
+ })
3369
+ },
3370
+ async ({ scenePath, signal, fromNode, toNode, method, flags, binds }) => {
3371
+ const fullPath = await resolveProjectPath(scenePath);
3372
+ const oldContent = await fs.readFile(fullPath, "utf8");
3373
+ const newContent = connectSignalInSceneTscn(oldContent, signal, fromNode, toNode, method, flags, binds);
3374
+ await fs.writeFile(fullPath, newContent, "utf8");
3375
+ return asJsonTextResult({ modified: true, scenePath, signal, fromNode, toNode, method });
3376
+ }
3377
+ );
3378
+
3379
+ const scenePatchOperationSchema = z.discriminatedUnion("type", [
3380
+ z.object({
3381
+ type: z.literal("add_node"),
3382
+ parentPath: z.string().min(1).describe("父节点路径,根节点用 . 表示"),
3383
+ nodeType: z.string().min(1).describe("节点类型,例如 VBoxContainer、Label、Button"),
3384
+ nodeName: z.string().min(1).describe("节点名称"),
3385
+ properties: z.record(z.string(), z.string()).optional().describe("节点属性,值必须是 .tscn 表达式字符串,例如 text 用 '\"Hello\"'")
3386
+ }),
3387
+ z.object({
3388
+ type: z.literal("attach_script"),
3389
+ nodePath: z.string().min(1).describe("目标节点路径"),
3390
+ scriptPath: z.string().min(1).describe("脚本资源路径,例如 res://scripts/main.gd")
3391
+ }),
3392
+ z.object({
3393
+ type: z.literal("connect_signal"),
3394
+ signal: z.string().min(1).describe("信号名称,例如 pressed"),
3395
+ fromNode: z.string().min(1).describe("发送信号的节点路径"),
3396
+ toNode: z.string().min(1).describe("接收信号的节点路径"),
3397
+ method: z.string().min(1).describe("回调方法名称"),
3398
+ flags: z.number().int().optional().describe("连接标志"),
3399
+ binds: z.string().optional().describe("绑定参数表达式")
3400
+ })
3401
+ ]);
3402
+
3403
+ server.registerTool(
3404
+ "propose_apply_scene_patch",
3405
+ {
3406
+ title: "Propose Apply Scene Patch",
3407
+ description: "提出批量修改已有 Godot .tscn 场景的提案。不会写入磁盘。支持一次性添加多个节点、挂载脚本、连接信号,适合减少碎片化工具调用。",
3408
+ inputSchema: z.object({
3409
+ scenePath: z.string().min(1).describe("已有场景文件的相对路径"),
3410
+ operations: z.array(scenePatchOperationSchema).min(1).max(50).describe("按顺序执行的场景操作列表")
3411
+ })
3412
+ },
3413
+ async ({ scenePath, operations }) => {
3414
+ try {
3415
+ const fullPath = await resolveProjectPath(scenePath);
3416
+ if (path.extname(fullPath) !== ".tscn") {
3417
+ return asJsonTextResult({ valid: false, scenePath, errors: ["File is not a .tscn scene file"] });
3418
+ }
3419
+
3420
+ const oldContent = await fs.readFile(fullPath, "utf8");
3421
+ const patchResult = applyScenePatchToTscn(oldContent, operations as ScenePatchOperation[]);
3422
+ const validationErrors: string[] = validateTscnContent(patchResult.content);
3423
+ if (validationErrors.length > 0) {
3424
+ return asJsonTextResult({ valid: false, scenePath, errors: validationErrors });
3425
+ }
3426
+
3427
+ return asJsonTextResult({
3428
+ valid: true,
3429
+ scenePath,
3430
+ operationCount: patchResult.applied.length,
3431
+ applied: patchResult.applied,
3432
+ oldSize: oldContent.length,
3433
+ newSize: patchResult.content.length,
3434
+ preview: patchResult.content.slice(0, 1200) + (patchResult.content.length > 1200 ? "\n..." : "")
3435
+ });
3436
+ } catch (error: unknown) {
3437
+ return asJsonTextResult({ valid: false, scenePath, errors: [error instanceof Error ? error.message : "Failed to preview scene patch"] });
3438
+ }
3439
+ }
3440
+ );
3441
+
3442
+ server.registerTool(
3443
+ "apply_scene_patch",
3444
+ {
3445
+ title: "Apply Scene Patch",
3446
+ description: "批量修改已有 Godot .tscn 场景,会实际写入磁盘并需要用户审批。支持一次性添加多个节点、挂载脚本、连接信号,适合创建复杂 UI/小游戏场景。",
3447
+ inputSchema: z.object({
3448
+ scenePath: z.string().min(1).describe("已有场景文件的相对路径"),
3449
+ operations: z.array(scenePatchOperationSchema).min(1).max(50).describe("按顺序执行的场景操作列表")
3450
+ })
3451
+ },
3452
+ async ({ scenePath, operations }) => {
3453
+ const fullPath = await resolveProjectPath(scenePath);
3454
+ if (path.extname(fullPath) !== ".tscn") {
3455
+ throw new Error("File is not a .tscn scene file");
3456
+ }
3457
+
3458
+ const oldContent = await fs.readFile(fullPath, "utf8");
3459
+ const patchResult = applyScenePatchToTscn(oldContent, operations as ScenePatchOperation[]);
3460
+ const validationErrors: string[] = validateTscnContent(patchResult.content);
3461
+ if (validationErrors.length > 0) {
3462
+ throw new Error(`TSCN validation failed: ${validationErrors.join("; ")}`);
3463
+ }
3464
+
3465
+ await fs.writeFile(fullPath, patchResult.content, "utf8");
3466
+ return asJsonTextResult({
3467
+ modified: true,
3468
+ scenePath,
3469
+ operationCount: patchResult.applied.length,
3470
+ applied: patchResult.applied
3471
+ });
3472
+ }
3473
+ );
3474
+
3475
+ const transport: StdioServerTransport = new StdioServerTransport();
3476
+ await server.connect(transport);
3477
+
3478
+ console.error(`Godot MCP Server started, project: ${projectRoot}`);
3479
+ }
3480
+
3481
+ main().catch((error: unknown): void => {
3482
+ console.error("MCP server fatal error:", error);
3483
+ process.exit(1);
3484
+ });