"""Integration tests for MCP Server using FastMCP Client.
Tests both Local (stdio) and Remote (HTTP) modes using in-memory transport.
Note: These are integration tests, not E2E tests, as they bypass the network layer.
"""
import base64
from pathlib import Path
import pytest
from fastmcp.client import Client
from skillport.interfaces.mcp.server import create_mcp_server
from skillport.modules.indexing import build_index
from skillport.shared.config import Config
def _create_test_skill(skills_dir: Path, skill_id: str, content: str = "Test content") -> None:
"""Create a minimal test skill."""
skill_dir = skills_dir / skill_id
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"""---
name: {skill_id}
description: Test skill {skill_id}
metadata:
skillport:
category: test
tags: [test]
---
# {skill_id}
{content}
""",
encoding="utf-8",
)
def _create_test_config(tmp_path: Path) -> Config:
"""Create a test config with temporary directories."""
skills_dir = tmp_path / "skills"
skills_dir.mkdir(parents=True)
return Config(
skills_dir=skills_dir,
db_path=tmp_path / "db.lancedb",
embedding_provider="none",
)
@pytest.fixture
def test_config(tmp_path: Path) -> Config:
"""Create test config with a sample skill."""
config = _create_test_config(tmp_path)
_create_test_skill(config.skills_dir, "test-skill", "Hello from test skill")
build_index(config=config, force=True)
return config
@pytest.fixture
async def local_client(test_config: Config):
"""FastMCP Client for Local mode (stdio)."""
mcp = create_mcp_server(config=test_config, is_remote=False)
async with Client(transport=mcp) as client:
yield client
@pytest.fixture
async def remote_client(test_config: Config):
"""FastMCP Client for Remote mode (HTTP)."""
mcp = create_mcp_server(config=test_config, is_remote=True)
async with Client(transport=mcp) as client:
yield client
class TestLocalMode:
"""Tests for Local mode (stdio transport)."""
async def test_list_tools_returns_two_tools(self, local_client: Client):
"""Local mode should have exactly 2 tools."""
tools = await local_client.list_tools()
tool_names = [t.name for t in tools]
assert sorted(tool_names) == ["load_skill", "search_skills"]
assert "read_skill_file" not in tool_names
async def test_search_skills_returns_results(self, local_client: Client):
"""search_skills should find indexed skills."""
result = await local_client.call_tool("search_skills", {"query": "test"})
assert result.data is not None
assert "skills" in result.data
assert len(result.data["skills"]) > 0
async def test_search_skills_empty_query_lists_all(self, local_client: Client):
"""Empty query should list all skills."""
result = await local_client.call_tool("search_skills", {"query": ""})
assert result.data is not None
assert "skills" in result.data
assert result.data["total"] >= 1
async def test_load_skill_returns_details(self, local_client: Client):
"""load_skill should return skill details with path."""
result = await local_client.call_tool("load_skill", {"skill_id": "test-skill"})
assert result.data is not None
assert result.data["id"] == "test-skill"
assert "instructions" in result.data
assert "path" in result.data
assert Path(result.data["path"]).exists()
class TestRemoteMode:
"""Tests for Remote mode (HTTP transport)."""
async def test_list_tools_returns_three_tools(self, remote_client: Client):
"""Remote mode should have 3 tools including read_skill_file."""
tools = await remote_client.list_tools()
tool_names = [t.name for t in tools]
assert sorted(tool_names) == ["load_skill", "read_skill_file", "search_skills"]
async def test_search_skills_works(self, remote_client: Client):
"""search_skills should work in Remote mode."""
result = await remote_client.call_tool("search_skills", {"query": "test"})
assert result.data is not None
assert "skills" in result.data
async def test_load_skill_works(self, remote_client: Client):
"""load_skill should work in Remote mode."""
result = await remote_client.call_tool("load_skill", {"skill_id": "test-skill"})
assert result.data is not None
assert result.data["id"] == "test-skill"
async def test_read_skill_file_text(self, remote_client: Client, test_config: Config):
"""read_skill_file should return UTF-8 text content."""
# Create a text file in the skill directory
skill_dir = test_config.skills_dir / "test-skill"
(skill_dir / "readme.txt").write_text("Hello, World!", encoding="utf-8")
result = await remote_client.call_tool(
"read_skill_file",
{"skill_id": "test-skill", "file_path": "readme.txt"},
)
assert result.data is not None
assert result.data["content"] == "Hello, World!"
assert result.data["encoding"] == "utf-8"
assert "text" in result.data["mime_type"]
async def test_read_skill_file_binary(self, remote_client: Client, test_config: Config):
"""read_skill_file should return base64-encoded binary content."""
# Create a binary file in the skill directory
skill_dir = test_config.skills_dir / "test-skill"
binary_data = bytes([0x89, 0x50, 0x4E, 0x47]) # PNG header
(skill_dir / "image.png").write_bytes(binary_data)
result = await remote_client.call_tool(
"read_skill_file",
{"skill_id": "test-skill", "file_path": "image.png"},
)
assert result.data is not None
assert result.data["encoding"] == "base64"
assert result.data["mime_type"] == "image/png"
decoded = base64.b64decode(result.data["content"])
assert decoded == binary_data
async def test_read_skill_file_path_traversal_blocked(self, remote_client: Client):
"""read_skill_file should block path traversal attempts."""
from fastmcp.exceptions import ToolError
with pytest.raises(ToolError, match="Path traversal"):
await remote_client.call_tool(
"read_skill_file",
{"skill_id": "test-skill", "file_path": "../escape.txt"},
)
class TestInstructions:
"""Tests for dynamic instructions generation."""
async def test_local_mode_instructions_mention_read_tool(self, local_client: Client):
"""Local mode instructions should mention using native Read tool."""
# Get server info which includes instructions
# Note: FastMCP Client may not expose instructions directly,
# so we test via the server creation
from skillport.interfaces.mcp.instructions import build_xml_instructions
from skillport.shared.config import Config
config = Config(core_skills_mode="none")
instructions = build_xml_instructions(config, ["search_skills", "load_skill"])
assert "Read tool" in instructions or "Read" in instructions
# Should NOT have read_skill_file in tools section
assert "read_skill_file" not in instructions.split("## Tips")[0]
async def test_remote_mode_instructions_mention_read_skill_file(self, remote_client: Client):
"""Remote mode instructions should mention read_skill_file."""
from skillport.interfaces.mcp.instructions import build_xml_instructions
from skillport.shared.config import Config
config = Config(core_skills_mode="none")
instructions = build_xml_instructions(
config, ["search_skills", "load_skill", "read_skill_file"]
)
assert "read_skill_file" in instructions
assert "base64" in instructions or "encoding" in instructions