mcpforunityserver 8.5.0__py3-none-any.whl → 9.1.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 (79) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +207 -62
  26. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
  27. mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
  28. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
  31. services/custom_tool_service.py +179 -19
  32. services/resources/__init__.py +6 -1
  33. services/resources/active_tool.py +1 -1
  34. services/resources/custom_tools.py +2 -2
  35. services/resources/editor_state.py +283 -21
  36. services/resources/gameobject.py +243 -0
  37. services/resources/layers.py +1 -1
  38. services/resources/prefab_stage.py +1 -1
  39. services/resources/project_info.py +1 -1
  40. services/resources/selection.py +1 -1
  41. services/resources/tags.py +1 -1
  42. services/resources/unity_instances.py +1 -1
  43. services/resources/windows.py +1 -1
  44. services/state/external_changes_scanner.py +245 -0
  45. services/tools/__init__.py +6 -1
  46. services/tools/batch_execute.py +24 -9
  47. services/tools/debug_request_context.py +8 -2
  48. services/tools/execute_custom_tool.py +6 -1
  49. services/tools/execute_menu_item.py +6 -3
  50. services/tools/find_gameobjects.py +89 -0
  51. services/tools/find_in_file.py +26 -19
  52. services/tools/manage_asset.py +19 -43
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +120 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +34 -13
  59. services/tools/manage_script.py +62 -19
  60. services/tools/manage_scriptable_object.py +22 -10
  61. services/tools/manage_shader.py +8 -1
  62. services/tools/manage_vfx.py +738 -0
  63. services/tools/preflight.py +110 -0
  64. services/tools/read_console.py +81 -18
  65. services/tools/refresh_unity.py +153 -0
  66. services/tools/run_tests.py +202 -41
  67. services/tools/script_apply_edits.py +15 -7
  68. services/tools/set_active_instance.py +12 -7
  69. services/tools/utils.py +60 -6
  70. transport/legacy/port_discovery.py +2 -2
  71. transport/legacy/unity_connection.py +129 -26
  72. transport/plugin_hub.py +191 -19
  73. transport/unity_instance_middleware.py +93 -2
  74. transport/unity_transport.py +17 -6
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
  78. routes/__init__.py +0 -0
  79. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,98 @@
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
9
11
  from transport.legacy.unity_connection import async_send_command_with_retry
10
12
  from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
13
+ from services.tools.preflight import preflight
14
+
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__}"
11
83
 
12
84
 
13
85
  @mcp_for_unity_tool(
14
- 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
+ ),
15
91
  )
