Merge branch 'main' into responsecachingmiddleware

This commit is contained in:
William Easton 2025-10-10 17:37:34 -04:00 committed by GitHub
commit 28370827dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
187 changed files with 8368 additions and 2798 deletions

View file

@ -11,6 +11,7 @@ from fastmcp.server.middleware.error_handling import (
RetryMiddleware,
)
from fastmcp.server.middleware.middleware import MiddlewareContext
from fastmcp.utilities.tests import caplog_for_fastmcp
@pytest.fixture
@ -60,8 +61,9 @@ class TestErrorHandlingMiddleware:
middleware = ErrorHandlingMiddleware()
error = ValueError("test error")
with caplog.at_level(logging.ERROR):
middleware._log_error(error, mock_context)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
middleware._log_error(error, mock_context)
assert "Error in test_method: ValueError: test error" in caplog.text
assert "ValueError:test_method" in middleware.error_counts
@ -72,8 +74,9 @@ class TestErrorHandlingMiddleware:
middleware = ErrorHandlingMiddleware(include_traceback=True)
error = ValueError("test error")
with caplog.at_level(logging.ERROR):
middleware._log_error(error, mock_context)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
middleware._log_error(error, mock_context)
assert "Error in test_method: ValueError: test error" in caplog.text
# The traceback is added to the log message
@ -95,8 +98,9 @@ class TestErrorHandlingMiddleware:
middleware = ErrorHandlingMiddleware(error_callback=callback)
error = ValueError("test error")
with caplog.at_level(logging.ERROR):
middleware._log_error(error, mock_context)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
middleware._log_error(error, mock_context)
assert "Error in error callback: callback error" in caplog.text
@ -189,9 +193,10 @@ class TestErrorHandlingMiddleware:
middleware = ErrorHandlingMiddleware()
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
with caplog.at_level(logging.ERROR):
with pytest.raises(McpError) as exc_info:
await middleware.on_message(mock_context, mock_call_next)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
with pytest.raises(McpError) as exc_info:
await middleware.on_message(mock_context, mock_call_next)
assert isinstance(exc_info.value, McpError)
assert exc_info.value.error.code == -32602
@ -293,8 +298,9 @@ class TestRetryMiddleware:
]
)
with caplog.at_level(logging.WARNING):
result = await middleware.on_request(mock_context, mock_call_next)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.WARNING):
result = await middleware.on_request(mock_context, mock_call_next)
assert result == "test_result"
assert mock_call_next.call_count == 3
@ -307,9 +313,10 @@ class TestRetryMiddleware:
# Fail all attempts
mock_call_next = AsyncMock(side_effect=ConnectionError("connection failed"))
with caplog.at_level(logging.WARNING):
with pytest.raises(ConnectionError):
await middleware.on_request(mock_context, mock_call_next)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.WARNING):
with pytest.raises(ConnectionError):
await middleware.on_request(mock_context, mock_call_next)
assert mock_call_next.call_count == 3 # initial + 2 retries
assert "Retrying in" in caplog.text
@ -385,14 +392,19 @@ class TestErrorHandlingMiddlewareIntegration:
error_handling_server.add_middleware(ErrorHandlingMiddleware())
with caplog.at_level(logging.ERROR):
async with Client(error_handling_server) as client:
# Test different types of errors
with pytest.raises(Exception):
await client.call_tool("failing_operation", {"error_type": "value"})
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
async with Client(error_handling_server) as client:
# Test different types of errors
with pytest.raises(Exception):
await client.call_tool(
"failing_operation", {"error_type": "value"}
)
with pytest.raises(Exception):
await client.call_tool("failing_operation", {"error_type": "file"})
with pytest.raises(Exception):
await client.call_tool(
"failing_operation", {"error_type": "file"}
)
log_text = caplog.text
@ -443,17 +455,20 @@ class TestErrorHandlingMiddlewareIntegration:
error_handling_server.add_middleware(ErrorHandlingMiddleware())
with caplog.at_level(logging.ERROR):
async with Client(error_handling_server) as client:
# Successful operation (should not generate error logs)
await client.call_tool("reliable_operation", {"data": "test"})
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
async with Client(error_handling_server) as client:
# Successful operation (should not generate error logs)
await client.call_tool("reliable_operation", {"data": "test"})
# Failed operation (should generate error log)
with pytest.raises(Exception):
await client.call_tool("failing_operation", {"error_type": "value"})
# Failed operation (should generate error log)
with pytest.raises(Exception):
await client.call_tool(
"failing_operation", {"error_type": "value"}
)
# Another successful operation
await client.call_tool("reliable_operation", {"data": "test2"})
# Another successful operation
await client.call_tool("reliable_operation", {"data": "test2"})
log_text = caplog.text
@ -533,18 +548,19 @@ class TestRetryMiddlewareIntegration:
)
)
with caplog.at_level(logging.WARNING):
async with Client(error_handling_server) as client:
# This operation fails intermittently - try several times
success_count = 0
for _ in range(5):
try:
await client.call_tool(
"intermittent_operation", {"fail_rate": 0.7}
)
success_count += 1
except Exception:
pass # Some failures expected even with retries
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.WARNING):
async with Client(error_handling_server) as client:
# This operation fails intermittently - try several times
success_count = 0
for _ in range(5):
try:
await client.call_tool(
"intermittent_operation", {"fail_rate": 0.7}
)
success_count += 1
except Exception:
pass # Some failures expected even with retries
# Should have some retry log messages
# Note: Retry logs might not appear if the underlying errors are wrapped by FastMCP
@ -584,17 +600,22 @@ class TestRetryMiddlewareIntegration:
)
)
with caplog.at_level(logging.ERROR):
async with Client(error_handling_server) as client:
# Try intermittent operation
try:
await client.call_tool("intermittent_operation", {"fail_rate": 0.9})
except Exception:
pass # May still fail even with retries
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
async with Client(error_handling_server) as client:
# Try intermittent operation
try:
await client.call_tool(
"intermittent_operation", {"fail_rate": 0.9}
)
except Exception:
pass # May still fail even with retries
# Try permanent failure
with pytest.raises(Exception):
await client.call_tool("failing_operation", {"error_type": "value"})
# Try permanent failure
with pytest.raises(Exception):
await client.call_tool(
"failing_operation", {"error_type": "value"}
)
log_text = caplog.text

