Skip to content

Commit 8512eb4

Browse files
HumanBean17claude
andauthored
fix(mcp): don't auto-scope to a microservice absent from the index (#331)
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 dir 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, even though the index was intact and path resolution was correct (reproduced with the bank-chat fixture: search returned [] from bank-chat-context/ but real hits from the parent). Validate the detected scope against the indexed microservice set (ScopeManager._indexed_microservices -> graph microservice_counts); a candidate with no indexed code is suppressed to None (system scope). A real microservice the operator is working in is, by definition, present in the index, so this cannot suppress a legitimate scope. An empty or unreadable index keeps the detected candidate (never silently disables auto-scope). Query-time only: no schema, ontology, embedding, or env-var change; no reindex required. Co-authored-by: Claude <noreply@anthropic.com>
1 parent a5427f9 commit 8512eb4

2 files changed

Lines changed: 156 additions & 1 deletion

File tree

server.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,44 @@ def __init__(self, source_root: Path):
112112

113113
def _detect_scope(self) -> str | None:
114114
from graph_enrich import detect_microservice_from_path
115-
return detect_microservice_from_path(Path.cwd(), self.source_root)
115+
116+
candidate = detect_microservice_from_path(Path.cwd(), self.source_root)
117+
if candidate is None:
118+
return None
119+
# Only auto-scope to a microservice that actually has indexed code.
120+
# detect_microservice_from_path can mislabel a non-microservice
121+
# top-level child of source_root — most importantly the config/context
122+
# directory the MCP server is launched from (no build marker, no
123+
# source) — via its "first path segment under root" fallback. Scoping
124+
# every query to such a name yields zero matches, so all tools return
125+
# empty. A real microservice the operator is working in is, by
126+
# definition, present in the index, so validating against the indexed
127+
# set cannot suppress a legitimate scope. When the index is unreadable
128+
# (empty known set) we keep the detected candidate rather than silently
129+
# disabling auto-scope on a transient graph error.
130+
known = self._indexed_microservices()
131+
if known and candidate not in known:
132+
return None
133+
return candidate
134+
135+
def _indexed_microservices(self) -> set[str]:
136+
"""Microservice names that have indexed type symbols.
137+
138+
Graph-only source of truth: the graph is always built alongside Lance,
139+
and a Lance-only index (no graph) is not a supported state. Any failure
140+
(graph missing, open error, empty index) returns an empty set, which
141+
``_detect_scope`` treats as "cannot validate — keep detection".
142+
"""
143+
try:
144+
if not LadybugGraph.exists():
145+
return set()
146+
# LadybugGraph.get() opens the DB and runs meta(); it can raise
147+
# (e.g. RuntimeError on ontology-version mismatch). Caught here ->
148+
# empty set -> _detect_scope keeps the detected scope.
149+
counts = LadybugGraph.get().microservice_counts()
150+
return {name for name in counts if name}
151+
except Exception:
152+
return set()
116153

117154
def _log_detection(self) -> None:
118155
if self.default_scope:

