Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
118 changes: 118 additions & 0 deletions tests/test_microservice_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<project/>")

# 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("<project/>")

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("<project/>")

# 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("<project/>")

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"}
Loading