Skip to content

Commit 75d2d11

Browse files
committed
fix: make Context logging methods spec-compliant by accepting Any data type
Per the MCP specification, the data field in LoggingMessageNotificationParams allows any JSON serializable type, not just strings. The Context.log() and convenience methods (debug, info, warning, error) were typed as message: str, which prevented users from logging structured data like dicts, lists, numbers, etc. Changes: - Change message: str to data: Any in Context.log() and all convenience methods - Remove the extra parameter (structured data can now be passed directly as data) - Update docstrings to document that any JSON serializable type is accepted - Update class docstring with examples of structured data logging - Add test for logging structured data (dict, list, number, boolean, None) Fixes #397
1 parent 62575ed commit 75d2d11

File tree

2 files changed

+68
-24
lines changed

2 files changed

+68
-24
lines changed

src/mcp/server/mcpserver/server.py

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,11 @@ async def my_tool(x: int, ctx: Context) -> str:
11221122
await ctx.warning("Warning message")
11231123
await ctx.error("Error message")
11241124
1125+
# Log structured data (any JSON serializable type)
1126+
await ctx.info({"event": "processing", "input": x})
1127+
await ctx.debug(["step1", "step2", "step3"])
1128+
await ctx.info(42)
1129+
11251130
# Report progress
11261131
await ctx.report_progress(50, 100)
11271132
@@ -1272,28 +1277,25 @@ async def elicit_url(
12721277
async def log(
12731278
self,
12741279
level: Literal["debug", "info", "warning", "error"],
1275-
message: str,
1280+
data: Any,
12761281
*,
12771282
logger_name: str | None = None,
1278-
extra: dict[str, Any] | None = None,
12791283
) -> None:
12801284
"""Send a log message to the client.
12811285
1286+
Per the MCP specification, the data can be any JSON serializable type,
1287+
such as a string message, a dictionary, a list, a number, or any other
1288+
JSON-compatible value.
1289+
12821290
Args:
12831291
level: Log level (debug, info, warning, error)
1284-
message: Log message
1292+
data: The data to be logged. Any JSON serializable type is allowed.
12851293
logger_name: Optional logger name
1286-
extra: Optional dictionary with additional structured data to include
12871294
"""
12881295

1289-
if extra:
1290-
log_data = {"message": message, **extra}
1291-
else:
1292-
log_data = message
1293-
12941296
await self.request_context.session.send_log_message(
12951297
level=level,
1296-
data=log_data,
1298+
data=data,
12971299
logger=logger_name,
12981300
related_request_id=self.request_id,
12991301
)
@@ -1346,20 +1348,18 @@ async def close_standalone_sse_stream(self) -> None:
13461348
await self._request_context.close_standalone_sse_stream()
13471349

13481350
# Convenience methods for common log levels
1349-
async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
1350-
"""Send a debug log message."""
1351-
await self.log("debug", message, logger_name=logger_name, extra=extra)
1351+
async def debug(self, data: Any, *, logger_name: str | None = None) -> None:
1352+
"""Send a debug log message. Data can be any JSON serializable type."""
1353+
await self.log("debug", data, logger_name=logger_name)
13521354

1353-
async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
1354-
"""Send an info log message."""
1355-
await self.log("info", message, logger_name=logger_name, extra=extra)
1355+
async def info(self, data: Any, *, logger_name: str | None = None) -> None:
1356+
"""Send an info log message. Data can be any JSON serializable type."""
1357+
await self.log("info", data, logger_name=logger_name)
13561358

1357-
async def warning(
1358-
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
1359-
) -> None:
1360-
"""Send a warning log message."""
1361-
await self.log("warning", message, logger_name=logger_name, extra=extra)
1359+
async def warning(self, data: Any, *, logger_name: str | None = None) -> None:
1360+
"""Send a warning log message. Data can be any JSON serializable type."""
1361+
await self.log("warning", data, logger_name=logger_name)
13621362

1363-
async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
1364-
"""Send an error log message."""
1365-
await self.log("error", message, logger_name=logger_name, extra=extra)
1363+
async def error(self, data: Any, *, logger_name: str | None = None) -> None:
1364+
"""Send an error log message. Data can be any JSON serializable type."""
1365+
await self.log("error", data, logger_name=logger_name)

tests/server/mcpserver/test_server.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,50 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str:
10701070
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1")
10711071
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1")
10721072

1073+
async def test_context_logging_structured_data(self):
1074+
"""Test that context logging methods accept any JSON serializable type per MCP spec."""
1075+
mcp = MCPServer()
1076+
1077+
async def structured_logging_tool(ctx: Context[ServerSession, None]) -> str:
1078+
# Log a dictionary
1079+
await ctx.info({"event": "processing", "count": 5})
1080+
# Log a list
1081+
await ctx.debug(["step1", "step2", "step3"])
1082+
# Log a number
1083+
await ctx.warning(42)
1084+
# Log a boolean
1085+
await ctx.error(True)
1086+
# Log None
1087+
await ctx.info(None)
1088+
return "done"
1089+
1090+
mcp.add_tool(structured_logging_tool)
1091+
1092+
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
1093+
async with Client(mcp) as client:
1094+
result = await client.call_tool("structured_logging_tool", {})
1095+
assert len(result.content) == 1
1096+
content = result.content[0]
1097+
assert isinstance(content, TextContent)
1098+
assert content.text == "done"
1099+
1100+
assert mock_log.call_count == 5
1101+
mock_log.assert_any_call(
1102+
level="info",
1103+
data={"event": "processing", "count": 5},
1104+
logger=None,
1105+
related_request_id="1",
1106+
)
1107+
mock_log.assert_any_call(
1108+
level="debug",
1109+
data=["step1", "step2", "step3"],
1110+
logger=None,
1111+
related_request_id="1",
1112+
)
1113+
mock_log.assert_any_call(level="warning", data=42, logger=None, related_request_id="1")
1114+
mock_log.assert_any_call(level="error", data=True, logger=None, related_request_id="1")
1115+
mock_log.assert_any_call(level="info", data=None, logger=None, related_request_id="1")
1116+
10731117
async def test_optional_context(self):
10741118
"""Test that context is optional."""
10751119
mcp = MCPServer()

0 commit comments

Comments
 (0)