diff --git a/CHANGELOG.md b/CHANGELOG.md index a50fcd5..3111cb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Print MCP Surface / Supply Chain / Dependency Hygiene breakdown when `--min-score` or `--ci` gate fails. - Validate resolvable live launch configuration before the consent gate on `mcts snapshot` and `mcts fuzz`. +- **Doctor + MCP server startup hints** — `mcts doctor` now reports whether the optional `[mcp]` extra is installed, and `mcts-mcp` prints a direct install hint instead of a bare import failure when the extra is missing (#219). ## [0.1.2] - 2026-06-10 diff --git a/docs/get-started/getting-started.md b/docs/get-started/getting-started.md index 4d7646e..d8b7fc5 100644 --- a/docs/get-started/getting-started.md +++ b/docs/get-started/getting-started.md @@ -68,6 +68,8 @@ Install these only when you need the corresponding feature: For your first scan, the base install is enough. +If `mcts doctor .` reports `Extra [mcp]` as missing, install that optional extra before using `mcts-mcp`, `mcts scan --live`, or `mcts fuzz`. + --- ## Install diff --git a/src/mcts/cli/doctor.py b/src/mcts/cli/doctor.py index be26949..eef2cd9 100644 --- a/src/mcts/cli/doctor.py +++ b/src/mcts/cli/doctor.py @@ -22,10 +22,7 @@ console = Console() -_OPTIONAL_EXTRA_CHECKS = ( - ("[mcp] extra", "mcp", "mcp-mcts[mcp]"), - ("[api] extra", "fastapi", "mcp-mcts[api]"), -) +_OPTIONAL_EXTRA_CHECKS = (("[api] extra", "fastapi", "mcp-mcts[api]"),) _OPTIONAL_CLI_CHECKS = ( ("semgrep CLI", "semgrep"), ("pip-audit CLI", "pip-audit"), @@ -58,6 +55,14 @@ def run_doctor( failures += 1 checks.append(("pass", "mcts", __version__)) + if _append_optional_extra_check( + checks, + extra_label="Extra [mcp]", + module_name="mcp", + available_detail="installed — live scan / mcts-mcp available", + missing_detail='missing — install with `pip install "mcp-mcts[mcp]"` or `uv sync --extra mcp`', + ): + warnings += 1 if root.is_dir(): checks.append(("pass", "Target", str(root))) @@ -227,3 +232,19 @@ def _rel(path: Path, root: Path) -> str: return path.relative_to(root).as_posix() except ValueError: return str(path) + + +def _append_optional_extra_check( + checks: list[tuple[str, str, str]], + *, + extra_label: str, + module_name: str, + available_detail: str, + missing_detail: str, +) -> bool: + if importlib_util.find_spec(module_name) is None: + checks.append(("warn", extra_label, missing_detail)) + return True + + checks.append(("pass", extra_label, available_detail)) + return False diff --git a/src/mcts/mcp_server/server.py b/src/mcts/mcp_server/server.py index cdd27b0..f333d3a 100644 --- a/src/mcts/mcp_server/server.py +++ b/src/mcts/mcp_server/server.py @@ -85,7 +85,12 @@ def create_server(): try: from mcp.server.fastmcp import FastMCP except ImportError as exc: - raise RuntimeError("MCP server mode requires optional mcp extra: uv sync --extra mcp") from exc + raise RuntimeError( + "MCP server mode requires the [mcp] extra.\n" + 'Install with: pip install "mcp-mcts[mcp]"\n' + "Or, from a repo checkout: uv sync --extra mcp\n" + "Run `mcts doctor .` to verify optional extras." + ) from exc app = FastMCP("mcts") app.tool()(scan_mcp_target) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index d2eea66..1e84f8a 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -3,9 +3,11 @@ from __future__ import annotations import json +from importlib.machinery import ModuleSpec from pathlib import Path from types import SimpleNamespace +import pytest from typer.testing import CliRunner import mcts.cli.doctor as doctor_module @@ -34,6 +36,45 @@ def test_doctor_finds_config_and_entrypoint(tmp_path: Path) -> None: assert "bridge.py" in result.stdout +@pytest.mark.parametrize( + ("spec", "expected_status"), + [ + (ModuleSpec("mcp", loader=None), "pass"), + (None, "warn"), + ], +) +def test_doctor_reports_mcp_extra_status( + monkeypatch: pytest.MonkeyPatch, + spec: ModuleSpec | None, + expected_status: str, +) -> None: + monkeypatch.setattr( + doctor_module.importlib_util, + "find_spec", + lambda name: spec if name == "mcp" else None, + ) + + checks: list[tuple[str, str, str]] = [] + did_warn = doctor_module._append_optional_extra_check( + checks, + extra_label="Extra [mcp]", + module_name="mcp", + available_detail="installed — live scan / mcts-mcp available", + missing_detail='missing — install with `pip install "mcp-mcts[mcp]"` or `uv sync --extra mcp`', + ) + + assert did_warn is (expected_status == "warn") + assert checks == [ + ( + expected_status, + "Extra [mcp]", + "installed — live scan / mcts-mcp available" + if expected_status == "pass" + else 'missing — install with `pip install "mcp-mcts[mcp]"` or `uv sync --extra mcp`', + ) + ] + + def test_doctor_deep_missing_optional_tools_show_warnings(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(doctor_module.importlib_util, "find_spec", lambda _module: None) monkeypatch.setattr(doctor_module.shutil, "which", lambda _executable: None) @@ -42,7 +83,7 @@ def test_doctor_deep_missing_optional_tools_show_warnings(monkeypatch, tmp_path: result = runner.invoke(app, ["doctor", "--deep", str(tmp_path)]) assert result.exit_code == 0 - assert "[mcp] extra: module 'mcp' not found" in result.stdout + assert "Extra [mcp]: missing" in result.stdout assert "[api] extra: module 'fastapi' not found" in result.stdout assert "semgrep CLI: not found on PATH" in result.stdout assert "pip-audit CLI: not found on PATH" in result.stdout @@ -58,7 +99,7 @@ def test_doctor_deep_present_optional_tools_show_pass_lines(monkeypatch, tmp_pat result = runner.invoke(app, ["doctor", "--deep", str(tmp_path)]) assert result.exit_code == 0 - assert "[mcp] extra: module 'mcp' importable" in result.stdout + assert "Extra [mcp]: installed" in result.stdout assert "[api] extra: module 'fastapi' importable" in result.stdout assert "semgrep CLI: found at C:\\tools\\semgrep.exe" in result.stdout assert "pip-audit CLI: found at C:\\tools\\pip-audit.exe" in result.stdout @@ -78,7 +119,7 @@ def test_doctor_deep_missing_optional_extras_do_not_fail_core_only_install( result = runner.invoke(app, ["doctor", "--deep", str(tmp_path)]) assert result.exit_code == 0 - assert "[mcp] extra: module 'mcp' not found" in result.stdout + assert "Extra [mcp]: missing" in result.stdout assert "[api] extra: module 'fastapi' not found" in result.stdout diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index afc97b5..09ae6c4 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import builtins import json import pytest @@ -65,3 +66,23 @@ def test_compare_baselines_tool() -> None: payload = json.loads(raw) assert "score_delta" in payload assert payload["finding_delta"] >= 0 + + +def test_create_server_reports_missing_mcp_extra(monkeypatch: pytest.MonkeyPatch) -> None: + original_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "mcp.server.fastmcp": + raise ImportError("No module named 'mcp'") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + from mcts.mcp_server.server import create_server + + with pytest.raises(RuntimeError) as excinfo: + create_server() + + assert "requires the [mcp] extra" in str(excinfo.value) + assert 'pip install "mcp-mcts[mcp]"' in str(excinfo.value) + assert "mcts doctor ." in str(excinfo.value)