Skip to content

Commit 008c74e

Browse files
committed
Document the prewarm recipe and the rebuild lock's terms
The startup page now leads its prewarming section with warm(version), the form a host actually wants: it names the version the transport negotiates and takes the first connection from ~200 ms of one-time builds to within a millisecond of steady state; the no-argument and all_versions forms are described as the narrower and wider variants. The magnitude table is labelled as single-machine measurements. Two consequences of holding one process-wide lock across a model's first build are spelled out where they can be found - the lock's docstring and an FAQ entry: user code inside pydantic schema/subclass hooks runs under the lock (so ordinary lock-ordering rules apply), a thread mid-build during fork() leaves the child's lock held, warm() at startup sidesteps both, and the SDK's lock goes once a pydantic release with a thread-safe model_rebuild is the dependency floor. The FAQ also notes that get_type_hints() imports what the hints name (evaluating mcp.Client's hints imports mcp.server), and the migration guide's list of incidental changes is no longer titled "two" now that it holds four.
1 parent 6da7ba4 commit 008c74e

3 files changed

Lines changed: 59 additions & 15 deletions

File tree

docs/advanced/import-cost.md

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ From there, each stack loads with the feature that needs it, once:
2424
| the first `MCPServer(...)` (its default `requestState` codec) | `cryptography` | ~10 ms |
2525
| the first OAuth-enabled server | the OAuth provider models | ~10 ms |
2626

27+
(The magnitudes are single-machine measurements; expect the same shape, not the same numbers.)
28+
2729
None of these is per request. Each is a one-time cost paid where the work happens, and the
2830
steady state afterwards is identical to having loaded it up front. Client code never loads the
2931
server stack, and a stdio server never loads the web stack. Concretely, a fresh process pays
@@ -33,23 +35,38 @@ from ~600 ms to ~3 ms and a typical `from mcp.types import ...` from ~600 ms to
3335

3436
## Prewarming
3537

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:
38+
A long-running host that would rather pay the deferred work at startup than on its first
39+
connection calls `mcp.warm(version)` from its startup hook, naming the protocol version its
40+
clients negotiate:
3841

3942
```python
4043
--8<-- "docs_src/import_cost/tutorial001.py"
4144
```
4245

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.
46+
`warm(version)` builds the version-independent validators (the `mcp.types` models, the JSON-RPC
47+
envelopes and the routing union adapters, ~50 ms) and imports that protocol version's wire package
48+
and builds the routing surface a connection at that version uses (~+100 ms), so the first
49+
connection finds everything built: measured, a host's first connection (initialize plus the first
50+
tools/resources/prompts requests) drops from ~200 ms of one-time builds to ~65 ms, within a
51+
millisecond of a steady-state connection. Name the version your transport negotiates: stdio and
52+
streamable-HTTP sessions negotiate `2025-11-25` today, the in-memory `Client` `2026-07-28`.
53+
54+
The two other spellings are narrower and wider:
55+
56+
- `warm()` with no version builds only the version-independent set; the first connection still
57+
imports and builds its version's routing surface.
58+
- `warm(all_versions=True)` warms every known version's routing surface, for a proxy or gateway
59+
serving clients of both eras.
60+
61+
The models are always completed before the adapters that reference them, nothing is built twice,
62+
and repeat calls are no-ops. The returned `WarmReport` (a frozen dataclass: `models`, `adapters`,
63+
`elapsed_ms`) counts what a call built, for logging. Do it once, at startup: nothing in the SDK
64+
calls `warm()` for you, and importing the SDK stays fast either way.
5065

5166
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.
67+
startup is exactly the moment its web stack loads anyway. An `MCPServer`'s own settings, tool and
68+
prompt models build when the server object and its tools are constructed, which is startup work in
69+
its own right.
5370

5471
## FAQ
5572

@@ -80,4 +97,17 @@ and `--collect-submodules mcp` for the SDK's own lazily-resolved submodules).
8097
annotations of `MCPServer.sse_app` / `streamable_http_app` (and the lowlevel `Server`'s
8198
`streamable_http_app`) are typing-only, since evaluating them would import the web stack. Every
8299
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).
100+
need the full hints evaluated. Note that evaluating hints imports what they name:
101+
`typing.get_type_hints(mcp.Client)` resolves `Client.server`'s annotation and so imports
102+
`mcp.server`. See the [migration guide](../migration.md#import-graph-and-startup).
103+
104+
**Locks around the first (deferred) build.** A model's first build - and the JSON-schema generation
105+
of a not-yet-built model - runs under one process-wide reentrant lock, so concurrent first uses build
106+
each model exactly once instead of racing (the same design pydantic itself adopted for its own
107+
`model_rebuild`). Two consequences: don't hold your own lock across a model's first use or inside a
108+
custom `__get_pydantic_core_schema__` / subclass hook and then take that same lock elsewhere while
109+
another thread first-uses a model (an ordinary lock-order inversion), and a thread that is mid-build
110+
during `os.fork()` leaves the child's lock held (the standard fork-with-threads caveat). Calling
111+
`mcp.warm(version)` at startup builds everything on one thread and makes concurrent first builds
112+
impossible in the first place. Once a pydantic release with a thread-safe `model_rebuild` is the
113+
SDK's dependency floor, the SDK's lock will be dropped.

docs/migration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2691,7 +2691,7 @@ before a message needs them). Nothing you import changes name or location, and
26912691
before. See [Imports & startup time](advanced/import-cost.md) for what loads when and how to
26922692
prewarm it.
26932693

2694-
Two incidental things did change:
2694+
A few incidental things did change:
26952695

26962696
* **Attribute chains that were never imported explicitly.** `import mcp` used to bind most
26972697
submodules as a side effect, so `import mcp` followed by `mcp.client.stdio.stdio_client(...)`
@@ -2718,7 +2718,9 @@ Two incidental things did change:
27182718
`MCPServer.custom_route`'s handler type) raises `NameError` for the Starlette-owned annotations
27192719
(`Starlette`, `Route`, `Request`, `Response`), which are typing-only now; every SDK-owned name in
27202720
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.
2721+
names into your own namespace and pass `localns=` if you need those hints evaluated. Relatedly,
2722+
evaluating hints imports what they name: `typing.get_type_hints(mcp.Client)` resolves the
2723+
`server` parameter's annotation and so imports `mcp.server`.
27222724

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

src/mcp-types/mcp_types/_deferred.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,20 @@
5252
5353
Reentrant on purpose: completing one model rebuilds the models it references
5454
on the same thread, re-entering `model_rebuild()`. Held only while a model
55-
actually builds; a completed model's `model_rebuild()` returns before the
56-
lock, and no per-message path takes it.
55+
actually builds (and while a not-yet-built model's JSON schema is generated,
56+
a cold path); a completed model's `model_rebuild()` returns before the lock,
57+
and no per-message path takes it.
58+
59+
The lock is held across pydantic's own build machinery, and that machinery
60+
runs user code: custom `__get_pydantic_core_schema__` hooks and subclass /
61+
metaclass hooks execute under it. So the usual lock-order rule applies - user
62+
code must not block on some other lock inside those hooks while another
63+
thread holds that lock and first-uses a model - and a thread mid-build during
64+
`os.fork()` leaves the child's lock held (the standard fork-with-threads
65+
caveat). Both are the same terms upstream pydantic adopted for its own,
66+
identical, process-wide rebuild lock (pydantic#13438); once a pydantic release
67+
containing it is our dependency floor this SDK lock can go. A host that calls
68+
`mcp.warm()` at startup builds everything on one thread and never contends here.
5769
"""
5870

5971

0 commit comments

Comments
 (0)