diff --git a/AGENTS.md b/AGENTS.md index 2812ed6d17..9523ef3666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,49 @@ - `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a symbol there is a deliberate API decision, not a convenience re-export. - IMPORTANT: All imports go at the top of the file — inline imports hide - dependencies and obscure circular-import bugs. Only exception: when a + dependencies and obscure circular-import bugs. Exceptions: when a top-level import genuinely can't work (lazy-loading optional deps, or - tests that re-import a module). + tests that re-import a module), and the import-cost cases below. + +### Import cost + +Startup time is part of the API: `import mcp` is a lazy namespace and each +entry point only loads what it uses. `tests/test_import_guards.py` pins +which heavy dependencies each import path may load; a hoisted import that +breaks one of these fails that test, so keep them off the paths below. Each +such function-level or lazy import carries a one-line comment saying which +stack it keeps out of which import path. + +- `mcp/__init__.py` resolves each of its exports on first access, and the + `mcp`, `mcp.client`, `mcp.server`, `mcp.server.auth`, `mcp.shared` and + `mcp.os` package inits resolve their *submodules* on attribute access - + both through `mcp.shared._lazy` (PEP 562 `__getattr__`, bound under + `if not TYPE_CHECKING:` so type checkers still flag typos); a bare + `import mcp` loads no pydantic, no `mcp_types` and no SDK stack. +- Client code never imports `mcp.server.*`: the in-process transport pieces + are imported inside the connector that only a live in-process `Server` + triggers, and `Server`/`MCPServer` are `TYPE_CHECKING`-only names. +- `httpx2` loads only for the HTTP client transports (`streamable_http`, + `sse`), on the first URL `Client`; a stdio client never imports it. +- The web stack (`starlette`, `sse_starlette`, `uvicorn`) loads only when an + HTTP app is built (`streamable_http_app()`, `sse_app()`, `custom_route()`), + never from `mcp.server`, the lowlevel `Server`, `MCPServer` or stdio; + types those signatures name are either defined in web-framework-free + modules (`mcp.server.event_store`, `mcp.server.transport_security`, + `mcp.server.auth.access_token`) or spelled through the lazy `mcp.server` + namespace so `typing.get_type_hints()` still resolves them. +- `opentelemetry` is imported on the first span; `cryptography` with the + first request-state codec (`MCPServer(...)` builds one), and the OAuth + provider models with the first auth-enabled server or authenticated + request - none of them at `import mcp.server*`; `jwt` only by the + client-credentials auth extension. +- The per-version wire packages (`mcp_types._v2025_11_25`, `_v2026_07_28`) + load on the first message parsed for that protocol version (via the + `mcp_types.methods` surface maps), on the first rendered elicitation + schema, or via `mcp.warm(version)` - not on `import mcp_types`. +- Pydantic models set `defer_build=True` (through `MCPModel`, the generated + wire bases, or `@deferred_model`): validators build on first use, not at + import; `mcp.warm()` is the opt-in way to build them up front. ## Testing diff --git a/docs/advanced/import-cost.md b/docs/advanced/import-cost.md new file mode 100644 index 0000000000..be1fd8deda --- /dev/null +++ b/docs/advanced/import-cost.md @@ -0,0 +1,113 @@ +# Imports & startup time + +`import mcp` is close to free, and every entry point loads only what it uses. You get that +without doing anything; this page is for when you want to know *what* loads *when*, how big the +deferred bills are, or when you want to move that work to a moment of your choosing. + +## What loads when + +The `mcp` package is a lazy namespace: `import mcp` binds no protocol types, no pydantic, and +none of the client or server modules. Each name resolves from its home module the first time you +touch it (`from mcp import Client` imports the client, `mcp.Tool` imports the types), and is then +an ordinary attribute. `from mcp import *`, `dir(mcp)` and object identity are unchanged; the +supported way to reach a submodule is still to import it (`import mcp.client.stdio`). + +From there, each stack loads with the feature that needs it, once: + +| Loaded on first use of | What loads | Roughly | +| --- | --- | --- | +| a URL `Client(...)` (or the SSE / streamable-HTTP client modules) | the HTTP client stack (`httpx2`) | ~60 ms | +| `streamable_http_app()` / `sse_app()` / `custom_route()` | the web stack (`starlette`, `sse_starlette`, `uvicorn`) | ~55 ms | +| the first message parsed for a protocol version | that version's wire-schema package (`mcp_types._v2025_11_25` / `_v2026_07_28`) | ~50 ms | +| the first construction, validation or JSON schema of a model | that model's pydantic validator (`defer_build=True`) | ~0.3 ms per model | +| the first server span | the OpenTelemetry API | ~7 ms | +| the first `MCPServer(...)` (its default `requestState` codec) | `cryptography` | ~10 ms | +| the first OAuth-enabled server | the OAuth provider models | ~10 ms | + +(The magnitudes are single-machine measurements; expect the same shape, not the same numbers.) + +None of these is per request. Each is a one-time cost paid where the work happens, and the +steady state afterwards is identical to having loaded it up front. Client code never loads the +server stack, and a stdio server never loads the web stack. Concretely, a fresh process pays +about **+130 ms once on its first in-memory connection and tool call** compared to a process that +built everything at import — the same work moved, not added — while `import mcp` itself dropped +from ~600 ms to ~3 ms and a typical `from mcp.types import ...` from ~600 ms to ~130 ms. + +## Prewarming + +A long-running host that would rather pay the deferred work at startup than on its first +connection calls `mcp.warm(version)` from its startup hook, naming the protocol version its +clients negotiate: + +```python +--8<-- "docs_src/import_cost/tutorial001.py" +``` + +`warm(version)` builds the version-independent validators (the `mcp.types` models, the JSON-RPC +envelopes and the routing union adapters, ~50 ms) and imports that protocol version's wire package +and builds the routing surface a connection at that version uses (~+100 ms), so the first +connection finds everything built: measured, a host's first connection (initialize plus the first +tools/resources/prompts requests) drops from ~200 ms of one-time builds to ~65 ms, within a +millisecond of a steady-state connection. Name the version your transport negotiates: stdio and +streamable-HTTP sessions negotiate `2025-11-25` today, the in-memory `Client` `2026-07-28`. + +The two other spellings are narrower and wider: + +- `warm()` with no version builds only the version-independent set; the first connection still + imports and builds its version's routing surface. +- `warm(all_versions=True)` warms every known version's routing surface, for a proxy or gateway + serving clients of both eras. + +The models are always completed before the adapters that reference them, nothing is built twice, +and repeat calls are no-ops. The returned `WarmReport` (a frozen dataclass: `models`, `adapters`, +`elapsed_ms`) counts what a call built, for logging. Do it once, at startup: nothing in the SDK +calls `warm()` for you, and importing the SDK stays fast either way. + +An HTTP server needs nothing extra for its transport: building the app (`streamable_http_app()`) at +startup is exactly the moment its web stack loads anyway. An `MCPServer`'s own settings, tool and +prompt models build when the server object and its tools are constructed, which is startup work in +its own right. + +## FAQ + +**A pydantic plugin (e.g. `logfire`) is installed and `import mcp.types` is slow.** pydantic +auto-loads every installed plugin the first time a model class is created, and a few of the SDK's +generic base classes are created at import; with `logfire` installed that adds its import +(~200 ms) to `import mcp.types`. Set `PYDANTIC_DISABLE_PLUGINS=__all__` (or list the plugins you do +want) to keep it out of your import path. `import mcp` alone is unaffected. + +**`Model.__pydantic_complete__` is `False` right after import.** Expected: the model builds on +first use. Code that checks the flag and then calls `Model.model_rebuild()` still works (the call is +a no-op once built); code asserting the flag at import will trip. `mcp.warm()` completes them all if +you need that up front. + +**A subclass I defined inside a function shows `(**data)` from `inspect.signature()`.** The SDK's +models complete their build (and their real signature) on the first `inspect.signature()` access, +but a class defined in a *local* namespace whose annotations reference other locals cannot resolve +those from outside that function, so it keeps pydantic's generic signature until it is first used +where the names resolve. Module-level subclasses are unaffected. + +**Bundling with PyInstaller / Nuitka / cx_Freeze.** The per-version wire-schema packages +(`mcp_types._v2025_11_25`, `mcp_types._v2026_07_28`) and the SDK's submodules are imported lazily. The +imports are still present in the bytecode, so import scanners generally find them; if your bundler +does not, add the packages to its hidden imports (PyInstaller: `--hidden-import mcp_types._v2026_07_28`, +and `--collect-submodules mcp` for the SDK's own lazily-resolved submodules). + +**`typing.get_type_hints()` on the HTTP app builders raises `NameError`.** The Starlette-owned +annotations of `MCPServer.sse_app` / `streamable_http_app` (and the lowlevel `Server`'s +`streamable_http_app`) are typing-only, since evaluating them would import the web stack. Every +SDK-owned name in those signatures still resolves; supply starlette's names via `localns=` if you +need the full hints evaluated. Note that evaluating hints imports what they name: +`typing.get_type_hints(mcp.Client)` resolves `Client.server`'s annotation and so imports +`mcp.server`. See the [migration guide](../migration.md#import-graph-and-startup). + +**Locks around the first (deferred) build.** A model's first build - and the JSON-schema generation +of a not-yet-built model - runs under one process-wide reentrant lock, so concurrent first uses build +each model exactly once instead of racing (the same design pydantic itself adopted for its own +`model_rebuild`). Two consequences: don't hold your own lock across a model's first use or inside a +custom `__get_pydantic_core_schema__` / subclass hook and then take that same lock elsewhere while +another thread first-uses a model (an ordinary lock-order inversion), and a thread that is mid-build +during `os.fork()` leaves the child's lock held (the standard fork-with-threads caveat). Calling +`mcp.warm(version)` at startup builds everything on one thread and makes concurrent first builds +impossible in the first place. Once a pydantic release with a thread-safe `model_rebuild` is the +SDK's dependency floor, the SDK's lock will be dropped. diff --git a/docs/advanced/index.md b/docs/advanced/index.md index 92af6d1782..7039bd3398 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -11,6 +11,8 @@ layer is in the way: can *only* do on the low-level `Server`. * **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's extension surface. Compose extension packages into a server, or write your own. +* **[Imports & startup time](import-cost.md)**: what the SDK loads when, and how to + move its deferred first-use work to startup if you want it there. A few things you might reasonably look for here live where you'd actually use them instead: diff --git a/docs/migration.md b/docs/migration.md index 931d470d1a..db3597a15e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -40,6 +40,7 @@ Every section heading below names the API it affects, so searching this page for | use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) | | maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) | | relied on lenient handling of off-schema traffic, or assert on exact wire bytes | [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) | +| rely on `import mcp` importing submodules, patch SDK internals, or subclass SDK pydantic models | [Import graph and startup](#import-graph-and-startup) | | test against in-memory server/client pairs | [Testing utilities](#testing-utilities) | | use roots, sampling, logging, or client-to-server progress | [Deprecations](#deprecations) | | operate servers that 2026-era clients will also connect to | [Notes for 2026-era connections](#notes-for-2026-era-connections) | @@ -2101,7 +2102,7 @@ v1's internal client set `follow_redirects=True`; set it explicitly when supplyi `streamable_http_client` itself keeps a small signature — `streamable_http_client(url, *, http_client=None, terminate_on_close=True)` — and now yields a 2-tuple (next section). The removed function's other parameters map onto the client you build: - `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts and `follow_redirects=True`. -- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`. +- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`. The `McpHttpClientFactory` protocol type is no longer re-exported from `mcp.client.streamable_http` either; annotate your factory as `Callable[..., httpx2.AsyncClient]` (or your own protocol) instead of importing it. - `terminate_on_close`: unchanged (default `True`). Client-side stream resumption is also unchanged: the transport reconnects a dropped GET stream with `Last-Event-ID` on its own, and `session.send_request(..., metadata=ClientMessageMetadata(resumption_token=..., on_resumption_token_update=...))` (from `mcp.shared.message`) works as in v1. @@ -2678,6 +2679,61 @@ The envelope exists for OpenTelemetry trace propagation ([SEP-414](https://githu The SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). +## Import graph and startup + +### Imports are lazy; explicit imports are the supported form + +`import mcp` no longer imports the SDK's whole module graph: the `mcp` package resolves each +export from its home module on first access, and every entry point loads only its own stack +(no server code behind a client, no web framework behind a stdio server, no wire schemas +before a message needs them). Nothing you import changes name or location, and +`from mcp import Client`, `mcp.types.Tool`, `from mcp import *` and object identity behave as +before. See [Imports & startup time](advanced/import-cost.md) for what loads when and how to +prewarm it. + +A few incidental things did change: + +* **Attribute chains that were never imported explicitly.** `import mcp` used to bind most + submodules as a side effect, so `import mcp` followed by `mcp.client.stdio.stdio_client(...)` + happened to work. It still resolves (the packages import a submodule on attribute access), but + the supported form is to import what you use: `from mcp.client.stdio import stdio_client`, or + `import mcp.client.stdio`. +* **Namespace bindings that tests used to patch.** Modules stopped re-binding names they only + imported: patch or import each object where it is defined. In particular, + `mcp.client.client.streamable_http_client` is `mcp.client.streamable_http.streamable_http_client`, + the HTTP-app pieces formerly bound in `mcp.server.lowlevel.server` / `mcp.server.mcpserver.server` + (`SseServerTransport`, `StreamableHTTPSessionManager`, the auth middleware and route builders) + live in `mcp.server.sse`, `mcp.server.streamable_http_manager` and `mcp.server.auth.*`, and + `get_access_token` / `auth_context_var` are defined in the transport-agnostic + `mcp.server.auth.access_token` (still importable from `mcp.server.auth.middleware.auth_context`). +* **Two web-framework-free homes.** The resumability contract (`EventStore`, `EventMessage`, + `EventCallback`, `EventId`, `StreamId`) is defined in `mcp.server.event_store`, and + `TransportSecurityMiddleware` now lives beside the transports rather than in + `mcp.server.transport_security`; both keep their previous import paths + (`mcp.server.streamable_http.EventStore`, `mcp.server.transport_security.TransportSecurityMiddleware`, + same objects). Only code introspecting `Class.__module__` or pickling those *class objects* by + reference notices. +* **Introspecting the app-builder signatures.** `typing.get_type_hints()` on the HTTP app + builders (`MCPServer.sse_app`, `MCPServer.streamable_http_app`, `Server.streamable_http_app`, + `MCPServer.custom_route`'s handler type) raises `NameError` for the Starlette-owned annotations + (`Starlette`, `Route`, `Request`, `Response`), which are typing-only now; every SDK-owned name in + those signatures still evaluates, and `inspect.signature()` is unaffected. Import starlette's + names into your own namespace and pass `localns=` if you need those hints evaluated. Relatedly, + evaluating hints imports what they name: `typing.get_type_hints(mcp.Client)` resolves the + `server` parameter's annotation and so imports `mcp.server`. + +### Pydantic models build on first use (`defer_build=True`) + +The SDK's models and `TypeAdapter`s defer their validator build to first construction or +validation instead of building at import; `inspect.signature(Model)`, `model_fields` and +`model_json_schema()` report what they always did, and the one-time build is serialized across +threads (fixing a rare failure earlier v2 releases could hit when several threads first-used the +same type at once). A model you subclass from the SDK (`Tool`, `Resource`, `AuthSettings`, ...) +inherits the deferral, so an annotation that cannot resolve in your subclass now surfaces at +first use rather than at class creation, and `Model.__pydantic_complete__` reads `False` until +that first use. Set `model_config = ConfigDict(defer_build=False)` in your subclass, or call +`Model.model_rebuild()` after defining it, if you want the build (and its errors) up front. + ## Testing utilities ### `create_connected_server_and_client_session` removed diff --git a/docs/whats-new.md b/docs/whats-new.md index bc1bfd6c56..3ce67f738b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -124,6 +124,10 @@ On those types, every Python attribute is now snake_case: `result.is_error`, `to **[Running your server](run/index.md)** covers the options; **[Add to an existing app](run/asgi.md)** covers mounting. +### Imports pay for what you use + +`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. + ### Behavior that changes without an import error The renames announce themselves. These do not: diff --git a/docs_src/import_cost/__init__.py b/docs_src/import_cost/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/import_cost/tutorial001.py b/docs_src/import_cost/tutorial001.py new file mode 100644 index 0000000000..724dd7edc9 --- /dev/null +++ b/docs_src/import_cost/tutorial001.py @@ -0,0 +1,14 @@ +"""Prewarm the SDK's deferred startup work in a host's startup hook. + +Imports stay fast because the heavy pieces load on first use: a protocol +version's wire schemas load on the first message parsed for that version, and +each pydantic model builds its validator the first time it is used. A host that +would rather pay that once, before serving traffic, calls `mcp.warm()`. +""" + +import mcp + +# Build the deferred validators now: the version-independent set, plus the +# routing surface of each protocol version this host will serve. +report = mcp.warm("2025-11-25") +print(f"prewarmed {report.models} models and {report.adapters} adapters in {report.elapsed_ms} ms") diff --git a/mkdocs.yml b/mkdocs.yml index 06b293f876..debadadd5f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,7 @@ nav: - Middleware: advanced/middleware.md - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md + - "Imports & startup time": advanced/import-cost.md - Troubleshooting: troubleshooting.md - Migration Guide: migration.md - API Reference: api/ diff --git a/pyproject.toml b/pyproject.toml index 3c814106d1..ce3ae2460b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -221,6 +221,8 @@ max-complexity = 24 # Default is 10 "src/mcp/types/*.py" = ["F403"] # Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators). "src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"] +# The deferred RootModel base the generated validators derive from. +"src/mcp-types/mcp_types/_wire_base.py" = ["TID251"] "tests/server/mcpserver/test_func_metadata.py" = ["E501"] "tests/shared/test_progress_notifications.py" = ["PLW0603"] @@ -304,6 +306,9 @@ exclude_also = [ "@overload", "raise NotImplementedError", ] +# `if not TYPE_CHECKING:` installs runtime-only lazy module attributes; its +# false arc belongs to the type checker and never runs (like `if TYPE_CHECKING:`). +partial_also = ["if not TYPE_CHECKING:"] # https://coverage.readthedocs.io/en/latest/config.html#paths [tool.coverage.paths] diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index ab8be15cf3..892e714a1c 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse +import ast import difflib import hashlib import json @@ -196,6 +197,151 @@ def run_codegen(schema_path: Path, output_path: Path) -> None: raise SystemExit(f"datamodel-codegen failed:\n{result.stderr}") +def use_deferred_bases(source: str) -> str: + """Route generated `RootModel[T]` classes through the deferred `WireRootModel[T]` base. + + Object models already derive from `WireModel` via codegen's `--base-class`; + RootModel classes get no such hook, so rewrite them here. Both bases carry + `defer_build=True` (see `mcp_types/_wire_base.py`), which is only a win if + EVERY class in the package is deferred: an eagerly-built union rollup would + regenerate the schemas of every deferred model it references. + """ + source = source.replace("RootModel[", "WireRootModel[") + source = source.replace( + "from mcp_types._wire_base import WireModel", "from mcp_types._wire_base import WireModel, WireRootModel" + ) + source = re.sub(r"^from pydantic import (.*), RootModel$", r"from pydantic import \1", source, flags=re.MULTILINE) + assert "RootModel[" not in source.replace("WireRootModel[", ""), "unrewritten RootModel base" + return source + + +def strip_rebuild_epilogue(source: str) -> str: + """Drop codegen's trailing `X.model_rebuild()` calls. + + codegen emits them to force-resolve forward references eagerly; with the + deferred bases each model is built once on first use, when its whole module + namespace already exists, so the calls would only re-introduce the eager + build cost the deferral removes. + """ + return re.sub(r"^\w+\.model_rebuild\(\)\n", "", source, flags=re.MULTILINE) + + +def define_before_use(source: str) -> str: + """Reorder the generated classes so every referenced class is defined first. + + codegen's ordering leaves whole subtrees referring to not-yet-defined + classes (it relied on the eager `model_rebuild()` epilogue to patch them + up). A deferred model whose annotations name an undefined class collects + incomplete `model_fields` (an `Annotated[...]` alias trapped in an + unevaluated string) until its first-use build; defining dependencies first + keeps field metadata complete at import for every class. Only genuine + recursion cannot be linearized (the mutually-recursive JSONValue trio in + 2026-07-28); such a cycle is kept together in codegen's own order, its + string self-references resolving on first use. + """ + tree = ast.parse(source) + lines = source.splitlines(keepends=True) + classes = [node for node in tree.body if isinstance(node, ast.ClassDef)] + if not classes: + return source + names = {node.name for node in classes} + index = {node.name: i for i, node in enumerate(classes)} + + def referenced(node: ast.ClassDef, *, quoted: bool) -> set[str]: + refs: set[str] = set() + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and sub.id in names: + refs.add(sub.id) + elif quoted and isinstance(sub, ast.Constant) and isinstance(sub.value, str) and sub.value in names: + refs.add(sub.value) # quoted forward reference + refs.discard(node.name) + return refs + + deps = {node.name: referenced(node, quoted=True) for node in classes} + + # Tarjan's SCC: a cycle (real recursion) is placed as one unit. + counter: list[int] = [0] + stack: list[str] = [] + on_stack: set[str] = set() + ids: dict[str, int] = {} + low: dict[str, int] = {} + comps: list[list[str]] = [] + + def strongconnect(v: str) -> None: + ids[v] = low[v] = counter[0] + counter[0] += 1 + stack.append(v) + on_stack.add(v) + for w in sorted(deps[v], key=index.__getitem__): + if w not in ids: + strongconnect(w) + low[v] = min(low[v], low[w]) + elif w in on_stack: + low[v] = min(low[v], ids[w]) + if low[v] == ids[v]: + comp: list[str] = [] + while True: + w = stack.pop() + on_stack.discard(w) + comp.append(w) + if w == v: + break + comps.append(sorted(comp, key=index.__getitem__)) + + for name in sorted(names, key=index.__getitem__): + if name not in ids: + strongconnect(name) + + # Emit each SCC once, dependencies first, stable on codegen's original order. + comp_of = {name: i for i, comp in enumerate(comps) for name in comp} + emitted: list[str] = [] + done: set[int] = set() + + def emit(ci: int) -> None: + if ci in done: + return + done.add(ci) + for name in comps[ci]: + for dep in sorted(deps[name], key=index.__getitem__): + if comp_of[dep] != ci: + emit(comp_of[dep]) + emitted.extend(comps[ci]) + + for name in sorted(names, key=index.__getitem__): + emit(comp_of[name]) + assert set(emitted) == names + # Invariant: every class named by a bare identifier is defined earlier; + # only quoted references inside a genuine recursive cycle may still point + # forward. A regression here silently reintroduces incomplete + # `model_fields` at import, so fail generation instead. + position = {name: i for i, name in enumerate(emitted)} + unresolved = { + (node.name, ref) + for node in classes + for ref in referenced(node, quoted=False) + if position[ref] > position[node.name] + } + assert not unresolved, f"classes still name a later-defined class: {sorted(unresolved)}" + + # The reorder reassembles the file from the class blocks, the text before + # the first class and the text after the last: any other statement between + # two classes would silently vanish, so codegen must never emit one there. + first_line = classes[0].lineno + last_line = max(node.end_lineno or 0 for node in classes) + interstitial = [ + node.lineno + for node in tree.body + if not isinstance(node, ast.ClassDef) and first_line <= node.lineno <= last_line + ] + assert not interstitial, f"non-class statements between generated classes would be dropped: lines {interstitial}" + + node_of = {node.name: node for node in classes} + blocks = ["".join(lines[node_of[name].lineno - 1 : node_of[name].end_lineno]) for name in emitted] + head = "".join(lines[: first_line - 1]) + tail = "".join(lines[last_line:]) + return head + "\n\n\n".join(block.strip("\n") + "\n" for block in blocks) + tail + + def allow_open_class_extras(source: str, open_classes: frozenset[str]) -> str: """Restore `extra="allow"` on `open_classes` only. @@ -243,12 +389,11 @@ def build(entry: dict[str, str]) -> str: # strict mkdocs link validation. source = source.replace("](/", "](https://modelcontextprotocol.io/") source = allow_open_class_extras(source, OPEN_CLASSES[version]) + source = use_deferred_bases(source) + source = strip_rebuild_epilogue(source) + source = define_before_use(source) if epilogue := EPILOGUES.get(version, ""): - # Insert before the trailing model_rebuild() block: pyright's evaluation - # order for the recursive RootModel block is sensitive to placement. - match = re.search(r"^\w+\.model_rebuild\(\)$", source, flags=re.MULTILINE) - cut = match.start() if match else len(source) - source = f"{source[:cut]}{epilogue}\n\n{source[cut:]}" + source = f"{source.rstrip()}\n\n\n{epilogue}" source = HEADER.format(version=version, sha=entry["sha256"]) + source staging = TYPES_DIR / f"_staging_{version}.py" diff --git a/src/mcp-types/mcp_types/_deferred.py b/src/mcp-types/mcp_types/_deferred.py new file mode 100644 index 0000000000..d9e6f9ca61 --- /dev/null +++ b/src/mcp-types/mcp_types/_deferred.py @@ -0,0 +1,181 @@ +"""Support for the SDK's `defer_build=True` pydantic models. + +The SDK's model bases set `defer_build=True`, so pydantic builds a model's core +schema, validator and serializer on first use instead of while the class +statement runs; a module full of models then costs almost nothing to import in +a process that never validates them. Two things need help until that first +build happens: + +* `inspect.signature(Model)`. Under `defer_build` the synthesized `__init__` + signature (`__signature__`) exists only once the model has built; until then + a class would report the generic `(**data)`. `DeferredSignature` is a + class-level `__signature__` that completes the (one-time) build via the + public `model_rebuild()` on first signature access, so runtime introspection + matches an eagerly-built model while nothing is built at import. +* The first build under concurrency. Released pydantic (<= 2.13) does not + lock `model_rebuild()`, so several threads first-using one model at once + (sync tools on worker threads, one session per thread) can raise + `AttributeError: __pydantic_core_schema__` or install a stale validator + (pydantic#13419; fixed unreleased in pydantic#13438). One process-wide + reentrant lock around the build closes that window: a late thread waits, + then finds the model complete and its own rebuild is a no-op. + +`deferred_model` installs both on the root class of a deferred model hierarchy +(the type-layer bases `MCPModel` / `WireModel` / `WireRootModel`, the JSON-RPC +envelopes, and the `mcp` package's own model roots); subclasses inherit them. + +This is a private module of `mcp-types`, but the `mcp` package (which +exact-pins its `mcp-types` sibling) imports `deferred_model` for its own model +roots too: keep the signatures stable across both packages. +""" + +from __future__ import annotations + +import threading +from inspect import Signature +from typing import Any, Final, TypeVar, cast + +from pydantic import BaseModel + +__all__ = [ + "REBUILD_LOCK", + "DeferredSignature", + "deferred_model", + "install_deferred_signature", + "new_deferred_signature", +] + +_ModelT = TypeVar("_ModelT", bound=BaseModel) + +REBUILD_LOCK: Final = threading.RLock() +"""Serializes the first (deferred) build of every SDK model across threads. + +Reentrant on purpose: completing one model rebuilds the models it references +on the same thread, re-entering `model_rebuild()`. Held only while a model +actually builds (and while a not-yet-built model's JSON schema is generated, +a cold path); a completed model's `model_rebuild()` returns before the lock, +and no per-message path takes it. + +The lock is held across pydantic's own build machinery, and that machinery +runs user code: custom `__get_pydantic_core_schema__` hooks and subclass / +metaclass hooks execute under it. So the usual lock-order rule applies - user +code must not block on some other lock inside those hooks while another +thread holds that lock and first-uses a model - and a thread mid-build during +`os.fork()` leaves the child's lock held (the standard fork-with-threads +caveat). Both are the same terms upstream pydantic adopted for its own, +identical, process-wide rebuild lock (pydantic#13438); once a pydantic release +containing it is our dependency floor this SDK lock can go. A host that calls +`mcp.warm()` at startup builds everything on one thread and never contends here. +""" + + +class DeferredSignature: + """Class-level `__signature__` for a `defer_build=True` pydantic model. + + First access completes the model build via `model_rebuild()` (public API, + a no-op once complete); pydantic then binds its own signature on the class, + replacing this attribute, and that is what gets returned. If the build cannot + complete (an unresolvable forward reference), no signature results and + `inspect` falls back to `__init__` exactly as for any unbuilt deferred model. + """ + + def __get__(self, obj: object | None, owner: type[BaseModel] | None = None) -> Any: + if obj is not None or owner is None: + # Instances take their signature from `__call__`; only the class has one. + raise AttributeError("__signature__") + # `raise_errors=False`: an annotation that cannot resolve yet leaves the + # model unbuilt (and this attribute in place) instead of raising here, + # so `inspect` falls back to `__init__` like it does for pydantic itself. + owner.model_rebuild(raise_errors=False) + bound = vars(owner).get("__signature__") + if bound is None or bound is self: + raise AttributeError("__signature__") + return bound.__get__(None, owner) if hasattr(bound, "__get__") else bound + + +def new_deferred_signature() -> Signature: + """A `DeferredSignature` typed as the `Signature` it stands in for. + + Bound in a class namespace WITHOUT an annotation (matching how `BaseModel` + itself binds `__signature__`), so it never appears in `get_type_hints(Model)`. + """ + return cast(Signature, DeferredSignature()) + + +def install_deferred_signature(cls: type[BaseModel]) -> None: + """Give a still-deferred model class its own lazily-completing `__signature__`. + + Every deferred subclass needs its OWN `__signature__` entry, otherwise it + would inherit an already-built parent's signature. A class that pydantic + already completed (e.g. one that turned `defer_build` off) keeps the real + signature it has. + """ + if not cls.__pydantic_complete__: + setattr(cls, "__signature__", new_deferred_signature()) + + +def deferred_model(cls: type[_ModelT]) -> type[_ModelT]: + """Class decorator for the root of a `defer_build=True` model hierarchy. + + Installs, on `cls` and (through inheritance) on every model that later + subclasses it - a subclass inherits the deferred build through the config: + + * the lazy `__signature__`, via a `__pydantic_init_subclass__` hook that + stamps a fresh one on each still-deferred subclass; + * a `model_rebuild` classmethod that takes `REBUILD_LOCK`, so the first + build of any model in the hierarchy is serialized across threads (public + pydantic API; `_parent_namespace_depth + 1` accounts for this extra + frame); + * a `model_json_schema` classmethod that completes the (deferred) model + and generates the schema under that lock, so concurrent first schema + calls race neither on the not-yet-built class nor on a referenced + (e.g. mutually recursive) model that another thread is completing. + + Raises: + TypeError: `cls` does not set `defer_build=True` in its `model_config`, + or defines its own `__pydantic_init_subclass__` (which the hook + installed here would silently replace). + """ + if not cls.model_config.get("defer_build"): + raise TypeError(f"@deferred_model expects {cls.__name__} to set model_config['defer_build'] = True") + if "__pydantic_init_subclass__" in vars(cls): + raise TypeError(f"@deferred_model would replace {cls.__name__}'s own __pydantic_init_subclass__") + + install_deferred_signature(cls) + + def __pydantic_init_subclass__(sub: type[BaseModel], **kwargs: Any) -> None: + install_deferred_signature(sub) + # Chain to whatever hook `cls`'s bases define (BaseModel's is a no-op). + # (`super()` from a classmethod defined outside the class body needs the + # two-argument form, which is opaque to the type checker.) + cast(Any, super(cls, sub)).__pydantic_init_subclass__(**kwargs) + + def model_rebuild(sub: type[BaseModel], *args: Any, _parent_namespace_depth: int = 2, **kwargs: Any) -> Any: + # An already-built model is the common case: answer it like pydantic + # does, without touching the lock. + if sub.__pydantic_complete__ and not (args or kwargs.get("force")): + return None + # One process-wide lock serializes the deferred first build across + # threads; a late thread finds the model complete and no-ops. + with REBUILD_LOCK: + return cast(Any, super(cls, sub)).model_rebuild( + *args, _parent_namespace_depth=_parent_namespace_depth + 1, **kwargs + ) + + def model_json_schema(sub: type[BaseModel], *args: Any, **kwargs: Any) -> Any: + # Complete the class AND generate under the lock: pydantic re-reads a + # class's (mock) core schema around a concurrent first build, and the + # generation for one model reads the core schema of every model it + # references (a mutually recursive family such as the wire JSON models + # references its siblings) while another thread may still be + # completing one of those. Schema generation is a cold path, so it + # simply serializes with the deferred first builds. + with REBUILD_LOCK: + if not sub.__pydantic_complete__: + sub.model_rebuild(raise_errors=False) + return cast(Any, super(cls, sub)).model_json_schema(*args, **kwargs) + + setattr(cls, "__pydantic_init_subclass__", classmethod(__pydantic_init_subclass__)) + setattr(cls, "model_rebuild", classmethod(model_rebuild)) + setattr(cls, "model_json_schema", classmethod(model_json_schema)) + return cls diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index 5852d9bba3..3537b39a72 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -21,6 +21,7 @@ from pydantic.alias_generators import to_camel from typing_extensions import NotRequired, Self, TypedDict +from mcp_types._deferred import deferred_model from mcp_types.jsonrpc import RequestId DEFAULT_NEGOTIATED_VERSION: Final[str] = "2025-03-26" @@ -42,10 +43,16 @@ """Theme an icon is designed for. Wire values of `Icon.theme` (2025-11-25+).""" +@deferred_model class MCPModel(BaseModel): """Base class for all MCP protocol types.""" - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + # defer_build: the core schema / validator / serializer for each model is + # built on first use instead of at class creation, so importing this module + # does not pay for ~150 models most processes never touch. `deferred_model` + # keeps `inspect.signature()` accurate before that first use and makes the + # first build thread-safe (see `mcp_types._deferred`). + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, defer_build=True) Meta: TypeAlias = dict[str, Any] @@ -868,35 +875,6 @@ class ListResourceTemplatesResult(PaginatedResult, CacheableResult): """See `ResultType`. Always serialized; older peers ignore it.""" -class InputResponseRequestParams(RequestParams): - """Base params for client requests that can carry responses to a server's - input requests (2026-07-28 multi-round-trip flow). - - When a request returns an `InputRequiredResult`, the client retries the - original request with these fields populated. - """ - - input_responses: InputResponses | None = None - """Responses to the server's `InputRequiredResult.input_requests`, keyed identically.""" - request_state: str | None = None - """Opaque state from the `InputRequiredResult`, passed back verbatim on retry.""" - - -class ReadResourceRequestParams(InputResponseRequestParams): - uri: str - """ - The URI of the resource. The URI can use any protocol; it is up to the server - how to interpret it. - """ - - -class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]): - """Sent from the client to the server, to read a specific resource URI.""" - - method: Literal["resources/read"] = "resources/read" - params: ReadResourceRequestParams - - class ResourceContents(MCPModel): """The contents of a specific resource or sub-resource.""" @@ -1133,20 +1111,6 @@ class ListPromptsResult(PaginatedResult, CacheableResult): """See `ResultType`. Always serialized; older peers ignore it.""" -class GetPromptRequestParams(InputResponseRequestParams): - name: str - """The name of the prompt or prompt template.""" - arguments: dict[str, str] | None = None - """Arguments to use for templating the prompt.""" - - -class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): - """Used by the client to get a prompt provided by the server.""" - - method: Literal["prompts/get"] = "prompts/get" - params: GetPromptRequestParams - - class TextContent(MCPModel): """Text provided to or from an LLM.""" @@ -1199,6 +1163,37 @@ class AudioContent(MCPModel): """ +class EmbeddedResource(MCPModel): + """The contents of a resource, embedded into a prompt or tool call result. + + It is up to the client how best to render embedded resources for the benefit + of the LLM and/or the user. + """ + + type: Literal["resource"] = "resource" + resource: TextResourceContents | BlobResourceContents + annotations: Annotations | None = None + """Optional annotations for the client.""" + meta: Meta | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + +class ResourceLink(Resource): + """A resource that the server is capable of reading, included in a prompt or tool call result. + + Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + """ + + type: Literal["resource_link"] = "resource_link" + + +ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource +"""A content block that can be used in prompts and tool results.""" + + class ToolUseContent(MCPModel): """An assistant's request to invoke a tool during sampling (2025-11-25+). @@ -1289,37 +1284,6 @@ def content_as_list(self) -> list[SamplingMessageContentBlock]: return self.content if isinstance(self.content, list) else [self.content] -class EmbeddedResource(MCPModel): - """The contents of a resource, embedded into a prompt or tool call result. - - It is up to the client how best to render embedded resources for the benefit - of the LLM and/or the user. - """ - - type: Literal["resource"] = "resource" - resource: TextResourceContents | BlobResourceContents - annotations: Annotations | None = None - """Optional annotations for the client.""" - meta: Meta | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - - -class ResourceLink(Resource): - """A resource that the server is capable of reading, included in a prompt or tool call result. - - Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. - """ - - type: Literal["resource_link"] = "resource_link" - - -ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource -"""A content block that can be used in prompts and tool results.""" - - class PromptMessage(MCPModel): """Describes a message returned as part of a prompt. @@ -1446,20 +1410,6 @@ class ListToolsResult(PaginatedResult, CacheableResult): """See `ResultType`. Always serialized; older peers ignore it.""" -class CallToolRequestParams(InputResponseRequestParams): - name: str - arguments: dict[str, Any] | None = None - task: TaskMetadata | None = None - """If specified, the caller requests task-augmented execution (2025-11-25 only).""" - - -class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): - """Used by the client to invoke a tool provided by the server.""" - - method: Literal["tools/call"] = "tools/call" - params: CallToolRequestParams - - class CallToolResult(Result): """The server's response to a tool call. @@ -2071,6 +2021,68 @@ class ElicitationRequiredErrorData(MCPModel): """ +# The request params below carry `InputResponses`, so they are defined after it: +# a forward reference would leave their fields (and camelCase aliases) +# unresolved until first use now that models are `defer_build`. + + +class InputResponseRequestParams(RequestParams): + """Base params for client requests that can carry responses to a server's + input requests (2026-07-28 multi-round-trip flow). + + When a request returns an `InputRequiredResult`, the client retries the + original request with these fields populated. + """ + + input_responses: InputResponses | None = None + """Responses to the server's `InputRequiredResult.input_requests`, keyed identically.""" + request_state: str | None = None + """Opaque state from the `InputRequiredResult`, passed back verbatim on retry.""" + + +class ReadResourceRequestParams(InputResponseRequestParams): + uri: str + """ + The URI of the resource. The URI can use any protocol; it is up to the server + how to interpret it. + """ + + +class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]): + """Sent from the client to the server, to read a specific resource URI.""" + + method: Literal["resources/read"] = "resources/read" + params: ReadResourceRequestParams + + +class GetPromptRequestParams(InputResponseRequestParams): + name: str + """The name of the prompt or prompt template.""" + arguments: dict[str, str] | None = None + """Arguments to use for templating the prompt.""" + + +class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): + """Used by the client to get a prompt provided by the server.""" + + method: Literal["prompts/get"] = "prompts/get" + params: GetPromptRequestParams + + +class CallToolRequestParams(InputResponseRequestParams): + name: str + arguments: dict[str, Any] | None = None + task: TaskMetadata | None = None + """If specified, the caller requests task-augmented execution (2025-11-25 only).""" + + +class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): + """Used by the client to invoke a tool provided by the server.""" + + method: Literal["tools/call"] = "tools/call" + params: CallToolRequestParams + + class InputRequiredResult(Result): """The server needs additional input before the original request can complete (2026-07-28). @@ -2097,15 +2109,12 @@ def _require_one_field(self) -> Self: return self -# Forward refs to InputResponses; rebuild at import time rather than first use. -InputResponseRequestParams.model_rebuild() -ReadResourceRequestParams.model_rebuild() -GetPromptRequestParams.model_rebuild() -CallToolRequestParams.model_rebuild() - # Top-level message unions: superset across all supported protocol versions. # Per-version validity is recorded in `mcp_types.methods`, not enforced here. +_DEFER = ConfigDict(defer_build=True) +"""TypeAdapter config: build the union validator on first use, not at import.""" + ClientRequest = ( PingRequest | InitializeRequest @@ -2128,7 +2137,7 @@ def _require_one_field(self) -> Self: The 2025-11-25 task requests are deliberately excluded (types-only). """ -client_request_adapter = TypeAdapter[ClientRequest](ClientRequest) +client_request_adapter = TypeAdapter[ClientRequest](ClientRequest, config=_DEFER) ClientNotification = ( @@ -2139,11 +2148,11 @@ def _require_one_field(self) -> Self: `TaskStatusNotification` is deliberately excluded (types-only). """ -client_notification_adapter = TypeAdapter[ClientNotification](ClientNotification) +client_notification_adapter = TypeAdapter[ClientNotification](ClientNotification, config=_DEFER) ClientResult = EmptyResult | CreateMessageResult | CreateMessageResultWithTools | ListRootsResult | ElicitResult -client_result_adapter = TypeAdapter[ClientResult](ClientResult) +client_result_adapter = TypeAdapter[ClientResult](ClientResult, config=_DEFER) ServerRequest = PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest @@ -2153,7 +2162,7 @@ def _require_one_field(self) -> Self: requests (these payloads are embedded in `InputRequiredResult` instead). """ -server_request_adapter = TypeAdapter[ServerRequest](ServerRequest) +server_request_adapter = TypeAdapter[ServerRequest](ServerRequest, config=_DEFER) ServerNotification = ( @@ -2172,7 +2181,7 @@ def _require_one_field(self) -> Self: `TaskStatusNotification` is deliberately excluded (types-only). """ -server_notification_adapter = TypeAdapter[ServerNotification](ServerNotification) +server_notification_adapter = TypeAdapter[ServerNotification](ServerNotification, config=_DEFER) ServerResult = ( @@ -2195,4 +2204,4 @@ def _require_one_field(self) -> Self: `InputRequiredResult` is deliberately last: both of its fields are optional, so an earlier position would shadow other members during union resolution. """ -server_result_adapter = TypeAdapter[ServerResult](ServerResult) +server_result_adapter = TypeAdapter[ServerResult](ServerResult, config=_DEFER) diff --git a/src/mcp-types/mcp_types/_v2025_11_25/__init__.py b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py index b639bf7af8..a0d97a13e9 100644 --- a/src/mcp-types/mcp_types/_v2025_11_25/__init__.py +++ b/src/mcp-types/mcp_types/_v2025_11_25/__init__.py @@ -8,8 +8,8 @@ from typing import Annotated, Any, Literal -from mcp_types._wire_base import WireModel -from pydantic import ConfigDict, Field, RootModel +from mcp_types._wire_base import WireModel, WireRootModel +from pydantic import ConfigDict, Field class BaseMetadata(WireModel): @@ -285,7 +285,7 @@ class CompleteResult(WireModel): completion: Completion -class Cursor(RootModel[str]): +class Cursor(WireRootModel[str]): root: str """ An opaque token used to represent a cursor for pagination. @@ -557,7 +557,7 @@ class LegacyTitledEnumSchema(WireModel): class LoggingLevel( - RootModel[ + WireRootModel[ Literal[ "alert", "critical", @@ -723,7 +723,7 @@ class PaginatedResult(WireModel): """ -class ProgressToken(RootModel[str | int]): +class ProgressToken(WireRootModel[str | int]): root: str | int """ A progress token, used to associate progress notifications with the original request. @@ -853,7 +853,7 @@ class Request(WireModel): params: dict[str, Any] | None = None -class RequestId(RootModel[str | int]): +class RequestId(WireRootModel[str | int]): root: str | int """ A uniquely identifying ID for a request in JSON-RPC. @@ -970,7 +970,7 @@ class Result(WireModel): """ -class Role(RootModel[Literal["assistant", "user"]]): +class Role(WireRootModel[Literal["assistant", "user"]]): root: Literal["assistant", "user"] """ The sender or recipient of messages and data in a conversation. @@ -1216,7 +1216,7 @@ class TaskMetadata(WireModel): """ -class TaskStatus(RootModel[Literal["cancelled", "completed", "failed", "input_required", "working"]]): +class TaskStatus(WireRootModel[Literal["cancelled", "completed", "failed", "input_required", "working"]]): root: Literal["cancelled", "completed", "failed", "input_required", "working"] """ The status of a task. @@ -1867,12 +1867,12 @@ class EmbeddedResource(WireModel): type: Literal["resource"] -class EmptyResult(RootModel[Result]): +class EmptyResult(WireRootModel[Result]): root: Result class EnumSchema( - RootModel[ + WireRootModel[ UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema | UntitledMultiSelectEnumSchema @@ -2115,7 +2115,7 @@ class LoggingMessageNotification(WireModel): params: LoggingMessageNotificationParams -class MultiSelectEnumSchema(RootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): +class MultiSelectEnumSchema(WireRootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): root: UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema @@ -2153,7 +2153,7 @@ class PingRequest(WireModel): class PrimitiveSchemaDefinition( - RootModel[ + WireRootModel[ StringSchema | NumberSchema | BooleanSchema @@ -2499,7 +2499,7 @@ class SetLevelRequest(WireModel): params: SetLevelRequestParams -class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): +class SingleSelectEnumSchema(WireRootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema @@ -2793,7 +2793,7 @@ class CompleteRequest(WireModel): params: CompleteRequestParams -class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): +class ContentBlock(WireRootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource @@ -2812,7 +2812,7 @@ class CreateTaskResult(WireModel): task: Task -class ElicitRequestParams(RootModel[ElicitRequestURLParams | ElicitRequestFormParams]): +class ElicitRequestParams(WireRootModel[ElicitRequestURLParams | ElicitRequestFormParams]): root: ElicitRequestURLParams | ElicitRequestFormParams """ The parameters for a request to elicit additional information from the user via the client. @@ -2857,14 +2857,16 @@ class InitializeRequest(WireModel): params: InitializeRequestParams -class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): +class JSONRPCMessage( + WireRootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse] +): root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse """ Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. """ -class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): +class JSONRPCResponse(WireRootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): root: JSONRPCResultResponse | JSONRPCErrorResponse """ A response to a request, containing either the result or error. @@ -3174,7 +3176,7 @@ class CallToolResult(WireModel): class ClientNotification( - RootModel[ + WireRootModel[ CancelledNotification | InitializedNotification | ProgressNotification @@ -3192,7 +3194,7 @@ class ClientNotification( class ClientRequest( - RootModel[ + WireRootModel[ InitializeRequest | PingRequest | ListResourcesRequest @@ -3267,13 +3269,13 @@ class GetPromptResult(WireModel): class SamplingMessageContentBlock( - RootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] + WireRootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] ): root: TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent class ServerNotification( - RootModel[ + WireRootModel[ CancelledNotification | ProgressNotification | ResourceListChangedNotification @@ -3299,7 +3301,7 @@ class ServerNotification( class ServerResult( - RootModel[ + WireRootModel[ Result | InitializeResult | ListResourcesResult @@ -3399,7 +3401,7 @@ class SamplingMessage(WireModel): class ClientResult( - RootModel[ + WireRootModel[ Result | GetTaskResult | GetTaskPayloadResult @@ -3503,7 +3505,7 @@ class CreateMessageRequest(WireModel): class ServerRequest( - RootModel[ + WireRootModel[ PingRequest | GetTaskRequest | GetTaskPayloadRequest diff --git a/src/mcp-types/mcp_types/_v2026_07_28/__init__.py b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py index fb168b3059..503e872315 100644 --- a/src/mcp-types/mcp_types/_v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/_v2026_07_28/__init__.py @@ -8,8 +8,8 @@ from typing import Annotated, Any, Literal, Union -from mcp_types._wire_base import WireModel -from pydantic import ConfigDict, Field, RootModel +from mcp_types._wire_base import WireModel, WireRootModel +from pydantic import ConfigDict, Field class BaseMetadata(WireModel): @@ -95,7 +95,7 @@ class Completion(WireModel): """ -class Cursor(RootModel[str]): +class Cursor(WireRootModel[str]): root: str """ An opaque token used to represent a cursor for pagination. @@ -437,7 +437,7 @@ class LegacyTitledEnumSchema(WireModel): class LoggingLevel( - RootModel[ + WireRootModel[ Literal[ "alert", "critical", @@ -624,7 +624,7 @@ class ParseError(WireModel): """ -class ProgressToken(RootModel[str | int]): +class ProgressToken(WireRootModel[str | int]): root: str | int """ A progress token, used to associate progress notifications with the original request. @@ -694,7 +694,7 @@ class Request(WireModel): params: dict[str, Any] | None = None -class RequestId(RootModel[str | int]): +class RequestId(WireRootModel[str | int]): root: str | int """ A uniquely identifying ID for a request in JSON-RPC. @@ -759,7 +759,7 @@ class ResultMetaObject(WireModel): """ -class ResultType(RootModel[str]): +class ResultType(WireRootModel[str]): root: str """ Indicates the type of a {@link Result} object, allowing the client to @@ -770,7 +770,7 @@ class ResultType(RootModel[str]): """ -class Role(RootModel[Literal["assistant", "user"]]): +class Role(WireRootModel[Literal["assistant", "user"]]): root: Literal["assistant", "user"] """ The sender or recipient of messages and data in a conversation. @@ -1468,7 +1468,7 @@ class CompleteResultResponse(WireModel): result: CompleteResult -class ElicitRequestParams(RootModel[ElicitRequestFormParams | ElicitRequestURLParams]): +class ElicitRequestParams(WireRootModel[ElicitRequestFormParams | ElicitRequestURLParams]): root: ElicitRequestFormParams | ElicitRequestURLParams """ The parameters for a request to elicit additional information from the user via the client. @@ -1496,7 +1496,7 @@ class EmbeddedResource(WireModel): class EnumSchema( - RootModel[ + WireRootModel[ UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema | UntitledMultiSelectEnumSchema @@ -1618,7 +1618,7 @@ class ListRootsResult(WireModel): roots: list[Root] -class MultiSelectEnumSchema(RootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): +class MultiSelectEnumSchema(WireRootModel[UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema]): root: UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema @@ -1681,7 +1681,7 @@ class PaginatedResult(WireModel): class PrimitiveSchemaDefinition( - RootModel[ + WireRootModel[ StringSchema | NumberSchema | BooleanSchema @@ -2064,7 +2064,7 @@ class Result(WireModel): """ -class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): +class SingleSelectEnumSchema(WireRootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema @@ -2256,14 +2256,14 @@ class ClientNotification(WireModel): params: CancelledNotificationParams -class ClientResult(RootModel[Result]): +class ClientResult(WireRootModel[Result]): root: Result """ Common result fields. """ -class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): +class ContentBlock(WireRootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource @@ -2279,7 +2279,7 @@ class ElicitRequest(WireModel): params: ElicitRequestParams -class EmptyResult(RootModel[Result]): +class EmptyResult(WireRootModel[Result]): root: Result """ Common result fields. @@ -2776,14 +2776,16 @@ class GetPromptResult(WireModel): """ -class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): +class JSONRPCMessage( + WireRootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse] +): root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse """ Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. """ -class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): +class JSONRPCResponse(WireRootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): root: JSONRPCResultResponse | JSONRPCErrorResponse """ A response to a request, containing either the result or error. @@ -2804,13 +2806,13 @@ class LoggingMessageNotification(WireModel): class SamplingMessageContentBlock( - RootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] + WireRootModel[TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent] ): root: TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent class ServerNotification( - RootModel[ + WireRootModel[ CancelledNotification | ProgressNotification | ResourceListChangedNotification @@ -2871,11 +2873,11 @@ class CreateMessageResult(WireModel): """ -class InputResponse(RootModel[CreateMessageResult | ListRootsResult | ElicitResult]): +class InputResponse(WireRootModel[CreateMessageResult | ListRootsResult | ElicitResult]): root: CreateMessageResult | ListRootsResult | ElicitResult -class InputResponses(RootModel[dict[str, InputResponse]]): +class InputResponses(WireRootModel[dict[str, InputResponse]]): """ A map of client responses to server-initiated requests. Keys correspond to the keys in the {@link InputRequests} map; @@ -2905,52 +2907,12 @@ class SamplingMessage(WireModel): role: Role -class CallToolRequest(WireModel): - """ - Used by the client to invoke a tool provided by the server. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["tools/call"] - params: CallToolRequestParams - - -class CallToolRequestParams(WireModel): - """ - Parameters for a `tools/call` request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - arguments: dict[str, Any] | None = None - """ - Arguments to use for the tool call. - """ - input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None - name: str - """ - The name of the tool. - """ - request_state: Annotated[str | None, Field(alias="requestState")] = None - +class JSONObject(WireRootModel[dict[str, "JSONValue"]]): + root: dict[str, "JSONValue"] -class CallToolResultResponse(WireModel): - """ - A successful response from the server for a {@link CallToolRequesttools/call} request. - """ - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - result: InputRequiredResult | CallToolResult +class JSONValue(WireRootModel[Union[JSONObject, list["JSONValue"], str | int | float | bool | None]]): + root: Union[JSONObject, list["JSONValue"], str | int | float | bool | None] class Elicitation(WireModel): @@ -3019,50 +2981,98 @@ class ClientCapabilities(WireModel): """ -class CompleteRequest(WireModel): +class RequestMetaObject(WireModel): """ - A request from the client to the server, to ask for completion options. + Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. """ model_config = ConfigDict( - extra="ignore", + extra="allow", ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["completion/complete"] - params: CompleteRequestParams + io_modelcontextprotocol_client_capabilities: Annotated[ + ClientCapabilities, Field(alias="io.modelcontextprotocol/clientCapabilities") + ] + """ + The client's capabilities for this specific request. Required. + Capabilities are declared per-request rather than once at initialization; + an empty object means the client supports no optional capabilities. + Servers MUST NOT infer capabilities from prior requests. + """ + io_modelcontextprotocol_client_info: Annotated[ + Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo") + ] = None + """ + Identifies the client software making the request. Clients SHOULD + include this field on every request unless specifically configured not + to do so. -class CompleteRequestParams(WireModel): + The {@link Implementation} schema requires `name` and `version`; other + fields are optional. + + The value is self-reported by the client and is not verified by the + protocol. It is intended for display, logging, and debugging. Servers + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. """ - Parameters for a `completion/complete` request. + io_modelcontextprotocol_log_level: Annotated[ + LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel") + ] = None + """ + The desired log level for this request. Optional. + + If absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message} + notifications for this request. The client opts in to log messages by + explicitly setting a level. Replaces the former `logging/setLevel` RPC. + """ + io_modelcontextprotocol_protocol_version: Annotated[str, Field(alias="io.modelcontextprotocol/protocolVersion")] + """ + The MCP Protocol Version being used for this request. Required. + + For the HTTP transport, this value MUST match the `MCP-Protocol-Version` + header; otherwise the server MUST return a `400 Bad Request`. If the + server does not support the requested version, it MUST return an + {@link UnsupportedProtocolVersionError}. + """ + progress_token: Annotated[ProgressToken | None, Field(alias="progressToken")] = None + """ + If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + """ + + +class CallToolRequestParams(WireModel): + """ + Parameters for a `tools/call` request. """ model_config = ConfigDict( extra="ignore", ) meta: Annotated[RequestMetaObject, Field(alias="_meta")] - argument: Argument + arguments: dict[str, Any] | None = None """ - The argument's information + Arguments to use for the tool call. """ - context: Context | None = None + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + name: str """ - Additional, optional context for completions + The name of the tool. """ - ref: PromptReference | ResourceTemplateReference + request_state: Annotated[str | None, Field(alias="requestState")] = None -class CreateMessageRequest(WireModel): +class CallToolRequest(WireModel): """ - A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + Used by the client to invoke a tool provided by the server. """ model_config = ConfigDict( extra="ignore", ) - method: Literal["sampling/createMessage"] - params: CreateMessageRequestParams + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["tools/call"] + params: CallToolRequestParams class CreateMessageRequestParams(WireModel): @@ -3112,29 +3122,181 @@ class CreateMessageRequestParams(WireModel): The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. Default is `{ mode: "auto" }`. """ - tools: list[Tool] | None = None + tools: list[Tool] | None = None + """ + Tools that the model may use during generation. + The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + """ + + +class CreateMessageRequest(WireModel): + """ + A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + """ + + model_config = ConfigDict( + extra="ignore", + ) + method: Literal["sampling/createMessage"] + params: CreateMessageRequestParams + + +class InputRequest(WireRootModel[CreateMessageRequest | ListRootsRequest | ElicitRequest]): + root: CreateMessageRequest | ListRootsRequest | ElicitRequest + + +class InputRequests(WireRootModel[dict[str, InputRequest]]): + """ + A map of server-initiated requests that the client must fulfill. + Keys are server-assigned identifiers; values are the request objects. + """ + + root: dict[str, InputRequest] + + +class InputRequiredResult(WireModel): + """ + An InputRequiredResult sent by the server to indicate that additional input is needed + before the request can be completed. + + At least one of `inputRequests` or `requestState` MUST be present. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None + request_state: Annotated[str | None, Field(alias="requestState")] = None + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + +class CallToolResultResponse(WireModel): + """ + A successful response from the server for a {@link CallToolRequesttools/call} request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: InputRequiredResult | CallToolResult + + +class CompleteRequestParams(WireModel): + """ + Parameters for a `completion/complete` request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + argument: Argument + """ + The argument's information + """ + context: Context | None = None + """ + Additional, optional context for completions + """ + ref: PromptReference | ResourceTemplateReference + + +class CompleteRequest(WireModel): + """ + A request from the client to the server, to ask for completion options. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["completion/complete"] + params: CompleteRequestParams + + +class RequestParams(WireModel): + """ + Common params for any request. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + + +class DiscoverRequest(WireModel): + """ + A request from the client asking the server to advertise its supported + protocol versions, capabilities, and other metadata. Servers **MUST** + implement `server/discover`. Clients **MAY** call it but are not required + to — version negotiation can also happen inline via per-request `_meta`. + """ + + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + method: Literal["server/discover"] + params: RequestParams + + +class ServerCapabilities(WireModel): + """ + Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + """ + + model_config = ConfigDict( + extra="ignore", + ) + completions: JSONObject | None = None + """ + Present if the server supports argument autocompletion suggestions. + """ + experimental: dict[str, JSONObject] | None = None + """ + Experimental, non-standard capabilities that the server supports. + """ + extensions: dict[str, JSONObject] | None = None + """ + Optional MCP extensions that the server supports. Keys are extension identifiers + (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings + objects. An empty object indicates support with no settings. + + Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a + mandatory prefix. + """ + logging: JSONObject | None = None + """ + Present if the server supports sending log messages to the client. + """ + prompts: Prompts | None = None + """ + Present if the server offers any prompt templates. + """ + resources: Resources | None = None """ - Tools that the model may use during generation. - The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + Present if the server offers any resources to read. """ - - -class DiscoverRequest(WireModel): + tools: Tools | None = None """ - A request from the client asking the server to advertise its supported - protocol versions, capabilities, and other metadata. Servers **MUST** - implement `server/discover`. Clients **MAY** call it but are not required - to — version negotiation can also happen inline via per-request `_meta`. + Present if the server offers any tools to call. """ - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["server/discover"] - params: RequestParams - class DiscoverResult(WireModel): """ @@ -3212,20 +3374,6 @@ class DiscoverResultResponse(WireModel): result: DiscoverResult -class GetPromptRequest(WireModel): - """ - Used by the client to get a prompt provided by the server. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - method: Literal["prompts/get"] - params: GetPromptRequestParams - - class GetPromptRequestParams(WireModel): """ Parameters for a `prompts/get` request. @@ -3247,9 +3395,9 @@ class GetPromptRequestParams(WireModel): request_state: Annotated[str | None, Field(alias="requestState")] = None -class GetPromptResultResponse(WireModel): +class GetPromptRequest(WireModel): """ - A successful response from the server for a {@link GetPromptRequestprompts/get} request. + Used by the client to get a prompt provided by the server. """ model_config = ConfigDict( @@ -3257,33 +3405,21 @@ class GetPromptResultResponse(WireModel): ) id: RequestId jsonrpc: Literal["2.0"] - result: InputRequiredResult | GetPromptResult + method: Literal["prompts/get"] + params: GetPromptRequestParams -class InputRequiredResult(WireModel): +class GetPromptResultResponse(WireModel): """ - An InputRequiredResult sent by the server to indicate that additional input is needed - before the request can be completed. - - At least one of `inputRequests` or `requestState` MUST be present. + A successful response from the server for a {@link GetPromptRequestprompts/get} request. """ model_config = ConfigDict( extra="ignore", ) - meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None - input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None - request_state: Annotated[str | None, Field(alias="requestState")] = None - result_type: Annotated[str, Field(alias="resultType")] - """ - Indicates the type of the result, which allows the client to determine - how to parse the result object. - - Servers implementing this protocol version MUST include this field. - For backward compatibility, when a client receives a result from a - server implementing an earlier protocol version (which does not include - `resultType`), the client MUST treat the absent field as `"complete"`. - """ + id: RequestId + jsonrpc: Literal["2.0"] + result: InputRequiredResult | GetPromptResult class InputResponseRequestParams(WireModel): @@ -3295,6 +3431,22 @@ class InputResponseRequestParams(WireModel): request_state: Annotated[str | None, Field(alias="requestState")] = None +class PaginatedRequestParams(WireModel): + """ + Common params for paginated requests. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + cursor: str | None = None + """ + An opaque token representing the current pagination position. + If provided, the server should return results starting after this cursor. + """ + + class ListPromptsRequest(WireModel): """ Sent from the client to request a list of prompts and prompt templates the server has. @@ -3404,19 +3556,20 @@ class PaginatedRequest(WireModel): params: PaginatedRequestParams -class PaginatedRequestParams(WireModel): +class ReadResourceRequestParams(WireModel): """ - Common params for paginated requests. + Parameters for a `resources/read` request. """ model_config = ConfigDict( extra="ignore", ) meta: Annotated[RequestMetaObject, Field(alias="_meta")] - cursor: str | None = None + input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None + request_state: Annotated[str | None, Field(alias="requestState")] = None + uri: str """ - An opaque token representing the current pagination position. - If provided, the server should return results starting after this cursor. + The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. """ @@ -3434,23 +3587,6 @@ class ReadResourceRequest(WireModel): params: ReadResourceRequestParams -class ReadResourceRequestParams(WireModel): - """ - Parameters for a `resources/read` request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - input_responses: Annotated[InputResponses | None, Field(alias="inputResponses")] = None - request_state: Annotated[str | None, Field(alias="requestState")] = None - uri: str - """ - The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. - """ - - class ReadResourceResultResponse(WireModel): """ A successful response from the server for a {@link ReadResourceRequestresources/read} request. @@ -3464,76 +3600,6 @@ class ReadResourceResultResponse(WireModel): result: InputRequiredResult | ReadResourceResult -class RequestMetaObject(WireModel): - """ - Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. - """ - - model_config = ConfigDict( - extra="allow", - ) - io_modelcontextprotocol_client_capabilities: Annotated[ - ClientCapabilities, Field(alias="io.modelcontextprotocol/clientCapabilities") - ] - """ - The client's capabilities for this specific request. Required. - - Capabilities are declared per-request rather than once at initialization; - an empty object means the client supports no optional capabilities. - Servers MUST NOT infer capabilities from prior requests. - """ - io_modelcontextprotocol_client_info: Annotated[ - Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo") - ] = None - """ - Identifies the client software making the request. Clients SHOULD - include this field on every request unless specifically configured not - to do so. - - The {@link Implementation} schema requires `name` and `version`; other - fields are optional. - - The value is self-reported by the client and is not verified by the - protocol. It is intended for display, logging, and debugging. Servers - SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for - security decisions. - """ - io_modelcontextprotocol_log_level: Annotated[ - LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel") - ] = None - """ - The desired log level for this request. Optional. - - If absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message} - notifications for this request. The client opts in to log messages by - explicitly setting a level. Replaces the former `logging/setLevel` RPC. - """ - io_modelcontextprotocol_protocol_version: Annotated[str, Field(alias="io.modelcontextprotocol/protocolVersion")] - """ - The MCP Protocol Version being used for this request. Required. - - For the HTTP transport, this value MUST match the `MCP-Protocol-Version` - header; otherwise the server MUST return a `400 Bad Request`. If the - server does not support the requested version, it MUST return an - {@link UnsupportedProtocolVersionError}. - """ - progress_token: Annotated[ProgressToken | None, Field(alias="progressToken")] = None - """ - If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - """ - - -class RequestParams(WireModel): - """ - Common params for any request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - - class ResourceRequestParams(WireModel): """ Common params for resource-related requests. @@ -3549,46 +3615,20 @@ class ResourceRequestParams(WireModel): """ -class ServerCapabilities(WireModel): +class SubscriptionsListenRequestParams(WireModel): """ - Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request. """ model_config = ConfigDict( extra="ignore", ) - completions: JSONObject | None = None - """ - Present if the server supports argument autocompletion suggestions. - """ - experimental: dict[str, JSONObject] | None = None - """ - Experimental, non-standard capabilities that the server supports. - """ - extensions: dict[str, JSONObject] | None = None - """ - Optional MCP extensions that the server supports. Keys are extension identifiers - (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings - objects. An empty object indicates support with no settings. - - Keys MUST follow the {@link MetaObject`_meta` key naming rules}, with a - mandatory prefix. - """ - logging: JSONObject | None = None - """ - Present if the server supports sending log messages to the client. - """ - prompts: Prompts | None = None - """ - Present if the server offers any prompt templates. - """ - resources: Resources | None = None - """ - Present if the server offers any resources to read. - """ - tools: Tools | None = None + meta: Annotated[RequestMetaObject, Field(alias="_meta")] + notifications: SubscriptionFilter """ - Present if the server offers any tools to call. + The notifications the client opts in to on this stream. The server + **MUST NOT** send notification types the client has not explicitly + requested. """ @@ -3608,29 +3648,8 @@ class SubscriptionsListenRequest(WireModel): params: SubscriptionsListenRequestParams -class SubscriptionsListenRequestParams(WireModel): - """ - Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[RequestMetaObject, Field(alias="_meta")] - notifications: SubscriptionFilter - """ - The notifications the client opts in to on this stream. The server - **MUST NOT** send notification types the client has not explicitly - requested. - """ - - -class InputRequest(RootModel[CreateMessageRequest | ListRootsRequest | ElicitRequest]): - root: CreateMessageRequest | ListRootsRequest | ElicitRequest - - class ServerResult( - RootModel[ + WireRootModel[ Result | InputRequiredResult | DiscoverResult @@ -3662,7 +3681,7 @@ class ServerResult( class ClientRequest( - RootModel[ + WireRootModel[ DiscoverRequest | ListResourcesRequest | ListResourceTemplatesRequest @@ -3689,58 +3708,10 @@ class ClientRequest( ) -class InputRequests(RootModel[dict[str, InputRequest]]): - """ - A map of server-initiated requests that the client must fulfill. - Keys are server-assigned identifiers; values are the request objects. - """ - - root: dict[str, InputRequest] - - -class JSONArray(RootModel[list["JSONValue"]]): +class JSONArray(WireRootModel[list["JSONValue"]]): root: list["JSONValue"] -class JSONObject(RootModel[dict[str, "JSONValue"]]): - root: dict[str, "JSONValue"] - - -class JSONValue(RootModel[Union[JSONObject, list["JSONValue"], str | int | float | bool | None]]): - root: Union[JSONObject, list["JSONValue"], str | int | float | bool | None] - - AnyCallToolResult = CallToolResult | InputRequiredResult AnyGetPromptResult = GetPromptResult | InputRequiredResult AnyReadResourceResult = ReadResourceResult | InputRequiredResult - - -CallToolRequest.model_rebuild() -CallToolRequestParams.model_rebuild() -CallToolResultResponse.model_rebuild() -Elicitation.model_rebuild() -Sampling.model_rebuild() -ClientCapabilities.model_rebuild() -CompleteRequest.model_rebuild() -CompleteRequestParams.model_rebuild() -CreateMessageRequest.model_rebuild() -CreateMessageRequestParams.model_rebuild() -DiscoverRequest.model_rebuild() -DiscoverResult.model_rebuild() -GetPromptRequest.model_rebuild() -GetPromptRequestParams.model_rebuild() -GetPromptResultResponse.model_rebuild() -InputRequiredResult.model_rebuild() -InputResponseRequestParams.model_rebuild() -ListPromptsRequest.model_rebuild() -ListResourceTemplatesRequest.model_rebuild() -ListResourcesRequest.model_rebuild() -ListToolsRequest.model_rebuild() -PaginatedRequest.model_rebuild() -PaginatedRequestParams.model_rebuild() -ReadResourceRequest.model_rebuild() -ReadResourceRequestParams.model_rebuild() -ServerCapabilities.model_rebuild() -SubscriptionsListenRequest.model_rebuild() -JSONArray.model_rebuild() -JSONObject.model_rebuild() diff --git a/src/mcp-types/mcp_types/_wire_base.py b/src/mcp-types/mcp_types/_wire_base.py index 69254a850b..b25eab612c 100644 --- a/src/mcp-types/mcp_types/_wire_base.py +++ b/src/mcp-types/mcp_types/_wire_base.py @@ -1,9 +1,39 @@ -"""Shared pydantic base for the generated `mcp_types._v*` wire-shape packages.""" +"""Shared pydantic bases for the generated `mcp_types._v*` wire-shape packages. -from pydantic import BaseModel, ConfigDict +Both bases set `defer_build=True`: pydantic then skips core-schema, validator +and serializer construction at class creation and builds each model once, on +its first validation. A generated package defines ~175 models but a connection +touches only the handful its negotiated version and methods use, so importing a +package is class-body execution only and the schema build is paid lazily, per +model, exactly once. `@deferred_model` keeps `inspect.signature()` on a +not-yet-built model accurate and serializes that first build across threads +(see `mcp_types._deferred`). +""" +from typing import Generic, TypeVar +from pydantic import BaseModel, ConfigDict, RootModel + +from mcp_types._deferred import deferred_model + +_RootT = TypeVar("_RootT") + + +@deferred_model class WireModel(BaseModel): """Base for generated wire models: enables `populate_by_name`; subclasses set `extra` themselves.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, defer_build=True) + + +@deferred_model +class WireRootModel(RootModel[_RootT], Generic[_RootT]): + """Base for generated named-alias root models (`X(WireRootModel[T])`). + + Carrying `defer_build` on this generic base defers the `WireRootModel[T]` + parameterization itself (a model class in its own right) as well as the + named subclass; a config on the subclass alone would still build the + parameterized base eagerly. + """ + + model_config = ConfigDict(defer_build=True) diff --git a/src/mcp-types/mcp_types/jsonrpc.py b/src/mcp-types/mcp_types/jsonrpc.py index e9c6db96b4..a3ac4be1bf 100644 --- a/src/mcp-types/mcp_types/jsonrpc.py +++ b/src/mcp-types/mcp_types/jsonrpc.py @@ -4,7 +4,9 @@ from typing import Annotated, Any, Final, Literal -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from mcp_types._deferred import deferred_model as _deferred_model __all__ = [ "CONNECTION_CLOSED", @@ -36,29 +38,38 @@ """The JSON-RPC version string carried by every MCP message envelope.""" +@_deferred_model class JSONRPCRequest(BaseModel): """A JSON-RPC request that expects a response.""" + model_config = ConfigDict(defer_build=True) + jsonrpc: Literal["2.0"] id: RequestId method: str params: dict[str, Any] | None = None +@_deferred_model class JSONRPCNotification(BaseModel): """A JSON-RPC notification which does not expect a response.""" + model_config = ConfigDict(defer_build=True) + jsonrpc: Literal["2.0"] method: str params: dict[str, Any] | None = None +@_deferred_model class JSONRPCResponse(BaseModel): """A successful (non-error) response to a request. Named `JSONRPCResultResponse` in the 2025-11-25+ schemas; the SDK keeps the original name. """ + model_config = ConfigDict(defer_build=True) + jsonrpc: Literal["2.0"] id: RequestId result: dict[str, Any] @@ -110,9 +121,12 @@ class JSONRPCResponse(BaseModel): """ +@_deferred_model class ErrorData(BaseModel): """Error information for JSON-RPC error responses.""" + model_config = ConfigDict(defer_build=True) + code: int """The error type that occurred.""" @@ -129,9 +143,12 @@ class ErrorData(BaseModel): """ +@_deferred_model class JSONRPCError(BaseModel): """A response to a request that indicates an error occurred.""" + model_config = ConfigDict(defer_build=True) + jsonrpc: Literal["2.0"] id: RequestId | None """The id of the request this error responds to. @@ -145,4 +162,4 @@ class JSONRPCError(BaseModel): JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError """Any JSON-RPC envelope that can be decoded off the wire or encoded to be sent.""" -jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) +jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage, config=ConfigDict(defer_build=True)) diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 41959c56d7..efe3ed771f 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -6,22 +6,40 @@ Surface maps key `(method, version)` to per-version wire types (key absence is the version gate; shape validation is per schema era, i.e. 2025-11-25 for every pre-2026 version and 2026-07-28 for 2026). Monolith maps key `method` to the -version-free `mcp_types` models user code receives.""" +version-free `mcp_types` models user code receives. + +The surface maps do not import the per-version wire packages up front: each +`mcp_types._v*` package is ~100 ms of pydantic model construction and a +connection negotiates exactly one version, so a row's package is imported the +first time that row is read (see `_LazyWireMap`).""" from __future__ import annotations -from collections.abc import Mapping +import time +from collections.abc import Iterable, Iterator, Mapping +from dataclasses import dataclass from functools import cache -from types import MappingProxyType, UnionType -from typing import Any, Final, Literal, TypeGuard, TypeVar, cast, get_args +from importlib import import_module +from types import MappingProxyType, ModuleType, UnionType +from typing import TYPE_CHECKING, Any, Final, Literal, TypeGuard, TypeVar, cast, get_args from pydantic import BaseModel, TypeAdapter import mcp_types as types -import mcp_types._v2025_11_25 as v2025 -import mcp_types._v2026_07_28 as v2026 +from mcp_types import jsonrpc from mcp_types.version import KNOWN_PROTOCOL_VERSIONS +if TYPE_CHECKING: + # The wire packages are imported by module name at runtime (below), which a + # static bundler (PyInstaller and friends) cannot see; these compiled-in but + # never-executed imports keep the dependency discoverable for such tools + # (else add the packages to the bundler's hidden imports; see + # docs/advanced/import-cost.md). + import mcp_types._v2025_11_25 as _v2025_11_25 + import mcp_types._v2026_07_28 as _v2026_07_28 + + _WIRE_PACKAGE_MODULES: Final = (_v2025_11_25, _v2026_07_28) + __all__ = [ "CACHEABLE_METHODS", "CLIENT_NOTIFICATIONS", @@ -37,6 +55,7 @@ "SERVER_RESULTS", "SPEC_CLIENT_METHODS", "SPEC_CLIENT_NOTIFICATION_METHODS", + "WarmReport", "is_input_required", "parse_client_notification", "parse_client_request", @@ -49,280 +68,371 @@ "validate_client_request", "validate_client_result", "validate_server_result", + "warm", ] +# --- Lazy per-version wire package loading --- + +_WIRE_PACKAGE_BY_VERSION: Final[Mapping[str, str]] = MappingProxyType( + { + # Every pre-2026 version validates against the 2025-11-25 schema era. + "2024-11-05": "mcp_types._v2025_11_25", + "2025-03-26": "mcp_types._v2025_11_25", + "2025-06-18": "mcp_types._v2025_11_25", + "2025-11-25": "mcp_types._v2025_11_25", + "2026-07-28": "mcp_types._v2026_07_28", + } +) +"""The internal wire package holding each protocol version's surface types (by module name).""" + + +@cache +def _wire_package(version: str) -> ModuleType: + """Import (once) and return the generated wire package that validates `version`. + + Deliberately lazy - the documented exception to top-of-module imports: each + `mcp_types._v*` package costs ~100 ms of pydantic model construction, and a + process typically speaks a single negotiated version, so a package loads the + first time one of its surface rows is read rather than at `import` time. + """ + return import_module(_WIRE_PACKAGE_BY_VERSION[version]) + + +class _LazyWireMap(Mapping[tuple[str, str], Any]): + """`(method, version)` -> wire-row map that resolves rows on first read. + + A row is recorded as the attribute name of its wire type inside its + version's package (static data); reading the row imports that package via + `_wire_package` and caches the resolved value. Key operations (`in`, + iteration, `len`) never import; whole-map operations (`values()`, + `items()`, `==`, `repr()`) resolve every row. + + Public surface maps are this class wrapped in `MappingProxyType`, so their + type (`mappingproxy`), read-only behaviour and repr are identical to a + proxied dict of the resolved rows. + """ + + __slots__ = ("_names", "_rows") + + def __init__(self, names: dict[tuple[str, str], str]) -> None: + unmapped = {version for _, version in names} - _WIRE_PACKAGE_BY_VERSION.keys() + if unmapped: + raise ValueError(f"no wire package registered for protocol version(s) {sorted(unmapped)}") + self._names = names + self._rows: dict[tuple[str, str], Any] = {} + + def __getitem__(self, key: tuple[str, str]) -> Any: + try: + return self._rows[key] + except KeyError: + pass + name = self._names[key] # KeyError here is the version gate. + row = self._rows[key] = getattr(_wire_package(key[1]), name) + return row + + def __contains__(self, key: object) -> bool: + return key in self._names + + def __iter__(self) -> Iterator[tuple[str, str]]: + return iter(self._names) + + def __reversed__(self) -> Iterator[tuple[str, str]]: + return reversed(self._names) + + def __len__(self) -> int: + return len(self._names) + + def __repr__(self) -> str: + return repr(dict(self)) + + def copy(self) -> dict[tuple[str, str], Any]: + """A resolved plain-dict copy, matching `mappingproxy.copy()` over a dict.""" + return dict(self) + + def __or__(self, other: Mapping[Any, Any]) -> dict[Any, Any]: + return {**self, **other} + + def __ror__(self, other: Mapping[Any, Any]) -> dict[Any, Any]: + return {**other, **self} + + +def _surface(names: dict[tuple[str, str], str]) -> MappingProxyType[tuple[str, str], Any]: + """Build a read-only surface map from `(method, version)` -> wire-type name rows.""" + return MappingProxyType(_LazyWireMap(names)) + + # --- Surface maps: client-to-server --- -CLIENT_REQUESTS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( +CLIENT_REQUESTS: Final[Mapping[tuple[str, str], type[BaseModel]]] = _surface( { # 2024-11-05 - ("completion/complete", "2024-11-05"): v2025.CompleteRequest, - ("initialize", "2024-11-05"): v2025.InitializeRequest, - ("logging/setLevel", "2024-11-05"): v2025.SetLevelRequest, - ("ping", "2024-11-05"): v2025.PingRequest, - ("prompts/get", "2024-11-05"): v2025.GetPromptRequest, - ("prompts/list", "2024-11-05"): v2025.ListPromptsRequest, - ("resources/list", "2024-11-05"): v2025.ListResourcesRequest, - ("resources/read", "2024-11-05"): v2025.ReadResourceRequest, - ("resources/subscribe", "2024-11-05"): v2025.SubscribeRequest, - ("resources/templates/list", "2024-11-05"): v2025.ListResourceTemplatesRequest, - ("resources/unsubscribe", "2024-11-05"): v2025.UnsubscribeRequest, - ("tools/call", "2024-11-05"): v2025.CallToolRequest, - ("tools/list", "2024-11-05"): v2025.ListToolsRequest, + ("completion/complete", "2024-11-05"): "CompleteRequest", + ("initialize", "2024-11-05"): "InitializeRequest", + ("logging/setLevel", "2024-11-05"): "SetLevelRequest", + ("ping", "2024-11-05"): "PingRequest", + ("prompts/get", "2024-11-05"): "GetPromptRequest", + ("prompts/list", "2024-11-05"): "ListPromptsRequest", + ("resources/list", "2024-11-05"): "ListResourcesRequest", + ("resources/read", "2024-11-05"): "ReadResourceRequest", + ("resources/subscribe", "2024-11-05"): "SubscribeRequest", + ("resources/templates/list", "2024-11-05"): "ListResourceTemplatesRequest", + ("resources/unsubscribe", "2024-11-05"): "UnsubscribeRequest", + ("tools/call", "2024-11-05"): "CallToolRequest", + ("tools/list", "2024-11-05"): "ListToolsRequest", # 2025-03-26 - ("completion/complete", "2025-03-26"): v2025.CompleteRequest, - ("initialize", "2025-03-26"): v2025.InitializeRequest, - ("logging/setLevel", "2025-03-26"): v2025.SetLevelRequest, - ("ping", "2025-03-26"): v2025.PingRequest, - ("prompts/get", "2025-03-26"): v2025.GetPromptRequest, - ("prompts/list", "2025-03-26"): v2025.ListPromptsRequest, - ("resources/list", "2025-03-26"): v2025.ListResourcesRequest, - ("resources/read", "2025-03-26"): v2025.ReadResourceRequest, - ("resources/subscribe", "2025-03-26"): v2025.SubscribeRequest, - ("resources/templates/list", "2025-03-26"): v2025.ListResourceTemplatesRequest, - ("resources/unsubscribe", "2025-03-26"): v2025.UnsubscribeRequest, - ("tools/call", "2025-03-26"): v2025.CallToolRequest, - ("tools/list", "2025-03-26"): v2025.ListToolsRequest, + ("completion/complete", "2025-03-26"): "CompleteRequest", + ("initialize", "2025-03-26"): "InitializeRequest", + ("logging/setLevel", "2025-03-26"): "SetLevelRequest", + ("ping", "2025-03-26"): "PingRequest", + ("prompts/get", "2025-03-26"): "GetPromptRequest", + ("prompts/list", "2025-03-26"): "ListPromptsRequest", + ("resources/list", "2025-03-26"): "ListResourcesRequest", + ("resources/read", "2025-03-26"): "ReadResourceRequest", + ("resources/subscribe", "2025-03-26"): "SubscribeRequest", + ("resources/templates/list", "2025-03-26"): "ListResourceTemplatesRequest", + ("resources/unsubscribe", "2025-03-26"): "UnsubscribeRequest", + ("tools/call", "2025-03-26"): "CallToolRequest", + ("tools/list", "2025-03-26"): "ListToolsRequest", # 2025-06-18 - ("completion/complete", "2025-06-18"): v2025.CompleteRequest, - ("initialize", "2025-06-18"): v2025.InitializeRequest, - ("logging/setLevel", "2025-06-18"): v2025.SetLevelRequest, - ("ping", "2025-06-18"): v2025.PingRequest, - ("prompts/get", "2025-06-18"): v2025.GetPromptRequest, - ("prompts/list", "2025-06-18"): v2025.ListPromptsRequest, - ("resources/list", "2025-06-18"): v2025.ListResourcesRequest, - ("resources/read", "2025-06-18"): v2025.ReadResourceRequest, - ("resources/subscribe", "2025-06-18"): v2025.SubscribeRequest, - ("resources/templates/list", "2025-06-18"): v2025.ListResourceTemplatesRequest, - ("resources/unsubscribe", "2025-06-18"): v2025.UnsubscribeRequest, - ("tools/call", "2025-06-18"): v2025.CallToolRequest, - ("tools/list", "2025-06-18"): v2025.ListToolsRequest, + ("completion/complete", "2025-06-18"): "CompleteRequest", + ("initialize", "2025-06-18"): "InitializeRequest", + ("logging/setLevel", "2025-06-18"): "SetLevelRequest", + ("ping", "2025-06-18"): "PingRequest", + ("prompts/get", "2025-06-18"): "GetPromptRequest", + ("prompts/list", "2025-06-18"): "ListPromptsRequest", + ("resources/list", "2025-06-18"): "ListResourcesRequest", + ("resources/read", "2025-06-18"): "ReadResourceRequest", + ("resources/subscribe", "2025-06-18"): "SubscribeRequest", + ("resources/templates/list", "2025-06-18"): "ListResourceTemplatesRequest", + ("resources/unsubscribe", "2025-06-18"): "UnsubscribeRequest", + ("tools/call", "2025-06-18"): "CallToolRequest", + ("tools/list", "2025-06-18"): "ListToolsRequest", # 2025-11-25 (tasks/* deliberately absent) - ("completion/complete", "2025-11-25"): v2025.CompleteRequest, - ("initialize", "2025-11-25"): v2025.InitializeRequest, - ("logging/setLevel", "2025-11-25"): v2025.SetLevelRequest, - ("ping", "2025-11-25"): v2025.PingRequest, - ("prompts/get", "2025-11-25"): v2025.GetPromptRequest, - ("prompts/list", "2025-11-25"): v2025.ListPromptsRequest, - ("resources/list", "2025-11-25"): v2025.ListResourcesRequest, - ("resources/read", "2025-11-25"): v2025.ReadResourceRequest, - ("resources/subscribe", "2025-11-25"): v2025.SubscribeRequest, - ("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesRequest, - ("resources/unsubscribe", "2025-11-25"): v2025.UnsubscribeRequest, - ("tools/call", "2025-11-25"): v2025.CallToolRequest, - ("tools/list", "2025-11-25"): v2025.ListToolsRequest, + ("completion/complete", "2025-11-25"): "CompleteRequest", + ("initialize", "2025-11-25"): "InitializeRequest", + ("logging/setLevel", "2025-11-25"): "SetLevelRequest", + ("ping", "2025-11-25"): "PingRequest", + ("prompts/get", "2025-11-25"): "GetPromptRequest", + ("prompts/list", "2025-11-25"): "ListPromptsRequest", + ("resources/list", "2025-11-25"): "ListResourcesRequest", + ("resources/read", "2025-11-25"): "ReadResourceRequest", + ("resources/subscribe", "2025-11-25"): "SubscribeRequest", + ("resources/templates/list", "2025-11-25"): "ListResourceTemplatesRequest", + ("resources/unsubscribe", "2025-11-25"): "UnsubscribeRequest", + ("tools/call", "2025-11-25"): "CallToolRequest", + ("tools/list", "2025-11-25"): "ListToolsRequest", # 2026-07-28 (lifecycle, logging, subscribe pair removed; discover/listen added) - ("completion/complete", "2026-07-28"): v2026.CompleteRequest, - ("prompts/get", "2026-07-28"): v2026.GetPromptRequest, - ("prompts/list", "2026-07-28"): v2026.ListPromptsRequest, - ("resources/list", "2026-07-28"): v2026.ListResourcesRequest, - ("resources/read", "2026-07-28"): v2026.ReadResourceRequest, - ("resources/templates/list", "2026-07-28"): v2026.ListResourceTemplatesRequest, - ("server/discover", "2026-07-28"): v2026.DiscoverRequest, - ("subscriptions/listen", "2026-07-28"): v2026.SubscriptionsListenRequest, - ("tools/call", "2026-07-28"): v2026.CallToolRequest, - ("tools/list", "2026-07-28"): v2026.ListToolsRequest, + ("completion/complete", "2026-07-28"): "CompleteRequest", + ("prompts/get", "2026-07-28"): "GetPromptRequest", + ("prompts/list", "2026-07-28"): "ListPromptsRequest", + ("resources/list", "2026-07-28"): "ListResourcesRequest", + ("resources/read", "2026-07-28"): "ReadResourceRequest", + ("resources/templates/list", "2026-07-28"): "ListResourceTemplatesRequest", + ("server/discover", "2026-07-28"): "DiscoverRequest", + ("subscriptions/listen", "2026-07-28"): "SubscriptionsListenRequest", + ("tools/call", "2026-07-28"): "CallToolRequest", + ("tools/list", "2026-07-28"): "ListToolsRequest", } ) -CLIENT_NOTIFICATIONS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( +CLIENT_NOTIFICATIONS: Final[Mapping[tuple[str, str], type[BaseModel]]] = _surface( { # 2024-11-05 - ("notifications/cancelled", "2024-11-05"): v2025.CancelledNotification, - ("notifications/initialized", "2024-11-05"): v2025.InitializedNotification, - ("notifications/progress", "2024-11-05"): v2025.ProgressNotification, - ("notifications/roots/list_changed", "2024-11-05"): v2025.RootsListChangedNotification, + ("notifications/cancelled", "2024-11-05"): "CancelledNotification", + ("notifications/initialized", "2024-11-05"): "InitializedNotification", + ("notifications/progress", "2024-11-05"): "ProgressNotification", + ("notifications/roots/list_changed", "2024-11-05"): "RootsListChangedNotification", # 2025-03-26 - ("notifications/cancelled", "2025-03-26"): v2025.CancelledNotification, - ("notifications/initialized", "2025-03-26"): v2025.InitializedNotification, - ("notifications/progress", "2025-03-26"): v2025.ProgressNotification, - ("notifications/roots/list_changed", "2025-03-26"): v2025.RootsListChangedNotification, + ("notifications/cancelled", "2025-03-26"): "CancelledNotification", + ("notifications/initialized", "2025-03-26"): "InitializedNotification", + ("notifications/progress", "2025-03-26"): "ProgressNotification", + ("notifications/roots/list_changed", "2025-03-26"): "RootsListChangedNotification", # 2025-06-18 - ("notifications/cancelled", "2025-06-18"): v2025.CancelledNotification, - ("notifications/initialized", "2025-06-18"): v2025.InitializedNotification, - ("notifications/progress", "2025-06-18"): v2025.ProgressNotification, - ("notifications/roots/list_changed", "2025-06-18"): v2025.RootsListChangedNotification, + ("notifications/cancelled", "2025-06-18"): "CancelledNotification", + ("notifications/initialized", "2025-06-18"): "InitializedNotification", + ("notifications/progress", "2025-06-18"): "ProgressNotification", + ("notifications/roots/list_changed", "2025-06-18"): "RootsListChangedNotification", # 2025-11-25 (tasks/status deliberately absent) - ("notifications/cancelled", "2025-11-25"): v2025.CancelledNotification, - ("notifications/initialized", "2025-11-25"): v2025.InitializedNotification, - ("notifications/progress", "2025-11-25"): v2025.ProgressNotification, - ("notifications/roots/list_changed", "2025-11-25"): v2025.RootsListChangedNotification, + ("notifications/cancelled", "2025-11-25"): "CancelledNotification", + ("notifications/initialized", "2025-11-25"): "InitializedNotification", + ("notifications/progress", "2025-11-25"): "ProgressNotification", + ("notifications/roots/list_changed", "2025-11-25"): "RootsListChangedNotification", # 2026-07-28 (initialized, progress and roots/list_changed removed) - ("notifications/cancelled", "2026-07-28"): v2026.CancelledNotification, + ("notifications/cancelled", "2026-07-28"): "CancelledNotification", } ) # --- Surface maps: server-to-client --- -SERVER_REQUESTS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( +SERVER_REQUESTS: Final[Mapping[tuple[str, str], type[BaseModel]]] = _surface( { # 2024-11-05 - ("ping", "2024-11-05"): v2025.PingRequest, - ("roots/list", "2024-11-05"): v2025.ListRootsRequest, - ("sampling/createMessage", "2024-11-05"): v2025.CreateMessageRequest, + ("ping", "2024-11-05"): "PingRequest", + ("roots/list", "2024-11-05"): "ListRootsRequest", + ("sampling/createMessage", "2024-11-05"): "CreateMessageRequest", # 2025-03-26 - ("ping", "2025-03-26"): v2025.PingRequest, - ("roots/list", "2025-03-26"): v2025.ListRootsRequest, - ("sampling/createMessage", "2025-03-26"): v2025.CreateMessageRequest, + ("ping", "2025-03-26"): "PingRequest", + ("roots/list", "2025-03-26"): "ListRootsRequest", + ("sampling/createMessage", "2025-03-26"): "CreateMessageRequest", # 2025-06-18 (adds elicitation/create) - ("elicitation/create", "2025-06-18"): v2025.ElicitRequest, - ("ping", "2025-06-18"): v2025.PingRequest, - ("roots/list", "2025-06-18"): v2025.ListRootsRequest, - ("sampling/createMessage", "2025-06-18"): v2025.CreateMessageRequest, + ("elicitation/create", "2025-06-18"): "ElicitRequest", + ("ping", "2025-06-18"): "PingRequest", + ("roots/list", "2025-06-18"): "ListRootsRequest", + ("sampling/createMessage", "2025-06-18"): "CreateMessageRequest", # 2025-11-25 (tasks/* deliberately absent) - ("elicitation/create", "2025-11-25"): v2025.ElicitRequest, - ("ping", "2025-11-25"): v2025.PingRequest, - ("roots/list", "2025-11-25"): v2025.ListRootsRequest, - ("sampling/createMessage", "2025-11-25"): v2025.CreateMessageRequest, + ("elicitation/create", "2025-11-25"): "ElicitRequest", + ("ping", "2025-11-25"): "PingRequest", + ("roots/list", "2025-11-25"): "ListRootsRequest", + ("sampling/createMessage", "2025-11-25"): "CreateMessageRequest", # 2026-07-28: none (schema defines no ServerRequest union) } ) -SERVER_NOTIFICATIONS: Final[Mapping[tuple[str, str], type[BaseModel]]] = MappingProxyType( +SERVER_NOTIFICATIONS: Final[Mapping[tuple[str, str], type[BaseModel]]] = _surface( { # 2024-11-05 - ("notifications/cancelled", "2024-11-05"): v2025.CancelledNotification, - ("notifications/message", "2024-11-05"): v2025.LoggingMessageNotification, - ("notifications/progress", "2024-11-05"): v2025.ProgressNotification, - ("notifications/prompts/list_changed", "2024-11-05"): v2025.PromptListChangedNotification, - ("notifications/resources/list_changed", "2024-11-05"): v2025.ResourceListChangedNotification, - ("notifications/resources/updated", "2024-11-05"): v2025.ResourceUpdatedNotification, - ("notifications/tools/list_changed", "2024-11-05"): v2025.ToolListChangedNotification, + ("notifications/cancelled", "2024-11-05"): "CancelledNotification", + ("notifications/message", "2024-11-05"): "LoggingMessageNotification", + ("notifications/progress", "2024-11-05"): "ProgressNotification", + ("notifications/prompts/list_changed", "2024-11-05"): "PromptListChangedNotification", + ("notifications/resources/list_changed", "2024-11-05"): "ResourceListChangedNotification", + ("notifications/resources/updated", "2024-11-05"): "ResourceUpdatedNotification", + ("notifications/tools/list_changed", "2024-11-05"): "ToolListChangedNotification", # 2025-03-26 - ("notifications/cancelled", "2025-03-26"): v2025.CancelledNotification, - ("notifications/message", "2025-03-26"): v2025.LoggingMessageNotification, - ("notifications/progress", "2025-03-26"): v2025.ProgressNotification, - ("notifications/prompts/list_changed", "2025-03-26"): v2025.PromptListChangedNotification, - ("notifications/resources/list_changed", "2025-03-26"): v2025.ResourceListChangedNotification, - ("notifications/resources/updated", "2025-03-26"): v2025.ResourceUpdatedNotification, - ("notifications/tools/list_changed", "2025-03-26"): v2025.ToolListChangedNotification, + ("notifications/cancelled", "2025-03-26"): "CancelledNotification", + ("notifications/message", "2025-03-26"): "LoggingMessageNotification", + ("notifications/progress", "2025-03-26"): "ProgressNotification", + ("notifications/prompts/list_changed", "2025-03-26"): "PromptListChangedNotification", + ("notifications/resources/list_changed", "2025-03-26"): "ResourceListChangedNotification", + ("notifications/resources/updated", "2025-03-26"): "ResourceUpdatedNotification", + ("notifications/tools/list_changed", "2025-03-26"): "ToolListChangedNotification", # 2025-06-18 - ("notifications/cancelled", "2025-06-18"): v2025.CancelledNotification, - ("notifications/message", "2025-06-18"): v2025.LoggingMessageNotification, - ("notifications/progress", "2025-06-18"): v2025.ProgressNotification, - ("notifications/prompts/list_changed", "2025-06-18"): v2025.PromptListChangedNotification, - ("notifications/resources/list_changed", "2025-06-18"): v2025.ResourceListChangedNotification, - ("notifications/resources/updated", "2025-06-18"): v2025.ResourceUpdatedNotification, - ("notifications/tools/list_changed", "2025-06-18"): v2025.ToolListChangedNotification, + ("notifications/cancelled", "2025-06-18"): "CancelledNotification", + ("notifications/message", "2025-06-18"): "LoggingMessageNotification", + ("notifications/progress", "2025-06-18"): "ProgressNotification", + ("notifications/prompts/list_changed", "2025-06-18"): "PromptListChangedNotification", + ("notifications/resources/list_changed", "2025-06-18"): "ResourceListChangedNotification", + ("notifications/resources/updated", "2025-06-18"): "ResourceUpdatedNotification", + ("notifications/tools/list_changed", "2025-06-18"): "ToolListChangedNotification", # 2025-11-25 (adds elicitation/complete; tasks/status deliberately absent) - ("notifications/cancelled", "2025-11-25"): v2025.CancelledNotification, - ("notifications/elicitation/complete", "2025-11-25"): v2025.ElicitationCompleteNotification, - ("notifications/message", "2025-11-25"): v2025.LoggingMessageNotification, - ("notifications/progress", "2025-11-25"): v2025.ProgressNotification, - ("notifications/prompts/list_changed", "2025-11-25"): v2025.PromptListChangedNotification, - ("notifications/resources/list_changed", "2025-11-25"): v2025.ResourceListChangedNotification, - ("notifications/resources/updated", "2025-11-25"): v2025.ResourceUpdatedNotification, - ("notifications/tools/list_changed", "2025-11-25"): v2025.ToolListChangedNotification, + ("notifications/cancelled", "2025-11-25"): "CancelledNotification", + ("notifications/elicitation/complete", "2025-11-25"): "ElicitationCompleteNotification", + ("notifications/message", "2025-11-25"): "LoggingMessageNotification", + ("notifications/progress", "2025-11-25"): "ProgressNotification", + ("notifications/prompts/list_changed", "2025-11-25"): "PromptListChangedNotification", + ("notifications/resources/list_changed", "2025-11-25"): "ResourceListChangedNotification", + ("notifications/resources/updated", "2025-11-25"): "ResourceUpdatedNotification", + ("notifications/tools/list_changed", "2025-11-25"): "ToolListChangedNotification", # 2026-07-28 (adds subscriptions/acknowledged; elicitation/complete removed) - ("notifications/cancelled", "2026-07-28"): v2026.CancelledNotification, - ("notifications/message", "2026-07-28"): v2026.LoggingMessageNotification, - ("notifications/progress", "2026-07-28"): v2026.ProgressNotification, - ("notifications/prompts/list_changed", "2026-07-28"): v2026.PromptListChangedNotification, - ("notifications/resources/list_changed", "2026-07-28"): v2026.ResourceListChangedNotification, - ("notifications/resources/updated", "2026-07-28"): v2026.ResourceUpdatedNotification, - ("notifications/subscriptions/acknowledged", "2026-07-28"): v2026.SubscriptionsAcknowledgedNotification, - ("notifications/tools/list_changed", "2026-07-28"): v2026.ToolListChangedNotification, + ("notifications/cancelled", "2026-07-28"): "CancelledNotification", + ("notifications/message", "2026-07-28"): "LoggingMessageNotification", + ("notifications/progress", "2026-07-28"): "ProgressNotification", + ("notifications/prompts/list_changed", "2026-07-28"): "PromptListChangedNotification", + ("notifications/resources/list_changed", "2026-07-28"): "ResourceListChangedNotification", + ("notifications/resources/updated", "2026-07-28"): "ResourceUpdatedNotification", + ("notifications/subscriptions/acknowledged", "2026-07-28"): "SubscriptionsAcknowledgedNotification", + ("notifications/tools/list_changed", "2026-07-28"): "ToolListChangedNotification", } ) # --- Surface maps: results --- -SERVER_RESULTS: Final[Mapping[tuple[str, str], type[BaseModel] | UnionType]] = MappingProxyType( +SERVER_RESULTS: Final[Mapping[tuple[str, str], type[BaseModel] | UnionType]] = _surface( { # 2024-11-05 - ("completion/complete", "2024-11-05"): v2025.CompleteResult, - ("initialize", "2024-11-05"): v2025.InitializeResult, - ("logging/setLevel", "2024-11-05"): v2025.EmptyResult, - ("ping", "2024-11-05"): v2025.EmptyResult, - ("prompts/get", "2024-11-05"): v2025.GetPromptResult, - ("prompts/list", "2024-11-05"): v2025.ListPromptsResult, - ("resources/list", "2024-11-05"): v2025.ListResourcesResult, - ("resources/read", "2024-11-05"): v2025.ReadResourceResult, - ("resources/subscribe", "2024-11-05"): v2025.EmptyResult, - ("resources/templates/list", "2024-11-05"): v2025.ListResourceTemplatesResult, - ("resources/unsubscribe", "2024-11-05"): v2025.EmptyResult, - ("tools/call", "2024-11-05"): v2025.CallToolResult, - ("tools/list", "2024-11-05"): v2025.ListToolsResult, + ("completion/complete", "2024-11-05"): "CompleteResult", + ("initialize", "2024-11-05"): "InitializeResult", + ("logging/setLevel", "2024-11-05"): "EmptyResult", + ("ping", "2024-11-05"): "EmptyResult", + ("prompts/get", "2024-11-05"): "GetPromptResult", + ("prompts/list", "2024-11-05"): "ListPromptsResult", + ("resources/list", "2024-11-05"): "ListResourcesResult", + ("resources/read", "2024-11-05"): "ReadResourceResult", + ("resources/subscribe", "2024-11-05"): "EmptyResult", + ("resources/templates/list", "2024-11-05"): "ListResourceTemplatesResult", + ("resources/unsubscribe", "2024-11-05"): "EmptyResult", + ("tools/call", "2024-11-05"): "CallToolResult", + ("tools/list", "2024-11-05"): "ListToolsResult", # 2025-03-26 - ("completion/complete", "2025-03-26"): v2025.CompleteResult, - ("initialize", "2025-03-26"): v2025.InitializeResult, - ("logging/setLevel", "2025-03-26"): v2025.EmptyResult, - ("ping", "2025-03-26"): v2025.EmptyResult, - ("prompts/get", "2025-03-26"): v2025.GetPromptResult, - ("prompts/list", "2025-03-26"): v2025.ListPromptsResult, - ("resources/list", "2025-03-26"): v2025.ListResourcesResult, - ("resources/read", "2025-03-26"): v2025.ReadResourceResult, - ("resources/subscribe", "2025-03-26"): v2025.EmptyResult, - ("resources/templates/list", "2025-03-26"): v2025.ListResourceTemplatesResult, - ("resources/unsubscribe", "2025-03-26"): v2025.EmptyResult, - ("tools/call", "2025-03-26"): v2025.CallToolResult, - ("tools/list", "2025-03-26"): v2025.ListToolsResult, + ("completion/complete", "2025-03-26"): "CompleteResult", + ("initialize", "2025-03-26"): "InitializeResult", + ("logging/setLevel", "2025-03-26"): "EmptyResult", + ("ping", "2025-03-26"): "EmptyResult", + ("prompts/get", "2025-03-26"): "GetPromptResult", + ("prompts/list", "2025-03-26"): "ListPromptsResult", + ("resources/list", "2025-03-26"): "ListResourcesResult", + ("resources/read", "2025-03-26"): "ReadResourceResult", + ("resources/subscribe", "2025-03-26"): "EmptyResult", + ("resources/templates/list", "2025-03-26"): "ListResourceTemplatesResult", + ("resources/unsubscribe", "2025-03-26"): "EmptyResult", + ("tools/call", "2025-03-26"): "CallToolResult", + ("tools/list", "2025-03-26"): "ListToolsResult", # 2025-06-18 - ("completion/complete", "2025-06-18"): v2025.CompleteResult, - ("initialize", "2025-06-18"): v2025.InitializeResult, - ("logging/setLevel", "2025-06-18"): v2025.EmptyResult, - ("ping", "2025-06-18"): v2025.EmptyResult, - ("prompts/get", "2025-06-18"): v2025.GetPromptResult, - ("prompts/list", "2025-06-18"): v2025.ListPromptsResult, - ("resources/list", "2025-06-18"): v2025.ListResourcesResult, - ("resources/read", "2025-06-18"): v2025.ReadResourceResult, - ("resources/subscribe", "2025-06-18"): v2025.EmptyResult, - ("resources/templates/list", "2025-06-18"): v2025.ListResourceTemplatesResult, - ("resources/unsubscribe", "2025-06-18"): v2025.EmptyResult, - ("tools/call", "2025-06-18"): v2025.CallToolResult, - ("tools/list", "2025-06-18"): v2025.ListToolsResult, + ("completion/complete", "2025-06-18"): "CompleteResult", + ("initialize", "2025-06-18"): "InitializeResult", + ("logging/setLevel", "2025-06-18"): "EmptyResult", + ("ping", "2025-06-18"): "EmptyResult", + ("prompts/get", "2025-06-18"): "GetPromptResult", + ("prompts/list", "2025-06-18"): "ListPromptsResult", + ("resources/list", "2025-06-18"): "ListResourcesResult", + ("resources/read", "2025-06-18"): "ReadResourceResult", + ("resources/subscribe", "2025-06-18"): "EmptyResult", + ("resources/templates/list", "2025-06-18"): "ListResourceTemplatesResult", + ("resources/unsubscribe", "2025-06-18"): "EmptyResult", + ("tools/call", "2025-06-18"): "CallToolResult", + ("tools/list", "2025-06-18"): "ListToolsResult", # 2025-11-25 - ("completion/complete", "2025-11-25"): v2025.CompleteResult, - ("initialize", "2025-11-25"): v2025.InitializeResult, - ("logging/setLevel", "2025-11-25"): v2025.EmptyResult, - ("ping", "2025-11-25"): v2025.EmptyResult, - ("prompts/get", "2025-11-25"): v2025.GetPromptResult, - ("prompts/list", "2025-11-25"): v2025.ListPromptsResult, - ("resources/list", "2025-11-25"): v2025.ListResourcesResult, - ("resources/read", "2025-11-25"): v2025.ReadResourceResult, - ("resources/subscribe", "2025-11-25"): v2025.EmptyResult, - ("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesResult, - ("resources/unsubscribe", "2025-11-25"): v2025.EmptyResult, - ("tools/call", "2025-11-25"): v2025.CallToolResult, - ("tools/list", "2025-11-25"): v2025.ListToolsResult, + ("completion/complete", "2025-11-25"): "CompleteResult", + ("initialize", "2025-11-25"): "InitializeResult", + ("logging/setLevel", "2025-11-25"): "EmptyResult", + ("ping", "2025-11-25"): "EmptyResult", + ("prompts/get", "2025-11-25"): "GetPromptResult", + ("prompts/list", "2025-11-25"): "ListPromptsResult", + ("resources/list", "2025-11-25"): "ListResourcesResult", + ("resources/read", "2025-11-25"): "ReadResourceResult", + ("resources/subscribe", "2025-11-25"): "EmptyResult", + ("resources/templates/list", "2025-11-25"): "ListResourceTemplatesResult", + ("resources/unsubscribe", "2025-11-25"): "EmptyResult", + ("tools/call", "2025-11-25"): "CallToolResult", + ("tools/list", "2025-11-25"): "ListToolsResult", # 2026-07-28 (dual-result rows use the version's union aliases) - ("completion/complete", "2026-07-28"): v2026.CompleteResult, - ("prompts/get", "2026-07-28"): v2026.AnyGetPromptResult, - ("prompts/list", "2026-07-28"): v2026.ListPromptsResult, - ("resources/list", "2026-07-28"): v2026.ListResourcesResult, - ("resources/read", "2026-07-28"): v2026.AnyReadResourceResult, - ("resources/templates/list", "2026-07-28"): v2026.ListResourceTemplatesResult, - ("server/discover", "2026-07-28"): v2026.DiscoverResult, - ("subscriptions/listen", "2026-07-28"): v2026.SubscriptionsListenResult, - ("tools/call", "2026-07-28"): v2026.AnyCallToolResult, - ("tools/list", "2026-07-28"): v2026.ListToolsResult, + ("completion/complete", "2026-07-28"): "CompleteResult", + ("prompts/get", "2026-07-28"): "AnyGetPromptResult", + ("prompts/list", "2026-07-28"): "ListPromptsResult", + ("resources/list", "2026-07-28"): "ListResourcesResult", + ("resources/read", "2026-07-28"): "AnyReadResourceResult", + ("resources/templates/list", "2026-07-28"): "ListResourceTemplatesResult", + ("server/discover", "2026-07-28"): "DiscoverResult", + ("subscriptions/listen", "2026-07-28"): "SubscriptionsListenResult", + ("tools/call", "2026-07-28"): "AnyCallToolResult", + ("tools/list", "2026-07-28"): "ListToolsResult", } ) """Results servers send, keyed by the originating client request's (method, version).""" -CLIENT_RESULTS: Final[Mapping[tuple[str, str], type[BaseModel] | UnionType]] = MappingProxyType( +CLIENT_RESULTS: Final[Mapping[tuple[str, str], type[BaseModel] | UnionType]] = _surface( { # 2024-11-05 - ("ping", "2024-11-05"): v2025.EmptyResult, - ("roots/list", "2024-11-05"): v2025.ListRootsResult, - ("sampling/createMessage", "2024-11-05"): v2025.CreateMessageResult, + ("ping", "2024-11-05"): "EmptyResult", + ("roots/list", "2024-11-05"): "ListRootsResult", + ("sampling/createMessage", "2024-11-05"): "CreateMessageResult", # 2025-03-26 - ("ping", "2025-03-26"): v2025.EmptyResult, - ("roots/list", "2025-03-26"): v2025.ListRootsResult, - ("sampling/createMessage", "2025-03-26"): v2025.CreateMessageResult, + ("ping", "2025-03-26"): "EmptyResult", + ("roots/list", "2025-03-26"): "ListRootsResult", + ("sampling/createMessage", "2025-03-26"): "CreateMessageResult", # 2025-06-18 - ("elicitation/create", "2025-06-18"): v2025.ElicitResult, - ("ping", "2025-06-18"): v2025.EmptyResult, - ("roots/list", "2025-06-18"): v2025.ListRootsResult, - ("sampling/createMessage", "2025-06-18"): v2025.CreateMessageResult, + ("elicitation/create", "2025-06-18"): "ElicitResult", + ("ping", "2025-06-18"): "EmptyResult", + ("roots/list", "2025-06-18"): "ListRootsResult", + ("sampling/createMessage", "2025-06-18"): "CreateMessageResult", # 2025-11-25 - ("elicitation/create", "2025-11-25"): v2025.ElicitResult, - ("ping", "2025-11-25"): v2025.EmptyResult, - ("roots/list", "2025-11-25"): v2025.ListRootsResult, - ("sampling/createMessage", "2025-11-25"): v2025.CreateMessageResult, + ("elicitation/create", "2025-11-25"): "ElicitResult", + ("ping", "2025-11-25"): "EmptyResult", + ("roots/list", "2025-11-25"): "ListRootsResult", + ("sampling/createMessage", "2025-11-25"): "CreateMessageResult", # 2026-07-28: none (no server-to-client requests at this version) } ) @@ -744,3 +854,140 @@ def parse_client_result( validate_client_result(method, version, data, surface=surface) result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) return result + + +# --- Opt-in prewarm --- + +_ALL_SURFACES: Final = ( + CLIENT_REQUESTS, + CLIENT_NOTIFICATIONS, + CLIENT_RESULTS, + SERVER_REQUESTS, + SERVER_NOTIFICATIONS, + SERVER_RESULTS, +) +"""Every surface map, for `warm(version)` (rows of one version import one wire package).""" + +_ALL_MONOLITHS: Final = (MONOLITH_REQUESTS, MONOLITH_NOTIFICATIONS, MONOLITH_RESULTS) +"""Every monolith map, for `warm()`.""" + + +@dataclass(frozen=True) +class WarmReport: + """What one `warm()` call built (already-built work is skipped and not counted). + + A frozen dataclass rather than a tuple, so a later field addition does not + change how existing fields are read. + """ + + models: int + """Deferred model classes whose validators this call built.""" + + adapters: int + """Deferred TypeAdapters (union / routing adapters) this call built.""" + + elapsed_ms: float + """Wall-clock milliseconds the call took.""" + + +def _model_classes(target: object) -> Iterator[type[BaseModel]]: + """The model classes in a surface/monolith row: the class itself, or a union's arms.""" + if isinstance(target, type): + if issubclass(target, BaseModel): + yield target + return + for arg in get_args(target): + yield from _model_classes(arg) + + +def _warm_models(rows: Iterable[object]) -> int: + """Complete every not-yet-built model class among `rows`; return how many were built.""" + built = 0 + for row in rows: + for cls in _model_classes(row): + if not cls.__pydantic_complete__: + cls.model_rebuild() + built += 1 + return built + + +def _warm_adapter(adapter: TypeAdapter[Any]) -> int: + """Build a deferred TypeAdapter's validator if it is not built yet; return 0 or 1.""" + return 0 if adapter.rebuild() is None else 1 + + +def warm(version: str | None = None, *, all_versions: bool = False) -> WarmReport: + """Build the SDK's deferred validators up front, before the first message needs them. + + Importing the SDK stays fast because pydantic validators build on first use + and each protocol version's wire schemas load with the first message parsed + at that version. A long-running host that would rather pay that once, at + startup, calls this from a startup hook; nothing in the SDK calls it, and + steady-state behaviour is identical either way. + + * `warm(version)` is what a host wants: it builds the version-independent + set (below) and imports that protocol version's wire package and builds + its routing surface (the wire models and the cached adapters a + connection at that version routes through), so the first connection at + that version builds nothing. + * `warm()` builds only the version-independent set: the monolith + `mcp_types` models a session routes through, the JSON-RPC envelopes and + the module-level union adapters. The first connection still imports and + builds its version's routing surface. + * `warm(all_versions=True)` warms every known protocol version's routing + surface, for a proxy or gateway that serves clients of both eras. + + Model classes are completed before the adapters that reference them are + built, so no schema is generated twice; anything already built is skipped, + so the call is idempotent and repeat calls are cheap. + + Args: + version: The protocol version whose routing surface to build too. + all_versions: Warm every known protocol version's routing surface + (a proxy or gateway serving both eras; benchmarks and tests). + + Returns: + Counts of what this call built and the milliseconds it took. + + Raises: + ValueError: `version` is not a known protocol version. + """ + started = time.perf_counter() + versions: list[str] = [] + if all_versions: + versions = sorted(KNOWN_PROTOCOL_VERSIONS) + elif version is not None: + _check_known_version(version) + versions = [version] + + # Model classes first (the exported monolith models, the JSON-RPC + # envelopes, then the wire packages and their routing rows), so the + # adapters below never generate a referenced model's schema inline. + models = _warm_models(getattr(types, name) for name in types.__all__) + models += _warm_models(row for monolith in _ALL_MONOLITHS for row in monolith.values()) + models += _warm_models(get_args(jsonrpc.JSONRPCMessage)) + rows: list[Any] = [] + for warmed_version in versions: + package = _wire_package(warmed_version) # imports the package once + models += _warm_models(vars(package).values()) + for surface in _ALL_SURFACES: + rows.extend(surface[key] for key in surface if key[1] == warmed_version) + models += _warm_models(rows) + + adapters = _warm_adapter(jsonrpc.jsonrpc_message_adapter) + for adapter in ( + types.client_request_adapter, + types.client_notification_adapter, + types.client_result_adapter, + types.server_request_adapter, + types.server_notification_adapter, + types.server_result_adapter, + ): + adapters += _warm_adapter(adapter) + for row in rows: + # `_adapter` is the cached routing-adapter builder the parse/serialize + # functions use; warming it here (and completing the adapter's own + # core schema from the now-complete row model) leaves nothing for the + # first message at that version to build. + adapters += _warm_adapter(_adapter(row)) + return WarmReport(models=models, adapters=adapters, elapsed_ms=round((time.perf_counter() - started) * 1000, 1)) diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 28bc4703ed..1b178470d7 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -1,75 +1,93 @@ -from mcp_types import ( - CallToolRequest, - ClientCapabilities, - ClientNotification, - ClientRequest, - ClientResult, - CompleteRequest, - CreateMessageRequest, - CreateMessageResult, - CreateMessageResultWithTools, - ErrorData, - GetPromptRequest, - GetPromptResult, - Implementation, - IncludeContext, - InitializedNotification, - InitializeRequest, - InitializeResult, - JSONRPCError, - JSONRPCRequest, - JSONRPCResponse, - ListPromptsRequest, - ListPromptsResult, - ListResourcesRequest, - ListResourcesResult, - ListToolsResult, - LoggingLevel, - LoggingMessageNotification, - Notification, - PingRequest, - ProgressNotification, - PromptsCapability, - ReadResourceRequest, - ReadResourceResult, - Resource, - ResourcesCapability, - ResourceUpdatedNotification, - RootsCapability, - SamplingCapability, - SamplingContent, - SamplingContextCapability, - SamplingMessage, - SamplingMessageContentBlock, - SamplingToolsCapability, - ServerCapabilities, - ServerNotification, - ServerRequest, - ServerResult, - SetLevelRequest, - StopReason, - SubscribeRequest, - Tool, - ToolChoice, - ToolResultContent, - ToolsCapability, - ToolUseContent, - UnsubscribeRequest, -) -from mcp_types import Role as SamplingRole +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +# Every name below is resolved LAZILY on first access via the module +# `__getattr__` at the bottom of this file (PEP 562), so a bare `import mcp` no +# longer imports the protocol types nor the client and server stacks. Type +# checkers still see the real bindings through these `TYPE_CHECKING` imports, +# which mirror the runtime lazy map name for name. +if TYPE_CHECKING: + from mcp_types import ( + CallToolRequest, + ClientCapabilities, + ClientNotification, + ClientRequest, + ClientResult, + CompleteRequest, + CreateMessageRequest, + CreateMessageResult, + CreateMessageResultWithTools, + ErrorData, + GetPromptRequest, + GetPromptResult, + Implementation, + IncludeContext, + InitializedNotification, + InitializeRequest, + InitializeResult, + JSONRPCError, + JSONRPCRequest, + JSONRPCResponse, + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListToolsResult, + LoggingLevel, + LoggingMessageNotification, + Notification, + PingRequest, + ProgressNotification, + PromptsCapability, + ReadResourceRequest, + ReadResourceResult, + Resource, + ResourcesCapability, + ResourceUpdatedNotification, + RootsCapability, + SamplingCapability, + SamplingContent, + SamplingContextCapability, + SamplingMessage, + SamplingMessageContentBlock, + SamplingToolsCapability, + ServerCapabilities, + ServerNotification, + ServerRequest, + ServerResult, + SetLevelRequest, + StopReason, + SubscribeRequest, + Tool, + ToolChoice, + ToolResultContent, + ToolsCapability, + ToolUseContent, + UnsubscribeRequest, + ) + from mcp_types import Role as SamplingRole + from mcp_types.methods import warm as warm -# Bind the `mcp.types` submodule on the package, as v1's `from .types import -# ...` did, so `import mcp` followed by `mcp.types.Tool` keeps working. -from . import types as types -from .client._input_required import InputRequiredRoundsExceededError -from .client.client import Client -from .client.session import ClientSession -from .client.session_group import ClientSessionGroup -from .client.stdio import StdioServerParameters, stdio_client -from .server.session import ServerSession -from .server.stdio import stdio_server -from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError -from .shared.uri_template import InvalidUriTemplate, UriTemplate + # Bind the `mcp.types` submodule on the package, as v1's `from .types import + # ...` did, so `import mcp` followed by `mcp.types.Tool` keeps working. The + # other subpackages are mirrored too: at runtime `__getattr__` imports them + # on first attribute access (`_LAZY_SUBMODULES`), so `mcp.server.Server` + # style chains resolve, and type checkers must see the same members. + from . import client as client + from . import os as os + from . import server as server + from . import shared as shared + from . import types as types + from .client._input_required import InputRequiredRoundsExceededError + from .client.client import Client + from .client.session import ClientSession + from .client.session_group import ClientSessionGroup + from .client.stdio import StdioServerParameters, stdio_client + from .server.session import ServerSession + from .server.stdio import stdio_server + from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError + from .shared.uri_template import InvalidUriTemplate, UriTemplate __all__ = [ "CallToolRequest", @@ -142,4 +160,127 @@ "InvalidUriTemplate", "stdio_client", "stdio_server", + "warm", ] + +# --------------------------------------------------------------------------- +# Lazy attributes (PEP 562). +# +# `import mcp` used to pull in the protocol types (`mcp_types`, ~500 pydantic +# models) plus the entire client and server stacks (in-memory transport -> +# lowlevel server -> transports/otel/auth) only to bind the names above. +# Every name is still here - `from mcp import Client`, `mcp.Tool`, +# `from mcp import *`, `dir(mcp)` all behave exactly as before and resolve to the +# very same objects - but each is imported from its home module on first access +# and then cached in this namespace, so `import mcp` alone is nearly free and +# each entry point pays only for what it actually uses. +# --------------------------------------------------------------------------- + +_MCP_TYPES_NAMES = ( + "CallToolRequest", + "ClientCapabilities", + "ClientNotification", + "ClientRequest", + "ClientResult", + "CompleteRequest", + "CreateMessageRequest", + "CreateMessageResult", + "CreateMessageResultWithTools", + "ErrorData", + "GetPromptRequest", + "GetPromptResult", + "Implementation", + "IncludeContext", + "InitializedNotification", + "InitializeRequest", + "InitializeResult", + "JSONRPCError", + "JSONRPCRequest", + "JSONRPCResponse", + "ListPromptsRequest", + "ListPromptsResult", + "ListResourcesRequest", + "ListResourcesResult", + "ListToolsResult", + "LoggingLevel", + "LoggingMessageNotification", + "Notification", + "PingRequest", + "ProgressNotification", + "PromptsCapability", + "ReadResourceRequest", + "ReadResourceResult", + "Resource", + "ResourcesCapability", + "ResourceUpdatedNotification", + "RootsCapability", + "SamplingCapability", + "SamplingContent", + "SamplingContextCapability", + "SamplingMessage", + "SamplingMessageContentBlock", + "SamplingToolsCapability", + "ServerCapabilities", + "ServerNotification", + "ServerRequest", + "ServerResult", + "SetLevelRequest", + "StopReason", + "SubscribeRequest", + "Tool", + "ToolChoice", + "ToolResultContent", + "ToolsCapability", + "ToolUseContent", + "UnsubscribeRequest", +) + +# name -> (home module, attribute in it); mirrors the TYPE_CHECKING imports +# above name for name. (No module-level annotations here: on 3.14+ they would +# add `__annotate__`/`__conditional_annotations__` to the namespace and show up +# in `dir(mcp)`.) +_LAZY_ATTRS = { + **{name: ("mcp_types", name) for name in _MCP_TYPES_NAMES}, + "SamplingRole": ("mcp_types", "Role"), + "InputRequiredRoundsExceededError": ("mcp.client._input_required", "InputRequiredRoundsExceededError"), + "Client": ("mcp.client.client", "Client"), + "ClientSession": ("mcp.client.session", "ClientSession"), + "ClientSessionGroup": ("mcp.client.session_group", "ClientSessionGroup"), + "StdioServerParameters": ("mcp.client.stdio", "StdioServerParameters"), + "stdio_client": ("mcp.client.stdio", "stdio_client"), + "ServerSession": ("mcp.server.session", "ServerSession"), + "stdio_server": ("mcp.server.stdio", "stdio_server"), + "MCPDeprecationWarning": ("mcp.shared.exceptions", "MCPDeprecationWarning"), + "MCPError": ("mcp.shared.exceptions", "MCPError"), + "UrlElicitationRequiredError": ("mcp.shared.exceptions", "UrlElicitationRequiredError"), + "InvalidUriTemplate": ("mcp.shared.uri_template", "InvalidUriTemplate"), + "UriTemplate": ("mcp.shared.uri_template", "UriTemplate"), + "warm": ("mcp_types.methods", "warm"), +} + +# Submodules a bare `import mcp` used to bind on the package as a side effect +# of the eager imports (`mcp.types` is documented: v1's `mcp.types.Tool` idiom; +# `client`/`server`/`shared`/`os` were merely incidental). Each is imported on +# first touch; the import system then binds it here, so it is cached as well. +_LAZY_SUBMODULES = frozenset({"types", "client", "os", "server", "shared"}) + +# Names the lazy machinery itself adds to this namespace; kept out of +# `dir(mcp)` so it reports exactly what it did when the imports were eager. +_LAZY_MACHINERY = frozenset( + { + "TYPE_CHECKING", + "_LAZY_ATTRS", + "_LAZY_MACHINERY", + "_LAZY_SUBMODULES", + "_MCP_TYPES_NAMES", + "_lazy_module_attrs", + } +) + +# Runtime-only: a type checker takes the real bindings from the TYPE_CHECKING +# imports above and must not also see a `__getattr__(name) -> object`, which +# would type every misspelled `mcp.` as `object` instead of reporting it. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs( + __name__, globals(), exports=_LAZY_ATTRS, submodules=_LAZY_SUBMODULES, hidden=_LAZY_MACHINERY + ) diff --git a/src/mcp/client/__init__.py b/src/mcp/client/__init__.py index d6b07045ce..8a0851f01c 100644 --- a/src/mcp/client/__init__.py +++ b/src/mcp/client/__init__.py @@ -1,5 +1,7 @@ """MCP Client module.""" +from typing import TYPE_CHECKING + from mcp.client._input_required import InputRequiredRoundsExceededError from mcp.client._transport import Transport from mcp.client.caching import ( @@ -21,6 +23,7 @@ advertise, ) from mcp.client.session import ClientSession, IncomingMessage +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs __all__ = [ "CacheConfig", @@ -42,3 +45,10 @@ "UnexpectedClaimedResult", "advertise", ] + +# `mcp.client.` (stdio, sse, session_group, auth, ...) resolves by +# attribute access even before that submodule was imported: the lazy `mcp` +# package no longer imports them all up front (see mcp.shared._lazy). Runtime +# only, so a type checker still flags a misspelled `mcp.client.`. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 29197bb504..01265a92ec 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -13,7 +13,8 @@ import httpx2 import jwt -from pydantic import BaseModel, Field +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, Field from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata @@ -134,6 +135,7 @@ async def provider(audience: str) -> str: return provider +@_deferred_model class SignedJWTParameters(BaseModel): """Parameters for creating SDK-signed JWT assertions. @@ -156,6 +158,9 @@ class SignedJWTParameters(BaseModel): ``` """ + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + issuer: str = Field(description="Issuer for JWT assertions (typically client_id).") subject: str = Field(description="Subject identifier for JWT assertions (typically client_id).") signing_key: str = Field(description="Private key for JWT signing (PEM format).") diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 7dc62b52b9..ed8eaac6fc 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -16,8 +16,9 @@ import anyio import httpx2 +from mcp_types._deferred import deferred_model as _deferred_model from mcp_types.version import is_version_at_least -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError from mcp.client.auth.utils import ( @@ -109,9 +110,13 @@ def check_registration_usable(client_info: OAuthClientInformationFull) -> None: ) +@_deferred_model class PKCEParameters(BaseModel): """PKCE (Proof Key for Code Exchange) parameters.""" + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + code_verifier: str = Field(..., min_length=43, max_length=128) code_challenge: str = Field(..., min_length=43, max_length=128) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index ed7c40f123..56cc5f561f 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -4,11 +4,12 @@ import hashlib import logging +import sys import uuid from collections.abc import Awaitable, Callable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack from dataclasses import KW_ONLY, dataclass, field -from typing import Any, Literal, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast import anyio import anyio.lowlevel @@ -40,10 +41,10 @@ ServerCapabilities, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS -from typing_extensions import deprecated +from typing_extensions import TypeIs, deprecated +import mcp from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver -from mcp.client._memory import InMemoryTransport from mcp.client._probe import negotiate_auto from mcp.client._transport import Transport from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore @@ -58,12 +59,8 @@ MessageHandlerFnT, SamplingFnT, ) -from mcp.client.streamable_http import streamable_http_client from mcp.client.subscriptions import ServerEvent, Subscription from mcp.client.subscriptions import listen as _listen -from mcp.server import Server -from mcp.server.mcpserver import MCPServer -from mcp.server.runner import modern_on_request from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair from mcp.shared.dispatcher import Dispatcher, ProgressFnT from mcp.shared.exceptions import MCPDeprecationWarning, MCPError @@ -71,6 +68,13 @@ from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.subscriptions import event_to_notification +if TYPE_CHECKING: + # Typing-only: the server stack is never imported by the client at runtime. + # An in-process `Server`/`MCPServer` argument already implies the caller + # imported it (see `_is_lowlevel_server`); URL/`Transport` clients pay nothing. + from mcp.server import Server + from mcp.server.mcpserver import MCPServer + logger = logging.getLogger(__name__) ConnectMode = Literal["legacy", "auto"] | str @@ -99,10 +103,45 @@ async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_excepti return connect +def _connect_url(url: str) -> _Connector: + """Connector for a URL string: the streamable-HTTP client transport.""" + # Function-level import (documented exception): `httpx2` is the heaviest client-side + # dependency and only the HTTP transports need it, so a stdio-only client (or bare + # `import mcp.client`) never loads it; the first URL `Client` constructed does, once. + from mcp.client.streamable_http import streamable_http_client + + return _connect_transport(streamable_http_client(url)) + + +def _loaded_class(module: str, name: str) -> type | None: + """Return class `name` from `module` if that module has already been imported, else `None`. + + A `Server`/`MCPServer` instance can only exist once its defining module has run, so + looking the class up in `sys.modules` (never importing it) gives an exact `isinstance` + test while keeping the whole server stack out of a transport-only client's imports. + """ + return getattr(sys.modules.get(module), name, None) + + +def _is_mcpserver(obj: object) -> TypeIs[MCPServer]: + cls = _loaded_class("mcp.server.mcpserver.server", "MCPServer") + return cls is not None and isinstance(obj, cls) + + +def _is_lowlevel_server(obj: object) -> TypeIs[Server[Any]]: + cls = _loaded_class("mcp.server.lowlevel.server", "Server") + return cls is not None and isinstance(obj, cls) + + def _connect_inproc(server: Server[Any]) -> _Connector: """Connector for an in-process ``Server``: legacy mode drives the stream loop via ``InMemoryTransport``; any other mode drives the modern per-request path through a ``DirectDispatcher`` peer pair (no streams, no JSON-RPC framing, no initialize handshake).""" + # Function-level imports (documented exception): this connector is only built for a live + # in-process `Server`, whose stack the caller has therefore already imported; binding these + # here rather than at module top keeps `import mcp.client` free of the server stack. + from mcp.client._memory import InMemoryTransport + from mcp.server.runner import modern_on_request async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exceptions: bool) -> Dispatcher[Any]: if mode == "legacy": @@ -283,7 +322,11 @@ async def main(): ``` """ - server: Server[Any] | MCPServer | Transport | str + # Spelled through the `mcp.server` namespace (not the TYPE_CHECKING-only + # `Server`/`MCPServer` names above) so `typing.get_type_hints(Client)` + # still resolves at runtime: the lazy `mcp` package imports `mcp.server` + # only when the annotation is actually evaluated. + server: mcp.server.Server[Any] | mcp.server.MCPServer | Transport | str """The MCP server to connect to. If the server is a `Server` or `MCPServer` instance, it will be connected in-process. @@ -388,12 +431,12 @@ def __post_init__(self) -> None: self._folded_extensions = _fold_extensions(self.extensions) srv = self.server - if isinstance(srv, MCPServer): + if _is_mcpserver(srv): srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage] - if isinstance(srv, Server): + if _is_lowlevel_server(srv): self._connect = _connect_inproc(srv) elif isinstance(srv, str): - self._connect = _connect_transport(streamable_http_client(srv)) + self._connect = _connect_url(srv) else: self._connect = _connect_transport(srv) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 895339ca18..bf4d1c1b61 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -34,7 +34,7 @@ LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS, ) -from pydantic import BaseModel, Discriminator, Tag, TypeAdapter, ValidationError +from pydantic import BaseModel, ConfigDict, Discriminator, Tag, TypeAdapter, ValidationError from typing_extensions import Self, TypeVar, deprecated from mcp.client._transport import ReadStream, WriteStream @@ -63,7 +63,9 @@ # `attrs`/`referencing` tree) in at module scope costs every client that never validates. from jsonschema.protocols import Validator -DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") +# Trusted literal: `model_construct` skips validation so importing this module does not +# force-build the deferred `Implementation` model; equal to the validated instance. +DEFAULT_CLIENT_INFO = types.Implementation.model_construct(name="mcp", version="0.1.0") DISCOVER_TIMEOUT_SECONDS = 10.0 _NOTIFICATION_QUEUE_SIZE: Final = 256 @@ -248,17 +250,23 @@ async def _default_logging_callback( pass -ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) +# `defer_build`: these adapters wrap `defer_build` monolith models, so build +# each validator on first use (once) instead of at import. +_DEFER: Final = ConfigDict(defer_build=True) + +ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter( + types.ClientResult | types.ErrorData, config=_DEFER +) # Typed against the wide parse union so adopt-built claim adapters share this attribute type. _CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result] = TypeAdapter( - types.CallToolResult | types.InputRequiredResult + types.CallToolResult | types.InputRequiredResult, config=_DEFER ) _GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = TypeAdapter( - types.GetPromptResult | types.InputRequiredResult + types.GetPromptResult | types.InputRequiredResult, config=_DEFER ) _ReadResourceResultAdapter: TypeAdapter[types.ReadResourceResult | types.InputRequiredResult] = TypeAdapter( - types.ReadResourceResult | types.InputRequiredResult + types.ReadResourceResult | types.InputRequiredResult, config=_DEFER ) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index a544cecbe8..e7a3637147 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -16,7 +16,8 @@ import anyio import httpx2 import mcp_types as types -from pydantic import BaseModel, Field +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, Field from typing_extensions import Self import mcp @@ -28,10 +29,16 @@ from mcp.shared.dispatcher import ProgressFnT from mcp.shared.exceptions import MCPError +# `defer_build=True` below: pydantic builds each validator on first use rather than at +# import (import-time cost); the build is transparent at first construction/validation. + +@_deferred_model class SseServerParameters(BaseModel): """Parameters for initializing an sse_client.""" + model_config = ConfigDict(defer_build=True) + # The endpoint URL. url: str @@ -45,9 +52,12 @@ class SseServerParameters(BaseModel): sse_read_timeout: float = 300.0 +@_deferred_model class StreamableHttpParameters(BaseModel): """Parameters for initializing a streamable_http_client.""" + model_config = ConfigDict(defer_build=True) + # The endpoint URL. url: str @@ -101,9 +111,12 @@ class ClientSessionGroup: ``` """ + @_deferred_model class _ComponentNames(BaseModel): """Used for reverse index to find components.""" + model_config = ConfigDict(defer_build=True) + prompts: set[str] = Field(default_factory=set) resources: set[str] = Field(default_factory=set) tools: set[str] = Field(default_factory=set) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 3e03eef9ef..d6d0e03cf7 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -21,7 +21,8 @@ import mcp_types as types from anyio.abc import AsyncResource, Process from anyio.streams.text import TextReceiveStream -from pydantic import BaseModel, Field +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, Field from mcp.client._transport import TransportStreams from mcp.os.posix.utilities import terminate_posix_process_tree @@ -90,7 +91,11 @@ def get_default_environment() -> dict[str, str]: return env +@_deferred_model class StdioServerParameters(BaseModel): + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + command: str """The executable to run to start the server.""" diff --git a/src/mcp/os/__init__.py b/src/mcp/os/__init__.py index fa5dbc809c..2fe3240f17 100644 --- a/src/mcp/os/__init__.py +++ b/src/mcp/os/__init__.py @@ -1 +1,10 @@ """Platform-specific utilities for MCP.""" + +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +# `mcp.os.posix` / `mcp.os.win32` resolve by attribute access even before they +# were imported. Runtime only, so a type checker still flags a typo. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/os/posix/__init__.py b/src/mcp/os/posix/__init__.py index 23aff8bb02..3f024f53c4 100644 --- a/src/mcp/os/posix/__init__.py +++ b/src/mcp/os/posix/__init__.py @@ -1 +1,11 @@ """POSIX-specific utilities for MCP.""" + +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +# `mcp.os.posix.utilities` resolves by attribute access even before it was +# imported explicitly, like the other packages. Runtime only, so a type +# checker still flags a typo. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/os/win32/__init__.py b/src/mcp/os/win32/__init__.py index f1ebab98df..07c70a9652 100644 --- a/src/mcp/os/win32/__init__.py +++ b/src/mcp/os/win32/__init__.py @@ -1 +1,11 @@ """Windows-specific utilities for MCP.""" + +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +# `mcp.os.win32.utilities` resolves by attribute access even before it was +# imported explicitly, like the other packages. Runtime only, so a type +# checker still flags a typo. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index 5897e8aa8b..123b13cf64 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -1,7 +1,26 @@ +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + from .caching import CacheHint from .context import ServerRequestContext from .lowlevel import NotificationOptions, Server from .mcpserver import MCPServer from .models import InitializationOptions +if TYPE_CHECKING: + # Submodules named by qualified annotations elsewhere in the SDK (e.g. + # `mcp.server.streamable_http_manager.StreamableHTTPSessionManager`); the + # mirror lets type checkers follow the chain the runtime `__getattr__` + # resolves. Add to it when spelling a new qualified annotation. + from . import auth as auth + from . import streamable_http_manager as streamable_http_manager + __all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] + +# `mcp.server.` (stdio, session, streamable_http, auth, ...) +# resolves by attribute access even before that submodule was imported: +# the lazy `mcp` package no longer imports them all up front. Runtime only, +# so a type checker still flags a misspelled `mcp.server.`. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/server/_http_defaults.py b/src/mcp/server/_http_defaults.py new file mode 100644 index 0000000000..64aefa1a55 --- /dev/null +++ b/src/mcp/server/_http_defaults.py @@ -0,0 +1,13 @@ +"""Streamable HTTP defaults shared by the transport and the servers' signatures. + +A leaf module with no third-party imports: `mcp.server.lowlevel` and +`mcp.server.mcpserver` use these as parameter defaults, and importing them from +the HTTP transport modules (which need starlette) would drag the HTTP stack +into every stdio server at import time. `mcp.server.streamable_http_manager` +re-exports the constant under its documented import path. +""" + +from typing import Final + +DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024 +"""Default maximum Streamable HTTP request body size in bytes (4 MiB).""" diff --git a/src/mcp/server/_otel.py b/src/mcp/server/_otel.py index ff722eb903..282a0051e1 100644 --- a/src/mcp/server/_otel.py +++ b/src/mcp/server/_otel.py @@ -3,11 +3,10 @@ from typing import Any from mcp_types import INVALID_PARAMS, CallToolResult -from opentelemetry.trace import SpanKind, StatusCode from pydantic import ValidationError from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext -from mcp.shared._otel import extract_trace_context, otel_span +from mcp.shared._otel import extract_trace_context, otel_span, set_span_error from mcp.shared.exceptions import MCPError @@ -34,7 +33,7 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex with otel_span( name=f"{ctx.method}{f' {target}' if target else ''}", - kind=SpanKind.SERVER, + kind="server", attributes=attributes, context=extract_trace_context(ctx.meta), record_exception=False, @@ -45,18 +44,18 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex except MCPError as e: code = str(e.error.code) span.set_attributes({"error.type": code, "rpc.response.status_code": code}) - span.set_status(StatusCode.ERROR, e.error.message) + set_span_error(span, e.error.message) raise except ValidationError: # Mirror the sanitized wire response; pydantic messages carry client input. code = str(INVALID_PARAMS) span.set_attributes({"error.type": code, "rpc.response.status_code": code}) - span.set_status(StatusCode.ERROR, "Invalid request parameters") + set_span_error(span, "Invalid request parameters") raise except Exception as e: span.set_attribute("error.type", type(e).__qualname__) span.record_exception(e) - span.set_status(StatusCode.ERROR, str(e)) + set_span_error(span, str(e)) raise if ctx.method == "tools/call": # Tool errors are detected pre-serialization, so only shapes that reach the wire as an error @@ -66,7 +65,7 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex match result: case CallToolResult(is_error=True) | {"isError": True}: span.set_attribute("error.type", "tool_error") - span.set_status(StatusCode.ERROR) + set_span_error(span) case _: pass return result diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 1db6b35f8a..5615b59e87 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -47,10 +47,11 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send +from mcp.server._transport_security_middleware import TransportSecurityMiddleware from mcp.server.connection import Connection from mcp.server.runner import modern_error_data, serve_one from mcp.server.streamable_http import check_accept_headers -from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings +from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.dispatcher import CallOptions from mcp.shared.exceptions import NoBackChannelError from mcp.shared.inbound import ( diff --git a/src/mcp/server/_transport_security_middleware.py b/src/mcp/server/_transport_security_middleware.py new file mode 100644 index 0000000000..d050b8f360 --- /dev/null +++ b/src/mcp/server/_transport_security_middleware.py @@ -0,0 +1,100 @@ +"""Starlette middleware that enforces `TransportSecuritySettings` on HTTP transports. + +Kept apart from `mcp.server.transport_security` (which defines only the +web-framework-free settings model, so `import mcp.server` and stdio servers +never load starlette). `mcp.server.transport_security` still re-exports +`TransportSecurityMiddleware` for existing imports of it. +""" + +import logging + +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.transport_security import TransportSecuritySettings + +logger = logging.getLogger(__name__) + + +# TODO(Marcelo): This should be a proper ASGI middleware. I'm sad to see this. +class TransportSecurityMiddleware: + """Middleware to enforce DNS rebinding protection for MCP transport endpoints.""" + + def __init__(self, settings: TransportSecuritySettings | None = None): + # If not specified, disable DNS rebinding protection by default for backwards compatibility + self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False) + + def _validate_host(self, host: str | None) -> bool: + """Validate the Host header against allowed values.""" + if not host: + logger.warning("Missing Host header in request") + return False + + # Check exact match first + if host in self.settings.allowed_hosts: + return True + + # Check wildcard port patterns + for allowed in self.settings.allowed_hosts: + if allowed.endswith(":*"): + # Extract base host from pattern + base_host = allowed[:-2] + # Check if the actual host starts with base host and has a port + if host.startswith(base_host + ":"): + return True + + logger.warning(f"Invalid Host header: {host}") + return False + + def _validate_origin(self, origin: str | None) -> bool: + """Validate the Origin header against allowed values.""" + # Origin can be absent for same-origin requests + if not origin: + return True + + # Check exact match first + if origin in self.settings.allowed_origins: + return True + + # Check wildcard port patterns + for allowed in self.settings.allowed_origins: + if allowed.endswith(":*"): + # Extract base origin from pattern + base_origin = allowed[:-2] + # Check if the actual origin starts with base origin and has a port + if origin.startswith(base_origin + ":"): + return True + + logger.warning(f"Invalid Origin header: {origin}") + return False + + def _validate_content_type(self, content_type: str | None) -> bool: + """Validate the Content-Type header for POST requests.""" + return content_type is not None and content_type.lower().startswith("application/json") + + async def validate_request(self, request: Request, is_post: bool = False) -> Response | None: + """Validate request headers for DNS rebinding protection. + + Returns None if validation passes, or an error Response if validation fails. + """ + # Always validate Content-Type for POST requests + if is_post: + content_type = request.headers.get("content-type") + if not self._validate_content_type(content_type): + return Response("Invalid Content-Type header", status_code=400) + + # Skip remaining validation if DNS rebinding protection is disabled + if not self.settings.enable_dns_rebinding_protection: + return None + + # Validate Host header + host = request.headers.get("host") + if not self._validate_host(host): + return Response("Invalid Host header", status_code=421) + + # Validate Origin header + origin = request.headers.get("origin") + if not self._validate_origin(origin): + return Response("Invalid Origin header", status_code=403) + + return None diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py index 583e203ac0..1433b76163 100644 --- a/src/mcp/server/apps.py +++ b/src/mcp/server/apps.py @@ -32,6 +32,7 @@ def get_time(ctx: Context) -> str: from collections.abc import Callable, Sequence from typing import Any, Literal, TypeVar +from mcp_types._deferred import deferred_model as _deferred_model from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -52,10 +53,12 @@ def get_time(ctx: Context) -> str: _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) +@_deferred_model class ResourcePermissions(BaseModel): """Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`).""" - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, defer_build=True) camera: dict[str, Any] | None = None microphone: dict[str, Any] | None = None @@ -63,10 +66,11 @@ class ResourcePermissions(BaseModel): clipboard_write: dict[str, Any] | None = None +@_deferred_model class ResourceCsp(BaseModel): """Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`).""" - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, defer_build=True) connect_domains: list[str] | None = None resource_domains: list[str] | None = None diff --git a/src/mcp/server/auth/__init__.py b/src/mcp/server/auth/__init__.py index 61b60e3487..9c67ce0098 100644 --- a/src/mcp/server/auth/__init__.py +++ b/src/mcp/server/auth/__init__.py @@ -1 +1,20 @@ """MCP OAuth server authorization components.""" + +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +if TYPE_CHECKING: + # Submodules named by qualified annotations elsewhere in the SDK + # (`mcp.server.auth.provider.TokenVerifier`, ...): mirrored so type + # checkers follow the chain the runtime `__getattr__` resolves. + from . import provider as provider + from . import settings as settings + +# `mcp.server.auth.` (settings, provider, routes, ...) resolves by +# attribute access without importing the auth stack up front, so qualified +# annotations such as `mcp.server.auth.provider.TokenVerifier` evaluate on +# demand in `typing.get_type_hints`. Runtime only, so a type checker still +# flags a misspelled `mcp.server.auth.`. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/server/auth/access_token.py b/src/mcp/server/auth/access_token.py new file mode 100644 index 0000000000..50130a7cde --- /dev/null +++ b/src/mcp/server/auth/access_token.py @@ -0,0 +1,38 @@ +"""The access token of the request being served, exposed via a contextvar. + +This module is deliberately transport- and auth-stack-agnostic: it imports no +HTTP framework and no OAuth model, so `mcp.server.request_state` (and any tool +handler) can read the caller's token without loading either. On HTTP transports +the contextvar is populated by +`mcp.server.auth.middleware.auth_context.AuthContextMiddleware`, which also +re-exports both names under their long-standing import path. +""" + +from __future__ import annotations + +import contextvars +from typing import TYPE_CHECKING + +import mcp + +if TYPE_CHECKING: + from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + +# Create a contextvar to store the authenticated user +# The default is None, indicating no authenticated user is present +auth_context_var: contextvars.ContextVar[AuthenticatedUser | None] = contextvars.ContextVar( + "auth_context", default=None +) + + +# The return type is spelled through the lazy `mcp.server` namespace so this +# module never imports the OAuth provider models, while +# `typing.get_type_hints(get_access_token)` still resolves it on demand. +def get_access_token() -> mcp.server.auth.provider.AccessToken | None: + """Get the access token from the current context. + + Returns: + The access token if an authenticated user is available, None otherwise. + """ + auth_user = auth_context_var.get() + return auth_user.access_token if auth_user else None diff --git a/src/mcp/server/auth/handlers/__init__.py b/src/mcp/server/auth/handlers/__init__.py index fd8a462b37..bdfaa47b12 100644 --- a/src/mcp/server/auth/handlers/__init__.py +++ b/src/mcp/server/auth/handlers/__init__.py @@ -1 +1,11 @@ """Request handlers for MCP authorization endpoints.""" + +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +# `mcp.server.auth.handlers.` resolves by attribute access even +# before that submodule was imported explicitly, like the other packages. +# Runtime only, so a type checker still flags a typo. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/server/auth/handlers/authorize.py b/src/mcp/server/auth/handlers/authorize.py index 5cf93cf8c2..e732400f9a 100644 --- a/src/mcp/server/auth/handlers/authorize.py +++ b/src/mcp/server/auth/handlers/authorize.py @@ -3,7 +3,8 @@ from typing import Any, Literal # TODO(Marcelo): We should drop the `RootModel`. -from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError # noqa: TID251 +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import AnyUrl, BaseModel, ConfigDict, Field, RootModel, ValidationError # noqa: TID251 from starlette.datastructures import FormData, QueryParams from starlette.requests import Request from starlette.responses import RedirectResponse, Response @@ -22,7 +23,14 @@ logger = logging.getLogger(__name__) +# `defer_build=True` below: pydantic builds each validator on first use rather than at +# import (import-time cost); the build is transparent at first construction/validation. + + +@_deferred_model class AuthorizationRequest(BaseModel): + model_config = ConfigDict(defer_build=True) + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 client_id: str = Field(..., description="The client ID") redirect_uri: AnyUrl | None = Field(None, description="URL to redirect to after authorization") @@ -42,7 +50,10 @@ class AuthorizationRequest(BaseModel): ) +@_deferred_model class AuthorizationErrorResponse(BaseModel): + model_config = ConfigDict(defer_build=True) + error: AuthorizationErrorCode error_description: str | None error_uri: AnyUrl | None = None @@ -59,7 +70,10 @@ def best_effort_extract_string(key: str, params: None | FormData | QueryParams) return None +@_deferred_model class AnyUrlModel(RootModel[AnyUrl]): + model_config = ConfigDict(defer_build=True) + root: AnyUrl diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 7fb14b2c43..af9dd54470 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -4,7 +4,8 @@ from typing import Any from uuid import uuid4 -from pydantic import BaseModel, ValidationError +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, ValidationError from starlette.requests import Request from starlette.responses import Response @@ -19,7 +20,11 @@ RegistrationRequest = OAuthClientMetadata +@_deferred_model class RegistrationErrorResponse(BaseModel): + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + error: RegistrationErrorCode error_description: str | None diff --git a/src/mcp/server/auth/handlers/revoke.py b/src/mcp/server/auth/handlers/revoke.py index 4efd154001..79d3fedb5e 100644 --- a/src/mcp/server/auth/handlers/revoke.py +++ b/src/mcp/server/auth/handlers/revoke.py @@ -2,7 +2,8 @@ from functools import partial from typing import Any, Literal -from pydantic import BaseModel, ValidationError +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, ValidationError from starlette.requests import Request from starlette.responses import Response @@ -13,17 +14,26 @@ from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken +# `defer_build=True` below: pydantic builds each validator on first use rather than at +# import (import-time cost); the build is transparent at first construction/validation. + +@_deferred_model class RevocationRequest(BaseModel): """See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1""" + model_config = ConfigDict(defer_build=True) + token: str token_type_hint: Literal["access_token", "refresh_token"] | None = None client_id: str client_secret: str | None +@_deferred_model class RevocationErrorResponse(BaseModel): + model_config = ConfigDict(defer_build=True) + error: Literal["invalid_request", "unauthorized_client"] error_description: str | None = None diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py index 0e644c378a..16aeea1c78 100644 --- a/src/mcp/server/auth/handlers/token.py +++ b/src/mcp/server/auth/handlers/token.py @@ -4,7 +4,8 @@ from dataclasses import dataclass from typing import Annotated, Any, Literal -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, TypeAdapter, ValidationError +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError from starlette.requests import Request from mcp.server.auth.errors import stringify_pydantic_error @@ -18,8 +19,14 @@ ) from mcp.shared.auth import OAuthToken +# `defer_build=True` below: pydantic builds each validator on first use rather than at +# import (import-time cost); the build is transparent at first construction/validation. + +@_deferred_model class AuthorizationCodeRequest(BaseModel): + model_config = ConfigDict(defer_build=True) + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 grant_type: Literal["authorization_code"] code: str = Field(..., description="The authorization code") @@ -33,7 +40,10 @@ class AuthorizationCodeRequest(BaseModel): resource: str | None = Field(None, description="Resource indicator for the token") +@_deferred_model class RefreshTokenRequest(BaseModel): + model_config = ConfigDict(defer_build=True) + # See https://datatracker.ietf.org/doc/html/rfc6749#section-6 grant_type: Literal["refresh_token"] refresh_token: str = Field(..., description="The refresh token") @@ -45,7 +55,10 @@ class RefreshTokenRequest(BaseModel): resource: str | None = Field(None, description="Resource indicator for the token") +@_deferred_model class JwtBearerRequest(BaseModel): + model_config = ConfigDict(defer_build=True) + # RFC 7523 §2.1 JWT bearer authorization grant. SEP-990 leg 2: the client presents the # enterprise IdP-issued ID-JAG to the MCP authorization server as the `assertion`. grant_type: Literal["urn:ietf:params:oauth:grant-type:jwt-bearer"] @@ -63,12 +76,15 @@ class JwtBearerRequest(BaseModel): AuthorizationCodeRequest | RefreshTokenRequest | JwtBearerRequest, Field(discriminator="grant_type"), ] -token_request_adapter = TypeAdapter[TokenRequest](TokenRequest) +token_request_adapter = TypeAdapter[TokenRequest](TokenRequest, config=ConfigDict(defer_build=True)) +@_deferred_model class TokenErrorResponse(BaseModel): """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2""" + model_config = ConfigDict(defer_build=True) + error: TokenErrorCode error_description: str | None = None error_uri: AnyHttpUrl | None = None diff --git a/src/mcp/server/auth/middleware/__init__.py b/src/mcp/server/auth/middleware/__init__.py index ab07d84161..efcdf71a6c 100644 --- a/src/mcp/server/auth/middleware/__init__.py +++ b/src/mcp/server/auth/middleware/__init__.py @@ -1 +1,11 @@ """Middleware for MCP authorization.""" + +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +# `mcp.server.auth.middleware.` resolves by attribute access even +# before that submodule was imported explicitly, like the other packages. +# Runtime only, so a type checker still flags a typo. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/server/auth/middleware/auth_context.py b/src/mcp/server/auth/middleware/auth_context.py index 1d34a5546b..714aad92d8 100644 --- a/src/mcp/server/auth/middleware/auth_context.py +++ b/src/mcp/server/auth/middleware/auth_context.py @@ -1,23 +1,11 @@ -import contextvars - from starlette.types import ASGIApp, Receive, Scope, Send +# The contextvar and its accessor are defined in a transport-agnostic module +# (no starlette) so request-state code can read the token without loading the +# web stack; they are re-exported here under their long-standing import path. +from mcp.server.auth.access_token import auth_context_var as auth_context_var +from mcp.server.auth.access_token import get_access_token as get_access_token from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken - -# Create a contextvar to store the authenticated user -# The default is None, indicating no authenticated user is present -auth_context_var = contextvars.ContextVar[AuthenticatedUser | None]("auth_context", default=None) - - -def get_access_token() -> AccessToken | None: - """Get the access token from the current context. - - Returns: - The access token if an authenticated user is available, None otherwise. - """ - auth_user = auth_context_var.get() - return auth_user.access_token if auth_user else None class AuthContextMiddleware: diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 644868f3e5..3215c54241 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -2,12 +2,19 @@ from typing import Any, Generic, Literal, Protocol, TypeVar from urllib.parse import parse_qs, urlencode, urlparse, urlunparse -from pydantic import AnyUrl, BaseModel +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import AnyUrl, BaseModel, ConfigDict from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +# `defer_build=True` on the models below: pydantic builds each validator on first use +# rather than at import (import-time cost); the build is transparent at first construction. + +@_deferred_model class AuthorizationParams(BaseModel): + model_config = ConfigDict(defer_build=True) + state: str | None scopes: list[str] | None code_challenge: str @@ -16,6 +23,7 @@ class AuthorizationParams(BaseModel): resource: str | None = None # RFC 8707 resource indicator +@_deferred_model class IdentityAssertionParams(BaseModel): """Validated parameters of a SEP-990 identity-assertion (RFC 7523 jwt-bearer) request. @@ -24,12 +32,17 @@ class IdentityAssertionParams(BaseModel): RFC 7523 §3 and the SEP-990 §5.1 processing rules before issuing an access token. """ + model_config = ConfigDict(defer_build=True) + assertion: str # RFC 7523 §2.1: the JWT (ID-JAG) presented as the authorization grant scopes: list[str] | None = None resource: str | None = None # RFC 8707 resource indicator from the token request +@_deferred_model class AuthorizationCode(BaseModel): + model_config = ConfigDict(defer_build=True) + code: str scopes: list[str] expires_at: float @@ -41,7 +54,10 @@ class AuthorizationCode(BaseModel): subject: str | None = None # resource owner; propagate to the issued AccessToken +@_deferred_model class RefreshToken(BaseModel): + model_config = ConfigDict(defer_build=True) + token: str client_id: str scopes: list[str] @@ -49,7 +65,10 @@ class RefreshToken(BaseModel): subject: str | None = None # resource owner; propagate to refreshed AccessTokens +@_deferred_model class AccessToken(BaseModel): + model_config = ConfigDict(defer_build=True) + token: str client_id: str scopes: list[str] diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index ae2083a38b..195b7ad471 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -1,23 +1,34 @@ +from mcp_types._deferred import deferred_model as _deferred_model from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field +# `defer_build=True` below: pydantic builds each validator on first use rather than at +# import (import-time cost); the build is transparent at first construction/validation. + +@_deferred_model class ClientRegistrationOptions(BaseModel): + model_config = ConfigDict(defer_build=True) + enabled: bool = False client_secret_expiry_seconds: int | None = None valid_scopes: list[str] | None = None default_scopes: list[str] | None = None +@_deferred_model class RevocationOptions(BaseModel): + model_config = ConfigDict(defer_build=True) + enabled: bool = False +@_deferred_model class AuthSettings(BaseModel): # Preserve empty URL paths so a path-less issuer/resource passed as a string keeps its # canonical form (no trailing slash). RFC 8414/9207 issuer comparison is exact string # comparison, so a spurious trailing slash would break it. See PR #2925 for the metadata # models; this applies the same to the server's own configured URLs. - model_config = ConfigDict(url_preserve_empty_path=True) + model_config = ConfigDict(url_preserve_empty_path=True, defer_build=True) issuer_url: AnyHttpUrl = Field( ..., diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 26425c1338..dcb5b7a548 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -2,38 +2,55 @@ from __future__ import annotations -from typing import Any, Generic, Literal, TypeVar +from functools import cache +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar from mcp_types import RequestId - -# Internal surface package; imported as the gate's source of truth for spec-valid property schemas. -from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition -from pydantic import BaseModel, ValidationError +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, ValidationError from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue from pydantic_core import core_schema from typing_extensions import TypeAliasType from mcp.server.session import ServerSession +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +if TYPE_CHECKING: + # Explicit re-export: the name resolves lazily on this module at runtime. + from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition as PrimitiveSchemaDefinition ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel) +# `defer_build` on the result models below: pydantic builds each validator on +# first use instead of at import; the build happens once, transparently. + + +@_deferred_model class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]): """Result when user accepts the elicitation.""" + model_config = ConfigDict(defer_build=True) + action: Literal["accept"] = "accept" data: ElicitSchemaModelT +@_deferred_model class DeclinedElicitation(BaseModel): """Result when user declines the elicitation.""" + model_config = ConfigDict(defer_build=True) + action: Literal["decline"] = "decline" +@_deferred_model class CancelledElicitation(BaseModel): """Result when user cancels the elicitation.""" + model_config = ConfigDict(defer_build=True) + action: Literal["cancel"] = "cancel" @@ -44,9 +61,12 @@ class CancelledElicitation(BaseModel): ) +@_deferred_model class AcceptedUrlElicitation(BaseModel): """Result when user accepts a URL mode elicitation.""" + model_config = ConfigDict(defer_build=True) + action: Literal["accept"] = "accept" @@ -71,15 +91,30 @@ def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaVal return result +@cache +def _primitive_schema_definition() -> type[PrimitiveSchemaDefinition]: + """The spec-valid property schema the elicitation gate validates against. + + Its home is the internal `mcp_types._v2025_11_25` wire package (~50 ms of + pydantic model construction), so it is imported on the first elicitation + schema instead of at module import - once: this accessor is cached, so no + later call re-runs the import. + """ + from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition + + return PrimitiveSchemaDefinition + + def _validate_rendered_properties(json_schema: dict[str, Any]) -> None: """Reject any `properties` entry the spec's `PrimitiveSchemaDefinition` won't accept. Catches whatever the renderer let through that isn't spec-valid: bare `list[str]` (no enum), multi-primitive unions, nested models. """ + primitive_schema_definition = _primitive_schema_definition() for field_name, prop in json_schema.get("properties", {}).items(): try: - PrimitiveSchemaDefinition.model_validate(prop) + primitive_schema_definition.model_validate(prop) except ValidationError: raise TypeError( f"Elicitation schema field {field_name!r} rendered as {prop!r}, " @@ -188,3 +223,14 @@ async def elicit_url( else: # pragma: no cover # This should never happen, but handle it just in case raise ValueError(f"Unexpected elicitation action: {result.action}") + + +# `PrimitiveSchemaDefinition` used to be bound in this module by a module-level +# import of the internal wire package; that import now happens on the first +# rendered schema (`_primitive_schema_definition`) so importing this module does +# not build the wire package. The name still resolves lazily for existing +# references to it. Runtime only: the TYPE_CHECKING import above types it. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs( + __name__, globals(), exports={"PrimitiveSchemaDefinition": _primitive_schema_definition} + ) diff --git a/src/mcp/server/event_store.py b/src/mcp/server/event_store.py new file mode 100644 index 0000000000..5fc1b2ab60 --- /dev/null +++ b/src/mcp/server/event_store.py @@ -0,0 +1,65 @@ +"""Event storage interface for streamable-HTTP resumability. + +`EventStore` and its supporting types are the contract a resumable +streamable-HTTP server plugs a store into (see `mcp.server.streamable_http`, +which re-exports every name here). They are defined in this transport-agnostic +module - no web framework imported - so servers and application code can name +and type against them without loading the HTTP stack. +""" + +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from mcp_types import JSONRPCMessage + +__all__ = ["EventCallback", "EventId", "EventMessage", "EventStore", "StreamId"] + +# Type aliases +StreamId = str +EventId = str + + +@dataclass +class EventMessage: + """A JSONRPCMessage with an optional event ID for stream resumability.""" + + message: JSONRPCMessage + event_id: str | None = None + + +EventCallback = Callable[[EventMessage], Awaitable[None]] + + +class EventStore(ABC): + """Interface for resumability support via event storage.""" + + @abstractmethod + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event for later retrieval. + + Args: + stream_id: ID of the stream the event belongs to + message: The JSON-RPC message to store, or None for priming events + + Returns: + The generated event ID for the stored event. + """ + pass # pragma: no cover + + @abstractmethod + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID. + + Args: + last_event_id: The ID of the last event the client received + send_callback: A callback function to send events to the client + + Returns: + The stream ID of the replayed events, or None if no events were found. + """ + pass # pragma: no cover diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 1cbd3f2bd6..9b68629a83 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -43,38 +43,36 @@ async def main(): from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass from functools import cached_property -from typing import Any, Generic, overload +from typing import TYPE_CHECKING, Any, Generic, overload import mcp_types as types from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import BaseModel -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.routing import Mount, Route from typing_extensions import TypeVar, deprecated +import mcp +from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE from mcp.server._otel import OpenTelemetryMiddleware -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware -from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier -from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes -from mcp.server.auth.settings import AuthSettings from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.event_store import EventStore from mcp.server.models import InitializationOptions from mcp.server.runner import serve_dual_era_loop -from mcp.server.streamable_http import EventStore -from mcp.server.streamable_http_manager import ( - DEFAULT_MAX_REQUEST_BODY_SIZE, - StreamableHTTPASGIApp, - StreamableHTTPSessionManager, -) from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.message import SessionMessage +if TYPE_CHECKING: + # Starlette types appear only in `streamable_http_app`'s signature; the + # runtime imports live inside that method, so `import mcp.server` (and + # every stdio server) never loads the HTTP stack: starlette, + # sse_starlette, uvicorn. The auth and session-manager annotations are + # spelled through the lazy `mcp.server` namespace instead (below), so + # `typing.get_type_hints` still resolves them on demand at runtime. + from starlette.applications import Starlette + from starlette.routing import Route + logger = logging.getLogger(__name__) LifespanResultT = TypeVar("LifespanResultT", default=Any) @@ -426,7 +424,7 @@ def __init__( self.lifespan = lifespan self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} - self._session_manager: StreamableHTTPSessionManager | None = None + self._session_manager: mcp.server.streamable_http_manager.StreamableHTTPSessionManager | None = None # Context-tier middleware: wraps every inbound request (including # `initialize`, lookup, validation, handler) with # `(ctx, call_next)`. Applied in `ServerRunner._on_request`. @@ -675,7 +673,7 @@ async def _handle_discover( ) @property - def session_manager(self) -> StreamableHTTPSessionManager: + def session_manager(self) -> mcp.server.streamable_http_manager.StreamableHTTPSessionManager: """Get the StreamableHTTP session manager. Raises: @@ -728,13 +726,31 @@ def streamable_http_app( max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE, transport_security: TransportSecuritySettings | None = None, host: str = "127.0.0.1", - auth: AuthSettings | None = None, - token_verifier: TokenVerifier | None = None, - auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, + auth: mcp.server.auth.settings.AuthSettings | None = None, + token_verifier: mcp.server.auth.provider.TokenVerifier | None = None, + auth_server_provider: mcp.server.auth.provider.OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, custom_starlette_routes: list[Route] | None = None, debug: bool = False, ) -> Starlette: """Return an instance of the StreamableHTTP server app.""" + # The HTTP transport stack (starlette, plus this SDK's HTTP transport + # and auth ASGI modules) is imported here rather than at module top so + # that `import mcp.server` and stdio servers never load starlette, + # sse_starlette or uvicorn: only building an HTTP app pays for it, once. + from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.middleware.authentication import AuthenticationMiddleware + from starlette.routing import Mount, Route + + from mcp.server.auth.middleware.auth_context import AuthContextMiddleware + from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware + from mcp.server.auth.routes import ( + build_resource_metadata_url, + create_auth_routes, + create_protected_resource_routes, + ) + from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager + # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): transport_security = TransportSecuritySettings( diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index bf4c26a248..e57e1a565c 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -4,7 +4,8 @@ from typing import TYPE_CHECKING, Any, Generic, cast from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel -from pydantic import AnyUrl, BaseModel +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import AnyUrl, BaseModel, ConfigDict from typing_extensions import deprecated from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext @@ -29,6 +30,7 @@ from mcp.server.mcpserver.server import MCPServer +@_deferred_model class Context(BaseModel, Generic[LifespanContextT, RequestT]): """Context object providing access to MCP capabilities. @@ -62,6 +64,9 @@ async def my_tool(x: int, ctx: Context) -> str: The context is optional - tools that don't need it can omit the parameter. """ + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + _request_context: ServerRequestContext[LifespanContextT, RequestT] | None _mcp_server: MCPServer | None _input_params: InputResponseRequestParams | None diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 0a010de7d2..65770d0ac5 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -9,7 +9,8 @@ import anyio.to_thread import pydantic_core from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent -from pydantic import BaseModel, Field, TypeAdapter, validate_call +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, validate_call from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context from mcp.server.mcpserver.utilities.func_metadata import func_metadata @@ -21,9 +22,16 @@ from mcp.server.mcpserver.context import Context +# `defer_build=True` below: pydantic builds each validator on first use rather than at +# import (import-time cost); the build is transparent at first construction/validation. + + +@_deferred_model class Message(BaseModel): """Base class for all prompt messages.""" + model_config = ConfigDict(defer_build=True) + role: Literal["user", "assistant"] content: ContentBlock @@ -51,23 +59,31 @@ def __init__(self, content: str | ContentBlock, **kwargs: Any): super().__init__(content=content, **kwargs) -message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage) +message_validator = TypeAdapter[UserMessage | AssistantMessage]( + UserMessage | AssistantMessage, config=ConfigDict(defer_build=True) +) SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]] PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] +@_deferred_model class PromptArgument(BaseModel): """An argument that can be passed to a prompt.""" + model_config = ConfigDict(defer_build=True) + name: str = Field(description="Name of the argument") description: str | None = Field(None, description="Description of what the argument does") required: bool = Field(default=False, description="Whether the argument is required") +@_deferred_model class Prompt(BaseModel): """A prompt template that can be rendered with parameters.""" + model_config = ConfigDict(defer_build=True) + name: str = Field(description="Name of the prompt") title: str | None = Field(None, description="Human-readable title of the prompt") description: str | None = Field(None, description="Description of what the prompt does") diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index d4a744af37..0a05e2fddc 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -66,8 +66,9 @@ Tool, ToolChoice, ) +from mcp_types._deferred import deferred_model as _deferred_model from mcp_types.version import is_version_at_least -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ConfigDict, ValidationError from typing_extensions import TypeVar from mcp.server.elicitation import ( @@ -723,9 +724,16 @@ def _result_type( ) +# `defer_build=True` below: pydantic builds each validator on first use rather than at +# import (import-time cost); the build is transparent at first construction/validation. + + +@_deferred_model class _StateEntry(BaseModel): """One resolver's recorded outcome inside `request_state`.""" + model_config = ConfigDict(defer_build=True) + action: Literal["accept", "decline", "cancel"] data: Any = None q: str | None = None @@ -743,9 +751,12 @@ def _request_digest(request: InputRequest) -> str: return base64.urlsafe_b64encode(digest).decode().rstrip("=") +@_deferred_model class _State(BaseModel): """The decoded `request_state`: resolver progress from earlier rounds.""" + model_config = ConfigDict(defer_build=True) + v: int outcomes: dict[str, _StateEntry] = {} asked: dict[str, str] = {} diff --git a/src/mcp/server/mcpserver/resources/base.py b/src/mcp/server/mcpserver/resources/base.py index a1d9cebf3f..116ddd63ab 100644 --- a/src/mcp/server/mcpserver/resources/base.py +++ b/src/mcp/server/mcpserver/resources/base.py @@ -4,6 +4,7 @@ from typing import Any from mcp_types import Annotations, Icon +from mcp_types._deferred import deferred_model as _deferred_model from pydantic import ( BaseModel, ConfigDict, @@ -13,10 +14,13 @@ ) +@_deferred_model class Resource(BaseModel, abc.ABC): """Base class for all resources.""" - model_config = ConfigDict(validate_default=True, extra="forbid") + # defer_build: build the validator on first use rather than at import (import-time cost); + # inherited by every Resource subclass. + model_config = ConfigDict(validate_default=True, extra="forbid", defer_build=True) uri: str = Field(default=..., description="URI of the resource") name: str | None = Field(description="Name of the resource", default=None) diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 096e821d81..7afc9c0863 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -9,7 +9,8 @@ import anyio.to_thread from mcp_types import Annotations, Icon, InputRequiredResult -from pydantic import BaseModel, Field, validate_call +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, Field, validate_call from mcp.server.mcpserver.exceptions import ResourceError from mcp.server.mcpserver.resources.types import FunctionResource, Resource @@ -104,9 +105,13 @@ def __init__(self, template: str, param: str) -> None: self.param = param +@_deferred_model class ResourceTemplate(BaseModel): """A template for dynamically creating resources.""" + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)") name: str = Field(description="Name of the resource") title: str | None = Field(description="Human-readable title of the resource", default=None) diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index 2edf342337..df53b83e03 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -10,7 +10,6 @@ import anyio import anyio.to_thread -import httpx2 import pydantic import pydantic_core from mcp_types import Annotations, Icon, InputRequiredResult @@ -199,7 +198,12 @@ class HttpResource(Resource): async def read(self) -> str | bytes: """Read the HTTP content.""" - async with httpx2.AsyncClient() as client: # pragma: no cover + # httpx2 is imported here rather than at module top: this is the only + # resource type that needs the HTTP client stack, and a server that + # never registers an HttpResource should not pay for it at import time. + import httpx2 + + async with httpx2.AsyncClient() as client: response = await client.get(self.url) response.raise_for_status() return response.text diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7bf1993e29..973236b572 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -6,7 +6,7 @@ import inspect from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager -from typing import Any, Generic, Literal, TypeVar, overload +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, overload import anyio import pydantic_core @@ -44,22 +44,20 @@ from mcp_types import Resource as MCPResource from mcp_types import ResourceTemplate as MCPResourceTemplate from mcp_types import Tool as MCPTool -from pydantic import BaseModel +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict from pydantic.networks import AnyUrl -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import Mount, Route -from starlette.types import Receive, Scope, Send - -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware -from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier -from mcp.server.auth.settings import AuthSettings + +import mcp +from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE + +# `MCPServer.__init__`'s auth-provider annotations are spelled through the lazy +# `mcp.server.auth` namespace, so this module never imports the OAuth provider +# stack (it loads with the first auth-enabled server instead). +from mcp.server.auth.settings import AuthSettings # light (pydantic-only); the Settings model needs it from mcp.server.caching import CacheableMethod, CacheHint from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.event_store import EventStore from mcp.server.extension import ( Extension, MethodBinding, @@ -84,23 +82,38 @@ from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity -from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server -from mcp.server.streamable_http import EventStore -from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import UriTemplate +if TYPE_CHECKING: + # Starlette types appear only in the SSE / streamable-HTTP methods' + # signatures and in narrowed attributes. Their runtime imports live inside + # those methods (and `custom_route`), so `import mcp.server.mcpserver` + # (and every stdio server) never loads starlette, sse_starlette or + # uvicorn - only building an HTTP app pays for the web stack, once. The + # session-manager annotation is spelled through the lazy `mcp.server` + # namespace instead, so `typing.get_type_hints` resolves it on demand. + from starlette.applications import Starlette + from starlette.requests import Request + from starlette.responses import Response + from starlette.routing import Route + from starlette.types import Receive, Scope, Send + logger = get_logger(__name__) _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) +@_deferred_model class Settings(BaseModel, Generic[LifespanResultT]): """MCPServer settings, as passed to the `MCPServer` constructor.""" + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + # Server settings debug: bool log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] @@ -154,8 +167,8 @@ def __init__( website_url: str | None = None, icons: list[Icon] | None = None, version: str = "", - auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, - token_verifier: TokenVerifier | None = None, + auth_server_provider: mcp.server.auth.provider.OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, + token_verifier: mcp.server.auth.provider.TokenVerifier | None = None, *, tools: list[Tool] | None = None, resources: list[Resource] | None = None, @@ -246,6 +259,9 @@ def __init__( # Create token verifier from provider if needed (backwards compatibility) if auth_server_provider and not token_verifier: + # The OAuth provider stack loads only for a server configured with one. + from mcp.server.auth.provider import ProviderTokenVerifier + self._token_verifier = ProviderTokenVerifier(auth_server_provider) self._custom_starlette_routes: list[Route] = [] @@ -297,7 +313,7 @@ def version(self) -> str: return self._lowlevel_server.version @property - def session_manager(self) -> StreamableHTTPSessionManager: + def session_manager(self) -> mcp.server.streamable_http_manager.StreamableHTTPSessionManager: """Get the StreamableHTTP session manager. This is exposed to enable advanced use cases like mounting multiple @@ -1005,6 +1021,10 @@ async def health_check(request: Request) -> Response: ``` """ + # A custom route is an HTTP feature: starlette is imported here rather + # than at module top so stdio servers never load the HTTP stack. + from starlette.routing import Route + def decorator( func: Callable[[Request], Awaitable[Response]], ) -> Callable[[Request], Awaitable[Response]]: @@ -1097,6 +1117,20 @@ def sse_app( host: str = "127.0.0.1", ) -> Starlette: """Return an instance of the SSE server app.""" + # The SSE transport stack (starlette, sse_starlette, plus this SDK's SSE + # transport and auth ASGI modules) is imported here rather than at module + # top so that stdio servers never load it: only building an SSE app + # pays for it, once. + from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.middleware.authentication import AuthenticationMiddleware + from starlette.responses import Response + from starlette.routing import Mount, Route + + from mcp.server.auth.middleware.auth_context import AuthContextMiddleware + from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware + from mcp.server.sse import SseServerTransport + # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): transport_security = TransportSecuritySettings( diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 23248707a3..454dee1ed1 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -5,7 +5,8 @@ from typing import TYPE_CHECKING, Any from mcp_types import Icon, InputRequiredResult, ToolAnnotations -from pydantic import BaseModel, Field +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, Field from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError from mcp.server.mcpserver.resolve import ( @@ -25,9 +26,13 @@ from mcp.server.mcpserver.context import Context +@_deferred_model class Tool(BaseModel): """Internal tool registration info.""" + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + fn: Callable[..., Any] = Field(exclude=True) name: str = Field(description="Name of the tool") title: str | None = Field(None, description="Human-readable title of the tool") diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..74b26b3b87 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -10,6 +10,7 @@ import anyio.to_thread import pydantic_core from mcp_types import CallToolResult, ContentBlock, InputRequiredResult, TextContent +from mcp_types._deferred import deferred_model as _deferred_model from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model from pydantic.fields import FieldInfo from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind @@ -63,6 +64,7 @@ def model_dump_one_level(self) -> dict[str, Any]: model_config = ConfigDict(arbitrary_types_allowed=True) +@_deferred_model class FuncMetadata(BaseModel): arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] output_schema: dict[str, Any] | None = None @@ -185,6 +187,8 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: model_config = ConfigDict( arbitrary_types_allowed=True, + # build the validator on first use rather than at import (import-time cost) + defer_build=True, ) diff --git a/src/mcp/server/models.py b/src/mcp/server/models.py index 6b129165a1..1086603e30 100644 --- a/src/mcp/server/models.py +++ b/src/mcp/server/models.py @@ -3,10 +3,15 @@ """ from mcp_types import Icon, ServerCapabilities -from pydantic import BaseModel +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict +@_deferred_model class InitializationOptions(BaseModel): + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + server_name: str server_version: str title: str | None = None diff --git a/src/mcp/server/request_state.py b/src/mcp/server/request_state.py index ad1abe8c36..152ad8b0ec 100644 --- a/src/mcp/server/request_state.py +++ b/src/mcp/server/request_state.py @@ -17,20 +17,21 @@ import time from collections.abc import Callable, Mapping, Sequence from dataclasses import replace -from typing import Any, NoReturn, Protocol, cast +from functools import cache +from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn, Protocol, cast -from cryptography.exceptions import InvalidTag -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.primitives.hashes import SHA256 -from cryptography.hazmat.primitives.kdf.hkdf import HKDF from mcp_types import INTERNAL_ERROR, INVALID_PARAMS from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required -from mcp.server.auth.middleware.auth_context import get_access_token -from mcp.server.auth.provider import principal_components +from mcp.server.auth.access_token import get_access_token from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.shared.exceptions import MCPError +if TYPE_CHECKING: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + from mcp.server.auth.provider import AccessToken + __all__ = [ "AESGCMRequestStateCodec", "InvalidRequestState", @@ -88,7 +89,7 @@ def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None: token = get_access_token() if token is None: return None - return compact_json(principal_components(token)) + return compact_json(_principal_components()(token)) class RequestStateSecurity: @@ -179,9 +180,47 @@ def _b64u_decode(text: str) -> bytes: return raw +class _Crypto(NamedTuple): + """The `cryptography` primitives the built-in codec uses, imported together.""" + + aesgcm: type[AESGCM] + hkdf: Any + sha256: Any + invalid_tag: type[BaseException] + + +@cache +def _crypto() -> _Crypto: + """Import (once) the AEAD/KDF primitives from `cryptography`. + + Kept off `import mcp.server*`: only a server that seals `requestState` with the + built-in codec needs them, so the ~20 modules load with the first codec + construction instead of with every server import. + """ + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.hashes import SHA256 + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + return _Crypto(AESGCM, HKDF, SHA256, InvalidTag) + + +@cache +def _principal_components() -> Callable[[AccessToken], tuple[str, str | None, str | None]]: + """Import (once) the auth stack's principal decomposition. + + Kept off `import mcp.server*`: only an authenticated transport reaches it, + on the first request that binds request state to a principal. + """ + from mcp.server.auth.provider import principal_components + + return principal_components + + def _derive_key(secret: bytes) -> bytes: """Stretch an operator secret (>= 32 bytes, any format) into the AES-256 key.""" - return HKDF(algorithm=SHA256(), length=32, salt=None, info=_KDF_INFO).derive(secret) + crypto = _crypto() + return crypto.hkdf(algorithm=crypto.sha256(), length=32, salt=None, info=_KDF_INFO).derive(secret) class AESGCMRequestStateCodec: @@ -212,6 +251,7 @@ def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None: f"keys[{i}] is {len(k)} bytes. " 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' ) + crypto = self._crypto = _crypto() self._ring: dict[bytes, AESGCM] = {} self._mint_kid = b"" for i, secret in enumerate(material): @@ -219,7 +259,7 @@ def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None: kid = hashlib.sha256(_KID_INFO + key).digest()[:_KID_LEN] if kid in self._ring: raise ValueError(f"keys[{i}] duplicates an earlier ring key") - self._ring[kid] = AESGCM(key) + self._ring[kid] = crypto.aesgcm(key) if i == 0: self._mint_kid = kid @@ -244,7 +284,7 @@ def unseal(self, token: str) -> bytes: raise InvalidRequestState("unknown key") try: return aead.decrypt(nonce, sealed, _TOKEN_PREFIX.encode() + kid) - except InvalidTag: + except self._crypto.invalid_tag: raise InvalidRequestState("seal") from None diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 4d02fc4a73..e0e5d73a6b 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -50,11 +50,9 @@ async def handle_sse(request): from starlette.responses import Response from starlette.types import Receive, Scope, Send +from mcp.server._transport_security_middleware import TransportSecurityMiddleware from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context -from mcp.server.transport_security import ( - TransportSecurityMiddleware, - TransportSecuritySettings, -) +from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._context_streams import ContextSendStream, create_context_streams from mcp.shared.message import ServerMessageMetadata, SessionMessage diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 1a4e9939a4..23f8bab2a7 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -8,10 +8,8 @@ import logging import re -from abc import ABC, abstractmethod from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager -from dataclasses import dataclass from functools import partial from http import HTTPStatus from typing import Any, Final @@ -40,7 +38,15 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send -from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings +from mcp.server._transport_security_middleware import TransportSecurityMiddleware + +# Re-exported: the resumability contract's documented home is this module. +from mcp.server.event_store import EventCallback as EventCallback +from mcp.server.event_store import EventId as EventId +from mcp.server.event_store import EventMessage as EventMessage +from mcp.server.event_store import EventStore as EventStore +from mcp.server.event_store import StreamId as StreamId +from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER @@ -76,9 +82,9 @@ # Pattern ensures entire string contains only valid characters by using ^ and $ anchors SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$") -# Type aliases -StreamId = str -EventId = str +# The resumability contract (`EventStore` and friends) lives in the +# transport-agnostic `mcp.server.event_store`; every name stays importable +# from this module too. # An SSE event-dict as accepted by sse-starlette (`event`, `data`, `id`, `retry`). SSEEvent = dict[str, Any] @@ -101,51 +107,6 @@ def check_accept_headers(request: Request) -> tuple[bool, bool]: return has_json, has_sse -@dataclass -class EventMessage: - """A JSONRPCMessage with an optional event ID for stream resumability.""" - - message: JSONRPCMessage - event_id: str | None = None - - -EventCallback = Callable[[EventMessage], Awaitable[None]] - - -class EventStore(ABC): - """Interface for resumability support via event storage.""" - - @abstractmethod - async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """Stores an event for later retrieval. - - Args: - stream_id: ID of the stream the event belongs to - message: The JSON-RPC message to store, or None for priming events - - Returns: - The generated event ID for the stored event. - """ - pass # pragma: no cover - - @abstractmethod - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - """Replays events that occurred after the specified event ID. - - Args: - last_event_id: The ID of the last event the client received - send_callback: A callback function to send events to the client - - Returns: - The stream ID of the replayed events, or None if no events were found. - """ - pass # pragma: no cover - - class StreamableHTTPServerTransport: """HTTP server transport with event streaming support for MCP. diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 31f587ee66..3a4ddb87b6 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -6,7 +6,7 @@ import logging from collections import deque from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any from uuid import uuid4 import anyio @@ -18,6 +18,7 @@ from starlette.responses import Response from starlette.types import ASGIApp, Message, Receive, Scope, Send +from mcp.server._http_defaults import DEFAULT_MAX_REQUEST_BODY_SIZE as DEFAULT_MAX_REQUEST_BODY_SIZE from mcp.server._streamable_http_modern import handle_modern_request from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.connection import Connection @@ -34,9 +35,6 @@ logger = logging.getLogger(__name__) -DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024 -"""Default maximum Streamable HTTP request body size in bytes (4 MiB).""" - class StreamableHTTPSessionManager: """Manages StreamableHTTP sessions with optional resumability via event store. diff --git a/src/mcp/server/transport_security.py b/src/mcp/server/transport_security.py index d9e9f965b3..e56ae205c4 100644 --- a/src/mcp/server/transport_security.py +++ b/src/mcp/server/transport_security.py @@ -1,21 +1,38 @@ -"""DNS rebinding protection for MCP server transports.""" +"""DNS rebinding protection settings for MCP server transports. -import logging +This module defines only the (web-framework-free) `TransportSecuritySettings` +model, so servers and applications can name and construct the settings +without loading starlette. The `TransportSecurityMiddleware` that enforces them +lives in `mcp.server._transport_security_middleware` (starlette-based) and stays +importable from here. +""" -from pydantic import BaseModel, Field -from starlette.requests import Request -from starlette.responses import Response +from typing import TYPE_CHECKING -logger = logging.getLogger(__name__) +from mcp_types._deferred import deferred_model as _deferred_model +from pydantic import BaseModel, ConfigDict, Field + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +if TYPE_CHECKING: + from mcp.server._transport_security_middleware import ( + TransportSecurityMiddleware as TransportSecurityMiddleware, + ) + +__all__ = ["TransportSecurityMiddleware", "TransportSecuritySettings"] # TODO(Marcelo): We should flatten these settings. To be fair, I don't think we should even have this middleware. +@_deferred_model class TransportSecuritySettings(BaseModel): """Settings for MCP transport security features. These settings help protect against DNS rebinding attacks by validating incoming request headers. """ + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + enable_dns_rebinding_protection: bool = True """Enable DNS rebinding protection (recommended for production).""" @@ -32,85 +49,18 @@ class TransportSecuritySettings(BaseModel): """ -# TODO(Marcelo): This should be a proper ASGI middleware. I'm sad to see this. -class TransportSecurityMiddleware: - """Middleware to enforce DNS rebinding protection for MCP transport endpoints.""" - - def __init__(self, settings: TransportSecuritySettings | None = None): - # If not specified, disable DNS rebinding protection by default for backwards compatibility - self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False) - - def _validate_host(self, host: str | None) -> bool: - """Validate the Host header against allowed values.""" - if not host: - logger.warning("Missing Host header in request") - return False - - # Check exact match first - if host in self.settings.allowed_hosts: - return True - - # Check wildcard port patterns - for allowed in self.settings.allowed_hosts: - if allowed.endswith(":*"): - # Extract base host from pattern - base_host = allowed[:-2] - # Check if the actual host starts with base host and has a port - if host.startswith(base_host + ":"): - return True - - logger.warning(f"Invalid Host header: {host}") - return False - - def _validate_origin(self, origin: str | None) -> bool: - """Validate the Origin header against allowed values.""" - # Origin can be absent for same-origin requests - if not origin: - return True - - # Check exact match first - if origin in self.settings.allowed_origins: - return True - - # Check wildcard port patterns - for allowed in self.settings.allowed_origins: - if allowed.endswith(":*"): - # Extract base origin from pattern - base_origin = allowed[:-2] - # Check if the actual origin starts with base origin and has a port - if origin.startswith(base_origin + ":"): - return True - - logger.warning(f"Invalid Origin header: {origin}") - return False - - def _validate_content_type(self, content_type: str | None) -> bool: - """Validate the Content-Type header for POST requests.""" - return content_type is not None and content_type.lower().startswith("application/json") - - async def validate_request(self, request: Request, is_post: bool = False) -> Response | None: - """Validate request headers for DNS rebinding protection. - - Returns None if validation passes, or an error Response if validation fails. - """ - # Always validate Content-Type for POST requests - if is_post: - content_type = request.headers.get("content-type") - if not self._validate_content_type(content_type): - return Response("Invalid Content-Type header", status_code=400) - - # Skip remaining validation if DNS rebinding protection is disabled - if not self.settings.enable_dns_rebinding_protection: - return None - - # Validate Host header - host = request.headers.get("host") - if not self._validate_host(host): - return Response("Invalid Host header", status_code=421) - - # Validate Origin header - origin = request.headers.get("origin") - if not self._validate_origin(origin): - return Response("Invalid Origin header", status_code=403) - - return None +# `TransportSecurityMiddleware` is the starlette-based enforcement of the +# settings above; resolving it lazily keeps this module (imported by every +# server, including stdio) free of the web stack. Runtime only, so the +# TYPE_CHECKING import above is what types the name. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs( + __name__, + globals(), + exports={ + "TransportSecurityMiddleware": ( + "mcp.server._transport_security_middleware", + "TransportSecurityMiddleware", + ) + }, + ) diff --git a/src/mcp/shared/__init__.py b/src/mcp/shared/__init__.py index e69de29bb2..0d4e59a19d 100644 --- a/src/mcp/shared/__init__.py +++ b/src/mcp/shared/__init__.py @@ -0,0 +1,9 @@ +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +# `mcp.shared.` (exceptions, memory, message, ...) resolves by +# attribute access even before that submodule was imported. Runtime only, +# so a type checker still flags a misspelled `mcp.shared.`. +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals()) diff --git a/src/mcp/shared/_lazy.py b/src/mcp/shared/_lazy.py new file mode 100644 index 0000000000..3a93d73c3f --- /dev/null +++ b/src/mcp/shared/_lazy.py @@ -0,0 +1,112 @@ +"""Lazy module attributes (PEP 562) shared by the SDK's package inits. + +`import mcp` no longer walks the SDK's whole module graph; each package instead +resolves what it exports the first time it is asked for it. `lazy_module_attrs` +builds the `__getattr__`/`__dir__` pair for such a module from two kinds of +lazy names: + +* `exports` - a re-exported object, described as `("home.module", "attr")` + or as a zero-argument loader, imported/loaded on first access and then + cached in the module namespace, so every later lookup is a plain attribute + read; +* `submodules` - the package's own submodules, imported when the attribute + chain reaches them (`import mcp` then `mcp.client.stdio` used to work as a + side effect of eager imports; this keeps such chains resolving). Pass the + set explicitly, or `None` to use the package's real submodules (listed + once, on first need). + +A name that is neither raises `AttributeError` immediately: no filesystem +search and no speculative import on a miss. Callers bind the pair under +`if not TYPE_CHECKING:` so a static type checker never sees a module-level +`__getattr__` (it would type every misspelled attribute as `object` instead of +reporting it) while the mirrored `TYPE_CHECKING` imports keep the real names +typed. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Mapping +from importlib import import_module + +__all__ = ["lazy_module_attrs"] + +Loader = tuple[str, str] | Callable[[], object] +"""How a lazy export is produced: `("home.module", "attr")` or a zero-arg loader.""" + +_HELPER_NAMES = frozenset({"__getattr__", "__dir__"}) +"""The two functions this helper adds to a module; never reported by its `__dir__`.""" + +DEFAULT_HIDDEN = frozenset({"TYPE_CHECKING", "_lazy_module_attrs"}) +"""The conventional machinery names a caller binds to install the pair (hidden from `dir()`).""" + + +def lazy_module_attrs( + module_name: str, + module_globals: dict[str, object], + *, + exports: Mapping[str, Loader] | None = None, + submodules: frozenset[str] | None = None, + hidden: frozenset[str] = DEFAULT_HIDDEN, +) -> tuple[Callable[[str], object], Callable[[], list[str]]]: + """Build a module's `__getattr__` and `__dir__` for lazily-resolved names. + + Args: + module_name: The `__name__` of the module the pair is installed on. + module_globals: That module's `globals()`; resolved exports are cached + in it, so `__getattr__` runs at most once per name. + exports: Lazy re-exports, name -> `("home.module", "attr")` or a + zero-argument loader. Resolved on first access. + submodules: Submodule names reachable as attributes without an + explicit import. `None` (the default) means the package's real + submodules, discovered once on first need; a frozenset (possibly + empty) means exactly those. + hidden: Namespace entries that are lazy-loading machinery and must not + appear in `dir(module)` (defaults to the two conventional binding + names, `TYPE_CHECKING` and `_lazy_module_attrs`). + + Returns: + The `(__getattr__, __dir__)` pair to bind in the module namespace. + """ + export_map: Mapping[str, Loader] = exports or {} + known_submodules = submodules + + def submodule_names() -> frozenset[str]: + # An explicit set, or the real submodules of the package (one directory + # listing, cached; dunder entries such as `__main__` are not attributes). + # A plain module (no `__path__`) has no submodules. + nonlocal known_submodules + if known_submodules is None: + # pkgutil is imported for the one listing rather than at module top, + # keeping this helper (and so a bare `import mcp`) as light as possible. + import pkgutil + + search_path = getattr(sys.modules[module_name], "__path__", None) + known_submodules = frozenset( + info.name + for info in (pkgutil.iter_modules(search_path) if search_path else ()) + if not info.name.startswith("__") + ) + return known_submodules + + def __getattr__(name: str) -> object: + loader = export_map.get(name) + if loader is not None: + if callable(loader): + value = loader() + else: + home_module, attr = loader + value = getattr(import_module(home_module), attr) + elif not name.startswith("__") and name in submodule_names(): + # The import system also binds the submodule on the package, so + # this too runs at most once per name. + value = import_module(f"{module_name}.{name}") + else: + raise AttributeError(f"module {module_name!r} has no attribute {name!r}") + module_globals[name] = value # cache: later accesses never reach __getattr__ + return value + + def __dir__() -> list[str]: + return sorted((module_globals.keys() - hidden - _HELPER_NAMES) | export_map.keys() | submodule_names()) + + return __getattr__, __dir__ diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index b7b05b11ab..3504bf82b1 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -1,33 +1,93 @@ -"""OpenTelemetry helpers for MCP.""" +"""OpenTelemetry helpers for MCP. + +`opentelemetry-api` is a hard dependency, but it is imported here on the first +span rather than at module import: every SDK entry point (client and server, +over every transport) routes messages through this module, and a tracer that is +a no-op until an exporter is installed should not add its import cost to +`import mcp` or to a process that never emits a span. The API modules and the +tracer are resolved once and cached in module globals; nothing is re-imported +per message. +""" from __future__ import annotations from collections.abc import Generator, Mapping from contextlib import contextmanager -from typing import Any +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from opentelemetry.context import Context + from opentelemetry.trace import SpanKind, Tracer + from opentelemetry.trace.span import Span + +SpanKindName = Literal["client", "server"] +"""Name of an `opentelemetry.trace.SpanKind` member, resolved once the API loads. + +Callers pass the name (or a `SpanKind` member directly) so they need not import +opentelemetry themselves. +""" -from opentelemetry.context import Context -from opentelemetry.propagate import extract, inject -from opentelemetry.trace import SpanKind, get_current_span, get_tracer -from opentelemetry.trace.span import Span +# The tracer used for every MCP span: `None` until the first span, then the +# tracer OpenTelemetry late-binds to the global provider (so an exporter +# configured after import still lights spans up). Assigning it directly is +# the seam tests use to scope spans to a capturing provider. +_tracer: Tracer | None = None -_tracer = get_tracer("mcp-python-sdk") +# The `opentelemetry.trace` / `opentelemetry.propagate` modules, imported +# together on first use by `_load` (typed `Any`: they are modules). +_trace: Any = None +_propagate: Any = None + + +def _load() -> Any: + """Import the OpenTelemetry API, cache it, and return `opentelemetry.trace`. + + Deferred to first use purely for import time (see the module docstring); + every later call is served from the module-level cache by `_api`. + """ + global _trace, _propagate + from opentelemetry import propagate, trace + + _propagate = propagate + _trace = trace + return trace + + +def _api() -> Any: + """The `opentelemetry.trace` module, importing the OTel API on first use.""" + return _trace if _trace is not None else _load() + + +def _get_tracer() -> Tracer: + """The MCP tracer, created from the global provider on the first span.""" + global _tracer + tracer = _tracer + if tracer is None: + tracer = _tracer = _api().get_tracer("mcp-python-sdk") + return tracer @contextmanager def otel_span( name: str, *, - kind: SpanKind, + kind: SpanKindName | SpanKind, attributes: dict[str, Any] | None = None, context: Context | None = None, record_exception: bool = True, set_status_on_exception: bool = True, ) -> Generator[Span]: """Create an OTel span.""" - with _tracer.start_as_current_span( + tracer = _get_tracer() + if kind == "client": + span_kind: SpanKind = _api().SpanKind.CLIENT + elif kind == "server": + span_kind = _api().SpanKind.SERVER + else: + span_kind = kind + with tracer.start_as_current_span( name, - kind=kind, + kind=span_kind, attributes=attributes, context=context, record_exception=record_exception, @@ -36,9 +96,15 @@ def otel_span( yield span +def set_span_error(span: Span, description: str | None = None) -> None: + """Mark `span` as errored (`StatusCode.ERROR`), with an optional description.""" + span.set_status(_api().StatusCode.ERROR, description) + + def inject_trace_context(meta: dict[str, Any]) -> None: """Inject W3C trace context (traceparent/tracestate) into a `_meta` dict.""" - inject(meta) + _api() + _propagate.inject(meta) def extract_trace_context(meta: Mapping[str, Any] | None) -> Context | None: @@ -51,10 +117,11 @@ def extract_trace_context(meta: Mapping[str, Any] | None) -> Context | None: """ if not meta: return None + trace = _api() try: - ctx = extract(meta) + ctx = _propagate.extract(meta) except (ValueError, TypeError): return None - if not get_current_span(ctx).get_span_context().is_valid: + if not trace.get_current_span(ctx).get_span_context().is_valid: return None return ctx diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 881379d381..f2c1768ce1 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,5 +1,6 @@ from typing import Any, Literal, cast +from mcp_types._deferred import deferred_model as _deferred_model from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator # RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG. @@ -23,9 +24,13 @@ def _empty_str_to_none(v: object) -> object: return v +@_deferred_model class OAuthToken(BaseModel): """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1""" + # defer_build: build the validator on first use rather than at import (import-time cost). + model_config = ConfigDict(defer_build=True) + access_token: str token_type: Literal["Bearer"] = "Bearer" expires_in: int | None = None @@ -42,6 +47,7 @@ def normalize_token_type(cls, v: str | None) -> str | None: return v # pragma: no cover +@_deferred_model class AuthorizationCodeResult(BaseModel): """Authorization-code-grant redirect parameters returned by a callback handler. @@ -49,6 +55,8 @@ class AuthorizationCodeResult(BaseModel): includes it in the redirect; the client validates it against the expected issuer. """ + model_config = ConfigDict(defer_build=True) + code: str state: str | None = None iss: str | None = None @@ -64,6 +72,7 @@ def __init__(self, message: str): self.message = message +@_deferred_model class OAuthClientMetadataBase(BaseModel): """RFC 7591 OAuth 2.0 Dynamic Client Registration metadata shared verbatim by the registration request (`OAuthClientMetadata`) and the authorization server's record of a @@ -73,7 +82,7 @@ class OAuthClientMetadataBase(BaseModel): See https://datatracker.ietf.org/doc/html/rfc7591#section-2 """ - model_config = ConfigDict(url_preserve_empty_path=True) + model_config = ConfigDict(url_preserve_empty_path=True, defer_build=True) # The MCP spec requires the "code" response type, but OAuth # servers may also return additional types they support @@ -198,12 +207,13 @@ def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: ) +@_deferred_model class OAuthMetadata(BaseModel): """RFC 8414 OAuth 2.0 Authorization Server Metadata. See https://datatracker.ietf.org/doc/html/rfc8414#section-2 """ - model_config = ConfigDict(url_preserve_empty_path=True) + model_config = ConfigDict(url_preserve_empty_path=True, defer_build=True) issuer: AnyHttpUrl authorization_endpoint: AnyHttpUrl @@ -233,12 +243,13 @@ class OAuthMetadata(BaseModel): authorization_grant_profiles_supported: list[str] | None = None +@_deferred_model class ProtectedResourceMetadata(BaseModel): """RFC 9728 OAuth 2.0 Protected Resource Metadata. See https://datatracker.ietf.org/doc/html/rfc9728#section-2 """ - model_config = ConfigDict(url_preserve_empty_path=True) + model_config = ConfigDict(url_preserve_empty_path=True, defer_build=True) resource: AnyHttpUrl authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 87bdf31ceb..1dd4efd621 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -32,7 +32,6 @@ ProgressToken, RequestId, ) -from opentelemetry.trace import SpanKind from pydantic import ValidationError from typing_extensions import TypeVar @@ -383,7 +382,7 @@ async def send_raw_request( try: with otel_span( span_name, - kind=SpanKind.CLIENT, + kind="client", attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)}, ): # SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer. diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 697383e8ae..7a83af424e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextvars +import typing from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager, contextmanager from unittest.mock import patch @@ -397,11 +398,25 @@ async def test_complete_with_prompt_reference(simple_server: Server): def test_client_with_url_initializes_streamable_http_transport(): - with patch("mcp.client.client.streamable_http_client") as mock: + # `Client` imports the streamable-HTTP transport lazily (at construction of the + # first URL client) so a stdio-only client never loads httpx2; patch its source. + with patch("mcp.client.streamable_http.streamable_http_client") as mock: _ = Client("http://localhost:8000/mcp") mock.assert_called_once_with("http://localhost:8000/mcp") +def test_client_annotations_still_evaluate_at_runtime(): + """SDK-defined: `typing.get_type_hints` on `Client` resolves even though the server stack + is a typing-only import in the client module; the `server` annotation is qualified + through the lazy `mcp.server` namespace, which imports it only when the hints are + evaluated. Probed via `__init__` because class-level `get_type_hints(Client)` trips a + CPython 3.10 bug with `KW_ONLY` dataclasses (fixed in 3.11), unrelated to the SDK.""" + hints = typing.get_type_hints(Client.__init__) + server_targets = typing.get_args(hints["server"]) + assert MCPServer in server_targets + assert str in server_targets + + async def test_client_uses_transport_directly(app: MCPServer): transport = InMemoryTransport(app) async with Client(transport, mode="legacy") as client: diff --git a/tests/docs_src/test_import_cost.py b/tests/docs_src/test_import_cost.py new file mode 100644 index 0000000000..e8e72ddaf8 --- /dev/null +++ b/tests/docs_src/test_import_cost.py @@ -0,0 +1,46 @@ +"""`docs/advanced/import-cost.md`: the prewarm recipe does what the page says.""" + +import json +import os +import pathlib +import subprocess +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] + +# The recipe's effect is only observable in a fresh interpreter: this process has long +# since loaded the wire packages and built the models. +PROBE = """ +import json, sys +import mcp_types + +before = { + "wire_2025_loaded": "mcp_types._v2025_11_25" in sys.modules, + "tool_built": mcp_types.Tool.__pydantic_complete__, +} +import docs_src.import_cost.tutorial001 # the recipe under test +after = { + "wire_2025_loaded": "mcp_types._v2025_11_25" in sys.modules, + "tool_built": mcp_types.Tool.__pydantic_complete__, +} +print(json.dumps({"before": before, "after": after})) +""" + + +def test_prewarm_recipe_loads_the_wire_package_and_builds_the_models() -> None: + """tutorial001: before the recipe nothing is loaded/built; after `mcp.warm(version)` the + named version's wire package is imported and the models are built.""" + result = subprocess.run( + [sys.executable, "-c", PROBE], + capture_output=True, + text=True, + check=False, + timeout=60, + cwd=REPO_ROOT, # the recipe module is imported by its docs_src path + # pydantic plugins (e.g. logfire) building models is the environment's cost, not ours + env={**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"}, + ) + assert result.returncode == 0, result.stderr + observed = json.loads(result.stdout.strip().splitlines()[-1]) + assert observed["before"] == {"wire_2025_loaded": False, "tool_built": False} + assert observed["after"] == {"wire_2025_loaded": True, "tool_built": True} diff --git a/tests/server/mcpserver/resources/test_http_resource.py b/tests/server/mcpserver/resources/test_http_resource.py new file mode 100644 index 0000000000..a32788c79a --- /dev/null +++ b/tests/server/mcpserver/resources/test_http_resource.py @@ -0,0 +1,37 @@ +from collections.abc import Callable + +import httpx2 +import pytest + +from mcp.server.mcpserver.resources import HttpResource + +pytestmark = pytest.mark.anyio + + +def mock_client_factory(handler: Callable[[httpx2.Request], httpx2.Response]) -> Callable[[], httpx2.AsyncClient]: + """An `httpx2.AsyncClient` stand-in whose requests are answered by `handler`.""" + real_client = httpx2.AsyncClient + + def factory() -> httpx2.AsyncClient: + return real_client(transport=httpx2.MockTransport(handler)) + + return factory + + +async def test_http_resource_reads_the_response_body_as_text(monkeypatch: pytest.MonkeyPatch): + """`HttpResource.read()` fetches its URL and returns the response text.""" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, text=f"body of {request.url}") + + monkeypatch.setattr(httpx2, "AsyncClient", mock_client_factory(handler)) + resource = HttpResource(uri="resource://remote", url="http://example.test/data") + assert await resource.read() == "body of http://example.test/data" + + +async def test_http_resource_read_raises_for_error_status(monkeypatch: pytest.MonkeyPatch): + """A non-2xx response surfaces as `httpx2.HTTPStatusError` rather than being read as content.""" + monkeypatch.setattr(httpx2, "AsyncClient", mock_client_factory(lambda request: httpx2.Response(404))) + resource = HttpResource(uri="resource://remote", url="http://example.test/missing") + with pytest.raises(httpx2.HTTPStatusError): + await resource.read() diff --git a/tests/server/test_elicitation.py b/tests/server/test_elicitation.py new file mode 100644 index 0000000000..ece9cc8df5 --- /dev/null +++ b/tests/server/test_elicitation.py @@ -0,0 +1,52 @@ +"""`mcp.server.elicitation` module-level behaviour.""" + +import builtins +from collections.abc import Mapping, Sequence +from types import ModuleType +from unittest.mock import patch + +import mcp_types._v2025_11_25 as wire +from pydantic import BaseModel + +from mcp.server import elicitation +from mcp.server.elicitation import render_elicitation_schema + + +def test_wire_schema_gate_type_is_still_reachable_on_the_module(): + """`PrimitiveSchemaDefinition` used to be bound here by a module-level import; it now + resolves lazily to the same class, and `dir()` still lists it.""" + assert elicitation.PrimitiveSchemaDefinition is wire.PrimitiveSchemaDefinition + assert "PrimitiveSchemaDefinition" in dir(elicitation) + + +def test_rendering_schemas_repeatedly_executes_no_further_imports(): + """The wire-schema gate type is resolved once; rendering more schemas afterwards never + runs another import statement (the import cost is a one-time first-use bill, not a + per-call one).""" + + class Prompt(BaseModel): + name: str + age: int = 0 + + render_elicitation_schema(Prompt) # warm-up: pays the one-time wire-package import + + real_import = builtins.__import__ + import_calls: list[str] = [] + + def counting_import( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: Sequence[str] = (), + level: int = 0, + ) -> ModuleType: + import_calls.append(name) + return real_import(name, globals, locals, fromlist, level) + + with patch.object(builtins, "__import__", counting_import): + for _ in range(20): + render_elicitation_schema(Prompt) + + # pydantic runs its own function-level imports while generating a schema; the SDK's + # gate (the wire package) must not add any of ours. + assert [name for name in import_calls if name.startswith("mcp")] == [] diff --git a/tests/server/test_type_hints.py b/tests/server/test_type_hints.py new file mode 100644 index 0000000000..571ee50d4f --- /dev/null +++ b/tests/server/test_type_hints.py @@ -0,0 +1,63 @@ +"""Public server APIs keep runtime-evaluable type hints. + +The HTTP transport and auth stacks load lazily (see AGENTS.md "Import cost"), so their +types are not module globals of the server modules. Every SDK-owned type a public +signature names is instead spelled so that `typing.get_type_hints()` can still resolve +it at runtime: through the web-framework-free `mcp.server.event_store` / +`mcp.server.transport_security` modules, or through the lazy `mcp.server` namespace. +Only the starlette-owned annotations of the HTTP app builders (`Starlette`, `Route`) +stay typing-only, which `docs/migration.md` documents. +""" + +import sys +import typing + +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.event_store import EventStore +from mcp.server.lowlevel import Server +from mcp.server.mcpserver import MCPServer +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.transport_security import TransportSecuritySettings + + +def test_lazily_loaded_sdk_types_resolve_in_public_method_hints(): + """SDK-defined: the event-store, transport-security, auth and session-manager + annotations evaluate at runtime to the very classes users import.""" + run_http = typing.get_type_hints(MCPServer.run_streamable_http_async) + assert typing.get_args(run_http["event_store"])[0] is EventStore + assert typing.get_args(run_http["transport_security"])[0] is TransportSecuritySettings + run_sse = typing.get_type_hints(MCPServer.run_sse_async) + assert typing.get_args(run_sse["transport_security"])[0] is TransportSecuritySettings + + init = typing.get_type_hints(MCPServer.__init__) + assert typing.get_args(init["auth"])[0] is AuthSettings + assert typing.get_args(init["token_verifier"])[0] is TokenVerifier + assert typing.get_origin(typing.get_args(init["auth_server_provider"])[0]) is OAuthAuthorizationServerProvider + + for cls in (Server, MCPServer): + prop = vars(cls)["session_manager"] + assert isinstance(prop, property) + assert typing.get_type_hints(prop.fget)["return"] is StreamableHTTPSessionManager + + +def test_sdk_owned_names_in_the_app_builder_signatures_still_evaluate(): + """SDK-defined: in the app builders (whose full hints are typing-only because of the + starlette return/route types), every SDK-owned parameter annotation still evaluates + against the module namespace, exactly as `get_type_hints` would evaluate it.""" + method = Server.streamable_http_app + namespace = vars(sys.modules[method.__module__]) + + def _probe() -> None: + """Carries the SDK-owned subset of the app builder's annotations.""" + + _probe.__annotations__ = { + name: method.__annotations__[name] + for name in ("event_store", "transport_security", "auth", "token_verifier", "auth_server_provider") + } + resolved = typing.get_type_hints(_probe, globalns=namespace) + assert typing.get_args(resolved["event_store"])[0] is EventStore + assert typing.get_args(resolved["transport_security"])[0] is TransportSecuritySettings + assert typing.get_args(resolved["auth"])[0] is AuthSettings + assert typing.get_args(resolved["token_verifier"])[0] is TokenVerifier + assert typing.get_origin(typing.get_args(resolved["auth_server_provider"])[0]) is OAuthAuthorizationServerProvider diff --git a/tests/shared/lazy_submodule_fixture/__init__.py b/tests/shared/lazy_submodule_fixture/__init__.py new file mode 100644 index 0000000000..b938c9bfb4 --- /dev/null +++ b/tests/shared/lazy_submodule_fixture/__init__.py @@ -0,0 +1,22 @@ +"""A package that installs the lazy-attribute helper, for tests/shared/test_lazy.py.""" + +from typing import TYPE_CHECKING + +from mcp.shared._lazy import lazy_module_attrs as _lazy_module_attrs + +load_calls: int = 0 + +if TYPE_CHECKING: + # Type checkers see the lazy export as a plain module attribute. + ANSWER: int + + +def _load_answer() -> int: + """A lazily-resolved re-export: a zero-argument loader that must run at most once.""" + global load_calls + load_calls += 1 + return 42 + + +if not TYPE_CHECKING: + __getattr__, __dir__ = _lazy_module_attrs(__name__, globals(), exports={"ANSWER": _load_answer}) diff --git a/tests/shared/lazy_submodule_fixture/broken.py b/tests/shared/lazy_submodule_fixture/broken.py new file mode 100644 index 0000000000..4e88bfce60 --- /dev/null +++ b/tests/shared/lazy_submodule_fixture/broken.py @@ -0,0 +1,5 @@ +"""A submodule whose own import fails on a missing dependency.""" + +from importlib import import_module + +MARKER = import_module("definitely_not_installed_dependency_xyz") diff --git a/tests/shared/lazy_submodule_fixture/fine.py b/tests/shared/lazy_submodule_fixture/fine.py new file mode 100644 index 0000000000..8199f542af --- /dev/null +++ b/tests/shared/lazy_submodule_fixture/fine.py @@ -0,0 +1,3 @@ +"""An importable submodule.""" + +MARKER = "ok" diff --git a/tests/shared/test_lazy.py b/tests/shared/test_lazy.py new file mode 100644 index 0000000000..0b47068637 --- /dev/null +++ b/tests/shared/test_lazy.py @@ -0,0 +1,78 @@ +"""`mcp.shared._lazy`: lazy exports and submodules resolved on attribute access.""" + +import subprocess +import sys +from types import ModuleType + +import pytest +from inline_snapshot import snapshot + +import tests.shared.lazy_submodule_fixture as fixture_pkg + + +def test_package_attribute_imports_the_submodule_of_that_name(): + """SDK-defined: `pkg.` imports `pkg.` on first touch and binds it on the package.""" + submodule = getattr(fixture_pkg, "fine") + assert isinstance(submodule, ModuleType) + assert submodule.MARKER == "ok" + assert submodule is sys.modules["tests.shared.lazy_submodule_fixture.fine"] + assert vars(fixture_pkg)["fine"] is submodule + + +def test_lazy_export_is_loaded_once_and_cached_in_the_namespace(): + """SDK-defined: a lazy export runs its loader on first access, then reads as a plain attribute.""" + assert fixture_pkg.ANSWER == 42 + assert fixture_pkg.ANSWER == 42 + assert fixture_pkg.load_calls == 1 + assert vars(fixture_pkg)["ANSWER"] == 42 + + +def test_dir_lists_exports_and_submodules_but_not_the_lazy_machinery(): + """SDK-defined: `dir(pkg)` reports the lazy exports and real submodules, and hides the + helper's own bindings so the namespace looks the way an eager module would.""" + listing = dir(fixture_pkg) + assert {"ANSWER", "fine", "broken", "load_calls"} <= set(listing) + assert {"__getattr__", "__dir__", "_lazy_module_attrs"}.isdisjoint(listing) + + +def test_missing_name_raises_attribute_error_without_importing_anything(): + """SDK-defined: a name that is neither an export nor a real submodule is a plain + AttributeError; nothing is speculatively imported for it.""" + with pytest.raises(AttributeError): + getattr(fixture_pkg, "no_such_submodule") + assert "tests.shared.lazy_submodule_fixture.no_such_submodule" not in sys.modules + + +def test_dunder_probe_raises_attribute_error_without_a_submodule_search(): + """SDK-defined: dunder names are protocol probes, never lazy names.""" + with pytest.raises(AttributeError): + getattr(fixture_pkg, "__no_such_dunder__") + + +def test_submodule_with_a_missing_dependency_surfaces_the_real_import_error(): + """SDK-defined: an existing submodule that fails to import raises its own error, not AttributeError.""" + with pytest.raises(ModuleNotFoundError) as exc_info: + getattr(fixture_pkg, "broken") + assert exc_info.value.name == "definitely_not_installed_dependency_xyz" + + +def test_bare_import_mcp_still_resolves_deep_submodule_chains(): + """SDK-defined: after only `import mcp`, `mcp.client.stdio.stdio_client` etc. still resolve, + and each package's `dir()` lists submodules that were never imported explicitly. + + A fresh interpreter is required: this process already imported those modules. + """ + probe = ( + "import mcp\n" + "print([mcp.client.stdio.stdio_client.__name__, mcp.server.stdio.stdio_server.__name__,\n" + " mcp.shared.memory.__name__, mcp.os.posix.utilities.__name__,\n" + " mcp.os.win32.utilities.__name__, mcp.server.auth.handlers.token.__name__,\n" + " mcp.server.auth.middleware.auth_context.__name__,\n" + " 'stdio' in dir(mcp.client), 'stdio' in dir(mcp.server), 'exceptions' in dir(mcp.shared)])\n" + ) + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, check=False, timeout=20) + assert result.returncode == 0, result.stderr + assert result.stdout == snapshot("""\ +['stdio_client', 'stdio_server', 'mcp.shared.memory', 'mcp.os.posix.utilities', 'mcp.os.win32.utilities', \ +'mcp.server.auth.handlers.token', 'mcp.server.auth.middleware.auth_context', True, True, True] +""") diff --git a/tests/test_import_guards.py b/tests/test_import_guards.py new file mode 100644 index 0000000000..569db1b87a --- /dev/null +++ b/tests/test_import_guards.py @@ -0,0 +1,174 @@ +"""Import-cost ratchet: pins which heavy dependencies each entry point may load. + +Every check runs in a fresh interpreter - this process has long since imported the whole +SDK, so its own `sys.modules` proves nothing - and asserts on the module footprint of a +single import. A hoisted or newly-eager import that regresses one of these promises fails +here instead of silently making startup slower for every downstream package. + +The invariants (see also the "Import cost" section of AGENTS.md and docs/advanced/import-cost.md): + +* `import mcp` is a lazy namespace: it loads no pydantic, no `mcp_types` and no `mcp` + submodule; each `mcp.` resolves its home module on first access. +* Client entry points never load the server stack, and the HTTP client stack + (httpx2) loads only for the HTTP transports; a stdio-only client pays for neither. +* Transport-agnostic server entry points (`mcp.server`, the lowlevel `Server`, + `MCPServer`, the stdio transport) never load the web stack (starlette, + sse_starlette, uvicorn), the OpenTelemetry API or an HTTP client; those load when an + HTTP app is built / a span is emitted / an `HttpResource` is read. +* Every documented module imports as the FIRST import of a fresh interpreter, without + warnings (the lazy resolution must never depend on import order). +""" + +from __future__ import annotations + +import json +import os +import pkgutil +import subprocess +import sys + +import mcp_types +import pytest + +import mcp + +# pydantic auto-loads every installed pydantic plugin (e.g. logfire, which imports +# opentelemetry) when the first model class is created. That is the environment's cost, +# not the SDK's, so the child interpreters run with plugins disabled to measure the +# SDK's own import graph. +CHILD_ENV = {**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"} + +# Web/HTTP server-side stack that only HTTP transports may load. +HTTP_SERVER_STACK = ("starlette", "sse_starlette", "uvicorn", "python_multipart") + +# Everything a client entry point must never load: the server side of the SDK, the web +# stack, and the auth/telemetry dependencies that only server-side code paths use. +CLIENT_BANNED = ("mcp.server", *HTTP_SERVER_STACK, "opentelemetry", "cryptography", "jwt") + +# Everything a transport-agnostic server entry point must never load. `cryptography` backs +# the built-in request-state codec and loads with the first codec construction (`MCPServer()` +# builds one), never at import. +SERVER_BANNED = (*HTTP_SERVER_STACK, "opentelemetry", "httpx2", "jwt", "cryptography") + +# Per-version wire-schema packages: loaded only when that protocol version is used. +WIRE_PACKAGES = ("mcp_types._v2025_11_25", "mcp_types._v2026_07_28") + +IMPORT_GUARDS: list[tuple[str, tuple[str, ...]]] = [ + ("import mcp.types", (*CLIENT_BANNED, "httpx2", *WIRE_PACKAGES)), + ("import mcp_types", (*CLIENT_BANNED, "httpx2", *WIRE_PACKAGES)), + ("import mcp_types.methods", (*CLIENT_BANNED, "httpx2", *WIRE_PACKAGES)), + ("from mcp.types import Tool", (*CLIENT_BANNED, "httpx2", *WIRE_PACKAGES)), + ("import mcp.client", (*CLIENT_BANNED, "httpx2")), + ("import mcp.client.stdio", (*CLIENT_BANNED, "httpx2")), + ("import mcp.client.session", (*CLIENT_BANNED, "httpx2")), + ("from mcp import Client", (*CLIENT_BANNED, "httpx2")), + ("from mcp import ClientSession", (*CLIENT_BANNED, "httpx2")), + # The HTTP client transports are the one place httpx2 belongs. + ("import mcp.client.streamable_http", CLIENT_BANNED), + ("import mcp.client.sse", CLIENT_BANNED), + ("import mcp.server", SERVER_BANNED), + ("import mcp.server.lowlevel", SERVER_BANNED), + ("import mcp.server.stdio", SERVER_BANNED), + ("import mcp.server.mcpserver", SERVER_BANNED), + ("from mcp.server.mcpserver import MCPServer", SERVER_BANNED), + ("from mcp import stdio_server", SERVER_BANNED), +] + + +def _run(code: str) -> str: + """Run `code` in a fresh interpreter with warnings as errors and return its stdout.""" + result = subprocess.run( + [sys.executable, "-W", "error", "-c", code], + capture_output=True, + text=True, + check=False, + timeout=60, + env=CHILD_ENV, + ) + assert result.returncode == 0, result.stderr + return result.stdout + + +def _modules_after(statement: str) -> set[str]: + """Every module in `sys.modules` after running `statement` in a fresh interpreter.""" + probe = f"import json, sys\n{statement}\nprint(json.dumps(sorted(sys.modules)))" + return set(json.loads(_run(probe))) + + +def _hits(loaded: set[str], banned: tuple[str, ...]) -> list[str]: + """The banned packages (or submodules of them) that ended up loaded.""" + return sorted({p for p in banned for m in loaded if m == p or m.startswith(p + ".")}) + + +def _documented_modules() -> list[str]: + """Every public (documented) module: the same rule the API reference uses. + + A module is documented when no component of its dotted path is `_`-private + (`scripts/docs/gen_ref_pages.py`). Optional-dependency modules that this environment + cannot import are skipped, exactly as they are for any other test. + """ + names: list[str] = [] + for pkg in (mcp, mcp_types): + names.append(pkg.__name__) + names.extend( + info.name + for info in pkgutil.walk_packages(pkg.__path__, prefix=pkg.__name__ + ".") + if not any(part.startswith("_") for part in info.name.split(".")) + ) + return sorted(names) + + +def test_bare_import_mcp_is_a_lazy_namespace(): + """`import mcp` loads no protocol types, no pydantic and no SDK submodule beyond the + tiny lazy-loading helper the namespace itself is built with.""" + loaded = _modules_after("import mcp") + mcpish = {m for m in loaded if m.startswith("mcp") or m.startswith("pydantic")} + assert mcpish <= {"mcp", "mcp.shared", "mcp.shared._lazy"}, sorted(mcpish) + + +@pytest.mark.parametrize(("statement", "banned"), IMPORT_GUARDS, ids=[case[0] for case in IMPORT_GUARDS]) +def test_entry_point_does_not_load_the_heavy_stacks_it_does_not_use(statement: str, banned: tuple[str, ...]): + """Each entry point keeps the stacks it does not need out of its import graph.""" + assert _hits(_modules_after(statement), banned) == [] + + +def test_first_url_client_is_what_loads_the_http_client_transport(): + """The deferred load happens where it is used: building the first URL `Client` (which + only connects on `__aenter__`) is what imports httpx2 - and the server stack stays out.""" + probe = ( + "import json, sys\n" + "from mcp import Client\n" + "before = sorted(sys.modules)\n" + "Client('http://localhost/mcp')\n" + "print(json.dumps([before, sorted(sys.modules)]))\n" + ) + before, after = (set(names) for names in json.loads(_run(probe))) + assert _hits(before, (*CLIENT_BANNED, "httpx2")) == [] + assert _hits(after, ("httpx2",)) == ["httpx2"] + assert _hits(after, CLIENT_BANNED) == [] + + +def test_request_state_codec_is_what_loads_cryptography(): + """The deferred load happens where it is used: importing the server does not load + `cryptography`; constructing an `MCPServer` (which builds the default request-state + codec) is what does.""" + probe = ( + "import json, sys\n" + "from mcp.server.mcpserver import MCPServer\n" + "before = sorted(sys.modules)\n" + "MCPServer('probe')\n" + "print(json.dumps([before, sorted(sys.modules)]))\n" + ) + before, after = (set(names) for names in json.loads(_run(probe))) + assert _hits(before, SERVER_BANNED) == [] + assert _hits(after, ("cryptography",)) == ["cryptography"] + assert _hits(after, HTTP_SERVER_STACK) == [] + + +@pytest.mark.parametrize("module_name", _documented_modules()) +def test_documented_module_imports_first_in_a_fresh_interpreter(module_name: str): + """Every documented module imports cleanly and warning-free as a process's first + import, so the lazy resolution never depends on something having been imported first.""" + if module_name.startswith("mcp.cli"): + pytest.importorskip("typer") + _run(f"import {module_name}") diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000000..2ed3c3fce6 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,46 @@ +"""The `mcp` package namespace: lazy exports (PEP 562) that stay identical to the eager originals. + +The import-footprint half of the contract (what `import mcp` may load) lives in +tests/test_import_guards.py, together with the other entry points' guards. +""" + +import pytest +from mcp_types import methods + +import mcp +import mcp.server.session +import mcp.server.stdio +import mcp.shared.exceptions +from mcp.client.session_group import ClientSessionGroup + + +def test_exported_name_is_the_home_module_object_and_is_cached_on_the_package(): + """SDK-defined: `mcp.MCPError` is `mcp.shared.exceptions.MCPError`, cached after first access.""" + assert mcp.MCPError is mcp.shared.exceptions.MCPError + assert vars(mcp)["MCPError"] is mcp.shared.exceptions.MCPError + + +def test_client_and_server_reexports_are_the_defining_objects(): + """SDK-defined: `mcp.ServerSession`, `mcp.stdio_server` and `mcp.ClientSessionGroup` + resolve on first access to the very objects their defining modules export.""" + assert mcp.ServerSession is mcp.server.session.ServerSession + assert mcp.stdio_server is mcp.server.stdio.stdio_server + assert mcp.ClientSessionGroup is ClientSessionGroup + + +def test_warm_is_the_types_layer_prewarm_helper_itself(): + """SDK-defined: `mcp.warm` is `mcp_types.methods.warm` - one helper, two spellings.""" + assert mcp.warm is methods.warm + + +def test_dir_lists_every_export_and_the_bound_submodules(): + """SDK-defined: `dir(mcp)` reports all of `__all__` plus the submodules a bare import binds.""" + listing = set(dir(mcp)) + assert set(mcp.__all__) <= listing + assert {"client", "os", "server", "shared", "types"} <= listing + + +def test_unknown_attribute_raises_attribute_error(): + """SDK-defined: a name that is neither an export nor a submodule raises AttributeError.""" + with pytest.raises(AttributeError): + getattr(mcp, "no_such_name") diff --git a/tests/types/test_deferred.py b/tests/types/test_deferred.py new file mode 100644 index 0000000000..681449c56a --- /dev/null +++ b/tests/types/test_deferred.py @@ -0,0 +1,220 @@ +"""Deferred (`defer_build`) models keep runtime introspection identical to eagerly-built ones, +and their first (lazy) build is safe under concurrency.""" + +import inspect +import json +import os +import subprocess +import sys +import typing + +import pytest +from inline_snapshot import snapshot +from mcp_types import Implementation, Tool +from mcp_types._deferred import deferred_model +from mcp_types._types import MCPModel +from pydantic import BaseModel, ConfigDict + + +def test_never_used_model_reports_its_real_init_signature() -> None: + """`inspect.signature()` completes the deferred build itself, so a model nobody has + validated yet still reports its fields rather than pydantic's generic `(**data)`.""" + + class Probe(MCPModel): + first_name: str + age: int = 0 + + assert not Probe.__pydantic_complete__ + signature = inspect.signature(Probe) + assert Probe.__pydantic_complete__ # the first signature access built it, once + assert str(signature) == "(*, firstName: str, age: int = 0) -> None" + + +def test_deferred_signature_is_not_a_type_hint_and_is_class_only() -> None: + """The lazy `__signature__` never leaks into `get_type_hints`, and instances do not + expose one (matching `BaseModel`).""" + + class Probe(MCPModel): + name: str + + assert "__signature__" not in typing.get_type_hints(Probe) + assert not hasattr(Probe.model_construct(name="x"), "__signature__") # still-deferred class + assert not hasattr(Implementation(name="probe", version="1"), "__signature__") + assert "__signature__" not in typing.get_type_hints(Tool) + + +def test_a_subclass_that_disables_defer_build_keeps_its_eager_signature() -> None: + """A subclass may turn `defer_build` back off; it builds at class creation and keeps the + signature pydantic gave it (the lazy one is only installed on still-deferred classes).""" + + class Eager(MCPModel): + model_config = ConfigDict(defer_build=False) + value: int + + assert Eager.__pydantic_complete__ + assert str(inspect.signature(Eager)) == "(*, value: int) -> None" + + +def test_decorated_root_model_covers_its_subclasses_too() -> None: + """`@deferred_model` gives a `BaseModel`-derived deferred model - and every model that + later subclasses it - an accurate pre-first-use signature.""" + + @deferred_model + class Root(BaseModel): + model_config = ConfigDict(defer_build=True) + x: int + + class Child(Root): + y: str = "y" + + for cls, expected in ((Root, "(*, x: int) -> None"), (Child, "(*, x: int, y: str = 'y') -> None")): + assert not cls.__pydantic_complete__ + assert str(inspect.signature(cls)) == expected + + +def test_the_decorator_refuses_a_root_that_would_not_defer_or_that_owns_a_subclass_hook() -> None: + """`@deferred_model` marks a *deferred* hierarchy root and installs its own subclass hook: + a class without `defer_build`, or one defining `__pydantic_init_subclass__` itself (which + the installed hook would replace), is refused at decoration time.""" + + class Eager(BaseModel): + value: int + + class OwnsHook(BaseModel): + model_config = ConfigDict(defer_build=True) + value: int + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: object) -> None: + raise NotImplementedError # never runs: the decorator refuses the class first + + with pytest.raises(TypeError) as eager_error: + deferred_model(Eager) + assert str(eager_error.value) == snapshot("@deferred_model expects Eager to set model_config['defer_build'] = True") + with pytest.raises(TypeError) as hook_error: + deferred_model(OwnsHook) + assert str(hook_error.value) == snapshot("@deferred_model would replace OwnsHook's own __pydantic_init_subclass__") + + +def test_unresolvable_model_falls_back_to_the_generic_signature() -> None: + """A model whose build cannot complete (a forward reference that does not resolve at + that point) reports the generic initializer signature, as pydantic does for any + unbuilt model - and asking for the signature never raises.""" + + class Pending(MCPModel): + value: "Later" + + # `Later` is not defined yet, so the deferred build cannot complete here and the + # class keeps pydantic's generic `(**data)` initializer signature. + parameters = inspect.signature(Pending).parameters + assert [(name, p.kind) for name, p in parameters.items()] == [("data", inspect.Parameter.VAR_KEYWORD)] + assert not Pending.__pydantic_complete__ + with pytest.raises(NameError): + Pending.model_rebuild() + + class Later(MCPModel): # defined only afterwards: too late for `Pending` + done: bool + + assert Later + + +# The lock only matters for the FIRST build of each model, which happens once per process: +# race a broad set of never-used models from a barrier-released thread pool inside fresh +# interpreters (this process built them long ago). Without the process-wide rebuild +# lock in `mcp_types._deferred`, a round like this raises in every run (pydantic <= 2.13 +# does not lock `model_rebuild`; `AttributeError: __pydantic_core_schema__` and friends). +_RACE_PROBE = r""" +import inspect, json, sys, threading, traceback +sys.setswitchinterval(1e-6) # maximize preemption inside the builds +import pydantic +import mcp_types._types as _types +import mcp_types._v2025_11_25 as v2025 +import mcp_types._v2026_07_28 as v2026 +import mcp_types.jsonrpc as jsonrpc +from mcp_types import methods +import mcp.client.session as session # deferred module-level TypeAdapters +import mcp.server.mcpserver.server as mcpserver # the SDK-layer deferred model roots +import mcp.server.mcpserver.prompts.base as prompts_base +import mcp.server.mcpserver.tools.base as tools_base +import mcp.server.mcpserver.resources.types as resource_types +import mcp.server.auth.settings as auth_settings +import mcp.shared.auth as shared_auth + +def own_models(mod): + return [o for o in vars(mod).values() if isinstance(o, type) and issubclass(o, pydantic.BaseModel) + and o.__module__ == mod.__name__ and not o.__pydantic_complete__] + +MODULES = (_types, v2025, v2026, jsonrpc, mcpserver, prompts_base, tools_base, resource_types, auth_settings, + shared_auth) +CLASSES = [cls for mod in MODULES for cls in own_models(mod)] +ADAPTERS = [v for m in (_types, jsonrpc, session, prompts_base) for v in vars(m).values() + if isinstance(v, pydantic.TypeAdapter)] +N = int(sys.argv[1]) +barrier = threading.Barrier(N) +errors, lock = [], threading.Lock() + +def first_use_everything(): + # The mutually recursive 2026 JSON models first: a FIRST json-schema generation + # reads the core schema of each sibling it references while another thread may + # still be completing that sibling. + for cls in (v2026.JSONObject, v2026.JSONArray, v2026.JSONValue): + cls.model_json_schema() + for cls in CLASSES: + try: + cls.model_validate({"__probe__": 1}) + except (pydantic.ValidationError, TypeError): + # junk payload on purpose: the first-use build is the point (TypeError = a + # model with a custom __init__ rejecting the junk kwargs after the build) + pass + for adapter in ADAPTERS: + try: + adapter.validate_python({"__probe__": 1}) + except (pydantic.ValidationError, TypeError): + pass + methods.parse_client_request("ping", "2025-11-25", None) + _types.Tool.model_json_schema() + v2025.Root.model_json_schema() + inspect.signature(_types.CallToolRequest) + +def worker(): + barrier.wait() + try: + first_use_everything() + except Exception as exc: + with lock: + errors.append(f"{type(exc).__name__}: {exc}"[:300]) + +threads = [threading.Thread(target=worker) for _ in range(N)] +for t in threads: + t.start() +for t in threads: + t.join() +incomplete = [c.__name__ for c in CLASSES if not c.__pydantic_complete__] +print(json.dumps({"n_classes": len(CLASSES), "errors": errors, "incomplete": incomplete})) +""" + + +def test_concurrent_first_use_of_deferred_models_never_raises() -> None: + """Eight threads first-using a broad set of never-built models (monolith, both wire + packages incl. the mutually recursive JSON models' first schema, JSON-RPC envelopes, the + SDK's own models, the module-level adapters, first schema and signature access) build + every class exactly once and raise nothing. + + The models must be unbuilt when the race starts, so the probe runs in fresh interpreters. + """ + for _ in range(2): + result = subprocess.run( + [sys.executable, "-c", _RACE_PROBE, "8"], + capture_output=True, + text=True, + check=False, + timeout=120, + cwd=os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + # pydantic plugins (e.g. logfire) building models are the environment's cost, not ours + env={**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"}, + ) + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout.strip().splitlines()[-1]) + assert report["n_classes"] > 200 # the probe really did cover the class set + assert report["errors"] == [] + assert report["incomplete"] == [] diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 3e25bba23d..da87a7abee 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -1,6 +1,11 @@ """Tests for the wire-method maps and two-step parse functions in `mcp_types.methods`.""" +import dataclasses import importlib.util +import json +import os +import subprocess +import sys from collections.abc import Mapping from types import MappingProxyType, UnionType from typing import Any, get_args @@ -10,6 +15,7 @@ import mcp_types._v2026_07_28 as v2026 import pydantic import pytest +from inline_snapshot import snapshot from mcp_types import methods from mcp_types.version import KNOWN_PROTOCOL_VERSIONS from pydantic import BaseModel @@ -549,6 +555,52 @@ def test_built_in_maps_are_immutable(): _assign_item(built_in) +def test_importing_methods_defers_each_wire_package_until_one_of_its_rows_is_read(): + """SDK-defined: the per-version wire packages load on first row read, not at import. + + A fresh interpreter is required: this test process has already imported both packages. + """ + script = "\n".join( + [ + "import sys", + "from mcp_types import methods", + "wire = lambda: sorted(m for m in sys.modules if m.startswith('mcp_types._v'))", + "assert wire() == [], wire()", + "assert ('tools/list', '2025-11-25') in methods.CLIENT_REQUESTS", + "assert len(methods.CLIENT_REQUESTS) and methods.SPEC_CLIENT_METHODS", + "assert wire() == [], wire()", + "assert methods.parse_client_request('ping', '2025-11-25', None).method == 'ping'", + "assert wire() == ['mcp_types._v2025_11_25'], wire()", + "methods.CLIENT_REQUESTS[('server/discover', '2026-07-28')]", + "assert wire() == ['mcp_types._v2025_11_25', 'mcp_types._v2026_07_28'], wire()", + ] + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=False, timeout=20) + assert result.returncode == 0, result.stderr + + +def test_surface_maps_keep_the_read_only_mapping_extras_of_a_proxied_dict(): + """SDK-defined: repr, `copy()`, `|`, `reversed()` and equality match a proxied dict of the rows.""" + surface_map = methods.CLIENT_REQUESTS + resolved = dict(surface_map) + assert isinstance(surface_map, MappingProxyType) + assert surface_map == resolved + assert repr(surface_map) == repr(MappingProxyType(resolved)) + assert surface_map.copy() == resolved + assert (surface_map | {}) == resolved + assert ({} | surface_map) == resolved + assert list(reversed(surface_map)) == list(reversed(resolved)) + assert surface_map.get(("ping", "2025-11-25")) is v2025.PingRequest + assert surface_map.get(("ping", "2026-07-28")) is None + + +def test_surface_rows_for_a_version_with_no_wire_package_fail_at_map_construction(): + """SDK-defined: an unregistered protocol version fails loudly at import, not as a version-gate KeyError.""" + with pytest.raises(ValueError) as excinfo: + methods._LazyWireMap({("ping", "2099-01-01"): "PingRequest"}) + assert str(excinfo.value) == snapshot("no wire package registered for protocol version(s) ['2099-01-01']") + + def test_cacheable_methods_mirror_the_cacheable_method_literal(): """SEP-2549 weld: the hand-written Literal and the set derived from `MONOLITH_RESULTS` must agree.""" assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod)) @@ -978,3 +1030,105 @@ def test_importing_the_module_builds_no_adapters_and_identical_rows_share_one(): # Identical row values at another version: no new adapters. fresh.parse_server_result("ping", "2024-11-05", {}) assert fresh._adapter.cache_info().currsize == 2 + + +# --- warm(): the opt-in prewarm helper --- + +_WARM_PROBE = """ +import dataclasses, json, sys +import pydantic +import mcp_types +from mcp_types import methods + +report = methods.warm({args}) if {args!r} != "None" else None +loaded_wire = sorted(m for m in sys.modules if m.startswith("mcp_types._v")) + +# From here on nothing may build: count model completions and adapter builds. +built = {{"models": 0, "adapters": 0}} +import pydantic._internal._model_construction as construction +_complete = construction.complete_model_class +def counting_complete(*a, **k): + built["models"] += 1 + return _complete(*a, **k) +construction.complete_model_class = counting_complete +_init = pydantic.TypeAdapter._init_core_attrs +def counting_init(self, *a, **k): + result = _init(self, *a, **k) + if result: + built["adapters"] += 1 + return result +pydantic.TypeAdapter._init_core_attrs = counting_init + +methods.parse_client_request( + "tools/call", "2026-07-28", + {{"name": "t", "arguments": {{}}, + "_meta": {{"io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {{}}}}}}, +) +methods.serialize_server_result( + "tools/list", "2026-07-28", + {{"tools": [], "cacheScope": "private", "ttlMs": 0, "resultType": "complete"}}, +) +mcp_types.jsonrpc_message_adapter.validate_python({{"jsonrpc": "2.0", "id": 1, "method": "ping"}}) +print(json.dumps({{"report": dataclasses.asdict(report) if report else None, "wire": loaded_wire, "built": built}})) +""" + + +def _run_warm_probe(args: str) -> dict[str, Any]: + """Warm (with `args`) in a fresh interpreter, then report what the first messages built. + + The probe counts builds through pydantic internals purely as a test observer. + """ + result = subprocess.run( + [sys.executable, "-c", _WARM_PROBE.format(args=args)], + capture_output=True, + text=True, + check=False, + timeout=120, + # a pydantic plugin (e.g. logfire) building models is the environment's cost, not ours + env={**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"}, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def test_first_messages_build_validators_lazily_without_a_warm_up(): + """SDK-defined: with no `warm()`, nothing is loaded or built up front; the first messages + at a version import that version's wire package and build what they route through.""" + observed = _run_warm_probe("None") + assert observed["report"] is None + assert observed["wire"] == [] # no wire package loaded before the first message + assert observed["built"]["models"] > 0 + assert observed["built"]["adapters"] > 0 + + +def test_warming_a_version_leaves_nothing_for_its_first_messages_to_build(): + """SDK-defined: `warm(version)` imports that version's wire package and builds its models + and routing adapters up front, so the first messages at that version build nothing.""" + observed = _run_warm_probe('"2026-07-28"') + assert observed["report"]["models"] > 0 + assert observed["report"]["adapters"] > 0 + assert observed["wire"] == ["mcp_types._v2026_07_28"] + assert observed["built"] == {"models": 0, "adapters": 0} + + +def test_warm_is_idempotent_and_gates_unknown_versions(): + """SDK-defined: repeating a `warm()` builds nothing more; an unknown version is a ValueError.""" + baseline = methods.warm() # the version-independent set (this process may already have built it) + assert baseline.elapsed_ms >= 0 + methods.warm(all_versions=True) + again = methods.warm(all_versions=True) + assert (again.models, again.adapters) == (0, 0) + assert again.elapsed_ms >= 0 + with pytest.raises(ValueError) as excinfo: + methods.warm("2099-01-01") + assert str(excinfo.value) == snapshot("version must be a known protocol version, got '2099-01-01'") + + +def test_warm_report_is_an_immutable_dataclass(): + """SDK-defined: the report is a frozen dataclass (fields read by name, arity free to grow), + not a tuple.""" + report = methods.warm() + assert dataclasses.is_dataclass(report) + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(report, "models", -1) diff --git a/tests/types/test_wire_base.py b/tests/types/test_wire_base.py new file mode 100644 index 0000000000..217cd64149 --- /dev/null +++ b/tests/types/test_wire_base.py @@ -0,0 +1,57 @@ +"""The generated wire packages' bases keep import cheap: pydantic builds each model on first use.""" + +import mcp_types._v2025_11_25 as v2025 +import mcp_types._v2026_07_28 as v2026 +from mcp_types._wire_base import WireModel, WireRootModel +from pydantic import BaseModel + + +def test_wire_model_defers_the_pydantic_build_to_first_use() -> None: + """SDK-defined: a `WireModel` subclass creates its class without building a validator; + the first `model_validate` builds it, once.""" + + class Probe(WireModel): + x: int + + assert not Probe.__pydantic_complete__ # class creation built no validator + assert Probe.model_validate({"x": 1}).x == 1 # first use builds it, once + assert Probe.__pydantic_complete__ + + +def test_wire_root_model_defers_the_parameterized_base_and_the_alias() -> None: + """SDK-defined: a `WireRootModel[T]` alias defers both itself and its generic + parameterization, and still validates through the root on first use.""" + + class Payload(WireModel): + x: int + + class Alias(WireRootModel[Payload]): + root: Payload + + assert not Alias.__pydantic_complete__ + assert not WireRootModel[Payload].__pydantic_complete__ # the parameterization is a class too + assert Alias.model_validate({"x": 2}).root.x == 2 + + +def test_every_generated_class_derives_from_a_deferred_base() -> None: + """An eagerly-built union rollup would regenerate every deferred model's schema inline.""" + for module in (v2025, v2026): + models = [c for c in vars(module).values() if isinstance(c, type) and issubclass(c, BaseModel)] + assert models + for cls in models: + assert issubclass(cls, WireModel | WireRootModel), f"{module.__name__}.{cls.__name__}" + + +def test_generated_classes_have_complete_field_metadata_at_import() -> None: + """Dependency-ordered emission resolves every annotation at class creation. + + Only the genuinely recursive JSON alias trio may still name a class defined + later (its own cycle); every other class must expose complete `model_fields` + (aliases included) without a first-use build. + """ + recursion = {"JSONArray", "JSONObject", "JSONValue"} + for module in (v2025, v2026): + for name, cls in vars(module).items(): + if isinstance(cls, type) and issubclass(cls, WireModel) and name not in recursion: + assert cls.__pydantic_fields_complete__, f"{module.__name__}.{name}" + assert v2026.CallToolRequestParams.model_fields["meta"].alias == "_meta"