mcpforunityserver 9.3.0b20260129104751__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 (103) 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 +258 -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 +52 -0
  33. core/logging_decorator.py +37 -0
  34. core/telemetry.py +551 -0
  35. core/telemetry_decorator.py +164 -0
  36. main.py +713 -0
  37. mcpforunityserver-9.3.0b20260129104751.dist-info/METADATA +216 -0
  38. mcpforunityserver-9.3.0b20260129104751.dist-info/RECORD +103 -0
  39. mcpforunityserver-9.3.0b20260129104751.dist-info/WHEEL +5 -0
  40. mcpforunityserver-9.3.0b20260129104751.dist-info/entry_points.txt +3 -0
  41. mcpforunityserver-9.3.0b20260129104751.dist-info/licenses/LICENSE +21 -0
  42. mcpforunityserver-9.3.0b20260129104751.dist-info/top_level.txt +7 -0
  43. models/__init__.py +4 -0
  44. models/models.py +56 -0
  45. models/unity_response.py +47 -0
  46. services/__init__.py +0 -0
  47. services/custom_tool_service.py +499 -0
  48. services/registry/__init__.py +22 -0
  49. services/registry/resource_registry.py +53 -0
  50. services/registry/tool_registry.py +51 -0
  51. services/resources/__init__.py +86 -0
  52. services/resources/active_tool.py +47 -0
  53. services/resources/custom_tools.py +57 -0
  54. services/resources/editor_state.py +304 -0
  55. services/resources/gameobject.py +243 -0
  56. services/resources/layers.py +29 -0
  57. services/resources/menu_items.py +34 -0
  58. services/resources/prefab.py +191 -0
  59. services/resources/prefab_stage.py +39 -0
  60. services/resources/project_info.py +39 -0
  61. services/resources/selection.py +55 -0
  62. services/resources/tags.py +30 -0
  63. services/resources/tests.py +87 -0
  64. services/resources/unity_instances.py +122 -0
  65. services/resources/windows.py +47 -0
  66. services/state/external_changes_scanner.py +245 -0
  67. services/tools/__init__.py +83 -0
  68. services/tools/batch_execute.py +93 -0
  69. services/tools/debug_request_context.py +86 -0
  70. services/tools/execute_custom_tool.py +43 -0
  71. services/tools/execute_menu_item.py +32 -0
  72. services/tools/find_gameobjects.py +110 -0
  73. services/tools/find_in_file.py +181 -0
  74. services/tools/manage_asset.py +119 -0
  75. services/tools/manage_components.py +131 -0
  76. services/tools/manage_editor.py +64 -0
  77. services/tools/manage_gameobject.py +260 -0
  78. services/tools/manage_material.py +111 -0
  79. services/tools/manage_prefabs.py +174 -0
  80. services/tools/manage_scene.py +111 -0
  81. services/tools/manage_script.py +645 -0
  82. services/tools/manage_scriptable_object.py +87 -0
  83. services/tools/manage_shader.py +71 -0
  84. services/tools/manage_texture.py +581 -0
  85. services/tools/manage_vfx.py +120 -0
  86. services/tools/preflight.py +110 -0
  87. services/tools/read_console.py +151 -0
  88. services/tools/refresh_unity.py +153 -0
  89. services/tools/run_tests.py +317 -0
  90. services/tools/script_apply_edits.py +1006 -0
  91. services/tools/set_active_instance.py +117 -0
  92. services/tools/utils.py +348 -0
  93. transport/__init__.py +0 -0
  94. transport/legacy/port_discovery.py +329 -0
  95. transport/legacy/stdio_port_registry.py +65 -0
  96. transport/legacy/unity_connection.py +888 -0
  97. transport/models.py +63 -0
  98. transport/plugin_hub.py +585 -0
  99. transport/plugin_registry.py +126 -0
  100. transport/unity_instance_middleware.py +232 -0
  101. transport/unity_transport.py +63 -0
  102. utils/focus_nudge.py +589 -0
  103. utils/module_discovery.py +55 -0
