chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# This file makes tests a package so test modules can import from each other
+132
View File
@@ -0,0 +1,132 @@
import os
import sys
import types
from pathlib import Path
SERVER_ROOT = Path(__file__).resolve().parents[2]
if str(SERVER_ROOT) not in sys.path:
sys.path.insert(0, str(SERVER_ROOT))
SERVER_SRC = SERVER_ROOT / "src"
if str(SERVER_SRC) not in sys.path:
sys.path.insert(0, str(SERVER_SRC))
# Ensure telemetry is disabled during test collection and execution to avoid
# any background network or thread startup that could slow or block pytest.
os.environ.setdefault("DISABLE_TELEMETRY", "true")
os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true")
os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true")
# NOTE: These tests are integration tests for the MCP server Python code.
# They test tools, resources, and utilities without requiring Unity to be running.
# Tests can now import directly from the parent package since they're inside src/
# To run: cd Server && uv run pytest tests/integration/ -v
# Stub telemetry modules to avoid file I/O during import of tools package
telemetry = types.ModuleType("telemetry")
def _noop(*args, **kwargs):
pass
class MilestoneType:
pass
telemetry.record_resource_usage = _noop
telemetry.record_tool_usage = _noop
telemetry.record_milestone = _noop
telemetry.MilestoneType = MilestoneType
telemetry.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", telemetry)
telemetry_decorator = types.ModuleType("telemetry_decorator")
def _noop_decorator(*_dargs, **_dkwargs):
def _wrap(fn):
return fn
return _wrap
telemetry_decorator.telemetry_tool = _noop_decorator
telemetry_decorator.telemetry_resource = _noop_decorator
sys.modules.setdefault("telemetry_decorator", telemetry_decorator)
# Stub fastmcp module (not mcp.server.fastmcp)
fastmcp = types.ModuleType("fastmcp")
class _DummyFastMCP:
pass
class _DummyContext:
pass
class _DummyMiddleware:
"""Base middleware class stub."""
pass
class _DummyMiddlewareContext:
"""Middleware context stub."""
pass
class _DummyToolResult:
"""Stub for fastmcp.server.server.ToolResult"""
def __init__(self, content=None, is_error=False):
self.content = content or []
self.is_error = is_error
fastmcp.FastMCP = _DummyFastMCP
fastmcp.Context = _DummyContext
sys.modules.setdefault("fastmcp", fastmcp)
# Stub fastmcp.server, fastmcp.server.middleware, fastmcp.server.server submodules
fastmcp_server = types.ModuleType("fastmcp.server")
fastmcp_server_middleware = types.ModuleType("fastmcp.server.middleware")
fastmcp_server_middleware.Middleware = _DummyMiddleware
fastmcp_server_middleware.MiddlewareContext = _DummyMiddlewareContext
fastmcp_server_server = types.ModuleType("fastmcp.server.server")
fastmcp_server_server.ToolResult = _DummyToolResult
fastmcp.server = fastmcp_server
fastmcp_server.middleware = fastmcp_server_middleware
fastmcp_server.server = fastmcp_server_server
sys.modules.setdefault("fastmcp.server", fastmcp_server)
sys.modules.setdefault("fastmcp.server.middleware", fastmcp_server_middleware)
sys.modules.setdefault("fastmcp.server.server", fastmcp_server_server)
# Stub mcp.types for TextContent, ImageContent, ToolAnnotations
_mcp_types = sys.modules.get("mcp.types")
if _mcp_types is None:
_mcp_mod = sys.modules.setdefault("mcp", types.ModuleType("mcp"))
_mcp_types = types.ModuleType("mcp.types")
class _TextContent:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class _ImageContent:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class _ToolAnnotations:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
_mcp_types.TextContent = _TextContent
_mcp_types.ImageContent = _ImageContent
_mcp_types.ToolAnnotations = _ToolAnnotations
_mcp_mod.types = _mcp_types
sys.modules["mcp.types"] = _mcp_types
# Note: starlette is now a proper dependency (via mcp package), so we don't stub it anymore.
# The real starlette package will be imported when needed.
@@ -0,0 +1,456 @@
"""Tests for ApiKeyService: validation, caching, retries, and singleton lifecycle."""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from services.api_key_service import ApiKeyService, ValidationResult
@pytest.fixture(autouse=True)
def _reset_singleton():
"""Reset the ApiKeyService singleton between tests."""
ApiKeyService._instance = None
yield
ApiKeyService._instance = None
def _make_service(
validation_url="https://auth.example.com/validate",
cache_ttl=300.0,
service_token_header=None,
service_token=None,
):
return ApiKeyService(
validation_url=validation_url,
cache_ttl=cache_ttl,
service_token_header=service_token_header,
service_token=service_token,
)
def _mock_response(status_code=200, json_data=None):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.json.return_value = json_data or {}
return resp
# ---------------------------------------------------------------------------
# Singleton lifecycle
# ---------------------------------------------------------------------------
class TestSingletonLifecycle:
def test_get_instance_before_init_raises(self):
with pytest.raises(RuntimeError, match="not initialized"):
ApiKeyService.get_instance()
def test_is_initialized_false_before_init(self):
assert ApiKeyService.is_initialized() is False
def test_is_initialized_true_after_init(self):
_make_service()
assert ApiKeyService.is_initialized() is True
def test_get_instance_returns_service(self):
svc = _make_service()
assert ApiKeyService.get_instance() is svc
# ---------------------------------------------------------------------------
# Basic validation
# ---------------------------------------------------------------------------
class TestBasicValidation:
@pytest.mark.asyncio
async def test_valid_key(self):
svc = _make_service()
mock_resp = _mock_response(
200, {"valid": True, "user_id": "user-1", "metadata": {"plan": "pro"}})
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = AsyncMock(return_value=mock_resp)
MockClient.return_value = instance
result = await svc.validate("test-valid-key-12345678")
assert result.valid is True
assert result.user_id == "user-1"
assert result.metadata == {"plan": "pro"}
@pytest.mark.asyncio
async def test_invalid_key_200_body(self):
svc = _make_service()
mock_resp = _mock_response(
200, {"valid": False, "error": "Key revoked"})
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = AsyncMock(return_value=mock_resp)
MockClient.return_value = instance
result = await svc.validate("test-invalid-key-1234")
assert result.valid is False
assert result.error == "Key revoked"
@pytest.mark.asyncio
async def test_invalid_key_401_status(self):
svc = _make_service()
mock_resp = _mock_response(401)
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = AsyncMock(return_value=mock_resp)
MockClient.return_value = instance
result = await svc.validate("test-bad-key-12345678")
assert result.valid is False
assert "Invalid API key" in result.error
@pytest.mark.asyncio
async def test_empty_key_fast_path(self):
svc = _make_service()
with patch("httpx.AsyncClient") as MockClient:
result = await svc.validate("")
assert result.valid is False
assert "required" in result.error.lower()
# No HTTP call should have been made
MockClient.assert_not_called()
# ---------------------------------------------------------------------------
# Caching
# ---------------------------------------------------------------------------
class TestCaching:
@pytest.mark.asyncio
async def test_cache_hit_valid_key(self):
svc = _make_service(cache_ttl=300.0)
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
call_count = 0
async def counting_post(*args, **kwargs):
nonlocal call_count
call_count += 1
return mock_resp
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = counting_post
MockClient.return_value = instance
r1 = await svc.validate("test-cached-valid-key1")
r2 = await svc.validate("test-cached-valid-key1")
assert r1.valid is True
assert r2.valid is True
assert r2.user_id == "u1"
assert call_count == 1 # Only one HTTP call
@pytest.mark.asyncio
async def test_cache_hit_invalid_key(self):
svc = _make_service(cache_ttl=300.0)
mock_resp = _mock_response(200, {"valid": False, "error": "bad"})
call_count = 0
async def counting_post(*args, **kwargs):
nonlocal call_count
call_count += 1
return mock_resp
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = counting_post
MockClient.return_value = instance
r1 = await svc.validate("test-cached-bad-key12")
r2 = await svc.validate("test-cached-bad-key12")
assert r1.valid is False
assert r2.valid is False
assert call_count == 1
@pytest.mark.asyncio
async def test_cache_expiry(self):
svc = _make_service(cache_ttl=1.0) # 1 second TTL
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
call_count = 0
async def counting_post(*args, **kwargs):
nonlocal call_count
call_count += 1
return mock_resp
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = counting_post
MockClient.return_value = instance
await svc.validate("test-expiry-key-12345")
assert call_count == 1
# Manually expire the cache entry by manipulating the stored tuple
async with svc._cache_lock:
key = "test-expiry-key-12345"
valid, user_id, metadata, _expires = svc._cache[key]
svc._cache[key] = (valid, user_id, metadata, time.time() - 1)
await svc.validate("test-expiry-key-12345")
assert call_count == 2 # Had to re-validate
@pytest.mark.asyncio
async def test_invalidate_cache(self):
svc = _make_service(cache_ttl=300.0)
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
call_count = 0
async def counting_post(*args, **kwargs):
nonlocal call_count
call_count += 1
return mock_resp
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = counting_post
MockClient.return_value = instance
await svc.validate("test-invalidate-key12")
assert call_count == 1
await svc.invalidate_cache("test-invalidate-key12")
await svc.validate("test-invalidate-key12")
assert call_count == 2
@pytest.mark.asyncio
async def test_clear_cache(self):
svc = _make_service(cache_ttl=300.0)
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
call_count = 0
async def counting_post(*args, **kwargs):
nonlocal call_count
call_count += 1
return mock_resp
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = counting_post
MockClient.return_value = instance
await svc.validate("test-clear-key1-12345")
await svc.validate("test-clear-key2-12345")
assert call_count == 2
await svc.clear_cache()
await svc.validate("test-clear-key1-12345")
await svc.validate("test-clear-key2-12345")
assert call_count == 4 # Both had to re-validate
# ---------------------------------------------------------------------------
# Transient failures & retries
# ---------------------------------------------------------------------------
class TestTransientFailures:
@pytest.mark.asyncio
async def test_5xx_not_cached(self):
svc = _make_service(cache_ttl=300.0)
mock_500 = _mock_response(500)
mock_ok = _mock_response(200, {"valid": True, "user_id": "u1"})
responses = [mock_500, mock_500, mock_ok] # Extra for retry
call_idx = 0
async def sequential_post(*args, **kwargs):
nonlocal call_idx
resp = responses[min(call_idx, len(responses) - 1)]
call_idx += 1
return resp
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = sequential_post
MockClient.return_value = instance
# First call: 500 -> not cached
r1 = await svc.validate("test-5xx-test-key1234")
assert r1.valid is False
assert r1.cacheable is False
# Second call should hit HTTP again (not cached)
r2 = await svc.validate("test-5xx-test-key1234")
# Second call also gets 500 from our mock sequence
assert r2.valid is False
@pytest.mark.asyncio
async def test_timeout_then_retry_succeeds(self):
svc = _make_service()
mock_ok = _mock_response(200, {"valid": True, "user_id": "u1"})
attempt = 0
async def timeout_then_ok(*args, **kwargs):
nonlocal attempt
attempt += 1
if attempt == 1:
raise httpx.TimeoutException("timed out")
return mock_ok
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = timeout_then_ok
MockClient.return_value = instance
result = await svc.validate("test-timeout-retry-ok")
assert result.valid is True
assert result.user_id == "u1"
assert attempt == 2
@pytest.mark.asyncio
async def test_timeout_exhausts_retries(self):
svc = _make_service()
async def always_timeout(*args, **kwargs):
raise httpx.TimeoutException("timed out")
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = always_timeout
MockClient.return_value = instance
result = await svc.validate("test-timeout-exhaust1")
assert result.valid is False
assert "timeout" in result.error.lower()
assert result.cacheable is False
@pytest.mark.asyncio
async def test_request_error_then_retry_succeeds(self):
svc = _make_service()
mock_ok = _mock_response(200, {"valid": True, "user_id": "u1"})
attempt = 0
async def error_then_ok(*args, **kwargs):
nonlocal attempt
attempt += 1
if attempt == 1:
raise httpx.ConnectError("connection refused")
return mock_ok
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = error_then_ok
MockClient.return_value = instance
result = await svc.validate("test-reqerr-retry-ok1")
assert result.valid is True
assert attempt == 2
@pytest.mark.asyncio
async def test_request_error_exhausts_retries(self):
svc = _make_service()
async def always_error(*args, **kwargs):
raise httpx.ConnectError("connection refused")
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = always_error
MockClient.return_value = instance
result = await svc.validate("test-reqerr-exhaust1")
assert result.valid is False
assert "unavailable" in result.error.lower()
assert result.cacheable is False
@pytest.mark.asyncio
async def test_unexpected_exception(self):
svc = _make_service()
async def unexpected(*args, **kwargs):
raise ValueError("something unexpected")
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = unexpected
MockClient.return_value = instance
result = await svc.validate("test-unexpected-err12")
assert result.valid is False
assert result.cacheable is False
# ---------------------------------------------------------------------------
# Service token
# ---------------------------------------------------------------------------
class TestServiceToken:
@pytest.mark.asyncio
async def test_service_token_sent_in_headers(self):
svc = _make_service(
service_token_header="X-Service-Token",
service_token="test-svc-token-123",
)
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
captured_headers = {}
async def capture_post(url, *, json=None, headers=None):
captured_headers.update(headers or {})
return mock_resp
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = capture_post
MockClient.return_value = instance
await svc.validate("test-svctoken-key1234")
assert captured_headers.get("X-Service-Token") == "test-svc-token-123"
assert captured_headers.get("Content-Type") == "application/json"
@@ -0,0 +1,114 @@
"""Tests for auth configuration validation and startup routes."""
import json
import sys
from unittest.mock import MagicMock
import pytest
from core.config import config
from starlette.requests import Request
from starlette.responses import JSONResponse
@pytest.fixture(autouse=True)
def _restore_config(monkeypatch):
"""Prevent main() side effects on the global config from leaking to other tests."""
monkeypatch.setattr(config, "http_remote_hosted", config.http_remote_hosted)
monkeypatch.setattr(config, "api_key_validation_url", config.api_key_validation_url)
monkeypatch.setattr(config, "api_key_login_url", config.api_key_login_url)
monkeypatch.setattr(config, "api_key_cache_ttl", config.api_key_cache_ttl)
monkeypatch.setattr(config, "api_key_service_token_header", config.api_key_service_token_header)
monkeypatch.setattr(config, "api_key_service_token", config.api_key_service_token)
class TestStartupConfigValidation:
def test_remote_hosted_flag_without_validation_url_exits(self, monkeypatch):
"""--http-remote-hosted without --api-key-validation-url should SystemExit(1)."""
monkeypatch.setattr(
sys,
"argv",
[
"main",
"--transport", "http",
"--http-remote-hosted",
# Deliberately omit --api-key-validation-url
],
)
monkeypatch.delenv("UNITY_MCP_API_KEY_VALIDATION_URL", raising=False)
monkeypatch.delenv("UNITY_MCP_HTTP_REMOTE_HOSTED", raising=False)
from main import main
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
def test_remote_hosted_env_var_without_validation_url_exits(self, monkeypatch):
"""UNITY_MCP_HTTP_REMOTE_HOSTED=true without validation URL should SystemExit(1)."""
monkeypatch.setattr(
sys,
"argv",
[
"main",
"--transport", "http",
# No --http-remote-hosted flag
],
)
monkeypatch.setenv("UNITY_MCP_HTTP_REMOTE_HOSTED", "true")
monkeypatch.delenv("UNITY_MCP_API_KEY_VALIDATION_URL", raising=False)
from main import main
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
class TestLoginUrlEndpoint:
"""Test the /api/auth/login-url route handler logic.
These tests replicate the handler inline to avoid full MCP server construction.
The logic mirrors main.py's auth_login_url route exactly.
"""
@staticmethod
async def _auth_login_url(_request):
"""Replicate the route handler from main.py."""
if not config.api_key_login_url:
return JSONResponse(
{
"success": False,
"error": "API key management not configured. Contact your server administrator.",
},
status_code=404,
)
return JSONResponse({
"success": True,
"login_url": config.api_key_login_url,
})
@pytest.mark.asyncio
async def test_login_url_returns_url_when_configured(self, monkeypatch):
monkeypatch.setattr(config, "api_key_login_url",
"https://app.example.com/keys")
response = await self._auth_login_url(MagicMock(spec=Request))
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["success"] is True
assert body["login_url"] == "https://app.example.com/keys"
@pytest.mark.asyncio
async def test_login_url_returns_404_when_not_configured(self, monkeypatch):
monkeypatch.setattr(config, "api_key_login_url", None)
response = await self._auth_login_url(MagicMock(spec=Request))
assert response.status_code == 404
body = json.loads(response.body.decode())
assert body["success"] is False
assert "not configured" in body["error"]
@@ -0,0 +1,110 @@
"""Connection resilience when a domain reload leaves the Unity socket half-open."""
from __future__ import annotations
import json
import socket
import threading
import time
from pathlib import Path
import pytest
from core.config import config
import transport.legacy.unity_connection as uc
from transport.legacy.unity_connection import UnityConnection
def _start_silent_bridge():
"""Accept and handshake, then never answer (a wedged half-open bridge)."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(8)
port = srv.getsockname()[1]
accepted: list[socket.socket] = []
stop = threading.Event()
def serve():
srv.settimeout(0.5)
while not stop.is_set():
try:
client, _ = srv.accept()
except socket.timeout:
continue
except OSError:
break
accepted.append(client)
try:
client.sendall(b"WELCOME UNITY-MCP 1 FRAMING=1\n")
except OSError:
pass
threading.Thread(target=serve, daemon=True).start()
return srv, port, stop, accepted
def _write_reloading_status(tmp_path):
status_dir = Path(tmp_path) / ".unity-mcp"
status_dir.mkdir(parents=True, exist_ok=True)
(status_dir / "unity-mcp-status-deadbeef.json").write_text(
json.dumps({"reloading": True, "reason": "reloading", "project_path": "x"})
)
@pytest.fixture
def silent_bridge(monkeypatch, tmp_path):
srv, port, stop, accepted = _start_silent_bridge()
monkeypatch.setattr(uc.Path, "home", lambda: Path(tmp_path))
monkeypatch.setattr(uc.stdio_port_registry, "get_port", lambda instance_id=None: port)
monkeypatch.setattr(uc.stdio_port_registry, "get_instance", lambda instance_id: None)
monkeypatch.setattr(config, "connection_timeout", 1.0)
monkeypatch.setattr(config, "command_total_timeout", 2.0, raising=False)
try:
yield port
finally:
stop.set()
try:
srv.close()
except OSError:
pass
for client in accepted:
try:
client.close()
except OSError:
pass
def test_send_command_against_wedged_socket_is_bounded(silent_bridge, monkeypatch):
"""A connection_timeout longer than the total budget must not let a single blocking
recv overrun the ceiling: the deadline caps each recv, so the call still stops near
command_total_timeout rather than connection_timeout."""
monkeypatch.setattr(config, "connection_timeout", 5.0)
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
start = time.monotonic()
with pytest.raises(TimeoutError, match="exceeded total deadline"):
conn.send_command("get_editor_state", {})
assert time.monotonic() - start < 4.0
def test_send_command_with_retry_is_bounded_by_deadline(silent_bridge, tmp_path, monkeypatch):
"""The reload-wait loop honours command_total_timeout, not just max_wait_s."""
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
monkeypatch.setattr(uc, "get_unity_connection", lambda instance_id=None: conn)
_write_reloading_status(tmp_path)
start = time.monotonic()
resp = uc.send_command_with_retry("get_editor_state", {}, instance_id="Repro@deadbeef")
assert time.monotonic() - start < 6.0
assert uc._extract_response_reason(resp) == "reloading"
def test_reload_signal_drops_socket_for_fresh_reconnect(silent_bridge, tmp_path):
"""A 'reloading' status drops the socket so the next command reconnects."""
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
assert conn.connect() is True
assert conn.sock is not None
_write_reloading_status(tmp_path)
response = conn.send_command("get_editor_state", {})
assert conn.sock is None
assert uc._extract_response_reason(response) == "reloading"
@@ -0,0 +1,30 @@
import pytest
@pytest.mark.asyncio
async def test_debug_request_context_includes_server_diagnostics(monkeypatch):
# Import inside test so stubs in conftest are applied.
import services.tools.debug_request_context as mod
class DummyCtx:
# minimal surface for debug_request_context
request_context = None
session_id = None
client_id = None
async def get_state(self, _k):
return None
# Ensure get_package_version is stable for assertion
monkeypatch.setattr(mod, "get_package_version", lambda: "9.9.9-test")
res = await mod.debug_request_context(DummyCtx())
assert res.get("success") is True
data = res.get("data") or {}
server = data.get("server") or {}
assert server.get("version") == "9.9.9-test"
assert "cwd" in server
assert "argv" in server
@@ -0,0 +1,282 @@
"""
Integration test for domain reload resilience.
Tests that the MCP server can handle rapid-fire requests during Unity domain reloads
by waiting for the plugin to reconnect instead of failing immediately.
"""
import asyncio
import pytest
from unittest.mock import AsyncMock, patch
from datetime import datetime
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_plugin_hub_waits_for_reconnection_during_reload():
"""Test that PluginHub._resolve_session_id waits for plugin reconnection."""
# Import after conftest stubs are set up
from transport.plugin_hub import PluginHub
from transport.plugin_registry import PluginRegistry, PluginSession
# Create a mock registry
mock_registry = AsyncMock(spec=PluginRegistry)
# Simulate plugin reconnection sequence:
# First 2 calls: no sessions (plugin disconnected)
# Third call: session appears (plugin reconnected)
call_count = [0]
async def mock_list_sessions(**kwargs):
call_count[0] += 1
if call_count[0] <= 2:
# Plugin not yet reconnected
return {}
else:
# Plugin reconnected
now = datetime.now()
session = PluginSession(
session_id="test-session-123",
project_name="TestProject",
project_hash="abc123",
unity_version="2022.3.0f1",
registered_at=now,
connected_at=now
)
return {"test-session-123": session}
mock_registry.list_sessions = mock_list_sessions
# Configure PluginHub with our mock while preserving the original state
original_registry = PluginHub._registry
original_lock = PluginHub._lock
PluginHub._registry = mock_registry
PluginHub._lock = asyncio.Lock()
try:
# Call _resolve_session_id when no session is available
# It should wait and retry until the session appears
session_id = await PluginHub._resolve_session_id(unity_instance=None)
# Should have retried and eventually found the session
assert session_id == "test-session-123"
assert call_count[0] >= 3 # Should have tried at least 3 times
finally:
# Clean up: restore original PluginHub state
PluginHub._registry = original_registry
PluginHub._lock = original_lock
@pytest.mark.asyncio
async def test_plugin_hub_fails_after_timeout():
"""Test that PluginHub._resolve_session_id eventually times out if plugin never reconnects."""
from transport.plugin_hub import PluginHub
from transport.plugin_registry import PluginRegistry
# Create a mock registry that never returns sessions
mock_registry = AsyncMock(spec=PluginRegistry)
async def mock_list_sessions(**kwargs):
return {} # Never returns sessions
mock_registry.list_sessions = mock_list_sessions
# Configure PluginHub with our mock while preserving the original state
original_registry = PluginHub._registry
original_lock = PluginHub._lock
PluginHub._registry = mock_registry
PluginHub._lock = asyncio.Lock()
# Temporarily override config for a short timeout
with patch('transport.plugin_hub.config') as mock_config:
mock_config.reload_max_retries = 3 # Only 3 retries
mock_config.reload_retry_ms = 10 # 10ms between retries
try:
# Should raise RuntimeError after timeout
with pytest.raises(RuntimeError, match="No Unity plugins are currently connected"):
await PluginHub._resolve_session_id(unity_instance=None)
finally:
# Clean up: restore original PluginHub state
PluginHub._registry = original_registry
PluginHub._lock = original_lock
@pytest.mark.asyncio
async def test_plugin_hub_no_wait_when_retry_disabled(monkeypatch):
"""retry_on_reload=False should skip reconnect wait loops."""
from transport.plugin_hub import PluginHub, NoUnitySessionError
from transport.plugin_registry import PluginRegistry
mock_registry = AsyncMock(spec=PluginRegistry)
mock_registry.get_session_id_by_hash = AsyncMock(return_value=None)
mock_registry.list_sessions = AsyncMock(return_value={})
original_registry = PluginHub._registry
original_lock = PluginHub._lock
PluginHub._registry = mock_registry
PluginHub._lock = asyncio.Lock()
monkeypatch.setenv("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0")
try:
with pytest.raises(NoUnitySessionError):
await PluginHub._resolve_session_id(
unity_instance="hash-missing",
retry_on_reload=False,
)
assert mock_registry.get_session_id_by_hash.await_count == 1
assert mock_registry.list_sessions.await_count == 1
finally:
PluginHub._registry = original_registry
PluginHub._lock = original_lock
@pytest.mark.asyncio
async def test_send_command_for_instance_fails_fast_on_stale_when_retry_disabled(monkeypatch):
"""Stale HTTP session should not send command when retry_on_reload is disabled."""
from transport.plugin_hub import PluginHub
resolve_mock = AsyncMock(return_value="sess-stale")
ensure_mock = AsyncMock(return_value=False)
send_mock = AsyncMock()
monkeypatch.setattr(PluginHub, "_resolve_session_id", resolve_mock)
monkeypatch.setattr(PluginHub, "_ensure_live_connection", ensure_mock)
monkeypatch.setattr(PluginHub, "send_command", send_mock)
result = await PluginHub.send_command_for_instance(
unity_instance="Project@hash-stale",
command_type="manage_script",
params={"action": "edit"},
retry_on_reload=False,
)
assert result["success"] is False
assert result["hint"] == "retry"
assert result.get("data", {}).get("reason") == "stale_connection"
assert resolve_mock.await_count == 1
_, resolve_kwargs = resolve_mock.await_args
assert resolve_kwargs.get("retry_on_reload") is False
send_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_read_console_during_simulated_reload(monkeypatch):
"""
Simulate the stress test: create script (triggers reload) + rapid read_console calls.
This test simulates what happens when:
1. A script is created (triggering domain reload)
2. Multiple read_console calls are made immediately
3. The plugin disconnects and reconnects during those calls
"""
# Setup tools
from services.tools.read_console import read_console
call_count = [0]
async def fake_send_command(*args, **kwargs):
"""Simulate successful command execution."""
call_count[0] += 1
return {
"success": True,
"message": f"Retrieved {call_count[0]} log entries.",
"data": ["<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Auto-discovered 10 tools"]
}
# Patch the async_send_command_with_retry directly
import services.tools.read_console
monkeypatch.setattr(
services.tools.read_console,
"async_send_command_with_retry",
fake_send_command
)
# Run multiple read_console calls rapidly (simulating the stress test)
results = []
for i in range(5):
result = await read_console(
ctx=DummyContext(),
action="get",
types=["all"],
count=50,
format="plain",
include_stacktrace=False
)
results.append(result)
# All calls should succeed
assert len(results) == 5
for i, result in enumerate(results):
assert result["success"] is True, f"Call {i+1} failed with result: {result}"
assert "data" in result
# At least 5 calls should have been made
assert call_count[0] == 5
@pytest.mark.asyncio
async def test_plugin_hub_respects_unity_instance_preference():
"""Test that _resolve_session_id prefers a specific Unity instance if requested."""
from transport.plugin_hub import PluginHub, InstanceSelectionRequiredError
from transport.plugin_registry import PluginRegistry, PluginSession
# Create a mock registry with two sessions
mock_registry = AsyncMock(spec=PluginRegistry)
now = datetime.now()
session1 = PluginSession(
session_id="session-1",
project_name="Project1",
project_hash="hash1",
unity_version="2022.3.0f1",
registered_at=now,
connected_at=now
)
session2 = PluginSession(
session_id="session-2",
project_name="Project2",
project_hash="hash2",
unity_version="2022.3.0f1",
registered_at=now,
connected_at=now
)
async def mock_list_sessions(**kwargs):
return {
"session-1": session1,
"session-2": session2
}
async def mock_get_session_id_by_hash(project_hash, user_id=None):
if project_hash == "hash2":
return "session-2"
return None
mock_registry.list_sessions = mock_list_sessions
mock_registry.get_session_id_by_hash = mock_get_session_id_by_hash
# Configure PluginHub with our mock while preserving the original state
original_registry = PluginHub._registry
original_lock = PluginHub._lock
PluginHub._registry = mock_registry
PluginHub._lock = asyncio.Lock()
try:
# Request specific Unity instance
session_id = await PluginHub._resolve_session_id(unity_instance="hash2")
# Should return the requested instance
assert session_id == "session-2"
# Request default (no specific instance)
with pytest.raises(InstanceSelectionRequiredError, match="Multiple Unity instances"):
await PluginHub._resolve_session_id(unity_instance=None)
finally:
# Clean up: restore original PluginHub state
PluginHub._registry = original_registry
PluginHub._lock = original_lock
@@ -0,0 +1,138 @@
import pytest
from .test_helpers import DummyContext, DummyMCP, setup_script_tools
@pytest.mark.asyncio
async def test_normalizes_lsp_and_index_ranges(monkeypatch):
tools = setup_script_tools()
apply = tools["apply_text_edits"]
calls = []
async def fake_send(cmd, params, **kwargs):
calls.append(params)
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
# LSP-style
edits = [{
"range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}},
"newText": "// lsp\n"
}]
await apply(
DummyContext(),
uri="mcpforunity://path/Assets/Scripts/F.cs",
edits=edits,
precondition_sha256="x",
)
p = calls[-1]
e = p["edits"][0]
assert e["startLine"] == 11 and e["startCol"] == 3
# Index pair
calls.clear()
edits = [{"range": [0, 0], "text": "// idx\n"}]
# fake read to provide contents length
async def fake_read(cmd, params, **kwargs):
if params.get("action") == "read":
return {"success": True, "data": {"contents": "hello\n"}}
calls.append(params)
return {"success": True}
# Override unity_connection for this read normalization case
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_read,
)
await apply(
DummyContext(),
uri="mcpforunity://path/Assets/Scripts/F.cs",
edits=edits,
precondition_sha256="x",
)
# last call is apply_text_edits
@pytest.mark.asyncio
async def test_noop_evidence_shape(monkeypatch):
tools = setup_script_tools()
apply = tools["apply_text_edits"]
# Route response from Unity indicating no-op
async def fake_send(cmd, params, **kwargs):
return {
"success": True,
"data": {"no_op": True, "evidence": {"reason": "identical_content"}},
}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
resp = await apply(
DummyContext(),
uri="mcpforunity://path/Assets/Scripts/F.cs",
edits=[
{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}
],
precondition_sha256="x",
)
assert resp["success"] is True
assert resp.get("data", {}).get("no_op") is True
@pytest.mark.asyncio
async def test_atomic_multi_span_and_relaxed(monkeypatch):
tools_text = setup_script_tools()
apply_text = tools_text["apply_text_edits"]
# Fake send for read and write; verify atomic applyMode and validate=relaxed passes through
sent = {}
async def fake_send(cmd, params, **kwargs):
if params.get("action") == "read":
return {
"success": True,
"data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"},
}
sent.setdefault("calls", []).append(params)
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
edits = [
{"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"},
{"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"}
]
resp = await apply_text(
DummyContext(),
uri="mcpforunity://path/Assets/Scripts/C.cs",
edits=edits,
precondition_sha256="sha",
options={"validate": "relaxed", "applyMode": "atomic"},
)
assert resp["success"] is True
# Last manage_script call should include options with applyMode atomic and validate relaxed
last = sent["calls"][-1]
assert last.get("options", {}).get("applyMode") == "atomic"
assert last.get("options", {}).get("validate") == "relaxed"
@@ -0,0 +1,66 @@
import pytest
from .test_helpers import DummyContext, setup_script_tools
@pytest.mark.asyncio
async def test_explicit_zero_based_normalized_warning(monkeypatch):
tools = setup_script_tools()
apply_edits = tools["apply_text_edits"]
async def fake_send(cmd, params, **kwargs):
# Simulate Unity path returning minimal success
return {"success": True}
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# Explicit fields given as 0-based (invalid); SDK should normalize and warn
edits = [{"startLine": 0, "startCol": 0,
"endLine": 0, "endCol": 0, "newText": "//x"}]
resp = await apply_edits(
DummyContext(),
uri="mcpforunity://path/Assets/Scripts/F.cs",
edits=edits,
precondition_sha256="sha",
)
assert resp["success"] is True
data = resp.get("data", {})
assert "normalizedEdits" in data
assert any(
w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", []))
ne = data["normalizedEdits"][0]
assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1
@pytest.mark.asyncio
async def test_strict_zero_based_error(monkeypatch):
tools = setup_script_tools()
apply_edits = tools["apply_text_edits"]
async def fake_send(cmd, params, **kwargs):
return {"success": True}
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
edits = [{"startLine": 0, "startCol": 0,
"endLine": 0, "endCol": 0, "newText": "//x"}]
resp = await apply_edits(
DummyContext(),
uri="mcpforunity://path/Assets/Scripts/F.cs",
edits=edits,
precondition_sha256="sha",
strict=True,
)
assert resp["success"] is False
assert resp.get("code") == "zero_based_explicit_fields"
@@ -0,0 +1,59 @@
import pytest
from services.registry import get_registered_resources
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_editor_state_v2_is_registered_and_has_contract_fields(monkeypatch):
"""
Canonical editor state resource should be `mcpforunity://editor/state` and conform to v2 contract fields.
"""
# Import module to ensure it registers its decorator without disturbing global registry state.
import services.resources.editor_state # noqa: F401
resources = get_registered_resources()
state_res = next(
(r for r in resources if r.get("uri") == "mcpforunity://editor/state"),
None,
)
assert state_res is not None, (
"Expected canonical editor state resource `mcpforunity://editor/state` to be registered. "
"This is required so clients can poll readiness/staleness and avoid tool loops."
)
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
# Minimal stub payload for v2 resource tests. The server layer should enrich with staleness/advice.
assert command_type == "get_editor_state"
return {
"success": True,
"data": {
"schema_version": "unity-mcp/editor_state@2",
"observed_at_unix_ms": 1730000000000,
"sequence": 1,
"compilation": {"is_compiling": False, "is_domain_reload_pending": False},
"tests": {"is_running": False},
},
}
# Patch transport so the resource can be invoked without Unity running.
import transport.unity_transport as unity_transport
monkeypatch.setattr(unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
result = await state_res["func"](DummyContext())
payload = result.model_dump() if hasattr(result, "model_dump") else result
assert isinstance(payload, dict)
# Contract assertions (top-level)
assert payload.get("success") is True
data = payload.get("data")
assert isinstance(data, dict)
assert data.get("schema_version") == "unity-mcp/editor_state@2"
assert "observed_at_unix_ms" in data
assert "sequence" in data
assert "advice" in data
assert "staleness" in data
@@ -0,0 +1,86 @@
import os
import time
from pathlib import Path
def test_external_changes_scanner_marks_dirty_and_clears(tmp_path, monkeypatch):
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.state.external_changes_scanner import ExternalChangesScanner
# Create a minimal Unity-like layout
root = tmp_path / "Project"
(root / "Assets").mkdir(parents=True)
(root / "ProjectSettings").mkdir(parents=True)
(root / "Packages").mkdir(parents=True)
inst = "Test@deadbeef"
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
s.set_project_root(inst, str(root))
# Create a file before baseline so the initial scan establishes a stable reference point.
p = root / "Assets" / "x.txt"
p.write_text("hi")
# Baseline scan: should not be dirty.
first = s.update_and_get(inst)
assert first["external_changes_dirty"] is False
# Touch the file and scan again: should become dirty.
now = time.time()
os.utime(p, (now + 10.0, now + 10.0))
second = s.update_and_get(inst)
assert second["external_changes_dirty"] is True
assert isinstance(second["external_changes_last_seen_unix_ms"], int)
assert isinstance(second["dirty_since_unix_ms"], int)
# Clear and confirm dirty flag resets.
s.clear_dirty(inst)
third = s.update_and_get(inst)
assert third["external_changes_dirty"] is False
assert isinstance(third["last_cleared_unix_ms"], int)
def test_external_changes_scanner_includes_file_dependency_roots(tmp_path, monkeypatch):
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.state.external_changes_scanner import ExternalChangesScanner
# Unity project root
root = tmp_path / "Project"
(root / "Assets").mkdir(parents=True)
(root / "ProjectSettings").mkdir(parents=True)
(root / "Packages").mkdir(parents=True)
# External local package root (outside project root)
pkg = tmp_path / "ExternalPkg"
(pkg / "Editor").mkdir(parents=True)
target = pkg / "Editor" / "Some.cs"
target.write_text("// v1")
# manifest.json referencing file: dependency
manifest = root / "Packages" / "manifest.json"
manifest.write_text(
'{\n "dependencies": {\n "com.example.pkg": "file:../../ExternalPkg"\n }\n}\n',
encoding="utf-8",
)
inst = "Test@deadbeef"
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
s.set_project_root(inst, str(root))
# Baseline scan captures current mtimes across project + external pkg
baseline = s.update_and_get(inst)
assert baseline["external_changes_dirty"] is False
# Touch external package file and scan again -> should mark dirty
now = time.time()
os.utime(target, (now + 10.0, now + 10.0))
changed = s.update_and_get(inst)
assert changed["external_changes_dirty"] is True
@@ -0,0 +1,200 @@
"""
Tests for the find_gameobjects tool.
This tool provides paginated GameObject search, returning instance IDs only.
"""
import pytest
from .test_helpers import DummyContext
import services.tools.find_gameobjects as find_go_mod
@pytest.mark.asyncio
async def test_find_gameobjects_basic_search(monkeypatch):
"""Test basic search returns instance IDs."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"instanceIDs": [12345, 67890],
"pageSize": 25,
"cursor": 0,
"totalCount": 2,
"hasMore": False,
},
}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Player",
search_method="by_name",
)
assert resp.get("success") is True
assert captured["cmd"] == "find_gameobjects"
assert captured["params"]["searchTerm"] == "Player"
assert captured["params"]["searchMethod"] == "by_name"
@pytest.mark.asyncio
async def test_find_gameobjects_by_component(monkeypatch):
"""Test search by component type."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"instanceIDs": [111, 222, 333],
"pageSize": 25,
"cursor": 0,
"totalCount": 3,
"hasMore": False,
},
}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Camera",
search_method="by_component",
)
assert resp.get("success") is True
assert captured["params"]["searchTerm"] == "Camera"
assert captured["params"]["searchMethod"] == "by_component"
@pytest.mark.asyncio
async def test_find_gameobjects_pagination_params(monkeypatch):
"""Test pagination parameters are passed correctly."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"instanceIDs": [444, 555],
"pageSize": 10,
"cursor": 20,
"totalCount": 50,
"hasMore": True,
"nextCursor": "30",
},
}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Enemy",
search_method="by_tag",
page_size="10",
cursor="20",
)
assert resp.get("success") is True
p = captured["params"]
assert p["pageSize"] == 10
assert p["cursor"] == 20
@pytest.mark.asyncio
async def test_find_gameobjects_boolean_coercion(monkeypatch):
"""Test boolean string coercion for include_inactive."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceIDs": []}}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="HiddenObject",
search_method="by_name",
include_inactive="true",
)
assert resp.get("success") is True
p = captured["params"]
assert p["includeInactive"] is True
@pytest.mark.asyncio
async def test_find_gameobjects_by_layer(monkeypatch):
"""Test search by layer."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceIDs": [999]}}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="UI",
search_method="by_layer",
)
assert resp.get("success") is True
assert captured["params"]["searchMethod"] == "by_layer"
assert captured["params"]["searchTerm"] == "UI"
@pytest.mark.asyncio
async def test_find_gameobjects_by_path(monkeypatch):
"""Test search by hierarchy path."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceIDs": [777]}}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Canvas/Panel/Button",
search_method="by_path",
)
assert resp.get("success") is True
assert captured["params"]["searchMethod"] == "by_path"
assert captured["params"]["searchTerm"] == "Canvas/Panel/Button"
@@ -0,0 +1,254 @@
"""
Tests for the GameObject resources.
Resources:
- mcpforunity://scene/gameobject/{instance_id}
- mcpforunity://scene/gameobject/{instance_id}/components
- mcpforunity://scene/gameobject/{instance_id}/component/{component_name}
"""
import pytest
from .test_helpers import DummyContext
import services.resources.gameobject as gameobject_res_mod
@pytest.mark.asyncio
async def test_get_gameobject_data(monkeypatch):
"""Test reading a single GameObject resource."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"instanceID": 12345,
"name": "Player",
"tag": "Player",
"layer": 0,
"activeSelf": True,
"activeInHierarchy": True,
"isStatic": False,
"path": "/Player",
"componentTypes": ["Transform", "PlayerController", "Rigidbody"],
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject(
ctx=DummyContext(),
instance_id="12345",
)
assert resp.success is True
assert captured["params"]["instanceID"] == 12345
@pytest.mark.asyncio
async def test_get_gameobject_components(monkeypatch):
"""Test reading all components for a GameObject."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"cursor": 0,
"pageSize": 25,
"next_cursor": None,
"truncated": False,
"total": 3,
"items": [
{"typeName": "UnityEngine.Transform", "instanceID": 1, "enabled": True},
{"typeName": "UnityEngine.MeshRenderer", "instanceID": 2, "enabled": True},
{"typeName": "UnityEngine.BoxCollider", "instanceID": 3, "enabled": True},
],
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_components(
ctx=DummyContext(),
instance_id="12345",
)
assert resp.success is True
assert captured["params"]["instanceID"] == 12345
@pytest.mark.asyncio
async def test_get_gameobject_components_pagination(monkeypatch):
"""Test pagination parameters for components resource."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"cursor": 10,
"pageSize": 5,
"next_cursor": "15",
"truncated": True,
"total": 20,
"items": [],
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_components(
ctx=DummyContext(),
instance_id="12345",
page_size=5,
cursor=10,
)
assert resp.success is True
p = captured["params"]
assert p["pageSize"] == 5
assert p["cursor"] == 10
@pytest.mark.asyncio
async def test_get_gameobject_components_include_properties(monkeypatch):
"""Test include_properties flag for components resource."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"items": [
{
"typeName": "UnityEngine.Rigidbody",
"instanceID": 123,
"mass": 1.0,
"drag": 0.0,
"useGravity": True,
}
]
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_components(
ctx=DummyContext(),
instance_id="12345",
include_properties=True,
)
assert resp.success is True
assert captured["params"]["includeProperties"] is True
@pytest.mark.asyncio
async def test_get_gameobject_component_single(monkeypatch):
"""Test reading a single component by name."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"typeName": "UnityEngine.Rigidbody",
"instanceID": 67890,
"mass": 5.0,
"drag": 0.1,
"angularDrag": 0.05,
"useGravity": True,
"isKinematic": False,
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_component(
ctx=DummyContext(),
instance_id="12345",
component_name="Rigidbody",
)
assert resp.success is True
p = captured["params"]
assert p["instanceID"] == 12345
assert p["componentName"] == "Rigidbody"
@pytest.mark.asyncio
async def test_get_gameobject_component_not_found(monkeypatch):
"""Test error when component is not found."""
async def fake_send(cmd, params, **kwargs):
return {
"success": False,
"message": "GameObject '12345' does not have a 'NonExistent' component.",
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_component(
ctx=DummyContext(),
instance_id="12345",
component_name="NonExistent",
)
assert resp.success is False
assert "NonExistent" in (resp.message or "")
@pytest.mark.asyncio
async def test_get_gameobject_not_found(monkeypatch):
"""Test error when GameObject is not found."""
async def fake_send(cmd, params, **kwargs):
return {
"success": False,
"message": "GameObject with instanceID '99999' not found.",
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject(
ctx=DummyContext(),
instance_id="99999",
)
assert resp.success is False
assert "99999" in (resp.message or "")
+33
View File
@@ -0,0 +1,33 @@
import pytest
from .test_helpers import DummyContext, setup_script_tools
@pytest.mark.asyncio
async def test_get_sha_param_shape_and_routing(monkeypatch):
tools = setup_script_tools()
get_sha = tools["get_sha"]
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "mcpforunity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
resp = await get_sha(DummyContext(), uri="mcpforunity://path/Assets/Scripts/A.cs")
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "get_sha"
assert captured["params"]["name"] == "A"
assert captured["params"]["path"].endswith("Assets/Scripts")
assert resp["success"] is True
assert resp["data"] == {"sha256": "abc", "lengthBytes": 1}
+88
View File
@@ -0,0 +1,88 @@
class _DummyMeta(dict):
def __getattr__(self, item):
try:
return self[item]
except KeyError as exc:
raise AttributeError(item) from exc
model_extra = property(lambda self: self)
def model_dump(self, exclude_none=True):
if not exclude_none:
return dict(self)
return {k: v for k, v in self.items() if v is not None}
class DummyContext:
"""Mock context object for testing"""
def __init__(self, **meta):
import uuid
self.log_info = []
self.log_warning = []
self.log_error = []
self._meta = _DummyMeta(meta)
# Give each context a unique session_id to avoid state leakage between tests
self.session_id = str(uuid.uuid4())
# Add state storage to mimic FastMCP context state
self._state = {}
class _RequestContext:
def __init__(self, meta):
self.meta = meta
self.request_context = _RequestContext(self._meta)
async def info(self, message):
self.log_info.append(message)
async def warning(self, message):
self.log_warning.append(message)
# Some code paths call warn(); treat it as an alias of warning()
async def warn(self, message):
await self.warning(message)
async def error(self, message):
self.log_error.append(message)
async def set_state(self, key, value):
"""Set state value (mimics FastMCP context.set_state)"""
self._state[key] = value
async def get_state(self, key, default=None):
"""Get state value (mimics FastMCP context.get_state)"""
return self._state.get(key, default)
class DummyMCP:
"""Mock MCP server for testing tool registration patterns."""
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn):
self.tools[fn.__name__] = fn
return fn
return deco
def setup_script_tools():
"""
Setup script-related tools for testing.
Returns a dict mapping tool names to their async handler functions.
Useful for testing parameter serialization and SDK behavior.
"""
mcp = DummyMCP()
# Import tools to trigger decorator-based registration
import services.tools.manage_script
from services.registry import get_registered_tools
for tool_info in get_registered_tools():
name = tool_info['name']
if any(k in name for k in ['script', 'apply_text', 'create_script',
'delete_script', 'validate_script', 'get_sha']):
mcp.tools[name] = tool_info['func']
return mcp.tools
@@ -0,0 +1,151 @@
"""
Test the improved anchor matching logic.
"""
import re
import pytest
import services.tools.script_apply_edits as script_apply_edits_module
def test_improved_anchor_matching():
"""Test that our improved anchor matching finds the right closing brace."""
test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
void Start()
{
Debug.Log("test");
}
void Update()
{
// Update logic
}
}'''
# Test the problematic anchor pattern
anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE
# Test our improved function
best_match = script_apply_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
assert best_match is not None, "anchor pattern not found"
match_pos = best_match.start()
line_num = test_code[:match_pos].count('\n') + 1
total_lines = test_code.count('\n') + 1
assert line_num >= total_lines - \
2, f"expected match near end (>= {total_lines-2}), got line {line_num}"
def test_old_vs_new_matching():
"""Compare old vs new matching behavior."""
test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
void Start()
{
Debug.Log("test");
}
void Update()
{
if (condition)
{
DoSomething();
}
}
void LateUpdate()
{
// More logic
}
}'''
import re
anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE
# Old behavior (first match)
old_match = re.search(anchor_pattern, test_code, flags)
old_line = test_code[:old_match.start()].count(
'\n') + 1 if old_match else None
# New behavior (improved matching)
new_match = script_apply_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
new_line = test_code[:new_match.start()].count(
'\n') + 1 if new_match else None
assert old_line is not None and new_line is not None, "failed to locate anchors"
assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})"
total_lines = test_code.count('\n') + 1
assert new_line >= total_lines - \
2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}"
@pytest.mark.asyncio
async def test_apply_edits_with_improved_matching():
"""Test that _apply_edits_locally uses improved matching."""
original_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
public string message = "Hello World";
void Start()
{
Debug.Log(message);
}
}'''
# Test anchor_insert with the problematic pattern
edits = [{
"op": "anchor_insert",
"anchor": r"\s*}\s*$", # This should now find the class end
"position": "before",
"text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n"
}]
result = await script_apply_edits_module._apply_edits_locally(
original_code, edits)
lines = result.split('\n')
try:
idx = next(i for i, line in enumerate(lines) if "NewMethod" in line)
except StopIteration:
assert False, "NewMethod not found in result"
total_lines = len(lines)
assert idx >= total_lines - \
5, f"method inserted too early (idx={idx}, total_lines={total_lines})"
if __name__ == "__main__":
print("Testing improved anchor matching...")
print("="*60)
success1 = test_improved_anchor_matching()
print("\n" + "="*60)
print("Comparing old vs new behavior...")
success2 = test_old_vs_new_matching()
print("\n" + "="*60)
print("Testing _apply_edits_locally with improved matching...")
success3 = test_apply_edits_with_improved_matching()
print("\n" + "="*60)
if success1 and success2 and success3:
print("🎉 ALL TESTS PASSED! Improved anchor matching is working!")
else:
print("💥 Some tests failed. Need more work on anchor matching.")
@@ -0,0 +1,428 @@
"""
Tests for per-call unity_instance routing via middleware argument interception.
When a tool call includes unity_instance in its arguments, the middleware:
1. Pops the key before Pydantic validation sees it
2. Resolves it to a validated instance identifier
3. Sets it in request-scoped state for that call only (does NOT persist to session)
"""
import sys
import types
from types import SimpleNamespace
import pytest
from .test_helpers import DummyContext
from core.config import config
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class DummyMiddlewareContext:
"""Minimal MiddlewareContext stand-in with a mutable arguments dict."""
def __init__(self, ctx, arguments: dict | None = None):
self.fastmcp_context = ctx
self.message = SimpleNamespace(arguments=arguments if arguments is not None else {})
def _make_middleware(monkeypatch, *, transport="stdio", plugin_hub_configured=False, sessions=None, pool_instances=None):
"""
Build a UnityInstanceMiddleware with patched transport dependencies.
sessions: dict of session_id -> SimpleNamespace(project=..., hash=...)
pool_instances: list of SimpleNamespace(id=..., hash=...)
"""
plugin_hub_mod = types.ModuleType("transport.plugin_hub")
_sessions = sessions or {}
_configured = plugin_hub_configured
class FakePluginHub:
@classmethod
def is_configured(cls):
return _configured
@classmethod
async def get_sessions(cls, user_id=None):
return SimpleNamespace(sessions=_sessions)
@classmethod
async def _resolve_session_id(cls, instance, user_id=None):
return None
plugin_hub_mod.PluginHub = FakePluginHub
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub_mod)
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
from transport.unity_instance_middleware import UnityInstanceMiddleware
middleware = UnityInstanceMiddleware()
monkeypatch.setattr(config, "transport_mode", transport)
monkeypatch.setattr(config, "http_remote_hosted", False)
if pool_instances is not None:
async def fake_discover(ctx):
return pool_instances
monkeypatch.setattr(middleware, "_discover_instances", fake_discover)
return middleware
# ---------------------------------------------------------------------------
# Pop behaviour
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unity_instance_is_popped_from_arguments(monkeypatch):
"""unity_instance key must be removed from arguments before the tool function sees them."""
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
args = {"action": "get_active", "unity_instance": "abc123"}
mw_ctx = DummyMiddlewareContext(ctx, arguments=args)
await mw._inject_unity_instance(mw_ctx)
assert "unity_instance" not in args
assert "action" in args # other keys untouched
@pytest.mark.asyncio
async def test_arguments_without_unity_instance_untouched(monkeypatch):
"""When unity_instance is absent, arguments dict is left completely untouched."""
mw = _make_middleware(monkeypatch, pool_instances=[SimpleNamespace(id="Proj@abc123", hash="abc123")])
ctx = DummyContext()
ctx.client_id = "client-1"
# Seed a persisted instance so auto-select isn't needed
await mw.set_active_instance(ctx, "Proj@abc123")
args = {"action": "get_active", "name": "Test"}
mw_ctx = DummyMiddlewareContext(ctx, arguments=args)
await mw._inject_unity_instance(mw_ctx)
assert args == {"action": "get_active", "name": "Test"}
# ---------------------------------------------------------------------------
# Per-call routing (no persistence)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inline_routes_to_specified_instance(monkeypatch):
"""Per-call unity_instance sets request state to the resolved instance."""
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc123"})
await mw._inject_unity_instance(mw_ctx)
assert await ctx.get_state("unity_instance") == "Proj@abc123"
@pytest.mark.asyncio
async def test_inline_does_not_persist_to_session(monkeypatch):
"""Per-call unity_instance must not change the session-persisted instance."""
instances = [
SimpleNamespace(id="ProjA@aaa111", hash="aaa111"),
SimpleNamespace(id="ProjB@bbb222", hash="bbb222"),
]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
await mw.set_active_instance(ctx, "ProjA@aaa111")
# Call 1: inline override to ProjB
mw_ctx1 = DummyMiddlewareContext(ctx, arguments={"unity_instance": "bbb222"})
await mw._inject_unity_instance(mw_ctx1)
assert await ctx.get_state("unity_instance") == "ProjB@bbb222"
# Call 2: no inline — must revert to session-persisted ProjA
mw_ctx2 = DummyMiddlewareContext(ctx, arguments={})
await mw._inject_unity_instance(mw_ctx2)
assert await ctx.get_state("unity_instance") == "ProjA@aaa111"
@pytest.mark.asyncio
async def test_inline_overrides_session_persisted_instance(monkeypatch):
"""Inline unity_instance takes precedence over session-persisted instance."""
instances = [
SimpleNamespace(id="ProjA@aaa111", hash="aaa111"),
SimpleNamespace(id="ProjB@bbb222", hash="bbb222"),
]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
await mw.set_active_instance(ctx, "ProjA@aaa111")
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "ProjB@bbb222"})
await mw._inject_unity_instance(mw_ctx)
assert await ctx.get_state("unity_instance") == "ProjB@bbb222"
# Session still pinned to ProjA
assert await mw.get_active_instance(ctx) == "ProjA@aaa111"
# ---------------------------------------------------------------------------
# Port number resolution (stdio)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_port_number_resolves_to_name_hash_stdio(monkeypatch):
"""Bare port number resolves to the matching Name@hash in stdio mode."""
instances = [
SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401),
SimpleNamespace(id="Other@def456", hash="def456", port=6402),
]
mw = _make_middleware(monkeypatch, transport="stdio", pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "6401"})
await mw._inject_unity_instance(mw_ctx)
assert await ctx.get_state("unity_instance") == "Proj@abc123"
@pytest.mark.asyncio
async def test_port_number_not_found_raises(monkeypatch):
"""Port number with no matching instance raises ValueError."""
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401)]
mw = _make_middleware(monkeypatch, transport="stdio", pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "9999"})
with pytest.raises(ValueError, match="No Unity instance found on port 9999"):
await mw._inject_unity_instance(mw_ctx)
@pytest.mark.asyncio
async def test_port_number_errors_in_http_mode(monkeypatch):
"""Bare port number raises ValueError in HTTP transport mode."""
mw = _make_middleware(monkeypatch, transport="http")
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "6401"})
with pytest.raises(ValueError, match="not supported in HTTP transport mode"):
await mw._inject_unity_instance(mw_ctx)
# ---------------------------------------------------------------------------
# Name@hash and hash prefix resolution
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_name_at_hash_resolves_exactly(monkeypatch):
"""Full Name@hash resolves directly without discovery."""
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "Proj@abc123"})
await mw._inject_unity_instance(mw_ctx)
assert await ctx.get_state("unity_instance") == "Proj@abc123"
@pytest.mark.asyncio
async def test_unknown_name_at_hash_raises(monkeypatch):
"""Unknown Name@hash raises ValueError."""
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "Ghost@deadbeef"})
with pytest.raises(ValueError, match="not found"):
await mw._inject_unity_instance(mw_ctx)
@pytest.mark.asyncio
async def test_hash_prefix_resolves_unique(monkeypatch):
"""Unique hash prefix resolves to the full Name@hash."""
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc"})
await mw._inject_unity_instance(mw_ctx)
assert await ctx.get_state("unity_instance") == "Proj@abc123"
@pytest.mark.asyncio
async def test_ambiguous_hash_prefix_raises(monkeypatch):
"""Ambiguous hash prefix raises ValueError."""
instances = [
SimpleNamespace(id="ProjA@abc111", hash="abc111"),
SimpleNamespace(id="ProjB@abc222", hash="abc222"),
]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc"})
with pytest.raises(ValueError, match="ambiguous"):
await mw._inject_unity_instance(mw_ctx)
@pytest.mark.asyncio
async def test_no_match_raises(monkeypatch):
"""Hash prefix matching nothing raises ValueError."""
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
mw = _make_middleware(monkeypatch, pool_instances=instances)
ctx = DummyContext()
ctx.client_id = "client-1"
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "xyz"})
with pytest.raises(ValueError, match="No running Unity instance"):
await mw._inject_unity_instance(mw_ctx)
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_none_unity_instance_falls_through_to_session(monkeypatch):
"""None value for unity_instance falls through to session-persisted instance."""
mw = _make_middleware(monkeypatch)
ctx = DummyContext()
ctx.client_id = "client-1"
await mw.set_active_instance(ctx, "Proj@abc123")
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": None, "action": "x"})
await mw._inject_unity_instance(mw_ctx)
assert await ctx.get_state("unity_instance") == "Proj@abc123"
@pytest.mark.asyncio
async def test_empty_string_unity_instance_falls_through_to_session(monkeypatch):
"""Empty string unity_instance falls through to session-persisted instance."""
mw = _make_middleware(monkeypatch)
ctx = DummyContext()
ctx.client_id = "client-1"
await mw.set_active_instance(ctx, "Proj@abc123")
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": " "})
await mw._inject_unity_instance(mw_ctx)
assert await ctx.get_state("unity_instance") == "Proj@abc123"
@pytest.mark.asyncio
async def test_resource_read_unaffected(monkeypatch):
"""on_read_resource with no .arguments attribute routes via session state normally."""
mw = _make_middleware(monkeypatch)
ctx = DummyContext()
ctx.client_id = "client-1"
await mw.set_active_instance(ctx, "Proj@abc123")
# ReadResourceRequestParams has .uri not .arguments
resource_ctx = SimpleNamespace(
fastmcp_context=ctx,
message=SimpleNamespace(uri="mcpforunity://scene/active"),
)
await mw._inject_unity_instance(resource_ctx)
assert await ctx.get_state("unity_instance") == "Proj@abc123"
# ---------------------------------------------------------------------------
# set_active_instance tool: port number support
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_set_active_instance_port_stdio(monkeypatch):
"""set_active_instance accepts a port number in stdio mode and resolves to Name@hash."""
monkeypatch.setattr(config, "transport_mode", "stdio")
monkeypatch.setattr(config, "http_remote_hosted", False)
from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
mw = UnityInstanceMiddleware()
set_unity_instance_middleware(mw)
pool_instance = SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401)
class FakePool:
def discover_all_instances(self, force_refresh=False):
return [pool_instance]
import services.tools.set_active_instance as sat
monkeypatch.setattr(sat, "get_unity_connection_pool", lambda: FakePool())
from services.tools.set_active_instance import set_active_instance
ctx = DummyContext()
ctx.client_id = "client-1"
result = await set_active_instance(ctx, instance="6401")
assert result["success"] is True
assert result["data"]["instance"] == "Proj@abc123"
assert await mw.get_active_instance(ctx) == "Proj@abc123"
@pytest.mark.asyncio
async def test_set_active_instance_port_http_errors(monkeypatch):
"""set_active_instance rejects port numbers in HTTP mode."""
monkeypatch.setattr(config, "transport_mode", "http")
monkeypatch.setattr(config, "http_remote_hosted", False)
from services.tools.set_active_instance import set_active_instance
ctx = DummyContext()
ctx.client_id = "client-1"
result = await set_active_instance(ctx, instance="6401")
assert result["success"] is False
assert "not supported in HTTP transport mode" in result["error"]
# ---------------------------------------------------------------------------
# batch_execute rejects inner unity_instance
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_batch_execute_rejects_inner_unity_instance():
"""batch_execute raises ValueError when an inner command contains unity_instance."""
from services.tools.batch_execute import batch_execute
ctx = DummyContext()
ctx.client_id = "client-1"
ctx._state["unity_instance"] = "Proj@abc123"
commands = [
{"tool": "manage_scene", "params": {"action": "get_active", "unity_instance": "6402"}},
]
with pytest.raises(ValueError, match="Per-command instance routing is not supported inside batch_execute"):
await batch_execute(ctx, commands=commands)
@@ -0,0 +1,139 @@
import pytest
import sys
import types
from types import SimpleNamespace
from .test_helpers import DummyContext
from core.config import config
class DummyMiddlewareContext:
def __init__(self, ctx):
self.fastmcp_context = ctx
@pytest.mark.asyncio
async def test_auto_selects_single_instance_via_pluginhub(monkeypatch):
plugin_hub = types.ModuleType("transport.plugin_hub")
class PluginHub:
@classmethod
def is_configured(cls) -> bool:
return True
@classmethod
async def get_sessions(cls):
raise AssertionError("get_sessions should be stubbed in test")
plugin_hub.PluginHub = PluginHub
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub)
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as ImportedPluginHub
assert ImportedPluginHub is plugin_hub.PluginHub
monkeypatch.setattr(config, "transport_mode", "http")
middleware = UnityInstanceMiddleware()
ctx = DummyContext()
ctx.client_id = "client-1"
middleware_context = DummyMiddlewareContext(ctx)
call_count = {"sessions": 0}
async def fake_get_sessions():
call_count["sessions"] += 1
return SimpleNamespace(
sessions={
"session-1": SimpleNamespace(project="Ramble", hash="deadbeef"),
}
)
monkeypatch.setattr(plugin_hub.PluginHub, "get_sessions", fake_get_sessions)
selected = await middleware._maybe_autoselect_instance(ctx)
assert selected == "Ramble@deadbeef"
assert await middleware.get_active_instance(ctx) == "Ramble@deadbeef"
assert call_count["sessions"] == 1
await middleware._inject_unity_instance(middleware_context)
assert await ctx.get_state("unity_instance") == "Ramble@deadbeef"
assert call_count["sessions"] == 1
@pytest.mark.asyncio
async def test_auto_selects_single_instance_via_stdio(monkeypatch):
plugin_hub = types.ModuleType("transport.plugin_hub")
class PluginHub:
@classmethod
def is_configured(cls) -> bool:
return False
plugin_hub.PluginHub = PluginHub
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub)
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as ImportedPluginHub
assert ImportedPluginHub is plugin_hub.PluginHub
monkeypatch.setattr(config, "transport_mode", "stdio")
middleware = UnityInstanceMiddleware()
ctx = DummyContext()
ctx.client_id = "client-1"
middleware_context = DummyMiddlewareContext(ctx)
class PoolStub:
def discover_all_instances(self, force_refresh=False):
assert force_refresh is True
return [SimpleNamespace(id="UnityMCPTests@cc8756d4")]
unity_connection = types.ModuleType("transport.legacy.unity_connection")
unity_connection.get_unity_connection_pool = lambda: PoolStub()
monkeypatch.setitem(sys.modules, "transport.legacy.unity_connection", unity_connection)
selected = await middleware._maybe_autoselect_instance(ctx)
assert selected == "UnityMCPTests@cc8756d4"
assert await middleware.get_active_instance(ctx) == "UnityMCPTests@cc8756d4"
await middleware._inject_unity_instance(middleware_context)
assert await ctx.get_state("unity_instance") == "UnityMCPTests@cc8756d4"
@pytest.mark.asyncio
async def test_auto_select_handles_stdio_errors(monkeypatch):
plugin_hub = types.ModuleType("transport.plugin_hub")
class PluginHub:
@classmethod
def is_configured(cls) -> bool:
return False
plugin_hub.PluginHub = PluginHub
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub)
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as ImportedPluginHub
assert ImportedPluginHub is plugin_hub.PluginHub
middleware = UnityInstanceMiddleware()
ctx = DummyContext()
ctx.client_id = "client-1"
class PoolStub:
def discover_all_instances(self, force_refresh=False):
raise ConnectionError("stdio unavailable")
unity_connection = types.ModuleType("transport.legacy.unity_connection")
unity_connection.get_unity_connection_pool = lambda: PoolStub()
monkeypatch.setitem(sys.modules, "transport.legacy.unity_connection", unity_connection)
selected = await middleware._maybe_autoselect_instance(ctx)
assert selected is None
assert await middleware.get_active_instance(ctx) is None
@@ -0,0 +1,446 @@
"""
Comprehensive test suite for Unity instance routing.
These tests validate that set_active_instance correctly routes subsequent
tool calls to the intended Unity instance across ALL tool categories.
DESIGN: Single source of truth via middleware state:
- set_active_instance tool stores instance per session in UnityInstanceMiddleware
- Middleware injects instance into ctx.set_state() for each tool call
- get_unity_instance_from_context() reads from ctx.get_state()
- All tools (GameObject, Script, Asset, etc.) use get_unity_instance_from_context()
"""
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock, patch
from fastmcp import Context
from core.config import config
from transport.unity_instance_middleware import UnityInstanceMiddleware
from services.tools import get_unity_instance_from_context
from services.tools.set_active_instance import set_active_instance as set_active_instance_tool
from transport.models import SessionList, SessionDetails
def _make_stateful_ctx(session_id: str) -> Mock:
"""Build a Context shim whose set/get/delete_state share one dict.
The middleware now defers persistence to FastMCP's session-scoped state
store, so a useful unit-test mock must actually round-trip values rather
than returning fresh Mocks. This helper does that minimally.
"""
state: dict[str, object] = {}
ctx = Mock(spec=Context)
ctx.session_id = session_id
ctx.set_state = AsyncMock(side_effect=lambda k, v: state.__setitem__(k, v))
ctx.get_state = AsyncMock(side_effect=lambda k: state.get(k))
ctx.delete_state = AsyncMock(side_effect=lambda k: state.pop(k, None))
return ctx
class TestInstanceRoutingBasics:
"""Test basic middleware functionality."""
@pytest.mark.asyncio
async def test_middleware_stores_and_retrieves_instance(self):
"""Middleware should store and retrieve instance per session."""
middleware = UnityInstanceMiddleware()
ctx = _make_stateful_ctx("test-session-1")
await middleware.set_active_instance(ctx, "TestProject@abc123")
assert await middleware.get_active_instance(ctx) == "TestProject@abc123"
@pytest.mark.asyncio
async def test_middleware_isolates_sessions(self):
"""Two MCP sessions must not see each other's active instance.
Regression test for #1023: the prior implementation keyed on the
peer-supplied client_id and collapsed multiple clients onto a shared
record. Each ctx in this test holds its own private state dict, so
leakage would surface as a cross-read.
"""
middleware = UnityInstanceMiddleware()
ctx1 = _make_stateful_ctx("session-1")
ctx2 = _make_stateful_ctx("session-2")
await middleware.set_active_instance(ctx1, "Project1@aaa")
await middleware.set_active_instance(ctx2, "Project2@bbb")
assert await middleware.get_active_instance(ctx1) == "Project1@aaa"
assert await middleware.get_active_instance(ctx2) == "Project2@bbb"
class TestInstanceRoutingIntegration:
"""Test that instance routing works end-to-end for all tool categories."""
@pytest.mark.asyncio
async def test_middleware_injects_state_into_context(self):
"""Middleware on_call_tool should inject instance into ctx state.
After this PR the middleware writes two distinct keys: a persistence
key (``mcpforunity.active_instance``) when ``set_active_instance`` is
called, and a per-request injection key (``unity_instance``) inside
``on_call_tool``. Tools downstream read ``unity_instance``.
"""
middleware = UnityInstanceMiddleware()
ctx = _make_stateful_ctx("test-session")
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
await middleware.set_active_instance(ctx, "TestProject@abc123")
async def mock_call_next(ctx):
return {"success": True}
await middleware.on_call_tool(middleware_ctx, mock_call_next)
# The per-request injection must have happened so tools downstream
# can read it; the persistence write is verified by the round-trip
# test in TestInstanceRoutingBasics above.
ctx.set_state.assert_any_call("unity_instance", "TestProject@abc123")
@pytest.mark.asyncio
async def test_get_unity_instance_from_context_checks_state(self):
"""get_unity_instance_from_context must read from ctx.get_state()."""
ctx = Mock(spec=Context)
# Set up state storage (only source of truth now)
state_storage = {"unity_instance": "Project@state123"}
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
# Call and verify
result = await get_unity_instance_from_context(ctx)
assert result == "Project@state123", \
"get_unity_instance_from_context must read from ctx.get_state()!"
@pytest.mark.asyncio
async def test_get_unity_instance_returns_none_when_not_set(self):
"""Should return None when no instance is set."""
ctx = Mock(spec=Context)
# Empty state storage
state_storage = {}
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
result = await get_unity_instance_from_context(ctx)
assert result is None
class TestInstanceRoutingToolCategories:
"""Test instance routing for each tool category."""
def _create_mock_context_with_instance(self, instance_id: str):
"""Helper to create a mock context with instance set via middleware."""
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
# Set up state storage (only source of truth)
state_storage = {"unity_instance": instance_id}
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
ctx.set_state = AsyncMock(side_effect=lambda k,
v: state_storage.__setitem__(k, v))
return ctx
class TestInstanceRoutingHTTP:
"""Validate HTTP-specific routing helpers."""
@pytest.mark.asyncio
async def test_set_active_instance_http_transport(self, monkeypatch):
"""set_active_instance should enumerate PluginHub sessions under HTTP."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "http-session"
state_storage = {}
ctx.set_state = AsyncMock(side_effect=lambda k,
v: state_storage.__setitem__(k, v))
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
monkeypatch.setattr(config, "transport_mode", "http")
fake_sessions = SessionList(
sessions={
"sess-1": SessionDetails(
project="Ramble",
hash="8e29de57",
unity_version="6000.2.10f1",
connected_at="2025-11-21T03:30:03.682353+00:00",
)
}
)
monkeypatch.setattr(
"services.tools.set_active_instance.PluginHub.get_sessions",
AsyncMock(return_value=fake_sessions),
)
monkeypatch.setattr(
"services.tools.set_active_instance.get_unity_instance_middleware",
lambda: middleware,
)
result = await set_active_instance_tool(ctx, "Ramble@8e29de57")
assert result["success"] is True
assert await middleware.get_active_instance(ctx) == "Ramble@8e29de57"
@pytest.mark.asyncio
async def test_set_active_instance_http_hash_only(self, monkeypatch):
"""Hash-only selection should resolve via PluginHub registry."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "http-session-2"
state_storage = {}
ctx.set_state = AsyncMock(side_effect=lambda k,
v: state_storage.__setitem__(k, v))
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
monkeypatch.setattr(config, "transport_mode", "http")
fake_sessions = SessionList(
sessions={
"sess-99": SessionDetails(
project="UnityMCPTests",
hash="cc8756d4",
unity_version="2021.3.45f2",
connected_at="2025-11-21T03:37:01.501022+00:00",
)
}
)
monkeypatch.setattr(
"services.tools.set_active_instance.PluginHub.get_sessions",
AsyncMock(return_value=fake_sessions),
)
monkeypatch.setattr(
"services.tools.set_active_instance.get_unity_instance_middleware",
lambda: middleware,
)
result = await set_active_instance_tool(ctx, "UnityMCPTests@cc8756d4")
assert result["success"] is True
assert await middleware.get_active_instance(ctx) == "UnityMCPTests@cc8756d4"
@pytest.mark.asyncio
async def test_set_active_instance_http_hash_missing(self, monkeypatch):
"""Unknown hashes should surface a clear error."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "http-session-3"
monkeypatch.setattr(config, "transport_mode", "http")
fake_sessions = SessionList(sessions={})
monkeypatch.setattr(
"services.tools.set_active_instance.PluginHub.get_sessions",
AsyncMock(return_value=fake_sessions),
)
monkeypatch.setattr(
"services.tools.set_active_instance.get_unity_instance_middleware",
lambda: middleware,
)
result = await set_active_instance_tool(ctx, "Unknown@deadbeef")
assert result["success"] is False
assert "No Unity instances" in result["error"]
@pytest.mark.asyncio
async def test_set_active_instance_http_hash_ambiguous(self, monkeypatch):
"""Ambiguous hash prefixes should mirror stdio error messaging."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "http-session-4"
monkeypatch.setattr(config, "transport_mode", "http")
fake_sessions = SessionList(
sessions={
"sess-a": SessionDetails(project="ProjA", hash="abc12345", unity_version="2022", connected_at="now"),
"sess-b": SessionDetails(project="ProjB", hash="abc98765", unity_version="2022", connected_at="now"),
}
)
monkeypatch.setattr(
"services.tools.set_active_instance.PluginHub.get_sessions",
AsyncMock(return_value=fake_sessions),
)
monkeypatch.setattr(
"services.tools.set_active_instance.get_unity_instance_middleware",
lambda: middleware,
)
result = await set_active_instance_tool(ctx, "abc")
assert result["success"] is False
assert "Name@hash" in result["error"]
class TestInstanceRoutingRaceConditions:
"""Test for race conditions and timing issues."""
@pytest.mark.asyncio
async def test_rapid_instance_switching(self):
"""Rapidly switching instances should not cause routing errors."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
state_storage = {}
ctx.set_state = AsyncMock(side_effect=lambda k,
v: state_storage.__setitem__(k, v))
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
instances = ["Project1@aaa", "Project2@bbb", "Project3@ccc"]
for instance in instances:
await middleware.set_active_instance(ctx, instance)
# Create middleware context
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_call_next(ctx):
return {"success": True}
# Execute middleware
await middleware.on_call_tool(middleware_ctx, mock_call_next)
# Verify correct instance is set
assert state_storage.get("unity_instance") == instance
@pytest.mark.asyncio
async def test_set_then_immediate_create_script(self):
"""Setting instance then immediately creating script should route correctly."""
# This reproduces the bug: set_active_instance → create_script went to wrong instance
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
ctx.info = Mock()
state_storage = {}
ctx.set_state = AsyncMock(side_effect=lambda k,
v: state_storage.__setitem__(k, v))
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
ctx.request_context = None
# Set active instance
await middleware.set_active_instance(ctx, "ramble@8e29de57")
# Simulate middleware intercepting create_script call
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_create_script_call(ctx):
# This simulates what create_script does
instance = await get_unity_instance_from_context(ctx)
return {"success": True, "routed_to": instance}
# Inject state via middleware
await middleware.on_call_tool(middleware_ctx, mock_create_script_call)
# Verify create_script would route to correct instance
result = await mock_create_script_call(ctx)
assert result["routed_to"] == "ramble@8e29de57", \
"create_script must route to the instance set by set_active_instance"
class TestInstanceRoutingSequentialOperations:
"""Test the exact failure scenario from user report."""
@pytest.mark.asyncio
async def test_four_script_creation_sequence(self):
"""
Reproduce the exact failure:
1. set_active(ramble) → create_script1 → should go to ramble
2. set_active(UnityMCPTests) → create_script2 → should go to UnityMCPTests
3. set_active(ramble) → create_script3 → should go to ramble
4. set_active(UnityMCPTests) → create_script4 → should go to UnityMCPTests
ACTUAL BEHAVIOR:
- Script1 went to UnityMCPTests (WRONG)
- Script2 went to ramble (WRONG)
- Script3 went to ramble (CORRECT)
- Script4 went to UnityMCPTests (CORRECT)
"""
middleware = UnityInstanceMiddleware()
# Track which instance each script was created in
script_routes = {}
async def simulate_create_script(ctx, script_name, expected_instance):
# Inject state via middleware
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_tool_call(middleware_ctx):
# The middleware passes the middleware_ctx, we need the fastmcp_context
tool_ctx = middleware_ctx.fastmcp_context
instance = await get_unity_instance_from_context(tool_ctx)
script_routes[script_name] = instance
return {"success": True}
await middleware.on_call_tool(middleware_ctx, mock_tool_call)
return expected_instance
# Session context
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
ctx.info = Mock()
state_storage = {}
ctx.set_state = AsyncMock(side_effect=lambda k,
v: state_storage.__setitem__(k, v))
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
# Execute sequence
await middleware.set_active_instance(ctx, "ramble@8e29de57")
expected1 = await simulate_create_script(ctx, "Script1", "ramble@8e29de57")
await middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
expected2 = await simulate_create_script(ctx, "Script2", "UnityMCPTests@cc8756d4")
await middleware.set_active_instance(ctx, "ramble@8e29de57")
expected3 = await simulate_create_script(ctx, "Script3", "ramble@8e29de57")
await middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
expected4 = await simulate_create_script(ctx, "Script4", "UnityMCPTests@cc8756d4")
# Assertions - these will FAIL until the bug is fixed
assert script_routes.get("Script1") == expected1, \
f"Script1 should route to {expected1}, got {script_routes.get('Script1')}"
assert script_routes.get("Script2") == expected2, \
f"Script2 should route to {expected2}, got {script_routes.get('Script2')}"
assert script_routes.get("Script3") == expected3, \
f"Script3 should route to {expected3}, got {script_routes.get('Script3')}"
assert script_routes.get("Script4") == expected4, \
f"Script4 should route to {expected4}, got {script_routes.get('Script4')}"
# Test regimen summary
"""
COMPREHENSIVE TEST REGIMEN FOR INSTANCE ROUTING
Prerequisites:
- Two Unity instances running (e.g., ramble, UnityMCPTests)
- MCP server connected to both instances
Test Categories:
1. ✅ Middleware State Management (4 tests)
2. ✅ Middleware Integration (2 tests)
3. ✅ get_unity_instance_from_context (2 tests)
4. ✅ Tool Category Coverage (11 categories)
5. ✅ Race Conditions (2 tests)
6. ✅ Sequential Operations (1 test - reproduces exact user bug)
Total: 21 tests
DESIGN:
Single source of truth via middleware state:
- set_active_instance stores instance per session in UnityInstanceMiddleware
- Middleware injects instance into ctx.set_state() for each tool call
- get_unity_instance_from_context() reads from ctx.get_state()
- All tools use get_unity_instance_from_context()
This ensures consistent routing across ALL tool categories (Script, GameObject, Asset, etc.)
"""
@@ -0,0 +1,88 @@
import pytest
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_manage_gameobject_uses_session_state(monkeypatch):
"""Test that tools use session-stored active instance via middleware"""
from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
# Arrange: Initialize middleware and set a session-scoped active instance
middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(middleware)
ctx = DummyContext()
await middleware.set_active_instance(ctx, "SessionProj@AAAA1111")
assert await middleware.get_active_instance(ctx) == "SessionProj@AAAA1111"
# Simulate middleware injection into request state
await ctx.set_state("unity_instance", "SessionProj@AAAA1111")
captured = {}
# Monkeypatch transport to capture the resolved instance_id
async def fake_send(command_type, params, **kwargs):
captured["command_type"] = command_type
captured["params"] = params
captured["instance_id"] = kwargs.get("instance_id")
return {"success": True, "data": {}}
import services.tools.manage_gameobject as mg
monkeypatch.setattr(
"services.tools.manage_gameobject.async_send_command_with_retry",
fake_send,
)
# Act: call tool - should use session state from context
res = await mg.manage_gameobject(
ctx,
action="create",
name="SessionSphere",
primitive_type="Sphere",
)
# Assert: uses session-stored instance
assert res.get("success") is True
assert captured.get("command_type") == "manage_gameobject"
assert captured.get("instance_id") == "SessionProj@AAAA1111"
@pytest.mark.asyncio
async def test_manage_gameobject_without_active_instance(monkeypatch):
"""Test that tools work with no active instance set (uses None/default)"""
from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
# Arrange: Initialize middleware with no active instance set
middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(middleware)
ctx = DummyContext()
assert await middleware.get_active_instance(ctx) is None
# Don't set any state in context
captured = {}
async def fake_send(command_type, params, **kwargs):
captured["instance_id"] = kwargs.get("instance_id")
return {"success": True, "data": {}}
import services.tools.manage_gameobject as mg
monkeypatch.setattr(
"services.tools.manage_gameobject.async_send_command_with_retry",
fake_send,
)
# Act: call without active instance
res = await mg.manage_gameobject(
ctx,
action="create",
name="DefaultSphere",
primitive_type="Sphere",
)
# Assert: uses None (connection pool will pick default)
assert res.get("success") is True
assert captured.get("instance_id") is None
@@ -0,0 +1,113 @@
"""
Simple tests for JSON string parameter parsing logic.
Tests the core JSON parsing functionality without MCP server dependencies.
"""
import json
import pytest
def parse_properties_json(properties):
"""
Test the JSON parsing logic that would be used in manage_asset.
This simulates the core parsing functionality.
"""
if isinstance(properties, str):
try:
parsed = json.loads(properties)
return parsed, "success"
except json.JSONDecodeError as e:
return properties, f"failed to parse: {e}"
return properties, "no_parsing_needed"
class TestJsonParsingLogic:
"""Test the core JSON parsing logic."""
def test_valid_json_string_parsing(self):
"""Test that valid JSON strings are correctly parsed."""
json_string = '{"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}'
result, status = parse_properties_json(json_string)
assert status == "success"
assert isinstance(result, dict)
assert result["shader"] == "Universal Render Pipeline/Lit"
assert result["color"] == [0, 0, 1, 1]
def test_invalid_json_string_handling(self):
"""Test that invalid JSON strings are handled gracefully."""
invalid_json = '{"invalid": json, "missing": quotes}'
result, status = parse_properties_json(invalid_json)
assert "failed to parse" in status
assert result == invalid_json # Original string returned
def test_dict_input_unchanged(self):
"""Test that dict inputs are passed through unchanged."""
original_dict = {
"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}
result, status = parse_properties_json(original_dict)
assert status == "no_parsing_needed"
assert result == original_dict
def test_none_input_handled(self):
"""Test that None input is handled correctly."""
result, status = parse_properties_json(None)
assert status == "no_parsing_needed"
assert result is None
def test_complex_json_parsing(self):
"""Test parsing of complex JSON with nested objects and arrays."""
complex_json = '''
{
"shader": "Universal Render Pipeline/Lit",
"color": [1, 0, 0, 1],
"float": {"name": "_Metallic", "value": 0.5},
"texture": {"name": "_MainTex", "path": "Assets/Textures/Test.png"}
}
'''
result, status = parse_properties_json(complex_json)
assert status == "success"
assert isinstance(result, dict)
assert result["shader"] == "Universal Render Pipeline/Lit"
assert result["color"] == [1, 0, 0, 1]
assert result["float"]["name"] == "_Metallic"
assert result["float"]["value"] == 0.5
assert result["texture"]["name"] == "_MainTex"
assert result["texture"]["path"] == "Assets/Textures/Test.png"
def test_empty_json_string(self):
"""Test handling of empty JSON string."""
empty_json = "{}"
result, status = parse_properties_json(empty_json)
assert status == "success"
assert isinstance(result, dict)
assert len(result) == 0
def test_malformed_json_edge_cases(self):
"""Test various malformed JSON edge cases."""
test_cases = [
'{"incomplete": }',
'{"missing": "quote}',
'{"trailing": "comma",}',
'{"unclosed": [1, 2, 3}',
'not json at all',
'{"nested": {"broken": }'
]
for malformed_json in test_cases:
result, status = parse_properties_json(malformed_json)
assert "failed to parse" in status
assert result == malformed_json
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,69 @@
import ast
from pathlib import Path
import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server
candidates = [
ROOT / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"MCP for Unity server source not found. Tried:\n" + searched,
allow_module_level=True,
)
def test_no_print_statements_in_codebase():
"""Ensure no stray print/sys.stdout writes remain in server source."""
# CLI tools that intentionally print to stdout
ALLOWED_PRINT_FILES = {
Path("scene_generator") / "test_pipeline.py",
}
offenders = []
syntax_errors = []
for py_file in SRC.rglob("*.py"):
# Skip virtual envs and third-party packages if they exist under SRC
parts = set(py_file.parts)
if ".venv" in parts or "site-packages" in parts:
continue
try:
text = py_file.read_text(encoding="utf-8", errors="strict")
except UnicodeDecodeError:
# Be tolerant of encoding edge cases in source tree without silently dropping bytes
text = py_file.read_text(encoding="utf-8", errors="replace")
try:
tree = ast.parse(text, filename=str(py_file))
except SyntaxError:
syntax_errors.append(py_file.relative_to(SRC))
continue
class StdoutVisitor(ast.NodeVisitor):
def __init__(self):
self.hit = False
def visit_Call(self, node: ast.Call):
# print(...)
if isinstance(node.func, ast.Name) and node.func.id == "print":
self.hit = True
# sys.stdout.write(...)
if isinstance(node.func, ast.Attribute) and node.func.attr == "write":
val = node.func.value
if isinstance(val, ast.Attribute) and val.attr == "stdout":
if isinstance(val.value, ast.Name) and val.value.id == "sys":
self.hit = True
self.generic_visit(node)
v = StdoutVisitor()
v.visit(tree)
rel_path = py_file.relative_to(SRC)
if v.hit and rel_path not in ALLOWED_PRINT_FILES:
offenders.append(rel_path)
assert not syntax_errors, "syntax errors in: " + \
", ".join(str(e) for e in syntax_errors)
assert not offenders, "stdout writes found in: " + \
", ".join(str(o) for o in offenders)
@@ -0,0 +1,164 @@
"""
Tests for JSON string parameter parsing in manage_asset tool.
"""
import pytest
import json
from .test_helpers import DummyContext
from services.tools.manage_asset import manage_asset
class TestManageAssetJsonParsing:
"""Test JSON string parameter parsing functionality."""
@pytest.mark.asyncio
async def test_properties_json_string_parsing(self, monkeypatch):
"""Test that JSON string properties are correctly parsed to dict."""
# Mock context
ctx = DummyContext()
# Patch Unity transport
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully", "data": {"path": "Assets/Test.mat"}}
monkeypatch.setattr(
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with JSON string properties
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties='{"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}'
)
# Verify the result - JSON string was successfully parsed and passed to Unity
assert result["success"] is True
assert "Asset created successfully" in result["message"]
@pytest.mark.asyncio
async def test_properties_invalid_json_string(self, monkeypatch):
"""Test handling of invalid JSON string properties."""
ctx = DummyContext()
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr(
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with invalid JSON string
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties='{"invalid": json, "missing": quotes}'
)
# Verify behavior: parsing fails with a clear error
assert result.get("success") is False
assert "properties must be" in result.get("message", "")
@pytest.mark.asyncio
async def test_properties_dict_unchanged(self, monkeypatch):
"""Test that dict properties are passed through unchanged."""
ctx = DummyContext()
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr(
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with dict properties
properties_dict = {
"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties=properties_dict
)
# Verify no JSON parsing was attempted (allow initial Processing log)
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result["success"] is True
@pytest.mark.asyncio
async def test_properties_none_handling(self, monkeypatch):
"""Test that None properties are handled correctly."""
ctx = DummyContext()
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr(
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with None properties
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties=None
)
# Verify no JSON parsing was attempted (allow initial Processing log)
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result["success"] is True
class TestManageGameObjectJsonParsing:
"""Test JSON string parameter parsing for manage_gameobject tool."""
@pytest.mark.asyncio
async def test_component_properties_json_string_parsing(self, monkeypatch):
"""Test that JSON string component_properties result in successful operation."""
from services.tools.manage_gameobject import manage_gameobject
ctx = DummyContext()
async def fake_send(_cmd, params, **_kwargs):
return {"success": True, "message": "GameObject created successfully"}
monkeypatch.setattr(
"services.tools.manage_gameobject.async_send_command_with_retry",
fake_send,
)
# Test with JSON string component_properties
result = await manage_gameobject(
ctx=ctx,
action="create",
name="TestObject",
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
)
# Verify the result
assert result["success"] is True
@pytest.mark.asyncio
async def test_component_properties_parsing_verification(self, monkeypatch):
"""Test that component_properties are actually parsed to dict before sending."""
from services.tools.manage_gameobject import manage_gameobject
ctx = DummyContext()
captured_params = {}
async def fake_send(_cmd, params, **_kwargs):
captured_params.update(params)
return {"success": True, "message": "GameObject created successfully"}
monkeypatch.setattr(
"services.tools.manage_gameobject.async_send_command_with_retry",
fake_send,
)
await manage_gameobject(
ctx=ctx,
action="create",
name="TestObject",
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
)
assert isinstance(captured_params.get("componentProperties"), dict)
@@ -0,0 +1,29 @@
import asyncio
from .test_helpers import DummyContext
import services.tools.manage_asset as manage_asset_mod
def test_manage_asset_pagination_coercion(monkeypatch):
captured = {}
async def fake_async_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_asset_mod, "async_send_command_with_retry", fake_async_send)
result = asyncio.run(
manage_asset_mod.manage_asset(
ctx=DummyContext(),
action="search",
path="Assets",
page_size="50",
page_number="2",
)
)
assert result == {"success": True, "data": {}}
assert captured["params"]["pageSize"] == 50
assert captured["params"]["pageNumber"] == 2
@@ -0,0 +1,242 @@
"""
Tests for the manage_components tool.
This tool handles component lifecycle operations (add, remove, set_property).
"""
import pytest
from .test_helpers import DummyContext
import services.tools.manage_components as manage_comp_mod
@pytest.mark.asyncio
async def test_manage_components_add_single(monkeypatch):
"""Test adding a single component."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"addedComponents": [{"typeName": "UnityEngine.Rigidbody", "instanceID": 12345}]
},
}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target="Player",
component_type="Rigidbody",
)
assert resp.get("success") is True
assert captured["cmd"] == "manage_components"
assert captured["params"]["action"] == "add"
assert captured["params"]["target"] == "Player"
assert captured["params"]["componentType"] == "Rigidbody"
@pytest.mark.asyncio
async def test_manage_components_remove(monkeypatch):
"""Test removing a component."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345, "name": "Player"}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="remove",
target="Player",
component_type="Rigidbody",
)
assert resp.get("success") is True
assert captured["params"]["action"] == "remove"
assert captured["params"]["componentType"] == "Rigidbody"
@pytest.mark.asyncio
async def test_manage_components_set_property_single(monkeypatch):
"""Test setting a single component property."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="set_property",
target="Player",
component_type="Rigidbody",
property="mass",
value=5.0,
)
assert resp.get("success") is True
assert captured["params"]["action"] == "set_property"
assert captured["params"]["property"] == "mass"
assert captured["params"]["value"] == 5.0
@pytest.mark.asyncio
async def test_manage_components_set_property_multiple(monkeypatch):
"""Test setting multiple component properties via properties dict."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="set_property",
target="Player",
component_type="Rigidbody",
properties={"mass": 5.0, "drag": 0.5},
)
assert resp.get("success") is True
assert captured["params"]["action"] == "set_property"
assert captured["params"]["properties"] == {"mass": 5.0, "drag": 0.5}
@pytest.mark.asyncio
async def test_manage_components_set_property_json_string(monkeypatch):
"""Test setting component properties with JSON string input."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="set_property",
target="Player",
component_type="Rigidbody",
properties='{"mass": 10.0}', # JSON string
)
assert resp.get("success") is True
assert captured["params"]["properties"] == {"mass": 10.0}
@pytest.mark.asyncio
async def test_manage_components_add_with_properties(monkeypatch):
"""Test adding a component with initial properties."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {"addedComponents": [{"typeName": "Rigidbody", "instanceID": 123}]},
}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target="Player",
component_type="Rigidbody",
properties={"mass": 2.0, "useGravity": False},
)
assert resp.get("success") is True
assert captured["params"]["properties"] == {"mass": 2.0, "useGravity": False}
@pytest.mark.asyncio
async def test_manage_components_search_method_passthrough(monkeypatch):
"""Test that search_method is correctly passed through."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target="Canvas/Panel",
component_type="Image",
search_method="by_path",
)
assert resp.get("success") is True
assert captured["params"]["searchMethod"] == "by_path"
@pytest.mark.asyncio
async def test_manage_components_target_by_id(monkeypatch):
"""Test targeting by instance ID."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target=12345, # Integer instance ID
component_type="BoxCollider",
search_method="by_id",
)
assert resp.get("success") is True
assert captured["params"]["target"] == 12345
assert captured["params"]["searchMethod"] == "by_id"
@@ -0,0 +1,135 @@
import pytest
from .test_helpers import DummyContext
import services.tools.manage_gameobject as manage_go_mod
@pytest.mark.asyncio
async def test_manage_gameobject_is_static_true(monkeypatch):
"""Test that is_static=True is passed as isStatic in params."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="modify",
target="Ground",
is_static=True,
)
assert resp.get("success") is True
assert captured["params"]["action"] == "modify"
assert captured["params"]["target"] == "Ground"
assert captured["params"]["isStatic"] is True
@pytest.mark.asyncio
async def test_manage_gameobject_is_static_false(monkeypatch):
"""Test that is_static=False is passed as isStatic in params."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="modify",
target="Ground",
is_static=False,
)
assert resp.get("success") is True
assert captured["params"]["isStatic"] is False
@pytest.mark.asyncio
async def test_manage_gameobject_is_static_string_coercion(monkeypatch):
"""Test that string 'true' is coerced to bool for is_static."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="modify",
target="Ground",
is_static="true",
)
assert resp.get("success") is True
assert captured["params"]["isStatic"] is True
@pytest.mark.asyncio
async def test_manage_gameobject_is_static_string_false_coercion(monkeypatch):
"""Test that string 'false' is coerced to bool for is_static."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="modify",
target="Ground",
is_static="false",
)
assert resp.get("success") is True
assert captured["params"]["isStatic"] is False
@pytest.mark.asyncio
async def test_manage_gameobject_is_static_omitted(monkeypatch):
"""Test that omitting is_static does not include isStatic in params."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="modify",
target="Ground",
)
assert resp.get("success") is True
assert "isStatic" not in captured["params"]
@@ -0,0 +1,77 @@
import pytest
from .test_helpers import DummyContext
import services.tools.manage_gameobject as manage_go_mod
@pytest.mark.asyncio
async def test_look_at_vector_target(monkeypatch):
"""look_at action forwards look_at_target as a vector."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"rotation": [0, 90, 0]}}
monkeypatch.setattr(manage_go_mod, "async_send_command_with_retry", fake_send)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="look_at",
target="MainCamera",
look_at_target=[10.0, 0.0, 5.0],
)
assert resp.get("success") is True
p = captured["params"]
assert p["action"] == "look_at"
assert p["target"] == "MainCamera"
assert p["look_at_target"] == [10.0, 0.0, 5.0]
@pytest.mark.asyncio
async def test_look_at_string_target(monkeypatch):
"""look_at action forwards look_at_target as a GO name string."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"rotation": [0, 45, 0]}}
monkeypatch.setattr(manage_go_mod, "async_send_command_with_retry", fake_send)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="look_at",
target="MainCamera",
look_at_target="Player",
look_at_up=[0, 1, 0],
)
assert resp.get("success") is True
p = captured["params"]
assert p["action"] == "look_at"
assert p["look_at_target"] == "Player"
assert p["look_at_up"] == [0, 1, 0]
@pytest.mark.asyncio
async def test_look_at_without_target_still_sends(monkeypatch):
"""look_at without look_at_target should still send the command (C# will error)."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": False, "message": "look_at_target is required"}
monkeypatch.setattr(manage_go_mod, "async_send_command_with_retry", fake_send)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="look_at",
target="MainCamera",
)
p = captured["params"]
assert p["action"] == "look_at"
assert "look_at_target" not in p
@@ -0,0 +1,65 @@
import pytest
from .test_helpers import DummyContext
import services.tools.manage_gameobject as manage_go_mod
@pytest.mark.asyncio
async def test_manage_gameobject_boolean_coercion(monkeypatch):
"""Test that string boolean values are properly coerced for valid actions."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_go_mod,
"async_send_command_with_retry",
fake_send,
)
# Test boolean coercion with "modify" action (valid action)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="modify",
target="Player",
set_active="true", # String should be coerced to bool
)
assert resp.get("success") is True
assert captured["params"]["action"] == "modify"
assert captured["params"]["target"] == "Player"
# setActive string "true" is coerced to bool True
assert captured["params"]["setActive"] is True
@pytest.mark.asyncio
async def test_manage_gameobject_create_with_tag(monkeypatch):
"""Test that create action properly passes tag parameter."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="create",
name="TestObject",
tag="Player",
position=[1.0, 2.0, 3.0],
)
assert resp.get("success") is True
p = captured["params"]
assert p["action"] == "create"
assert p["name"] == "TestObject"
assert p["tag"] == "Player"
assert p["position"] == [1.0, 2.0, 3.0]
@@ -0,0 +1,44 @@
import pytest
from .test_helpers import DummyContext
import services.tools.manage_scene as manage_scene_mod
@pytest.mark.asyncio
async def test_manage_scene_get_hierarchy_paging_params_pass_through(monkeypatch):
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_scene_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_scene_mod.manage_scene(
ctx=DummyContext(),
action="get_hierarchy",
parent="Player",
page_size="10",
cursor="20",
max_nodes="1000",
max_depth="6",
max_children_per_node="200",
include_transform="true",
)
assert resp.get("success") is True
p = captured["params"]
assert p["action"] == "get_hierarchy"
assert p["parent"] == "Player"
assert p["pageSize"] in (10, "10")
assert p["cursor"] in (20, "20")
assert p["maxNodes"] in (1000, "1000")
assert p["maxDepth"] in (6, "6")
assert p["maxChildrenPerNode"] in (200, "200")
assert p["includeTransform"] in (True, "true")
@@ -0,0 +1,99 @@
import pytest
from .test_helpers import DummyContext, setup_script_tools
@pytest.mark.asyncio
async def test_split_uri_unity_path(monkeypatch):
test_tools = setup_script_tools()
captured = {}
async def fake_send(cmd, params, **kwargs): # capture params and return success
captured['cmd'] = cmd
captured['params'] = params
return {"success": True, "message": "ok"}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = test_tools['apply_text_edits']
uri = "mcpforunity://path/Assets/Scripts/MyScript.cs"
await fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
assert captured['cmd'] == 'manage_script'
assert captured['params']['name'] == 'MyScript'
assert captured['params']['path'] == 'Assets/Scripts'
@pytest.mark.asyncio
@pytest.mark.parametrize(
"uri, expected_name, expected_path",
[
("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs",
"Foo Bar", "Assets/Scripts"),
("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"),
("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs",
"Hello", "Assets/Scripts"),
# outside Assets → fall back to normalized dir
("file:///tmp/Other.cs", "Other", "tmp"),
],
)
async def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path):
test_tools = setup_script_tools()
captured = {}
async def fake_send(_cmd, params, **kwargs):
captured['cmd'] = _cmd
captured['params'] = params
return {"success": True, "message": "ok"}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = test_tools['apply_text_edits']
await fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
assert captured['params']['name'] == expected_name
assert captured['params']['path'] == expected_path
@pytest.mark.asyncio
async def test_split_uri_plain_path(monkeypatch):
test_tools = setup_script_tools()
captured = {}
async def fake_send(_cmd, params, **kwargs):
captured['params'] = params
return {"success": True, "message": "ok"}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = test_tools['apply_text_edits']
await fn(
DummyContext(),
uri="Assets/Scripts/Thing.cs",
edits=[],
precondition_sha256=None,
)
assert captured['params']['name'] == 'Thing'
assert captured['params']['path'] == 'Assets/Scripts'
@@ -0,0 +1,134 @@
import pytest
from .test_helpers import DummyContext
import services.tools.manage_scriptable_object as mod
@pytest.mark.asyncio
async def test_manage_scriptable_object_forwards_create_params(monkeypatch):
captured = {}
async def fake_async_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"ok": True}}
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
ctx = DummyContext()
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
result = await (
mod.manage_scriptable_object(
ctx=ctx,
action="create",
type_name="My.Namespace.TestDefinition",
folder_path="Assets/Temp/Foo",
asset_name="Bar",
overwrite="true",
patches='[{"propertyPath":"displayName","op":"set","value":"Hello"}]',
)
)
assert result["success"] is True
assert captured["cmd"] == "manage_scriptable_object"
assert captured["params"]["action"] == "create"
assert captured["params"]["typeName"] == "My.Namespace.TestDefinition"
assert captured["params"]["folderPath"] == "Assets/Temp/Foo"
assert captured["params"]["assetName"] == "Bar"
assert captured["params"]["overwrite"] is True
assert isinstance(captured["params"]["patches"], list)
assert captured["params"]["patches"][0]["propertyPath"] == "displayName"
@pytest.mark.asyncio
async def test_manage_scriptable_object_forwards_modify_params(monkeypatch):
captured = {}
async def fake_async_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"ok": True}}
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
ctx = DummyContext()
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
result = await (
mod.manage_scriptable_object(
ctx=ctx,
action="modify",
target='{"guid":"abc"}',
patches=[{"propertyPath": "materials.Array.size", "op": "array_resize", "value": 2}],
)
)
assert result["success"] is True
assert captured["cmd"] == "manage_scriptable_object"
assert captured["params"]["action"] == "modify"
assert captured["params"]["target"] == {"guid": "abc"}
assert captured["params"]["patches"][0]["op"] == "array_resize"
@pytest.mark.asyncio
async def test_manage_scriptable_object_forwards_dry_run_param(monkeypatch):
captured = {}
async def fake_async_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"dryRun": True, "validationResults": []}}
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
ctx = DummyContext()
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
result = await (
mod.manage_scriptable_object(
ctx=ctx,
action="modify",
target='{"guid":"abc123"}',
patches=[{"propertyPath": "intValue", "op": "set", "value": 42}],
dry_run=True,
)
)
assert result["success"] is True
assert captured["cmd"] == "manage_scriptable_object"
assert captured["params"]["action"] == "modify"
assert captured["params"]["dryRun"] is True
assert captured["params"]["target"] == {"guid": "abc123"}
@pytest.mark.asyncio
async def test_manage_scriptable_object_dry_run_string_coercion(monkeypatch):
"""Test that dry_run accepts string 'true' and coerces to boolean."""
captured = {}
async def fake_async_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"dryRun": True}}
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
ctx = DummyContext()
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
result = await (
mod.manage_scriptable_object(
ctx=ctx,
action="modify",
target={"guid": "xyz"},
patches=[],
dry_run="true", # String instead of bool
)
)
assert result["success"] is True
assert captured["params"]["dryRun"] is True
@@ -0,0 +1,277 @@
"""Integration tests for manage_texture tool."""
import pytest
import asyncio
from .test_helpers import DummyContext
import services.tools.manage_texture as manage_texture_mod
def run_async(coro):
"""Simple wrapper to run a coroutine synchronously."""
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
finally:
loop.close()
asyncio.set_event_loop(None)
async def noop_preflight(*args, **kwargs):
return None
class TestManageTextureIntegration:
"""Integration tests for texture management tool logic."""
def test_create_texture_with_color_array(self, monkeypatch):
"""Test creating a texture with RGB color array (0-255)."""
captured = {}
async def fake_send(func, instance, cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "message": "Created texture"}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="create",
path="Assets/TestTextures/Red.png",
width=64,
height=64,
fill_color=[255, 0, 0, 255]
))
assert resp["success"] is True
assert captured["params"]["fillColor"] == [255, 0, 0, 255]
def test_create_texture_with_normalized_color(self, monkeypatch):
"""Test creating a texture with normalized color (0.0-1.0)."""
captured = {}
async def fake_send(func, instance, cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Created texture"}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="create",
path="Assets/TestTextures/Blue.png",
fill_color=[0.0, 0.0, 1.0, 1.0]
))
assert resp["success"] is True
# Should be normalized to 0-255
assert captured["params"]["fillColor"] == [0, 0, 255, 255]
def test_create_sprite_with_pattern(self, monkeypatch):
"""Test creating a sprite with checkerboard pattern."""
captured = {}
async def fake_send(func, instance, cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Created sprite", "data": {"asSprite": True}}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="create_sprite",
path="Assets/TestTextures/Checkerboard.png",
pattern="checkerboard",
as_sprite={
"pixelsPerUnit": 100.0,
"pivot": [0.5, 0.5]
}
))
assert resp["success"] is True
assert captured["params"]["action"] == "create_sprite"
assert captured["params"]["pattern"] == "checkerboard"
assert captured["params"]["spriteSettings"]["pixelsPerUnit"] == 100.0
def test_create_texture_with_import_settings(self, monkeypatch):
"""Test creating a texture with import settings (conversion of snake_case to camelCase)."""
captured = {}
async def fake_send(func, instance, cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Created texture"}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="create",
path="Assets/TestTextures/SpriteTexture.png",
import_settings={
"texture_type": "sprite",
"sprite_pixels_per_unit": 100,
"filter_mode": "point",
"wrap_mode": "clamp"
}
))
assert resp["success"] is True
settings = captured["params"]["importSettings"]
assert settings["textureType"] == "Sprite"
assert settings["spritePixelsPerUnit"] == 100
assert settings["filterMode"] == "Point"
assert settings["wrapMode"] == "Clamp"
def test_texture_modify_params(self, monkeypatch):
"""Test texture modify parameter conversion."""
captured = {}
async def fake_send(func, instance, cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Modified texture"}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="modify",
path="Assets/Textures/Test.png",
set_pixels={
"x": 0,
"y": 0,
"width": 10,
"height": 10,
"color": [255, 0, 0, 255]
}
))
assert resp["success"] is True
assert captured["params"]["setPixels"]["color"] == [255, 0, 0, 255]
def test_texture_modify_pixels_array(self, monkeypatch):
"""Test texture modify pixel array normalization."""
captured = {}
async def fake_send(func, instance, cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Modified texture"}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="modify",
path="Assets/Textures/Test.png",
set_pixels={
"x": 0,
"y": 0,
"width": 2,
"height": 2,
"pixels": [
[1.0, 0.0, 0.0, 1.0],
[0.0, 1.0, 0.0, 1.0],
[0.0, 0.0, 1.0, 1.0],
[0.5, 0.5, 0.5, 1.0],
]
}
))
assert resp["success"] is True
assert captured["params"]["setPixels"]["pixels"] == [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255],
[128, 128, 128, 255],
]
def test_texture_modify_pixels_invalid_length(self, monkeypatch):
"""Test error handling for invalid pixel array length."""
async def fake_send(*args, **kwargs):
return {"success": True}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="modify",
path="Assets/Textures/Test.png",
set_pixels={
"x": 0,
"y": 0,
"width": 2,
"height": 2,
"pixels": [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255],
]
}
))
assert resp["success"] is False
assert "pixels array must have 4 entries" in resp["message"]
def test_texture_modify_invalid_set_pixels_type(self, monkeypatch):
"""Test error handling for invalid set_pixels input type."""
async def fake_send(*args, **kwargs):
return {"success": True}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="modify",
path="Assets/Textures/Test.png",
set_pixels=[]
))
assert resp["success"] is False
assert resp["message"] == "set_pixels must be a JSON object"
def test_texture_delete_params(self, monkeypatch):
"""Test texture delete parameter pass-through."""
captured = {}
async def fake_send(func, instance, cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Deleted texture"}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="delete",
path="Assets/Textures/Old.png"
))
assert resp["success"] is True
assert captured["params"]["path"] == "Assets/Textures/Old.png"
def test_invalid_dimensions(self, monkeypatch):
"""Test error handling for invalid dimensions."""
async def fake_send(func, instance, cmd, params, **kwargs):
w = params.get("width", 0)
if w > 4096:
return {"success": False, "message": "Invalid dimensions: 5000x64. Must be 1-4096."}
return {"success": True}
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
resp = run_async(manage_texture_mod.manage_texture(
ctx=DummyContext(),
action="create",
path="Assets/Invalid.png",
width=0,
height=64 # Non-positive dimension
))
assert resp["success"] is False
assert "positive" in resp["message"].lower()
+428
View File
@@ -0,0 +1,428 @@
"""Integration tests for manage_ui tool."""
import asyncio
import base64
import pytest
from .test_helpers import DummyContext
import services.tools.manage_ui as manage_ui_mod
def run_async(coro):
"""Simple wrapper to run a coroutine synchronously."""
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
finally:
loop.close()
asyncio.set_event_loop(None)
SAMPLE_UXML = """<ui:UXML xmlns:ui="UnityEngine.UIElements">
<ui:Label text="Hello World" />
</ui:UXML>"""
SAMPLE_USS = """.label {
font-size: 24px;
color: white;
}"""
class TestManageUIPathValidation:
"""Tests for path validation logic."""
def test_create_rejects_path_outside_assets(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create",
path="NotAssets/UI/Test.uxml",
contents=SAMPLE_UXML,
))
assert resp["success"] is False
assert "Assets/" in resp["message"]
def test_create_rejects_traversal(self, monkeypatch):
async def fake_send(*_args, **_kwargs):
return {"success": True}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create",
path="Assets/../etc/passwd.uxml",
contents=SAMPLE_UXML,
))
assert resp["success"] is False
# Path normalization resolves ".." so it either fails traversal or Assets/ check
assert "traversal" in resp["message"] or "Assets/" in resp["message"]
def test_create_rejects_invalid_extension(self, monkeypatch):
async def fake_send(*_args, **_kwargs):
return {"success": True}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create",
path="Assets/UI/Test.cs",
contents="some content",
))
assert resp["success"] is False
assert ".uxml or .uss" in resp["message"]
def test_create_accepts_uxml_extension(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Created"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create",
path="Assets/UI/Menu.uxml",
contents=SAMPLE_UXML,
))
assert resp["success"] is True
assert captured["params"]["path"] == "Assets/UI/Menu.uxml"
def test_create_accepts_uss_extension(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Created"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create",
path="Assets/UI/Styles.uss",
contents=SAMPLE_USS,
))
assert resp["success"] is True
assert captured["params"]["path"] == "Assets/UI/Styles.uss"
class TestManageUIContentsEncoding:
"""Tests for base64 content encoding."""
def test_create_encodes_contents_as_base64(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Created"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create",
path="Assets/UI/Test.uxml",
contents=SAMPLE_UXML,
))
params = captured["params"]
assert params["contentsEncoded"] is True
decoded = base64.b64decode(params["encodedContents"]).decode("utf-8")
assert decoded == SAMPLE_UXML
# Raw contents should NOT be in params (only encoded)
assert "contents" not in params
def test_update_encodes_contents_as_base64(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Updated"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="update",
path="Assets/UI/Test.uss",
contents=SAMPLE_USS,
))
params = captured["params"]
assert params["contentsEncoded"] is True
decoded = base64.b64decode(params["encodedContents"]).decode("utf-8")
assert decoded == SAMPLE_USS
class TestManageUIActionRouting:
"""Tests for action-based parameter routing."""
def test_read_uses_non_mutation_path(self, monkeypatch):
captured = {}
async def fake_send(_func, _instance, cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"contents": "test"}}
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="read",
path="Assets/UI/Test.uxml",
))
assert captured["cmd"] == "manage_ui"
assert captured["params"]["action"] == "read"
def test_create_uses_mutation_path(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, cmd, _params, **kwargs):
captured["cmd"] = cmd
return {"success": True, "message": "Created"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create",
path="Assets/UI/Test.uxml",
contents=SAMPLE_UXML,
))
assert captured["cmd"] == "manage_ui"
def test_attach_ui_document_params(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Attached"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="attach_ui_document",
target="MyCanvas",
source_asset="Assets/UI/Menu.uxml",
panel_settings="Assets/UI/PanelSettings.asset",
sort_order=5,
))
params = captured["params"]
assert params["action"] == "attach_ui_document"
assert params["target"] == "MyCanvas"
assert params["sourceAsset"] == "Assets/UI/Menu.uxml"
assert params["panelSettings"] == "Assets/UI/PanelSettings.asset"
assert params["sortOrder"] == 5
def test_create_panel_settings_params(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Created"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="create_panel_settings",
path="Assets/UI/MyPanel.asset",
scale_mode="ScaleWithScreenSize",
reference_resolution={"width": 1920, "height": 1080},
))
params = captured["params"]
assert params["action"] == "create_panel_settings"
assert params["scaleMode"] == "ScaleWithScreenSize"
assert params["referenceResolution"] == {"width": 1920, "height": 1080}
def test_get_visual_tree_params(self, monkeypatch):
captured = {}
async def fake_send(_func, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"tree": {}}}
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="get_visual_tree",
target="UIRoot",
max_depth=5,
))
params = captured["params"]
assert params["action"] == "get_visual_tree"
assert params["target"] == "UIRoot"
assert params["maxDepth"] == 5
def test_ping_uses_non_mutation_path(self, monkeypatch):
captured = {}
async def fake_send(_func, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "pong"}
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="ping",
))
assert resp["success"] is True
assert captured["params"]["action"] == "ping"
class TestManageUINoneRemoval:
"""Tests that None values are properly excluded from params."""
def test_none_params_excluded(self, monkeypatch):
captured = {}
async def fake_send(_func, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="read",
path="Assets/UI/Test.uxml",
# All other params are None
))
params = captured["params"]
assert "target" not in params
assert "sourceAsset" not in params
assert "panelSettings" not in params
assert "sortOrder" not in params
assert "scaleMode" not in params
assert "maxDepth" not in params
class TestManageUIReadResponse:
"""Tests for read response handling."""
def test_read_decodes_base64_response(self, monkeypatch):
encoded = base64.b64encode(SAMPLE_UXML.encode("utf-8")).decode("utf-8")
async def fake_send(*_args, **_kwargs):
return {
"success": True,
"data": {
"path": "Assets/UI/Test.uxml",
"contents": SAMPLE_UXML,
"encodedContents": encoded,
"contentsEncoded": True,
}
}
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(),
action="read",
path="Assets/UI/Test.uxml",
))
assert resp["success"] is True
data = resp["data"]
assert data["contents"] == SAMPLE_UXML
assert "encodedContents" not in data
assert "contentsEncoded" not in data
class TestManageUIRenderUI:
"""Tests for render_ui action."""
def test_render_ui_routes_params(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Rendered",
"data": {"path": "Assets/Screenshots/test.png"}}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(), action="render_ui",
target="UIRoot", width=1280, height=720,
include_image=True, max_resolution=480,
screenshot_file_name="my-preview",
))
assert resp["success"] is True
p = captured["params"]
assert p["action"] == "render_ui"
assert p["target"] == "UIRoot"
assert p["width"] == 1280
assert p["height"] == 720
assert p["include_image"] is True
assert p["max_resolution"] == 480
assert p["file_name"] == "my-preview"
def test_render_ui_none_excluded(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "ok"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(), action="render_ui", target="X"))
p = captured["params"]
for k in ("width", "height", "include_image", "max_resolution", "file_name"):
assert k not in p
class TestManageUILinkStylesheet:
"""Tests for link_stylesheet action."""
def test_link_stylesheet_routes_params(self, monkeypatch):
captured = {}
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "message": "Linked"}
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
resp = run_async(manage_ui_mod.manage_ui(
ctx=DummyContext(), action="link_stylesheet",
path="Assets/UI/Menu.uxml",
stylesheet="Assets/UI/Styles.uss",
))
assert resp["success"] is True
p = captured["params"]
assert p["action"] == "link_stylesheet"
assert p["path"] == "Assets/UI/Menu.uxml"
assert p["stylesheet"] == "Assets/UI/Styles.uss"
for k in ("width", "height", "include_image"):
assert k not in p
@@ -0,0 +1,142 @@
"""Tests for UnityInstanceMiddleware auth enforcement in remote-hosted mode."""
import asyncio
import sys
from unittest.mock import AsyncMock, Mock, patch
import pytest
from core.config import config
from tests.integration.test_helpers import DummyContext
class TestMiddlewareAuthEnforcement:
@pytest.mark.asyncio
async def test_remote_hosted_requires_user_id(self, monkeypatch):
"""_inject_unity_instance should raise RuntimeError when remote-hosted and no user_id."""
monkeypatch.setattr(config, "http_remote_hosted", True)
from transport.unity_instance_middleware import UnityInstanceMiddleware
middleware = UnityInstanceMiddleware()
# Mock _resolve_user_id to return None (no API key / failed validation)
monkeypatch.setattr(middleware, "_resolve_user_id",
AsyncMock(return_value=None))
ctx = DummyContext()
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
with pytest.raises(RuntimeError, match="API key authentication required"):
await middleware._inject_unity_instance(middleware_ctx)
@pytest.mark.asyncio
async def test_sets_user_id_in_context_state(self, monkeypatch):
"""_inject_unity_instance should set user_id in ctx state when resolved."""
monkeypatch.setattr(config, "http_remote_hosted", True)
from transport.unity_instance_middleware import UnityInstanceMiddleware
middleware = UnityInstanceMiddleware()
monkeypatch.setattr(middleware, "_resolve_user_id",
AsyncMock(return_value="user-55"))
# We need PluginHub to be configured for the session resolution path
# But we don't need it to actually find a session for this test
from transport.plugin_hub import PluginHub
from transport.plugin_registry import PluginRegistry
registry = PluginRegistry()
loop = asyncio.get_running_loop()
PluginHub.configure(registry, loop)
ctx = DummyContext()
ctx.client_id = "client-1"
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
# Set an active instance so the middleware doesn't try to auto-select
await middleware.set_active_instance(ctx, "Proj@hash1")
# Register a matching session so resolution doesn't fail
await registry.register("s1", "Proj", "hash1", "2022", user_id="user-55")
await middleware._inject_unity_instance(middleware_ctx)
assert await ctx.get_state("user_id") == "user-55"
class TestAutoSelectDisabledRemoteHosted:
@pytest.mark.asyncio
async def test_auto_select_returns_none_in_remote_hosted(self, monkeypatch):
"""_maybe_autoselect_instance should return None in remote-hosted mode even with one session."""
monkeypatch.setattr(config, "http_remote_hosted", True)
monkeypatch.setattr(config, "transport_mode", "http")
# Re-import middleware to pick up the stubbed transport module
monkeypatch.delitem(
sys.modules, "transport.unity_instance_middleware", raising=False)
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as HubRef
# Configure PluginHub with one session so auto-select has something to find
from transport.plugin_registry import PluginRegistry
registry = PluginRegistry()
await registry.register("s1", "Proj", "h1", "2022", user_id="userA")
loop = asyncio.get_running_loop()
HubRef.configure(registry, loop)
middleware = UnityInstanceMiddleware()
ctx = DummyContext()
ctx.client_id = "client-1"
result = await middleware._maybe_autoselect_instance(ctx)
# Remote-hosted mode should NOT auto-select (early return at the transport check)
assert result is None
class TestHttpAuthBehavior:
@pytest.mark.asyncio
async def test_http_local_does_not_require_user_id(self, monkeypatch):
"""HTTP local mode should allow requests without user_id."""
monkeypatch.setattr(config, "http_remote_hosted", False)
monkeypatch.setattr(config, "transport_mode", "http")
from transport import unity_transport
async def fake_send_command_for_instance(*_args, **_kwargs):
return {"success": True, "data": {"ok": True}}
monkeypatch.setattr(
unity_transport.PluginHub,
"send_command_for_instance",
fake_send_command_for_instance,
)
async def _unused_send_fn(*_args, **_kwargs):
raise AssertionError("send_fn should not be used in HTTP mode")
result = await unity_transport.send_with_unity_instance(
_unused_send_fn, None, "ping", {}
)
assert result["success"] is True
assert result["data"] == {"ok": True}
@pytest.mark.asyncio
async def test_http_remote_requires_user_id(self, monkeypatch):
"""HTTP remote-hosted mode should reject requests without user_id."""
monkeypatch.setattr(config, "http_remote_hosted", True)
monkeypatch.setattr(config, "transport_mode", "http")
from transport import unity_transport
async def _unused_send_fn(*_args, **_kwargs):
raise AssertionError("send_fn should not be used in HTTP mode")
result = await unity_transport.send_with_unity_instance(
_unused_send_fn, None, "ping", {}
)
assert result["success"] is False
assert result["error"] == "auth_required"
@@ -0,0 +1,176 @@
"""Integration tests for multi-user session isolation in remote-hosted mode.
These tests compose PluginRegistry + PluginHub to verify that users
cannot see or interact with each other's Unity instances.
"""
import asyncio
from unittest.mock import AsyncMock
import pytest
from core.config import config
from transport.plugin_hub import NoUnitySessionError, PluginHub
from transport.plugin_registry import PluginRegistry
@pytest.fixture(autouse=True)
def _reset_plugin_hub():
old_registry = PluginHub._registry
old_connections = PluginHub._connections.copy()
old_pending = PluginHub._pending.copy()
old_lock = PluginHub._lock
old_loop = PluginHub._loop
yield
PluginHub._registry = old_registry
PluginHub._connections = old_connections
PluginHub._pending = old_pending
PluginHub._lock = old_lock
PluginHub._loop = old_loop
async def _setup_two_user_registry():
"""Set up a registry with two users, each having Unity instances.
Returns the configured registry. Also configures PluginHub to use it.
"""
registry = PluginRegistry()
loop = asyncio.get_running_loop()
PluginHub.configure(registry, loop)
await registry.register("sess-A1", "ProjectAlpha", "hashA1", "2022.3", user_id="userA")
await registry.register("sess-B1", "ProjectBeta", "hashB1", "2022.3", user_id="userB")
await registry.register("sess-A2", "ProjectGamma", "hashA2", "2022.3", user_id="userA")
return registry
class TestMultiUserSessionFiltering:
@pytest.mark.asyncio
async def test_get_sessions_filters_by_user(self):
"""PluginHub.get_sessions(user_id=X) returns only X's sessions."""
await _setup_two_user_registry()
sessions_a = await PluginHub.get_sessions(user_id="userA")
assert len(sessions_a.sessions) == 2
project_names = {s.project for s in sessions_a.sessions.values()}
assert project_names == {"ProjectAlpha", "ProjectGamma"}
sessions_b = await PluginHub.get_sessions(user_id="userB")
assert len(sessions_b.sessions) == 1
assert next(iter(sessions_b.sessions.values())
).project == "ProjectBeta"
@pytest.mark.asyncio
async def test_get_sessions_no_filter_returns_all_in_local_mode(self):
"""In local mode, PluginHub.get_sessions() without user_id returns everything."""
await _setup_two_user_registry()
all_sessions = await PluginHub.get_sessions()
assert len(all_sessions.sessions) == 3
@pytest.mark.asyncio
async def test_get_sessions_no_filter_raises_in_remote_hosted(self, monkeypatch):
"""In remote-hosted mode, PluginHub.get_sessions() without user_id raises."""
monkeypatch.setattr(config, "http_remote_hosted", True)
await _setup_two_user_registry()
with pytest.raises(ValueError, match="requires user_id"):
await PluginHub.get_sessions()
class TestResolveSessionIdIsolation:
@pytest.mark.asyncio
async def test_resolve_session_for_own_hash(self, monkeypatch):
"""User A can resolve their own project hash."""
monkeypatch.setattr(config, "http_remote_hosted", True)
await _setup_two_user_registry()
session_id = await PluginHub._resolve_session_id("hashA1", user_id="userA")
assert session_id == "sess-A1"
@pytest.mark.asyncio
async def test_cannot_resolve_other_users_hash(self, monkeypatch):
"""User A cannot resolve User B's project hash."""
monkeypatch.setattr(config, "http_remote_hosted", True)
monkeypatch.setenv("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "0.1")
await _setup_two_user_registry()
# userA tries to resolve userB's hash -> should not find it
with pytest.raises(NoUnitySessionError):
await PluginHub._resolve_session_id("hashB1", user_id="userA")
class TestInstanceListResourceIsolation:
@pytest.mark.asyncio
async def test_unity_instances_resource_filters_by_user(self, monkeypatch):
"""The unity_instances resource should pass user_id and return filtered results."""
monkeypatch.setattr(config, "http_remote_hosted", True)
monkeypatch.setattr(config, "transport_mode", "http")
await _setup_two_user_registry()
from services.resources.unity_instances import unity_instances
from tests.integration.test_helpers import DummyContext
ctx = DummyContext()
await ctx.set_state("user_id", "userA")
result = await unity_instances(ctx)
assert result["success"] is True
assert result["instance_count"] == 2
instance_names = {i["name"] for i in result["instances"]}
assert instance_names == {"ProjectAlpha", "ProjectGamma"}
assert "ProjectBeta" not in instance_names
class TestSetActiveInstanceIsolation:
@pytest.mark.asyncio
async def test_set_active_instance_only_sees_own_sessions(self, monkeypatch):
"""set_active_instance should only offer sessions belonging to the current user."""
monkeypatch.setattr(config, "http_remote_hosted", True)
monkeypatch.setattr(config, "transport_mode", "http")
await _setup_two_user_registry()
from services.tools.set_active_instance import set_active_instance
from transport.unity_instance_middleware import UnityInstanceMiddleware
from tests.integration.test_helpers import DummyContext
middleware = UnityInstanceMiddleware()
monkeypatch.setattr(
"services.tools.set_active_instance.get_unity_instance_middleware",
lambda: middleware,
)
ctx = DummyContext()
await ctx.set_state("user_id", "userA")
result = await set_active_instance(ctx, "ProjectAlpha@hashA1")
assert result["success"] is True
assert await middleware.get_active_instance(ctx) == "ProjectAlpha@hashA1"
@pytest.mark.asyncio
async def test_set_active_instance_rejects_other_users_instance(self, monkeypatch):
"""set_active_instance should not find another user's instance."""
monkeypatch.setattr(config, "http_remote_hosted", True)
monkeypatch.setattr(config, "transport_mode", "http")
await _setup_two_user_registry()
from services.tools.set_active_instance import set_active_instance
from transport.unity_instance_middleware import UnityInstanceMiddleware
from tests.integration.test_helpers import DummyContext
middleware = UnityInstanceMiddleware()
monkeypatch.setattr(
"services.tools.set_active_instance.get_unity_instance_middleware",
lambda: middleware,
)
ctx = DummyContext()
await ctx.set_state("user_id", "userA")
# UserA tries to select UserB's instance -> should fail
result = await set_active_instance(ctx, "ProjectBeta@hashB1")
assert result["success"] is False
@@ -0,0 +1,183 @@
"""Tests for PluginHub WebSocket API key authentication gate."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from core.config import config
from core.constants import API_KEY_HEADER
from services.api_key_service import ApiKeyService, ValidationResult
from transport.plugin_hub import PluginHub
from transport.plugin_registry import PluginRegistry
@pytest.fixture(autouse=True)
def _reset_api_key_singleton():
ApiKeyService._instance = None
yield
ApiKeyService._instance = None
@pytest.fixture(autouse=True)
def _reset_plugin_hub():
"""Ensure PluginHub class-level state doesn't leak between tests."""
old_registry = PluginHub._registry
old_connections = PluginHub._connections.copy()
old_pending = PluginHub._pending.copy()
old_lock = PluginHub._lock
old_loop = PluginHub._loop
yield
PluginHub._registry = old_registry
PluginHub._connections = old_connections
PluginHub._pending = old_pending
PluginHub._lock = old_lock
PluginHub._loop = old_loop
def _make_mock_websocket(headers=None, state_attrs=None):
"""Create a mock WebSocket with configurable headers and state."""
ws = AsyncMock()
ws.headers = headers or {}
ws.state = SimpleNamespace(**(state_attrs or {}))
ws.accept = AsyncMock()
ws.close = AsyncMock()
ws.send_json = AsyncMock()
return ws
def _make_hub():
"""Create a PluginHub instance with a minimal ASGI scope."""
scope = {"type": "websocket"}
return PluginHub(scope, receive=AsyncMock(), send=AsyncMock())
def _init_api_key_service(validate_result=None):
"""Initialize ApiKeyService with a mocked validate method."""
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
if validate_result is not None:
svc.validate = AsyncMock(return_value=validate_result)
return svc
class TestWebSocketAuthGate:
@pytest.mark.asyncio
async def test_no_api_key_remote_hosted_rejected(self, monkeypatch):
"""WebSocket without API key in remote-hosted mode -> close 4401."""
monkeypatch.setattr(config, "http_remote_hosted", True)
_init_api_key_service(ValidationResult(valid=True, user_id="u1"))
ws = _make_mock_websocket(headers={}) # No X-API-Key header
hub = _make_hub()
await hub.on_connect(ws)
ws.close.assert_called_once_with(code=4401, reason="API key required")
ws.accept.assert_not_called()
@pytest.mark.asyncio
async def test_invalid_api_key_rejected(self, monkeypatch):
"""WebSocket with invalid API key -> close 4403."""
monkeypatch.setattr(config, "http_remote_hosted", True)
_init_api_key_service(ValidationResult(
valid=False, error="Invalid API key"))
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-bad-key"})
hub = _make_hub()
await hub.on_connect(ws)
ws.close.assert_called_once_with(code=4403, reason="Invalid API key")
ws.accept.assert_not_called()
@pytest.mark.asyncio
async def test_valid_api_key_accepted(self, monkeypatch):
"""WebSocket with valid API key -> accepted, user_id stored in state."""
monkeypatch.setattr(config, "http_remote_hosted", True)
_init_api_key_service(
ValidationResult(valid=True, user_id="user-42",
metadata={"plan": "pro"})
)
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-valid-key"})
hub = _make_hub()
await hub.on_connect(ws)
ws.accept.assert_called_once()
ws.close.assert_not_called()
assert ws.state.user_id == "user-42"
assert ws.state.api_key_metadata == {"plan": "pro"}
# Should have sent welcome message
ws.send_json.assert_called_once()
@pytest.mark.asyncio
async def test_auth_service_unavailable_close_1013(self, monkeypatch):
"""Auth service error with 'unavailable' -> close 1013 (try again later)."""
monkeypatch.setattr(config, "http_remote_hosted", True)
_init_api_key_service(
ValidationResult(
valid=False, error="Auth service unavailable", cacheable=False)
)
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-some-key"})
hub = _make_hub()
await hub.on_connect(ws)
ws.close.assert_called_once_with(code=1013, reason="Try again later")
ws.accept.assert_not_called()
@pytest.mark.asyncio
async def test_not_remote_hosted_accepts_without_key(self, monkeypatch):
"""When not remote-hosted, WebSocket accepted without API key."""
monkeypatch.setattr(config, "http_remote_hosted", False)
ws = _make_mock_websocket(headers={})
hub = _make_hub()
await hub.on_connect(ws)
ws.accept.assert_called_once()
ws.close.assert_not_called()
class TestUserIdFlowsToRegistration:
@pytest.mark.asyncio
async def test_user_id_passed_to_registry_on_register(self, monkeypatch):
"""After valid auth, the register message should pass user_id to registry."""
monkeypatch.setattr(config, "http_remote_hosted", True)
_init_api_key_service(
ValidationResult(valid=True, user_id="user-99")
)
registry = PluginRegistry()
loop = asyncio.get_running_loop()
PluginHub.configure(registry, loop)
# Simulate full flow: connect, then register
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-valid-key"})
hub = _make_hub()
await hub.on_connect(ws)
assert ws.state.user_id == "user-99"
# Simulate register message
register_data = {
"type": "register",
"project_name": "TestProject",
"project_hash": "abc123",
"unity_version": "2022.3",
}
await hub.on_receive(ws, register_data)
# Verify registry has the user_id
sessions = await registry.list_sessions(user_id="user-99")
assert len(sessions) == 1
session = next(iter(sessions.values()))
assert session.user_id == "user-99"
assert session.project_name == "TestProject"
assert session.project_hash == "abc123"
@@ -0,0 +1,132 @@
"""Tests for PluginRegistry user-scoped session isolation (remote-hosted mode)."""
import pytest
from core.config import config
from transport.plugin_registry import PluginRegistry
class TestRegistryUserIsolation:
@pytest.mark.asyncio
async def test_register_with_user_id_stores_composite_key(self):
registry = PluginRegistry()
session, _ = await registry.register(
"sess-1", "MyProject", "hash1", "2022.3", user_id="user-A"
)
assert session.user_id == "user-A"
assert ("user-A", "hash1") in registry._user_hash_to_session
assert registry._user_hash_to_session[("user-A", "hash1")] == "sess-1"
@pytest.mark.asyncio
async def test_get_session_id_by_hash(self):
registry = PluginRegistry()
await registry.register("sess-1", "Proj", "h1", "2022", user_id="uA")
found = await registry.get_session_id_by_hash("h1", "uA")
assert found == "sess-1"
# Different user, same hash -> not found
not_found = await registry.get_session_id_by_hash("h1", "uB")
assert not_found is None
@pytest.mark.asyncio
async def test_register_same_user_same_hash_evicts_previous_session(self):
"""Same user + project_hash: second registration evicts the first session."""
registry = PluginRegistry()
first_session, first_evicted = await registry.register(
"sess-1", "MyProject", "hash1", "2022.3", user_id="user-A"
)
assert first_session.session_id == "sess-1"
assert first_evicted is None
second_session, second_evicted = await registry.register(
"sess-2", "MyProject", "hash1", "2022.3", user_id="user-A"
)
assert second_session.session_id == "sess-2"
assert second_evicted == "sess-1"
@pytest.mark.asyncio
async def test_cross_user_isolation_same_hash(self):
"""Two users registering with the same project_hash get independent sessions."""
registry = PluginRegistry()
sess_a, evicted_a = await registry.register("sA", "Proj", "hash1", "2022", user_id="userA")
sess_b, evicted_b = await registry.register("sB", "Proj", "hash1", "2022", user_id="userB")
assert sess_a.session_id == "sA"
assert sess_b.session_id == "sB"
# Different users should not evict each other's sessions
assert evicted_a is None
assert evicted_b is None
# Each user resolves to their own session
assert await registry.get_session_id_by_hash("hash1", "userA") == "sA"
assert await registry.get_session_id_by_hash("hash1", "userB") == "sB"
# Both sessions exist
all_sessions = await registry.list_sessions()
assert len(all_sessions) == 2
@pytest.mark.asyncio
async def test_list_sessions_filtered_by_user(self):
registry = PluginRegistry()
await registry.register("s1", "ProjA", "hA", "2022", user_id="userA")
await registry.register("s2", "ProjB", "hB", "2022", user_id="userB")
await registry.register("s3", "ProjC", "hC", "2022", user_id="userA")
user_a_sessions = await registry.list_sessions(user_id="userA")
assert len(user_a_sessions) == 2
assert "s1" in user_a_sessions
assert "s3" in user_a_sessions
user_b_sessions = await registry.list_sessions(user_id="userB")
assert len(user_b_sessions) == 1
assert "s2" in user_b_sessions
@pytest.mark.asyncio
async def test_list_sessions_no_filter_returns_all_in_local_mode(self):
"""In local mode (not remote-hosted), list_sessions(user_id=None) returns all."""
registry = PluginRegistry()
await registry.register("s1", "P1", "h1", "2022", user_id="uA")
await registry.register("s2", "P2", "h2", "2022", user_id="uB")
await registry.register("s3", "P3", "h3", "2022") # local mode, no user_id
all_sessions = await registry.list_sessions(user_id=None)
assert len(all_sessions) == 3
@pytest.mark.asyncio
async def test_list_sessions_no_filter_raises_in_remote_hosted(self, monkeypatch):
"""In remote-hosted mode, list_sessions(user_id=None) raises ValueError."""
monkeypatch.setattr(config, "http_remote_hosted", True)
registry = PluginRegistry()
await registry.register("s1", "P1", "h1", "2022", user_id="uA")
with pytest.raises(ValueError, match="requires user_id"):
await registry.list_sessions(user_id=None)
@pytest.mark.asyncio
async def test_unregister_cleans_user_scoped_mapping(self):
registry = PluginRegistry()
await registry.register("s1", "Proj", "h1", "2022", user_id="uA")
assert ("uA", "h1") in registry._user_hash_to_session
await registry.unregister("s1")
assert ("uA", "h1") not in registry._user_hash_to_session
assert "s1" not in (await registry.list_sessions())
@pytest.mark.asyncio
async def test_reconnect_replaces_previous_session(self):
"""Same (user_id, hash) re-registered evicts old session, stores new one."""
registry = PluginRegistry()
await registry.register("old-sess", "Proj", "h1", "2022", user_id="uA")
assert await registry.get_session_id_by_hash("h1", "uA") == "old-sess"
await registry.register("new-sess", "Proj", "h1", "2022", user_id="uA")
assert await registry.get_session_id_by_hash("h1", "uA") == "new-sess"
# Old session should be evicted
all_sessions = await registry.list_sessions()
assert "old-sess" not in all_sessions
assert "new-sess" in all_sessions
@@ -0,0 +1,248 @@
import pytest
from .test_helpers import DummyContext, DummyMCP
def setup_console_tools():
"""Setup console-related tools for testing."""
mcp = DummyMCP()
import services.tools.read_console
from services.registry import get_registered_tools
for tool_info in get_registered_tools():
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['read_console', 'console']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@pytest.mark.asyncio
async def test_read_console_full_default(monkeypatch):
tools = setup_console_tools()
read_console = tools["read_console"]
captured = {}
async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
return {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
}
# Patch the send_command_with_retry function in the tools module
import services.tools.read_console
monkeypatch.setattr(
services.tools.read_console,
"async_send_command_with_retry",
fake_send,
)
resp = await read_console(ctx=DummyContext(), action="get", count=10)
assert resp == {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "time": "t"}]},
}
assert captured["params"]["count"] == 10
assert captured["params"]["includeStacktrace"] is False
@pytest.mark.asyncio
async def test_read_console_truncated(monkeypatch):
tools = setup_console_tools()
read_console = tools["read_console"]
captured = {}
async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
return {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace"}]},
}
# Patch the send_command_with_retry function in the tools module
import services.tools.read_console
monkeypatch.setattr(
services.tools.read_console,
"async_send_command_with_retry",
fake_send,
)
resp = await read_console(ctx=DummyContext(), action="get", count=10, include_stacktrace=False)
assert resp == {"success": True, "data": {
"lines": [{"level": "error", "message": "oops"}]}}
assert captured["params"]["includeStacktrace"] is False
@pytest.mark.asyncio
async def test_read_console_default_count(monkeypatch):
"""Test that read_console defaults to count=10 when not specified."""
tools = setup_console_tools()
read_console = tools["read_console"]
captured = {}
async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
return {
"success": True,
"data": {"lines": [{"level": "error", "message": f"error {i}"} for i in range(15)]},
}
# Patch the send_command_with_retry function in the tools module
import services.tools.read_console
monkeypatch.setattr(
services.tools.read_console,
"async_send_command_with_retry",
fake_send,
)
# Call without specifying count - should default to 10
resp = await read_console(ctx=DummyContext(), action="get")
assert resp["success"] is True
# Verify that the default count of 10 was used
assert captured["params"]["count"] == 10
@pytest.mark.asyncio
async def test_read_console_paging(monkeypatch):
"""Test that read_console paging works with page_size and cursor."""
tools = setup_console_tools()
read_console = tools["read_console"]
captured = {}
async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
# Simulate Unity returning paging info matching C# structure
page_size = params.get("pageSize", 10)
cursor = params.get("cursor", 0)
# Simulate 25 total messages
all_messages = [{"level": "error", "message": f"error {i}"} for i in range(25)]
# Return a page of results
start = cursor
end = min(start + page_size, len(all_messages))
messages = all_messages[start:end]
return {
"success": True,
"data": {
"items": messages,
"cursor": cursor,
"pageSize": page_size,
"nextCursor": str(end) if end < len(all_messages) else None,
"truncated": end < len(all_messages),
"total": len(all_messages),
},
}
# Patch the send_command_with_retry function in the tools module
import services.tools.read_console
monkeypatch.setattr(
services.tools.read_console,
"async_send_command_with_retry",
fake_send,
)
# First page - get first 5 entries
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=0)
assert resp["success"] is True
assert captured["params"]["pageSize"] == 5
assert captured["params"]["cursor"] == 0
assert len(resp["data"]["items"]) == 5
assert resp["data"]["truncated"] is True
assert resp["data"]["nextCursor"] == "5"
assert resp["data"]["total"] == 25
# Second page - get next 5 entries
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=5)
assert resp["success"] is True
assert captured["params"]["cursor"] == 5
assert len(resp["data"]["items"]) == 5
assert resp["data"]["truncated"] is True
assert resp["data"]["nextCursor"] == "10"
# Last page - get remaining entries
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=20)
assert resp["success"] is True
assert len(resp["data"]["items"]) == 5
assert resp["data"]["truncated"] is False
assert resp["data"]["nextCursor"] is None
@pytest.mark.asyncio
async def test_read_console_types_json_string(monkeypatch):
"""Test that read_console handles types parameter as JSON string (fixes issue #561)."""
tools = setup_console_tools()
read_console = tools["read_console"]
captured = {}
async def fake_send_with_unity_instance(_send_fn, _unity_instance, _command_type, params, **_kwargs):
captured["params"] = params
return {
"success": True,
"data": {"lines": [{"level": "error", "message": "test error"}]},
}
import services.tools.read_console as read_console_mod
monkeypatch.setattr(
read_console_mod,
"send_with_unity_instance",
fake_send_with_unity_instance,
)
# Test with types as JSON string (the problematic case from issue #561)
resp = await read_console(ctx=DummyContext(), action="get", types='["error", "warning", "all"]')
assert resp["success"] is True
# Verify types was parsed correctly and sent as a list
assert isinstance(captured["params"]["types"], list)
assert captured["params"]["types"] == ["error", "warning", "all"]
# Test case normalization to lowercase
captured.clear()
resp = await read_console(ctx=DummyContext(), action="get", types='["ERROR", "Warning", "LOG"]')
assert resp["success"] is True
assert captured["params"]["types"] == ["error", "warning", "log"]
# Test with types as actual list (should still work)
captured.clear()
resp = await read_console(ctx=DummyContext(), action="get", types=["error", "warning"])
assert resp["success"] is True
assert isinstance(captured["params"]["types"], list)
assert captured["params"]["types"] == ["error", "warning"]
@pytest.mark.asyncio
async def test_read_console_types_validation(monkeypatch):
"""Test that read_console validates types entries and rejects invalid values."""
tools = setup_console_tools()
read_console = tools["read_console"]
captured = {}
async def fake_send_with_unity_instance(_send_fn, _unity_instance, _command_type, params, **_kwargs):
captured["params"] = params
return {"success": True, "data": {"lines": []}}
import services.tools.read_console as read_console_mod
monkeypatch.setattr(
read_console_mod,
"send_with_unity_instance",
fake_send_with_unity_instance,
)
# Invalid entry in list should return a clear error and not send.
captured.clear()
resp = await read_console(ctx=DummyContext(), action="get", types='["error", "nope"]')
assert resp["success"] is False
assert "invalid types entry" in resp["message"]
assert captured == {}
# Non-string entry should return a clear error and not send.
captured.clear()
resp = await read_console(ctx=DummyContext(), action="get", types='[1, "error"]')
assert resp["success"] is False
assert "types entries must be strings" in resp["message"]
assert captured == {}
@@ -0,0 +1,8 @@
import asyncio
import pytest
from .test_helpers import DummyContext
# Tests for resource_tools.py have been removed since the file was deleted
# These tests were confusing LLMs that read resources
@@ -0,0 +1,14 @@
from services.registry import get_registered_tools
def test_refresh_unity_tool_is_registered():
"""
Red test: we expect an explicit refresh tool to exist so callers can force an import/refresh/compile cycle.
"""
# Import the specific module to ensure it registers its decorator without disturbing global registry state.
import services.tools.refresh_unity # noqa: F401
names = {t.get("name") for t in get_registered_tools()}
assert "refresh_unity" in names
@@ -0,0 +1,43 @@
import pytest
from models import MCPResponse
from services.state.external_changes_scanner import external_changes_scanner
from services.state.external_changes_scanner import ExternalChangesState
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_refresh_unity_recovers_from_retry_disconnect(monkeypatch):
"""
Option A: if Unity disconnects and the transport returns hint=retry, refresh_unity(wait_for_ready=true)
should poll readiness and then return success + clear external dirty.
"""
from services.tools.refresh_unity import refresh_unity
ctx = DummyContext()
await ctx.set_state("unity_instance", "UnityMCPTests@cc8756d4cce0805a")
# Seed dirty state
inst = "UnityMCPTests@cc8756d4cce0805a"
external_changes_scanner._states[inst] = ExternalChangesState(dirty=True, dirty_since_unix_ms=1)
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
if command_type == "refresh_unity":
return {"success": False, "error": "disconnected", "hint": "retry"}
elif command_type == "get_editor_state":
return {"success": True, "data": {"advice": {"ready_for_tools": True}}}
raise ValueError(f"Unexpected command: {command_type}")
import services.tools.refresh_unity as refresh_mod
monkeypatch.setattr(refresh_mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
resp = await refresh_unity(ctx, wait_for_ready=True)
payload = resp.model_dump() if hasattr(resp, "model_dump") else resp
assert payload["success"] is True
assert payload.get("data", {}).get("recovered_from_disconnect") is True
# Dirty should be cleared
assert external_changes_scanner._states[inst].dirty is False
@@ -0,0 +1,114 @@
"""Tests for _resolve_user_id_from_request in unity_transport.py."""
import sys
import types
from unittest.mock import AsyncMock
import pytest
from core.config import config
from services.api_key_service import ApiKeyService, ValidationResult
@pytest.fixture(autouse=True)
def _reset_api_key_singleton():
ApiKeyService._instance = None
yield
ApiKeyService._instance = None
class TestResolveUserIdFromRequest:
@pytest.mark.asyncio
async def test_returns_none_when_not_remote_hosted(self, monkeypatch):
monkeypatch.setattr(config, "http_remote_hosted", False)
from transport.unity_transport import _resolve_user_id_from_request
result = await _resolve_user_id_from_request()
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_service_not_initialized(self, monkeypatch):
monkeypatch.setattr(config, "http_remote_hosted", True)
# ApiKeyService._instance is None (from fixture)
from transport.unity_transport import _resolve_user_id_from_request
result = await _resolve_user_id_from_request()
assert result is None
@pytest.mark.asyncio
async def test_returns_user_id_for_valid_key(self, monkeypatch):
monkeypatch.setattr(config, "http_remote_hosted", True)
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
svc.validate = AsyncMock(
return_value=ValidationResult(valid=True, user_id="user-123")
)
# Stub the fastmcp dependency that provides HTTP headers
deps_mod = types.ModuleType("fastmcp.server.dependencies")
deps_mod.get_http_headers = lambda include_all=False: {
"x-api-key": "sk-valid"}
monkeypatch.setitem(
sys.modules, "fastmcp.server.dependencies", deps_mod)
from transport.unity_transport import _resolve_user_id_from_request
result = await _resolve_user_id_from_request()
assert result == "user-123"
svc.validate.assert_called_once_with("sk-valid")
@pytest.mark.asyncio
async def test_returns_none_for_invalid_key(self, monkeypatch):
monkeypatch.setattr(config, "http_remote_hosted", True)
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
svc.validate = AsyncMock(
return_value=ValidationResult(valid=False, error="bad key")
)
deps_mod = types.ModuleType("fastmcp.server.dependencies")
deps_mod.get_http_headers = lambda include_all=False: {
"x-api-key": "sk-bad"}
monkeypatch.setitem(
sys.modules, "fastmcp.server.dependencies", deps_mod)
from transport.unity_transport import _resolve_user_id_from_request
result = await _resolve_user_id_from_request()
assert result is None
@pytest.mark.asyncio
async def test_returns_none_on_exception(self, monkeypatch):
monkeypatch.setattr(config, "http_remote_hosted", True)
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
svc.validate = AsyncMock(side_effect=RuntimeError("boom"))
deps_mod = types.ModuleType("fastmcp.server.dependencies")
deps_mod.get_http_headers = lambda include_all=False: {
"x-api-key": "sk-err"}
monkeypatch.setitem(
sys.modules, "fastmcp.server.dependencies", deps_mod)
from transport.unity_transport import _resolve_user_id_from_request
result = await _resolve_user_id_from_request()
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_no_api_key_header(self, monkeypatch):
monkeypatch.setattr(config, "http_remote_hosted", True)
ApiKeyService(validation_url="https://auth.example.com/validate")
deps_mod = types.ModuleType("fastmcp.server.dependencies")
deps_mod.get_http_headers = lambda include_all=False: {} # No x-api-key
monkeypatch.setitem(
sys.modules, "fastmcp.server.dependencies", deps_mod)
from transport.unity_transport import _resolve_user_id_from_request
result = await _resolve_user_id_from_request()
assert result is None
@@ -0,0 +1,116 @@
import pytest
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_run_tests_async_forwards_params(monkeypatch):
from services.tools.run_tests import run_tests
captured = {}
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["command_type"] = command_type
captured["params"] = params
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}}
import services.tools.run_tests as mod
monkeypatch.setattr(
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
resp = await run_tests(
DummyContext(),
mode="EditMode",
test_names="MyNamespace.MyTests.TestA",
include_details=True,
)
assert captured["command_type"] == "run_tests"
assert captured["params"]["mode"] == "EditMode"
assert captured["params"]["testNames"] == ["MyNamespace.MyTests.TestA"]
assert captured["params"]["includeDetails"] is True
assert resp.success is True
assert resp.data is not None
assert resp.data.job_id == "abc123"
@pytest.mark.asyncio
async def test_run_tests_forwards_init_timeout(monkeypatch):
from services.tools.run_tests import run_tests
captured = {}
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "PlayMode"}}
import services.tools.run_tests as mod
monkeypatch.setattr(
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
resp = await run_tests(
DummyContext(),
mode="PlayMode",
init_timeout=120000,
)
assert captured["params"]["initTimeout"] == 120000
assert resp.success is True
@pytest.mark.asyncio
async def test_run_tests_omits_init_timeout_when_none(monkeypatch):
from services.tools.run_tests import run_tests
captured = {}
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}}
import services.tools.run_tests as mod
monkeypatch.setattr(
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
resp = await run_tests(DummyContext(), mode="EditMode")
assert "initTimeout" not in captured["params"]
assert resp.success is True
@pytest.mark.asyncio
async def test_run_tests_rejects_negative_init_timeout():
from services.tools.run_tests import run_tests
resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=-1)
assert resp.success is False
assert "init_timeout" in resp.error
@pytest.mark.asyncio
async def test_run_tests_rejects_zero_init_timeout():
from services.tools.run_tests import run_tests
resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=0)
assert resp.success is False
assert "init_timeout" in resp.error
@pytest.mark.asyncio
async def test_get_test_job_forwards_job_id(monkeypatch):
from services.tools.run_tests import get_test_job
captured = {}
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["command_type"] = command_type
captured["params"] = params
return {"success": True, "data": {"job_id": params["job_id"], "status": "running", "mode": "EditMode"}}
import services.tools.run_tests as mod
monkeypatch.setattr(
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
resp = await get_test_job(DummyContext(), job_id="job-1")
assert captured["command_type"] == "get_test_job"
assert captured["params"]["job_id"] == "job-1"
assert resp.success is True
assert resp.data is not None
assert resp.data.job_id == "job-1"
@@ -0,0 +1,291 @@
"""Tests for script_apply_edits.py local helper functions.
Focuses on _apply_edits_locally, _find_best_closing_brace_match,
and _is_in_string_context — especially around C# string variants
(verbatim, interpolated, raw) that can fool brace/anchor matching.
"""
import re
import pytest
from services.tools.script_apply_edits import (
_apply_edits_locally,
_find_best_closing_brace_match,
_find_best_anchor_match,
_is_in_string_context,
)
# ── _is_in_string_context ────────────────────────────────────────────
class TestIsInStringContext:
def test_plain_code_not_in_string(self):
text = 'int x = 42;'
assert not _is_in_string_context(text, 4)
def test_inside_regular_string(self):
text = 'string s = "hello world";'
# Position inside "hello world"
pos = text.index("hello")
assert _is_in_string_context(text, pos)
def test_inside_verbatim_string(self):
text = 'string s = @"C:\\Users\\file";'
pos = text.index("C:")
assert _is_in_string_context(text, pos)
def test_inside_interpolated_string(self):
text = 'string s = $"Value: {x}";'
# The "Value" part is inside the string
pos = text.index("Value")
assert _is_in_string_context(text, pos)
def test_interpolation_hole_is_not_string(self):
text = 'string s = $"Value: {x}";'
# The x inside {x} is in an interpolation hole — it's code, not string
brace_pos = text.index("{x}") + 1 # the 'x'
assert not _is_in_string_context(text, brace_pos)
def test_inside_single_line_comment(self):
text = 'int x = 1; // this is a comment'
pos = text.index("this")
assert _is_in_string_context(text, pos)
def test_inside_multi_line_comment(self):
text = 'int x = 1; /* block { } */ int y = 2;'
pos = text.index("block")
assert _is_in_string_context(text, pos)
def test_after_comment_is_code(self):
text = '// comment\nint x = 1;'
pos = text.index("int")
assert not _is_in_string_context(text, pos)
def test_verbatim_string_doubled_quotes(self):
text = 'string s = @"He said ""hello""";'
# The whole thing is one string ending at the final ";
pos = text.index("hello")
assert _is_in_string_context(text, pos)
def test_interpolated_verbatim_combined(self):
text = 'string s = $@"Path: {dir}\\file";'
# "Path" is inside the string
pos = text.index("Path")
assert _is_in_string_context(text, pos)
def test_raw_string_literal(self):
text = 'string s = """\n{ }\n""";'
pos = text.index("{ }")
assert _is_in_string_context(text, pos)
def test_interpolated_raw_string_content(self):
text = 'string s = $"""\n Hello {name}\n """;'
# "Hello" is string content (non-code)
pos = text.index("Hello")
assert _is_in_string_context(text, pos)
def test_interpolated_raw_string_hole_is_code(self):
text = 'string s = $"""\n Hello {name}\n """;'
# "name" inside {name} is in an interpolation hole — code
pos = text.index("name")
assert not _is_in_string_context(text, pos)
def test_multi_dollar_raw_string_content(self):
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
# {literal} has only 1 brace — it's literal string content
pos = text.index("literal")
assert _is_in_string_context(text, pos)
def test_multi_dollar_raw_string_hole_is_code(self):
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
# {{interp}} has 2 braces matching $$ — it's an interpolation hole
pos = text.index("interp")
assert not _is_in_string_context(text, pos)
def test_interpolated_raw_string_closing(self):
text = 'string s = $"""\n body\n """; int x = 1;'
# "x" after the closing """ is code
pos = text.index("x = 1")
assert not _is_in_string_context(text, pos)
# ── _find_best_closing_brace_match ───────────────────────────────────
class TestFindBestClosingBraceMatch:
def test_skips_braces_in_interpolated_strings(self):
"""Braces inside $"...{x}..." should not be scored as class-end."""
code = (
'public class Foo {\n'
' void M() {\n'
' string s = $"Score: {score}";\n'
' }\n'
'}\n'
)
pattern = r'^\s*}\s*$'
matches = list(re.finditer(pattern, code, re.MULTILINE))
# There should be matches for the method close and class close
assert len(matches) >= 1
best = _find_best_closing_brace_match(matches, code)
# The best match should be the class-closing brace, not one inside a string
assert best is not None
line_num = code[:best.start()].count('\n')
# Class close is the last "}" line
assert line_num == 4 # 0-indexed, line 5 is "}"
def test_skips_braces_in_verbatim_strings(self):
"""@"{ }" should not confuse the scorer."""
code = (
'public class Foo {\n'
' string s = @"{ }";\n'
'}\n'
)
pattern = r'^\s*}\s*$'
matches = list(re.finditer(pattern, code, re.MULTILINE))
best = _find_best_closing_brace_match(matches, code)
assert best is not None
def test_prefers_class_brace_over_method_brace(self):
"""Should pick class-closing } (depth 1) over method-closing } (depth 2)."""
code = (
'public class Foo : MonoBehaviour\n'
'{\n'
' private int score = 42;\n'
'\n'
' void Start()\n'
' {\n'
' Debug.Log($"Score: {score}");\n'
' }\n'
'\n'
' void OnGUI()\n'
' {\n'
' GUI.Label(new Rect(10, 10, 200, 20), $"Score: {score}");\n'
' }\n'
'}\n'
)
pattern = r'^\s*}\s*$'
matches = list(re.finditer(pattern, code, re.MULTILINE))
# Should have 3 matches: Start close, OnGUI close, class close
assert len(matches) == 3
best = _find_best_closing_brace_match(matches, code)
assert best is not None
best_line = code[:best.start()].count('\n')
# Class close is the last "}" — line 13 (0-indexed)
assert best_line == 13
def test_skips_braces_in_interpolated_raw_strings(self):
"""$\"\"\"{x}\"\"\" braces should not confuse the scorer."""
code = (
'public class Foo {\n'
' string s = $"""\n'
' { literal }\n'
' {interp}\n'
' """;\n'
'}\n'
)
pattern = r'^\s*}\s*$'
matches = list(re.finditer(pattern, code, re.MULTILINE))
best = _find_best_closing_brace_match(matches, code)
assert best is not None
best_line = code[:best.start()].count('\n')
assert best_line == 5 # class-closing brace
def test_closing_brace_scorer_with_interpolated_code(self):
"""Realistic C# with multiple $"" strings should still find class-end."""
code = (
'using UnityEngine;\n'
'public class HUD : MonoBehaviour {\n'
' void OnGUI() {\n'
' Debug.Log($"Score: {score}");\n'
' Debug.Log($@"Path: {path}\\save");\n'
' }\n'
'}\n'
)
pattern = r'^\s*}\s*$'
matches = list(re.finditer(pattern, code, re.MULTILINE))
best = _find_best_closing_brace_match(matches, code)
assert best is not None
# Should pick the class-closing brace (last one)
best_line = code[:best.start()].count('\n')
assert best_line == 6 # 0-indexed
# ── _apply_edits_locally regression guards ───────────────────────────
class TestApplyEditsLocally:
@pytest.mark.asyncio
async def test_replace_range_basic(self):
original = "line1\nline2\nline3\n"
edits = [{
"op": "replace_range",
"startLine": 2,
"startCol": 1,
"endLine": 2,
"endCol": 6,
"text": "REPLACED",
}]
result = await _apply_edits_locally(original, edits)
assert "REPLACED" in result
assert "line1" in result
assert "line3" in result
@pytest.mark.asyncio
async def test_prepend_and_append(self):
original = "middle\n"
edits = [
{"op": "prepend", "text": "top\n"},
{"op": "append", "text": "bottom\n"},
]
result = await _apply_edits_locally(original, edits)
assert result.startswith("top\n")
assert "bottom" in result
@pytest.mark.asyncio
async def test_regex_replace_near_interpolated_strings(self):
"""regex_replace should work even when interpolated strings are in the code."""
original = (
'void M() {\n'
' Debug.Log($"x={x}");\n'
' int OLD = 1;\n'
'}\n'
)
edits = [{
"op": "regex_replace",
"pattern": r"OLD",
"replacement": "NEW",
"text": "NEW",
}]
result = await _apply_edits_locally(original, edits)
assert "NEW" in result
assert "OLD" not in result
# ── _find_best_anchor_match with string-aware filtering ──────────────
class TestAnchorMatchFiltering:
def test_anchor_skips_braces_in_interpolated_strings(self):
"""$"...{x}..." brace should not be picked as anchor match."""
code = (
'class Foo {\n'
' string s = $"val: {x}";\n'
' void M() { }\n'
'}\n'
)
# Pattern looking for closing brace at end of line
pattern = r'^\s*}\s*$'
flags = re.MULTILINE
match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
assert match is not None
# Should match the class-closing brace, not anything inside the string
best_line = code[:match.start()].count('\n')
assert best_line == 3 # 0-indexed, class close
def test_anchor_skips_braces_in_verbatim_strings(self):
"""@"{ }" should not confuse anchor matching."""
code = (
'class Foo {\n'
' string s = @"{ }";\n'
'}\n'
)
pattern = r'^\s*}\s*$'
flags = re.MULTILINE
match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
assert match is not None
@@ -0,0 +1,176 @@
import pytest
import asyncio
from .test_helpers import DummyContext, DummyMCP, setup_script_tools
def setup_asset_tools():
"""Setup asset-related tools for testing."""
mcp = DummyMCP()
import services.tools.manage_asset
from services.registry import get_registered_tools
for tool_info in get_registered_tools():
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['asset', 'manage_asset']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@pytest.mark.asyncio
async def test_apply_text_edits_long_file(monkeypatch):
tools = setup_script_tools()
apply_edits = tools["apply_text_edits"]
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edit = {"startLine": 1005, "startCol": 0,
"endLine": 1005, "endCol": 5, "newText": "Hello"}
ctx = DummyContext()
resp = await apply_edits(ctx, "mcpforunity://path/Assets/Scripts/LongFile.cs", [edit])
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "apply_text_edits"
assert captured["params"]["edits"][0]["startLine"] == 1005
assert resp["success"] is True
@pytest.mark.asyncio
async def test_sequential_edits_use_precondition(monkeypatch):
tools = setup_script_tools()
apply_edits = tools["apply_text_edits"]
calls = []
async def fake_send(cmd, params, **kwargs):
calls.append(params)
return {"success": True, "sha256": f"hash{len(calls)}"}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edit1 = {"startLine": 1, "startCol": 0, "endLine": 1,
"endCol": 0, "newText": "//header\n"}
resp1 = await apply_edits(DummyContext(), "mcpforunity://path/Assets/Scripts/File.cs", [edit1])
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2,
"endCol": 0, "newText": "//second\n"}
resp2 = await apply_edits(
DummyContext(),
"mcpforunity://path/Assets/Scripts/File.cs",
[edit2],
precondition_sha256=resp1["sha256"],
)
assert calls[1]["precondition_sha256"] == resp1["sha256"]
assert resp2["sha256"] == "hash2"
@pytest.mark.asyncio
async def test_apply_text_edits_forwards_options(monkeypatch):
tools = setup_script_tools()
apply_edits = tools["apply_text_edits"]
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"}
await apply_edits(
DummyContext(),
"mcpforunity://path/Assets/Scripts/File.cs",
[{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}],
options=opts,
)
assert captured["params"].get("options") == opts
@pytest.mark.asyncio
async def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch):
tools = setup_script_tools()
apply_edits = tools["apply_text_edits"]
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(
transport.legacy.unity_connection,
"async_send_command_with_retry",
fake_send,
)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edits = [
{"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"},
{"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"},
]
await apply_edits(
DummyContext(),
"mcpforunity://path/Assets/Scripts/File.cs",
edits,
precondition_sha256="x",
)
opts = captured["params"].get("options", {})
assert opts.get("applyMode") == "atomic"
@pytest.mark.asyncio
async def test_manage_asset_prefab_modify_request(monkeypatch):
tools = setup_asset_tools()
manage_asset = tools["manage_asset"]
captured = {}
async def fake_async(cmd, params, loop=None):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True}
# Patch the async function in the tools module
import services.tools.manage_asset as tools_manage_asset
# Patch both at the module and at the function closure location
monkeypatch.setattr(tools_manage_asset,
"async_send_command_with_retry", fake_async)
# Also patch the globals of the function object (handles dynamically loaded module alias)
manage_asset.__globals__["async_send_command_with_retry"] = fake_async
resp = await manage_asset(
DummyContext(),
action="modify",
path="Assets/Prefabs/Player.prefab",
properties={"hp": 100},
)
assert captured["cmd"] == "manage_asset"
assert captured["params"]["action"] == "modify"
assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab"
assert captured["params"]["properties"] == {"hp": 100}
assert resp["success"] is True
@@ -0,0 +1,270 @@
"""
Tests for stdio-mode custom tool discovery (GitHub issue #837).
Verifies that:
1. sync_tool_visibility_from_unity registers custom tools when extended metadata is present
2. Custom tools are skipped gracefully when metadata is missing (old Unity package)
3. Reconnection flag triggers a background re-sync
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_unity_response(tools, include_extended=True):
"""Build a fake get_tool_states response from Unity."""
tool_list = []
for t in tools:
entry = {
"name": t["name"],
"group": t.get("group", "core"),
"enabled": t.get("enabled", True),
}
if include_extended:
entry.update({
"description": t.get("description", f"Tool: {t['name']}"),
"auto_register": t.get("auto_register", True),
"is_built_in": t.get("is_built_in", True),
"structured_output": t.get("structured_output", False),
"requires_polling": t.get("requires_polling", False),
"poll_action": t.get("poll_action", "status"),
"max_poll_seconds": t.get("max_poll_seconds", 0),
"parameters": t.get("parameters", []),
})
tool_list.append(entry)
return {
"data": {
"tools": tool_list,
"groups": [],
}
}
BUILTIN_TOOL = {
"name": "manage_gameobject",
"group": "core",
"is_built_in": True,
"description": "Manage GameObjects in the scene.",
}
CUSTOM_TOOL = {
"name": "test_ping",
"group": "core",
"is_built_in": False,
"description": "Simple test tool that returns a pong.",
"parameters": [
{"name": "message", "description": "Message to echo", "type": "string", "required": False, "default_value": "pong"},
],
}
# ---------------------------------------------------------------------------
# sync_tool_visibility_from_unity — custom tool registration
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sync_registers_custom_tools():
"""Custom (non-built-in) tools should be registered via CustomToolService."""
response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL])
mock_service = MagicMock()
mock_service.register_global_tools = MagicMock()
with patch(
"transport.legacy.unity_connection.async_send_command_with_retry",
new_callable=AsyncMock,
return_value=response,
), patch(
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
), patch(
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
new_callable=AsyncMock,
), patch(
"services.custom_tool_service.CustomToolService.get_instance",
return_value=mock_service,
):
from services.tools import sync_tool_visibility_from_unity
result = await sync_tool_visibility_from_unity(notify=False)
assert result["synced"] is True
assert result["custom_tool_count"] == 1
# Verify register_global_tools was called with the custom tool
mock_service.register_global_tools.assert_called_once()
registered = mock_service.register_global_tools.call_args[0][0]
assert len(registered) == 1
assert registered[0].name == "test_ping"
assert registered[0].description == "Simple test tool that returns a pong."
assert len(registered[0].parameters) == 1
assert registered[0].parameters[0].name == "message"
@pytest.mark.asyncio
async def test_sync_skips_builtin_tools():
"""Built-in tools should NOT be passed to register_global_tools."""
response = _make_unity_response([BUILTIN_TOOL])
with patch(
"transport.legacy.unity_connection.async_send_command_with_retry",
new_callable=AsyncMock,
return_value=response,
), patch(
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
), patch(
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
new_callable=AsyncMock,
), patch(
"services.custom_tool_service.CustomToolService.get_instance",
) as mock_get_instance:
from services.tools import sync_tool_visibility_from_unity
result = await sync_tool_visibility_from_unity(notify=False)
assert result["synced"] is True
assert result["custom_tool_count"] == 0
# No custom tools → register_global_tools should NOT be called
mock_get_instance.assert_not_called()
@pytest.mark.asyncio
async def test_sync_skips_when_no_extended_metadata():
"""When Unity returns old-format data (no is_built_in), skip custom tool registration."""
response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL], include_extended=False)
with patch(
"transport.legacy.unity_connection.async_send_command_with_retry",
new_callable=AsyncMock,
return_value=response,
), patch(
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
), patch(
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
new_callable=AsyncMock,
), patch(
"services.custom_tool_service.CustomToolService.get_instance",
) as mock_get_instance:
from services.tools import sync_tool_visibility_from_unity
result = await sync_tool_visibility_from_unity(notify=False)
assert result["synced"] is True
assert result["custom_tool_count"] == 0
mock_get_instance.assert_not_called()
@pytest.mark.asyncio
async def test_sync_handles_custom_tool_service_not_initialized():
"""If CustomToolService isn't initialized yet, skip gracefully (no crash)."""
response = _make_unity_response([CUSTOM_TOOL])
with patch(
"transport.legacy.unity_connection.async_send_command_with_retry",
new_callable=AsyncMock,
return_value=response,
), patch(
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
), patch(
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
new_callable=AsyncMock,
), patch(
"services.custom_tool_service.CustomToolService.get_instance",
side_effect=RuntimeError("not initialized"),
):
from services.tools import sync_tool_visibility_from_unity
result = await sync_tool_visibility_from_unity(notify=False)
# Should succeed overall even though custom tool registration failed
assert result["synced"] is True
assert result["custom_tool_count"] == 0
# ---------------------------------------------------------------------------
# Reconnection re-sync trigger
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reconnection_flag_triggers_resync():
"""After reconnection, async_send_command_with_retry should schedule a re-sync."""
mock_conn = MagicMock()
mock_conn._needs_tool_resync = True
mock_conn.instance_id = None
mock_pool = MagicMock()
mock_pool.get_connection.return_value = mock_conn
with patch(
"transport.legacy.unity_connection.send_command_with_retry",
return_value={"success": True, "message": "ok"},
), patch(
"transport.legacy.unity_connection.get_unity_connection_pool",
return_value=mock_pool,
), patch(
"transport.legacy.unity_connection._resync_tools_after_reconnect",
new_callable=AsyncMock,
) as mock_resync:
from transport.legacy.unity_connection import async_send_command_with_retry
result = await async_send_command_with_retry("manage_gameobject", {"action": "list"})
# ensure_future schedules on the event loop; give it a tick to run
await asyncio.sleep(0)
assert result["success"] is True
# Flag should be cleared
assert mock_conn._needs_tool_resync is False
# Re-sync should have been scheduled
mock_resync.assert_awaited_once_with(None)
@pytest.mark.asyncio
async def test_no_resync_for_get_tool_states():
"""get_tool_states itself should NOT trigger re-sync (avoids recursion)."""
mock_conn = MagicMock()
mock_conn._needs_tool_resync = True
mock_pool = MagicMock()
mock_pool.get_connection.return_value = mock_conn
with patch(
"transport.legacy.unity_connection.send_command_with_retry",
return_value={"data": {"tools": []}},
), patch(
"transport.legacy.unity_connection.get_unity_connection_pool",
return_value=mock_pool,
), patch(
"transport.legacy.unity_connection._resync_tools_after_reconnect",
new_callable=AsyncMock,
) as mock_resync:
from transport.legacy.unity_connection import async_send_command_with_retry
await async_send_command_with_retry("get_tool_states", {})
# Flag should be cleared, but no re-sync task should be scheduled
assert mock_conn._needs_tool_resync is False
mock_resync.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_resync_when_not_reconnected():
"""When _needs_tool_resync is False, no re-sync should be scheduled."""
mock_conn = MagicMock()
mock_conn._needs_tool_resync = False
mock_pool = MagicMock()
mock_pool.get_connection.return_value = mock_conn
with patch(
"transport.legacy.unity_connection.send_command_with_retry",
return_value={"success": True},
), patch(
"transport.legacy.unity_connection.get_unity_connection_pool",
return_value=mock_pool,
), patch(
"transport.legacy.unity_connection._resync_tools_after_reconnect",
new_callable=AsyncMock,
) as mock_resync:
from transport.legacy.unity_connection import async_send_command_with_retry
await async_send_command_with_retry("manage_gameobject", {"action": "list"})
mock_resync.assert_not_awaited()
@@ -0,0 +1,62 @@
import os
import importlib
import pytest
def test_endpoint_rejects_non_http(tmp_path, monkeypatch):
# Point data dir to temp to avoid touching real files
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd")
# Import the telemetry module
telemetry = importlib.import_module("core.telemetry")
importlib.reload(telemetry)
tc = telemetry.TelemetryCollector()
# Should have fallen back to default endpoint
assert tc.config.endpoint == tc.config.default_endpoint
def test_config_preferred_then_env_override(tmp_path, monkeypatch):
# Simulate config telemetry endpoint
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.delenv("UNITY_MCP_TELEMETRY_ENDPOINT", raising=False)
# Patch config.telemetry_endpoint via import mocking
cfg_mod = importlib.import_module("src.core.config")
old_endpoint = cfg_mod.config.telemetry_endpoint
cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry"
try:
telemetry = importlib.import_module("core.telemetry")
importlib.reload(telemetry)
tc = telemetry.TelemetryCollector()
# When no env override is set, config endpoint is preferred
assert tc.config.endpoint == "https://example.com/telemetry"
# Env should override config
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT",
"https://override.example/ep")
importlib.reload(telemetry)
tc2 = telemetry.TelemetryCollector()
assert tc2.config.endpoint == "https://override.example/ep"
finally:
cfg_mod.config.telemetry_endpoint = old_endpoint
def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
# Import the telemetry module
telemetry = importlib.import_module("core.telemetry")
importlib.reload(telemetry)
tc1 = telemetry.TelemetryCollector()
first_uuid = tc1._customer_uuid
# Write malformed milestones
tc1.config.milestones_file.write_text("{not-json}", encoding="utf-8")
# Reload collector; UUID should remain same despite bad milestones
importlib.reload(telemetry)
tc2 = telemetry.TelemetryCollector()
assert tc2._customer_uuid == first_uuid
@@ -0,0 +1,65 @@
import logging
import types
import threading
import time
import queue as q
import core.telemetry as telemetry
def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
# Directly attach caplog's handler to the telemetry logger so that
# earlier tests calling logging.basicConfig() can't steal the records
# via a root handler before caplog sees them.
tel_logger = logging.getLogger("unity-mcp-telemetry")
tel_logger.addHandler(caplog.handler)
try:
caplog.set_level("DEBUG", logger="unity-mcp-telemetry")
collector = telemetry.TelemetryCollector()
# Force-enable telemetry regardless of env settings from conftest
collector.config.enabled = True
# Wake existing worker once so it observes the new queue on the next loop
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": -1})
# Replace queue with tiny one to trigger backpressure quickly
small_q = q.Queue(maxsize=2)
collector._queue = small_q
# Give the worker time to finish processing the seeded item and
# re-enter _queue.get() on the new small queue
time.sleep(0.2)
# Make sends slow to build backlog and exercise worker
def slow_send(self, rec):
time.sleep(0.05)
collector._send_telemetry = types.MethodType(slow_send, collector)
# Fire many events quickly; record() should not block even when queue fills
start = time.perf_counter()
for i in range(50):
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": i})
elapsed_ms = (time.perf_counter() - start) * 1000.0
# Should be fast despite backpressure (non-blocking enqueue or drop)
# Threshold set high (500ms) to accommodate CI environments with variable load.
# The key assertion is that 50 record() calls don't block on a full queue;
# even under heavy CI load, non-blocking calls should complete well under 500ms.
assert elapsed_ms < 500.0, f"Took {elapsed_ms:.1f}ms (expected <500ms for non-blocking calls)"
# Allow worker to process some
time.sleep(0.3)
# Verify drops were logged (queue full backpressure)
dropped_logs = [
m for m in caplog.messages if "Telemetry queue full; dropping" in m]
assert len(dropped_logs) >= 1
# Ensure only one worker thread exists and is alive
assert collector._worker.is_alive()
worker_threads = [
t for t in threading.enumerate() if t is collector._worker]
assert len(worker_threads) == 1
finally:
if caplog.handler in tel_logger.handlers:
tel_logger.removeHandler(caplog.handler)
@@ -0,0 +1,116 @@
import importlib
def _get_decorator_module():
# Import the telemetry_decorator module from the MCP for Unity server src
import sys
import pathlib
import types
# Tests can now import directly from parent package
# Remove any previously stubbed module to force real import
sys.modules.pop("core.telemetry_decorator", None)
# Preload a minimal telemetry stub to satisfy telemetry_decorator imports
tel = types.ModuleType("core.telemetry")
class _MilestoneType:
FIRST_TOOL_USAGE = "first_tool_usage"
FIRST_SCRIPT_CREATION = "first_script_creation"
FIRST_SCENE_MODIFICATION = "first_scene_modification"
tel.MilestoneType = _MilestoneType
def _noop(*a, **k):
pass
tel.record_resource_usage = _noop
tel.record_tool_usage = _noop
tel.record_milestone = _noop
tel.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("core.telemetry", tel)
mod = importlib.import_module("core.telemetry_decorator")
# Drop stub to avoid bleed-through into other tests
sys.modules.pop("core.telemetry", None)
# Ensure attributes exist for monkeypatch targets even if not exported
if not hasattr(mod, "record_tool_usage"):
def _noop_record_tool_usage(*a, **k):
pass
mod.record_tool_usage = _noop_record_tool_usage
if not hasattr(mod, "record_milestone"):
def _noop_record_milestone(*a, **k):
pass
mod.record_milestone = _noop_record_milestone
if not hasattr(mod, "_decorator_log_count"):
mod._decorator_log_count = 0
return mod
def test_subaction_extracted_from_keyword(monkeypatch):
td = _get_decorator_module()
captured = {}
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
captured["tool_name"] = tool_name
captured["success"] = success
captured["error"] = error
captured["sub_action"] = sub_action
# Silence milestones/logging in test
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
monkeypatch.setattr(td, "_decorator_log_count", 999)
def dummy_tool(ctx, action: str, name: str = ""):
return {"success": True, "name": name}
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
resp = wrapped(None, action="get_hierarchy", name="Sample")
assert resp["success"] is True
assert captured["tool_name"] == "manage_scene"
assert captured["success"] is True
assert captured["error"] is None
assert captured["sub_action"] == "get_hierarchy"
def test_subaction_extracted_from_positionals(monkeypatch):
td = _get_decorator_module()
captured = {}
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
captured["tool_name"] = tool_name
captured["sub_action"] = sub_action
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
monkeypatch.setattr(td, "_decorator_log_count", 999)
def dummy_tool(ctx, action: str, name: str = ""):
return True
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
_ = wrapped(None, "save", "MyScene")
assert captured["tool_name"] == "manage_scene"
assert captured["sub_action"] == "save"
def test_subaction_none_when_not_present(monkeypatch):
td = _get_decorator_module()
captured = {}
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
captured["tool_name"] = tool_name
captured["sub_action"] = sub_action
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
monkeypatch.setattr(td, "_decorator_log_count", 999)
def dummy_tool_without_action(ctx, name: str):
return 123
wrapped = td.telemetry_tool("apply_text_edits")(dummy_tool_without_action)
_ = wrapped(None, name="X")
assert captured["tool_name"] == "apply_text_edits"
assert captured["sub_action"] is None
@@ -0,0 +1,41 @@
import inspect
# pyright: reportMissingImports=false
def test_manage_scene_signature_includes_paging_params():
import services.tools.manage_scene as mod
sig = inspect.signature(mod.manage_scene)
names = list(sig.parameters.keys())
# get_hierarchy paging/safety params
assert "parent" in names
assert "page_size" in names
assert "cursor" in names
assert "max_nodes" in names
assert "max_depth" in names
assert "max_children_per_node" in names
assert "include_transform" in names
def test_manage_gameobject_signature_excludes_vestigial_params():
"""Paging/find/component params were removed — they belong to separate tools."""
import services.tools.manage_gameobject as mod
sig = inspect.signature(mod.manage_gameobject)
names = list(sig.parameters.keys())
# These params now live on find_gameobjects / manage_components / gameobject_components resource
assert "page_size" not in names
assert "cursor" not in names
assert "max_components" not in names
assert "include_properties" not in names
assert "search_term" not in names
assert "find_all" not in names
assert "search_in_children" not in names
assert "search_inactive" not in names
assert "component_name" not in names
assert "includeNonPublicSerialized" not in names
@@ -0,0 +1,203 @@
from transport.legacy.unity_connection import UnityConnection
import sys
import json
import struct
import socket
import threading
import time
import select
from pathlib import Path
import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server
candidates = [
ROOT / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"MCP for Unity server source not found. Tried:\n" + searched,
allow_module_level=True,
)
# Tests can now import directly from parent package
def start_dummy_server(greeting: bytes, respond_ping: bool = False):
"""Start a minimal TCP server for handshake tests."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
conn.settimeout(1.0)
if greeting:
conn.sendall(greeting)
if respond_ping:
try:
# Read exactly n bytes helper
def _read_exact(n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
break
buf += chunk
return buf
header = _read_exact(8)
if len(header) == 8:
length = struct.unpack(">Q", header)[0]
payload = _read_exact(length)
if payload == b'{"type":"ping"}':
resp = b'{"type":"pong"}'
conn.sendall(struct.pack(">Q", len(resp)) + resp)
except Exception:
pass
time.sleep(0.1)
try:
conn.close()
except Exception:
pass
finally:
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
return port
def start_handshake_enforcing_server():
"""Server that drops connection if client sends data before handshake."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
# If client sends any data before greeting, disconnect (poll briefly)
try:
conn.setblocking(False)
deadline = time.time() + 0.15 # short, reduces race with legitimate clients
while time.time() < deadline:
r, _, _ = select.select([conn], [], [], 0.01)
if r:
try:
peek = conn.recv(1, socket.MSG_PEEK)
except BlockingIOError:
peek = b""
except Exception:
peek = b"\x00"
if peek:
conn.close()
sock.close()
return
# No pre-handshake data observed; send greeting
conn.setblocking(True)
conn.sendall(b"MCP/0.1 FRAMING=1\n")
time.sleep(0.1)
finally:
try:
conn.close()
finally:
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
return port
def test_handshake_requires_framing():
port = start_dummy_server(b"MCP/0.1\n")
conn = UnityConnection(host="127.0.0.1", port=port)
assert conn.connect() is False
assert conn.sock is None
def test_small_frame_ping_pong():
port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True)
conn = UnityConnection(host="127.0.0.1", port=port)
try:
assert conn.connect() is True
assert conn.use_framing is True
payload = b'{"type":"ping"}'
conn.sock.sendall(struct.pack(">Q", len(payload)) + payload)
resp = conn.receive_full_response(conn.sock)
assert json.loads(resp.decode("utf-8"))["type"] == "pong"
finally:
conn.disconnect()
def test_unframed_data_disconnect():
port = start_handshake_enforcing_server()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", port))
sock.settimeout(1.0)
sock.sendall(b"BAD")
time.sleep(0.4)
try:
data = sock.recv(1024)
assert data == b""
except (ConnectionResetError, ConnectionAbortedError):
# Some platforms raise instead of returning empty bytes when the
# server closes the connection after detecting pre-handshake data.
pass
finally:
sock.close()
def test_zero_length_payload_heartbeat():
# Server that sends handshake and a zero-length heartbeat frame followed by a pong payload
import socket
import struct
import threading
import time
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
try:
conn.sendall(b"MCP/0.1 FRAMING=1\n")
time.sleep(0.02)
# Heartbeat frame (length=0)
conn.sendall(struct.pack(">Q", 0))
time.sleep(0.02)
# Real payload frame
payload = b'{"type":"pong"}'
conn.sendall(struct.pack(">Q", len(payload)) + payload)
time.sleep(0.02)
finally:
try:
conn.close()
except Exception:
pass
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
conn = UnityConnection(host="127.0.0.1", port=port)
try:
assert conn.connect() is True
# Receive should skip heartbeat and return the pong payload (or empty if only heartbeats seen)
resp = conn.receive_full_response(conn.sock)
assert resp in (b'{"type":"pong"}', b"")
finally:
conn.disconnect()
@@ -0,0 +1,113 @@
"""End-to-end-ish smoke tests for transport routing paths."""
from __future__ import annotations
import pytest
from core.config import config
from transport import unity_transport
@pytest.mark.asyncio
async def test_http_local_smoke(monkeypatch):
"""HTTP local should route through PluginHub without requiring user_id."""
monkeypatch.setattr(config, "transport_mode", "http")
monkeypatch.setattr(config, "http_remote_hosted", False)
async def fake_send_command_for_instance(_instance, _command, _params, **_kwargs):
return {"status": "success", "result": {"message": "ok", "data": {"via": "http"}}}
monkeypatch.setattr(
unity_transport.PluginHub,
"send_command_for_instance",
fake_send_command_for_instance,
)
async def _unused_send_fn(*_args, **_kwargs):
raise AssertionError("send_fn should not be used in HTTP mode")
result = await unity_transport.send_with_unity_instance(
_unused_send_fn, None, "ping", {}
)
assert result["success"] is True
assert result["data"] == {"via": "http"}
@pytest.mark.asyncio
async def test_http_remote_smoke(monkeypatch):
"""HTTP remote-hosted should route through PluginHub when user_id is provided."""
monkeypatch.setattr(config, "transport_mode", "http")
monkeypatch.setattr(config, "http_remote_hosted", True)
async def fake_send_command_for_instance(_instance, _command, _params, **_kwargs):
return {"status": "success", "result": {"data": {"via": "http-remote"}}}
monkeypatch.setattr(
unity_transport.PluginHub,
"send_command_for_instance",
fake_send_command_for_instance,
)
async def _unused_send_fn(*_args, **_kwargs):
raise AssertionError("send_fn should not be used in HTTP mode")
result = await unity_transport.send_with_unity_instance(
_unused_send_fn, None, "ping", {}, user_id="user-1"
)
assert result["success"] is True
assert result["data"] == {"via": "http-remote"}
@pytest.mark.asyncio
async def test_http_forwards_retry_on_reload(monkeypatch):
"""HTTP transport should pass retry_on_reload through to PluginHub."""
monkeypatch.setattr(config, "transport_mode", "http")
monkeypatch.setattr(config, "http_remote_hosted", False)
captured: dict[str, object] = {}
async def fake_send_command_for_instance(_instance, _command, _params, **kwargs):
captured.update(kwargs)
return {"status": "success", "result": {"data": {"via": "http"}}}
monkeypatch.setattr(
unity_transport.PluginHub,
"send_command_for_instance",
fake_send_command_for_instance,
)
async def _unused_send_fn(*_args, **_kwargs):
raise AssertionError("send_fn should not be used in HTTP mode")
result = await unity_transport.send_with_unity_instance(
_unused_send_fn,
None,
"manage_script",
{"action": "edit"},
retry_on_reload=False,
)
assert result["success"] is True
assert captured.get("retry_on_reload") is False
@pytest.mark.asyncio
async def test_stdio_smoke(monkeypatch):
"""Stdio transport should call the legacy send fn with instance_id."""
monkeypatch.setattr(config, "transport_mode", "stdio")
monkeypatch.setattr(config, "http_remote_hosted", False)
async def fake_send_fn(command_type, params, *, instance_id=None, **_kwargs):
return {
"success": True,
"data": {"via": "stdio", "command": command_type, "instance": instance_id, "params": params},
}
result = await unity_transport.send_with_unity_instance(
fake_send_fn, "Project@abcd1234", "ping", {"x": 1}
)
assert result["success"] is True
assert result["data"]["via"] == "stdio"
assert result["data"]["instance"] == "Project@abcd1234"
@@ -0,0 +1,30 @@
import pytest
from .test_helpers import DummyContext, setup_script_tools
@pytest.mark.asyncio
async def test_validate_script_returns_counts(monkeypatch):
tools = setup_script_tools()
validate_script = tools["validate_script"]
async def fake_send(cmd, params, **kwargs):
return {
"success": True,
"data": {
"diagnostics": [
{"severity": "warning"},
{"severity": "error"},
{"severity": "fatal"},
]
},
}
# Patch the send_command_with_retry function at the module level where it's imported
import transport.legacy.unity_connection
monkeypatch.setattr(transport.legacy.unity_connection,
"async_send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
resp = await validate_script(DummyContext(), uri="mcpforunity://path/Assets/Scripts/A.cs")
assert resp == {"success": True, "data": {"warnings": 1, "errors": 2}}
@@ -0,0 +1,239 @@
import asyncio
import os
import pytest
from services.tools.refresh_unity import is_reloading_rejection
from .test_helpers import DummyContext
@pytest.mark.asyncio
async def test_returns_immediately_in_pytest(monkeypatch):
"""_in_pytest() detects PYTEST_CURRENT_TEST and returns (True, 0.0) immediately."""
# PYTEST_CURRENT_TEST is set by pytest automatically, so this should short-circuit.
from services.tools.refresh_unity import wait_for_editor_ready
ctx = DummyContext()
ready, elapsed = await wait_for_editor_ready(ctx, timeout_s=5.0)
assert ready is True
assert elapsed == 0.0
@pytest.mark.asyncio
async def test_polls_until_ready(monkeypatch):
"""When not in pytest, the helper polls get_editor_state until ready_for_tools."""
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.tools import refresh_unity as mod
call_count = 0
async def fake_get_editor_state(ctx):
nonlocal call_count
call_count += 1
if call_count < 3:
return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}}
return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}}
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
ctx = DummyContext()
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=10.0)
assert ready is True
assert call_count >= 3
assert elapsed > 0
@pytest.mark.asyncio
async def test_timeout_returns_false(monkeypatch):
"""When editor never becomes ready, returns (False, ~timeout)."""
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.tools import refresh_unity as mod
async def fake_get_editor_state(ctx):
return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}}
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
ctx = DummyContext()
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=0.6)
assert ready is False
assert elapsed >= 0.5
@pytest.mark.asyncio
async def test_stale_only_treated_as_ready(monkeypatch):
"""If the only blocking reason is stale_status, consider ready."""
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.tools import refresh_unity as mod
async def fake_get_editor_state(ctx):
return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["stale_status"]}}}
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
ctx = DummyContext()
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=5.0)
assert ready is True
@pytest.mark.asyncio
async def test_exception_during_poll_keeps_trying(monkeypatch):
"""If get_editor_state throws, the helper keeps polling until ready."""
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
from services.tools import refresh_unity as mod
call_count = 0
async def fake_get_editor_state(ctx):
nonlocal call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Unity disconnected")
return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}}
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
ctx = DummyContext()
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=10.0)
assert ready is True
assert call_count >= 3
def test_is_reloading_rejection_true():
"""Detects a reloading rejection response."""
resp = {"success": False, "error": "Unity is reloading", "data": {"reason": "reloading"}, "hint": "retry"}
assert is_reloading_rejection(resp) is True
def test_is_reloading_rejection_false_on_success():
assert is_reloading_rejection({"success": True, "data": {"reason": "reloading"}, "hint": "retry"}) is False
def test_is_reloading_rejection_false_on_other_error():
assert is_reloading_rejection({"success": False, "error": "timeout", "data": {}, "hint": "retry"}) is False
def test_is_reloading_rejection_false_on_non_dict():
assert is_reloading_rejection("some string") is False
assert is_reloading_rejection(None) is False
# --- is_connection_lost_after_send tests ---
from services.tools.refresh_unity import is_connection_lost_after_send
def test_connection_lost_on_connection_closed():
resp = {"success": False, "error": "Connection closed before reading expected bytes"}
assert is_connection_lost_after_send(resp) is True
def test_connection_lost_on_disconnected():
resp = {"success": False, "error": "Unity disconnected"}
assert is_connection_lost_after_send(resp) is True
def test_connection_lost_on_aborted():
resp = {"success": False, "error": "Connection aborted"}
assert is_connection_lost_after_send(resp) is True
def test_connection_lost_false_on_success():
resp = {"success": True, "error": "Connection closed before reading expected bytes"}
assert is_connection_lost_after_send(resp) is False
def test_connection_lost_false_on_other_error():
resp = {"success": False, "error": "timeout"}
assert is_connection_lost_after_send(resp) is False
def test_connection_lost_false_on_non_dict():
assert is_connection_lost_after_send("some string") is False
assert is_connection_lost_after_send(None) is False
# --- send_mutation tests ---
from services.tools.refresh_unity import send_mutation
@pytest.mark.asyncio
async def test_send_mutation_returns_success_directly(monkeypatch):
"""Normal success response is returned as-is."""
from services.tools import refresh_unity as mod
async def fake_send(*args, **kwargs):
return {"success": True, "data": {"ok": True}}
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
ctx = DummyContext()
resp = await send_mutation(ctx, None, "manage_script", {"action": "create"})
assert resp == {"success": True, "data": {"ok": True}}
@pytest.mark.asyncio
async def test_send_mutation_retries_on_reloading_rejection(monkeypatch):
"""Reloading rejection triggers one retry after wait."""
from services.tools import refresh_unity as mod
call_count = 0
async def fake_send(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return {"success": False, "data": {"reason": "reloading"}, "hint": "retry"}
return {"success": True, "data": {"retried": True}}
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
ctx = DummyContext()
resp = await send_mutation(ctx, None, "manage_script", {"action": "create"})
assert resp.get("success") is True
assert call_count == 2
@pytest.mark.asyncio
async def test_send_mutation_calls_verify_on_connection_lost(monkeypatch):
"""Connection lost triggers verify callback."""
from services.tools import refresh_unity as mod
async def fake_send(*args, **kwargs):
return {"success": False, "error": "Connection closed before reading expected bytes"}
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
verify_called = False
async def fake_verify():
nonlocal verify_called
verify_called = True
return {"success": True, "message": "Verified!"}
ctx = DummyContext()
resp = await send_mutation(ctx, None, "manage_script", {}, verify_after_disconnect=fake_verify)
assert verify_called
assert resp == {"success": True, "message": "Verified!"}
@pytest.mark.asyncio
async def test_send_mutation_keeps_error_when_verify_returns_none(monkeypatch):
"""When verify callback returns None, original error is preserved."""
from services.tools import refresh_unity as mod
async def fake_send(*args, **kwargs):
return {"success": False, "error": "Connection closed before reading expected bytes"}
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
async def fake_verify():
return None
ctx = DummyContext()
resp = await send_mutation(ctx, None, "manage_script", {}, verify_after_disconnect=fake_verify)
assert resp.get("success") is False