mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Improve HTTP server startup performance (#4729)
This commit is contained in:
parent
c428a08fea
commit
34bdd480c9
15 changed files with 488 additions and 128 deletions
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
fastmcp_slim/fastmcp/server/session_scoped_event_store.py
Normal file
68
fastmcp_slim/fastmcp/server/session_scoped_event_store.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
74
fastmcp_slim/fastmcp/utilities/prefab.py
Normal file
74
fastmcp_slim/fastmcp/utilities/prefab.py
Normal file
|
|
@ -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)
|
||||
169
scripts/benchmark_http_startup.py
Normal file
169
scripts/benchmark_http_startup.py
Normal file
|
|
@ -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()
|
||||
73
tests/server/http/test_startup_imports.py
Normal file
73
tests/server/http/test_startup_imports.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue