Lazy-load heavy imports to reduce import time

Defer auth providers (JWTVerifier, OAuthProxy, OIDCProxy) and Client
to avoid eagerly importing authlib, cryptography, key_value.aio, and
beartype on every `from fastmcp import FastMCP`.
This commit is contained in:
Jeremiah Lowin 2026-02-25 12:44:24 -05:00
commit d9541c9c21
5 changed files with 275 additions and 9 deletions

View file

@ -0,0 +1,212 @@
#!/usr/bin/env python
"""Benchmark import times for fastmcp and its dependency chain.
Each measurement runs in a fresh subprocess so there's no shared module cache.
Incremental costs are measured by pre-importing dependencies, so we can see
what each module truly adds.
Usage:
uv run python scripts/benchmark_imports.py
uv run python scripts/benchmark_imports.py --runs 10
uv run python scripts/benchmark_imports.py --json
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
@dataclass
class BenchmarkCase:
label: str
stmt: str
prereqs: str = ""
group: str = ""
CASES = [
# --- Floor ---
BenchmarkCase("pydantic", "import pydantic", group="floor"),
BenchmarkCase("mcp", "import mcp", group="floor"),
BenchmarkCase(
"mcp (server only)", "import mcp.server.lowlevel.server", group="floor"
),
# --- Auth stack (incremental over mcp) ---
BenchmarkCase(
"authlib.jose", "import authlib.jose", prereqs="import mcp", group="auth"
),
BenchmarkCase(
"cryptography.fernet",
"from cryptography.fernet import Fernet",
prereqs="import mcp",
group="auth",
),
BenchmarkCase(
"authlib.integrations.httpx_client",
"from authlib.integrations.httpx_client import AsyncOAuth2Client",
prereqs="import mcp",
group="auth",
),
BenchmarkCase(
"key_value.aio", "import key_value.aio", prereqs="import mcp", group="auth"
),
BenchmarkCase(
"key_value.aio.stores.filetree",
"from key_value.aio.stores.filetree import FileTreeStore",
prereqs="import mcp",
group="auth",
),
BenchmarkCase("beartype", "import beartype", prereqs="import mcp", group="auth"),
# --- Docket stack (incremental over mcp) ---
BenchmarkCase("redis", "import redis", prereqs="import mcp", group="docket"),
BenchmarkCase(
"opentelemetry.sdk.metrics",
"import opentelemetry.sdk.metrics",
prereqs="import mcp",
group="docket",
),
BenchmarkCase("docket", "import docket", prereqs="import mcp", group="docket"),
BenchmarkCase("croniter", "import croniter", prereqs="import mcp", group="docket"),
# --- Other deps (incremental over mcp) ---
BenchmarkCase("httpx", "import httpx", prereqs="import mcp", group="other"),
BenchmarkCase(
"starlette",
"from starlette.applications import Starlette",
prereqs="import mcp",
group="other",
),
BenchmarkCase(
"pydantic_settings",
"import pydantic_settings",
prereqs="import mcp",
group="other",
),
BenchmarkCase(
"rich.console", "import rich.console", prereqs="import mcp", group="other"
),
BenchmarkCase("jsonref", "import jsonref", prereqs="import mcp", group="other"),
BenchmarkCase("requests", "import requests", prereqs="import mcp", group="other"),
# --- FastMCP (total and incremental) ---
BenchmarkCase("fastmcp (total)", "from fastmcp import FastMCP", group="fastmcp"),
BenchmarkCase(
"fastmcp (over mcp)",
"from fastmcp import FastMCP",
prereqs="import mcp",
group="fastmcp",
),
BenchmarkCase(
"fastmcp (over mcp+docket)",
"from fastmcp import FastMCP",
prereqs="import mcp; import docket",
group="fastmcp",
),
BenchmarkCase(
"fastmcp (over mcp+docket+auth deps)",
"from fastmcp import FastMCP",
prereqs=(
"import mcp; import docket; import authlib.jose;"
" from cryptography.fernet import Fernet;"
" import key_value.aio"
),
group="fastmcp",
),
]
def measure_once(stmt: str, prereqs: str) -> float | None:
pre = prereqs + "; " if prereqs else ""
code = (
f"{pre}"
"import time as _t; _s=_t.perf_counter(); "
f"{stmt}; "
"print(f'{(_t.perf_counter()-_s)*1000:.2f}')"
)
r = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
timeout=30,
)
if r.returncode == 0 and r.stdout.strip():
return float(r.stdout.strip())
return None
def measure(case: BenchmarkCase, runs: int) -> dict[str, float | str | None]:
times: list[float] = []
for _ in range(runs):
t = measure_once(case.stmt, case.prereqs)
if t is not None:
times.append(t)
if not times:
return {"label": case.label, "group": case.group, "median_ms": None}
times.sort()
median = times[len(times) // 2]
return {
"label": case.label,
"group": case.group,
"median_ms": round(median, 1),
"min_ms": round(times[0], 1),
"max_ms": round(times[-1], 1),
"runs": len(times),
}
def print_table(results: list[dict[str, float | str | None]]) -> None:
current_group = None
print(f"\n{'Module':<45} {'Median':>8} {'Min':>8} {'Max':>8}")
print("-" * 71)
for r in results:
if r["group"] != current_group:
current_group = r["group"]
group_labels = {
"floor": "--- Unavoidable floor ---",
"auth": "--- Auth stack (incremental over mcp) ---",
"docket": "--- Docket stack (incremental over mcp) ---",
"other": "--- Other deps (incremental over mcp) ---",
"fastmcp": "--- FastMCP totals ---",
}
print(f"\n{group_labels.get(current_group, current_group)}")
if r["median_ms"] is not None:
print(
f" {r['label']:<43} {r['median_ms']:>7.1f}ms"
f" {r['min_ms']:>7.1f}ms {r['max_ms']:>7.1f}ms"
)
else:
print(f" {r['label']:<43} error")
def main() -> None:
parser = argparse.ArgumentParser(description="Benchmark fastmcp import times")
parser.add_argument(
"--runs", type=int, default=5, help="Number of runs per measurement (default 5)"
)
parser.add_argument("--json", action="store_true", help="Output results as JSON")
args = parser.parse_args()
print(f"Benchmarking import times ({args.runs} runs each)...")
print(f"Python: {sys.version.split()[0]}")
print(f"Executable: {sys.executable}")
results = []
for case in CASES:
r = measure(case, args.runs)
results.append(r)
if not args.json:
ms = f"{r['median_ms']:.1f}ms" if r["median_ms"] is not None else "error"
print(f" {case.label}: {ms}")
if args.json:
print(json.dumps(results, indent=2))
else:
print_table(results)
if __name__ == "__main__":
main()

View file

@ -1,10 +1,16 @@
"""FastMCP - An ergonomic MCP interface."""
import importlib
import warnings
from importlib.metadata import version as _version
from typing import TYPE_CHECKING
from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging
if TYPE_CHECKING:
from fastmcp.client import Client as Client
settings = Settings()
if settings.log_enabled:
_configure_logging(
@ -16,9 +22,6 @@ from fastmcp.server.server import FastMCP
from fastmcp.server.context import Context
import fastmcp.server
from fastmcp.client import Client
from . import client
__version__ = _version("fastmcp")
@ -27,6 +30,21 @@ if settings.deprecation_warnings:
warnings.simplefilter("default", DeprecationWarning)
# --- Lazy imports for performance (see #3292) ---
# Client and the client submodule are deferred so that server-only users
# don't pay for the client import chain. Do not convert back to top-level.
def __getattr__(name: str) -> object:
if name == "Client":
from fastmcp.client import Client
return Client
if name == "client":
return importlib.import_module("fastmcp.client")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"Client",
"Context",

View file

@ -1,6 +1,5 @@
from .context import Context
from .server import FastMCP, create_proxy
from . import dependencies
__all__ = ["Context", "FastMCP", "create_proxy"]

View file

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING
from .auth import (
OAuthProvider,
TokenVerifier,
@ -12,10 +14,44 @@ from .authorization import (
restrict_tag,
run_auth_checks,
)
from .providers.debug import DebugTokenVerifier
from .providers.jwt import JWTVerifier, StaticTokenVerifier
from .oauth_proxy import OAuthProxy
from .oidc_proxy import OIDCProxy
if TYPE_CHECKING:
from .oauth_proxy import OAuthProxy as OAuthProxy
from .oidc_proxy import OIDCProxy as OIDCProxy
from .providers.debug import DebugTokenVerifier as DebugTokenVerifier
from .providers.jwt import JWTVerifier as JWTVerifier
from .providers.jwt import StaticTokenVerifier as StaticTokenVerifier
# --- Lazy imports for performance (see #3292) ---
# These providers pull in heavy deps (authlib, cryptography, key_value.aio,
# beartype) that most users never need. Keeping them behind __getattr__
# avoids ~150ms+ of import overhead for the common server-only case.
# Do not convert these back to top-level imports.
def __getattr__(name: str) -> object:
if name == "DebugTokenVerifier":
from .providers.debug import DebugTokenVerifier
return DebugTokenVerifier
if name == "JWTVerifier":
from .providers.jwt import JWTVerifier
return JWTVerifier
if name == "StaticTokenVerifier":
from .providers.jwt import StaticTokenVerifier
return StaticTokenVerifier
if name == "OAuthProxy":
from .oauth_proxy import OAuthProxy
return OAuthProxy
if name == "OIDCProxy":
from .oidc_proxy import OIDCProxy
return OIDCProxy
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [

View file

@ -62,7 +62,6 @@ from fastmcp.server.apps import (
resolve_ui_mime_type,
)
from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.lifespan import Lifespan
from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
@ -162,6 +161,8 @@ def _get_auth_context() -> tuple[bool, Any]:
is_stdio = _current_transport.get() == "stdio"
if is_stdio:
return (True, None)
from fastmcp.server.dependencies import get_access_token
return (False, get_access_token())