godot-agent-tools-mcp 0.1.0 → 0.2.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 (2) hide show
  1. package/package.json +1 -1
  2. package/server.mjs +285 -51
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.0",
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",
@@ -121,6 +136,20 @@ const TOOLS = [
121
136
  },
122
137
  },
123
138
  },
139
+ {
140
+ name: "scene_get_property",
141
+ method: "scene.get_property",
142
+ description:
143
+ "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.",
144
+ inputSchema: {
145
+ type: "object",
146
+ required: ["node_path", "property"],
147
+ properties: {
148
+ node_path: { type: "string" },
149
+ property: { type: "string" },
150
+ },
151
+ },
152
+ },
124
153
  {
125
154
  name: "scene_open",
126
155
  method: "scene.open",
@@ -147,6 +176,21 @@ const TOOLS = [
147
176
  description: "Describe the currently-edited scene, or return {open: false} if none is open.",
148
177
  inputSchema: { type: "object", properties: {} },
149
178
  },
179
+ {
180
+ name: "scene_capture_screenshot",
181
+ method: "scene.capture_screenshot",
182
+ description:
183
+ "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.",
184
+ inputSchema: {
185
+ type: "object",
186
+ properties: {
187
+ output: {
188
+ type: "string",
189
+ description: "Optional output path (res://...). Defaults to res://.godot/agent_tools/<scene>.png.",
190
+ },
191
+ },
192
+ },
193
+ },
150
194
  {
151
195
  name: "signal_connect",
152
196
  method: "signal.connect",
@@ -281,7 +325,7 @@ const TOOLS = [
281
325
  name: "refs_rename",
282
326
  method: "refs.rename",
283
327
  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.",
328
+ "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
329
  inputSchema: {
286
330
  type: "object",
287
331
  required: ["from", "to"],
@@ -293,6 +337,21 @@ const TOOLS = [
293
337
  },
294
338
  },
295
339
  },
340
+ {
341
+ name: "refs_rename_class",
342
+ method: "refs.rename_class",
343
+ description:
344
+ "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.",
345
+ inputSchema: {
346
+ type: "object",
347
+ required: ["from", "to"],
348
+ properties: {
349
+ from: { type: "string" },
350
+ to: { type: "string" },
351
+ dry_run: { type: "boolean", default: false },
352
+ },
353
+ },
354
+ },
296
355
  {
297
356
  name: "project_get_setting",
298
357
  method: "project.get_setting",
@@ -354,6 +413,12 @@ const TOOLS = [
354
413
  "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
414
  inputSchema: { type: "object", properties: {} },
356
415
  },
416
+ {
417
+ name: "editor_save_all_scenes",
418
+ method: "editor.save_all_scenes",
419
+ description: "Save every currently-open scene in the editor.",
420
+ inputSchema: { type: "object", properties: {} },
421
+ },
357
422
  {
358
423
  name: "docs_class_ref",
359
424
  method: "docs.class_ref",
@@ -416,6 +481,20 @@ const TOOLS = [
416
481
  },
417
482
  },
418
483
  },
484
+ {
485
+ name: "input_map_remove_event",
486
+ method: "input_map.remove_event",
487
+ description:
488
+ "Remove an event from an action by index. Call input_map_list first to see indices (events are listed in add order).",
489
+ inputSchema: {
490
+ type: "object",
491
+ required: ["action", "event_index"],
492
+ properties: {
493
+ action: { type: "string" },
494
+ event_index: { type: "integer" },
495
+ },
496
+ },
497
+ },
419
498
  {
420
499
  name: "input_map_remove_action",
421
500
  method: "input_map.remove_action",
@@ -445,76 +524,231 @@ const TOOLS = [
445
524
  },
446
525
  },
447
526
  },
527
+ {
528
+ name: "fs_list",
529
+ method: "fs.list",
530
+ description:
531
+ "Enumerate project files by type with optional glob filter. Types: all | scene | script | resource | shader | image | audio. Skips the agent_tools addon by default.",
532
+ inputSchema: {
533
+ type: "object",
534
+ properties: {
535
+ type: {
536
+ type: "string",
537
+ enum: ["all", "scene", "script", "resource", "shader", "image", "audio"],
538
+ default: "all",
539
+ },
540
+ glob: { type: "string", description: "Optional case-insensitive glob, e.g. 'res://scenes/**/*.tscn'." },
541
+ include_addons: { type: "boolean", default: false },
542
+ },
543
+ },
544
+ },
545
+ {
546
+ name: "animation_list",
547
+ method: "animation.list",
548
+ description: "List animations on an AnimationPlayer node with their tracks.",
549
+ inputSchema: {
550
+ type: "object",
551
+ required: ["node_path"],
552
+ properties: { node_path: { type: "string" } },
553
+ },
554
+ },
555
+ {
556
+ name: "animation_add_animation",
557
+ method: "animation.add_animation",
558
+ description: "Create an empty animation in the player's library. Use animation_add_value_track to populate it.",
559
+ inputSchema: {
560
+ type: "object",
561
+ required: ["node_path", "name"],
562
+ properties: {
563
+ node_path: { type: "string" },
564
+ name: { type: "string" },
565
+ length: { type: "number", default: 1.0 },
566
+ library: { type: "string", default: "", description: "Library name; '' is the default library." },
567
+ },
568
+ },
569
+ },
570
+ {
571
+ name: "animation_remove_animation",
572
+ method: "animation.remove_animation",
573
+ description: "Delete an animation from an AnimationPlayer.",
574
+ inputSchema: {
575
+ type: "object",
576
+ required: ["node_path", "name"],
577
+ properties: {
578
+ node_path: { type: "string" },
579
+ name: { type: "string" },
580
+ library: { type: "string", default: "" },
581
+ },
582
+ },
583
+ },
584
+ {
585
+ name: "animation_add_value_track",
586
+ method: "animation.add_value_track",
587
+ description:
588
+ "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.",
589
+ inputSchema: {
590
+ type: "object",
591
+ required: ["node_path", "animation", "target_node", "property", "keyframes"],
592
+ properties: {
593
+ node_path: { type: "string", description: "AnimationPlayer node path." },
594
+ animation: { type: "string", description: "Animation name — use 'lib/anim' for non-default libraries." },
595
+ target_node: { type: "string", description: "NodePath to animated node, relative to the player's root." },
596
+ property: { type: "string", description: "Property name to animate." },
597
+ keyframes: {
598
+ type: "array",
599
+ items: {
600
+ type: "object",
601
+ required: ["time", "value"],
602
+ properties: {
603
+ time: { type: "number" },
604
+ value: { description: "Property value at this time." },
605
+ easing: { type: "number", default: 1.0, description: "Transition curve; 1.0 = linear." },
606
+ },
607
+ },
608
+ },
609
+ },
610
+ },
611
+ },
448
612
  ];
