Skip to content

Commit 7fae576

Browse files
committed
Document the deferred-work bills and correct the import-cost bullets
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.
1 parent 33471c7 commit 7fae576

3 files changed

Lines changed: 81 additions & 31 deletions

File tree

‎AGENTS.md‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,28 +56,36 @@ breaks one of these fails that test, so keep them off the paths below. Each
5656
such function-level or lazy import carries a one-line comment saying which
5757
stack it keeps out of which import path.
5858

59-
- `mcp/__init__.py` and the `mcp.client` / `mcp.server` / `mcp.shared` /
60-
`mcp.os` package inits resolve their exports and submodules on first
61-
access (PEP 562 `__getattr__`); a bare `import mcp` loads no pydantic,
62-
`mcp_types` or SDK submodule.
59+
- `mcp/__init__.py` resolves each of its exports on first access, and the
60+
`mcp`, `mcp.client`, `mcp.server`, `mcp.server.auth`, `mcp.shared` and
61+
`mcp.os` package inits resolve their *submodules* on attribute access -
62+
both through `mcp.shared._lazy` (PEP 562 `__getattr__`, bound under
63+
`if not TYPE_CHECKING:` so type checkers still flag typos); a bare
64+
`import mcp` loads no pydantic, no `mcp_types` and no SDK stack.
6365
- Client code never imports `mcp.server.*`: the in-process transport pieces
6466
are imported inside the connector that only a live in-process `Server`
6567
triggers, and `Server`/`MCPServer` are `TYPE_CHECKING`-only names.
6668
- `httpx2` loads only for the HTTP client transports (`streamable_http`,
6769
`sse`), on the first URL `Client`; a stdio client never imports it.
6870
- The web stack (`starlette`, `sse_starlette`, `uvicorn`) loads only when an
6971
HTTP app is built (`streamable_http_app()`, `sse_app()`, `custom_route()`),
70-
never from `mcp.server`, the lowlevel `Server`, `MCPServer` or stdio; the
71-
request access-token contextvar lives in the starlette-free
72-
`mcp.server.auth.access_token` for that reason.
73-
- `opentelemetry` is imported on the first span, `cryptography`-backed
74-
request-state codecs on first construction, `jwt`/`cryptography` only by
75-
the auth paths that use them.
72+
never from `mcp.server`, the lowlevel `Server`, `MCPServer` or stdio;
73+
types those signatures name are either defined in web-framework-free
74+
modules (`mcp.server.event_store`, `mcp.server.transport_security`,
75+
`mcp.server.auth.access_token`) or spelled through the lazy `mcp.server`
76+
namespace so `typing.get_type_hints()` still resolves them.
77+
- `opentelemetry` is imported on the first span; `cryptography` with the
78+
first request-state codec (`MCPServer(...)` builds one), and the OAuth
79+
provider models with the first auth-enabled server or authenticated
80+
request - none of them at `import mcp.server*`; `jwt` only by the
81+
client-credentials auth extension.
7682
- The per-version wire packages (`mcp_types._v2025_11_25`, `_v2026_07_28`)
77-
load on the first message parsed for that protocol version, via the
78-
`mcp_types.methods` surface maps - not on `import mcp_types`.
83+
load on the first message parsed for that protocol version (via the
84+
`mcp_types.methods` surface maps), on the first rendered elicitation
85+
schema, or via `mcp.warm(version)` - not on `import mcp_types`.
7986
- Pydantic models set `defer_build=True` (through `MCPModel`, the generated
80-
wire bases, or per class): validators build on first use, not at import.
87+
wire bases, or `@deferred_model`): validators build on first use, not at
88+
import; `mcp.warm()` is the opt-in way to build them up front.
8189

8290
## Testing
8391

‎docs/advanced/import-cost.md‎

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Imports & startup time
22

33
`import mcp` is close to free, and every entry point loads only what it uses. You get that
4-
without doing anything; this page is for when you want to know *what* loads *when*, or when you
5-
want to move the deferred work to a moment of your choosing.
4+
without doing anything; this page is for when you want to know *what* loads *when*, how big the
5+
deferred bills are, or when you want to move that work to a moment of your choosing.
66

77
## What loads when
88

@@ -12,30 +12,72 @@ touch it (`from mcp import Client` imports the client, `mcp.Tool` imports the ty
1212
an ordinary attribute. `from mcp import *`, `dir(mcp)` and object identity are unchanged; the
1313
supported way to reach a submodule is still to import it (`import mcp.client.stdio`).
1414

15-
From there, each stack loads with the feature that needs it:
15+
From there, each stack loads with the feature that needs it, once:
1616

17-
| Loaded on first use of | What loads |
18-
| --- | --- |
19-
| a URL `Client(...)` (or the SSE / streamable-HTTP client modules) | the HTTP client stack (`httpx2`) |
20-
| `streamable_http_app()` / `sse_app()` / `custom_route()` | the web stack (`starlette`, `sse_starlette`, `uvicorn`) |
21-
| the first message parsed for a protocol version | that version's wire-schema package (`mcp_types._v2025_11_25` / `_v2026_07_28`) |
22-
| the first construction or validation of a model | that model's pydantic validator (`defer_build=True`) |
23-
| the first span | the OpenTelemetry API |
17+
| Loaded on first use of | What loads | Roughly |
18+
| --- | --- | --- |
19+
| a URL `Client(...)` (or the SSE / streamable-HTTP client modules) | the HTTP client stack (`httpx2`) | ~60 ms |
20+
| `streamable_http_app()` / `sse_app()` / `custom_route()` | the web stack (`starlette`, `sse_starlette`, `uvicorn`) | ~55 ms |
21+
| the first message parsed for a protocol version | that version's wire-schema package (`mcp_types._v2025_11_25` / `_v2026_07_28`) | ~50 ms |
22+
| the first construction, validation or JSON schema of a model | that model's pydantic validator (`defer_build=True`) | ~0.3 ms per model |
23+
| the first server span | the OpenTelemetry API | ~7 ms |
24+
| the first `MCPServer(...)` (its default `requestState` codec) | `cryptography` | ~10 ms |
25+
| the first OAuth-enabled server | the OAuth provider models | ~10 ms |
2426

2527
None of these is per request. Each is a one-time cost paid where the work happens, and the
2628
steady state afterwards is identical to having loaded it up front. Client code never loads the
27-
server stack, and a stdio server never loads the web stack.
29+
server stack, and a stdio server never loads the web stack. Concretely, a fresh process pays
30+
about **+130 ms once on its first in-memory connection and tool call** compared to a process that
31+
built everything at import — the same work moved, not added — while `import mcp` itself dropped
32+
from ~600 ms to ~3 ms and a typical `from mcp.types import ...` from ~600 ms to ~130 ms.
2833

2934
## Prewarming
3035

31-
If you would rather pay the deferred work at startup than on the first request — a
32-
latency-sensitive host, say — trigger it explicitly before you start serving:
36+
If you would rather pay the deferred model work at startup than on the first message — a
37+
latency-sensitive long-running host, say — call `mcp.warm()` from your startup hook:
3338

3439
```python
3540
--8<-- "docs_src/import_cost/tutorial001.py"
3641
```
3742

38-
Reading a row of a surface map imports that protocol version's wire package, and
39-
`model_rebuild()` builds a model's validator; both are no-ops on anything already built. An HTTP
40-
server needs nothing extra for its transport: building the app (`streamable_http_app()`) at startup
41-
is exactly the moment its web stack loads anyway.
43+
`warm()` builds the version-independent validators (the `mcp.types` models, the JSON-RPC
44+
envelopes and the routing union adapters, ~50 ms); pass the protocol version(s) you will serve to
45+
also import that version's wire package and build the routing surface a connection uses (~+100 ms
46+
per version); `warm(everything=True)` covers every known version. The models are always completed
47+
before the adapters that reference them, nothing is built twice, and repeat calls are no-ops. The
48+
returned `WarmReport` counts what a call built, for logging. Do it once, at startup: nothing in
49+
the SDK calls `warm()` for you, and importing the SDK stays fast either way.
50+
51+
An HTTP server needs nothing extra for its transport: building the app (`streamable_http_app()`) at
52+
startup is exactly the moment its web stack loads anyway.
53+
54+
## FAQ
55+
56+
**A pydantic plugin (e.g. `logfire`) is installed and `import mcp.types` is slow.** pydantic
57+
auto-loads every installed plugin the first time a model class is created, and a few of the SDK's
58+
generic base classes are created at import; with `logfire` installed that adds its import
59+
(~200 ms) to `import mcp.types`. Set `PYDANTIC_DISABLE_PLUGINS=__all__` (or list the plugins you do
60+
want) to keep it out of your import path. `import mcp` alone is unaffected.
61+
62+
**`Model.__pydantic_complete__` is `False` right after import.** Expected: the model builds on
63+
first use. Code that checks the flag and then calls `Model.model_rebuild()` still works (the call is
64+
a no-op once built); code asserting the flag at import will trip. `mcp.warm()` completes them all if
65+
you need that up front.
66+
67+
**A subclass I defined inside a function shows `(**data)` from `inspect.signature()`.** The SDK's
68+
models complete their build (and their real signature) on the first `inspect.signature()` access,
69+
but a class defined in a *local* namespace whose annotations reference other locals cannot resolve
70+
those from outside that function, so it keeps pydantic's generic signature until it is first used
71+
where the names resolve. Module-level subclasses are unaffected.
72+
73+
**Bundling with PyInstaller / Nuitka / cx_Freeze.** The per-version wire-schema packages
74+
(`mcp_types._v2025_11_25`, `mcp_types._v2026_07_28`) and the SDK's submodules are imported lazily. The
75+
imports are still present in the bytecode, so import scanners generally find them; if your bundler
76+
does not, add the packages to its hidden imports (PyInstaller: `--hidden-import mcp_types._v2026_07_28`,
77+
and `--collect-submodules mcp` for the SDK's own lazily-resolved submodules).
78+
79+
**`typing.get_type_hints()` on the HTTP app builders raises `NameError`.** The Starlette-owned
80+
annotations of `MCPServer.sse_app` / `streamable_http_app` (and the lowlevel `Server`'s
81+
`streamable_http_app`) are typing-only, since evaluating them would import the web stack. Every
82+
SDK-owned name in those signatures still resolves; supply starlette's names via `localns=` if you
83+
need the full hints evaluated. See the [migration guide](../migration.md#import-graph-and-startup).

‎docs/migration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2102,7 +2102,7 @@ v1's internal client set `follow_redirects=True`; set it explicitly when supplyi
21022102
`streamable_http_client` itself keeps a small signature — `streamable_http_client(url, *, http_client=None, terminate_on_close=True)` — and now yields a 2-tuple (next section). The removed function's other parameters map onto the client you build:
21032103

21042104
- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts and `follow_redirects=True`.
2105-
- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
2105+
- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`. The `McpHttpClientFactory` protocol type is no longer re-exported from `mcp.client.streamable_http` either; annotate your factory as `Callable[..., httpx2.AsyncClient]` (or your own protocol) instead of importing it.
21062106
- `terminate_on_close`: unchanged (default `True`).
21072107

21082108
Client-side stream resumption is also unchanged: the transport reconnects a dropped GET stream with `Last-Event-ID` on its own, and `session.send_request(..., metadata=ClientMessageMetadata(resumption_token=..., on_resumption_token_update=...))` (from `mcp.shared.message`) works as in v1.

0 commit comments

Comments
 (0)