godot-agent-tools-mcp 0.1.0 → 0.2.1

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 (2) hide show
  1. package/package.json +1 -1
  2. package/server.mjs +470 -60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "godot-agent-tools-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "MCP server that bridges coding agents to the Godot Agent Tools editor plugin.",
6
6
  "main": "server.mjs",
package/server.mjs CHANGED
@@ -91,6 +91,21 @@ const TOOLS = [
91
91
  properties: { node_path: { type: "string" } },
92
92
  },
93
93
  },
94
+ {
95
+ name: "scene_duplicate_node",
96
+ method: "scene.duplicate_node",
97
+ description:
98
+ "Clone a node (with descendants) in the currently-edited scene. Owner is set recursively so the duplicated subtree serializes. Defaults to adding under the source's parent; override with parent_path.",
99
+ inputSchema: {
100
+ type: "object",
101
+ required: ["node_path"],
102
+ properties: {
103
+ node_path: { type: "string", description: "Source node to clone." },
104
+ new_name: { type: "string", description: "Name for the copy; defaults to auto-generated '<name>2' style." },
105
+ parent_path: { type: "string", description: "Destination parent; defaults to the source's parent." },
106
+ },
107
+ },
108
+ },
94
109
  {
95
110
  name: "scene_reparent",
96
111
  method: "scene.reparent",
@@ -110,7 +125,22 @@ const TOOLS = [
110
125
  name: "scene_set_property",
111
126
  method: "scene.set_property",
112
127
  description:
113
- "Set a property on a node. Coerces JSON to Godot types: Vector2/3 from [x,y(,z)], Color from [r,g,b(,a)] or '#hex', NodePath from string.",
128
+ "Set a property on a node. JSON-to-Godot coercion: " +
129
+ "primitives (bool/int/float/String/StringName) pass through; " +
130
+ "NodePath from a string; " +
131
+ "Vector2/2i/3/3i/4/4i from [x, y(, z(, w))]; " +
132
+ "Rect2/2i from [x, y, width, height]; " +
133
+ "Quaternion from [x, y, z, w]; " +
134
+ "Color from [r, g, b(, a)] or '#rrggbb(aa)'; " +
135
+ "Transform2D from {origin: [x,y], rotation: radians, scale: [x,y], skew?}; " +
136
+ "Transform3D from {origin: [x,y,z], rotation: [x,y,z] (euler rad), scale: [x,y,z]}; " +
137
+ "Basis from {rotation, scale} (same shape as Transform3D minus origin); " +
138
+ "AABB from {position: [x,y,z], size: [x,y,z]}; " +
139
+ "Plane from {normal: [x,y,z], d: float}; " +
140
+ "Resource-typed properties (e.g. a CollisionShape2D's 'shape') auto-load from 'res://...' or 'uid://...' path strings; " +
141
+ "Packed{String,Int32,Int64,Float32,Float64,Vector2,Vector3,Color}Array from plain JSON arrays (element-wise coerced); " +
142
+ "other types pass through as-is. " +
143
+ "Returns an error if Godot drops the assignment (e.g. type mismatch) instead of echoing a misleading null.",
114
144
  inputSchema: {
115
145
  type: "object",
116
146
  required: ["node_path", "property"],
@@ -121,6 +151,70 @@ const TOOLS = [
121
151
  },
122
152
  },
123
153
  },
154
+ {
155
+ name: "scene_get_property",
156
+ method: "scene.get_property",
157
+ description:
158
+ "Read a property from a node in the currently-edited scene. Mirror of scene_set_property — useful for 'what's the current value?' queries without dumping the whole tree.",
159
+ inputSchema: {
160
+ type: "object",
161
+ required: ["node_path", "property"],
162
+ properties: {
163
+ node_path: { type: "string" },
164
+ property: { type: "string" },
165
+ },
166
+ },
167
+ },
168
+ {
169
+ name: "scene_build_tree",
170
+ method: "scene.build_tree",
171
+ description:
172
+ "Build a subtree in the currently-edited scene in one call — recursive spec instead of dozens of scene_add_node + scene_set_property + script_attach round trips. " +
173
+ "Each tree entry: {type: 'ClassName' (required), name?: string, properties?: {propname: value, ...}, script?: 'res://...', children?: [entry, ...]}. " +
174
+ "Properties are coerced via the same rules as scene_set_property (Vectors from arrays, Resources auto-loaded from res:// paths, Transforms from {origin,rotation,scale}, etc.). " +
175
+ "Script is attached before properties so script-exported vars are settable in the same call. " +
176
+ "Atomic: if any entry fails (unknown type, missing property, coercion error, read-only slot, silent-null assignment), every node created during this call is rolled back — the scene is left in its pre-call state.",
177
+ inputSchema: {
178
+ type: "object",
179
+ required: ["nodes"],
180
+ properties: {
181
+ parent_path: { type: "string", default: ".", description: "Where to attach the new subtree. '.' = scene root." },
182
+ nodes: {
183
+ type: "array",
184
+ description: "Top-level tree entries (each may have arbitrarily nested children).",
185
+ items: {
186
+ type: "object",
187
+ required: ["type"],
188
+ properties: {
189
+ type: { type: "string", description: "Godot class name (e.g. 'PanelContainer', 'Button')." },
190
+ name: { type: "string" },
191
+ script: { type: "string", description: "Path to a .gd to attach before properties are applied." },
192
+ properties: { type: "object", additionalProperties: true, description: "Property → value pairs; values use the scene_set_property coercion rules." },
193
+ children: { type: "array", description: "Nested entries using the same shape as this one." },
194
+ },
195
+ },
196
+ },
197
+ },
198
+ },
199
+ },
200
+ {
201
+ name: "scene_call_method",
202
+ method: "scene.call_method",
203
+ description:
204
+ "Invoke a method on a node in the currently-edited scene (e.g. a custom helper, or Godot built-ins like 'queue_free', 'add_to_group'). Args are coerced against the method's declared parameter types using the same rules as scene_set_property — including 'res://...' → Resource auto-load. Return value is JSON-native. Useful when properties alone can't express what you need — e.g. calling a script method with multiple args.",
205
+ inputSchema: {
206
+ type: "object",
207
+ required: ["node_path", "method"],
208
+ properties: {
209
+ node_path: { type: "string" },
210
+ method: { type: "string" },
211
+ args: {
212
+ type: "array",
213
+ description: "Method arguments in order. Each is coerced based on the method's declared parameter type.",
214
+ },
215
+ },
216
+ },
217
+ },
124
218
  {
125
219
  name: "scene_open",
126
220
  method: "scene.open",
@@ -135,10 +229,11 @@ const TOOLS = [
135
229
  name: "scene_save",
136
230
  method: "scene.save",
137
231
  description:
138
- "Save the currently-edited scene. Pass 'path' to save-as (rebinds the scene to that path).",
232
+ "Save the currently-edited scene. Pass 'path' to save-as (rebinds the scene to that path). " +
233
+ "REQUIRED for fresh scenes: if the scene has never been saved (no backing file), the tool returns an error rather than triggering Godot's native Save-As dialog (which would block the editor waiting for a human click). Always pass 'path' when you built the scene from scratch via scene.build_tree / scene.add_node without going through scene.new.",
139
234
  inputSchema: {
140
235
  type: "object",
141
- properties: { path: { type: "string", description: "Optional save-as target." } },
236
+ properties: { path: { type: "string", description: "Save-as target, required when the scene has no existing file." } },
142
237
  },
143
238
  },
144
239
  {
@@ -147,6 +242,21 @@ const TOOLS = [
147
242
  description: "Describe the currently-edited scene, or return {open: false} if none is open.",
148
243
  inputSchema: { type: "object", properties: {} },
149
244
  },
245
+ {
246
+ name: "scene_capture_screenshot",
247
+ method: "scene.capture_screenshot",
248
+ description:
249
+ "Save a PNG of the editor viewport for the open scene (2D or 3D selected automatically). Clean capture — no editor grid/gizmos. Empty scenes render as the viewport background color. Default output: res://.godot/agent_tools/<scene-name>.png.",
250
+ inputSchema: {
251
+ type: "object",
252
+ properties: {
253
+ output: {
254
+ type: "string",
255
+ description: "Optional output path (res://...). Defaults to res://.godot/agent_tools/<scene>.png.",
256
+ },
257
+ },
258
+ },
259
+ },
150
260
  {
151
261
  name: "signal_connect",
152
262
  method: "signal.connect",
@@ -246,7 +356,8 @@ const TOOLS = [
246
356
  {
247
357
  name: "resource_set_property",
248
358
  method: "resource.set_property",
249
- description: "Load a .tres, set one property, and save. Uses the same JSON-to-Godot coercion as scene_set_property.",
359
+ description:
360
+ "Load a .tres, set one property, and save. Same JSON-to-Godot coercion as scene_set_property: primitives, NodePath, Vector2/2i/3/3i/4/4i, Rect2/2i, Quaternion, Color, Transform2D/3D, Basis, AABB, Plane, Resource auto-load from 'res://'/'uid://' paths, and all Packed*Array variants. Errors on silently-dropped assignments.",
250
361
  inputSchema: {
251
362
  type: "object",
252
363
  required: ["path", "property"],
@@ -257,6 +368,22 @@ const TOOLS = [
257
368
  },
258
369
  },
259
370
  },
371
+ {
372
+ name: "resource_call_method",
373
+ method: "resource.call_method",
374
+ description:
375
+ "Load a .tres, invoke a method on it, save, return the method's result. Rounds out what set_property can't express — e.g. StyleBoxFlat.set_border_width_all(4), set_corner_radius_all(14), Curve.add_point(...). Args coerce via the same rules as resource_set_property. Pass save:false to call without persisting (read-only method calls).",
376
+ inputSchema: {
377
+ type: "object",
378
+ required: ["path", "method"],
379
+ properties: {
380
+ path: { type: "string" },
381
+ method: { type: "string" },
382
+ args: { type: "array", description: "Method arguments in order, coerced per the method's parameter types." },
383
+ save: { type: "boolean", default: true, description: "Save the resource after the call. Set false for read-only method calls." },
384
+ },
385
+ },
386
+ },
260
387
  {
261
388
  name: "refs_validate_project",
262
389
  method: "refs.validate_project",
@@ -281,7 +408,7 @@ const TOOLS = [
281
408
  name: "refs_rename",
282
409
  method: "refs.rename",
283
410
  description:
284
- "Move a file and rewrite every path-form reference to it. The .uid and .import sidecars are moved too so UID-form references keep resolving. Supports dry_run to preview changes without touching disk. Writes to multiple files — review dry_run output first for anything non-trivial.",
411
+ "Move a file and rewrite every path-form reference to it. The .uid and .import sidecars are moved too so UID-form references keep resolving. Supports dry_run to preview changes without touching disk.",
285
412
  inputSchema: {
286
413
  type: "object",
287
414
  required: ["from", "to"],
@@ -293,6 +420,21 @@ const TOOLS = [
293
420
  },
294
421
  },
295
422
  },
423
+ {
424
+ name: "refs_rename_class",
425
+ method: "refs.rename_class",
426
+ description:
427
+ "Rename 'class_name X' to 'class_name Y' across the project — updates the defining script and every word-boundary reference in .gd / .tscn / .tres files. Best-effort (won't distinguish an X that happens to be a local variable); use dry_run first.",
428
+ inputSchema: {
429
+ type: "object",
430
+ required: ["from", "to"],
431
+ properties: {
432
+ from: { type: "string" },
433
+ to: { type: "string" },
434
+ dry_run: { type: "boolean", default: false },
435
+ },
436
+ },
437
+ },
296
438
  {
297
439
  name: "project_get_setting",
298
440
  method: "project.get_setting",
@@ -354,6 +496,12 @@ const TOOLS = [
354
496
  "Trigger an editor filesystem rescan. Call this after creating/moving/deleting files via tools that bypass the editor, so load() and the FileSystem dock see the changes.",
355
497
  inputSchema: { type: "object", properties: {} },
356
498
  },
499
+ {
500
+ name: "editor_save_all_scenes",
501
+ method: "editor.save_all_scenes",
502
+ description: "Save every currently-open scene in the editor.",
503
+ inputSchema: { type: "object", properties: {} },
504
+ },
357
505
  {
358
506
  name: "docs_class_ref",
359
507
  method: "docs.class_ref",
@@ -385,7 +533,11 @@ const TOOLS = [
385
533
  name: "input_map_add_event",
386
534
  method: "input_map.add_event",
387
535
  description:
388
- "Attach an input event to an existing action. Event shapes: {type:'key', keycode:'A'|'Space'|...}, {type:'mouse_button', button_index:1|2|3}, {type:'joy_button', button_index:0..}.",
536
+ "Attach an input event to an existing action. Every event accepts an optional 'device' field (default -1 = all devices; set 0, 1, etc. for local-multiplayer device-specific bindings). Event shapes: " +
537
+ "{type:'key', keycode:'A'|'Space'|...}; " +
538
+ "{type:'mouse_button', button_index:1|2|3}; " +
539
+ "{type:'joy_button', button_index:0..}; " +
540
+ "{type:'joy_motion', axis:0..5 or 'left_x'|'left_y'|'right_x'|'right_y'|'trigger_left'|'trigger_right', axis_value:-1.0..1.0} (axis_value sign picks direction).",
389
541
  inputSchema: {
390
542
  type: "object",
391
543
  required: ["action", "event"],
@@ -395,10 +547,13 @@ const TOOLS = [
395
547
  type: "object",
396
548
  required: ["type"],
397
549
  properties: {
398
- type: { type: "string", enum: ["key", "mouse_button", "joy_button"] },
550
+ type: { type: "string", enum: ["key", "mouse_button", "joy_button", "joy_motion"] },
551
+ device: { type: "integer", default: -1, description: "-1 = all devices (default); 0, 1, etc. bind a specific controller for local multiplayer." },
399
552
  keycode: { description: "For type='key': 'A', 'Space', 'F1', or int keycode." },
400
- button_index: { type: "integer", description: "For mouse/joy button events." },
401
553
  physical: { type: "boolean", default: true, description: "For type='key': use physical keycode (recommended)." },
554
+ button_index: { type: "integer", description: "For mouse/joy button events." },
555
+ axis: { description: "For type='joy_motion': int 0..5 or 'left_x'|'left_y'|'right_x'|'right_y'|'trigger_left'|'trigger_right'." },
556
+ axis_value: { type: "number", description: "For type='joy_motion': -1.0 to 1.0; sign selects direction that triggers the action." },
402
557
  },
403
558
  },
404
559
  },
@@ -416,6 +571,20 @@ const TOOLS = [
416
571
  },
417
572
  },
418
573
  },
574
+ {
575
+ name: "input_map_remove_event",
576
+ method: "input_map.remove_event",
577
+ description:
578
+ "Remove an event from an action by index. Call input_map_list first to see indices (events are listed in add order).",
579
+ inputSchema: {
580
+ type: "object",
581
+ required: ["action", "event_index"],
582
+ properties: {
583
+ action: { type: "string" },
584
+ event_index: { type: "integer" },
585
+ },
586
+ },
587
+ },
419
588
  {
420
589
  name: "input_map_remove_action",
421
590
  method: "input_map.remove_action",
@@ -430,18 +599,189 @@ const TOOLS = [
430
599
  name: "run_scene_headless",
431
600
  method: "run.scene_headless",
432
601
  description:
433
- "Run a scene in a headless child Godot process and return exit code plus combined stdout/stderr. BLOCKS the editor for the duration — use small quit_after_seconds (1-3) for smoke tests.",
602
+ "Run a scene in a child Godot process with structured output. " +
603
+ "MODES: BARE (default) runs --headless — fast, no window, good for 'does _ready not crash' checks. " +
604
+ "DRIVEN (anything beyond path + quit_after_seconds) runs under a wrapper driver that can inject scripted input, capture screenshots at multiple frames, dump final scene state as JSON, and use a deterministic RNG seed. " +
605
+ "SCREENSHOTS: when screenshot(s) requested the subprocess drops --headless and runs with a real (offscreen) window because Godot 4.6's headless mode uses a dummy renderer. Expect a brief window flash. " +
606
+ "STRUCTURED RESULTS: tool parses stdout for ERROR: / USER ERROR: / SCRIPT ERROR: / WARNING: / USER WARNING: lines and returns them as result.errors and result.warnings arrays so the agent doesn't have to regex the raw output. state_dump:true adds result.final_state with the scene tree + common properties. " +
607
+ "Event types for input_script: " +
608
+ "{frame, type: 'action_tap', action}; " +
609
+ "{frame, type: 'action_press', action, strength?}; " +
610
+ "{frame, type: 'action_release', action}; " +
611
+ "{frame, type: 'key', keycode: 'Space'|int, pressed?: true}; " +
612
+ "{frame, type: 'mouse_click', position: [x, y], button?: 1}; " +
613
+ "{frame, type: 'mouse_motion', position: [x, y]}. " +
614
+ "BLOCKS the editor for the duration — use small quit_after_seconds (1-5).",
434
615
  inputSchema: {
435
616
  type: "object",
436
617
  required: ["path"],
437
618
  properties: {
438
619
  path: { type: "string", description: "Scene to run, e.g. 'res://Main.tscn'." },
439
- quit_after_seconds: { type: "number", default: 2, description: "Converted to frames assuming 60 fps." },
620
+ quit_after_seconds: { type: "number", default: 2, description: "Converted to frames assuming 60 fps. In DRIVEN mode this is also the quit_frame the driver targets." },
440
621
  extra_args: {
441
622
  type: "array",
442
623
  items: { type: "string" },
443
624
  description: "Additional CLI args passed to the child godot process.",
444
625
  },
626
+ input_script: {
627
+ type: "array",
628
+ description: "Optional: enters DRIVEN mode. Array of event specs keyed by frame. See description for event type shapes.",
629
+ items: {
630
+ type: "object",
631
+ required: ["frame", "type"],
632
+ properties: {
633
+ frame: { type: "integer", description: "0-based frame to fire on." },
634
+ type: { type: "string", enum: ["action_press", "action_release", "action_tap", "key", "mouse_click", "mouse_motion"] },
635
+ action: { type: "string" },
636
+ strength: { type: "number", default: 1.0 },
637
+ keycode: { description: "For type='key': 'A', 'Space', 'F1', or int keycode." },
638
+ pressed: { type: "boolean" },
639
+ button: { type: "integer", description: "Mouse button (1=left, 2=right, 3=middle)." },
640
+ position: { type: "array", description: "[x, y] viewport coordinates." },
641
+ },
642
+ },
643
+ },
644
+ screenshot: {
645
+ type: "string",
646
+ description: "Shorthand: one PNG saved at the final frame. Equivalent to screenshots:[{frame: quit_frame, path: ...}]. Triggers offscreen-windowed subprocess (brief window flash).",
647
+ },
648
+ screenshots: {
649
+ type: "array",
650
+ description: "Capture PNGs at multiple specific frames during the run — useful for verifying state transitions (spawn at frame 30, mid-animation at 60, final at 120).",
651
+ items: {
652
+ type: "object",
653
+ required: ["frame", "path"],
654
+ properties: {
655
+ frame: { type: "integer" },
656
+ path: { type: "string" },
657
+ },
658
+ },
659
+ },
660
+ resolution: {
661
+ type: "string",
662
+ default: "320x240",
663
+ description: "Window resolution 'WxH' when screenshots are captured. Default is tiny to minimize offscreen footprint; bump to 1280x720 or similar for UI verification.",
664
+ },
665
+ state_dump: {
666
+ type: "boolean",
667
+ default: false,
668
+ description: "When true, driver writes a JSON snapshot of the final scene tree (name, class, node_path, script, children, plus common props like visible/position/text/value/modulate). Returned as result.final_state. Lets agents verify end state programmatically instead of eyeballing the screenshot.",
669
+ },
670
+ seed: {
671
+ type: "integer",
672
+ description: "Optional RNG seed. Set before the target scene is instanced so randi/randf are reproducible. Useful for deterministic tests.",
673
+ },
674
+ },
675
+ },
676
+ },
677
+ {
678
+ name: "user_fs_read",
679
+ method: "user_fs.read",
680
+ description:
681
+ "Read a text file from the user:// data directory — where games persist save files, custom-level JSON, settings, etc. Separate from fs_list (which is res://-only) because user:// is runtime-written state.",
682
+ inputSchema: {
683
+ type: "object",
684
+ required: ["path"],
685
+ properties: {
686
+ path: { type: "string", description: "Must begin with 'user://'." },
687
+ },
688
+ },
689
+ },
690
+ {
691
+ name: "user_fs_list",
692
+ method: "user_fs.list",
693
+ description:
694
+ "List files and subdirectories under a user:// directory. Optionally recursive.",
695
+ inputSchema: {
696
+ type: "object",
697
+ properties: {
698
+ dir: { type: "string", default: "user://", description: "Must begin with 'user://'." },
699
+ recursive: { type: "boolean", default: false },
700
+ },
701
+ },
702
+ },
703
+ {
704
+ name: "fs_list",
705
+ method: "fs.list",
706
+ description:
707
+ "Enumerate project files by type with optional glob filter. Types: all | scene | script | resource | shader | image | audio. Skips the agent_tools addon by default.",
708
+ inputSchema: {
709
+ type: "object",
710
+ properties: {
711
+ type: {
712
+ type: "string",
713
+ enum: ["all", "scene", "script", "resource", "shader", "image", "audio"],
714
+ default: "all",
715
+ },
716
+ glob: { type: "string", description: "Optional case-insensitive glob, e.g. 'res://scenes/**/*.tscn'." },
717
+ include_addons: { type: "boolean", default: false },
718
+ },
719
+ },
720
+ },
721
+ {
722
+ name: "animation_list",
723
+ method: "animation.list",
724
+ description: "List animations on an AnimationPlayer node with their tracks.",
725
+ inputSchema: {
726
+ type: "object",
727
+ required: ["node_path"],
728
+ properties: { node_path: { type: "string" } },
729
+ },
730
+ },
731
+ {
732
+ name: "animation_add_animation",
733
+ method: "animation.add_animation",
734
+ description: "Create an empty animation in the player's library. Use animation_add_value_track to populate it.",
735
+ inputSchema: {
736
+ type: "object",
737
+ required: ["node_path", "name"],
738
+ properties: {
739
+ node_path: { type: "string" },
740
+ name: { type: "string" },
741
+ length: { type: "number", default: 1.0 },
742
+ library: { type: "string", default: "", description: "Library name; '' is the default library." },
743
+ },
744
+ },
745
+ },
746
+ {
747
+ name: "animation_remove_animation",
748
+ method: "animation.remove_animation",
749
+ description: "Delete an animation from an AnimationPlayer.",
750
+ inputSchema: {
751
+ type: "object",
752
+ required: ["node_path", "name"],
753
+ properties: {
754
+ node_path: { type: "string" },
755
+ name: { type: "string" },
756
+ library: { type: "string", default: "" },
757
+ },
758
+ },
759
+ },
760
+ {
761
+ name: "animation_add_value_track",
762
+ method: "animation.add_value_track",
763
+ description:
764
+ "Add a value track to an animation that animates a property on a target node. target_node is resolved relative to the AnimationPlayer's root. Auto-extends the animation's length if keyframes go past it.",
765
+ inputSchema: {
766
+ type: "object",
767
+ required: ["node_path", "animation", "target_node", "property", "keyframes"],
768
+ properties: {
769
+ node_path: { type: "string", description: "AnimationPlayer node path." },
770
+ animation: { type: "string", description: "Animation name — use 'lib/anim' for non-default libraries." },
771
+ target_node: { type: "string", description: "NodePath to animated node, relative to the player's root." },
772
+ property: { type: "string", description: "Property name to animate." },
773
+ keyframes: {
774
+ type: "array",
775
+ items: {
776
+ type: "object",
777
+ required: ["time", "value"],
778
+ properties: {
779
+ time: { type: "number" },
780
+ value: { description: "Property value at this time." },
781
+ easing: { type: "number", default: 1.0, description: "Transition curve; 1.0 = linear." },
782
+ },
783
+ },
784
+ },
445
785
  },
446
786
  },
447
787
  },
@@ -449,72 +789,142 @@ const TOOLS = [
449
789
 
450
790
  const BY_NAME = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
451
791
 
452
- function callGodot(method, params) {
453
- return new Promise((resolve, reject) => {
454
- const socket = new net.Socket();
455
- let buffer = "";
456
- let settled = false;
792
+ // Persistent Godot TCP client. One socket reused across tool calls; outstanding
793
+ // requests are tracked by id so multiple in-flight calls don't interleave data.
794
+ class GodotClient {
795
+ constructor(host, port) {
796
+ this.host = host;
797
+ this.port = port;
798
+ this.socket = null;
799
+ this.buffer = "";
800
+ this.pending = new Map(); // id -> { resolve, reject, timer }
801
+ this.nextId = 0;
802
+ this.connecting = null;
803
+ }
457
804
 
458
- const settle = (fn, arg) => {
459
- if (settled) return;
460
- settled = true;
461
- clearTimeout(timer);
462
- try {
463
- socket.end();
464
- } catch {}
465
- fn(arg);
466
- };
805
+ async _ensureConnected() {
806
+ if (this.socket && !this.socket.destroyed) return;
807
+ if (this.connecting) return this.connecting;
467
808
 
468
- const timer = setTimeout(
469
- () => settle(reject, new Error(`Godot tool '${method}' timed out after ${TIMEOUT_MS}ms`)),
470
- TIMEOUT_MS
471
- );
809
+ this.connecting = new Promise((resolve, reject) => {
810
+ const s = new net.Socket();
811
+ s.setEncoding("utf8");
812
+ s.setNoDelay(true);
472
813
 
473
- socket.setEncoding("utf8");
814
+ const onConnect = () => {
815
+ s.removeListener("error", onErrorPreConnect);
816
+ this.socket = s;
817
+ this.connecting = null;
818
+ resolve();
819
+ };
474
820
 
475
- socket.on("data", (data) => {
476
- buffer += data;
477
- const nl = buffer.indexOf("\n");
478
- if (nl < 0) return;
479
- const line = buffer.slice(0, nl);
480
- try {
481
- const resp = JSON.parse(line);
482
- if (resp.error) {
483
- settle(
484
- reject,
485
- new Error(`Godot error ${resp.error.code}: ${resp.error.message}`)
486
- );
821
+ const onErrorPreConnect = (e) => {
822
+ this.connecting = null;
823
+ if (e.code === "ECONNREFUSED") {
824
+ reject(new Error(
825
+ `Godot editor not reachable on ${this.host}:${this.port}. ` +
826
+ `Open the project in the Godot editor with the 'Agent Tools' plugin enabled.`
827
+ ));
487
828
  } else {
488
- settle(resolve, resp.result);
829
+ reject(e);
830
+ }
831
+ };
832
+
833
+ s.once("connect", onConnect);
834
+ s.once("error", onErrorPreConnect);
835
+
836
+ s.on("data", (data) => this._onData(data));
837
+ s.on("error", (e) => this._onFatalError(e));
838
+ s.on("close", () => this._onClose());
839
+
840
+ s.connect(this.port, this.host);
841
+ });
842
+
843
+ return this.connecting;
844
+ }
845
+
846
+ call(method, params) {
847
+ const id = ++this.nextId;
848
+ return new Promise(async (resolve, reject) => {
849
+ const timer = setTimeout(() => {
850
+ if (this.pending.has(id)) {
851
+ this.pending.delete(id);
852
+ reject(new Error(`Godot tool '${method}' timed out after ${TIMEOUT_MS}ms`));
489
853
  }
854
+ }, TIMEOUT_MS);
855
+
856
+ this.pending.set(id, { resolve, reject, timer });
857
+
858
+ try {
859
+ await this._ensureConnected();
490
860
  } catch (e) {
491
- settle(reject, e);
861
+ clearTimeout(timer);
862
+ this.pending.delete(id);
863
+ reject(e);
864
+ return;
492
865
  }
866
+
867
+ const line = JSON.stringify({ id, method, params: params || {} }) + "\n";
868
+ this.socket.write(line);
493
869
  });
870
+ }
871
+
872
+ _onData(data) {
873
+ this.buffer += data;
874
+ while (true) {
875
+ const nl = this.buffer.indexOf("\n");
876
+ if (nl < 0) break;
877
+ const line = this.buffer.slice(0, nl);
878
+ this.buffer = this.buffer.slice(nl + 1);
879
+ if (!line) continue;
494
880
 
495
- socket.on("error", (e) => {
496
- if (e.code === "ECONNREFUSED") {
497
- settle(
498
- reject,
499
- new Error(
500
- `Godot editor not reachable on ${HOST}:${PORT}. Open the project in the Godot editor with the 'Agent Tools' plugin enabled.`
501
- )
502
- );
881
+ let msg;
882
+ try {
883
+ msg = JSON.parse(line);
884
+ } catch {
885
+ continue; // ignore malformed lines — shouldn't happen
886
+ }
887
+
888
+ const pending = this.pending.get(msg.id);
889
+ if (!pending) continue;
890
+ this.pending.delete(msg.id);
891
+ clearTimeout(pending.timer);
892
+
893
+ if (msg.error) {
894
+ pending.reject(new Error(`Godot error ${msg.error.code}: ${msg.error.message}`));
503
895
  } else {
504
- settle(reject, e);
896
+ pending.resolve(msg.result);
505
897
  }
506
- });
898
+ }
899
+ }
507
900
 
508
- socket.on("end", () => settle(reject, new Error("Godot closed the connection before responding")));
901
+ _onFatalError(e) {
902
+ for (const { reject, timer } of this.pending.values()) {
903
+ clearTimeout(timer);
904
+ reject(e);
905
+ }
906
+ this.pending.clear();
907
+ if (this.socket) {
908
+ this.socket.destroy();
909
+ this.socket = null;
910
+ }
911
+ }
509
912
 
510
- socket.connect(PORT, HOST, () => {
511
- socket.write(JSON.stringify({ id: 1, method, params: params || {} }) + "\n");
512
- });
513
- });
913
+ _onClose() {
914
+ for (const { reject, timer } of this.pending.values()) {
915
+ clearTimeout(timer);
916
+ reject(new Error("Godot closed the connection"));
917
+ }
918
+ this.pending.clear();
919
+ this.socket = null;
920
+ this.buffer = "";
921
+ }
514
922
  }
515
923
 
924
+ const client = new GodotClient(HOST, PORT);
925
+
516
926
  const server = new Server(
517
- { name: "godot-agent-tools", version: "0.1.0" },
927
+ { name: "godot-agent-tools", version: "0.2.0" },
518
928
  { capabilities: { tools: {} } }
519
929
  );
520
930
 
@@ -535,7 +945,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
535
945
  };
536
946
  }
537
947
  try {
538
- const result = await callGodot(tool.method, req.params.arguments || {});
948
+ const result = await client.call(tool.method, req.params.arguments || {});
539
949
  return {
540
950
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
541
951
  };