Skip to content

Commit 25b93f3

Browse files
fix: make context logging methods accept Any data type per MCP spec
The MCP spec defines logged data as `unknown` (any JSON-serializable type), but the context logging convenience methods (info, debug, warning, error) restricted the message parameter to `str`. This widens the type to `Any` to match the spec and the underlying ServerSession.send_log_message API. When dict data is passed with extra, the dicts are merged instead of wrapping in a {"message": ...} envelope. Closes #397 Github-Issue: #397 Reported-by: alejandro5042
1 parent d5b9155 commit 25b93f3

File tree

2 files changed

+79
-8
lines changed

2 files changed

+79
-8
lines changed

src/mcp/server/mcpserver/context.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,22 +187,29 @@ async def elicit_url(
187187
async def log(
188188
self,
189189
level: Literal["debug", "info", "warning", "error"],
190-
message: str,
190+
message: Any,
191191
*,
192192
logger_name: str | None = None,
193193
extra: dict[str, Any] | None = None,
194194
) -> None:
195195
"""Send a log message to the client.
196196
197+
Per the MCP spec, the data to be logged can be any JSON-serializable type
198+
(string, dict, list, number, bool, etc.), not just strings.
199+
197200
Args:
198201
level: Log level (debug, info, warning, error)
199-
message: Log message
202+
message: Any JSON-serializable data to log
200203
logger_name: Optional logger name
201-
extra: Optional dictionary with additional structured data to include
204+
extra: Optional dictionary with additional structured data to include.
205+
When provided, data is wrapped in a dict with the extra fields merged in.
202206
"""
203207

204208
if extra:
205-
log_data = {"message": message, **extra}
209+
if isinstance(message, dict):
210+
log_data = {**message, **extra}
211+
else:
212+
log_data = {"message": message, **extra}
206213
else:
207214
log_data = message
208215

@@ -261,20 +268,20 @@ async def close_standalone_sse_stream(self) -> None:
261268
await self._request_context.close_standalone_sse_stream()
262269

263270
# 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:
271+
async def debug(self, message: Any, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
265272
"""Send a debug log message."""
266273
await self.log("debug", message, logger_name=logger_name, extra=extra)
267274

268-
async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
275+
async def info(self, message: Any, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
269276
"""Send an info log message."""
270277
await self.log("info", message, logger_name=logger_name, extra=extra)
271278

272279
async def warning(
273-
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
280+
self, message: Any, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
274281
) -> None:
275282
"""Send a warning log message."""
276283
await self.log("warning", message, logger_name=logger_name, extra=extra)
277284

278-
async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
285+
async def error(self, message: Any, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
279286
"""Send an error log message."""
280287
await self.log("error", message, logger_name=logger_name, extra=extra)

tests/server/mcpserver/test_server.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,70 @@ 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_any_data(self):
1082+
"""Test that context logging methods accept any JSON-serializable data per MCP spec."""
1083+
mcp = MCPServer()
1084+
1085+
async def logging_any_tool(ctx: Context) -> str:
1086+
await ctx.info({"event": "user_login", "user_id": 42})
1087+
await ctx.debug(["step1", "step2", "step3"])
1088+
await ctx.warning(12345)
1089+
await ctx.error({"code": 500, "details": {"reason": "timeout"}})
1090+
return "done"
1091+
1092+
mcp.add_tool(logging_any_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("logging_any_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": 42},
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(
1116+
level="warning", data=12345, logger=None, related_request_id="1"
1117+
)
1118+
mock_log.assert_any_call(
1119+
level="error",
1120+
data={"code": 500, "details": {"reason": "timeout"}},
1121+
logger=None,
1122+
related_request_id="1",
1123+
)
1124+
1125+
async def test_context_logging_dict_with_extra(self):
1126+
"""Test that dict data is merged with extra fields."""
1127+
mcp = MCPServer()
1128+
1129+
async def logging_extra_tool(ctx: Context) -> str:
1130+
await ctx.info({"event": "request"}, extra={"trace_id": "abc123"})
1131+
return "done"
1132+
1133+
mcp.add_tool(logging_extra_tool)
1134+
1135+
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
1136+
async with Client(mcp) as client:
1137+
await client.call_tool("logging_extra_tool", {})
1138+
mock_log.assert_any_call(
1139+
level="info",
1140+
data={"event": "request", "trace_id": "abc123"},
1141+
logger=None,
1142+
related_request_id="1",
1143+
)
1144+
10811145
async def test_optional_context(self):
10821146
"""Test that context is optional."""
10831147
mcp = MCPServer()

0 commit comments

Comments
 (0)