Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
622aed5
Resolve the mcp package's exports lazily (PEP 562)
maxisbey Jul 29, 2026
2128b57
Resolve submodules of mcp.client, mcp.server, mcp.shared and mcp.os o…
maxisbey Jul 29, 2026
1c1b7f8
Stop mcp.client from importing the server stack
maxisbey Jul 29, 2026
1e00f42
Load httpx2 with the HTTP transports only
maxisbey Jul 29, 2026
5b25303
Keep typing.get_type_hints(Client) resolvable at runtime
maxisbey Jul 29, 2026
fcaf297
Make server-side imports pay-for-what-you-use
maxisbey Jul 29, 2026
3ac881f
Test HttpResource.read() instead of excluding it from coverage
maxisbey Jul 29, 2026
0237ed9
Load per-version wire packages lazily from the methods surface maps
maxisbey Jul 29, 2026
2a7fb0c
Keep PrimitiveSchemaDefinition reachable on mcp.server.elicitation
maxisbey Jul 29, 2026
ff6c36e
Add an import-cost ratchet for the SDK's entry points
maxisbey Jul 29, 2026
2a13e6d
Keep the Client type-hints test green on Python 3.10
maxisbey Jul 29, 2026
1f3fbc7
Use one lazy-attribute helper for the packages, invisible to type che…
maxisbey Jul 29, 2026
cdc78fc
Keep the public server signatures resolvable by typing.get_type_hints
maxisbey Jul 29, 2026
9ef39b6
Load the OAuth provider stack and cryptography with their first user,…
maxisbey Jul 29, 2026
695b1ab
Keep the wire packages statically discoverable to bundlers
maxisbey Jul 29, 2026
b8bc17d
Defer building the generated wire models and emit them in dependency …
maxisbey Jul 29, 2026
08f4385
Defer building the monolith protocol models and routing adapters
maxisbey Jul 29, 2026
45bb6c9
Defer building the SDK's own eager pydantic models and adapters
maxisbey Jul 29, 2026
1facb57
Document the import-cost contract and the deferred-work model
maxisbey Jul 29, 2026
d515c85
Cover the deferred-signature edge cases: instance access and defer_bu…
maxisbey Jul 29, 2026
8c9f2a2
Serialize the deferred first build of the SDK's pydantic models
maxisbey Jul 29, 2026
33471c7
Add mcp.warm(): opt-in prewarming for the deferred validators
maxisbey Jul 29, 2026
7fae576
Document the deferred-work bills and correct the import-cost bullets
maxisbey Jul 29, 2026
53a46e6
Construct DEFAULT_CLIENT_INFO without building the Implementation model
maxisbey Jul 29, 2026
986d3b9
Import the internal deferred_model decorator under a private name
maxisbey Jul 29, 2026
e02caca
Fail generation if a non-class statement sits between generated classes
maxisbey Jul 29, 2026
fc4f4e6
Cover the decorator's refusal paths and the runtime-only lazy-init br…
maxisbey Jul 29, 2026
b592f09
Generate a deferred model's JSON schema under the rebuild lock too
maxisbey Jul 29, 2026
6da7ba4
Spell warm()'s every-version flag as all_versions and return a frozen…
maxisbey Jul 29, 2026
008c74e
Document the prewarm recipe and the rebuild lock's terms
maxisbey Jul 29, 2026
cb7c68c
Resolve attribute chains through the four leaf sub-packages too
maxisbey Jul 29, 2026
2716d09
Pin the decorator's refusal messages and describe two wire-base tests
maxisbey Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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.
  • Loading branch information
maxisbey committed Jul 29, 2026
commit 7fae57664b0546944ada4579d4f8611e5b3d7d59
34 changes: 21 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,36 @@ breaks one of these fails that test, so keep them off the paths below. Each
such function-level or lazy import carries a one-line comment saying which
stack it keeps out of which import path.

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

## Testing

Expand Down
76 changes: 59 additions & 17 deletions docs/advanced/import-cost.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Imports & startup time

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

## What loads when

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

From there, each stack loads with the feature that needs it:
From there, each stack loads with the feature that needs it, once:

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

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

## Prewarming

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

```python
--8<-- "docs_src/import_cost/tutorial001.py"
```

Reading a row of a surface map imports that protocol version's wire package, and
`model_rebuild()` builds a model's validator; both are no-ops on anything already built. An HTTP
server needs nothing extra for its transport: building the app (`streamable_http_app()`) at startup
is exactly the moment its web stack loads anyway.
`warm()` builds the version-independent validators (the `mcp.types` models, the JSON-RPC
envelopes and the routing union adapters, ~50 ms); pass the protocol version(s) you will serve to
also import that version's wire package and build the routing surface a connection uses (~+100 ms
per version); `warm(everything=True)` covers every known version. The models are always completed
before the adapters that reference them, nothing is built twice, and repeat calls are no-ops. The
returned `WarmReport` counts what a call built, for logging. Do it once, at startup: nothing in
the SDK calls `warm()` for you, and importing the SDK stays fast either way.

An HTTP server needs nothing extra for its transport: building the app (`streamable_http_app()`) at
startup is exactly the moment its web stack loads anyway.

## FAQ

**A pydantic plugin (e.g. `logfire`) is installed and `import mcp.types` is slow.** pydantic
auto-loads every installed plugin the first time a model class is created, and a few of the SDK's
generic base classes are created at import; with `logfire` installed that adds its import
(~200 ms) to `import mcp.types`. Set `PYDANTIC_DISABLE_PLUGINS=__all__` (or list the plugins you do
want) to keep it out of your import path. `import mcp` alone is unaffected.

**`Model.__pydantic_complete__` is `False` right after import.** Expected: the model builds on
first use. Code that checks the flag and then calls `Model.model_rebuild()` still works (the call is
a no-op once built); code asserting the flag at import will trip. `mcp.warm()` completes them all if
you need that up front.

**A subclass I defined inside a function shows `(**data)` from `inspect.signature()`.** The SDK's
models complete their build (and their real signature) on the first `inspect.signature()` access,
but a class defined in a *local* namespace whose annotations reference other locals cannot resolve
those from outside that function, so it keeps pydantic's generic signature until it is first used
where the names resolve. Module-level subclasses are unaffected.

**Bundling with PyInstaller / Nuitka / cx_Freeze.** The per-version wire-schema packages
(`mcp_types._v2025_11_25`, `mcp_types._v2026_07_28`) and the SDK's submodules are imported lazily. The
imports are still present in the bytecode, so import scanners generally find them; if your bundler
does not, add the packages to its hidden imports (PyInstaller: `--hidden-import mcp_types._v2026_07_28`,
and `--collect-submodules mcp` for the SDK's own lazily-resolved submodules).

**`typing.get_type_hints()` on the HTTP app builders raises `NameError`.** The Starlette-owned
annotations of `MCPServer.sse_app` / `streamable_http_app` (and the lowlevel `Server`'s
`streamable_http_app`) are typing-only, since evaluating them would import the web stack. Every
SDK-owned name in those signatures still resolves; supply starlette's names via `localns=` if you
need the full hints evaluated. See the [migration guide](../migration.md#import-graph-and-startup).
2 changes: 1 addition & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2102,7 +2102,7 @@ v1's internal client set `follow_redirects=True`; set it explicitly when supplyi
`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:

- `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`.
- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
- `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.
- `terminate_on_close`: unchanged (default `True`).

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.
Expand Down