View file

@ -0,0 +1,251 @@
"""Tests for middleware support during initialization."""
from typing import Any
import mcp.types as mt
from fastmcp import Client, FastMCP
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
class InitializationMiddleware(Middleware):
"""Middleware that captures initialization details."""
def __init__(self):
super().__init__()
self.initialized = False
self.client_info = None
self.session_data = {}
async def on_initialize(
self,
context: MiddlewareContext[mt.InitializeRequest],
call_next: CallNext[mt.InitializeRequest, None],
) -> None:
"""Capture initialization details and store session data."""
self.initialized = True
# Extract client info from the initialize params
if hasattr(context.message, "params") and hasattr(
context.message.params, "clientInfo"
):
self.client_info = context.message.params.clientInfo
# Store data in the context state for cross-request access
if context.fastmcp_context:
context.fastmcp_context.set_state("client_initialized", True)
if self.client_info:
context.fastmcp_context.set_state(
"client_name", getattr(self.client_info, "name", "unknown")
)
return await call_next(context)
class ClientDetectionMiddleware(Middleware):
"""Middleware that detects specific clients and modifies behavior.
This demonstrates storing data in the middleware instance itself
for cross-request access, since context state is request-scoped.
"""
def __init__(self):
super().__init__()
self.is_test_client = False
self.tools_modified = False
self.initialization_called = False
async def on_initialize(
self,
context: MiddlewareContext[mt.InitializeRequest],
call_next: CallNext[mt.InitializeRequest, None],
) -> None:
"""Detect test client during initialization."""
self.initialization_called = True
# For testing purposes, always set it to true
# Store in instance variable for cross-request access
self.is_test_client = True
return await call_next(context)
async def on_list_tools(
self,
context: MiddlewareContext[mt.ListToolsRequest],
call_next: CallNext[mt.ListToolsRequest, list],
) -> list:
"""Modify tools based on client detection."""
tools = await call_next(context)
# Use the instance variable set during initialization
if self.is_test_client:
# Add a special annotation to tools for test clients
for tool in tools:
if not hasattr(tool, "annotations"):
tool.annotations = mt.ToolAnnotations()
if tool.annotations is None:
tool.annotations = mt.ToolAnnotations()
# Mark as read-only for test clients
tool.annotations.readOnlyHint = True
self.tools_modified = True
return tools
async def test_simple_initialization_hook():
"""Test that the on_initialize hook is called."""
server = FastMCP("TestServer")
class SimpleInitMiddleware(Middleware):
def __init__(self):
super().__init__()
self.called = False
async def on_initialize(
self,
context: MiddlewareContext[mt.InitializeRequest],
call_next: CallNext[mt.InitializeRequest, None],
) -> None:
self.called = True
return await call_next(context)
middleware = SimpleInitMiddleware()
server.add_middleware(middleware)
# Connect client
async with Client(server):
# Middleware should have been called
assert middleware.called is True, "on_initialize was not called"
async def test_middleware_receives_initialization():
"""Test that middleware can intercept initialization requests."""
server = FastMCP("TestServer")
middleware = InitializationMiddleware()
server.add_middleware(middleware)
@server.tool
def test_tool(x: int) -> str:
return f"Result: {x}"
# Connect client
async with Client(server) as client:
# Middleware should have been called during initialization
assert middleware.initialized is True
# Test that the tool still works
result = await client.call_tool("test_tool", {"x": 42})
assert result.content[0].text == "Result: 42" # type: ignore[attr-defined]
async def test_client_detection_middleware():
"""Test middleware that detects specific clients and modifies behavior."""
server = FastMCP("TestServer")
middleware = ClientDetectionMiddleware()
server.add_middleware(middleware)
@server.tool
def example_tool() -> str:
return "example"
# Connect with a client
async with Client(server) as client:
# Middleware should have been called during initialization
assert middleware.initialization_called is True
assert middleware.is_test_client is True
# List tools to trigger modification
tools = await client.list_tools()
assert len(tools) == 1
assert middleware.tools_modified is True
# Check that the tool has the modified annotation
tool = tools[0]
assert tool.annotations is not None
assert tool.annotations.readOnlyHint is True
async def test_multiple_middleware_initialization():
"""Test that multiple middleware can handle initialization."""
server = FastMCP("TestServer")
init_mw = InitializationMiddleware()
detect_mw = ClientDetectionMiddleware()
server.add_middleware(init_mw)
server.add_middleware(detect_mw)
@server.tool
def test_tool() -> str:
return "test"
async with Client(server) as client:
# Both middleware should have processed initialization
assert init_mw.initialized is True
assert detect_mw.initialization_called is True
assert detect_mw.is_test_client is True
# List tools to check detection worked
await client.list_tools()
assert detect_mw.tools_modified is True
async def test_initialization_middleware_with_state_sharing():
"""Test that state set during initialization is available in later requests."""
server = FastMCP("TestServer")
class StateTrackingMiddleware(Middleware):
def __init__(self):
super().__init__()
self.init_state = {}
self.tool_state = {}
async def on_initialize(
self,
context: MiddlewareContext[mt.InitializeRequest],
call_next: CallNext[mt.InitializeRequest, None],
) -> None:
# Store some state during initialization
if context.fastmcp_context:
context.fastmcp_context.set_state("init_timestamp", "2024-01-01")
context.fastmcp_context.set_state("client_id", "test-123")
self.init_state["timestamp"] = "2024-01-01"
self.init_state["client_id"] = "test-123"
return await call_next(context)
async def on_call_tool(
self,
context: MiddlewareContext[mt.CallToolRequestParams],
call_next: CallNext[mt.CallToolRequestParams, Any],
) -> Any:
# Try to access state from initialization
if context.fastmcp_context:
timestamp = context.fastmcp_context.get_state("init_timestamp")
client_id = context.fastmcp_context.get_state("client_id")
self.tool_state["timestamp"] = timestamp
self.tool_state["client_id"] = client_id
return await call_next(context)
middleware = StateTrackingMiddleware()
server.add_middleware(middleware)
@server.tool
def test_tool() -> str:
return "success"
async with Client(server) as client:
# Initialization should have set state
assert middleware.init_state["timestamp"] == "2024-01-01"
assert middleware.init_state["client_id"] == "test-123"
# Call a tool - state should be accessible
result = await client.call_tool("test_tool", {})
assert result.content[0].text == "success" # type: ignore[attr-defined]
# State should have been accessible during tool call
# Note: State is request-scoped, so it won't persist across requests
# This test shows the pattern, but actual cross-request state would need
# external storage (Redis, DB, etc.)
# The middleware.tool_state might be None if state doesn't persist

