Skip to content

Commit ec5b225

Browse files
committed
Cut import and startup cost with deferred model builds and lazy imports
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.
1 parent a4f4ccd commit ec5b225

30 files changed

Lines changed: 1003 additions & 191 deletions

AGENTS.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,21 @@
4545
- IMPORTANT: All imports go at the top of the file — inline imports hide
4646
dependencies and obscure circular-import bugs. Only exception: when a
4747
top-level import genuinely can't work (lazy-loading optional deps, or
48-
tests that re-import a module).
48+
tests that re-import a module), plus the deliberate startup-cost seams
49+
below — each of those local imports carries a why-comment; don't hoist them.
50+
- Startup-cost seams (pinned by `tests/test_import_footprint.py`, so a
51+
hoisted import fails a test rather than review): `mcp/__init__.py` binds
52+
the client/server names and `mcp.types` lazily; `mcp.client.client` never
53+
imports the server, and imports the streamable-HTTP client (httpx2) only
54+
for a URL; `mcp.server.elicitation` imports the 2025-era wire package inside
55+
its schema-validation gate; the two server hubs (`lowlevel/server.py`,
56+
`mcpserver/server.py`) import the HTTP web stack inside
57+
`streamable_http_app()` / `sse_app()` / `custom_route()`; the auth context
58+
accessor imports its `AuthenticatedUser` type inside the middleware
59+
constructor; `HttpResource.read` imports httpx2 in the method; and
60+
`mcp_types.methods` resolves each version's wire package
61+
(`mcp_types._v20*`) on the first surface-map row read, never at import.
62+
`docs/advanced/startup.md` states the user-facing contract.
4963

5064
## Testing
5165

docs/advanced/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ layer is in the way:
1111
can *only* do on the low-level `Server`.
1212
* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's
1313
extension surface. Compose extension packages into a server, or write your own.
14+
* **[Startup cost](startup.md)**: what an import loads, and the one-time bills paid
15+
on first use instead — for when you are measuring cold start.
1416

1517
A few things you might reasonably look for here live where you'd actually use them
1618
instead:

docs/advanced/startup.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Startup cost
2+
3+
The SDK is arranged so a process pays only for what it actually uses, and pays for
4+
each thing once. Two rules produce that; both are occasionally observable, so they
5+
are written down here.
6+
7+
## What an import loads
8+
9+
`import mcp` loads the protocol types (`mcp_types`) and nothing else: no client, no
10+
server, no web stack, no HTTP client. The client/server names it exports (`mcp.Client`,
11+
`mcp.ClientSession`, `mcp.stdio_server`, ...) and `mcp.types` resolve their home
12+
module on first access and are then cached on the package, so `from mcp import Client`
13+
costs the client import exactly once, when you ask for it.
14+
15+
Importing an entry point loads only its own side: a client entry point
16+
(`import mcp.client.stdio`) never imports the server stack or the HTTP client stack,
17+
which loads with your first URL-shaped `Client`; a server entry point never imports the
18+
client, and a transport-agnostic one (`mcp.server.stdio`, `MCPServer`, the lowlevel
19+
`Server`) never imports the HTTP web stack (starlette's app/request stack,
20+
`sse_starlette`, `uvicorn`) — that loads when you first build an HTTP app
21+
(`streamable_http_app()`, `sse_app()`, a custom route). Because these are import-graph
22+
promises, they are tested: adding an eager import that breaks one fails the suite.
23+
24+
One introspection consequence: `typing.get_type_hints()` on the seven HTTP-app methods
25+
(`Server.streamable_http_app`, `Server.session_manager`, `MCPServer.streamable_http_app`,
26+
`MCPServer.sse_app`, `MCPServer.run_sse_async`, `MCPServer.run_streamable_http_async`,
27+
`MCPServer.session_manager`) raises `NameError`: their annotations name HTTP types that
28+
those modules import for type checkers only. Signatures, static typing, and calling the
29+
methods are unaffected; if you evaluate the hints at runtime, pass the types yourself, e.g.
30+
`typing.get_type_hints(MCPServer.sse_app, localns={"TransportSecuritySettings":
31+
mcp.server.transport_security.TransportSecuritySettings, "Starlette":
32+
starlette.applications.Starlette})`.
33+
34+
## One-time first-use bills
35+
36+
The generated wire types for each protocol version load with the first message a
37+
connection parses for that version, not at import — a connection negotiates one
38+
version, so a process loads that version's models (a few tens of milliseconds once)
39+
and never the other's. Protocol model validators are then built on a model's first use
40+
(validation, dumping, `model_json_schema()`), a few milliseconds once for the models a
41+
message touches; everything after is at full speed. There is no per-call cost.
42+
43+
Reading whole surface maps in `mcp_types.methods` (`.values()`, `.items()`, spreading
44+
one into an extension map) loads both versions' wire types at that moment, and a
45+
server's first elicitation loads the wire types its schema gate validates against.
46+
47+
If pydantic plugins are installed (`logfire`, for example) pydantic loads them at that
48+
first model build. When you are measuring or shaving cold start and don't use them,
49+
export `PYDANTIC_DISABLE_PLUGINS=__all__`.
50+
51+
## Introspecting a model before its first use
52+
53+
Because a model is built on first use, class-level introspection of a protocol model
54+
that nothing in the process has used yet reflects the not-yet-built state:
55+
`inspect.signature(Tool)` shows the generic `(**data)` initializer, and
56+
`Tool.__pydantic_complete__` is `False`. Using the model once, or calling
57+
`Tool.model_rebuild()`, resolves it; from then on introspection is identical to an
58+
eagerly-built model. Instances, validation, serialization, and schemas are unaffected.
59+
60+
## First use from threads
61+
62+
First-use builds are serialised across threads by one process-wide lock, so concurrent
63+
first use is safe. Two consequences worth knowing: generate schemas through the model's
64+
own `Model.model_json_schema()` (pydantic's module-level `pydantic.json_schema.model_json_schema(Model)`
65+
bypasses the serialisation), and don't `fork()` while another thread is mid-way through a
66+
model's first use — the child inherits the held build lock; use the model once first, or the
67+
`spawn` start method.

