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,329 @@
1
+ """
2
+ Port discovery utility for MCP for Unity Server.
3
+
4
+ What changed and why:
5
+ - Unity now writes a per-project port file named like
6
+ `~/.unity-mcp/unity-mcp-port-<hash>.json` to avoid projects overwriting
7
+ each other's saved port. The legacy file `unity-mcp-port.json` may still
8
+ exist.
9
+ - This module now scans for both patterns, prefers the most recently
10
+ modified file, and verifies that the port is actually a MCP for Unity listener
11
+ (quick socket connect + ping) before choosing it.
12
+ """
13
+
14
+ import glob
15
+ import json
16
+ import logging
17
+ import os
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ import socket
21
+ import struct
22
+
23
+ from models.models import UnityInstanceInfo
24
+
25
+ logger = logging.getLogger("mcp-for-unity-server")
26
+
27
+
28
+ class PortDiscovery:
29
+ """Handles port discovery from Unity Bridge registry"""
30
+ REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file
31
+ DEFAULT_PORT = 6400
32
+ CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery
33
+
34
+ @staticmethod
35
+ def get_registry_path() -> Path:
36
+ """Get the path to the port registry file"""
37
+ return PortDiscovery.get_registry_dir() / PortDiscovery.REGISTRY_FILE
38
+
39
+ @staticmethod
40
+ def get_registry_dir() -> Path:
41
+ env_dir = os.environ.get("UNITY_MCP_STATUS_DIR")
42
+ if env_dir:
43
+ return Path(env_dir)
44
+ return Path.home() / ".unity-mcp"
45
+
46
+ @staticmethod
47
+ def list_candidate_files() -> list[Path]:
48
+ """Return candidate registry files, newest first.
49
+ Includes hashed per-project files and the legacy file (if present).
50
+ """
51
+ base = PortDiscovery.get_registry_dir()
52
+ hashed = sorted(
53
+ (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))),
54
+ key=lambda p: p.stat().st_mtime,
55
+ reverse=True,
56
+ )
57
+ legacy = PortDiscovery.get_registry_path()
58
+ if legacy.exists():
59
+ # Put legacy at the end so hashed, per-project files win
60
+ hashed.append(legacy)
61
+ return hashed
62
+
63
+ @staticmethod
64
+ def _try_probe_unity_mcp(port: int) -> bool:
65
+ """Quickly check if a MCP for Unity listener is on this port.
66
+ Uses Unity's framed protocol: receives handshake, sends framed ping, expects framed pong.
67
+ """
68
+ try:
69
+ with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s:
70
+ s.settimeout(PortDiscovery.CONNECT_TIMEOUT)
71
+ try:
72
+ # 1. Receive handshake from Unity
73
+ handshake = s.recv(512)
74
+ if not handshake or b"FRAMING=1" not in handshake:
75
+ # Try legacy mode as fallback
76
+ s.sendall(b"ping")
77
+ data = s.recv(512)
78
+ return data and b'"message":"pong"' in data
79
+
80
+ # 2. Send framed ping command
81
+ # Frame format: 8-byte length header (big-endian uint64) + payload
82
+ payload = b"ping"
83
+ header = struct.pack('>Q', len(payload))
84
+ s.sendall(header + payload)
85
+
86
+ # 3. Receive framed response
87
+ # Helper to receive exact number of bytes
88
+ def _recv_exact(expected: int) -> bytes | None:
89
+ chunks = bytearray()
90
+ while len(chunks) < expected:
91
+ chunk = s.recv(expected - len(chunks))
92
+ if not chunk:
93
+ return None
94
+ chunks.extend(chunk)
95
+ return bytes(chunks)
96
+
97
+ response_header = _recv_exact(8)
98
+ if response_header is None:
99
+ return False
100
+
101
+ response_length = struct.unpack('>Q', response_header)[0]
102
+ if response_length > 10000: # Sanity check
103
+ return False
104
+
105
+ response = _recv_exact(response_length)
106
+ if response is None:
107
+ return False
108
+ return b'"message":"pong"' in response
109
+ except Exception as e:
110
+ logger.debug(f"Port probe failed for {port}: {e}")
111
+ return False
112
+ except Exception as e:
113
+ logger.debug(f"Connection failed for port {port}: {e}")
114
+ return False
115
+
116
+ @staticmethod
117
+ def _read_latest_status() -> dict | None:
118
+ try:
119
+ base = PortDiscovery.get_registry_dir()
120
+ status_files = sorted(
121
+ (Path(p)
122
+ for p in glob.glob(str(base / "unity-mcp-status-*.json"))),
123
+ key=lambda p: p.stat().st_mtime,
124
+ reverse=True,
125
+ )
126
+ if not status_files:
127
+ return None
128
+ with status_files[0].open('r') as f:
129
+ return json.load(f)
130
+ except Exception:
131
+ return None
132
+
133
+ @staticmethod
134
+ def discover_unity_port() -> int:
135
+ """
136
+ Discover Unity port by scanning per-project and legacy registry files.
137
+ Prefer the newest file whose port responds; fall back to first parsed
138
+ value; finally default to 6400.
139
+
140
+ Returns:
141
+ Port number to connect to
142
+ """
143
+ # Prefer the latest heartbeat status if it points to a responsive port
144
+ status = PortDiscovery._read_latest_status()
145
+ if status:
146
+ port = status.get('unity_port')
147
+ if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port):
148
+ logger.info(f"Using Unity port from status: {port}")
149
+ return port
150
+
151
+ candidates = PortDiscovery.list_candidate_files()
152
+
153
+ first_seen_port: int | None = None
154
+
155
+ for path in candidates:
156
+ try:
157
+ with open(path, 'r') as f:
158
+ cfg = json.load(f)
159
+ unity_port = cfg.get('unity_port')
160
+ if isinstance(unity_port, int):
161
+ if first_seen_port is None:
162
+ first_seen_port = unity_port
163
+ if PortDiscovery._try_probe_unity_mcp(unity_port):
164
+ logger.info(
165
+ f"Using Unity port from {path.name}: {unity_port}")
166
+ return unity_port
167
+ except Exception as e:
168
+ logger.warning(f"Could not read port registry {path}: {e}")
169
+
170
+ if first_seen_port is not None:
171
+ logger.info(
172
+ f"No responsive port found; using first seen value {first_seen_port}")
173
+ return first_seen_port
174
+
175
+ # Fallback to default port
176
+ logger.info(
177
+ f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}")
178
+ return PortDiscovery.DEFAULT_PORT
179
+
180
+ @staticmethod
181
+ def get_port_config() -> dict | None:
182
+ """
183
+ Get the most relevant port configuration from registry.
184
+ Returns the most recent hashed file's config if present,
185
+ otherwise the legacy file's config. Returns None if nothing exists.
186
+
187
+ Returns:
188
+ Port configuration dict or None if not found
189
+ """
190
+ candidates = PortDiscovery.list_candidate_files()
191
+ if not candidates:
192
+ return None
193
+ for path in candidates:
194
+ try:
195
+ with open(path, 'r') as f:
196
+ return json.load(f)
197
+ except Exception as e:
198
+ logger.warning(
199
+ f"Could not read port configuration {path}: {e}")
200
+ return None
201
+
202
+ @staticmethod
203
+ def _extract_project_name(project_path: str) -> str:
204
+ """Extract project name from Assets path.
205
+
206
+ Examples:
207
+ /Users/sakura/Projects/MyGame/Assets -> MyGame
208
+ C:\\Projects\\TestProject\\Assets -> TestProject
209
+ """
210
+ if not project_path:
211
+ return "Unknown"
212
+
213
+ try:
214
+ # Remove trailing /Assets or \Assets
215
+ path = project_path.rstrip('/\\')
216
+ if path.endswith('Assets'):
217
+ path = path[:-6].rstrip('/\\')
218
+
219
+ # Get the last directory name
220
+ name = os.path.basename(path)
221
+ return name if name else "Unknown"
222
+ except Exception:
223
+ return "Unknown"
224
+
225
+ @staticmethod
226
+ def discover_all_unity_instances() -> list[UnityInstanceInfo]:
227
+ """
228
+ Discover all running Unity Editor instances by scanning status files.
229
+
230
+ Returns:
231
+ List of UnityInstanceInfo objects for all discovered instances
232
+ """
233
+ instances_by_port: dict[int, tuple[UnityInstanceInfo, datetime]] = {}
234
+ base = PortDiscovery.get_registry_dir()
235
+
236
+ # Scan all status files
237
+ status_pattern = str(base / "unity-mcp-status-*.json")
238
+ status_files = glob.glob(status_pattern)
239
+
240
+ for status_file_path in status_files:
241
+ try:
242
+ status_path = Path(status_file_path)
243
+ file_mtime = datetime.fromtimestamp(
244
+ status_path.stat().st_mtime)
245
+
246
+ with status_path.open('r') as f:
247
+ data = json.load(f)
248
+
249
+ # Extract hash from filename: unity-mcp-status-{hash}.json
250
+ filename = os.path.basename(status_file_path)
251
+ hash_value = filename.replace(
252
+ 'unity-mcp-status-', '').replace('.json', '')
253
+
254
+ # Extract information
255
+ project_path = data.get('project_path', '')
256
+ project_name = PortDiscovery._extract_project_name(
257
+ project_path)
258
+ port = data.get('unity_port')
259
+ is_reloading = data.get('reloading', False)
260
+
261
+ # Parse last_heartbeat
262
+ last_heartbeat = None
263
+ heartbeat_str = data.get('last_heartbeat')
264
+ if heartbeat_str:
265
+ try:
266
+ last_heartbeat = datetime.fromisoformat(
267
+ heartbeat_str.replace('Z', '+00:00'))
268
+ except Exception:
269
+ pass
270
+
271
+ # Verify port is actually responding
272
+ is_alive = PortDiscovery._try_probe_unity_mcp(
273
+ port) if isinstance(port, int) else False
274
+
275
+ if not is_alive:
276
+ # If Unity says it's reloading and the status is fresh, don't drop the instance.
277
+ freshness = last_heartbeat or file_mtime
278
+ now = datetime.now()
279
+ if freshness.tzinfo:
280
+ from datetime import timezone
281
+ now = datetime.now(timezone.utc)
282
+
283
+ age_s = (now - freshness).total_seconds()
284
+
285
+ if is_reloading and age_s < 60:
286
+ pass # keep it, status="reloading"
287
+ else:
288
+ logger.debug(
289
+ f"Instance {project_name}@{hash_value} has heartbeat but port {port} not responding")
290
+ continue
291
+
292
+ freshness = last_heartbeat or file_mtime
293
+
294
+ existing = instances_by_port.get(port)
295
+ if existing:
296
+ _, existing_time = existing
297
+ if existing_time >= freshness:
298
+ logger.debug(
299
+ f"Skipping stale status entry {status_path.name} in favor of more recent data for port {port}")
300
+ continue
301
+
302
+ # Create instance info
303
+ instance = UnityInstanceInfo(
304
+ id=f"{project_name}@{hash_value}",
305
+ name=project_name,
306
+ path=project_path,
307
+ hash=hash_value,
308
+ port=port,
309
+ status="reloading" if is_reloading else "running",
310
+ last_heartbeat=last_heartbeat,
311
+ # May not be available in current version
312
+ unity_version=data.get('unity_version')
313
+ )
314
+
315
+ instances_by_port[port] = (instance, freshness)
316
+ logger.debug(
317
+ f"Discovered Unity instance: {instance.id} on port {instance.port}")
318
+
319
+ except Exception as e:
320
+ logger.debug(
321
+ f"Failed to parse status file {status_file_path}: {e}")
322
+ continue
323
+
324
+ deduped_instances = [entry[0] for entry in sorted(
325
+ instances_by_port.values(), key=lambda item: item[1], reverse=True)]
326
+
327
+ logger.info(
328
+ f"Discovered {len(deduped_instances)} Unity instances (after de-duplication by port)")
329
+ return deduped_instances
@@ -0,0 +1,65 @@
1
+ """Caching registry for STDIO Unity instance discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+
9
+ from core.config import config
10
+ from models.models import UnityInstanceInfo
11
+ from transport.legacy.port_discovery import PortDiscovery
12
+
13
+ logger = logging.getLogger("mcp-for-unity-server")
14
+
15
+
16
+ class StdioPortRegistry:
17
+ """Caches Unity instance discovery results for STDIO transport."""
18
+
19
+ def __init__(self) -> None:
20
+ self._lock = threading.RLock()
21
+ self._instances: dict[str, UnityInstanceInfo] = {}
22
+ self._last_refresh: float = 0.0
23
+
24
+ def _refresh_locked(self) -> None:
25
+ instances = PortDiscovery.discover_all_unity_instances()
26
+ self._instances = {inst.id: inst for inst in instances}
27
+ self._last_refresh = time.time()
28
+ logger.debug(
29
+ f"STDIO port registry refreshed with {len(instances)} instance(s)")
30
+
31
+ def get_instances(self, *, force_refresh: bool = False) -> list[UnityInstanceInfo]:
32
+ ttl = getattr(config, "port_registry_ttl", 5.0)
33
+ with self._lock:
34
+ now = time.time()
35
+ if not force_refresh and self._instances and (now - self._last_refresh) < ttl:
36
+ return list(self._instances.values())
37
+ self._refresh_locked()
38
+ return list(self._instances.values())
39
+
40
+ def get_instance(self, instance_id: str | None) -> UnityInstanceInfo | None:
41
+ instances = self.get_instances()
42
+ if instance_id:
43
+ return next((inst for inst in instances if inst.id == instance_id), None)
44
+ if not instances:
45
+ return None
46
+
47
+ def _instance_sort_key(inst: UnityInstanceInfo) -> tuple[float, int]:
48
+ heartbeat = inst.last_heartbeat.timestamp() if inst.last_heartbeat else 0.0
49
+ return heartbeat, inst.port or 0
50
+
51
+ return max(instances, key=_instance_sort_key)
52
+
53
+ def get_port(self, instance_id: str | None = None) -> int:
54
+ instance = self.get_instance(instance_id)
55
+ if instance and isinstance(instance.port, int):
56
+ return instance.port
57
+ return PortDiscovery.discover_unity_port()
58
+
59
+ def clear(self) -> None:
60
+ with self._lock:
61
+ self._instances.clear()
62
+ self._last_refresh = 0.0
63
+
64
+
65
+ stdio_port_registry = StdioPortRegistry()