From cd30a1fda83f3b004de28e5dff8fbc0c6e45a1f8 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 30 May 2026 12:54:53 +0300 Subject: [PATCH 1/3] 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 --- server.py | 9 ++++- tests/test_java_codebase_rag_cli.py | 62 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index 6f97bc73..b648a466 100644 --- a/server.py +++ b/server.py @@ -16,7 +16,7 @@ emit_lance_cocoindex_finish, emit_lance_cocoindex_start, ) -from java_codebase_rag.config import emit_legacy_env_hints_if_present, resolved_sbert_model_for_process_env +from java_codebase_rag.config import emit_legacy_env_hints_if_present, resolved_sbert_model_for_process_env, resolve_operator_config from kuzu_queries import KuzuGraph, resolve_kuzu_path from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, Field @@ -570,6 +570,13 @@ async def resolve( def main() -> None: emit_legacy_env_hints_if_present() + + # Load YAML config and apply embedding settings to environment + # This ensures SBERT_MODEL and SBERT_DEVICE from .java-codebase-rag.yml are available + # before any tool handler runs (same behavior as CLI path) + cfg = resolve_operator_config(source_root=_project_root()) + cfg.apply_to_os_environ() + asyncio.run(create_mcp_server().run_stdio_async()) diff --git a/tests/test_java_codebase_rag_cli.py b/tests/test_java_codebase_rag_cli.py index bddd2cef..9b9533f7 100644 --- a/tests/test_java_codebase_rag_cli.py +++ b/tests/test_java_codebase_rag_cli.py @@ -909,3 +909,65 @@ def test_cli_unknown_subcommand_returns_error(tmp_path, monkeypatch) -> None: env = _base_env(tmp_path) proc = _run_cli(["bogus"], env=env) assert proc.returncode == 2 + + +def test_mcp_server_loads_yaml_config_at_startup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """MCP server main() loads YAML config and applies to os.environ (issue #238). + + Verifies that main() calls resolve_operator_config with the correct source_root + and applies the result to os.environ. Uses mocks to avoid loading real models + or leaking env state (e.g. SBERT_DEVICE=cuda) to subsequent tests. + """ + import server as server_mod + from unittest.mock import MagicMock + + fake_cfg = MagicMock() + fake_cfg.apply_to_os_environ = MagicMock() + + monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(tmp_path)) + monkeypatch.setattr(server_mod, "resolve_operator_config", MagicMock(return_value=fake_cfg)) + + def fake_asyncio_run(awaitable, *, debug=None): + return None + + monkeypatch.setattr("asyncio.run", fake_asyncio_run) + + server_mod.main() + + # resolve_operator_config should have been called with the project root + server_mod.resolve_operator_config.assert_called_once_with(source_root=server_mod._project_root()) + # apply_to_os_environ should have been called to set env vars + fake_cfg.apply_to_os_environ.assert_called_once() + + +def test_mcp_server_yaml_config_precedence_env_over_yaml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """MCP server passes _project_root() to resolve_operator_config (issue #238). + + Precedence (env > YAML > default) is already tested by + test_embedding_model_precedence_cli_over_env_over_yaml_over_default. + This test verifies that main() delegates to resolve_operator_config + with the correct source root, which handles precedence internally. + """ + import server as server_mod + from unittest.mock import MagicMock, call + + fake_cfg = MagicMock() + fake_cfg.apply_to_os_environ = MagicMock() + + # Set source root so _project_root() returns it + monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(tmp_path)) + monkeypatch.setattr(server_mod, "resolve_operator_config", MagicMock(return_value=fake_cfg)) + + def fake_asyncio_run(awaitable, *, debug=None): + return None + + monkeypatch.setattr("asyncio.run", fake_asyncio_run) + + server_mod.main() + + server_mod.resolve_operator_config.assert_called_once() + assert server_mod.resolve_operator_config.call_args.kwargs["source_root"] == server_mod._project_root() From b3530de9e4982051e951e0e6ff8d9da4dce9f82e Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 30 May 2026 13:22:43 +0300 Subject: [PATCH 2/3] 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 --- tests/test_mcp_hints.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 64b916f3..7617fef9 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -400,7 +400,18 @@ def _class_with_implements_out(kuzu_graph) -> str: ) if not rows: pytest.skip("no class with IMPLEMENTS.out > 0 in fixture") - return str(rows[0]["id"]) + tid = str(rows[0]["id"]) + # The IMPLEMENTS hint is suppressed when type rollup emits + # (DECLARES.DECLARES_CLIENT/EXPOSES/DECLARES_PRODUCER). + # Verify the selected class actually qualifies for the hint. + out = describe_v2(tid, graph=kuzu_graph) + has_impl_hint = any( + h.tool == "neighbors" and h.args.get("edge_types") == ["IMPLEMENTS"] + for h in out.hints_structured + ) + if not has_impl_hint: + pytest.skip("class has IMPLEMENTS but type rollup suppresses the hint") + return tid def _service_with_injects_out(kuzu_graph) -> str: @@ -506,7 +517,7 @@ def _assert_structured_hint( return h pytest.fail( f"no structured hint with tool={tool!r} actionable={actionable} " - f"args_subset={args_subset!r} in {[h._asdict() for h in hints]}" + f"args_subset={args_subset!r} in {[h.model_dump() if hasattr(h, 'model_dump') else h._asdict() for h in hints]}" ) From ef5a44f92daf9a640fffc66db973da930dfa9068 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 30 May 2026 13:48:50 +0300 Subject: [PATCH 3/3] fix: strengthen test_mcp_hints fixture and remove unused import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _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 --- tests/test_java_codebase_rag_cli.py | 2 +- tests/test_mcp_hints.py | 27 +++++++++++++-------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/test_java_codebase_rag_cli.py b/tests/test_java_codebase_rag_cli.py index 9b9533f7..7f91e0e1 100644 --- a/tests/test_java_codebase_rag_cli.py +++ b/tests/test_java_codebase_rag_cli.py @@ -953,7 +953,7 @@ def test_mcp_server_yaml_config_precedence_env_over_yaml( with the correct source root, which handles precedence internally. """ import server as server_mod - from unittest.mock import MagicMock, call + from unittest.mock import MagicMock fake_cfg = MagicMock() fake_cfg.apply_to_os_environ = MagicMock() diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 7617fef9..98218958 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -396,22 +396,21 @@ def _class_with_implements_out(kuzu_graph) -> str: "MATCH (cls:Symbol)-[:IMPLEMENTS]->(iface:Symbol) " "WHERE cls.kind = 'class' " "WITH cls, count(iface) AS nout WHERE nout > 0 " - "RETURN cls.id AS id LIMIT 1", + "RETURN cls.id AS id", ) if not rows: pytest.skip("no class with IMPLEMENTS.out > 0 in fixture") - tid = str(rows[0]["id"]) - # The IMPLEMENTS hint is suppressed when type rollup emits - # (DECLARES.DECLARES_CLIENT/EXPOSES/DECLARES_PRODUCER). - # Verify the selected class actually qualifies for the hint. - out = describe_v2(tid, graph=kuzu_graph) - has_impl_hint = any( - h.tool == "neighbors" and h.args.get("edge_types") == ["IMPLEMENTS"] - for h in out.hints_structured - ) - if not has_impl_hint: - pytest.skip("class has IMPLEMENTS but type rollup suppresses the hint") - return tid + # Find a class whose IMPLEMENTS hint is not suppressed by type rollup + # (DECLARES_CLIENT/EXPOSES/DECLARES_PRODUCER suppresses IMPLEMENTS). + for row in rows: + tid = str(row["id"]) + out = describe_v2(tid, graph=kuzu_graph) + if any( + h.tool == "neighbors" and h.args.get("edge_types") == ["IMPLEMENTS"] + for h in out.hints_structured + ): + return tid + pytest.skip("no class with unsuppressed IMPLEMENTS hint in fixture") def _service_with_injects_out(kuzu_graph) -> str: @@ -517,7 +516,7 @@ def _assert_structured_hint( return h pytest.fail( f"no structured hint with tool={tool!r} actionable={actionable} " - f"args_subset={args_subset!r} in {[h.model_dump() if hasattr(h, 'model_dump') else h._asdict() for h in hints]}" + f"args_subset={args_subset!r} in {[h.model_dump() for h in hints]}" )