gopeak 2.3.5 → 2.3.6

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/README.md CHANGED
@@ -144,6 +144,20 @@ In `compact` mode, 78 additional tools are organized into **22 groups** that act
144
144
  > "Use `tool.groups` to reset all active groups."
145
145
 
146
146
  The server sends `notifications/tools/list_changed` so MCP clients (Claude Code, Claude Desktop) automatically refresh the tool list.
147
+ If your MCP client caches tools aggressively and does not refresh after activation, reconnect the client or call the newly activated tool directly once to force a fresh `tools/list` round-trip.
148
+
149
+ ### Typed property values for scene tools
150
+
151
+ Bridge-backed scene tools (`add_node`, `set_node_properties`) now coerce common vector payloads such as `{ "x": 100, "y": 200 }` and `[100, 200]` for typed properties like `position` and `scale`. Tagged values are still the safest cross-tool form:
152
+
153
+ ```json
154
+ {
155
+ "position": { "type": "Vector2", "x": 100, "y": 200 },
156
+ "scale": { "type": "Vector2", "x": 2, "y": 2 }
157
+ }
158
+ ```
159
+
160
+ The internal headless serializer uses `_type`, but MCP callers should prefer `type` when they need an explicit cross-tool Godot value tag.
147
161
 
148
162
  ### Don't worry about tokens
149
163
 
