Skip to content

Commit 1facb57

Browse files
committed
Document the import-cost contract and the deferred-work model
- 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.
1 parent 45bb6c9 commit 1facb57

9 files changed

Lines changed: 194 additions & 2 deletions

File tree

‎AGENTS.md‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,41 @@
4343
- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a
4444
symbol there is a deliberate API decision, not a convenience re-export.
4545
- IMPORTANT: All imports go at the top of the file — inline imports hide
46-
dependencies and obscure circular-import bugs. Only exception: when a
46+
dependencies and obscure circular-import bugs. Exceptions: 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), and the import-cost cases below.
49+
50+
### Import cost
51+
52+
Startup time is part of the API: `import mcp` is a lazy namespace and each
53+
entry point only loads what it uses. `tests/test_import_guards.py` pins
54+
which heavy dependencies each import path may load; a hoisted import that
55+
breaks one of these fails that test, so keep them off the paths below. Each
56+
such function-level or lazy import carries a one-line comment saying which
57+
stack it keeps out of which import path.
58+
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.
63+
- Client code never imports `mcp.server.*`: the in-process transport pieces
64+
are imported inside the connector that only a live in-process `Server`
65+
triggers, and `Server`/`MCPServer` are `TYPE_CHECKING`-only names.
66+
- `httpx2` loads only for the HTTP client transports (`streamable_http`,
67+
`sse`), on the first URL `Client`; a stdio client never imports it.
68+
- The web stack (`starlette`, `sse_starlette`, `uvicorn`) loads only when an
69+
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.
76+
- 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`.
79+
- Pydantic models set `defer_build=True` (through `MCPModel`, the generated
80+
wire bases, or per class): validators build on first use, not at import.
4981

5082
## Testing
5183

‎docs/advanced/import-cost.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Imports & startup time
2+
3+
`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.
6+
7+
## What loads when
8+
9+
The `mcp` package is a lazy namespace: `import mcp` binds no protocol types, no pydantic, and
10+
none of the client or server modules. Each name resolves from its home module the first time you
11+
touch it (`from mcp import Client` imports the client, `mcp.Tool` imports the types), and is then
12+
an ordinary attribute. `from mcp import *`, `dir(mcp)` and object identity are unchanged; the
13+
supported way to reach a submodule is still to import it (`import mcp.client.stdio`).
14+
15+
From there, each stack loads with the feature that needs it:
16+
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 |
24+
25+
None of these is per request. Each is a one-time cost paid where the work happens, and the
26+
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.
28+
29+
## Prewarming
30+
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:
33+
34+
```python
35+
--8<-- "docs_src/import_cost/tutorial001.py"
36+
```
37+
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.

‎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+
* **[Imports & startup time](import-cost.md)**: what the SDK loads when, and how to
15+
move its deferred first-use work to startup if you want it there.
1416

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

