diff --git a/server.py b/server.py index 526c4aa..80fb8a8 100644 --- a/server.py +++ b/server.py @@ -112,7 +112,44 @@ def __init__(self, source_root: Path): def _detect_scope(self) -> str | None: from graph_enrich import detect_microservice_from_path - return detect_microservice_from_path(Path.cwd(), self.source_root) + + candidate = detect_microservice_from_path(Path.cwd(), self.source_root) + if candidate is None: + return None + # Only auto-scope to a microservice that actually has indexed code. + # detect_microservice_from_path can mislabel a non-microservice + # top-level child of source_root — most importantly the config/context + # directory the MCP server is launched from (no build marker, no + # source) — via its "first path segment under root" fallback. Scoping + # every query to such a name yields zero matches, so all tools return + # empty. A real microservice the operator is working in is, by + # definition, present in the index, so validating against the indexed + # set cannot suppress a legitimate scope. When the index is unreadable + # (empty known set) we keep the detected candidate rather than silently + # disabling auto-scope on a transient graph error. + known = self._indexed_microservices() + if known and candidate not in known: + return None + return candidate + + def _indexed_microservices(self) -> set[str]: + """Microservice names that have indexed type symbols. + + Graph-only source of truth: the graph is always built alongside Lance, + and a Lance-only index (no graph) is not a supported state. Any failure + (graph missing, open error, empty index) returns an empty set, which + ``_detect_scope`` treats as "cannot validate — keep detection". + """ + try: + if not LadybugGraph.exists(): + return set() + # LadybugGraph.get() opens the DB and runs meta(); it can raise + # (e.g. RuntimeError on ontology-version mismatch). Caught here -> + # empty set -> _detect_scope keeps the detected scope. + counts = LadybugGraph.get().microservice_counts() + return {name for name in counts if name} + except Exception: + return set() def _log_detection(self) -> None: if self.default_scope: diff --git a/tests/test_microservice_scope.py b/tests/test_microservice_scope.py index d8ccb8d..01b1418 100644 --- a/tests/test_microservice_scope.py +++ b/tests/test_microservice_scope.py @@ -150,3 +150,121 @@ def test_detect_scope_integration(self, tmp_path): nf = NodeFilter(role="CONTROLLER") result = mgr.apply_auto_scope(nf) assert result is nf + + +class TestScopeManagerAutoScopeValidation: + """Auto-scope must not fire for a microservice absent from the index. + + Regression: launching the MCP server from the config/context directory (a + top-level child of source_root with no build marker and no source) made + ``detect_microservice_from_path`` return that directory's name via its + "first path segment under root" fallback. ScopeManager then auto-scoped + every query to a microservice with zero indexed rows, so all tools + returned empty results. The detected scope is now validated against the + indexed microservice set; a candidate with no indexed code is suppressed. + """ + + @staticmethod + def _stub_index(monkeypatch, microservices: set[str]) -> None: + """Make ScopeManager._indexed_microservices() see a fake graph.""" + import server + + class _FakeGraph: + def microservice_counts(self): + return {name: 1 for name in microservices} + + monkeypatch.setattr( + server.LadybugGraph, "exists", lambda db_path=None: len(microservices) > 0 + ) + monkeypatch.setattr( + server.LadybugGraph, "get", lambda db_path=None: _FakeGraph() + ) + + def test_context_dir_not_detected_as_microservice(self, tmp_path, monkeypatch): + """Launching from a codeless context dir must NOT auto-scope (the bug).""" + from server import ScopeManager + + # Reported layout: source_root holds both the context dir and a real + # microservice; the server is launched from the context dir. + context_dir = tmp_path / "bank-chat-context" + context_dir.mkdir() + ms_dir = tmp_path / "microservice-a" + (ms_dir / "src").mkdir(parents=True) + (ms_dir / "pom.xml").write_text("") + + # The index only knows the real microservice, not the context dir. + self._stub_index(monkeypatch, {"microservice-a"}) + monkeypatch.chdir(context_dir) + + mgr = ScopeManager(tmp_path) + assert mgr.default_scope is None + + def test_real_microservice_dir_still_scopes(self, tmp_path, monkeypatch): + """Launching from inside an indexed microservice keeps auto-scope.""" + from server import ScopeManager + + ms_dir = tmp_path / "microservice-a" + (ms_dir / "src").mkdir(parents=True) + (ms_dir / "pom.xml").write_text("") + + self._stub_index(monkeypatch, {"microservice-a"}) + monkeypatch.chdir(ms_dir) + + mgr = ScopeManager(tmp_path) + assert mgr.default_scope == "microservice-a" + + def test_empty_index_keeps_detection(self, tmp_path, monkeypatch): + """When the index is missing (exists()->False), keep detection.""" + from server import ScopeManager + + ms_dir = tmp_path / "microservice-a" + ms_dir.mkdir() + (ms_dir / "pom.xml").write_text("") + + # Graph missing -> exists() False -> empty known set. + self._stub_index(monkeypatch, set()) + monkeypatch.chdir(ms_dir) + + mgr = ScopeManager(tmp_path) + assert mgr.default_scope == "microservice-a" + + def test_empty_graph_present_keeps_detection(self, tmp_path, monkeypatch): + """Graph present but reporting no microservices also keeps detection. + + Covers the exists()->True branch with empty microservice_counts() — + distinct from test_empty_index_keeps_detection (missing graph). Both + paths must converge to keeping the detected scope rather than silently + disabling auto-scope. + """ + import server + from server import ScopeManager + + ms_dir = tmp_path / "microservice-a" + ms_dir.mkdir() + (ms_dir / "pom.xml").write_text("") + + class _EmptyGraph: + def microservice_counts(self): + return {} + + monkeypatch.setattr(server.LadybugGraph, "exists", lambda db_path=None: True) + monkeypatch.setattr(server.LadybugGraph, "get", lambda db_path=None: _EmptyGraph()) + monkeypatch.chdir(ms_dir) + + mgr = ScopeManager(tmp_path) + assert mgr.default_scope == "microservice-a" + + def test_indexed_microservices_extracts_nonempty_keys(self, tmp_path, monkeypatch): + """_indexed_microservices drops empty-string buckets, keeps the rest.""" + import server + from server import ScopeManager + + class _FakeGraph: + def microservice_counts(self): + return {"chat-core": 140, "chat-assign": 50, "": 3} + + monkeypatch.setattr(server.LadybugGraph, "exists", lambda db_path=None: True) + monkeypatch.setattr(server.LadybugGraph, "get", lambda db_path=None: _FakeGraph()) + + mgr = ScopeManager(tmp_path) + assert mgr._indexed_microservices() == {"chat-core", "chat-assign"}