diff --git a/src/skillspector/mcp_server.py b/src/skillspector/mcp_server.py index e8aadedc..26bc4927 100644 --- a/src/skillspector/mcp_server.py +++ b/src/skillspector/mcp_server.py @@ -144,10 +144,24 @@ def build_server(name: str = "skillspector") -> FastMCP: try: from mcp.server.fastmcp import FastMCP except ModuleNotFoundError as exc: - raise ModuleNotFoundError( - "The MCP server requires the optional 'mcp' dependency. " - "Install it with: pip install 'skillspector[mcp]'" - ) from exc + if exc.name == "mcp": + raise ModuleNotFoundError( + "The MCP server requires the optional 'mcp' dependency. " + "Install it with: pip install 'skillspector[mcp]'" + ) from exc + if exc.name == "mcp.server.fastmcp": + # mcp>=2.0.0 removed mcp.server.fastmcp, so an install that + # resolved mcp 2.x fails here even though mcp is present. + # Point at the real cause instead of the misleading + # "extra not installed" error. + raise ModuleNotFoundError( + "The installed 'mcp' package is incompatible with the " + "SkillSpector MCP server: 'mcp.server.fastmcp' is " + "unavailable. Reinstall with: pip install 'skillspector[mcp]'" + ) from exc + # Some other module was missing (e.g. a transitive dependency of + # the installed mcp package); keep the original error. + raise exc server = FastMCP(name) diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py index 12149095..16e3197a 100644 --- a/tests/unit/test_mcp_server.py +++ b/tests/unit/test_mcp_server.py @@ -231,6 +231,65 @@ async def test_build_server_registers_scan_skill() -> None: assert "scan_skill" in {tool.name for tool in tools} +def test_build_server_reports_missing_mcp_extra(monkeypatch: pytest.MonkeyPatch) -> None: + """An absent mcp package must still report the missing optional extra.""" + import builtins + + original_import = builtins.__import__ + + def import_without_mcp(name: str, *args: object, **kwargs: object) -> object: + if name == "mcp.server.fastmcp": + raise ModuleNotFoundError("No module named 'mcp'", name="mcp") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_without_mcp) + + with pytest.raises(ModuleNotFoundError, match="requires the optional 'mcp' dependency"): + mcp_server.build_server() + + +def test_build_server_reports_incompatible_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + """An installed package without FastMCP must not be reported as missing.""" + import builtins + + original_import = builtins.__import__ + + def import_without_fastmcp(name: str, *args: object, **kwargs: object) -> object: + if name == "mcp.server.fastmcp": + raise ModuleNotFoundError( + "No module named 'mcp.server.fastmcp'", name="mcp.server.fastmcp" + ) + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_without_fastmcp) + + with pytest.raises(ModuleNotFoundError, match="installed 'mcp' package is incompatible"): + mcp_server.build_server() + + +def test_build_server_preserves_transitive_import_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing transitive dependency must keep the original error.""" + import builtins + + original_import = builtins.__import__ + + def import_with_missing_transitive(name: str, *args: object, **kwargs: object) -> object: + if name == "mcp.server.fastmcp": + raise ModuleNotFoundError( + "No module named 'mcp.server.fastmcp.uvicorn'", + name="mcp.server.fastmcp.uvicorn", + ) + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_with_missing_transitive) + + with pytest.raises(ModuleNotFoundError, match="mcp.server.fastmcp.uvicorn") as excinfo: + mcp_server.build_server() + assert excinfo.value.name == "mcp.server.fastmcp.uvicorn" + + async def test_mcp_stdio_initialize_registers_scan_skill() -> None: """The real stdio CLI must initialize and expose the scan_skill tool.""" pytest.importorskip("mcp")