mcpforunityserver 8.7.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 +177 -62
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
- mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-8.7.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 -30
- 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 +3 -4
- 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 +13 -44
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +9 -8
- services/tools/manage_gameobject.py +115 -79
- services/tools/manage_material.py +80 -31
- services/tools/manage_prefabs.py +7 -1
- services/tools/manage_scene.py +30 -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 +15 -12
- services/tools/read_console.py +70 -17
- services/tools/refresh_unity.py +92 -29
- services/tools/run_tests.py +187 -53
- 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 +85 -24
- transport/unity_instance_middleware.py +4 -3
- transport/unity_transport.py +2 -1
- utils/focus_nudge.py +321 -0
- __init__.py +0 -0
- mcpforunityserver-8.7.0.dist-info/RECORD +0 -71
- routes/__init__.py +0 -0
- services/resources/editor_state_v2.py +0 -270
- services/tools/test_jobs.py +0 -94
- {mcpforunityserver-8.7.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
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,15 +1,16 @@
|
|
|
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
|
|
15
16
|
from services.tools.preflight import preflight
|
|
@@ -20,7 +21,11 @@ from services.tools.preflight import preflight
|
|
|
20
21
|
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
|
|
21
22
|
"Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
|
|
22
23
|
"`generate_preview=false` (previews can add large base64 blobs)."
|
|
23
|
-
)
|
|
24
|
+
),
|
|
25
|
+
annotations=ToolAnnotations(
|
|
26
|
+
title="Manage Asset",
|
|
27
|
+
destructiveHint=True,
|
|
28
|
+
),
|
|
24
29
|
)
|
|
25
30
|
async def manage_asset(
|
|
26
31
|
ctx: Context,
|
|
@@ -28,8 +33,8 @@ async def manage_asset(
|
|
|
28
33
|
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
|
|
29
34
|
asset_type: Annotated[str,
|
|
30
35
|
"Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
|
|
31
|
-
properties: Annotated[dict[str, Any]
|
|
32
|
-
"Dictionary
|
|
36
|
+
properties: Annotated[dict[str, Any],
|
|
37
|
+
"Dictionary of properties for 'create'/'modify'. Keys are property names, values are property values."] | None = None,
|
|
33
38
|
destination: Annotated[str,
|
|
34
39
|
"Target path for 'duplicate'/'move'."] | None = None,
|
|
35
40
|
generate_preview: Annotated[bool,
|
|
@@ -54,46 +59,10 @@ async def manage_asset(
|
|
|
54
59
|
if gate is not None:
|
|
55
60
|
return gate.model_dump()
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
parsed = json.loads(raw)
|
|
60
|
-
if not isinstance(parsed, dict):
|
|
61
|
-
return None, f"manage_asset: properties JSON must decode to a dictionary; received {type(parsed)}"
|
|
62
|
-
return parsed, "JSON"
|
|
63
|
-
except json.JSONDecodeError as json_err:
|
|
64
|
-
try:
|
|
65
|
-
parsed = ast.literal_eval(raw)
|
|
66
|
-
if not isinstance(parsed, dict):
|
|
67
|
-
return None, f"manage_asset: properties string must evaluate to a dictionary; received {type(parsed)}"
|
|
68
|
-
return parsed, "Python literal"
|
|
69
|
-
except (ValueError, SyntaxError) as literal_err:
|
|
70
|
-
return None, f"manage_asset: failed to parse properties string. JSON error: {json_err}; literal_eval error: {literal_err}"
|
|
71
|
-
|
|
72
|
-
async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[str, Any] | None, str | None]:
|
|
73
|
-
if raw is None:
|
|
74
|
-
return {}, None
|
|
75
|
-
if isinstance(raw, dict):
|
|
76
|
-
await ctx.info(f"manage_asset: received properties as dict with keys: {list(raw.keys())}")
|
|
77
|
-
return raw, None
|
|
78
|
-
if isinstance(raw, str):
|
|
79
|
-
await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
|
|
80
|
-
# Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
|
|
81
|
-
parsed = parse_json_payload(raw)
|
|
82
|
-
if isinstance(parsed, dict):
|
|
83
|
-
await ctx.info("manage_asset: coerced properties using centralized parser")
|
|
84
|
-
return parsed, None
|
|
85
|
-
|
|
86
|
-
# Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
|
|
87
|
-
parsed, source = _parse_properties_string(raw)
|
|
88
|
-
if parsed is None:
|
|
89
|
-
return None, source
|
|
90
|
-
await ctx.info(f"manage_asset: coerced properties from {source} string to dict")
|
|
91
|
-
return parsed, None
|
|
92
|
-
return None, f"manage_asset: properties must be a dict or JSON string; received {type(raw)}"
|
|
93
|
-
|
|
94
|
-
properties, parse_error = await _normalize_properties(properties)
|
|
62
|
+
# --- Normalize properties using robust module-level helper ---
|
|
63
|
+
properties, parse_error = normalize_properties(properties)
|
|
95
64
|
if parse_error:
|
|
96
|
-
await ctx.error(parse_error)
|
|
65
|
+
await ctx.error(f"manage_asset: {parse_error}")
|
|
97
66
|
return {"success": False, "message": parse_error}
|
|
98
67
|
|
|
99
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
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import math
|
|
3
|
-
from typing import Annotated, Any, Literal
|
|
3
|
+
from typing import Annotated, Any, Literal
|
|
4
4
|
|
|
5
5
|
from fastmcp import Context
|
|
6
|
+
from mcp.types import ToolAnnotations
|
|
7
|
+
|
|
6
8
|
from services.registry import mcp_for_unity_tool
|
|
7
9
|
from services.tools import get_unity_instance_from_context
|
|
8
10
|
from transport.unity_transport import send_with_unity_instance
|
|
@@ -11,12 +13,86 @@ from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
|
|
11
13
|
from services.tools.preflight import preflight
|
|
12
14
|
|
|
13
15
|
|
|
16
|
+
def _normalize_vector(value: Any, default: Any = None) -> list[float] | None:
|
|
17
|
+
"""
|
|
18
|
+
Robustly normalize a vector parameter to [x, y, z] format.
|
|
19
|
+
Handles: list, tuple, JSON string, comma-separated string.
|
|
20
|
+
Returns None if parsing fails.
|
|
21
|
+
"""
|
|
22
|
+
if value is None:
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
# If already a list/tuple with 3 elements, convert to floats
|
|
26
|
+
if isinstance(value, (list, tuple)) and len(value) == 3:
|
|
27
|
+
try:
|
|
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
|
|
30
|
+
except (ValueError, TypeError):
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
# Try parsing as JSON string
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
parsed = parse_json_payload(value)
|
|
36
|
+
if isinstance(parsed, list) and len(parsed) == 3:
|
|
37
|
+
try:
|
|
38
|
+
vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
|
|
39
|
+
return vec if all(math.isfinite(n) for n in vec) else default
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
# Handle legacy comma-separated strings "1,2,3" or "[1,2,3]"
|
|
44
|
+
s = value.strip()
|
|
45
|
+
if s.startswith("[") and s.endswith("]"):
|
|
46
|
+
s = s[1:-1]
|
|
47
|
+
parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
|
|
48
|
+
if len(parts) == 3:
|
|
49
|
+
try:
|
|
50
|
+
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
|
|
51
|
+
return vec if all(math.isfinite(n) for n in vec) else default
|
|
52
|
+
except (ValueError, TypeError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any]] | None, str | None]:
|
|
59
|
+
"""
|
|
60
|
+
Robustly normalize component_properties to a dict.
|
|
61
|
+
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
|
62
|
+
"""
|
|
63
|
+
if value is None:
|
|
64
|
+
return None, None
|
|
65
|
+
|
|
66
|
+
# Already a dict - validate structure
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
return value, None
|
|
69
|
+
|
|
70
|
+
# Try parsing as JSON string
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
# Check for obviously invalid values
|
|
73
|
+
if value in ("[object Object]", "undefined", "null", ""):
|
|
74
|
+
return None, f"component_properties received invalid value: '{value}'. Expected a JSON object like {{\"ComponentName\": {{\"property\": value}}}}"
|
|
75
|
+
|
|
76
|
+
parsed = parse_json_payload(value)
|
|
77
|
+
if isinstance(parsed, dict):
|
|
78
|
+
return parsed, None
|
|
79
|
+
|
|
80
|
+
return None, f"component_properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
|
81
|
+
|
|
82
|
+
return None, f"component_properties must be a dict or JSON string, got {type(value).__name__}"
|
|
83
|
+
|
|
84
|
+
|
|
14
85
|
@mcp_for_unity_tool(
|
|
15
|
-
description="Performs CRUD operations on GameObjects
|
|
86
|
+
description="Performs CRUD operations on GameObjects. Actions: create, modify, delete, duplicate, move_relative. For finding GameObjects use find_gameobjects tool. For component operations use manage_components tool.",
|
|
87
|
+
annotations=ToolAnnotations(
|
|
88
|
+
title="Manage GameObject",
|
|
89
|
+
destructiveHint=True,
|
|
90
|
+
),
|
|
16
91
|
)
|
|
17
92
|
async def manage_gameobject(
|
|
18
93
|
ctx: Context,
|
|
19
|
-
action: Annotated[Literal["create", "modify", "delete", "
|
|
94
|
+
action: Annotated[Literal["create", "modify", "delete", "duplicate",
|
|
95
|
+
"move_relative"], "Action to perform on GameObject."] | None = None,
|
|
20
96
|
target: Annotated[str,
|
|
21
97
|
"GameObject identifier by name or path for modify/delete/component actions"] | None = None,
|
|
22
98
|
search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"],
|
|
@@ -27,12 +103,12 @@ async def manage_gameobject(
|
|
|
27
103
|
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
|
|
28
104
|
parent: Annotated[str,
|
|
29
105
|
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
|
|
30
|
-
position: Annotated[
|
|
31
|
-
"Position
|
|
32
|
-
rotation: Annotated[
|
|
33
|
-
"Rotation
|
|
34
|
-
scale: Annotated[
|
|
35
|
-
"Scale
|
|
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,
|
|
36
112
|
components_to_add: Annotated[list[str],
|
|
37
113
|
"List of component names to add"] | None = None,
|
|
38
114
|
primitive_type: Annotated[str,
|
|
@@ -48,7 +124,7 @@ async def manage_gameobject(
|
|
|
48
124
|
layer: Annotated[str, "Layer name"] | None = None,
|
|
49
125
|
components_to_remove: Annotated[list[str],
|
|
50
126
|
"List of component names to remove"] | None = None,
|
|
51
|
-
component_properties: Annotated[
|
|
127
|
+
component_properties: Annotated[dict[str, dict[str, Any]],
|
|
52
128
|
"""Dictionary of component names to their properties to set. For example:
|
|
53
129
|
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
|
|
54
130
|
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
|
|
@@ -70,24 +146,28 @@ async def manage_gameobject(
|
|
|
70
146
|
includeNonPublicSerialized: Annotated[bool | str,
|
|
71
147
|
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
|
|
72
148
|
# --- Paging/safety for get_components ---
|
|
73
|
-
page_size: Annotated[int | str,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
149
|
+
page_size: Annotated[int | str,
|
|
150
|
+
"Page size for get_components paging."] | None = None,
|
|
151
|
+
cursor: Annotated[int | str,
|
|
152
|
+
"Opaque cursor for get_components paging (offset)."] | None = None,
|
|
153
|
+
max_components: Annotated[int | str,
|
|
154
|
+
"Hard cap on returned components per request (safety)."] | None = None,
|
|
155
|
+
include_properties: Annotated[bool | str,
|
|
156
|
+
"If true, include serialized component properties (bounded)."] | None = None,
|
|
77
157
|
# --- Parameters for 'duplicate' ---
|
|
78
158
|
new_name: Annotated[str,
|
|
79
159
|
"New name for the duplicated object (default: SourceName_Copy)"] | None = None,
|
|
80
|
-
offset: Annotated[
|
|
81
|
-
"Offset from original/reference position
|
|
160
|
+
offset: Annotated[list[float],
|
|
161
|
+
"Offset from original/reference position as [x, y, z] array"] | None = None,
|
|
82
162
|
# --- Parameters for 'move_relative' ---
|
|
83
163
|
reference_object: Annotated[str,
|
|
84
|
-
|
|
164
|
+
"Reference object for relative movement (required for move_relative)"] | None = None,
|
|
85
165
|
direction: Annotated[Literal["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"],
|
|
86
|
-
|
|
166
|
+
"Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None,
|
|
87
167
|
distance: Annotated[float,
|
|
88
|
-
|
|
168
|
+
"Distance to move in the specified direction (default: 1.0)"] | None = None,
|
|
89
169
|
world_space: Annotated[bool | str,
|
|
90
|
-
|
|
170
|
+
"If True (default), use world space directions; if False, use reference object's local directions"] | None = None,
|
|
91
171
|
) -> dict[str, Any]:
|
|
92
172
|
# Get active instance from session state
|
|
93
173
|
# Removed session_state import
|
|
@@ -100,44 +180,16 @@ async def manage_gameobject(
|
|
|
100
180
|
if action is None:
|
|
101
181
|
return {
|
|
102
182
|
"success": False,
|
|
103
|
-
"message": "Missing required parameter 'action'. Valid actions: create, modify, delete,
|
|
183
|
+
"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."
|
|
104
184
|
}
|
|
105
185
|
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# First try to parse if it's a string
|
|
112
|
-
val = parse_json_payload(value)
|
|
113
|
-
|
|
114
|
-
def _to_vec3(parts):
|
|
115
|
-
try:
|
|
116
|
-
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
|
|
117
|
-
except (ValueError, TypeError):
|
|
118
|
-
return default
|
|
119
|
-
return vec if all(math.isfinite(n) for n in vec) else default
|
|
120
|
-
|
|
121
|
-
if isinstance(val, list) and len(val) == 3:
|
|
122
|
-
return _to_vec3(val)
|
|
123
|
-
|
|
124
|
-
# Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays)
|
|
125
|
-
if isinstance(val, str):
|
|
126
|
-
s = val.strip()
|
|
127
|
-
# minimal tolerant parse for "[x,y,z]" or "x,y,z"
|
|
128
|
-
if s.startswith("[") and s.endswith("]"):
|
|
129
|
-
s = s[1:-1]
|
|
130
|
-
# support "x,y,z" and "x y z"
|
|
131
|
-
parts = [p.strip()
|
|
132
|
-
for p in (s.split(",") if "," in s else s.split())]
|
|
133
|
-
if len(parts) == 3:
|
|
134
|
-
return _to_vec3(parts)
|
|
135
|
-
return default
|
|
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)
|
|
136
191
|
|
|
137
|
-
|
|
138
|
-
rotation = _coerce_vec(rotation, default=rotation)
|
|
139
|
-
scale = _coerce_vec(scale, default=scale)
|
|
140
|
-
offset = _coerce_vec(offset, default=offset)
|
|
192
|
+
# --- Normalize boolean parameters ---
|
|
141
193
|
save_as_prefab = coerce_bool(save_as_prefab)
|
|
142
194
|
set_active = coerce_bool(set_active)
|
|
143
195
|
find_all = coerce_bool(find_all)
|
|
@@ -146,36 +198,20 @@ async def manage_gameobject(
|
|
|
146
198
|
includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
|
|
147
199
|
include_properties = coerce_bool(include_properties)
|
|
148
200
|
world_space = coerce_bool(world_space, default=True)
|
|
149
|
-
|
|
201
|
+
|
|
202
|
+
# --- Normalize integer parameters ---
|
|
150
203
|
page_size = coerce_int(page_size, default=None)
|
|
151
204
|
cursor = coerce_int(cursor, default=None)
|
|
152
205
|
max_components = coerce_int(max_components, default=None)
|
|
153
206
|
|
|
154
|
-
#
|
|
155
|
-
component_properties =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return {"success": False, "message": "component_properties must be a JSON object (dict)."}
|
|
160
|
-
|
|
161
|
-
try:
|
|
162
|
-
# Map tag to search_term when search_method is by_tag for backward compatibility
|
|
163
|
-
if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
|
|
164
|
-
search_term = tag
|
|
207
|
+
# --- Normalize component_properties with detailed error handling ---
|
|
208
|
+
component_properties, comp_props_error = _normalize_component_properties(
|
|
209
|
+
component_properties)
|
|
210
|
+
if comp_props_error:
|
|
211
|
+
return {"success": False, "message": comp_props_error}
|
|
165
212
|
|
|
213
|
+
try:
|
|
166
214
|
# Validate parameter usage to prevent silent failures
|
|
167
|
-
if action == "find":
|
|
168
|
-
if name is not None:
|
|
169
|
-
return {
|
|
170
|
-
"success": False,
|
|
171
|
-
"message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'"
|
|
172
|
-
}
|
|
173
|
-
if search_term is None:
|
|
174
|
-
return {
|
|
175
|
-
"success": False,
|
|
176
|
-
"message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find."
|
|
177
|
-
}
|
|
178
|
-
|
|
179
215
|
if action in ["create", "modify"]:
|
|
180
216
|
if search_term is not None:
|
|
181
217
|
return {
|