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..7f91e0e1 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 + + 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() diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 64b916f3..98218958 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -396,11 +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") - return str(rows[0]["id"]) + # 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: @@ -506,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._asdict() for h in hints]}" + f"args_subset={args_subset!r} in {[h.model_dump() for h in hints]}" )