Skip to content
Open
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
22 changes: 18 additions & 4 deletions src/skillspector/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
59 changes: 59 additions & 0 deletions tests/unit/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down