From ec5b2258c91bd6be21de0907e7357427133fb66f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:06:11 +0000 Subject: [PATCH] Cut import and startup cost with deferred model builds and lazy imports Every import path now pays only for what it uses, with no public API added or removed: - The protocol models (mcp.types / mcp_types, incl. the JSON-RPC envelopes and the generated per-version wire packages) build their pydantic validators on first use instead of at import (defer_build), through one shared private base class. First-use builds are serialised behind a single process-wide lock, since released pydantic does not make concurrent first use of a deferred model thread-safe; this also fixes a pre-existing concurrent-first-use failure that reproduces on main. - `import mcp` binds the client/server names lazily on first attribute access (PEP 562) instead of importing both stacks eagerly, and the client no longer imports the server, so client entry points stop loading the server, the web stack, httpx2 and cryptography. - The web application stack (starlette's app machinery, sse_starlette, uvicorn) loads with the app builders that use it, and each protocol version's wire package loads on the first message parsed for that version rather than both loading at import. On the fresh-interpreter harness `import mcp` is ~0.4x of v1 (main is ~1.6x), the client entry points ~0.6x of v1, `import mcp.server.mcpserver` ~0.7x, and time-to-ready / stdio cold start land at parity with v1. RSS after `import mcp` is 19 MiB (v1 43.5, main 57). Steady-state per-call latency is unchanged. Observable-but-incidental differences (removed incidental namespace bindings, deeper submodules no longer imported as a side effect of a bare `import mcp`, get_type_hints needing localns= for a documented set of callables, pre-first-use introspection) are catalogued in docs/migration.md; ratchet tests pin the import footprints and the concurrent-first-use safety. --- AGENTS.md | 16 ++- docs/advanced/index.md | 2 + docs/advanced/startup.md | 67 +++++++++ docs/migration.md | 95 +++++++++++++ mkdocs.yml | 1 + pyproject.toml | 4 +- scripts/gen_surface_types.py | 29 +++- src/mcp-types/mcp_types/_types.py | 22 +-- .../mcp_types/_v2025_11_25/__init__.py | 50 +++---- .../mcp_types/_v2026_07_28/__init__.py | 91 ++++-------- src/mcp-types/mcp_types/_wire_base.py | 134 +++++++++++++++++- src/mcp-types/mcp_types/jsonrpc.py | 16 ++- src/mcp-types/mcp_types/methods.py | 78 ++++++++-- src/mcp/__init__.py | 60 ++++++-- src/mcp/client/client.py | 39 +++-- src/mcp/client/session.py | 16 ++- .../server/auth/middleware/auth_context.py | 18 ++- src/mcp/server/elicitation.py | 7 +- src/mcp/server/lowlevel/server.py | 51 +++++-- src/mcp/server/mcpserver/resources/types.py | 7 +- src/mcp/server/mcpserver/server.py | 56 +++++--- src/mcp/server/streamable_http_manager.py | 6 +- tests/client/test_client.py | 2 +- .../mcpserver/resources/test_resources.py | 30 +++- tests/server/test_streamable_http_manager.py | 14 ++ tests/test_import_footprint.py | 49 +++++++ tests/test_init.py | 101 +++++++++++++ tests/types/test_methods.py | 5 + tests/types/test_parity.py | 1 + tests/types/test_wire_base.py | 127 +++++++++++++++++ 30 files changed, 1003 insertions(+), 191 deletions(-) create mode 100644 docs/advanced/startup.md create mode 100644 tests/test_import_footprint.py create mode 100644 tests/test_init.py create mode 100644 tests/types/test_wire_base.py diff --git a/AGENTS.md b/AGENTS.md index 2812ed6d17..dfeff67a78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,21 @@ - IMPORTANT: All imports go at the top of the file — inline imports hide dependencies and obscure circular-import bugs. Only exception: 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), plus the deliberate startup-cost seams + below — each of those local imports carries a why-comment; don't hoist them. +- Startup-cost seams (pinned by `tests/test_import_footprint.py`, so a + hoisted import fails a test rather than review): `mcp/__init__.py` binds + the client/server names and `mcp.types` lazily; `mcp.client.client` never + imports the server, and imports the streamable-HTTP client (httpx2) only + for a URL; `mcp.server.elicitation` imports the 2025-era wire package inside + its schema-validation gate; the two server hubs (`lowlevel/server.py`, + `mcpserver/server.py`) import the HTTP web stack inside + `streamable_http_app()` / `sse_app()` / `custom_route()`; the auth context + accessor imports its `AuthenticatedUser` type inside the middleware + constructor; `HttpResource.read` imports httpx2 in the method; and + `mcp_types.methods` resolves each version's wire package + (`mcp_types._v20*`) on the first surface-map row read, never at import. + `docs/advanced/startup.md` states the user-facing contract. ## Testing diff --git a/docs/advanced/index.md b/docs/advanced/index.md index 92af6d1782..ba140b258b 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. +* **[Startup cost](startup.md)**: what an import loads, and the one-time bills paid + on first use instead — for when you are measuring cold start. A few things you might reasonably look for here live where you'd actually use them instead: diff --git a/docs/advanced/startup.md b/docs/advanced/startup.md new file mode 100644 index 0000000000..723d09e461 --- /dev/null +++ b/docs/advanced/startup.md @@ -0,0 +1,67 @@ +# Startup cost + +The SDK is arranged so a process pays only for what it actually uses, and pays for +each thing once. Two rules produce that; both are occasionally observable, so they +are written down here. + +## What an import loads + +`import mcp` loads the protocol types (`mcp_types`) and nothing else: no client, no +server, no web stack, no HTTP client. The client/server names it exports (`mcp.Client`, +`mcp.ClientSession`, `mcp.stdio_server`, ...) and `mcp.types` resolve their home +module on first access and are then cached on the package, so `from mcp import Client` +costs the client import exactly once, when you ask for it. + +Importing an entry point loads only its own side: a client entry point +(`import mcp.client.stdio`) never imports the server stack or the HTTP client stack, +which loads with your first URL-shaped `Client`; a server entry point never imports the +client, and a transport-agnostic one (`mcp.server.stdio`, `MCPServer`, the lowlevel +`Server`) never imports the HTTP web stack (starlette's app/request stack, +`sse_starlette`, `uvicorn`) — that loads when you first build an HTTP app +(`streamable_http_app()`, `sse_app()`, a custom route). Because these are import-graph +promises, they are tested: adding an eager import that breaks one fails the suite. + +One introspection consequence: `typing.get_type_hints()` on the seven HTTP-app methods +(`Server.streamable_http_app`, `Server.session_manager`, `MCPServer.streamable_http_app`, +`MCPServer.sse_app`, `MCPServer.run_sse_async`, `MCPServer.run_streamable_http_async`, +`MCPServer.session_manager`) raises `NameError`: their annotations name HTTP types that +those modules import for type checkers only. Signatures, static typing, and calling the +methods are unaffected; if you evaluate the hints at runtime, pass the types yourself, e.g. +`typing.get_type_hints(MCPServer.sse_app, localns={"TransportSecuritySettings": +mcp.server.transport_security.TransportSecuritySettings, "Starlette": +starlette.applications.Starlette})`. + +## One-time first-use bills + +The generated wire types for each protocol version load with the first message a +connection parses for that version, not at import — a connection negotiates one +version, so a process loads that version's models (a few tens of milliseconds once) +and never the other's. Protocol model validators are then built on a model's first use +(validation, dumping, `model_json_schema()`), a few milliseconds once for the models a +message touches; everything after is at full speed. There is no per-call cost. + +Reading whole surface maps in `mcp_types.methods` (`.values()`, `.items()`, spreading +one into an extension map) loads both versions' wire types at that moment, and a +server's first elicitation loads the wire types its schema gate validates against. + +If pydantic plugins are installed (`logfire`, for example) pydantic loads them at that +first model build. When you are measuring or shaving cold start and don't use them, +export `PYDANTIC_DISABLE_PLUGINS=__all__`. + +## Introspecting a model before its first use + +Because a model is built on first use, class-level introspection of a protocol model +that nothing in the process has used yet reflects the not-yet-built state: +`inspect.signature(Tool)` shows the generic `(**data)` initializer, and +`Tool.__pydantic_complete__` is `False`. Using the model once, or calling +`Tool.model_rebuild()`, resolves it; from then on introspection is identical to an +eagerly-built model. Instances, validation, serialization, and schemas are unaffected. + +## First use from threads + +First-use builds are serialised across threads by one process-wide lock, so concurrent +first use is safe. Two consequences worth knowing: generate schemas through the model's +own `Model.model_json_schema()` (pydantic's module-level `pydantic.json_schema.model_json_schema(Model)` +bypasses the serialisation), and don't `fork()` while another thread is mid-way through a +model's first use — the child inherits the held build lock; use the model once first, or the +`spawn` start method. diff --git a/docs/migration.md b/docs/migration.md index b094d79f84..46c9208568 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -197,6 +197,26 @@ Both commands now pin the requirement to the version you are running (`mcp==`). Source builds and other unpublished versions, which have nothing on PyPI to pin to, keep the unpinned form. +### `import mcp` no longer imports the client and server stacks + +`import mcp` used to import the whole client and server stack (and with it starlette, +uvicorn, httpx2, ...) as a side effect. It now imports only the protocol types; `Client`, +`ClientSession`, `ClientSessionGroup`, `StdioServerParameters`, `stdio_client`, +`ServerSession`, `stdio_server`, `InputRequiredRoundsExceededError`, and the `mcp.types` +submodule are the same names and objects, resolved on first access. This is invisible +unless code depended on the side effects: + +* `sys.modules` after `import mcp` no longer contains `mcp.client*`, `mcp.server*`, or + their dependencies. Import what you use. +* Attribute chains from a bare `import mcp` still reach `mcp.types`, `mcp.client`, + `mcp.server`, and `mcp.os`, but a module the old package init imported for you needs its + own `import` before `mcp.client.stdio.` works — for example `mcp.client.stdio`, + `mcp.client.session_group`, `mcp.client.sse`, `mcp.client.streamable_http`, and + `mcp.shared.memory`. +* Client entry points (`mcp.client.stdio`, `from mcp import Client`, ...) no longer import + the server stack, and the HTTP client stack loads with your first URL-shaped `Client` + rather than at import. See [Startup cost](advanced/startup.md). + ## Types and wire format ### `mcp.types` moved to the `mcp-types` package @@ -640,6 +660,45 @@ JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message=" Delete any shim that accepted or synthesized null-id error responses. Code that assumed `error.id` was always a `str | int` must now handle `None`, and tests that pinned v1's rejection of `"id": null` now fail because validation succeeds. +### Protocol models build their validators on first use + +The protocol models (`mcp.types` / `mcp_types`, including the JSON-RPC envelopes) now build +their pydantic validators on a model's first use in the process instead of at import +(`defer_build`), which is most of the SDK's startup cost. Validation, serialization, JSON +schemas, and everything after a model's first use are unchanged (one wrinkle: `inspect.signature` +on the `model_rebuild` / `model_json_schema` classmethods shows the SDK's evaluated annotations +rather than pydantic's stringised type aliases — parameter names, kinds and defaults are +identical). Two things are observable *before* a model's first use: + +* `inspect.signature(Model)` / `help(Model)` show pydantic's generic `(**data)` initializer + and `Model.__pydantic_complete__` is `False`. A few models (`CallToolRequest`, + `GetPromptRequest`, `ReadResourceRequest`, `SamplingMessage`, `ToolResultContent`) already + behaved this way; it now applies to all of them until first use. Use the model once, or call + `Model.model_rebuild()`, when you need the resolved signature earlier. +* Every model's MRO gains one private base (`mcp_types._wire_base.DeferredModel`) between it + and `pydantic.BaseModel`, visible only to code that walks `__mro__`. +* The module-level parse adapters (`client_request_adapter`, ..., `jsonrpc_message_adapter`) + are instances of a private `TypeAdapter` subclass (`mcp_types._wire_base.DeferredAdapter`) so + their first-use build takes the same lock; `isinstance(adapter, TypeAdapter)` and every + documented `TypeAdapter` operation are unchanged. + +The one-time build cost moves from `import` to each model's first use — a few milliseconds +for the first message a connection parses; see [Startup cost](advanced/startup.md). + +The per-version wire types behind the `mcp_types.methods` surface maps +(`CLIENT_REQUESTS`, `SERVER_RESULTS`, ...) go further: a version's wire types load on the +first row read for that version — in practice with the first message a connection parses — +rather than at `import mcp_types.methods`, so a process loads only the protocol version it +negotiates. Every documented map operation is unchanged (lookup, `in`, iteration, `len`, +`get`, `==`, spreading into an extension map, `repr`); the whole-map reads among them +load both versions at that moment. Three obscurities are observable: the maps' +non-`Mapping` dict extras are gone (`.copy()`, `|`, `reversed()`, and `keys()`/`values()`/ +`items()` return view objects), the internal `mcp_types.methods.v2025`/`v2026` attributes +are import-free stand-ins rather than the wire-package modules, and the wire types are no +longer bound in `mcp.server.elicitation`. Import the generated packages +(`mcp_types._v2025_11_25`, `mcp_types._v2026_07_28`) directly if you need them, though the +version-free `mcp.types` models remain the supported surface. + ## MCPServer (formerly FastMCP) ### `FastMCP` renamed to `MCPServer` @@ -877,6 +936,27 @@ Beyond the constructor parameters that moved to `run()`/`streamable_http_app()` Only private attributes moved: `mcp._mcp_server` is now `mcp._lowlevel_server` (see [Registering lowlevel handlers from `MCPServer`](#registering-lowlevel-handlers-from-mcpserver)), and `_session_manager` now lives on that lowlevel `Server`. Prefer the public `mcp.session_manager` property to either. +### The server modules no longer import the HTTP stack + +`mcp.server.lowlevel.server` and `mcp.server.mcpserver.server` used to import the Streamable +HTTP / SSE stack at module top, so any server — including a stdio one — loaded starlette, +`sse_starlette`, and `uvicorn` at import. That stack now loads inside `streamable_http_app()`, +`sse_app()`, and `custom_route()`, their only users; a stdio server never pays for it. Two +things follow: + +* The HTTP names that were only incidentally reachable as attributes of those two modules + (`Starlette`, `Route`, `Mount`, `EventStore`, `TransportSecuritySettings`, + `StreamableHTTPSessionManager`, `SseServerTransport`, and the auth middlewares/routes) are no + longer bound there. Import them from their homes (`starlette.applications`, + `mcp.server.streamable_http`, `mcp.server.transport_security`, + `mcp.server.streamable_http_manager`, `mcp.server.sse`, `mcp.server.auth.middleware.*`, + `mcp.server.auth.routes`). +* `typing.get_type_hints()` on the HTTP-app methods (`streamable_http_app`, `sse_app`, + `run_sse_async`, `run_streamable_http_async`, and the `session_manager` properties) raises + `NameError`, because their annotations name types those modules import for type checkers only; + pass them yourself as `localns={...}` if you evaluate the hints at runtime. See + [Startup cost](advanced/startup.md). + ### `MCPServer.get_context()` removed `MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. @@ -2056,6 +2136,21 @@ result = await client.call_tool("long_running_task", {}, progress_callback=on_pr Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool definitions: the `TASK_REQUIRED` / `TASK_OPTIONAL` / `TASK_FORBIDDEN` constants are gone from `mcp.types` (`ToolExecution.task_support` takes the plain `"required"` / `"optional"` / `"forbidden"` literal), and no v2 client or server reads the field. +### `mcp.client.client` no longer imports the server stack + +The client module no longer imports the server, so names that were only incidentally +reachable as attributes of `mcp.client.client` (`Server`, `MCPServer`, `modern_on_request`, +`InMemoryTransport`, `streamable_http_client`) are no longer bound there. Import and +`mock.patch` them at their own modules: `mcp.server.Server`, `mcp.server.mcpserver.MCPServer`, +`mcp.server.runner.modern_on_request`, `mcp.client.streamable_http.streamable_http_client` +(the in-memory transport is constructed for you by `Client(server)`). + +One introspection consequence: `typing.get_type_hints(mcp.Client)` (and of `Client.__init__`) +now raises `NameError`, because the `server` field annotation names imports that exist only for +type checkers. Static typing, `inspect.signature`, `dataclasses.fields`, and every documented +use are unaffected; if you do evaluate those hints at runtime, pass +`localns={"Server": mcp.server.Server, "MCPServer": mcp.server.mcpserver.MCPServer}`. + ## Transports Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)), so the sections below are client-side apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors); the other server-side transport changes ([lifespan entered once](#streamable-http-lifespan-now-entered-once-at-manager-startup), the [4 MiB request-body limit](#streamable-http-request-bodies-are-limited-to-4-mib)) sit under MCPServer. diff --git a/mkdocs.yml b/mkdocs.yml index 06b293f876..a2b6701930 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 + - Startup cost: advanced/startup.md - Troubleshooting: troubleshooting.md - Migration Guide: migration.md - API Reference: api/ diff --git a/pyproject.toml b/pyproject.toml index 3c814106d1..65c74216de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,8 +219,10 @@ max-complexity = 24 # Default is 10 "__init__.py" = ["F401"] # The mcp.types package is an alias that mirrors mcp_types namespaces by design. "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). +# Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators +# and their shared `WireRootModel` base). "src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"] +"src/mcp-types/mcp_types/_wire_base.py" = ["TID251"] "tests/server/mcpserver/test_func_metadata.py" = ["E501"] "tests/shared/test_progress_notifications.py" = ["PLW0603"] diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index ab8be15cf3..4ffb1b1520 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -219,6 +219,28 @@ def patch(match: re.Match[str]) -> str: return source +def use_deferred_bases(source: str) -> str: + """Route root models through the deferred `WireRootModel`; drop the trailing `model_rebuild()` calls. + + Object models already defer via `--base-class WireModel`. A bare `RootModel[X]` + base parametrizes (and builds) eagerly, inline-generating the schema of every + deferred model the union references, so root models must defer too. The + trailing `X.model_rebuild()` block datamodel-codegen emits only force-built + forward references, which a deferred model resolves from the module namespace + on its first use. + """ + source = source.replace("RootModel[", "WireRootModel[") + source = source.replace("import WireModel", "import WireModel, WireRootModel") + source = re.sub(r"^(from pydantic import .*), RootModel$", r"\1", source, flags=re.MULTILINE) + source = re.sub(r"^\w+\.model_rebuild\(\)\n", "", source, flags=re.MULTILINE) + # Drift guard: every root model routes through the deferred base and no rebuild call survives. + assert "WireRootModel[" in source and "RootModel[" not in source.replace("WireRootModel[", "") + assert ".model_rebuild()" not in source + # ...and no stray pydantic RootModel import survived (the strip above assumes it is trailing). + assert not re.search(r"^from pydantic import .*\bRootModel\b", source, flags=re.MULTILINE) + return source + + def build(entry: dict[str, str]) -> str: """Generate, post-process, and format one version's surface module text.""" version = entry["protocol_version"] @@ -243,12 +265,9 @@ 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) 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/_types.py b/src/mcp-types/mcp_types/_types.py index 5852d9bba3..5c099d4637 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -11,16 +11,15 @@ from typing import Annotated, Any, ClassVar, Final, Generic, Literal, TypeAlias, TypeVar, get_args from pydantic import ( - BaseModel, ConfigDict, Field, FileUrl, - TypeAdapter, model_validator, ) from pydantic.alias_generators import to_camel from typing_extensions import NotRequired, Self, TypedDict +from mcp_types._wire_base import DeferredAdapter, DeferredModel from mcp_types.jsonrpc import RequestId DEFAULT_NEGOTIATED_VERSION: Final[str] = "2025-03-26" @@ -42,7 +41,7 @@ """Theme an icon is designed for. Wire values of `Icon.theme` (2025-11-25+).""" -class MCPModel(BaseModel): +class MCPModel(DeferredModel): """Base class for all MCP protocol types.""" model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -2097,7 +2096,10 @@ def _require_one_field(self) -> Self: return self -# Forward refs to InputResponses; rebuild at import time rather than first use. +# These four forward-reference `InputResponses` (defined below them). They are the one place +# a build stays eager: rebuilding at import makes a user's subclass of them, defined before +# their first use, complete and usable — a deferred parent with an unresolved forward +# reference otherwise leaves such a subclass "not fully defined". Keep them. InputResponseRequestParams.model_rebuild() ReadResourceRequestParams.model_rebuild() GetPromptRequestParams.model_rebuild() @@ -2128,7 +2130,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 = DeferredAdapter[ClientRequest](ClientRequest) ClientNotification = ( @@ -2139,11 +2141,11 @@ def _require_one_field(self) -> Self: `TaskStatusNotification` is deliberately excluded (types-only). """ -client_notification_adapter = TypeAdapter[ClientNotification](ClientNotification) +client_notification_adapter = DeferredAdapter[ClientNotification](ClientNotification) ClientResult = EmptyResult | CreateMessageResult | CreateMessageResultWithTools | ListRootsResult | ElicitResult -client_result_adapter = TypeAdapter[ClientResult](ClientResult) +client_result_adapter = DeferredAdapter[ClientResult](ClientResult) ServerRequest = PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest @@ -2153,7 +2155,7 @@ def _require_one_field(self) -> Self: requests (these payloads are embedded in `InputRequiredResult` instead). """ -server_request_adapter = TypeAdapter[ServerRequest](ServerRequest) +server_request_adapter = DeferredAdapter[ServerRequest](ServerRequest) ServerNotification = ( @@ -2172,7 +2174,7 @@ def _require_one_field(self) -> Self: `TaskStatusNotification` is deliberately excluded (types-only). """ -server_notification_adapter = TypeAdapter[ServerNotification](ServerNotification) +server_notification_adapter = DeferredAdapter[ServerNotification](ServerNotification) ServerResult = ( @@ -2195,4 +2197,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 = DeferredAdapter[ServerResult](ServerResult) 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..1876d28246 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; @@ -3625,12 +3627,12 @@ class SubscriptionsListenRequestParams(WireModel): """ -class InputRequest(RootModel[CreateMessageRequest | ListRootsRequest | ElicitRequest]): +class InputRequest(WireRootModel[CreateMessageRequest | ListRootsRequest | ElicitRequest]): root: CreateMessageRequest | ListRootsRequest | ElicitRequest class ServerResult( - RootModel[ + WireRootModel[ Result | InputRequiredResult | DiscoverResult @@ -3662,7 +3664,7 @@ class ServerResult( class ClientRequest( - RootModel[ + WireRootModel[ DiscoverRequest | ListResourcesRequest | ListResourceTemplatesRequest @@ -3689,7 +3691,7 @@ class ClientRequest( ) -class InputRequests(RootModel[dict[str, InputRequest]]): +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. @@ -3698,49 +3700,18 @@ class InputRequests(RootModel[dict[str, InputRequest]]): root: dict[str, InputRequest] -class JSONArray(RootModel[list["JSONValue"]]): +class JSONArray(WireRootModel[list["JSONValue"]]): root: list["JSONValue"] -class JSONObject(RootModel[dict[str, "JSONValue"]]): +class JSONObject(WireRootModel[dict[str, "JSONValue"]]): root: dict[str, "JSONValue"] -class JSONValue(RootModel[Union[JSONObject, list["JSONValue"], str | int | float | bool | None]]): +class JSONValue(WireRootModel[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..4c735fdc74 100644 --- a/src/mcp-types/mcp_types/_wire_base.py +++ b/src/mcp-types/mcp_types/_wire_base.py @@ -1,9 +1,137 @@ -"""Shared pydantic base for the generated `mcp_types._v*` wire-shape packages.""" +"""Shared pydantic bases for the type layer. -from pydantic import BaseModel, ConfigDict +`DeferredModel` makes pydantic build a model's validator on first use instead +of at import (`defer_build`) and serialises those first-use builds across +threads; `DeferredAdapter` does the same for the module-level union +`TypeAdapter`s. The generated `mcp_types._v*` wire packages use `WireModel` +for object models and `WireRootModel` for their root/union models: every +class in a package must defer together, since an eagerly-built union +inline-generates the schema of each deferred member it references. +""" +import inspect +import threading +from collections.abc import Mapping +from dataclasses import is_dataclass +from typing import Annotated, Any, Generic, Literal, TypeVar, get_args, get_origin -class WireModel(BaseModel): +from pydantic import BaseModel, ConfigDict, RootModel, TypeAdapter +from pydantic.json_schema import DEFAULT_REF_TEMPLATE, GenerateJsonSchema, JsonSchemaMode +from typing_extensions import is_typeddict + +RootT = TypeVar("RootT") +AdaptedT = TypeVar("AdaptedT") + +# Released pydantic (<= 2.13) does not lock a deferred model's or adapter's +# first-use build, so first use from several threads at once can raise +# (pydantic/pydantic#13419). One process-wide lock serialises those first +# builds; it is re-entrant because a build rebuilds referenced models on the +# same thread. Nothing is rebuilt once built, so the lock is only ever +# contended during first use. +_REBUILD_LOCK = threading.RLock() + + +class DeferredModel(BaseModel): + """`BaseModel` built on first use, with that first build serialised across threads.""" + + model_config = ConfigDict(defer_build=True) + + @classmethod + def model_rebuild( + cls, + *, + force: bool = False, + raise_errors: bool = True, + _parent_namespace_depth: int = 2, + _types_namespace: Mapping[str, Any] | None = None, + ) -> bool | None: + with _REBUILD_LOCK: # +1: this override adds one frame above pydantic's namespace lookup + return super().model_rebuild( + force=force, + raise_errors=raise_errors, + _parent_namespace_depth=_parent_namespace_depth + 1, + _types_namespace=_types_namespace, + ) + + @classmethod + def model_json_schema( + cls, + by_alias: bool = True, + ref_template: str = DEFAULT_REF_TEMPLATE, + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, + mode: JsonSchemaMode = "validation", + *, + union_format: Literal["any_of", "primitive_type_array"] = "any_of", + ) -> dict[str, Any]: + # Generation reads the core schemas of referenced models that another thread + # may be first-building at that moment, so it runs under the same lock (without + # it, concurrent first calls were observed returning ancestor schemas). This + # serialises every call, since pydantic regenerates the schema each time, which + # is acceptable: no request path generates protocol-model JSON schemas. + with _REBUILD_LOCK: + return super().model_json_schema( + by_alias=by_alias, + ref_template=ref_template, + schema_generator=schema_generator, + mode=mode, + union_format=union_format, + ) + + +# `DeferredModel` and `DeferredAdapter` override pydantic's build and schema entry points +# with pydantic's own signatures mirrored parameter-for-parameter, so type checkers and +# `inspect.signature` still see the real methods. If a future pydantic adds a parameter to +# one of them these overrides need updating; the signature-parity test in +# tests/types/test_wire_base.py is the tripwire. Pydantic decorates `TypeAdapter` with +# `@final`, but overriding `rebuild()` is the only hook for serialising a deferred adapter's +# first-use build (there is no lock in released pydantic); the subclass is behaviourally an +# ordinary TypeAdapter. +class DeferredAdapter(TypeAdapter[AdaptedT], Generic[AdaptedT]): # pyright: ignore[reportGeneralTypeIssues] + """`TypeAdapter` built on first use, with that first build serialised on the same lock. + + An adapter over a union of deferred models builds outside any model's own + `model_rebuild`, so its build is serialised here instead. Targets that carry + their own config (a BaseModel, dataclass or TypedDict, possibly wrapped in + `Annotated`) keep it; every other target gets `defer_build=True`. + """ + + def __init__( + self, type: Any, *, config: ConfigDict | None = None, _parent_depth: int = 2, module: str | None = None + ) -> None: + # pydantic rejects an adapter `config` for a target that carries its own (a + # BaseModel, dataclass or TypedDict, possibly wrapped in Annotated) - the same + # rule pydantic applies; a model target already defers via its own config, and + # every other target defers its build here. + target = get_args(type)[0] if get_origin(type) is Annotated else type + carries_config = inspect.isclass(target) and ( + issubclass(target, BaseModel) or is_dataclass(target) or is_typeddict(target) + ) + if config is None and not carries_config: + config = ConfigDict(defer_build=True) + super().__init__(type, config=config, _parent_depth=_parent_depth + 1, module=module) # +1: this frame + + def rebuild( + self, + *, + force: bool = False, + raise_errors: bool = True, + _parent_namespace_depth: int = 2, + _types_namespace: Mapping[str, Any] | None = None, + ) -> bool | None: + with _REBUILD_LOCK: # +1: this override adds one frame above pydantic's namespace lookup + return super().rebuild( + force=force, + raise_errors=raise_errors, + _parent_namespace_depth=_parent_namespace_depth + 1, + _types_namespace=_types_namespace, + ) + + +class WireModel(DeferredModel): """Base for generated wire models: enables `populate_by_name`; subclasses set `extra` themselves.""" model_config = ConfigDict(populate_by_name=True) + + +class WireRootModel(DeferredModel, RootModel[RootT], Generic[RootT]): + """Base for the generated root (union/alias) models: a deferred `RootModel`.""" diff --git a/src/mcp-types/mcp_types/jsonrpc.py b/src/mcp-types/mcp_types/jsonrpc.py index e9c6db96b4..09828bc929 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 Field, TypeAdapter + +from mcp_types._wire_base import DeferredAdapter, DeferredModel __all__ = [ "CONNECTION_CLOSED", @@ -36,7 +38,7 @@ """The JSON-RPC version string carried by every MCP message envelope.""" -class JSONRPCRequest(BaseModel): +class JSONRPCRequest(DeferredModel): """A JSON-RPC request that expects a response.""" jsonrpc: Literal["2.0"] @@ -45,7 +47,7 @@ class JSONRPCRequest(BaseModel): params: dict[str, Any] | None = None -class JSONRPCNotification(BaseModel): +class JSONRPCNotification(DeferredModel): """A JSON-RPC notification which does not expect a response.""" jsonrpc: Literal["2.0"] @@ -53,7 +55,7 @@ class JSONRPCNotification(BaseModel): params: dict[str, Any] | None = None -class JSONRPCResponse(BaseModel): +class JSONRPCResponse(DeferredModel): """A successful (non-error) response to a request. Named `JSONRPCResultResponse` in the 2025-11-25+ schemas; the SDK keeps the original name. @@ -110,7 +112,7 @@ class JSONRPCResponse(BaseModel): """ -class ErrorData(BaseModel): +class ErrorData(DeferredModel): """Error information for JSON-RPC error responses.""" code: int @@ -129,7 +131,7 @@ class ErrorData(BaseModel): """ -class JSONRPCError(BaseModel): +class JSONRPCError(DeferredModel): """A response to a request that indicates an error occurred.""" jsonrpc: Literal["2.0"] @@ -145,4 +147,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] = DeferredAdapter(JSONRPCMessage) diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 41959c56d7..ca2f7250f3 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -10,16 +10,16 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from functools import cache +from importlib import import_module from types import MappingProxyType, UnionType -from typing import Any, Final, Literal, TypeGuard, TypeVar, cast, get_args +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._wire_base import DeferredAdapter from mcp_types.version import KNOWN_PROTOCOL_VERSIONS __all__ = [ @@ -52,9 +52,64 @@ ] +_K = TypeVar("_K") +_V = TypeVar("_V") + + +class _WirePackage: + """Import-free stand-in for a `mcp_types._v*` package: `.Name` yields a `(module, name)` placeholder.""" + + def __init__(self, module: str) -> None: + self._module = module + + def __getattr__(self, name: str) -> Any: + return (self._module, name) + + +class _LazyRows(Mapping[Any, Any]): + """Surface-map store resolving `(module, name)` placeholders: `map[k]` imports; `in`/iter/len never do.""" + + def __init__(self, rows: dict[Any, Any]) -> None: + self._rows = rows + + def __getitem__(self, key: Any) -> Any: + row = self._rows[key] # KeyError here is the version gate. + if isinstance(row, tuple): # a wire type not yet imported: load its package now (once) + module, name = cast("tuple[str, str]", row) + row = self._rows[key] = getattr(import_module(module), name) + return row + + def __contains__(self, key: object) -> bool: + return key in self._rows + + def __iter__(self) -> Iterator[Any]: + return iter(self._rows) + + def __len__(self) -> int: + return len(self._rows) + + def __repr__(self) -> str: + return repr(dict(self)) + + +def _surface(rows: dict[_K, _V]) -> MappingProxyType[_K, _V]: + """Wrap `rows` read-only; placeholder rows load their wire package on first read.""" + return MappingProxyType(_LazyRows(rows)) + + +if TYPE_CHECKING: # a type checker (and bundlers) see the real wire packages behind the rows below + import mcp_types._v2025_11_25 as v2025 + import mcp_types._v2026_07_28 as v2026 +else: + # Deliberately lazy: each wire package is ~100 ms of pydantic model builds and a + # connection uses one version, so the rows below are placeholders until first read. + v2025 = _WirePackage("mcp_types._v2025_11_25") + v2026 = _WirePackage("mcp_types._v2026_07_28") + + # --- 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, @@ -126,7 +181,7 @@ } ) -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, @@ -156,7 +211,7 @@ # --- 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, @@ -180,7 +235,7 @@ } ) -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, @@ -230,7 +285,7 @@ # --- 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, @@ -303,7 +358,7 @@ ) """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, @@ -465,7 +520,8 @@ def _body(method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: @cache def _adapter(target: type[BaseModel] | UnionType) -> TypeAdapter[Any]: - return TypeAdapter(target) + # A per-call adapter cache shared across threads: build under the type layer's lock. + return DeferredAdapter(target) _MonolithT = TypeVar("_MonolithT") diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 28bc4703ed..7ac592bde1 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -1,3 +1,6 @@ +from importlib import import_module +from typing import TYPE_CHECKING + from mcp_types import ( CallToolRequest, ClientCapabilities, @@ -58,19 +61,56 @@ ) from mcp_types import Role as SamplingRole -# 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 +# The client/server names and the `mcp.types` submodule are bound lazily on +# first access (PEP 562), so `import mcp` no longer imports the client and +# server stacks; type checkers see the same names through the block below. +_LAZY = { # name -> the module it is imported from + "Client": "mcp.client.client", + "ClientSession": "mcp.client.session", + "ClientSessionGroup": "mcp.client.session_group", + "InputRequiredRoundsExceededError": "mcp.client._input_required", + "ServerSession": "mcp.server.session", + "StdioServerParameters": "mcp.client.stdio", + "stdio_client": "mcp.client.stdio", + "stdio_server": "mcp.server.stdio", +} +_SUBMODULES = ("client", "os", "server", "types") # bound as the submodule itself + +if TYPE_CHECKING: + 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 +else: + + def __getattr__(name: str) -> object: + if name in _SUBMODULES: + value: object = import_module(f"mcp.{name}") + elif name in _LAZY: + module_name = _LAZY[name] + # Import the module's package first, as an eager `import mcp` did: importing + # the submodule directly takes its module lock before its package's, which + # deadlocks a thread importing a sibling that package's __init__ pulls in. + # (Every lazy target is a direct child of mcp.client / mcp.server, and `mcp` + # itself is complete before __getattr__ can run, so the parent suffices.) + import_module(module_name.rpartition(".")[0]) + value = getattr(import_module(module_name), name) + else: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + globals()[name] = value # cache: every later access is a plain attribute lookup + return value + + def __dir__() -> list[str]: + return sorted(globals().keys() | _LAZY.keys() | set(_SUBMODULES)) + + __all__ = [ "CallToolRequest", "Client", diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index ed7c40f123..a27ab57ba6 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,9 @@ ServerCapabilities, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS -from typing_extensions import deprecated +from typing_extensions import TypeIs, deprecated 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 +58,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 +67,10 @@ from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.subscriptions import event_to_notification +if TYPE_CHECKING: # annotations only; the client never imports the server stack at runtime + from mcp.server import Server + from mcp.server.mcpserver import MCPServer + logger = logging.getLogger(__name__) ConnectMode = Literal["legacy", "auto"] | str @@ -99,10 +99,27 @@ async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_excepti return connect +# An in-process `Server`/`MCPServer` instance can only exist once its defining module has +# run, so an exact `isinstance` needs no import — just a `sys.modules` lookup. This keeps the +# whole server stack (starlette/uvicorn/...) out of `import mcp.client` for URL/Transport clients. +def _is_mcpserver(obj: object) -> TypeIs[MCPServer]: + cls = getattr(sys.modules.get("mcp.server.mcpserver.server"), "MCPServer", None) + return cls is not None and isinstance(obj, cls) + + +def _is_server(obj: object) -> TypeIs[Server[Any]]: + cls = getattr(sys.modules.get("mcp.server.lowlevel.server"), "Server", None) + 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).""" + # Local imports: only an in-process `Server` (whose stack is therefore already loaded) + # reaches here; binding these at module top would import the server stack for every client. + 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": @@ -388,11 +405,15 @@ 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_server(srv): self._connect = _connect_inproc(srv) elif isinstance(srv, str): + # Local import: httpx2 (the heavy HTTP client dep) loads with the first URL client, + # not for every `import mcp.client`; a stdio-only client never pays for it. + from mcp.client.streamable_http import streamable_http_client + self._connect = _connect_transport(streamable_http_client(srv)) else: self._connect = _connect_transport(srv) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 895339ca18..dbe59049b9 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -28,6 +28,7 @@ RequestParamsMeta, ) from mcp_types import methods as _methods +from mcp_types._wire_base import DeferredAdapter from mcp_types.version import ( HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, @@ -248,16 +249,19 @@ async def _default_logging_callback( pass -ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) +# Deferred: these adapters build their validators on first use rather than at import. +ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = DeferredAdapter( + types.ClientResult | types.ErrorData +) # Typed against the wide parse union so adopt-built claim adapters share this attribute type. -_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result] = TypeAdapter( +_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result] = DeferredAdapter( types.CallToolResult | types.InputRequiredResult ) -_GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = TypeAdapter( +_GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = DeferredAdapter( types.GetPromptResult | types.InputRequiredResult ) -_ReadResourceResultAdapter: TypeAdapter[types.ReadResourceResult | types.InputRequiredResult] = TypeAdapter( +_ReadResourceResultAdapter: TypeAdapter[types.ReadResourceResult | types.InputRequiredResult] = DeferredAdapter( types.ReadResourceResult | types.InputRequiredResult ) @@ -304,7 +308,9 @@ def _route(value: Any) -> str: arms: list[Any] = [Annotated[types.CallToolResult | types.InputRequiredResult, Tag(core_arm)]] arms += [Annotated[claim.model, Tag(tag)] for tag, claim in active.items()] # reduce(or_) rather than Union star-unpack, which needs py3.11+. - return TypeAdapter(Annotated[reduce(or_, arms), Discriminator(_route)]) + # DeferredAdapter, not TypeAdapter: this per-session union adapter also builds its + # deferred members on first use, so its build takes the same process-wide lock. + return DeferredAdapter(Annotated[reduce(or_, arms), Discriminator(_route)]) def _index_claims( diff --git a/src/mcp/server/auth/middleware/auth_context.py b/src/mcp/server/auth/middleware/auth_context.py index 1d34a5546b..a38ca9a89a 100644 --- a/src/mcp/server/auth/middleware/auth_context.py +++ b/src/mcp/server/auth/middleware/auth_context.py @@ -1,13 +1,18 @@ import contextvars +from typing import TYPE_CHECKING from starlette.types import ASGIApp, Receive, Scope, Send -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import AccessToken +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]("auth_context", default=None) +auth_context_var: "contextvars.ContextVar[AuthenticatedUser | None]" = contextvars.ContextVar( + "auth_context", default=None +) def get_access_token() -> AccessToken | None: @@ -30,11 +35,18 @@ class AuthContextMiddleware: """ def __init__(self, app: ASGIApp): + # `AuthenticatedUser` (starlette's authentication/request stack) is + # imported once per app here rather than at module top: import-time + # cost, so `get_access_token` above stays importable by transport- + # agnostic code (request_state) without loading starlette's HTTP stack. + from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + self.app = app + self._authenticated_user = AuthenticatedUser async def __call__(self, scope: Scope, receive: Receive, send: Send): user = scope.get("user") - if isinstance(user, AuthenticatedUser): + if isinstance(user, self._authenticated_user): # Set the authenticated user in the contextvar token = auth_context_var.set(user) try: diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 26425c1338..2f452e0d14 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -5,9 +5,6 @@ from typing import 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 pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue from pydantic_core import core_schema @@ -77,6 +74,10 @@ def _validate_rendered_properties(json_schema: dict[str, Any]) -> None: Catches whatever the renderer let through that isn't spec-valid: bare `list[str]` (no enum), multi-primitive unions, nested models. """ + # The gate's source of truth is the internal surface package; imported here on purpose, + # not at module top: the generated package is heavy and only elicitation needs it. + from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition + for field_name, prop in json_schema.get("properties", {}).items(): try: PrimitiveSchemaDefinition.model_validate(prop) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index efdf4b216e..e6d68d4268 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -43,38 +43,42 @@ 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, Final, 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 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.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: + # The HTTP transport stack is imported inside `streamable_http_app()`, its + # only user, so `import mcp.server` and stdio servers never load starlette, + # sse_starlette or uvicorn; these names are needed only in annotations. + from starlette.applications import Starlette + from starlette.routing import Mount, Route + + from mcp.server.streamable_http import EventStore + from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + from mcp.server.transport_security import TransportSecuritySettings + +DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024 +"""Default maximum Streamable HTTP request body size in bytes (4 MiB). + +Defined in this starlette-free module so the app-builder signatures can name it without +importing the HTTP stack; `mcp.server.streamable_http_manager` re-exports it. +""" + logger = logging.getLogger(__name__) LifespanResultT = TypeVar("LifespanResultT", default=Any) @@ -735,6 +739,23 @@ def streamable_http_app( debug: bool = False, ) -> Starlette: """Return an instance of the StreamableHTTP server app.""" + # The HTTP transport stack is imported here, in its only user, rather than + # at module top: import-time cost, so stdio servers never pay for it. + from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.middleware.authentication import AuthenticationMiddleware + from starlette.routing import 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 + from mcp.server.transport_security import TransportSecuritySettings + # 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/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index 2edf342337..0763c4f339 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,11 @@ 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: import-time cost, + # and this is the only resource type that needs the HTTP client stack. + 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 bc79c44a36..bbab5f497f 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 @@ -46,16 +46,7 @@ from mcp_types import Tool as MCPTool from pydantic import BaseModel 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 from mcp.server.caching import CacheableMethod, CacheHint @@ -68,7 +59,7 @@ validate_extension_identifier, ) from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.lowlevel.server import LifespanResultT, Server +from mcp.server.lowlevel.server import DEFAULT_MAX_REQUEST_BODY_SIZE, LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError @@ -84,15 +75,25 @@ 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: + # The HTTP transport stack is imported inside `sse_app()` / `custom_route()`, + # its only users, so stdio servers never load starlette, sse_starlette or + # uvicorn at import; these names are needed only in annotations. + 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 + + from mcp.server.streamable_http import EventStore + from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + from mcp.server.transport_security import TransportSecuritySettings + logger = get_logger(__name__) _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -1005,6 +1006,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 pay for it at import time. + from starlette.routing import Route + def decorator( func: Callable[[Request], Awaitable[Response]], ) -> Callable[[Request], Awaitable[Response]]: @@ -1097,6 +1102,19 @@ def sse_app( host: str = "127.0.0.1", ) -> Starlette: """Return an instance of the SSE server app.""" + # The SSE transport stack is imported here, in its only user, rather than + # at module top: import-time cost, so stdio servers never pay for it. + 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 + from mcp.server.transport_security import TransportSecuritySettings + # 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( @@ -1342,3 +1360,9 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st message=f"Client did not declare required extension {identifier!r}", data=data.model_dump(by_alias=True, mode="json", exclude_none=True), ) + + +# `Settings` names `MCPServer` (defined above) in a field annotation, so it is +# incomplete until then; complete it at import so no thread first-builds it +# concurrently during `MCPServer(...)` construction. +Settings.model_rebuild() diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 31f587ee66..07c79ac483 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 @@ -21,6 +21,7 @@ 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 +from mcp.server.lowlevel.server import DEFAULT_MAX_REQUEST_BODY_SIZE from mcp.server.runner import serve_connection, serve_loop from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport from mcp.server.transport_security import TransportSecuritySettings @@ -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/tests/client/test_client.py b/tests/client/test_client.py index 697383e8ae..138b26fbd1 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -397,7 +397,7 @@ 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: + 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") diff --git a/tests/server/mcpserver/resources/test_resources.py b/tests/server/mcpserver/resources/test_resources.py index 744f483619..cb577268bd 100644 --- a/tests/server/mcpserver/resources/test_resources.py +++ b/tests/server/mcpserver/resources/test_resources.py @@ -2,7 +2,7 @@ from mcp_types import Annotations from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.resources import FunctionResource, Resource +from mcp.server.mcpserver.resources import FunctionResource, HttpResource, Resource class TestResourceValidation: @@ -238,3 +238,31 @@ def dummy_func() -> str: # pragma: no cover ) assert resource.meta is None + + +@pytest.mark.anyio +async def test_http_resource_read_returns_the_fetched_body(monkeypatch: pytest.MonkeyPatch) -> None: + """SDK-defined: `HttpResource.read` fetches its URL and returns the response text. The HTTP + client is stubbed at its module boundary (no network); `read` is where that stack loads.""" + + class _StubResponse: + text = "hello from http" + + def raise_for_status(self) -> None: + pass + + class _StubClient: + async def __aenter__(self) -> "_StubClient": + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + async def get(self, url: str) -> _StubResponse: + assert url == "https://example.com/data" + return _StubResponse() + + monkeypatch.setattr("httpx2.AsyncClient", _StubClient) + resource = HttpResource(uri="https://example.com/data", url="https://example.com/data", name="data") + + assert await resource.read() == "hello from http" diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 70440d9d03..f8d2fd6686 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -1,5 +1,6 @@ """Tests for StreamableHTTPSessionManager.""" +import inspect import json import logging from collections.abc import Iterator @@ -17,6 +18,7 @@ from mcp.server import Server, ServerRequestContext, streamable_http_manager from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import AccessToken +from mcp.server.mcpserver import MCPServer from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport from mcp.server.streamable_http_manager import ( DEFAULT_MAX_REQUEST_BODY_SIZE, @@ -225,6 +227,18 @@ def test_request_body_limit_defaults_to_four_mib() -> None: assert manager.max_request_body_size == DEFAULT_MAX_REQUEST_BODY_SIZE == 4 * 1024 * 1024 +def test_app_signatures_default_to_the_same_request_body_limit() -> None: + """SDK-defined: the `max_request_body_size` defaults of the app/run entry points are spelled + as a value (their modules do not import the HTTP stack), so pin them to the one constant.""" + entry_points = ( + Server.streamable_http_app, + MCPServer.streamable_http_app, + MCPServer.run_streamable_http_async, + ) + defaults = {inspect.signature(f).parameters["max_request_body_size"].default for f in entry_points} + assert defaults == {DEFAULT_MAX_REQUEST_BODY_SIZE} + + @pytest.mark.parametrize("max_request_body_size", [0, -1]) def test_request_body_limit_rejects_non_positive_values(max_request_body_size: int) -> None: """SDK-defined: callers cannot disable request-size protection with a non-positive value.""" diff --git a/tests/test_import_footprint.py b/tests/test_import_footprint.py new file mode 100644 index 0000000000..bb972a3e4f --- /dev/null +++ b/tests/test_import_footprint.py @@ -0,0 +1,49 @@ +"""What each entry point imports: a ratchet on the SDK's import graph.""" + +import os +import subprocess +import sys + +import pytest + +# pydantic auto-loads installed pydantic plugins (logfire pulls in opentelemetry) at the +# first model build. That is the environment's cost, not the SDK's, so the children run +# with plugins disabled to measure the SDK's own import graph. +_CHILD_ENV = {**os.environ, "PYDANTIC_DISABLE_PLUGINS": "__all__"} + +_WEB_STACK = ("starlette", "sse_starlette", "uvicorn") + +# What a transport-agnostic server entry point must not load: starlette is banned by its +# heavy subpackages rather than the root, because the auth context accessor still uses +# starlette's small typing leaf. +_SERVER_WEB_STACK = ("starlette.applications", "starlette.routing", "starlette.requests", "sse_starlette", "uvicorn") + +# The generated per-version wire packages. No entry point loads them: a connection loads +# only its negotiated version's, on the first message it parses. +_WIRE_PACKAGES = ("mcp_types._v2025_11_25", "mcp_types._v2026_07_28") + +# Entry statement -> package prefixes it must not load (on top of the wire packages). +IMPORT_GUARDS: dict[str, tuple[str, ...]] = { + "import mcp": ("mcp.client", "mcp.server", *_WEB_STACK, "httpx2", "opentelemetry", "cryptography", "jwt"), + "import mcp.client.stdio": ("mcp.server", "httpx2"), + "import mcp.server.stdio": ("mcp.client", *_SERVER_WEB_STACK), + "import mcp_types.methods": (), +} + + +@pytest.mark.parametrize("statement", IMPORT_GUARDS) +def test_entry_point_does_not_import_unrelated_stacks(statement: str) -> None: + """SDK-defined: each entry point loads only what it needs, so a hoisted or newly-eager + import fails here instead of silently slowing every startup. Runs in a fresh interpreter, + since this process imported the whole SDK long ago.""" + probe = f"import sys\n{statement}\nprint(' '.join(sorted(sys.modules)))" + result = subprocess.run( + [sys.executable, "-c", probe], capture_output=True, text=True, check=False, timeout=30, env=_CHILD_ENV + ) + assert result.returncode == 0, result.stderr + loaded = result.stdout.split() + banned = (*IMPORT_GUARDS[statement], *_WIRE_PACKAGES) + hits = sorted( + {module for module in loaded for prefix in banned if module == prefix or module.startswith(prefix + ".")} + ) + assert hits == [] diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000000..ebee5b9dc7 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,101 @@ +"""The `mcp` package binds its client/server names, `mcp.types`, and its subpackages lazily (PEP 562).""" + +import json +import subprocess +import sys + +# Runs in a fresh interpreter: this test process imported the client/server stacks +# long ago, so only a clean one can observe first-access resolution and caching. +_LAZY_ACCESS_PROBE = """ +import json, sys +import mcp +report = { + "client_is_home_object": mcp.Client is sys.modules["mcp.client.client"].Client, + "client_cached_in_namespace": "Client" in vars(mcp), + "types_is_module": mcp.types is sys.modules["mcp.types"], + "subpackage_chain": mcp.client.session.ClientSession.__name__, + "dir_lists_every_all_name": set(mcp.__all__) <= set(dir(mcp)), + "unresolvable_all_names": [n for n in mcp.__all__ if getattr(mcp, n, None) is None], +} +try: + mcp.Toool +except AttributeError as exc: + report["typo_error"] = str(exc) +print(json.dumps(report)) +""" + + +# Runs in a fresh interpreter (each first access imports; a warm process has nothing to race). +# All threads are released at the same instant so their first-access imports genuinely overlap. +_THREADED_FIRST_ACCESS_PROBE = """ +import json, sys, threading +sys.setswitchinterval(1e-6) # maximise preemption inside the imports +import mcp +names = ["Client", "ClientSession", "ClientSessionGroup", "StdioServerParameters", "stdio_client", + "ServerSession", "stdio_server", "InputRequiredRoundsExceededError", "types", "client", "server"] +barrier = threading.Barrier(len(names)) +errors, resolved = [], {} + +def first_access(name): + barrier.wait() + try: + resolved[name] = getattr(mcp, name).__name__ + except BaseException as exc: # an import deadlock raises importlib's _DeadlockError, a RuntimeError + errors.append(f"{name}: {type(exc).__name__}: {exc}") + +threads = [threading.Thread(target=first_access, args=(n,), daemon=True) for n in names] +for thread in threads: + thread.start() +for thread in threads: + thread.join(20) +print(json.dumps({"errors": errors, "hung": [t.name for t in threads if t.is_alive()], + "resolved": sorted(resolved)})) +""" + + +def test_concurrent_first_access_of_different_lazy_names_never_deadlocks(): + """SDK-defined regression bar: threads that first-access different lazy `mcp.`s at + the same instant all resolve them. An eager `import mcp` used to serialise these imports; + the lazy resolution must not invert the import locks and raise a threaded-import deadlock.""" + result = subprocess.run( + [sys.executable, "-c", _THREADED_FIRST_ACCESS_PROBE], capture_output=True, text=True, check=False, timeout=60 + ) + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout) + assert report["errors"] == [] + assert report["hung"] == [] + assert report["resolved"] == sorted( + [ + "Client", + "ClientSession", + "ClientSessionGroup", + "InputRequiredRoundsExceededError", + "ServerSession", + "StdioServerParameters", + "client", + "server", + "stdio_client", + "stdio_server", + "types", + ] + ) + + +def test_lazy_names_resolve_to_their_home_objects_and_cache_on_first_access(): + """SDK-defined: a lazy `mcp.` is the object from its home module and is stored in the + package namespace once resolved; `mcp.types` and the `mcp.client` subpackage bind on first + access; `dir(mcp)` lists every `__all__` name and every `__all__` name resolves (so the lazy + tables cannot drift from `__all__`); and an unknown name is a plain AttributeError.""" + result = subprocess.run( + [sys.executable, "-c", _LAZY_ACCESS_PROBE], capture_output=True, text=True, check=False, timeout=20 + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "client_is_home_object": True, + "client_cached_in_namespace": True, + "types_is_module": True, + "subpackage_chain": "ClientSession", + "dir_lists_every_all_name": True, + "unresolvable_all_names": [], + "typo_error": "module 'mcp' has no attribute 'Toool'", + } diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 3e25bba23d..6154545dbf 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -549,6 +549,11 @@ def test_built_in_maps_are_immutable(): _assign_item(built_in) +def test_built_in_maps_repr_as_a_proxied_dict_of_their_rows(): + """The lazy row store is invisible in repr: each surface map renders as a proxied dict of its resolved rows.""" + assert repr(methods.SERVER_REQUESTS) == f"mappingproxy({dict(methods.SERVER_REQUESTS)!r})" + + 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)) diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py index 3531992141..b595af0274 100644 --- a/tests/types/test_parity.py +++ b/tests/types/test_parity.py @@ -142,6 +142,7 @@ def _wire_aliases(model: type[BaseModel]) -> set[str]: + model.model_rebuild() # deferred wire models resolve forward-ref field aliases on first build return {field.alias or name for name, field in model.model_fields.items()} diff --git a/tests/types/test_wire_base.py b/tests/types/test_wire_base.py new file mode 100644 index 0000000000..1b2ba34fc2 --- /dev/null +++ b/tests/types/test_wire_base.py @@ -0,0 +1,127 @@ +"""The deferred type-layer bases build on first use, and that first build is safe under concurrency.""" + +import inspect +import json +import os +import subprocess +import sys +from collections.abc import Callable + +from mcp_types._wire_base import DeferredAdapter, DeferredModel +from pydantic import BaseModel, TypeAdapter + +# The build lock only matters for the FIRST use of each model/adapter, which happens once per +# process, so the race runs in a fresh interpreter (this process built everything long ago). +# Barrier-released threads split four ways: some first-parse the same rows through `methods` +# (the shared per-target adapter cache), some first-use the module-level union adapters, some +# first-validate every never-built model, some first-generate the recursive JSON models' schema. +# Without the lock these interleavings raised on released pydantic (an adapter's schema +# regeneration iterating a class namespace another thread's completion mutates; a shared +# adapter's `__pydantic_core_schema__` swapping mid-build). +_RACE_PROBE = r""" +import json, sys, threading +sys.setswitchinterval(1e-6) # maximise 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 +import mcp.client.session as session +from mcp_types import methods + +MODULES = (_types, v2025, v2026, jsonrpc) +CLASSES = [ + obj for mod in MODULES for obj in vars(mod).values() + if isinstance(obj, type) and issubclass(obj, pydantic.BaseModel) and obj.__module__ == mod.__name__ +] +ADAPTERS = ( + _types.client_request_adapter, _types.client_result_adapter, _types.server_notification_adapter, + _types.server_result_adapter, jsonrpc.jsonrpc_message_adapter, session.ClientResponse, +) +# the same server-result rows first-parsed from every thread: the SDK route through the +# shared per-target adapter cache and the union-schema regeneration over unbuilt members +ROWS = [ + ("ping", "2025-11-25", {}), + ("tools/call", "2025-11-25", {"content": [{"type": "text", "text": "x"}], "isError": False}), + ("tools/list", "2026-07-28", {"tools": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"}), +] +N = 12 +barrier = threading.Barrier(N) +errors, guard = [], threading.Lock() + +def use_rows(): + for method, version, data in ROWS: + methods.parse_server_result(method, version, data) + +def use_adapters(): + for adapter in ADAPTERS: + try: + adapter.validate_python({"__probe__": 1}) + except pydantic.ValidationError: + pass # junk payload on purpose: the first-use build is what is under test + +def use_models(): + for cls in CLASSES: + try: + cls.model_validate({"__probe__": 1}) + except (pydantic.ValidationError, TypeError): + pass + +def use_schemas(): + for cls in (v2026.JSONObject, v2026.JSONArray, v2026.JSONValue): + cls.model_json_schema() + +def worker(job): + barrier.wait() + try: + job() + except Exception as exc: + with guard: + errors.append(f"{type(exc).__name__}: {exc}"[:200]) + +jobs = (use_rows, use_adapters, use_models, use_schemas) +threads = [threading.Thread(target=worker, args=(jobs[i % 4],)) for i 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_the_deferred_type_layer_never_raises() -> None: + """SDK-defined regression bar: twelve barrier-released threads first-using the never-built type + layer at once (parsed rows through the adapter cache, union adapters, every model, the + recursive JSON models' schema) raise nothing and leave every class complete.""" + result = subprocess.run( + [sys.executable, "-c", _RACE_PROBE], + capture_output=True, + text=True, + check=False, + timeout=60, + # 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"] > 500 # the probe really covered the type layer + assert report["errors"] == [] + assert report["incomplete"] == [] + + +def _shape(fn: Callable[..., object]) -> list[tuple[str, str, object]]: + empty = inspect.Parameter.empty + return [ + (p.name, str(p.kind), "" if p.default is empty else p.default) + for p in inspect.signature(fn).parameters.values() + ] + + +def test_deferred_bases_keep_pydantics_public_method_signatures() -> None: + """SDK-defined: the lock-guarding overrides replicate pydantic's parameters exactly, so the + typed signatures of these public methods are not erased on any model or adapter.""" + assert _shape(DeferredModel.model_rebuild) == _shape(BaseModel.model_rebuild) + assert _shape(DeferredModel.model_json_schema) == _shape(BaseModel.model_json_schema) + assert _shape(DeferredAdapter[int].rebuild) == _shape(TypeAdapter[int].rebuild) + assert _shape(DeferredAdapter[int]) == _shape(TypeAdapter[int])