From 354f43874c3996dad3f8c5438b00339b16006d4a Mon Sep 17 00:00:00 2001 From: ded-furby <190979964+ded-furby@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:26:35 +1000 Subject: [PATCH] fix: surface missing mcp extra guidance --- CHANGELOG.md | 4 +++ docs/get-started/getting-started.md | 2 ++ src/mcts/cli/doctor.py | 25 +++++++++++++++++ src/mcts/mcp_server/server.py | 7 ++++- tests/test_doctor.py | 42 +++++++++++++++++++++++++++++ tests/test_mcp_server.py | 21 +++++++++++++++ 6 files changed, 100 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3f46a..8a93ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **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 + ## [0.1.2] - 2026-06-10 ### Added 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 7150f9a..b56660b 100644 --- a/src/mcts/cli/doctor.py +++ b/src/mcts/cli/doctor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib.util import sys from pathlib import Path @@ -38,6 +39,14 @@ def run_doctor(path: Path, *, deep: bool = False, json_output: bool = False) -> 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))) @@ -157,3 +166,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 6bea614..cdb41a4 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -3,10 +3,13 @@ from __future__ import annotations import json +from importlib.machinery import ModuleSpec from pathlib import Path +import pytest from typer.testing import CliRunner +from mcts.cli import doctor as doctor_module from mcts.cli.main import app runner = CliRunner() @@ -30,3 +33,42 @@ def test_doctor_finds_config_and_entrypoint(tmp_path: Path) -> None: assert result.exit_code == 0 assert ".mcp.json" in result.stdout 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`', + ) + ] 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)