|
15 | 15 |
|
16 | 16 | from __future__ import annotations |
17 | 17 |
|
18 | | -from collections.abc import Iterator, Mapping |
| 18 | +import time |
| 19 | +from collections.abc import Iterable, Iterator, Mapping |
19 | 20 | from functools import cache |
20 | 21 | from importlib import import_module |
21 | 22 | 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 |
23 | 24 |
|
24 | 25 | from pydantic import BaseModel, TypeAdapter |
25 | 26 |
|
26 | 27 | import mcp_types as types |
| 28 | +from mcp_types import jsonrpc |
27 | 29 | from mcp_types.version import KNOWN_PROTOCOL_VERSIONS |
28 | 30 |
|
29 | 31 | if TYPE_CHECKING: |
|
52 | 54 | "SERVER_RESULTS", |
53 | 55 | "SPEC_CLIENT_METHODS", |
54 | 56 | "SPEC_CLIENT_NOTIFICATION_METHODS", |
| 57 | + "WarmReport", |
55 | 58 | "is_input_required", |
56 | 59 | "parse_client_notification", |
57 | 60 | "parse_client_request", |
|
64 | 67 | "validate_client_request", |
65 | 68 | "validate_client_result", |
66 | 69 | "validate_server_result", |
| 70 | + "warm", |
67 | 71 | ] |
68 | 72 |
|
69 | 73 |
|
@@ -849,3 +853,130 @@ def parse_client_result( |
849 | 853 | validate_client_result(method, version, data, surface=surface) |
850 | 854 | result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) |
851 | 855 | 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)) |
0 commit comments