Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
44 changes: 42 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,49 @@
- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a
symbol there is a deliberate API decision, not a convenience re-export.
- IMPORTANT: All imports go at the top of the file — inline imports hide
dependencies and obscure circular-import bugs. Only exception: when a
dependencies and obscure circular-import bugs. Exceptions: when a
top-level import genuinely can't work (lazy-loading optional deps, or
tests that re-import a module).
tests that re-import a module), and the import-cost cases below.

### Import cost

Startup time is part of the API: `import mcp` is a lazy namespace and each
entry point only loads what it uses. `tests/test_import_guards.py` pins
which heavy dependencies each import path may load; a hoisted import that
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` 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;
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), 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 `@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
113 changes: 113 additions & 0 deletions docs/advanced/import-cost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 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*, how big the
deferred bills are, or when you want to move that work to a moment of your choosing.

## What loads when

The `mcp` package is a lazy namespace: `import mcp` binds no protocol types, no pydantic, and
none of the client or server modules. Each name resolves from its home module the first time you
touch it (`from mcp import Client` imports the client, `mcp.Tool` imports the types), and is then
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, once:

| 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 |

(The magnitudes are single-machine measurements; expect the same shape, not the same numbers.)

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

A long-running host that would rather pay the deferred work at startup than on its first
connection calls `mcp.warm(version)` from its startup hook, naming the protocol version its
clients negotiate:

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

`warm(version)` builds the version-independent validators (the `mcp.types` models, the JSON-RPC
envelopes and the routing union adapters, ~50 ms) and imports that protocol version's wire package
and builds the routing surface a connection at that version uses (~+100 ms), so the first
connection finds everything built: measured, a host's first connection (initialize plus the first
tools/resources/prompts requests) drops from ~200 ms of one-time builds to ~65 ms, within a
millisecond of a steady-state connection. Name the version your transport negotiates: stdio and
streamable-HTTP sessions negotiate `2025-11-25` today, the in-memory `Client` `2026-07-28`.

The two other spellings are narrower and wider:

- `warm()` with no version builds only the version-independent set; the first connection still
imports and builds its version's routing surface.
- `warm(all_versions=True)` warms every known version's routing surface, for a proxy or gateway
serving clients of both eras.

The models are always completed before the adapters that reference them, nothing is built twice,
and repeat calls are no-ops. The returned `WarmReport` (a frozen dataclass: `models`, `adapters`,
`elapsed_ms`) 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. An `MCPServer`'s own settings, tool and
prompt models build when the server object and its tools are constructed, which is startup work in
its own right.

