"""Logging utilities for FastMCP.""" import contextlib import logging from typing import Any, Literal, cast from rich.console import Console from rich.logging import RichHandler from typing_extensions import override import fastmcp def get_logger(name: str) -> logging.Logger: """Get a logger nested under FastMCP namespace. Args: name: the name of the logger, which will be prefixed with 'FastMCP.' Returns: a configured logger instance """ if name.startswith("fastmcp."): return logging.getLogger(name=name) return logging.getLogger(name=f"fastmcp.{name}") def configure_logging( level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO", logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any, ) -> None: """ Configure logging for FastMCP. Args: logger: the logger to configure level: the log level to use rich_kwargs: the parameters to use for creating RichHandler """ # Check if logging is disabled in settings if not fastmcp.settings.log_enabled: return # Use settings default if not specified if enable_rich_tracebacks is None: enable_rich_tracebacks = fastmcp.settings.enable_rich_tracebacks if logger is None: logger = logging.getLogger("fastmcp") formatter = logging.Formatter("%(message)s") # Don't propagate to the root logger logger.propagate = False logger.setLevel(level) # Remove any existing handlers to avoid duplicates on reconfiguration for hdlr in logger.handlers[:]: logger.removeHandler(hdlr) # Use standard logging handlers if rich logging is disabled if not fastmcp.settings.enable_rich_logging: # Create a standard StreamHandler for stderr handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) logger.addHandler(handler) return # Configure the handler for normal logs handler = RichHandler( console=Console(stderr=True), **rich_kwargs, ) handler.setFormatter(formatter) # filter to exclude tracebacks handler.addFilter(lambda record: record.exc_info is None) # Configure the handler for tracebacks, for tracebacks we use a compressed format: # no path or level name to maximize width available for the traceback # suppress framework frames and limit the number of frames to 3 import pydantic try: import mcp except ImportError: tracebacks_suppress = [fastmcp, pydantic] else: tracebacks_suppress = [fastmcp, mcp, pydantic] # Build traceback kwargs with defaults that can be overridden traceback_kwargs = { "console": Console(stderr=True), "show_path": False, "show_level": False, "rich_tracebacks": enable_rich_tracebacks, "tracebacks_max_frames": 3, "tracebacks_suppress": tracebacks_suppress, } # Override defaults with user-provided values traceback_kwargs.update(rich_kwargs) traceback_handler = RichHandler(**traceback_kwargs) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] traceback_handler.setFormatter(formatter) traceback_handler.addFilter(lambda record: record.exc_info is not None) logger.addHandler(handler) logger.addHandler(traceback_handler) @contextlib.contextmanager def temporary_log_level( level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any, ): """Context manager to temporarily set log level and restore it afterwards. Args: level: The temporary log level to set (e.g., "DEBUG", "INFO") logger: Optional logger to configure (defaults to FastMCP logger) enable_rich_tracebacks: Whether to enable rich tracebacks **rich_kwargs: Additional parameters for RichHandler Usage: with temporary_log_level("DEBUG"): # Code that runs with DEBUG logging pass # Original log level is restored here """ if level: # Get the original log level from settings original_level = fastmcp.settings.log_level # Configure with new level # Cast to proper type for type checker log_level_literal = cast( Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], level.upper(), ) configure_logging( level=log_level_literal, logger=logger, enable_rich_tracebacks=enable_rich_tracebacks, **rich_kwargs, ) try: yield finally: # Restore original configuration using configure_logging # This will respect the log_enabled setting configure_logging( level=original_level, logger=logger, enable_rich_tracebacks=enable_rich_tracebacks, **rich_kwargs, ) else: yield _level_to_no: dict[ Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, int | None ] = { "DEBUG": logging.DEBUG, "INFO": logging.INFO, "WARNING": logging.WARNING, "ERROR": logging.ERROR, "CRITICAL": logging.CRITICAL, None: None, } class _ClampedLogFilter(logging.Filter): min_level: tuple[int, str] | None max_level: tuple[int, str] | None def __init__( self, min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, ): self.min_level = None self.max_level = None if min_level_no := _level_to_no.get(min_level): self.min_level = (min_level_no, str(min_level)) if max_level_no := _level_to_no.get(max_level): self.max_level = (max_level_no, str(max_level)) super().__init__() @override def filter(self, record: logging.LogRecord) -> bool: if self.max_level: max_level_no, max_level_name = self.max_level if record.levelno > max_level_no: record.levelno = max_level_no record.levelname = max_level_name return True if self.min_level: min_level_no, min_level_name = self.min_level if record.levelno < min_level_no: record.levelno = min_level_no record.levelname = min_level_name return True return True def _clamp_logger( logger: logging.Logger, min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, ) -> None: """Clamp the logger to a minimum and maximum level. If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`. If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`. Args: min_level: The lower bound of the clamp max_level: The upper bound of the clamp """ _unclamp_logger(logger=logger) logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level)) def _unclamp_logger(logger: logging.Logger) -> None: """Remove all clamped log filters from the logger.""" for filter in logger.filters[:]: if isinstance(filter, _ClampedLogFilter): logger.removeFilter(filter)