[experimental - do not merge] Cut startup cost: lazy exports, pay-for-what-you-use imports, deferred model builds - #3220
Draft
maxisbey wants to merge 32 commits into
Draft
[experimental - do not merge] Cut startup cost: lazy exports, pay-for-what-you-use imports, deferred model builds#3220maxisbey wants to merge 32 commits into
maxisbey wants to merge 32 commits into
Conversation
`import mcp` used to import the protocol types plus the entire client and server stacks (in-memory transport, lowlevel server, HTTP transports, otel, auth) just to bind the names in `mcp.__all__`. Each export is now resolved from its home module by a module-level `__getattr__` on first access and cached in the package namespace; the old eager import block is mirrored name-for-name under `TYPE_CHECKING` so type checkers and the docs generator see the same bindings. `__all__`, `dir(mcp)`, `from mcp import *`, object identity and the `mcp.types` submodule binding are unchanged. A bare `import mcp` no longer loads pydantic, mcp_types or any mcp submodule; each entry point pays only for what it uses.
…n attribute access Adds a small PEP 562 __getattr__ factory (mcp.shared._lazy_submodules) and installs it on the four packages, so attribute chains that used to resolve only because the top-level package imported everything eagerly - e.g. import mcp; mcp.client.stdio.stdio_client - keep resolving now that the mcp package is lazy: touching pkg.name imports the submodule pkg.name if it exists, raising AttributeError otherwise and surfacing a submodule's own missing dependency as its real ImportError.
Client only needs Server/MCPServer/InMemoryTransport/modern_on_request when it is handed a live in-process server, and a Server instance can only exist once the user has imported the server stack. Detect that case via sys.modules instead of eager imports, keep the server types for static checking under TYPE_CHECKING, and import the in-memory bridge inside _connect_inproc.
The streamable-HTTP client transport (httpx2) is now imported when the first URL Client is constructed instead of at import time, so a stdio-only client (or a bare `import mcp.client`) never loads it. Tests cover the resulting import contract: the client entry points load neither the server stack nor httpx2 until they are used, and the package's lazy server-side re-exports still resolve to their defining objects. The test that patched `mcp.client.client.streamable_http_client` now patches the transport at its defining module.
Making the server stack a typing-only import in the client module left the `Client.server` annotation naming `Server` / `MCPServer`, which `typing.get_type_hints` can then no longer evaluate: string annotations are evaluated against the module dict, so a module-level `__getattr__` would not be consulted either. Spell the annotation through the `mcp.server` namespace instead. Evaluating the hints then walks the lazy `mcp` package, which imports `mcp.server` at that moment only; `import mcp.client` stays free of the server stack and the evaluated hint is the same union of classes as before. The package's TYPE_CHECKING mirror now also lists the subpackages that its `__getattr__` resolves on first access, so type checkers see the same chains.
The HTTP transport stack (starlette, sse_starlette, uvicorn) is no longer imported by the transport-agnostic server modules at module import: the lowlevel Server and MCPServer import the web stack inside streamable_http_app() / sse_app() / custom_route(), with annotation-only names moved under TYPE_CHECKING, so a stdio server never loads it. The request access-token contextvar and get_access_token, which the request-state boundary and handlers read regardless of transport, move to a starlette-free module, mcp.server.auth.access_token, and are re-exported from mcp.server.auth.middleware.auth_context so the existing import path keeps working; the boundary now reads the principal without the HTTP stack. opentelemetry-api is imported on the first span instead of at import (cached in module globals; otel_span takes the span kind by name), and httpx2 is imported only when an HttpResource is read. The streamable-HTTP request-body-size default moves to a leaf module so the servers' signatures do not import the transport.
The method body had been marked `# pragma: no cover` (widened further once httpx2 moved into the method). Exercise it against an httpx2 MockTransport instead, covering both the response body and the raise_for_status path, and drop the pragma.
mcp_types.methods imported both generated wire packages (_v2025_11_25 and _v2026_07_28, roughly 200 pydantic models each) at module import solely to fill the surface maps' values, although a connection negotiates exactly one protocol version and nothing reads a value at import time. The map rows now record the wire type's attribute name; the package a row lives in follows from the key's protocol version and is imported by a cached lookup on the first row read, then cached per row. The public maps stay MappingProxyType objects over the resolved rows (same protocol, repr and equality as before), so importing mcp_types.methods - and therefore mcp, mcp.types and every entry point above them - no longer builds any wire package; the first message parsed for a version builds that version once. The elicitation module's single wire-schema import moves into the function that uses it for the same reason.
Its module-level import moved into the validation function that uses it, so the attribute would otherwise disappear from the module namespace. Resolve it on first access via a module-level __getattr__ (and keep it in dir()) so existing references to mcp.server.elicitation.PrimitiveSchemaDefinition keep working without reintroducing the import at module load.
tests/test_import_guards.py runs every check in a fresh interpreter and pins the module footprint each entry point is allowed to have: `import mcp` is a lazy namespace, client entry points never load the server or web stack (and httpx2 only for the HTTP transports), transport-agnostic server entry points never load starlette / sse_starlette / uvicorn / opentelemetry / httpx2, and the wire-schema packages load only when their protocol version is used. A positive parametrized case imports every documented module as the first import of a fresh process, warning-free, so the lazy resolution can never grow an import-order dependency. The earlier per-file fresh-interpreter probes are folded into this module, which is now the single home for import-graph assertions.
typing.get_type_hints(Client) raises TypeError on CPython 3.10 for any dataclass using KW_ONLY (a stdlib bug fixed in 3.11), which has nothing to do with the SDK and turned the test red on the 3.10 CI cells. Probe Client.__init__ instead: it evaluates the same qualified mcp.server annotation on every supported version.
…ckers Three implementations of lazy module attributes had grown: the hand-rolled __getattr__/__dir__ in mcp/__init__.py, the submodule fallback factory used by the four package inits, and a one-name __getattr__ in mcp.server.elicitation. They shared two problems. An unconditional module-level __getattr__ is visible to type checkers, so pyright typed every misspelled `mcp.<name>` (and `mcp.client.<name>`, ...) as `object` instead of reporting it. And the submodule fallback ran a filesystem find_spec on every attribute miss and speculatively imported whatever matched, so a name sweep (cloudpickle's whichmodule, hasattr probes) could import real submodules as a side effect, while dir(mcp.client) no longer listed submodules it would happily resolve. mcp.shared._lazy.lazy_module_attrs now serves all of them: lazy exports (a `(module, attr)` pair or a zero-argument loader, resolved once and cached in the namespace) plus known submodules (an explicit set, or the package's real submodules listed once on first need). A miss is a plain AttributeError with no search and no import, and __dir__ reports the exports and submodules. Every caller binds the pair under `if not TYPE_CHECKING:`, so attribute typos are pyright errors again while the TYPE_CHECKING mirrors keep the real names typed. The mcp.server.auth package gets the same fallback so qualified annotations such as `mcp.server.auth.provider.TokenVerifier` resolve on demand. The elicitation gate's wire-schema type is resolved through the same mechanism from a cached accessor, so validating rendered schemas no longer re-executes the wire-package import on every call.
Moving the HTTP transport and auth imports under TYPE_CHECKING made typing.get_type_hints() raise NameError on public methods it used to resolve on: MCPServer.streamable_http_app / sse_app / run_sse_async / run_streamable_http_async / session_manager and Server.streamable_http_app / session_manager (EventStore, TransportSecuritySettings, StreamableHTTPSessionManager, AuthSettings, OAuthAuthorizationServerProvider, TokenVerifier were typing-only names in real annotations). Two of those types get web-framework-free homes so the server modules can import them for real without loading starlette: the resumability contract (EventStore, EventMessage, EventCallback, EventId, StreamId) moves to mcp.server.event_store, and mcp.server.transport_security keeps only the pydantic TransportSecuritySettings while its starlette middleware moves beside the transports. Both old import paths keep working with the same objects. The session manager and the OAuth annotations are spelled through the lazy mcp.server namespace instead (the trick already used for Client.server), so they evaluate on demand and MCPServer stops importing the OAuth provider stack at module load. The starlette-owned annotations of the app builders (Starlette, Route, Request/Response) stay typing-only; get_type_hints on those methods needs starlette's names supplied by the caller.
… not the server Two dependencies still rode along with every `import mcp.server*` for features most servers never use: the OAuth provider models (via mcp.shared.auth and urllib.parse, ~10 ms) were imported by the access-token leaf only for a return annotation and by request_state for principal decomposition, and cryptography (~10 ms, 20-odd modules) was imported at request_state's module top for the built-in codec. The access-token module now spells its return type through the lazy mcp.server.auth namespace and imports no OAuth model; request_state resolves the provider's principal_components and the AEAD/KDF primitives through cached loaders on first use (a codec is built by MCPServer(), so that is where cryptography now loads - once, at construction, never per request). The import-cost ratchet adds cryptography to the banned set of every transport-agnostic server entry point and pins where its deferred load lands.
The per-version wire packages are now imported by module name at runtime, so PyInstaller-style dependency scanners could no longer see them. A compiled-in but never-executed TYPE_CHECKING import of both packages next to the registry keeps them discoverable (and typed) without importing them at module load.
…order The generated _v2025_11_25 and _v2026_07_28 packages spent ~100-115 ms each at import building pydantic core schemas, validators and serializers for ~175 models per version, of which a connection touches only a handful. Both generated bases now carry defer_build=True: WireModel, and a new generic WireRootModel[T] the RootModel aliases derive from (deferring only the object models made things slower, as the eager union roll-ups then regenerated every deferred model's schema inline). The generator drops codegen's trailing model_rebuild() epilogue and emits classes in dependency order - failing if any class names a class defined later - so every non-recursive generated class still exposes complete field metadata at import. Building each model's validator now happens once, on first use.
Set defer_build=True on MCPModel, the JSON-RPC envelope models and every module-level TypeAdapter (the six union adapters in mcp_types, the message adapter, and the four in mcp.client.session), so importing mcp_types no longer pays for ~150 models' core schemas, validators and serializers: each is built once, on first use. The explicit model_rebuild() calls go away (they force an eager build), and the request-parameter models that carry InputResponses plus the ContentBlock members are defined before their users so their field metadata is complete without a first-use rebuild. Under defer_build pydantic derives a model's __signature__ only when it builds; a class-level lazy __signature__ (mcp_types._deferred, public pydantic API only) completes the one-time build via model_rebuild() on first inspect.signature() access, so introspection matches an eagerly-built model. MCPModel and the generated wire bases install it for all their subclasses; models deriving from BaseModel directly opt in with @deferred_model.
Every SDK-defined model that used to be built at import - the auth and OAuth handler models, mcpserver's Resource/ResourceTemplate/Prompt/Tool/ Settings/Context/FuncMetadata, the client's parameter models, transport security, apps and elicitation results - now sets defer_build=True, and the remaining module-level TypeAdapters (message_validator, token_request_adapter) build on first use as well. Each model or validator is built once, at its first construction or validation, instead of adding its cost to the import of the module that defines it. Models that derive from BaseModel directly opt into the lazy __signature__ with @deferred_model, so inspect.signature() on a never-used model keeps reporting its fields (and mcpserver Settings, whose signature could not resolve at all before, now does).
- AGENTS.md: state the import-cost exception to the imports-at-top rule, listing which stacks stay off which import paths and pointing at the ratchet test that enforces it. - New docs page "Imports & startup time" (Advanced): what loads when, the one-time first-use costs, and a tested prewarm recipe for hosts that want the deferred work paid at startup. - Migration guide: the lazy import graph, the incidental namespace bindings that moved to their defining modules, and defer_build inheritance for user subclasses of SDK models. - What's new: one paragraph on imports paying only for what they use.
Released pydantic (through 2.13) does not lock model_rebuild(), so the first use of a defer_build model from several threads at once - sync tools running on worker threads, or one client session per thread at process start - could raise (AttributeError: __pydantic_core_schema__ and friends) or install a stale validator instead of just building twice. A narrow version of the same hazard already existed for the handful of models that were incomplete at import; deferring every model made it reachable from every entry point. One process-wide RLock now serializes that one-time build. The @deferred_model decorator becomes the single mechanism for a deferred model root: it installs the lazy __signature__, the subclass hook, a locked model_rebuild (public pydantic API; the extra wrapper frame is accounted for via _parent_namespace_depth + 1) and a model_json_schema that completes the class under the same lock before pydantic reads it (pydantic re-reads the mock schema around a concurrent build). MCPModel, WireModel and WireRootModel are decorated rather than hand-writing the hook, and the remaining private deferred roots get the decorator too, so no deferred model in the SDK builds outside the lock. The decorator refuses a class that lacks defer_build or that defines its own __pydantic_init_subclass__ (which it would otherwise replace). Steady state is untouched: an already-built model never takes the lock. A subprocess ratchet test races 8 threads over the monolith models, a wire package, the JSON-RPC envelopes, the SDK's own models, the module-level adapters and first schema/signature access, and asserts zero exceptions (it fails without the lock).
The wire schemas load on the first message per protocol version and the pydantic validators build on first use, which keeps imports fast but puts a one-time bill on a process's first messages. A long-running host that would rather pay that at startup than on its first request had only a DIY recipe of model_rebuild() loops. mcp_types.methods.warm(version=None, *, everything=False) is the supported version: with no argument it builds the version-independent set (the exported mcp_types models, the JSON-RPC envelopes and the routing union adapters, ~50 ms); with a version it also imports that version's wire package and builds the routing surface a connection at that version uses (so its first messages then build nothing); everything=True covers every known version. Model classes are always completed before the adapters that reference them, so no schema is generated twice, and repeat calls are no-ops. It returns a small WarmReport for logging and is re-exported as mcp.warm. Nothing in the SDK calls it. The import-cost docs recipe now uses it.
docs/advanced/import-cost.md now states the size of every deferred bill (the per-version wire package, the first HTTP app, the first span, cryptography, the OAuth provider models, and the ~130 ms a fresh process pays once on its first connection), and grows an FAQ: an installed pydantic plugin such as logfire still loads at `import mcp.types` (PYDANTIC_DISABLE_PLUGINS is the lever), __pydantic_complete__ reads False until first use, function-local model subclasses keep the generic signature until first use, what to hand a static bundler, and the get_type_hints waiver for the app builders. AGENTS.md's import-cost bullets are corrected to match the code: only mcp/__init__ resolves exports lazily while the other package inits resolve submodules; cryptography and the OAuth provider models load with their first user rather than at import; the wire packages also load for the first rendered elicitation schema. The migration guide notes the removed McpHttpClientFactory re-export from mcp.client.streamable_http.
The default Implementation instance was validated at import, which force-built the deferred Implementation model on every client import (~0.7 ms). The two literals are trusted, so model_construct produces an equal instance (same dict, same fields set) without touching the validator; the model now builds on the first real message instead.
Twenty public modules imported the decorator as `deferred_model`, so it showed up as e.g. mcp.server.elicitation.deferred_model and mcp_types.jsonrpc.deferred_model even though it is machinery of the type-layer package, not part of any module's surface. Import it as _deferred_model so the public namespaces list only what they mean to export.
The define-before-use reorder reassembles a generated wire package from its class blocks plus the text before the first class and after the last, so a module-level statement emitted between two classes would silently disappear. codegen emits none today; assert it stays that way.
…anches The two TypeError guards in deferred_model and plain warm() were untested, and coverage.py counts the never-taken false arc of the `if not TYPE_CHECKING:` guards around the lazy module attributes; that arc belongs to the type checker, so declare it partial by design next to the existing exclusion rules.
Completing a deferred model under the process-wide rebuild lock and then letting pydantic generate its JSON schema outside it left one interleaving: schema generation for a model reads the core schema of every model it references, and for the mutually recursive wire JSON models (JSONObject / JSONArray / JSONValue) a sibling could still be mid-build on another thread, surfacing as a rare KeyError inside pydantic's schema generation on some pydantic versions when many threads generated their first schema of that family at once. No SDK-internal path did this (registration is single-threaded), but user code may. The generation now runs under the same lock as the build. Schema generation is a cold path, so it just serializes with the deferred first builds; steady state is unchanged. The concurrency ratchet also covers a concurrent first schema over the recursive JSON family and the 2026 wire package.
… dataclass `warm(everything=True)` warmed every protocol version's routing surface - not "everything the SDK could ever build": the mcp package's own model roots (mcpserver settings, tool/prompt argument models) belong to the mcp layer that mcp-types cannot see, and they build during server construction and tool registration anyway, which is startup work rather than first-request work. Naming the flag `all_versions` says what it does; the recommended host recipe is unchanged (`warm(version)` for the version the transport negotiates). WarmReport becomes a frozen dataclass instead of a NamedTuple, so a later field addition does not disturb code that reads the existing fields, and `mcp.warm` stays the very same function as `mcp_types.methods.warm` (pinned by a test).
The startup page now leads its prewarming section with warm(version), the form a host actually wants: it names the version the transport negotiates and takes the first connection from ~200 ms of one-time builds to within a millisecond of steady state; the no-argument and all_versions forms are described as the narrower and wider variants. The magnitude table is labelled as single-machine measurements. Two consequences of holding one process-wide lock across a model's first build are spelled out where they can be found - the lock's docstring and an FAQ entry: user code inside pydantic schema/subclass hooks runs under the lock (so ordinary lock-ordering rules apply), a thread mid-build during fork() leaves the child's lock held, warm() at startup sidesteps both, and the SDK's lock goes once a pydantic release with a thread-safe model_rebuild is the dependency floor. The FAQ also notes that get_type_hints() imports what the hints name (evaluating mcp.Client's hints imports mcp.server), and the migration guide's list of incidental changes is no longer titled "two" now that it holds four.
Every package init in the SDK resolves its submodules on attribute access except four docstring-only leaves - mcp.os.posix, mcp.os.win32, mcp.server.auth.handlers and mcp.server.auth.middleware - so a chain like `import mcp; mcp.server.auth.handlers.token` stopped resolving even though `mcp.client.stdio` and friends do. Give them the same lazy fallback, so "attribute chains still resolve" is true without a footnote (explicit imports remain the supported form), and pin one chain through each in the ratchet.
The two `@deferred_model` refusals asserted only the exception type; they now snapshot the message so a wording change is a deliberate diff rather than a silent one. The first two wire-base tests gain the provenance docstrings the rest of the file carries.
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.
Warning
EXPERIMENTAL — DO NOT MERGE. This is an exploration branch for cutting v2's startup cost, opened as a draft so the approach and the numbers can be discussed. It is intentionally the whole thing in one place; if we go ahead it will be split into a small stack (structural laziness first, deferred model builds second) rather than merged as-is.
Cuts
importcost across the SDK so that each entry point pays only for what it uses, without removing any public API. On the same benchmark harness used for the v1-vs-v2 comparison, per-entry-point import cost goes from ~1.6× slower than v1 to ~0.5× of v1, time-to-first-tool-call to ~0.9× of v1, and post-import RSS from 57 MiB to ~26 MiB.Motivation and Context
import mcpon v2 costs ~600 ms of fresh-interpreter wall time (v1: ~360 ms), and every deeper import path costs the same becausemcp/__init__.pyeagerly binds the client stack, which reaches the whole server/HTTP/auth stack, plus both per-protocol-version wire packages. About 90 % of the delta versus v1 is pydantic building ~640 model classes at import (the version-free type monolith plus the two generated wire packages, only one of which a connection ever uses); the rest is module-graph growth (starlette/sse-starlette/uvicorn/otel/cryptography reachable fromimport mcp). This is user-visible: stdio servers pay it on every host session start, and libraries pay it just to import a handful of types.Techniques used, all intended to be structural rather than clever:
mcp/__init__.pyand the package inits resolve their exports/submodules on first attribute access (PEP 562), through one helper, with a mirroredTYPE_CHECKINGblock so type checkers and IDEs still see and check every name. Bareimport mcpbecomes a namespace shell (~3 ms).Server/MCPServerinstance is passed;httpx2loads only with the HTTP client transports.starlette,sse_starlette,uvicorn), OpenTelemetry,cryptography, and the OAuth provider models load with the feature that needs them (HTTP app builders, first span, first request-state codec, first auth-enabled server), not fromimport mcp.server. Names those public signatures reference stay resolvable fortyping.get_type_hints()via small web-framework-free leaf modules or the lazy namespace.mcp_types.methodssurface maps are lazily materialised behind the sameMappingProxyType), not onimport mcp_types.defer_build=Trueon the SDK's model bases plus a lazy__signature__soinspect.signature(Model)is unchanged; the code generator now emits the wire models in dependency order with deferred bases. First-use builds are serialised behind one process-wide lock (this also fixes a pre-existing thread-safety issue where concurrent first use of a not-yet-built model could raise on released pydantic — reproducible onmainin a per-thread-session workload).mcp.warm(version)/mcp_types.methods.warm(...)for hosts that want the one-time build cost paid at startup instead of on the first connection.tests/test_import_guards.py) pin which heavy dependencies each import path may load, so a hoisted import that regresses this fails CI;AGENTS.mdgains the corresponding import-cost note; a docs page explains the model and the one-time bills.Numbers
Original v1-vs-v2 harness methodology (fresh interpreter per sample, wheels installed into otherwise-identical venvs on the same CPython 3.14.5, round-interleaved arms, A/A band, three sessions; ratios are paired geomeans with 95 % CIs; the box was under background load, so quote ratios, not absolutes).
import_wallis the clock around the import statement; the earlier "443 → 715 ms" headline is thelatencymetric, which additionally counts non-cancelling module-graph teardown.import mcp.server.mcpserverimport mcp.client.stdioimport mcp.client.streamable_httpimport mcp.typesv1 /import mcp_typesv2)import mcp(import_wall)import mcp.server.mcpserverimport mcpis a category change (a lazy namespace shell), so its ratio is not a headline; the honest rows are the per-entry-point imports and the end-to-end time-to-ready. A separate probe replays the module-scope import statements of 26 real consumers (fastmcp, langchain-mcp-adapters, google-adk, openai-agents, claude-agent-sdk, litellm, mcpo, official-servers pattern, …): all 450 import statements still resolve, and every profile is 2.0–4.8× faster than main and 1.1–2.9× faster than v1; a real stdio server cold start (spawn → firsttools/list) goes from ~745 ms (main) to ~381 ms, versus ~465 ms on v1.Where the deferred work goes
Nothing is deleted, only moved off import. One-time bills, each paid once per process: first message for a protocol version loads that wire package (~50 ms on the bench box), first HTTP app build (~55 ms), and a warm host's first connection carries the deferred validator builds (~150 ms of moved work — still ~230 ms better than main end-to-end because import fell more). Hosts that care about first-request latency call
mcp.warm("2026-07-28")at startup and the first connection lands within a millisecond or two of steady state. Steady-state per-request work is unchanged (measured: no new imports, model builds or cache misses on the request path after warmup; per-call parse/validate timings within noise; a runtime throughput campaign is still running and I'll append it here).How Has This Been Tested?
pyright,ruff,pre-commit, the codegen--check, and the docs build; the correctness-sensitive subsets on CPython 3.10 through 3.14 with both lowest-direct and locked dependency sets.__all__s, object identity acrossmcp.types/mcp_types, function signatures, model fields/JSON-schemas, MROs, star-import sets, warnings, pickling) run in fresh interpreters; atyping.get_type_hints()sweep over every public callable; monkeypatch-target and import-order probes; zipimport /-OO/-W errorsmokes.tools/calland per-thread-session workloads, on pydantic 2.12.0/2.12.5/2.13.4 — zero exceptions (main shows failures in the per-thread-session workload; that fix is included here).Breaking Changes
No public API is removed or renamed;
__all__,dir(mcp), object identity betweenmcp.types.Xandmcp_types.X, subclassing, pickling and warning behaviour are unchanged. 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 instead. Every object still lives at its defining path.typing.get_type_hints()on the three app-builder methods that return starlette types (streamable_http_app,sse_app,custom_route) needsstarletteimported first, since the web stack no longer loads with the server.__pydantic_complete__ = Falseuntil first use;inspect.signature,model_fieldsandmodel_json_schema()are kept identical to today.httpx2/starlette) now fails at first use rather than atimport mcp.New public API in this branch (would need to be committed to if released):
mcp.warm()/mcp_types.methods.warm()returningWarmReport, and the web-framework-free modulesmcp.server.event_store,mcp.server.auth.access_tokenand themcp.server.transport_securitysplit (old paths keep re-exporting).Types of changes
Checklist
Additional context
defer_buildlayer + rebuild lock +warm(), which is where the cold-start and memory wins come from. The commit series is already ordered that way (structural prefix through695b1ab2).from mcp.types import Toolcosts ~90 ms and bareimport mcp.types~3 ms. It works, but it changes the models'__module__(and therefore the module path in new pickles), so it is a separate decision.MCP_EAGER_IMPORT=1mode that restores today's eager import graph, with a CI job running the suite in that mode.AI Disclaimer