Skip to content

Cut import and startup cost with deferred model builds and lazy imports - #3242

Draft
maxisbey wants to merge 1 commit into
mainfrom
startup-cost
Draft

Cut import and startup cost with deferred model builds and lazy imports#3242
maxisbey wants to merge 1 commit into
mainfrom
startup-cost

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Cuts import and startup cost across the SDK so that each entry point pays only for what it uses, without adding or removing any public API. It is a much smaller take on the ground #3220 explored: the same big wins from about 440 added / 105 removed lines of product code instead of ~3700 changed, so it can be reverted cleanly if anything shakes loose.

Three things account for essentially all of it:

  • The SDK's ~500 pydantic model classes now build their validators on first use rather than at import (defer_build), via one shared private base class. Those first builds are serialised behind a single process-wide lock, because released pydantic (2.12–2.13) does not make concurrent first use of a deferred model thread-safe. That lock also fixes a pre-existing thread-safety issue that reproduces on main today in a one-session-per-thread workload.
  • import mcp binds the client and server names lazily on first attribute access (PEP 562), and the client no longer imports the server (a five-line accidental dependency), so a client entry point stops loading the server, the web stack, httpx2 and cryptography (the OpenTelemetry tracing API is still imported by the dispatcher).
  • The web application stack (starlette's app/routing/request machinery, sse_starlette, uvicorn) loads with the app builders that use it rather than with import mcp.server (only starlette's small types typing leaf still loads there), and each protocol version's wire-model package loads on the first message parsed for that version instead of both loading at import.

The type re-exports on mcp and mcp.types are deliberately kept eager, so import mcp remains a real types namespace (~180 ms) rather than an empty shell.

Motivation and Context

On main, import mcp costs about 1.6× what it did on v1, and every deeper import path pays the same, because mcp/__init__.py eagerly binds the whole client and server stack and both per-version wire packages load with the method maps. Around 90 % of the regression is pydantic building model classes at import; the rest is module-graph growth. Stdio servers pay this on every host session start, and libraries pay it just to import a handful of types.

Numbers

Paired geomean ratios from the same fresh-interpreter harness used for the v1-vs-v2 comparison (wheels installed into otherwise-identical CPython 3.14 venvs, round-interleaved arms with an A/A twin per arm; the box is a shared host under load, so the ratios with their 95 % CIs are the finding and the absolute milliseconds are load-inflated; single A/A-gated session per row).

row this branch / v1 this branch / main
import mcp 0.40 [0.38, 0.43] 0.25
import mcp.client.stdio 0.60 [0.58, 0.62] 0.37
import mcp.client.streamable_http 0.59 0.38
import mcp.server.mcpserver (v1: mcp.server.fastmcp) 0.71 [0.65, 0.75] 0.46
types (mcp_types / v1 mcp.types) 0.36 0.78
time-to-ready: import + 10-tool server + in-memory handshake + first call 1.01 [0.99, 1.03] parity 0.64
stdio cold start: spawn → handshake → first call 0.99 [0.93, 1.05] parity 0.65
RSS after import mcp 0.44 (19 MiB vs 43.5) 0.33
RSS after import mcp.server.mcpserver 0.85 0.65
steady-state per-call latency on a warm session unchanged unchanged

A replay of the module-scope import statements of 26 real consumers (fastmcp, langchain-mcp-adapters, google-adk, openai-agents, litellm, mcpo, an official-servers pattern, …) still resolves all ~450 statements, and every profile is faster than main (0.25×–0.61× depending on how much of the SDK it imports); none is slower.

Where the deferred work goes

Nothing is deleted, only moved off import. Each bill is paid once per process: the first message parsed for a protocol version imports that version's wire package (~50 ms once); the first HTTP app build loads the web stack (~60 ms once); a server's first elicitation-schema render pays the older wire package (~50 ms once); and a warm host's first tools/call carries ~15 ms of deferred validator builds. Steady-state per-message work is unchanged (no new imports, model builds or cache misses on the request path after warm-up).

How Has This Been Tested?

  • Full suite green with 100 % branch coverage, strict-no-cover, pyright, ruff, the codegen --check, the docs build and pre-commit; the correctness-sensitive subsets on CPython 3.10 with both the locked and lowest-direct dependency sets.
  • Concurrency: N-thread first-use races over every deferred class, the union adapters and both protocol eras, plus end-to-end per-thread-session and concurrent-tools/call workloads, on pydantic 2.12.0 / 2.12.5 / 2.13.4 — zero failures across thousands of raced rounds, where main fails a meaningful fraction of the same runs. Two subprocess regression tests pin this. Threaded first access of the lazily-bound names is deadlock-free (importing the leaf module's package before the module keeps importlib's lock order parent-first).
  • A public-surface differ (module __all__s, object identity between mcp.types.X and mcp_types.X, signatures and model_fields after first use, MROs, star-import sets, warnings, pickling) in fresh interpreters; a typing.get_type_hints() sweep over every public callable; subclassing every public model before its first use behaves exactly as on main.
  • Import-footprint ratchet tests pin which heavy modules each entry point may load, so a hoisted import that regresses this fails CI.

