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..805a5fede --- /dev/null +++ b/fastmcp_slim/fastmcp/utilities/prefab.py @@ -0,0 +1,74 @@ +"""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