449
613
 
450
614
  const BY_NAME = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
451
615
 
452
- function callGodot(method, params) {
453
- return new Promise((resolve, reject) => {
454
- const socket = new net.Socket();
455
- let buffer = "";
456
- let settled = false;
616
+ // Persistent Godot TCP client. One socket reused across tool calls; outstanding
617
+ // requests are tracked by id so multiple in-flight calls don't interleave data.
618
+ class GodotClient {
619
+ constructor(host, port) {
620
+ this.host = host;
621
+ this.port = port;
622
+ this.socket = null;
623
+ this.buffer = "";
624
+ this.pending = new Map(); // id -> { resolve, reject, timer }
625
+ this.nextId = 0;
626
+ this.connecting = null;
627
+ }
457
628
 
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
- };
629
+ async _ensureConnected() {
630
+ if (this.socket && !this.socket.destroyed) return;
631
+ if (this.connecting) return this.connecting;
467
632
 
468
- const timer = setTimeout(
469
- () => settle(reject, new Error(`Godot tool '${method}' timed out after ${TIMEOUT_MS}ms`)),
470
- TIMEOUT_MS
471
- );
633
+ this.connecting = new Promise((resolve, reject) => {
634
+ const s = new net.Socket();
635
+ s.setEncoding("utf8");
636
+ s.setNoDelay(true);
472
637
 
473
- socket.setEncoding("utf8");
638
+ const onConnect = () => {
639
+ s.removeListener("error", onErrorPreConnect);
640
+ this.socket = s;
641
+ this.connecting = null;
642
+ resolve();
643
+ };
474
644
 
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
- );
645
+ const onErrorPreConnect = (e) => {
646
+ this.connecting = null;
647
+ if (e.code === "ECONNREFUSED") {
648
+ reject(new Error(
649
+ `Godot editor not reachable on ${this.host}:${this.port}. ` +
650
+ `Open the project in the Godot editor with the 'Agent Tools' plugin enabled.`
651
+ ));
487
652
  } else {
488
- settle(resolve, resp.result);
653
+ reject(e);
654
+ }
655
+ };
656
+
657
+ s.once("connect", onConnect);
658
+ s.once("error", onErrorPreConnect);
659
+
660
+ s.on("data", (data) => this._onData(data));
661
+ s.on("error", (e) => this._onFatalError(e));
662
+ s.on("close", () => this._onClose());
663
+
664
+ s.connect(this.port, this.host);
665
+ });
666
+
667
+ return this.connecting;
668
+ }
669
+
670
+ call(method, params) {
671
+ const id = ++this.nextId;
672
+ return new Promise(async (resolve, reject) => {
673
+ const timer = setTimeout(() => {
674
+ if (this.pending.has(id)) {
675
+ this.pending.delete(id);
676
+ reject(new Error(`Godot tool '${method}' timed out after ${TIMEOUT_MS}ms`));
489
677
  }
678
+ }, TIMEOUT_MS);
679
+
680
+ this.pending.set(id, { resolve, reject, timer });
681
+
682
+ try {
683
+ await this._ensureConnected();
490
684
  } catch (e) {
491
- settle(reject, e);
685
+ clearTimeout(timer);
686
+ this.pending.delete(id);
687
+ reject(e);
688
+ return;
492
689
  }
690
+
691
+ const line = JSON.stringify({ id, method, params: params || {} }) + "\n";
692
+ this.socket.write(line);
493
693
  });
694
+ }
695
+
696
+ _onData(data) {
697
+ this.buffer += data;
698
+ while (true) {
699
+ const nl = this.buffer.indexOf("\n");
700
+ if (nl < 0) break;
701
+ const line = this.buffer.slice(0, nl);
702
+ this.buffer = this.buffer.slice(nl + 1);
703
+ if (!line) continue;
704
+
705
+ let msg;
706
+ try {
707
+ msg = JSON.parse(line);
708
+ } catch {
709
+ continue; // ignore malformed lines — shouldn't happen
710
+ }
711
+
712
+ const pending = this.pending.get(msg.id);
713
+ if (!pending) continue;
714
+ this.pending.delete(msg.id);
715
+ clearTimeout(pending.timer);
494
716
 
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
- );
717
+ if (msg.error) {
718
+ pending.reject(new Error(`Godot error ${msg.error.code}: ${msg.error.message}`));
503
719
  } else {
504
- settle(reject, e);
720
+ pending.resolve(msg.result);
505
721
  }
506
- });
722
+ }
723
+ }
507
724
 
