@npgamedev/godot-mcp-server 0.0.1 → 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 (129) hide show
  1. package/ATTRIBUTIONS.md +141 -0
  2. package/LICENSE +28 -0
  3. package/README.md +386 -4
  4. package/dist/extensions/extensionChanges.js +131 -0
  5. package/dist/extensions/extensionCommand.js +25 -0
  6. package/dist/extensions/extensionDiscovery.js +141 -0
  7. package/dist/extensions/extensionRegistrar.js +82 -0
  8. package/dist/extensions/extensions.js +33 -0
  9. package/dist/groups/builtinGroups.js +60 -0
  10. package/dist/groups/defs/3dTools.js +17 -0
  11. package/dist/groups/defs/animationAuthoring.js +16 -0
  12. package/dist/groups/defs/assetOps.js +6 -0
  13. package/dist/groups/defs/audio.js +6 -0
  14. package/dist/groups/defs/classdb.js +6 -0
  15. package/dist/groups/defs/cleanup.js +6 -0
  16. package/dist/groups/defs/debugger.js +6 -0
  17. package/dist/groups/defs/editorAdvanced.js +16 -0
  18. package/dist/groups/defs/inputMap.js +6 -0
  19. package/dist/groups/defs/layerNaming.js +6 -0
  20. package/dist/groups/defs/lspCodeAnalysis.js +23 -0
  21. package/dist/groups/defs/lspCodeNavigation.js +15 -0
  22. package/dist/groups/defs/navigation.js +17 -0
  23. package/dist/groups/defs/particles.js +21 -0
  24. package/dist/groups/defs/pathEditing.js +22 -0
  25. package/dist/groups/defs/placeholders.js +28 -0
  26. package/dist/groups/defs/procedural.js +6 -0
  27. package/dist/groups/defs/resourceIo.js +6 -0
  28. package/dist/groups/defs/runtimeAdvanced.js +17 -0
  29. package/dist/groups/defs/sceneAdvanced.js +6 -0
  30. package/dist/groups/defs/sceneInheritance.js +6 -0
  31. package/dist/groups/defs/signals.js +6 -0
  32. package/dist/groups/defs/spriteframes.js +16 -0
  33. package/dist/groups/defs/theme.js +6 -0
  34. package/dist/groups/defs/tilemap.js +6 -0
  35. package/dist/groups/defs/tileset.js +22 -0
  36. package/dist/groups/defs/tilesetEdit.js +21 -0
  37. package/dist/groups/defs/userData.js +6 -0
  38. package/dist/groups/extensionGroups.js +182 -0
  39. package/dist/groups/groupActivation.js +210 -0
  40. package/dist/groups/groupCatalogue.js +52 -0
  41. package/dist/groups/groupMatch.js +182 -0
  42. package/dist/groups/groupResult.js +16 -0
  43. package/dist/groups/groupState.js +15 -0
  44. package/dist/groups/groupToolHandlers.js +103 -0
  45. package/dist/groups/groupTypes.js +10 -0
  46. package/dist/groups/groups.js +205 -0
  47. package/dist/index.js +144 -0
  48. package/dist/lsp/lspClient.js +509 -0
  49. package/dist/lsp/lspLabels.js +105 -0
  50. package/dist/lsp/lspProjectScan.js +156 -0
  51. package/dist/lsp/lspSession.js +139 -0
  52. package/dist/lsp/lspStatusReporter.js +76 -0
  53. package/dist/lsp/lspUri.js +68 -0
  54. package/dist/mcp/prompts.js +59 -0
  55. package/dist/mcp/resources.js +113 -0
  56. package/dist/mcp/roots.js +30 -0
  57. package/dist/registration/catalogue.js +123 -0
  58. package/dist/registration/extensionCollision.js +38 -0
  59. package/dist/registration/operations.js +67 -0
  60. package/dist/registration/screenshotResponse.js +71 -0
  61. package/dist/registration/toolDispatch.js +76 -0
  62. package/dist/registration/toolMeta.js +104 -0
  63. package/dist/registration/toolRefs.js +48 -0
  64. package/dist/registration/toolRegistry.js +211 -0
  65. package/dist/registry.js +291 -0
  66. package/dist/registryLiveness.js +113 -0
  67. package/dist/security/pathGuard.js +97 -0
  68. package/dist/security/profiles.js +104 -0
  69. package/dist/security/untrusted.js +23 -0
  70. package/dist/shared/errorContract.js +154 -0
  71. package/dist/shared/errors.js +21 -0
  72. package/dist/shared/pagination.js +135 -0
  73. package/dist/shared/schemaCoercion.js +173 -0
  74. package/dist/shared/stableJson.js +27 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/shared/version.js +82 -0
  77. package/dist/startup/cliArgs.js +103 -0
  78. package/dist/startup/configReload.js +57 -0
  79. package/dist/startup/hooks.js +89 -0
  80. package/dist/startup/lifecycle.js +22 -0
  81. package/dist/startup/portConfig.js +127 -0
  82. package/dist/startup/reconcile.js +81 -0
  83. package/dist/startup/registrars.js +59 -0
  84. package/dist/startup/serverMode.js +19 -0
  85. package/dist/startup/startupEnv.js +142 -0
  86. package/dist/tools/animation.js +88 -0
  87. package/dist/tools/asset.js +58 -0
  88. package/dist/tools/assetWrite.js +16 -0
  89. package/dist/tools/audio.js +40 -0
  90. package/dist/tools/classdb.js +43 -0
  91. package/dist/tools/collision.js +23 -0
  92. package/dist/tools/debug.js +46 -0
  93. package/dist/tools/diff.js +18 -0
  94. package/dist/tools/editor.js +222 -0
  95. package/dist/tools/file.js +38 -0
  96. package/dist/tools/folder.js +27 -0
  97. package/dist/tools/inputMap.js +54 -0
  98. package/dist/tools/layerNames.js +29 -0
  99. package/dist/tools/lsp.js +524 -0
  100. package/dist/tools/navigation.js +24 -0
  101. package/dist/tools/node.js +145 -0
  102. package/dist/tools/nodeManagement.js +82 -0
  103. package/dist/tools/particles.js +83 -0
  104. package/dist/tools/path.js +30 -0
  105. package/dist/tools/playtest.js +99 -0
  106. package/dist/tools/procedural.js +101 -0
  107. package/dist/tools/resource.js +47 -0
  108. package/dist/tools/runtime.js +338 -0
  109. package/dist/tools/save.js +64 -0
  110. package/dist/tools/scene.js +102 -0
  111. package/dist/tools/sceneInheritance.js +19 -0
  112. package/dist/tools/sceneQuery.js +38 -0
  113. package/dist/tools/script.js +75 -0
  114. package/dist/tools/signals.js +53 -0
  115. package/dist/tools/sound.js +31 -0
  116. package/dist/tools/spatial.js +41 -0
  117. package/dist/tools/spriteframes.js +94 -0
  118. package/dist/tools/texture.js +35 -0
  119. package/dist/tools/theme.js +35 -0
  120. package/dist/tools/threeD.js +116 -0
  121. package/dist/tools/tilemap.js +51 -0
  122. package/dist/tools/tileset.js +226 -0
  123. package/dist/transport/authHandshake.js +43 -0
  124. package/dist/transport/bridge.js +225 -0
  125. package/dist/transport/channel.js +352 -0
  126. package/dist/transport/heartbeat.js +54 -0
  127. package/dist/transport/runtimeConnection.js +239 -0
  128. package/dist/transport/tokenPath.js +98 -0
  129. package/package.json +97 -4
