diff --git a/README.md b/README.md
index 1324ac57e..382297664 100644
--- a/README.md
+++ b/README.md
@@ -18,13 +18,13 @@
>
> **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.**
>
-> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).
+> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/) for the tour of what changed, and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for every breaking change. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml), or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).
## Documentation
**The documentation lives at .**
-It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
+It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
## What is MCP?
diff --git a/docs/index.md b/docs/index.md
index a729cfba2..8aa1a5b67 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -2,6 +2,7 @@
!!! info "You are viewing the in-development v2 documentation"
For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/).
+ New to v2, or coming from v1? **[What's new in v2](whats-new.md)** is the five-minute tour of what changed.
Trying v2? [Tell us what you find](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — it is the most useful thing you can do for the SDK right now.
The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a standardized way, separating the concern of *providing* context from the LLM interaction itself.
@@ -93,6 +94,7 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
* Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**.
* Already have a FastAPI or Starlette app? **[Add to an existing app](run/asgi.md)** mounts an MCP server inside it.
* Hunting an exact error message? **[Troubleshooting](troubleshooting.md)** is keyed by the verbatim text.
+* Wondering what changed in v2? **[What's new in v2](whats-new.md)** is the five-minute tour.
* Migrating from v1? Start with the **[Migration Guide](migration.md)**.
* Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source.
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
diff --git a/docs/migration.md b/docs/migration.md
index 186f3d40e..9ff5a054c 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -706,6 +706,26 @@ async def my_tool(x: int, ctx: Context) -> str:
return str(x)
```
+### Sync handler functions now run on a worker thread
+
+In v1, a synchronous (`def`) tool, resource, or prompt function was called inline on the event
+loop, so a body that blocked (an HTTP call with a sync client, `time.sleep()`, heavy
+computation) stalled every other in-flight request on the server. In v2 the SDK runs
+synchronous handler functions in a worker thread via `anyio.to_thread.run_sync()`;
+`async def` handlers are unchanged. Resolver functions (`Resolve(...)`) follow the same rule.
+
+Most servers simply gain concurrency. Port with care if a synchronous handler relied on
+running on the event-loop thread:
+
+- Thread-affine state (thread locals shared with startup code, non-thread-safe objects that
+ were only ever touched from the event loop's thread) is now touched from a worker thread.
+- `asyncio.get_running_loop()` inside a synchronous handler body raises `RuntimeError`; there
+ is no running loop in a worker thread.
+- Synchronous handlers can run concurrently with each other, up to anyio's default
+ worker-thread limit.
+
+Declare the handler `async def` to keep it on the event loop.
+
### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter
`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise.
@@ -1607,6 +1627,16 @@ params = CallToolRequestParams(
If you relied on extra fields round-tripping through MCP types, move that data into `_meta`.
+### `mcp dev` and `mcp install` pin the spawned environment to your SDK version
+
+Both commands run your server through a fresh `uv run --with ...` environment. In v1 the
+`mcp` requirement in that command was unpinned, so the spawned environment resolved to the
+newest stable release rather than the version you had installed; with a v2 pre-release
+installed, `mcp dev server.py` built a v1 environment that could not import a v2 server.
+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.
+
## New Features
### OAuth client credentials are bound to their authorization server ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352))
diff --git a/docs/whats-new.md b/docs/whats-new.md
new file mode 100644
index 000000000..d197833db
--- /dev/null
+++ b/docs/whats-new.md
@@ -0,0 +1,210 @@
+# What's new in v2
+
+Two things happened at once in v2. The **SDK was rebuilt**: a new engine under both the client and the server, a first-class `Client`, and a set of renames that a v1 codebase meets on its first import. And the **protocol moved**: v2 speaks the 2026-07-28 revision of MCP, which removes the connection handshake, the session, and every server-initiated request, without stranding the clients you already have.
+
+This page is the tour of both halves, one section per headline, each ending in the page that owns the topic. It is not the porting manual. That is the **[Migration Guide](migration.md)**: every breaking change, with before and after code.
+
+!!! note "v2 is a beta"
+ `pip install mcp` still installs v1.x: you opt into v2 with an exact version pin, and the
+ API can still move before the stable release, which lands alongside the spec release.
+ **[Installation](get-started/installation.md)** has the copy-paste install line and the
+ pinning rules. And if anything in v2 breaks, surprises, or slows you down,
+ [tell us](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml):
+ while v2 is in beta, that is the most useful thing you can send us.
+
+## The SDK: v1 to v2
+
+### `FastMCP` is now `MCPServer`
+
+The high-level server class was renamed, and its module with it. This is the first thing every v1 server hits, because the old import path is gone rather than deprecated:
+
+```python
+from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP
+
+mcp = MCPServer("Demo") # v1: FastMCP("Demo")
+```
+
+It is also, for a decorator-built server, most of the port. `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` accept what they accepted in v1 (`@mcp.resource()` adds one optional `security=` keyword), and the input schema still comes from your type hints. Around the edges: everything under `mcp.server.fastmcp.*` now lives under `mcp.server.mcpserver.*`, `ctx.fastmcp` is `ctx.mcp_server`, `get_context()` is gone (declare a `ctx: Context` parameter instead), and the exception base `FastMCPError` is `MCPServerError`. The **[Migration Guide](migration.md#fastmcp-renamed-to-mcpserver)** has the import table.
+
+### `Resolve`: the new way to ask the user for input
+
+Not everything a tool needs should come from the model. New in v2, a tool parameter annotated with `Resolve(fn)` is filled by a function you write instead, invisibly to the model, and that function can return `Elicit(...)` to put a question in front of the user. This is the preferred way to get anything from the client mid-call: the SDK carries the question over whichever mechanism the connection supports (a live elicitation request for a legacy client, a multi-round-trip on 2026-07-28), so one tool body serves both eras. **[Dependencies](handlers/dependencies.md)** is the page.
+
+!!! note
+ The other two forms remain when you need them: `ctx.elicit()` still works for clients on
+ legacy connections (**[Elicitation](handlers/elicitation.md)**), and a handler can return an
+ `InputRequiredResult` itself and drive the rounds by hand, which is also how sampling and
+ roots requests travel at 2026-07-28 (**[Multi-round-trip requests](handlers/multi-round-trip.md)**).
+
+### A first-class `Client`
+
+v1 handed you three nested layers: a transport context manager yielding raw streams, a `ClientSession` wrapped around them, and a hand-called `await session.initialize()`. v2 has one object:
+
+```python title="client.py" hl_lines="14-18"
+--8<-- "docs_src/client/tutorial001.py"
+```
+
+`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_info`, `client.server_capabilities`, and `client.protocol_version` are simply there afterwards. The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down.
+
+**[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper.
+
+### The low-level `Server` was rebuilt, not renamed
+
+If you work at the JSON-RPC layer, this is the "everything is different" part of v2. Here is the same one-tool server both ways; click the markers for what moved.
+
+
+
+```python title="v1"
+from typing import Any
+
+import mcp.types as types
+from mcp.server.lowlevel import Server
+
+server = Server("Bookshop")
+
+
+@server.list_tools() # (1)!
+async def list_tools() -> list[types.Tool]:
+ return [ # (2)!
+ types.Tool(
+ name="search_books",
+ description="Search the catalog by title or author.",
+ inputSchema={ # (3)!
+ "type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"],
+ },
+ )
+ ]
+
+
+@server.call_tool()
+async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)!
+ if name != "search_books":
+ raise ValueError(f"Unknown tool: {name}") # (5)!
+ ctx = server.request_context # (6)!
+ return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)!
+```
+
+1. Handlers are registered with decorators (called, with parentheses), any time after the server exists.
+2. You return a bare `list[Tool]` and the SDK wraps it into a `ListToolsResult`.
+3. Fields are camelCase in Python, and the schema is **enforced**: the SDK jsonschema-validates `call_tool` arguments against it before your function runs, which is why `arguments["query"]` below is safe.
+4. One `call_tool` handler serves every tool, and it receives the tool name and the already-validated arguments, unpacked and never `None`.
+5. Raising is how a v1 tool signals failure: any exception is caught and returned as `CallToolResult(isError=True)` with `str(e)` as its text, so the calling model reads this message and can retry.
+6. The context comes from an ambient ContextVar, reached through the server object mid-request.
+7. Bare content blocks are wrapped into a `CallToolResult` for you.
+
+```python title="v2"
+--8<-- "docs_src/whats_new/tutorial001.py"
+```
+
+1. Fields are snake_case now, and the schema is **advertised but never applied**: nothing checks the arguments before your handler runs.
+2. Every handler has the same shape: `async (ctx, params) -> result`. The context is the first argument (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` live on it); this is where `server.request_context` went.
+3. You build the full `ListToolsResult` yourself. Returning a bare list is a server-side `TypeError` now, not something the SDK wraps.
+4. Typed params in (`params.name`, `params.arguments`), a full result out. Nothing is unpacked, wrapped, or converted for you.
+5. Same check, different verb. A `ValueError` here would reach the model as an opaque `-32603` (see below), so a deliberate wire error is raised as `MCPError`: it passes through with its code and message intact, and `-32602` with this text is the spec's own answer for an unknown tool.
+6. `params.arguments` can be `None`; v1 defaulted it to `{}` before your code ever saw it. With no validation in front of the handler, this line is load-bearing.
+7. An unexpected exception raised here becomes a **sanitized** protocol error, `-32603` `"Internal server error"`: the model never sees the message. For a failure the model should read and react to, return `CallToolResult(is_error=True, ...)`.
+8. Handlers are constructor arguments, so the server's surface is complete the moment it exists; `add_request_handler()` is the post-construction escape hatch, and the door to custom methods.
+
+The example is the pattern. More generally: every handler has the same shape, with typed params in and a full result type out; the old jsonschema check of tool arguments is gone; an exception is a protocol error, never an `is_error=True` tool result; and the ambient `server.request_context` ContextVar is gone. Custom, vendor-namespaced methods are first class through `add_request_handler(method, params_type, handler)`, which validates inbound params against your model before your handler runs. And a `middleware` list (deliberately marked provisional) wraps every inbound message, replacing the private `_handle_*` methods people used to override.
+
+Underneath, the v1 `BaseSession` receive loop was replaced by a dispatcher engine that the client and the server now share, and it is what makes several things on this page true at once: one `Server` object serves both protocol eras, `Client(server)` dispatches in process with no JSON-RPC framing, and a timed-out client request now actually cancels the server-side handler.
+
+**[The low-level Server](advanced/low-level-server.md)** is the page; the **[Migration Guide](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** walks every removed hook. If you never dropped below `MCPServer`, none of this touches you.
+
+### The wire types moved to `mcp-types`, and every field is snake_case
+
+The protocol types now live in their own distribution, `mcp-types`, imported as `mcp_types`. It depends on nothing but pydantic and typing-extensions, so a gateway, a proxy, or a code generator can consume MCP's wire shapes without installing an HTTP stack. `mcp` depends on it at an exact version and re-exports the common names, so `from mcp import Tool` still works; `import mcp.types` does not.
+
+On those types, every Python attribute is now snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. The JSON on the wire is camelCase, exactly as before; only the attribute spelling changed. Two stricter defaults ride along: unknown fields are ignored instead of round-tripped (put extras in `_meta`), and both sides validate traffic against the protocol version they negotiated. See the **[Migration Guide](migration.md#field-names-changed-from-camelcase-to-snake_case)** for the rename table.
+
+### Transport configuration moved to `run()`
+
+`MCPServer(...)` is about what your server *is*: its name, its instructions, its lifespan, its auth. How it is *served* now belongs to `run()` and the app builders, which is where `host`, `port`, `stateless_http`, `json_response`, the endpoint paths, and `transport_security` went (`MCPServer("x", port=9000)` is a `TypeError`). The overloads are typed per transport, so your editor tells you which options `stdio` takes and which `streamable-http` takes. One removal worth knowing: `mount_path` is gone; mounting the ASGI app is the supported way to serve under a prefix.
+
+**[Running your server](run/index.md)** covers the options; **[Add to an existing app](run/asgi.md)** covers mounting.
+
+### Behavior that changes without an import error
+
+The renames announce themselves. These do not:
+
+* **Sync functions run on a worker thread.** A `def` tool (or resource, prompt, or resolver) no longer blocks the event loop; the trade is that its body no longer runs *on* the event-loop thread, which matters to thread-affine code. `async def` handlers are untouched. **[Migration Guide](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**.
+* **`MCPError` (v1's `McpError`) raised inside a tool is a protocol error now.** The model never sees it. Every other exception still becomes an `is_error=True` result the model can read and react to. **[Handling errors](servers/handling-errors.md)** is the split.
+* **Results are validated before they leave.** A hand-built `Tool` whose `input_schema` is `{}` now fails `tools/list` (the spec requires `"type": "object"`). Servers built on `@mcp.tool()` never see this; the SDK writes their schemas.
+* **Your client validates what it receives.** `list_tools()` and `call_tool()` check the server's answer against the negotiated protocol version, so a not-quite-valid server that v1's lenient parse tolerated now raises `pydantic.ValidationError`. If you connect to servers you do not control, expect to be the one who finds them; the **[Migration Guide](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** has the details.
+* **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**.
+* **The streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**.
+* **`mcp dev` and `mcp install` pin the environment they spawn** to your installed SDK version. Both commands run your server in a fresh `uv run --with ...` environment, which used to resolve `mcp` to the newest stable release rather than the version you are developing against. **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**.
+
+### Removed outright
+
+Each of these is a section in the **[Migration Guide](migration.md)**:
+
+* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification.
+* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
+* `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths.
+* The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams).
+* `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor.
+* `MCPServer.get_context()`, `mount_path=`, and the lowlevel `Server`'s decorator methods, ContextVar, and handler dicts.
+
+## The protocol: 2025-11-25 to 2026-07-28
+
+v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. What follows is what the new revision itself changes.
+
+### No handshake, no session
+
+A 2026-07-28 client does not open a connection, negotiate, and then talk. Every request carries its protocol version, client info, and client capabilities in `_meta`, and the one discovery call, `server/discover`, is a plain request like any other. `Client` does the right thing by default: it probes `server/discover` once and falls back to the `initialize` handshake if the server is older.
+
+Over Streamable HTTP there is no `Mcp-Session-Id` on the 2026 path, which is the operational headline: **nothing ties a modern request to a worker**, so any replica behind a plain round-robin load balancer can answer it. Two honest qualifiers. Your 2025-era clients (today, that is most clients) still open sessions and still need whatever stickiness they needed on v1; nothing changes for them. And the one thing a *multi-round-trip* retry has to carry across workers is its sealed `request_state`, whose default key is minted per process, so a scaled-out deployment passes `RequestStateSecurity(keys=[...])`. (`stateless_http=True` is unrelated: it only affects how 2025-era clients are served, and 2026 traffic never reads it; if you already set it in v1, nothing changes.)
+
+**[Protocol versions](protocol-versions.md)** is the client's side of this, **[Deploy & scale](run/deploy.md)** is the operator's checklist (the Host allowlist, the `request_state` key, notifications across replicas), and **[Serving legacy clients](run/legacy-clients.md)** is the both-eras-at-once story.
+
+### The server cannot call the client: multi-round-trip requests
+
+Every server-initiated request is gone at 2026-07-28: push elicitation, sampling, `roots/list`. On a 2026 connection there is no channel for them, so `ctx.elicit()` and `ctx.session.create_message()` fail there with `NoBackChannelError` (they still work for legacy clients).
+
+The replacement turns the call around. A tool that needs something from the user *returns* the question (`InputRequiredResult`), the client answers it with the same callbacks it always had, and the call is retried with the answers attached. `Client` drives that loop for you. On the server you rarely build the result yourself, because a **[dependency](handlers/dependencies.md)** does it: annotate a parameter with `Resolve(ask_quantity)`, where `ask_quantity` is an ordinary function you write, and the SDK asks over whichever mechanism the connection supports, a live elicitation request on a legacy session or a multi-round-trip on 2026. One tool body, both eras:
+
+```python title="dual_era.py" hl_lines="24 37-38"
+--8<-- "docs_src/legacy_clients/tutorial001.py"
+```
+
+That file is the pitch in one place: one server, one `Resolve`-backed tool, and a legacy client plus a modern client both getting their answer, in memory. **[Multi-round-trip requests](handlers/multi-round-trip.md)** explains the mechanism (including `request_state`, which the SDK seals and verifies for you); **[Elicitation](handlers/elicitation.md)** covers the asking.
+
+!!! warning "This is the one place a ported v1 server changes behavior"
+ Your own tests hit it first: `Client(mcp)` negotiates 2026-07-28 against your v2 server by
+ default, so a tool that calls `ctx.elicit()` fails in a test that passed on v1. Move the
+ question into a `Resolve(...)` parameter (era-portable), or pin the test client to
+ `mode="legacy"` if you genuinely want the push behavior.
+
+### Roots, sampling, and protocol logging are deprecated; `ping` is removed
+
+[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates three whole *capabilities*, on every protocol version: roots, sampling, and MCP-level logging (`ctx.info()` and friends). That is a separate axis from the missing back-channel above; deprecated is advisory, everything keeps working against 2025-era sessions, and nothing changes on the wire. What you notice is `MCPDeprecationWarning`, which is a `UserWarning`, so it prints by default; expect your first `ctx.info(...)` after the upgrade to say so.
+
+`ping` is stricter: removed from the protocol, not deprecated. Two of the deprecated features' standalone methods are removed at 2026-07-28 the same way, `logging/setLevel` and the client's `notifications/roots/list_changed`, and progress notifications are now server-to-client only.
+
+**[Deprecated features](deprecated.md)** has the full table, the replacement for each, and the one-line filter if you need a quiet log while you serve legacy clients.
+
+### Change notifications become one stream
+
+At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. Two honest caveats as of `2.0.0b1`: the Python `Client` cannot open the listen stream yet (the driver ships in a later pre-release), and over stdio the server does not serve it. The net for a Python *client* on that release is that nothing delivers change notifications on a 2026-07-28 connection; a host that relies on `resources/updated` should connect with `mode="legacy"` until the driver lands.
+
+**[Subscriptions](handlers/subscriptions.md)** on the server, and **[Deploy & scale](run/deploy.md)** for the bus.
+
+### The rest, quickly
+
+* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules.
+* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**.
+* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**.
+* **Error codes got standardized.** A missing resource is `-32602` with the URI in `error.data`, and the new spec-reserved codes appear as `-32020` (header mismatch), `-32021` (missing required capability), and `-32022` (unsupported protocol version). **[Troubleshooting](troubleshooting.md)** is keyed by the exact messages.
+* **Authorization got harder to hold wrong.** The client validates the `iss` returned with the authorization code ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); your `callback_handler` now returns an `AuthorizationCodeResult`), sends `application_type` when it registers, and never replays credentials against a different authorization server. New in the enterprise corner: the [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow. The **[Migration Guide](migration.md)** lists every OAuth change; **[OAuth for clients](client/oauth-clients.md)** and **[Identity assertion](client/identity-assertion.md)** are the pages.
+* **Every server is traceable.** OpenTelemetry ships on by default as middleware: every request gets a server span, at no cost until the process configures an exporter. When both ends run the SDK, the client also propagates W3C trace context in `_meta`, so the traces join up. **[OpenTelemetry](run/opentelemetry.md)**.
+
+## Upgrading from v1?
+
+* The **[Migration Guide](migration.md)** is the complete, exact list of what to change; this page was the why.
+* **v1.x is not going anywhere.** It stays the stable line, with critical fixes and security patches, and nothing about the 2026-07-28 spec release breaks it. If you publish a library that depends on `mcp`, add an upper bound (for example `mcp>=1.27,<2`) so stable v2 does not surprise your users.
+* Something rough, confusing, or broken? **[File v2 feedback](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; it all gets read.
diff --git a/docs_src/whats_new/__init__.py b/docs_src/whats_new/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs_src/whats_new/tutorial001.py b/docs_src/whats_new/tutorial001.py
new file mode 100644
index 000000000..5e41ae1c0
--- /dev/null
+++ b/docs_src/whats_new/tutorial001.py
@@ -0,0 +1,37 @@
+from mcp_types import (
+ INVALID_PARAMS,
+ CallToolRequestParams,
+ CallToolResult,
+ ListToolsResult,
+ PaginatedRequestParams,
+ TextContent,
+ Tool,
+)
+
+from mcp import MCPError
+from mcp.server import Server, ServerRequestContext
+
+SEARCH_BOOKS = Tool(
+ name="search_books",
+ description="Search the catalog by title or author.",
+ input_schema={ # (1)!
+ "type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"],
+ },
+)
+
+
+async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: # (2)!
+ return ListToolsResult(tools=[SEARCH_BOOKS]) # (3)!
+
+
+async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # (4)!
+ if params.name != "search_books":
+ raise MCPError(INVALID_PARAMS, f"Unknown tool: {params.name}") # (5)!
+ args = params.arguments or {} # (6)!
+ text = f"Found 3 books matching {args['query']!r}."
+ return CallToolResult(content=[TextContent(type="text", text=text)]) # (7)!
+
+
+server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) # (8)!
diff --git a/mkdocs.yml b/mkdocs.yml
index 5da05cc42..2d4754f1c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -12,6 +12,7 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/
nav:
- MCP Python SDK: index.md
+ - "What's new in v2": whats-new.md
- Get started:
- get-started/index.md
- Installation: get-started/installation.md
diff --git a/tests/docs_src/test_whats_new.py b/tests/docs_src/test_whats_new.py
new file mode 100644
index 000000000..d9c6143e2
--- /dev/null
+++ b/tests/docs_src/test_whats_new.py
@@ -0,0 +1,65 @@
+"""`docs/whats-new.md`: the v2 half of the low-level before/after example, proved against the real SDK.
+
+The v1 half of that example targets the 1.x line and cannot run here; it was
+validated by running it verbatim against a real `mcp==1.28.1` install.
+"""
+
+import pytest
+from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, TextContent
+
+from docs_src.whats_new import tutorial001
+from mcp import Client, MCPError
+
+# See test_index.py for why this is a per-module mark and not a conftest hook.
+pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+
+
+async def test_the_advertised_schema_is_the_literal_dict() -> None:
+ """Annotation 1: the schema is advertised to clients exactly as written."""
+ async with Client(tutorial001.server) as client:
+ (tool,) = (await client.list_tools()).tools
+ assert tool.name == "search_books"
+ assert tool.input_schema == {
+ "type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"],
+ }
+
+
+async def test_a_valid_call_answers() -> None:
+ """The example works end to end through the in-process `Client`."""
+ async with Client(tutorial001.server) as client:
+ result = await client.call_tool("search_books", {"query": "dune"})
+ assert not result.is_error
+ assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
+
+
+async def test_arguments_are_not_validated_and_a_handler_exception_is_sanitized() -> None:
+ """Annotations 1, 6, and 7, in one flow.
+
+ A call missing the required `query` REACHES the handler (nothing validates
+ arguments against `input_schema`; v1 rejected this call before the handler
+ ran). The handler's own `KeyError` then comes back as a sanitized protocol
+ error, never an `is_error=True` result the model could read. A call with no
+ arguments at all exercises `params.arguments or {}` the same way.
+ """
+ async with Client(tutorial001.server) as client:
+ with pytest.raises(MCPError) as excinfo:
+ await client.call_tool("search_books", {"limit": 5})
+ assert excinfo.value.code == INTERNAL_ERROR
+ assert excinfo.value.message == "Internal server error"
+
+ with pytest.raises(MCPError) as excinfo:
+ await client.call_tool("search_books")
+ assert excinfo.value.code == INTERNAL_ERROR
+ assert excinfo.value.message == "Internal server error"
+
+
+async def test_an_unknown_tool_is_a_deliberate_wire_error() -> None:
+ """Annotation 5: a raised `MCPError` passes through with its code and message
+ intact (the spec's answer for an unknown tool), unlike the sanitized path."""
+ async with Client(tutorial001.server) as client:
+ with pytest.raises(MCPError) as excinfo:
+ await client.call_tool("shelve_book", {"query": "dune"})
+ assert excinfo.value.code == INVALID_PARAMS
+ assert excinfo.value.message == "Unknown tool: shelve_book"
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 9236503a9..0104d5398 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -102,6 +102,7 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
find_examples(
"README.md",
"docs/index.md",
+ "docs/whats-new.md",
"docs/protocol-versions.md",
"docs/deprecated.md",
"docs/troubleshooting.md",