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 haven't had to care, because Client negotiates for you. This chapter is about the one constructor argument that controls it, mode=, and the three times you change it.
mode="auto"
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
async def main() -> None:
async with Client(mcp) as client:
print(client.protocol_version)
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/discoverand returns an error. The client falls back to the classicinitializehandshake and takes whatever that negotiates.
Either way you come out connected, and client.protocol_version tells you which it was:
2026-07-28
That is the whole feature. One Client, any era of server, no branching in your code.
Info
MCPServer answers server/discover, so against your own in-memory 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"
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
async def main() -> None:
async with Client(mcp, mode="legacy") as client:
print(client.protocol_version)
mode="legacy" never probes. It runs the initialize handshake, the same connection a pre-2026 client opens.
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).
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 goes through each.
Pinning a version
mode also accepts a modern protocol version string. Today that set is exactly ["2026-07-28"].
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
async def main() -> None:
async with Client(mcp, mode="2026-07-28") as client:
print(client.protocol_version)
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:
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:
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:
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
async def main() -> None:
async with Client(mcp) as client:
saved = client.session.discover_result
async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client:
print(client.protocol_version)
print(client.server_info.name)
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, theinitializehandshake) and a modern era (2026-07-28,server/discover).Clientbridges them. mode="auto"is the default: probe, fall back. Leave it alone unless one of the other three rows describes you.client.protocol_versionis 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 blankserver_info. prior_discover=pays that cost back: saveclient.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.