diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 75f2d2237..a4a21abd8 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -1249,31 +1249,28 @@ async def elicit_url( async def log( self, level: Literal["debug", "info", "warning", "error"], - message: str, + data: Any, *, logger_name: str | None = None, - extra: dict[str, Any] | None = None, ) -> None: """Send a log message to the client. + Per MCP spec, data can be any JSON-serializable type (str, dict, list, int, etc.). + Args: level: Log level (debug, info, warning, error) - message: Log message + data: Data to log - can be string, dict, list, number, boolean, etc. logger_name: Optional logger name - extra: Optional dictionary with additional structured data to include - """ - - if extra: - log_data = { - "message": message, - **extra, - } - else: - log_data = message + Examples: + await ctx.info("Simple message") + await ctx.debug({"status": "processing", "progress": 50}) + await ctx.warning(["item1", "item2"]) + await ctx.error(42) + """ await self.request_context.session.send_log_message( level=level, - data=log_data, + data=data, logger=logger_name, related_request_id=self.request_id, ) @@ -1328,20 +1325,38 @@ async def close_standalone_sse_stream(self) -> None: await self._request_context.close_standalone_sse_stream() # Convenience methods for common log levels - async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: - """Send a debug log message.""" - await self.log("debug", message, logger_name=logger_name, extra=extra) + async def debug(self, data: Any, *, logger_name: str | None = None) -> None: + """Send a debug log message. - async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: - """Send an info log message.""" - await self.log("info", message, logger_name=logger_name, extra=extra) + Args: + data: Data to log (any JSON-serializable type) + logger_name: Optional logger name + """ + await self.log("debug", data, logger_name=logger_name) - async def warning( - self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None - ) -> None: - """Send a warning log message.""" - await self.log("warning", message, logger_name=logger_name, extra=extra) + async def info(self, data: Any, *, logger_name: str | None = None) -> None: + """Send an info log message. - async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: - """Send an error log message.""" - await self.log("error", message, logger_name=logger_name, extra=extra) + Args: + data: Data to log (any JSON-serializable type) + logger_name: Optional logger name + """ + await self.log("info", data, logger_name=logger_name) + + async def warning(self, data: Any, *, logger_name: str | None = None) -> None: + """Send a warning log message. + + Args: + data: Data to log (any JSON-serializable type) + logger_name: Optional logger name + """ + await self.log("warning", data, logger_name=logger_name) + + async def error(self, data: Any, *, logger_name: str | None = None) -> None: + """Send an error log message. + + Args: + data: Data to log (any JSON-serializable type) + logger_name: Optional logger name + """ + await self.log("error", data, logger_name=logger_name) diff --git a/tests/client/test_logging_callback.py b/tests/client/test_logging_callback.py index 687efca71..0807aeca6 100644 --- a/tests/client/test_logging_callback.py +++ b/tests/client/test_logging_callback.py @@ -39,7 +39,7 @@ async def test_tool_with_log( """Send a log notification to the client.""" await server.get_context().log( level=level, - message=message, + data=message, logger_name=logger, ) return True @@ -55,9 +55,12 @@ async def test_tool_with_log_extra( """Send a log notification to the client with extra fields.""" await server.get_context().log( level=level, - message=message, + data={ + "message": message, + "extra_string": extra_string, + "extra_dict": extra_dict, + }, logger_name=logger, - extra={"extra_string": extra_string, "extra_dict": extra_dict}, ) return True diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 6d1cee58e..0c73abf25 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -1117,6 +1117,77 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: related_request_id="1", ) + @pytest.mark.anyio + async def test_context_logging_with_structured_data(self): + """Test that context logging accepts structured data per MCP spec (issue #397).""" + mcp = FastMCP() + + async def structured_logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: + # Test with dictionary + await ctx.info({"status": "success", "message": msg, "count": 42}) + # Test with list + await ctx.debug([1, 2, 3, "item"]) + # Test with number + await ctx.warning(404) + # Test with boolean + await ctx.error(True) + # Test string still works (backward compatibility) + await ctx.info("Plain string message") + return f"Logged structured data for {msg}" + + mcp.add_tool(structured_logging_tool) + + with patch("mcp.server.session.ServerSession.send_log_message") as mock_log: + async with Client(mcp) as client: + result = await client.call_tool("structured_logging_tool", {"msg": "test"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Logged structured data for test" in content.text + + # Verify all log calls were made with correct data types + assert mock_log.call_count == 5 + + # Check dictionary logging + mock_log.assert_any_call( + level="info", + data={"status": "success", "message": "test", "count": 42}, + logger=None, + related_request_id="1", + ) + + # Check list logging + mock_log.assert_any_call( + level="debug", + data=[1, 2, 3, "item"], + logger=None, + related_request_id="1", + ) + + # Check number logging + mock_log.assert_any_call( + level="warning", + data=404, + logger=None, + related_request_id="1", + ) + + # Check boolean logging + mock_log.assert_any_call( + level="error", + data=True, + logger=None, + related_request_id="1", + ) + + # Check string still works + mock_log.assert_any_call( + level="info", + data="Plain string message", + logger=None, + related_request_id="1", + ) + @pytest.mark.anyio async def test_optional_context(self): """Test that context is optional."""