docs/migration.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,26 @@ Both commands now pin the requirement to the version you are running
197197
(`mcp==<installed version>`). Source builds and other unpublished versions, which have
198198
nothing on PyPI to pin to, keep the unpinned form.
199199

200+
### `import mcp` no longer imports the client and server stacks
201+
202+
`import mcp` used to import the whole client and server stack (and with it starlette,
203+
uvicorn, httpx2, ...) as a side effect. It now imports only the protocol types; `Client`,
204+
`ClientSession`, `ClientSessionGroup`, `StdioServerParameters`, `stdio_client`,
205+
`ServerSession`, `stdio_server`, `InputRequiredRoundsExceededError`, and the `mcp.types`
206+
submodule are the same names and objects, resolved on first access. This is invisible
207+
unless code depended on the side effects:
208+
209+
* `sys.modules` after `import mcp` no longer contains `mcp.client*`, `mcp.server*`, or
210+
their dependencies. Import what you use.
211+
* Attribute chains from a bare `import mcp` still reach `mcp.types`, `mcp.client`,
212+
`mcp.server`, and `mcp.os`, but a module the old package init imported for you needs its
213+
own `import` before `mcp.client.stdio.<name>` works — for example `mcp.client.stdio`,
214+
`mcp.client.session_group`, `mcp.client.sse`, `mcp.client.streamable_http`, and
215+
`mcp.shared.memory`.
216+
* Client entry points (`mcp.client.stdio`, `from mcp import Client`, ...) no longer import
217+
the server stack, and the HTTP client stack loads with your first URL-shaped `Client`
218+
rather than at import. See [Startup cost](advanced/startup.md).
219+
200220
## Types and wire format
201221

202222
### `mcp.types` moved to the `mcp-types` package
@@ -640,6 +660,45 @@ JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="
640660

641661
Delete any shim that accepted or synthesized null-id error responses. Code that assumed `error.id` was always a `str | int` must now handle `None`, and tests that pinned v1's rejection of `"id": null` now fail because validation succeeds.
642662