@@ -0,0 +1,226 @@
1
+ import { z } from "zod";
2
+ import { registerTools } from "../registration/toolRegistry.js";
3
+ import { coercedBoolean, jsonCoerce } from "../shared/schemaCoercion.js";
4
+ import { PROJECT_FILE_PATH } from "../security/pathGuard.js";
5
+ // ── tileset group (structural) ──────────────────────────────────────
6
+ // Every tileset tool guards its res:// file_path; texture_path is NOT guarded
7
+ // (toolkit load() is res://-scoped). Declared uniformly via .map below.
8
+ const tilesetStructuralDefs = [
9
+ {
10
+ name: "tileset_create",
11
+ method: "tileset.create",
12
+ description: "Create a TileSet .tres from a texture. Generates atlas tiles " +
13
+ "with full-tile rectangular collision (physics on by default). Returns " +
14
+ "source_id + grid dims — use these with tilemap_set_cells.",
15
+ inputSchema: {
16
+ file_path: z.string().describe("Output path, e.g. 'res://resources/tileset.tres'"),
17
+ texture_path: z.string().describe("Texture for the atlas source, e.g. 'res://assets/tiles.png'"),
18
+ tile_size: z
19
+ .object({
20
+ x: z.coerce.number().int().positive(),
21
+ y: z.coerce.number().int().positive(),
22
+ })
23
+ .optional()
24
+ .describe("Tile size in pixels. Default {x:16, y:16}"),
25
+ physics: coercedBoolean().optional().describe("Add physics layer. Default true"),
26
+ collision_layer: z
27
+ .union([z.coerce.number().int(), z.array(z.union([z.coerce.number().int(), z.string()]))])
28
+ .optional()
29
+ .describe("Physics collision layer. Integer bitmask OR array of layer numbers [1,6] or names ['player','walls']. Default 1"),
30
+ collision_mask: z
31
+ .union([z.coerce.number().int(), z.array(z.union([z.coerce.number().int(), z.string()]))])
32
+ .optional()
33
+ .describe("Physics collision mask. Integer bitmask OR array of layer numbers [2,4] or names ['enemies','collectibles']. Default 1"),
34
+ },
35
+ annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: false, destructiveHint: false },
36
+ successHint: "Use tileset_add_source to add more atlas textures, then tileset_setup_layers to configure " +
37
+ "physics/terrain/navigation layers. For per-tile properties (collision polygons, terrain, navigation), " +
38
+ "use discover_tools(request: 'tileset_edit') to activate the per-tile editing group.",
39
+ },
40
+ {
41
+ name: "tileset_add_source",
42
+ method: "tileset.add_source",
43
+ description: "Add an atlas source to an existing TileSet. Auto-creates tiles for every " +
44
+ "grid cell in the texture. Returns the new source_id.",
45
+ inputSchema: {
46
+ file_path: z.string().describe("Path to existing .tres TileSet"),
47
+ texture_path: z.string().describe("Texture for the new atlas source"),
48
+ tile_size: z
49
+ .object({
50
+ x: z.coerce.number().int().positive(),
51
+ y: z.coerce.number().int().positive(),
52
+ })
53
+ .optional()
54
+ .describe("Tile size in pixels. Defaults to the TileSet's tile_size"),
55
+ },
56
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
57
+ successHint: "Source added. Use tileset_setup_layers to configure physics/terrain/navigation layers if not done yet.",
58
+ },
59
+ {
60
+ name: "tileset_remove_source",
61
+ method: "tileset.remove_source",
62
+ description: "Remove an atlas source from a TileSet. This deletes all tile data for that source.",
63
+ inputSchema: {
64
+ file_path: z.string().describe("Path to existing .tres TileSet"),
65
+ source_id: z.coerce.number().int().describe("Atlas source id to remove"),
66
+ },
67
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true },
68
+ },
69
+ {
70
+ name: "tileset_add_alternative",
71
+ method: "tileset.add_alternative",
72
+ description: "Create an alternative tile variant (flip, rotate, recolor) for a base tile.",
73
+ inputSchema: {
74
+ file_path: z.string().describe("Path to existing .tres TileSet"),
75
+ source_id: z.coerce.number().int().optional().describe("Atlas source id. Default 0"),
76
+ atlas_x: z.coerce.number().int().describe("Base tile X coordinate in the atlas"),
77
+ atlas_y: z.coerce.number().int().describe("Base tile Y coordinate in the atlas"),
78
+ flip_h: coercedBoolean().optional().describe("Flip horizontally"),
79
+ flip_v: coercedBoolean().optional().describe("Flip vertically"),
80
+ transpose: coercedBoolean().optional().describe("Transpose (swap X/Y)"),
81
+ modulate: z
82
+ .object({
83
+ r: z.coerce.number().optional(),
84
+ g: z.coerce.number().optional(),
85
+ b: z.coerce.number().optional(),
86
+ a: z.coerce.number().optional(),
87
+ })
88
+ .optional()
89
+ .describe("Color modulation {r, g, b, a} — each 0.0–1.0"),
90
+ },
91
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
92
+ successHint: "Alternative created. Use tilemap_set_cells with alternative_tile to place it.",
93
+ },
94
+ {
95
+ name: "tileset_remove_alternative",
96
+ method: "tileset.remove_alternative",
97
+ description: "Remove an alternative tile variant from a base tile.",
98
+ inputSchema: {
99
+ file_path: z.string().describe("Path to existing .tres TileSet"),
100
+ source_id: z.coerce.number().int().optional().describe("Atlas source id. Default 0"),
101
+ atlas_x: z.coerce.number().int().describe("Base tile X coordinate in the atlas"),
102
+ atlas_y: z.coerce.number().int().describe("Base tile Y coordinate in the atlas"),
103
+ alternative_id: z.coerce.number().int().describe("Alternative tile id to remove"),
104
+ },
105
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true },
106
+ },
107
+ {
108
+ name: "tileset_setup_layers",
109
+ method: "tileset.setup_layers",
110
+ description: "Configure TileSet layers: terrain sets (with named terrains), custom data layers, " +
111
+ "and physics/navigation/occlusion layer counts.",
112
+ inputSchema: {
113
+ file_path: z.string().describe("Path to existing .tres TileSet"),
114
+ terrain_sets: z
115
+ .preprocess(jsonCoerce, z.array(z.object({
116
+ mode: z
117
+ .enum(["match_corners_and_sides", "match_corners", "match_sides"])
118
+ .optional()
119
+ .describe("Terrain matching mode. Default match_corners_and_sides"),
120
+ terrains: z.array(z.string()).optional().describe("Named terrains in this set"),
121
+ })))
122
+ .optional()
123
+ .describe("Terrain sets to add"),
124
+ custom_data: z
125
+ .preprocess(jsonCoerce, z.array(z.object({
126
+ name: z.string().describe("Layer name"),
127
+ type: z
128
+ .enum(["int", "float", "bool", "string", "vector2", "vector2i", "vector3", "color"])
129
+ .optional()
130
+ .describe("Data type. Default int"),
131
+ })))
132
+ .optional()
133
+ .describe("Custom data layers to add"),
134
+ navigation_layers: z.coerce.number().int().optional().describe("Desired navigation layer count"),
135
+ occlusion_layers: z.coerce.number().int().optional().describe("Desired occlusion layer count"),
136
+ physics_layers: z.coerce.number().int().optional().describe("Desired physics layer count"),
137
+ },
138
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
139
+ successHint: "Layers configured. Use discover_tools(request: 'tileset_edit') to set per-tile properties.",
140
+ },
141
+ ];
142
+ export const tilesetStructuralTools = tilesetStructuralDefs.map((t) => ({
143
+ ...t,
144
+ pathParams: [PROJECT_FILE_PATH],
145
+ }));
146
+ // ── tileset_edit group (per-tile properties) ─────────────────────────
147
+ const tileArraySchema = z
148
+ .preprocess(jsonCoerce, z.array(z.record(z.string(), z.unknown())))
149
+ .describe("Per-tile edits: [{atlas_x, atlas_y, ...domain-specific params}]");
150
+ const tilesetEditDefs = [
151
+ {
152
+ name: "tileset_edit_physics",
153
+ method: "tileset.edit_physics",
154
+ description: "Set collision polygons on TileSet tiles. Supports shortcuts ('full', 'none', 'one_way') " +
155
+ "or custom polygon arrays [{x, y}].",
156
+ inputSchema: {
157
+ file_path: z.string().describe("Path to existing .tres TileSet"),
158
+ source_id: z.coerce.number().int().optional().describe("Atlas source id. Default 0"),
159
+ tiles: tileArraySchema.describe("Per-tile edits: [{atlas_x, atlas_y, physics_polygon: 'full'|'none'|'one_way'|[{x,y}], " +
160
+ "physics_layer?: int, one_way_collision?: bool}]"),
161
+ },
162
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
163
+ successHint: "Collision set. Other per-tile tools in this group: tileset_edit_terrain, " +
164
+ "tileset_edit_navigation, tileset_edit_visuals, tileset_edit_custom_data.",
165
+ },
166
+ {
167
+ name: "tileset_edit_terrain",
168
+ method: "tileset.edit_terrain",
169
+ description: "Assign terrain sets and peering bits to TileSet tiles for auto-tiling.",
170
+ inputSchema: {
171
+ file_path: z.string().describe("Path to existing .tres TileSet"),
172
+ source_id: z.coerce.number().int().optional().describe("Atlas source id. Default 0"),
173
+ tiles: tileArraySchema.describe("Per-tile edits: [{atlas_x, atlas_y, terrain_set: int, terrain?: int, " +
174
+ "terrain_peering?: {right?: int, bottom?: int, left?: int, top?: int, ...}}]"),
175
+ },
176
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
177
+ successHint: "Terrain assigned. Set up terrain sets first with tileset_setup_layers if needed.",
178
+ },
179
+ {
180
+ name: "tileset_edit_navigation",
181
+ method: "tileset.edit_navigation",
182
+ description: "Set navigation polygons on TileSet tiles. Supports 'full', 'none', or custom polygon arrays.",
183
+ inputSchema: {
184
+ file_path: z.string().describe("Path to existing .tres TileSet"),
185
+ source_id: z.coerce.number().int().optional().describe("Atlas source id. Default 0"),
186
+ tiles: tileArraySchema.describe("Per-tile edits: [{atlas_x, atlas_y, navigation_polygon: 'full'|'none'|[{x,y}], " + "navigation_layer?: int}]"),
187
+ },
188
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
189
+ successHint: "Navigation polygon set. Other per-tile tools: tileset_edit_physics, tileset_edit_terrain.",
190
+ },
191
+ {
192
+ name: "tileset_edit_visuals",
193
+ method: "tileset.edit_visuals",
194
+ description: "Set occlusion polygons, tile animations, and probability weights on TileSet tiles.",
195
+ inputSchema: {
196
+ file_path: z.string().describe("Path to existing .tres TileSet"),
197
+ source_id: z.coerce.number().int().optional().describe("Atlas source id. Default 0"),
198
+ tiles: tileArraySchema.describe("Per-tile edits: [{atlas_x, atlas_y, occlusion_polygon?: 'full'|'none'|[{x,y}], " +
199
+ "occlusion_layer?: int, animation?: {frame_count, columns?, frame_duration?, separation?}, " +
200
+ "probability?: number}]"),
201
+ },
202
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true },
203
+ successHint: "Visual properties set. Other per-tile tools: tileset_edit_physics, tileset_edit_terrain.",
204
+ },
205
+ {
206
+ name: "tileset_edit_custom_data",
207
+ method: "tileset.edit_custom_data",
208
+ description: "Set custom data values on TileSet tiles. Custom data layers must be " +
209
+ "configured first with tileset_setup_layers.",
210
+ inputSchema: {
211
+ file_path: z.string().describe("Path to existing .tres TileSet"),
212
+ source_id: z.coerce.number().int().optional().describe("Atlas source id. Default 0"),
213
+ tiles: tileArraySchema.describe('Per-tile edits: [{atlas_x, atlas_y, custom_data: {"layer_name": value, ...}}]'),
214
+ },
215
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
216
+ successHint: "Custom data set. Configure layers first with tileset_setup_layers if needed.",
217
+ },
218
+ ];
219
+ export const tilesetEditTools = tilesetEditDefs.map((t) => ({
220
+ ...t,
221
+ pathParams: [PROJECT_FILE_PATH],
222
+ }));
223
+ export const tilesetTools = [...tilesetStructuralTools, ...tilesetEditTools];
224
+ export function register(server, bridge, allowedTools) {
225
+ registerTools(server, bridge, tilesetTools, allowedTools);
226
+ }
@@ -0,0 +1,43 @@
1
+ import { BridgeError } from "../shared/errors.js";
2
+ import { getServerVersion } from "../shared/version.js";
3
+ const AUTH_TIMEOUT_MS = 5_000;
4
+ /**
5
+ * Send the auth handshake and wait for {"authed": true} or a close frame.
6
+ * Resolves with the full auth response.
7
+ */
8
+ export function authenticate(ws, token) {
9
+ return new Promise((resolve, reject) => {
10
+ const timer = setTimeout(() => {
11
+ cleanup();
12
+ reject(new BridgeError("AUTH_FAILED", "auth handshake timed out"));
13
+ }, AUTH_TIMEOUT_MS);
14
+ function cleanup() {
15
+ clearTimeout(timer);
16
+ ws.removeListener("message", onMessage);
17
+ ws.removeListener("close", onClose);
18
+ }
19
+ function onMessage(data) {
20
+ try {
21
+ const msg = JSON.parse(String(data));
22
+ if (msg.authed === true) {
23
+ cleanup();
24
+ resolve({
25
+ godotVersion: msg.godot_version,
26
+ toolkitVersion: msg.version,
27
+ headless: msg.headless,
28
+ });
29
+ }
30
+ }
31
+ catch {
32
+ // Not JSON — ignore, keep waiting.
33
+ }
34
+ }
35
+ function onClose(_code, reason) {
36
+ cleanup();
37
+ reject(new BridgeError("AUTH_FAILED", `server closed connection during auth: ${reason.toString()}`));
38
+ }
39
+ ws.on("message", onMessage);
40
+ ws.on("close", onClose);
41
+ ws.send(JSON.stringify({ auth: token, version: getServerVersion() }));
42
+ });
43
+ }
@@ -0,0 +1,225 @@
1
+ import { BridgeError } from "../shared/errors.js";
2
+ import { createChannel } from "./channel.js";
3
+ import { createRuntimeConnection } from "./runtimeConnection.js";
4
+ import { parseGodotVer } from "../shared/version.js";
5
+ import { lookupProject } from "../registry.js";
6
+ /**
7
+ * Build the bridge for one Godot project. Connection is lazy — the first
8
+ * {@link Bridge.call} performs the WebSocket connect + auth handshake; the runtime
9
+ * channel connects on demand when a playtest is discovered.
10
+ *
11
+ * @param editorUrl - the editor WebSocket URL (`ws://127.0.0.1:<port>`); the port
12
+ * is re-discovered from the registry on disconnect unless `explicitEditorPort` is
13
+ * set or the project path is unknown
14
+ * @param opts - see {@link BridgeOptions}: project path for registry discovery,
15
+ * static-port overrides, and the response/buffer caps pushed to the plugin after auth
16
+ * @returns the {@link Bridge}, augmented with `onNotification` (unsolicited plugin
17
+ * pushes) and `onGodotVersionKnown` (fires once on the unknown → known version
18
+ * transition, so the composition root can complete a tool surface registered
19
+ * before the editor reported its version)
20
+ */
21
+ export function createBridge(editorUrl, opts) {
22
+ const projectPath = opts?.projectPath;
23
+ let godotVersion = undefined;
24
+ // Editor display mode from the Mode-A auth ack. undefined until the editor
25
+ // authenticates — mirrors godotVersion's unknown state, and NOT pre-populated
26
+ // from the registry (no pre-auth consumer; the registry carries no display mode).
27
+ let headless = undefined;
28
+ let notificationHandler = undefined;
29
+ let versionKnownHandler = undefined;
30
+ // Record the connected Godot version. Fires the version-resolved hook on the
31
+ // unknown → known transition (and only then) so the composition root can
32
+ // complete a tool surface that was registered before the editor reported its
33
+ // version — version-gated tools (scene_close) and extension tools stranded on
34
+ // a server-before-editor cold start. Routing every version-set
35
+ // site through here keeps the unknown → known lifecycle owned in one place.
36
+ function setGodotVersion(v) {
37
+ const versionWasUnknown = godotVersion == null;
38
+ godotVersion = v;
39
+ if (versionWasUnknown && versionKnownHandler)
40
+ versionKnownHandler();
41
+ }
42
+ // Pre-populate version from registry entry (available before auth).
43
+ if (projectPath) {
44
+ const regEntry = lookupProject(projectPath);
45
+ if (regEntry) {
46
+ const regVer = regEntry.godot_version;
47
+ if (regVer != null && regVer.length > 0) {
48
+ godotVersion = regVer;
49
+ }
50
+ }
51
+ }
52
+ // After auth, push server-side response caps to the plugin so it can
53
+ // enforce them (server env var > dock UI ProjectSettings > defaults).
54
+ function sendLimitsIfConfigured(channel) {
55
+ const scriptKb = opts?.scriptReadLimitBytes ? Math.round(opts.scriptReadLimitBytes / 1024) : 0;
56
+ const wsKb = opts?.wsBufferLimitBytes ? Math.round(opts.wsBufferLimitBytes / 1024) : 0;
57
+ if (scriptKb > 0 || wsKb > 0) {
58
+ const params = {};
59
+ if (scriptKb > 0)
60
+ params.script_read_cap_kb = scriptKb;
61
+ if (wsKb > 0)
62
+ params.ws_buffer_kb = wsKb;
63
+ // Fire-and-forget — failure here is non-fatal.
64
+ void channel.call("meta.set_limits", params, 5000).catch(() => { });
65
+ }
66
+ }
67
+ const getNotificationHandler = () => notificationHandler;
68
+ let editor = createChannel(editorUrl, projectPath, ({ version, headless: h }) => {
69
+ setGodotVersion(version);
70
+ headless = h;
71
+ sendLimitsIfConfigured(editor);
72
+ }, getNotificationHandler);
73
+ let cachedEditorPort = Number(new URL(editorUrl).port);
74
+ // ── Editor-port re-discovery ─────────────────────────────────────
75
+ // When the editor channel fails with CONNECT_FAILED / DISCONNECTED,
76
+ // re-read the registry. If the port changed (plugin restarted on a
77
+ // different port), close the old channel, create a fresh one, and
78
+ // retry the call once. Skipped when the editor port is a pin
79
+ // (GODOT_MCP_EDITOR_PORT / --editor-port) or no projectPath is available for
80
+ // registry lookup. TTL prevents thrashing the registry file when the editor
81
+ // is truly unreachable.
82
+ const staticEditor = !!opts?.explicitEditorPort;
83
+ const EDITOR_REDISCOVER_TTL_MS = 5_000;
84
+ let lastRediscoverAt = 0;
85
+ async function rediscoverEditor() {
86
+ if (staticEditor || !projectPath)
87
+ return false;
88
+ const now = Date.now();
89
+ if (now - lastRediscoverAt < EDITOR_REDISCOVER_TTL_MS)
90
+ return false;
91
+ lastRediscoverAt = now;
92
+ const entry = lookupProject(projectPath);
93
+ if (!entry)
94
+ return false;
95
+ if (entry.port === cachedEditorPort)
96
+ return false;
97
+ const oldPort = cachedEditorPort;
98
+ cachedEditorPort = entry.port;
99
+ await editor.close();
100
+ editor = createChannel(`ws://127.0.0.1:${cachedEditorPort}`, projectPath, ({ version, headless: h }) => {
101
+ setGodotVersion(version);
102
+ headless = h;
103
+ }, getNotificationHandler);
104
+ process.stderr.write(`[bridge] editor port changed ${oldPort} → ${cachedEditorPort}\n`);
105
+ return true;
106
+ }
107
+ // ── Fail-fast desync cross-check (pinned editor) ─────────────────
108
+ // A pinned editor port skips re-discovery, so a stale or unsynced pin (the
109
+ // classic ".mcp.json sets the pin for the server only, the editor is launched
110
+ // separately" case) would otherwise surface only as a generic error. Two
111
+ // failure shapes reach here: a connection-level loss (nothing on the pinned
112
+ // port), and a FAILED AUTH HANDSHAKE — a foreign WebSocket server on the
113
+ // pinned port passes the TCP+WS upgrade and then stalls or drops the auth, so
114
+ // the desync surfaces as AUTH_FAILED, never CONNECT_FAILED. For either, read
115
+ // the registry ONCE — the toolkit publishes its real bound port even in pin
116
+ // mode — and synthesize a precise, actionable error naming the mismatch,
117
+ // PRESERVING the original transport code (retry semantics stay intact).
118
+ // Registry I/O stays on the FAILURE path only; the healthy path never reads
119
+ // it. Returns undefined when there is nothing to add — no projectPath for
120
+ // ground truth, or an auth failure with the registry AGREEING on the port
121
+ // (the real editor answered and rejected auth: a token problem, not a port
122
+ // desync) — leaving the original error to propagate.
123
+ function pinnedEditorDesyncError(original) {
124
+ if (!projectPath)
125
+ return undefined;
126
+ const authFailure = original.code === "AUTH_FAILED";
127
+ const entry = lookupProject(projectPath);
128
+ if (!entry) {
129
+ return new BridgeError(original.code, authFailure
130
+ ? `auth failed on pinned editor port ${cachedEditorPort} and no live editor is registered for this ` +
131
+ `project — something else may be occupying the pinned port; launch the Godot editor with ` +
132
+ `GODOT_MCP_EDITOR_PORT=${cachedEditorPort} so it binds the same port, or unset the pin to use ` +
133
+ `registry discovery`
134
+ : `editor pinned to port ${cachedEditorPort}, but no live editor is registered for this project — ` +
135
+ `launch the Godot editor (set GODOT_MCP_EDITOR_PORT=${cachedEditorPort} so it binds the same port), ` +
136
+ `or unset the pin to use registry discovery`);
137
+ }
138
+ if (entry.port !== cachedEditorPort) {
139
+ return new BridgeError(original.code, authFailure
140
+ ? `auth failed on pinned editor port ${cachedEditorPort}, but the live editor for this project is ` +
141
+ `listening on ${entry.port} — something else may be occupying the pinned port; launch the editor ` +
142
+ `with the same GODOT_MCP_EDITOR_PORT, or unset the pin to use discovery`
143
+ : `editor pinned to port ${cachedEditorPort}, but the live editor for this project is listening on ` +
144
+ `${entry.port} — launch the editor with the same GODOT_MCP_EDITOR_PORT, or unset the pin to use discovery`);
145
+ }
146
+ // Registry agrees with the pin. An auth failure here came from the real
147
+ // editor (token trouble, not a desync) — add nothing. A connection loss
148
+ // means the editor is down, restarting, or crashed — say so plainly.
149
+ if (authFailure)
150
+ return undefined;
151
+ return new BridgeError(original.code, `editor pinned to port ${cachedEditorPort} matches the registry, but the port is not accepting ` +
152
+ `connections — the editor may be starting or has stopped`);
153
+ }
154
+ // ── Runtime connection ───────────────────────────────────────────
155
+ // The playtest runtime-connection aggregate — discovery, the registry
156
+ // watcher, port-waiters, and the frozen-game heartbeat — lives in
157
+ // runtimeConnection.ts. The composition root builds one and delegates the
158
+ // runtime facade methods (callRuntime / waitForRuntimeConnection /
159
+ // clearRuntime) to it. It touches neither version state nor notifications.
160
+ const runtime = createRuntimeConnection({ projectPath, explicitRuntimePort: opts?.explicitRuntimePort });
161
+ return {
162
+ async call(method, params, timeoutMs, signal) {
163
+ try {
164
+ return await editor.call(method, params, timeoutMs, signal);
165
+ }
166
+ catch (err) {
167
+ if (err instanceof BridgeError) {
168
+ const connectionLoss = err.code === "CONNECT_FAILED" || err.code === "DISCONNECTED";
169
+ if (staticEditor) {
170
+ // Pinned: no re-discovery. A connection loss OR a failed auth
171
+ // handshake (a foreign server on the pinned port) gets the desync
172
+ // diagnosis (one registry read, on the failure path only).
173
+ if (connectionLoss || err.code === "AUTH_FAILED") {
174
+ const desync = pinnedEditorDesyncError(err);
175
+ if (desync)
176
+ throw desync;
177
+ }
178
+ }
179
+ else if (connectionLoss) {
180
+ // On editor connection failure, re-read the registry. If the port
181
+ // changed, retry once against the new channel.
182
+ const changed = await rediscoverEditor();
183
+ if (changed)
184
+ return editor.call(method, params, timeoutMs, signal);
185
+ }
186
+ }
187
+ throw err;
188
+ }
189
+ },
190
+ callRuntime(method, params, timeoutMs, signal) {
191
+ return runtime.callRuntime(method, params, timeoutMs, signal);
192
+ },
193
+ async close() {
194
+ // Runtime first: tearing it down resolves any outstanding port-waiters
195
+ // and stops the heartbeat/watcher before either socket closes. The editor
196
+ // and runtime are independent sockets, so their relative close order is
197
+ // unobservable.
198
+ await runtime.close();
199
+ await editor.close();
200
+ },
201
+ getGodotVersionString() {
202
+ return godotVersion;
203
+ },
204
+ getGodotVersion() {
205
+ if (!godotVersion)
206
+ return undefined;
207
+ return parseGodotVer(godotVersion);
208
+ },
209
+ isHeadless() {
210
+ return headless;
211
+ },
212
+ waitForRuntimeConnection(timeoutMs) {
213
+ return runtime.waitForRuntimeConnection(timeoutMs);
214
+ },
215
+ clearRuntime() {
216
+ runtime.clearRuntime();
217
+ },
218
+ onNotification(handler) {
219
+ notificationHandler = handler;
220
+ },
221
+ onGodotVersionKnown(handler) {
222
+ versionKnownHandler = handler;
223
+ },
224
+ };
225
+ }