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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/get-started/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions src/mcts/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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
7 changes: 6 additions & 1 deletion src/mcts/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 44 additions & 3 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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


Expand Down
21 changes: 21 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import builtins
import json

import pytest
Expand Down Expand Up @@ -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)
Loading