Skip to content

Commit 8c9f2a2

Browse files
committed
Serialize the deferred first build of the SDK's pydantic models
Released pydantic (through 2.13) does not lock model_rebuild(), so the first use of a defer_build model from several threads at once - sync tools running on worker threads, or one client session per thread at process start - could raise (AttributeError: __pydantic_core_schema__ and friends) or install a stale validator instead of just building twice. A narrow version of the same hazard already existed for the handful of models that were incomplete at import; deferring every model made it reachable from every entry point. One process-wide RLock now serializes that one-time build. The @deferred_model decorator becomes the single mechanism for a deferred model root: it installs the lazy __signature__, the subclass hook, a locked model_rebuild (public pydantic API; the extra wrapper frame is accounted for via _parent_namespace_depth + 1) and a model_json_schema that completes the class under the same lock before pydantic reads it (pydantic re-reads the mock schema around a concurrent build). MCPModel, WireModel and WireRootModel are decorated rather than hand-writing the hook, and the remaining private deferred roots get the decorator too, so no deferred model in the SDK builds outside the lock. The decorator refuses a class that lacks defer_build or that defines its own __pydantic_init_subclass__ (which it would otherwise replace). Steady state is untouched: an already-built model never takes the lock. A subprocess ratchet test races 8 threads over the monolith models, a wire package, the JSON-RPC envelopes, the SDK's own models, the module-level adapters and first schema/signature access, and asserts zero exceptions (it fails without the lock).
1 parent d515c85 commit 8c9f2a2

8 files changed

Lines changed: 227 additions & 68 deletions

File tree

‎docs/migration.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2724,11 +2724,13 @@ Two incidental things did change:
27242724

27252725
The SDK's models and `TypeAdapter`s defer their validator build to first construction or
27262726
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.
2727+
`model_json_schema()` report what they always did, and the one-time build is serialized across
2728+
threads (fixing a rare failure earlier v2 releases could hit when several threads first-used the
2729+
same type at once). A model you subclass from the SDK (`Tool`, `Resource`, `AuthSettings`, ...)
2730+
inherits the deferral, so an annotation that cannot resolve in your subclass now surfaces at
2731+
first use rather than at class creation, and `Model.__pydantic_complete__` reads `False` until
2732+
that first use. Set `model_config = ConfigDict(defer_build=False)` in your subclass, or call
2733+
`Model.model_rebuild()` after defining it, if you want the build (and its errors) up front.
27322734

27332735
## Testing utilities
27342736

