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,112 @@
1
+ from typing import Annotated, Any
2
+ from types import SimpleNamespace
3
+
4
+ from fastmcp import Context
5
+ from services.registry import mcp_for_unity_tool
6
+ from transport.legacy.unity_connection import get_unity_connection_pool
7
+ from transport.unity_instance_middleware import get_unity_instance_middleware
8
+ from transport.plugin_hub import PluginHub
9
+ from transport.unity_transport import _current_transport
10
+
11
+
12
+ @mcp_for_unity_tool(
13
+ description="Set the active Unity instance for this client/session. Accepts Name@hash or hash."
14
+ )
15
+ async def set_active_instance(
16
+ ctx: Context,
17
+ instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
18
+ ) -> dict[str, Any]:
19
+ transport = _current_transport()
20
+
21
+ # Discover running instances based on transport
22
+ if transport == "http":
23
+ sessions_data = await PluginHub.get_sessions()
24
+ sessions = sessions_data.sessions
25
+ instances = []
26
+ for session_id, session in sessions.items():
27
+ project = session.project or "Unknown"
28
+ hash_value = session.hash
29
+ if not hash_value:
30
+ continue
31
+ inst_id = f"{project}@{hash_value}"
32
+ instances.append(SimpleNamespace(
33
+ id=inst_id,
34
+ hash=hash_value,
35
+ name=project,
36
+ session_id=session_id,
37
+ ))
38
+ else:
39
+ pool = get_unity_connection_pool()
40
+ instances = pool.discover_all_instances(force_refresh=True)
41
+
42
+ if not instances:
43
+ return {
44
+ "success": False,
45
+ "error": "No Unity instances are currently connected. Start Unity and press 'Start Session'."
46
+ }
47
+ ids = {inst.id: inst for inst in instances if getattr(inst, "id", None)}
48
+
49
+ value = (instance or "").strip()
50
+ if not value:
51
+ return {
52
+ "success": False,
53
+ "error": "Instance identifier is required. "
54
+ "Use unity://instances to copy a Name@hash or provide a hash prefix."
55
+ }
56
+ resolved = None
57
+ if "@" in value:
58
+ resolved = ids.get(value)
59
+ if resolved is None:
60
+ return {
61
+ "success": False,
62
+ "error": f"Instance '{value}' not found. "
63
+ "Use unity://instances to copy an exact Name@hash."
64
+ }
65
+ else:
66
+ lookup = value.lower()
67
+ matches = []
68
+ for inst in instances:
69
+ if not getattr(inst, "id", None):
70
+ continue
71
+ inst_hash = getattr(inst, "hash", "")
72
+ if inst_hash and inst_hash.lower().startswith(lookup):
73
+ matches.append(inst)
74
+ if not matches:
75
+ return {
76
+ "success": False,
77
+ "error": f"Instance hash '{value}' does not match any running Unity editors. "
78
+ "Use unity://instances to confirm the available hashes."
79
+ }
80
+ if len(matches) > 1:
81
+ matching_ids = ", ".join(
82
+ inst.id for inst in matches if getattr(inst, "id", None)
83
+ ) or "multiple instances"
84
+ return {
85
+ "success": False,
86
+ "error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
87
+ "Provide the full Name@hash from unity://instances."
88
+ }
89
+ resolved = matches[0]
90
+
91
+ if resolved is None:
92
+ # Should be unreachable due to logic above, but satisfies static analysis
93
+ return {
94
+ "success": False,
95
+ "error": "Internal error: Instance resolution failed."
96
+ }
97
+
98
+ # Store selection in middleware (session-scoped)
99
+ middleware = get_unity_instance_middleware()
100
+ # We use middleware.set_active_instance to persist the selection.
101
+ # The session key is an internal detail but useful for debugging response.
102
+ middleware.set_active_instance(ctx, resolved.id)
103
+ session_key = middleware.get_session_key(ctx)
104
+
105
+ return {
106
+ "success": True,
107
+ "message": f"Active instance set to {resolved.id}",
108
+ "data": {
109
+ "instance": resolved.id,
110
+ "session_key": session_key,
111
+ },
112
+ }
@@ -0,0 +1,60 @@
1
+ """Shared helper utilities for MCP server tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ _TRUTHY = {"true", "1", "yes", "on"}
9
+ _FALSY = {"false", "0", "no", "off"}
10
+
11
+ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
12
+ """Attempt to coerce a loosely-typed value to a boolean."""
13
+ if value is None:
14
+ return default
15
+ if isinstance(value, bool):
16
+ return value
17
+ if isinstance(value, str):
18
+ lowered = value.strip().lower()
19
+ if lowered in _TRUTHY:
20
+ return True
21
+ if lowered in _FALSY:
22
+ return False
23
+ return default
24
+ return bool(value)
25
+
26
+
27
+ def parse_json_payload(value: Any) -> Any:
28
+ """
29
+ Attempt to parse a value that might be a JSON string into its native object.
30
+
31
+ This is a tolerant parser used to handle cases where MCP clients or LLMs
32
+ serialize complex objects (lists, dicts) into strings. It also handles
33
+ scalar values like numbers, booleans, and null.
34
+
35
+ Args:
36
+ value: The input value (can be str, list, dict, etc.)
37
+
38
+ Returns:
39
+ The parsed JSON object/list if the input was a valid JSON string,
40
+ or the original value if parsing failed or wasn't necessary.
41
+ """
42
+ if not isinstance(value, str):
43
+ return value
44
+
45
+ val_trimmed = value.strip()
46
+
47
+ # Fast path: if it doesn't look like JSON structure, return as is
48
+ if not (
49
+ (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
50
+ (val_trimmed.startswith("[") and val_trimmed.endswith("]")) or
51
+ val_trimmed in ("true", "false", "null") or
52
+ (val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit())
53
+ ):
54
+ return value
55
+
56
+ try:
57
+ return json.loads(value)
58
+ except (json.JSONDecodeError, ValueError):
59
+ # If parsing fails, assume it was meant to be a literal string
60
+ return value
transport/__init__.py ADDED
File without changes
@@ -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()