diff --git a/README.v2.md b/README.v2.md index 55d867586..d0851c04e 100644 --- a/README.v2.md +++ b/README.v2.md @@ -681,11 +681,11 @@ The Context object provides the following capabilities: - `ctx.mcp_server` - Access to the MCPServer server instance (see [MCPServer Properties](#mcpserver-properties)) - `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) - `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) -- `await ctx.debug(message)` - Send debug log message -- `await ctx.info(message)` - Send info log message -- `await ctx.warning(message)` - Send warning log message -- `await ctx.error(message)` - Send error log message -- `await ctx.log(level, message, logger_name=None)` - Send log with custom level +- `await ctx.debug(data)` - Send debug log message +- `await ctx.info(data)` - Send info log message +- `await ctx.warning(data)` - Send warning log message +- `await ctx.error(data)` - Send error log message +- `await ctx.log(level, data, logger_name=None)` - Send log with custom level - `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress - `await ctx.read_resource(uri)` - Read a resource by URI - `await ctx.elicit(message, schema)` - Request additional information from user with validation diff --git a/docs/migration.md b/docs/migration.md index 2528f046c..8b70885e8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -467,6 +467,26 @@ mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscrib This is a private API and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. +### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed + +On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed — pass structured data directly as `data`. + +The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. + +`Context.log()` also now accepts all eight RFC-5424 log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. + +```python +# Before +await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432}) +await ctx.log(level="info", message="hello") + +# After +await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) +await ctx.log(level="info", data="hello") +``` + +Positional calls (`await ctx.info("hello")`) are unaffected. + ### Replace `RootModel` by union types with `TypeAdapter` validation The following union types are no longer `RootModel` subclasses: diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 1538adc7c..e87388eee 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Generic, Literal +from typing import TYPE_CHECKING, Any, Generic from pydantic import AnyUrl, BaseModel @@ -14,6 +14,7 @@ elicit_with_validation, ) from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.types import LoggingLevel if TYPE_CHECKING: from mcp.server.mcpserver.server import MCPServer @@ -186,29 +187,23 @@ async def elicit_url( async def log( self, - level: Literal["debug", "info", "warning", "error"], - message: str, + level: LoggingLevel, + data: Any, *, logger_name: str | None = None, - extra: dict[str, Any] | None = None, ) -> None: """Send a log message to the client. Args: - level: Log level (debug, info, warning, error) - message: Log message + level: Log level (debug, info, notice, warning, error, critical, + alert, emergency) + data: The data to be logged. Any JSON serializable type is allowed + (string, dict, list, number, bool, etc.) per the MCP specification. 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 - await self.request_context.session.send_log_message( level=level, - data=log_data, + data=data, logger=logger_name, related_request_id=self.request_id, ) @@ -261,20 +256,18 @@ 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: + async def debug(self, data: Any, *, logger_name: str | None = None) -> None: """Send a debug log message.""" - await self.log("debug", message, logger_name=logger_name, extra=extra) + await self.log("debug", data, logger_name=logger_name) - async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: + async def info(self, data: Any, *, logger_name: str | None = None) -> None: """Send an info log message.""" - await self.log("info", message, logger_name=logger_name, extra=extra) + await self.log("info", data, logger_name=logger_name) - async def warning( - self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None - ) -> None: + async def warning(self, data: Any, *, logger_name: str | None = None) -> None: """Send a warning log message.""" - await self.log("warning", message, logger_name=logger_name, extra=extra) + await self.log("warning", data, logger_name=logger_name) - async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: + async def error(self, data: Any, *, logger_name: str | None = None) -> None: """Send an error log message.""" - await self.log("error", message, logger_name=logger_name, extra=extra) + 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 1598fd55f..454c1d338 100644 --- a/tests/client/test_logging_callback.py +++ b/tests/client/test_logging_callback.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Literal import pytest @@ -36,24 +36,20 @@ async def test_tool_with_log( message: str, level: Literal["debug", "info", "warning", "error"], logger: str, ctx: Context ) -> bool: """Send a log notification to the client.""" - await ctx.log(level=level, message=message, logger_name=logger) + await ctx.log(level=level, data=message, logger_name=logger) return True - @server.tool("test_tool_with_log_extra") - async def test_tool_with_log_extra( - message: str, + @server.tool("test_tool_with_log_dict") + async def test_tool_with_log_dict( level: Literal["debug", "info", "warning", "error"], logger: str, - extra_string: str, - extra_dict: dict[str, Any], ctx: Context, ) -> bool: - """Send a log notification to the client with extra fields.""" + """Send a log notification with a dict payload.""" await ctx.log( level=level, - message=message, + data={"message": "Test log message", "extra_string": "example", "extra_dict": {"a": 1, "b": 2, "c": 3}}, logger_name=logger, - extra={"extra_string": extra_string, "extra_dict": extra_dict}, ) return True @@ -84,18 +80,15 @@ async def message_handler( "logger": "test_logger", }, ) - log_result_with_extra = await client.call_tool( - "test_tool_with_log_extra", + log_result_with_dict = await client.call_tool( + "test_tool_with_log_dict", { - "message": "Test log message", "level": "info", "logger": "test_logger", - "extra_string": "example", - "extra_dict": {"a": 1, "b": 2, "c": 3}, }, ) assert log_result.is_error is False - assert log_result_with_extra.is_error is False + assert log_result_with_dict.is_error is False assert len(logging_collector.log_messages) == 2 # Create meta object with related_request_id added dynamically log = logging_collector.log_messages[0] @@ -103,10 +96,10 @@ async def message_handler( assert log.logger == "test_logger" assert log.data == "Test log message" - log_with_extra = logging_collector.log_messages[1] - assert log_with_extra.level == "info" - assert log_with_extra.logger == "test_logger" - assert log_with_extra.data == { + log_with_dict = logging_collector.log_messages[1] + assert log_with_dict.level == "info" + assert log_with_dict.logger == "test_logger" + assert log_with_dict.data == { "message": "Test log message", "extra_string": "example", "extra_dict": {"a": 1, "b": 2, "c": 3},