mcpforunityserver 8.2.3__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 (65) hide show
  1. __init__.py +0 -0
  2. core/__init__.py +0 -0
  3. core/config.py +56 -0
  4. core/logging_decorator.py +37 -0
  5. core/telemetry.py +533 -0
  6. core/telemetry_decorator.py +164 -0
  7. main.py +411 -0
  8. mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
  9. mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
  10. mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
  11. mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
  12. mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
  13. mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
  14. models/__init__.py +4 -0
  15. models/models.py +56 -0
  16. models/unity_response.py +47 -0
  17. routes/__init__.py +0 -0
  18. services/__init__.py +0 -0
  19. services/custom_tool_service.py +339 -0
  20. services/registry/__init__.py +22 -0
  21. services/registry/resource_registry.py +53 -0
  22. services/registry/tool_registry.py +51 -0
  23. services/resources/__init__.py +81 -0
  24. services/resources/active_tool.py +47 -0
  25. services/resources/custom_tools.py +57 -0
  26. services/resources/editor_state.py +42 -0
  27. services/resources/layers.py +29 -0
  28. services/resources/menu_items.py +34 -0
  29. services/resources/prefab_stage.py +39 -0
  30. services/resources/project_info.py +39 -0
  31. services/resources/selection.py +55 -0
  32. services/resources/tags.py +30 -0
  33. services/resources/tests.py +55 -0
  34. services/resources/unity_instances.py +122 -0
  35. services/resources/windows.py +47 -0
  36. services/tools/__init__.py +76 -0
  37. services/tools/batch_execute.py +78 -0
  38. services/tools/debug_request_context.py +71 -0
  39. services/tools/execute_custom_tool.py +38 -0
  40. services/tools/execute_menu_item.py +29 -0
  41. services/tools/find_in_file.py +174 -0
  42. services/tools/manage_asset.py +129 -0
  43. services/tools/manage_editor.py +63 -0
  44. services/tools/manage_gameobject.py +240 -0
  45. services/tools/manage_material.py +95 -0
  46. services/tools/manage_prefabs.py +62 -0
  47. services/tools/manage_scene.py +75 -0
  48. services/tools/manage_script.py +602 -0
  49. services/tools/manage_shader.py +64 -0
  50. services/tools/read_console.py +115 -0
  51. services/tools/run_tests.py +108 -0
  52. services/tools/script_apply_edits.py +998 -0
  53. services/tools/set_active_instance.py +112 -0
  54. services/tools/utils.py +60 -0
  55. transport/__init__.py +0 -0
  56. transport/legacy/port_discovery.py +329 -0
  57. transport/legacy/stdio_port_registry.py +65 -0
  58. transport/legacy/unity_connection.py +785 -0
  59. transport/models.py +62 -0
  60. transport/plugin_hub.py +412 -0
  61. transport/plugin_registry.py +123 -0
  62. transport/unity_instance_middleware.py +141 -0
  63. transport/unity_transport.py +103 -0
  64. utils/module_discovery.py +55 -0
  65. utils/reload_sentinel.py +9 -0
