mcpforunityserver 9.3.0__py3-none-any.whl → 9.3.0b20260128055651__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.
@@ -0,0 +1,191 @@
1
+ """
2
+ MCP Resources for reading Prefab data from Unity.
3
+
4
+ These resources provide read-only access to:
5
+ - Prefab info by asset path (mcpforunity://prefab/{path})
6
+ - Prefab hierarchy by asset path (mcpforunity://prefab/{path}/hierarchy)
7
+ - Currently open prefab stage (mcpforunity://editor/prefab-stage - see prefab_stage.py)
8
+ """
9
+ from typing import Any
10
+ from urllib.parse import unquote
11
+ from pydantic import BaseModel
12
+ from fastmcp import Context
13
+
14
+ from models import MCPResponse
15
+ from services.registry import mcp_for_unity_resource
16
+ from services.tools import get_unity_instance_from_context
17
+ from transport.unity_transport import send_with_unity_instance
18
+ from transport.legacy.unity_connection import async_send_command_with_retry
19
+
20
+
21
+ def _normalize_response(response: dict | MCPResponse | Any) -> MCPResponse:
22
+ """Normalize Unity transport response to MCPResponse."""
23
+ if isinstance(response, dict):
24
+ return MCPResponse(**response)
25
+ if isinstance(response, MCPResponse):
26
+ return response
27
+ # Fallback: wrap unexpected types in an error response
28
+ return MCPResponse(success=False, error=f"Unexpected response type: {type(response).__name__}")
29
+
30
+
31
+ def _decode_prefab_path(encoded_path: str) -> str:
32
+ """
33
+ Decode a URL-encoded prefab path.
34
+ Handles paths like 'Assets%2FPrefabs%2FMyPrefab.prefab' -> 'Assets/Prefabs/MyPrefab.prefab'
35
+ """
36
+ return unquote(encoded_path)
37
+
38
+
39
+ # =============================================================================
40
+ # Static Helper Resource (shows in UI)
41
+ # =============================================================================
42
+
43
+ @mcp_for_unity_resource(
44
+ uri="mcpforunity://prefab-api",
45
+ name="prefab_api",
46
+ description="Documentation for Prefab resources. Use manage_asset action=search filterType=Prefab to find prefabs, then access resources below."
47
+ )
48
+ async def get_prefab_api_docs(_ctx: Context) -> MCPResponse:
49
+ """
50
+ Returns documentation for the Prefab resource API.
51
+
52
+ This is a helper resource that explains how to use the parameterized
53
+ Prefab resources which require an asset path.
54
+ """
55
+ docs = {
56
+ "overview": "Prefab resources provide read-only access to Unity prefab assets.",
57
+ "workflow": [
58
+ "1. Use manage_asset action=search filterType=Prefab to find prefabs",
59
+ "2. Use the asset path to access detailed data via resources below",
60
+ "3. Use manage_prefabs tool for prefab stage operations (open, save, close)"
61
+ ],
62
+ "path_encoding": {
63
+ "note": "Prefab paths must be URL-encoded when used in resource URIs",
64
+ "example": "Assets/Prefabs/MyPrefab.prefab -> Assets%2FPrefabs%2FMyPrefab.prefab"
65
+ },
66
+ "resources": {
67
+ "mcpforunity://prefab/{encoded_path}": {
68
+ "description": "Get prefab asset info (type, root name, components, variant info)",
69
+ "example": "mcpforunity://prefab/Assets%2FPrefabs%2FPlayer.prefab",
70
+ "returns": ["assetPath", "guid", "prefabType", "rootObjectName", "rootComponentTypes", "childCount", "isVariant", "parentPrefab"]
71
+ },
72
+ "mcpforunity://prefab/{encoded_path}/hierarchy": {
73
+ "description": "Get full prefab hierarchy with nested prefab information",
74
+ "example": "mcpforunity://prefab/Assets%2FPrefabs%2FPlayer.prefab/hierarchy",
75
+ "returns": ["prefabPath", "total", "items (with name, instanceId, path, componentTypes, prefab nesting info)"]
76
+ },
77
+ "mcpforunity://editor/prefab-stage": {
78
+ "description": "Get info about the currently open prefab stage (if any)",
79
+ "returns": ["isOpen", "assetPath", "prefabRootName", "mode", "isDirty"]
80
+ }
81
+ },
82
+ "related_tools": {
83
+ "manage_prefabs": "Open/close prefab stages, save changes, create prefabs from GameObjects",
84
+ "manage_asset": "Search for prefab assets, get asset info",
85
+ "manage_gameobject": "Modify GameObjects in open prefab stage",
86
+ "manage_components": "Add/remove/modify components on prefab GameObjects"
87
+ }
88
+ }
89
+ return MCPResponse(success=True, data=docs)
90
+
91
+
92
+ # =============================================================================
93
+ # Prefab Info Resource
94
+ # =============================================================================
95
+
96
+ # TODO: Use these typed response classes for better type safety once
97
+ # we update the endpoints to validate response structure more strictly.
98
+
99
+
100
+ class PrefabInfoData(BaseModel):
101
+ """Data for a prefab asset."""
102
+ assetPath: str
103
+ guid: str = ""
104
+ prefabType: str = "Regular"
105
+ rootObjectName: str = ""
106
+ rootComponentTypes: list[str] = []
107
+ childCount: int = 0
108
+ isVariant: bool = False
109
+ parentPrefab: str | None = None
110
+
111
+
112
+ class PrefabInfoResponse(MCPResponse):
113
+ """Response containing prefab info data."""
114
+ data: PrefabInfoData | None = None
115
+
116
+
117
+ @mcp_for_unity_resource(
118
+ uri="mcpforunity://prefab/{encoded_path}",
119
+ name="prefab_info",
120
+ description="Get detailed information about a prefab asset by URL-encoded path. Returns prefab type, root object name, component types, child count, and variant info."
121
+ )
122
+ async def get_prefab_info(ctx: Context, encoded_path: str) -> MCPResponse:
123
+ """Get prefab asset info by path."""
124
+ unity_instance = get_unity_instance_from_context(ctx)
125
+
126
+ # Decode the URL-encoded path
127
+ decoded_path = _decode_prefab_path(encoded_path)
128
+
129
+ response = await send_with_unity_instance(
130
+ async_send_command_with_retry,
131
+ unity_instance,
132
+ "manage_prefabs",
133
+ {
134
+ "action": "get_info",
135
+ "prefabPath": decoded_path
136
+ }
137
+ )
138
+
139
+ return _normalize_response(response)
140
+
141
+
142
+ # =============================================================================
143
+ # Prefab Hierarchy Resource
144
+ # =============================================================================
145
+
146
+ class PrefabHierarchyItem(BaseModel):
147
+ """Single item in prefab hierarchy."""
148
+ name: str
149
+ instanceId: int
150
+ path: str
151
+ activeSelf: bool = True
152
+ childCount: int = 0
153
+ componentTypes: list[str] = []
154
+ prefab: dict[str, Any] = {}
155
+
156
+
157
+ class PrefabHierarchyData(BaseModel):
158
+ """Data for prefab hierarchy."""
159
+ prefabPath: str
160
+ total: int = 0
161
+ items: list[PrefabHierarchyItem] = []
162
+
163
+
164
+ class PrefabHierarchyResponse(MCPResponse):
165
+ """Response containing prefab hierarchy data."""
166
+ data: PrefabHierarchyData | None = None
167
+
168
+
169
+ @mcp_for_unity_resource(
170
+ uri="mcpforunity://prefab/{encoded_path}/hierarchy",
171
+ name="prefab_hierarchy",
172
+ description="Get the full hierarchy of a prefab with nested prefab information. Returns all GameObjects with their components and nesting depth."
173
+ )
174
+ async def get_prefab_hierarchy(ctx: Context, encoded_path: str) -> MCPResponse:
175
+ """Get prefab hierarchy by path."""
176
+ unity_instance = get_unity_instance_from_context(ctx)
177
+
178
+ # Decode the URL-encoded path
179
+ decoded_path = _decode_prefab_path(encoded_path)
180
+
181
+ response = await send_with_unity_instance(
182
+ async_send_command_with_retry,
183
+ unity_instance,
184
+ "manage_prefabs",
185
+ {
186
+ "action": "get_hierarchy",
187
+ "prefabPath": decoded_path
188
+ }
189
+ )
190
+
191
+ return _normalize_response(response)
@@ -37,7 +37,7 @@ async def manage_components(
37
37
  # For set_property action - single property
38
38
  property: Annotated[str,
39
39
  "Property name to set (for set_property action)"] | None = None,
40
- value: Annotated[Any,
40
+ value: Annotated[str | int | float | bool | dict | list ,
41
41
  "Value to set (for set_property action)"] | None = None,
42
42
  # For add/set_property - multiple properties
43
43
  properties: Annotated[
@@ -13,32 +13,40 @@ from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
13
13
  from services.tools.preflight import preflight
14
14
 
15
15
 
16
- def _normalize_vector(value: Any, default: Any = None) -> list[float] | None:
16
+ def _normalize_vector(value: Any, param_name: str = "vector") -> tuple[list[float] | None, str | None]:
17
17
  """
18
18
  Robustly normalize a vector parameter to [x, y, z] format.
19
19
  Handles: list, tuple, JSON string, comma-separated string.
20
- Returns None if parsing fails.
20
+ Returns (parsed_vector, error_message). If error_message is set, parsed_vector is None.
21
21
  """
22
22
  if value is None:
23
- return default
23
+ return None, None
24
24
 
25
25
  # If already a list/tuple with 3 elements, convert to floats
26
26
  if isinstance(value, (list, tuple)) and len(value) == 3:
27
27
  try:
28
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
29
+ if all(math.isfinite(n) for n in vec):
30
+ return vec, None
31
+ return None, f"{param_name} values must be finite numbers, got {value}"
30
32
  except (ValueError, TypeError):
31
- return default
33
+ return None, f"{param_name} values must be numbers, got {value}"
32
34
 
33
35
  # Try parsing as JSON string
34
36
  if isinstance(value, str):
37
+ # Check for obviously invalid values
38
+ if value in ("[object Object]", "undefined", "null", ""):
39
+ return None, f"{param_name} received invalid value: '{value}'. Expected [x, y, z] array (list or JSON string)"
40
+
35
41
  parsed = parse_json_payload(value)
36
42
  if isinstance(parsed, list) and len(parsed) == 3:
37
43
  try:
38
44
  vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
39
- return vec if all(math.isfinite(n) for n in vec) else default
45
+ if all(math.isfinite(n) for n in vec):
46
+ return vec, None
47
+ return None, f"{param_name} values must be finite numbers, got {parsed}"
40
48
  except (ValueError, TypeError):
41
- pass
49
+ return None, f"{param_name} values must be numbers, got {parsed}"
42
50
 
43
51
  # Handle legacy comma-separated strings "1,2,3" or "[1,2,3]"
44
52
  s = value.strip()
@@ -48,11 +56,15 @@ def _normalize_vector(value: Any, default: Any = None) -> list[float] | None:
48
56
  if len(parts) == 3:
49
57
  try:
50
58
  vec = [float(parts[0]), float(parts[1]), float(parts[2])]
51
- return vec if all(math.isfinite(n) for n in vec) else default
59
+ if all(math.isfinite(n) for n in vec):
60
+ return vec, None
61
+ return None, f"{param_name} values must be finite numbers, got {value}"
52
62
  except (ValueError, TypeError):
53
- pass
63
+ return None, f"{param_name} values must be numbers, got {value}"
64
+
65
+ return None, f"{param_name} must be a [x, y, z] array (list or JSON string), got: {value}"
54
66
 
55
- return default
67
+ return None, f"{param_name} must be a list or JSON string, got {type(value).__name__}"
56
68
 
57
69
 
58
70
  def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any]] | None, str | None]:
