Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/advanced/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
67 changes: 67 additions & 0 deletions docs/advanced/startup.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,26 @@ Both commands now pin the requirement to the version you are running
(`mcp==<installed version>`). 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.<name>` 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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
29 changes: 24 additions & 5 deletions scripts/gen_surface_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"
Expand Down
Loading
Loading