OpenSpace/tests/test_mcp_proxy_runtime.py

257 lines
9 KiB
Python

from __future__ import annotations
import asyncio
import threading
from pathlib import Path
from openspace import mcp_proxy
from openspace import shared_mcp_runtime
from openspace.mcp_tool_registration import register_main_tools
def test_proxy_mode_defaults_follow_split_rollout(monkeypatch) -> None:
monkeypatch.delenv("OPENSPACE_MCP_PROXY_MODE", raising=False)
assert mcp_proxy._proxy_mode_for("main") == "daemon"
assert mcp_proxy._proxy_mode_for("evolution") == "daemon"
monkeypatch.setenv("OPENSPACE_MCP_PROXY_MODE", "daemon")
assert mcp_proxy._proxy_mode_for("main") == "daemon"
assert mcp_proxy._proxy_mode_for("evolution") == "daemon"
def test_proxy_idle_timeout_prefers_proxy_specific_env(monkeypatch) -> None:
monkeypatch.setenv("OPENSPACE_MCP_PROXY_IDLE_TIMEOUT_SECONDS", "7")
monkeypatch.setenv("OPENSPACE_MCP_IDLE_TIMEOUT_SECONDS", "900")
assert mcp_proxy._proxy_idle_timeout_seconds() == 7
def test_proxy_idle_timeout_defaults_to_180(monkeypatch) -> None:
monkeypatch.delenv("OPENSPACE_MCP_PROXY_IDLE_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("OPENSPACE_MCP_IDLE_TIMEOUT_SECONDS", raising=False)
assert mcp_proxy._proxy_idle_timeout_seconds() == 180
def test_proxy_registration_is_lazy(monkeypatch) -> None:
async def _fail_if_called(server_kind):
raise AssertionError(f"ensure_daemon should not run during tool registration ({server_kind})")
monkeypatch.setattr(mcp_proxy, "ensure_daemon", _fail_if_called)
mcp = mcp_proxy._build_fastmcp("main")
register_main_tools(mcp, mcp_proxy._MainProxyImplementation())
assert {tool.name for tool in mcp._tool_manager.list_tools()} == {
"execute_task",
"search_skills",
"fix_skill",
"upload_skill",
}
def test_compute_daemon_identity_normalizes_repo_workspace(monkeypatch) -> None:
repo_root = Path(__file__).resolve().parents[1]
nested_path = repo_root / "openspace"
monkeypatch.setattr(
shared_mcp_runtime,
"build_llm_kwargs",
lambda model: ("resolved-model", {"api_base": "http://unit.test/v1"}),
)
monkeypatch.setattr(shared_mcp_runtime, "build_grounding_config_path", lambda: None)
monkeypatch.setattr(
shared_mcp_runtime,
"get_agent_config",
lambda name: {"backend_scope": ["mcp", "shell"]},
)
monkeypatch.delenv("OPENSPACE_BACKEND_SCOPE", raising=False)
monkeypatch.delenv("OPENSPACE_HOST_SKILL_DIRS", raising=False)
monkeypatch.setenv("OPENSPACE_WORKSPACE", str(repo_root))
root_identity = shared_mcp_runtime.compute_daemon_identity("main")
monkeypatch.setenv("OPENSPACE_WORKSPACE", str(nested_path))
nested_identity = shared_mcp_runtime.compute_daemon_identity("main")
assert root_identity.workspace == str(repo_root.resolve())
assert nested_identity.workspace == str(repo_root.resolve())
assert root_identity.instance_key == nested_identity.instance_key
def test_compute_daemon_identity_changes_when_skill_dirs_change(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(
shared_mcp_runtime,
"build_llm_kwargs",
lambda model: ("resolved-model", {"api_base": "http://unit.test/v1"}),
)
monkeypatch.setattr(shared_mcp_runtime, "build_grounding_config_path", lambda: None)
monkeypatch.setattr(
shared_mcp_runtime,
"get_agent_config",
lambda name: {"backend_scope": ["shell", "mcp"]},
)
monkeypatch.setenv("OPENSPACE_WORKSPACE", str(tmp_path))
monkeypatch.delenv("OPENSPACE_BACKEND_SCOPE", raising=False)
first = tmp_path / "skills-a"
second = tmp_path / "skills-b"
first.mkdir()
second.mkdir()
monkeypatch.setenv("OPENSPACE_HOST_SKILL_DIRS", str(first))
first_identity = shared_mcp_runtime.compute_daemon_identity("main")
monkeypatch.setenv("OPENSPACE_HOST_SKILL_DIRS", f"{first},{second}")
second_identity = shared_mcp_runtime.compute_daemon_identity("main")
assert first_identity.host_skill_dirs == (str(first.resolve()),)
assert second_identity.host_skill_dirs == (
str(first.resolve()),
str(second.resolve()),
)
assert first_identity.instance_key != second_identity.instance_key
async def _ready_probe(record):
return True
def test_ensure_daemon_marks_main_ready_but_not_warmed(monkeypatch, tmp_path) -> None:
identity = shared_mcp_runtime.MCPDaemonIdentity(
server_kind="main",
workspace=str(tmp_path),
resolved_model="model",
llm_kwargs_fingerprint="llm",
backend_scope=("shell",),
host_skill_dirs=(str(tmp_path),),
grounding_config_fingerprint="cfg",
instance_key="main-key",
state_dir=str(tmp_path),
)
monkeypatch.setattr(shared_mcp_runtime, "compute_daemon_identity", lambda kind: identity)
monkeypatch.setattr(shared_mcp_runtime, "_pick_free_port", lambda: 12345)
monkeypatch.setattr(shared_mcp_runtime, "_spawn_daemon", lambda ident, port: shared_mcp_runtime.MCPDaemonRecord(
server_kind="main",
instance_key=ident.instance_key,
pid=4321,
port=port,
workspace=ident.workspace,
resolved_model=ident.resolved_model,
llm_kwargs_fingerprint=ident.llm_kwargs_fingerprint,
backend_scope=list(ident.backend_scope),
host_skill_dirs=list(ident.host_skill_dirs),
grounding_config_fingerprint=ident.grounding_config_fingerprint,
started_at=1.0,
log_path=str(identity.log_path),
))
monkeypatch.setattr(shared_mcp_runtime, "_wait_until_ready", _ready_probe)
monkeypatch.setattr(shared_mcp_runtime, "_pid_exists", lambda pid: True)
record = asyncio.run(shared_mcp_runtime.ensure_daemon("main"))
assert record.ready is True
assert record.warmed is False
assert record.ready_at is not None
assert record.warmed_at is None
def test_update_current_daemon_status_marks_warmed(monkeypatch, tmp_path) -> None:
metadata_path = tmp_path / "main-key.json"
lock_path = tmp_path / "main-key.lock"
record = shared_mcp_runtime.MCPDaemonRecord(
server_kind="main",
instance_key="key",
pid=4321,
port=12345,
workspace=str(tmp_path),
resolved_model="model",
llm_kwargs_fingerprint="llm",
backend_scope=["shell"],
host_skill_dirs=[str(tmp_path)],
grounding_config_fingerprint="cfg",
started_at=1.0,
log_path=str(tmp_path / "main-key.log"),
ready=True,
warmed=False,
ready_at=2.0,
)
shared_mcp_runtime._write_record(metadata_path, record)
lock_path.touch()
monkeypatch.setenv("OPENSPACE_MCP_INSTANCE_KEY", "key")
monkeypatch.setenv("OPENSPACE_MCP_DAEMON_STATE_DIR", str(tmp_path))
updated = shared_mcp_runtime.update_current_daemon_status(
"main",
warmed=True,
warmup_error=None,
)
assert updated is not None
assert updated.ready is True
assert updated.warmed is True
assert updated.warmed_at is not None
def test_ensure_daemon_rejects_root_workspace(monkeypatch) -> None:
identity = shared_mcp_runtime.MCPDaemonIdentity(
server_kind="evolution",
workspace="/",
resolved_model="model",
llm_kwargs_fingerprint="llm",
backend_scope=("shell",),
host_skill_dirs=(),
grounding_config_fingerprint="cfg",
instance_key="root-key",
state_dir="/tmp/openspace-state",
)
monkeypatch.setattr(shared_mcp_runtime, "compute_daemon_identity", lambda kind: identity)
monkeypatch.setattr(
shared_mcp_runtime,
"_spawn_daemon",
lambda identity, port: (_ for _ in ()).throw(AssertionError("spawn should not be reached")),
)
try:
asyncio.run(shared_mcp_runtime.ensure_daemon("evolution"))
except RuntimeError as exc:
assert "workspace" in str(exc)
else:
raise AssertionError("ensure_daemon should reject workspace=/")
def test_proxy_idle_watchdog_exits_only_after_idle(monkeypatch) -> None:
shutdown_reasons: list[str] = []
sleep_calls = {"count": 0}
now = {"value": 100.0}
monkeypatch.setattr(mcp_proxy.time, "monotonic", lambda: now["value"])
monkeypatch.setattr(
mcp_proxy.time,
"sleep",
lambda seconds: sleep_calls.__setitem__("count", sleep_calls["count"] + 1) or now.__setitem__("value", now["value"] + seconds),
)
monkeypatch.setattr(mcp_proxy, "_begin_proxy_shutdown", lambda reason: shutdown_reasons.append(reason))
mcp_proxy._proxy_activity_lock = threading.Lock()
mcp_proxy._proxy_active_request_count = 1
mcp_proxy._proxy_last_activity_at = 100.0
def _drop_activity():
if sleep_calls["count"] == 1:
mcp_proxy._proxy_active_request_count = 0
mcp_proxy._proxy_last_activity_at = now["value"]
original_sleep = mcp_proxy.time.sleep
def _sleep(seconds):
original_sleep(seconds)
_drop_activity()
monkeypatch.setattr(mcp_proxy.time, "sleep", _sleep)
mcp_proxy._proxy_idle_watchdog_loop(5)
assert shutdown_reasons == ["idle timeout after 5.0s"]