From 25e4753d4c01439c9a08ebcbb79e647dab69405e Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 02:09:41 +0000 Subject: [PATCH 1/5] chore: Update fastmcp.json schema --- docs/public/schemas/fastmcp.json/latest.json | 16 ++++++++++++---- docs/public/schemas/fastmcp.json/v1.json | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json index 81d0ad754..c28a50452 100644 --- a/docs/public/schemas/fastmcp.json/latest.json +++ b/docs/public/schemas/fastmcp.json/latest.json @@ -243,17 +243,25 @@ "editable": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Directory to install in editable mode", + "description": "Directories to install in editable mode", "examples": [ - ".", - "../my-package" + [ + ".", + "../my-package" + ], + [ + "/path/to/package" + ] ], "title": "Editable" } diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json index 81d0ad754..c28a50452 100644 --- a/docs/public/schemas/fastmcp.json/v1.json +++ b/docs/public/schemas/fastmcp.json/v1.json @@ -243,17 +243,25 @@ "editable": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Directory to install in editable mode", + "description": "Directories to install in editable mode", "examples": [ - ".", - "../my-package" + [ + ".", + "../my-package" + ], + [ + "/path/to/package" + ] ], "title": "Editable" } From ddf691cf5356c68a22275e1f1d107803228d66be Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 26 Aug 2025 10:06:51 -0400 Subject: [PATCH 2/5] Support multiple --with-editable flags in CLI commands (#1634) --- src/fastmcp/cli/claude.py | 6 ++-- src/fastmcp/cli/cli.py | 42 +++++++++++++---------- src/fastmcp/cli/install/claude_code.py | 29 +++++++++------- src/fastmcp/cli/install/claude_desktop.py | 29 +++++++++------- src/fastmcp/cli/install/cursor.py | 35 +++++++++++-------- src/fastmcp/cli/install/mcp_json.py | 29 +++++++++------- src/fastmcp/cli/install/shared.py | 7 ++-- tests/cli/test_cursor.py | 4 +-- 8 files changed, 104 insertions(+), 77 deletions(-) diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index fa4d529de..8873a3a7a 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -34,7 +34,7 @@ def update_claude_config( file_spec: str, server_name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, ) -> bool: @@ -43,7 +43,7 @@ def update_claude_config( Args: file_spec: Path to the server file, optionally with :object suffix server_name: Name for the server in Claude's config - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables. These are merged with any existing variables, with new values taking precedence. @@ -101,7 +101,7 @@ def update_claude_config( # Build uv run command using Environment.build_uv_args() env_config = Environment( dependencies=deduplicated_packages, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index aba034e20..84611d0a3 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -126,20 +126,21 @@ async def dev( server_spec: str | None = None, *, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory containing pyproject.toml to install in editable mode", + "--with-editable", + help="Directory containing pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, inspector_version: Annotated[ str | None, cyclopts.Parameter( @@ -188,6 +189,9 @@ async def dev( Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] from pathlib import Path from fastmcp.utilities.fastmcp_config import FastMCPConfig @@ -229,13 +233,9 @@ async def dev( if config.environment.requirements else None ) - # Note: config.environment.editable is a list, but CLI only supports single path - # Take the first editable path if available - with_editable = with_editable or ( - Path(config.environment.editable[0]) - if config.environment.editable and config.environment.editable[0] - else None - ) + # Merge editable paths from config with CLI args + if config.environment.editable and not with_editable: + with_editable = [Path(p) for p in config.environment.editable] # Merge packages from both sources if config.environment.dependencies: @@ -256,7 +256,7 @@ async def dev( "Starting dev server", extra={ "server_spec": server_spec, - "with_editable": str(with_editable) if with_editable else None, + "with_editable": [str(p) for p in with_editable] if with_editable else None, "with_packages": with_packages, "ui_port": ui_port, "server_port": server_port, @@ -305,7 +305,7 @@ async def dev( dependencies=with_packages if with_packages else None, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) uv_cmd = ["uv"] + env_config.build_uv_args(["fastmcp", "run", server_spec]) @@ -396,13 +396,13 @@ async def run( ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, project: Annotated[ Path | None, cyclopts.Parameter( @@ -450,6 +450,8 @@ async def run( Args: server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect """ + # Convert None to empty lists for list parameters + with_packages = with_packages or [] # Load configuration if needed from pathlib import Path @@ -631,13 +633,13 @@ async def inspect( ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, project: Annotated[ Path | None, cyclopts.Parameter( @@ -670,6 +672,8 @@ async def inspect( Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ + # Convert None to empty lists for list parameters + with_packages = with_packages or [] from pathlib import Path from fastmcp.utilities.fastmcp_config import FastMCPConfig diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index 472a34c54..aaa07503c 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -75,7 +75,7 @@ def install_claude_code( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -88,7 +88,7 @@ def install_claude_code( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Claude Code - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -121,7 +121,7 @@ def install_claude_code( dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -171,28 +171,29 @@ async def claude_code_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -227,6 +228,10 @@ async def claude_code_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 69e543415..28ce20eee 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -40,7 +40,7 @@ def install_claude_desktop( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -53,7 +53,7 @@ def install_claude_desktop( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Claude's config - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -86,7 +86,7 @@ def install_claude_desktop( dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -143,28 +143,29 @@ async def claude_desktop_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -199,6 +200,10 @@ async def claude_desktop_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, with_packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index 472dd610f..bbb94a872 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -71,7 +71,7 @@ def install_cursor_workspace( name: str, workspace_path: Path, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -85,7 +85,7 @@ def install_cursor_workspace( server_object: Optional server object name (for :object suffix) name: Name for the server in Cursor workspace_path: Path to the workspace directory - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -120,7 +120,7 @@ def install_cursor_workspace( dependencies=deduplicated_packages, requirements=str(with_requirements.resolve()) if with_requirements else None, project=str(project.resolve()) if project else None, - editable=[str(with_editable.resolve())] if with_editable else None, + editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -161,7 +161,7 @@ def install_cursor( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -175,7 +175,7 @@ def install_cursor( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Cursor - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -200,7 +200,7 @@ def install_cursor( dependencies=deduplicated_packages, requirements=str(with_requirements.resolve()) if with_requirements else None, project=str(project.resolve()) if project else None, - editable=[str(with_editable.resolve())] if with_editable else None, + editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -262,28 +262,29 @@ async def cursor_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -325,6 +326,10 @@ async def cursor_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, with_packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 9315c40ef..42696ae6c 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -22,7 +22,7 @@ def install_mcp_json( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, copy: bool = False, @@ -36,7 +36,7 @@ def install_mcp_json( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in MCP config - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables copy: If True, copy to clipboard instead of printing to stdout @@ -61,7 +61,7 @@ def install_mcp_json( dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -116,28 +116,29 @@ async def mcp_json_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -180,6 +181,10 @@ async def mcp_json_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 2f92fbd89..6db83403f 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -29,14 +29,17 @@ def parse_env_var(env_var: str) -> tuple[str, str]: async def process_common_args( server_spec: str, server_name: str | None, - with_packages: list[str], - env_vars: list[str], + with_packages: list[str] | None, + env_vars: list[str] | None, env_file: Path | None, ) -> tuple[Path, str | None, str, list[str], dict[str, str] | None]: """Process common arguments shared by all install commands. Handles both fastmcp.json config files and traditional file.py:object syntax. """ + # Convert None to empty lists for list parameters + with_packages = with_packages or [] + env_vars = env_vars or [] # Create FastMCPConfig from server_spec config = None if server_spec.endswith(".json"): diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 008aff843..c5531bd8d 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -246,7 +246,7 @@ class TestInstallCursor: file=Path("/path/to/server.py"), server_object="custom_app", name="test-server", - with_editable=editable_path, + with_editable=[editable_path], ) assert result is True @@ -328,7 +328,7 @@ class TestCursorCommand: file=Path("server.py"), server_object=None, name="test-server", - with_editable=None, + with_editable=[], with_packages=[], env_vars={}, python_version=None, From 0822093f4693408b8d338adb5d310945d669c0ce Mon Sep 17 00:00:00 2001 From: Vincent Liu <128127889+vl-kp@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:30:21 +0800 Subject: [PATCH 3/5] fix: fix StructuredLoggingMiddleware payload serialization --- src/fastmcp/server/middleware/logging.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index f770e2faa..0e0390385 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -152,7 +152,7 @@ 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)) + self.logger.log(self.log_level, json.dumps(start_entry, default=str)) try: result = await call_next(context) @@ -162,7 +162,7 @@ class StructuredLoggingMiddleware(Middleware): "request_success", result_type=type(result).__name__ if result else None, ) - self.logger.log(self.log_level, json.dumps(success_entry)) + self.logger.log(self.log_level, json.dumps(success_entry, default=str)) return result except Exception as e: @@ -172,5 +172,5 @@ class StructuredLoggingMiddleware(Middleware): error_type=type(e).__name__, error_message=str(e), ) - self.logger.log(logging.ERROR, json.dumps(error_entry)) + self.logger.log(logging.ERROR, json.dumps(error_entry, default=str)) raise From 76a0ed89541d0bd6f1f6557a69daac50124ec8f6 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 26 Aug 2025 13:55:24 +0800 Subject: [PATCH 4/5] chore: add test --- tests/server/middleware/test_logging.py | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 9217bb485..4bb0b98ac 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -191,6 +191,57 @@ class TestStructuredLoggingMiddleware: assert error_entry["error_type"] == "ValueError" assert error_entry["error_message"] == "test error" + async def test_on_message_with_pydantic_types_in_payload( + self, mock_context, mock_call_next, caplog + ): + """Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True.""" + from pydantic import AnyUrl + + mock_context.message.__dict__["url"] = AnyUrl("test://example/1") + + 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"]["url"] == "test://example/1" + + 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.""" + from fastmcp.resources import ResourceTemplate + + template = ResourceTemplate( + name="tmpl", + uri_template="tmpl://{id}", + parameters={"id": {"type": "string"}}, + ) + + mock_context.message.__dict__["template"] = template + + 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 "template" in start_entry["payload"] + # After json.loads, default=str ensures complex object became a JSON string + assert isinstance(start_entry["payload"]["template"], str) + @pytest.fixture def logging_server(): From 36975a518282601a964e7ce92bc998df5d4abecc Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 26 Aug 2025 22:01:11 +0800 Subject: [PATCH 5/5] 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