tests/test_microservice_scope.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,121 @@ def test_detect_scope_integration(self, tmp_path):
150150
nf = NodeFilter(role="CONTROLLER")
151151
result = mgr.apply_auto_scope(nf)
152152
assert result is nf
153+
154+
155+
class TestScopeManagerAutoScopeValidation:
156+
"""Auto-scope must not fire for a microservice absent from the index.
157+
158+
Regression: launching the MCP server from the config/context directory (a
159+
top-level child of source_root with no build marker and no source) made
160+
``detect_microservice_from_path`` return that directory's name via its
161+
"first path segment under root" fallback. ScopeManager then auto-scoped
162+
every query to a microservice with zero indexed rows, so all tools
163+
returned empty results. The detected scope is now validated against the
164+
indexed microservice set; a candidate with no indexed code is suppressed.
165+
"""
166+
167+
@staticmethod
168+
def _stub_index(monkeypatch, microservices: set[str]) -> None:
169+
"""Make ScopeManager._indexed_microservices() see a fake graph."""
170+
import server
171+
172+
class _FakeGraph:
173+
def microservice_counts(self):
174+
return {name: 1 for name in microservices}
175+
176+
monkeypatch.setattr(
177+
server.LadybugGraph, "exists", lambda db_path=None: len(microservices) > 0
178+
)
179+
monkeypatch.setattr(
180+
server.LadybugGraph, "get", lambda db_path=None: _FakeGraph()
181+
)
182+
183+
def test_context_dir_not_detected_as_microservice(self, tmp_path, monkeypatch):
184+
"""Launching from a codeless context dir must NOT auto-scope (the bug)."""
185+
from server import ScopeManager
186+
187+
# Reported layout: source_root holds both the context dir and a real
188+
# microservice; the server is launched from the context dir.
189+
context_dir = tmp_path / "bank-chat-context"
190+
context_dir.mkdir()
191+
ms_dir = tmp_path / "microservice-a"
192+
(ms_dir / "src").mkdir(parents=True)
193+
(ms_dir / "pom.xml").write_text("<project/>")
194+
195+
# The index only knows the real microservice, not the context dir.
196+
self._stub_index(monkeypatch, {"microservice-a"})
197+
monkeypatch.chdir(context_dir)
198+
199+
mgr = ScopeManager(tmp_path)
200+
assert mgr.default_scope is None
201+
202+
def test_real_microservice_dir_still_scopes(self, tmp_path, monkeypatch):
203+
"""Launching from inside an indexed microservice keeps auto-scope."""
204+
from server import ScopeManager
205+
206+
ms_dir = tmp_path / "microservice-a"
207+
(ms_dir / "src").mkdir(parents=True)
208+
(ms_dir / "pom.xml").write_text("<project/>")
209+
210+
self._stub_index(monkeypatch, {"microservice-a"})
211+
monkeypatch.chdir(ms_dir)
212+
213+
mgr = ScopeManager(tmp_path)
214+
assert mgr.default_scope == "microservice-a"
215+
216+
def test_empty_index_keeps_detection(self, tmp_path, monkeypatch):
217+
"""When the index is missing (exists()->False), keep detection."""
218+
from server import ScopeManager
219+
220+
ms_dir = tmp_path / "microservice-a"
221+
ms_dir.mkdir()
222+
(ms_dir / "pom.xml").write_text("<project/>")
223+
224+
# Graph missing -> exists() False -> empty known set.
225+
self._stub_index(monkeypatch, set())
226+
monkeypatch.chdir(ms_dir)
227+
228+
mgr = ScopeManager(tmp_path)
229+
assert mgr.default_scope == "microservice-a"
230+
231+
def test_empty_graph_present_keeps_detection(self, tmp_path, monkeypatch):
232+
"""Graph present but reporting no microservices also keeps detection.
233+
234+
Covers the exists()->True branch with empty microservice_counts() —
235+
distinct from test_empty_index_keeps_detection (missing graph). Both
236+
paths must converge to keeping the detected scope rather than silently
237+
disabling auto-scope.
238+
"""
239+
import server
240+
from server import ScopeManager
241+
242+
ms_dir = tmp_path / "microservice-a"
243+
ms_dir.mkdir()
244+
(ms_dir / "pom.xml").write_text("<project/>")
245+
246+
class _EmptyGraph:
247+
def microservice_counts(self):
248+
return {}
249+
250+
monkeypatch.setattr(server.LadybugGraph, "exists", lambda db_path=None: True)
251+
monkeypatch.setattr(server.LadybugGraph, "get", lambda db_path=None: _EmptyGraph())
252+
monkeypatch.chdir(ms_dir)
253+
254+
mgr = ScopeManager(tmp_path)
255+
assert mgr.default_scope == "microservice-a"
256+
257+
def test_indexed_microservices_extracts_nonempty_keys(self, tmp_path, monkeypatch):
258+
"""_indexed_microservices drops empty-string buckets, keeps the rest."""
259+
import server
260+
from server import ScopeManager
261+
262+
class _FakeGraph:
263+
def microservice_counts(self):
264+
return {"chat-core": 140, "chat-assign": 50, "": 3}
265+
266+
monkeypatch.setattr(server.LadybugGraph, "exists", lambda db_path=None: True)
267+
monkeypatch.setattr(server.LadybugGraph, "get", lambda db_path=None: _FakeGraph())
268+
269+
mgr = ScopeManager(tmp_path)
270+
assert mgr._indexed_microservices() == {"chat-core", "chat-assign"}

0 commit comments

Comments
 (0)