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,126 @@
1
+ """In-memory registry for connected Unity plugin sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+
8
+ import asyncio
9
+
10
+ from models.models import ToolDefinitionModel
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class PluginSession:
15
+ """Represents a single Unity plugin connection."""
16
+
17
+ session_id: str
18
+ project_name: str
19
+ project_hash: str
20
+ unity_version: str
21
+ registered_at: datetime
22
+ connected_at: datetime
23
+ tools: dict[str, ToolDefinitionModel] = field(default_factory=dict)
24
+ project_id: str | None = None
25
+ project_path: str | None = None # Full path to project root (for focus nudging)
26
+
27
+
28
+ class PluginRegistry:
29
+ """Stores active plugin sessions in-memory.
30
+
31
+ The registry is optimised for quick lookup by either ``session_id`` or
32
+ ``project_hash`` (which is used as the canonical "instance id" across the
33
+ HTTP command routing stack).
34
+ """
35
+
36
+ def __init__(self) -> None:
37
+ self._sessions: dict[str, PluginSession] = {}
38
+ self._hash_to_session: dict[str, str] = {}
39
+ self._lock = asyncio.Lock()
40
+
41
+ async def register(
42
+ self,
43
+ session_id: str,
44
+ project_name: str,
45
+ project_hash: str,
46
+ unity_version: str,
47
+ project_path: str | None = None,
48
+ ) -> PluginSession:
49
+ """Register (or replace) a plugin session.
50
+
51
+ If an existing session already claims the same ``project_hash`` it will be
52
+ replaced, ensuring that reconnect scenarios always map to the latest
53
+ WebSocket connection.
54
+ """
55
+
56
+ async with self._lock:
57
+ now = datetime.now(timezone.utc)
58
+ session = PluginSession(
59
+ session_id=session_id,
60
+ project_name=project_name,
61
+ project_hash=project_hash,
62
+ unity_version=unity_version,
63
+ registered_at=now,
64
+ connected_at=now,
65
+ project_path=project_path,
66
+ )
67
+
68
+ # Remove old mapping for this hash if it existed under a different session
69
+ previous_session_id = self._hash_to_session.get(project_hash)
70
+ if previous_session_id and previous_session_id != session_id:
71
+ self._sessions.pop(previous_session_id, None)
72
+
73
+ self._sessions[session_id] = session
74
+ self._hash_to_session[project_hash] = session_id
75
+ return session
76
+
77
+ async def touch(self, session_id: str) -> None:
78
+ """Update the ``connected_at`` timestamp when a heartbeat is received."""
79
+
80
+ async with self._lock:
81
+ session = self._sessions.get(session_id)
82
+ if session:
83
+ session.connected_at = datetime.now(timezone.utc)
84
+
85
+ async def unregister(self, session_id: str) -> None:
86
+ """Remove a plugin session from the registry."""
87
+
88
+ async with self._lock:
89
+ session = self._sessions.pop(session_id, None)
90
+ if session and session.project_hash in self._hash_to_session:
91
+ # Only delete the mapping if it still points at the removed session.
92
+ mapped = self._hash_to_session.get(session.project_hash)
93
+ if mapped == session_id:
94
+ del self._hash_to_session[session.project_hash]
95
+
96
+ async def register_tools_for_session(self, session_id: str, tools: list[ToolDefinitionModel]) -> None:
97
+ """Register tools for a specific session."""
98
+ async with self._lock:
99
+ session = self._sessions.get(session_id)
100
+ if session:
101
+ # Replace existing tools or merge? Usually replace for "set state".
102
+ # We will replace the dict but keep the field.
103
+ session.tools.clear()
104
+ for tool in tools:
105
+ session.tools[tool.name] = tool
106
+
107
+ async def get_session(self, session_id: str) -> PluginSession | None:
108
+ """Fetch a session by its ``session_id``."""
109
+
110
+ async with self._lock:
111
+ return self._sessions.get(session_id)
112
+
113
+ async def get_session_id_by_hash(self, project_hash: str) -> str | None:
114
+ """Resolve a ``project_hash`` (Unity instance id) to a session id."""
115
+
116
+ async with self._lock:
117
+ return self._hash_to_session.get(project_hash)
118
+
119
+ async def list_sessions(self) -> dict[str, PluginSession]:
120
+ """Return a shallow copy of all known sessions."""
121
+
122
+ async with self._lock:
123
+ return dict(self._sessions)
124
+
125
+
126
+ __all__ = ["PluginRegistry", "PluginSession"]
@@ -0,0 +1,232 @@
1
+ """
2
+ Middleware for managing Unity instance selection per session.
3
+
4
+ This middleware intercepts all tool calls and injects the active Unity instance
5
+ into the request-scoped state, allowing tools to access it via ctx.get_state("unity_instance").
6
+ """
7
+ from threading import RLock
8
+ import logging
9
+
10
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
11
+
12
+ from transport.plugin_hub import PluginHub
13
+
14
+ logger = logging.getLogger("mcp-for-unity-server")
15
+
16
+ # Store a global reference to the middleware instance so tools can interact
17
+ # with it to set or clear the active unity instance.
18
+ _unity_instance_middleware = None
19
+ _middleware_lock = RLock()
20
+
21
+
22
+ def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
23
+ """Get the global Unity instance middleware."""
24
+ global _unity_instance_middleware
25
+ if _unity_instance_middleware is None:
26
+ with _middleware_lock:
27
+ if _unity_instance_middleware is None:
28
+ # Auto-initialize if not set (lazy singleton) to handle import order or test cases
29
+ _unity_instance_middleware = UnityInstanceMiddleware()
30
+
31
+ return _unity_instance_middleware
32
+
33
+
34
+ def set_unity_instance_middleware(middleware: 'UnityInstanceMiddleware') -> None:
35
+ """Set the global Unity instance middleware (called during server initialization)."""
36
+ global _unity_instance_middleware
37
+ _unity_instance_middleware = middleware
38
+
39
+
40
+ class UnityInstanceMiddleware(Middleware):
41
+ """
42
+ Middleware that manages per-session Unity instance selection.
43
+
44
+ Stores active instance per session_id and injects it into request state
45
+ for all tool and resource calls.
46
+ """
47
+
48
+ def __init__(self):
49
+ super().__init__()
50
+ self._active_by_key: dict[str, str] = {}
51
+ self._lock = RLock()
52
+
53
+ def get_session_key(self, ctx) -> str:
54
+ """
55
+ Derive a stable key for the calling session.
56
+
57
+ Prioritizes client_id for stability.
58
+ If client_id is missing, falls back to 'global' (assuming single-user local mode),
59
+ ignoring session_id which can be unstable in some transports/clients.
60
+ """
61
+ client_id = getattr(ctx, "client_id", None)
62
+ if isinstance(client_id, str) and client_id:
63
+ return client_id
64
+
65
+ # Fallback to global for local dev stability
66
+ return "global"
67
+
68
+ def set_active_instance(self, ctx, instance_id: str) -> None:
69
+ """Store the active instance for this session."""
70
+ key = self.get_session_key(ctx)
71
+ with self._lock:
72
+ self._active_by_key[key] = instance_id
73
+
74
+ def get_active_instance(self, ctx) -> str | None:
75
+ """Retrieve the active instance for this session."""
76
+ key = self.get_session_key(ctx)
77
+ with self._lock:
78
+ return self._active_by_key.get(key)
79
+
80
+ def clear_active_instance(self, ctx) -> None:
81
+ """Clear the stored instance for this session."""
82
+ key = self.get_session_key(ctx)
83
+ with self._lock:
84
+ self._active_by_key.pop(key, None)
85
+
86
+ async def _maybe_autoselect_instance(self, ctx) -> str | None:
87
+ """
88
+ Auto-select the sole Unity instance when no active instance is set.
89
+
90
+ Note: This method both *discovers* and *persists* the selection via
91
+ `set_active_instance` as a side-effect, since callers expect the selection
92
+ to stick for subsequent tool/resource calls in the same session.
93
+ """
94
+ try:
95
+ # Import here to avoid circular dependencies / optional transport modules.
96
+ from transport.unity_transport import _current_transport
97
+
98
+ transport = _current_transport()
99
+ if PluginHub.is_configured():
100
+ try:
101
+ sessions_data = await PluginHub.get_sessions()
102
+ sessions = sessions_data.sessions or {}
103
+ ids: list[str] = []
104
+ for session_info in sessions.values():
105
+ project = getattr(
106
+ session_info, "project", None) or "Unknown"
107
+ hash_value = getattr(session_info, "hash", None)
108
+ if hash_value:
109
+ ids.append(f"{project}@{hash_value}")
110
+ if len(ids) == 1:
111
+ chosen = ids[0]
112
+ self.set_active_instance(ctx, chosen)
113
+ logger.info(
114
+ "Auto-selected sole Unity instance via PluginHub: %s",
115
+ chosen,
116
+ )
117
+ return chosen
118
+ except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
119
+ logger.debug(
120
+ "PluginHub auto-select probe failed (%s); falling back to stdio",
121
+ type(exc).__name__,
122
+ exc_info=True,
123
+ )
124
+ except Exception as exc:
125
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
126
+ raise
127
+ logger.debug(
128
+ "PluginHub auto-select probe failed with unexpected error (%s); falling back to stdio",
129
+ type(exc).__name__,
130
+ exc_info=True,
131
+ )
132
+
133
+ if transport != "http":
134
+ try:
135
+ # Import here to avoid circular imports in legacy transport paths.
136
+ from transport.legacy.unity_connection import get_unity_connection_pool
137
+
138
+ pool = get_unity_connection_pool()
139
+ instances = pool.discover_all_instances(force_refresh=True)
140
+ ids = [getattr(inst, "id", None) for inst in instances]
141
+ ids = [inst_id for inst_id in ids if inst_id]
142
+ if len(ids) == 1:
143
+ chosen = ids[0]
144
+ self.set_active_instance(ctx, chosen)
145
+ logger.info(
146
+ "Auto-selected sole Unity instance via stdio discovery: %s",
147
+ chosen,
148
+ )
149
+ return chosen
150
+ except (ConnectionError, ValueError, KeyError, TimeoutError, AttributeError) as exc:
151
+ logger.debug(
152
+ "Stdio auto-select probe failed (%s)",
153
+ type(exc).__name__,
154
+ exc_info=True,
155
+ )
156
+ except Exception as exc:
157
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
158
+ raise
159
+ logger.debug(
160
+ "Stdio auto-select probe failed with unexpected error (%s)",
161
+ type(exc).__name__,
162
+ exc_info=True,
163
+ )
164
+ except Exception as exc:
165
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
166
+ raise
167
+ logger.debug(
168
+ "Auto-select path encountered an unexpected error (%s)",
169
+ type(exc).__name__,
170
+ exc_info=True,
171
+ )
172
+
173
+ return None
174
+
175
+ async def _inject_unity_instance(self, context: MiddlewareContext) -> None:
176
+ """Inject active Unity instance into context if available."""
177
+ ctx = context.fastmcp_context
178
+
179
+ active_instance = self.get_active_instance(ctx)
180
+ if not active_instance:
181
+ active_instance = await self._maybe_autoselect_instance(ctx)
182
+ if active_instance:
183
+ # If using HTTP transport (PluginHub configured), validate session
184
+ # But for stdio transport (no PluginHub needed or maybe partially configured),
185
+ # we should be careful not to clear instance just because PluginHub can't resolve it.
186
+ # The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
187
+
188
+ session_id: str | None = None
189
+ # Only validate via PluginHub if we are actually using HTTP transport
190
+ # OR if we want to support hybrid mode. For now, let's be permissive.
191
+ if PluginHub.is_configured():
192
+ try:
193
+ # resolving session_id might fail if the plugin disconnected
194
+ # We only need session_id for HTTP transport routing.
195
+ # For stdio, we just need the instance ID.
196
+ session_id = await PluginHub._resolve_session_id(active_instance)
197
+ except (ConnectionError, ValueError, KeyError, TimeoutError) as exc:
198
+ # If resolution fails, it means the Unity instance is not reachable via HTTP/WS.
199
+ # If we are in stdio mode, this might still be fine if the user is just setting state?
200
+ # But usually if PluginHub is configured, we expect it to work.
201
+ # Let's LOG the error but NOT clear the instance immediately to avoid flickering,
202
+ # or at least debug why it's failing.
203
+ logger.debug(
204
+ "PluginHub session resolution failed for %s: %s; leaving active_instance unchanged",
205
+ active_instance,
206
+ exc,
207
+ exc_info=True,
208
+ )
209
+ except Exception as exc:
210
+ # Re-raise unexpected system exceptions to avoid swallowing critical failures
211
+ if isinstance(exc, (SystemExit, KeyboardInterrupt)):
212
+ raise
213
+ logger.error(
214
+ "Unexpected error during PluginHub session resolution for %s: %s",
215
+ active_instance,
216
+ exc,
217
+ exc_info=True
218
+ )
219
+
220
+ ctx.set_state("unity_instance", active_instance)
221
+ if session_id is not None:
222
+ ctx.set_state("unity_session_id", session_id)
223
+
224
+ async def on_call_tool(self, context: MiddlewareContext, call_next):
225
+ """Inject active Unity instance into tool context if available."""
226
+ await self._inject_unity_instance(context)
227
+ return await call_next(context)
228
+
229
+ async def on_read_resource(self, context: MiddlewareContext, call_next):
230
+ """Inject active Unity instance into resource context if available."""
231
+ await self._inject_unity_instance(context)
232
+ return await call_next(context)
@@ -0,0 +1,63 @@
1
+ """Transport helpers for routing commands to Unity."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import inspect
6
+ import os
7
+ from typing import Awaitable, Callable, TypeVar
8
+
9
+ from fastmcp import Context
10
+
11
+ from transport.plugin_hub import PluginHub
12
+ from models.models import MCPResponse
13
+ from models.unity_response import normalize_unity_response
14
+ from services.tools import get_unity_instance_from_context
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ def _is_http_transport() -> bool:
20
+ return os.environ.get("UNITY_MCP_TRANSPORT", "stdio").lower() == "http"
21
+
22
+
23
+ def _current_transport() -> str:
24
+ """Expose the active transport mode as a simple string identifier."""
25
+ return "http" if _is_http_transport() else "stdio"
26
+
27
+
28
+ async def send_with_unity_instance(
29
+ send_fn: Callable[..., Awaitable[T]],
30
+ unity_instance: str | None,
31
+ *args,
32
+ **kwargs,
33
+ ) -> T:
34
+ if _is_http_transport():
35
+ if not args:
36
+ raise ValueError("HTTP transport requires command arguments")
37
+ command_type = args[0]
38
+ params = args[1] if len(args) > 1 else kwargs.get("params")
39
+ if params is None:
40
+ params = {}
41
+ if not isinstance(params, dict):
42
+ raise TypeError(
43
+ "Command parameters must be a dict for HTTP transport")
44
+ try:
45
+ raw = await PluginHub.send_command_for_instance(
46
+ unity_instance,
47
+ command_type,
48
+ params,
49
+ )
50
+ return normalize_unity_response(raw)
51
+ except Exception as exc:
52
+ # NOTE: asyncio.TimeoutError has an empty str() by default, which is confusing for clients.
53
+ err = str(exc) or f"{type(exc).__name__}"
54
+ # Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT.
55
+ # The client can decide whether retrying is appropriate for the command.
56
+ return normalize_unity_response(
57
+ MCPResponse(success=False, error=err,
58
+ hint="retry").model_dump()
59
+ )
60
+
61
+ if unity_instance:
62
+ kwargs.setdefault("instance_id", unity_instance)
63
+ return await send_fn(*args, **kwargs)