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
@@ -5,13 +5,19 @@ import sys
5
5
  from core.telemetry import get_package_version
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 transport.unity_instance_middleware import get_unity_instance_middleware
10
12
  from transport.plugin_hub import PluginHub
11
13
 
12
14
 
13
15
  @mcp_for_unity_tool(
14
- description="Return the current FastMCP request context details (client_id, session_id, and meta dump)."
16
+ description="Return the current FastMCP request context details (client_id, session_id, and meta dump).",
17
+ annotations=ToolAnnotations(
18
+ title="Debug Request Context",
19
+ readOnlyHint=True,
20
+ ),
15
21
  )
16
22
  def debug_request_context(ctx: Context) -> dict[str, Any]:
17
23
  # Check request_context properties
@@ -42,7 +48,7 @@ def debug_request_context(ctx: Context) -> dict[str, Any]:
42
48
  middleware = get_unity_instance_middleware()
43
49
  derived_key = middleware.get_session_key(ctx)
44
50
  active_instance = middleware.get_active_instance(ctx)
45
-
51
+
46
52
  # Debugging middleware internals
47
53
  # NOTE: These fields expose internal implementation details and may change between versions.
48
54
  with middleware._lock:
@@ -1,4 +1,5 @@
1
1
  from fastmcp import Context
2
+ from mcp.types import ToolAnnotations
2
3
  from models.models import MCPResponse
3
4
 
