diff --git a/fastmcp_slim/fastmcp/__init__.py b/fastmcp_slim/fastmcp/__init__.py index 170ffb0f4..9d64f128e 100644 --- a/fastmcp_slim/fastmcp/__init__.py +++ b/fastmcp_slim/fastmcp/__init__.py @@ -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") diff --git a/fastmcp_slim/fastmcp/_compat.py b/fastmcp_slim/fastmcp/_compat.py index 402b8c238..cefc67b49 100644 --- a/fastmcp_slim/fastmcp/_compat.py +++ b/fastmcp_slim/fastmcp/_compat.py @@ -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). diff --git a/fastmcp_slim/fastmcp/_warnings.py b/fastmcp_slim/fastmcp/_warnings.py new file mode 100644 index 000000000..c63b97a5d --- /dev/null +++ b/fastmcp_slim/fastmcp/_warnings.py @@ -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. + """ diff --git a/fastmcp_slim/fastmcp/exceptions.py b/fastmcp_slim/fastmcp/exceptions.py index 4042f80b7..3fa255acd 100644 --- a/fastmcp_slim/fastmcp/exceptions.py +++ b/fastmcp_slim/fastmcp/exceptions.py @@ -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): diff --git a/fastmcp_slim/fastmcp/server/mixins/transport.py b/fastmcp_slim/fastmcp/server/mixins/transport.py index 13bef1ced..b26e02d18 100644 --- a/fastmcp_slim/fastmcp/server/mixins/transport.py +++ b/fastmcp_slim/fastmcp/server/mixins/transport.py @@ -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 {} diff --git a/fastmcp_slim/fastmcp/utilities/logging.py b/fastmcp_slim/fastmcp/utilities/logging.py index d39d6ef4a..eebf4eda8 100644 --- a/fastmcp_slim/fastmcp/utilities/logging.py +++ b/fastmcp_slim/fastmcp/utilities/logging.py @@ -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 = { diff --git a/tests/server/http/test_startup_imports.py b/tests/server/http/test_startup_imports.py index 52af15fc1..addcd99b7 100644 --- a/tests/server/http/test_startup_imports.py +++ b/tests/server/http/test_startup_imports.py @@ -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( diff --git a/tests/test_compat.py b/tests/test_compat.py index ff5356a64..a44991f02 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -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() diff --git a/tests/utilities/test_logging.py b/tests/utilities/test_logging.py index d15b9c355..c74b6c436 100644 --- a/tests/utilities/test_logging.py +++ b/tests/utilities/test_logging.py @@ -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