Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 43 additions & 28 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1249,31 +1249,28 @@ async def elicit_url(
async def log(
self,
level: Literal["debug", "info", "warning", "error"],
message: str,
data: Any,
*,
logger_name: str | None = None,
extra: dict[str, Any] | None = None,
) -> None:
"""Send a log message to the client.

Per MCP spec, data can be any JSON-serializable type (str, dict, list, int, etc.).

Args:
level: Log level (debug, info, warning, error)
message: Log message
data: Data to log - can be string, dict, list, number, boolean, etc.
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

Examples:
await ctx.info("Simple message")
await ctx.debug({"status": "processing", "progress": 50})
await ctx.warning(["item1", "item2"])
await ctx.error(42)
"""
await self.request_context.session.send_log_message(
level=level,
data=log_data,
data=data,
logger=logger_name,
related_request_id=self.request_id,
)
Expand Down Expand Up @@ -1328,20 +1325,38 @@ async def close_standalone_sse_stream(self) -> None:
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:
"""Send a debug log message."""
await self.log("debug", message, logger_name=logger_name, extra=extra)
async def debug(self, data: Any, *, logger_name: str | None = None) -> None:
"""Send a debug log message.

async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
"""Send an info log message."""
await self.log("info", message, logger_name=logger_name, extra=extra)
Args:
data: Data to log (any JSON-serializable type)
logger_name: Optional logger name
"""
await self.log("debug", data, logger_name=logger_name)

async def warning(
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
) -> None:
"""Send a warning log message."""
await self.log("warning", message, logger_name=logger_name, extra=extra)
async def info(self, data: Any, *, logger_name: str | None = None) -> None:
"""Send an info log message.

async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
"""Send an error log message."""
await self.log("error", message, logger_name=logger_name, extra=extra)
Args:
data: Data to log (any JSON-serializable type)
logger_name: Optional logger name
"""
await self.log("info", data, logger_name=logger_name)

async def warning(self, data: Any, *, logger_name: str | None = None) -> None:
"""Send a warning log message.

Args:
data: Data to log (any JSON-serializable type)
logger_name: Optional logger name
"""
await self.log("warning", data, logger_name=logger_name)

async def error(self, data: Any, *, logger_name: str | None = None) -> None:
"""Send an error log message.

Args:
data: Data to log (any JSON-serializable type)
logger_name: Optional logger name
"""
await self.log("error", data, logger_name=logger_name)
9 changes: 6 additions & 3 deletions tests/client/test_logging_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def test_tool_with_log(
"""Send a log notification to the client."""
await server.get_context().log(
level=level,
message=message,
data=message,
logger_name=logger,
)
return True
Expand All @@ -55,9 +55,12 @@ async def test_tool_with_log_extra(
"""Send a log notification to the client with extra fields."""
await server.get_context().log(
level=level,
message=message,
data={
"message": message,
"extra_string": extra_string,
"extra_dict": extra_dict,
},
logger_name=logger,
extra={"extra_string": extra_string, "extra_dict": extra_dict},
)
return True

Expand Down
71 changes: 71 additions & 0 deletions tests/server/fastmcp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,77 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str:
related_request_id="1",
)

@pytest.mark.anyio
async def test_context_logging_with_structured_data(self):
"""Test that context logging accepts structured data per MCP spec (issue #397)."""
mcp = FastMCP()

async def structured_logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str:
# Test with dictionary
await ctx.info({"status": "success", "message": msg, "count": 42})
# Test with list
await ctx.debug([1, 2, 3, "item"])
# Test with number
await ctx.warning(404)
# Test with boolean
await ctx.error(True)
# Test string still works (backward compatibility)
await ctx.info("Plain string message")
return f"Logged structured data for {msg}"

mcp.add_tool(structured_logging_tool)

with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
async with Client(mcp) as client:
result = await client.call_tool("structured_logging_tool", {"msg": "test"})
assert len(result.content) == 1
content = result.content[0]
assert isinstance(content, TextContent)
assert "Logged structured data for test" in content.text

# Verify all log calls were made with correct data types
assert mock_log.call_count == 5

# Check dictionary logging
mock_log.assert_any_call(
level="info",
data={"status": "success", "message": "test", "count": 42},
logger=None,
related_request_id="1",
)

# Check list logging
mock_log.assert_any_call(
level="debug",
data=[1, 2, 3, "item"],
logger=None,
related_request_id="1",
)

# Check number logging
mock_log.assert_any_call(
level="warning",
data=404,
logger=None,
related_request_id="1",
)

# Check boolean logging
mock_log.assert_any_call(
level="error",
data=True,
logger=None,
related_request_id="1",
)

# Check string still works
mock_log.assert_any_call(
level="info",
data="Plain string message",
logger=None,
related_request_id="1",
)

@pytest.mark.anyio
async def test_optional_context(self):
"""Test that context is optional."""
Expand Down