16
92
  async def manage_gameobject(
17
93
  ctx: Context,
18
- 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,
19
96
  target: Annotated[str,
20
97
  "GameObject identifier by name or path for modify/delete/component actions"] | None = None,
21
98
  search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
@@ -26,12 +103,12 @@ async def manage_gameobject(
26
103
  "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
27
104
  parent: Annotated[str,
28
105
  "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
29
- position: Annotated[Union[list[float], str],
30
- "Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
31
- rotation: Annotated[Union[list[float], str],
32
- "Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
33
- scale: Annotated[Union[list[float], str],
34
- "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,
35
112
  components_to_add: Annotated[list[str],
36
113
  "List of component names to add"] | None = None,
37
114
  primitive_type: Annotated[str,
@@ -47,7 +124,7 @@ async def manage_gameobject(
47
124
  layer: Annotated[str, "Layer name"] | None = None,
48
125
  components_to_remove: Annotated[list[str],
49
126
  "List of component names to remove"] | None = None,
50
- component_properties: Annotated[Union[dict[str, dict[str, Any]], str],
127
+ component_properties: Annotated[dict[str, dict[str, Any]],
51
128
  """Dictionary of component names to their properties to set. For example:
52
129
  `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
53
130
  `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
@@ -69,70 +146,50 @@ async def manage_gameobject(
69
146
  includeNonPublicSerialized: Annotated[bool | str,
70
147
  "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
71
148
  # --- Paging/safety for get_components ---
72
- page_size: Annotated[int | str, "Page size for get_components paging."] | None = None,
73
- cursor: Annotated[int | str, "Opaque cursor for get_components paging (offset)."] | None = None,
74
- max_components: Annotated[int | str, "Hard cap on returned components per request (safety)."] | None = None,
75
- 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,
76
157
  # --- Parameters for 'duplicate' ---
77
158
  new_name: Annotated[str,
78
159
  "New name for the duplicated object (default: SourceName_Copy)"] | None = None,
79
- offset: Annotated[Union[list[float], str],
80
- "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,
81
162
  # --- Parameters for 'move_relative' ---
82
163
  reference_object: Annotated[str,
83
- "Reference object for relative movement (required for move_relative)"] | None = None,
164
+ "Reference object for relative movement (required for move_relative)"] | None = None,
84
165
  direction: Annotated[Literal["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"],
85
- "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None,
166
+ "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None,
86
167
  distance: Annotated[float,
87
- "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,
88
169
  world_space: Annotated[bool | str,
89
- "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,
90
171
  ) -> dict[str, Any]:
91
172
  # Get active instance from session state
92
173
  # Removed session_state import
93
174
  unity_instance = get_unity_instance_from_context(ctx)
94
175
 
176
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
177
+ if gate is not None:
178
+ return gate.model_dump()
179
+
95
180
  if action is None:
96
181
  return {
97
182
  "success": False,
98
- "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."
99
184
  }
100
185
 
101
- # Coercers to tolerate stringified booleans and vectors
102
- def _coerce_vec(value, default=None):
103
- if value is None:
104
- return default
105
-
106
- # First try to parse if it's a string
107
- val = parse_json_payload(value)
108
-
109
- def _to_vec3(parts):
110
- try:
111
- vec = [float(parts[0]), float(parts[1]), float(parts[2])]
112
- except (ValueError, TypeError):
113
- return default
114
- return vec if all(math.isfinite(n) for n in vec) else default
115
-
116
- if isinstance(val, list) and len(val) == 3:
117
- return _to_vec3(val)
118
-
119
- # Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays)
120
- if isinstance(val, str):
121
- s = val.strip()
122
- # minimal tolerant parse for "[x,y,z]" or "x,y,z"
123
- if s.startswith("[") and s.endswith("]"):
124
- s = s[1:-1]
125
- # support "x,y,z" and "x y z"
126
- parts = [p.strip()
127
- for p in (s.split(",") if "," in s else s.split())]
128
- if len(parts) == 3:
129
- return _to_vec3(parts)
130
- 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)
131
191
 
132
- position = _coerce_vec(position, default=position)
133
- rotation = _coerce_vec(rotation, default=rotation)
134
- scale = _coerce_vec(scale, default=scale)
135
- offset = _coerce_vec(offset, default=offset)
192
+ # --- Normalize boolean parameters ---
136
193
  save_as_prefab = coerce_bool(save_as_prefab)
137
194
  set_active = coerce_bool(set_active)
138
195
  find_all = coerce_bool(find_all)
@@ -141,36 +198,20 @@ async def manage_gameobject(
141
198
  includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
142
199
  include_properties = coerce_bool(include_properties)
143
200
  world_space = coerce_bool(world_space, default=True)
144
- # If coercion fails, omit these fields (None) rather than preserving invalid input.
201
+
202
+ # --- Normalize integer parameters ---
145
203
  page_size = coerce_int(page_size, default=None)
146
204
  cursor = coerce_int(cursor, default=None)
147
205
  max_components = coerce_int(max_components, default=None)
148
206
 
149
- # Coerce 'component_properties' from JSON string to dict for client compatibility
150
- component_properties = parse_json_payload(component_properties)
151
-
152
- # Ensure final type is a dict (object) if provided
153
- if component_properties is not None and not isinstance(component_properties, dict):
154
- return {"success": False, "message": "component_properties must be a JSON object (dict)."}
155
-
156
- try:
157
- # Map tag to search_term when search_method is by_tag for backward compatibility
158
- if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
159
- 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}
160
212
 
213
+ try:
161
214
  # Validate parameter usage to prevent silent failures
162
- if action == "find":
163
- if name is not None:
164
- return {
165
- "success": False,
166
- "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'"
167
- }
168
- if search_term is None:
169
- return {
170
- "success": False,
171
- "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find."
172
- }
173
-
174
215
  if action in ["create", "modify"]:
175
216
  if search_term is not None:
176
217
  return {
@@ -2,18 +2,57 @@
2
2
  Defines the manage_material tool for interacting with Unity materials.
3
3
  """
4
4
  import json
5
- from typing import Annotated, Any, Literal, Union
5
+ from typing import Annotated, Any, Literal
6
6
 
7
7
  from fastmcp import Context
8
+ from mcp.types import ToolAnnotations
9
+
8
10
  from services.registry import mcp_for_unity_tool
9
11
  from services.tools import get_unity_instance_from_context
10
- from services.tools.utils import parse_json_payload
12
+ from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
11
13
  from transport.unity_transport import send_with_unity_instance
12
14
  from transport.legacy.unity_connection import async_send_command_with_retry
13
15
 
14
16
 
17
+ def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]:
18
+ """
19
+ Normalize color parameter to [r, g, b] or [r, g, b, a] format.
20
+ Returns (parsed_color, error_message).
21
+ """
22
+ if value is None:
23
+ return None, None
24
+
25
+ # Already a list - validate
26
+ if isinstance(value, (list, tuple)):
27
+ if len(value) in (3, 4):
28
+ try:
29
+ return [float(c) for c in value], None
30
+ except (ValueError, TypeError):
31
+ return None, f"color values must be numbers, got {value}"
32
+ return None, f"color must have 3 or 4 components, got {len(value)}"
33
+
34
+ # Try parsing as string
35
+ if isinstance(value, str):
36
+ if value in ("[object Object]", "undefined", "null", ""):
37
+ return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]"
38
+
39
+ parsed = parse_json_payload(value)
40
+ if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
41
+ try:
42
+ return [float(c) for c in parsed], None
43
+ except (ValueError, TypeError):
44
+ return None, f"color values must be numbers, got {parsed}"
45
+ return None, f"Failed to parse color string: {value}"
46
+
47
+ return None, f"color must be a list or JSON string, got {type(value).__name__}"
48
+
49
+
15
50
  @mcp_for_unity_tool(
16
- description="Manages Unity materials (set properties, colors, shaders, etc)."
51
+ description="Manages Unity materials (set properties, colors, shaders, etc). Read-only actions: ping, get_material_info. Modifying actions: create, set_material_shader_property, set_material_color, assign_material_to_renderer, set_renderer_color.",
52
+ annotations=ToolAnnotations(
53
+ title="Manage Material",
54
+ destructiveHint=True,
55
+ ),
17
56
  )
18
57
  async def manage_material(
19
58
  ctx: Context,
@@ -26,45 +65,55 @@ async def manage_material(
26
65
  "set_renderer_color",
27
66
  "get_material_info"
28
67
  ], "Action to perform."],
29
-
68
+
30
69
  # Common / Shared
31
- material_path: Annotated[str, "Path to material asset (Assets/...)"] | None = None,
32
- property: Annotated[str, "Shader property name (e.g., _BaseColor, _MainTex)"] | None = None,
70
+ material_path: Annotated[str,
71
+ "Path to material asset (Assets/...)"] | None = None,
72
+ property: Annotated[str,
73
+ "Shader property name (e.g., _BaseColor, _MainTex)"] | None = None,
33
74
 
34
75
  # create
35
76
  shader: Annotated[str, "Shader name (default: Standard)"] | None = None,
36
- properties: Annotated[Union[dict[str, Any], str], "Initial properties to set {name: value}."] | None = None,
37
-
77
+ properties: Annotated[dict[str, Any],
78
+ "Initial properties to set as {name: value} dict."] | None = None,
79
+
38
80
  # set_material_shader_property
39
- value: Annotated[Union[list, float, int, str, bool, None], "Value to set (color array, float, texture path/instruction)"] | None = None,
40
-
81
+ value: Annotated[list | float | int | str | bool | None,
82
+ "Value to set (color array, float, texture path/instruction)"] | None = None,
83
+
41
84
  # set_material_color / set_renderer_color
42
- color: Annotated[Union[list[float], list[int], str], "Color as [r,g,b] or [r,g,b,a]."] | None = None,
43
-
85
+ color: Annotated[list[float],
86
+ "Color as [r, g, b] or [r, g, b, a] array."] | None = None,
87
+
44
88
  # assign_material_to_renderer / set_renderer_color
45
- target: Annotated[str, "Target GameObject (name, path, or find instruction)"] | None = None,
46
- search_method: Annotated[Literal["by_name", "by_path", "by_tag", "by_layer", "by_component"], "Search method for target"] | None = None,
47
- slot: Annotated[int | str, "Material slot index"] | None = None,
48
- mode: Annotated[Literal["shared", "instance", "property_block"], "Assignment/modification mode"] | None = None,
49
-
89
+ target: Annotated[str,
90
+ "Target GameObject (name, path, or find instruction)"] | None = None,
91
+ search_method: Annotated[Literal["by_name", "by_path", "by_tag",
92
+ "by_layer", "by_component"], "Search method for target"] | None = None,
93
+ slot: Annotated[int, "Material slot index (0-based)"] | None = None,
94
+ mode: Annotated[Literal["shared", "instance", "property_block"],
95
+ "Assignment/modification mode"] | None = None,
96
+
50
97
  ) -> dict[str, Any]:
51
98
  unity_instance = get_unity_instance_from_context(ctx)
52
99
 
53
- # Parse inputs that might be stringified JSON
54
- color = parse_json_payload(color)
55
- properties = parse_json_payload(properties)
100
+ # --- Normalize color with validation ---
101
+ color, color_error = _normalize_color(color)
102
+ if color_error:
103
+ return {"success": False, "message": color_error}
104
+
105
+ # --- Normalize properties with validation ---
106
+ properties, props_error = normalize_properties(properties)
107
+ if props_error:
108
+ return {"success": False, "message": props_error}
109
+
110
+ # --- Normalize value (parse JSON if string) ---
56
111
  value = parse_json_payload(value)
112
+ if isinstance(value, str) and value in ("[object Object]", "undefined"):
113
+ return {"success": False, "message": f"value received invalid input: '{value}'"}
57
114
 
58
- # Coerce slot to int if it's a string
59
- if slot is not None:
60
- if isinstance(slot, str):
61
- try:
62
- slot = int(slot)
63
- except ValueError:
64
- return {
65
- "success": False,
66
- "message": f"Invalid slot value: '{slot}' must be a valid integer"
67
- }
115
+ # --- Normalize slot to int ---
116
+ slot = coerce_int(slot)
68
117
 
69
118
  # Prepare parameters for the C# handler
70
119
  params_dict = {
@@ -91,5 +140,5 @@ async def manage_material(
91
140
  "manage_material",
92
141
  params_dict,
93
142
  )
94
-
143
+
95
144
  return result if isinstance(result, dict) else {"success": False, "message": str(result)}
@@ -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 services.tools import get_unity_instance_from_context
6
8
  from transport.unity_transport import send_with_unity_instance
@@ -9,7 +11,11 @@ from services.tools.utils import coerce_bool
9
11
 
10
12
 
11
13
  @mcp_for_unity_tool(
12
- description="Performs prefab operations (open_stage, close_stage, save_open_stage, create_from_gameobject)."
14
+ description="Performs prefab operations (open_stage, close_stage, save_open_stage, create_from_gameobject).",
15
+ annotations=ToolAnnotations(
16
+ title="Manage Prefabs",
17
+ destructiveHint=True,
18
+ ),
13
19
  )
14
20
  async def manage_prefabs(
15
21
  ctx: Context,
@@ -1,15 +1,22 @@
1
1
  from typing import Annotated, Literal, Any
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 services.tools import get_unity_instance_from_context
6
8
  from services.tools.utils import coerce_int, coerce_bool
7
9
  from transport.unity_transport import send_with_unity_instance
8
10
  from transport.legacy.unity_connection import async_send_command_with_retry
11
+ from services.tools.preflight import preflight
9
12
 
10
13
 
11
14
  @mcp_for_unity_tool(
12
- description="Performs CRUD operations on Unity scenes."
15
+ description="Performs CRUD operations on Unity scenes. Read-only actions: get_hierarchy, get_active, get_build_settings, screenshot. Modifying actions: create, load, save.",
16
+ annotations=ToolAnnotations(
17
+ title="Manage Scene",
18
+ destructiveHint=True,
19
+ ),
13
20
  )
14
21
  async def manage_scene(
15
22
  ctx: Context,
@@ -26,20 +33,32 @@ async def manage_scene(
26
33
  path: Annotated[str, "Scene path."] | None = None,
27
34
  build_index: Annotated[int | str,
28
35
  "Unity build index (quote as string, e.g., '0')."] | None = None,
29
- screenshot_file_name: Annotated[str, "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None,
30
- screenshot_super_size: Annotated[int | str, "Screenshot supersize multiplier (integer ≥1). Optional." ] | None = None,
36
+ screenshot_file_name: Annotated[str,
37
+ "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None,
38
+ screenshot_super_size: Annotated[int | str,
39
+ "Screenshot supersize multiplier (integer ≥1). Optional."] | None = None,
31
40
  # --- get_hierarchy paging/safety ---
32
- parent: Annotated[str | int, "Optional parent GameObject reference (name/path/instanceID) to list direct children."] | None = None,
33
- page_size: Annotated[int | str, "Page size for get_hierarchy paging."] | None = None,
34
- cursor: Annotated[int | str, "Opaque cursor for paging (offset)."] | None = None,
35
- max_nodes: Annotated[int | str, "Hard cap on returned nodes per request (safety)."] | None = None,
36
- max_depth: Annotated[int | str, "Accepted for forward-compatibility; current paging returns a single level."] | None = None,
37
- max_children_per_node: Annotated[int | str, "Child paging hint (safety)."] | None = None,
38
- include_transform: Annotated[bool | str, "If true, include local transform in node summaries."] | None = None,
41
+ parent: Annotated[str | int,
42
+ "Optional parent GameObject reference (name/path/instanceID) to list direct children."] | None = None,
43
+ page_size: Annotated[int | str,
44
+ "Page size for get_hierarchy paging."] | None = None,
45
+ cursor: Annotated[int | str,
46
+ "Opaque cursor for paging (offset)."] | None = None,
47
+ max_nodes: Annotated[int | str,
48
+ "Hard cap on returned nodes per request (safety)."] | None = None,
49
+ max_depth: Annotated[int | str,
50
+ "Accepted for forward-compatibility; current paging returns a single level."] | None = None,
51
+ max_children_per_node: Annotated[int | str,
52
+ "Child paging hint (safety)."] | None = None,
53
+ include_transform: Annotated[bool | str,
54
+ "If true, include local transform in node summaries."] | None = None,
39
55
  ) -> dict[str, Any]:
40
56
  # Get active instance from session state
41
57
  # Removed session_state import
42
58
  unity_instance = get_unity_instance_from_context(ctx)
59
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
60
+ if gate is not None:
61
+ return gate.model_dump()
43
62
  try:
44
63
  coerced_build_index = coerce_int(build_index, default=None)
45
64
  coerced_super_size = coerce_int(screenshot_super_size, default=None)
@@ -47,8 +66,10 @@ async def manage_scene(
47
66
  coerced_cursor = coerce_int(cursor, default=None)
48
67
  coerced_max_nodes = coerce_int(max_nodes, default=None)
49
68
  coerced_max_depth = coerce_int(max_depth, default=None)
50
- coerced_max_children_per_node = coerce_int(max_children_per_node, default=None)
51
- coerced_include_transform = coerce_bool(include_transform, default=None)
69
+ coerced_max_children_per_node = coerce_int(
70
+ max_children_per_node, default=None)
71
+ coerced_include_transform = coerce_bool(
72
+ include_transform, default=None)
52
73
 
53
74
  params: dict[str, Any] = {"action": action}
54
75
  if name:
@@ -61,7 +82,7 @@ async def manage_scene(
61
82
  params["fileName"] = screenshot_file_name
62
83
  if coerced_super_size is not None:
63
84
  params["superSize"] = coerced_super_size
64
-
85
+
65
86
  # get_hierarchy paging/safety params (optional)
66
87
  if parent is not None:
67
88
  params["parent"] = parent