Skip to content

Commit 2394fa1

Browse files
HumanBean17claude
andauthored
fix: MCP server loads embedding config from .java-codebase-rag.yml (#238) (#239)
* fix: MCP server loads embedding config from .java-codebase-rag.yml (#238) The MCP server now reads embedding.model and embedding.device from .java-codebase-rag.yml at startup and applies them to os.environ, matching the behavior of CLI commands (init, reprocess, etc.). Previously, the MCP server ignored YAML config and always fell back to the HuggingFace hub default, causing search tool failures when offline or behind corporate proxies. Changes: - server.py: Call resolve_operator_config() in main() and apply to os.environ - tests: Add tests for MCP server YAML config loading and precedence Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: use model_dump() for pydantic StructuredHint in test error message _assert_structured_hint receives StructuredHint(BaseModel) from describe_v2 output, not _StructuredHint(NamedTuple). _asdict() is a NamedTuple method and raises AttributeError on BaseModel instances. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: strengthen test_mcp_hints fixture and remove unused import - _class_with_implements_out: iterate through all classes with IMPLEMENTS.out instead of skipping on the first suppressed match, ensuring the test actually validates hint emission - _assert_structured_hint: drop hasattr fallback — StructuredHint is always a Pydantic BaseModel, use model_dump() directly - Remove unused `call` import in test_mcp_server_yaml_config_precedence Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6b883d9 commit 2394fa1

3 files changed

Lines changed: 83 additions & 4 deletions

File tree

server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
emit_lance_cocoindex_finish,
1717
emit_lance_cocoindex_start,
1818
)
19-
from java_codebase_rag.config import emit_legacy_env_hints_if_present, resolved_sbert_model_for_process_env
19+
from java_codebase_rag.config import emit_legacy_env_hints_if_present, resolved_sbert_model_for_process_env, resolve_operator_config
2020
from kuzu_queries import KuzuGraph, resolve_kuzu_path
2121
from mcp.server.fastmcp import FastMCP
2222
from pydantic import BaseModel, Field
@@ -570,6 +570,13 @@ async def resolve(
570570

571571
def main() -> None:
572572
emit_legacy_env_hints_if_present()
573+
574+
# Load YAML config and apply embedding settings to environment
575+
# This ensures SBERT_MODEL and SBERT_DEVICE from .java-codebase-rag.yml are available
576+
# before any tool handler runs (same behavior as CLI path)
577+
cfg = resolve_operator_config(source_root=_project_root())
578+
cfg.apply_to_os_environ()
579+
573580
asyncio.run(create_mcp_server().run_stdio_async())
574581

575582

tests/test_java_codebase_rag_cli.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,3 +909,65 @@ def test_cli_unknown_subcommand_returns_error(tmp_path, monkeypatch) -> None:
909909
env = _base_env(tmp_path)
910910
proc = _run_cli(["bogus"], env=env)
911911
assert proc.returncode == 2
912+
913+
914+
def test_mcp_server_loads_yaml_config_at_startup(
915+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
916+
) -> None:
917+
"""MCP server main() loads YAML config and applies to os.environ (issue #238).
918+
919+
Verifies that main() calls resolve_operator_config with the correct source_root
920+
and applies the result to os.environ. Uses mocks to avoid loading real models
921+
or leaking env state (e.g. SBERT_DEVICE=cuda) to subsequent tests.
922+
"""
923+
import server as server_mod
924+
from unittest.mock import MagicMock
925+
926+
fake_cfg = MagicMock()
927+
fake_cfg.apply_to_os_environ = MagicMock()
928+
929+
monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(tmp_path))
930+
monkeypatch.setattr(server_mod, "resolve_operator_config", MagicMock(return_value=fake_cfg))
931+
932+
def fake_asyncio_run(awaitable, *, debug=None):
933+
return None
934+
935+
monkeypatch.setattr("asyncio.run", fake_asyncio_run)
936+
937+
server_mod.main()
938+
939+
# resolve_operator_config should have been called with the project root
940+
server_mod.resolve_operator_config.assert_called_once_with(source_root=server_mod._project_root())
941+
# apply_to_os_environ should have been called to set env vars
942+
fake_cfg.apply_to_os_environ.assert_called_once()
943+
944+
945+
def test_mcp_server_yaml_config_precedence_env_over_yaml(
946+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
947+
) -> None:
948+
"""MCP server passes _project_root() to resolve_operator_config (issue #238).
949+
950+
Precedence (env > YAML > default) is already tested by
951+
test_embedding_model_precedence_cli_over_env_over_yaml_over_default.
952+
This test verifies that main() delegates to resolve_operator_config
953+
with the correct source root, which handles precedence internally.
954+
"""
955+
import server as server_mod
956+
from unittest.mock import MagicMock
957+
958+
fake_cfg = MagicMock()
959+
fake_cfg.apply_to_os_environ = MagicMock()
960+
961+
# Set source root so _project_root() returns it
962+
monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(tmp_path))
963+
monkeypatch.setattr(server_mod, "resolve_operator_config", MagicMock(return_value=fake_cfg))
964+
965+
def fake_asyncio_run(awaitable, *, debug=None):
966+
return None
967+
968+
monkeypatch.setattr("asyncio.run", fake_asyncio_run)
969+
970+
server_mod.main()
971+
972+
server_mod.resolve_operator_config.assert_called_once()
973+
assert server_mod.resolve_operator_config.call_args.kwargs["source_root"] == server_mod._project_root()

tests/test_mcp_hints.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,21 @@ def _class_with_implements_out(kuzu_graph) -> str:
396396
"MATCH (cls:Symbol)-[:IMPLEMENTS]->(iface:Symbol) "
397397
"WHERE cls.kind = 'class' "
398398
"WITH cls, count(iface) AS nout WHERE nout > 0 "
399-
"RETURN cls.id AS id LIMIT 1",
399+
"RETURN cls.id AS id",
400400
)
401401
if not rows:
402402
pytest.skip("no class with IMPLEMENTS.out > 0 in fixture")
403-
return str(rows[0]["id"])
403+
# Find a class whose IMPLEMENTS hint is not suppressed by type rollup
404+
# (DECLARES_CLIENT/EXPOSES/DECLARES_PRODUCER suppresses IMPLEMENTS).
405+
for row in rows:
406+
tid = str(row["id"])
407+
out = describe_v2(tid, graph=kuzu_graph)
408+
if any(
409+
h.tool == "neighbors" and h.args.get("edge_types") == ["IMPLEMENTS"]
410+
for h in out.hints_structured
411+
):
412+
return tid
413+
pytest.skip("no class with unsuppressed IMPLEMENTS hint in fixture")
404414

405415

406416
def _service_with_injects_out(kuzu_graph) -> str:
@@ -506,7 +516,7 @@ def _assert_structured_hint(
506516
return h
507517
pytest.fail(
508518
f"no structured hint with tool={tool!r} actionable={actionable} "
509-
f"args_subset={args_subset!r} in {[h._asdict() for h in hints]}"
519+
f"args_subset={args_subset!r} in {[h.model_dump() for h in hints]}"
510520
)
511521

512522

0 commit comments

Comments
 (0)