pyro-mcp/tests/test_host_helpers.py
Thales Maciel d0cf6d8f21 Add opinionated MCP modes for workspace workflows
Introduce explicit repro-fix, inspect, cold-start, and review-eval modes across the MCP server, CLI, and host helpers, with canonical mode-to-tool mappings, narrowed schemas, and mode-specific tool descriptions on top of the existing workspace runtime.

Reposition the docs, host onramps, and use-case recipes so named modes are the primary user-facing startup story while the generic no-mode workspace-core path remains the escape hatch, and update the shared smoke runner to validate repro-fix and cold-start through mode-backed servers.

Validation: UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_api.py tests/test_server.py tests/test_host_helpers.py tests/test_public_contract.py tests/test_cli.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed make smoke-repro-fix-loop smoke-cold-start-validation outside the sandbox.
2026-03-13 20:00:35 -03:00

501 lines
16 KiB
Python

from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
from subprocess import CompletedProcess
import pytest
import pyro_mcp.host_helpers as host_helpers
from pyro_mcp.host_helpers import (
DEFAULT_OPENCODE_CONFIG_PATH,
HostServerConfig,
_canonical_server_command,
_command_matches,
_repair_command,
connect_cli_host,
doctor_hosts,
print_or_write_opencode_config,
repair_host,
)
def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]:
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True)
state_path = tmp_path / f"{name}-state.json"
script_path = bin_dir / name
script_path.write_text(
"\n".join(
[
f"#!{sys.executable}",
"import json",
"import shlex",
"import sys",
f"STATE_PATH = {str(state_path)!r}",
"try:",
" with open(STATE_PATH, 'r', encoding='utf-8') as handle:",
" state = json.load(handle)",
"except FileNotFoundError:",
" state = {}",
"args = sys.argv[1:]",
"if args[:2] == ['mcp', 'add']:",
" name = args[2]",
" marker = args.index('--')",
" state[name] = args[marker + 1:]",
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
" json.dump(state, handle)",
" print(f'added {name}')",
" raise SystemExit(0)",
"if args[:2] == ['mcp', 'remove']:",
" name = args[2]",
" if name in state:",
" del state[name]",
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
" json.dump(state, handle)",
" print(f'removed {name}')",
" raise SystemExit(0)",
" print('not found', file=sys.stderr)",
" raise SystemExit(1)",
"if args[:2] == ['mcp', 'get']:",
" name = args[2]",
" if name not in state:",
" print('not found', file=sys.stderr)",
" raise SystemExit(1)",
" print(f'{name}: {shlex.join(state[name])}')",
" raise SystemExit(0)",
"if args[:2] == ['mcp', 'list']:",
" for item in sorted(state):",
" print(item)",
" raise SystemExit(0)",
"print('unsupported', file=sys.stderr)",
"raise SystemExit(2)",
]
),
encoding="utf-8",
)
script_path.chmod(0o755)
return bin_dir, state_path
def test_connect_cli_host_replaces_existing_entry(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex")
state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8")
monkeypatch.setenv("PATH", str(bin_dir))
payload = connect_cli_host("codex", config=HostServerConfig())
assert payload["host"] == "codex"
assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
assert json.loads(state_path.read_text(encoding="utf-8")) == {
"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
}
def test_canonical_server_command_validates_and_renders_variants() -> None:
assert _canonical_server_command(HostServerConfig(installed_package=True)) == [
"pyro",
"mcp",
"serve",
]
assert _canonical_server_command(
HostServerConfig(profile="workspace-full", project_path="/repo")
) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"workspace-full",
"--project-path",
"/repo",
]
assert _canonical_server_command(
HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main")
) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--repo-url",
"https://example.com/repo.git",
"--repo-ref",
"main",
]
assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--mode",
"repro-fix",
]
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--no-project-source",
]
with pytest.raises(ValueError, match="mutually exclusive"):
_canonical_server_command(
HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git")
)
with pytest.raises(ValueError, match="cannot be combined"):
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
with pytest.raises(ValueError, match="requires --repo-url"):
_canonical_server_command(HostServerConfig(repo_ref="main"))
with pytest.raises(ValueError, match="mutually exclusive"):
_canonical_server_command(
HostServerConfig(profile="workspace-full", mode="repro-fix")
)
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex"
assert _repair_command("codex", HostServerConfig(project_path="/repo")) == (
"pyro host repair codex --project-path /repo"
)
assert _repair_command(
"opencode",
HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"),
config_path=Path("/tmp/opencode.json"),
) == (
"pyro host repair opencode --installed-package --profile workspace-full "
"--repo-url file:///repo --config-path /tmp/opencode.json"
)
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
"pyro host repair codex --no-project-source"
)
assert _repair_command("codex", HostServerConfig(mode="inspect")) == (
"pyro host repair codex --mode inspect"
)
assert _command_matches(
"pyro: uvx --from pyro-mcp pyro mcp serve",
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
)
assert _command_matches(
'"uvx --from pyro-mcp pyro mcp serve',
['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"],
)
assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
])
def test_connect_cli_host_reports_missing_cli_and_add_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
with pytest.raises(ValueError, match="unsupported CLI host"):
connect_cli_host("unsupported", config=HostServerConfig())
monkeypatch.setenv("PATH", "")
with pytest.raises(RuntimeError, match="codex CLI is not installed"):
connect_cli_host("codex", config=HostServerConfig())
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
script_path = bin_dir / "codex"
script_path.write_text(
"\n".join(
[
f"#!{sys.executable}",
"import sys",
"raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)",
]
),
encoding="utf-8",
)
script_path.chmod(0o755)
monkeypatch.setenv("PATH", str(bin_dir))
with pytest.raises(RuntimeError, match="codex mcp add failed"):
connect_cli_host("codex", config=HostServerConfig())
def test_doctor_hosts_reports_ok_and_drifted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex")
claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude")
combined_path = str(codex_bin) + ":" + str(claude_bin)
monkeypatch.setenv("PATH", combined_path)
codex_state.write_text(
json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}),
encoding="utf-8",
)
claude_state.write_text(
json.dumps(
{
"pyro": [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"workspace-full",
]
}
),
encoding="utf-8",
)
opencode_config = tmp_path / "opencode.json"
opencode_config.write_text(
json.dumps(
{
"mcp": {
"pyro": {
"type": "local",
"enabled": True,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
}
}
}
),
encoding="utf-8",
)
entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config)
by_host = {entry.host: entry for entry in entries}
assert by_host["codex"].status == "ok"
assert by_host["codex"].configured is True
assert by_host["claude-code"].status == "drifted"
assert by_host["claude-code"].configured is True
assert by_host["opencode"].status == "ok"
assert by_host["opencode"].configured is True
def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("[]", encoding="utf-8")
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert "JSON object" in by_host["opencode"].details
config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8")
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert "missing mcp.pyro" in by_host["opencode"].details
config_path.write_text(
json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}),
encoding="utf-8",
)
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "drifted"
assert by_host["opencode"].configured is True
def test_doctor_hosts_reports_invalid_json_for_installed_opencode(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_path = tmp_path / "opencode.json"
config_path.write_text("{invalid", encoding="utf-8")
monkeypatch.setattr(
shutil,
"which",
lambda name: "/usr/bin/opencode" if name == "opencode" else None,
)
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "drifted"
assert "invalid JSON" in by_host["opencode"].details
def test_repair_opencode_preserves_unrelated_keys(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text(
json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}),
encoding="utf-8",
)
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
assert payload["config_path"] == str(config_path.resolve())
repaired = json.loads(config_path.read_text(encoding="utf-8"))
assert repaired["theme"] == "light"
assert repaired["mcp"]["other"] == {"type": "local"}
assert repaired["mcp"]["pyro"] == {
"type": "local",
"enabled": True,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
}
def test_repair_opencode_backs_up_non_object_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("[]", encoding="utf-8")
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
backup_path = Path(str(payload["backup_path"]))
assert backup_path.exists()
assert backup_path.read_text(encoding="utf-8") == "[]"
def test_repair_opencode_backs_up_invalid_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("{invalid", encoding="utf-8")
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
backup_path = Path(str(payload["backup_path"]))
assert backup_path.exists()
assert backup_path.read_text(encoding="utf-8") == "{invalid"
repaired = json.loads(config_path.read_text(encoding="utf-8"))
assert repaired["mcp"]["pyro"]["command"] == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
]
def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None:
output_path = tmp_path / "opencode.json"
payload = print_or_write_opencode_config(
config=HostServerConfig(project_path="/repo"),
output_path=output_path,
)
assert payload["output_path"] == str(output_path)
rendered = json.loads(output_path.read_text(encoding="utf-8"))
assert rendered == {
"mcp": {
"pyro": {
"type": "local",
"enabled": True,
"command": [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--project-path",
"/repo",
],
}
}
}
def test_print_or_write_opencode_config_returns_rendered_text() -> None:
payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run"))
assert payload["host"] == "opencode"
assert payload["server_command"] == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"vm-run",
]
rendered = str(payload["rendered_config"])
assert '"type": "local"' in rendered
assert '"command": [' in rendered
def test_doctor_reports_opencode_missing_when_config_absent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
entries = doctor_hosts(
config=HostServerConfig(),
config_path=tmp_path / "missing-opencode.json",
)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details
def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
host_helpers,
"connect_cli_host",
lambda host, *, config: {"host": host, "profile": config.profile},
)
assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == {
"host": "codex",
"profile": "vm-run",
}
commands: list[list[str]] = []
def _fake_run_command(command: list[str]) -> CompletedProcess[str]:
commands.append(command)
if command[:3] == ["codex", "mcp", "get"]:
return CompletedProcess(command, 1, "", "not found")
if command[:3] == ["codex", "mcp", "list"]:
return CompletedProcess(command, 0, "pyro\n", "")
raise AssertionError(command)
monkeypatch.setattr(
shutil,
"which",
lambda name: "/usr/bin/codex" if name == "codex" else None,
)
monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command)
entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig())
assert entry.status == "drifted"
assert entry.configured is True
assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]]