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,339 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import JSONResponse
|
|
11
|
+
|
|
12
|
+
from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
|
|
13
|
+
from transport.unity_transport import send_with_unity_instance
|
|
14
|
+
from transport.legacy.unity_connection import (
|
|
15
|
+
async_send_command_with_retry,
|
|
16
|
+
get_unity_connection_pool,
|
|
17
|
+
)
|
|
18
|
+
from transport.plugin_hub import PluginHub
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("mcp-for-unity-server")
|
|
21
|
+
|
|
22
|
+
_DEFAULT_POLL_INTERVAL = 1.0
|
|
23
|
+
_MAX_POLL_SECONDS = 600
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RegisterToolsPayload(BaseModel):
|
|
27
|
+
project_id: str
|
|
28
|
+
project_hash: str | None = None
|
|
29
|
+
tools: list[ToolDefinitionModel]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolRegistrationResponse(BaseModel):
|
|
33
|
+
success: bool
|
|
34
|
+
registered: list[str]
|
|
35
|
+
replaced: list[str]
|
|
36
|
+
message: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CustomToolService:
|
|
40
|
+
_instance: "CustomToolService | None" = None
|
|
41
|
+
|
|
42
|
+
def __init__(self, mcp: FastMCP):
|
|
43
|
+
CustomToolService._instance = self
|
|
44
|
+
self._mcp = mcp
|
|
45
|
+
self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
|
|
46
|
+
self._hash_to_project: dict[str, str] = {}
|
|
47
|
+
self._register_http_routes()
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def get_instance(cls) -> "CustomToolService":
|
|
51
|
+
if cls._instance is None:
|
|
52
|
+
raise RuntimeError("CustomToolService has not been initialized")
|
|
53
|
+
return cls._instance
|
|
54
|
+
|
|
55
|
+
# --- HTTP Routes -----------------------------------------------------
|
|
56
|
+
def _register_http_routes(self) -> None:
|
|
57
|
+
@self._mcp.custom_route("/register-tools", methods=["POST"])
|
|
58
|
+
async def register_tools(request: Request) -> JSONResponse:
|
|
59
|
+
try:
|
|
60
|
+
payload = RegisterToolsPayload.model_validate(await request.json())
|
|
61
|
+
except ValidationError as exc:
|
|
62
|
+
return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
|
|
63
|
+
|
|
64
|
+
registered: list[str] = []
|
|
65
|
+
replaced: list[str] = []
|
|
66
|
+
for tool in payload.tools:
|
|
67
|
+
if self._is_registered(payload.project_id, tool.name):
|
|
68
|
+
replaced.append(tool.name)
|
|
69
|
+
self._register_tool(payload.project_id, tool)
|
|
70
|
+
registered.append(tool.name)
|
|
71
|
+
|
|
72
|
+
if payload.project_hash:
|
|
73
|
+
self._hash_to_project[payload.project_hash.lower(
|
|
74
|
+
)] = payload.project_id
|
|
75
|
+
|
|
76
|
+
message = f"Registered {len(registered)} tool(s)"
|
|
77
|
+
if replaced:
|
|
78
|
+
message += f" (replaced: {', '.join(replaced)})"
|
|
79
|
+
|
|
80
|
+
response = ToolRegistrationResponse(
|
|
81
|
+
success=True,
|
|
82
|
+
registered=registered,
|
|
83
|
+
replaced=replaced,
|
|
84
|
+
message=message,
|
|
85
|
+
)
|
|
86
|
+
return JSONResponse(response.model_dump())
|
|
87
|
+
|
|
88
|
+
# --- Public API for MCP tools ---------------------------------------
|
|
89
|
+
async def list_registered_tools(self, project_id: str) -> list[ToolDefinitionModel]:
|
|
90
|
+
legacy = list(self._project_tools.get(project_id, {}).values())
|
|
91
|
+
hub_tools = await PluginHub.get_tools_for_project(project_id)
|
|
92
|
+
return legacy + hub_tools
|
|
93
|
+
|
|
94
|
+
async def get_tool_definition(self, project_id: str, tool_name: str) -> ToolDefinitionModel | None:
|
|
95
|
+
tool = self._project_tools.get(project_id, {}).get(tool_name)
|
|
96
|
+
if tool:
|
|
97
|
+
return tool
|
|
98
|
+
return await PluginHub.get_tool_definition(project_id, tool_name)
|
|
99
|
+
|
|
100
|
+
async def execute_tool(
|
|
101
|
+
self,
|
|
102
|
+
project_id: str,
|
|
103
|
+
tool_name: str,
|
|
104
|
+
unity_instance: str | None,
|
|
105
|
+
params: dict[str, object] | None = None,
|
|
106
|
+
) -> MCPResponse:
|
|
107
|
+
params = params or {}
|
|
108
|
+
logger.info(
|
|
109
|
+
f"Executing tool '{tool_name}' for project '{project_id}' (instance={unity_instance}) with params: {params}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
definition = await self.get_tool_definition(project_id, tool_name)
|
|
113
|
+
if definition is None:
|
|
114
|
+
return MCPResponse(
|
|
115
|
+
success=False,
|
|
116
|
+
message=f"Tool '{tool_name}' not found for project {project_id}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
response = await send_with_unity_instance(
|
|
120
|
+
async_send_command_with_retry,
|
|
121
|
+
unity_instance,
|
|
122
|
+
tool_name,
|
|
123
|
+
params,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if not definition.requires_polling:
|
|
127
|
+
result = self._normalize_response(response)
|
|
128
|
+
logger.info(f"Tool '{tool_name}' immediate response: {result}")
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
result = await self._poll_until_complete(
|
|
132
|
+
tool_name,
|
|
133
|
+
unity_instance,
|
|
134
|
+
params,
|
|
135
|
+
response,
|
|
136
|
+
definition.poll_action or "status",
|
|
137
|
+
)
|
|
138
|
+
logger.info(f"Tool '{tool_name}' polled response: {result}")
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
# --- Internal helpers ------------------------------------------------
|
|
142
|
+
def _is_registered(self, project_id: str, tool_name: str) -> bool:
|
|
143
|
+
return tool_name in self._project_tools.get(project_id, {})
|
|
144
|
+
|
|
145
|
+
def _register_tool(self, project_id: str, definition: ToolDefinitionModel) -> None:
|
|
146
|
+
self._project_tools.setdefault(project_id, {})[
|
|
147
|
+
definition.name] = definition
|
|
148
|
+
|
|
149
|
+
def get_project_id_for_hash(self, project_hash: str | None) -> str | None:
|
|
150
|
+
if not project_hash:
|
|
151
|
+
return None
|
|
152
|
+
return self._hash_to_project.get(project_hash.lower())
|
|
153
|
+
|
|
154
|
+
async def _poll_until_complete(
|
|
155
|
+
self,
|
|
156
|
+
tool_name: str,
|
|
157
|
+
unity_instance,
|
|
158
|
+
initial_params: dict[str, object],
|
|
159
|
+
initial_response,
|
|
160
|
+
poll_action: str,
|
|
161
|
+
) -> MCPResponse:
|
|
162
|
+
poll_params = dict(initial_params)
|
|
163
|
+
poll_params["action"] = poll_action or "status"
|
|
164
|
+
|
|
165
|
+
deadline = time.time() + _MAX_POLL_SECONDS
|
|
166
|
+
response = initial_response
|
|
167
|
+
|
|
168
|
+
while True:
|
|
169
|
+
status, poll_interval = self._interpret_status(response)
|
|
170
|
+
|
|
171
|
+
if status in ("complete", "error", "final"):
|
|
172
|
+
return self._normalize_response(response)
|
|
173
|
+
|
|
174
|
+
if time.time() > deadline:
|
|
175
|
+
return MCPResponse(
|
|
176
|
+
success=False,
|
|
177
|
+
message=f"Timeout waiting for {tool_name} to complete",
|
|
178
|
+
data=self._safe_response(response),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
await asyncio.sleep(poll_interval)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
response = await send_with_unity_instance(
|
|
185
|
+
async_send_command_with_retry, unity_instance, tool_name, poll_params
|
|
186
|
+
)
|
|
187
|
+
except Exception as exc: # pragma: no cover - network/domain reload variability
|
|
188
|
+
logger.debug(f"Polling {tool_name} failed, will retry: {exc}")
|
|
189
|
+
# Back off modestly but stay responsive.
|
|
190
|
+
response = {
|
|
191
|
+
"_mcp_status": "pending",
|
|
192
|
+
"_mcp_poll_interval": min(max(poll_interval * 2, _DEFAULT_POLL_INTERVAL), 5.0),
|
|
193
|
+
"message": f"Retrying after transient error: {exc}",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _interpret_status(self, response) -> tuple[str, float]:
|
|
197
|
+
if response is None:
|
|
198
|
+
return "pending", _DEFAULT_POLL_INTERVAL
|
|
199
|
+
|
|
200
|
+
if not isinstance(response, dict):
|
|
201
|
+
return "final", _DEFAULT_POLL_INTERVAL
|
|
202
|
+
|
|
203
|
+
status = response.get("_mcp_status")
|
|
204
|
+
if status is None:
|
|
205
|
+
if len(response.keys()) == 0:
|
|
206
|
+
return "pending", _DEFAULT_POLL_INTERVAL
|
|
207
|
+
return "final", _DEFAULT_POLL_INTERVAL
|
|
208
|
+
|
|
209
|
+
if status == "pending":
|
|
210
|
+
interval_raw = response.get(
|
|
211
|
+
"_mcp_poll_interval", _DEFAULT_POLL_INTERVAL)
|
|
212
|
+
try:
|
|
213
|
+
interval = float(interval_raw)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
interval = _DEFAULT_POLL_INTERVAL
|
|
216
|
+
|
|
217
|
+
interval = max(0.1, min(interval, 5.0))
|
|
218
|
+
return "pending", interval
|
|
219
|
+
|
|
220
|
+
if status == "complete":
|
|
221
|
+
return "complete", _DEFAULT_POLL_INTERVAL
|
|
222
|
+
|
|
223
|
+
if status == "error":
|
|
224
|
+
return "error", _DEFAULT_POLL_INTERVAL
|
|
225
|
+
|
|
226
|
+
return "final", _DEFAULT_POLL_INTERVAL
|
|
227
|
+
|
|
228
|
+
def _normalize_response(self, response) -> MCPResponse:
|
|
229
|
+
if isinstance(response, MCPResponse):
|
|
230
|
+
return response
|
|
231
|
+
if isinstance(response, dict):
|
|
232
|
+
return MCPResponse(
|
|
233
|
+
success=response.get("success", True),
|
|
234
|
+
message=response.get("message"),
|
|
235
|
+
error=response.get("error"),
|
|
236
|
+
data=response.get(
|
|
237
|
+
"data", response) if "data" not in response else response["data"],
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
success = True
|
|
241
|
+
message = None
|
|
242
|
+
error = None
|
|
243
|
+
data = None
|
|
244
|
+
|
|
245
|
+
if isinstance(response, dict):
|
|
246
|
+
success = response.get("success", True)
|
|
247
|
+
if "_mcp_status" in response and response["_mcp_status"] == "error":
|
|
248
|
+
success = False
|
|
249
|
+
message = str(response.get("message")) if response.get(
|
|
250
|
+
"message") else None
|
|
251
|
+
error = str(response.get("error")) if response.get(
|
|
252
|
+
"error") else None
|
|
253
|
+
data = response.get("data")
|
|
254
|
+
if "success" not in response and "_mcp_status" not in response:
|
|
255
|
+
data = response
|
|
256
|
+
else:
|
|
257
|
+
success = False
|
|
258
|
+
message = str(response)
|
|
259
|
+
|
|
260
|
+
return MCPResponse(success=success, message=message, error=error, data=data)
|
|
261
|
+
|
|
262
|
+
def _safe_response(self, response):
|
|
263
|
+
if isinstance(response, dict):
|
|
264
|
+
return response
|
|
265
|
+
if response is None:
|
|
266
|
+
return None
|
|
267
|
+
return {"message": str(response)}
|
|
268
|
+
|
|
269
|
+
def _safe_response(self, response):
|
|
270
|
+
if isinstance(response, dict):
|
|
271
|
+
return response
|
|
272
|
+
if response is None:
|
|
273
|
+
return None
|
|
274
|
+
return {"message": str(response)}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def compute_project_id(project_name: str, project_path: str) -> str:
|
|
278
|
+
combined = f"{project_name}:{project_path}"
|
|
279
|
+
return sha256(combined.encode("utf-8")).hexdigest().upper()[:16]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | None:
|
|
283
|
+
if unity_instance is None:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
# stdio transport: resolve via discovered instances with name+path
|
|
287
|
+
try:
|
|
288
|
+
pool = get_unity_connection_pool()
|
|
289
|
+
instances = pool.discover_all_instances()
|
|
290
|
+
target = None
|
|
291
|
+
if "@" in unity_instance:
|
|
292
|
+
name_part, _, hash_hint = unity_instance.partition("@")
|
|
293
|
+
target = next(
|
|
294
|
+
(
|
|
295
|
+
inst for inst in instances
|
|
296
|
+
if inst.name == name_part and inst.hash.startswith(hash_hint)
|
|
297
|
+
),
|
|
298
|
+
None,
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
target = next(
|
|
302
|
+
(
|
|
303
|
+
inst for inst in instances
|
|
304
|
+
if inst.id == unity_instance or inst.hash.startswith(unity_instance)
|
|
305
|
+
),
|
|
306
|
+
None,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if target:
|
|
310
|
+
return compute_project_id(target.name, target.path)
|
|
311
|
+
except Exception:
|
|
312
|
+
logger.debug(
|
|
313
|
+
f"Failed to resolve project id via connection pool for {unity_instance}")
|
|
314
|
+
|
|
315
|
+
# HTTP/WebSocket transport: resolve via PluginHub using project_hash
|
|
316
|
+
try:
|
|
317
|
+
hash_part: Optional[str] = None
|
|
318
|
+
if "@" in unity_instance:
|
|
319
|
+
_, _, suffix = unity_instance.partition("@")
|
|
320
|
+
hash_part = suffix or None
|
|
321
|
+
else:
|
|
322
|
+
hash_part = unity_instance
|
|
323
|
+
|
|
324
|
+
if hash_part:
|
|
325
|
+
lowered = hash_part.lower()
|
|
326
|
+
mapped: Optional[str] = None
|
|
327
|
+
try:
|
|
328
|
+
service = CustomToolService.get_instance()
|
|
329
|
+
mapped = service.get_project_id_for_hash(lowered)
|
|
330
|
+
except RuntimeError:
|
|
331
|
+
mapped = None
|
|
332
|
+
if mapped:
|
|
333
|
+
return mapped
|
|
334
|
+
return lowered
|
|
335
|
+
except Exception:
|
|
336
|
+
logger.debug(
|
|
337
|
+
f"Failed to resolve project id via plugin hub for {unity_instance}")
|
|
338
|
+
|
|
339
|
+
return None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry package for MCP tool auto-discovery.
|
|
3
|
+
"""
|
|
4
|
+
from .tool_registry import (
|
|
5
|
+
mcp_for_unity_tool,
|
|
6
|
+
get_registered_tools,
|
|
7
|
+
clear_tool_registry,
|
|
8
|
+
)
|
|
9
|
+
from .resource_registry import (
|
|
10
|
+
mcp_for_unity_resource,
|
|
11
|
+
get_registered_resources,
|
|
12
|
+
clear_resource_registry,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
'mcp_for_unity_tool',
|
|
17
|
+
'get_registered_tools',
|
|
18
|
+
'clear_tool_registry',
|
|
19
|
+
'mcp_for_unity_resource',
|
|
20
|
+
'get_registered_resources',
|
|
21
|
+
'clear_resource_registry'
|
|
22
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource registry for auto-discovery of MCP resources.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Callable, Any
|
|
5
|
+
|
|
6
|
+
# Global registry to collect decorated resources
|
|
7
|
+
_resource_registry: list[dict[str, Any]] = []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mcp_for_unity_resource(
|
|
11
|
+
uri: str,
|
|
12
|
+
name: str | None = None,
|
|
13
|
+
description: str | None = None,
|
|
14
|
+
**kwargs
|
|
15
|
+
) -> Callable:
|
|
16
|
+
"""
|
|
17
|
+
Decorator for registering MCP resources in the server's resources directory.
|
|
18
|
+
|
|
19
|
+
Resources are registered in the global resource registry.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
name: Resource name (defaults to function name)
|
|
23
|
+
description: Resource description
|
|
24
|
+
**kwargs: Additional arguments passed to @mcp.resource()
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
@mcp_for_unity_resource("mcpforunity://resource", description="Gets something interesting")
|
|
28
|
+
async def my_custom_resource(ctx: Context, ...):
|
|
29
|
+
pass
|
|
30
|
+
"""
|
|
31
|
+
def decorator(func: Callable) -> Callable:
|
|
32
|
+
resource_name = name if name is not None else func.__name__
|
|
33
|
+
_resource_registry.append({
|
|
34
|
+
'func': func,
|
|
35
|
+
'uri': uri,
|
|
36
|
+
'name': resource_name,
|
|
37
|
+
'description': description,
|
|
38
|
+
'kwargs': kwargs
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return func
|
|
42
|
+
|
|
43
|
+
return decorator
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_registered_resources() -> list[dict[str, Any]]:
|
|
47
|
+
"""Get all registered resources"""
|
|
48
|
+
return _resource_registry.copy()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clear_resource_registry():
|
|
52
|
+
"""Clear the resource registry (useful for testing)"""
|
|
53
|
+
_resource_registry.clear()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool registry for auto-discovery of MCP tools.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Callable, Any
|
|
5
|
+
|
|
6
|
+
# Global registry to collect decorated tools
|
|
7
|
+
_tool_registry: list[dict[str, Any]] = []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mcp_for_unity_tool(
|
|
11
|
+
name: str | None = None,
|
|
12
|
+
description: str | None = None,
|
|
13
|
+
**kwargs
|
|
14
|
+
) -> Callable:
|
|
15
|
+
"""
|
|
16
|
+
Decorator for registering MCP tools in the server's tools directory.
|
|
17
|
+
|
|
18
|
+
Tools are registered in the global tool registry.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
name: Tool name (defaults to function name)
|
|
22
|
+
description: Tool description
|
|
23
|
+
**kwargs: Additional arguments passed to @mcp.tool()
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
@mcp_for_unity_tool(description="Does something cool")
|
|
27
|
+
async def my_custom_tool(ctx: Context, ...):
|
|
28
|
+
pass
|
|
29
|
+
"""
|
|
30
|
+
def decorator(func: Callable) -> Callable:
|
|
31
|
+
tool_name = name if name is not None else func.__name__
|
|
32
|
+
_tool_registry.append({
|
|
33
|
+
'func': func,
|
|
34
|
+
'name': tool_name,
|
|
35
|
+
'description': description,
|
|
36
|
+
'kwargs': kwargs
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return func
|
|
40
|
+
|
|
41
|
+
return decorator
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_registered_tools() -> list[dict[str, Any]]:
|
|
45
|
+
"""Get all registered tools"""
|
|
46
|
+
return _tool_registry.copy()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def clear_tool_registry():
|
|
50
|
+
"""Clear the tool registry (useful for testing)"""
|
|
51
|
+
_tool_registry.clear()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Resources package - Auto-discovers and registers all resources in this directory.
|
|
3
|
+
"""
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from core.telemetry_decorator import telemetry_resource
|
|
10
|
+
from core.logging_decorator import log_execution
|
|
11
|
+
|
|
12
|
+
from services.registry import get_registered_resources
|
|
13
|
+
from utils.module_discovery import discover_modules
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("mcp-for-unity-server")
|
|
16
|
+
|
|
17
|
+
# Export decorator for easy imports within tools
|
|
18
|
+
__all__ = ['register_all_resources']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_all_resources(mcp: FastMCP):
|
|
22
|
+
"""
|
|
23
|
+
Auto-discover and register all resources in the resources/ directory.
|
|
24
|
+
|
|
25
|
+
Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
|
|
26
|
+
functions will be automatically registered.
|
|
27
|
+
"""
|
|
28
|
+
logger.info("Auto-discovering MCP for Unity Server resources...")
|
|
29
|
+
# Dynamic import of all modules in this directory
|
|
30
|
+
resources_dir = Path(__file__).parent
|
|
31
|
+
|
|
32
|
+
# Discover and import all modules
|
|
33
|
+
list(discover_modules(resources_dir, __package__))
|
|
34
|
+
|
|
35
|
+
resources = get_registered_resources()
|
|
36
|
+
|
|
37
|
+
if not resources:
|
|
38
|
+
logger.warning("No MCP resources registered!")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
registered_count = 0
|
|
42
|
+
for resource_info in resources:
|
|
43
|
+
func = resource_info['func']
|
|
44
|
+
uri = resource_info['uri']
|
|
45
|
+
resource_name = resource_info['name']
|
|
46
|
+
description = resource_info['description']
|
|
47
|
+
kwargs = resource_info['kwargs']
|
|
48
|
+
|
|
49
|
+
# Check if URI contains query parameters (e.g., {?unity_instance})
|
|
50
|
+
has_query_params = '{?' in uri
|
|
51
|
+
|
|
52
|
+
if has_query_params:
|
|
53
|
+
wrapped_template = log_execution(resource_name, "Resource")(func)
|
|
54
|
+
wrapped_template = telemetry_resource(
|
|
55
|
+
resource_name)(wrapped_template)
|
|
56
|
+
wrapped_template = mcp.resource(
|
|
57
|
+
uri=uri,
|
|
58
|
+
name=resource_name,
|
|
59
|
+
description=description,
|
|
60
|
+
**kwargs,
|
|
61
|
+
)(wrapped_template)
|
|
62
|
+
logger.debug(
|
|
63
|
+
f"Registered resource template: {resource_name} - {uri}")
|
|
64
|
+
registered_count += 1
|
|
65
|
+
resource_info['func'] = wrapped_template
|
|
66
|
+
else:
|
|
67
|
+
wrapped = log_execution(resource_name, "Resource")(func)
|
|
68
|
+
wrapped = telemetry_resource(resource_name)(wrapped)
|
|
69
|
+
wrapped = mcp.resource(
|
|
70
|
+
uri=uri,
|
|
71
|
+
name=resource_name,
|
|
72
|
+
description=description,
|
|
73
|
+
**kwargs,
|
|
74
|
+
)(wrapped)
|
|
75
|
+
resource_info['func'] = wrapped
|
|
76
|
+
logger.debug(
|
|
77
|
+
f"Registered resource: {resource_name} - {description}")
|
|
78
|
+
registered_count += 1
|
|
79
|
+
|
|
80
|
+
logger.info(
|
|
81
|
+
f"Registered {registered_count} MCP resources ({len(resources)} unique)")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.registry import mcp_for_unity_resource
|
|
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
|
+
|
|
10
|
+
|
|
11
|
+
class Vector3(BaseModel):
|
|
12
|
+
"""3D vector."""
|
|
13
|
+
x: float = 0.0
|
|
14
|
+
y: float = 0.0
|
|
15
|
+
z: float = 0.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActiveToolData(BaseModel):
|
|
19
|
+
"""Active tool data fields."""
|
|
20
|
+
activeTool: str = ""
|
|
21
|
+
isCustom: bool = False
|
|
22
|
+
pivotMode: str = ""
|
|
23
|
+
pivotRotation: str = ""
|
|
24
|
+
handleRotation: Vector3 = Vector3()
|
|
25
|
+
handlePosition: Vector3 = Vector3()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ActiveToolResponse(MCPResponse):
|
|
29
|
+
"""Information about the currently active editor tool."""
|
|
30
|
+
data: ActiveToolData = ActiveToolData()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@mcp_for_unity_resource(
|
|
34
|
+
uri="unity://editor/active-tool",
|
|
35
|
+
name="editor_active_tool",
|
|
36
|
+
description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings."
|
|
37
|
+
)
|
|
38
|
+
async def get_active_tool(ctx: Context) -> ActiveToolResponse | MCPResponse:
|
|
39
|
+
"""Get active editor tool information."""
|
|
40
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
41
|
+
response = await send_with_unity_instance(
|
|
42
|
+
async_send_command_with_retry,
|
|
43
|
+
unity_instance,
|
|
44
|
+
"get_active_tool",
|
|
45
|
+
{}
|
|
46
|
+
)
|
|
47
|
+
return ActiveToolResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from fastmcp import Context
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.custom_tool_service import (
|
|
6
|
+
CustomToolService,
|
|
7
|
+
resolve_project_id_for_unity_instance,
|
|
8
|
+
ToolDefinitionModel,
|
|
9
|
+
)
|
|
10
|
+
from services.registry import mcp_for_unity_resource
|
|
11
|
+
from services.tools import get_unity_instance_from_context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomToolsData(BaseModel):
|
|
15
|
+
project_id: str
|
|
16
|
+
tool_count: int
|
|
17
|
+
tools: list[ToolDefinitionModel]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CustomToolsResourceResponse(MCPResponse):
|
|
21
|
+
data: CustomToolsData | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp_for_unity_resource(
|
|
25
|
+
uri="unity://custom-tools",
|
|
26
|
+
name="custom_tools",
|
|
27
|
+
description="Lists custom tools available for the active Unity project.",
|
|
28
|
+
)
|
|
29
|
+
async def get_custom_tools(ctx: Context) -> CustomToolsResourceResponse | MCPResponse:
|
|
30
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
31
|
+
if not unity_instance:
|
|
32
|
+
return MCPResponse(
|
|
33
|
+
success=False,
|
|
34
|
+
message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
project_id = resolve_project_id_for_unity_instance(unity_instance)
|
|
38
|
+
if project_id is None:
|
|
39
|
+
return MCPResponse(
|
|
40
|
+
success=False,
|
|
41
|
+
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
service = CustomToolService.get_instance()
|
|
45
|
+
tools = await service.list_registered_tools(project_id)
|
|
46
|
+
|
|
47
|
+
data = CustomToolsData(
|
|
48
|
+
project_id=project_id,
|
|
49
|
+
tool_count=len(tools),
|
|
50
|
+
tools=tools,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return CustomToolsResourceResponse(
|
|
54
|
+
success=True,
|
|
55
|
+
message="Custom tools retrieved successfully.",
|
|
56
|
+
data=data,
|
|
57
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.registry import mcp_for_unity_resource
|
|
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
|
+
|
|
10
|
+
|
|
11
|
+
class EditorStateData(BaseModel):
|
|
12
|
+
"""Editor state data fields."""
|
|
13
|
+
isPlaying: bool = False
|
|
14
|
+
isPaused: bool = False
|
|
15
|
+
isCompiling: bool = False
|
|
16
|
+
isUpdating: bool = False
|
|
17
|
+
timeSinceStartup: float = 0.0
|
|
18
|
+
activeSceneName: str = ""
|
|
19
|
+
selectionCount: int = 0
|
|
20
|
+
activeObjectName: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EditorStateResponse(MCPResponse):
|
|
24
|
+
"""Dynamic editor state information that changes frequently."""
|
|
25
|
+
data: EditorStateData = EditorStateData()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@mcp_for_unity_resource(
|
|
29
|
+
uri="unity://editor/state",
|
|
30
|
+
name="editor_state",
|
|
31
|
+
description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information."
|
|
32
|
+
)
|
|
33
|
+
async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse:
|
|
34
|
+
"""Get current editor runtime state."""
|
|
35
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
36
|
+
response = await send_with_unity_instance(
|
|
37
|
+
async_send_command_with_retry,
|
|
38
|
+
unity_instance,
|
|
39
|
+
"get_editor_state",
|
|
40
|
+
{}
|
|
41
|
+
)
|
|
42
|
+
return EditorStateResponse(**response) if isinstance(response, dict) else response
|