Skip to content

Commit b592f09

Browse files
committed
Generate a deferred model's JSON schema under the rebuild lock too
Completing a deferred model under the process-wide rebuild lock and then letting pydantic generate its JSON schema outside it left one interleaving: schema generation for a model reads the core schema of every model it references, and for the mutually recursive wire JSON models (JSONObject / JSONArray / JSONValue) a sibling could still be mid-build on another thread, surfacing as a rare KeyError inside pydantic's schema generation on some pydantic versions when many threads generated their first schema of that family at once. No SDK-internal path did this (registration is single-threaded), but user code may. The generation now runs under the same lock as the build. Schema generation is a cold path, so it just serializes with the deferred first builds; steady state is unchanged. The concurrency ratchet also covers a concurrent first schema over the recursive JSON family and the 2026 wire package.
1 parent fc4f4e6 commit b592f09

2 files changed

Lines changed: 26 additions & 12 deletions

File tree

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ def deferred_model(cls: type[_ModelT]) -> type[_ModelT]:
115115
pydantic API; `_parent_namespace_depth + 1` accounts for this extra
116116
frame);
117117
* 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.
118+
and generates the schema under that lock, so concurrent first schema
119+
calls race neither on the not-yet-built class nor on a referenced
120+
(e.g. mutually recursive) model that another thread is completing.
120121
121122
Raises:
122123
TypeError: `cls` does not set `defer_build=True` in its `model_config`,
@@ -150,12 +151,17 @@ def model_rebuild(sub: type[BaseModel], *args: Any, _parent_namespace_depth: int
150151
)
151152

152153
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)
154+
# Complete the class AND generate under the lock: pydantic re-reads a
155+
# class's (mock) core schema around a concurrent first build, and the
156+
# generation for one model reads the core schema of every model it
157+
# references (a mutually recursive family such as the wire JSON models
158+
# references its siblings) while another thread may still be
159+
# completing one of those. Schema generation is a cold path, so it
160+
# simply serializes with the deferred first builds.
161+
with REBUILD_LOCK:
162+
if not sub.__pydantic_complete__:
163+
sub.model_rebuild(raise_errors=False)
164+
return cast(Any, super(cls, sub)).model_json_schema(*args, **kwargs)
159165

160166
setattr(cls, "__pydantic_init_subclass__", classmethod(__pydantic_init_subclass__))
161167
setattr(cls, "model_rebuild", classmethod(model_rebuild))

‎tests/types/test_deferred.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ class Later(MCPModel): # defined only afterwards: too late for `Pending`
126126
import pydantic
127127
import mcp_types._types as _types
128128
import mcp_types._v2025_11_25 as v2025
129+
import mcp_types._v2026_07_28 as v2026
129130
import mcp_types.jsonrpc as jsonrpc
130131
from mcp_types import methods
131132
import mcp.client.session as session # deferred module-level TypeAdapters
@@ -140,7 +141,8 @@ def own_models(mod):
140141
return [o for o in vars(mod).values() if isinstance(o, type) and issubclass(o, pydantic.BaseModel)
141142
and o.__module__ == mod.__name__ and not o.__pydantic_complete__]
142143
143-
MODULES = (_types, v2025, jsonrpc, mcpserver, prompts_base, tools_base, resource_types, auth_settings, shared_auth)
144+
MODULES = (_types, v2025, v2026, jsonrpc, mcpserver, prompts_base, tools_base, resource_types, auth_settings,
145+
shared_auth)
144146
CLASSES = [cls for mod in MODULES for cls in own_models(mod)]
145147
ADAPTERS = [v for m in (_types, jsonrpc, session, prompts_base) for v in vars(m).values()
146148
if isinstance(v, pydantic.TypeAdapter)]
@@ -149,6 +151,11 @@ def own_models(mod):
149151
errors, lock = [], threading.Lock()
150152
151153
def first_use_everything():
154+
# The mutually recursive 2026 JSON models first: a FIRST json-schema generation
155+
# reads the core schema of each sibling it references while another thread may
156+
# still be completing that sibling.
157+
for cls in (v2026.JSONObject, v2026.JSONArray, v2026.JSONValue):
158+
cls.model_json_schema()
152159
for cls in CLASSES:
153160
try:
154161
cls.model_validate({"__probe__": 1})
@@ -185,9 +192,10 @@ def worker():
185192

186193

187194
def test_concurrent_first_use_of_deferred_models_never_raises() -> None:
188-
"""Eight threads first-using a broad set of never-built models (monolith, wire package,
189-
JSON-RPC envelopes, the SDK's own models, the module-level adapters, first schema and
190-
signature access) build every class exactly once and raise nothing.
195+
"""Eight threads first-using a broad set of never-built models (monolith, both wire
196+
packages incl. the mutually recursive JSON models' first schema, JSON-RPC envelopes, the
197+
SDK's own models, the module-level adapters, first schema and signature access) build
198+
every class exactly once and raise nothing.
191199
192200
The models must be unbuilt when the race starts, so the probe runs in fresh interpreters.
193201
"""

0 commit comments

Comments
 (0)