mcpforunityserver 8.7.0__py3-none-any.whl → 9.0.0__py3-none-any.whl

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 (53) hide show
  1. main.py +4 -3
  2. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/METADATA +2 -2
  3. mcpforunityserver-9.0.0.dist-info/RECORD +72 -0
  4. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/top_level.txt +0 -1
  5. services/custom_tool_service.py +13 -8
  6. services/resources/active_tool.py +1 -1
  7. services/resources/custom_tools.py +2 -2
  8. services/resources/editor_state.py +283 -30
  9. services/resources/gameobject.py +243 -0
  10. services/resources/layers.py +1 -1
  11. services/resources/prefab_stage.py +1 -1
  12. services/resources/project_info.py +1 -1
  13. services/resources/selection.py +1 -1
  14. services/resources/tags.py +1 -1
  15. services/resources/unity_instances.py +1 -1
  16. services/resources/windows.py +1 -1
  17. services/state/external_changes_scanner.py +3 -4
  18. services/tools/batch_execute.py +24 -9
  19. services/tools/debug_request_context.py +8 -2
  20. services/tools/execute_custom_tool.py +6 -1
  21. services/tools/execute_menu_item.py +6 -3
  22. services/tools/find_gameobjects.py +89 -0
  23. services/tools/find_in_file.py +26 -19
  24. services/tools/manage_asset.py +13 -44
  25. services/tools/manage_components.py +131 -0
  26. services/tools/manage_editor.py +9 -8
  27. services/tools/manage_gameobject.py +115 -79
  28. services/tools/manage_material.py +80 -31
  29. services/tools/manage_prefabs.py +7 -1
  30. services/tools/manage_scene.py +30 -13
  31. services/tools/manage_script.py +62 -19
  32. services/tools/manage_scriptable_object.py +22 -10
  33. services/tools/manage_shader.py +8 -1
  34. services/tools/manage_vfx.py +738 -0
  35. services/tools/preflight.py +15 -12
  36. services/tools/read_console.py +31 -14
  37. services/tools/refresh_unity.py +28 -18
  38. services/tools/run_tests.py +162 -53
  39. services/tools/script_apply_edits.py +15 -7
  40. services/tools/set_active_instance.py +12 -7
  41. services/tools/utils.py +60 -6
  42. transport/legacy/port_discovery.py +2 -2
  43. transport/legacy/unity_connection.py +102 -17
  44. transport/plugin_hub.py +68 -24
  45. transport/unity_instance_middleware.py +4 -3
  46. transport/unity_transport.py +2 -1
  47. mcpforunityserver-8.7.0.dist-info/RECORD +0 -71
  48. routes/__init__.py +0 -0
  49. services/resources/editor_state_v2.py +0 -270
  50. services/tools/test_jobs.py +0 -94
  51. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/WHEEL +0 -0
  52. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/entry_points.txt +0 -0
  53. {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@ from typing import Annotated, Any
5
5
  from urllib.parse import unquote, urlparse
6
6
 
7
7
  from fastmcp import Context
8
+ from mcp.types import ToolAnnotations
8
9
 
9
10
  from services.registry import mcp_for_unity_tool
10
11
  from services.tools import get_unity_instance_from_context
@@ -16,7 +17,7 @@ def _split_uri(uri: str) -> tuple[str, str]:
16
17
  """Split an incoming URI or path into (name, directory) suitable for Unity.
17
18
 
18
19
  Rules:
19
- - unity://path/Assets/... → keep as Assets-relative (after decode/normalize)
20
+ - mcpforunity://path/Assets/... → keep as Assets-relative (after decode/normalize)
20
21
  - file://... → percent-decode, normalize, strip host and leading slashes,
21
22
  then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
22
23
  Otherwise, fall back to original name/dir behavior.
@@ -24,8 +25,8 @@ def _split_uri(uri: str) -> tuple[str, str]:
24
25
  return relative to 'Assets'.
25
26
  """
26
27
  raw_path: str
27
- if uri.startswith("unity://path/"):
28
- raw_path = uri[len("unity://path/"):]
28
+ if uri.startswith("mcpforunity://path/"):
29
+ raw_path = uri[len("mcpforunity://path/"):]
29
30
  elif uri.startswith("file://"):
30
31
  parsed = urlparse(uri)
31
32
  host = (parsed.netloc or "").strip()
@@ -64,14 +65,21 @@ def _split_uri(uri: str) -> tuple[str, str]:
64
65
  return name, directory
65
66
 
66
67
 
67
- @mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.")
68
+ @mcp_for_unity_tool(
69
+ description="Searches a file with a regex pattern and returns line numbers and excerpts.",
70
+ annotations=ToolAnnotations(
71
+ title="Find in File",
72
+ readOnlyHint=True,
73
+ ),
74
+ )
68
75
  async def find_in_file(
69
76
  ctx: Context,
70
77
  uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
71
78
  pattern: Annotated[str, "The regex pattern to search for"],
72
79
  project_root: Annotated[str | None, "Optional project root path"] = None,
73
80
  max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200,
74
- ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True,
81
+ ignore_case: Annotated[bool | str | None,
82
+ "Case insensitive search"] = True,
75
83
  ) -> dict[str, Any]:
76
84
  # project_root is currently unused but kept for interface consistency
77
85
  unity_instance = get_unity_instance_from_context(ctx)
@@ -79,7 +87,7 @@ async def find_in_file(
79
87
  f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
80
88
 
81
89
  name, directory = _split_uri(uri)
82
-
90
+
83
91
  # 1. Read file content via Unity
84
92
  read_resp = await send_with_unity_instance(
85
93
  async_send_command_with_retry,
@@ -103,7 +111,7 @@ async def find_in_file(
103
111
  "utf-8")).decode("utf-8", "replace")
104
112
  except (ValueError, TypeError, base64.binascii.Error):
105
113
  contents = contents or ""
106
-
114
+
107
115
  if contents is None:
108
116
  return {"success": False, "message": "Could not read file content."}
109
117
 
@@ -121,26 +129,26 @@ async def find_in_file(
121
129
  except re.error as e:
122
130
  return {"success": False, "message": f"Invalid regex pattern: {e}"}
123
131
 
124
- # If the regex is not multiline specific (doesn't contain \n literal match logic),
132
+ # If the regex is not multiline specific (doesn't contain \n literal match logic),
125
133
  # we could iterate lines. But users might use multiline regexes.
126
134
  # Let's search the whole content and map back to lines.
127
-
135
+
128
136
  found = list(regex.finditer(contents))
129
-
137
+
130
138
  results = []
131
139
  count = 0
132
-
140
+
133
141
  for m in found:
134
142
  if count >= max_results:
135
143
  break
136
-
144
+
137
145
  start_idx = m.start()
138
146
  end_idx = m.end()
139
-
147
+
140
148
  # Calculate line number
141
149
  # Count newlines up to start_idx
142
150
  line_num = contents.count('\n', 0, start_idx) + 1
143
-
151
+
144
152
  # Get line content for excerpt
145
153
  # Find start of line
146
154
  line_start = contents.rfind('\n', 0, start_idx) + 1
@@ -148,15 +156,15 @@ async def find_in_file(
148
156
  line_end = contents.find('\n', start_idx)
149
157
  if line_end == -1:
150
158
  line_end = len(contents)
151
-
159
+
152
160
  line_content = contents[line_start:line_end]
153
-
161
+
154
162
  # Create excerpt
155
163
  # We can just return the line content as excerpt
156
-
164
+
157
165
  results.append({
158
166
  "line": line_num,
159
- "content": line_content.strip(), # detailed match info?
167
+ "content": line_content.strip(), # detailed match info?
160
168
  "match": m.group(0),
161
169
  "start": start_idx,
162
170
  "end": end_idx
@@ -171,4 +179,3 @@ async def find_in_file(
171
179
  "total_matches": len(found)
172
180
  }
173
181
  }
174
-
@@ -1,15 +1,16 @@
1
1
  """
2
2
  Defines the manage_asset tool for interacting with Unity assets.
3
3
  """
4
- import ast
5
4
  import asyncio
6
5
  import json
7
6
  from typing import Annotated, Any, Literal
8
7
 
9
8
  from fastmcp import Context
9
+ from mcp.types import ToolAnnotations
10
+
10
11
  from services.registry import mcp_for_unity_tool
11
12
  from services.tools import get_unity_instance_from_context
12
- from services.tools.utils import parse_json_payload, coerce_int
13
+ from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
13
14
  from transport.unity_transport import send_with_unity_instance
14
15
  from transport.legacy.unity_connection import async_send_command_with_retry
15
16
  from services.tools.preflight import preflight
@@ -20,7 +21,11 @@ from services.tools.preflight import preflight
20
21
  "Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
21
22
  "Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
22
23
  "`generate_preview=false` (previews can add large base64 blobs)."
23
- )
24
+ ),
25
+ annotations=ToolAnnotations(
26
+ title="Manage Asset",
27
+ destructiveHint=True,
28
+ ),
24
29
  )
25
30
  async def manage_asset(
26
31
  ctx: Context,
@@ -28,8 +33,8 @@ async def manage_asset(
28
33
  path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
29
34
  asset_type: Annotated[str,
30
35
  "Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
31
- properties: Annotated[dict[str, Any] | str,
32
- "Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
36
+ properties: Annotated[dict[str, Any],
37
+ "Dictionary of properties for 'create'/'modify'. Keys are property names, values are property values."] | None = None,
33
38
  destination: Annotated[str,
34
39
  "Target path for 'duplicate'/'move'."] | None = None,
35
40
  generate_preview: Annotated[bool,
@@ -54,46 +59,10 @@ async def manage_asset(
54
59
  if gate is not None:
55
60
  return gate.model_dump()
56
61
 
57
- def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
58
- try:
59
- parsed = json.loads(raw)
60
- if not isinstance(parsed, dict):
61
- return None, f"manage_asset: properties JSON must decode to a dictionary; received {type(parsed)}"
62
- return parsed, "JSON"
63
- except json.JSONDecodeError as json_err:
64
- try:
65
- parsed = ast.literal_eval(raw)
66
- if not isinstance(parsed, dict):
67
- return None, f"manage_asset: properties string must evaluate to a dictionary; received {type(parsed)}"
68
- return parsed, "Python literal"
69
- except (ValueError, SyntaxError) as literal_err:
70
- return None, f"manage_asset: failed to parse properties string. JSON error: {json_err}; literal_eval error: {literal_err}"
71
-
72
- async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[str, Any] | None, str | None]:
73
- if raw is None:
74
- return {}, None
75
- if isinstance(raw, dict):
76
- await ctx.info(f"manage_asset: received properties as dict with keys: {list(raw.keys())}")
77
- return raw, None
78
- if isinstance(raw, str):
79
- await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
80
- # Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
81
- parsed = parse_json_payload(raw)
82
- if isinstance(parsed, dict):
83
- await ctx.info("manage_asset: coerced properties using centralized parser")
84
- return parsed, None
85
-
86
- # Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
87
- parsed, source = _parse_properties_string(raw)
88
- if parsed is None:
89
- return None, source
90
- await ctx.info(f"manage_asset: coerced properties from {source} string to dict")
91
- return parsed, None
92
- return None, f"manage_asset: properties must be a dict or JSON string; received {type(raw)}"
93
-
94
- properties, parse_error = await _normalize_properties(properties)
62
+ # --- Normalize properties using robust module-level helper ---
63
+ properties, parse_error = normalize_properties(properties)
95
64
  if parse_error:
96
- await ctx.error(parse_error)
65
+ await ctx.error(f"manage_asset: {parse_error}")
97
66
  return {"success": False, "message": parse_error}
98
67
 
99
68
  page_size = coerce_int(page_size)
@@ -0,0 +1,131 @@
1
+ """
2
+ Tool for managing components on GameObjects in Unity.
3
+ Supports add, remove, and set_property operations.
4
+ """
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from fastmcp import Context
8
+ from services.registry import mcp_for_unity_tool
9
+ from services.tools import get_unity_instance_from_context
10
+ from transport.unity_transport import send_with_unity_instance
11
+ from transport.legacy.unity_connection import async_send_command_with_retry
12
+ from services.tools.utils import parse_json_payload, normalize_properties
13
+ from services.tools.preflight import preflight
14
+
15
+
16
+ @mcp_for_unity_tool(
17
+ description="Manages components on GameObjects (add, remove, set_property). For reading component data, use the mcpforunity://scene/gameobject/{id}/components resource."
18
+ )
19
+ async def manage_components(
20
+ ctx: Context,
21
+ action: Annotated[
22
+ Literal["add", "remove", "set_property"],
23
+ "Action to perform: add (add component), remove (remove component), set_property (set component property)"
24
+ ],
25
+ target: Annotated[
26
+ str | int,
27
+ "Target GameObject - instance ID (preferred) or name/path"
28
+ ],
29
+ component_type: Annotated[
30
+ str,
31
+ "Component type name (e.g., 'Rigidbody', 'BoxCollider', 'MyScript')"
32
+ ],
33
+ search_method: Annotated[
34
+ Literal["by_id", "by_name", "by_path"],
35
+ "How to find the target GameObject"
36
+ ] | None = None,
37
+ # For set_property action - single property
38
+ property: Annotated[str,
39
+ "Property name to set (for set_property action)"] | None = None,
40
+ value: Annotated[Any,
41
+ "Value to set (for set_property action)"] | None = None,
42
+ # For add/set_property - multiple properties
43
+ properties: Annotated[
44
+ dict[str, Any],
45
+ "Dictionary of property names to values. Example: {\"mass\": 5.0, \"useGravity\": false}"
46
+ ] | None = None,
47
+ ) -> dict[str, Any]:
48
+ """
49
+ Manage components on GameObjects.
50
+
51
+ Actions:
52
+ - add: Add a new component to a GameObject
53
+ - remove: Remove a component from a GameObject
54
+ - set_property: Set one or more properties on a component
55
+
56
+ Examples:
57
+ - Add Rigidbody: action="add", target="Player", component_type="Rigidbody"
58
+ - Remove BoxCollider: action="remove", target=-12345, component_type="BoxCollider"
59
+ - Set single property: action="set_property", target="Enemy", component_type="Rigidbody", property="mass", value=5.0
60
+ - Set multiple properties: action="set_property", target="Enemy", component_type="Rigidbody", properties={"mass": 5.0, "useGravity": false}
61
+ """
62
+ unity_instance = get_unity_instance_from_context(ctx)
63
+
64
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
65
+ if gate is not None:
66
+ return gate.model_dump()
67
+
68
+ if not action:
69
+ return {
70
+ "success": False,
71
+ "message": "Missing required parameter 'action'. Valid actions: add, remove, set_property"
72
+ }
73
+
74
+ if not target:
75
+ return {
76
+ "success": False,
77
+ "message": "Missing required parameter 'target'. Specify GameObject instance ID or name."
78
+ }
79
+
80
+ if not component_type:
81
+ return {
82
+ "success": False,
83
+ "message": "Missing required parameter 'component_type'. Specify the component type name."
84
+ }
85
+
86
+ # --- Normalize properties with detailed error handling ---
87
+ properties, props_error = normalize_properties(properties)
88
+ if props_error:
89
+ return {"success": False, "message": props_error}
90
+
91
+ # --- Validate value parameter for serialization issues ---
92
+ if value is not None and isinstance(value, str) and value in ("[object Object]", "undefined"):
93
+ return {"success": False, "message": f"value received invalid input: '{value}'. Expected an actual value."}
94
+
95
+ try:
96
+ params = {
97
+ "action": action,
98
+ "target": target,
99
+ "componentType": component_type,
100
+ }
101
+
102
+ if search_method:
103
+ params["searchMethod"] = search_method
104
+
105
+ if action == "set_property":
106
+ if property and value is not None:
107
+ params["property"] = property
108
+ params["value"] = value
109
+ if properties:
110
+ params["properties"] = properties
111
+
112
+ if action == "add" and properties:
113
+ params["properties"] = properties
114
+
115
+ response = await send_with_unity_instance(
116
+ async_send_command_with_retry,
117
+ unity_instance,
118
+ "manage_components",
119
+ params,
120
+ )
121
+
122
+ if isinstance(response, dict) and response.get("success"):
123
+ return {
124
+ "success": True,
125
+ "message": response.get("message", f"Component {action} successful."),
126
+ "data": response.get("data")
127
+ }
128
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
129
+
130
+ except Exception as e:
131
+ return {"success": False, "message": f"Error managing component: {e!s}"}
@@ -1,6 +1,8 @@
1
1
  from typing import Annotated, Any, Literal
2
2
 
3
3
  from fastmcp import Context
4
+ from mcp.types import ToolAnnotations
5
+
4
6
  from services.registry import mcp_for_unity_tool
5
7
  from core.telemetry import is_telemetry_enabled, record_tool_usage
6
8
  from services.tools import get_unity_instance_from_context
@@ -10,7 +12,10 @@ from services.tools.utils import coerce_bool
10
12
 
11
13
 
12
14
  @mcp_for_unity_tool(
13
- description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted."
15
+ description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer.",
16
+ annotations=ToolAnnotations(
17
+ title="Manage Editor",
18
+ ),
14
19
  )
15
20
  async def manage_editor(
16
21
  ctx: Context,
@@ -41,13 +46,9 @@ async def manage_editor(
41
46
  params = {
42
47
  "action": action,
43
48
  "waitForCompletion": wait_for_completion,
44
- "toolName": tool_name, # Corrected parameter name to match C#
45
- "tagName": tag_name, # Pass tag name
46
- "layerName": layer_name, # Pass layer name
47
- # Add other parameters based on the action being performed
48
- # "width": width,
49
- # "height": height,
50
- # etc.
49
+ "toolName": tool_name,
50
+ "tagName": tag_name,
51
+ "layerName": layer_name,
51
52
  }
52
53
  params = {k: v for k, v in params.items() if v is not None}
53
54
 
@@ -1,8 +1,10 @@
1
1
  import json
2
2
  import math
3
- from typing import Annotated, Any, Literal, Union
3
+ from typing import Annotated, Any, Literal
4
4
 
5
5
  from fastmcp import Context
6
+ from mcp.types import ToolAnnotations
7
+
6
8
  from services.registry import mcp_for_unity_tool
7
9
  from services.tools import get_unity_instance_from_context
8
10
  from transport.unity_transport import send_with_unity_instance
@@ -11,12 +13,86 @@ from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
11
13
  from services.tools.preflight import preflight
12
14
 
13
15
 
16
+ def _normalize_vector(value: Any, default: Any = None) -> list[float] | None:
17
+ """
18
+ Robustly normalize a vector parameter to [x, y, z] format.
19
+ Handles: list, tuple, JSON string, comma-separated string.
20
+ Returns None if parsing fails.
21
+ """
22
+ if value is None:
23
+ return default
24
+
25
+ # If already a list/tuple with 3 elements, convert to floats
26
+ if isinstance(value, (list, tuple)) and len(value) == 3:
27
+ try:
28
+ vec = [float(value[0]), float(value[1]), float(value[2])]
29
+ return vec if all(math.isfinite(n) for n in vec) else default
30
+ except (ValueError, TypeError):
31
+ return default
32
+
33
+ # Try parsing as JSON string
34
+ if isinstance(value, str):
35
+ parsed = parse_json_payload(value)
36
+ if isinstance(parsed, list) and len(parsed) == 3:
37
+ try:
38
+ vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
39
+ return vec if all(math.isfinite(n) for n in vec) else default
40
+ except (ValueError, TypeError):
41
+ pass
42
+
43
+ # Handle legacy comma-separated strings "1,2,3" or "[1,2,3]"
44
+ s = value.strip()
45
+ if s.startswith("[") and s.endswith("]"):
46
+ s = s[1:-1]
47
+ parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
48
+ if len(parts) == 3:
49
+ try:
50
+ vec = [float(parts[0]), float(parts[1]), float(parts[2])]
51
+ return vec if all(math.isfinite(n) for n in vec) else default
52
+ except (ValueError, TypeError):
53
+ pass
54
+
55
+ return default
56
+
57
+
58
+ def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any]] | None, str | None]:
59
+ """
60
+ Robustly normalize component_properties to a dict.
61
+ Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
62
+ """
63
+ if value is None:
64
+ return None, None
65
+
66
+ # Already a dict - validate structure
67
+ if isinstance(value, dict):
68
+ return value, None
69
+
70
+ # Try parsing as JSON string
71
+ if isinstance(value, str):
72
+ # Check for obviously invalid values
73
+ if value in ("[object Object]", "undefined", "null", ""):
74
+ return None, f"component_properties received invalid value: '{value}'. Expected a JSON object like {{\"ComponentName\": {{\"property\": value}}}}"
75
+
76
+ parsed = parse_json_payload(value)
77
+ if isinstance(parsed, dict):
78
+ return parsed, None
79
+
80
+ return None, f"component_properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
81
+
82
+ return None, f"component_properties must be a dict or JSON string, got {type(value).__name__}"
83
+
84
+
14
85
  @mcp_for_unity_tool(
15
- description="Performs CRUD operations on GameObjects and components."
86
+ description="Performs CRUD operations on GameObjects. Actions: create, modify, delete, duplicate, move_relative. For finding GameObjects use find_gameobjects tool. For component operations use manage_components tool.",
87
+ annotations=ToolAnnotations(
88
+ title="Manage GameObject",
89
+ destructiveHint=True,
90
+ ),
16
91
  )
17
92
  async def manage_gameobject(
18
93
  ctx: Context,
19
- action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component", "duplicate", "move_relative"], "Perform CRUD operations on GameObjects and components."] | None = None,
94
+ action: Annotated[Literal["create", "modify", "delete", "duplicate",
95
+ "move_relative"], "Action to perform on GameObject."] | None = None,
20
96
  target: Annotated[str,
21
97
  "GameObject identifier by name or path for modify/delete/component actions"] | None = None,
22
98
  search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
@@ -27,12 +103,12 @@ async def manage_gameobject(
27
103
  "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
28
104
  parent: Annotated[str,
29
105
  "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
30
- position: Annotated[Union[list[float], str],
31
- "Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
32
- rotation: Annotated[Union[list[float], str],
33
- "Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
34
- scale: Annotated[Union[list[float], str],
35
- "Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
106
+ position: Annotated[list[float],
107
+ "Position as [x, y, z] array"] | None = None,
108
+ rotation: Annotated[list[float],
109
+ "Rotation as [x, y, z] euler angles array"] | None = None,
110
+ scale: Annotated[list[float],
111
+ "Scale as [x, y, z] array"] | None = None,
36
112
  components_to_add: Annotated[list[str],
37
113
  "List of component names to add"] | None = None,
38
114
  primitive_type: Annotated[str,
@@ -48,7 +124,7 @@ async def manage_gameobject(
48
124
  layer: Annotated[str, "Layer name"] | None = None,
49
125
  components_to_remove: Annotated[list[str],
50
126
  "List of component names to remove"] | None = None,
51
- component_properties: Annotated[Union[dict[str, dict[str, Any]], str],
127
+ component_properties: Annotated[dict[str, dict[str, Any]],
52
128
  """Dictionary of component names to their properties to set. For example:
53
129
  `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
54
130
  `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
@@ -70,24 +146,28 @@ async def manage_gameobject(
70
146
  includeNonPublicSerialized: Annotated[bool | str,
71
147
  "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
72
148
  # --- Paging/safety for get_components ---
73
- page_size: Annotated[int | str, "Page size for get_components paging."] | None = None,
74
- cursor: Annotated[int | str, "Opaque cursor for get_components paging (offset)."] | None = None,
75
- max_components: Annotated[int | str, "Hard cap on returned components per request (safety)."] | None = None,
76
- include_properties: Annotated[bool | str, "If true, include serialized component properties (bounded)."] | None = None,
149
+ page_size: Annotated[int | str,
150
+ "Page size for get_components paging."] | None = None,
151
+ cursor: Annotated[int | str,
152
+ "Opaque cursor for get_components paging (offset)."] | None = None,
153
+ max_components: Annotated[int | str,
154
+ "Hard cap on returned components per request (safety)."] | None = None,
155
+ include_properties: Annotated[bool | str,
156
+ "If true, include serialized component properties (bounded)."] | None = None,
77
157
  # --- Parameters for 'duplicate' ---
78
158
  new_name: Annotated[str,
79
159
  "New name for the duplicated object (default: SourceName_Copy)"] | None = None,
80
- offset: Annotated[Union[list[float], str],
81
- "Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None,
160
+ offset: Annotated[list[float],
161
+ "Offset from original/reference position as [x, y, z] array"] | None = None,
82
162
  # --- Parameters for 'move_relative' ---
83
163
  reference_object: Annotated[str,
84
- "Reference object for relative movement (required for move_relative)"] | None = None,
164
+ "Reference object for relative movement (required for move_relative)"] | None = None,
85
165
  direction: Annotated[Literal["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"],
86
- "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None,
166
+ "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None,
87
167
  distance: Annotated[float,
88
- "Distance to move in the specified direction (default: 1.0)"] | None = None,
168
+ "Distance to move in the specified direction (default: 1.0)"] | None = None,
89
169
  world_space: Annotated[bool | str,
90
- "If True (default), use world space directions; if False, use reference object's local directions"] | None = None,
170
+ "If True (default), use world space directions; if False, use reference object's local directions"] | None = None,
91
171
  ) -> dict[str, Any]:
92
172
  # Get active instance from session state
93
173
  # Removed session_state import
@@ -100,44 +180,16 @@ async def manage_gameobject(
100
180
  if action is None:
101
181
  return {
102
182
  "success": False,
103
- "message": "Missing required parameter 'action'. Valid actions: create, modify, delete, find, add_component, remove_component, set_component_property, get_components, get_component, duplicate, move_relative"
183
+ "message": "Missing required parameter 'action'. Valid actions: create, modify, delete, duplicate, move_relative. For finding GameObjects use find_gameobjects tool. For component operations use manage_components tool."
104
184
  }
105
185
 
106
- # Coercers to tolerate stringified booleans and vectors
107
- def _coerce_vec(value, default=None):
108
- if value is None:
109
- return default
110
-
111
- # First try to parse if it's a string
112
- val = parse_json_payload(value)
113
-
114
- def _to_vec3(parts):
115
- try:
116
- vec = [float(parts[0]), float(parts[1]), float(parts[2])]
117
- except (ValueError, TypeError):
118
- return default
119
- return vec if all(math.isfinite(n) for n in vec) else default
120
-
121
- if isinstance(val, list) and len(val) == 3:
122
- return _to_vec3(val)
123
-
124
- # Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays)
125
- if isinstance(val, str):
126
- s = val.strip()
127
- # minimal tolerant parse for "[x,y,z]" or "x,y,z"
128
- if s.startswith("[") and s.endswith("]"):
129
- s = s[1:-1]
130
- # support "x,y,z" and "x y z"
131
- parts = [p.strip()
132
- for p in (s.split(",") if "," in s else s.split())]
133
- if len(parts) == 3:
134
- return _to_vec3(parts)
135
- return default
186
+ # --- Normalize vector parameters using robust helper ---
187
+ position = _normalize_vector(position)
188
+ rotation = _normalize_vector(rotation)
189
+ scale = _normalize_vector(scale)
190
+ offset = _normalize_vector(offset)
136
191
 
137
- position = _coerce_vec(position, default=position)
138
- rotation = _coerce_vec(rotation, default=rotation)
139
- scale = _coerce_vec(scale, default=scale)
140
- offset = _coerce_vec(offset, default=offset)
192
+ # --- Normalize boolean parameters ---
141
193
  save_as_prefab = coerce_bool(save_as_prefab)
142
194
  set_active = coerce_bool(set_active)
143
195
  find_all = coerce_bool(find_all)
@@ -146,36 +198,20 @@ async def manage_gameobject(
146
198
  includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
147
199
  include_properties = coerce_bool(include_properties)
148
200
  world_space = coerce_bool(world_space, default=True)
149
- # If coercion fails, omit these fields (None) rather than preserving invalid input.
201
+
202
+ # --- Normalize integer parameters ---
150
203
  page_size = coerce_int(page_size, default=None)
151
204
  cursor = coerce_int(cursor, default=None)
152
205
  max_components = coerce_int(max_components, default=None)
153
206
 
154
- # Coerce 'component_properties' from JSON string to dict for client compatibility
155
- component_properties = parse_json_payload(component_properties)
156
-
157
- # Ensure final type is a dict (object) if provided
158
- if component_properties is not None and not isinstance(component_properties, dict):
159
- return {"success": False, "message": "component_properties must be a JSON object (dict)."}
160
-
161
- try:
162
- # Map tag to search_term when search_method is by_tag for backward compatibility
163
- if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
164
- search_term = tag
207
+ # --- Normalize component_properties with detailed error handling ---
208
+ component_properties, comp_props_error = _normalize_component_properties(
209
+ component_properties)
210
+ if comp_props_error:
211
+ return {"success": False, "message": comp_props_error}
165
212
 
213
+ try:
166
214
  # Validate parameter usage to prevent silent failures
167
- if action == "find":
168
- if name is not None:
169
- return {
170
- "success": False,
171
- "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'"
172
- }
173
- if search_term is None:
174
- return {
175
- "success": False,
176
- "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find."
177
- }
178
-
179
215
  if action in ["create", "modify"]:
180
216
  if search_term is not None:
181
217
  return {