From 36975a518282601a964e7ce92bc998df5d4abecc Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 26 Aug 2025 22:01:11 +0800 Subject: [PATCH] chore: add configurable serializer --- src/fastmcp/server/middleware/logging.py | 38 +++++++++++++-- tests/server/middleware/test_logging.py | 61 ++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index 0e0390385..7fcfe37f8 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -2,8 +2,11 @@ import json import logging +from collections.abc import Callable from typing import Any +import pydantic_core + from .middleware import CallNext, Middleware, MiddlewareContext @@ -111,6 +114,7 @@ class StructuredLoggingMiddleware(Middleware): log_level: int = logging.INFO, include_payloads: bool = False, methods: list[str] | None = None, + serializer: Callable[[Any], Any] | None = None, ): """Initialize structured logging middleware. @@ -119,11 +123,33 @@ class StructuredLoggingMiddleware(Middleware): log_level: Log level for messages (default: INFO) include_payloads: Whether to include message payloads in logs methods: List of methods to log. If None, logs all methods. + serializer: Optional callable to convert objects to JSON-serializable + values when logging. Defaults to a safe converter that tries + pydantic_core.to_jsonable_python and falls back to str. """ self.logger = logger or logging.getLogger("fastmcp.structured") self.log_level = log_level self.include_payloads = include_payloads self.methods = methods + self.serializer = serializer + + def _json_default(self, obj: Any) -> Any: + """Default converter for json.dumps to handle non-serializable objects. + + Tries a user-provided serializer first, then pydantic conversion, then str. + """ + if self.serializer is not None: + try: + return self.serializer(obj) + except Exception: + pass + try: + return pydantic_core.to_jsonable_python(obj) + except Exception: + try: + return str(obj) + except Exception: + return "" def _create_log_entry( self, context: MiddlewareContext, event: str, **extra_fields @@ -152,7 +178,9 @@ class StructuredLoggingMiddleware(Middleware): if self.methods and context.method not in self.methods: return await call_next(context) - self.logger.log(self.log_level, json.dumps(start_entry, default=str)) + self.logger.log( + self.log_level, json.dumps(start_entry, default=self._json_default) + ) try: result = await call_next(context) @@ -162,7 +190,9 @@ class StructuredLoggingMiddleware(Middleware): "request_success", result_type=type(result).__name__ if result else None, ) - self.logger.log(self.log_level, json.dumps(success_entry, default=str)) + self.logger.log( + self.log_level, json.dumps(success_entry, default=self._json_default) + ) return result except Exception as e: @@ -172,5 +202,7 @@ class StructuredLoggingMiddleware(Middleware): error_type=type(e).__name__, error_message=str(e), ) - self.logger.log(logging.ERROR, json.dumps(error_entry, default=str)) + self.logger.log( + logging.ERROR, json.dumps(error_entry, default=self._json_default) + ) raise diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 4bb0b98ac..46c84cf00 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -216,7 +216,7 @@ class TestStructuredLoggingMiddleware: async def test_on_message_with_resource_template_in_payload( self, mock_context, mock_call_next, caplog ): - """Ensure ResourceTemplate in payload serializes via default=str without errors.""" + """Ensure ResourceTemplate in payload serializes via pydantic conversion without errors.""" from fastmcp.resources import ResourceTemplate template = ResourceTemplate( @@ -239,8 +239,63 @@ class TestStructuredLoggingMiddleware: start_entry = json.loads(log_lines[0]) assert start_entry["event"] == "request_start" assert "template" in start_entry["payload"] - # After json.loads, default=str ensures complex object became a JSON string - assert isinstance(start_entry["payload"]["template"], str) + # With pydantic conversion, complex object becomes a JSONable dict + assert isinstance(start_entry["payload"]["template"], dict) + assert start_entry["payload"]["template"]["uri_template"] == "tmpl://{id}" + + async def test_on_message_with_nonserializable_payload_falls_back_to_str( + self, mock_context, mock_call_next, caplog + ): + """Ensure non-JSONable objects fall back to string serialization in payload.""" + + class NonSerializable: + def __str__(self) -> str: + return "NON_SERIALIZABLE" + + mock_context.message.__dict__["obj"] = NonSerializable() + + middleware = StructuredLoggingMiddleware(include_payloads=True) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) >= 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["obj"] == "NON_SERIALIZABLE" + + async def test_on_message_with_custom_serializer_applied( + self, mock_context, mock_call_next, caplog + ): + """Ensure a custom serializer is used for non-JSONable payloads.""" + + class CustomType: + pass + + def custom_serializer(o): + if isinstance(o, CustomType): + return "CUSTOM:CustomType" + raise TypeError("unsupported") + + mock_context.message.__dict__["special"] = CustomType() + + middleware = StructuredLoggingMiddleware( + include_payloads=True, serializer=custom_serializer + ) + + with caplog.at_level(logging.INFO): + result = await middleware.on_message(mock_context, mock_call_next) + + assert result == "test_result" + + log_lines = [record.message for record in caplog.records] + assert len(log_lines) >= 2 + start_entry = json.loads(log_lines[0]) + assert start_entry["event"] == "request_start" + assert start_entry["payload"]["special"] == "CUSTOM:CustomType" @pytest.fixture