663+
### Protocol models build their validators on first use
664+
665+
The protocol models (`mcp.types` / `mcp_types`, including the JSON-RPC envelopes) now build
666+
their pydantic validators on a model's first use in the process instead of at import
667+
(`defer_build`), which is most of the SDK's startup cost. Validation, serialization, JSON
668+
schemas, and everything after a model's first use are unchanged (one wrinkle: `inspect.signature`
669+
on the `model_rebuild` / `model_json_schema` classmethods shows the SDK's evaluated annotations
670+
rather than pydantic's stringised type aliases — parameter names, kinds and defaults are
671+
identical). Two things are observable *before* a model's first use:
672+
673+
* `inspect.signature(Model)` / `help(Model)` show pydantic's generic `(**data)` initializer
674+
and `Model.__pydantic_complete__` is `False`. A few models (`CallToolRequest`,
675+
`GetPromptRequest`, `ReadResourceRequest`, `SamplingMessage`, `ToolResultContent`) already
676+
behaved this way; it now applies to all of them until first use. Use the model once, or call
677+
`Model.model_rebuild()`, when you need the resolved signature earlier.
678+
* Every model's MRO gains one private base (`mcp_types._wire_base.DeferredModel`) between it
679+
and `pydantic.BaseModel`, visible only to code that walks `__mro__`.
680+
* The module-level parse adapters (`client_request_adapter`, ..., `jsonrpc_message_adapter`)
681+
are instances of a private `TypeAdapter` subclass (`mcp_types._wire_base.DeferredAdapter`) so
682+
their first-use build takes the same lock; `isinstance(adapter, TypeAdapter)` and every
683+
documented `TypeAdapter` operation are unchanged.
684+
685+
The one-time build cost moves from `import` to each model's first use — a few milliseconds
686+
for the first message a connection parses; see [Startup cost](advanced/startup.md).
687+
688+
The per-version wire types behind the `mcp_types.methods` surface maps
689+
(`CLIENT_REQUESTS`, `SERVER_RESULTS`, ...) go further: a version's wire types load on the
690+
first row read for that version — in practice with the first message a connection parses —
691+
rather than at `import mcp_types.methods`, so a process loads only the protocol version it
692+
negotiates. Every documented map operation is unchanged (lookup, `in`, iteration, `len`,
693+
`get`, `==`, spreading into an extension map, `repr`); the whole-map reads among them
694+
load both versions at that moment. Three obscurities are observable: the maps'
695+
non-`Mapping` dict extras are gone (`.copy()`, `|`, `reversed()`, and `keys()`/`values()`/
696+
`items()` return view objects), the internal `mcp_types.methods.v2025`/`v2026` attributes
697+
are import-free stand-ins rather than the wire-package modules, and the wire types are no
698+
longer bound in `mcp.server.elicitation`. Import the generated packages
699+
(`mcp_types._v2025_11_25`, `mcp_types._v2026_07_28`) directly if you need them, though the
700+
version-free `mcp.types` models remain the supported surface.
701+
643702
## MCPServer (formerly FastMCP)
644703

645704
### `FastMCP` renamed to `MCPServer`
@@ -877,6 +936,27 @@ Beyond the constructor parameters that moved to `run()`/`streamable_http_app()`
877936

878937
Only private attributes moved: `mcp._mcp_server` is now `mcp._lowlevel_server` (see [Registering lowlevel handlers from `MCPServer`](#registering-lowlevel-handlers-from-mcpserver)), and `_session_manager` now lives on that lowlevel `Server`. Prefer the public `mcp.session_manager` property to either.
879938

939+
### The server modules no longer import the HTTP stack
940+
941+
`mcp.server.lowlevel.server` and `mcp.server.mcpserver.server` used to import the Streamable
942+
HTTP / SSE stack at module top, so any server — including a stdio one — loaded starlette,
943+
`sse_starlette`, and `uvicorn` at import. That stack now loads inside `streamable_http_app()`,
944+
`sse_app()`, and `custom_route()`, their only users; a stdio server never pays for it. Two
945+
things follow:
946+
947+
* The HTTP names that were only incidentally reachable as attributes of those two modules
948+
(`Starlette`, `Route`, `Mount`, `EventStore`, `TransportSecuritySettings`,
949+
`StreamableHTTPSessionManager`, `SseServerTransport`, and the auth middlewares/routes) are no
950+
longer bound there. Import them from their homes (`starlette.applications`,
951+
`mcp.server.streamable_http`, `mcp.server.transport_security`,
952+
`mcp.server.streamable_http_manager`, `mcp.server.sse`, `mcp.server.auth.middleware.*`,
953+
`mcp.server.auth.routes`).
954+
* `typing.get_type_hints()` on the HTTP-app methods (`streamable_http_app`, `sse_app`,
955+
`run_sse_async`, `run_streamable_http_async`, and the `session_manager` properties) raises
956+
`NameError`, because their annotations name types those modules import for type checkers only;
957+
pass them yourself as `localns={...}` if you evaluate the hints at runtime. See
958+
[Startup cost](advanced/startup.md).
959+
880960
### `MCPServer.get_context()` removed
881961

882962
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
@@ -2056,6 +2136,21 @@ result = await client.call_tool("long_running_task", {}, progress_callback=on_pr
20562136

20572137
Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool definitions: the `TASK_REQUIRED` / `TASK_OPTIONAL` / `TASK_FORBIDDEN` constants are gone from `mcp.types` (`ToolExecution.task_support` takes the plain `"required"` / `"optional"` / `"forbidden"` literal), and no v2 client or server reads the field.
20582138

2139+
### `mcp.client.client` no longer imports the server stack
2140+
2141+
The client module no longer imports the server, so names that were only incidentally
2142+
reachable as attributes of `mcp.client.client` (`Server`, `MCPServer`, `modern_on_request`,
2143+
`InMemoryTransport`, `streamable_http_client`) are no longer bound there. Import and
2144+
`mock.patch` them at their own modules: `mcp.server.Server`, `mcp.server.mcpserver.MCPServer`,
2145+
`mcp.server.runner.modern_on_request`, `mcp.client.streamable_http.streamable_http_client`
2146+
(the in-memory transport is constructed for you by `Client(server)`).
2147+
2148+
One introspection consequence: `typing.get_type_hints(mcp.Client)` (and of `Client.__init__`)
2149+
now raises `NameError`, because the `server` field annotation names imports that exist only for
2150+
type checkers. Static typing, `inspect.signature`, `dataclasses.fields`, and every documented
2151+
use are unaffected; if you do evaluate those hints at runtime, pass
2152+
`localns={"Server": mcp.server.Server, "MCPServer": mcp.server.mcpserver.MCPServer}`.
2153+
20592154
## Transports
20602155

20612156
Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)), so the sections below are client-side apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors); the other server-side transport changes ([lifespan entered once](#streamable-http-lifespan-now-entered-once-at-manager-startup), the [4 MiB request-body limit](#streamable-http-request-bodies-are-limited-to-4-mib)) sit under MCPServer.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ nav:
6767
- Middleware: advanced/middleware.md
6868
- Extensions: advanced/extensions.md
6969
- MCP Apps: advanced/apps.md
70+
- Startup cost: advanced/startup.md
7071
- Troubleshooting: troubleshooting.md
7172
- Migration Guide: migration.md
7273
- API Reference: api/

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,10 @@ max-complexity = 24 # Default is 10
219219
"__init__.py" = ["F401"]
220220
# The mcp.types package is an alias that mirrors mcp_types namespaces by design.
221221
"src/mcp/types/*.py" = ["F403"]
222-
# Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators).
222+
# Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators
223+
# and their shared `WireRootModel` base).
223224
"src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"]
225+
"src/mcp-types/mcp_types/_wire_base.py" = ["TID251"]
224226
"tests/server/mcpserver/test_func_metadata.py" = ["E501"]
225227
"tests/shared/test_progress_notifications.py" = ["PLW0603"]
226228

scripts/gen_surface_types.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,28 @@ def patch(match: re.Match[str]) -> str:
219219
return source
220220

221221

222+
def use_deferred_bases(source: str) -> str:
223+
"""Route root models through the deferred `WireRootModel`; drop the trailing `model_rebuild()` calls.
224+
225+
Object models already defer via `--base-class WireModel`. A bare `RootModel[X]`
226+
base parametrizes (and builds) eagerly, inline-generating the schema of every
227+
deferred model the union references, so root models must defer too. The
228+
trailing `X.model_rebuild()` block datamodel-codegen emits only force-built
229+
forward references, which a deferred model resolves from the module namespace
230+
on its first use.
231+
"""
232+
source = source.replace("RootModel[", "WireRootModel[")
233+
source = source.replace("import WireModel", "import WireModel, WireRootModel")
234+
source = re.sub(r"^(from pydantic import .*), RootModel$", r"\1", source, flags=re.MULTILINE)
235+
source = re.sub(r"^\w+\.model_rebuild\(\)\n", "", source, flags=re.MULTILINE)
236+
# Drift guard: every root model routes through the deferred base and no rebuild call survives.
237+
assert "WireRootModel[" in source and "RootModel[" not in source.replace("WireRootModel[", "")
238+
assert ".model_rebuild()" not in source
239+
# ...and no stray pydantic RootModel import survived (the strip above assumes it is trailing).
240+
assert not re.search(r"^from pydantic import .*\bRootModel\b", source, flags=re.MULTILINE)
241+
return source
242+
243+
222244
def build(entry: dict[str, str]) -> str:
223245
"""Generate, post-process, and format one version's surface module text."""
224246
version = entry["protocol_version"]
@@ -243,12 +265,9 @@ def build(entry: dict[str, str]) -> str:
243265
# strict mkdocs link validation.
244266
source = source.replace("](/", "](https://modelcontextprotocol.io/")
245267
source = allow_open_class_extras(source, OPEN_CLASSES[version])
268+
source = use_deferred_bases(source)
246269
if epilogue := EPILOGUES.get(version, ""):
247-
# Insert before the trailing model_rebuild() block: pyright's evaluation
248-
# order for the recursive RootModel block is sensitive to placement.
249-
match = re.search(r"^\w+\.model_rebuild\(\)$", source, flags=re.MULTILINE)
250-
cut = match.start() if match else len(source)
251-
source = f"{source[:cut]}{epilogue}\n\n{source[cut:]}"
270+
source = f"{source.rstrip()}\n\n\n{epilogue}"
252271
source = HEADER.format(version=version, sha=entry["sha256"]) + source
253272

254273
staging = TYPES_DIR / f"_staging_{version}.py"

0 commit comments

Comments
 (0)