fix: align Context logging methods with MCP spec data type (#2366)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Gyeongjun Paik (Kent) 2026-04-15 06:41:51 +09:00 • committed by GitHub
commit 3d7b311de0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 55 additions and 49 deletions

View file

@ -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

View file

@ -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:

View file

@ -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 @@ from mcp.server.elicitation import (
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 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
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 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
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)

View file

@ -1,4 +1,4 @@
from typing import Any, Literal
from typing import Literal
import pytest
@ -36,24 +36,20 @@ async def test_logging_callback():
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 test_logging_callback():
"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 test_logging_callback():
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},