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.
Files changed (65) hide show
  1. __init__.py +0 -0
  2. core/__init__.py +0 -0
  3. core/config.py +56 -0
  4. core/logging_decorator.py +37 -0
  5. core/telemetry.py +533 -0
  6. core/telemetry_decorator.py +164 -0
  7. main.py +411 -0
  8. mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
  9. mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
  10. mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
  11. mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
  12. mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
  13. mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
  14. models/__init__.py +4 -0
  15. models/models.py +56 -0
  16. models/unity_response.py +47 -0
  17. routes/__init__.py +0 -0
  18. services/__init__.py +0 -0
  19. services/custom_tool_service.py +339 -0
  20. services/registry/__init__.py +22 -0
  21. services/registry/resource_registry.py +53 -0
  22. services/registry/tool_registry.py +51 -0
  23. services/resources/__init__.py +81 -0
  24. services/resources/active_tool.py +47 -0
  25. services/resources/custom_tools.py +57 -0
  26. services/resources/editor_state.py +42 -0
  27. services/resources/layers.py +29 -0
  28. services/resources/menu_items.py +34 -0
  29. services/resources/prefab_stage.py +39 -0
  30. services/resources/project_info.py +39 -0
  31. services/resources/selection.py +55 -0
  32. services/resources/tags.py +30 -0
  33. services/resources/tests.py +55 -0
  34. services/resources/unity_instances.py +122 -0
  35. services/resources/windows.py +47 -0
  36. services/tools/__init__.py +76 -0
  37. services/tools/batch_execute.py +78 -0
  38. services/tools/debug_request_context.py +71 -0
  39. services/tools/execute_custom_tool.py +38 -0
  40. services/tools/execute_menu_item.py +29 -0
  41. services/tools/find_in_file.py +174 -0
  42. services/tools/manage_asset.py +129 -0
  43. services/tools/manage_editor.py +63 -0
  44. services/tools/manage_gameobject.py +240 -0
  45. services/tools/manage_material.py +95 -0
  46. services/tools/manage_prefabs.py +62 -0
  47. services/tools/manage_scene.py +75 -0
  48. services/tools/manage_script.py +602 -0
  49. services/tools/manage_shader.py +64 -0
  50. services/tools/read_console.py +115 -0
  51. services/tools/run_tests.py +108 -0
  52. services/tools/script_apply_edits.py +998 -0
  53. services/tools/set_active_instance.py +112 -0
  54. services/tools/utils.py +60 -0
  55. transport/__init__.py +0 -0
  56. transport/legacy/port_discovery.py +329 -0
  57. transport/legacy/stdio_port_registry.py +65 -0
  58. transport/legacy/unity_connection.py +785 -0
  59. transport/models.py +62 -0
  60. transport/plugin_hub.py +412 -0
  61. transport/plugin_registry.py +123 -0
  62. transport/unity_instance_middleware.py +141 -0
  63. transport/unity_transport.py +103 -0
  64. utils/module_discovery.py +55 -0
  65. 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