"""Tests for FastMCP error handling and edge cases - Phase 9."""
import pytest
import logging
from unittest.mock import patch
from typing import Dict, Any
from mcp_chain.fastmcp import FastMCPServer
from mcp_chain.serve import serve
# Import the logger used in fastmcp.py so we can test it
logger = logging.getLogger("mcp_chain.fastmcp")
class MockDictMCPServer:
"""Mock DictMCPServer for testing error cases."""
def __init__(
self,
metadata: Dict[str, Any] = None,
should_fail_metadata: bool = False,
should_fail_request: bool = False,
):
self._metadata = metadata or {}
self._should_fail_metadata = should_fail_metadata
self._should_fail_request = should_fail_request
def get_metadata(self) -> Dict[str, Any]:
if self._should_fail_metadata:
raise RuntimeError("Metadata retrieval failed")
return self._metadata
def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
if self._should_fail_request:
raise RuntimeError("Request handling failed")
return {"result": "success"}
def test_fastmcp_server_handles_no_tools_or_resources():
"""Test FastMCPServer handles case where middleware chain has no tools or resources."""
mock_server = MockDictMCPServer({}) # Empty metadata
# Should not fail to initialize
fastmcp_server = FastMCPServer(mock_server)
assert fastmcp_server is not None
# Should handle empty metadata without errors
metadata = fastmcp_server._downstream.get_metadata()
assert metadata == {}
# Should not have any tools or resources registered
# We'll add logging to verify this in the implementation
def test_fastmcp_server_logs_when_no_tools_or_resources(caplog):
"""Test FastMCPServer logs appropriately when no tools or resources are found."""
with caplog.at_level(logging.INFO):
mock_server = MockDictMCPServer({}) # Empty metadata
FastMCPServer(mock_server)
# Should log when no tools/resources are found
assert "No tools found in metadata" in caplog.text
assert "No resources found in metadata" in caplog.text
def test_fastmcp_server_handles_downstream_request_failure():
"""Test FastMCPServer handles case where downstream server request fails."""
# RED: This test should fail initially because we don't handle downstream failures
mock_server = MockDictMCPServer(
metadata={"tools": [{"name": "test_tool", "description": "Test tool"}]},
should_fail_request=True, # This will make handle_request fail, but not get_metadata
)
# Should initialize successfully
FastMCPServer(mock_server)
# Simulate a tool call that should fail
try:
mock_server.handle_request(
{"method": "tools/call", "params": {"name": "test_tool", "arguments": {}}}
)
# If we get here, the test should fail because we expect an error
assert False, "Expected RuntimeError was not raised"
except RuntimeError as e:
# For now, the error should propagate
assert "Request handling failed" in str(e)
def test_fastmcp_server_handles_metadata_retrieval_failure():
"""Test FastMCPServer handles case where metadata retrieval fails."""
mock_server = MockDictMCPServer(should_fail_metadata=True)
with pytest.raises(RuntimeError, match="Metadata retrieval failed"):
# This should fail when trying to get metadata during initialization
FastMCPServer(mock_server)
def test_fastmcp_tool_handler_with_error_should_log_and_reraise(caplog):
"""Test that tool handlers log errors before re-raising them."""
# RED: This test should fail because we don't have error logging in tool handlers yet
mock_server = MockDictMCPServer(
metadata={"tools": [{"name": "test_tool", "description": "Test tool"}]},
should_fail_request=True,
)
fastmcp_server = FastMCPServer(mock_server)
# Test the tool handler directly by simulating what FastMCP would do
# We need to test the actual tool handler function created in _register_tool
with caplog.at_level(logging.ERROR):
# Get the tool handler function by accessing the registered tools
# Since we can't easily access the internal FastMCP tool registry,
# let's test by creating a tool handler manually with the same logic
tool_name = "test_tool"
# This simulates the tool_handler function created in _register_tool
def test_tool_handler(**kwargs):
request = {
"method": "tools/call",
"params": {"name": tool_name, "arguments": kwargs},
}
try:
response = fastmcp_server._downstream.handle_request(request)
return response
except Exception as e:
logger.error("Error executing tool '%s': %s", tool_name, e)
raise
# Test the tool handler with arguments that will cause a failure
try:
test_tool_handler(param1="value1")
assert False, "Expected error to be raised"
except RuntimeError:
pass # Expected, but we should have logged it
# Now we should see the error log
assert "Error executing tool 'test_tool'" in caplog.text
@patch("mcp_chain.fastmcp.FastMCP")
def test_fastmcp_server_handles_fastmcp_initialization_failure(
mock_fastmcp_class, caplog
):
"""Test FastMCPServer handles case where FastMCP fails to initialize."""
# RED: This test should fail initially because we don't handle FastMCP init failures
mock_fastmcp_class.side_effect = RuntimeError("FastMCP initialization failed")
mock_server = MockDictMCPServer(
{"tools": [{"name": "test_tool", "description": "Test tool"}]}
)
with caplog.at_level(logging.ERROR):
with pytest.raises(RuntimeError, match="FastMCP initialization failed"):
FastMCPServer(mock_server)
# Should log the FastMCP initialization failure
assert "Failed to initialize FastMCP" in caplog.text
def test_fastmcp_server_handles_duplicate_tool_names(caplog):
"""Test FastMCPServer handles case where tools have duplicate names."""
# RED: This test should fail initially because we don't handle duplicate tool names
mock_server = MockDictMCPServer(
{
"tools": [
{"name": "duplicate_tool", "description": "First tool"},
{"name": "duplicate_tool", "description": "Second tool with same name"},
]
}
)
with caplog.at_level(logging.WARNING):
# Should not fail to initialize, but should log warning about duplicates
fastmcp_server = FastMCPServer(mock_server)
assert fastmcp_server is not None
# Should log warning about duplicate tool names
assert (
"Duplicate tool name 'duplicate_tool' found, skipping registration"
in caplog.text
)
def test_fastmcp_server_handles_duplicate_resource_uris(caplog):
"""Test FastMCPServer handles case where resources have duplicate URIs."""
# RED: This test should fail initially because we don't handle duplicate resource URIs
mock_server = MockDictMCPServer(
{
"resources": [
{"uri": "test://resource", "name": "First resource"},
{"uri": "test://resource", "name": "Second resource with same URI"},
]
}
)
with caplog.at_level(logging.WARNING):
# Should not fail to initialize, but should log warning about duplicates
fastmcp_server = FastMCPServer(mock_server)
assert fastmcp_server is not None
# Should log warning about duplicate resource URIs
assert (
"Duplicate resource URI 'test://resource' found, skipping registration"
in caplog.text
)
def test_fastmcp_server_handles_malformed_tool_metadata(caplog):
"""Test FastMCPServer handles case where tool metadata is malformed."""
# RED: This test should fail initially because we don't handle malformed metadata
mock_server = MockDictMCPServer(
{
"tools": [
{"description": "Tool without name"}, # Missing required 'name' field
{"name": "valid_tool", "description": "Valid tool"},
]
}
)
with caplog.at_level(logging.ERROR):
# Should not fail to initialize, but should log error about malformed tool
fastmcp_server = FastMCPServer(mock_server)
assert fastmcp_server is not None
# Should log error about malformed tool metadata
assert "Malformed tool metadata" in caplog.text
def test_serve_function_handles_invalid_chain_gracefully():
"""Test that serve function validates input and provides helpful error messages."""
# RED: This test should fail initially because we don't have comprehensive validation
invalid_chain = "not a server"
with pytest.raises(TypeError, match="Chain must implement DictMCPServer protocol"):
serve(invalid_chain)
@patch("mcp_chain.serve.FastMCPServer")
def test_serve_function_handles_fastmcp_server_creation_failure(
mock_fastmcp_server_class, caplog
):
"""Test that serve function handles FastMCPServer creation failures gracefully."""
# RED: This test should fail initially because we don't handle server creation failures
mock_fastmcp_server_class.side_effect = RuntimeError("Server creation failed")
mock_server = MockDictMCPServer(
{"tools": [{"name": "test_tool", "description": "Test tool"}]}
)
with caplog.at_level(logging.ERROR):
with pytest.raises(RuntimeError, match="Server creation failed"):
serve(mock_server)
# Should log the server creation failure
assert "Failed to create FastMCPServer" in caplog.text