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
2 changes: 1 addition & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def __init__(
tools: list[Tool] | None = None,
resources: list[Resource] | None = None,
debug: bool = False,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "WARNING",
warn_on_duplicate_resources: bool = True,
warn_on_duplicate_tools: bool = True,
warn_on_duplicate_prompts: bool = True,
Expand Down
33 changes: 24 additions & 9 deletions src/mcp/server/mcpserver/utilities/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,38 @@ def get_logger(name: str) -> logging.Logger:


def configure_logging(
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "WARNING",
) -> None:
"""Configure logging for MCP.

Configures the ``mcp`` logger (not the root logger) so that library code
does not accidentally install handlers on the root namespace. This keeps
third-party libraries (httpx, urllib3, …) from routing their INFO-level
output through a RichHandler that writes to stderr, which can fill the
kernel's stderr SNDBUF and deadlock a stdio-transport MCP server under
back-pressure from the host process.

The function is idempotent: if the ``mcp`` logger already has handlers the
call is a no-op, allowing application code to configure logging before
instantiating :class:`~mcp.server.mcpserver.server.MCPServer`.

Args:
level: The log level to use.
level: The log level to use (default ``"WARNING"``).
"""
handlers: list[logging.Handler] = []
mcp_logger = logging.getLogger("mcp")

# Idempotent: skip if already configured.
if mcp_logger.handlers:
return

try:
from rich.console import Console
from rich.logging import RichHandler

handlers.append(RichHandler(console=Console(stderr=True), rich_tracebacks=True))
mcp_logger.addHandler(RichHandler(console=Console(stderr=True), rich_tracebacks=True))
except ImportError: # pragma: no cover
pass

if not handlers: # pragma: no cover
handlers.append(logging.StreamHandler())
mcp_logger.addHandler(logging.StreamHandler())

logging.basicConfig(level=level, format="%(message)s", handlers=handlers)
mcp_logger.setLevel(level)
# Do not propagate to the root logger; we own our own handler.
mcp_logger.propagate = False
91 changes: 91 additions & 0 deletions tests/issues/test_2527_fastmcp_logger_pollution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Test for issue #2527: MCPServer.__init__ must not pollute the root logger.

Regression test verifying that:
1. Instantiating MCPServer does NOT add any handlers to the root logger.
2. configure_logging() targets the "mcp" logger, not the root logger.
3. configure_logging() is idempotent (calling it twice doesn't add a second handler).
4. The "mcp" logger does not propagate to the root logger after configure_logging().
"""

import logging

import pytest

from mcp.server.mcpserver.utilities.logging import configure_logging


def test_configure_logging_does_not_touch_root_logger():
"""configure_logging() must not add handlers to the root logger."""
root = logging.getLogger()
handlers_before = list(root.handlers)

# Call explicitly; MCPServer.__init__ calls this too.
configure_logging()

assert root.handlers == handlers_before, (
"configure_logging() added a handler to the root logger, which pollutes "
"all third-party loggers and can deadlock stdio servers under back-pressure."
)


def test_configure_logging_adds_handler_to_mcp_logger():
"""configure_logging() must add a handler to the 'mcp' logger."""
mcp_logger = logging.getLogger("mcp")
# Remove any handlers that may have been added by a previous test run.
mcp_logger.handlers.clear()
mcp_logger.propagate = True # reset

configure_logging()

assert mcp_logger.handlers, "configure_logging() did not add any handler to the 'mcp' logger."


def test_configure_logging_sets_propagate_false():
"""The 'mcp' logger must not propagate to root after configure_logging()."""
mcp_logger = logging.getLogger("mcp")
mcp_logger.handlers.clear()
mcp_logger.propagate = True # reset

configure_logging()

assert not mcp_logger.propagate, (
"mcp logger propagates to root; any INFO log from mcp can reach third-party "
"root handlers and cause back-pressure on stdio stderr."
)


def test_configure_logging_is_idempotent():
"""Calling configure_logging() twice must not add a second handler."""
mcp_logger = logging.getLogger("mcp")
mcp_logger.handlers.clear()
mcp_logger.propagate = True # reset

configure_logging()
handler_count_after_first = len(mcp_logger.handlers)

configure_logging()
handler_count_after_second = len(mcp_logger.handlers)

assert handler_count_after_first == handler_count_after_second, (
"configure_logging() is not idempotent: calling it twice added extra handlers."
)


def test_mcpserver_init_does_not_pollute_root_logger():
"""MCPServer() must not add handlers to the root logger."""
# Remove any mcp logger handlers first so configure_logging runs fresh.
mcp_logger = logging.getLogger("mcp")
mcp_logger.handlers.clear()

root = logging.getLogger()
handlers_before = list(root.handlers)

# Import here to avoid side-effects at module import time.
from mcp.server.mcpserver.server import MCPServer

MCPServer("test-server")

assert root.handlers == handlers_before, (
"MCPServer.__init__ added a handler to the root logger. "
"This pollutes all third-party loggers and can deadlock stdio servers."
)
Loading