mcpforunityserver 9.4.0b20260203025228__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 (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. utils/module_discovery.py +55 -0
@@ -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[str | int | float | bool | dict | list ,
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}"}
@@ -0,0 +1,64 @@
1
+ from typing import Annotated, Any, Literal
2
+
3
+ from fastmcp import Context
4
+ from mcp.types import ToolAnnotations
5
+
6
+ from services.registry import mcp_for_unity_tool
7
+ from core.telemetry import is_telemetry_enabled, record_tool_usage
8
+ from services.tools import get_unity_instance_from_context
9
+ from transport.unity_transport import send_with_unity_instance
10
+ from transport.legacy.unity_connection import async_send_command_with_retry
11
+ from services.tools.utils import coerce_bool
12
+
13
+
14
+ @mcp_for_unity_tool(
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
+ ),
19
+ )
20
+ async def manage_editor(
21
+ ctx: Context,
22
+ action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer"], "Get and update the Unity Editor state."],
23
+ wait_for_completion: Annotated[bool | str,
24
+ "Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None,
25
+ tool_name: Annotated[str,
26
+ "Tool name when setting active tool"] | None = None,
27
+ tag_name: Annotated[str,
28
+ "Tag name when adding and removing tags"] | None = None,
29
+ layer_name: Annotated[str,
30
+ "Layer name when adding and removing layers"] | None = None,
31
+ ) -> dict[str, Any]:
32
+ # Get active instance from request state (injected by middleware)
33
+ unity_instance = get_unity_instance_from_context(ctx)
34
+
35
+ wait_for_completion = coerce_bool(wait_for_completion)
36
+
37
+ try:
38
+ # Diagnostics: quick telemetry checks
39
+ if action == "telemetry_status":
40
+ return {"success": True, "telemetry_enabled": is_telemetry_enabled()}
41
+
42
+ if action == "telemetry_ping":
43
+ record_tool_usage("diagnostic_ping", True, 1.0, None)
44
+ return {"success": True, "message": "telemetry ping queued"}
45
+ # Prepare parameters, removing None values
46
+ params = {
47
+ "action": action,
48
+ "waitForCompletion": wait_for_completion,
49
+ "toolName": tool_name,
50
+ "tagName": tag_name,
51
+ "layerName": layer_name,
52
+ }
53
+ params = {k: v for k, v in params.items() if v is not None}
54
+
55
+ # Send command using centralized retry helper with instance routing
56
+ response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_editor", params)
57
+
58
+ # Preserve structured failure data; unwrap success into a friendlier shape
59
+ if isinstance(response, dict) and response.get("success"):
60
+ return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")}
61
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
62
+
63
+ except Exception as e:
64
+ return {"success": False, "message": f"Python error managing editor: {str(e)}"}
@@ -0,0 +1,260 @@
1
+ import json
2
+ from typing import Annotated, Any, Literal
3
+
4
+ from fastmcp import Context
5
+ from mcp.types import ToolAnnotations
6
+
7
+ from services.registry import mcp_for_unity_tool
8
+ from services.tools import get_unity_instance_from_context
9
+ from transport.unity_transport import send_with_unity_instance
10
+ from transport.legacy.unity_connection import async_send_command_with_retry
11
+ from services.tools.utils import coerce_bool, parse_json_payload, coerce_int, normalize_vector3
12
+ from services.tools.preflight import preflight
13
+
14
+
15
+ def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any]] | None, str | None]:
16
+ """
17
+ Robustly normalize component_properties to a dict.
18
+ Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
19
+ """
20
+ if value is None:
21
+ return None, None
22
+
23
+ # Already a dict - validate structure
24
+ if isinstance(value, dict):
25
+ return value, None
26
+
27
+ # Try parsing as JSON string
28
+ if isinstance(value, str):
29
+ # Check for obviously invalid values
30
+ if value in ("[object Object]", "undefined", "null", ""):
31
+ return None, f"component_properties received invalid value: '{value}'. Expected a JSON object like {{\"ComponentName\": {{\"property\": value}}}}"
32
+
33
+ parsed = parse_json_payload(value)
34
+ if isinstance(parsed, dict):
35
+ return parsed, None
36
+
37
+ return None, f"component_properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
38
+
39
+ return None, f"component_properties must be a dict or JSON string, got {type(value).__name__}"
40
+
41
+
42
+ @mcp_for_unity_tool(
43
+ 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.",
44
+ annotations=ToolAnnotations(
45
+ title="Manage GameObject",
46
+ destructiveHint=True,
47
+ ),
48
+ )
49
+ async def manage_gameobject(
50
+ ctx: Context,
51
+ action: Annotated[Literal["create", "modify", "delete", "duplicate",
52
+ "move_relative"], "Action to perform on GameObject."] | None = None,
53
+ target: Annotated[str,
54
+ "GameObject identifier by name or path for modify/delete/component actions"] | None = None,
55
+ search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
56
+ "How to find objects. Used with 'find' and some 'target' lookups."] | None = None,
57
+ name: Annotated[str,
58
+ "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None,
59
+ tag: Annotated[str,
60
+ "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
61
+ parent: Annotated[str,
62
+ "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
63
+ position: Annotated[list[float] | dict[str, float] | str,
64
+ "Position as [x, y, z] array, {x, y, z} object, or JSON string"] | None = None,
65
+ rotation: Annotated[list[float] | dict[str, float] | str,
66
+ "Rotation as [x, y, z] euler angles array, {x, y, z} object, or JSON string"] | None = None,
67
+ scale: Annotated[list[float] | dict[str, float] | str,
68
+ "Scale as [x, y, z] array, {x, y, z} object, or JSON string"] | None = None,
69
+ components_to_add: Annotated[list[str],
70
+ "List of component names to add"] | None = None,
71
+ primitive_type: Annotated[str,
72
+ "Primitive type for 'create' action"] | None = None,
73
+ save_as_prefab: Annotated[bool | str,
74
+ "If True, saves the created GameObject as a prefab (accepts true/false or 'true'/'false')"] | None = None,
75
+ prefab_path: Annotated[str, "Path for prefab creation"] | None = None,
76
+ prefab_folder: Annotated[str,
77
+ "Folder for prefab creation"] | None = None,
78
+ # --- Parameters for 'modify' ---
79
+ set_active: Annotated[bool | str,
80
+ "If True, sets the GameObject active (accepts true/false or 'true'/'false')"] | None = None,
81
+ layer: Annotated[str, "Layer name"] | None = None,
82
+ components_to_remove: Annotated[list[str],
83
+ "List of component names to remove"] | None = None,
84
+ component_properties: Annotated[dict[str, dict[str, Any]],
85
+ """Dictionary of component names to their properties to set. For example:
86
+ `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
87
+ `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
88
+ Example set nested property:
89
+ - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None,
90
+ # --- Parameters for 'find' ---
91
+ search_term: Annotated[str,
92
+ "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None,
93
+ find_all: Annotated[bool | str,
94
+ "If True, finds all GameObjects matching the search term (accepts true/false or 'true'/'false')"] | None = None,
95
+ search_in_children: Annotated[bool | str,
96
+ "If True, searches in children of the GameObject (accepts true/false or 'true'/'false')"] | None = None,
97
+ search_inactive: Annotated[bool | str,
98
+ "If True, searches inactive GameObjects (accepts true/false or 'true'/'false')"] | None = None,
99
+ # -- Component Management Arguments --
100
+ component_name: Annotated[str,
101
+ "Component name for 'add_component' and 'remove_component' actions"] | None = None,
102
+ # Controls whether serialization of private [SerializeField] fields is included
103
+ includeNonPublicSerialized: Annotated[bool | str,
104
+ "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
105
+ # --- Paging/safety for get_components ---
106
+ page_size: Annotated[int | str,
107
+ "Page size for get_components paging."] | None = None,
108
+ cursor: Annotated[int | str,
109
+ "Opaque cursor for get_components paging (offset)."] | None = None,
110
+ max_components: Annotated[int | str,
111
+ "Hard cap on returned components per request (safety)."] | None = None,
112
+ include_properties: Annotated[bool | str,
113
+ "If true, include serialized component properties (bounded)."] | None = None,
114
+ # --- Parameters for 'duplicate' ---
115
+ new_name: Annotated[str,
116
+ "New name for the duplicated object (default: SourceName_Copy)"] | None = None,
117
+ offset: Annotated[list[float] | str,
118
+ "Offset from original/reference position as [x, y, z] array (list or JSON string)"] | None = None,
119
+ # --- Parameters for 'move_relative' ---
120
+ reference_object: Annotated[str,
121
+ "Reference object for relative movement (required for move_relative)"] | None = None,
122
+ direction: Annotated[Literal["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"],
123
+ "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None,
124
+ distance: Annotated[float,
125
+ "Distance to move in the specified direction (default: 1.0)"] | None = None,
126
+ world_space: Annotated[bool | str,
127
+ "If True (default), use world space directions; if False, use reference object's local directions"] | None = None,
128
+ ) -> dict[str, Any]:
129
+ # Get active instance from session state
130
+ # Removed session_state import
131
+ unity_instance = get_unity_instance_from_context(ctx)
132
+
133
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
134
+ if gate is not None:
135
+ return gate.model_dump()
136
+
137
+ if action is None:
138
+ return {
139
+ "success": False,
140
+ "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."
141
+ }
142
+
143
+ # --- Normalize vector parameters with detailed error handling ---
144
+ position, position_error = normalize_vector3(position, "position")
145
+ if position_error:
146
+ return {"success": False, "message": position_error}
147
+ rotation, rotation_error = normalize_vector3(rotation, "rotation")
148
+ if rotation_error:
149
+ return {"success": False, "message": rotation_error}
150
+ scale, scale_error = normalize_vector3(scale, "scale")
151
+ if scale_error:
152
+ return {"success": False, "message": scale_error}
153
+ offset, offset_error = normalize_vector3(offset, "offset")
154
+ if offset_error:
155
+ return {"success": False, "message": offset_error}
156
+
157
+ # --- Normalize boolean parameters ---
158
+ save_as_prefab = coerce_bool(save_as_prefab)
159
+ set_active = coerce_bool(set_active)
160
+ find_all = coerce_bool(find_all)
161
+ search_in_children = coerce_bool(search_in_children)
162
+ search_inactive = coerce_bool(search_inactive)
163
+ includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
164
+ include_properties = coerce_bool(include_properties)
165
+ world_space = coerce_bool(world_space, default=True)
166
+
167
+ # --- Normalize integer parameters ---
168
+ page_size = coerce_int(page_size, default=None)
169
+ cursor = coerce_int(cursor, default=None)
170
+ max_components = coerce_int(max_components, default=None)
171
+
172
+ # --- Normalize component_properties with detailed error handling ---
173
+ component_properties, comp_props_error = _normalize_component_properties(
174
+ component_properties)
175
+ if comp_props_error:
176
+ return {"success": False, "message": comp_props_error}
177
+
178
+ try:
179
+ # Validate parameter usage to prevent silent failures
180
+ if action in ["create", "modify"]:
181
+ if search_term is not None:
182
+ return {
183
+ "success": False,
184
+ "message": f"For '{action}' action, use 'name' parameter, not 'search_term'."
185
+ }
186
+
187
+ # Prepare parameters, removing None values
188
+ params = {
189
+ "action": action,
190
+ "target": target,
191
+ "searchMethod": search_method,
192
+ "name": name,
193
+ "tag": tag,
194
+ "parent": parent,
195
+ "position": position,
196
+ "rotation": rotation,
197
+ "scale": scale,
198
+ "componentsToAdd": components_to_add,
199
+ "primitiveType": primitive_type,
200
+ "saveAsPrefab": save_as_prefab,
201
+ "prefabPath": prefab_path,
202
+ "prefabFolder": prefab_folder,
203
+ "setActive": set_active,
204
+ "layer": layer,
205
+ "componentsToRemove": components_to_remove,
206
+ "componentProperties": component_properties,
207
+ "searchTerm": search_term,
208
+ "findAll": find_all,
209
+ "searchInChildren": search_in_children,
210
+ "searchInactive": search_inactive,
211
+ "componentName": component_name,
212
+ "includeNonPublicSerialized": includeNonPublicSerialized,
213
+ "pageSize": page_size,
214
+ "cursor": cursor,
215
+ "maxComponents": max_components,
216
+ "includeProperties": include_properties,
217
+ # Parameters for 'duplicate'
218
+ "new_name": new_name,
219
+ "offset": offset,
220
+ # Parameters for 'move_relative'
221
+ "reference_object": reference_object,
222
+ "direction": direction,
223
+ "distance": distance,
224
+ "world_space": world_space,
225
+ }
226
+ params = {k: v for k, v in params.items() if v is not None}
227
+
228
+ # --- Handle Prefab Path Logic ---
229
+ # Check if 'saveAsPrefab' is explicitly True in params
230
+ if action == "create" and params.get("saveAsPrefab"):
231
+ if "prefabPath" not in params:
232
+ if "name" not in params or not params["name"]:
233
+ return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."}
234
+ # Use the provided prefab_folder (which has a default) and the name to construct the path
235
+ constructed_path = f"{prefab_folder}/{params['name']}.prefab"
236
+ # Ensure clean path separators (Unity prefers '/')
237
+ params["prefabPath"] = constructed_path.replace("\\", "/")
238
+ elif not params["prefabPath"].lower().endswith(".prefab"):
239
+ return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"}
240
+ # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided
241
+ # The C# side only needs the final prefabPath
242
+ params.pop("prefabFolder", None)
243
+ # --------------------------------
244
+
245
+ # Use centralized retry helper with instance routing
246
+ response = await send_with_unity_instance(
247
+ async_send_command_with_retry,
248
+ unity_instance,
249
+ "manage_gameobject",
250
+ params,
251
+ )
252
+
253
+ # Check if the response indicates success
254
+ # If the response is not successful, raise an exception with the error message
255
+ if isinstance(response, dict) and response.get("success"):
256
+ return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")}
257
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
258
+
259
+ except Exception as e:
260
+ return {"success": False, "message": f"Python error managing GameObject: {e!s}"}
@@ -0,0 +1,111 @@
1
+ """
2
+ Defines the manage_material tool for interacting with Unity materials.
3
+ """
4
+ import json
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from fastmcp import Context
8
+ from mcp.types import ToolAnnotations
9
+
10
+ from services.registry import mcp_for_unity_tool
11
+ from services.tools import get_unity_instance_from_context
12
+ from services.tools.utils import parse_json_payload, coerce_int, normalize_properties, normalize_color
13
+ from transport.unity_transport import send_with_unity_instance
14
+ from transport.legacy.unity_connection import async_send_command_with_retry
15
+
16
+
17
+ @mcp_for_unity_tool(
18
+ 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.",
19
+ annotations=ToolAnnotations(
20
+ title="Manage Material",
21
+ destructiveHint=True,
22
+ ),
23
+ )
24
+ async def manage_material(
25
+ ctx: Context,
26
+ action: Annotated[Literal[
27
+ "ping",
28
+ "create",
29
+ "set_material_shader_property",
30
+ "set_material_color",
31
+ "assign_material_to_renderer",
32
+ "set_renderer_color",
33
+ "get_material_info"
34
+ ], "Action to perform."],
35
+
36
+ # Common / Shared
37
+ material_path: Annotated[str,
38
+ "Path to material asset (Assets/...)"] | None = None,
39
+ property: Annotated[str,
40
+ "Shader property name (e.g., _BaseColor, _MainTex)"] | None = None,
41
+
42
+ # create
43
+ shader: Annotated[str, "Shader name (default: Standard)"] | None = None,
44
+ properties: Annotated[dict[str, Any],
45
+ "Initial properties to set as {name: value} dict."] | None = None,
46
+
47
+ # set_material_shader_property
48
+ value: Annotated[list | float | int | str | bool | None,
49
+ "Value to set (color array, float, texture path/instruction)"] | None = None,
50
+
51
+ # set_material_color / set_renderer_color
52
+ color: Annotated[list[float] | dict[str, float] | str,
53
+ "Color as [r, g, b] or [r, g, b, a] array, {r, g, b, a} object, or JSON string."] | None = None,
54
+
55
+ # assign_material_to_renderer / set_renderer_color
56
+ target: Annotated[str,
57
+ "Target GameObject (name, path, or find instruction)"] | None = None,
58
+ search_method: Annotated[Literal["by_name", "by_path", "by_tag",
59
+ "by_layer", "by_component"], "Search method for target"] | None = None,
60
+ slot: Annotated[int, "Material slot index (0-based)"] | None = None,
61
+ mode: Annotated[Literal["shared", "instance", "property_block"],
62
+ "Assignment/modification mode"] | None = None,
63
+
64
+ ) -> dict[str, Any]:
65
+ unity_instance = get_unity_instance_from_context(ctx)
66
+
67
+ # --- Normalize color with validation ---
68
+ color, color_error = normalize_color(color, output_range="float")
69
+ if color_error:
70
+ return {"success": False, "message": color_error}
71
+
72
+ # --- Normalize properties with validation ---
73
+ properties, props_error = normalize_properties(properties)
74
+ if props_error:
75
+ return {"success": False, "message": props_error}
76
+
77
+ # --- Normalize value (parse JSON if string) ---
78
+ value = parse_json_payload(value)
79
+ if isinstance(value, str) and value in ("[object Object]", "undefined"):
80
+ return {"success": False, "message": f"value received invalid input: '{value}'"}
81
+
82
+ # --- Normalize slot to int ---
83
+ slot = coerce_int(slot)
84
+
85
+ # Prepare parameters for the C# handler
86
+ params_dict = {
87
+ "action": action.lower(),
88
+ "materialPath": material_path,
89
+ "shader": shader,
90
+ "properties": properties,
91
+ "property": property,
92
+ "value": value,
93
+ "color": color,
94
+ "target": target,
95
+ "searchMethod": search_method,
96
+ "slot": slot,
97
+ "mode": mode
98
+ }
99
+
100
+ # Remove None values
101
+ params_dict = {k: v for k, v in params_dict.items() if v is not None}
102
+
103
+ # Use centralized async retry helper with instance routing
104
+ result = await send_with_unity_instance(
105
+ async_send_command_with_retry,
106
+ unity_instance,
107
+ "manage_material",
108
+ params_dict,
109
+ )
110
+
111
+ return result if isinstance(result, dict) else {"success": False, "message": str(result)}