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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +87 -0
- cli/commands/asset.py +310 -0
- cli/commands/audio.py +133 -0
- cli/commands/batch.py +184 -0
- cli/commands/code.py +189 -0
- cli/commands/component.py +212 -0
- cli/commands/editor.py +487 -0
- cli/commands/gameobject.py +510 -0
- cli/commands/instance.py +101 -0
- cli/commands/lighting.py +128 -0
- cli/commands/material.py +268 -0
- cli/commands/prefab.py +144 -0
- cli/commands/scene.py +255 -0
- cli/commands/script.py +240 -0
- cli/commands/shader.py +238 -0
- cli/commands/ui.py +263 -0
- cli/commands/vfx.py +439 -0
- cli/main.py +248 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/connection.py +191 -0
- cli/utils/output.py +195 -0
- main.py +207 -62
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
- mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
- services/custom_tool_service.py +179 -19
- services/resources/__init__.py +6 -1
- services/resources/active_tool.py +1 -1
- services/resources/custom_tools.py +2 -2
- services/resources/editor_state.py +283 -21
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +1 -1
- services/resources/prefab_stage.py +1 -1
- services/resources/project_info.py +1 -1
- services/resources/selection.py +1 -1
- services/resources/tags.py +1 -1
- services/resources/unity_instances.py +1 -1
- services/resources/windows.py +1 -1
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +6 -1
- services/tools/batch_execute.py +24 -9
- services/tools/debug_request_context.py +8 -2
- services/tools/execute_custom_tool.py +6 -1
- services/tools/execute_menu_item.py +6 -3
- services/tools/find_gameobjects.py +89 -0
- services/tools/find_in_file.py +26 -19
- services/tools/manage_asset.py +19 -43
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +9 -8
- services/tools/manage_gameobject.py +120 -79
- services/tools/manage_material.py +80 -31
- services/tools/manage_prefabs.py +7 -1
- services/tools/manage_scene.py +34 -13
- services/tools/manage_script.py +62 -19
- services/tools/manage_scriptable_object.py +22 -10
- services/tools/manage_shader.py +8 -1
- services/tools/manage_vfx.py +738 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +81 -18
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +202 -41
- services/tools/script_apply_edits.py +15 -7
- services/tools/set_active_instance.py +12 -7
- services/tools/utils.py +60 -6
- transport/legacy/port_discovery.py +2 -2
- transport/legacy/unity_connection.py +129 -26
- transport/plugin_hub.py +191 -19
- transport/unity_instance_middleware.py +93 -2
- transport/unity_transport.py +17 -6
- utils/focus_nudge.py +321 -0
- __init__.py +0 -0
- mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
- routes/__init__.py +0 -0
- {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
|
|
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}"}
|
services/tools/find_in_file.py
CHANGED
|
@@ -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
|
-
-
|
|
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("
|
|
28
|
-
raw_path = uri[len("
|
|
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(
|
|
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,
|
|
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(),
|
|
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
|
-
|
services/tools/manage_asset.py
CHANGED
|
@@ -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]
|
|
31
|
-
"Dictionary
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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}"}
|
services/tools/manage_editor.py
CHANGED
|
@@ -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,
|
|
45
|
-
"tagName": tag_name,
|
|
46
|
-
"layerName": 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
|
|