diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 04dc559d0..1ed826a83 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -215,7 +215,7 @@ You can configure the prefix format globally in code: ```python import fastmcp -fastmcp.settings.settings.resource_prefix_format = "protocol" +fastmcp.settings.resource_prefix_format = "protocol" ``` Or via environment variable: diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index aaeab2340..465c23f3d 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -1,6 +1,9 @@ """FastMCP - An ergonomic MCP interface.""" from importlib.metadata import version +from fastmcp.settings import Settings + +settings = Settings() from fastmcp.server.server import FastMCP from fastmcp.server.context import Context @@ -8,7 +11,7 @@ import fastmcp.server from fastmcp.client import Client from fastmcp.utilities.types import Image -from . import client, settings +from . import client __version__ = version("fastmcp") __all__ = [ diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index dd2142b14..f3e326270 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -18,6 +18,7 @@ from typer import Context, Exit import fastmcp from fastmcp.cli import claude from fastmcp.cli import run as run_module +from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger logger = get_logger("cli") @@ -165,8 +166,8 @@ def dev( try: # Import server to get dependencies - server = run_module.import_server(file, server_object) - if hasattr(server, "dependencies") and server.dependencies is not None: + server: FastMCP = run_module.import_server(file, server_object) + if server.dependencies is not None: with_packages = list(set(with_packages + server.dependencies)) env_vars = {} diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index e8d988c10..ca92a5cc3 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -23,10 +23,10 @@ from mcp.shared.auth import ( ) from pydantic import AnyHttpUrl, ValidationError +from fastmcp import settings as fastmcp_global_settings from fastmcp.client.oauth_callback import ( create_oauth_callback_server, ) -from fastmcp.settings import settings as fastmcp_global_settings from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index cc82c6eb3..952f1af81 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -165,7 +165,7 @@ class Client(Generic[ClientTransportT]): # handle init handshake timeout if init_timeout is None: - init_timeout = fastmcp.settings.settings.client_init_timeout + init_timeout = fastmcp.settings.client_init_timeout if isinstance(init_timeout, datetime.timedelta): init_timeout = init_timeout.total_seconds() elif not init_timeout: diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index fea5ebcd4..d7380a8d7 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from mcp import GetPromptResult +from fastmcp import settings from fastmcp.exceptions import NotFoundError, PromptError from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult from fastmcp.settings import DuplicateBehavior @@ -23,10 +24,10 @@ class PromptManager: def __init__( self, duplicate_behavior: DuplicateBehavior | None = None, - mask_error_details: bool = False, + mask_error_details: bool | None = None, ): self._prompts: dict[str, Prompt] = {} - self.mask_error_details = mask_error_details + self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided if duplicate_behavior is None: diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index b802d7d8c..ed299d0c0 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -7,6 +7,7 @@ from typing import Any from pydantic import AnyUrl +from fastmcp import settings from fastmcp.exceptions import NotFoundError, ResourceError from fastmcp.resources.resource import Resource from fastmcp.resources.template import ( @@ -25,7 +26,7 @@ class ResourceManager: def __init__( self, duplicate_behavior: DuplicateBehavior | None = None, - mask_error_details: bool = False, + mask_error_details: bool | None = None, ): """Initialize the ResourceManager. @@ -37,7 +38,7 @@ class ResourceManager: """ self._resources: dict[str, Resource] = {} self._templates: dict[str, ResourceTemplate] = {} - self.mask_error_details = mask_error_details + self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided if duplicate_behavior is None: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 4395a7aca..8d51e33f4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -43,7 +43,6 @@ from starlette.routing import BaseRoute, Route import fastmcp import fastmcp.server -import fastmcp.settings from fastmcp.exceptions import NotFoundError from fastmcp.prompts import Prompt, PromptManager from fastmcp.prompts.prompt import FunctionPrompt @@ -56,6 +55,7 @@ from fastmcp.server.http import ( create_sse_app, create_streamable_http_app, ) +from fastmcp.settings import Settings from fastmcp.tools import ToolManager from fastmcp.tools.tool import FunctionTool, Tool from fastmcp.utilities.cache import TimedCache @@ -121,7 +121,6 @@ class FastMCP(Generic[LifespanResultT]): | None ) = None, tags: set[str] | None = None, - dependencies: list[str] | None = None, tool_serializer: Callable[[Any], str] | None = None, cache_expiration_seconds: float | None = None, on_duplicate_tools: DuplicateBehavior | None = None, @@ -130,44 +129,44 @@ class FastMCP(Generic[LifespanResultT]): resource_prefix_format: Literal["protocol", "path"] | None = None, mask_error_details: bool | None = None, tools: list[Tool | Callable[..., Any]] | None = None, - **settings: Any, + dependencies: list[str] | None = None, + # --- + # --- + # --- The following arguments are DEPRECATED --- + # --- + # --- + log_level: str | None = None, + debug: bool | None = None, + host: str | None = None, + port: int | None = None, + sse_path: str | None = None, + message_path: str | None = None, + streamable_http_path: str | None = None, + json_response: bool | None = None, + stateless_http: bool | None = None, ): - if cache_expiration_seconds is not None: - settings["cache_expiration_seconds"] = cache_expiration_seconds - self.settings = fastmcp.settings.ServerSettings(**settings) - - # If mask_error_details is provided, override the settings value - if mask_error_details is not None: - self.settings.mask_error_details = mask_error_details - - self.resource_prefix_format: Literal["protocol", "path"] - if resource_prefix_format is None: - self.resource_prefix_format = ( - fastmcp.settings.settings.resource_prefix_format - ) - else: - self.resource_prefix_format = resource_prefix_format + self.resource_prefix_format: Literal["protocol", "path"] = ( + resource_prefix_format or fastmcp.settings.resource_prefix_format + ) self.tags: set[str] = tags or set() - self.dependencies = dependencies + self._cache = TimedCache( - expiration=datetime.timedelta( - seconds=self.settings.cache_expiration_seconds - ) + expiration=datetime.timedelta(seconds=cache_expiration_seconds or 0) ) self._mounted_servers: dict[str, MountedServer] = {} self._additional_http_routes: list[BaseRoute] = [] self._tool_manager = ToolManager( duplicate_behavior=on_duplicate_tools, - mask_error_details=self.settings.mask_error_details, + mask_error_details=mask_error_details, ) self._resource_manager = ResourceManager( duplicate_behavior=on_duplicate_resources, - mask_error_details=self.settings.mask_error_details, + mask_error_details=mask_error_details, ) self._prompt_manager = PromptManager( duplicate_behavior=on_duplicate_prompts, - mask_error_details=self.settings.mask_error_details, + mask_error_details=mask_error_details, ) self._tool_serializer = tool_serializer @@ -182,7 +181,7 @@ class FastMCP(Generic[LifespanResultT]): lifespan=_lifespan_wrapper(self, lifespan), ) - if auth is None and self.settings.default_auth_provider == "bearer_env": + if auth is None and fastmcp.settings.default_auth_provider == "bearer_env": auth = EnvBearerAuthProvider() self.auth = auth @@ -194,10 +193,62 @@ class FastMCP(Generic[LifespanResultT]): # Set up MCP protocol handlers self._setup_handlers() + self.dependencies = dependencies or fastmcp.settings.server_dependencies + + # handle deprecated settings + self._handle_deprecated_settings( + log_level=log_level, + debug=debug, + host=host, + port=port, + sse_path=sse_path, + message_path=message_path, + streamable_http_path=streamable_http_path, + json_response=json_response, + stateless_http=stateless_http, + ) def __repr__(self) -> str: return f"{type(self).__name__}({self.name!r})" + def _handle_deprecated_settings( + self, + log_level: str | None, + debug: bool | None, + host: str | None, + port: int | None, + sse_path: str | None, + message_path: str | None, + streamable_http_path: str | None, + json_response: bool | None, + stateless_http: bool | None, + ) -> None: + """Handle deprecated settings. Deprecated in 2.8.0.""" + deprecated_settings: dict[str, Any] = {} + + for name, arg in [ + ("log_level", log_level), + ("debug", debug), + ("host", host), + ("port", port), + ("sse_path", sse_path), + ("message_path", message_path), + ("streamable_http_path", streamable_http_path), + ("json_response", json_response), + ("stateless_http", stateless_http), + ]: + if arg is not None: + # Deprecated in 2.8.0 + warnings.warn( + f"Providing `{name}` when creating a server is deprecated. Provide it when calling `run` or as a global setting instead.", + DeprecationWarning, + stacklevel=2, + ) + deprecated_settings[name] = arg + + combined_settings = fastmcp.settings.model_dump() | deprecated_settings + self._deprecated_settings = Settings(**combined_settings) + @property def name(self) -> str: return self._mcp_server.name @@ -1008,9 +1059,11 @@ class FastMCP(Generic[LifespanResultT]): path: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path) uvicorn_config: Additional configuration for the Uvicorn server """ - host = host or self.settings.host - port = port or self.settings.port - default_log_level_to_use = (log_level or self.settings.log_level).lower() + host = host or self._deprecated_settings.host + port = port or self._deprecated_settings.port + default_log_level_to_use = ( + log_level or self._deprecated_settings.log_level + ).lower() app = self.http_app(path=path, transport=transport, middleware=middleware) @@ -1084,10 +1137,10 @@ class FastMCP(Generic[LifespanResultT]): ) return create_sse_app( server=self, - message_path=message_path or self.settings.message_path, - sse_path=path or self.settings.sse_path, + message_path=message_path or self._deprecated_settings.message_path, + sse_path=path or self._deprecated_settings.sse_path, auth=self.auth, - debug=self.settings.debug, + debug=self._deprecated_settings.debug, middleware=middleware, ) @@ -1115,6 +1168,8 @@ class FastMCP(Generic[LifespanResultT]): self, path: str | None = None, middleware: list[Middleware] | None = None, + json_response: bool | None = None, + stateless_http: bool | None = None, transport: Literal["streamable-http", "sse"] = "streamable-http", ) -> StarletteWithLifespan: """Create a Starlette app using the specified HTTP transport. @@ -1131,21 +1186,22 @@ class FastMCP(Generic[LifespanResultT]): if transport == "streamable-http": return create_streamable_http_app( server=self, - streamable_http_path=path or self.settings.streamable_http_path, + streamable_http_path=path + or self._deprecated_settings.streamable_http_path, event_store=None, auth=self.auth, - json_response=self.settings.json_response, - stateless_http=self.settings.stateless_http, - debug=self.settings.debug, + json_response=self._deprecated_settings.json_response, + stateless_http=self._deprecated_settings.stateless_http, + debug=self._deprecated_settings.debug, middleware=middleware, ) elif transport == "sse": return create_sse_app( server=self, - message_path=self.settings.message_path, - sse_path=path or self.settings.sse_path, + message_path=self._deprecated_settings.message_path, + sse_path=path or self._deprecated_settings.sse_path, auth=self.auth, - debug=self.settings.debug, + debug=self._deprecated_settings.debug, middleware=middleware, ) @@ -1597,7 +1653,7 @@ def add_resource_prefix( # Get the server settings to check for legacy format preference if prefix_format is None: - prefix_format = fastmcp.settings.settings.resource_prefix_format + prefix_format = fastmcp.settings.resource_prefix_format if prefix_format == "protocol": # Legacy style: prefix+protocol://path @@ -1646,7 +1702,7 @@ def remove_resource_prefix( return uri if prefix_format is None: - prefix_format = fastmcp.settings.settings.resource_prefix_format + prefix_format = fastmcp.settings.resource_prefix_format if prefix_format == "protocol": # Legacy style: prefix+protocol://path @@ -1706,7 +1762,7 @@ def has_resource_prefix( # Get the server settings to check for legacy format preference if prefix_format is None: - prefix_format = fastmcp.settings.settings.resource_prefix_format + prefix_format = fastmcp.settings.resource_prefix_format if prefix_format == "protocol": # Legacy style: prefix+protocol://path diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index f39c82ff1..8fb7629ee 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -1,16 +1,47 @@ from __future__ import annotations as _annotations import inspect +import warnings from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated, Any, Literal from pydantic import Field, model_validator +from pydantic.fields import FieldInfo from pydantic_settings import ( BaseSettings, + EnvSettingsSource, + PydanticBaseSettingsSource, SettingsConfigDict, ) from typing_extensions import Self + +class ExtendedEnvSettingsSource(EnvSettingsSource): + def get_field_value( + self, field: FieldInfo, field_name: str + ) -> tuple[Any, str, bool]: + if prefixes := self.config.get("env_prefixes"): + for prefix in prefixes: + self.env_prefix = prefix + env_val, field_key, value_is_complex = super().get_field_value( + field, field_name + ) + if env_val is not None: + if prefix == "FASTMCP_SERVER_": + warnings.warn( + "Using `FASTMCP_SERVER_` environment variables is deprecated. Use `FASTMCP_` instead.", + DeprecationWarning, + stacklevel=2, + ) + return env_val, field_key, value_is_complex + + return super().get_field_value(field, field_name) + + +class ExtendedSettingsConfigDict(SettingsConfigDict, total=False): + env_prefixes: list[str] | None + + LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] @@ -19,14 +50,30 @@ DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] class Settings(BaseSettings): """FastMCP settings.""" - model_config = SettingsConfigDict( - env_prefix="FASTMCP_", + model_config = ExtendedSettingsConfigDict( + env_prefixes=["FASTMCP_", "FASTMCP_SERVER_"], env_file=".env", extra="ignore", env_nested_delimiter="__", nested_model_default_partial_update=True, ) + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + return ( + init_settings, + ExtendedEnvSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + home: Path = Path.home() / ".fastmcp" test_mode: bool = False @@ -107,27 +154,6 @@ class Settings(BaseSettings): return self - -class ServerSettings(BaseSettings): - """FastMCP server settings. - - All settings can be configured via environment variables with the prefix FASTMCP_. - For example, FASTMCP_DEBUG=true will set debug=True. - """ - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_SERVER_", - env_file=".env", - extra="ignore", - env_nested_delimiter="__", - nested_model_default_partial_update=True, - ) - - log_level: Annotated[ - LOG_LEVEL, - Field(default_factory=lambda: Settings().log_level), - ] - # HTTP settings host: str = "127.0.0.1" port: int = 8000 @@ -136,15 +162,6 @@ class ServerSettings(BaseSettings): streamable_http_path: str = "/mcp" debug: bool = False - # resource settings - on_duplicate_resources: DuplicateBehavior = "warn" - - # tool settings - on_duplicate_tools: DuplicateBehavior = "warn" - - # prompt settings - on_duplicate_prompts: DuplicateBehavior = "warn" - # error handling mask_error_details: Annotated[ bool, @@ -162,7 +179,7 @@ class ServerSettings(BaseSettings): ), ] = False - dependencies: Annotated[ + server_dependencies: Annotated[ list[str], Field( default_factory=list, @@ -170,9 +187,6 @@ class ServerSettings(BaseSettings): ), ] = [] - # cache settings (for getting attributes from servers, used to avoid repeated calls) - cache_expiration_seconds: float = 0 - # StreamableHTTP settings json_response: bool = False stateless_http: bool = ( @@ -197,6 +211,3 @@ class ServerSettings(BaseSettings): ), ), ] = None - - -settings = Settings() diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index aaa7ff35a..d179be870 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -185,7 +185,7 @@ class FunctionTool(Tool): if context_kwarg and context_kwarg not in arguments: arguments[context_kwarg] = get_context() - if fastmcp.settings.settings.tool_attempt_parse_json_args: + if fastmcp.settings.tool_attempt_parse_json_args: # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]` # being passed in as JSON inside a string rather than an actual list. # diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index a05701a17..fe9eeb14c 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations +from fastmcp import settings from fastmcp.exceptions import NotFoundError, ToolError from fastmcp.settings import DuplicateBehavior from fastmcp.tools.tool import Tool @@ -23,10 +24,10 @@ class ToolManager: def __init__( self, duplicate_behavior: DuplicateBehavior | None = None, - mask_error_details: bool = False, + mask_error_details: bool | None = None, ): self._tools: dict[str, Tool] = {} - self.mask_error_details = mask_error_details + self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided if duplicate_behavior is None: diff --git a/src/fastmcp/utilities/exceptions.py b/src/fastmcp/utilities/exceptions.py index e50dc57b0..a85bbafa7 100644 --- a/src/fastmcp/utilities/exceptions.py +++ b/src/fastmcp/utilities/exceptions.py @@ -43,7 +43,7 @@ def get_catch_handlers() -> Mapping[ type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any], ]: - if fastmcp.settings.settings.client_raise_first_exceptiongroup_error: + if fastmcp.settings.client_raise_first_exceptiongroup_error: return _catch_handlers else: return {} diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index fd9e45945..9b0084fc1 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal import uvicorn -from fastmcp.settings import settings +from fastmcp import settings from fastmcp.utilities.http import find_available_port if TYPE_CHECKING: @@ -32,8 +32,8 @@ def temporary_settings(**kwargs: Any): from fastmcp.utilities.tests import temporary_settings with temporary_settings(log_level='DEBUG'): - assert fastmcp.settings.settings.log_level == 'DEBUG' - assert fastmcp.settings.settings.log_level == 'INFO' + assert fastmcp.settings.log_level == 'DEBUG' + assert fastmcp.settings.log_level == 'INFO' ``` """ old_settings = copy.deepcopy(settings.model_dump()) diff --git a/tests/auth/providers/test_bearer_env.py b/tests/auth/providers/test_bearer_env.py index cadf7075a..5e56fc518 100644 --- a/tests/auth/providers/test_bearer_env.py +++ b/tests/auth/providers/test_bearer_env.py @@ -4,6 +4,8 @@ from pydantic import AnyHttpUrl, ValidationError from fastmcp import FastMCP from fastmcp.server.auth.providers.bearer import BearerAuthProvider from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider +from fastmcp.settings import Settings +from fastmcp.utilities.tests import temporary_settings def test_load_bearer_env_from_env_var(monkeypatch): @@ -13,7 +15,8 @@ def test_load_bearer_env_from_env_var(monkeypatch): monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") - mcp_with_auth = FastMCP() + with temporary_settings(**Settings().model_dump()): + mcp_with_auth = FastMCP() assert isinstance(mcp_with_auth.auth, EnvBearerAuthProvider) @@ -23,10 +26,11 @@ def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatc monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") - with pytest.raises( - ValueError, match="Either public_key or jwks_uri must be provided" - ): - FastMCP() + with temporary_settings(**Settings().model_dump()): + with pytest.raises( + ValueError, match="Either public_key or jwks_uri must be provided" + ): + FastMCP() def test_configure_bearer_env_from_env_var(monkeypatch): @@ -38,7 +42,8 @@ def test_configure_bearer_env_from_env_var(monkeypatch): "FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", '["test-scope1", "test-scope2"]' ) - mcp = FastMCP() + with temporary_settings(**Settings().model_dump()): + mcp = FastMCP() assert isinstance(mcp.auth, EnvBearerAuthProvider) assert mcp.auth.public_key == "test-public-key" assert mcp.auth.issuer_url == AnyHttpUrl("http://test-issuer") @@ -50,17 +55,19 @@ def test_list_of_scopes_must_be_a_list(monkeypatch): monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1") - with pytest.raises(ValidationError, match="Input should be a valid list"): - FastMCP() + with temporary_settings(**Settings().model_dump()): + with pytest.raises(ValidationError, match="Input should be a valid list"): + FastMCP() def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch): monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri") - mcp = FastMCP() - assert isinstance(mcp.auth, EnvBearerAuthProvider) - assert mcp.auth.jwks_uri == "test-jwks-uri" + with temporary_settings(**Settings().model_dump()): + mcp = FastMCP() + assert isinstance(mcp.auth, EnvBearerAuthProvider) + assert mcp.auth.jwks_uri == "test-jwks-uri" def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch): @@ -68,15 +75,17 @@ def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch): monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri") - with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"): - FastMCP() + with temporary_settings(**Settings().model_dump()): + with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"): + FastMCP() def test_provided_auth_takes_precedence_over_env_vars(monkeypatch): monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") - mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2")) - assert isinstance(mcp.auth, BearerAuthProvider) - assert not isinstance(mcp.auth, EnvBearerAuthProvider) - assert mcp.auth.public_key == "test-public-key-2" + with temporary_settings(**Settings().model_dump()): + mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2")) + assert isinstance(mcp.auth, BearerAuthProvider) + assert not isinstance(mcp.auth, EnvBearerAuthProvider) + assert mcp.auth.public_key == "test-public-key-2" diff --git a/tests/deprecated/test_server_init_kwargs.py b/tests/deprecated/test_server_init_kwargs.py new file mode 100644 index 000000000..92a02c6fd --- /dev/null +++ b/tests/deprecated/test_server_init_kwargs.py @@ -0,0 +1,305 @@ +import warnings +from unittest.mock import patch + +import pytest + +from fastmcp import FastMCP + +# reset deprecation warnings for this module +pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning") + + +class TestDeprecatedServerInitKwargs: + """Test deprecated server initialization keyword arguments.""" + + def test_log_level_deprecation_warning(self): + """Test that log_level raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `log_level` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", log_level="DEBUG") + + # Verify the setting is still applied + assert server._deprecated_settings.log_level == "DEBUG" + + def test_debug_deprecation_warning(self): + """Test that debug raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `debug` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", debug=True) + + # Verify the setting is still applied + assert server._deprecated_settings.debug is True + + def test_host_deprecation_warning(self): + """Test that host raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `host` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", host="0.0.0.0") + + # Verify the setting is still applied + assert server._deprecated_settings.host == "0.0.0.0" + + def test_port_deprecation_warning(self): + """Test that port raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `port` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", port=8080) + + # Verify the setting is still applied + assert server._deprecated_settings.port == 8080 + + def test_sse_path_deprecation_warning(self): + """Test that sse_path raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `sse_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", sse_path="/custom-sse") + + # Verify the setting is still applied + assert server._deprecated_settings.sse_path == "/custom-sse" + + def test_message_path_deprecation_warning(self): + """Test that message_path raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `message_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", message_path="/custom-message") + + # Verify the setting is still applied + assert server._deprecated_settings.message_path == "/custom-message" + + def test_streamable_http_path_deprecation_warning(self): + """Test that streamable_http_path raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `streamable_http_path` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", streamable_http_path="/custom-http") + + # Verify the setting is still applied + assert server._deprecated_settings.streamable_http_path == "/custom-http" + + def test_json_response_deprecation_warning(self): + """Test that json_response raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `json_response` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", json_response=True) + + # Verify the setting is still applied + assert server._deprecated_settings.json_response is True + + def test_stateless_http_deprecation_warning(self): + """Test that stateless_http raises a deprecation warning.""" + with pytest.warns( + DeprecationWarning, + match=r"Providing `stateless_http` when creating a server is deprecated\. Provide it when calling `run` or as a global setting instead\.", + ): + server = FastMCP("TestServer", stateless_http=True) + + # Verify the setting is still applied + assert server._deprecated_settings.stateless_http is True + + def test_multiple_deprecated_kwargs_warnings(self): + """Test that multiple deprecated kwargs each raise their own warning.""" + with warnings.catch_warnings(record=True) as recorded_warnings: + warnings.simplefilter("always") + server = FastMCP( + "TestServer", + log_level="INFO", + debug=False, + host="127.0.0.1", + port=9999, + sse_path="/sse", + message_path="/msg", + streamable_http_path="/http", + json_response=False, + stateless_http=False, + ) + + # Should have 9 deprecation warnings (one for each deprecated parameter) + deprecation_warnings = [ + w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 9 + + # Verify all expected parameters are mentioned in warnings + expected_params = { + "log_level", + "debug", + "host", + "port", + "sse_path", + "message_path", + "streamable_http_path", + "json_response", + "stateless_http", + } + mentioned_params = set() + for warning in deprecation_warnings: + message = str(warning.message) + for param in expected_params: + if f"Providing `{param}`" in message: + mentioned_params.add(param) + + assert mentioned_params == expected_params + + # Verify all settings are still applied + assert server._deprecated_settings.log_level == "INFO" + assert server._deprecated_settings.debug is False + assert server._deprecated_settings.host == "127.0.0.1" + assert server._deprecated_settings.port == 9999 + assert server._deprecated_settings.sse_path == "/sse" + assert server._deprecated_settings.message_path == "/msg" + assert server._deprecated_settings.streamable_http_path == "/http" + assert server._deprecated_settings.json_response is False + assert server._deprecated_settings.stateless_http is False + + def test_non_deprecated_kwargs_no_warnings(self): + """Test that non-deprecated kwargs don't raise warnings.""" + with warnings.catch_warnings(record=True) as recorded_warnings: + warnings.simplefilter("always") + server = FastMCP( + name="TestServer", + instructions="Test instructions", + tags={"test", "server"}, + cache_expiration_seconds=60.0, + on_duplicate_tools="warn", + on_duplicate_resources="error", + on_duplicate_prompts="replace", + resource_prefix_format="path", + mask_error_details=True, + ) + + # Should have no deprecation warnings + deprecation_warnings = [ + w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 0 + + # Verify server was created successfully + assert server.name == "TestServer" + assert server.instructions == "Test instructions" + assert server.tags == {"test", "server"} + + def test_none_values_no_warnings(self): + """Test that None values for deprecated kwargs don't raise warnings.""" + with warnings.catch_warnings(record=True) as recorded_warnings: + warnings.simplefilter("always") + server = FastMCP( + "TestServer", + log_level=None, + debug=None, + host=None, + port=None, + sse_path=None, + message_path=None, + streamable_http_path=None, + json_response=None, + stateless_http=None, + ) + + # Should have no deprecation warnings for None values + deprecation_warnings = [ + w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 0 + + def test_deprecated_settings_inheritance_from_global(self): + """Test that deprecated settings inherit from global settings when not provided.""" + # Mock fastmcp.settings to test inheritance + with patch("fastmcp.settings") as mock_settings: + mock_settings.model_dump.return_value = { + "log_level": "WARNING", + "debug": True, + "host": "0.0.0.0", + "port": 3000, + "sse_path": "/events", + "message_path": "/messages", + "streamable_http_path": "/stream", + "json_response": True, + "stateless_http": True, + } + + server = FastMCP("TestServer") + + # Verify settings are inherited from global settings + assert server._deprecated_settings.log_level == "WARNING" + assert server._deprecated_settings.debug is True + assert server._deprecated_settings.host == "0.0.0.0" + assert server._deprecated_settings.port == 3000 + assert server._deprecated_settings.sse_path == "/events" + assert server._deprecated_settings.message_path == "/messages" + assert server._deprecated_settings.streamable_http_path == "/stream" + assert server._deprecated_settings.json_response is True + assert server._deprecated_settings.stateless_http is True + + def test_deprecated_settings_override_global(self): + """Test that deprecated settings override global settings when provided.""" + # Mock fastmcp.settings to test override behavior + with patch("fastmcp.settings") as mock_settings: + mock_settings.model_dump.return_value = { + "log_level": "WARNING", + "debug": True, + "host": "0.0.0.0", + "port": 3000, + "sse_path": "/events", + "message_path": "/messages", + "streamable_http_path": "/stream", + "json_response": True, + "stateless_http": True, + } + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # Ignore warnings for this test + server = FastMCP( + "TestServer", + log_level="ERROR", + debug=False, + host="127.0.0.1", + port=8080, + ) + + # Verify provided settings override global settings + assert server._deprecated_settings.log_level == "ERROR" + assert server._deprecated_settings.debug is False + assert server._deprecated_settings.host == "127.0.0.1" + assert server._deprecated_settings.port == 8080 + # Non-overridden settings should still come from global + assert server._deprecated_settings.sse_path == "/events" + assert server._deprecated_settings.message_path == "/messages" + assert server._deprecated_settings.streamable_http_path == "/stream" + assert server._deprecated_settings.json_response is True + assert server._deprecated_settings.stateless_http is True + + def test_stacklevel_points_to_constructor_call(self): + """Test that deprecation warnings point to the FastMCP constructor call.""" + with warnings.catch_warnings(record=True) as recorded_warnings: + warnings.simplefilter("always") + + def create_server_with_deprecated_kwargs(): + return FastMCP("TestServer", log_level="DEBUG") + + server = create_server_with_deprecated_kwargs() + + # Should have exactly one deprecation warning + deprecation_warnings = [ + w for w in recorded_warnings if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 1 + + # The warning should point to the server.py file where FastMCP.__init__ is called + # This verifies the stacklevel is working as intended (pointing to constructor) + warning = deprecation_warnings[0] + assert "server.py" in warning.filename diff --git a/tests/utilities/test_tests.py b/tests/utilities/test_tests.py index 104aaf9e6..6cbcc0dce 100644 --- a/tests/utilities/test_tests.py +++ b/tests/utilities/test_tests.py @@ -4,7 +4,7 @@ from fastmcp.utilities.tests import temporary_settings class TestTemporarySettings: def test_temporary_settings(self): - assert fastmcp.settings.settings.log_level == "DEBUG" + assert fastmcp.settings.log_level == "DEBUG" with temporary_settings(log_level="ERROR"): - assert fastmcp.settings.settings.log_level == "ERROR" - assert fastmcp.settings.settings.log_level == "DEBUG" + assert fastmcp.settings.log_level == "ERROR" + assert fastmcp.settings.log_level == "DEBUG"