Skip to content

Commit 328bd80

Browse files
Fix context logging methods to accept any JSON serializable type per MCP spec
The MCP spec defines the logging data field as 'unknown' (any JSON serializable type), but Context.log() and convenience methods (debug, info, warning, error) only accepted str. This changes the parameter from 'message: str' to 'data: Any' to match the spec and the existing ServerSession.send_log_message signature. Also removes the 'extra' parameter which was a workaround for the str-only limitation and caused confusion (as noted in the issue). Fixes #397
1 parent d5b9155 commit 328bd80

File tree

3 files changed

+70
-44
lines changed

3 files changed

+70
-44
lines changed

src/mcp/server/mcpserver/context.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -187,28 +187,22 @@ async def elicit_url(
187187
async def log(
188188
self,
189189
level: Literal["debug", "info", "warning", "error"],
190-
message: str,
190+
data: Any,
191191
*,
192192
logger_name: str | None = None,
193-
extra: dict[str, Any] | None = None,
194193
) -> None:
195194
"""Send a log message to the client.
196195
197196
Args:
198197
level: Log level (debug, info, warning, error)
199-
message: Log message
198+
data: The data to be logged. Any JSON serializable type is allowed
199+
(string, dict, list, number, bool, None, etc.)
200200
logger_name: Optional logger name
201-
extra: Optional dictionary with additional structured data to include
202201
"""
203202

204-
if extra:
205-
log_data = {"message": message, **extra}
206-
else:
207-
log_data = message
208-
209203
await self.request_context.session.send_log_message(
210204
level=level,
211-
data=log_data,
205+
data=data,
212206
logger=logger_name,
213207
related_request_id=self.request_id,
214208
)
@@ -261,20 +255,18 @@ async def close_standalone_sse_stream(self) -> None:
261255
await self._request_context.close_standalone_sse_stream()
262256

263257
# Convenience methods for common log levels
264-
async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
258+
async def debug(self, data: Any, *, logger_name: str | None = None) -> None:
265259
"""Send a debug log message."""
266-
await self.log("debug", message, logger_name=logger_name, extra=extra)
260+
await self.log("debug", data, logger_name=logger_name)
267261

268-
async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
262+
async def info(self, data: Any, *, logger_name: str | None = None) -> None:
269263
"""Send an info log message."""
270-
await self.log("info", message, logger_name=logger_name, extra=extra)
264+
await self.log("info", data, logger_name=logger_name)
271265

272-
async def warning(
273-
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
274-
) -> None:
266+
async def warning(self, data: Any, *, logger_name: str | None = None) -> None:
275267
"""Send a warning log message."""
276-
await self.log("warning", message, logger_name=logger_name, extra=extra)
268+
await self.log("warning", data, logger_name=logger_name)
277269

278-
async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
270+
async def error(self, data: Any, *, logger_name: str | None = None) -> None:
279271
"""Send an error log message."""
280-
await self.log("error", message, logger_name=logger_name, extra=extra)
272+
await self.log("error", data, logger_name=logger_name)

tests/client/test_logging_callback.py

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,27 @@ async def test_tool() -> bool:
3030
# The actual tool is very simple and just returns True
3131
return True
3232

33-
# Create a function that can send a log notification
33+
# Create a function that can send a log notification with a string
3434
@server.tool("test_tool_with_log")
3535
async def test_tool_with_log(
3636
message: str, level: Literal["debug", "info", "warning", "error"], logger: str, ctx: Context
3737
) -> bool:
3838
"""Send a log notification to the client."""
39-
await ctx.log(level=level, message=message, logger_name=logger)
39+
await ctx.log(level=level, data=message, logger_name=logger)
4040
return True
4141

42-
@server.tool("test_tool_with_log_extra")
43-
async def test_tool_with_log_extra(
44-
message: str,
42+
# Create a function that can send structured data as a log notification
43+
@server.tool("test_tool_with_structured_log")
44+
async def test_tool_with_structured_log(
4545
level: Literal["debug", "info", "warning", "error"],
4646
logger: str,
47-
extra_string: str,
48-
extra_dict: dict[str, Any],
4947
ctx: Context,
5048
) -> bool:
51-
"""Send a log notification to the client with extra fields."""
49+
"""Send a structured log notification to the client."""
5250
await ctx.log(
5351
level=level,
54-
message=message,
52+
data={"message": "Test log message", "count": 42, "tags": ["a", "b"]},
5553
logger_name=logger,
56-
extra={"extra_string": extra_string, "extra_dict": extra_dict},
5754
)
5855
return True
5956

@@ -75,7 +72,7 @@ async def message_handler(
7572
assert isinstance(result.content[0], TextContent)
7673
assert result.content[0].text == "true"
7774

78-
# Now send a log message via our tool
75+
# Now send a string log message via our tool
7976
log_result = await client.call_tool(
8077
"test_tool_with_log",
8178
{
@@ -84,30 +81,30 @@ async def message_handler(
8481
"logger": "test_logger",
8582
},
8683
)
87-
log_result_with_extra = await client.call_tool(
88-
"test_tool_with_log_extra",
84+
# Send a structured log message
85+
log_result_structured = await client.call_tool(
86+
"test_tool_with_structured_log",
8987
{
90-
"message": "Test log message",
9188
"level": "info",
9289
"logger": "test_logger",
93-
"extra_string": "example",
94-
"extra_dict": {"a": 1, "b": 2, "c": 3},
9590
},
9691
)
9792
assert log_result.is_error is False
98-
assert log_result_with_extra.is_error is False
93+
assert log_result_structured.is_error is False
9994
assert len(logging_collector.log_messages) == 2
100-
# Create meta object with related_request_id added dynamically
95+
96+
# Verify string log
10197
log = logging_collector.log_messages[0]
10298
assert log.level == "info"
10399
assert log.logger == "test_logger"
104100
assert log.data == "Test log message"
105101

106-
log_with_extra = logging_collector.log_messages[1]
107-
assert log_with_extra.level == "info"
108-
assert log_with_extra.logger == "test_logger"
109-
assert log_with_extra.data == {
102+
# Verify structured log
103+
log_structured = logging_collector.log_messages[1]
104+
assert log_structured.level == "info"
105+
assert log_structured.logger == "test_logger"
106+
assert log_structured.data == {
110107
"message": "Test log message",
111-
"extra_string": "example",
112-
"extra_dict": {"a": 1, "b": 2, "c": 3},
108+
"count": 42,
109+
"tags": ["a", "b"],
113110
}

tests/server/mcpserver/test_server.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,43 @@ async def logging_tool(msg: str, ctx: Context) -> str:
10781078
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1")
10791079
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1")
10801080

1081+
async def test_context_logging_structured_data(self):
1082+
"""Test that context logging methods accept any JSON serializable type."""
1083+
mcp = MCPServer()
1084+
1085+
async def structured_logging_tool(ctx: Context) -> str:
1086+
await ctx.info({"event": "user_login", "user_id": 123})
1087+
await ctx.debug(["step1", "step2", "step3"])
1088+
await ctx.warning(42)
1089+
await ctx.error(None)
1090+
return "done"
1091+
1092+
mcp.add_tool(structured_logging_tool)
1093+
1094+
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
1095+
async with Client(mcp) as client:
1096+
result = await client.call_tool("structured_logging_tool", {})
1097+
assert len(result.content) == 1
1098+
content = result.content[0]
1099+
assert isinstance(content, TextContent)
1100+
assert content.text == "done"
1101+
1102+
assert mock_log.call_count == 4
1103+
mock_log.assert_any_call(
1104+
level="info",
1105+
data={"event": "user_login", "user_id": 123},
1106+
logger=None,
1107+
related_request_id="1",
1108+
)
1109+
mock_log.assert_any_call(
1110+
level="debug",
1111+
data=["step1", "step2", "step3"],
1112+
logger=None,
1113+
related_request_id="1",
1114+
)
1115+
mock_log.assert_any_call(level="warning", data=42, logger=None, related_request_id="1")
1116+
mock_log.assert_any_call(level="error", data=None, logger=None, related_request_id="1")
1117+
10811118
async def test_optional_context(self):
10821119
"""Test that context is optional."""
10831120
mcp = MCPServer()

0 commit comments

Comments
 (0)