Skip to content
12 changes: 6 additions & 6 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ async def elicit_url(
async def log(
self,
level: Literal["debug", "info", "warning", "error"],
message: str,
message: Any,
*,
logger_name: str | None = None,
extra: dict[str, Any] | None = None,
Comment on lines 189 to 193
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the extra parameter was removed from all logging methods (and marks this as a breaking change), but Context.log() and the convenience methods still accept extra. Either remove extra to match the stated API change or update the PR description/breaking-change note to reflect the actual behavior.

Copilot uses AI. Check for mistakes.
Expand All @@ -196,7 +196,7 @@ async def log(

Args:
level: Log level (debug, info, warning, error)
message: Log message
message: Log payload (any JSON-serializable type)
logger_name: Optional logger name
extra: Optional dictionary with additional structured data to include
"""
Expand Down Expand Up @@ -261,20 +261,20 @@ 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:
async def debug(self, message: Any, *, 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 info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
async def info(self, message: Any, *, 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)

async def warning(
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
self, message: Any, *, 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 error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
async def error(self, message: Any, *, 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)
5 changes: 4 additions & 1 deletion tests/client/test_logging_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ async def test_tool_with_log_extra(
level=level,
message=message,
logger_name=logger,
extra={"extra_string": extra_string, "extra_dict": extra_dict},
extra={
"extra_string": extra_string,
"extra_dict": extra_dict,
},
)
return True

Expand Down
81 changes: 81 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,87 @@ async def logging_tool(msg: str, ctx: Context) -> str:
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1")

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

async def structured_logging_tool(msg: str, ctx: Context) -> 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)
Comment on lines +1076 to +1084
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test covers object/array/number/bool/string, but not JSON null (None). Since the PR description claims coverage of “all JSON types” and the spec allows any JSON-serializable type, add a None case and assert it is passed through to send_log_message unchanged.

Copilot uses AI. Check for mistakes.
# Test with null
await ctx.info(None)
# 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 == 6

# 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 null logging
mock_log.assert_any_call(
level="info",
data=None,
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."""
mcp = MCPServer()
Expand Down
Loading