View file

@ -2,37 +2,40 @@
import datetime
import logging
import re
from collections.abc import Generator
from typing import Any, Literal, TypeVar
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import mcp
import mcp.types
import pytest
from inline_snapshot import snapshot
from pydantic import AnyUrl
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.middleware.logging import (
LoggingMiddleware,
StructuredLoggingMiddleware,
)
from fastmcp.server.middleware.middleware import MiddlewareContext
from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext
from fastmcp.utilities.tests import caplog_for_fastmcp
FIXED_DATE = datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc)
T = TypeVar("T")
def remove_line_numbers(logs: str) -> str:
"""Remove line numbers from log messages."""
trimmed_logs = ""
lines = logs.split("\n")
for line in lines:
# Match only the first `:\d+ `
line = re.sub(pattern=r":\d+ ", repl=":LINE_NUMBER ", string=line, count=1)
trimmed_logs += line + "\n"
return trimmed_logs
def get_log_lines(
caplog: pytest.LogCaptureFixture, module: str | None = None
) -> list[str]:
"""Get log lines from a caplog fixture."""
return [
record.message
for record in caplog.records
if (module or "logging") in record.name
]
def new_mock_context(
@ -51,6 +54,17 @@ def new_mock_context(
return context
@pytest.fixture(autouse=True)
def mock_duration_ms() -> Generator[float, None]:
"""Mock duration_ms."""
patched = patch(
"fastmcp.server.middleware.logging._get_duration_ms", return_value=0.02
)
patched.start()
yield
patched.stop()
@pytest.fixture
def mock_context():
"""Create a mock middleware context."""
@ -77,15 +91,14 @@ class TestStructuredLoggingMiddleware:
def test_init_default(self):
"""Test default initialization."""
middleware = LoggingMiddleware()
middleware = StructuredLoggingMiddleware()
assert middleware.logger.name == "fastmcp.requests"
assert middleware.logger.name == "fastmcp.middleware.structured_logging"
assert middleware.log_level == logging.INFO
assert middleware.include_payloads is False
assert middleware.max_payload_length == 1000
assert middleware.include_payload_length is False
assert middleware.estimate_payload_tokens is False
assert middleware.structured_logging is False
assert middleware.structured_logging is True
def test_init_custom(self):
"""Test custom initialization."""
@ -108,14 +121,12 @@ class TestStructuredLoggingMiddleware:
"""Test message formatting without payloads."""
middleware = StructuredLoggingMiddleware()
message = middleware._create_before_message(mock_context, "test_event")
message = middleware._create_before_message(mock_context)
assert message == snapshot(
{
"event": "test_event",
"timestamp": "2023-01-01T00:00:00+00:00",
"event": "request_start",
"source": "client",
"type": "request",
"method": "test_method",
}
)
@ -126,14 +137,12 @@ class TestStructuredLoggingMiddleware:
"""Test message formatting with payloads."""
middleware = StructuredLoggingMiddleware(include_payloads=True)
message = middleware._create_before_message(mock_context, "test_event")
message = middleware._create_before_message(mock_context)
assert message == snapshot(
{
"event": "test_event",
"timestamp": "2023-01-01T00:00:00+00:00",
"event": "request_start",
"source": "client",
"type": "request",
"method": "test_method",
"payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}',
"payload_type": "CallToolRequest",
@ -143,14 +152,12 @@ class TestStructuredLoggingMiddleware:
def test_calculate_response_size(self, mock_context: MiddlewareContext[Any]):
"""Test response size calculation."""
middleware = StructuredLoggingMiddleware(include_payload_length=True)
message = middleware._create_before_message(mock_context, "test_event")
message = middleware._create_before_message(mock_context)
assert message == snapshot(
{
"event": "test_event",
"timestamp": "2023-01-01T00:00:00+00:00",
"event": "request_start",
"source": "client",
"type": "request",
"method": "test_method",
"payload_length": 98,
}
@ -163,14 +170,12 @@ class TestStructuredLoggingMiddleware:
middleware = StructuredLoggingMiddleware(
include_payload_length=True, estimate_payload_tokens=True
)
message = middleware._create_before_message(mock_context, "test_event")
message = middleware._create_before_message(mock_context)
assert message == snapshot(
{
"event": "test_event",
"timestamp": "2023-01-01T00:00:00+00:00",
"event": "request_start",
"source": "client",
"type": "request",
"method": "test_method",
"payload_tokens": 24,
"payload_length": 98,
@ -186,16 +191,18 @@ class TestStructuredLoggingMiddleware:
middleware = StructuredLoggingMiddleware()
mock_call_next = AsyncMock(return_value="test_result")
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
result = await middleware.on_message(mock_context, mock_call_next)
assert result == "test_result"
assert mock_call_next.called
assert remove_line_numbers(caplog.text) == snapshot("""\
INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "test_method", "type": "request", "source": "client"}
INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": "request_success", "timestamp": "2023-01-01T00:00:00+00:00", "method": "test_method", "type": "request", "source": "client"}
""")
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client"}',
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
]
)
async def test_on_message_failure(
self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture
@ -204,12 +211,16 @@ INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event":
middleware = StructuredLoggingMiddleware()
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
with pytest.raises(ValueError):
await middleware.on_message(mock_context, mock_call_next)
assert "Processing message:" in caplog.text
assert "Failed message: test_method - test error" in caplog.text
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client"}',
'{"event": "request_error", "method": "test_method", "source": "client", "duration_ms": 0.02, "error": "test error"}',
]
)
class TestLoggingMiddleware:
@ -218,7 +229,7 @@ class TestLoggingMiddleware:
def test_init_default(self):
"""Test default initialization."""
middleware = LoggingMiddleware()
assert middleware.logger.name == "fastmcp.requests"
assert middleware.logger.name == "fastmcp.middleware.logging"
assert middleware.log_level == logging.INFO
assert middleware.include_payloads is False
assert middleware.include_payload_length is False
@ -227,11 +238,11 @@ class TestLoggingMiddleware:
def test_format_message(self, mock_context: MiddlewareContext[Any]):
"""Test message formatting."""
middleware = LoggingMiddleware()
message = middleware._create_before_message(mock_context, "test_event")
message = middleware._create_before_message(mock_context)
formatted = middleware._format_message(message)
assert formatted == snapshot(
"event=test_event timestamp=2023-01-01T00:00:00+00:00 method=test_method type=request source=client"
"event=request_start method=test_method source=client"
)
def test_create_before_message_long_payload(
@ -240,12 +251,196 @@ class TestLoggingMiddleware:
"""Test message formatting with long payload truncation."""
middleware = LoggingMiddleware(include_payloads=True, max_payload_length=10)
message = middleware._create_before_message(mock_context, "test_event")
message = middleware._create_before_message(mock_context)
formatted = middleware._format_message(message)
assert "payload=" in formatted
assert "..." in formatted
assert formatted == snapshot(
'event=request_start method=test_method source=client payload={"method":... payload_type=CallToolRequest'
)
async def test_on_message_failure(
self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture
):
"""Test structured logging of failed messages."""
middleware = StructuredLoggingMiddleware()
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
with caplog_for_fastmcp(caplog):
with pytest.raises(ValueError):
await middleware.on_message(mock_context, mock_call_next)
# Check that we have structured JSON logs
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client"}',
'{"event": "request_error", "method": "test_method", "source": "client", "duration_ms": 0.02, "error": "test error"}',
]
)
async def test_on_message_with_pydantic_types_in_payload(
self,
mock_call_next: CallNext[Any, Any],
caplog: pytest.LogCaptureFixture,
):
"""Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True."""
mock_context = new_mock_context(
message=mcp.types.ReadResourceRequest(
method="resources/read",
params=mcp.types.ReadResourceRequestParams(
uri=AnyUrl("test://example/1"),
),
)
)
middleware = StructuredLoggingMiddleware(include_payloads=True)
with caplog_for_fastmcp(caplog):
result = await middleware.on_message(mock_context, mock_call_next)
assert result == "test_result"
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"resources/read\\",\\"params\\":{\\"_meta\\":null,\\"uri\\":\\"test://example/1\\"}}", "payload_type": "ReadResourceRequest"}',
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
]
)
async def test_on_message_with_resource_template_in_payload(
self,
mock_call_next: CallNext[Any, Any],
caplog: pytest.LogCaptureFixture,
):
"""Ensure ResourceTemplate in payload serializes via pydantic conversion without errors."""
mock_context = new_mock_context(
message=ResourceTemplate(
name="tmpl",
uri_template="tmpl://{id}",
parameters={"id": {"type": "string"}},
)
)
middleware = StructuredLoggingMiddleware(include_payloads=True)
with caplog_for_fastmcp(caplog):
result = await middleware.on_message(mock_context, mock_call_next)
assert result == "test_result"
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"title\\":null,\\"description\\":null,\\"tags\\":[],\\"meta\\":null,\\"enabled\\":true,\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}',
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
]
)
async def test_on_message_with_nonserializable_payload_falls_back_to_str(
self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture
):
"""Ensure non-JSONable objects fall back to string serialization in payload."""
class NonSerializable:
def __str__(self) -> str:
return "NON_SERIALIZABLE"
mock_context = new_mock_context(
message=mcp.types.CallToolRequest(
method="tools/call",
params=mcp.types.CallToolRequestParams(
name="test_method",
arguments={"obj": NonSerializable()},
),
)
)
middleware = StructuredLoggingMiddleware(include_payloads=True)
with caplog_for_fastmcp(caplog):
result = await middleware.on_message(mock_context, mock_call_next)
assert result == "test_result"
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"tools/call\\",\\"params\\":{\\"_meta\\":null,\\"name\\":\\"test_method\\",\\"arguments\\":{\\"obj\\":\\"NON_SERIALIZABLE\\"}}}", "payload_type": "CallToolRequest"}',
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
]
)
async def test_on_message_with_custom_serializer_applied(
self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture
):
"""Ensure a custom serializer is used for non-JSONable payloads."""
# Provide a serializer that replaces entire payload with a fixed string
def custom_serializer(_: Any) -> str:
return "CUSTOM_PAYLOAD"
mock_context = new_mock_context(
message=mcp.types.CallToolRequest(
method="tools/call",
params=mcp.types.CallToolRequestParams(
name="test_method",
arguments={"obj": "OBJECT"},
),
)
)
middleware = StructuredLoggingMiddleware(
include_payloads=True, payload_serializer=custom_serializer
)
with caplog_for_fastmcp(caplog):
result = await middleware.on_message(mock_context, mock_call_next)
assert result == "test_result"
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "CUSTOM_PAYLOAD", "payload_type": "CallToolRequest"}',
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
]
)
@pytest.fixture
def logging_server():
"""Create a FastMCP server specifically for logging middleware tests."""
from fastmcp import FastMCP
mcp = FastMCP("LoggingTestServer")
@mcp.tool
def simple_operation(data: str) -> str:
"""A simple operation for testing logging."""
return f"Processed: {data}"
@mcp.tool
def complex_operation(items: list[str], mode: str = "default") -> dict:
"""A complex operation with structured data."""
return {"processed_items": len(items), "mode": mode, "result": "success"}
@mcp.tool
def operation_with_error(should_fail: bool = False) -> str:
"""An operation that can be made to fail."""
if should_fail:
raise ValueError("Operation failed intentionally")
return "Operation completed successfully"
@mcp.resource("log://test")
def test_resource() -> str:
"""A test resource for logging."""
return "Test resource content"
@mcp.prompt
def test_prompt() -> str:
"""A test prompt for logging."""
return "Test prompt content"
return mcp
class TestLoggingMiddlewareIntegration:
@ -290,33 +485,29 @@ class TestLoggingMiddlewareIntegration:
):
"""Test that logging middleware captures successful operations."""
logging_middleware = LoggingMiddleware(methods=["tools/call"])
logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment]
lambda _: FIXED_DATE.isoformat()
)
logging_server.add_middleware(logging_middleware)
with caplog.at_level(logging.INFO):
async with Client(logging_server) as client:
await client.call_tool(
name="simple_operation", arguments={"data": "test_data"}
)
await client.call_tool(
name="complex_operation",
arguments={"items": ["a", "b", "c"], "mode": "batch"},
)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.INFO):
async with Client(logging_server) as client:
await client.call_tool(
name="simple_operation", arguments={"data": "test_data"}
)
await client.call_tool(
name="complex_operation",
arguments={"items": ["a", "b", "c"], "mode": "batch"},
)
# Should have processing and completion logs for both operations
assert remove_line_numbers(caplog.text) == snapshot("""\
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest
INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client
INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type ListToolsRequest
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest
INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client
INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client
""")
assert get_log_lines(caplog) == snapshot(
[
"event=request_start method=tools/call source=client",
"event=request_success method=tools/call source=client duration_ms=0.02",
"event=request_start method=tools/call source=client",
"event=request_success method=tools/call source=client duration_ms=0.02",
]
)
async def test_logging_middleware_logs_failures(
self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
@ -324,7 +515,7 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques
"""Test that logging middleware captures failed operations."""
logging_server.add_middleware(LoggingMiddleware(methods=["tools/call"]))
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(logging_server) as client:
# This should fail and be logged
with pytest.raises(Exception):
@ -335,8 +526,9 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques
log_text = caplog.text
# Should have processing and failure logs
assert "Processing message:" in log_text
assert "Failed message: tools/call" in log_text
assert log_text.splitlines()[-1] == snapshot(
"ERROR fastmcp.middleware.logging:logging.py:122 event=request_error method=tools/call source=client duration_ms=0.02 error=Error calling tool 'operation_with_error': Operation failed intentionally"
)
async def test_logging_middleware_with_payloads(
self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
@ -346,24 +538,18 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques
middleware = LoggingMiddleware(
include_payloads=True, max_payload_length=500, methods=["tools/call"]
)
middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment]
lambda _: FIXED_DATE.isoformat()
)
logging_server.add_middleware(middleware)
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(logging_server) as client:
await client.call_tool("simple_operation", {"data": "payload_test"})
log_text = caplog.text
assert remove_line_numbers(log_text) == snapshot("""\
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest
INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams
INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type ListToolsRequest
""")
assert get_log_lines(caplog) == snapshot(
[
'event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams',
"event=request_success method=tools/call source=client duration_ms=0.02",
]
)
async def test_structured_logging_middleware_produces_json(
self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
@ -373,34 +559,21 @@ INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of
logging_middleware = StructuredLoggingMiddleware(
include_payloads=True, methods=["tools/call"]
)
logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment]
lambda _: FIXED_DATE.isoformat()
)
logging_server.add_middleware(logging_middleware)
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(logging_server) as client:
await client.call_tool(
name="simple_operation", arguments={"data": "json_test"}
)
# Extract JSON log entries
log_lines = [
record.message
for record in caplog.records
if record.name == "fastmcp.structured"
]
assert len(log_lines) >= 2 # Should have start and success entries
assert remove_line_numbers(caplog.text) == snapshot("""\
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest
INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}
INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": "request_success", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client"}
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type ListToolsRequest
""")
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "tools/call", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}',
'{"event": "request_success", "method": "tools/call", "source": "client", "duration_ms": 0.02}',
]
)
async def test_structured_logging_middleware_handles_errors(
self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
@ -408,25 +581,23 @@ INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of
"""Test structured logging of errors with JSON format."""
logging_middleware = StructuredLoggingMiddleware(methods=["tools/call"])
logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment]
lambda _: FIXED_DATE.isoformat()
)
logging_server.add_middleware(logging_middleware)
with caplog.at_level(logging.INFO):
async with Client(logging_server) as client:
with pytest.raises(Exception):
await client.call_tool(
"operation_with_error", {"should_fail": True}
)
with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.INFO):
async with Client(logging_server) as client:
with pytest.raises(Exception):
await client.call_tool(
"operation_with_error", {"should_fail": True}
)
assert remove_line_numbers(caplog.text) == snapshot("""\
INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest
INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client"}
ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call - Error calling tool 'operation_with_error': Operation failed intentionally
""")
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "tools/call", "source": "client"}',
'{"event": "request_error", "method": "tools/call", "source": "client", "duration_ms": 0.02, "error": "Error calling tool \'operation_with_error\': Operation failed intentionally"}',
]
)
async def test_logging_middleware_with_different_operations(
self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
@ -444,7 +615,7 @@ ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call -
)
)
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(logging_server) as client:
# Test different operation types
await client.call_tool("simple_operation", {"data": "test"})
@ -452,16 +623,18 @@ ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call -
await client.get_prompt("test_prompt")
await client.list_resources()
log_text = caplog.text
# Should have logs for all different operation types
# Note: Different operations may have different method names
processing_count = log_text.count("Processing message:")
completion_count = log_text.count("Completed message:")
# Should have processed all 4 operations
assert processing_count == 4
assert completion_count == 4
assert get_log_lines(caplog) == snapshot(
[
"event=request_start method=tools/call source=client",
"event=request_success method=tools/call source=client duration_ms=0.02",
"event=request_start method=resources/read source=client",
"event=request_success method=resources/read source=client duration_ms=0.02",
"event=request_start method=prompts/get source=client",
"event=request_success method=prompts/get source=client duration_ms=0.02",
"event=request_start method=resources/list source=client",
"event=request_success method=resources/list source=client duration_ms=0.02",
]
)
async def test_logging_middleware_custom_configuration(
self, logging_server: FastMCP
@ -491,5 +664,7 @@ ERROR fastmcp.structured:logging.py:LINE_NUMBER Failed message: tools/call -
# Check that our custom logger captured the logs
log_output = log_buffer.getvalue()
assert "Processing message:" in log_output
assert "payload=" in log_output
assert log_output == snapshot("""\
event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"custom_test"}} payload_type=CallToolRequestParams
event=request_success method=tools/call source=client duration_ms=0.02
""")

View file

@ -293,6 +293,17 @@ class TestMiddlewareHooks:
result = list_prompts_calls[0].result
assert isinstance(result, list)
async def test_initialize(
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
async with Client(mcp_server) as client:
await client.ping()
assert recording_middleware.assert_called(at_least=1)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_initialize", at_least=1)
async def test_list_tools_filtering_middleware(self):
"""Test that middleware can filter tools."""

View file

@ -306,9 +306,10 @@ class TestRateLimitingMiddlewareIntegration:
async def test_rate_limiting_blocks_rapid_requests(self, rate_limit_server):
"""Test that rate limiting blocks rapid successive requests."""
# Very restrictive rate limit (accounting for extra list_tools calls per tool call)
# Very restrictive rate limit (accounting for initialization and list_tools calls)
# Requests: 1 initialize + 1 list_tools + 4 call_tools = 6 total before limit
rate_limit_server.add_middleware(
RateLimitingMiddleware(max_requests_per_second=10.0, burst_capacity=5)
RateLimitingMiddleware(max_requests_per_second=10.0, burst_capacity=6)
)
async with Client(rate_limit_server) as client:
@ -356,7 +357,7 @@ class TestRateLimitingMiddlewareIntegration:
"""Test sliding window rate limiting implementation."""
rate_limit_server.add_middleware(
SlidingWindowRateLimitingMiddleware(
max_requests=5, # Accounting for extra list_tools calls
max_requests=6, # 1 init + 1 list_tools + 3 calls + 1 to fail
window_minutes=1, # 1-minute window
)
)
@ -374,7 +375,7 @@ class TestRateLimitingMiddlewareIntegration:
async def test_rate_limiting_with_different_operations(self, rate_limit_server):
"""Test that rate limiting applies to all types of operations."""
rate_limit_server.add_middleware(
RateLimitingMiddleware(max_requests_per_second=9.0, burst_capacity=4)
RateLimitingMiddleware(max_requests_per_second=9.0, burst_capacity=5)
)
async with Client(rate_limit_server) as client:
@ -395,8 +396,8 @@ class TestRateLimitingMiddlewareIntegration:
rate_limit_server.add_middleware(
RateLimitingMiddleware(
max_requests_per_second=6.0, # Accounting for extra list_tools calls
burst_capacity=3,
max_requests_per_second=6.0, # Accounting for initialization and list_tools calls
burst_capacity=4,
get_client_id=get_client_id,
)
)
@ -416,8 +417,8 @@ class TestRateLimitingMiddlewareIntegration:
rate_limit_server.add_middleware(
RateLimitingMiddleware(
max_requests_per_second=6.0,
burst_capacity=4,
global_limit=True, # Accounting for extra list_tools calls
burst_capacity=5, # 1 init + 2 list_tools + 2 calls before limit
global_limit=True, # Accounting for initialization and list_tools calls
)
)
@ -435,7 +436,7 @@ class TestRateLimitingMiddlewareIntegration:
rate_limit_server.add_middleware(
RateLimitingMiddleware(
max_requests_per_second=10.0, # 10 per second = 1 every 100ms
burst_capacity=3,
burst_capacity=4,
)
)

View file

@ -11,6 +11,7 @@ from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.middleware.middleware import MiddlewareContext
from fastmcp.server.middleware.timing import DetailedTimingMiddleware, TimingMiddleware
from fastmcp.utilities.tests import caplog_for_fastmcp
@pytest.fixture
@ -47,7 +48,7 @@ class TestTimingMiddleware:
"""Test timing successful requests."""
middleware = TimingMiddleware()
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
result = await middleware.on_request(mock_context, mock_call_next)
assert result == "test_result"
@ -60,7 +61,7 @@ class TestTimingMiddleware:
middleware = TimingMiddleware()
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
with pytest.raises(ValueError):
await middleware.on_request(mock_context, mock_call_next)
@ -84,7 +85,7 @@ class TestDetailedTimingMiddleware:
context.message.name = "test_tool"
mock_call_next = AsyncMock(return_value="tool_result")
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
result = await middleware.on_call_tool(context, mock_call_next)
assert result == "tool_result"
@ -97,7 +98,7 @@ class TestDetailedTimingMiddleware:
context.message.uri = "test://resource"
mock_call_next = AsyncMock(return_value="resource_result")
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
result = await middleware.on_read_resource(context, mock_call_next)
assert result == "resource_result"
@ -110,7 +111,7 @@ class TestDetailedTimingMiddleware:
context.message.name = "test_prompt"
mock_call_next = AsyncMock(return_value="prompt_result")
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
result = await middleware.on_get_prompt(context, mock_call_next)
assert result == "prompt_result"
@ -122,7 +123,7 @@ class TestDetailedTimingMiddleware:
context = MagicMock()
mock_call_next = AsyncMock(return_value="tools_result")
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
result = await middleware.on_list_tools(context, mock_call_next)
assert result == "tools_result"
@ -135,7 +136,7 @@ class TestDetailedTimingMiddleware:
context.message.name = "failing_tool"
mock_call_next = AsyncMock(side_effect=RuntimeError("operation failed"))
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
with pytest.raises(RuntimeError):
await middleware.on_call_tool(context, mock_call_next)
@ -155,15 +156,15 @@ def timing_server():
@mcp.tool
def short_task() -> str:
"""A task that takes 0.1 seconds."""
time.sleep(0.1)
return "Done after 0.1s"
"""A task that takes 0.01 seconds."""
time.sleep(0.01)
return "Done after 0.01 seconds"
@mcp.tool
def medium_task() -> str:
"""A task that takes 0.15 seconds."""
time.sleep(0.15)
return "Done after 0.15s"
"""A task that takes 0.02 seconds."""
time.sleep(0.02)
return "Done after 0.02 seconds"
@mcp.tool
def failing_task() -> str:
@ -173,14 +174,14 @@ def timing_server():
@mcp.resource("timer://test")
def test_resource() -> str:
"""A resource that takes time to read."""
time.sleep(0.05)
return "Resource content after 0.05s"
time.sleep(0.005)
return "Resource content after 0.005 seconds"
@mcp.prompt
def test_prompt() -> str:
"""A prompt that takes time to generate."""
time.sleep(0.08)
return "Prompt content after 0.08s"
time.sleep(0.008)
return "Prompt content after 0.008 seconds"
return mcp
@ -194,7 +195,7 @@ class TestTimingMiddlewareIntegration:
"""Test that timing middleware accurately measures tool execution times."""
timing_server.add_middleware(TimingMiddleware())
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(timing_server) as client:
# Test instant task
await client.call_tool("instant_task")
@ -225,7 +226,7 @@ class TestTimingMiddlewareIntegration:
"""Test that timing middleware measures time even for failed operations."""
timing_server.add_middleware(TimingMiddleware())
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(timing_server) as client:
# This should fail but still be timed
with pytest.raises(Exception):
@ -241,7 +242,7 @@ class TestTimingMiddlewareIntegration:
"""Test that detailed timing middleware provides operation-specific timing."""
timing_server.add_middleware(DetailedTimingMiddleware())
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(timing_server) as client:
# Test tool call
await client.call_tool("short_task")
@ -271,7 +272,7 @@ class TestTimingMiddlewareIntegration:
"""Test timing middleware with concurrent operations."""
timing_server.add_middleware(TimingMiddleware())
with caplog.at_level(logging.INFO):
with caplog_for_fastmcp(caplog):
async with Client(timing_server) as client:
# Run multiple operations concurrently
tasks = [
@ -290,7 +291,7 @@ class TestTimingMiddlewareIntegration:
len(timing_logs) >= 3
) # At least 3 tool calls, may have additional list_tools calls
async def test_timing_middleware_custom_logger(self, timing_server):
async def test_timing_middleware_custom_logger(self, timing_server, caplog):
"""Test timing middleware with custom logger configuration."""
import io
import logging