Improve HTTP server startup performance (#4729)

This commit is contained in:
Jeremiah Lowin 2026-08-02 14:11:26 -04:00 committed by GitHub
commit 34bdd480c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 488 additions and 128 deletions

View file

@ -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}")

View file

@ -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.

View file

@ -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.

View file

@ -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:

View file

@ -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,

View file

@ -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):

View file

@ -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

View 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

View file

@ -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)

View file

@ -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,
),

View file

@ -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)

View file

@ -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)

View 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)

View 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()

View 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