From 5c751510cc6042b4eef89124dd159a8885f5f290 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:41:36 -0400 Subject: [PATCH 1/3] Optimize HTTP server startup imports --- fastmcp_slim/fastmcp/server/__init__.py | 28 ++- fastmcp_slim/fastmcp/server/context.py | 19 +- fastmcp_slim/fastmcp/server/event_store.py | 55 +----- fastmcp_slim/fastmcp/server/http.py | 2 +- .../fastmcp/server/mixins/transport.py | 2 +- .../local_provider/decorators/tools.py | 18 +- fastmcp_slim/fastmcp/server/server.py | 30 +++- .../server/session_scoped_event_store.py | 68 +++++++ fastmcp_slim/fastmcp/tools/base.py | 47 +++-- .../fastmcp/tools/function_parsing.py | 14 +- .../fastmcp/utilities/docstring_parsing.py | 6 +- fastmcp_slim/fastmcp/utilities/json_schema.py | 11 +- fastmcp_slim/fastmcp/utilities/prefab.py | 72 ++++++++ scripts/benchmark_http_startup.py | 169 ++++++++++++++++++ tests/server/http/test_startup_imports.py | 73 ++++++++ 15 files changed, 486 insertions(+), 128 deletions(-) create mode 100644 fastmcp_slim/fastmcp/server/session_scoped_event_store.py create mode 100644 fastmcp_slim/fastmcp/utilities/prefab.py create mode 100644 scripts/benchmark_http_startup.py create mode 100644 tests/server/http/test_startup_imports.py diff --git a/fastmcp_slim/fastmcp/server/__init__.py b/fastmcp_slim/fastmcp/server/__init__.py index d6edbc4f1..63c1f0351 100644 --- a/fastmcp_slim/fastmcp/server/__init__.py +++ b/fastmcp_slim/fastmcp/server/__init__.py @@ -1,17 +1,31 @@ import importlib +from typing import TYPE_CHECKING from fastmcp import _install_hints -try: - from .context import Context - from .server import FastMCP, create_proxy -except ImportError as exc: - raise ImportError(_install_hints.SERVER_SUPPORT) from exc +if TYPE_CHECKING: + from .context import Context as Context + from .server import FastMCP as FastMCP + from .server import create_proxy as create_proxy def __getattr__(name: str) -> object: - if name == "dependencies": - return importlib.import_module("fastmcp.server.dependencies") + if name in {"context", "dependencies"}: + return importlib.import_module(f"fastmcp.server.{name}") + if name == "Context": + try: + from .context import Context + except ImportError as exc: + raise ImportError(_install_hints.SERVER_SUPPORT) from exc + + return Context + if name in {"FastMCP", "create_proxy"}: + try: + from .server import FastMCP, create_proxy + except ImportError as exc: + raise ImportError(_install_hints.SERVER_SUPPORT) from exc + + return FastMCP if name == "FastMCP" else create_proxy raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py index bd089289a..3373e09da 100644 --- a/fastmcp_slim/fastmcp/server/context.py +++ b/fastmcp_slim/fastmcp/server/context.py @@ -10,7 +10,6 @@ from logging import Logger from typing import Any, Literal, cast, overload import mcp_types -from key_value.aio.errors import SerializationError from mcp import LoggingLevel, ServerSession from mcp.server.context import ServerRequestContext from mcp_types import ( @@ -1146,10 +1145,9 @@ class Context: value=StateValue(value=value), ttl=self._STATE_TTL_SECONDS, ) - except (ValueError, SerializationError) as e: + except ValueError as e: # Pydantic raises PydanticSerializationError (a ValueError) and the - # key_value library raises SerializationError; both carry "serialize" - # in the message. Other ValueErrors propagate unchanged. + # message carries "serialize". Other ValueErrors propagate unchanged. if "serialize" in str(e).lower(): raise TypeError( f"Value for state key {key!r} is not serializable. " @@ -1158,6 +1156,19 @@ class Context: f"request-scoped and will not persist across requests." ) from e raise + except Exception as e: + # Import the optional storage implementation only on its error path, + # rather than adding the key_value package to every server startup. + from key_value.aio.errors import SerializationError + + if not isinstance(e, SerializationError): + raise + raise TypeError( + f"Value for state key {key!r} is not serializable. " + f"Use set_state({key!r}, value, serializable=False) to store " + f"non-serializable values. Note: non-serializable state is " + f"request-scoped and will not persist across requests." + ) from e async def get_state(self, key: str) -> Any: """Get a value from the state store. diff --git a/fastmcp_slim/fastmcp/server/event_store.py b/fastmcp_slim/fastmcp/server/event_store.py index 86897aac7..bdc504865 100644 --- a/fastmcp_slim/fastmcp/server/event_store.py +++ b/fastmcp_slim/fastmcp/server/event_store.py @@ -18,6 +18,9 @@ from mcp.server.streamable_http import EventStore as SDKEventStore from mcp_types import JSONRPCMessage from pydantic import TypeAdapter +from fastmcp.server.session_scoped_event_store import ( + SessionScopedEventStore as SessionScopedEventStore, +) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel @@ -42,58 +45,6 @@ class StreamEventList(FastMCPBaseModel): event_ids: list[str] -class SessionScopedEventStore(SDKEventStore): - """EventStore adapter that isolates stream IDs to one transport session.""" - - def __init__(self, event_store: SDKEventStore, session_id: str): - self._event_store = event_store - self._stream_prefix = f"{len(session_id)}:{session_id}:" - - def _scope_stream_id(self, stream_id: StreamId) -> StreamId: - return f"{self._stream_prefix}{stream_id}" - - def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None: - if not stream_id.startswith(self._stream_prefix): - return None - return stream_id[len(self._stream_prefix) :] - - async def store_event( - self, stream_id: StreamId, message: JSONRPCMessage | None - ) -> EventId: - return await self._event_store.store_event( - self._scope_stream_id(stream_id), message - ) - - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - replayed_events: list[EventMessage] = [] - - async def buffer_event(event: EventMessage) -> None: - replayed_events.append(event) - - scoped_stream_id = await self._event_store.replay_events_after( - last_event_id, buffer_event - ) - if scoped_stream_id is None: - return None - - stream_id = self._unscope_stream_id(scoped_stream_id) - if stream_id is None: - logger.warning( - "Event ID %s does not belong to this session-scoped event store", - last_event_id, - ) - return None - - for event in replayed_events: - await send_callback(event) - - return stream_id - - class EventStore(SDKEventStore): """EventStore implementation backed by AsyncKeyValue. diff --git a/fastmcp_slim/fastmcp/server/http.py b/fastmcp_slim/fastmcp/server/http.py index f196627b7..7bd8fff99 100644 --- a/fastmcp_slim/fastmcp/server/http.py +++ b/fastmcp_slim/fastmcp/server/http.py @@ -27,7 +27,7 @@ from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send from fastmcp.server.auth import AuthProvider from fastmcp.server.auth.middleware import RequireAuthMiddleware -from fastmcp.server.event_store import SessionScopedEventStore +from fastmcp.server.session_scoped_event_store import SessionScopedEventStore from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: diff --git a/fastmcp_slim/fastmcp/server/mixins/transport.py b/fastmcp_slim/fastmcp/server/mixins/transport.py index ddc96db20..13bef1ced 100644 --- a/fastmcp_slim/fastmcp/server/mixins/transport.py +++ b/fastmcp_slim/fastmcp/server/mixins/transport.py @@ -11,13 +11,13 @@ import anyio import uvicorn from mcp.server.lowlevel.server import NotificationOptions from mcp.server.stdio import stdio_server +from mcp.server.streamable_http import EventStore from starlette.middleware import Middleware as ASGIMiddleware from starlette.requests import Request from starlette.responses import Response from starlette.routing import BaseRoute, Route import fastmcp -from fastmcp.server.event_store import EventStore from fastmcp.server.http import ( HostOriginProtection, StarletteWithLifespan, diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py index 77081165a..3f4b322a5 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py @@ -28,17 +28,10 @@ from mcp_types import ToolAnnotations from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool from fastmcp.utilities.authorization import AuthCheck +from fastmcp.utilities.prefab import is_prefab_type, prefab_available from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT -try: - from prefab_ui.app import PrefabApp as _PrefabApp - from prefab_ui.components.base import Component as _PrefabComponent - - _HAS_PREFAB = True -except ImportError: - _HAS_PREFAB = False - if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider @@ -51,7 +44,7 @@ PREFAB_RENDERER_URI = "ui://prefab/renderer.html" def _is_prefab_type(tp: Any) -> bool: """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" - if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)): + if is_prefab_type(tp): return True origin = get_origin(tp) if origin is Union or origin is types.UnionType or origin is Annotated: @@ -61,7 +54,7 @@ def _is_prefab_type(tp: Any) -> bool: def _has_prefab_return_type(tool: Tool) -> bool: """Check if a FunctionTool's return type annotation is a prefab type.""" - if not _HAS_PREFAB or not isinstance(tool, FunctionTool): + if not isinstance(tool, FunctionTool): return False rt = tool.return_type if rt is None or rt is inspect.Parameter.empty: @@ -94,13 +87,10 @@ def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: it. ``app=True``, return-type inference, and ``PrefabAppConfig`` all funnel through the same placeholder marker. """ - if not _HAS_PREFAB: - return - meta = tool.meta or {} ui = meta.get("ui") - if ui is True: + if ui is True and prefab_available(): # Explicit app=True: stamp the placeholder so the synthesizer finds it. _stamp_prefab_marker(tool) elif ui is None and _has_prefab_return_type(tool): diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index 6fb97bace..a25a78712 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -20,9 +20,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload import httpx2 import mcp_types -from key_value.aio.adapters.pydantic import PydanticAdapter -from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.memory import MemoryStore from mcp.server.lowlevel.server import LifespanResultT from mcp.server.request_state import RequestStateSecurity from mcp.shared.exceptions import MCPError @@ -101,6 +98,9 @@ from fastmcp.utilities.versions import ( ) if TYPE_CHECKING: + from key_value.aio.adapters.pydantic import PydanticAdapter + from key_value.aio.protocols import AsyncKeyValue + from fastmcp.client import Client from fastmcp.client.client import SDKServer from fastmcp.client.transports import ClientTransport, ClientTransportT @@ -333,12 +333,8 @@ class FastMCP( self._additional_http_routes: list[BaseRoute] = [] # Session-scoped state store (shared across all requests) - self._state_storage: AsyncKeyValue = session_state_store or MemoryStore() - self._state_store: PydanticAdapter[StateValue] = PydanticAdapter[StateValue]( - key_value=self._state_storage, - pydantic_model=StateValue, - default_collection="fastmcp_state", - ) + self._state_storage: AsyncKeyValue | None = session_state_store + self.__state_store: PydanticAdapter[StateValue] | None = None # Create LocalProvider for local components self._local_provider: LocalProvider = LocalProvider( @@ -496,6 +492,22 @@ class FastMCP( def __repr__(self) -> str: return f"{type(self).__name__}({self.name!r})" + @property + def _state_store(self) -> PydanticAdapter[StateValue]: + """Create the session-state adapter only when state is first used.""" + if self.__state_store is None: + from key_value.aio.adapters.pydantic import PydanticAdapter + from key_value.aio.stores.memory import MemoryStore + + if self._state_storage is None: + self._state_storage = MemoryStore() + self.__state_store = PydanticAdapter[StateValue]( + key_value=self._state_storage, + pydantic_model=StateValue, + default_collection="fastmcp_state", + ) + return self.__state_store + @property def name(self) -> str: return self._mcp_server.name diff --git a/fastmcp_slim/fastmcp/server/session_scoped_event_store.py b/fastmcp_slim/fastmcp/server/session_scoped_event_store.py new file mode 100644 index 000000000..9d0adada4 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/session_scoped_event_store.py @@ -0,0 +1,68 @@ +"""Lightweight session scoping for Streamable HTTP event stores.""" + +from __future__ import annotations + +from mcp.server.streamable_http import ( + EventCallback, + EventId, + EventMessage, + EventStore, + StreamId, +) +from mcp_types import JSONRPCMessage + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class SessionScopedEventStore(EventStore): + """EventStore adapter that isolates stream IDs to one transport session.""" + + def __init__(self, event_store: EventStore, session_id: str): + self._event_store = event_store + self._stream_prefix = f"{len(session_id)}:{session_id}:" + + def _scope_stream_id(self, stream_id: StreamId) -> StreamId: + return f"{self._stream_prefix}{stream_id}" + + def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None: + if not stream_id.startswith(self._stream_prefix): + return None + return stream_id[len(self._stream_prefix) :] + + async def store_event( + self, stream_id: StreamId, message: JSONRPCMessage | None + ) -> EventId: + return await self._event_store.store_event( + self._scope_stream_id(stream_id), message + ) + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + replayed_events: list[EventMessage] = [] + + async def buffer_event(event: EventMessage) -> None: + replayed_events.append(event) + + scoped_stream_id = await self._event_store.replay_events_after( + last_event_id, buffer_event + ) + if scoped_stream_id is None: + return None + + stream_id = self._unscope_stream_id(scoped_stream_id) + if stream_id is None: + logger.warning( + "Event ID %s does not belong to this session-scoped event store", + last_event_id, + ) + return None + + for event in replayed_events: + await send_callback(event) + + return stream_id diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index 5e02246dd..ccba1f365 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -26,6 +26,11 @@ from pydantic.json_schema import SkipJsonSchema from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.prefab import ( + is_prefab_app, + is_prefab_component, + prefab_app_from_component, +) from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import ( Audio, @@ -35,14 +40,6 @@ from fastmcp.utilities.types import ( NotSetT, ) -try: - from prefab_ui.app import PrefabApp as _PrefabApp - from prefab_ui.components.base import Component as _PrefabComponent - - _HAS_PREFAB = True -except ImportError: - _HAS_PREFAB = False - if TYPE_CHECKING: from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ArgTransform, TransformedTool @@ -128,13 +125,12 @@ class ToolResult(BaseModel): if structured_content is not None: # Convert Prefab types to their wire-format envelope before # generic serialization, so the renderer gets the right shape. - if _HAS_PREFAB: - if isinstance(structured_content, _PrefabApp): - structured_content = _prefab_to_json(structured_content) - elif isinstance(structured_content, _PrefabComponent): - structured_content = _prefab_to_json( - _PrefabApp(view=structured_content) - ) + if is_prefab_app(structured_content): + structured_content = _prefab_to_json(structured_content) + elif is_prefab_component(structured_content): + structured_content = _prefab_to_json( + prefab_app_from_component(structured_content) + ) try: structured_content = pydantic_core.to_jsonable_python( @@ -379,17 +375,16 @@ class Tool(FastMCPComponent): if isinstance(raw_value, CallToolResult): return ToolResult.from_mcp_result(raw_value) - if _HAS_PREFAB: - if isinstance(raw_value, _PrefabApp): - return _prefab_to_tool_result( - raw_value, - fastmcp_app_name=_get_fastmcp_app_name(self), - ) - if isinstance(raw_value, _PrefabComponent): - return _prefab_to_tool_result( - _PrefabApp(view=raw_value), - fastmcp_app_name=_get_fastmcp_app_name(self), - ) + if is_prefab_app(raw_value): + return _prefab_to_tool_result( + raw_value, + fastmcp_app_name=_get_fastmcp_app_name(self), + ) + if is_prefab_component(raw_value): + return _prefab_to_tool_result( + prefab_app_from_component(raw_value), + fastmcp_app_name=_get_fastmcp_app_name(self), + ) content = _convert_to_content(raw_value) diff --git a/fastmcp_slim/fastmcp/tools/function_parsing.py b/fastmcp_slim/fastmcp/tools/function_parsing.py index cc42550fc..5972e7d0d 100644 --- a/fastmcp_slim/fastmcp/tools/function_parsing.py +++ b/fastmcp_slim/fastmcp/tools/function_parsing.py @@ -18,6 +18,7 @@ from fastmcp.tools.base import ToolResult, resolve_serialize_by_alias from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.prefab import is_prefab_type from fastmcp.utilities.types import ( Audio, File, @@ -27,14 +28,6 @@ from fastmcp.utilities.types import ( replace_type, ) -try: - from prefab_ui.app import PrefabApp as _PrefabApp - from prefab_ui.components.base import Component as _PrefabComponent - - _PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent) -except ImportError: - _PREFAB_TYPES = () - def _contains_bytes_type(tp: Any) -> bool: """Check if *tp* is or contains bytes, recursing through unions and Annotated.""" @@ -48,7 +41,7 @@ def _contains_bytes_type(tp: Any) -> bool: def _contains_prefab_type(tp: Any) -> bool: """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" - if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES): + if is_prefab_type(tp): return True origin = get_origin(tp) if origin is Union or origin is types.UnionType or origin is Annotated: @@ -405,7 +398,7 @@ class ParsedFunction: # so we handle subclass matching explicitly here. We also need # to handle composite types like ``Column | None`` and # ``Annotated[PrefabApp, ...]`` by recursing into their args. - if _PREFAB_TYPES and _contains_prefab_type(output_type): + if _contains_prefab_type(output_type): output_type = _UnserializableType # ToolResult subclasses should suppress schema generation just @@ -450,7 +443,6 @@ class ParsedFunction: # A guard tool's suspend signal is control flow, not # output data (any residual bare arm is suppressed). mcp_types.InputRequiredResult, - *_PREFAB_TYPES, ), _UnserializableType, ), diff --git a/fastmcp_slim/fastmcp/utilities/docstring_parsing.py b/fastmcp_slim/fastmcp/utilities/docstring_parsing.py index babcb1e96..111f37657 100644 --- a/fastmcp_slim/fastmcp/utilities/docstring_parsing.py +++ b/fastmcp_slim/fastmcp/utilities/docstring_parsing.py @@ -14,8 +14,6 @@ from collections.abc import Callable from dataclasses import dataclass, field from typing import Any -from griffe import Docstring, DocstringSectionKind - _PARSERS = ("google", "numpy", "sphinx") logger = logging.getLogger("griffe") @@ -43,6 +41,10 @@ def parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring: if not doc: return ParsedDocstring() + # Griffe is only needed for functions that actually have docstrings. This + # keeps its parser and model graph out of ordinary server startup. + from griffe import Docstring, DocstringSectionKind + # Try each parser and use the first one that finds parameters. for parser in _PARSERS: docstring = Docstring(doc, lineno=1, parser=parser) diff --git a/fastmcp_slim/fastmcp/utilities/json_schema.py b/fastmcp_slim/fastmcp/utilities/json_schema.py index 59715e8ed..533a4a7bf 100644 --- a/fastmcp_slim/fastmcp/utilities/json_schema.py +++ b/fastmcp_slim/fastmcp/utilities/json_schema.py @@ -3,7 +3,12 @@ from __future__ import annotations from collections import defaultdict from typing import Any -from jsonref import JsonRefError, replace_refs + +def replace_refs(*args: Any, **kwargs: Any) -> Any: + """Call jsonref lazily while preserving the module's patchable boundary.""" + from jsonref import replace_refs as _replace_refs + + return _replace_refs(*args, **kwargs) def _copy_schema(schema: dict[str, Any]) -> dict[str, Any]: @@ -221,6 +226,10 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: if _defs_have_cycles(schema.get("$defs", {})): return resolve_root_ref(schema) + # Most schema operations do not dereference. Keep jsonref (and its requests + # dependency tree) out of server startup until a schema actually needs it. + from jsonref import JsonRefError + try: # Use jsonref to resolve all $ref references # proxies=False returns plain dicts (not proxy objects) diff --git a/fastmcp_slim/fastmcp/utilities/prefab.py b/fastmcp_slim/fastmcp/utilities/prefab.py new file mode 100644 index 000000000..5d382fffa --- /dev/null +++ b/fastmcp_slim/fastmcp/utilities/prefab.py @@ -0,0 +1,72 @@ +"""Lazy helpers for FastMCP's optional Prefab UI integration.""" + +from __future__ import annotations + +import sys +from functools import lru_cache +from importlib.util import find_spec +from typing import Any + + +@lru_cache(maxsize=1) +def prefab_available() -> bool: + """Return whether Prefab UI is installed without importing it.""" + return find_spec("prefab_ui") is not None + + +@lru_cache(maxsize=1) +def _get_prefab_types() -> tuple[type[Any], type[Any]] | None: + """Import and return Prefab's public app and component types on demand.""" + if not prefab_available(): + return None + + from prefab_ui.app import PrefabApp + from prefab_ui.components.base import Component + + return PrefabApp, Component + + +def _could_be_prefab(value_or_type: Any) -> bool: + """Cheaply reject ordinary values before importing Prefab UI.""" + candidate_type = ( + value_or_type if isinstance(value_or_type, type) else type(value_or_type) + ) + module = getattr(candidate_type, "__module__", "") + return "prefab_ui" in sys.modules or module == "prefab_ui" or module.startswith( + "prefab_ui." + ) + + +def is_prefab_type(candidate: Any) -> bool: + """Return whether a type is a Prefab app or component type.""" + if not isinstance(candidate, type) or not _could_be_prefab(candidate): + return False + + prefab_types = _get_prefab_types() + return prefab_types is not None and issubclass(candidate, prefab_types) + + +def is_prefab_app(value: Any) -> bool: + """Return whether a value is a Prefab app.""" + if not _could_be_prefab(value): + return False + + prefab_types = _get_prefab_types() + return prefab_types is not None and isinstance(value, prefab_types[0]) + + +def is_prefab_component(value: Any) -> bool: + """Return whether a value is a Prefab component.""" + if not _could_be_prefab(value): + return False + + prefab_types = _get_prefab_types() + return prefab_types is not None and isinstance(value, prefab_types[1]) + + +def prefab_app_from_component(component: Any) -> Any: + """Wrap a Prefab component in a Prefab app.""" + prefab_types = _get_prefab_types() + if prefab_types is None or not isinstance(component, prefab_types[1]): + raise TypeError("Expected a Prefab UI component") + return prefab_types[0](view=component) diff --git a/scripts/benchmark_http_startup.py b/scripts/benchmark_http_startup.py new file mode 100644 index 000000000..54162ec4f --- /dev/null +++ b/scripts/benchmark_http_startup.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +"""Benchmark FastMCP's HTTP server cold-start path in fresh interpreters. + +The benchmark separates the work users pay before an HTTP server can accept +requests: + +1. import the public ``FastMCP`` entry point; +2. construct a server and register representative tools; +3. build the Streamable HTTP ASGI application. + +Every sample runs in a fresh interpreter. Use ratios and the shape of the +results rather than treating single-machine absolute timings as universal. + +Usage: + uv run python scripts/benchmark_http_startup.py + uv run python scripts/benchmark_http_startup.py --runs 10 + uv run python scripts/benchmark_http_startup.py --json +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import subprocess +import sys +import textwrap +from collections.abc import Sequence +from typing import TypedDict + + +class Sample(TypedDict): + import_ms: float + server_ms: float + app_ms: float + total_ms: float + module_count: int + rss_mib: float + heavy_module_counts: dict[str, int] + + +_PROBE = textwrap.dedent( + """ + import json + import resource + import sys + import time + + started = time.perf_counter() + from fastmcp import FastMCP + imported = time.perf_counter() + + server = FastMCP("HTTP cold-start benchmark") + + def make_tool(index): + def tool(value: int = index) -> int: + return value + + tool.__name__ = f"tool_{index}" + return tool + + for index in range(10): + server.tool(make_tool(index)) + configured = time.perf_counter() + + app = server.http_app(transport="http", stateless_http=True) + assert app is not None + ready = time.perf_counter() + + heavy_roots = { + "authlib", + "cryptography", + "httpx2", + "key_value", + "mcp", + "mcp_types", + "opentelemetry", + "pydantic", + "rich", + "sse_starlette", + "starlette", + "uvicorn", + } + heavy_module_counts = { + root: sum( + module == root or module.startswith(f"{root}.") for module in sys.modules + ) + for root in sorted(heavy_roots) + } + heavy_module_counts = { + root: count for root, count in heavy_module_counts.items() if count + } + + print( + json.dumps( + { + "import_ms": (imported - started) * 1000, + "server_ms": (configured - imported) * 1000, + "app_ms": (ready - configured) * 1000, + "total_ms": (ready - started) * 1000, + "module_count": len(sys.modules), + "rss_mib": ( + resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + / (1024 * 1024) + if sys.platform == "darwin" + else resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 + ), + "heavy_module_counts": heavy_module_counts, + } + ) + ) + """ +) + + +def _sample() -> Sample: + result = subprocess.run( + [sys.executable, "-c", _PROBE], + capture_output=True, + text=True, + check=False, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _median(samples: Sequence[Sample], key: str) -> float: + return statistics.median(float(sample[key]) for sample in samples) # type: ignore[literal-required] + + +def _summarize(samples: list[Sample]) -> dict[str, object]: + return { + "runs": len(samples), + "import_ms": round(_median(samples, "import_ms"), 1), + "server_ms": round(_median(samples, "server_ms"), 1), + "app_ms": round(_median(samples, "app_ms"), 1), + "total_ms": round(_median(samples, "total_ms"), 1), + "module_count": round(_median(samples, "module_count")), + "rss_mib": round(_median(samples, "rss_mib"), 1), + "heavy_module_counts": samples[-1]["heavy_module_counts"], + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--runs", type=int, default=5) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + samples = [_sample() for _ in range(args.runs)] + summary = _summarize(samples) + if args.json: + print(json.dumps(summary, indent=2)) + return + + print(f"Python: {sys.version.split()[0]}") + print(f"Runs: {summary['runs']}") + print(f"Import FastMCP: {summary['import_ms']:.1f} ms") + print(f"Construct + 10 tools: {summary['server_ms']:.1f} ms") + print(f"Build HTTP app: {summary['app_ms']:.1f} ms") + print(f"Total to ASGI app: {summary['total_ms']:.1f} ms") + print(f"Modules: {summary['module_count']}") + print(f"Peak RSS: {summary['rss_mib']:.1f} MiB") + + +if __name__ == "__main__": + main() diff --git a/tests/server/http/test_startup_imports.py b/tests/server/http/test_startup_imports.py new file mode 100644 index 000000000..52af15fc1 --- /dev/null +++ b/tests/server/http/test_startup_imports.py @@ -0,0 +1,73 @@ +"""Fresh-interpreter import guards for the default HTTP server path.""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap + +import pytest + + +@pytest.mark.subprocess_heavy +def test_default_http_app_does_not_load_opt_in_integrations() -> None: + script = textwrap.dedent( + """ + import sys + + from fastmcp import FastMCP + + server = FastMCP("HTTP import guard") + + @server.tool + def echo(value: str) -> str: + return value + + app = server.http_app(transport="http", stateless_http=True) + assert app is not None + + forbidden = ( + "fastmcp.server.event_store", + "griffe", + "jsonref", + "key_value", + "prefab_ui", + ) + loaded = [ + name + for name in sys.modules + if any(name == root or name.startswith(f"{root}.") for root in forbidden) + ] + assert not loaded, loaded + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.subprocess_heavy +def test_fastmcp_server_import_does_not_load_context() -> None: + script = textwrap.dedent( + """ + import sys + + from fastmcp import FastMCP + + assert FastMCP is not None + assert "fastmcp.server.context" not in sys.modules + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr From 0342e6cccd2616dc05652cd915fd49f85c5b942e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:42:14 -0400 Subject: [PATCH 2/3] Format Prefab import guard --- fastmcp_slim/fastmcp/utilities/prefab.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fastmcp_slim/fastmcp/utilities/prefab.py b/fastmcp_slim/fastmcp/utilities/prefab.py index 5d382fffa..805a5fede 100644 --- a/fastmcp_slim/fastmcp/utilities/prefab.py +++ b/fastmcp_slim/fastmcp/utilities/prefab.py @@ -32,8 +32,10 @@ def _could_be_prefab(value_or_type: Any) -> bool: value_or_type if isinstance(value_or_type, type) else type(value_or_type) ) module = getattr(candidate_type, "__module__", "") - return "prefab_ui" in sys.modules or module == "prefab_ui" or module.startswith( - "prefab_ui." + return ( + "prefab_ui" in sys.modules + or module == "prefab_ui" + or module.startswith("prefab_ui.") ) From 2906c74b83111bbb896ed78c560da19772647b88 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:59:37 -0400 Subject: [PATCH 3/3] Lazy-load public package exports --- fastmcp_slim/fastmcp/client/__init__.py | 98 +++++++++--- fastmcp_slim/fastmcp/client/auth/__init__.py | 52 ++++++- .../fastmcp/client/transports/__init__.py | 95 +++++++++--- fastmcp_slim/fastmcp/prompts/__init__.py | 35 ++++- fastmcp_slim/fastmcp/resources/__init__.py | 52 +++++-- .../fastmcp/server/middleware/__init__.py | 34 ++++- .../fastmcp/server/providers/__init__.py | 66 +++++--- .../fastmcp/server/transforms/__init__.py | 57 ++++++- fastmcp_slim/fastmcp/tools/__init__.py | 36 ++++- .../fastmcp/utilities/lazy_imports.py | 31 ++++ tests/test_lazy_package_imports.py | 142 ++++++++++++++++++ 11 files changed, 607 insertions(+), 91 deletions(-) create mode 100644 fastmcp_slim/fastmcp/utilities/lazy_imports.py create mode 100644 tests/test_lazy_package_imports.py diff --git a/fastmcp_slim/fastmcp/client/__init__.py b/fastmcp_slim/fastmcp/client/__init__.py index 9fc2f350b..84500b822 100644 --- a/fastmcp_slim/fastmcp/client/__init__.py +++ b/fastmcp_slim/fastmcp/client/__init__.py @@ -1,27 +1,55 @@ -from fastmcp import _install_hints +from typing import TYPE_CHECKING -try: +from fastmcp import _install_hints +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, +) + +if TYPE_CHECKING: from .auth import ( - BearerAuth, - ClientCredentialsOAuthProvider, - OAuth, - PrivateKeyJWTOAuthProvider, + BearerAuth as BearerAuth, ) - from .client import Client + from .auth import ( + ClientCredentialsOAuthProvider as ClientCredentialsOAuthProvider, + ) + from .auth import ( + OAuth as OAuth, + ) + from .auth import ( + PrivateKeyJWTOAuthProvider as PrivateKeyJWTOAuthProvider, + ) + from .client import Client as Client from .transports import ( - ClientTransport, - FastMCPTransport, - NodeStdioTransport, - NpxStdioTransport, - PythonStdioTransport, - SSETransport, - StdioTransport, - StreamableHttpTransport, - UvStdioTransport, - UvxStdioTransport, + ClientTransport as ClientTransport, + ) + from .transports import ( + FastMCPTransport as FastMCPTransport, + ) + from .transports import ( + NodeStdioTransport as NodeStdioTransport, + ) + from .transports import ( + NpxStdioTransport as NpxStdioTransport, + ) + from .transports import ( + PythonStdioTransport as PythonStdioTransport, + ) + from .transports import ( + SSETransport as SSETransport, + ) + from .transports import ( + StdioTransport as StdioTransport, + ) + from .transports import ( + StreamableHttpTransport as StreamableHttpTransport, + ) + from .transports import ( + UvStdioTransport as UvStdioTransport, + ) + from .transports import ( + UvxStdioTransport as UvxStdioTransport, ) -except ImportError as exc: - raise ImportError(_install_hints.CLIENT_SUPPORT) from exc __all__ = [ "BearerAuth", @@ -40,3 +68,35 @@ __all__ = [ "UvStdioTransport", "UvxStdioTransport", ] + +_LAZY_IMPORTS = { + "BearerAuth": (".auth", "BearerAuth"), + "Client": (".client", "Client"), + "ClientCredentialsOAuthProvider": ( + ".auth", + "ClientCredentialsOAuthProvider", + ), + "ClientTransport": (".transports", "ClientTransport"), + "FastMCPTransport": (".transports", "FastMCPTransport"), + "NodeStdioTransport": (".transports", "NodeStdioTransport"), + "NpxStdioTransport": (".transports", "NpxStdioTransport"), + "OAuth": (".auth", "OAuth"), + "PrivateKeyJWTOAuthProvider": (".auth", "PrivateKeyJWTOAuthProvider"), + "PythonStdioTransport": (".transports", "PythonStdioTransport"), + "SSETransport": (".transports", "SSETransport"), + "StdioTransport": (".transports", "StdioTransport"), + "StreamableHttpTransport": (".transports", "StreamableHttpTransport"), + "UvStdioTransport": (".transports", "UvStdioTransport"), + "UvxStdioTransport": (".transports", "UvxStdioTransport"), +} + + +def __getattr__(name: str) -> object: + try: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + except ImportError as exc: + raise ImportError(_install_hints.CLIENT_SUPPORT) from exc + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/client/auth/__init__.py b/fastmcp_slim/fastmcp/client/auth/__init__.py index e706c7f18..decda464b 100644 --- a/fastmcp_slim/fastmcp/client/auth/__init__.py +++ b/fastmcp_slim/fastmcp/client/auth/__init__.py @@ -1,11 +1,23 @@ -from .bearer import BearerAuth -from .client_credentials import ( - ClientCredentialsOAuthProvider, - PrivateKeyJWTOAuthProvider, - SignedJWTParameters, - static_assertion_provider, +from typing import TYPE_CHECKING + +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, ) -from .oauth import OAuth + +if TYPE_CHECKING: + from .bearer import BearerAuth as BearerAuth + from .client_credentials import ( + ClientCredentialsOAuthProvider as ClientCredentialsOAuthProvider, + ) + from .client_credentials import ( + PrivateKeyJWTOAuthProvider as PrivateKeyJWTOAuthProvider, + ) + from .client_credentials import SignedJWTParameters as SignedJWTParameters + from .client_credentials import ( + static_assertion_provider as static_assertion_provider, + ) + from .oauth import OAuth as OAuth __all__ = [ "BearerAuth", @@ -15,3 +27,29 @@ __all__ = [ "SignedJWTParameters", "static_assertion_provider", ] + +_LAZY_IMPORTS = { + "BearerAuth": (".bearer", "BearerAuth"), + "ClientCredentialsOAuthProvider": ( + ".client_credentials", + "ClientCredentialsOAuthProvider", + ), + "OAuth": (".oauth", "OAuth"), + "PrivateKeyJWTOAuthProvider": ( + ".client_credentials", + "PrivateKeyJWTOAuthProvider", + ), + "SignedJWTParameters": (".client_credentials", "SignedJWTParameters"), + "static_assertion_provider": ( + ".client_credentials", + "static_assertion_provider", + ), +} + + +def __getattr__(name: str) -> object: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/client/transports/__init__.py b/fastmcp_slim/fastmcp/client/transports/__init__.py index 287697b5c..89881d338 100644 --- a/fastmcp_slim/fastmcp/client/transports/__init__.py +++ b/fastmcp_slim/fastmcp/client/transports/__init__.py @@ -1,25 +1,37 @@ -from mcp.server.mcpserver import MCPServer as SDKServer +from typing import TYPE_CHECKING -from fastmcp.client.transports.base import ( - ClientTransport, - ClientTransportT, - SessionKwargs, -) -from fastmcp.client.transports.config import MCPConfigTransport -from fastmcp.client.transports.http import StreamableHttpTransport -from fastmcp.client.transports.inference import infer_transport -from fastmcp.client.transports.sse import SSETransport -from fastmcp.client.transports.memory import FastMCPTransport -from fastmcp.client.transports.stdio import ( - FastMCPStdioTransport, - NodeStdioTransport, - NpxStdioTransport, - PythonStdioTransport, - StdioTransport, - UvStdioTransport, - UvxStdioTransport, +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, ) +if TYPE_CHECKING: + from mcp.server.mcpserver import MCPServer as SDKServer + + from fastmcp.client.transports.base import ClientTransport as ClientTransport + from fastmcp.client.transports.base import ClientTransportT as ClientTransportT + from fastmcp.client.transports.base import SessionKwargs as SessionKwargs + from fastmcp.client.transports.config import ( + MCPConfigTransport as MCPConfigTransport, + ) + from fastmcp.client.transports.http import ( + StreamableHttpTransport as StreamableHttpTransport, + ) + from fastmcp.client.transports.inference import infer_transport as infer_transport + from fastmcp.client.transports.memory import FastMCPTransport as FastMCPTransport + from fastmcp.client.transports.sse import SSETransport as SSETransport + from fastmcp.client.transports.stdio import ( + FastMCPStdioTransport as FastMCPStdioTransport, + ) + from fastmcp.client.transports.stdio import NodeStdioTransport as NodeStdioTransport + from fastmcp.client.transports.stdio import NpxStdioTransport as NpxStdioTransport + from fastmcp.client.transports.stdio import ( + PythonStdioTransport as PythonStdioTransport, + ) + from fastmcp.client.transports.stdio import StdioTransport as StdioTransport + from fastmcp.client.transports.stdio import UvStdioTransport as UvStdioTransport + from fastmcp.client.transports.stdio import UvxStdioTransport as UvxStdioTransport + __all__ = [ "ClientTransport", "FastMCPStdioTransport", @@ -34,3 +46,48 @@ __all__ = [ "UvxStdioTransport", "infer_transport", ] + +_LAZY_IMPORTS = { + "ClientTransport": ("fastmcp.client.transports.base", "ClientTransport"), + "ClientTransportT": ("fastmcp.client.transports.base", "ClientTransportT"), + "FastMCPStdioTransport": ( + "fastmcp.client.transports.stdio", + "FastMCPStdioTransport", + ), + "FastMCPTransport": ("fastmcp.client.transports.memory", "FastMCPTransport"), + "MCPConfigTransport": ( + "fastmcp.client.transports.config", + "MCPConfigTransport", + ), + "NodeStdioTransport": ( + "fastmcp.client.transports.stdio", + "NodeStdioTransport", + ), + "NpxStdioTransport": ( + "fastmcp.client.transports.stdio", + "NpxStdioTransport", + ), + "PythonStdioTransport": ( + "fastmcp.client.transports.stdio", + "PythonStdioTransport", + ), + "SDKServer": ("mcp.server.mcpserver", "MCPServer"), + "SSETransport": ("fastmcp.client.transports.sse", "SSETransport"), + "SessionKwargs": ("fastmcp.client.transports.base", "SessionKwargs"), + "StdioTransport": ("fastmcp.client.transports.stdio", "StdioTransport"), + "StreamableHttpTransport": ( + "fastmcp.client.transports.http", + "StreamableHttpTransport", + ), + "UvStdioTransport": ("fastmcp.client.transports.stdio", "UvStdioTransport"), + "UvxStdioTransport": ("fastmcp.client.transports.stdio", "UvxStdioTransport"), + "infer_transport": ("fastmcp.client.transports.inference", "infer_transport"), +} + + +def __getattr__(name: str) -> object: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/prompts/__init__.py b/fastmcp_slim/fastmcp/prompts/__init__.py index b94b5952d..091f68dd5 100644 --- a/fastmcp_slim/fastmcp/prompts/__init__.py +++ b/fastmcp_slim/fastmcp/prompts/__init__.py @@ -1,5 +1,18 @@ -from .function_prompt import FunctionPrompt, prompt -from .base import Message, Prompt, PromptArgument, PromptMessage, PromptResult +from typing import TYPE_CHECKING + +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, +) + +if TYPE_CHECKING: + from .base import Message as Message + from .base import Prompt as Prompt + from .base import PromptArgument as PromptArgument + from .base import PromptMessage as PromptMessage + from .base import PromptResult as PromptResult + from .function_prompt import FunctionPrompt as FunctionPrompt + from .function_prompt import prompt as prompt __all__ = [ "FunctionPrompt", @@ -10,3 +23,21 @@ __all__ = [ "PromptResult", "prompt", ] + +_LAZY_IMPORTS = { + "FunctionPrompt": (".function_prompt", "FunctionPrompt"), + "Message": (".base", "Message"), + "Prompt": (".base", "Prompt"), + "PromptArgument": (".base", "PromptArgument"), + "PromptMessage": (".base", "PromptMessage"), + "PromptResult": (".base", "PromptResult"), + "prompt": (".function_prompt", "prompt"), +} + + +def __getattr__(name: str) -> object: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/resources/__init__.py b/fastmcp_slim/fastmcp/resources/__init__.py index b0e5b4524..f31e45693 100644 --- a/fastmcp_slim/fastmcp/resources/__init__.py +++ b/fastmcp_slim/fastmcp/resources/__init__.py @@ -1,15 +1,24 @@ -from .function_resource import FunctionResource, resource -from .base import Resource, ResourceContent, ResourceResult -from .security import ResourceSecurity -from .template import ResourceTemplate -from .types import ( - BinaryResource, - DirectoryResource, - FileResource, - HttpResource, - TextResource, +from typing import TYPE_CHECKING + +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, ) +if TYPE_CHECKING: + from .base import Resource as Resource + from .base import ResourceContent as ResourceContent + from .base import ResourceResult as ResourceResult + from .function_resource import FunctionResource as FunctionResource + from .function_resource import resource as resource + from .security import ResourceSecurity as ResourceSecurity + from .template import ResourceTemplate as ResourceTemplate + from .types import BinaryResource as BinaryResource + from .types import DirectoryResource as DirectoryResource + from .types import FileResource as FileResource + from .types import HttpResource as HttpResource + from .types import TextResource as TextResource + __all__ = [ "BinaryResource", "DirectoryResource", @@ -24,3 +33,26 @@ __all__ = [ "TextResource", "resource", ] + +_LAZY_IMPORTS = { + "BinaryResource": (".types", "BinaryResource"), + "DirectoryResource": (".types", "DirectoryResource"), + "FileResource": (".types", "FileResource"), + "FunctionResource": (".function_resource", "FunctionResource"), + "HttpResource": (".types", "HttpResource"), + "Resource": (".base", "Resource"), + "ResourceContent": (".base", "ResourceContent"), + "ResourceResult": (".base", "ResourceResult"), + "ResourceSecurity": (".security", "ResourceSecurity"), + "ResourceTemplate": (".template", "ResourceTemplate"), + "TextResource": (".types", "TextResource"), + "resource": (".function_resource", "resource"), +} + + +def __getattr__(name: str) -> object: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/server/middleware/__init__.py b/fastmcp_slim/fastmcp/server/middleware/__init__.py index 8df6962bd..292cb7816 100644 --- a/fastmcp_slim/fastmcp/server/middleware/__init__.py +++ b/fastmcp_slim/fastmcp/server/middleware/__init__.py @@ -1,10 +1,16 @@ -from .authorization import AuthMiddleware -from .middleware import ( - CallNext, - Middleware, - MiddlewareContext, +from typing import TYPE_CHECKING + +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, ) -from .ping import PingMiddleware + +if TYPE_CHECKING: + from .authorization import AuthMiddleware as AuthMiddleware + from .middleware import CallNext as CallNext + from .middleware import Middleware as Middleware + from .middleware import MiddlewareContext as MiddlewareContext + from .ping import PingMiddleware as PingMiddleware __all__ = [ "AuthMiddleware", @@ -13,3 +19,19 @@ __all__ = [ "MiddlewareContext", "PingMiddleware", ] + +_LAZY_IMPORTS = { + "AuthMiddleware": (".authorization", "AuthMiddleware"), + "CallNext": (".middleware", "CallNext"), + "Middleware": (".middleware", "Middleware"), + "MiddlewareContext": (".middleware", "MiddlewareContext"), + "PingMiddleware": (".ping", "PingMiddleware"), +} + + +def __getattr__(name: str) -> object: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/server/providers/__init__.py b/fastmcp_slim/fastmcp/server/providers/__init__.py index f138404f2..aecdab1fa 100644 --- a/fastmcp_slim/fastmcp/server/providers/__init__.py +++ b/fastmcp_slim/fastmcp/server/providers/__init__.py @@ -27,20 +27,32 @@ Example: from typing import TYPE_CHECKING -from fastmcp.server.providers.aggregate import AggregateProvider -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.fastmcp_provider import FastMCPProvider -from fastmcp.server.providers.filesystem import FileSystemProvider -from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.server.providers.skills import ( - ClaudeSkillsProvider, - SkillProvider, - SkillsDirectoryProvider, +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, ) if TYPE_CHECKING: + from fastmcp.server.providers.aggregate import ( + AggregateProvider as AggregateProvider, + ) + from fastmcp.server.providers.base import Provider as Provider + from fastmcp.server.providers.fastmcp_provider import ( + FastMCPProvider as FastMCPProvider, + ) + from fastmcp.server.providers.filesystem import ( + FileSystemProvider as FileSystemProvider, + ) + from fastmcp.server.providers.local_provider import LocalProvider as LocalProvider from fastmcp.server.providers.openapi import OpenAPIProvider as OpenAPIProvider from fastmcp.server.providers.proxy import ProxyProvider as ProxyProvider + from fastmcp.server.providers.skills import ( + ClaudeSkillsProvider as ClaudeSkillsProvider, + ) + from fastmcp.server.providers.skills import SkillProvider as SkillProvider + from fastmcp.server.providers.skills import ( + SkillsDirectoryProvider as SkillsDirectoryProvider, + ) __all__ = [ "AggregateProvider", @@ -55,15 +67,35 @@ __all__ = [ "SkillsDirectoryProvider", ] +_LAZY_IMPORTS = { + "AggregateProvider": ("fastmcp.server.providers.aggregate", "AggregateProvider"), + "ClaudeSkillsProvider": ( + "fastmcp.server.providers.skills", + "ClaudeSkillsProvider", + ), + "FastMCPProvider": ( + "fastmcp.server.providers.fastmcp_provider", + "FastMCPProvider", + ), + "FileSystemProvider": ( + "fastmcp.server.providers.filesystem", + "FileSystemProvider", + ), + "LocalProvider": ("fastmcp.server.providers.local_provider", "LocalProvider"), + "OpenAPIProvider": ("fastmcp.server.providers.openapi", "OpenAPIProvider"), + "Provider": ("fastmcp.server.providers.base", "Provider"), + "ProxyProvider": ("fastmcp.server.providers.proxy", "ProxyProvider"), + "SkillProvider": ("fastmcp.server.providers.skills", "SkillProvider"), + "SkillsDirectoryProvider": ( + "fastmcp.server.providers.skills", + "SkillsDirectoryProvider", + ), +} + def __getattr__(name: str) -> object: - """Lazy import for providers to avoid circular imports.""" - if name == "ProxyProvider": - from fastmcp.server.providers.proxy import ProxyProvider + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) - return ProxyProvider - if name == "OpenAPIProvider": - from fastmcp.server.providers.openapi import OpenAPIProvider - return OpenAPIProvider - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/server/transforms/__init__.py b/fastmcp_slim/fastmcp/server/transforms/__init__.py index 411a2e0f8..82a6dddb8 100644 --- a/fastmcp_slim/fastmcp/server/transforms/__init__.py +++ b/fastmcp_slim/fastmcp/server/transforms/__init__.py @@ -23,12 +23,31 @@ from __future__ import annotations from collections.abc import Awaitable, Sequence from typing import TYPE_CHECKING, Protocol +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, +) from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate + from fastmcp.server.transforms.namespace import Namespace as Namespace + from fastmcp.server.transforms.prompts_as_tools import ( + PromptsAsTools as PromptsAsTools, + ) + from fastmcp.server.transforms.resources_as_tools import ( + ResourcesAsTools as ResourcesAsTools, + ) + from fastmcp.server.transforms.tool_transform import ( + ToolTransform as ToolTransform, + ) + from fastmcp.server.transforms.version_filter import ( + VersionFilter as VersionFilter, + ) + from fastmcp.server.transforms.visibility import Visibility as Visibility + from fastmcp.server.transforms.visibility import is_enabled as is_enabled from fastmcp.tools.base import Tool @@ -219,14 +238,6 @@ class Transform: return await call_next(name, version=version) -# Re-export built-in transforms (must be after Transform class to avoid circular imports) -from fastmcp.server.transforms.visibility import Visibility, is_enabled # noqa: E402 -from fastmcp.server.transforms.namespace import Namespace # noqa: E402 -from fastmcp.server.transforms.prompts_as_tools import PromptsAsTools # noqa: E402 -from fastmcp.server.transforms.resources_as_tools import ResourcesAsTools # noqa: E402 -from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 -from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 - __all__ = [ "Namespace", "PromptsAsTools", @@ -238,3 +249,33 @@ __all__ = [ "Visibility", "is_enabled", ] + +_LAZY_IMPORTS = { + "Namespace": ("fastmcp.server.transforms.namespace", "Namespace"), + "PromptsAsTools": ( + "fastmcp.server.transforms.prompts_as_tools", + "PromptsAsTools", + ), + "ResourcesAsTools": ( + "fastmcp.server.transforms.resources_as_tools", + "ResourcesAsTools", + ), + "ToolTransform": ( + "fastmcp.server.transforms.tool_transform", + "ToolTransform", + ), + "VersionFilter": ( + "fastmcp.server.transforms.version_filter", + "VersionFilter", + ), + "Visibility": ("fastmcp.server.transforms.visibility", "Visibility"), + "is_enabled": ("fastmcp.server.transforms.visibility", "is_enabled"), +} + + +def __getattr__(name: str) -> object: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/tools/__init__.py b/fastmcp_slim/fastmcp/tools/__init__.py index d3f7303fc..956446d0f 100644 --- a/fastmcp_slim/fastmcp/tools/__init__.py +++ b/fastmcp_slim/fastmcp/tools/__init__.py @@ -1,6 +1,18 @@ -from .function_tool import FunctionTool, tool -from .base import InputRequiredToolResult, Tool, ToolResult -from .tool_transform import forward, forward_raw +from typing import TYPE_CHECKING + +from fastmcp.utilities.lazy_imports import ( + list_module_attributes, + resolve_lazy_import, +) + +if TYPE_CHECKING: + from .base import InputRequiredToolResult as InputRequiredToolResult + from .base import Tool as Tool + from .base import ToolResult as ToolResult + from .function_tool import FunctionTool as FunctionTool + from .function_tool import tool as tool + from .tool_transform import forward as forward + from .tool_transform import forward_raw as forward_raw __all__ = [ "FunctionTool", @@ -11,3 +23,21 @@ __all__ = [ "forward_raw", "tool", ] + +_LAZY_IMPORTS = { + "FunctionTool": (".function_tool", "FunctionTool"), + "InputRequiredToolResult": (".base", "InputRequiredToolResult"), + "Tool": (".base", "Tool"), + "ToolResult": (".base", "ToolResult"), + "forward": (".tool_transform", "forward"), + "forward_raw": (".tool_transform", "forward_raw"), + "tool": (".function_tool", "tool"), +} + + +def __getattr__(name: str) -> object: + return resolve_lazy_import(name, __name__, globals(), _LAZY_IMPORTS) + + +def __dir__() -> list[str]: + return list_module_attributes(globals(), _LAZY_IMPORTS) diff --git a/fastmcp_slim/fastmcp/utilities/lazy_imports.py b/fastmcp_slim/fastmcp/utilities/lazy_imports.py new file mode 100644 index 000000000..3e7c23734 --- /dev/null +++ b/fastmcp_slim/fastmcp/utilities/lazy_imports.py @@ -0,0 +1,31 @@ +"""Helpers for exposing public names without eagerly importing their modules.""" + +from collections.abc import Mapping +from importlib import import_module +from typing import Any + +LazyImports = Mapping[str, tuple[str, str]] + + +def resolve_lazy_import( + name: str, + package: str, + namespace: dict[str, Any], + lazy_imports: LazyImports, +) -> object: + """Resolve and cache a lazily exported module attribute.""" + try: + module_name, attr_name = lazy_imports[name] + except KeyError: + raise AttributeError(f"module {package!r} has no attribute {name!r}") from None + + value = getattr(import_module(module_name, package), attr_name) + namespace[name] = value + return value + + +def list_module_attributes( + namespace: dict[str, Any], lazy_imports: LazyImports +) -> list[str]: + """Include unresolved lazy exports in module introspection.""" + return sorted(namespace.keys() | lazy_imports.keys()) diff --git a/tests/test_lazy_package_imports.py b/tests/test_lazy_package_imports.py new file mode 100644 index 000000000..645f72ac1 --- /dev/null +++ b/tests/test_lazy_package_imports.py @@ -0,0 +1,142 @@ +"""Fresh-interpreter guards for lazy public package exports.""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap + +import pytest + + +@pytest.mark.parametrize( + ("statement", "excluded_modules"), + [ + ( + "from fastmcp.client import BearerAuth", + ( + "fastmcp.client.auth.client_credentials", + "fastmcp.client.auth.oauth", + "fastmcp.client.client", + "fastmcp.client.transports", + ), + ), + ( + "from fastmcp.client.transports import ClientTransport", + ( + "fastmcp.client.transports.config", + "fastmcp.client.transports.http", + "fastmcp.client.transports.inference", + "fastmcp.client.transports.memory", + "fastmcp.client.transports.sse", + "fastmcp.client.transports.stdio", + ), + ), + ( + "from fastmcp.tools import Tool", + ("fastmcp.tools.function_tool", "fastmcp.tools.tool_transform"), + ), + ( + "from fastmcp.resources import Resource", + ( + "fastmcp.resources.function_resource", + "fastmcp.resources.security", + "fastmcp.resources.template", + "fastmcp.resources.types", + ), + ), + ( + "from fastmcp.prompts import Prompt", + ("fastmcp.prompts.function_prompt",), + ), + ( + "from fastmcp.server.providers import Provider", + ( + "fastmcp.server.providers.aggregate", + "fastmcp.server.providers.fastmcp_provider", + "fastmcp.server.providers.filesystem", + "fastmcp.server.providers.local_provider", + "fastmcp.server.providers.skills", + ), + ), + ( + "from fastmcp.server.middleware import Middleware", + ( + "fastmcp.server.middleware.authorization", + "fastmcp.server.middleware.ping", + ), + ), + ( + "from fastmcp.server.transforms import Transform", + ( + "fastmcp.server.transforms.namespace", + "fastmcp.server.transforms.prompts_as_tools", + "fastmcp.server.transforms.resources_as_tools", + "fastmcp.server.transforms.tool_transform", + "fastmcp.server.transforms.version_filter", + "fastmcp.server.transforms.visibility", + ), + ), + ], +) +@pytest.mark.subprocess_heavy +def test_narrow_import_does_not_load_sibling_implementations( + statement: str, excluded_modules: tuple[str, ...] +) -> None: + script = textwrap.dedent( + f""" + import sys + + {statement} + + excluded = {excluded_modules!r} + loaded = [ + name + for name in sys.modules + if any(name == root or name.startswith(f"{{root}}.") for root in excluded) + ] + assert not loaded, loaded + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.subprocess_heavy +def test_all_public_package_exports_resolve() -> None: + script = textwrap.dedent( + """ + import importlib + + packages = ( + "fastmcp.client", + "fastmcp.client.auth", + "fastmcp.client.transports", + "fastmcp.prompts", + "fastmcp.resources", + "fastmcp.server.middleware", + "fastmcp.server.providers", + "fastmcp.server.transforms", + "fastmcp.tools", + ) + for package_name in packages: + package = importlib.import_module(package_name) + assert set(package.__all__) <= set(dir(package)) + for export in package.__all__: + assert getattr(package, export) is not None, (package_name, export) + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr