Cut import and startup cost with deferred model builds and lazy imports - #3242
Draft
maxisbey wants to merge 1 commit into
Draft
Cut import and startup cost with deferred model builds and lazy imports#3242maxisbey wants to merge 1 commit into
maxisbey wants to merge 1 commit into
Conversation
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.
Contributor
📚 Documentation preview
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 onmaintoday in a one-session-per-thread workload.import mcpbinds 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,httpx2andcryptography(the OpenTelemetry tracing API is still imported by the dispatcher).starlette's app/routing/request machinery,sse_starlette,uvicorn) loads with the app builders that use it rather than withimport mcp.server(only starlette's smalltypestyping 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
mcpandmcp.typesare deliberately kept eager, soimport mcpremains a real types namespace (~180 ms) rather than an empty shell.Motivation and Context
On
main,import mcpcosts about 1.6× what it did on v1, and every deeper import path pays the same, becausemcp/__init__.pyeagerly 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).
import mcpimport mcp.client.stdioimport mcp.client.streamable_httpimport mcp.server.mcpserver(v1:mcp.server.fastmcp)mcp_types/ v1mcp.types)import mcpimport mcp.server.mcpserverA 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/callcarries ~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?
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.tools/callworkloads, on pydantic 2.12.0 / 2.12.5 / 2.13.4 — zero failures across thousands of raced rounds, wheremainfails 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).__all__s, object identity betweenmcp.types.Xandmcp_types.X, signatures andmodel_fieldsafter first use, MROs, star-import sets, warnings, pickling) in fresh interpreters; atyping.get_type_hints()sweep over every public callable; subclassing every public model before its first use behaves exactly as onmain.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 indocs/migration.md: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.import mcp, deeper submodules such asmcp.client.stdio,mcp.client.streamable_httpandmcp.shared.memoryare no longer imported as a side effect; the immediatemcp.client/mcp.server/mcp.types/mcp.osroots still resolve. Import what you use.typing.get_type_hints()needslocalns=formcp.Client,Client.__init__and the seven HTTP-app methods whose annotations name starlette types, since those names are no longer imported at module scope.__pydantic_complete__ = Falseuntil first use andinspect.signature(Model)shows the generic form until then (mainalready does this for a handful of models); after first use introspection matches an eagerly-built model.starlette, say) now fails at the first HTTP app build rather than atimport mcp.server.Types of changes
Checklist
Additional context
mcp.warm()prewarm helper, no new leaf modules to keep everyget_type_hints()call resolvable, no lazy-__signature__machinery. Where a corner case differs frommainbefore first use, it is documented rather than hidden.AGENTS.mdgains 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.main.RootModelmachinery (~20 ms) on the types-only import path. That path is still faster thanmainhere, so nobody regresses, but emitting that base into the generated packages instead would recover the ~20 ms.AI Disclaimer