Skip to content

Commit f26a495

Browse files
fix: Make context logging functions spec-compliant - Fixes #397
1 parent dcc9b4f commit f26a495

File tree

2 files changed

+114
-26
lines changed

2 files changed

+114
-26
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,31 +1249,28 @@ async def elicit_url(
12491249
async def log(
12501250
self,
12511251
level: Literal["debug", "info", "warning", "error"],
1252-
message: str,
1252+
data: Any,
12531253
*,
12541254
logger_name: str | None = None,
1255-
extra: dict[str, Any] | None = None,
12561255
) -> None:
12571256
"""Send a log message to the client.
12581257
1258+
Per MCP spec, data can be any JSON-serializable type (str, dict, list, int, etc.).
1259+
12591260
Args:
12601261
level: Log level (debug, info, warning, error)
1261-
message: Log message
1262+
data: Data to log - can be string, dict, list, number, boolean, etc.
12621263
logger_name: Optional logger name
1263-
extra: Optional dictionary with additional structured data to include
1264-
"""
1265-
1266-
if extra:
1267-
log_data = {
1268-
"message": message,
1269-
**extra,
1270-
}
1271-
else:
1272-
log_data = message
12731264
1265+
Examples:
1266+
await ctx.info("Simple message")
1267+
await ctx.debug({"status": "processing", "progress": 50})
1268+
await ctx.warning(["item1", "item2"])
1269+
await ctx.error(42)
1270+
"""
12741271
await self.request_context.session.send_log_message(
12751272
level=level,
1276-
data=log_data,
1273+
data=data,
12771274
logger=logger_name,
12781275
related_request_id=self.request_id,
12791276
)
@@ -1328,20 +1325,40 @@ async def close_standalone_sse_stream(self) -> None:
13281325
await self._request_context.close_standalone_sse_stream()
13291326

13301327
# Convenience methods for common log levels
1331-
async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
1332-
"""Send a debug log message."""
1333-
await self.log("debug", message, logger_name=logger_name, extra=extra)
1328+
async def debug(self, data: Any, *, logger_name: str | None = None) -> None:
1329+
"""Send a debug log message.
1330+
1331+
Args:
1332+
data: Data to log (any JSON-serializable type)
1333+
logger_name: Optional logger name
1334+
"""
1335+
await self.log("debug", data, logger_name=logger_name)
13341336

1335-
async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
1336-
"""Send an info log message."""
1337-
await self.log("info", message, logger_name=logger_name, extra=extra)
1337+
async def info(self, data: Any, *, logger_name: str | None = None) -> None:
1338+
"""Send an info log message.
1339+
1340+
Args:
1341+
data: Data to log (any JSON-serializable type)
1342+
logger_name: Optional logger name
1343+
"""
1344+
await self.log("info", data, logger_name=logger_name)
13381345

13391346
async def warning(
1340-
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
1347+
self, data: Any, *, logger_name: str | None = None
13411348
) -> None:
1342-
"""Send a warning log message."""
1343-
await self.log("warning", message, logger_name=logger_name, extra=extra)
1349+
"""Send a warning log message.
1350+
1351+
Args:
1352+
data: Data to log (any JSON-serializable type)
1353+
logger_name: Optional logger name
1354+
"""
1355+
await self.log("warning", data, logger_name=logger_name)
13441356

1345-
async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
1346-
"""Send an error log message."""
1347-
await self.log("error", message, logger_name=logger_name, extra=extra)
1357+
async def error(self, data: Any, *, logger_name: str | None = None) -> None:
1358+
"""Send an error log message.
1359+
1360+
Args:
1361+
data: Data to log (any JSON-serializable type)
1362+
logger_name: Optional logger name
1363+
"""
1364+
await self.log("error", data, logger_name=logger_name)

tests/server/fastmcp/test_server.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,77 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str:
11171117
related_request_id="1",
11181118
)
11191119

1120+
@pytest.mark.anyio
1121+
async def test_context_logging_with_structured_data(self):
1122+
"""Test that context logging accepts structured data per MCP spec (issue #397)."""
1123+
mcp = FastMCP()
1124+
1125+
async def structured_logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str:
1126+
# Test with dictionary
1127+
await ctx.info({"status": "success", "message": msg, "count": 42})
1128+
# Test with list
1129+
await ctx.debug([1, 2, 3, "item"])
1130+
# Test with number
1131+
await ctx.warning(404)
1132+
# Test with boolean
1133+
await ctx.error(True)
1134+
# Test string still works (backward compatibility)
1135+
await ctx.info("Plain string message")
1136+
return f"Logged structured data for {msg}"
1137+
1138+
mcp.add_tool(structured_logging_tool)
1139+
1140+
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
1141+
async with Client(mcp) as client:
1142+
result = await client.call_tool("structured_logging_tool", {"msg": "test"})
1143+
assert len(result.content) == 1
1144+
content = result.content[0]
1145+
assert isinstance(content, TextContent)
1146+
assert "Logged structured data for test" in content.text
1147+
1148+
# Verify all log calls were made with correct data types
1149+
assert mock_log.call_count == 5
1150+
1151+
# Check dictionary logging
1152+
mock_log.assert_any_call(
1153+
level="info",
1154+
data={"status": "success", "message": "test", "count": 42},
1155+
logger=None,
1156+
related_request_id="1",
1157+
)
1158+
1159+
# Check list logging
1160+
mock_log.assert_any_call(
1161+
level="debug",
1162+
data=[1, 2, 3, "item"],
1163+
logger=None,
1164+
related_request_id="1",
1165+
)
1166+
1167+
# Check number logging
1168+
mock_log.assert_any_call(
1169+
level="warning",
1170+
data=404,
1171+
logger=None,
1172+
related_request_id="1",
1173+
)
1174+
1175+
# Check boolean logging
1176+
mock_log.assert_any_call(
1177+
level="error",
1178+
data=True,
1179+
logger=None,
1180+
related_request_id="1",
1181+
)
1182+
1183+
# Check string still works
1184+
mock_log.assert_any_call(
1185+
level="info",
1186+
data="Plain string message",
1187+
logger=None,
1188+
related_request_id="1",
1189+
)
1190+
11201191
@pytest.mark.anyio
11211192
async def test_optional_context(self):
11221193
"""Test that context is optional."""

0 commit comments

Comments
 (0)