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,86 @@
1
+ from typing import Any
2
+ import os
3
+ import sys
4
+
5
+ from core.telemetry import get_package_version
6
+
7
+ from fastmcp import Context
8
+ from mcp.types import ToolAnnotations
9
+
10
+ from services.registry import mcp_for_unity_tool
11
+ from transport.unity_instance_middleware import get_unity_instance_middleware
12
+ from transport.plugin_hub import PluginHub
13
+
14
+
15
+ @mcp_for_unity_tool(
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
+ ),
21
+ )
22
+ def debug_request_context(ctx: Context) -> dict[str, Any]:
23
+ # Check request_context properties
24
+ rc = getattr(ctx, "request_context", None)
25
+ rc_client_id = getattr(rc, "client_id", None)
26
+ rc_session_id = getattr(rc, "session_id", None)
27
+ meta = getattr(rc, "meta", None)
28
+
29
+ # Check direct ctx properties (per latest FastMCP docs)
30
+ ctx_session_id = getattr(ctx, "session_id", None)
31
+ ctx_client_id = getattr(ctx, "client_id", None)
32
+
33
+ meta_dump = None
34
+ if meta is not None:
35
+ try:
36
+ dump_fn = getattr(meta, "model_dump", None)
37
+ if callable(dump_fn):
38
+ meta_dump = dump_fn(exclude_none=False)
39
+ elif isinstance(meta, dict):
40
+ meta_dump = dict(meta)
41
+ except Exception as e:
42
+ meta_dump = {"_error": str(e)}
43
+
44
+ # List all ctx attributes for debugging
45
+ ctx_attrs = [attr for attr in dir(ctx) if not attr.startswith("_")]
46
+
47
+ # Get session state info via middleware
48
+ middleware = get_unity_instance_middleware()
49
+ derived_key = middleware.get_session_key(ctx)
50
+ active_instance = middleware.get_active_instance(ctx)
51
+
52
+ # Debugging middleware internals
53
+ # NOTE: These fields expose internal implementation details and may change between versions.
54
+ with middleware._lock:
55
+ all_keys = list(middleware._active_by_key.keys())
56
+
57
+ # Debugging PluginHub state
58
+ plugin_hub_configured = PluginHub.is_configured()
59
+
60
+ return {
61
+ "success": True,
62
+ "data": {
63
+ "server": {
64
+ "version": get_package_version(),
65
+ "cwd": os.getcwd(),
66
+ "argv": list(sys.argv),
67
+ },
68
+ "request_context": {
69
+ "client_id": rc_client_id,
70
+ "session_id": rc_session_id,
71
+ "meta": meta_dump,
72
+ },
73
+ "direct_properties": {
74
+ "session_id": ctx_session_id,
75
+ "client_id": ctx_client_id,
76
+ },
77
+ "session_state": {
78
+ "derived_key": derived_key,
79
+ "active_instance": active_instance,
80
+ "all_keys_in_store": all_keys,
81
+ "plugin_hub_configured": plugin_hub_configured,
82
+ "middleware_id": id(middleware),
83
+ },
84
+ "available_attributes": ctx_attrs,
85
+ },
86
+ }
@@ -0,0 +1,43 @@
1
+ from fastmcp import Context
2
+ from mcp.types import ToolAnnotations
3
+ from models.models import MCPResponse
4
+
5
+ from services.custom_tool_service import (
6
+ CustomToolService,
7
+ resolve_project_id_for_unity_instance,
8
+ )
9
+ from services.registry import mcp_for_unity_tool
10
+ from services.tools import get_unity_instance_from_context
11
+
12
+
13
+ @mcp_for_unity_tool(
14
+ name="execute_custom_tool",
15
+ description="Execute a project-scoped custom tool registered by Unity.",
16
+ annotations=ToolAnnotations(
17
+ title="Execute Custom Tool",
18
+ destructiveHint=True,
19
+ ),
20
+ )
21
+ async def execute_custom_tool(ctx: Context, tool_name: str, parameters: dict | None = None) -> MCPResponse:
22
+ unity_instance = get_unity_instance_from_context(ctx)
23
+ if not unity_instance:
24
+ return MCPResponse(
25
+ success=False,
26
+ message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
27
+ )
28
+
29
+ project_id = resolve_project_id_for_unity_instance(unity_instance)
30
+ if project_id is None:
31
+ return MCPResponse(
32
+ success=False,
33
+ message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
34
+ )
35
+
36
+ if not isinstance(parameters, dict):
37
+ return MCPResponse(
38
+ success=False,
39
+ message="parameters must be an object/dictionary",
40
+ )
41
+
42
+ service = CustomToolService.get_instance()
43
+ return await service.execute_tool(project_id, tool_name, unity_instance, parameters)
@@ -0,0 +1,32 @@
1
+ """
2
+ Defines the execute_menu_item tool for executing and reading Unity Editor menu items.
3
+ """
4
+ from typing import Annotated, Any
5
+
6
+ from fastmcp import Context
7
+ from mcp.types import ToolAnnotations
8
+
9
+ from models import MCPResponse
10
+ from services.registry import mcp_for_unity_tool
11
+ from services.tools import get_unity_instance_from_context
12
+ from transport.unity_transport import send_with_unity_instance
13
+ from transport.legacy.unity_connection import async_send_command_with_retry
14
+
15
+
16
+ @mcp_for_unity_tool(
17
+ description="Execute a Unity menu item by path.",
18
+ annotations=ToolAnnotations(
19
+ title="Execute Menu Item",
20
+ destructiveHint=True,
21
+ ),
22
+ )
23
+ async def execute_menu_item(
24
+ ctx: Context,
25
+ menu_path: Annotated[str,
26
+ "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
27
+ ) -> MCPResponse:
28
+ unity_instance = get_unity_instance_from_context(ctx)
29
+ params_dict: dict[str, Any] = {"menuPath": menu_path}
30
+ params_dict = {k: v for k, v in params_dict.items() if v is not None}
31
+ result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "execute_menu_item", params_dict)
32
+ return MCPResponse(**result) if isinstance(result, dict) else result
@@ -0,0 +1,110 @@
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 pydantic import Field
9
+ from services.registry import mcp_for_unity_tool
10
+ from services.tools import get_unity_instance_from_context
11
+ from transport.unity_transport import send_with_unity_instance
12
+ from transport.legacy.unity_connection import async_send_command_with_retry
13
+ from services.tools.utils import coerce_bool, coerce_int
14
+ from services.tools.preflight import preflight
15
+
16
+
17
+ @mcp_for_unity_tool(
18
+ description="Search for GameObjects in the scene. Requires search_term (name, tag, layer name, component type, or path). Returns instance IDs only (paginated). Use mcpforunity://scene/gameobject/{id} resource to get full GameObject data."
19
+ )
20
+ async def find_gameobjects(
21
+ ctx: Context,
22
+ search_term: Annotated[
23
+ str,
24
+ Field(description="The value to search for (name, tag, layer name, component type, or path)")
25
+ ],
26
+ search_method: Annotated[
27
+ Literal["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"],
28
+ Field(
29
+ default="by_name",
30
+ description="How to search for GameObjects"
31
+ )
32
+ ] = "by_name",
33
+ include_inactive: Annotated[
34
+ bool | str | None,
35
+ Field(
36
+ default=None,
37
+ description="Include inactive GameObjects in search"
38
+ )
39
+ ] = None,
40
+ page_size: Annotated[
41
+ int | str | None,
42
+ Field(
43
+ default=None,
44
+ description="Number of results per page (default: 50, max: 500)"
45
+ )
46
+ ] = None,
47
+ cursor: Annotated[
48
+ int | str | None,
49
+ Field(
50
+ default=None,
51
+ description="Pagination cursor (offset for next page)"
52
+ )
53
+ ] = None,
54
+ ) -> dict[str, Any]:
55
+ """
56
+ Search for GameObjects and return their instance IDs.
57
+
58
+ This is a focused search tool optimized for finding GameObjects efficiently.
59
+ It returns only instance IDs to minimize payload size.
60
+
61
+ For detailed GameObject information, use the returned IDs with:
62
+ - mcpforunity://scene/gameobject/{id} - Get full GameObject data
63
+ - mcpforunity://scene/gameobject/{id}/components - Get all components
64
+ - mcpforunity://scene/gameobject/{id}/component/{name} - Get specific component
65
+ """
66
+ unity_instance = get_unity_instance_from_context(ctx)
67
+
68
+ # Validate required parameters before preflight I/O
69
+ if not search_term:
70
+ return {
71
+ "success": False,
72
+ "message": "Missing required parameter 'search_term'. Specify what to search for."
73
+ }
74
+
75
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
76
+ if gate is not None:
77
+ return gate.model_dump()
78
+
79
+ # Coerce parameters
80
+ include_inactive = coerce_bool(include_inactive, default=False)
81
+ page_size = coerce_int(page_size, default=50)
82
+ cursor = coerce_int(cursor, default=0)
83
+
84
+ try:
85
+ params = {
86
+ "searchMethod": search_method,
87
+ "searchTerm": search_term,
88
+ "includeInactive": include_inactive,
89
+ "pageSize": page_size,
90
+ "cursor": cursor,
91
+ }
92
+ params = {k: v for k, v in params.items() if v is not None}
93
+
94
+ response = await send_with_unity_instance(
95
+ async_send_command_with_retry,
96
+ unity_instance,
97
+ "find_gameobjects",
98
+ params,
99
+ )
100
+
101
+ if isinstance(response, dict) and response.get("success"):
102
+ return {
103
+ "success": True,
104
+ "message": response.get("message", "Search completed."),
105
+ "data": response.get("data")
106
+ }
107
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
108
+
109
+ except Exception as e:
110
+ return {"success": False, "message": f"Error searching GameObjects: {e!s}"}
@@ -0,0 +1,181 @@
1
+ import base64
2
+ import os
3
+ import re
4
+ from typing import Annotated, Any
5
+ from urllib.parse import unquote, urlparse
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 transport.unity_transport import send_with_unity_instance
13
+ from transport.legacy.unity_connection import async_send_command_with_retry
14
+
15
+
16
+ def _split_uri(uri: str) -> tuple[str, str]:
17
+ """Split an incoming URI or path into (name, directory) suitable for Unity.
18
+
19
+ Rules:
20
+ - mcpforunity://path/Assets/... → keep as Assets-relative (after decode/normalize)
21
+ - file://... → percent-decode, normalize, strip host and leading slashes,
22
+ then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
23
+ Otherwise, fall back to original name/dir behavior.
24
+ - plain paths → decode/normalize separators; if they contain an 'Assets' segment,
25
+ return relative to 'Assets'.
26
+ """
27
+ raw_path: str
28
+ if uri.startswith("mcpforunity://path/"):
29
+ raw_path = uri[len("mcpforunity://path/"):]
30
+ elif uri.startswith("file://"):
31
+ parsed = urlparse(uri)
32
+ host = (parsed.netloc or "").strip()
33
+ p = parsed.path or ""
34
+ # UNC: file://server/share/... -> //server/share/...
35
+ if host and host.lower() != "localhost":
36
+ p = f"//{host}{p}"
37
+ # Use percent-decoded path, preserving leading slashes
38
+ raw_path = unquote(p)
39
+ else:
40
+ raw_path = uri
41
+
42
+ # Percent-decode any residual encodings and normalize separators
43
+ raw_path = unquote(raw_path).replace("\\", "/")
44
+ # Strip leading slash only for Windows drive-letter forms like "/C:/..."
45
+ if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
46
+ raw_path = raw_path[1:]
47
+
48
+ # Normalize path (collapse ../, ./)
49
+ norm = os.path.normpath(raw_path).replace("\\", "/")
50
+
51
+ # If an 'Assets' segment exists, compute path relative to it (case-insensitive)
52
+ parts = [p for p in norm.split("/") if p not in ("", ".")]
53
+ idx = next((i for i, seg in enumerate(parts)
54
+ if seg.lower() == "assets"), None)
55
+ assets_rel = "/".join(parts[idx:]) if idx is not None else None
56
+
57
+ effective_path = assets_rel if assets_rel else norm
58
+ # For POSIX absolute paths outside Assets, drop the leading '/'
59
+ # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
60
+ if effective_path.startswith("/"):
61
+ effective_path = effective_path[1:]
62
+
63
+ name = os.path.splitext(os.path.basename(effective_path))[0]
64
+ directory = os.path.dirname(effective_path)
65
+ return name, directory
66
+
67
+
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
+ )
75
+ async def find_in_file(
76
+ ctx: Context,
77
+ uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
78
+ pattern: Annotated[str, "The regex pattern to search for"],
79
+ project_root: Annotated[str | None, "Optional project root path"] = None,
80
+ max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200,
81
+ ignore_case: Annotated[bool | str | None,
82
+ "Case insensitive search"] = True,
83
+ ) -> dict[str, Any]:
84
+ # project_root is currently unused but kept for interface consistency
85
+ unity_instance = get_unity_instance_from_context(ctx)
86
+ await ctx.info(
87
+ f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
88
+
89
+ name, directory = _split_uri(uri)
90
+
91
+ # 1. Read file content via Unity
92
+ read_resp = await send_with_unity_instance(
93
+ async_send_command_with_retry,
94
+ unity_instance,
95
+ "manage_script",
96
+ {
97
+ "action": "read",
98
+ "name": name,
99
+ "path": directory,
100
+ },
101
+ )
102
+
103
+ if not isinstance(read_resp, dict) or not read_resp.get("success"):
104
+ return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
105
+
106
+ data = read_resp.get("data", {})
107
+ contents = data.get("contents")
108
+ if not contents and data.get("contentsEncoded") and data.get("encodedContents"):
109
+ try:
110
+ contents = base64.b64decode(data.get("encodedContents", "").encode(
111
+ "utf-8")).decode("utf-8", "replace")
112
+ except (ValueError, TypeError, base64.binascii.Error):
113
+ contents = contents or ""
114
+
115
+ if contents is None:
116
+ return {"success": False, "message": "Could not read file content."}
117
+
118
+ # 2. Perform regex search
119
+ flags = re.MULTILINE
120
+ # Handle ignore_case which can be boolean or string from some clients
121
+ ic = ignore_case
122
+ if isinstance(ic, str):
123
+ ic = ic.lower() in ("true", "1", "yes")
124
+ if ic:
125
+ flags |= re.IGNORECASE
126
+
127
+ try:
128
+ regex = re.compile(pattern, flags)
129
+ except re.error as e:
130
+ return {"success": False, "message": f"Invalid regex pattern: {e}"}
131
+
132
+ # If the regex is not multiline specific (doesn't contain \n literal match logic),
133
+ # we could iterate lines. But users might use multiline regexes.
134
+ # Let's search the whole content and map back to lines.
135
+
136
+ found = list(regex.finditer(contents))
137
+
138
+ results = []
139
+ count = 0
140
+
141
+ for m in found:
142
+ if count >= max_results:
143
+ break
144
+
145
+ start_idx = m.start()
146
+ end_idx = m.end()
147
+
148
+ # Calculate line number
149
+ # Count newlines up to start_idx
150
+ line_num = contents.count('\n', 0, start_idx) + 1
151
+
152
+ # Get line content for excerpt
153
+ # Find start of line
154
+ line_start = contents.rfind('\n', 0, start_idx) + 1
155
+ # Find end of line
156
+ line_end = contents.find('\n', start_idx)
157
+ if line_end == -1:
158
+ line_end = len(contents)
159
+
160
+ line_content = contents[line_start:line_end]
161
+
162
+ # Create excerpt
163
+ # We can just return the line content as excerpt
164
+
165
+ results.append({
166
+ "line": line_num,
167
+ "content": line_content.strip(), # detailed match info?
168
+ "match": m.group(0),
169
+ "start": start_idx,
170
+ "end": end_idx
171
+ })
172
+ count += 1
173
+
174
+ return {
175
+ "success": True,
176
+ "data": {
177
+ "matches": results,
178
+ "count": len(results),
179
+ "total_matches": len(found)
180
+ }
181
+ }
@@ -0,0 +1,119 @@
1
+ """
2
+ Defines the manage_asset tool for interacting with Unity assets.
3
+ """
4
+ import asyncio
5
+ import json
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from fastmcp import Context
9
+ from mcp.types import ToolAnnotations
10
+
11
+ from services.registry import mcp_for_unity_tool
12
+ from services.tools import get_unity_instance_from_context
13
+ from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
14
+ from transport.unity_transport import send_with_unity_instance
15
+ from transport.legacy.unity_connection import async_send_command_with_retry
16
+ from services.tools.preflight import preflight
17
+
18
+
19
+ @mcp_for_unity_tool(
20
+ description=(
21
+ "Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
22
+ "Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
23
+ "`generate_preview=false` (previews can add large base64 blobs)."
24
+ ),
25
+ annotations=ToolAnnotations(
26
+ title="Manage Asset",
27
+ destructiveHint=True,
28
+ ),
29
+ )
30
+ async def manage_asset(
31
+ ctx: Context,
32
+ action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
33
+ path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
34
+ asset_type: Annotated[str,
35
+ "Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | 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,
38
+ destination: Annotated[str,
39
+ "Target path for 'duplicate'/'move'."] | None = None,
40
+ generate_preview: Annotated[bool,
41
+ "Generate a preview/thumbnail for the asset when supported. "
42
+ "Warning: previews may include large base64 payloads; keep false unless needed."] = False,
43
+ search_pattern: Annotated[str,
44
+ "Search pattern (e.g., '*.prefab' or AssetDatabase filters like 't:MonoScript'). "
45
+ "Recommended: put queries like 't:MonoScript' here and set path='Assets'."] | None = None,
46
+ filter_type: Annotated[str, "Filter type for search"] | None = None,
47
+ filter_date_after: Annotated[str,
48
+ "Date after which to filter"] | None = None,
49
+ page_size: Annotated[int | float | str,
50
+ "Page size for pagination. Recommended: 25 (smaller for LLM-friendly responses)."] | None = None,
51
+ page_number: Annotated[int | float | str,
52
+ "Page number for pagination (1-based)."] | None = None,
53
+ ) -> dict[str, Any]:
54
+ unity_instance = get_unity_instance_from_context(ctx)
55
+
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()
61
+
62
+ # --- Normalize properties using robust module-level helper ---
63
+ properties, parse_error = normalize_properties(properties)
64
+ if parse_error:
65
+ await ctx.error(f"manage_asset: {parse_error}")
66
+ return {"success": False, "message": parse_error}
67
+
68
+ page_size = coerce_int(page_size)
69
+ page_number = coerce_int(page_number)
70
+
71
+ # --- Payload-safe normalization for common LLM mistakes (search) ---
72
+ # Unity's C# handler treats `path` as a folder scope. If a model mistakenly puts a query like
73
+ # "t:MonoScript" into `path`, Unity will consider it an invalid folder and fall back to searching
74
+ # the entire project, which is token-heavy. Normalize such cases into search_pattern + Assets scope.
75
+ action_l = (action or "").lower()
76
+ if action_l == "search":
77
+ try:
78
+ raw_path = (path or "").strip()
79
+ except (AttributeError, TypeError):
80
+ # Handle case where path is not a string despite type annotation
81
+ raw_path = ""
82
+
83
+ # If the caller put an AssetDatabase query into `path`, treat it as `search_pattern`.
84
+ if (not search_pattern) and raw_path.startswith("t:"):
85
+ search_pattern = raw_path
86
+ path = "Assets"
87
+ await ctx.info("manage_asset(search): normalized query from `path` into `search_pattern` and set path='Assets'")
88
+
89
+ # If the caller used `asset_type` to mean a search filter, map it to filter_type.
90
+ # (In Unity, filterType becomes `t:<filterType>`.)
91
+ if (not filter_type) and asset_type and isinstance(asset_type, str):
92
+ filter_type = asset_type
93
+ await ctx.info("manage_asset(search): mapped `asset_type` into `filter_type` for safer server-side filtering")
94
+
95
+ # Prepare parameters for the C# handler
96
+ params_dict = {
97
+ "action": action.lower(),
98
+ "path": path,
99
+ "assetType": asset_type,
100
+ "properties": properties,
101
+ "destination": destination,
102
+ "generatePreview": generate_preview,
103
+ "searchPattern": search_pattern,
104
+ "filterType": filter_type,
105
+ "filterDateAfter": filter_date_after,
106
+ "pageSize": page_size,
107
+ "pageNumber": page_number
108
+ }
109
+
110
+ # Remove None values to avoid sending unnecessary nulls
111
+ params_dict = {k: v for k, v in params_dict.items() if v is not None}
112
+
113
+ # Get the current asyncio event loop
114
+ loop = asyncio.get_running_loop()
115
+
116
+ # Use centralized async retry helper with instance routing
117
+ result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_asset", params_dict, loop=loop)
118
+ # Return the result obtained from Unity
119
+ return result if isinstance(result, dict) else {"success": False, "message": str(result)}