4
5
  from services.custom_tool_service import (
@@ -12,13 +13,17 @@ from services.tools import get_unity_instance_from_context
12
13
  @mcp_for_unity_tool(
13
14
  name="execute_custom_tool",
14
15
  description="Execute a project-scoped custom tool registered by Unity.",
16
+ annotations=ToolAnnotations(
17
+ title="Execute Custom Tool",
18
+ destructiveHint=True,
19
+ ),
15
20
  )
16
21
  async def execute_custom_tool(ctx: Context, tool_name: str, parameters: dict | None = None) -> MCPResponse:
17
22
  unity_instance = get_unity_instance_from_context(ctx)
18
23
  if not unity_instance:
19
24
  return MCPResponse(
20
25
  success=False,
21
- message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.",
26
+ message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
22
27
  )
23
28
 
24
29
  project_id = resolve_project_id_for_unity_instance(unity_instance)
@@ -4,6 +4,7 @@ Defines the execute_menu_item tool for executing and reading Unity Editor menu i
4
4
  from typing import Annotated, Any
5
5
 
6
6
  from fastmcp import Context
7
+ from mcp.types import ToolAnnotations
7
8
 
8
9
  from models import MCPResponse
9
10
  from services.registry import mcp_for_unity_tool
@@ -13,15 +14,17 @@ from transport.legacy.unity_connection import async_send_command_with_retry
13
14
 
14
15
 
15
16
  @mcp_for_unity_tool(
16
- description="Execute a Unity menu item by path."
17
+ description="Execute a Unity menu item by path.",
18
+ annotations=ToolAnnotations(
19
+ title="Execute Menu Item",
20
+ destructiveHint=True,
21
+ ),
17
22
  )
18
23
  async def execute_menu_item(
19
24
  ctx: Context,
20
25
  menu_path: Annotated[str,
21
26
  "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
22
27
  ) -> MCPResponse:
23
- # Get active instance from session state
24
- # Removed session_state import
25
28
  unity_instance = get_unity_instance_from_context(ctx)
26
29
  params_dict: dict[str, Any] = {"menuPath": menu_path}
27
30
  params_dict = {k: v for k, v in params_dict.items() if v is not None}
@@ -0,0 +1,89 @@
1
+ """
2
+ Tool for searching GameObjects in Unity scenes.
3
+ Returns only instance IDs with pagination support for efficient searches.
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 coerce_bool, coerce_int
13
+ from services.tools.preflight import preflight
14
+
15
+
16
+ @mcp_for_unity_tool(
17
+ description="Search for GameObjects in the scene. Returns instance IDs only (paginated) for efficient lookups. Use mcpforunity://scene/gameobject/{id} resource to get full GameObject data."
18
+ )
19
+ async def find_gameobjects(
20
+ ctx: Context,
21
+ search_term: Annotated[str, "The value to search for (name, tag, layer name, component type, or path)"],
22
+ search_method: Annotated[
23
+ Literal["by_name", "by_tag", "by_layer",
24
+ "by_component", "by_path", "by_id"],
25
+ "How to search for GameObjects"
26
+ ] = "by_name",
27
+ include_inactive: Annotated[bool | str,
28
+ "Include inactive GameObjects in search"] | None = None,
29
+ page_size: Annotated[int | str,
30
+ "Number of results per page (default: 50, max: 500)"] | None = None,
31
+ cursor: Annotated[int | str,
32
+ "Pagination cursor (offset for next page)"] | None = None,
33
+ ) -> dict[str, Any]:
34
+ """
35
+ Search for GameObjects and return their instance IDs.
36
+
37
+ This is a focused search tool optimized for finding GameObjects efficiently.
38
+ It returns only instance IDs to minimize payload size.
39
+
40
+ For detailed GameObject information, use the returned IDs with:
41
+ - mcpforunity://scene/gameobject/{id} - Get full GameObject data
42
+ - mcpforunity://scene/gameobject/{id}/components - Get all components
43
+ - mcpforunity://scene/gameobject/{id}/component/{name} - Get specific component
44
+ """
45
+ unity_instance = get_unity_instance_from_context(ctx)
46
+
47
+ # Validate required parameters before preflight I/O
48
+ if not search_term:
49
+ return {
50
+ "success": False,
51
+ "message": "Missing required parameter 'search_term'. Specify what to search for."
52
+ }
53
+
54
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
55
+ if gate is not None:
56
+ return gate.model_dump()
57
+
58
+ # Coerce parameters
59
+ include_inactive = coerce_bool(include_inactive, default=False)
60
+ page_size = coerce_int(page_size, default=50)
61
+ cursor = coerce_int(cursor, default=0)
62
+
63
+ try:
64
+ params = {
65
+ "searchMethod": search_method,
66
+ "searchTerm": search_term,
67
+ "includeInactive": include_inactive,
68
+ "pageSize": page_size,
69
+ "cursor": cursor,
70
+ }
71
+ params = {k: v for k, v in params.items() if v is not None}
72
+
73
+ response = await send_with_unity_instance(
74
+ async_send_command_with_retry,
75
+ unity_instance,
76
+ "find_gameobjects",
77
+ params,
78
+ )
79
+
80
+ if isinstance(response, dict) and response.get("success"):
81
+ return {
82
+ "success": True,
83
+ "message": response.get("message", "Search completed."),
84
+ "data": response.get("data")
85
+ }
86
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
87
+
88
+ except Exception as e:
89
+ return {"success": False, "message": f"Error searching GameObjects: {e!s}"}
@@ -5,6 +5,7 @@ from typing import Annotated, Any
5
5
  from urllib.parse import unquote, urlparse
6
6
 
7
7
  from fastmcp import Context
8
+ from mcp.types import ToolAnnotations
8
9
 
9
10
  from services.registry import mcp_for_unity_tool
10
11
  from services.tools import get_unity_instance_from_context
@@ -16,7 +17,7 @@ def _split_uri(uri: str) -> tuple[str, str]:
16
17
  """Split an incoming URI or path into (name, directory) suitable for Unity.
17
18
 
18
19
  Rules:
19
- - unity://path/Assets/... → keep as Assets-relative (after decode/normalize)
20
+ - mcpforunity://path/Assets/... → keep as Assets-relative (after decode/normalize)
20
21
  - file://... → percent-decode, normalize, strip host and leading slashes,
21
22
  then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
22
23
  Otherwise, fall back to original name/dir behavior.
@@ -24,8 +25,8 @@ def _split_uri(uri: str) -> tuple[str, str]:
24
25
  return relative to 'Assets'.
25
26
  """
26
27
  raw_path: str
27
- if uri.startswith("unity://path/"):
28
- raw_path = uri[len("unity://path/"):]
28
+ if uri.startswith("mcpforunity://path/"):
29
+ raw_path = uri[len("mcpforunity://path/"):]
29
30
  elif uri.startswith("file://"):
30
31
  parsed = urlparse(uri)
31
32
  host = (parsed.netloc or "").strip()
@@ -64,14 +65,21 @@ def _split_uri(uri: str) -> tuple[str, str]:
64
65
  return name, directory
65
66
 
66
67
 
67
- @mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.")
68
+ @mcp_for_unity_tool(
69
+ description="Searches a file with a regex pattern and returns line numbers and excerpts.",
70
+ annotations=ToolAnnotations(
71
+ title="Find in File",
72
+ readOnlyHint=True,
73
+ ),
74
+ )
68
75
  async def find_in_file(
69
76
  ctx: Context,
70
77
  uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
71
78
  pattern: Annotated[str, "The regex pattern to search for"],
72
79
  project_root: Annotated[str | None, "Optional project root path"] = None,
73
80
  max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200,
74
- ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True,
81
+ ignore_case: Annotated[bool | str | None,
82
+ "Case insensitive search"] = True,
75
83
  ) -> dict[str, Any]:
76
84
  # project_root is currently unused but kept for interface consistency
77
85
  unity_instance = get_unity_instance_from_context(ctx)
@@ -79,7 +87,7 @@ async def find_in_file(
79
87
  f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
80
88
 
81
89
  name, directory = _split_uri(uri)
82
-
90
+
83
91
  # 1. Read file content via Unity
84
92
  read_resp = await send_with_unity_instance(
85
93
  async_send_command_with_retry,
@@ -103,7 +111,7 @@ async def find_in_file(
103
111
  "utf-8")).decode("utf-8", "replace")
104
112
  except (ValueError, TypeError, base64.binascii.Error):
105
113
  contents = contents or ""
106
-
114
+
107
115
  if contents is None:
108
116
  return {"success": False, "message": "Could not read file content."}
109
117
 
@@ -121,26 +129,26 @@ async def find_in_file(
121
129
  except re.error as e:
122
130
  return {"success": False, "message": f"Invalid regex pattern: {e}"}
123
131
 
124
- # If the regex is not multiline specific (doesn't contain \n literal match logic),
132
+ # If the regex is not multiline specific (doesn't contain \n literal match logic),
125
133
  # we could iterate lines. But users might use multiline regexes.
126
134
  # Let's search the whole content and map back to lines.
127
-
135
+
128
136
  found = list(regex.finditer(contents))
129
-
137
+
130
138
  results = []
131
139
  count = 0
132
-
140
+
133
141
  for m in found:
134
142
  if count >= max_results:
135
143
  break
136
-
144
+
137
145
  start_idx = m.start()
138
146
  end_idx = m.end()
139
-
147
+
140
148
  # Calculate line number
141
149
  # Count newlines up to start_idx
142
150
  line_num = contents.count('\n', 0, start_idx) + 1
143
-
151
+
144
152
  # Get line content for excerpt
145
153
  # Find start of line
146
154
  line_start = contents.rfind('\n', 0, start_idx) + 1
@@ -148,15 +156,15 @@ async def find_in_file(
148
156
  line_end = contents.find('\n', start_idx)
149
157
  if line_end == -1:
150
158
  line_end = len(contents)
151
-
159
+
152
160
  line_content = contents[line_start:line_end]
153
-
161
+
154
162
  # Create excerpt
155
163
  # We can just return the line content as excerpt
156
-
164
+
157
165
  results.append({
158
166
  "line": line_num,
159
- "content": line_content.strip(), # detailed match info?
167
+ "content": line_content.strip(), # detailed match info?
160
168
  "match": m.group(0),
161
169
  "start": start_idx,
162
170
  "end": end_idx
@@ -171,4 +179,3 @@ async def find_in_file(
171
179
  "total_matches": len(found)
172
180
  }
173
181
  }
174
-
@@ -1,17 +1,19 @@
1
1
  """
2
2
  Defines the manage_asset tool for interacting with Unity assets.
3
3
  """
4
- import ast
5
4
  import asyncio
6
5
  import json
7
6
  from typing import Annotated, Any, Literal
8
7
 
9
8
  from fastmcp import Context
9
+ from mcp.types import ToolAnnotations
10
+
10
11
  from services.registry import mcp_for_unity_tool
11
12
  from services.tools import get_unity_instance_from_context
12
- from services.tools.utils import parse_json_payload, coerce_int
13
+ from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
13
14
  from transport.unity_transport import send_with_unity_instance
14
15
  from transport.legacy.unity_connection import async_send_command_with_retry
16
+ from services.tools.preflight import preflight
15
17
 
16
18
 
17
19
  @mcp_for_unity_tool(
@@ -19,7 +21,11 @@ from transport.legacy.unity_connection import async_send_command_with_retry
19
21
  "Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
20
22
  "Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
21
23
  "`generate_preview=false` (previews can add large base64 blobs)."
22
- )
24
+ ),
25
+ annotations=ToolAnnotations(
26
+ title="Manage Asset",
27
+ destructiveHint=True,
28
+ ),
23
29
  )
24
30
  async def manage_asset(
25
31
  ctx: Context,
@@ -27,8 +33,8 @@ async def manage_asset(
27
33
  path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
28
34
  asset_type: Annotated[str,
29
35
  "Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
30
- properties: Annotated[dict[str, Any] | str,
31
- "Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
36
+ properties: Annotated[dict[str, Any],
37
+ "Dictionary of properties for 'create'/'modify'. Keys are property names, values are property values."] | None = None,
32
38
  destination: Annotated[str,
33
39
  "Target path for 'duplicate'/'move'."] | None = None,
34
40
  generate_preview: Annotated[bool,
@@ -47,46 +53,16 @@ async def manage_asset(
47
53
  ) -> dict[str, Any]:
48
54
  unity_instance = get_unity_instance_from_context(ctx)
49
55
 
50
- def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
51
- try:
52
- parsed = json.loads(raw)
53
- if not isinstance(parsed, dict):
54
- return None, f"manage_asset: properties JSON must decode to a dictionary; received {type(parsed)}"
55
- return parsed, "JSON"
56
- except json.JSONDecodeError as json_err:
57
- try:
58
- parsed = ast.literal_eval(raw)
59
- if not isinstance(parsed, dict):
60
- return None, f"manage_asset: properties string must evaluate to a dictionary; received {type(parsed)}"
61
- return parsed, "Python literal"
62
- except (ValueError, SyntaxError) as literal_err:
63
- return None, f"manage_asset: failed to parse properties string. JSON error: {json_err}; literal_eval error: {literal_err}"
64
-
65
- async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[str, Any] | None, str | None]:
66
- if raw is None:
67
- return {}, None
68
- if isinstance(raw, dict):
69
- await ctx.info(f"manage_asset: received properties as dict with keys: {list(raw.keys())}")
70
- return raw, None
71
- if isinstance(raw, str):
72
- await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
73
- # Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
74
- parsed = parse_json_payload(raw)
75
- if isinstance(parsed, dict):
76
- await ctx.info("manage_asset: coerced properties using centralized parser")
77
- return parsed, None
78
-
79
- # Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
80
- parsed, source = _parse_properties_string(raw)
81
- if parsed is None:
82
- return None, source
83
- await ctx.info(f"manage_asset: coerced properties from {source} string to dict")
84
- return parsed, None
85
- return None, f"manage_asset: properties must be a dict or JSON string; received {type(raw)}"
56
+ # Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
57
+ # wait/refresh to avoid stale reads and flaky timeouts.
58
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
59
+ if gate is not None:
60
+ return gate.model_dump()
86
61
 
87
- properties, parse_error = await _normalize_properties(properties)
62
+ # --- Normalize properties using robust module-level helper ---
63
+ properties, parse_error = normalize_properties(properties)
88
64
  if parse_error:
89
- await ctx.error(parse_error)
65
+ await ctx.error(f"manage_asset: {parse_error}")
90
66
  return {"success": False, "message": parse_error}
91
67
 
92
68
  page_size = coerce_int(page_size)
@@ -0,0 +1,131 @@
1
+ """
2
+ Tool for managing components on GameObjects in Unity.
3
+ Supports add, remove, and set_property operations.
4
+ """
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from fastmcp import Context
8
+ from services.registry import mcp_for_unity_tool
9
+ from services.tools import get_unity_instance_from_context
10
+ from transport.unity_transport import send_with_unity_instance
11
+ from transport.legacy.unity_connection import async_send_command_with_retry
12
+ from services.tools.utils import parse_json_payload, normalize_properties
13
+ from services.tools.preflight import preflight
14
+
15
+
16
+ @mcp_for_unity_tool(
17
+ description="Manages components on GameObjects (add, remove, set_property). For reading component data, use the mcpforunity://scene/gameobject/{id}/components resource."
18
+ )
19
+ async def manage_components(
20
+ ctx: Context,
21
+ action: Annotated[
22
+ Literal["add", "remove", "set_property"],
23
+ "Action to perform: add (add component), remove (remove component), set_property (set component property)"
24
+ ],
25
+ target: Annotated[
26
+ str | int,
27
+ "Target GameObject - instance ID (preferred) or name/path"
28
+ ],
29
+ component_type: Annotated[
30
+ str,
31
+ "Component type name (e.g., 'Rigidbody', 'BoxCollider', 'MyScript')"
32
+ ],
33
+ search_method: Annotated[
34
+ Literal["by_id", "by_name", "by_path"],
35
+ "How to find the target GameObject"
36
+ ] | None = None,
37
+ # For set_property action - single property
38
+ property: Annotated[str,
39
+ "Property name to set (for set_property action)"] | None = None,
40
+ value: Annotated[Any,
41
+ "Value to set (for set_property action)"] | None = None,
42
+ # For add/set_property - multiple properties
43
+ properties: Annotated[
44
+ dict[str, Any],
45
+ "Dictionary of property names to values. Example: {\"mass\": 5.0, \"useGravity\": false}"
46
+ ] | None = None,
47
+ ) -> dict[str, Any]:
48
+ """
49
+ Manage components on GameObjects.
50
+
51
+ Actions:
52
+ - add: Add a new component to a GameObject
53
+ - remove: Remove a component from a GameObject
54
+ - set_property: Set one or more properties on a component
55
+
56
+ Examples:
57
+ - Add Rigidbody: action="add", target="Player", component_type="Rigidbody"
58
+ - Remove BoxCollider: action="remove", target=-12345, component_type="BoxCollider"
59
+ - Set single property: action="set_property", target="Enemy", component_type="Rigidbody", property="mass", value=5.0
60
+ - Set multiple properties: action="set_property", target="Enemy", component_type="Rigidbody", properties={"mass": 5.0, "useGravity": false}
61
+ """
62
+ unity_instance = get_unity_instance_from_context(ctx)
63
+
64
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
65
+ if gate is not None:
66
+ return gate.model_dump()
67
+
68
+ if not action:
69
+ return {
70
+ "success": False,
71
+ "message": "Missing required parameter 'action'. Valid actions: add, remove, set_property"
72
+ }
73
+
74
+ if not target:
75
+ return {
76
+ "success": False,
77
+ "message": "Missing required parameter 'target'. Specify GameObject instance ID or name."
78
+ }
79
+
80
+ if not component_type:
81
+ return {
82
+ "success": False,
83
+ "message": "Missing required parameter 'component_type'. Specify the component type name."
84
+ }
85
+
86
+ # --- Normalize properties with detailed error handling ---
87
+ properties, props_error = normalize_properties(properties)
88
+ if props_error:
89
+ return {"success": False, "message": props_error}
90
+
91
+ # --- Validate value parameter for serialization issues ---
92
+ if value is not None and isinstance(value, str) and value in ("[object Object]", "undefined"):
93
+ return {"success": False, "message": f"value received invalid input: '{value}'. Expected an actual value."}
94
+
95
+ try:
96
+ params = {
97
+ "action": action,
98
+ "target": target,
99
+ "componentType": component_type,
100
+ }
101
+
102
+ if search_method:
103
+ params["searchMethod"] = search_method
104
+
105
+ if action == "set_property":
106
+ if property and value is not None:
107
+ params["property"] = property
108
+ params["value"] = value
109
+ if properties:
110
+ params["properties"] = properties
111
+
112
+ if action == "add" and properties:
113
+ params["properties"] = properties
114
+
115
+ response = await send_with_unity_instance(
116
+ async_send_command_with_retry,
117
+ unity_instance,
118
+ "manage_components",
119
+ params,
120
+ )
121
+
122
+ if isinstance(response, dict) and response.get("success"):
123
+ return {
124
+ "success": True,
125
+ "message": response.get("message", f"Component {action} successful."),
126
+ "data": response.get("data")
127
+ }
128
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
129
+
130
+ except Exception as e:
131
+ return {"success": False, "message": f"Error managing component: {e!s}"}
@@ -1,6 +1,8 @@
1
1
  from typing import Annotated, Any, Literal
2
2
 
3
3
  from fastmcp import Context
4
+ from mcp.types import ToolAnnotations
5
+
4
6
  from services.registry import mcp_for_unity_tool
5
7
  from core.telemetry import is_telemetry_enabled, record_tool_usage
6
8
  from services.tools import get_unity_instance_from_context
@@ -10,7 +12,10 @@ from services.tools.utils import coerce_bool
10
12
 
11
13
 
12
14
  @mcp_for_unity_tool(
13
- description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted."
15
+ description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer.",
16
+ annotations=ToolAnnotations(
17
+ title="Manage Editor",
18
+ ),
14
19
  )
15
20
  async def manage_editor(
16
21
  ctx: Context,
@@ -41,13 +46,9 @@ async def manage_editor(
41
46
  params = {
42
47
  "action": action,
43
48
  "waitForCompletion": wait_for_completion,
44
- "toolName": tool_name, # Corrected parameter name to match C#
45
- "tagName": tag_name, # Pass tag name
46
- "layerName": layer_name, # Pass layer name
47
- # Add other parameters based on the action being performed
48
- # "width": width,
49
- # "height": height,
50
- # etc.
49
+ "toolName": tool_name,
50
+ "tagName": tag_name,
51
+ "layerName": layer_name,
51
52
  }
52
53
  params = {k: v for k, v in params.items() if v is not None}
53
54