diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 8135359d5..4b5c7ec9a 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -1,7 +1,8 @@ from __future__ import annotations as _annotations -from typing import Any, Generic, Literal +from typing import Any, Generic +from mcp import LoggingLevel from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.session import ServerSessionT from mcp.shared.context import LifespanContextT, RequestContext @@ -122,19 +123,20 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): async def log( self, - level: Literal["debug", "info", "warning", "error"], message: str, - *, + level: LoggingLevel | None = None, logger_name: str | None = None, ) -> None: """Send a log message to the client. Args: - level: Log level (debug, info, warning, error) message: Log message + level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical", + "alert", or "emergency". Default is "info". logger_name: Optional logger name - **extra: Additional structured data to include """ + if level is None: + level = "info" await self.request_context.session.send_log_message( level=level, data=message, logger=logger_name ) @@ -161,19 +163,19 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): # Convenience methods for common log levels async def debug(self, message: str, **extra: Any) -> None: """Send a debug log message.""" - await self.log("debug", message, **extra) + await self.log(level="debug", message=message, **extra) async def info(self, message: str, **extra: Any) -> None: """Send an info log message.""" - await self.log("info", message, **extra) + await self.log(level="info", message=message, **extra) async def warning(self, message: str, **extra: Any) -> None: """Send a warning log message.""" - await self.log("warning", message, **extra) + await self.log(level="warning", message=message, **extra) async def error(self, message: str, **extra: Any) -> None: """Send an error log message.""" - await self.log("error", message, **extra) + await self.log(level="error", message=message, **extra) async def list_roots(self) -> list[Root]: """List the roots available to the server, as indicated by the client.""" diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py new file mode 100644 index 000000000..2b088c8f7 --- /dev/null +++ b/tests/client/test_logs.py @@ -0,0 +1,60 @@ +import pytest +from mcp import LoggingLevel + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.logging import LogMessage + + +class LogHandler: + def __init__(self): + self.logs: list[LogMessage] = [] + + async def handle_log(self, params: LogMessage) -> None: + self.logs.append(params) + + +@pytest.fixture +def fastmcp_server(): + mcp = FastMCP() + + @mcp.tool() + async def log(context: Context) -> None: + await context.log(message="hello?") + + @mcp.tool() + async def echo_log( + message: str, + context: Context, + level: LoggingLevel | None = None, + logger: str | None = None, + ) -> None: + await context.log(message=message, level=level) + + return mcp + + +class TestClientLogs: + async def test_log(self, fastmcp_server: FastMCP): + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.call_tool("log", {}) + + assert len(log_handler.logs) == 1 + assert log_handler.logs[0].data == "hello?" + assert log_handler.logs[0].level == "info" + + async def test_echo_log(self, fastmcp_server: FastMCP): + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.call_tool("echo_log", {"message": "this is a log"}) + + assert len(log_handler.logs) == 1 + await client.call_tool( + "echo_log", {"message": "this is a warning log", "level": "warning"} + ) + assert len(log_handler.logs) == 2 + + assert log_handler.logs[0].data == "this is a log" + assert log_handler.logs[0].level == "info" + assert log_handler.logs[1].data == "this is a warning log" + assert log_handler.logs[1].level == "warning" diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 2653ea1a9..141149441 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -689,10 +689,10 @@ class TestToolContextInjection: """Test that context parameters are properly detected.""" mcp = FastMCP() + @mcp.tool() def tool_with_context(x: int, ctx: Context) -> str: return f"Request {ctx.request_id}: {x}" - mcp.add_tool(tool_with_context) async with Client(mcp) as client: tools = await client.list_tools() assert len(tools) == 1 @@ -719,11 +719,11 @@ class TestToolContextInjection: """Test that context works in async functions.""" mcp = FastMCP() + @mcp.tool() async def async_tool(x: int, ctx: Context) -> str: assert ctx.request_id is not None return f"Async request {ctx.request_id}: {x}" - mcp.add_tool(async_tool) async with Client(mcp) as client: result = await client.call_tool("async_tool", {"x": 42}) assert len(result) == 1 @@ -732,51 +732,14 @@ class TestToolContextInjection: assert "Async request" in content.text assert "42" in content.text - async def test_context_logging(self): - from unittest.mock import patch - - import mcp.server.session - - """Test that context logging methods work.""" - mcp = FastMCP() - - async def logging_tool(msg: str, ctx: Context) -> str: - await ctx.debug("Debug message") - await ctx.info("Info message") - await ctx.warning("Warning message") - await ctx.error("Error message") - return f"Logged messages for {msg}" - - mcp.add_tool(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("logging_tool", {"msg": "test"}) - assert len(result) == 1 - content = result[0] - assert isinstance(content, TextContent) - assert "Logged messages for test" in content.text - - assert mock_log.call_count == 4 - mock_log.assert_any_call( - level="debug", data="Debug message", logger=None - ) - mock_log.assert_any_call(level="info", data="Info message", logger=None) - mock_log.assert_any_call( - level="warning", data="Warning message", logger=None - ) - mock_log.assert_any_call( - level="error", data="Error message", logger=None - ) - async def test_optional_context(self): """Test that context is optional.""" mcp = FastMCP() + @mcp.tool() def no_context(x: int) -> int: return x * 2 - mcp.add_tool(no_context) async with Client(mcp) as client: result = await client.call_tool("no_context", {"x": 21}) assert len(result) == 1