@@ -353,6 +367,7 @@ Visualize your entire project architecture with `visualizer.map` (`map_project`
353
367
  - **Project path invalid** → confirm `project.godot` exists
354
368
  - **Runtime tools not working** → install/enable runtime addon plugin
355
369
  - **Need a tool that is not visible** → run `tool.catalog` to search and auto-activate matching groups, or use `tool.groups` to activate a specific group
370
+ - **`get_editor_status` says disconnected while the Godot editor shows connected** → check whether another `gopeak`/MCP server instance already owns bridge port `6505`; the status payload now reports the startup error and suggests stopping duplicate servers
356
371
 
357
372
  ---
358
373
 
@@ -81,49 +81,92 @@ func _find_node(root: Node, path: String) -> Node:
81
81
  return root.get_node_or_null(path)
82
82
 
83
83
 
84
- func _parse_value(value):
85
- if typeof(value) == TYPE_DICTIONARY and value.has("type"):
86
- match value["type"]:
87
- "Vector2":
88
- return Vector2(value.get("x", 0), value.get("y", 0))
89
- "Vector3":
90
- return Vector3(value.get("x", 0), value.get("y", 0), value.get("z", 0))
91
- "Color":
92
- return Color(value.get("r", 1), value.get("g", 1), value.get("b", 1), value.get("a", 1))
93
- "Vector2i":
94
- return Vector2i(value.get("x", 0), value.get("y", 0))
95
- "Vector3i":
96
- return Vector3i(value.get("x", 0), value.get("y", 0), value.get("z", 0))
97
- "Rect2":
98
- return Rect2(value.get("x", 0), value.get("y", 0), value.get("width", 0), value.get("height", 0))
99
- "Transform2D":
100
- if value.has("x") and value.has("y") and value.has("origin"):
101
- var xx: Dictionary = value["x"]
102
- var yy: Dictionary = value["y"]
103
- var oo: Dictionary = value["origin"]
104
- return Transform2D(
105
- Vector2(xx.get("x", 1), xx.get("y", 0)),
106
- Vector2(yy.get("x", 0), yy.get("y", 1)),
107
- Vector2(oo.get("x", 0), oo.get("y", 0))
108
- )
109
- "Transform3D":
110
- if value.has("basis") and value.has("origin"):
111
- var b: Dictionary = value["basis"]
112
- var o: Dictionary = value["origin"]
113
- var basis := Basis(
114
- Vector3(b.get("x", {}).get("x", 1), b.get("x", {}).get("y", 0), b.get("x", {}).get("z", 0)),
115
- Vector3(b.get("y", {}).get("x", 0), b.get("y", {}).get("y", 1), b.get("y", {}).get("z", 0)),
116
- Vector3(b.get("z", {}).get("x", 0), b.get("z", {}).get("y", 0), b.get("z", {}).get("z", 1))
117
- )
118
- return Transform3D(basis, Vector3(o.get("x", 0), o.get("y", 0), o.get("z", 0)))
119
- "NodePath":
120
- return NodePath(value.get("path", ""))
121
- "Resource":
122
- var resource_path: String = str(value.get("path", ""))
123
- if resource_path.is_empty():
124
- return null
125
- return load(resource_path)
84
+ func _parse_value(value, expected_type: int = TYPE_NIL):
85
+ if typeof(value) == TYPE_DICTIONARY:
86
+ var type_tag := ""
87
+ if value.has("type"):
88
+ type_tag = str(value["type"])
89
+ elif value.has("_type"):
90
+ type_tag = str(value["_type"])
91
+
92
+ if not type_tag.is_empty():
93
+ match type_tag:
94
+ "Vector2":
95
+ return Vector2(value.get("x", 0), value.get("y", 0))
96
+ "Vector3":
97
+ return Vector3(value.get("x", 0), value.get("y", 0), value.get("z", 0))
98
+ "Color":
99
+ return Color(value.get("r", 1), value.get("g", 1), value.get("b", 1), value.get("a", 1))
100
+ "Vector2i":
101
+ return Vector2i(value.get("x", 0), value.get("y", 0))
102
+ "Vector3i":
103
+ return Vector3i(value.get("x", 0), value.get("y", 0), value.get("z", 0))
104
+ "Rect2":
105
+ return Rect2(value.get("x", 0), value.get("y", 0), value.get("width", 0), value.get("height", 0))
106
+ "Transform2D":
107
+ if value.has("x") and value.has("y") and value.has("origin"):
108
+ var xx: Dictionary = value["x"]
109
+ var yy: Dictionary = value["y"]
110
+ var oo: Dictionary = value["origin"]
111
+ return Transform2D(
112
+ Vector2(xx.get("x", 1), xx.get("y", 0)),
113
+ Vector2(yy.get("x", 0), yy.get("y", 1)),
114
+ Vector2(oo.get("x", 0), oo.get("y", 0))
115
+ )
116
+ "Transform3D":
117
+ if value.has("basis") and value.has("origin"):
118
+ var b: Dictionary = value["basis"]
119
+ var o: Dictionary = value["origin"]
120
+ var basis := Basis(
121
+ Vector3(b.get("x", {}).get("x", 1), b.get("x", {}).get("y", 0), b.get("x", {}).get("z", 0)),
122
+ Vector3(b.get("y", {}).get("x", 0), b.get("y", {}).get("y", 1), b.get("y", {}).get("z", 0)),
123
+ Vector3(b.get("z", {}).get("x", 0), b.get("z", {}).get("y", 0), b.get("z", {}).get("z", 1))
124
+ )
125
+ return Transform3D(basis, Vector3(o.get("x", 0), o.get("y", 0), o.get("z", 0)))
126
+ "NodePath":
127
+ return NodePath(value.get("path", ""))
128
+ "Resource":
129
+ var resource_path: String = str(value.get("path", ""))
130
+ if resource_path.is_empty():
131
+ return null
132
+ return load(resource_path)
133
+
134
+ match expected_type:
135
+ TYPE_VECTOR2:
136
+ if value.has("x") and value.has("y"):
137
+ return Vector2(value.get("x", 0), value.get("y", 0))
138
+ TYPE_VECTOR2I:
139
+ if value.has("x") and value.has("y"):
140
+ return Vector2i(value.get("x", 0), value.get("y", 0))
141
+ TYPE_VECTOR3:
142
+ if value.has("x") and value.has("y") and value.has("z"):
143
+ return Vector3(value.get("x", 0), value.get("y", 0), value.get("z", 0))
144
+ TYPE_VECTOR3I:
145
+ if value.has("x") and value.has("y") and value.has("z"):
146
+ return Vector3i(value.get("x", 0), value.get("y", 0), value.get("z", 0))
147
+ TYPE_COLOR:
148
+ if value.has("r") and value.has("g") and value.has("b"):
149
+ return Color(value.get("r", 1), value.get("g", 1), value.get("b", 1), value.get("a", 1))
150
+ TYPE_RECT2:
151
+ if value.has("x") and value.has("y") and value.has("width") and value.has("height"):
152
+ return Rect2(value.get("x", 0), value.get("y", 0), value.get("width", 0), value.get("height", 0))
153
+ TYPE_NODE_PATH:
154
+ if value.has("path"):
155
+ return NodePath(value.get("path", ""))
126
156
  if typeof(value) == TYPE_ARRAY:
157
+ match expected_type:
158
+ TYPE_VECTOR2:
159
+ if value.size() >= 2:
160
+ return Vector2(value[0], value[1])
161
+ TYPE_VECTOR2I:
162
+ if value.size() >= 2:
163
+ return Vector2i(value[0], value[1])
164
+ TYPE_VECTOR3:
165
+ if value.size() >= 3:
166
+ return Vector3(value[0], value[1], value[2])
167
+ TYPE_VECTOR3I:
168
+ if value.size() >= 3:
169
+ return Vector3i(value[0], value[1], value[2])
127
170
  var result: Array = []
128
171
  for item in value:
129
172
  result.append(_parse_value(item))
@@ -131,6 +174,13 @@ func _parse_value(value):
131
174
  return value
132
175
 
133
176
 
177
+ func _get_property_type(node: Node, prop_name: String) -> int:
178
+ for prop in node.get_property_list():
179
+ if str(prop.get("name", "")) == prop_name:
180
+ return int(prop.get("type", TYPE_NIL))
181
+ return TYPE_NIL
182
+
183
+
134
184
  func _serialize_value(value) -> Variant:
135
185
  match typeof(value):
136
186
  TYPE_VECTOR2:
@@ -174,7 +224,8 @@ func _serialize_value(value) -> Variant:
174
224
 
175
225
  func _set_node_properties(node: Node, properties: Dictionary) -> void:
176
226
  for prop_name in properties:
177
- var val = _parse_value(properties[prop_name])
227
+ var expected_type := _get_property_type(node, str(prop_name))
228
+ var val = _parse_value(properties[prop_name], expected_type)
178
229
  node.set(prop_name, val)
179
230
 
180
231
 
@@ -45,6 +45,9 @@ func _process(_delta: float) -> void:
45
45
  continue
46
46
 
47
47
  client.poll()
48
+ if client.get_status() != StreamPeerTCP.STATUS_CONNECTED:
49
+ clients_to_remove.append(client)
50
+ continue
48
51
  var available = client.get_available_bytes()
49
52
  if available > 0:
50
53
  var data = client.get_utf8_string(available)
@@ -335,10 +335,13 @@ export class GodotBridge extends EventEmitter {
335
335
  nextSocket.on('close', (code, reasonBuffer) => {
336
336
  const reason = reasonBuffer.toString();
337
337
  this.log('warn', `Godot disconnected (code=${code}, reason=${reason || 'none'})`);
338
- this.handleDisconnect(new Error('Godot disconnected during request'));
338
+ this.handleDisconnect(nextSocket, new Error('Godot disconnected during request'));
339
339
  });
340
340
  nextSocket.on('error', (error) => {
341
341
  this.log('error', `WebSocket error: ${error.message}`);
342
+ if (nextSocket.readyState === WebSocket.CLOSED || nextSocket.readyState === WebSocket.CLOSING) {
343
+ this.handleDisconnect(nextSocket, error);
344
+ }
342
345
  });
343
346
  }
344
347
  handleRawMessage(data) {
@@ -463,7 +466,11 @@ export class GodotBridge extends EventEmitter {
463
466
  clearInterval(this.pingInterval);
464
467
  this.pingInterval = null;
465
468
  }
466
- handleDisconnect(reason) {
469
+ handleDisconnect(disconnectedSocket, reason) {
470
+ if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
471
+ this.log('debug', 'Ignoring stale Godot socket disconnect event');
472
+ return;
473
+ }
467
474
  this.stopKeepalive();
468
475
  this.socket = null;
469
476
  this.connectionInfo = null;
package/build/index.js CHANGED
@@ -8,7 +8,8 @@
8
8
  */
9
9
  import { fileURLToPath } from 'url';
10
10
  import { join, dirname, basename, normalize } from 'path';
11
- import { existsSync, readdirSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
11
+ import { existsSync, readdirSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
12
+ import { tmpdir } from 'os';
12
13
  import { spawn } from 'child_process';
13
14
  import { createConnection as createTcpConnection } from 'node:net';
14
15
  import { promisify } from 'util';
@@ -43,6 +44,7 @@ class GodotServer {
43
44
  godotDebugMode = GODOT_DEBUG_MODE_DEFAULT;
44
45
  lspClient = null;
45
46
  dapClient = null;
47
+ bridgeStartupError = null;
46
48
  lastProjectPath = null;
47
49
  recordingMode = (process.env.LOG_MODE === 'full' ? 'full' : 'lite');
48
50
  logQueue = [];
@@ -652,6 +654,87 @@ class GodotServer {
652
654
  }
653
655
  return compactTools;
654
656
  }
657
+ jsonTextResponse(payload) {
658
+ return {
659
+ content: [{
660
+ type: 'text',
661
+ text: JSON.stringify(payload, null, 2),
662
+ }],
663
+ };
664
+ }
665
+ buildLegacyToCompactAliasMap() {
666
+ return new Map(Object.entries(this.compactAliasToLegacy).map(([compactName, legacyName]) => [legacyName, compactName]));
667
+ }
668
+ buildToolGroupLookup() {
669
+ const toolToGroup = new Map();
670
+ const registerGroups = (groups, type) => {
671
+ for (const [groupName, group] of Object.entries(groups)) {
672
+ for (const toolName of group.tools) {
673
+ toolToGroup.set(toolName, { group: groupName, type });
674
+ }
675
+ }
676
+ };
677
+ registerGroups(CORE_TOOL_GROUPS, 'core');
678
+ registerGroups(TOOL_GROUPS, 'dynamic');
679
+ return toolToGroup;
680
+ }
681
+ getActivatedToolNames() {
682
+ const activatedToolNames = new Set();
683
+ for (const groupName of this.activeGroups) {
684
+ const group = TOOL_GROUPS[groupName];
685
+ if (!group) {
686
+ continue;
687
+ }
688
+ for (const toolName of group.tools) {
689
+ activatedToolNames.add(toolName);
690
+ }
691
+ }
692
+ return activatedToolNames;
693
+ }
694
+ getAvailableDynamicGroups() {
695
+ return Object.keys(TOOL_GROUPS);
696
+ }
697
+ getUnknownDynamicGroupError(groupName) {
698
+ return `Unknown group '${groupName}'. Available dynamic groups: ${this.getAvailableDynamicGroups().join(', ')}`;
699
+ }
700
+ notifyToolListChanged() {
701
+ this.cachedToolDefinitions = [];
702
+ this.server.sendToolListChanged().catch(() => { });
703
+ }
704
+ autoActivateMatchingGroups(query) {
705
+ if (!query || this.toolExposureProfile !== 'compact') {
706
+ return [];
707
+ }
708
+ const newlyActivated = [];
709
+ for (const [groupName, group] of Object.entries(TOOL_GROUPS)) {
710
+ if (this.activeGroups.has(groupName)) {
711
+ continue;
712
+ }
713
+ const hasMatchingKeyword = group.keywords.some((kw) => query.includes(kw) || kw.includes(query));
714
+ const hasMatchingToolName = group.tools.some((toolName) => toolName.toLowerCase().includes(query));
715
+ if (hasMatchingKeyword || hasMatchingToolName) {
716
+ this.activeGroups.add(groupName);
717
+ newlyActivated.push(groupName);
718
+ }
719
+ }
720
+ if (newlyActivated.length > 0) {
721
+ this.notifyToolListChanged();
722
+ }
723
+ return newlyActivated;
724
+ }
725
+ setDynamicGroupActivation(groupName, active) {
726
+ const wasActive = this.activeGroups.has(groupName);
727
+ if (active) {
728
+ this.activeGroups.add(groupName);
729
+ }
730
+ else {
731
+ this.activeGroups.delete(groupName);
732
+ }
733
+ if (wasActive !== active) {
734
+ this.notifyToolListChanged();
735
+ }
736
+ return wasActive;
737
+ }
655
738
  sanitizeToolsForList(tools) {
656
739
  const seenNames = new Map();
657
740
  return tools.map((tool) => {
@@ -680,15 +763,7 @@ class GodotServer {
680
763
  const exposed = this.buildCompactTools(allTools);
681
764
  // Add dynamically activated group tools (using their legacy names)
682
765
  if (this.activeGroups.size > 0) {
683
- const activatedToolNames = new Set();
684
- for (const groupName of this.activeGroups) {
685
- const group = TOOL_GROUPS[groupName];
686
- if (!group)
687
- continue;
688
- for (const toolName of group.tools) {
689
- activatedToolNames.add(toolName);
690
- }
691
- }
766
+ const activatedToolNames = this.getActivatedToolNames();
692
767
  for (const tool of allTools) {
693
768
  if (activatedToolNames.has(tool.name)) {
694
769
  exposed.push({
@@ -750,22 +825,8 @@ class GodotServer {
750
825
  const rawLimit = typeof normalizedArgs.limit === 'number' ? normalizedArgs.limit : 30;
751
826
  const limit = Math.max(1, Math.min(100, rawLimit));
752
827
  const tools = this.getAllToolDefinitions();
753
- const reverseAlias = new Map();
754
- for (const [compactName, legacyName] of Object.entries(this.compactAliasToLegacy)) {
755
- reverseAlias.set(legacyName, compactName);
756
- }
757
- // Build tool → group reverse map (core + dynamic)
758
- const toolToGroup = new Map();
759
- for (const [groupName, group] of Object.entries(CORE_TOOL_GROUPS)) {
760
- for (const toolName of group.tools) {
761
- toolToGroup.set(toolName, { group: groupName, type: 'core' });
762
- }
763
- }
764
- for (const [groupName, group] of Object.entries(TOOL_GROUPS)) {
765
- for (const toolName of group.tools) {
766
- toolToGroup.set(toolName, { group: groupName, type: 'dynamic' });
767
- }
768
- }
828
+ const reverseAlias = this.buildLegacyToCompactAliasMap();
829
+ const toolToGroup = this.buildToolGroupLookup();
769
830
  const filtered = tools.filter((tool) => {
770
831
  if (!query)
771
832
  return true;
@@ -785,40 +846,16 @@ class GodotServer {
785
846
  // Auto-activate matching tool groups when query matches their keywords
786
847
  // or when the query directly matches a group's tool NAME (not description).
787
848
  // This prevents over-activation from incidental description matches.
788
- const newlyActivated = [];
789
- if (query && this.toolExposureProfile === 'compact') {
790
- for (const [groupName, group] of Object.entries(TOOL_GROUPS)) {
791
- if (this.activeGroups.has(groupName))
792
- continue;
793
- // Activate if query matches a group keyword
794
- const hasMatchingKeyword = group.keywords.some((kw) => query.includes(kw) || kw.includes(query));
795
- // Or if query matches a tool NAME in the group (strict name-only match)
796
- const hasMatchingToolName = group.tools.some((t) => t.toLowerCase().includes(query));
797
- if (hasMatchingKeyword || hasMatchingToolName) {
798
- this.activeGroups.add(groupName);
799
- newlyActivated.push(groupName);
800
- }
801
- }
802
- // Notify clients that tool list changed so they re-fetch
803
- if (newlyActivated.length > 0) {
804
- this.cachedToolDefinitions = []; // Clear cache to force rebuild
805
- this.server.sendToolListChanged().catch(() => { });
806
- }
807
- }
808
- return {
809
- content: [{
810
- type: 'text',
811
- text: JSON.stringify({
812
- profile: this.toolExposureProfile,
813
- totalTools: tools.length,
814
- query: query || null,
815
- returned: items.length,
816
- activeGroups: Array.from(this.activeGroups),
817
- newlyActivated: newlyActivated.length > 0 ? newlyActivated : undefined,
818
- tools: items,
819
- }, null, 2),
820
- }],
821
- };
849
+ const newlyActivated = this.autoActivateMatchingGroups(query);
850
+ return this.jsonTextResponse({
851
+ profile: this.toolExposureProfile,
852
+ totalTools: tools.length,
853
+ query: query || null,
854
+ returned: items.length,
855
+ activeGroups: Array.from(this.activeGroups),
856
+ newlyActivated: newlyActivated.length > 0 ? newlyActivated : undefined,
857
+ tools: items,
858
+ });
822
859
  }
823
860
  async handleManageToolGroups(args) {
824
861
  const normalizedArgs = this.normalizeParameters(args || {});
@@ -845,102 +882,55 @@ class GodotServer {
845
882
  const allGroups = [...coreGroups, ...dynamicGroups];
846
883
  const totalCoreTools = coreGroups.reduce((sum, g) => sum + g.toolCount, 0);
847
884
  const totalDynTools = dynamicGroups.reduce((sum, g) => sum + g.toolCount, 0);
848
- return {
849
- content: [{
850
- type: 'text',
851
- text: JSON.stringify({
852
- totalGroups: allGroups.length,
853
- coreGroups: coreGroups.length,
854
- dynamicGroups: dynamicGroups.length,
855
- coreTools: totalCoreTools,
856
- dynamicTools: totalDynTools,
857
- groups: allGroups,
858
- }, null, 2),
859
- }],
860
- };
885
+ return this.jsonTextResponse({
886
+ totalGroups: allGroups.length,
887
+ coreGroups: coreGroups.length,
888
+ dynamicGroups: dynamicGroups.length,
889
+ coreTools: totalCoreTools,
890
+ dynamicTools: totalDynTools,
891
+ groups: allGroups,
892
+ });
861
893
  }
862
894
  case 'activate': {
863
895
  if (groupName && CORE_TOOL_GROUPS[groupName]) {
864
- return {
865
- content: [{ type: 'text', text: JSON.stringify({ error: `'${groupName}' is a core group and always visible. No activation needed.` }) }],
866
- };
896
+ return this.jsonTextResponse({ error: `'${groupName}' is a core group and always visible. No activation needed.` });
867
897
  }
868
898
  if (!groupName || !TOOL_GROUPS[groupName]) {
869
- const available = Object.keys(TOOL_GROUPS).join(', ');
870
- return {
871
- content: [{
872
- type: 'text',
873
- text: JSON.stringify({ error: `Unknown group '${groupName}'. Available dynamic groups: ${available}` }),
874
- }],
875
- };
876
- }
877
- const wasNew = !this.activeGroups.has(groupName);
878
- this.activeGroups.add(groupName);
879
- if (wasNew) {
880
- this.cachedToolDefinitions = [];
881
- this.server.sendToolListChanged().catch(() => { });
899
+ return this.jsonTextResponse({ error: this.getUnknownDynamicGroupError(groupName) });
882
900
  }
883
- return {
884
- content: [{
885
- type: 'text',
886
- text: JSON.stringify({
887
- activated: groupName,
888
- tools: TOOL_GROUPS[groupName].tools,
889
- wasAlreadyActive: !wasNew,
890
- activeGroups: Array.from(this.activeGroups),
891
- }, null, 2),
892
- }],
893
- };
901
+ const wasAlreadyActive = this.setDynamicGroupActivation(groupName, true);
902
+ return this.jsonTextResponse({
903
+ activated: groupName,
904
+ tools: TOOL_GROUPS[groupName].tools,
905
+ wasAlreadyActive,
906
+ activeGroups: Array.from(this.activeGroups),
907
+ });
894
908
  }
895
909
  case 'deactivate': {
896
910
  if (groupName && CORE_TOOL_GROUPS[groupName]) {
897
- return {
898
- content: [{ type: 'text', text: JSON.stringify({ error: `'${groupName}' is a core group and cannot be deactivated.` }) }],
899
- };
911
+ return this.jsonTextResponse({ error: `'${groupName}' is a core group and cannot be deactivated.` });
900
912
  }
901
913
  if (!groupName || !TOOL_GROUPS[groupName]) {
902
- const available = Object.keys(TOOL_GROUPS).join(', ');
903
- return {
904
- content: [{
905
- type: 'text',
906
- text: JSON.stringify({ error: `Unknown group '${groupName}'. Available dynamic groups: ${available}` }),
907
- }],
908
- };
909
- }
910
- const wasActive = this.activeGroups.has(groupName);
911
- this.activeGroups.delete(groupName);
912
- if (wasActive) {
913
- this.cachedToolDefinitions = [];
914
- this.server.sendToolListChanged().catch(() => { });
914
+ return this.jsonTextResponse({ error: this.getUnknownDynamicGroupError(groupName) });
915
915
  }
916
- return {
917
- content: [{
918
- type: 'text',
919
- text: JSON.stringify({
920
- deactivated: groupName,
921
- wasActive,
922
- activeGroups: Array.from(this.activeGroups),
923
- }, null, 2),
924
- }],
925
- };
916
+ const wasActive = this.setDynamicGroupActivation(groupName, false);
917
+ return this.jsonTextResponse({
918
+ deactivated: groupName,
919
+ wasActive,
920
+ activeGroups: Array.from(this.activeGroups),
921
+ });
926
922
  }
927
923
  case 'reset': {
928
924
  const previouslyActive = Array.from(this.activeGroups);
929
925
  this.activeGroups.clear();
930
926
  if (previouslyActive.length > 0) {
931
- this.cachedToolDefinitions = [];
932
- this.server.sendToolListChanged().catch(() => { });
927
+ this.notifyToolListChanged();
933
928
  }
934
- return {
935
- content: [{
936
- type: 'text',
937
- text: JSON.stringify({
938
- reset: true,
939
- deactivated: previouslyActive,
940
- activeGroups: [],
941
- }, null, 2),
942
- }],
943
- };
929
+ return this.jsonTextResponse({
930
+ reset: true,
931
+ deactivated: previouslyActive,
932
+ activeGroups: [],
933
+ });
944
934
  }
945
935
  case 'status':
946
936
  default: {
@@ -959,16 +949,11 @@ class GodotServer {
959
949
  }));
960
950
  const totalCoreTools = coreGroupDetails.reduce((sum, g) => sum + g.tools.length, 0);
961
951
  const totalDynamicTools = activeGroupDetails.reduce((sum, g) => sum + (g.tools?.length || 0), 0);
962
- return {
963
- content: [{
964
- type: 'text',
965
- text: JSON.stringify({
966
- coreGroups: { count: coreGroupDetails.length, tools: totalCoreTools, groups: coreGroupDetails },
967
- dynamicGroups: { activeCount: this.activeGroups.size, tools: totalDynamicTools, groups: activeGroupDetails },
968
- availableDynamicGroups: Object.keys(TOOL_GROUPS),
969
- }, null, 2),
970
- }],
971
- };
952
+ return this.jsonTextResponse({
953
+ coreGroups: { count: coreGroupDetails.length, tools: totalCoreTools, groups: coreGroupDetails },
954
+ dynamicGroups: { activeCount: this.activeGroups.size, tools: totalDynamicTools, groups: activeGroupDetails },
955
+ availableDynamicGroups: this.getAvailableDynamicGroups(),
956
+ });
972
957
  }
973
958
  }
974
959
  }
@@ -999,8 +984,11 @@ class GodotServer {
999
984
  for (const key in params) {
1000
985
  if (Object.prototype.hasOwnProperty.call(params, key)) {
1001
986
  let normalizedKey = key;
1002
- // If the key is in snake_case, convert it to camelCase using our mapping
1003
- if (key.includes('_')) {
987
+ // Preserve sentinel keys like _type, but normalize regular snake_case keys.
988
+ if (key.startsWith('_')) {
989
+ normalizedKey = key;
990
+ }
991
+ else if (key.includes('_')) {
1004
992
  normalizedKey = this.parameterMappings[key] || key.replace(/_([a-zA-Z0-9])/g, (_, letter) => letter.toUpperCase());
1005
993
  }
1006
994
  // Handle nested objects recursively
@@ -1023,8 +1011,10 @@ class GodotServer {
1023
1011
  const result = {};
1024
1012
  for (const key in params) {
1025
1013
  if (Object.prototype.hasOwnProperty.call(params, key)) {
1026
- // Convert camelCase to snake_case
1027
- const snakeKey = this.reverseParameterMappings[key] || key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
1014
+ // Convert camelCase to snake_case while preserving sentinel keys like _type.
1015
+ const snakeKey = key.startsWith('_')
1016
+ ? key
1017
+ : (this.reverseParameterMappings[key] || key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`));
1028
1018
  // Handle nested objects recursively
1029
1019
  if (typeof params[key] === 'object' && params[key] !== null && !Array.isArray(params[key])) {
1030
1020
  result[snakeKey] = this.convertCamelToSnakeCase(params[key]);
@@ -1057,16 +1047,18 @@ class GodotServer {
1057
1047
  }
1058
1048
  }
1059
1049
  try {
1060
- // Serialize the snake_case parameters to a valid JSON string
1050
+ // Serialize parameters into a temp file to avoid shell/cmd JSON escaping issues
1051
+ // (notably Windows command-line parsing of sequences such as \t, \r, and \").
1061
1052
  const paramsJson = JSON.stringify(snakeCaseParams);
1062
- // Escape single quotes in the JSON string to prevent command injection
1063
- const escapedParams = paramsJson.replace(/'/g, "'\\''");
1064
- // On Windows, cmd.exe does not strip single quotes, so we use
1065
- // double quotes and escape them to ensure the JSON is parsed
1066
- // correctly by Godot.
1053
+ const paramsDir = mkdtempSync(join(tmpdir(), 'gopeak-params-'));
1054
+ const paramsFilePath = join(paramsDir, `${operation}.json`);
1055
+ writeFileSync(paramsFilePath, paramsJson, 'utf8');
1056
+ // Escape the params file reference for the current shell.
1057
+ const paramsFileArg = `@file:${paramsFilePath}`;
1058
+ const escapedParams = paramsFileArg.replace(/'/g, "'\\''");
1067
1059
  const isWindows = process.platform === 'win32';
1068
1060
  const quotedParams = isWindows
1069
- ? `\"${paramsJson.replace(/\"/g, '\\"')}\"`
1061
+ ? `\"${paramsFileArg.replace(/\"/g, '\\"')}\"`
1070
1062
  : `'${escapedParams}'`;
1071
1063
  // Add debug arguments if debug mode is enabled
1072
1064
  const debugArgs = this.godotDebugMode ? ['--debug-godot'] : [];
@@ -1083,8 +1075,13 @@ class GodotServer {
1083
1075
  ...debugArgs,
1084
1076
  ].join(' ');
1085
1077
  this.logDebug(`Command: ${cmd}`);
1086
- const { stdout, stderr } = await execAsync(cmd);
1087
- return { stdout, stderr };
1078
+ try {
1079
+ const { stdout, stderr } = await execAsync(cmd);
1080
+ return { stdout, stderr };
1081
+ }
1082
+ finally {
1083
+ rmSync(paramsDir, { recursive: true, force: true });
1084
+ }
1088
1085
  }
1089
1086
  catch (error) {
1090
1087
  // If execAsync throws, it still contains stdout/stderr
@@ -1098,6 +1095,21 @@ class GodotServer {
1098
1095
  throw error;
1099
1096
  }
1100
1097
  }
1098
+ getEditorStatusPayload() {
1099
+ const status = this.godotBridge.getStatus();
1100
+ const isPortConflict = this.bridgeStartupError?.includes('EADDRINUSE') ?? false;
1101
+ return {
1102
+ ...status,
1103
+ bridgeAvailable: this.bridgeStartupError === null,
1104
+ startupError: this.bridgeStartupError,
1105
+ note: isPortConflict
1106
+ ? 'Bridge port is already in use. Another gopeak instance may own the editor bridge, so this server cannot report that editor connection.'
1107
+ : undefined,
1108
+ suggestion: isPortConflict
1109
+ ? 'Stop duplicate gopeak/MCP server instances or re-run the command from the same server process that owns the bridge port.'
1110
+ : undefined,
1111
+ };
1112
+ }
1101
1113
  /**
1102
1114
  * Get the structure of a Godot project
1103
1115
  * @param projectPath Path to the Godot project
@@ -1449,7 +1461,7 @@ class GodotServer {
1449
1461
  return await this.handleViaBridge('modify_resource', normalizedArgs);
1450
1462
  // Editor Plugin Bridge Status
1451
1463
  case 'get_editor_status':
1452
- return { content: [{ type: 'text', text: JSON.stringify(this.godotBridge.getStatus(), null, 2) }] };
1464
+ return { content: [{ type: 'text', text: JSON.stringify(this.getEditorStatusPayload(), null, 2) }] };
1453
1465
  // Project Visualizer Tool
1454
1466
  case 'map_project':
1455
1467
  return await this.handleMapProject(request.params.arguments);
@@ -4705,11 +4717,13 @@ class GodotServer {
4705
4717
  // Bridge startup issues should not take down the stdio MCP server.
4706
4718
  try {
4707
4719
  await this.godotBridge.start();
4720
+ this.bridgeStartupError = null;
4708
4721
  const bridgeStatus = this.godotBridge.getStatus();
4709
4722
  console.error(`[SERVER] Godot Editor Bridge started on ${bridgeStatus.host}:${bridgeStatus.port}`);
4710
4723
  }
4711
4724
  catch (bridgeError) {
4712
4725
  const bridgeMessage = bridgeError instanceof Error ? bridgeError.message : String(bridgeError);
4726
+ this.bridgeStartupError = bridgeMessage;
4713
4727
  console.error(`[SERVER] Warning: Godot Editor Bridge failed to start: ${bridgeMessage}`);
4714
4728
  console.error('[SERVER] Continuing without bridge-backed editor tools.');
4715
4729
  }
@@ -34,7 +34,15 @@ func _init():
34
34
 
35
35
  var operation = args[operation_index]
36
36
  var params_json = args[params_index]
37
-
37
+ if params_json.begins_with("@file:"):
38
+ var params_file_path = params_json.substr(6)
39
+ var params_file = FileAccess.open(params_file_path, FileAccess.READ)
40
+ if params_file == null:
41
+ log_error("Failed to open params file: " + params_file_path)
42
+ quit(1)
43
+ params_json = params_file.get_as_text()
44
+ params_file.close()
45
+
38
46
  log_info("Operation: " + operation)
39
47
  log_debug("Params JSON: " + params_json)
40
48
 
@@ -365,7 +365,7 @@ export function buildToolDefinitions(godotBridgePort) {
365
365
  },
366
366
  properties: {
367
367
  type: 'string',
368
- description: 'Optional properties to set on the node (as JSON string)',
368
+ description: 'Optional properties to set on the node (as JSON string). Tagged Godot values such as {"position":{"type":"Vector2","x":100,"y":200}} are the most explicit form; common typed properties like Vector2 also accept inferred shapes such as {"position":{"x":100,"y":200}} or {"position":[100,200]}.',
369
369
  },
370
370
  },
371
371
  required: ['projectPath', 'scenePath', 'nodeType', 'nodeName'],
@@ -526,7 +526,7 @@ export function buildToolDefinitions(godotBridgePort) {
526
526
  },
527
527
  properties: {
528
528
  type: 'string',
529
- description: 'JSON object of properties to set (e.g., {"position": {"x": 100, "y": 200}, "scale": {"x": 2, "y": 2}})',
529
+ description: 'JSON object of properties to set. Tagged Godot values are the most explicit form (e.g., {"position":{"type":"Vector2","x":100,"y":200},"scale":{"type":"Vector2","x":2,"y":2}}), but typed properties like Vector2 also accept inferred {"x","y"} objects and numeric arrays.',
530
530
  },
531
531
  saveScene: {
532
532
  type: 'boolean',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gopeak",
3
- "version": "2.3.5",
3
+ "version": "2.3.6",
4
4
  "mcpName": "io.github.HaD0Yun/gopeak",
5
5
  "description": "GoPeak — MCP server for Godot Engine with 110+ tools, compact/dynamic profiles, GDScript LSP, DAP debugger, screenshots, input injection, and CC0 asset search.",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "build",
14
+ "scripts/postinstall.mjs",
14
15
  "README.md",
15
16
  "LICENSE"
16
17
  ],
@@ -22,7 +23,8 @@
22
23
  "test:smoke": "node scripts/smoke-test.mjs",
23
24
  "test:integration": "node test-bridge.mjs",
24
25
  "test:dynamic-groups": "node test-dynamic-groups.mjs",
25
- "test:ci": "npm run test:smoke",
26
+ "test:regressions": "node test-regressions.mjs",
27
+ "test:ci": "npm run test:smoke && npm run test:regressions",
26
28
  "ci": "npm run build && npm run typecheck && npm run test:ci",
27
29
  "prepare": "npm run build",
28
30
  "postinstall": "node scripts/postinstall.mjs",
@@ -30,8 +32,9 @@
30
32
  "inspector": "npx @modelcontextprotocol/inspector build/index.js",
31
33
  "pack": "npm pack --dry-run",
32
34
  "version:bump": "node scripts/bump-version.mjs",
33
- "test:setup": "npm run build && node test-setup-hooks.mjs && node test-metadata-consistency.mjs",
34
- "test:metadata": "npm run build && node test-metadata-consistency.mjs"
35
+ "test:setup": "npm run build && node test-setup-hooks.mjs && node test-metadata-consistency.mjs && node test-packaging-consistency.mjs",
36
+ "test:metadata": "npm run build && node test-metadata-consistency.mjs",
37
+ "test:packaging": "npm run build && node test-packaging-consistency.mjs"
35
38
  },
36
39
  "engines": {
37
40
  "node": ">=18"
@@ -50,7 +53,8 @@
50
53
  },
51
54
  "overrides": {
52
55
  "@hono/node-server": "^1.19.11",
53
- "hono": "4.12.7"
56
+ "hono": "4.12.7",
57
+ "path-to-regexp": "8.4.1"
54
58
  },
55
59
  "license": "MIT",
56
60
  "repository": {
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ const OPT_IN_VALUES = new Set(['1', 'true', 'yes', 'on']);
8
+ const shouldInstallHooks = OPT_IN_VALUES.has(String(process.env.GOPEAK_SETUP_HOOKS || '').trim().toLowerCase());
9
+
10
+ if (!shouldInstallHooks) {
11
+ process.exit(0);
12
+ }
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ const cliPath = join(__dirname, '..', 'build', 'cli.js');
17
+
18
+ if (!existsSync(cliPath)) {
19
+ process.exit(0);
20
+ }
21
+
22
+ try {
23
+ execFileSync(process.execPath, [cliPath, 'setup', '--silent'], {
24
+ stdio: 'ignore',
25
+ env: process.env,
26
+ });
27
+ } catch {
28
+ process.exit(0);
29
+ }