# Protocol versions MCP has two eras. Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result. You almost never have to care, because `Client` negotiates for you. This page is about the one constructor argument that controls it, `mode=`, and the three times you change it. ## `mode="auto"` ```python title="client.py" hl_lines="14-15" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` You didn't pass `mode`, so you got the default: `"auto"`. Entering `async with` sends a single `server/discover` probe at the newest version this SDK speaks. Then: * A **modern server** answers it. The client adopts the result. One round trip, done. * An **older server** has never heard of `server/discover` and returns an error. The client falls back to the classic `initialize` handshake and takes whatever that negotiates. Either way you come out connected, and `client.protocol_version` tells you which it was: ```text 2026-07-28 ``` That is the whole feature. One `Client`, any era of server, no branching in your code. !!! info `MCPServer` answers `server/discover` on every transport — in-memory, stdio, streamable HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly when you want it to. ## `mode="legacy"` ```python title="client.py" hl_lines="14" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` `mode="legacy"` never probes. It runs the `initialize` handshake, the same connection a pre-2026 client opens. ```text 2025-11-25 ``` Same server. It speaks `2026-07-28` perfectly well; you told the client not to ask. You want this for the **push-style** features. A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session. At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**[Multi-round-trip requests](handlers/multi-round-trip.md)**). `mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **[Client callbacks](client/callbacks.md)** goes through each. ## Pinning a version `mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`. ```python title="client.py" hl_lines="14" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` A pin sends **nothing**. No probe, no handshake. The client adopts `2026-07-28` locally and the connection is live the instant `async with` returns. A pin is a promise *you* make: you already know the server speaks that version. The client doesn't check. !!! check A pin is not a discovery. Print `client.server_info` and the price is right there: ```text name='' title=None version='' description=None website_url=None icons=None ``` The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities` is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it); code that reads `server_capabilities` to decide what to offer does not. The next section is the fix. Only modern versions are pinnable. A handshake-era string is rejected at construction, before any I/O, and the error tells you what to write instead: ```text ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') ``` ## Reconnecting with `prior_discover` The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time: ```python title="client.py" hl_lines="15 17" --8<-- "docs_src/protocol_versions/tutorial004.py" ``` ```text 2026-07-28 Bookshop ``` The second connection made **zero** negotiation round trips and still knows exactly who it is talking to. That is the pinned mode done properly: `mode=` names the version, `prior_discover=` supplies the identity. ✨ `DiscoverResult` is a Pydantic model. `saved.model_dump_json()` goes into a file or a cache; `DiscoverResult.model_validate_json(...)` brings it back in the next process. !!! tip `prior_discover=` only does anything when `mode` is a version pin. Under `"auto"` the client probes the server anyway, and under `"legacy"` it is ignored. ## The four modes | You write | Negotiation traffic | You get | | --- | --- | --- | | `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era | | `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work | | `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` | | `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time | ## Recap * MCP has a handshake era (up to `2025-11-25`, the `initialize` handshake) and a modern era (`2026-07-28`, `server/discover`). `Client` bridges them. * `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you. * `client.protocol_version` is always the answer to "what did I get?". * `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`. * A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. * `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](handlers/multi-round-trip.md)**.