@@ -0,0 +1,240 @@
1
+ import json
2
+ import math
3
+ from typing import Annotated, Any, Literal, Union
4
+
5
+ from fastmcp import Context
6
+ from services.registry import mcp_for_unity_tool
7
+ from services.tools import get_unity_instance_from_context
8
+ from transport.unity_transport import send_with_unity_instance
9
+ from transport.legacy.unity_connection import async_send_command_with_retry
10
+ from services.tools.utils import coerce_bool, parse_json_payload
11
+
12
+
13
+ @mcp_for_unity_tool(
14
+ description="Performs CRUD operations on GameObjects and components."
15
+ )
16
+ async def manage_gameobject(
17
+ 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,
19
+ target: Annotated[str,
20
+ "GameObject identifier by name or path for modify/delete/component actions"] | None = None,
21
+ search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
22
+ "How to find objects. Used with 'find' and some 'target' lookups."] | None = None,
23
+ name: Annotated[str,
24
+ "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None,
25
+ tag: Annotated[str,
26
+ "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
27
+ parent: Annotated[str,
28
+ "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,
35
+ components_to_add: Annotated[list[str],
36
+ "List of component names to add"] | None = None,
37
+ primitive_type: Annotated[str,
38
+ "Primitive type for 'create' action"] | None = None,
39
+ save_as_prefab: Annotated[bool | str,
40
+ "If True, saves the created GameObject as a prefab (accepts true/false or 'true'/'false')"] | None = None,
41
+ prefab_path: Annotated[str, "Path for prefab creation"] | None = None,
42
+ prefab_folder: Annotated[str,
43
+ "Folder for prefab creation"] | None = None,
44
+ # --- Parameters for 'modify' ---
45
+ set_active: Annotated[bool | str,
46
+ "If True, sets the GameObject active (accepts true/false or 'true'/'false')"] | None = None,
47
+ layer: Annotated[str, "Layer name"] | None = None,
48
+ components_to_remove: Annotated[list[str],
49
+ "List of component names to remove"] | None = None,
50
+ component_properties: Annotated[Union[dict[str, dict[str, Any]], str],
51
+ """Dictionary of component names to their properties to set. For example:
52
+ `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
53
+ `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
54
+ Example set nested property:
55
+ - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None,
56
+ # --- Parameters for 'find' ---
57
+ search_term: Annotated[str,
58
+ "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None,
59
+ find_all: Annotated[bool | str,
60
+ "If True, finds all GameObjects matching the search term (accepts true/false or 'true'/'false')"] | None = None,
61
+ search_in_children: Annotated[bool | str,
62
+ "If True, searches in children of the GameObject (accepts true/false or 'true'/'false')"] | None = None,
63
+ search_inactive: Annotated[bool | str,
64
+ "If True, searches inactive GameObjects (accepts true/false or 'true'/'false')"] | None = None,
65
+ # -- Component Management Arguments --
66
+ component_name: Annotated[str,
67
+ "Component name for 'add_component' and 'remove_component' actions"] | None = None,
68
+ # Controls whether serialization of private [SerializeField] fields is included
69
+ includeNonPublicSerialized: Annotated[bool | str,
70
+ "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
71
+ # --- Parameters for 'duplicate' ---
72
+ new_name: Annotated[str,
73
+ "New name for the duplicated object (default: SourceName_Copy)"] | None = None,
74
+ offset: Annotated[Union[list[float], str],
75
+ "Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None,
76
+ # --- Parameters for 'move_relative' ---
77
+ reference_object: Annotated[str,
78
+ "Reference object for relative movement (required for move_relative)"] | None = None,
79
+ direction: Annotated[Literal["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"],
80
+ "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None,
81
+ distance: Annotated[float,
82
+ "Distance to move in the specified direction (default: 1.0)"] | None = None,
83
+ world_space: Annotated[bool | str,
84
+ "If True (default), use world space directions; if False, use reference object's local directions"] | None = None,
85
+ ) -> dict[str, Any]:
86
+ # Get active instance from session state
87
+ # Removed session_state import
88
+ unity_instance = get_unity_instance_from_context(ctx)
89
+
90
+ if action is None:
91
+ return {
92
+ "success": False,
93
+ "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"
94
+ }
95
+
96
+ # Coercers to tolerate stringified booleans and vectors
97
+ def _coerce_vec(value, default=None):
98
+ if value is None:
99
+ return default
100
+
101
+ # First try to parse if it's a string
102
+ val = parse_json_payload(value)
103
+
104
+ def _to_vec3(parts):
105
+ try:
106
+ vec = [float(parts[0]), float(parts[1]), float(parts[2])]
107
+ except (ValueError, TypeError):
108
+ return default
109
+ return vec if all(math.isfinite(n) for n in vec) else default
110
+
111
+ if isinstance(val, list) and len(val) == 3:
112
+ return _to_vec3(val)
113
+
114
+ # Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays)
115
+ if isinstance(val, str):
116
+ s = val.strip()
117
+ # minimal tolerant parse for "[x,y,z]" or "x,y,z"
118
+ if s.startswith("[") and s.endswith("]"):
119
+ s = s[1:-1]
120
+ # support "x,y,z" and "x y z"
121
+ parts = [p.strip()
122
+ for p in (s.split(",") if "," in s else s.split())]
123
+ if len(parts) == 3:
124
+ return _to_vec3(parts)
125
+ return default
126
+
127
+ position = _coerce_vec(position, default=position)
128
+ rotation = _coerce_vec(rotation, default=rotation)
129
+ scale = _coerce_vec(scale, default=scale)
130
+ offset = _coerce_vec(offset, default=offset)
131
+ save_as_prefab = coerce_bool(save_as_prefab)
132
+ set_active = coerce_bool(set_active)
133
+ find_all = coerce_bool(find_all)
134
+ search_in_children = coerce_bool(search_in_children)
135
+ search_inactive = coerce_bool(search_inactive)
136
+ includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
137
+ world_space = coerce_bool(world_space, default=True)
138
+
139
+ # Coerce 'component_properties' from JSON string to dict for client compatibility
140
+ component_properties = parse_json_payload(component_properties)
141
+
142
+ # Ensure final type is a dict (object) if provided
143
+ if component_properties is not None and not isinstance(component_properties, dict):
144
+ return {"success": False, "message": "component_properties must be a JSON object (dict)."}
145
+
146
+ try:
147
+ # Map tag to search_term when search_method is by_tag for backward compatibility
148
+ if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
149
+ search_term = tag
150
+
151
+ # Validate parameter usage to prevent silent failures
152
+ if action == "find":
153
+ if name is not None:
154
+ return {
155
+ "success": False,
156
+ "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'"
157
+ }
158
+ if search_term is None:
159
+ return {
160
+ "success": False,
161
+ "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find."
162
+ }
163
+
164
+ if action in ["create", "modify"]:
165
+ if search_term is not None:
166
+ return {
167
+ "success": False,
168
+ "message": f"For '{action}' action, use 'name' parameter, not 'search_term'."
169
+ }
170
+
171
+ # Prepare parameters, removing None values
172
+ params = {
173
+ "action": action,
174
+ "target": target,
175
+ "searchMethod": search_method,
176
+ "name": name,
177
+ "tag": tag,
178
+ "parent": parent,
179
+ "position": position,
180
+ "rotation": rotation,
181
+ "scale": scale,
182
+ "componentsToAdd": components_to_add,
183
+ "primitiveType": primitive_type,
184
+ "saveAsPrefab": save_as_prefab,
185
+ "prefabPath": prefab_path,
186
+ "prefabFolder": prefab_folder,
187
+ "setActive": set_active,
188
+ "layer": layer,
189
+ "componentsToRemove": components_to_remove,
190
+ "componentProperties": component_properties,
191
+ "searchTerm": search_term,
192
+ "findAll": find_all,
193
+ "searchInChildren": search_in_children,
194
+ "searchInactive": search_inactive,
195
+ "componentName": component_name,
196
+ "includeNonPublicSerialized": includeNonPublicSerialized,
197
+ # Parameters for 'duplicate'
198
+ "new_name": new_name,
199
+ "offset": offset,
200
+ # Parameters for 'move_relative'
201
+ "reference_object": reference_object,
202
+ "direction": direction,
203
+ "distance": distance,
204
+ "world_space": world_space,
205
+ }
206
+ params = {k: v for k, v in params.items() if v is not None}
207
+
208
+ # --- Handle Prefab Path Logic ---
209
+ # Check if 'saveAsPrefab' is explicitly True in params
210
+ if action == "create" and params.get("saveAsPrefab"):
211
+ if "prefabPath" not in params:
212
+ if "name" not in params or not params["name"]:
213
+ return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."}
214
+ # Use the provided prefab_folder (which has a default) and the name to construct the path
215
+ constructed_path = f"{prefab_folder}/{params['name']}.prefab"
216
+ # Ensure clean path separators (Unity prefers '/')
217
+ params["prefabPath"] = constructed_path.replace("\\", "/")
218
+ elif not params["prefabPath"].lower().endswith(".prefab"):
219
+ return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"}
220
+ # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided
221
+ # The C# side only needs the final prefabPath
222
+ params.pop("prefabFolder", None)
223
+ # --------------------------------
224
+
225
+ # Use centralized retry helper with instance routing
226
+ response = await send_with_unity_instance(
227
+ async_send_command_with_retry,
228
+ unity_instance,
229
+ "manage_gameobject",
230
+ params,
231
+ )
232
+
233
+ # Check if the response indicates success
234
+ # If the response is not successful, raise an exception with the error message
235
+ if isinstance(response, dict) and response.get("success"):
236
+ return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")}
237
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
238
+
239
+ except Exception as e:
240
+ return {"success": False, "message": f"Python error managing GameObject: {e!s}"}
@@ -0,0 +1,95 @@
1
+ """
2
+ Defines the manage_material tool for interacting with Unity materials.
3
+ """
4
+ import json
5
+ from typing import Annotated, Any, Literal, Union
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 services.tools.utils import parse_json_payload
11
+ from transport.unity_transport import send_with_unity_instance
12
+ from transport.legacy.unity_connection import async_send_command_with_retry
13
+
14
+
15
+ @mcp_for_unity_tool(
16
+ description="Manages Unity materials (set properties, colors, shaders, etc)."
17
+ )
18
+ async def manage_material(
19
+ ctx: Context,
20
+ action: Annotated[Literal[
21
+ "ping",
22
+ "create",
23
+ "set_material_shader_property",
24
+ "set_material_color",
25
+ "assign_material_to_renderer",
26
+ "set_renderer_color",
27
+ "get_material_info"
28
+ ], "Action to perform."],
29
+
30
+ # 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,
33
+
34
+ # create
35
+ 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
+
38
+ # 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
+
41
+ # 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
+
44
+ # 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
+
50
+ ) -> dict[str, Any]:
51
+ unity_instance = get_unity_instance_from_context(ctx)
52
+
53
+ # Parse inputs that might be stringified JSON
54
+ color = parse_json_payload(color)
55
+ properties = parse_json_payload(properties)
56
+ value = parse_json_payload(value)
57
+
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
+ }
68
+
69
+ # Prepare parameters for the C# handler
70
+ params_dict = {
71
+ "action": action.lower(),
72
+ "materialPath": material_path,
73
+ "shader": shader,
74
+ "properties": properties,
75
+ "property": property,
76
+ "value": value,
77
+ "color": color,
78
+ "target": target,
79
+ "searchMethod": search_method,
80
+ "slot": slot,
81
+ "mode": mode
82
+ }
83
+
84
+ # Remove None values
85
+ params_dict = {k: v for k, v in params_dict.items() if v is not None}
86
+
87
+ # Use centralized async retry helper with instance routing
88
+ result = await send_with_unity_instance(
89
+ async_send_command_with_retry,
90
+ unity_instance,
91
+ "manage_material",
92
+ params_dict,
93
+ )
94
+
95
+ return result if isinstance(result, dict) else {"success": False, "message": str(result)}
@@ -0,0 +1,62 @@
1
+ from typing import Annotated, Any, Literal
2
+
3
+ from fastmcp import Context
4
+ from services.registry import mcp_for_unity_tool
5
+ from services.tools import get_unity_instance_from_context
6
+ from transport.unity_transport import send_with_unity_instance
7
+ from transport.legacy.unity_connection import async_send_command_with_retry
8
+ from services.tools.utils import coerce_bool
9
+
10
+
11
+ @mcp_for_unity_tool(
12
+ description="Performs prefab operations (open_stage, close_stage, save_open_stage, create_from_gameobject)."
13
+ )
14
+ async def manage_prefabs(
15
+ ctx: Context,
16
+ action: Annotated[Literal["open_stage", "close_stage", "save_open_stage", "create_from_gameobject"], "Perform prefab operations."],
17
+ prefab_path: Annotated[str,
18
+ "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None,
19
+ mode: Annotated[str,
20
+ "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None,
21
+ save_before_close: Annotated[bool,
22
+ "When true, `close_stage` will save the prefab before exiting the stage."] | None = None,
23
+ target: Annotated[str,
24
+ "Scene GameObject name required for create_from_gameobject"] | None = None,
25
+ allow_overwrite: Annotated[bool,
26
+ "Allow replacing an existing prefab at the same path"] | None = None,
27
+ search_inactive: Annotated[bool,
28
+ "Include inactive objects when resolving the target name"] | None = None,
29
+ ) -> dict[str, Any]:
30
+ # Get active instance from session state
31
+ # Removed session_state import
32
+ unity_instance = get_unity_instance_from_context(ctx)
33
+
34
+ try:
35
+ params: dict[str, Any] = {"action": action}
36
+
37
+ if prefab_path:
38
+ params["prefabPath"] = prefab_path
39
+ if mode:
40
+ params["mode"] = mode
41
+ save_before_close_val = coerce_bool(save_before_close)
42
+ if save_before_close_val is not None:
43
+ params["saveBeforeClose"] = save_before_close_val
44
+ if target:
45
+ params["target"] = target
46
+ allow_overwrite_val = coerce_bool(allow_overwrite)
47
+ if allow_overwrite_val is not None:
48
+ params["allowOverwrite"] = allow_overwrite_val
49
+ search_inactive_val = coerce_bool(search_inactive)
50
+ if search_inactive_val is not None:
51
+ params["searchInactive"] = search_inactive_val
52
+ response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_prefabs", params)
53
+
54
+ if isinstance(response, dict) and response.get("success"):
55
+ return {
56
+ "success": True,
57
+ "message": response.get("message", "Prefab operation successful."),
58
+ "data": response.get("data"),
59
+ }
60
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
61
+ except Exception as exc:
62
+ return {"success": False, "message": f"Python error managing prefabs: {exc}"}
@@ -0,0 +1,75 @@
1
+ from typing import Annotated, Literal, Any
2
+
3
+ from fastmcp import Context
4
+ from services.registry import mcp_for_unity_tool
5
+ from services.tools import get_unity_instance_from_context
6
+ from transport.unity_transport import send_with_unity_instance
7
+ from transport.legacy.unity_connection import async_send_command_with_retry
8
+
9
+
10
+ @mcp_for_unity_tool(
11
+ description="Performs CRUD operations on Unity scenes."
12
+ )
13
+ async def manage_scene(
14
+ ctx: Context,
15
+ action: Annotated[Literal[
16
+ "create",
17
+ "load",
18
+ "save",
19
+ "get_hierarchy",
20
+ "get_active",
21
+ "get_build_settings",
22
+ "screenshot",
23
+ ], "Perform CRUD operations on Unity scenes, and capture a screenshot."],
24
+ name: Annotated[str, "Scene name."] | None = None,
25
+ path: Annotated[str, "Scene path."] | None = None,
26
+ build_index: Annotated[int | str,
27
+ "Unity build index (quote as string, e.g., '0')."] | None = None,
28
+ screenshot_file_name: Annotated[str, "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None,
29
+ screenshot_super_size: Annotated[int | str, "Screenshot supersize multiplier (integer ≥1). Optional." ] | None = None,
30
+ ) -> dict[str, Any]:
31
+ # Get active instance from session state
32
+ # Removed session_state import
33
+ unity_instance = get_unity_instance_from_context(ctx)
34
+ try:
35
+ # Coerce numeric inputs defensively
36
+ def _coerce_int(value, default=None):
37
+ if value is None:
38
+ return default
39
+ try:
40
+ if isinstance(value, bool):
41
+ return default
42
+ if isinstance(value, int):
43
+ return int(value)
44
+ s = str(value).strip()
45
+ if s.lower() in ("", "none", "null"):
46
+ return default
47
+ return int(float(s))
48
+ except Exception:
49
+ return default
50
+
51
+ coerced_build_index = _coerce_int(build_index, default=None)
52
+ coerced_super_size = _coerce_int(screenshot_super_size, default=None)
53
+
54
+ params: dict[str, Any] = {"action": action}
55
+ if name:
56
+ params["name"] = name
57
+ if path:
58
+ params["path"] = path
59
+ if coerced_build_index is not None:
60
+ params["buildIndex"] = coerced_build_index
61
+ if screenshot_file_name:
62
+ params["fileName"] = screenshot_file_name
63
+ if coerced_super_size is not None:
64
+ params["superSize"] = coerced_super_size
65
+
66
+ # Use centralized retry helper with instance routing
67
+ response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_scene", params)
68
+
69
+ # Preserve structured failure data; unwrap success into a friendlier shape
70
+ if isinstance(response, dict) and response.get("success"):
71
+ return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")}
72
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
73
+
74
+ except Exception as e:
75
+ return {"success": False, "message": f"Python error managing scene: {str(e)}"}