import os
import time
from typing import Any
from fastmcp import Context
from pydantic import BaseModel
from models import MCPResponse
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from services.state.external_changes_scanner import external_changes_scanner
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry
class EditorStateUnity(BaseModel):
instance_id: str | None = None
unity_version: str | None = None
project_id: str | None = None
platform: str | None = None
is_batch_mode: bool | None = None
class EditorStatePlayMode(BaseModel):
is_playing: bool | None = None
is_paused: bool | None = None
is_changing: bool | None = None
class EditorStateActiveScene(BaseModel):
path: str | None = None
guid: str | None = None
name: str | None = None
class EditorStateEditor(BaseModel):
is_focused: bool | None = None
play_mode: EditorStatePlayMode | None = None
active_scene: EditorStateActiveScene | None = None
class EditorStateActivity(BaseModel):
phase: str | None = None
since_unix_ms: int | None = None
reasons: list[str] | None = None
class EditorStateCompilation(BaseModel):
is_compiling: bool | None = None
is_domain_reload_pending: bool | None = None
last_compile_started_unix_ms: int | None = None
last_compile_finished_unix_ms: int | None = None
last_domain_reload_before_unix_ms: int | None = None
last_domain_reload_after_unix_ms: int | None = None
class EditorStateRefresh(BaseModel):
is_refresh_in_progress: bool | None = None
last_refresh_requested_unix_ms: int | None = None
last_refresh_finished_unix_ms: int | None = None
class EditorStateAssets(BaseModel):
is_updating: bool | None = None
external_changes_dirty: bool | None = None
external_changes_last_seen_unix_ms: int | None = None
external_changes_dirty_since_unix_ms: int | None = None
external_changes_last_cleared_unix_ms: int | None = None
refresh: EditorStateRefresh | None = None
class EditorStateLastRun(BaseModel):
finished_unix_ms: int | None = None
result: str | None = None
counts: Any | None = None
class EditorStateTests(BaseModel):
is_running: bool | None = None
mode: str | None = None
current_job_id: str | None = None
started_unix_ms: int | None = None
started_by: str | None = None
last_run: EditorStateLastRun | None = None
class EditorStateTransport(BaseModel):
unity_bridge_connected: bool | None = None
last_message_unix_ms: int | None = None
class EditorStateAdvice(BaseModel):
ready_for_tools: bool | None = None
blocking_reasons: list[str] | None = None
recommended_retry_after_ms: int | None = None
recommended_next_action: str | None = None
class EditorStateStaleness(BaseModel):
age_ms: int | None = None
is_stale: bool | None = None
class EditorStateData(BaseModel):
schema_version: str
observed_at_unix_ms: int
sequence: int
unity: EditorStateUnity | None = None
editor: EditorStateEditor | None = None
activity: EditorStateActivity | None = None
compilation: EditorStateCompilation | None = None
assets: EditorStateAssets | None = None
tests: EditorStateTests | None = None
transport: EditorStateTransport | None = None
advice: EditorStateAdvice | None = None
staleness: EditorStateStaleness | None = None
def _now_unix_ms() -> int:
return int(time.time() * 1000)
def _in_pytest() -> bool:
# Avoid instance-discovery side effects during the Python integration test suite.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
async def infer_single_instance_id(ctx: Context) -> str | None:
"""
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
This makes editor_state outputs self-describing even when no explicit active instance is set.
"""
await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
try:
transport = unity_transport._current_transport()
except Exception:
transport = None
if transport == "http":
# HTTP/WebSocket transport: derive from PluginHub sessions.
try:
from transport.plugin_hub import PluginHub
sessions_data = await PluginHub.get_sessions()
sessions = sessions_data.sessions if hasattr(
sessions_data, "sessions") else {}
if isinstance(sessions, dict) and len(sessions) == 1:
session = next(iter(sessions.values()))
project = getattr(session, "project", None)
project_hash = getattr(session, "hash", None)
if project and project_hash:
return f"{project}@{project_hash}"
except Exception:
return None
return None
# Stdio/TCP transport: derive from connection pool discovery.
try:
from transport.legacy.unity_connection import get_unity_connection_pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
if isinstance(instances, list) and len(instances) == 1:
inst = instances[0]
inst_id = getattr(inst, "id", None)
return str(inst_id) if inst_id else None
except Exception:
return None
return None
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
now_ms = _now_unix_ms()
observed = state_v2.get("observed_at_unix_ms")
try:
observed_ms = int(observed)
except Exception:
observed_ms = now_ms
age_ms = max(0, now_ms - observed_ms)
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
is_stale = age_ms > 2000
compilation = state_v2.get("compilation") or {}
tests = state_v2.get("tests") or {}
assets = state_v2.get("assets") or {}
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
blocking: list[str] = []
if compilation.get("is_compiling") is True:
blocking.append("compiling")
if compilation.get("is_domain_reload_pending") is True:
blocking.append("domain_reload")
if tests.get("is_running") is True:
blocking.append("running_tests")
if refresh.get("is_refresh_in_progress") is True:
blocking.append("asset_refresh")
if is_stale:
blocking.append("stale_status")
ready_for_tools = len(blocking) == 0
state_v2["advice"] = {
"ready_for_tools": ready_for_tools,
"blocking_reasons": blocking,
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
"recommended_next_action": "none" if ready_for_tools else "retry_later",
}
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
return state_v2
@mcp_for_unity_resource(
uri="mcpforunity://editor/state",
name="editor_state",
description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.",
)
async def get_editor_state(ctx: Context) -> MCPResponse:
unity_instance = get_unity_instance_from_context(ctx)
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_editor_state",
{},
)
# If Unity returns a structured retry hint or error, surface it directly.
if isinstance(response, dict) and not response.get("success", True):
return MCPResponse(**response)
state_v2 = response.get("data") if isinstance(
response, dict) and isinstance(response.get("data"), dict) else {}
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
state_v2.setdefault("sequence", 0)
# Ensure the returned snapshot is clearly associated with the targeted instance.
unity_section = state_v2.get("unity")
if not isinstance(unity_section, dict):
unity_section = {}
state_v2["unity"] = unity_section
current_instance_id = unity_section.get("instance_id")
if current_instance_id in (None, ""):
if unity_instance:
unity_section["instance_id"] = unity_instance
else:
inferred = await infer_single_instance_id(ctx)
if inferred:
unity_section["instance_id"] = inferred
# External change detection (server-side): compute per instance based on project root path.
try:
instance_id = unity_section.get("instance_id")
if isinstance(instance_id, str) and instance_id.strip():
from services.resources.project_info import get_project_info
proj_resp = await get_project_info(ctx)
proj = proj_resp.model_dump() if hasattr(
proj_resp, "model_dump") else proj_resp
proj_data = proj.get("data") if isinstance(proj, dict) else None
project_root = proj_data.get("projectRoot") if isinstance(
proj_data, dict) else None
if isinstance(project_root, str) and project_root.strip():
external_changes_scanner.set_project_root(
instance_id, project_root)
ext = external_changes_scanner.update_and_get(instance_id)
assets = state_v2.get("assets")
if not isinstance(assets, dict):
assets = {}
state_v2["assets"] = assets
assets["external_changes_dirty"] = bool(
ext.get("external_changes_dirty", False))
assets["external_changes_last_seen_unix_ms"] = ext.get(
"external_changes_last_seen_unix_ms")
assets["external_changes_dirty_since_unix_ms"] = ext.get(
"dirty_since_unix_ms")
assets["external_changes_last_cleared_unix_ms"] = ext.get(
"last_cleared_unix_ms")
except Exception:
pass
state_v2 = _enrich_advice_and_staleness(state_v2)
try:
if hasattr(EditorStateData, "model_validate"):
validated = EditorStateData.model_validate(state_v2)
else:
validated = EditorStateData.parse_obj(
state_v2) # type: ignore[attr-defined]
data = validated.model_dump() if hasattr(
validated, "model_dump") else validated.dict()
except Exception as e:
return MCPResponse(
success=False,
error="invalid_editor_state",
message=f"Editor state payload failed validation: {e}",
data={"raw": state_v2},
)
return MCPResponse(success=True, message="Retrieved editor state.", data=data)