Files
2025-07-14 13:57:47 +01:00

673 lines
25 KiB
Python

"""
Integration tests for FastMCP server functionality.
These tests validate the proper functioning of FastMCP features using focused,
single-feature servers across different transports (SSE and StreamableHTTP).
"""
import json
import multiprocessing
import socket
import time
from collections.abc import Generator
import pytest
import uvicorn
from pydantic import AnyUrl
from examples.snippets.servers import (
basic_prompt,
basic_resource,
basic_tool,
completion,
elicitation,
fastmcp_quickstart,
notifications,
sampling,
structured_output,
tool_progress,
)
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import (
CreateMessageResult,
ElicitResult,
GetPromptResult,
InitializeResult,
LoggingMessageNotification,
ProgressNotification,
ReadResourceResult,
ResourceListChangedNotification,
ServerNotification,
TextContent,
TextResourceContents,
ToolListChangedNotification,
)
class NotificationCollector:
"""Collects notifications from the server for testing."""
def __init__(self):
self.progress_notifications: list = []
self.log_messages: list = []
self.resource_notifications: list = []
self.tool_notifications: list = []
async def handle_generic_notification(self, message) -> None:
"""Handle any server notification and route to appropriate handler."""
if isinstance(message, ServerNotification):
if isinstance(message.root, ProgressNotification):
self.progress_notifications.append(message.root.params)
elif isinstance(message.root, LoggingMessageNotification):
self.log_messages.append(message.root.params)
elif isinstance(message.root, ResourceListChangedNotification):
self.resource_notifications.append(message.root.params)
elif isinstance(message.root, ToolListChangedNotification):
self.tool_notifications.append(message.root.params)
# Common fixtures
@pytest.fixture
def server_port() -> int:
"""Get a free port for testing."""
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture
def server_url(server_port: int) -> str:
"""Get the server URL for testing."""
return f"http://127.0.0.1:{server_port}"
def run_server_with_transport(module_name: str, port: int, transport: str) -> None:
"""Run server with specified transport."""
# Get the MCP instance based on module name
if module_name == "basic_tool":
mcp = basic_tool.mcp
elif module_name == "basic_resource":
mcp = basic_resource.mcp
elif module_name == "basic_prompt":
mcp = basic_prompt.mcp
elif module_name == "tool_progress":
mcp = tool_progress.mcp
elif module_name == "sampling":
mcp = sampling.mcp
elif module_name == "elicitation":
mcp = elicitation.mcp
elif module_name == "completion":
mcp = completion.mcp
elif module_name == "notifications":
mcp = notifications.mcp
elif module_name == "fastmcp_quickstart":
mcp = fastmcp_quickstart.mcp
elif module_name == "structured_output":
mcp = structured_output.mcp
else:
raise ImportError(f"Unknown module: {module_name}")
# Create app based on transport type
if transport == "sse":
app = mcp.sse_app()
elif transport == "streamable-http":
app = mcp.streamable_http_app()
else:
raise ValueError(f"Invalid transport for test server: {transport}")
server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=port, log_level="error"))
print(f"Starting {transport} server on port {port}")
server.run()
@pytest.fixture
def server_transport(request, server_port: int) -> Generator[str, None, None]:
"""Start server in a separate process with specified MCP instance and transport.
Args:
request: pytest request with param tuple of (module_name, transport)
server_port: Port to run the server on
Yields:
str: The transport type ('sse' or 'streamable_http')
"""
module_name, transport = request.param
proc = multiprocessing.Process(
target=run_server_with_transport,
args=(module_name, server_port, transport),
daemon=True,
)
proc.start()
# Wait for server to be running
max_attempts = 20
attempt = 0
while attempt < max_attempts:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", server_port))
break
except ConnectionRefusedError:
time.sleep(0.1)
attempt += 1
else:
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
yield transport
proc.kill()
proc.join(timeout=2)
if proc.is_alive():
print("Server process failed to terminate")
# Helper function to create client based on transport
def create_client_for_transport(transport: str, server_url: str):
"""Create the appropriate client context manager based on transport type."""
if transport == "sse":
endpoint = f"{server_url}/sse"
return sse_client(endpoint)
elif transport == "streamable-http":
endpoint = f"{server_url}/mcp"
return streamablehttp_client(endpoint)
else:
raise ValueError(f"Invalid transport: {transport}")
def unpack_streams(client_streams):
"""Unpack client streams handling different return values from SSE vs StreamableHTTP.
SSE client returns (read_stream, write_stream)
StreamableHTTP client returns (read_stream, write_stream, session_id_callback)
Args:
client_streams: Tuple from client context manager
Returns:
Tuple of (read_stream, write_stream)
"""
if len(client_streams) == 2:
return client_streams
else:
read_stream, write_stream, _ = client_streams
return read_stream, write_stream
# Callback functions for testing
async def sampling_callback(context, params) -> CreateMessageResult:
"""Sampling callback for tests."""
return CreateMessageResult(
role="assistant",
content=TextContent(
type="text",
text="This is a simulated LLM response for testing",
),
model="test-model",
)
async def elicitation_callback(context, params):
"""Elicitation callback for tests."""
# For restaurant booking test
if "No tables available" in params.message:
return ElicitResult(
action="accept",
content={"checkAlternative": True, "alternativeDate": "2024-12-26"},
)
else:
return ElicitResult(action="decline")
# Test basic tools
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("basic_tool", "sse"),
("basic_tool", "streamable-http"),
],
indirect=True,
)
async def test_basic_tools(server_transport: str, server_url: str) -> None:
"""Test basic tool functionality."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Tool Example"
assert result.capabilities.tools is not None
# Test sum tool
tool_result = await session.call_tool("sum", {"a": 5, "b": 3})
assert len(tool_result.content) == 1
assert isinstance(tool_result.content[0], TextContent)
assert tool_result.content[0].text == "8"
# Test weather tool
weather_result = await session.call_tool("get_weather", {"city": "London"})
assert len(weather_result.content) == 1
assert isinstance(weather_result.content[0], TextContent)
assert "Weather in London: 22degreesC" in weather_result.content[0].text
# Test resources
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("basic_resource", "sse"),
("basic_resource", "streamable-http"),
],
indirect=True,
)
async def test_basic_resources(server_transport: str, server_url: str) -> None:
"""Test basic resource functionality."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Resource Example"
assert result.capabilities.resources is not None
# Test document resource
doc_content = await session.read_resource(AnyUrl("file://documents/readme"))
assert isinstance(doc_content, ReadResourceResult)
assert len(doc_content.contents) == 1
assert isinstance(doc_content.contents[0], TextResourceContents)
assert "Content of readme" in doc_content.contents[0].text
# Test settings resource
settings_content = await session.read_resource(AnyUrl("config://settings"))
assert isinstance(settings_content, ReadResourceResult)
assert len(settings_content.contents) == 1
assert isinstance(settings_content.contents[0], TextResourceContents)
settings_json = json.loads(settings_content.contents[0].text)
assert settings_json["theme"] == "dark"
assert settings_json["language"] == "en"
# Test prompts
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("basic_prompt", "sse"),
("basic_prompt", "streamable-http"),
],
indirect=True,
)
async def test_basic_prompts(server_transport: str, server_url: str) -> None:
"""Test basic prompt functionality."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Prompt Example"
assert result.capabilities.prompts is not None
# Test review_code prompt
prompts = await session.list_prompts()
review_prompt = next((p for p in prompts.prompts if p.name == "review_code"), None)
assert review_prompt is not None
prompt_result = await session.get_prompt("review_code", {"code": "def hello():\n print('Hello')"})
assert isinstance(prompt_result, GetPromptResult)
assert len(prompt_result.messages) == 1
assert isinstance(prompt_result.messages[0].content, TextContent)
assert "Please review this code:" in prompt_result.messages[0].content.text
assert "def hello():" in prompt_result.messages[0].content.text
# Test debug_error prompt
debug_result = await session.get_prompt(
"debug_error", {"error": "TypeError: 'NoneType' object is not subscriptable"}
)
assert isinstance(debug_result, GetPromptResult)
assert len(debug_result.messages) == 3
assert debug_result.messages[0].role == "user"
assert isinstance(debug_result.messages[0].content, TextContent)
assert "I'm seeing this error:" in debug_result.messages[0].content.text
assert debug_result.messages[1].role == "user"
assert isinstance(debug_result.messages[1].content, TextContent)
assert "TypeError" in debug_result.messages[1].content.text
assert debug_result.messages[2].role == "assistant"
assert isinstance(debug_result.messages[2].content, TextContent)
assert "I'll help debug that" in debug_result.messages[2].content.text
# Test progress reporting
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("tool_progress", "sse"),
("tool_progress", "streamable-http"),
],
indirect=True,
)
async def test_tool_progress(server_transport: str, server_url: str) -> None:
"""Test tool progress reporting."""
transport = server_transport
collector = NotificationCollector()
async def message_handler(message):
await collector.handle_generic_notification(message)
if isinstance(message, Exception):
raise message
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Progress Example"
# Test progress callback
progress_updates = []
async def progress_callback(progress: float, total: float | None, message: str | None) -> None:
progress_updates.append((progress, total, message))
# Call tool with progress
steps = 3
tool_result = await session.call_tool(
"long_running_task",
{"task_name": "Test Task", "steps": steps},
progress_callback=progress_callback,
)
assert len(tool_result.content) == 1
assert isinstance(tool_result.content[0], TextContent)
assert "Task 'Test Task' completed" in tool_result.content[0].text
# Verify progress updates
assert len(progress_updates) == steps
for i, (progress, total, message) in enumerate(progress_updates):
expected_progress = (i + 1) / steps
assert abs(progress - expected_progress) < 0.01
assert total == 1.0
assert f"Step {i + 1}/{steps}" in message
# Verify log messages
assert len(collector.log_messages) > 0
# Test sampling
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("sampling", "sse"),
("sampling", "streamable-http"),
],
indirect=True,
)
async def test_sampling(server_transport: str, server_url: str) -> None:
"""Test sampling (LLM interaction) functionality."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Sampling Example"
assert result.capabilities.tools is not None
# Test sampling tool
sampling_result = await session.call_tool("generate_poem", {"topic": "nature"})
assert len(sampling_result.content) == 1
assert isinstance(sampling_result.content[0], TextContent)
assert "This is a simulated LLM response" in sampling_result.content[0].text
# Test elicitation
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("elicitation", "sse"),
("elicitation", "streamable-http"),
],
indirect=True,
)
async def test_elicitation(server_transport: str, server_url: str) -> None:
"""Test elicitation (user interaction) functionality."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream, elicitation_callback=elicitation_callback) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Elicitation Example"
# Test booking with unavailable date (triggers elicitation)
booking_result = await session.call_tool(
"book_table",
{
"date": "2024-12-25", # Unavailable date
"time": "19:00",
"party_size": 4,
},
)
assert len(booking_result.content) == 1
assert isinstance(booking_result.content[0], TextContent)
assert "[SUCCESS] Booked for 2024-12-26" in booking_result.content[0].text
# Test booking with available date (no elicitation)
booking_result = await session.call_tool(
"book_table",
{
"date": "2024-12-20", # Available date
"time": "20:00",
"party_size": 2,
},
)
assert len(booking_result.content) == 1
assert isinstance(booking_result.content[0], TextContent)
assert "[SUCCESS] Booked for 2024-12-20 at 20:00" in booking_result.content[0].text
# Test notifications
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("notifications", "sse"),
("notifications", "streamable-http"),
],
indirect=True,
)
async def test_notifications(server_transport: str, server_url: str) -> None:
"""Test notifications and logging functionality."""
transport = server_transport
collector = NotificationCollector()
async def message_handler(message):
await collector.handle_generic_notification(message)
if isinstance(message, Exception):
raise message
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Notifications Example"
# Call tool that generates notifications
tool_result = await session.call_tool("process_data", {"data": "test_data"})
assert len(tool_result.content) == 1
assert isinstance(tool_result.content[0], TextContent)
assert "Processed: test_data" in tool_result.content[0].text
# Verify log messages at different levels
assert len(collector.log_messages) >= 4
log_levels = {msg.level for msg in collector.log_messages}
assert "debug" in log_levels
assert "info" in log_levels
assert "warning" in log_levels
assert "error" in log_levels
# Verify resource list changed notification
assert len(collector.resource_notifications) > 0
# Test completion
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("completion", "sse"),
("completion", "streamable-http"),
],
indirect=True,
)
async def test_completion(server_transport: str, server_url: str) -> None:
"""Test completion (autocomplete) functionality."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Example"
assert result.capabilities.resources is not None
assert result.capabilities.prompts is not None
# Test resource completion
from mcp.types import ResourceTemplateReference
completion_result = await session.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"),
argument={"name": "repo", "value": ""},
context_arguments={"owner": "modelcontextprotocol"},
)
assert completion_result is not None
assert hasattr(completion_result, "completion")
assert completion_result.completion is not None
assert len(completion_result.completion.values) == 3
assert "python-sdk" in completion_result.completion.values
assert "typescript-sdk" in completion_result.completion.values
assert "specification" in completion_result.completion.values
# Test prompt completion
from mcp.types import PromptReference
completion_result = await session.complete(
ref=PromptReference(type="ref/prompt", name="review_code"),
argument={"name": "language", "value": "py"},
)
assert completion_result is not None
assert hasattr(completion_result, "completion")
assert completion_result.completion is not None
assert "python" in completion_result.completion.values
assert all(lang.startswith("py") for lang in completion_result.completion.values)
# Test FastMCP quickstart example
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("fastmcp_quickstart", "sse"),
("fastmcp_quickstart", "streamable-http"),
],
indirect=True,
)
async def test_fastmcp_quickstart(server_transport: str, server_url: str) -> None:
"""Test FastMCP quickstart example."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Demo"
# Test add tool
tool_result = await session.call_tool("add", {"a": 10, "b": 20})
assert len(tool_result.content) == 1
assert isinstance(tool_result.content[0], TextContent)
assert tool_result.content[0].text == "30"
# Test greeting resource directly
from pydantic import AnyUrl
resource_result = await session.read_resource(AnyUrl("greeting://Alice"))
assert len(resource_result.contents) == 1
assert isinstance(resource_result.contents[0], TextResourceContents)
assert resource_result.contents[0].text == "Hello, Alice!"
# Test structured output example
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_transport",
[
("structured_output", "sse"),
("structured_output", "streamable-http"),
],
indirect=True,
)
async def test_structured_output(server_transport: str, server_url: str) -> None:
"""Test structured output functionality."""
transport = server_transport
client_cm = create_client_for_transport(transport, server_url)
async with client_cm as client_streams:
read_stream, write_stream = unpack_streams(client_streams)
async with ClientSession(read_stream, write_stream) as session:
# Test initialization
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.serverInfo.name == "Structured Output Example"
# Test get_weather tool
weather_result = await session.call_tool("get_weather", {"city": "New York"})
assert len(weather_result.content) == 1
assert isinstance(weather_result.content[0], TextContent)
# Check that the result contains expected weather data
result_text = weather_result.content[0].text
assert "72.5" in result_text # temperature
assert "sunny" in result_text # condition
assert "45" in result_text # humidity
assert "5.2" in result_text # wind_speed