‎docs/migration.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Every section heading below names the API it affects, so searching this page for
4040
| use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) |
4141
| maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) |
4242
| 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) |
43+
| rely on `import mcp` importing submodules, patch SDK internals, or subclass SDK pydantic models | [Import graph and startup](#import-graph-and-startup) |
4344
| test against in-memory server/client pairs | [Testing utilities](#testing-utilities) |
4445
| use roots, sampling, logging, or client-to-server progress | [Deprecations](#deprecations) |
4546
| operate servers that 2026-era clients will also connect to | [Notes for 2026-era connections](#notes-for-2026-era-connections) |
@@ -2678,6 +2679,57 @@ The envelope exists for OpenTelemetry trace propagation ([SEP-414](https://githu
26782679

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

2682+
## Import graph and startup
2683+
2684+
### Imports are lazy; explicit imports are the supported form
2685+
2686+
`import mcp` no longer imports the SDK's whole module graph: the `mcp` package resolves each
2687+
export from its home module on first access, and every entry point loads only its own stack
2688+
(no server code behind a client, no web framework behind a stdio server, no wire schemas
2689+
before a message needs them). Nothing you import changes name or location, and
2690+
`from mcp import Client`, `mcp.types.Tool`, `from mcp import *` and object identity behave as
2691+
before. See [Imports & startup time](advanced/import-cost.md) for what loads when and how to
2692+
prewarm it.
2693+
2694+
Two incidental things did change:
2695+
2696+
* **Attribute chains that were never imported explicitly.** `import mcp` used to bind most
2697+
submodules as a side effect, so `import mcp` followed by `mcp.client.stdio.stdio_client(...)`
2698+
happened to work. It still resolves (the packages import a submodule on attribute access), but
2699+
the supported form is to import what you use: `from mcp.client.stdio import stdio_client`, or
2700+
`import mcp.client.stdio`.
2701+
* **Namespace bindings that tests used to patch.** Modules stopped re-binding names they only
2702+
imported: patch or import each object where it is defined. In particular,
2703+
`mcp.client.client.streamable_http_client` is `mcp.client.streamable_http.streamable_http_client`,
2704+
the HTTP-app pieces formerly bound in `mcp.server.lowlevel.server` / `mcp.server.mcpserver.server`
2705+
(`SseServerTransport`, `StreamableHTTPSessionManager`, the auth middleware and route builders)
2706+
live in `mcp.server.sse`, `mcp.server.streamable_http_manager` and `mcp.server.auth.*`, and
2707+
`get_access_token` / `auth_context_var` are defined in the transport-agnostic
2708+
`mcp.server.auth.access_token` (still importable from `mcp.server.auth.middleware.auth_context`).
2709+
* **Two web-framework-free homes.** The resumability contract (`EventStore`, `EventMessage`,
2710+
`EventCallback`, `EventId`, `StreamId`) is defined in `mcp.server.event_store`, and
2711+
`TransportSecurityMiddleware` now lives beside the transports rather than in
2712+
`mcp.server.transport_security`; both keep their previous import paths
2713+
(`mcp.server.streamable_http.EventStore`, `mcp.server.transport_security.TransportSecurityMiddleware`,
2714+
same objects). Only code introspecting `Class.__module__` or pickling those *class objects* by
2715+
reference notices.
2716+
* **Introspecting the app-builder signatures.** `typing.get_type_hints()` on the HTTP app
2717+
builders (`MCPServer.sse_app`, `MCPServer.streamable_http_app`, `Server.streamable_http_app`,
2718+
`MCPServer.custom_route`'s handler type) raises `NameError` for the Starlette-owned annotations
2719+
(`Starlette`, `Route`, `Request`, `Response`), which are typing-only now; every SDK-owned name in
2720+
those signatures still evaluates, and `inspect.signature()` is unaffected. Import starlette's
2721+
names into your own namespace and pass `localns=` if you need those hints evaluated.
2722+
2723+
### Pydantic models build on first use (`defer_build=True`)
2724+
2725+
The SDK's models and `TypeAdapter`s defer their validator build to first construction or
2726+
validation instead of building at import; `inspect.signature(Model)`, `model_fields` and
2727+
`model_json_schema()` report what they always did. A model you subclass from the SDK
2728+
(`Tool`, `Resource`, `AuthSettings`, ...) inherits the deferral, so an annotation that cannot
2729+
resolve in your subclass now surfaces at first use rather than at class creation. Set
2730+
`model_config = ConfigDict(defer_build=False)` in your subclass, or call `Model.model_rebuild()`
2731+
after defining it, if you want the build (and its errors) up front.
2732+
26812733
## Testing utilities
26822734

26832735
### `create_connected_server_and_client_session` removed

‎docs/whats-new.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ On those types, every Python attribute is now snake_case: `result.is_error`, `to
124124

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

127+
### Imports pay for what you use
128+
129+
`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. **[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.
130+
127131
### Behavior that changes without an import error
128132

129133
The renames announce themselves. These do not:

‎docs_src/import_cost/__init__.py‎

Whitespace-only changes.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Prewarm the SDK's deferred startup work.
2+
3+
Imports stay fast because the heavy pieces load on first use: a protocol
4+
version's wire schemas load on the first message parsed for that version, and
5+
each pydantic model builds its validator the first time it is used. A host that
6+
would rather pay all of that once, before serving traffic, can trigger it here.
7+
"""
8+
9+
import mcp_types
10+
from mcp_types.methods import CLIENT_REQUESTS
11+
12+
# Load the wire-schema package for each protocol version you will speak.
13+
# Reading any row of a surface map imports that version's package.
14+
_ = CLIENT_REQUESTS[("initialize", "2025-11-25")]
15+
16+
# Build the validators for the models on your hot path up front.
17+
for model in (mcp_types.CallToolRequest, mcp_types.CallToolResult, mcp_types.Tool):
18+
model.model_rebuild()

‎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+
- "Imports & startup time": advanced/import-cost.md
7071
- Troubleshooting: troubleshooting.md
7172
- Migration Guide: migration.md
7273
- API Reference: api/

‎tests/docs_src/test_import_cost.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""`docs/advanced/import-cost.md`: the prewarm recipe does what the page says."""
2+
3+
import json
4+
import os
5+
import subprocess
6+
import sys
7+
8+
# The recipe's effect is only observable in a fresh interpreter: this process has long
9+
# since loaded the wire packages and built the models.
10+
PROBE = """
11+
import json, sys
12+
import mcp_types
13+
14+
before = {
15+
"wire_2025_loaded": "mcp_types._v2025_11_25" in sys.modules,
16+
"tool_built": mcp_types.Tool.__pydantic_complete__,
17+
}
18+
import docs_src.import_cost.tutorial001 # the recipe under test
19+
after = {
20+
"wire_2025_loaded": "mcp_types._v2025_11_25" in sys.modules,
21+
"tool_built": mcp_types.Tool.__pydantic_complete__,
22+
}
23+
print(json.dumps({"before": before, "after": after}))
24+
"""
25+
26+
27+
def test_prewarm_recipe_loads_the_wire_package_and_builds_the_models() -> None:
28+
"""tutorial001: before the recipe nothing is loaded/built; after it, the negotiated
29+
version's wire package is imported and the named models are built."""
30+
result = subprocess.run(
31+
[sys.executable, "-c", PROBE],
32+
capture_output=True,
33+
text=True,
34+
check=False,
35+
timeout=60,
36+
# pydantic plugins (e.g. logfire) building models is the environment's cost, not ours
37+
env={**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"},
38+
)
39+
assert result.returncode == 0, result.stderr
40+
observed = json.loads(result.stdout)
41+
assert observed["before"] == {"wire_2025_loaded": False, "tool_built": False}
42+
assert observed["after"] == {"wire_2025_loaded": True, "tool_built": True}

0 commit comments

Comments
 (0)