‎docs/whats-new.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ On those types, every Python attribute is now snake_case: `result.is_error`, `to
126126

127127
### Imports pay for what you use
128128

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

131131
### Behavior that changes without an import error
132132

Lines changed: 98 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,61 @@
1-
"""Keep `inspect.signature()` accurate for `defer_build` models before their first use.
1+
"""Support for the SDK's `defer_build=True` pydantic models.
22
33
The SDK's model bases set `defer_build=True`, so pydantic builds a model's core
44
schema, validator and serializer on first use instead of while the class
55
statement runs; a module full of models then costs almost nothing to import in
6-
a process that never validates them.
7-
8-
The one observable pydantic ties to that build is the class's `__signature__`
9-
(the synthesized `__init__` signature that `inspect.signature(Model)` reports):
10-
under `defer_build` it exists only once the model has built, and until then a
11-
class reports the generic `(**data)`. `DeferredSignature` is a class-level
12-
`__signature__` that completes the (one-time) build via the public
13-
`model_rebuild()` on first signature access, so runtime introspection matches an
14-
eagerly-built model while nothing is built at import.
6+
a process that never validates them. Two things need help until that first
7+
build happens:
8+
9+
* `inspect.signature(Model)`. Under `defer_build` the synthesized `__init__`
10+
signature (`__signature__`) exists only once the model has built; until then
11+
a class would report the generic `(**data)`. `DeferredSignature` is a
12+
class-level `__signature__` that completes the (one-time) build via the
13+
public `model_rebuild()` on first signature access, so runtime introspection
14+
matches an eagerly-built model while nothing is built at import.
15+
* The first build under concurrency. Released pydantic (<= 2.13) does not
16+
lock `model_rebuild()`, so several threads first-using one model at once
17+
(sync tools on worker threads, one session per thread) can raise
18+
`AttributeError: __pydantic_core_schema__` or install a stale validator
19+
(pydantic#13419; fixed unreleased in pydantic#13438). One process-wide
20+
reentrant lock around the build closes that window: a late thread waits,
21+
then finds the model complete and its own rebuild is a no-op.
22+
23+
`deferred_model` installs both on the root class of a deferred model hierarchy
24+
(the type-layer bases `MCPModel` / `WireModel` / `WireRootModel`, the JSON-RPC
25+
envelopes, and the `mcp` package's own model roots); subclasses inherit them.
26+
27+
This is a private module of `mcp-types`, but the `mcp` package (which
28+
exact-pins its `mcp-types` sibling) imports `deferred_model` for its own model
29+
roots too: keep the signatures stable across both packages.
1530
"""
1631

1732
from __future__ import annotations
1833

34+
import threading
1935
from inspect import Signature
20-
from typing import Any, TypeVar, cast
36+
from typing import Any, Final, TypeVar, cast
2137

2238
from pydantic import BaseModel
2339

24-
__all__ = ["DeferredSignature", "deferred_model", "install_deferred_signature", "new_deferred_signature"]
40+
__all__ = [
41+
"REBUILD_LOCK",
42+
"DeferredSignature",
43+
"deferred_model",
44+
"install_deferred_signature",
45+
"new_deferred_signature",
46+
]
2547

2648
_ModelT = TypeVar("_ModelT", bound=BaseModel)
2749

50+
REBUILD_LOCK: Final = threading.RLock()
51+
"""Serializes the first (deferred) build of every SDK model across threads.
52+
53+
Reentrant on purpose: completing one model rebuilds the models it references
54+
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.
57+
"""
58+
2859

2960
class DeferredSignature:
3061
"""Class-level `__signature__` for a `defer_build=True` pydantic model.
@@ -53,41 +84,80 @@ def __get__(self, obj: object | None, owner: type[BaseModel] | None = None) -> A
5384
def new_deferred_signature() -> Signature:
5485
"""A `DeferredSignature` typed as the `Signature` it stands in for.
5586
56-
Assign it in a model class body WITHOUT an annotation (`__signature__ =
57-
new_deferred_signature()`), matching how `BaseModel` itself binds
58-
`__signature__`, so it never appears in `get_type_hints(Model)`.
87+
Bound in a class namespace WITHOUT an annotation (matching how `BaseModel`
88+
itself binds `__signature__`), so it never appears in `get_type_hints(Model)`.
5989
"""
6090
return cast(Signature, DeferredSignature())
6191

6292

6393
def install_deferred_signature(cls: type[BaseModel]) -> None:
6494
"""Give a still-deferred model class its own lazily-completing `__signature__`.
6595
66-
Meant for a base's `__pydantic_init_subclass__` hook: every deferred subclass
67-
needs its OWN `__signature__` entry, otherwise it would inherit an
68-
already-built parent's signature. A class that pydantic already completed
69-
(e.g. one that turned `defer_build` off) keeps the real signature it has.
96+
Every deferred subclass needs its OWN `__signature__` entry, otherwise it
97+
would inherit an already-built parent's signature. A class that pydantic
98+
already completed (e.g. one that turned `defer_build` off) keeps the real
99+
signature it has.
70100
"""
71101
if not cls.__pydantic_complete__:
72102
setattr(cls, "__signature__", new_deferred_signature())
73103

74104

75105
def deferred_model(cls: type[_ModelT]) -> type[_ModelT]:
76-
"""Class decorator for the root of a `defer_build` model hierarchy under `BaseModel`.
77-
78-
Installs the lazy `__signature__` on `cls` and, via a `__pydantic_init_subclass__`
79-
hook, on every model that later subclasses it (a subclass inherits the deferred
80-
build through the config). Models deriving from the SDK bases (`MCPModel`,
81-
`WireModel`, `WireRootModel`) get this from the base and do not need it.
106+
"""Class decorator for the root of a `defer_build=True` model hierarchy.
107+
108+
Installs, on `cls` and (through inheritance) on every model that later
109+
subclasses it - a subclass inherits the deferred build through the config:
110+
111+
* the lazy `__signature__`, via a `__pydantic_init_subclass__` hook that
112+
stamps a fresh one on each still-deferred subclass;
113+
* a `model_rebuild` classmethod that takes `REBUILD_LOCK`, so the first
114+
build of any model in the hierarchy is serialized across threads (public
115+
pydantic API; `_parent_namespace_depth + 1` accounts for this extra
116+
frame);
117+
* a `model_json_schema` classmethod that completes the (deferred) model
118+
under that lock before pydantic generates the schema, so concurrent first
119+
schema calls do not race on the not-yet-built class.
120+
121+
Raises:
122+
TypeError: `cls` does not set `defer_build=True` in its `model_config`,
123+
or defines its own `__pydantic_init_subclass__` (which the hook
124+
installed here would silently replace).
82125
"""
126+
if not cls.model_config.get("defer_build"):
127+
raise TypeError(f"@deferred_model expects {cls.__name__} to set model_config['defer_build'] = True")
128+
if "__pydantic_init_subclass__" in vars(cls):
129+
raise TypeError(f"@deferred_model would replace {cls.__name__}'s own __pydantic_init_subclass__")
130+
83131
install_deferred_signature(cls)
84132

85133
def __pydantic_init_subclass__(sub: type[BaseModel], **kwargs: Any) -> None:
86134
install_deferred_signature(sub)
87-
# Run whatever hook `cls`'s own bases define (BaseModel's is a no-op).
88-
# (`super()` from a classmethod defined outside the class body needs
89-
# both arguments; the two-argument form is opaque to the type checker.)
135+
# Chain to whatever hook `cls`'s bases define (BaseModel's is a no-op).
136+
# (`super()` from a classmethod defined outside the class body needs the
137+
# two-argument form, which is opaque to the type checker.)
90138
cast(Any, super(cls, sub)).__pydantic_init_subclass__(**kwargs)
91139

140+
def model_rebuild(sub: type[BaseModel], *args: Any, _parent_namespace_depth: int = 2, **kwargs: Any) -> Any:
141+
# An already-built model is the common case: answer it like pydantic
142+
# does, without touching the lock.
143+
if sub.__pydantic_complete__ and not (args or kwargs.get("force")):
144+
return None
145+
# One process-wide lock serializes the deferred first build across
146+
# threads; a late thread finds the model complete and no-ops.
147+
with REBUILD_LOCK:
148+
return cast(Any, super(cls, sub)).model_rebuild(
149+
*args, _parent_namespace_depth=_parent_namespace_depth + 1, **kwargs
150+
)
151+
152+
def model_json_schema(sub: type[BaseModel], *args: Any, **kwargs: Any) -> Any:
153+
# Complete the class before pydantic reads its (mock) core schema:
154+
# pydantic re-reads that attribute around the build, which is not safe
155+
# against a concurrent first build; a complete class is only read.
156+
if not sub.__pydantic_complete__:
157+
sub.model_rebuild(raise_errors=False)
158+
return cast(Any, super(cls, sub)).model_json_schema(*args, **kwargs)
159+
92160
setattr(cls, "__pydantic_init_subclass__", classmethod(__pydantic_init_subclass__))
161+
setattr(cls, "model_rebuild", classmethod(model_rebuild))
162+
setattr(cls, "model_json_schema", classmethod(model_json_schema))
93163
return cls

‎src/mcp-types/mcp_types/_types.py‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from pydantic.alias_generators import to_camel
2222
from typing_extensions import NotRequired, Self, TypedDict
2323

24-
from mcp_types._deferred import install_deferred_signature, new_deferred_signature
24+
from mcp_types._deferred import deferred_model
2525
from mcp_types.jsonrpc import RequestId
2626

2727
DEFAULT_NEGOTIATED_VERSION: Final[str] = "2025-03-26"
@@ -43,24 +43,17 @@
4343
"""Theme an icon is designed for. Wire values of `Icon.theme` (2025-11-25+)."""
4444

4545

46+
@deferred_model
4647
class MCPModel(BaseModel):
4748
"""Base class for all MCP protocol types."""
4849

4950
# defer_build: the core schema / validator / serializer for each model is
5051
# built on first use instead of at class creation, so importing this module
51-
# does not pay for ~150 models most processes never touch.
52+
# does not pay for ~150 models most processes never touch. `deferred_model`
53+
# keeps `inspect.signature()` accurate before that first use and makes the
54+
# first build thread-safe (see `mcp_types._deferred`).
5255
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, defer_build=True)
5356

54-
# Complete the deferred build on the first `inspect.signature()` access, so a
55-
# never-used model still reports its real field signature. (Bound without an
56-
# annotation, like `BaseModel.__signature__`, so it is not a type hint.)
57-
__signature__ = new_deferred_signature()
58-
59-
@classmethod
60-
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
61-
install_deferred_signature(cls)
62-
super().__pydantic_init_subclass__(**kwargs)
63-
6457

6558
Meta: TypeAlias = dict[str, Any]
6659

‎src/mcp-types/mcp_types/_wire_base.py‎

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,29 @@
44
and serializer construction at class creation and builds each model once, on
55
its first validation. A generated package defines ~175 models but a connection
66
touches only the handful its negotiated version and methods use, so importing a
7-
package is class-body execution only and the schema build is paid lazily,
8-
per model, exactly once. `inspect.signature()` on a not-yet-built model is kept
9-
accurate by the lazy `__signature__` from `mcp_types._deferred`.
7+
package is class-body execution only and the schema build is paid lazily, per
8+
model, exactly once. `@deferred_model` keeps `inspect.signature()` on a
9+
not-yet-built model accurate and serializes that first build across threads
10+
(see `mcp_types._deferred`).
1011
"""
1112

12-
from typing import Any, Generic, TypeVar
13+
from typing import Generic, TypeVar
1314

1415
from pydantic import BaseModel, ConfigDict, RootModel
1516

16-
from mcp_types._deferred import install_deferred_signature, new_deferred_signature
17+
from mcp_types._deferred import deferred_model
1718

1819
_RootT = TypeVar("_RootT")
1920

2021

22+
@deferred_model
2123
class WireModel(BaseModel):
2224
"""Base for generated wire models: enables `populate_by_name`; subclasses set `extra` themselves."""
2325

2426
model_config = ConfigDict(populate_by_name=True, defer_build=True)
2527

26-
__signature__ = new_deferred_signature()
27-
28-
@classmethod
29-
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
30-
install_deferred_signature(cls)
31-
super().__pydantic_init_subclass__(**kwargs)
32-
3328

29+
@deferred_model
3430
class WireRootModel(RootModel[_RootT], Generic[_RootT]):
3531
"""Base for generated named-alias root models (`X(WireRootModel[T])`).
3632
@@ -41,10 +37,3 @@ class WireRootModel(RootModel[_RootT], Generic[_RootT]):
4137
"""
4238

4339
model_config = ConfigDict(defer_build=True)
44-
45-
__signature__ = new_deferred_signature()
46-
47-
@classmethod
48-
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
49-
install_deferred_signature(cls)
50-
super().__pydantic_init_subclass__(**kwargs)

‎src/mcp/client/session_group.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class ClientSessionGroup:
111111
```
112112
"""
113113

114+
@deferred_model
114115
class _ComponentNames(BaseModel):
115116
"""Used for reverse index to find components."""
116117

‎src/mcp/server/mcpserver/resolve.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
Tool,
6767
ToolChoice,
6868
)
69+
from mcp_types._deferred import deferred_model as _deferred_model
6970
from mcp_types.version import is_version_at_least
7071
from pydantic import BaseModel, ConfigDict, ValidationError
7172
from typing_extensions import TypeVar
@@ -727,6 +728,7 @@ def _result_type(
727728
# import (import-time cost); the build is transparent at first construction/validation.
728729

729730

731+
@_deferred_model
730732
class _StateEntry(BaseModel):
731733
"""One resolver's recorded outcome inside `request_state`."""
732734

@@ -749,6 +751,7 @@ def _request_digest(request: InputRequest) -> str:
749751
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
750752

751753

754+
@_deferred_model
752755
class _State(BaseModel):
753756
"""The decoded `request_state`: resolver progress from earlier rounds."""
754757

0 commit comments

Comments
 (0)