508
- socket.on("end", () => settle(reject, new Error("Godot closed the connection before responding")));
725
+ _onFatalError(e) {
726
+ for (const { reject, timer } of this.pending.values()) {
727
+ clearTimeout(timer);
728
+ reject(e);
729
+ }
730
+ this.pending.clear();
731
+ if (this.socket) {
732
+ this.socket.destroy();
733
+ this.socket = null;
734
+ }
735
+ }
509
736
 
510
- socket.connect(PORT, HOST, () => {
511
- socket.write(JSON.stringify({ id: 1, method, params: params || {} }) + "\n");
512
- });
513
- });
737
+ _onClose() {
738
+ for (const { reject, timer } of this.pending.values()) {
739
+ clearTimeout(timer);
740
+ reject(new Error("Godot closed the connection"));
741
+ }
742
+ this.pending.clear();
743
+ this.socket = null;
744
+ this.buffer = "";
745
+ }
514
746
  }
515
747
 
748
+ const client = new GodotClient(HOST, PORT);
749
+
516
750
  const server = new Server(
517
- { name: "godot-agent-tools", version: "0.1.0" },
751
+ { name: "godot-agent-tools", version: "0.2.0" },
518
752
  { capabilities: { tools: {} } }
519
753
  );
520
754
 
@@ -535,7 +769,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
535
769
  };
536
770
  }
537
771
  try {
538
- const result = await callGodot(tool.method, req.params.arguments || {});
772
+ const result = await client.call(tool.method, req.params.arguments || {});
539
773
  return {
540
774
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
541
775
  };