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])