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.
- __init__.py +0 -0
- core/__init__.py +0 -0
- core/config.py +56 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +533 -0
- core/telemetry_decorator.py +164 -0
- main.py +411 -0
- mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
- mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
- mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
- mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
- mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +47 -0
- routes/__init__.py +0 -0
- services/__init__.py +0 -0
- services/custom_tool_service.py +339 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +81 -0
- services/resources/active_tool.py +47 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +42 -0
- services/resources/layers.py +29 -0
- services/resources/menu_items.py +34 -0
- services/resources/prefab_stage.py +39 -0
- services/resources/project_info.py +39 -0
- services/resources/selection.py +55 -0
- services/resources/tags.py +30 -0
- services/resources/tests.py +55 -0
- services/resources/unity_instances.py +122 -0
- services/resources/windows.py +47 -0
- services/tools/__init__.py +76 -0
- services/tools/batch_execute.py +78 -0
- services/tools/debug_request_context.py +71 -0
- services/tools/execute_custom_tool.py +38 -0
- services/tools/execute_menu_item.py +29 -0
- services/tools/find_in_file.py +174 -0
- services/tools/manage_asset.py +129 -0
- services/tools/manage_editor.py +63 -0
- services/tools/manage_gameobject.py +240 -0
- services/tools/manage_material.py +95 -0
- services/tools/manage_prefabs.py +62 -0
- services/tools/manage_scene.py +75 -0
- services/tools/manage_script.py +602 -0
- services/tools/manage_shader.py +64 -0
- services/tools/read_console.py +115 -0
- services/tools/run_tests.py +108 -0
- services/tools/script_apply_edits.py +998 -0
- services/tools/set_active_instance.py +112 -0
- services/tools/utils.py +60 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +785 -0
- transport/models.py +62 -0
- transport/plugin_hub.py +412 -0
- transport/plugin_registry.py +123 -0
- transport/unity_instance_middleware.py +141 -0
- transport/unity_transport.py +103 -0
- utils/module_discovery.py +55 -0
- 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
|
+
}
|
services/tools/utils.py
ADDED
|
@@ -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()
|