Skip to content

Commit 33471c7

Browse files
committed
Add mcp.warm(): opt-in prewarming for the deferred validators
The wire schemas load on the first message per protocol version and the pydantic validators build on first use, which keeps imports fast but puts a one-time bill on a process's first messages. A long-running host that would rather pay that at startup than on its first request had only a DIY recipe of model_rebuild() loops. mcp_types.methods.warm(version=None, *, everything=False) is the supported version: with no argument it builds the version-independent set (the exported mcp_types models, the JSON-RPC envelopes and the routing union adapters, ~50 ms); with a version it also imports that version's wire package and builds the routing surface a connection at that version uses (so its first messages then build nothing); everything=True covers every known version. Model classes are always completed before the adapters that reference them, so no schema is generated twice, and repeat calls are no-ops. It returns a small WarmReport for logging and is re-exported as mcp.warm. Nothing in the SDK calls it. The import-cost docs recipe now uses it.
1 parent 8c9f2a2 commit 33471c7

5 files changed

Lines changed: 245 additions & 17 deletions

File tree

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
1-
"""Prewarm the SDK's deferred startup work.
1+
"""Prewarm the SDK's deferred startup work in a host's startup hook.
22
33
Imports stay fast because the heavy pieces load on first use: a protocol
44
version's wire schemas load on the first message parsed for that version, and
55
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.
6+
would rather pay that once, before serving traffic, calls `mcp.warm()`.
77
"""
88

9-
import mcp_types
10-
from mcp_types.methods import CLIENT_REQUESTS
9+
import mcp
1110

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()
11+
# Build the deferred validators now: the version-independent set, plus the
12+
# routing surface of each protocol version this host will serve.
13+
report = mcp.warm("2025-11-25")
14+
print(f"prewarmed {report.models} models and {report.adapters} adapters in {report.elapsed_ms} ms")

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

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@
1515

1616
from __future__ import annotations
1717

18-
from collections.abc import Iterator, Mapping
18+
import time
19+
from collections.abc import Iterable, Iterator, Mapping
1920
from functools import cache
2021
from importlib import import_module
2122
from types import MappingProxyType, ModuleType, UnionType
22-
from typing import TYPE_CHECKING, Any, Final, Literal, TypeGuard, TypeVar, cast, get_args
23+
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, TypeGuard, TypeVar, cast, get_args
2324

2425
from pydantic import BaseModel, TypeAdapter
2526

2627
import mcp_types as types
28+
from mcp_types import jsonrpc
2729
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
2830

2931
if TYPE_CHECKING:
@@ -52,6 +54,7 @@
5254
"SERVER_RESULTS",
5355
"SPEC_CLIENT_METHODS",
5456
"SPEC_CLIENT_NOTIFICATION_METHODS",
57+
"WarmReport",
5558
"is_input_required",
5659
"parse_client_notification",
5760
"parse_client_request",
@@ -64,6 +67,7 @@
6467
"validate_client_request",
6568
"validate_client_result",
6669
"validate_server_result",
70+
"warm",
6771
]
6872

6973

@@ -849,3 +853,130 @@ def parse_client_result(
849853
validate_client_result(method, version, data, surface=surface)
850854
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False)
851855
return result
856+
857+
858+
# --- Opt-in prewarm ---
859+
860+
_ALL_SURFACES: Final = (
861+
CLIENT_REQUESTS,
862+
CLIENT_NOTIFICATIONS,
863+
CLIENT_RESULTS,
864+
SERVER_REQUESTS,
865+
SERVER_NOTIFICATIONS,
866+
SERVER_RESULTS,
867+
)
868+
"""Every surface map, for `warm(version)` (rows of one version import one wire package)."""
869+
870+
_ALL_MONOLITHS: Final = (MONOLITH_REQUESTS, MONOLITH_NOTIFICATIONS, MONOLITH_RESULTS)
871+
"""Every monolith map, for `warm()`."""
872+
873+
874+
class WarmReport(NamedTuple):
875+
"""What one `warm()` call built (already-built work is skipped and not counted)."""
876+
877+
models: int
878+
"""Deferred model classes whose validators this call built."""
879+
880+
adapters: int
881+
"""Deferred TypeAdapters (union / routing adapters) this call built."""
882+
883+
elapsed_ms: float
884+
"""Wall-clock milliseconds the call took."""
885+
886+
887+
def _model_classes(target: object) -> Iterator[type[BaseModel]]:
888+
"""The model classes in a surface/monolith row: the class itself, or a union's arms."""
889+
if isinstance(target, type):
890+
if issubclass(target, BaseModel):
891+
yield target
892+
return
893+
for arg in get_args(target):
894+
yield from _model_classes(arg)
895+
896+
897+
def _warm_models(rows: Iterable[object]) -> int:
898+
"""Complete every not-yet-built model class among `rows`; return how many were built."""
899+
built = 0
900+
for row in rows:
901+
for cls in _model_classes(row):
902+
if not cls.__pydantic_complete__:
903+
cls.model_rebuild()
904+
built += 1
905+
return built
906+
907+
908+
def _warm_adapter(adapter: TypeAdapter[Any]) -> int:
909+
"""Build a deferred TypeAdapter's validator if it is not built yet; return 0 or 1."""
910+
return 0 if adapter.rebuild() is None else 1
911+
912+
913+
def warm(version: str | None = None, *, everything: bool = False) -> WarmReport:
914+
"""Build the SDK's deferred validators up front, before the first message needs them.
915+
916+
Importing the SDK stays fast because pydantic validators build on first use
917+
and each protocol version's wire schemas load with the first message parsed
918+
at that version. A long-running host that would rather pay that once, at
919+
startup, calls this from a startup hook; nothing in the SDK calls it, and
920+
steady-state behaviour is identical either way.
921+
922+
* `warm()` builds the version-independent set: the monolith `mcp_types`
923+
models a session routes through, the JSON-RPC envelopes and the
924+
module-level union adapters.
925+
* `warm(version)` additionally imports that protocol version's wire
926+
package and builds its routing surface (the wire models and the cached
927+
adapters a connection at that version routes through).
928+
* `warm(everything=True)` does that for every known protocol version.
929+
930+
Model classes are completed before the adapters that reference them are
931+
built, so no schema is generated twice; anything already built is skipped,
932+
so the call is idempotent and repeat calls are cheap.
933+
934+
Args:
935+
version: The protocol version whose routing surface to build too.
936+
everything: Warm every known protocol version (benchmarks, tests).
937+
938+
Returns:
939+
Counts of what this call built and the milliseconds it took.
940+
941+
Raises:
942+
ValueError: `version` is not a known protocol version.
943+
"""
944+
started = time.perf_counter()
945+
versions: list[str] = []
946+
if everything:
947+
versions = sorted(KNOWN_PROTOCOL_VERSIONS)
948+
elif version is not None:
949+
_check_known_version(version)
950+
versions = [version]
951+
952+
# Model classes first (the exported monolith models, the JSON-RPC
953+
# envelopes, then the wire packages and their routing rows), so the
954+
# adapters below never generate a referenced model's schema inline.
955+
models = _warm_models(getattr(types, name) for name in types.__all__)
956+
models += _warm_models(row for monolith in _ALL_MONOLITHS for row in monolith.values())
957+
models += _warm_models(get_args(jsonrpc.JSONRPCMessage))
958+
rows: list[Any] = []
959+
for warmed_version in versions:
960+
package = _wire_package(warmed_version) # imports the package once
961+
models += _warm_models(vars(package).values())
962+
for surface in _ALL_SURFACES:
963+
rows.extend(surface[key] for key in surface if key[1] == warmed_version)
964+
models += _warm_models(rows)
965+
966+
adapters = _warm_adapter(jsonrpc.jsonrpc_message_adapter)
967+
for adapter in (
968+
types.client_request_adapter,
969+
types.client_notification_adapter,
970+
types.client_result_adapter,
971+
types.server_request_adapter,
972+
types.server_notification_adapter,
973+
types.server_result_adapter,
974+
):
975+
adapters += _warm_adapter(adapter)
976+
for row in rows:
977+
# `_adapter` is the cached routing-adapter builder the parse/serialize
978+
# functions use; warming it here (and completing the adapter's own
979+
# core schema from the now-complete row model) leaves nothing for the
980+
# first message at that version to build.
981+
adapters += _warm_adapter(_adapter(row))
982+
return WarmReport(models=models, adapters=adapters, elapsed_ms=round((time.perf_counter() - started) * 1000, 1))

