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
9 changes: 8 additions & 1 deletion server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())


Expand Down
62 changes: 62 additions & 0 deletions tests/test_java_codebase_rag_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
16 changes: 13 additions & 3 deletions tests/test_mcp_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]}"
)


Expand Down
Loading