From e2bdc9288b966f63f61c01d7e396985974f8ab94 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:36:01 -0400 Subject: [PATCH] Support logging/setLevel and add client_log_level setting (#3491) --- docs/more/settings.mdx | 3 +- docs/servers/server.mdx | 263 +++++++++++--------- src/fastmcp/server/context.py | 19 ++ src/fastmcp/server/low_level.py | 4 +- src/fastmcp/server/mixins/mcp_operations.py | 19 ++ src/fastmcp/server/server.py | 7 + src/fastmcp/settings.py | 18 ++ tests/client/test_logs.py | 93 +++++++ 8 files changed, 308 insertions(+), 118 deletions(-) diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index d2b4d0443..3c49cad1f 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -21,8 +21,9 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env | Environment Variable | Type | Default | Description | |---|---|---|---| -| `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level. Case-insensitive. | +| `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. | | `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. | +| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. | | `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. | | `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. | | `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. | diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index ed84b5e48..6b192a3b0 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -11,36 +11,105 @@ The `FastMCP` class is the central piece of every FastMCP application. It acts a ## Creating a Server -Instantiate a server by providing a name that identifies it in client applications and logs. You can also provide instructions that help clients understand the server's purpose. +At its simplest, a FastMCP server just needs a name. Everything else has sensible defaults. ```python from fastmcp import FastMCP -mcp = FastMCP(name="MyAssistantServer") +mcp = FastMCP("MyServer") +``` -# Instructions help clients understand how to interact with the server -mcp_with_instructions = FastMCP( - name="HelpfulAssistant", - instructions=""" - This server provides data analysis tools. - Call get_average() to analyze numerical data. - """, +Instructions help clients (and the LLMs behind them) understand what your server does and how to use it effectively. + +```python +mcp = FastMCP( + "DataAnalysis", + instructions="Provides tools for analyzing numerical datasets. Start with get_summary() for an overview.", ) ``` -The `FastMCP` constructor accepts several configuration options. The most commonly used parameters control server identity, authentication, and component behavior. +## Components - +FastMCP servers expose three types of components to clients, each serving a distinct role in the MCP protocol. + +**Tools** are functions that clients invoke to perform actions or access external systems. + +```python +@mcp.tool +def multiply(a: float, b: float) -> float: + """Multiplies two numbers together.""" + return a * b +``` + +**Resources** expose data that clients can read — passive data sources rather than invocable functions. + +```python +@mcp.resource("data://config") +def get_config() -> dict: + return {"theme": "dark", "version": "1.0"} +``` + +**Prompts** are reusable message templates that guide LLM interactions. + +```python +@mcp.prompt +def analyze_data(data_points: list[float]) -> str: + formatted_data = ", ".join(str(point) for point in data_points) + return f"Please analyze these data points: {formatted_data}" +``` + +Each component type has detailed documentation: [Tools](/servers/tools), [Resources](/servers/resources) (including [Resource Templates](/servers/resources#resource-templates)), and [Prompts](/servers/prompts). + +## Running the Server + +Start your server by calling `mcp.run()`. The `if __name__` guard ensures compatibility with MCP clients that launch your server as a subprocess. + +```python +from fastmcp import FastMCP + +mcp = FastMCP("MyServer") + +@mcp.tool +def greet(name: str) -> str: + """Greet a user by name.""" + return f"Hello, {name}!" + +if __name__ == "__main__": + mcp.run() +``` + +FastMCP supports several transports: +- **STDIO** (default): For local integrations and CLI tools +- **HTTP**: For web services using the Streamable HTTP protocol +- **SSE**: Legacy web transport (deprecated) + +```python +# Run with HTTP transport +mcp.run(transport="http", host="127.0.0.1", port=9000) +``` + +The server can also be run using the FastMCP CLI. For detailed information on transports and deployment, see [Running Your Server](/deployment/running-server). + + +## Configuration Reference + +The `FastMCP` constructor accepts parameters organized into four categories: identity, composition, behavior, and handlers. + +### Identity + +These parameters control how your server presents itself to clients. + + - A human-readable name for your server + A human-readable name for your server, shown in client applications and logs - Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality + Description of how to interact with this server. Clients surface these instructions to help LLMs understand the server's purpose and available functionality - Version string for your server. If not provided, defaults to the FastMCP library version + Version string for your server. Defaults to the FastMCP library version if not provided @@ -52,108 +121,106 @@ The `FastMCP` constructor accepts several configuration options. The most common - List of icon representations for your server. Icons help users visually identify your server in client applications. See [Icons](/servers/icons) for detailed examples + List of icon representations for your server. See [Icons](/servers/icons) for details + + + +### Composition + +These parameters control what your server is built from — its components, middleware, providers, and lifecycle. + + + + Tools to register on the server. An alternative to the `@mcp.tool` decorator when you need to add tools programmatically - Authentication provider for securing HTTP-based transports. See [Authentication](/servers/auth/authentication) for configuration options + Authentication provider for securing HTTP-based transports. See [Authentication](/servers/auth/authentication) for configuration - - Server-level setup and teardown logic. See [Lifespans](/servers/lifespan) for composable lifespans + + [Middleware](/servers/middleware) that intercepts and transforms every MCP message flowing through the server — requests, responses, and notifications in both directions. Use for cross-cutting concerns like logging, error handling, and rate limiting - - A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator + + [Providers](/servers/providers) that supply tools, resources, and prompts dynamically. Providers are queried at request time, so they can serve components from databases, APIs, or other external sources - Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients — for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery, and [CodeMode](/servers/transforms/code-mode) lets LLMs write scripts that chain tool calls in a sandbox + Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients — for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery + + Server-level setup and teardown logic that runs when the server starts and stops. See [Lifespans](/servers/lifespan) for composable lifespans + + + +### Behavior + +These parameters tune how the server processes requests and communicates with clients. + + How to handle duplicate component registrations - - Controls how tool input parameters are validated. When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` to `10` for int parameters). When `True`, uses the MCP SDK's JSON Schema validation to validate inputs against the exact schema before passing them to your function, rejecting any type mismatches. The default mode improves compatibility with LLM clients while maintaining type safety. See [Input Validation Modes](/servers/tools#input-validation-modes) for details + + When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, validates inputs against the exact JSON Schema before calling your function, rejecting type mismatches. See [Input Validation Modes](/servers/tools#input-validation-modes) for details + + + + When `True`, replaces internal error details in tool/resource responses with a generic message to avoid leaking implementation details to clients. Defaults to the `FASTMCP_MASK_ERROR_DETAILS` environment variable - Maximum number of items per page for list operations (`tools/list`, `resources/list`, etc.). When `None` (default), all results are returned in a single response. When set, responses are paginated and include a `nextCursor` for fetching additional pages. See [Pagination](/servers/pagination) for details + + Maximum items per page for list operations (`tools/list`, `resources/list`, etc.). When `None`, all results are returned in a single response. See [Pagination](/servers/pagination) for details + + Enable background task support. When `True`, tools and resources can return `CreateTaskResult` to run work asynchronously while the client polls for results + + + + + + Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"` + + + + Automatically dereference `$ref` pointers in JSON schemas generated from complex Pydantic models. Most clients require flat schemas without `$ref`, so this should usually stay enabled + -## Components +### Handlers and Storage -FastMCP servers expose three types of components to clients. Each type serves a distinct purpose in the MCP protocol. +These parameters provide custom handlers for MCP capabilities and persistent storage for session state. -### Tools + + + Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details + -Tools are functions that clients can invoke to perform actions or access external systems. They're the primary way clients interact with your server's capabilities. + + When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests + -```python -@mcp.tool -def multiply(a: float, b: float) -> float: - """Multiplies two numbers together.""" - return a * b -``` + + Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts + + -See [Tools](/servers/tools) for detailed documentation. - -### Resources - -Resources expose data that clients can read. Unlike tools, resources are passive data sources that clients pull from rather than invoke. - -```python -@mcp.resource("data://config") -def get_config() -> dict: - """Provides the application configuration.""" - return {"theme": "dark", "version": "1.0"} -``` - -See [Resources](/servers/resources) for detailed documentation. - -### Resource Templates - -Resource templates are parameterized resources. The client provides values for template parameters in the URI, and the server returns data specific to those parameters. - -```python -@mcp.resource("users://{user_id}/profile") -def get_user_profile(user_id: int) -> dict: - """Retrieves a user's profile by ID.""" - return {"id": user_id, "name": f"User {user_id}", "status": "active"} -``` - -See [Resource Templates](/servers/resources#resource-templates) for detailed documentation. - -### Prompts - -Prompts are reusable message templates that guide LLM interactions. They help establish consistent patterns for how clients should frame requests. - -```python -@mcp.prompt -def analyze_data(data_points: list[float]) -> str: - """Creates a prompt asking for analysis of numerical data.""" - formatted_data = ", ".join(str(point) for point in data_points) - return f"Please analyze these data points: {formatted_data}" -``` - -See [Prompts](/servers/prompts) for detailed documentation. ## Tag-Based Filtering -Tags let you categorize components and selectively expose them based on configurable include/exclude sets. This is useful for creating different views of your server for different environments or user types. - -Components can be tagged when defined using the `tags` parameter. A component can have multiple tags, and filtering operates on tag membership. +Tags let you categorize components and selectively expose them. This is useful for creating different views of your server for different environments or user types. ```python @mcp.tool(tags={"public", "utility"}) @@ -174,8 +241,6 @@ The filtering logic works as follows: To ensure a component is never exposed, you can set `enabled=False` on the component itself. See the component-specific documentation for details. -Configure tag-based filtering after creating your server. - ```python # Only expose components tagged with "public" mcp = FastMCP() @@ -192,38 +257,9 @@ mcp.enable(tags={"admin"}, only=True).disable(tags={"deprecated"}) This filtering applies to all component types (tools, resources, resource templates, and prompts) and affects both listing and access. -## Running the Server - -FastMCP servers communicate with clients through transport mechanisms. Start your server by calling `mcp.run()`, typically within an `if __name__ == "__main__":` block. This pattern ensures compatibility with various MCP clients. - -```python -from fastmcp import FastMCP - -mcp = FastMCP(name="MyServer") - -@mcp.tool -def greet(name: str) -> str: - """Greet a user by name.""" - return f"Hello, {name}!" - -if __name__ == "__main__": - # Defaults to STDIO transport - mcp.run() - - # Or use HTTP transport - # mcp.run(transport="http", host="127.0.0.1", port=9000) -``` - -FastMCP supports several transports: -- **STDIO** (default): For local integrations and CLI tools -- **HTTP**: For web services using the Streamable HTTP protocol -- **SSE**: Legacy web transport (deprecated) - -The server can also be run using the FastMCP CLI. For detailed information on transports and configuration, see the [Running Your Server](/deployment/running-server) guide. - ## Custom Routes -When running with HTTP transport, you can add custom web routes alongside your MCP endpoint using the `@custom_route` decorator. This is useful for auxiliary endpoints like health checks. +When running with HTTP transport, you can add custom web routes alongside your MCP endpoint using the `@custom_route` decorator. ```python from fastmcp import FastMCP @@ -240,9 +276,4 @@ if __name__ == "__main__": mcp.run(transport="http") # Health check at http://localhost:8000/health ``` -Custom routes are served alongside your MCP endpoint and are useful for: -- Health check endpoints for monitoring -- Simple status or info endpoints -- Basic webhooks or callbacks - -For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). +Custom routes are useful for health checks, status endpoints, and simple webhooks. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index d7b23a245..736271c30 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -1356,6 +1356,18 @@ class Context: await _reset_visibility(self) +_MCP_LEVEL_SEVERITY: dict[LoggingLevel, int] = { + "debug": 0, + "info": 1, + "notice": 2, + "warning": 3, + "error": 4, + "critical": 5, + "alert": 6, + "emergency": 7, +} + + async def _log_to_server_and_client( data: LogData, session: ServerSession, @@ -1364,6 +1376,13 @@ async def _log_to_server_and_client( related_request_id: str | None = None, ) -> None: """Log a message to the server and client.""" + from fastmcp.server.low_level import MiddlewareServerSession + + if isinstance(session, MiddlewareServerSession): + min_level = session._minimum_logging_level or session.fastmcp.client_log_level + if min_level is not None: + if _MCP_LEVEL_SEVERITY[level] < _MCP_LEVEL_SEVERITY[min_level]: + return msg_prefix = f"Sending {level.upper()} to client" diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index 420424fc1..043115a4e 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any import anyio import mcp.types from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp import McpError +from mcp import LoggingLevel, McpError from mcp.server.lowlevel.server import ( LifespanResultT, NotificationOptions, @@ -41,6 +41,8 @@ class MiddlewareServerSession(ServerSession): self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) # Task group for subscription tasks (set during session run) self._subscription_task_group: anyio.TaskGroup | None = None # type: ignore[valid-type] + # Minimum logging level requested by the client via logging/setLevel + self._minimum_logging_level: LoggingLevel | None = None @property def fastmcp(self) -> FastMCP: diff --git a/src/fastmcp/server/mixins/mcp_operations.py b/src/fastmcp/server/mixins/mcp_operations.py index 440fba4b3..77cb4c171 100644 --- a/src/fastmcp/server/mixins/mcp_operations.py +++ b/src/fastmcp/server/mixins/mcp_operations.py @@ -79,6 +79,7 @@ class MCPOperationsMixin: ) self._mcp_server.read_resource()(self._read_resource_mcp) self._mcp_server.get_prompt()(self._get_prompt_mcp) + self._mcp_server.set_logging_level()(self._set_logging_level_mcp) # Register SEP-1686 task protocol handlers self._setup_task_protocol_handlers() @@ -352,3 +353,21 @@ class MCPOperationsMixin: raise NotFoundError(f"Unknown prompt: {name!r}") from e except NotFoundError: raise + + async def _set_logging_level_mcp(self, level: mcp.types.LoggingLevel) -> None: + """Handle MCP 'logging/setLevel' requests. + + Stores the requested minimum log level on the session so that + subsequent log messages below this level are suppressed. + """ + from fastmcp.server.low_level import MiddlewareServerSession + + server = cast("FastMCP", self) + logger.debug(f"[{server.name}] Handler called: set_logging_level %s", level) + try: + ctx = server._mcp_server.request_context + session = ctx.session + if isinstance(session, MiddlewareServerSession): + session._minimum_logging_level = level + except LookupError: + pass diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index f65cb86c9..b2e40df8d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -240,6 +240,7 @@ class FastMCP( session_state_store: AsyncKeyValue | None = None, sampling_handler: SamplingHandler | None = None, sampling_handler_behavior: Literal["always", "fallback"] | None = None, + client_log_level: mcp.types.LoggingLevel | None = None, **kwargs: Any, ): _check_removed_kwargs(kwargs) @@ -332,6 +333,12 @@ class FastMCP( else fastmcp.settings.strict_input_validation ) + self.client_log_level: mcp.types.LoggingLevel | None = ( + client_log_level + if client_log_level is not None + else fastmcp.settings.client_log_level + ) + self.middleware: list[Middleware] = list(middleware or []) if dereference_schemas: diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index bc6d22065..d98d7ed00 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -21,6 +21,10 @@ ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +MCP_LOG_LEVEL = Literal[ + "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" +] + DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] TEN_MB_IN_BYTES = 1024 * 1024 * 10 @@ -254,6 +258,20 @@ class Settings(BaseSettings): ), ] = False + client_log_level: Annotated[ + MCP_LOG_LEVEL | None, + Field( + description=inspect.cleandoc( + """ + Default minimum log level for messages sent to MCP clients. + When set, log messages below this level are suppressed. + Individual clients can override this per-session using the + MCP logging/setLevel request. + """ + ), + ), + ] = None + strict_input_validation: Annotated[ bool, Field( diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py index f04af4338..e599fefc6 100644 --- a/tests/client/test_logs.py +++ b/tests/client/test_logs.py @@ -90,6 +90,99 @@ class TestClientLogs: assert caplog.records[1].levelname == "WARNING" +class TestSetLoggingLevel: + async def test_set_logging_level(self, fastmcp_server: FastMCP): + """Client can set the minimum log level and lower-level messages are suppressed.""" + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.set_logging_level("warning") + await client.call_tool( + "echo_log", {"message": "debug msg", "level": "debug"} + ) + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + await client.call_tool( + "echo_log", {"message": "warning msg", "level": "warning"} + ) + await client.call_tool( + "echo_log", {"message": "error msg", "level": "error"} + ) + + assert len(log_handler.logs) == 2 + assert log_handler.logs[0].data["msg"] == "warning msg" + assert log_handler.logs[1].data["msg"] == "error msg" + + async def test_set_logging_level_debug_allows_all(self, fastmcp_server: FastMCP): + """Setting level to debug allows all messages through.""" + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.set_logging_level("debug") + await client.call_tool( + "echo_log", {"message": "debug msg", "level": "debug"} + ) + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + + assert len(log_handler.logs) == 2 + + async def test_default_level_allows_all(self, fastmcp_server: FastMCP): + """Without calling set_logging_level, all messages are sent.""" + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.call_tool( + "echo_log", {"message": "debug msg", "level": "debug"} + ) + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + + assert len(log_handler.logs) == 2 + + async def test_server_default_client_log_level(self): + """Server-wide client_log_level filters messages for all sessions.""" + mcp = FastMCP(client_log_level="error") + + @mcp.tool + async def echo_log( + message: str, context: Context, level: LoggingLevel | None = None + ) -> None: + await context.log(message=message, level=level) + + log_handler = LogHandler() + async with Client(mcp, log_handler=log_handler.handle_log) as client: + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + await client.call_tool( + "echo_log", {"message": "warning msg", "level": "warning"} + ) + await client.call_tool( + "echo_log", {"message": "error msg", "level": "error"} + ) + + assert len(log_handler.logs) == 1 + assert log_handler.logs[0].data["msg"] == "error msg" + + async def test_session_level_overrides_server_default(self): + """Per-session setLevel overrides the server's client_log_level.""" + mcp = FastMCP(client_log_level="error") + + @mcp.tool + async def echo_log( + message: str, context: Context, level: LoggingLevel | None = None + ) -> None: + await context.log(message=message, level=level) + + log_handler = LogHandler() + async with Client(mcp, log_handler=log_handler.handle_log) as client: + await client.set_logging_level("warning") + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + await client.call_tool( + "echo_log", {"message": "warning msg", "level": "warning"} + ) + await client.call_tool( + "echo_log", {"message": "error msg", "level": "error"} + ) + + assert len(log_handler.logs) == 2 + assert log_handler.logs[0].data["msg"] == "warning msg" + assert log_handler.logs[1].data["msg"] == "error msg" + + class TestDefaultLogHandler: """Tests for default_log_handler with data as any JSON-serializable type."""