‎src/mcp/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
UnsubscribeRequest,
6868
)
6969
from mcp_types import Role as SamplingRole
70+
from mcp_types.methods import warm as warm
7071

7172
# Bind the `mcp.types` submodule on the package, as v1's `from .types import
7273
# ...` did, so `import mcp` followed by `mcp.types.Tool` keeps working. The
@@ -159,6 +160,7 @@
159160
"InvalidUriTemplate",
160161
"stdio_client",
161162
"stdio_server",
163+
"warm",
162164
]
163165

164166
# ---------------------------------------------------------------------------
@@ -253,6 +255,7 @@
253255
"UrlElicitationRequiredError": ("mcp.shared.exceptions", "UrlElicitationRequiredError"),
254256
"InvalidUriTemplate": ("mcp.shared.uri_template", "InvalidUriTemplate"),
255257
"UriTemplate": ("mcp.shared.uri_template", "UriTemplate"),
258+
"warm": ("mcp_types.methods", "warm"),
256259
}
257260

258261
# Submodules a bare `import mcp` used to bind on the package as a side effect

‎tests/docs_src/test_import_cost.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import json
44
import os
5+
import pathlib
56
import subprocess
67
import sys
78

9+
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
10+
811
# The recipe's effect is only observable in a fresh interpreter: this process has long
912
# since loaded the wire packages and built the models.
1013
PROBE = """
@@ -25,18 +28,19 @@
2528

2629

2730
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."""
31+
"""tutorial001: before the recipe nothing is loaded/built; after `mcp.warm(version)` the
32+
named version's wire package is imported and the models are built."""
3033
result = subprocess.run(
3134
[sys.executable, "-c", PROBE],
3235
capture_output=True,
3336
text=True,
3437
check=False,
3538
timeout=60,
39+
cwd=REPO_ROOT, # the recipe module is imported by its docs_src path
3640
# pydantic plugins (e.g. logfire) building models is the environment's cost, not ours
3741
env={**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"},
3842
)
3943
assert result.returncode == 0, result.stderr
40-
observed = json.loads(result.stdout)
44+
observed = json.loads(result.stdout.strip().splitlines()[-1])
4145
assert observed["before"] == {"wire_2025_loaded": False, "tool_built": False}
4246
assert observed["after"] == {"wire_2025_loaded": True, "tool_built": True}

‎tests/types/test_methods.py‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Tests for the wire-method maps and two-step parse functions in `mcp_types.methods`."""
22

33
import importlib.util
4+
import json
5+
import os
46
import subprocess
57
import sys
68
from collections.abc import Mapping
@@ -12,6 +14,7 @@
1214
import mcp_types._v2026_07_28 as v2026
1315
import pydantic
1416
import pytest
17+
from inline_snapshot import snapshot
1518
from mcp_types import methods
1619
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
1720
from pydantic import BaseModel
@@ -594,7 +597,7 @@ def test_surface_rows_for_a_version_with_no_wire_package_fail_at_map_constructio
594597
"""SDK-defined: an unregistered protocol version fails loudly at import, not as a version-gate KeyError."""
595598
with pytest.raises(ValueError) as excinfo:
596599
methods._LazyWireMap({("ping", "2099-01-01"): "PingRequest"})
597-
assert "2099-01-01" in str(excinfo.value)
600+
assert str(excinfo.value) == snapshot("no wire package registered for protocol version(s) ['2099-01-01']")
598601

599602

600603
def test_cacheable_methods_mirror_the_cacheable_method_literal():
@@ -1026,3 +1029,94 @@ def test_importing_the_module_builds_no_adapters_and_identical_rows_share_one():
10261029
# Identical row values at another version: no new adapters.
10271030
fresh.parse_server_result("ping", "2024-11-05", {})
10281031
assert fresh._adapter.cache_info().currsize == 2
1032+
1033+
1034+
# --- warm(): the opt-in prewarm helper ---
1035+
1036+
_WARM_PROBE = """
1037+
import json, sys
1038+
import pydantic
1039+
import mcp_types
1040+
from mcp_types import methods
1041+
1042+
report = methods.warm({args}) if {args!r} != "None" else None
1043+
loaded_wire = sorted(m for m in sys.modules if m.startswith("mcp_types._v"))
1044+
1045+
# From here on nothing may build: count model completions and adapter builds.
1046+
built = {{"models": 0, "adapters": 0}}
1047+
import pydantic._internal._model_construction as construction
1048+
_complete = construction.complete_model_class
1049+
def counting_complete(*a, **k):
1050+
built["models"] += 1
1051+
return _complete(*a, **k)
1052+
construction.complete_model_class = counting_complete
1053+
_init = pydantic.TypeAdapter._init_core_attrs
1054+
def counting_init(self, *a, **k):
1055+
result = _init(self, *a, **k)
1056+
if result:
1057+
built["adapters"] += 1
1058+
return result
1059+
pydantic.TypeAdapter._init_core_attrs = counting_init
1060+
1061+
methods.parse_client_request(
1062+
"tools/call", "2026-07-28",
1063+
{{"name": "t", "arguments": {{}},
1064+
"_meta": {{"io.modelcontextprotocol/protocolVersion": "2026-07-28",
1065+
"io.modelcontextprotocol/clientCapabilities": {{}}}}}},
1066+
)
1067+
methods.serialize_server_result(
1068+
"tools/list", "2026-07-28",
1069+
{{"tools": [], "cacheScope": "private", "ttlMs": 0, "resultType": "complete"}},
1070+
)
1071+
mcp_types.jsonrpc_message_adapter.validate_python({{"jsonrpc": "2.0", "id": 1, "method": "ping"}})
1072+
print(json.dumps({{"report": report._asdict() if report else None, "wire": loaded_wire, "built": built}}))
1073+
"""
1074+
1075+
1076+
def _run_warm_probe(args: str) -> dict[str, Any]:
1077+
"""Warm (with `args`) in a fresh interpreter, then report what the first messages built.
1078+
1079+
The probe counts builds through pydantic internals purely as a test observer.
1080+
"""
1081+
result = subprocess.run(
1082+
[sys.executable, "-c", _WARM_PROBE.format(args=args)],
1083+
capture_output=True,
1084+
text=True,
1085+
check=False,
1086+
timeout=120,
1087+
# a pydantic plugin (e.g. logfire) building models is the environment's cost, not ours
1088+
env={**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"},
1089+
)
1090+
assert result.returncode == 0, result.stderr
1091+
return json.loads(result.stdout.strip().splitlines()[-1])
1092+
1093+
1094+
def test_first_messages_build_validators_lazily_without_a_warm_up():
1095+
"""SDK-defined: with no `warm()`, nothing is loaded or built up front; the first messages
1096+
at a version import that version's wire package and build what they route through."""
1097+
observed = _run_warm_probe("None")
1098+
assert observed["report"] is None
1099+
assert observed["wire"] == [] # no wire package loaded before the first message
1100+
assert observed["built"]["models"] > 0
1101+
assert observed["built"]["adapters"] > 0
1102+
1103+
1104+
def test_warming_a_version_leaves_nothing_for_its_first_messages_to_build():
1105+
"""SDK-defined: `warm(version)` imports that version's wire package and builds its models
1106+
and routing adapters up front, so the first messages at that version build nothing."""
1107+
observed = _run_warm_probe('"2026-07-28"')
1108+
assert observed["report"]["models"] > 0
1109+
assert observed["report"]["adapters"] > 0
1110+
assert observed["wire"] == ["mcp_types._v2026_07_28"]
1111+
assert observed["built"] == {"models": 0, "adapters": 0}
1112+
1113+
1114+
def test_warm_is_idempotent_and_gates_unknown_versions():
1115+
"""SDK-defined: repeating a `warm()` builds nothing more; an unknown version is a ValueError."""
1116+
methods.warm(everything=True)
1117+
again = methods.warm(everything=True)
1118+
assert (again.models, again.adapters) == (0, 0)
1119+
assert again.elapsed_ms >= 0
1120+
with pytest.raises(ValueError) as excinfo:
1121+
methods.warm("2099-01-01")
1122+
assert "2099-01-01" in str(excinfo.value)

0 commit comments

Comments
 (0)