mcpforunityserver 9.4.0b20260203025228__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -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 +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -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 +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from fastmcp import Context, FastMCP
|
|
9
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import JSONResponse
|
|
12
|
+
|
|
13
|
+
from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
|
|
14
|
+
from core.logging_decorator import log_execution
|
|
15
|
+
from core.telemetry_decorator import telemetry_tool
|
|
16
|
+
from transport.unity_transport import send_with_unity_instance
|
|
17
|
+
from transport.legacy.unity_connection import (
|
|
18
|
+
async_send_command_with_retry,
|
|
19
|
+
get_unity_connection_pool,
|
|
20
|
+
)
|
|
21
|
+
from transport.plugin_hub import PluginHub
|
|
22
|
+
from services.tools import get_unity_instance_from_context
|
|
23
|
+
from services.registry import get_registered_tools
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("mcp-for-unity-server")
|
|
26
|
+
|
|
27
|
+
_DEFAULT_POLL_INTERVAL = 1.0
|
|
28
|
+
_MAX_POLL_SECONDS = 600
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RegisterToolsPayload(BaseModel):
|
|
32
|
+
project_id: str
|
|
33
|
+
project_hash: str | None = None
|
|
34
|
+
tools: list[ToolDefinitionModel]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolRegistrationResponse(BaseModel):
|
|
38
|
+
success: bool
|
|
39
|
+
registered: list[str]
|
|
40
|
+
replaced: list[str]
|
|
41
|
+
message: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CustomToolService:
|
|
45
|
+
_instance: "CustomToolService | None" = None
|
|
46
|
+
|
|
47
|
+
def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
|
|
48
|
+
CustomToolService._instance = self
|
|
49
|
+
self._mcp = mcp
|
|
50
|
+
self._project_scoped_tools = project_scoped_tools
|
|
51
|
+
self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
|
|
52
|
+
self._hash_to_project: dict[str, str] = {}
|
|
53
|
+
self._global_tools: dict[str, ToolDefinitionModel] = {}
|
|
54
|
+
self._register_http_routes()
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def get_instance(cls) -> "CustomToolService":
|
|
58
|
+
if cls._instance is None:
|
|
59
|
+
raise RuntimeError("CustomToolService has not been initialized")
|
|
60
|
+
return cls._instance
|
|
61
|
+
|
|
62
|
+
# --- HTTP Routes -----------------------------------------------------
|
|
63
|
+
def _register_http_routes(self) -> None:
|
|
64
|
+
@self._mcp.custom_route("/register-tools", methods=["POST"])
|
|
65
|
+
async def register_tools(request: Request) -> JSONResponse:
|
|
66
|
+
try:
|
|
67
|
+
payload = RegisterToolsPayload.model_validate(await request.json())
|
|
68
|
+
except ValidationError as exc:
|
|
69
|
+
return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
|
|
70
|
+
|
|
71
|
+
registered, replaced = self._register_project_tools(
|
|
72
|
+
payload.project_id, payload.tools, project_hash=payload.project_hash)
|
|
73
|
+
|
|
74
|
+
message = f"Registered {len(registered)} tool(s)"
|
|
75
|
+
if replaced:
|
|
76
|
+
message += f" (replaced: {', '.join(replaced)})"
|
|
77
|
+
|
|
78
|
+
response = ToolRegistrationResponse(
|
|
79
|
+
success=True,
|
|
80
|
+
registered=registered,
|
|
81
|
+
replaced=replaced,
|
|
82
|
+
message=message,
|
|
83
|
+
)
|
|
84
|
+
return JSONResponse(response.model_dump())
|
|
85
|
+
|
|
86
|
+
# --- Public API for MCP tools ---------------------------------------
|
|
87
|
+
async def list_registered_tools(self, project_id: str) -> list[ToolDefinitionModel]:
|
|
88
|
+
legacy = list(self._project_tools.get(project_id, {}).values())
|
|
89
|
+
hub_tools = await PluginHub.get_tools_for_project(project_id)
|
|
90
|
+
return legacy + hub_tools
|
|
91
|
+
|
|
92
|
+
async def get_tool_definition(self, project_id: str, tool_name: str) -> ToolDefinitionModel | None:
|
|
93
|
+
tool = self._project_tools.get(project_id, {}).get(tool_name)
|
|
94
|
+
if tool:
|
|
95
|
+
return tool
|
|
96
|
+
return await PluginHub.get_tool_definition(project_id, tool_name)
|
|
97
|
+
|
|
98
|
+
async def execute_tool(
|
|
99
|
+
self,
|
|
100
|
+
project_id: str,
|
|
101
|
+
tool_name: str,
|
|
102
|
+
unity_instance: str | None,
|
|
103
|
+
params: dict[str, object] | None = None,
|
|
104
|
+
) -> MCPResponse:
|
|
105
|
+
params = params or {}
|
|
106
|
+
logger.info(
|
|
107
|
+
f"Executing tool '{tool_name}' for project '{project_id}' (instance={unity_instance}) with params: {params}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
definition = await self.get_tool_definition(project_id, tool_name)
|
|
111
|
+
if definition is None:
|
|
112
|
+
return MCPResponse(
|
|
113
|
+
success=False,
|
|
114
|
+
message=f"Tool '{tool_name}' not found for project {project_id}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
response = await send_with_unity_instance(
|
|
118
|
+
async_send_command_with_retry,
|
|
119
|
+
unity_instance,
|
|
120
|
+
tool_name,
|
|
121
|
+
params,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if not definition.requires_polling:
|
|
125
|
+
result = self._normalize_response(response)
|
|
126
|
+
logger.info(f"Tool '{tool_name}' immediate response: {result}")
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
result = await self._poll_until_complete(
|
|
130
|
+
tool_name,
|
|
131
|
+
unity_instance,
|
|
132
|
+
params,
|
|
133
|
+
response,
|
|
134
|
+
definition.poll_action or "status",
|
|
135
|
+
)
|
|
136
|
+
logger.info(f"Tool '{tool_name}' polled response: {result}")
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
# --- Internal helpers ------------------------------------------------
|
|
140
|
+
def _is_registered(self, project_id: str, tool_name: str) -> bool:
|
|
141
|
+
return tool_name in self._project_tools.get(project_id, {})
|
|
142
|
+
|
|
143
|
+
def _register_tool(self, project_id: str, definition: ToolDefinitionModel) -> None:
|
|
144
|
+
self._project_tools.setdefault(project_id, {})[
|
|
145
|
+
definition.name] = definition
|
|
146
|
+
|
|
147
|
+
def get_project_id_for_hash(self, project_hash: str | None) -> str | None:
|
|
148
|
+
if not project_hash:
|
|
149
|
+
return None
|
|
150
|
+
return self._hash_to_project.get(project_hash.lower())
|
|
151
|
+
|
|
152
|
+
async def _poll_until_complete(
|
|
153
|
+
self,
|
|
154
|
+
tool_name: str,
|
|
155
|
+
unity_instance,
|
|
156
|
+
initial_params: dict[str, object],
|
|
157
|
+
initial_response,
|
|
158
|
+
poll_action: str,
|
|
159
|
+
) -> MCPResponse:
|
|
160
|
+
poll_params = dict(initial_params)
|
|
161
|
+
poll_params["action"] = poll_action or "status"
|
|
162
|
+
|
|
163
|
+
deadline = time.time() + _MAX_POLL_SECONDS
|
|
164
|
+
response = initial_response
|
|
165
|
+
|
|
166
|
+
while True:
|
|
167
|
+
status, poll_interval = self._interpret_status(response)
|
|
168
|
+
|
|
169
|
+
if status in ("complete", "error", "final"):
|
|
170
|
+
return self._normalize_response(response)
|
|
171
|
+
|
|
172
|
+
if time.time() > deadline:
|
|
173
|
+
return MCPResponse(
|
|
174
|
+
success=False,
|
|
175
|
+
message=f"Timeout waiting for {tool_name} to complete",
|
|
176
|
+
data=self._safe_response(response),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
await asyncio.sleep(poll_interval)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
response = await send_with_unity_instance(
|
|
183
|
+
async_send_command_with_retry, unity_instance, tool_name, poll_params
|
|
184
|
+
)
|
|
185
|
+
except Exception as exc: # pragma: no cover - network/domain reload variability
|
|
186
|
+
logger.debug(f"Polling {tool_name} failed, will retry: {exc}")
|
|
187
|
+
# Back off modestly but stay responsive.
|
|
188
|
+
response = {
|
|
189
|
+
"_mcp_status": "pending",
|
|
190
|
+
"_mcp_poll_interval": min(max(poll_interval * 2, _DEFAULT_POLL_INTERVAL), 5.0),
|
|
191
|
+
"message": f"Retrying after transient error: {exc}",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def _interpret_status(self, response) -> tuple[str, float]:
|
|
195
|
+
if response is None:
|
|
196
|
+
return "pending", _DEFAULT_POLL_INTERVAL
|
|
197
|
+
|
|
198
|
+
if not isinstance(response, dict):
|
|
199
|
+
return "final", _DEFAULT_POLL_INTERVAL
|
|
200
|
+
|
|
201
|
+
status = response.get("_mcp_status")
|
|
202
|
+
if status is None:
|
|
203
|
+
if len(response.keys()) == 0:
|
|
204
|
+
return "pending", _DEFAULT_POLL_INTERVAL
|
|
205
|
+
return "final", _DEFAULT_POLL_INTERVAL
|
|
206
|
+
|
|
207
|
+
if status == "pending":
|
|
208
|
+
interval_raw = response.get(
|
|
209
|
+
"_mcp_poll_interval", _DEFAULT_POLL_INTERVAL)
|
|
210
|
+
try:
|
|
211
|
+
interval = float(interval_raw)
|
|
212
|
+
except (TypeError, ValueError):
|
|
213
|
+
interval = _DEFAULT_POLL_INTERVAL
|
|
214
|
+
|
|
215
|
+
interval = max(0.1, min(interval, 5.0))
|
|
216
|
+
return "pending", interval
|
|
217
|
+
|
|
218
|
+
if status == "complete":
|
|
219
|
+
return "complete", _DEFAULT_POLL_INTERVAL
|
|
220
|
+
|
|
221
|
+
if status == "error":
|
|
222
|
+
return "error", _DEFAULT_POLL_INTERVAL
|
|
223
|
+
|
|
224
|
+
return "final", _DEFAULT_POLL_INTERVAL
|
|
225
|
+
|
|
226
|
+
def _normalize_response(self, response) -> MCPResponse:
|
|
227
|
+
if isinstance(response, MCPResponse):
|
|
228
|
+
return response
|
|
229
|
+
if isinstance(response, dict):
|
|
230
|
+
return MCPResponse(
|
|
231
|
+
success=response.get("success", True),
|
|
232
|
+
message=response.get("message"),
|
|
233
|
+
error=response.get("error"),
|
|
234
|
+
data=response.get(
|
|
235
|
+
"data", response) if "data" not in response else response["data"],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
success = True
|
|
239
|
+
message = None
|
|
240
|
+
error = None
|
|
241
|
+
data = None
|
|
242
|
+
|
|
243
|
+
if isinstance(response, dict):
|
|
244
|
+
success = response.get("success", True)
|
|
245
|
+
if "_mcp_status" in response and response["_mcp_status"] == "error":
|
|
246
|
+
success = False
|
|
247
|
+
message = str(response.get("message")) if response.get(
|
|
248
|
+
"message") else None
|
|
249
|
+
error = str(response.get("error")) if response.get(
|
|
250
|
+
"error") else None
|
|
251
|
+
data = response.get("data")
|
|
252
|
+
if "success" not in response and "_mcp_status" not in response:
|
|
253
|
+
data = response
|
|
254
|
+
else:
|
|
255
|
+
success = False
|
|
256
|
+
message = str(response)
|
|
257
|
+
|
|
258
|
+
return MCPResponse(success=success, message=message, error=error, data=data)
|
|
259
|
+
|
|
260
|
+
def _safe_response(self, response):
|
|
261
|
+
if isinstance(response, dict):
|
|
262
|
+
return response
|
|
263
|
+
if response is None:
|
|
264
|
+
return None
|
|
265
|
+
return {"message": str(response)}
|
|
266
|
+
|
|
267
|
+
def _register_project_tools(
|
|
268
|
+
self,
|
|
269
|
+
project_id: str,
|
|
270
|
+
tools: list[ToolDefinitionModel],
|
|
271
|
+
project_hash: str | None = None,
|
|
272
|
+
) -> tuple[list[str], list[str]]:
|
|
273
|
+
registered: list[str] = []
|
|
274
|
+
replaced: list[str] = []
|
|
275
|
+
for tool in tools:
|
|
276
|
+
if self._is_registered(project_id, tool.name):
|
|
277
|
+
replaced.append(tool.name)
|
|
278
|
+
self._register_tool(project_id, tool)
|
|
279
|
+
registered.append(tool.name)
|
|
280
|
+
if not self._project_scoped_tools:
|
|
281
|
+
self._register_global_tool(tool)
|
|
282
|
+
|
|
283
|
+
if project_hash:
|
|
284
|
+
self._hash_to_project[project_hash.lower()] = project_id
|
|
285
|
+
|
|
286
|
+
return registered, replaced
|
|
287
|
+
|
|
288
|
+
def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None:
|
|
289
|
+
if self._project_scoped_tools:
|
|
290
|
+
return
|
|
291
|
+
builtin_names = self._get_builtin_tool_names()
|
|
292
|
+
for tool in tools:
|
|
293
|
+
if tool.name in builtin_names:
|
|
294
|
+
logger.info(
|
|
295
|
+
"Skipping global custom tool registration for built-in tool '%s'",
|
|
296
|
+
tool.name,
|
|
297
|
+
)
|
|
298
|
+
continue
|
|
299
|
+
self._register_global_tool(tool)
|
|
300
|
+
|
|
301
|
+
def _get_builtin_tool_names(self) -> set[str]:
|
|
302
|
+
return {tool["name"] for tool in get_registered_tools()}
|
|
303
|
+
|
|
304
|
+
def _register_global_tool(self, definition: ToolDefinitionModel) -> None:
|
|
305
|
+
existing = self._global_tools.get(definition.name)
|
|
306
|
+
if existing:
|
|
307
|
+
if existing.model_dump() != definition.model_dump():
|
|
308
|
+
logger.warning(
|
|
309
|
+
"Custom tool '%s' already registered with a different schema; keeping existing definition.",
|
|
310
|
+
definition.name,
|
|
311
|
+
)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
handler = self._build_global_tool_handler(definition)
|
|
315
|
+
wrapped = log_execution(definition.name, "Tool")(handler)
|
|
316
|
+
wrapped = telemetry_tool(definition.name)(wrapped)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
wrapped = self._mcp.tool(
|
|
320
|
+
name=definition.name,
|
|
321
|
+
description=definition.description,
|
|
322
|
+
)(wrapped)
|
|
323
|
+
except Exception as exc: # pragma: no cover - defensive against tool conflicts
|
|
324
|
+
logger.warning(
|
|
325
|
+
"Failed to register custom tool '%s' globally: %s",
|
|
326
|
+
definition.name,
|
|
327
|
+
exc,
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
self._global_tools[definition.name] = definition
|
|
332
|
+
|
|
333
|
+
def _build_global_tool_handler(self, definition: ToolDefinitionModel):
|
|
334
|
+
async def _handler(ctx: Context, **kwargs) -> MCPResponse:
|
|
335
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
336
|
+
if not unity_instance:
|
|
337
|
+
return MCPResponse(
|
|
338
|
+
success=False,
|
|
339
|
+
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
project_id = resolve_project_id_for_unity_instance(unity_instance)
|
|
343
|
+
if project_id is None:
|
|
344
|
+
return MCPResponse(
|
|
345
|
+
success=False,
|
|
346
|
+
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
params = {k: v for k, v in kwargs.items() if v is not None}
|
|
350
|
+
service = CustomToolService.get_instance()
|
|
351
|
+
return await service.execute_tool(project_id, definition.name, unity_instance, params)
|
|
352
|
+
|
|
353
|
+
_handler.__name__ = f"custom_tool_{definition.name}"
|
|
354
|
+
_handler.__doc__ = definition.description or ""
|
|
355
|
+
_handler.__signature__ = self._build_signature(definition)
|
|
356
|
+
_handler.__annotations__ = self._build_annotations(definition)
|
|
357
|
+
return _handler
|
|
358
|
+
|
|
359
|
+
def _build_signature(self, definition: ToolDefinitionModel) -> inspect.Signature:
|
|
360
|
+
params: list[inspect.Parameter] = [
|
|
361
|
+
inspect.Parameter(
|
|
362
|
+
"ctx",
|
|
363
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
364
|
+
annotation=Context,
|
|
365
|
+
)
|
|
366
|
+
]
|
|
367
|
+
for param in definition.parameters:
|
|
368
|
+
if not param.name.isidentifier():
|
|
369
|
+
logger.warning(
|
|
370
|
+
"Custom tool '%s' has non-identifier parameter '%s'; exposing via kwargs only.",
|
|
371
|
+
definition.name,
|
|
372
|
+
param.name,
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
default = inspect._empty if param.required else self._coerce_default(
|
|
376
|
+
param.default_value, param.type)
|
|
377
|
+
params.append(
|
|
378
|
+
inspect.Parameter(
|
|
379
|
+
param.name,
|
|
380
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
381
|
+
default=default,
|
|
382
|
+
annotation=self._map_param_type(param),
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
return inspect.Signature(parameters=params)
|
|
386
|
+
|
|
387
|
+
def _build_annotations(self, definition: ToolDefinitionModel) -> dict[str, object]:
|
|
388
|
+
annotations: dict[str, object] = {"ctx": Context}
|
|
389
|
+
for param in definition.parameters:
|
|
390
|
+
if not param.name.isidentifier():
|
|
391
|
+
continue
|
|
392
|
+
annotations[param.name] = self._map_param_type(param)
|
|
393
|
+
return annotations
|
|
394
|
+
|
|
395
|
+
def _map_param_type(self, param: ToolParameterModel):
|
|
396
|
+
ptype = (param.type or "string").lower()
|
|
397
|
+
if ptype in ("integer", "int"):
|
|
398
|
+
return int
|
|
399
|
+
if ptype in ("number", "float", "double"):
|
|
400
|
+
return float
|
|
401
|
+
if ptype in ("bool", "boolean"):
|
|
402
|
+
return bool
|
|
403
|
+
if ptype in ("array", "list"):
|
|
404
|
+
return list
|
|
405
|
+
if ptype in ("object", "dict"):
|
|
406
|
+
return dict
|
|
407
|
+
return str
|
|
408
|
+
|
|
409
|
+
def _coerce_default(self, value: str | None, param_type: str | None):
|
|
410
|
+
if value is None:
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
ptype = (param_type or "string").lower()
|
|
414
|
+
if ptype in ("integer", "int"):
|
|
415
|
+
return int(value)
|
|
416
|
+
if ptype in ("number", "float", "double"):
|
|
417
|
+
return float(value)
|
|
418
|
+
if ptype in ("bool", "boolean"):
|
|
419
|
+
return str(value).lower() in ("1", "true", "yes", "on")
|
|
420
|
+
return value
|
|
421
|
+
except Exception:
|
|
422
|
+
return value
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def compute_project_id(project_name: str, project_path: str) -> str:
|
|
426
|
+
"""
|
|
427
|
+
DEPRECATED: Computes a SHA256-based project ID.
|
|
428
|
+
This function is no longer used as of the multi-session fix.
|
|
429
|
+
Unity instances now use their native project_hash (SHA1-based) for consistency
|
|
430
|
+
across stdio and WebSocket transports.
|
|
431
|
+
"""
|
|
432
|
+
combined = f"{project_name}:{project_path}"
|
|
433
|
+
return sha256(combined.encode("utf-8")).hexdigest().upper()[:16]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | None:
|
|
437
|
+
if unity_instance is None:
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
# stdio transport: resolve via discovered instances with name+path
|
|
441
|
+
try:
|
|
442
|
+
pool = get_unity_connection_pool()
|
|
443
|
+
instances = pool.discover_all_instances()
|
|
444
|
+
target = None
|
|
445
|
+
if "@" in unity_instance:
|
|
446
|
+
name_part, _, hash_hint = unity_instance.partition("@")
|
|
447
|
+
target = next(
|
|
448
|
+
(
|
|
449
|
+
inst for inst in instances
|
|
450
|
+
if inst.name == name_part and inst.hash.startswith(hash_hint)
|
|
451
|
+
),
|
|
452
|
+
None,
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
target = next(
|
|
456
|
+
(
|
|
457
|
+
inst for inst in instances
|
|
458
|
+
if inst.id == unity_instance or inst.hash.startswith(unity_instance)
|
|
459
|
+
),
|
|
460
|
+
None,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if target:
|
|
464
|
+
# Return the project_hash from Unity (not a computed SHA256 hash).
|
|
465
|
+
# This matches the hash Unity uses when registering tools via WebSocket.
|
|
466
|
+
if target.hash:
|
|
467
|
+
return target.hash
|
|
468
|
+
logger.warning(
|
|
469
|
+
f"Unity instance {target.id} has empty hash; cannot resolve project ID")
|
|
470
|
+
return None
|
|
471
|
+
except Exception:
|
|
472
|
+
logger.debug(
|
|
473
|
+
f"Failed to resolve project id via connection pool for {unity_instance}")
|
|
474
|
+
|
|
475
|
+
# HTTP/WebSocket transport: resolve via PluginHub using project_hash
|
|
476
|
+
try:
|
|
477
|
+
hash_part: Optional[str] = None
|
|
478
|
+
if "@" in unity_instance:
|
|
479
|
+
_, _, suffix = unity_instance.partition("@")
|
|
480
|
+
hash_part = suffix or None
|
|
481
|
+
else:
|
|
482
|
+
hash_part = unity_instance
|
|
483
|
+
|
|
484
|
+
if hash_part:
|
|
485
|
+
lowered = hash_part.lower()
|
|
486
|
+
mapped: Optional[str] = None
|
|
487
|
+
try:
|
|
488
|
+
service = CustomToolService.get_instance()
|
|
489
|
+
mapped = service.get_project_id_for_hash(lowered)
|
|
490
|
+
except RuntimeError:
|
|
491
|
+
mapped = None
|
|
492
|
+
if mapped:
|
|
493
|
+
return mapped
|
|
494
|
+
return lowered
|
|
495
|
+
except Exception:
|
|
496
|
+
logger.debug(
|
|
497
|
+
f"Failed to resolve project id via plugin hub for {unity_instance}")
|
|
498
|
+
|
|
499
|
+
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()
|