Breaking Changes

No public API is added, removed or renamed; __all__, object identity, subclassing, pickling and warning behaviour are unchanged. The observable-but-incidental differences, all in docs/migration.md:

  • Roughly a dozen incidental namespace bindings inside private modules are gone (e.g. mcp.client.client.streamable_http_client, mcp.server.lowlevel.server.Starlette) because those imports became local or type-only — patch the defining module. Every object still lives at its defining path.
  • After a bare import mcp, deeper submodules such as mcp.client.stdio, mcp.client.streamable_http and mcp.shared.memory are no longer imported as a side effect; the immediate mcp.client / mcp.server / mcp.types / mcp.os roots still resolve. Import what you use.
  • typing.get_type_hints() needs localns= for mcp.Client, Client.__init__ and the seven HTTP-app methods whose annotations name starlette types, since those names are no longer imported at module scope.
  • Protocol models report __pydantic_complete__ = False until first use and inspect.signature(Model) shows the generic form until then (main already does this for a handful of models); after first use introspection matches an eagerly-built model.
  • Every model gains one private base class in its MRO; a broken install (missing starlette, say) now fails at the first HTTP app build rather than at import mcp.server.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Relationship to [experimental - do not merge] Cut startup cost: lazy exports, pay-for-what-you-use imports, deferred model builds #3220: same objective and roughly the same performance envelope, deliberately without the parts that grew the public surface — no mcp.warm() prewarm helper, no new leaf modules to keep every get_type_hints() call resolvable, no lazy-__signature__ machinery. Where a corner case differs from main before first use, it is documented rather than hidden.
  • AGENTS.md gains a short note listing which heavy modules stay off which import paths, so the deliberately local imports (each carries a one-line why-comment) don't get hoisted back.
  • The one behaviour worth calling out is a fix rather than a change: concurrent first use of the SDK's models from multiple threads is safe on this branch and is not on main.
  • Known follow-up, left out to keep this small: the shared deferred-base module also defines the generated packages' root/union base, which puts pydantic's RootModel machinery (~20 ms) on the types-only import path. That path is still faster than main here, so nobody regresses, but emitting that base into the generated packages instead would recover the ~20 ms.

AI Disclaimer

Every import path now pays only for what it uses, with no public API
added or removed:

- The protocol models (mcp.types / mcp_types, incl. the JSON-RPC
  envelopes and the generated per-version wire packages) build their
  pydantic validators on first use instead of at import (defer_build),
  through one shared private base class. First-use builds are
  serialised behind a single process-wide lock, since released pydantic
  does not make concurrent first use of a deferred model thread-safe;
  this also fixes a pre-existing concurrent-first-use failure that
  reproduces on main.
- `import mcp` binds the client/server names lazily on first attribute
  access (PEP 562) instead of importing both stacks eagerly, and the
  client no longer imports the server, so client entry points stop
  loading the server, the web stack, httpx2 and cryptography.
- The web application stack (starlette's app machinery, sse_starlette,
  uvicorn) loads with the app builders that use it, and each protocol
  version's wire package loads on the first message parsed for that
  version rather than both loading at import.

On the fresh-interpreter harness `import mcp` is ~0.4x of v1 (main is
~1.6x), the client entry points ~0.6x of v1, `import mcp.server.mcpserver`
~0.7x, and time-to-ready / stdio cold start land at parity with v1. RSS
after `import mcp` is 19 MiB (v1 43.5, main 57). Steady-state per-call
latency is unchanged.

Observable-but-incidental differences (removed incidental namespace
bindings, deeper submodules no longer imported as a side effect of a
bare `import mcp`, get_type_hints needing localns= for a documented set
of callables, pre-first-use introspection) are catalogued in
docs/migration.md; ratchet tests pin the import footprints and the
concurrent-first-use safety.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3242.mcp-python-docs.pages.dev
Deployment https://d9dded2d.mcp-python-docs.pages.dev
Commit ec5b225
Triggered by @maxisbey
Updated 2026-08-03 15:10:30 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant