Avoid loading MCP and CLI stacks during lightweight imports (#4763)

This commit is contained in:
Jeremiah Lowin 2026-08-05 10:56:13 -04:00 committed by GitHub
commit e4d8ca648a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 104 additions and 33 deletions

View file

@ -6,15 +6,13 @@ from importlib.metadata import PackageNotFoundError, version as _version
from typing import TYPE_CHECKING
from fastmcp import _install_hints
from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging
if TYPE_CHECKING:
from fastmcp.client import Client as Client
from fastmcp.apps.app import FastMCPApp as FastMCPApp
from fastmcp.exceptions import (
FastMCPDeprecationWarning as FastMCPDeprecationWarning,
)
from fastmcp.server.context import Context as Context
from fastmcp.server.server import FastMCP as FastMCP
@ -39,12 +37,7 @@ except PackageNotFoundError:
__version__ = _version("fastmcp")
if settings.deprecation_warnings:
try:
from fastmcp.exceptions import FastMCPDeprecationWarning
except ImportError:
pass
else:
warnings.simplefilter("default", FastMCPDeprecationWarning)
warnings.simplefilter("default", FastMCPDeprecationWarning)
# --- Lazy imports for performance (see #3292) ---
@ -81,10 +74,6 @@ def __getattr__(name: str) -> object:
raise ImportError(_install_hints.APP_SUPPORT) from exc
return FastMCPApp
if name == "FastMCPDeprecationWarning":
from fastmcp.exceptions import FastMCPDeprecationWarning
return FastMCPDeprecationWarning
if name == "client":
try:
return importlib.import_module("fastmcp.client")

View file

@ -33,7 +33,7 @@ import warnings
import mcp_types
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp._warnings import FastMCPDeprecationWarning
# Map each SDK model class to the camelCase -> snake_case field reads we bridge.
# Limited to fields FastMCP users actually read (docs boundary inventory).

View file

@ -0,0 +1,10 @@
"""Warning types that can be imported without loading FastMCP's exception stack."""
class FastMCPDeprecationWarning(DeprecationWarning):
"""Deprecation warning for FastMCP APIs.
Subclass of DeprecationWarning so that standard warning filters
still apply, but FastMCP can selectively enable its own warnings
without affecting other libraries in the process.
"""

View file

@ -5,6 +5,8 @@ from typing import Any
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData
from fastmcp import _warnings
try:
from mcp import MCPError
except ImportError:
@ -30,14 +32,7 @@ except ImportError:
# see the migration notes.
McpError = MCPError
class FastMCPDeprecationWarning(DeprecationWarning):
"""Deprecation warning for FastMCP APIs.
Subclass of DeprecationWarning so that standard warning filters
still apply, but FastMCP can selectively enable its own warnings
without affecting other libraries in the process.
"""
FastMCPDeprecationWarning = _warnings.FastMCPDeprecationWarning
class FastMCPError(Exception):

View file

@ -28,7 +28,6 @@ from fastmcp.server.http import (
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
from fastmcp.server.providers.wrapped_provider import _WrappedProvider
from fastmcp.utilities.cli import log_server_banner
from fastmcp.utilities.logging import get_logger, temporary_log_level
if TYPE_CHECKING:
@ -230,6 +229,8 @@ class TransportMixin:
# Display server banner
if show_banner:
from fastmcp.utilities.cli import log_server_banner
log_server_banner(server=self)
token = set_transport("stdio")
@ -337,6 +338,8 @@ class TransportMixin:
# Display server banner
if show_banner:
from fastmcp.utilities.cli import log_server_banner
log_server_banner(server=self)
uvicorn_config_from_user = uvicorn_config or {}

View file

@ -1,7 +1,9 @@
"""Logging utilities for FastMCP."""
import contextlib
import importlib.util
import logging
from pathlib import Path
from typing import Any, Literal, cast
from rich.console import Console
@ -11,6 +13,17 @@ from typing_extensions import override
import fastmcp
def _get_package_path(package: str) -> str | None:
"""Return a package directory without importing the package."""
try:
spec = importlib.util.find_spec(package)
except ImportError:
return None
if spec is None or spec.origin is None:
return None
return str(Path(spec.origin).parent)
def get_logger(name: str) -> logging.Logger:
"""Get a logger nested under FastMCP namespace.
@ -83,14 +96,11 @@ def configure_logging(
# 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]
tracebacks_suppress = [
package_path
for package in ("fastmcp", "mcp", "pydantic")
if (package_path := _get_package_path(package)) is not None
]
# Build traceback kwargs with defaults that can be overridden
traceback_kwargs = {

View file

@ -9,6 +9,51 @@ import textwrap
import pytest
@pytest.mark.subprocess_heavy
def test_root_import_does_not_load_mcp_sdk() -> None:
script = textwrap.dedent(
"""
import sys
import fastmcp
assert fastmcp.settings is not None
assert "mcp" not in sys.modules
assert "fastmcp.exceptions" not in sys.modules
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@pytest.mark.subprocess_heavy
def test_server_import_does_not_load_cli() -> None:
script = textwrap.dedent(
"""
import sys
from fastmcp import FastMCP
assert FastMCP is not None
assert "fastmcp.utilities.cli" not in sys.modules
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@pytest.mark.subprocess_heavy
def test_default_http_app_does_not_load_opt_in_integrations() -> None:
script = textwrap.dedent(

View file

@ -10,6 +10,7 @@ from mcp import MCPError as SDKMCPError
import fastmcp
import fastmcp._compat as _compat
from fastmcp import Client, FastMCP
from fastmcp import FastMCPDeprecationWarning as PublicWarning
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import FastMCPDeprecationWarning, MCPError, McpError
@ -30,6 +31,10 @@ def _reset_warn_once() -> None:
_compat.install()
def test_deprecation_warning_is_same_from_public_imports() -> None:
assert PublicWarning is FastMCPDeprecationWarning
@pytest.fixture(autouse=True)
def fresh_shims():
_reset_warn_once()

View file

@ -1,4 +1,7 @@
import logging
from pathlib import Path
from rich.logging import RichHandler
import fastmcp
from fastmcp.utilities.logging import configure_logging, get_logger
@ -42,6 +45,19 @@ def test_configure_logging_with_traceback_kwargs():
assert len(logger.handlers) == 2 # One for normal logs, one for tracebacks
def test_configure_logging_suppresses_framework_package_paths():
configure_logging(enable_rich_tracebacks=True)
traceback_handler = logging.getLogger("fastmcp").handlers[-1]
assert isinstance(traceback_handler, RichHandler)
suppressed_packages = {
Path(path).name
for path in traceback_handler.tracebacks_suppress
if isinstance(path, str)
}
assert {"fastmcp", "mcp", "pydantic"} <= suppressed_packages
def test_configure_logging_traceback_defaults_can_be_overridden():
"""Test that default traceback settings can be overridden by kwargs."""
configure_logging(
@ -91,8 +107,6 @@ def test_configure_logging_with_rich_enabled():
# Should have two handlers when rich logging is enabled (normal + traceback)
assert len(logger.handlers) == 2
# Both should be RichHandler instances
from rich.logging import RichHandler
assert all(isinstance(h, RichHandler) for h in logger.handlers)
finally:
fastmcp.settings.enable_rich_logging = original_enable_rich