"""The runner attaches MCP connections source-agnostically. When a caller supplies ``mcp_connection_requests`` the runner attaches those; when it does not, the runner reads ``~/.strix/mcp-servers.json`` itself and wraps each config in a bare request. Either way the one shared ``attach_mcp_requests`` routine does the connecting. """ from __future__ import annotations import types from typing import Any import pytest from agents import ModelSettings import strix.tools.mcp as mcp_pkg import strix.tools.notes.tools as notes_tools import strix.tools.todo.tools as todo_tools from strix.core import runner from strix.core.agents import AgentCoordinator from strix.runtime import session_manager from strix.tools.mcp import McpConnectionConfig, McpConnectionRequest def _settings() -> Any: return types.SimpleNamespace( llm=types.SimpleNamespace( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, timeout=300, prompt_cache=True, extra_headers=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path) monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path) monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None) monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None) monkeypatch.setattr(runner, "load_settings", _settings) monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _s: None) monkeypatch.setattr(runner, "uses_chat_completions_tool_schema", lambda _m, _s: False) monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _d: None) monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _d: None) async def _create_or_reuse(*_a: Any, **_k: Any) -> dict[str, Any]: return {"client": object(), "session": object(), "caido_client": None} async def _cleanup(*_a: Any, **_k: Any) -> None: return None monkeypatch.setattr(session_manager, "create_or_reuse", _create_or_reuse) monkeypatch.setattr(session_manager, "cleanup", _cleanup) monkeypatch.setattr(runner, "build_root_task", lambda _c: "task") monkeypatch.setattr(runner, "build_scope_context", lambda _c: {}) monkeypatch.setattr(runner, "make_model_settings", lambda *_a, **_k: ModelSettings()) monkeypatch.setattr(runner, "build_strix_agent", lambda **_k: object()) monkeypatch.setattr(runner, "make_child_factory", lambda **_k: lambda **_kk: object()) monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object()) async def _run_agent_loop(**_kwargs: Any) -> None: return None monkeypatch.setattr(runner, "run_agent_loop", _run_agent_loop) @pytest.mark.asyncio async def test_none_default_attaches_from_the_user_config_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Any ) -> None: _wire_runner(monkeypatch, tmp_path) file_config = McpConnectionConfig( name="local_fs", transport="stdio", command="npx", notes="local files" ) monkeypatch.setattr(mcp_pkg, "load_user_mcp_configs", lambda: [file_config]) captured: list[McpConnectionRequest] = [] original_register = mcp_pkg.McpRegistry.register def _capture(registry: Any, request: McpConnectionRequest) -> Any: captured.append(request) return original_register(registry, request) monkeypatch.setattr(mcp_pkg.McpRegistry, "register", _capture) await runner.run_strix_scan( scan_config={"targets": [], "scan_mode": "deep"}, scan_id="scan-none", image="img", coordinator=AgentCoordinator(), ) # Each config from the file is wrapped in a bare request: no provider, no # transform, no explicit purpose (purpose falls back to notes at attach time). (request,) = captured assert request.config is file_config assert request.provider is None assert request.result_transform is None assert request.purpose is None @pytest.mark.asyncio async def test_supplied_requests_are_attached_and_the_user_file_is_not_read( monkeypatch: pytest.MonkeyPatch, tmp_path: Any ) -> None: _wire_runner(monkeypatch, tmp_path) def _fail_if_read() -> list[Any]: raise AssertionError("load_user_mcp_configs must not be read when requests are supplied") monkeypatch.setattr(mcp_pkg, "load_user_mcp_configs", _fail_if_read) captured: list[McpConnectionRequest] = [] original_register = mcp_pkg.McpRegistry.register def _capture(registry: Any, request: McpConnectionRequest) -> Any: captured.append(request) return original_register(registry, request) monkeypatch.setattr(mcp_pkg.McpRegistry, "register", _capture) supplied = [ McpConnectionRequest( config=McpConnectionConfig(name="db", url="https://mcp.example.com"), provider="supabase", ) ] await runner.run_strix_scan( scan_config={"targets": [], "scan_mode": "deep"}, scan_id="scan-supplied", image="img", coordinator=AgentCoordinator(), mcp_connection_requests=supplied, ) assert captured == supplied @pytest.mark.asyncio async def test_roster_is_persisted_even_without_a_status_sink( monkeypatch: pytest.MonkeyPatch, tmp_path: Any ) -> None: """The viewer reads the roster off disk, so persistence must not depend on the interface status sink: with ``mcp_status_sink=None`` the configured roster is still written, carrying only non-secret lifecycle fields.""" _wire_runner(monkeypatch, tmp_path) monkeypatch.setattr( mcp_pkg, "load_user_mcp_configs", lambda: [McpConnectionConfig(name="local_fs", transport="stdio", command="npx")], ) persisted: list[list[dict[str, Any]]] = [] def _capture_persist(roster: list[dict[str, Any]]) -> None: persisted.append(roster) monkeypatch.setattr(runner, "_persist_mcp_status", _capture_persist) await runner.run_strix_scan( scan_config={"targets": [], "scan_mode": "deep"}, scan_id="scan-persist", image="img", coordinator=AgentCoordinator(), mcp_status_sink=None, ) assert persisted, "roster must persist even when no status sink is attached" assert persisted[0] == [ { "name": "local_fs", "provider": None, "tool_count": 0, "dead": False, "state": "configured", } ]