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.
Files changed (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. 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()