diff --git a/.gitignore b/.gitignore index 27e8dfb..1661531 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,11 @@ tests/skill_engine/evolution/* !tests/cloud/ tests/cloud/* !tests/cloud/test_upload_trust.py +!tests/entrypoints/ +tests/entrypoints/* +!tests/entrypoints/mcp/ +tests/entrypoints/mcp/* +!tests/entrypoints/mcp/test_server_search.py scripts/ # Local agent/project memory diff --git a/openspace/entrypoints/mcp/server.py b/openspace/entrypoints/mcp/server.py index c56f64c..45ec489 100644 --- a/openspace/entrypoints/mcp/server.py +++ b/openspace/entrypoints/mcp/server.py @@ -231,6 +231,46 @@ async def _get_runtime_store(*, required: bool = True): return None +async def _get_local_search_context(): + """Return local skills without booting the full agent runtime for search. + + ``search_skills`` is a discovery-only MCP tool. Initializing OpenSpace here + also initializes grounding providers, memory, evolution, and model config, + which can take longer than a normal MCP tool timeout on Windows. Reuse the + runtime registry when it already exists; otherwise build the same canonical + registry directly and omit optional quality-store enrichment. + """ + + if _openspace_instance is not None and _openspace_instance.is_initialized(): + host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") + if host_skill_dirs_raw: + env_dirs = [ + directory.strip() + for directory in host_skill_dirs_raw.split(",") + if directory.strip() + ] + if env_dirs: + await _auto_register_skill_dirs(env_dirs) + + registry = _openspace_instance.get_skill_registry() + if not registry: + return None, None + store = _openspace_instance.get_skill_store() + if store and getattr(store, "_closed", False): + store = None + return registry.list_skills(), store + + from openspace.runtime.skill_registry import build_skill_registry + + registry = await asyncio.to_thread( + build_skill_registry, + workspace_dir=os.environ.get("OPENSPACE_WORKSPACE"), + ) + if not registry: + return None, None + return registry.list_skills(), None + + async def _get_cloud_mapping_store(): from openspace.cloud.local_mapping import CloudLocalMappingStore @@ -818,21 +858,7 @@ async def search_skills( if not q: return _json_ok({"results": [], "count": 0}) - # Re-scan host skill directories so newly created skills are searchable. - local_skills = None - store = None - openspace = await _get_openspace() - - host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") - if host_skill_dirs_raw: - env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] - if env_dirs: - await _auto_register_skill_dirs(env_dirs) - - registry = openspace.get_skill_registry() - if registry: - local_skills = registry.list_skills() - store = await _get_runtime_store(required=False) + local_skills, store = await _get_local_search_context() results = await hybrid_search_skills( query=q, diff --git a/tests/entrypoints/mcp/test_server_search.py b/tests/entrypoints/mcp/test_server_search.py new file mode 100644 index 0000000..032936e --- /dev/null +++ b/tests/entrypoints/mcp/test_server_search.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from openspace.entrypoints.mcp import server + + +class _Registry: + def __init__(self, skills): + self._skills = skills + + def list_skills(self): + return self._skills + + +@pytest.mark.asyncio +async def test_local_search_context_builds_registry_without_full_runtime( + monkeypatch, +): + expected = [SimpleNamespace(skill_id="skill-1")] + calls = [] + + def build_registry(*, workspace_dir=None): + calls.append(workspace_dir) + return _Registry(expected) + + async def fail_if_full_runtime_starts(): + raise AssertionError( + "local skill search must not initialize OpenSpace" + ) + + monkeypatch.setattr(server, "_openspace_instance", None) + monkeypatch.setattr(server, "_get_openspace", fail_if_full_runtime_starts) + monkeypatch.setenv("OPENSPACE_WORKSPACE", "test-workspace") + monkeypatch.setattr( + "openspace.runtime.skill_registry.build_skill_registry", + build_registry, + ) + + skills, store = await server._get_local_search_context() + + assert skills == expected + assert store is None + assert calls == ["test-workspace"] + + +@pytest.mark.asyncio +async def test_local_search_context_reuses_initialized_runtime(monkeypatch): + expected = [SimpleNamespace(skill_id="skill-1")] + store = SimpleNamespace(_closed=False) + runtime = SimpleNamespace( + is_initialized=lambda: True, + get_skill_registry=lambda: _Registry(expected), + get_skill_store=lambda: store, + ) + + monkeypatch.setattr(server, "_openspace_instance", runtime) + monkeypatch.delenv("OPENSPACE_HOST_SKILL_DIRS", raising=False) + + skills, actual_store = await server._get_local_search_context() + + assert skills == expected + assert actual_store is store + + +@pytest.mark.asyncio +async def test_local_search_context_ignores_closed_runtime_store(monkeypatch): + runtime = SimpleNamespace( + is_initialized=lambda: True, + get_skill_registry=lambda: _Registry([]), + get_skill_store=lambda: SimpleNamespace(_closed=True), + ) + + monkeypatch.setattr(server, "_openspace_instance", runtime) + monkeypatch.delenv("OPENSPACE_HOST_SKILL_DIRS", raising=False) + + skills, store = await server._get_local_search_context() + + assert skills == [] + assert store is None