godot-agent-tools-mcp 0.2.1 → 0.3.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.
- package/package.json +1 -1
- package/server.mjs +524 -6
package/package.json
CHANGED
package/server.mjs
CHANGED
|
@@ -7,13 +7,68 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
import {
|
|
8
8
|
CallToolRequestSchema,
|
|
9
9
|
ListToolsRequestSchema,
|
|
10
|
+
ListResourcesRequestSchema,
|
|
11
|
+
ListResourceTemplatesRequestSchema,
|
|
12
|
+
ReadResourceRequestSchema,
|
|
10
13
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
11
14
|
import net from "node:net";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
12
18
|
|
|
13
19
|
const HOST = process.env.GODOT_AGENT_HOST || "127.0.0.1";
|
|
14
|
-
|
|
20
|
+
// GODOT_AGENT_PORT env var forces a specific target, bypassing session discovery.
|
|
21
|
+
// Leave unset to use the multi-session registry.
|
|
22
|
+
const FORCED_PORT = process.env.GODOT_AGENT_PORT ? parseInt(process.env.GODOT_AGENT_PORT, 10) : null;
|
|
15
23
|
const TIMEOUT_MS = parseInt(process.env.GODOT_AGENT_TIMEOUT_MS || "15000", 10);
|
|
16
24
|
|
|
25
|
+
// Multi-editor session registry — matches the plugin-side writer at
|
|
26
|
+
// <home>/.godot-agent-tools/sessions/<pid>.json. Each file describes one
|
|
27
|
+
// running Godot editor with the plugin enabled.
|
|
28
|
+
const SESSION_DIR = path.join(os.homedir(), ".godot-agent-tools", "sessions");
|
|
29
|
+
|
|
30
|
+
function isProcessAlive(pid) {
|
|
31
|
+
try {
|
|
32
|
+
// signal 0 doesn't send anything — just tests whether we can signal the pid.
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// EPERM means the process exists but we can't signal it (still "alive" for us).
|
|
37
|
+
return e.code === "EPERM";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listSessions() {
|
|
42
|
+
if (!fs.existsSync(SESSION_DIR)) return [];
|
|
43
|
+
let entries;
|
|
44
|
+
try { entries = fs.readdirSync(SESSION_DIR); }
|
|
45
|
+
catch { return []; }
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const name of entries) {
|
|
48
|
+
if (!name.endsWith(".json")) continue;
|
|
49
|
+
const full = path.join(SESSION_DIR, name);
|
|
50
|
+
let parsed;
|
|
51
|
+
try { parsed = JSON.parse(fs.readFileSync(full, "utf8")); }
|
|
52
|
+
catch { continue; }
|
|
53
|
+
if (!parsed.pid || !parsed.port) continue;
|
|
54
|
+
const alive = isProcessAlive(parsed.pid);
|
|
55
|
+
if (!alive) {
|
|
56
|
+
// Clean up a stale entry opportunistically — plugin _exit_tree didn't fire
|
|
57
|
+
// (probably editor crashed or was killed).
|
|
58
|
+
try { fs.unlinkSync(full); } catch {}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
out.push(parsed);
|
|
62
|
+
}
|
|
63
|
+
// Most recently started first.
|
|
64
|
+
out.sort((a, b) => (b.started_at_unix || 0) - (a.started_at_unix || 0));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Tracks which session the shim is currently forwarding tool calls to. Starts
|
|
69
|
+
// unset; first call resolves via listSessions() or FORCED_PORT.
|
|
70
|
+
let activeSessionPid = null;
|
|
71
|
+
|
|
17
72
|
const TOOLS = [
|
|
18
73
|
{
|
|
19
74
|
name: "scene_inspect",
|
|
@@ -302,6 +357,32 @@ const TOOLS = [
|
|
|
302
357
|
},
|
|
303
358
|
},
|
|
304
359
|
},
|
|
360
|
+
{
|
|
361
|
+
name: "script_patch",
|
|
362
|
+
method: "script.patch",
|
|
363
|
+
description:
|
|
364
|
+
"Apply targeted edits to an existing .gd file. Two modes: 'replacements' is an array of {old, new} where each 'old' must match exactly once in the file (ambiguous or missing matches return a clean error instead of silently mangling); 'full_source' overwrites the whole file. After writing, the tool parse-checks the result via ResourceLoader.load; if parsing fails the original is restored and an error is returned. Supports dry_run.",
|
|
365
|
+
inputSchema: {
|
|
366
|
+
type: "object",
|
|
367
|
+
required: ["path"],
|
|
368
|
+
properties: {
|
|
369
|
+
path: { type: "string", description: "Target .gd file." },
|
|
370
|
+
replacements: {
|
|
371
|
+
type: "array",
|
|
372
|
+
items: {
|
|
373
|
+
type: "object",
|
|
374
|
+
required: ["old", "new"],
|
|
375
|
+
properties: {
|
|
376
|
+
old: { type: "string" },
|
|
377
|
+
new: { type: "string" },
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
full_source: { type: "string", description: "Alternative to replacements: overwrite with this full source." },
|
|
382
|
+
dry_run: { type: "boolean", default: false },
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
},
|
|
305
386
|
{
|
|
306
387
|
name: "script_create",
|
|
307
388
|
method: "script.create",
|
|
@@ -489,6 +570,247 @@ const TOOLS = [
|
|
|
489
570
|
description: "List all registered autoloads with their paths and singleton flags.",
|
|
490
571
|
inputSchema: { type: "object", properties: {} },
|
|
491
572
|
},
|
|
573
|
+
{
|
|
574
|
+
name: "editor_state",
|
|
575
|
+
method: "editor.state",
|
|
576
|
+
description:
|
|
577
|
+
"Consolidated editor + project status in one call: Godot version, project name, current scene (path/class/root_name/open), list of open scenes, is-playing flag, playing scene path.",
|
|
578
|
+
inputSchema: { type: "object", properties: {} },
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: "editor_selection_get",
|
|
582
|
+
method: "editor.selection_get",
|
|
583
|
+
description: "Return the currently-selected nodes in the editor tree dock — for 'operate on what I clicked' workflows.",
|
|
584
|
+
inputSchema: { type: "object", properties: {} },
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
name: "editor_selection_set",
|
|
588
|
+
method: "editor.selection_set",
|
|
589
|
+
description: "Select specific nodes in the editor tree dock. Useful after an agent operation to point the user's attention at the result.",
|
|
590
|
+
inputSchema: {
|
|
591
|
+
type: "object",
|
|
592
|
+
required: ["node_paths"],
|
|
593
|
+
properties: {
|
|
594
|
+
node_paths: { type: "array", items: { type: "string" }, description: "NodePaths relative to the scene root. '.' = root." },
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: "editor_game_screenshot",
|
|
600
|
+
method: "editor.game_screenshot",
|
|
601
|
+
description:
|
|
602
|
+
"Capture the viewport of the CURRENTLY RUNNING game (after user pressed F5 etc.). Works via the _MCPGameBridge autoload registered by this plugin. If no game is running, returns an error pointing the user to run.scene_headless as the subprocess-based alternative.",
|
|
603
|
+
inputSchema: {
|
|
604
|
+
type: "object",
|
|
605
|
+
properties: {
|
|
606
|
+
output: { type: "string", default: "res://.godot/agent_tools/game_screenshot.png" },
|
|
607
|
+
timeout_ms: { type: "integer", default: 5000 },
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "logs_read",
|
|
613
|
+
method: "logs.read",
|
|
614
|
+
description:
|
|
615
|
+
"Read print / push_error / push_warning output from the currently running game (captured by the _MCPGameBridge autoload). Entries include level, message, and timestamp. Returns an empty buffer with a helpful note if the game isn't running.",
|
|
616
|
+
inputSchema: {
|
|
617
|
+
type: "object",
|
|
618
|
+
properties: {
|
|
619
|
+
clear: { type: "boolean", default: false, description: "Clear the buffer after reading." },
|
|
620
|
+
max_lines: { type: "integer", default: 200, description: "Cap on entries returned; older entries are omitted first." },
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
name: "logs_clear",
|
|
626
|
+
method: "logs.clear",
|
|
627
|
+
description: "Drop the game log buffer. Safe to call whether the game is running or not.",
|
|
628
|
+
inputSchema: { type: "object", properties: {} },
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "performance_monitors",
|
|
632
|
+
method: "performance.monitors",
|
|
633
|
+
description:
|
|
634
|
+
"Read Godot's Performance monitors (FPS, frame time, memory, object/node counts, draw calls, etc.). Default returns a common set; pass 'monitors' with specific names (fps, frame_time, mem_static, draw_calls, orphan_nodes, ...) for targeted reads.",
|
|
635
|
+
inputSchema: {
|
|
636
|
+
type: "object",
|
|
637
|
+
properties: {
|
|
638
|
+
monitors: {
|
|
639
|
+
type: "array",
|
|
640
|
+
items: { type: "string" },
|
|
641
|
+
description: "Optional subset of monitor names. Full list: fps, frame_time, physics_time, mem_static, mem_static_max, objects, resources, nodes, orphan_nodes, draw_calls, primitives, 2d_items, 2d_draw_calls, video_mem, audio_latency, physics_2d_active_objects, physics_3d_active_objects.",
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: "test_run",
|
|
648
|
+
method: "test.run",
|
|
649
|
+
description:
|
|
650
|
+
"Detect and run a GDScript test framework (GUT or GdUnit4), return structured results. Auto-detects the installed framework (via addons/gut or addons/gdUnit4), can be forced via 'framework'. Returns {total, passed, failed, skipped, failures: [{name, file, line, message}], raw_output}. Higher level than run.scene_headless — understands the framework's test concepts and summary format instead of asking you to parse stdout.",
|
|
651
|
+
inputSchema: {
|
|
652
|
+
type: "object",
|
|
653
|
+
properties: {
|
|
654
|
+
framework: { type: "string", enum: ["auto", "gut", "gdunit4"], default: "auto" },
|
|
655
|
+
directory: { type: "string", description: "Test directory (defaults to 'res://test')." },
|
|
656
|
+
pattern: { type: "string", description: "Filename pattern (framework-specific default)." },
|
|
657
|
+
timeout_seconds: { type: "integer", default: 60 },
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: "client_list",
|
|
663
|
+
method: "client.list",
|
|
664
|
+
description: "List supported MCP clients and whether each has the godot-agent-tools server configured. Shows the config file path for every client so users know where to look.",
|
|
665
|
+
inputSchema: { type: "object", properties: {} },
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
name: "client_configure",
|
|
669
|
+
method: "client.configure",
|
|
670
|
+
description:
|
|
671
|
+
"Write the godot-agent-tools MCP server entry into the specified client's config file. Idempotent (won't duplicate); pass overwrite:true to force-replace an existing entry. Supported clients: claude_code_project, claude_code_user, claude_desktop, cursor_project, cursor_user.",
|
|
672
|
+
inputSchema: {
|
|
673
|
+
type: "object",
|
|
674
|
+
required: ["client"],
|
|
675
|
+
properties: {
|
|
676
|
+
client: { type: "string", enum: ["claude_code_project", "claude_code_user", "claude_desktop", "cursor_project", "cursor_user"] },
|
|
677
|
+
overwrite: { type: "boolean", default: false },
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
name: "client_remove",
|
|
683
|
+
method: "client.remove",
|
|
684
|
+
description: "Remove the godot-agent-tools entry from the specified client's config.",
|
|
685
|
+
inputSchema: {
|
|
686
|
+
type: "object",
|
|
687
|
+
required: ["client"],
|
|
688
|
+
properties: {
|
|
689
|
+
client: { type: "string", enum: ["claude_code_project", "claude_code_user", "claude_desktop", "cursor_project", "cursor_user"] },
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: "physics_autofit_collision_shape_2d",
|
|
695
|
+
method: "physics.autofit_collision_shape_2d",
|
|
696
|
+
description:
|
|
697
|
+
"Compute a CollisionShape2D sized to a sibling Sprite2D/AnimatedSprite2D's visual bounds. Can auto-create the CollisionShape2D if it doesn't exist yet (pass create:true). Shape type: 'rectangle' (default), 'circle', or 'capsule'. Optional margin shrinks the shape.",
|
|
698
|
+
inputSchema: {
|
|
699
|
+
type: "object",
|
|
700
|
+
required: ["node_path"],
|
|
701
|
+
properties: {
|
|
702
|
+
node_path: { type: "string", description: "CollisionShape2D to fit. Created if missing + create:true." },
|
|
703
|
+
source: { type: "string", description: "NodePath to a Sprite2D/AnimatedSprite2D. Auto-detected among siblings if omitted." },
|
|
704
|
+
shape: { type: "string", enum: ["rectangle", "circle", "capsule"], default: "rectangle" },
|
|
705
|
+
margin: { type: "number", default: 0 },
|
|
706
|
+
create: { type: "boolean", default: false },
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: "theme_set_color",
|
|
712
|
+
method: "theme.set_color",
|
|
713
|
+
description: "Set a color entry in a Theme resource. Wraps Theme.set_color(item, type, color) — e.g. item='font_color', type='Label'.",
|
|
714
|
+
inputSchema: {
|
|
715
|
+
type: "object",
|
|
716
|
+
required: ["path", "item", "type", "color"],
|
|
717
|
+
properties: {
|
|
718
|
+
path: { type: "string", description: "Path to .tres Theme resource." },
|
|
719
|
+
item: { type: "string", description: "Theme item name (e.g. 'font_color', 'bg_color')." },
|
|
720
|
+
type: { type: "string", description: "Control class name (e.g. 'Button', 'Label')." },
|
|
721
|
+
color: { description: "[r,g,b(,a)] or '#hex'." },
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
name: "theme_set_constant",
|
|
727
|
+
method: "theme.set_constant",
|
|
728
|
+
description: "Set an int constant in a Theme resource. E.g. item='h_separation', type='HBoxContainer'.",
|
|
729
|
+
inputSchema: {
|
|
730
|
+
type: "object",
|
|
731
|
+
required: ["path", "item", "type", "value"],
|
|
732
|
+
properties: {
|
|
733
|
+
path: { type: "string" },
|
|
734
|
+
item: { type: "string" },
|
|
735
|
+
type: { type: "string" },
|
|
736
|
+
value: { type: "integer" },
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
name: "theme_set_font_size",
|
|
742
|
+
method: "theme.set_font_size",
|
|
743
|
+
description: "Set a font-size entry in a Theme resource. E.g. item='font_size', type='Label'.",
|
|
744
|
+
inputSchema: {
|
|
745
|
+
type: "object",
|
|
746
|
+
required: ["path", "item", "type", "value"],
|
|
747
|
+
properties: {
|
|
748
|
+
path: { type: "string" },
|
|
749
|
+
item: { type: "string" },
|
|
750
|
+
type: { type: "string" },
|
|
751
|
+
value: { type: "integer" },
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
name: "theme_set_stylebox_flat",
|
|
757
|
+
method: "theme.set_stylebox_flat",
|
|
758
|
+
description:
|
|
759
|
+
"Create (or replace) a StyleBoxFlat on a Theme with the given properties and assign it to theme.<item>.<type>. Saves the usual multi-step StyleBoxFlat setup — e.g. {item: 'normal', type: 'Button', properties: {bg_color: [0.1,0.1,0.12,1], corner_radius_top_left: 8, ...}}.",
|
|
760
|
+
inputSchema: {
|
|
761
|
+
type: "object",
|
|
762
|
+
required: ["path", "item", "type"],
|
|
763
|
+
properties: {
|
|
764
|
+
path: { type: "string" },
|
|
765
|
+
item: { type: "string" },
|
|
766
|
+
type: { type: "string" },
|
|
767
|
+
properties: { type: "object", additionalProperties: true },
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
name: "session_list",
|
|
773
|
+
method: "__local__.session_list",
|
|
774
|
+
description:
|
|
775
|
+
"List every running Godot editor with the Agent Tools plugin enabled (each becomes a separate 'session' the shim can target). Each entry: {pid, port, project_path, project_name, godot_version, started_at_unix, active}. The shim's default target is the most-recently-started session; use session_activate to pin a specific one.",
|
|
776
|
+
inputSchema: { type: "object", properties: {} },
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
name: "session_activate",
|
|
780
|
+
method: "__local__.session_activate",
|
|
781
|
+
description:
|
|
782
|
+
"Pin subsequent tool calls to a specific Godot editor session (by pid from session_list). Pass pid:null to clear the pin and fall back to 'most-recently-started'. Changing the active session tears down the existing TCP connection; the next call reconnects to the new target.",
|
|
783
|
+
inputSchema: {
|
|
784
|
+
type: "object",
|
|
785
|
+
properties: {
|
|
786
|
+
pid: { type: ["integer", "null"], description: "PID of the session to target; null to clear." },
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
name: "batch_execute",
|
|
792
|
+
method: "batch.execute",
|
|
793
|
+
description:
|
|
794
|
+
"Run multiple tool calls in one round trip. Each call is dispatched server-side and results are returned in order. Useful when you know the exact sequence you want — saves TCP round trips vs. parallel MCP calls.",
|
|
795
|
+
inputSchema: {
|
|
796
|
+
type: "object",
|
|
797
|
+
required: ["calls"],
|
|
798
|
+
properties: {
|
|
799
|
+
calls: {
|
|
800
|
+
type: "array",
|
|
801
|
+
items: {
|
|
802
|
+
type: "object",
|
|
803
|
+
required: ["method"],
|
|
804
|
+
properties: {
|
|
805
|
+
method: { type: "string", description: "Dotted method name (e.g. 'scene.add_node')." },
|
|
806
|
+
params: { type: "object" },
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
stop_on_error: { type: "boolean", default: false, description: "Halt the batch on the first failure. Default keeps going." },
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
},
|
|
492
814
|
{
|
|
493
815
|
name: "editor_reload_filesystem",
|
|
494
816
|
method: "editor.reload_filesystem",
|
|
@@ -674,6 +996,31 @@ const TOOLS = [
|
|
|
674
996
|
},
|
|
675
997
|
},
|
|
676
998
|
},
|
|
999
|
+
{
|
|
1000
|
+
name: "fs_read_text",
|
|
1001
|
+
method: "fs.read_text",
|
|
1002
|
+
description: "Read a text file under res://. Complement to user_fs_read (which targets user:// for runtime-written state).",
|
|
1003
|
+
inputSchema: {
|
|
1004
|
+
type: "object",
|
|
1005
|
+
required: ["path"],
|
|
1006
|
+
properties: { path: { type: "string", description: "Must begin with 'res://'." } },
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
name: "fs_write_text",
|
|
1011
|
+
method: "fs.write_text",
|
|
1012
|
+
description:
|
|
1013
|
+
"Write a text file under res://. Creates parent directories if needed; triggers the editor's filesystem rescan so the new file shows up in the FileSystem dock immediately. For .gd scripts prefer script_create / script_patch — those run a parse check.",
|
|
1014
|
+
inputSchema: {
|
|
1015
|
+
type: "object",
|
|
1016
|
+
required: ["path", "content"],
|
|
1017
|
+
properties: {
|
|
1018
|
+
path: { type: "string" },
|
|
1019
|
+
content: { type: "string" },
|
|
1020
|
+
overwrite: { type: "boolean", default: false },
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
},
|
|
677
1024
|
{
|
|
678
1025
|
name: "user_fs_read",
|
|
679
1026
|
method: "user_fs.read",
|
|
@@ -791,10 +1138,12 @@ const BY_NAME = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
|
|
|
791
1138
|
|
|
792
1139
|
// Persistent Godot TCP client. One socket reused across tool calls; outstanding
|
|
793
1140
|
// requests are tracked by id so multiple in-flight calls don't interleave data.
|
|
1141
|
+
// Target port is resolved lazily per call so session.activate / session death
|
|
1142
|
+
// switch over without proactive teardown.
|
|
794
1143
|
class GodotClient {
|
|
795
|
-
constructor(host
|
|
1144
|
+
constructor(host) {
|
|
796
1145
|
this.host = host;
|
|
797
|
-
this.port =
|
|
1146
|
+
this.port = null;
|
|
798
1147
|
this.socket = null;
|
|
799
1148
|
this.buffer = "";
|
|
800
1149
|
this.pending = new Map(); // id -> { resolve, reject, timer }
|
|
@@ -802,9 +1151,46 @@ class GodotClient {
|
|
|
802
1151
|
this.connecting = null;
|
|
803
1152
|
}
|
|
804
1153
|
|
|
1154
|
+
// Priority: GODOT_AGENT_PORT env > pinned active session > most recent session.
|
|
1155
|
+
_resolvePort() {
|
|
1156
|
+
if (FORCED_PORT != null) return FORCED_PORT;
|
|
1157
|
+
const sessions = listSessions();
|
|
1158
|
+
if (sessions.length === 0) return null;
|
|
1159
|
+
if (activeSessionPid != null) {
|
|
1160
|
+
const pinned = sessions.find((s) => s.pid === activeSessionPid);
|
|
1161
|
+
if (pinned) return pinned.port;
|
|
1162
|
+
activeSessionPid = null; // pinned session died — fall back
|
|
1163
|
+
}
|
|
1164
|
+
return sessions[0].port;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// If the active session changed, drop the old socket so the next call
|
|
1168
|
+
// reconnects to the new target.
|
|
1169
|
+
_maybeResetForPortChange() {
|
|
1170
|
+
const target = this._resolvePort();
|
|
1171
|
+
if (target !== this.port && this.socket) {
|
|
1172
|
+
try { this.socket.destroy(); } catch {}
|
|
1173
|
+
this.socket = null;
|
|
1174
|
+
this.buffer = "";
|
|
1175
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
1176
|
+
clearTimeout(timer);
|
|
1177
|
+
reject(new Error("session target changed mid-flight"));
|
|
1178
|
+
}
|
|
1179
|
+
this.pending.clear();
|
|
1180
|
+
}
|
|
1181
|
+
this.port = target;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
805
1184
|
async _ensureConnected() {
|
|
1185
|
+
this._maybeResetForPortChange();
|
|
806
1186
|
if (this.socket && !this.socket.destroyed) return;
|
|
807
1187
|
if (this.connecting) return this.connecting;
|
|
1188
|
+
if (this.port == null) {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
"No Godot editor session found. Open a project with the 'Agent Tools' plugin enabled, " +
|
|
1191
|
+
"or set the GODOT_AGENT_PORT env var to target a specific port."
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
808
1194
|
|
|
809
1195
|
this.connecting = new Promise((resolve, reject) => {
|
|
810
1196
|
const s = new net.Socket();
|
|
@@ -921,13 +1307,62 @@ class GodotClient {
|
|
|
921
1307
|
}
|
|
922
1308
|
}
|
|
923
1309
|
|
|
924
|
-
const client = new GodotClient(HOST
|
|
1310
|
+
const client = new GodotClient(HOST);
|
|
925
1311
|
|
|
926
1312
|
const server = new Server(
|
|
927
|
-
{ name: "godot-agent-tools", version: "0.
|
|
928
|
-
{ capabilities: { tools: {} } }
|
|
1313
|
+
{ name: "godot-agent-tools", version: "0.3.1" },
|
|
1314
|
+
{ capabilities: { tools: {}, resources: {} } }
|
|
929
1315
|
);
|
|
930
1316
|
|
|
1317
|
+
// MCP Resources — subscribable read-only endpoints. Agents that support
|
|
1318
|
+
// resources can 'watch' these without repeatedly calling tools.
|
|
1319
|
+
const RESOURCES = [
|
|
1320
|
+
{
|
|
1321
|
+
uri: "godot://editor/state",
|
|
1322
|
+
name: "Editor state",
|
|
1323
|
+
description: "Current editor state: Godot version, project name, current scene, playing status.",
|
|
1324
|
+
mimeType: "application/json",
|
|
1325
|
+
method: "editor.state",
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
uri: "godot://scene/current",
|
|
1329
|
+
name: "Current scene",
|
|
1330
|
+
description: "Currently-edited scene (path, root name, root class, open?).",
|
|
1331
|
+
mimeType: "application/json",
|
|
1332
|
+
method: "scene.current",
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
uri: "godot://scene/hierarchy",
|
|
1336
|
+
name: "Current scene hierarchy",
|
|
1337
|
+
description: "Full tree of the currently-edited scene.",
|
|
1338
|
+
mimeType: "application/json",
|
|
1339
|
+
method: "scene.inspect",
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
uri: "godot://selection/current",
|
|
1343
|
+
name: "Editor selection",
|
|
1344
|
+
description: "Nodes currently selected in the editor tree dock.",
|
|
1345
|
+
mimeType: "application/json",
|
|
1346
|
+
method: "editor.selection_get",
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
uri: "godot://logs/recent",
|
|
1350
|
+
name: "Recent game logs",
|
|
1351
|
+
description: "Recent print / push_error / push_warning output from the running game.",
|
|
1352
|
+
mimeType: "application/json",
|
|
1353
|
+
method: "logs.read",
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
uri: "godot://performance/monitors",
|
|
1357
|
+
name: "Performance monitors",
|
|
1358
|
+
description: "FPS, frame time, memory, draw calls, object counts.",
|
|
1359
|
+
mimeType: "application/json",
|
|
1360
|
+
method: "performance.monitors",
|
|
1361
|
+
},
|
|
1362
|
+
];
|
|
1363
|
+
|
|
1364
|
+
const RESOURCE_BY_URI = Object.fromEntries(RESOURCES.map((r) => [r.uri, r]));
|
|
1365
|
+
|
|
931
1366
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
932
1367
|
tools: TOOLS.map(({ name, description, inputSchema }) => ({
|
|
933
1368
|
name,
|
|
@@ -944,6 +1379,42 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
944
1379
|
content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
|
|
945
1380
|
};
|
|
946
1381
|
}
|
|
1382
|
+
|
|
1383
|
+
// session_list / session_activate are shim-local — they manage the MCP shim's
|
|
1384
|
+
// own routing state and don't forward to any Godot process.
|
|
1385
|
+
if (tool.method === "__local__.session_list") {
|
|
1386
|
+
const all = listSessions();
|
|
1387
|
+
// Default target is the most-recently-started session (all[0]). Compare by
|
|
1388
|
+
// pid — listSessions() returns freshly-parsed objects each call, so object
|
|
1389
|
+
// identity (s === listSessions()[0]) would never hold.
|
|
1390
|
+
const defaultPid = all.length > 0 ? all[0].pid : null;
|
|
1391
|
+
const sessions = all.map((s) => ({
|
|
1392
|
+
...s,
|
|
1393
|
+
active: activeSessionPid != null ? s.pid === activeSessionPid : s.pid === defaultPid,
|
|
1394
|
+
}));
|
|
1395
|
+
return {
|
|
1396
|
+
content: [{ type: "text", text: JSON.stringify({ sessions, count: sessions.length, active_pid: activeSessionPid }, null, 2) }],
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
if (tool.method === "__local__.session_activate") {
|
|
1400
|
+
const pid = req.params.arguments?.pid ?? null;
|
|
1401
|
+
if (pid === null) {
|
|
1402
|
+
activeSessionPid = null;
|
|
1403
|
+
} else {
|
|
1404
|
+
const sessions = listSessions();
|
|
1405
|
+
if (!sessions.some((s) => s.pid === pid)) {
|
|
1406
|
+
return {
|
|
1407
|
+
isError: true,
|
|
1408
|
+
content: [{ type: "text", text: `No active session with pid=${pid}. Call session_list to see candidates.` }],
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
activeSessionPid = pid;
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
content: [{ type: "text", text: JSON.stringify({ active_pid: activeSessionPid }, null, 2) }],
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
947
1418
|
try {
|
|
948
1419
|
const result = await client.call(tool.method, req.params.arguments || {});
|
|
949
1420
|
return {
|
|
@@ -957,5 +1428,52 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
957
1428
|
}
|
|
958
1429
|
});
|
|
959
1430
|
|
|
1431
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
1432
|
+
resources: RESOURCES.map(({ uri, name, description, mimeType }) => ({
|
|
1433
|
+
uri,
|
|
1434
|
+
name,
|
|
1435
|
+
description,
|
|
1436
|
+
mimeType,
|
|
1437
|
+
})),
|
|
1438
|
+
}));
|
|
1439
|
+
|
|
1440
|
+
// resources/templates/list — this server exposes only fixed-URI resources, not
|
|
1441
|
+
// URI templates. But MCP clients (e.g. VS Code's Continue plugin) probe this
|
|
1442
|
+
// method during connection setup; without a handler the SDK answers -32601
|
|
1443
|
+
// "Method not found", which Continue surfaces as a scary "Error loading resource
|
|
1444
|
+
// templates" prompt. Answer with an empty template list so the probe succeeds.
|
|
1445
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
|
1446
|
+
resourceTemplates: [],
|
|
1447
|
+
}));
|
|
1448
|
+
|
|
1449
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
1450
|
+
const resource = RESOURCE_BY_URI[req.params.uri];
|
|
1451
|
+
if (!resource) {
|
|
1452
|
+
throw new Error(`Unknown resource URI: ${req.params.uri}`);
|
|
1453
|
+
}
|
|
1454
|
+
try {
|
|
1455
|
+
const result = await client.call(resource.method, {});
|
|
1456
|
+
return {
|
|
1457
|
+
contents: [
|
|
1458
|
+
{
|
|
1459
|
+
uri: resource.uri,
|
|
1460
|
+
mimeType: resource.mimeType,
|
|
1461
|
+
text: JSON.stringify(result, null, 2),
|
|
1462
|
+
},
|
|
1463
|
+
],
|
|
1464
|
+
};
|
|
1465
|
+
} catch (e) {
|
|
1466
|
+
return {
|
|
1467
|
+
contents: [
|
|
1468
|
+
{
|
|
1469
|
+
uri: resource.uri,
|
|
1470
|
+
mimeType: resource.mimeType,
|
|
1471
|
+
text: JSON.stringify({ error: e.message }, null, 2),
|
|
1472
|
+
},
|
|
1473
|
+
],
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
|
|
960
1478
|
const transport = new StdioServerTransport();
|
|
961
1479
|
await server.connect(transport);
|