mcpforunityserver 8.2.3__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.
- __init__.py +0 -0
- core/__init__.py +0 -0
- core/config.py +56 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +533 -0
- core/telemetry_decorator.py +164 -0
- main.py +411 -0
- mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
- mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
- mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
- mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
- mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +47 -0
- routes/__init__.py +0 -0
- services/__init__.py +0 -0
- services/custom_tool_service.py +339 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +81 -0
- services/resources/active_tool.py +47 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +42 -0
- services/resources/layers.py +29 -0
- services/resources/menu_items.py +34 -0
- services/resources/prefab_stage.py +39 -0
- services/resources/project_info.py +39 -0
- services/resources/selection.py +55 -0
- services/resources/tags.py +30 -0
- services/resources/tests.py +55 -0
- services/resources/unity_instances.py +122 -0
- services/resources/windows.py +47 -0
- services/tools/__init__.py +76 -0
- services/tools/batch_execute.py +78 -0
- services/tools/debug_request_context.py +71 -0
- services/tools/execute_custom_tool.py +38 -0
- services/tools/execute_menu_item.py +29 -0
- services/tools/find_in_file.py +174 -0
- services/tools/manage_asset.py +129 -0
- services/tools/manage_editor.py +63 -0
- services/tools/manage_gameobject.py +240 -0
- services/tools/manage_material.py +95 -0
- services/tools/manage_prefabs.py +62 -0
- services/tools/manage_scene.py +75 -0
- services/tools/manage_script.py +602 -0
- services/tools/manage_shader.py +64 -0
- services/tools/read_console.py +115 -0
- services/tools/run_tests.py +108 -0
- services/tools/script_apply_edits.py +998 -0
- services/tools/set_active_instance.py +112 -0
- services/tools/utils.py +60 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +785 -0
- transport/models.py +62 -0
- transport/plugin_hub.py +412 -0
- transport/plugin_registry.py +123 -0
- transport/unity_instance_middleware.py +141 -0
- transport/unity_transport.py +103 -0
- utils/module_discovery.py +55 -0
- utils/reload_sentinel.py +9 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from fastmcp import Context
|
|
2
|
+
from models.models import MCPResponse
|
|
3
|
+
|
|
4
|
+
from services.custom_tool_service import (
|
|
5
|
+
CustomToolService,
|
|
6
|
+
resolve_project_id_for_unity_instance,
|
|
7
|
+
)
|
|
8
|
+
from services.registry import mcp_for_unity_tool
|
|
9
|
+
from services.tools import get_unity_instance_from_context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp_for_unity_tool(
|
|
13
|
+
name="execute_custom_tool",
|
|
14
|
+
description="Execute a project-scoped custom tool registered by Unity.",
|
|
15
|
+
)
|
|
16
|
+
async def execute_custom_tool(ctx: Context, tool_name: str, parameters: dict | None = None) -> MCPResponse:
|
|
17
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
18
|
+
if not unity_instance:
|
|
19
|
+
return MCPResponse(
|
|
20
|
+
success=False,
|
|
21
|
+
message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
project_id = resolve_project_id_for_unity_instance(unity_instance)
|
|
25
|
+
if project_id is None:
|
|
26
|
+
return MCPResponse(
|
|
27
|
+
success=False,
|
|
28
|
+
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if not isinstance(parameters, dict):
|
|
32
|
+
return MCPResponse(
|
|
33
|
+
success=False,
|
|
34
|
+
message="parameters must be an object/dictionary",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
service = CustomToolService.get_instance()
|
|
38
|
+
return await service.execute_tool(project_id, tool_name, unity_instance, parameters)
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
|
|
8
|
+
from models import MCPResponse
|
|
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
|
+
|
|
14
|
+
|
|
15
|
+
@mcp_for_unity_tool(
|
|
16
|
+
description="Execute a Unity menu item by path."
|
|
17
|
+
)
|
|
18
|
+
async def execute_menu_item(
|
|
19
|
+
ctx: Context,
|
|
20
|
+
menu_path: Annotated[str,
|
|
21
|
+
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
|
|
22
|
+
) -> MCPResponse:
|
|
23
|
+
# Get active instance from session state
|
|
24
|
+
# Removed session_state import
|
|
25
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
26
|
+
params_dict: dict[str, Any] = {"menuPath": menu_path}
|
|
27
|
+
params_dict = {k: v for k, v in params_dict.items() if v is not None}
|
|
28
|
+
result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "execute_menu_item", params_dict)
|
|
29
|
+
return MCPResponse(**result) if isinstance(result, dict) else result
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
|
|
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
|
+
|
|
14
|
+
|
|
15
|
+
def _split_uri(uri: str) -> tuple[str, str]:
|
|
16
|
+
"""Split an incoming URI or path into (name, directory) suitable for Unity.
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
- unity://path/Assets/... → keep as Assets-relative (after decode/normalize)
|
|
20
|
+
- file://... → percent-decode, normalize, strip host and leading slashes,
|
|
21
|
+
then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
|
|
22
|
+
Otherwise, fall back to original name/dir behavior.
|
|
23
|
+
- plain paths → decode/normalize separators; if they contain an 'Assets' segment,
|
|
24
|
+
return relative to 'Assets'.
|
|
25
|
+
"""
|
|
26
|
+
raw_path: str
|
|
27
|
+
if uri.startswith("unity://path/"):
|
|
28
|
+
raw_path = uri[len("unity://path/"):]
|
|
29
|
+
elif uri.startswith("file://"):
|
|
30
|
+
parsed = urlparse(uri)
|
|
31
|
+
host = (parsed.netloc or "").strip()
|
|
32
|
+
p = parsed.path or ""
|
|
33
|
+
# UNC: file://server/share/... -> //server/share/...
|
|
34
|
+
if host and host.lower() != "localhost":
|
|
35
|
+
p = f"//{host}{p}"
|
|
36
|
+
# Use percent-decoded path, preserving leading slashes
|
|
37
|
+
raw_path = unquote(p)
|
|
38
|
+
else:
|
|
39
|
+
raw_path = uri
|
|
40
|
+
|
|
41
|
+
# Percent-decode any residual encodings and normalize separators
|
|
42
|
+
raw_path = unquote(raw_path).replace("\\", "/")
|
|
43
|
+
# Strip leading slash only for Windows drive-letter forms like "/C:/..."
|
|
44
|
+
if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
|
|
45
|
+
raw_path = raw_path[1:]
|
|
46
|
+
|
|
47
|
+
# Normalize path (collapse ../, ./)
|
|
48
|
+
norm = os.path.normpath(raw_path).replace("\\", "/")
|
|
49
|
+
|
|
50
|
+
# If an 'Assets' segment exists, compute path relative to it (case-insensitive)
|
|
51
|
+
parts = [p for p in norm.split("/") if p not in ("", ".")]
|
|
52
|
+
idx = next((i for i, seg in enumerate(parts)
|
|
53
|
+
if seg.lower() == "assets"), None)
|
|
54
|
+
assets_rel = "/".join(parts[idx:]) if idx is not None else None
|
|
55
|
+
|
|
56
|
+
effective_path = assets_rel if assets_rel else norm
|
|
57
|
+
# For POSIX absolute paths outside Assets, drop the leading '/'
|
|
58
|
+
# to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
|
|
59
|
+
if effective_path.startswith("/"):
|
|
60
|
+
effective_path = effective_path[1:]
|
|
61
|
+
|
|
62
|
+
name = os.path.splitext(os.path.basename(effective_path))[0]
|
|
63
|
+
directory = os.path.dirname(effective_path)
|
|
64
|
+
return name, directory
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.")
|
|
68
|
+
async def find_in_file(
|
|
69
|
+
ctx: Context,
|
|
70
|
+
uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
|
|
71
|
+
pattern: Annotated[str, "The regex pattern to search for"],
|
|
72
|
+
project_root: Annotated[str | None, "Optional project root path"] = None,
|
|
73
|
+
max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200,
|
|
74
|
+
ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
# project_root is currently unused but kept for interface consistency
|
|
77
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
78
|
+
await ctx.info(
|
|
79
|
+
f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
|
|
80
|
+
|
|
81
|
+
name, directory = _split_uri(uri)
|
|
82
|
+
|
|
83
|
+
# 1. Read file content via Unity
|
|
84
|
+
read_resp = await send_with_unity_instance(
|
|
85
|
+
async_send_command_with_retry,
|
|
86
|
+
unity_instance,
|
|
87
|
+
"manage_script",
|
|
88
|
+
{
|
|
89
|
+
"action": "read",
|
|
90
|
+
"name": name,
|
|
91
|
+
"path": directory,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if not isinstance(read_resp, dict) or not read_resp.get("success"):
|
|
96
|
+
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
|
|
97
|
+
|
|
98
|
+
data = read_resp.get("data", {})
|
|
99
|
+
contents = data.get("contents")
|
|
100
|
+
if not contents and data.get("contentsEncoded") and data.get("encodedContents"):
|
|
101
|
+
try:
|
|
102
|
+
contents = base64.b64decode(data.get("encodedContents", "").encode(
|
|
103
|
+
"utf-8")).decode("utf-8", "replace")
|
|
104
|
+
except (ValueError, TypeError, base64.binascii.Error):
|
|
105
|
+
contents = contents or ""
|
|
106
|
+
|
|
107
|
+
if contents is None:
|
|
108
|
+
return {"success": False, "message": "Could not read file content."}
|
|
109
|
+
|
|
110
|
+
# 2. Perform regex search
|
|
111
|
+
flags = re.MULTILINE
|
|
112
|
+
# Handle ignore_case which can be boolean or string from some clients
|
|
113
|
+
ic = ignore_case
|
|
114
|
+
if isinstance(ic, str):
|
|
115
|
+
ic = ic.lower() in ("true", "1", "yes")
|
|
116
|
+
if ic:
|
|
117
|
+
flags |= re.IGNORECASE
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
regex = re.compile(pattern, flags)
|
|
121
|
+
except re.error as e:
|
|
122
|
+
return {"success": False, "message": f"Invalid regex pattern: {e}"}
|
|
123
|
+
|
|
124
|
+
# If the regex is not multiline specific (doesn't contain \n literal match logic),
|
|
125
|
+
# we could iterate lines. But users might use multiline regexes.
|
|
126
|
+
# Let's search the whole content and map back to lines.
|
|
127
|
+
|
|
128
|
+
found = list(regex.finditer(contents))
|
|
129
|
+
|
|
130
|
+
results = []
|
|
131
|
+
count = 0
|
|
132
|
+
|
|
133
|
+
for m in found:
|
|
134
|
+
if count >= max_results:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
start_idx = m.start()
|
|
138
|
+
end_idx = m.end()
|
|
139
|
+
|
|
140
|
+
# Calculate line number
|
|
141
|
+
# Count newlines up to start_idx
|
|
142
|
+
line_num = contents.count('\n', 0, start_idx) + 1
|
|
143
|
+
|
|
144
|
+
# Get line content for excerpt
|
|
145
|
+
# Find start of line
|
|
146
|
+
line_start = contents.rfind('\n', 0, start_idx) + 1
|
|
147
|
+
# Find end of line
|
|
148
|
+
line_end = contents.find('\n', start_idx)
|
|
149
|
+
if line_end == -1:
|
|
150
|
+
line_end = len(contents)
|
|
151
|
+
|
|
152
|
+
line_content = contents[line_start:line_end]
|
|
153
|
+
|
|
154
|
+
# Create excerpt
|
|
155
|
+
# We can just return the line content as excerpt
|
|
156
|
+
|
|
157
|
+
results.append({
|
|
158
|
+
"line": line_num,
|
|
159
|
+
"content": line_content.strip(), # detailed match info?
|
|
160
|
+
"match": m.group(0),
|
|
161
|
+
"start": start_idx,
|
|
162
|
+
"end": end_idx
|
|
163
|
+
})
|
|
164
|
+
count += 1
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"success": True,
|
|
168
|
+
"data": {
|
|
169
|
+
"matches": results,
|
|
170
|
+
"count": len(results),
|
|
171
|
+
"total_matches": len(found)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines the manage_asset tool for interacting with Unity assets.
|
|
3
|
+
"""
|
|
4
|
+
import ast
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Annotated, Any, Literal
|
|
8
|
+
|
|
9
|
+
from fastmcp import Context
|
|
10
|
+
from services.registry import mcp_for_unity_tool
|
|
11
|
+
from services.tools import get_unity_instance_from_context
|
|
12
|
+
from services.tools.utils import parse_json_payload
|
|
13
|
+
from transport.unity_transport import send_with_unity_instance
|
|
14
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@mcp_for_unity_tool(
|
|
18
|
+
description="Performs asset operations (import, create, modify, delete, etc.) in Unity."
|
|
19
|
+
)
|
|
20
|
+
async def manage_asset(
|
|
21
|
+
ctx: Context,
|
|
22
|
+
action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
|
|
23
|
+
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."],
|
|
24
|
+
asset_type: Annotated[str,
|
|
25
|
+
"Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None,
|
|
26
|
+
properties: Annotated[dict[str, Any] | str,
|
|
27
|
+
"Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
|
|
28
|
+
destination: Annotated[str,
|
|
29
|
+
"Target path for 'duplicate'/'move'."] | None = None,
|
|
30
|
+
generate_preview: Annotated[bool,
|
|
31
|
+
"Generate a preview/thumbnail for the asset when supported."] = False,
|
|
32
|
+
search_pattern: Annotated[str,
|
|
33
|
+
"Search pattern (e.g., '*.prefab')."] | None = None,
|
|
34
|
+
filter_type: Annotated[str, "Filter type for search"] | None = None,
|
|
35
|
+
filter_date_after: Annotated[str,
|
|
36
|
+
"Date after which to filter"] | None = None,
|
|
37
|
+
page_size: Annotated[int | float | str,
|
|
38
|
+
"Page size for pagination"] | None = None,
|
|
39
|
+
page_number: Annotated[int | float | str,
|
|
40
|
+
"Page number for pagination"] | None = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
43
|
+
|
|
44
|
+
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
45
|
+
try:
|
|
46
|
+
parsed = json.loads(raw)
|
|
47
|
+
if not isinstance(parsed, dict):
|
|
48
|
+
return None, f"manage_asset: properties JSON must decode to a dictionary; received {type(parsed)}"
|
|
49
|
+
return parsed, "JSON"
|
|
50
|
+
except json.JSONDecodeError as json_err:
|
|
51
|
+
try:
|
|
52
|
+
parsed = ast.literal_eval(raw)
|
|
53
|
+
if not isinstance(parsed, dict):
|
|
54
|
+
return None, f"manage_asset: properties string must evaluate to a dictionary; received {type(parsed)}"
|
|
55
|
+
return parsed, "Python literal"
|
|
56
|
+
except (ValueError, SyntaxError) as literal_err:
|
|
57
|
+
return None, f"manage_asset: failed to parse properties string. JSON error: {json_err}; literal_eval error: {literal_err}"
|
|
58
|
+
|
|
59
|
+
async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[str, Any] | None, str | None]:
|
|
60
|
+
if raw is None:
|
|
61
|
+
return {}, None
|
|
62
|
+
if isinstance(raw, dict):
|
|
63
|
+
await ctx.info(f"manage_asset: received properties as dict with keys: {list(raw.keys())}")
|
|
64
|
+
return raw, None
|
|
65
|
+
if isinstance(raw, str):
|
|
66
|
+
await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
|
|
67
|
+
# Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
|
|
68
|
+
parsed = parse_json_payload(raw)
|
|
69
|
+
if isinstance(parsed, dict):
|
|
70
|
+
await ctx.info("manage_asset: coerced properties using centralized parser")
|
|
71
|
+
return parsed, None
|
|
72
|
+
|
|
73
|
+
# Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
|
|
74
|
+
parsed, source = _parse_properties_string(raw)
|
|
75
|
+
if parsed is None:
|
|
76
|
+
return None, source
|
|
77
|
+
await ctx.info(f"manage_asset: coerced properties from {source} string to dict")
|
|
78
|
+
return parsed, None
|
|
79
|
+
return None, f"manage_asset: properties must be a dict or JSON string; received {type(raw)}"
|
|
80
|
+
|
|
81
|
+
properties, parse_error = await _normalize_properties(properties)
|
|
82
|
+
if parse_error:
|
|
83
|
+
await ctx.error(parse_error)
|
|
84
|
+
return {"success": False, "message": parse_error}
|
|
85
|
+
|
|
86
|
+
# Coerce numeric inputs defensively
|
|
87
|
+
def _coerce_int(value, default=None):
|
|
88
|
+
if value is None:
|
|
89
|
+
return default
|
|
90
|
+
try:
|
|
91
|
+
if isinstance(value, bool):
|
|
92
|
+
return default
|
|
93
|
+
if isinstance(value, int):
|
|
94
|
+
return int(value)
|
|
95
|
+
s = str(value).strip()
|
|
96
|
+
if s.lower() in ("", "none", "null"):
|
|
97
|
+
return default
|
|
98
|
+
return int(float(s))
|
|
99
|
+
except Exception:
|
|
100
|
+
return default
|
|
101
|
+
|
|
102
|
+
page_size = _coerce_int(page_size)
|
|
103
|
+
page_number = _coerce_int(page_number)
|
|
104
|
+
|
|
105
|
+
# Prepare parameters for the C# handler
|
|
106
|
+
params_dict = {
|
|
107
|
+
"action": action.lower(),
|
|
108
|
+
"path": path,
|
|
109
|
+
"assetType": asset_type,
|
|
110
|
+
"properties": properties,
|
|
111
|
+
"destination": destination,
|
|
112
|
+
"generatePreview": generate_preview,
|
|
113
|
+
"searchPattern": search_pattern,
|
|
114
|
+
"filterType": filter_type,
|
|
115
|
+
"filterDateAfter": filter_date_after,
|
|
116
|
+
"pageSize": page_size,
|
|
117
|
+
"pageNumber": page_number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Remove None values to avoid sending unnecessary nulls
|
|
121
|
+
params_dict = {k: v for k, v in params_dict.items() if v is not None}
|
|
122
|
+
|
|
123
|
+
# Get the current asyncio event loop
|
|
124
|
+
loop = asyncio.get_running_loop()
|
|
125
|
+
|
|
126
|
+
# Use centralized async retry helper with instance routing
|
|
127
|
+
result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_asset", params_dict, loop=loop)
|
|
128
|
+
# Return the result obtained from Unity
|
|
129
|
+
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Annotated, Any, Literal
|
|
2
|
+
|
|
3
|
+
from fastmcp import Context
|
|
4
|
+
from services.registry import mcp_for_unity_tool
|
|
5
|
+
from core.telemetry import is_telemetry_enabled, record_tool_usage
|
|
6
|
+
from services.tools import get_unity_instance_from_context
|
|
7
|
+
from transport.unity_transport import send_with_unity_instance
|
|
8
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
from services.tools.utils import coerce_bool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@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."
|
|
14
|
+
)
|
|
15
|
+
async def manage_editor(
|
|
16
|
+
ctx: Context,
|
|
17
|
+
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer"], "Get and update the Unity Editor state."],
|
|
18
|
+
wait_for_completion: Annotated[bool | str,
|
|
19
|
+
"Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None,
|
|
20
|
+
tool_name: Annotated[str,
|
|
21
|
+
"Tool name when setting active tool"] | None = None,
|
|
22
|
+
tag_name: Annotated[str,
|
|
23
|
+
"Tag name when adding and removing tags"] | None = None,
|
|
24
|
+
layer_name: Annotated[str,
|
|
25
|
+
"Layer name when adding and removing layers"] | None = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
# Get active instance from request state (injected by middleware)
|
|
28
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
29
|
+
|
|
30
|
+
wait_for_completion = coerce_bool(wait_for_completion)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
# Diagnostics: quick telemetry checks
|
|
34
|
+
if action == "telemetry_status":
|
|
35
|
+
return {"success": True, "telemetry_enabled": is_telemetry_enabled()}
|
|
36
|
+
|
|
37
|
+
if action == "telemetry_ping":
|
|
38
|
+
record_tool_usage("diagnostic_ping", True, 1.0, None)
|
|
39
|
+
return {"success": True, "message": "telemetry ping queued"}
|
|
40
|
+
# Prepare parameters, removing None values
|
|
41
|
+
params = {
|
|
42
|
+
"action": action,
|
|
43
|
+
"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.
|
|
51
|
+
}
|
|
52
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
53
|
+
|
|
54
|
+
# Send command using centralized retry helper with instance routing
|
|
55
|
+
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_editor", params)
|
|
56
|
+
|
|
57
|
+
# Preserve structured failure data; unwrap success into a friendlier shape
|
|
58
|
+
if isinstance(response, dict) and response.get("success"):
|
|
59
|
+
return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")}
|
|
60
|
+
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
|
|
61
|
+
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return {"success": False, "message": f"Python error managing editor: {str(e)}"}
|