@@ -0,0 +1,86 @@
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, *, project_scoped_tools: bool = True):
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
+ if not project_scoped_tools and resource_name == "custom_tools":
50
+ logger.info(
51
+ "Skipping custom_tools resource registration (project-scoped tools disabled)")
52
+ continue
53
+
54
+ # Check if URI contains query parameters (e.g., {?unity_instance})
55
+ has_query_params = '{?' in uri
56
+
57
+ if has_query_params:
58
+ wrapped_template = log_execution(resource_name, "Resource")(func)
59
+ wrapped_template = telemetry_resource(
60
+ resource_name)(wrapped_template)
61
+ wrapped_template = mcp.resource(
62
+ uri=uri,
63
+ name=resource_name,
64
+ description=description,
65
+ **kwargs,
66
+ )(wrapped_template)
67
+ logger.debug(
68
+ f"Registered resource template: {resource_name} - {uri}")
69
+ registered_count += 1
70
+ resource_info['func'] = wrapped_template
71
+ else:
72
+ wrapped = log_execution(resource_name, "Resource")(func)
73
+ wrapped = telemetry_resource(resource_name)(wrapped)
74
+ wrapped = mcp.resource(
75
+ uri=uri,
76
+ name=resource_name,
77
+ description=description,
78
+ **kwargs,
79
+ )(wrapped)
80
+ resource_info['func'] = wrapped
81
+ logger.debug(
82
+ f"Registered resource: {resource_name} - {description}")
83
+ registered_count += 1
84
+
85
+ logger.info(
86
+ 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="mcpforunity://editor/active-tool",
35
+ name="editor_active_tool",
36
+ description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings.\n\nURI: mcpforunity://editor/active-tool"
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="mcpforunity://custom-tools",
26
+ name="custom_tools",
27
+ description="Lists custom tools available for the active Unity project.\n\nURI: mcpforunity://custom-tools",
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 mcpforunity://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,304 @@
1
+ import os
2
+ import time
3
+ from typing import Any
4
+
5
+ from fastmcp import Context
6
+ from pydantic import BaseModel
7
+
8
+ from models import MCPResponse
9
+ from services.registry import mcp_for_unity_resource
10
+ from services.tools import get_unity_instance_from_context
11
+ from services.state.external_changes_scanner import external_changes_scanner
12
+ import transport.unity_transport as unity_transport
13
+ from transport.legacy.unity_connection import async_send_command_with_retry
14
+
15
+
16
+ class EditorStateUnity(BaseModel):
17
+ instance_id: str | None = None
18
+ unity_version: str | None = None
19
+ project_id: str | None = None
20
+ platform: str | None = None
21
+ is_batch_mode: bool | None = None
22
+
23
+
24
+ class EditorStatePlayMode(BaseModel):
25
+ is_playing: bool | None = None
26
+ is_paused: bool | None = None
27
+ is_changing: bool | None = None
28
+
29
+
30
+ class EditorStateActiveScene(BaseModel):
31
+ path: str | None = None
32
+ guid: str | None = None
33
+ name: str | None = None
34
+
35
+
36
+ class EditorStateEditor(BaseModel):
37
+ is_focused: bool | None = None
38
+ play_mode: EditorStatePlayMode | None = None
39
+ active_scene: EditorStateActiveScene | None = None
40
+
41
+
42
+ class EditorStateActivity(BaseModel):
43
+ phase: str | None = None
44
+ since_unix_ms: int | None = None
45
+ reasons: list[str] | None = None
46
+
47
+
48
+ class EditorStateCompilation(BaseModel):
49
+ is_compiling: bool | None = None
50
+ is_domain_reload_pending: bool | None = None
51
+ last_compile_started_unix_ms: int | None = None
52
+ last_compile_finished_unix_ms: int | None = None
53
+ last_domain_reload_before_unix_ms: int | None = None
54
+ last_domain_reload_after_unix_ms: int | None = None
55
+
56
+
57
+ class EditorStateRefresh(BaseModel):
58
+ is_refresh_in_progress: bool | None = None
59
+ last_refresh_requested_unix_ms: int | None = None
60
+ last_refresh_finished_unix_ms: int | None = None
61
+
62
+
63
+ class EditorStateAssets(BaseModel):
64
+ is_updating: bool | None = None
65
+ external_changes_dirty: bool | None = None
66
+ external_changes_last_seen_unix_ms: int | None = None
67
+ external_changes_dirty_since_unix_ms: int | None = None
68
+ external_changes_last_cleared_unix_ms: int | None = None
69
+ refresh: EditorStateRefresh | None = None
70
+
71
+
72
+ class EditorStateLastRun(BaseModel):
73
+ finished_unix_ms: int | None = None
74
+ result: str | None = None
75
+ counts: Any | None = None
76
+
77
+
78
+ class EditorStateTests(BaseModel):
79
+ is_running: bool | None = None
80
+ mode: str | None = None
81
+ current_job_id: str | None = None
82
+ started_unix_ms: int | None = None
83
+ started_by: str | None = None
84
+ last_run: EditorStateLastRun | None = None
85
+
86
+
87
+ class EditorStateTransport(BaseModel):
88
+ unity_bridge_connected: bool | None = None
89
+ last_message_unix_ms: int | None = None
90
+
91
+
92
+ class EditorStateAdvice(BaseModel):
93
+ ready_for_tools: bool | None = None
94
+ blocking_reasons: list[str] | None = None
95
+ recommended_retry_after_ms: int | None = None
96
+ recommended_next_action: str | None = None
97
+
98
+
99
+ class EditorStateStaleness(BaseModel):
100
+ age_ms: int | None = None
101
+ is_stale: bool | None = None
102
+
103
+
104
+ class EditorStateData(BaseModel):
105
+ schema_version: str
106
+ observed_at_unix_ms: int
107
+ sequence: int
108
+ unity: EditorStateUnity | None = None
109
+ editor: EditorStateEditor | None = None
110
+ activity: EditorStateActivity | None = None
111
+ compilation: EditorStateCompilation | None = None
112
+ assets: EditorStateAssets | None = None
113
+ tests: EditorStateTests | None = None
114
+ transport: EditorStateTransport | None = None
115
+ advice: EditorStateAdvice | None = None
116
+ staleness: EditorStateStaleness | None = None
117
+
118
+
119
+ def _now_unix_ms() -> int:
120
+ return int(time.time() * 1000)
121
+
122
+
123
+ def _in_pytest() -> bool:
124
+ # Avoid instance-discovery side effects during the Python integration test suite.
125
+ return bool(os.environ.get("PYTEST_CURRENT_TEST"))
126
+
127
+
128
+ async def infer_single_instance_id(ctx: Context) -> str | None:
129
+ """
130
+ Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
131
+ This makes editor_state outputs self-describing even when no explicit active instance is set.
132
+ """
133
+ await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
134
+
135
+ try:
136
+ transport = unity_transport._current_transport()
137
+ except Exception:
138
+ transport = None
139
+
140
+ if transport == "http":
141
+ # HTTP/WebSocket transport: derive from PluginHub sessions.
142
+ try:
143
+ from transport.plugin_hub import PluginHub
144
+
145
+ sessions_data = await PluginHub.get_sessions()
146
+ sessions = sessions_data.sessions if hasattr(
147
+ sessions_data, "sessions") else {}
148
+ if isinstance(sessions, dict) and len(sessions) == 1:
149
+ session = next(iter(sessions.values()))
150
+ project = getattr(session, "project", None)
151
+ project_hash = getattr(session, "hash", None)
152
+ if project and project_hash:
153
+ return f"{project}@{project_hash}"
154
+ except Exception:
155
+ return None
156
+ return None
157
+
158
+ # Stdio/TCP transport: derive from connection pool discovery.
159
+ try:
160
+ from transport.legacy.unity_connection import get_unity_connection_pool
161
+
162
+ pool = get_unity_connection_pool()
163
+ instances = pool.discover_all_instances(force_refresh=False)
164
+ if isinstance(instances, list) and len(instances) == 1:
165
+ inst = instances[0]
166
+ inst_id = getattr(inst, "id", None)
167
+ return str(inst_id) if inst_id else None
168
+ except Exception:
169
+ return None
170
+ return None
171
+
172
+
173
+ def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
174
+ now_ms = _now_unix_ms()
175
+ observed = state_v2.get("observed_at_unix_ms")
176
+ try:
177
+ observed_ms = int(observed)
178
+ except Exception:
179
+ observed_ms = now_ms
180
+
181
+ age_ms = max(0, now_ms - observed_ms)
182
+ # Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
183
+ is_stale = age_ms > 2000
184
+
185
+ compilation = state_v2.get("compilation") or {}
186
+ tests = state_v2.get("tests") or {}
187
+ assets = state_v2.get("assets") or {}
188
+ refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
189
+
190
+ blocking: list[str] = []
191
+ if compilation.get("is_compiling") is True:
192
+ blocking.append("compiling")
193
+ if compilation.get("is_domain_reload_pending") is True:
194
+ blocking.append("domain_reload")
195
+ if tests.get("is_running") is True:
196
+ blocking.append("running_tests")
197
+ if refresh.get("is_refresh_in_progress") is True:
198
+ blocking.append("asset_refresh")
199
+ if is_stale:
200
+ blocking.append("stale_status")
201
+
202
+ ready_for_tools = len(blocking) == 0
203
+
204
+ state_v2["advice"] = {
205
+ "ready_for_tools": ready_for_tools,
206
+ "blocking_reasons": blocking,
207
+ "recommended_retry_after_ms": 0 if ready_for_tools else 500,
208
+ "recommended_next_action": "none" if ready_for_tools else "retry_later",
209
+ }
210
+ state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
211
+ return state_v2
212
+
213
+
214
+ @mcp_for_unity_resource(
215
+ uri="mcpforunity://editor/state",
216
+ name="editor_state",
217
+ description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.\n\nURI: mcpforunity://editor/state",
218
+ )
219
+ async def get_editor_state(ctx: Context) -> MCPResponse:
220
+ unity_instance = get_unity_instance_from_context(ctx)
221
+
222
+ response = await unity_transport.send_with_unity_instance(
223
+ async_send_command_with_retry,
224
+ unity_instance,
225
+ "get_editor_state",
226
+ {},
227
+ )
228
+
229
+ # If Unity returns a structured retry hint or error, surface it directly.
230
+ if isinstance(response, dict) and not response.get("success", True):
231
+ return MCPResponse(**response)
232
+
233
+ state_v2 = response.get("data") if isinstance(
234
+ response, dict) and isinstance(response.get("data"), dict) else {}
235
+ state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
236
+ state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
237
+ state_v2.setdefault("sequence", 0)
238
+
239
+ # Ensure the returned snapshot is clearly associated with the targeted instance.
240
+ unity_section = state_v2.get("unity")
241
+ if not isinstance(unity_section, dict):
242
+ unity_section = {}
243
+ state_v2["unity"] = unity_section
244
+ current_instance_id = unity_section.get("instance_id")
245
+ if current_instance_id in (None, ""):
246
+ if unity_instance:
247
+ unity_section["instance_id"] = unity_instance
248
+ else:
249
+ inferred = await infer_single_instance_id(ctx)
250
+ if inferred:
251
+ unity_section["instance_id"] = inferred
252
+
253
+ # External change detection (server-side): compute per instance based on project root path.
254
+ try:
255
+ instance_id = unity_section.get("instance_id")
256
+ if isinstance(instance_id, str) and instance_id.strip():
257
+ from services.resources.project_info import get_project_info
258
+
259
+ proj_resp = await get_project_info(ctx)
260
+ proj = proj_resp.model_dump() if hasattr(
261
+ proj_resp, "model_dump") else proj_resp
262
+ proj_data = proj.get("data") if isinstance(proj, dict) else None
263
+ project_root = proj_data.get("projectRoot") if isinstance(
264
+ proj_data, dict) else None
265
+ if isinstance(project_root, str) and project_root.strip():
266
+ external_changes_scanner.set_project_root(
267
+ instance_id, project_root)
268
+
269
+ ext = external_changes_scanner.update_and_get(instance_id)
270
+
271
+ assets = state_v2.get("assets")
272
+ if not isinstance(assets, dict):
273
+ assets = {}
274
+ state_v2["assets"] = assets
275
+ assets["external_changes_dirty"] = bool(
276
+ ext.get("external_changes_dirty", False))
277
+ assets["external_changes_last_seen_unix_ms"] = ext.get(
278
+ "external_changes_last_seen_unix_ms")
279
+ assets["external_changes_dirty_since_unix_ms"] = ext.get(
280
+ "dirty_since_unix_ms")
281
+ assets["external_changes_last_cleared_unix_ms"] = ext.get(
282
+ "last_cleared_unix_ms")
283
+ except Exception:
284
+ pass
285
+
286
+ state_v2 = _enrich_advice_and_staleness(state_v2)
287
+
288
+ try:
289
+ if hasattr(EditorStateData, "model_validate"):
290
+ validated = EditorStateData.model_validate(state_v2)
291
+ else:
292
+ validated = EditorStateData.parse_obj(
293
+ state_v2) # type: ignore[attr-defined]
294
+ data = validated.model_dump() if hasattr(
295
+ validated, "model_dump") else validated.dict()
296
+ except Exception as e:
297
+ return MCPResponse(
298
+ success=False,
299
+ error="invalid_editor_state",
300
+ message=f"Editor state payload failed validation: {e}",
301
+ data={"raw": state_v2},
302
+ )
303
+
304
+ return MCPResponse(success=True, message="Retrieved editor state.", data=data)