## 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. Note that evaluating hints imports what they name:
`typing.get_type_hints(mcp.Client)` resolves `Client.server`'s annotation and so imports
`mcp.server`. See the [migration guide](../migration.md#import-graph-and-startup).

**Locks around the first (deferred) build.** A model's first build - and the JSON-schema generation
of a not-yet-built model - runs under one process-wide reentrant lock, so concurrent first uses build
each model exactly once instead of racing (the same design pydantic itself adopted for its own
`model_rebuild`). Two consequences: don't hold your own lock across a model's first use or inside a
custom `__get_pydantic_core_schema__` / subclass hook and then take that same lock elsewhere while
another thread first-uses a model (an ordinary lock-order inversion), and a thread that is mid-build
during `os.fork()` leaves the child's lock held (the standard fork-with-threads caveat). Calling
`mcp.warm(version)` at startup builds everything on one thread and makes concurrent first builds
impossible in the first place. Once a pydantic release with a thread-safe `model_rebuild` is the
SDK's dependency floor, the SDK's lock will be dropped.
2 changes: 2 additions & 0 deletions docs/advanced/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ layer is in the way:
can *only* do on the low-level `Server`.
* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's
extension surface. Compose extension packages into a server, or write your own.
* **[Imports & startup time](import-cost.md)**: what the SDK loads when, and how to
move its deferred first-use work to startup if you want it there.

A few things you might reasonably look for here live where you'd actually use them
instead:
Expand Down
58 changes: 57 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Every section heading below names the API it affects, so searching this page for
| use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) |
| maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) |
| relied on lenient handling of off-schema traffic, or assert on exact wire bytes | [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) |
| rely on `import mcp` importing submodules, patch SDK internals, or subclass SDK pydantic models | [Import graph and startup](#import-graph-and-startup) |
| test against in-memory server/client pairs | [Testing utilities](#testing-utilities) |
| use roots, sampling, logging, or client-to-server progress | [Deprecations](#deprecations) |
| operate servers that 2026-era clients will also connect to | [Notes for 2026-era connections](#notes-for-2026-era-connections) |
Expand Down Expand Up @@ -2101,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 Expand Up @@ -2678,6 +2679,61 @@ The envelope exists for OpenTelemetry trace propagation ([SEP-414](https://githu

The SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli).

## Import graph and startup

### Imports are lazy; explicit imports are the supported form

`import mcp` no longer imports the SDK's whole module graph: the `mcp` package resolves each
export from its home module on first access, and every entry point loads only its own stack
(no server code behind a client, no web framework behind a stdio server, no wire schemas
before a message needs them). Nothing you import changes name or location, and
`from mcp import Client`, `mcp.types.Tool`, `from mcp import *` and object identity behave as
before. See [Imports & startup time](advanced/import-cost.md) for what loads when and how to
prewarm it.

A few incidental things did change:

* **Attribute chains that were never imported explicitly.** `import mcp` used to bind most
submodules as a side effect, so `import mcp` followed by `mcp.client.stdio.stdio_client(...)`
happened to work. It still resolves (the packages import a submodule on attribute access), but
the supported form is to import what you use: `from mcp.client.stdio import stdio_client`, or
`import mcp.client.stdio`.
* **Namespace bindings that tests used to patch.** Modules stopped re-binding names they only
imported: patch or import each object where it is defined. In particular,
`mcp.client.client.streamable_http_client` is `mcp.client.streamable_http.streamable_http_client`,
the HTTP-app pieces formerly bound in `mcp.server.lowlevel.server` / `mcp.server.mcpserver.server`
(`SseServerTransport`, `StreamableHTTPSessionManager`, the auth middleware and route builders)
live in `mcp.server.sse`, `mcp.server.streamable_http_manager` and `mcp.server.auth.*`, and
`get_access_token` / `auth_context_var` are defined in the transport-agnostic
`mcp.server.auth.access_token` (still importable from `mcp.server.auth.middleware.auth_context`).
* **Two web-framework-free homes.** The resumability contract (`EventStore`, `EventMessage`,
`EventCallback`, `EventId`, `StreamId`) is defined in `mcp.server.event_store`, and
`TransportSecurityMiddleware` now lives beside the transports rather than in
`mcp.server.transport_security`; both keep their previous import paths
(`mcp.server.streamable_http.EventStore`, `mcp.server.transport_security.TransportSecurityMiddleware`,
same objects). Only code introspecting `Class.__module__` or pickling those *class objects* by
reference notices.
* **Introspecting the app-builder signatures.** `typing.get_type_hints()` on the HTTP app
builders (`MCPServer.sse_app`, `MCPServer.streamable_http_app`, `Server.streamable_http_app`,
`MCPServer.custom_route`'s handler type) raises `NameError` for the Starlette-owned annotations
(`Starlette`, `Route`, `Request`, `Response`), which are typing-only now; every SDK-owned name in
those signatures still evaluates, and `inspect.signature()` is unaffected. Import starlette's
names into your own namespace and pass `localns=` if you need those hints evaluated. Relatedly,
evaluating hints imports what they name: `typing.get_type_hints(mcp.Client)` resolves the
`server` parameter's annotation and so imports `mcp.server`.

### Pydantic models build on first use (`defer_build=True`)

The SDK's models and `TypeAdapter`s defer their validator build to first construction or
validation instead of building at import; `inspect.signature(Model)`, `model_fields` and
`model_json_schema()` report what they always did, and the one-time build is serialized across
threads (fixing a rare failure earlier v2 releases could hit when several threads first-used the
same type at once). A model you subclass from the SDK (`Tool`, `Resource`, `AuthSettings`, ...)
inherits the deferral, so an annotation that cannot resolve in your subclass now surfaces at
first use rather than at class creation, and `Model.__pydantic_complete__` reads `False` until
that first use. Set `model_config = ConfigDict(defer_build=False)` in your subclass, or call
`Model.model_rebuild()` after defining it, if you want the build (and its errors) up front.

## Testing utilities

### `create_connected_server_and_client_session` removed
Expand Down
4 changes: 4 additions & 0 deletions docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ On those types, every Python attribute is now snake_case: `result.is_error`, `to

**[Running your server](run/index.md)** covers the options; **[Add to an existing app](run/asgi.md)** covers mounting.

### Imports pay for what you use

`import mcp` is now nearly free, and every entry point loads only its own stack: a stdio server never loads the web framework, client code never loads the server, and the protocol's wire schemas load per negotiated version. The work did not vanish, it moved to first use (the first URL `Client`, the first HTTP app, the first message per protocol version), each a one-time cost. That first use is also thread-safe now: the SDK serializes each model's one-time build, which fixes an occasional failure v2 could hit when several threads first-used the same protocol type at once (for example, one client session per thread at process start). **[Imports & startup time](advanced/import-cost.md)** lists what loads when, and how to prewarm all of it at startup if that is where you want it.

### Behavior that changes without an import error

The renames announce themselves. These do not:
Expand Down
Empty file.
14 changes: 14 additions & 0 deletions docs_src/import_cost/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Prewarm the SDK's deferred startup work in a host's startup hook.

Imports stay fast because the heavy pieces load on first use: a protocol
version's wire schemas load on the first message parsed for that version, and
each pydantic model builds its validator the first time it is used. A host that
would rather pay that once, before serving traffic, calls `mcp.warm()`.
"""

import mcp

# Build the deferred validators now: the version-independent set, plus the
# routing surface of each protocol version this host will serve.
report = mcp.warm("2025-11-25")
print(f"prewarmed {report.models} models and {report.adapters} adapters in {report.elapsed_ms} ms")
Loading
Loading