@@ -103,12 +115,12 @@ async def manage_gameobject(
103
115
  "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
104
116
  parent: Annotated[str,
105
117
  "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | 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,
118
+ position: Annotated[list[float] | str,
119
+ "Position as [x, y, z] array (list or JSON string)"] | None = None,
120
+ rotation: Annotated[list[float] | str,
121
+ "Rotation as [x, y, z] euler angles array (list or JSON string)"] | None = None,
122
+ scale: Annotated[list[float] | str,
123
+ "Scale as [x, y, z] array (list or JSON string)"] | None = None,
112
124
  components_to_add: Annotated[list[str],
113
125
  "List of component names to add"] | None = None,
114
126
  primitive_type: Annotated[str,
@@ -157,8 +169,8 @@ async def manage_gameobject(
157
169
  # --- Parameters for 'duplicate' ---
158
170
  new_name: Annotated[str,
159
171
  "New name for the duplicated object (default: SourceName_Copy)"] | None = None,
160
- offset: Annotated[list[float],
161
- "Offset from original/reference position as [x, y, z] array"] | None = None,
172
+ offset: Annotated[list[float] | str,
173
+ "Offset from original/reference position as [x, y, z] array (list or JSON string)"] | None = None,
162
174
  # --- Parameters for 'move_relative' ---
163
175
  reference_object: Annotated[str,
164
176
  "Reference object for relative movement (required for move_relative)"] | None = None,
@@ -183,11 +195,19 @@ async def manage_gameobject(
183
195
  "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."
184
196
  }
185
197
 
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)
198
+ # --- Normalize vector parameters with detailed error handling ---
199
+ position, position_error = _normalize_vector(position, "position")
200
+ if position_error:
201
+ return {"success": False, "message": position_error}
202
+ rotation, rotation_error = _normalize_vector(rotation, "rotation")
203
+ if rotation_error:
204
+ return {"success": False, "message": rotation_error}
205
+ scale, scale_error = _normalize_vector(scale, "scale")
206
+ if scale_error:
207
+ return {"success": False, "message": scale_error}
208
+ offset, offset_error = _normalize_vector(offset, "offset")
209
+ if offset_error:
210
+ return {"success": False, "message": offset_error}
191
211
 
192
212
  # --- Normalize boolean parameters ---
193
213
  save_as_prefab = coerce_bool(save_as_prefab)
@@ -82,8 +82,8 @@ async def manage_material(
82
82
  "Value to set (color array, float, texture path/instruction)"] | None = None,
83
83
 
84
84
  # set_material_color / set_renderer_color
85
- color: Annotated[list[float],
86
- "Color as [r, g, b] or [r, g, b, a] array."] | None = None,
85
+ color: Annotated[list[float] | str,
86
+ "Color as [r, g, b] or [r, g, b, a] array (list or JSON string)."] | None = None,
87
87
 
88
88
  # assign_material_to_renderer / set_renderer_color
89
89
  target: Annotated[str,
@@ -5,13 +5,28 @@ from mcp.types import ToolAnnotations
5
5
 
6
6
  from services.registry import mcp_for_unity_tool
7
7
  from services.tools import get_unity_instance_from_context
8
+ from services.tools.utils import coerce_bool
8
9
  from transport.unity_transport import send_with_unity_instance
9
10
  from transport.legacy.unity_connection import async_send_command_with_retry
10
- from services.tools.utils import coerce_bool
11
+ from services.tools.preflight import preflight
12
+
13
+
14
+ # Required parameters for each action
15
+ REQUIRED_PARAMS = {
16
+ "get_info": ["prefab_path"],
17
+ "get_hierarchy": ["prefab_path"],
18
+ "create_from_gameobject": ["target", "prefab_path"],
19
+ "modify_contents": ["prefab_path"],
20
+ }
11
21
 
12
22
 
13
23
  @mcp_for_unity_tool(
14
- description="Performs prefab operations (open_stage, close_stage, save_open_stage, create_from_gameobject).",
24
+ description=(
25
+ "Manages Unity Prefab assets via headless operations (no UI, no prefab stages). "
26
+ "Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents. "
27
+ "Use modify_contents for headless prefab editing - ideal for automated workflows. "
28
+ "Use manage_asset action=search filterType=Prefab to list prefabs."
29
+ ),
15
30
  annotations=ToolAnnotations(
16
31
  title="Manage Prefabs",
17
32
  destructiveHint=True,
@@ -19,50 +34,132 @@ from services.tools.utils import coerce_bool
19
34
  )
20
35
  async def manage_prefabs(
21
36
  ctx: Context,
22
- action: Annotated[Literal["open_stage", "close_stage", "save_open_stage", "create_from_gameobject"], "Perform prefab operations."],
23
- prefab_path: Annotated[str,
24
- "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None,
25
- mode: Annotated[str,
26
- "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None,
27
- save_before_close: Annotated[bool,
28
- "When true, `close_stage` will save the prefab before exiting the stage."] | None = None,
29
- target: Annotated[str,
30
- "Scene GameObject name required for create_from_gameobject"] | None = None,
31
- allow_overwrite: Annotated[bool,
32
- "Allow replacing an existing prefab at the same path"] | None = None,
33
- search_inactive: Annotated[bool,
34
- "Include inactive objects when resolving the target name"] | None = None,
37
+ action: Annotated[
38
+ Literal[
39
+ "create_from_gameobject",
40
+ "get_info",
41
+ "get_hierarchy",
42
+ "modify_contents",
43
+ ],
44
+ "Prefab operation to perform.",
45
+ ],
46
+ prefab_path: Annotated[str, "Prefab asset path (e.g., Assets/Prefabs/MyPrefab.prefab)."] | None = None,
47
+ target: Annotated[str, "Target GameObject: scene object for create_from_gameobject, or object within prefab for modify_contents (name or path like 'Parent/Child')."] | None = None,
48
+ allow_overwrite: Annotated[bool, "Allow replacing existing prefab."] | None = None,
49
+ search_inactive: Annotated[bool, "Include inactive GameObjects in search."] | None = None,
50
+ unlink_if_instance: Annotated[bool, "Unlink from existing prefab before creating new one."] | None = None,
51
+ # modify_contents parameters
52
+ position: Annotated[list[float], "New local position [x, y, z] for modify_contents."] | None = None,
53
+ rotation: Annotated[list[float], "New local rotation (euler angles) [x, y, z] for modify_contents."] | None = None,
54
+ scale: Annotated[list[float], "New local scale [x, y, z] for modify_contents."] | None = None,
55
+ name: Annotated[str, "New name for the target object in modify_contents."] | None = None,
56
+ tag: Annotated[str, "New tag for the target object in modify_contents."] | None = None,
57
+ layer: Annotated[str, "New layer name for the target object in modify_contents."] | None = None,
58
+ set_active: Annotated[bool, "Set active state of target object in modify_contents."] | None = None,
59
+ parent: Annotated[str, "New parent object name/path within prefab for modify_contents."] | None = None,
60
+ components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None,
61
+ components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None,
35
62
  ) -> dict[str, Any]:
36
- # Get active instance from session state
37
- # Removed session_state import
63
+ # Back-compat: map 'name' 'target' for create_from_gameobject (Unity accepts both)
64
+ if action == "create_from_gameobject" and target is None and name is not None:
65
+ target = name
66
+
67
+ # Validate required parameters
68
+ required = REQUIRED_PARAMS.get(action, [])
69
+ for param_name in required:
70
+ # Use updated local value for target after back-compat mapping
71
+ param_value = target if param_name == "target" else locals().get(param_name)
72
+ # Check for None and empty/whitespace strings
73
+ if param_value is None or (isinstance(param_value, str) and not param_value.strip()):
74
+ return {
75
+ "success": False,
76
+ "message": f"Action '{action}' requires parameter '{param_name}'."
77
+ }
78
+
38
79
  unity_instance = get_unity_instance_from_context(ctx)
39
80
 
81
+ # Preflight check for operations to ensure Unity is ready
40
82
  try:
83
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
84
+ if gate is not None:
85
+ return gate.model_dump()
86
+ except Exception as exc:
87
+ return {
88
+ "success": False,
89
+ "message": f"Unity preflight check failed: {exc}"
90
+ }
91
+
92
+ try:
93
+ # Build parameters dictionary
41
94
  params: dict[str, Any] = {"action": action}
42
95
 
96
+ # Handle prefab path parameter
43
97
  if prefab_path:
44
98
  params["prefabPath"] = prefab_path
45
- if mode:
46
- params["mode"] = mode
47
- save_before_close_val = coerce_bool(save_before_close)
48
- if save_before_close_val is not None:
49
- params["saveBeforeClose"] = save_before_close_val
99
+
50
100
  if target:
51
101
  params["target"] = target
102
+
52
103
  allow_overwrite_val = coerce_bool(allow_overwrite)
53
104
  if allow_overwrite_val is not None:
54
105
  params["allowOverwrite"] = allow_overwrite_val
106
+
55
107
  search_inactive_val = coerce_bool(search_inactive)
56
108
  if search_inactive_val is not None:
57
109
  params["searchInactive"] = search_inactive_val
58
- response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_prefabs", params)
59
110
 
60
- if isinstance(response, dict) and response.get("success"):
61
- return {
62
- "success": True,
63
- "message": response.get("message", "Prefab operation successful."),
64
- "data": response.get("data"),
65
- }
66
- return response if isinstance(response, dict) else {"success": False, "message": str(response)}
111
+ unlink_if_instance_val = coerce_bool(unlink_if_instance)
112
+ if unlink_if_instance_val is not None:
113
+ params["unlinkIfInstance"] = unlink_if_instance_val
114
+
115
+ # modify_contents parameters
116
+ if position is not None:
117
+ params["position"] = position
118
+ if rotation is not None:
119
+ params["rotation"] = rotation
120
+ if scale is not None:
121
+ params["scale"] = scale
122
+ if name is not None:
123
+ params["name"] = name
124
+ if tag is not None:
125
+ params["tag"] = tag
126
+ if layer is not None:
127
+ params["layer"] = layer
128
+ set_active_val = coerce_bool(set_active)
129
+ if set_active_val is not None:
130
+ params["setActive"] = set_active_val
131
+ if parent is not None:
132
+ params["parent"] = parent
133
+ if components_to_add is not None:
134
+ params["componentsToAdd"] = components_to_add
135
+ if components_to_remove is not None:
136
+ params["componentsToRemove"] = components_to_remove
137
+
138
+ # Send command to Unity
139
+ response = await send_with_unity_instance(
140
+ async_send_command_with_retry, unity_instance, "manage_prefabs", params
141
+ )
142
+
143
+ # Return Unity response directly; ensure success field exists
144
+ # Handle MCPResponse objects (returned on error) by converting to dict
145
+ if hasattr(response, 'model_dump'):
146
+ return response.model_dump()
147
+ if isinstance(response, dict):
148
+ if "success" not in response:
149
+ response["success"] = False
150
+ return response
151
+ return {
152
+ "success": False,
153
+ "message": f"Unexpected response type: {type(response).__name__}"
154
+ }
155
+
156
+ except TimeoutError:
157
+ return {
158
+ "success": False,
159
+ "message": "Unity connection timeout. Please check if Unity is running and responsive."
160
+ }
67
161
  except Exception as exc:
68
- return {"success": False, "message": f"Python error managing prefabs: {exc}"}
162
+ return {
163
+ "success": False,
164
+ "message": f"Error managing prefabs: {exc}"
165
+ }