Migrate a 2025-era server
About 26 minutes to read, with 6 examples in 9 sections.
Written with AI. Verified by people. How we make books
Anyone can check this book or correct it. Report an error · How to fix one
Labs
Learn this chapter by doing it
Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Opens in about 30 seconds.
Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Included with membership: 20 hours a month with Member, or 60 with Member Plus.
Checking your hours…
Checking for your Lab…
Every chapter is read aloud for members. Chapter 1 of every book is free for everyone. Become a member, or sign in.
Read by an AI voice, word for word from this edition's text. Code, output and tables are on the page.
Pocket TTS by Kyutai, voice alba, from Alba MacKenna's recordings, CC BY 4.0.Pocket TTS by Kyutai, voice eve, from VCTK corpus speaker p361, CSTR, University of Edinburgh, CC BY 4.0, adapted by Kyutai.
On this page
- Inventory what the old server depends on
- Remove initialize and session IDs
- Move from HTTP+SSE to Streamable HTTP
- Replace sampling, roots and logging
- Replace dynamic client registration
- Port the code from SDK 1.x to 2.x
- Serve old and new clients through the window
- What goes wrong in production
- Decisions this chapter made
By the end of this chapter you can move a Model Context Protocol server written for the 2025 revisions and the Python SDK's 1.x line to the specification at revision 2026-07-28 and mcp 2.3.0: find what it depends on, remove its sessions, move it off the HTTP+SSE transport, replace sampling, roots, logging and Dynamic Client Registration, port the code, and serve clients of both eras while the old ones are still around.
This book follows one case study throughout: a small online shop for kitchen and garden goods, which we call the shop, and whose support team answers customer tickets through a host. Meet the shop and its API introduces it. Every earlier chapter built the shop's server, shop-support, for the pinned revision from the start. Most teams don't start there. They have a server that a 2025-era team wrote against revisions 2025-03-26 to 2025-11-25 with SDK 1.x, and hosts that still speak those revisions. This chapter is the only one in the book that teaches the 2025 protocol, and it teaches it only as the thing you migrate from.
Between those revisions and the pinned one, the Model Context Protocol (MCP) removed the initialize handshake and protocol sessions, removed requests that a server pushes to the client in the middle of a call, and deprecated five features and two values of one sampling field (changelog). The shop's 2025-era server uses most of that. Here it is, as a team would have written it for mcp==1.30.0:
"""shop-support as a 2025-era team wrote it, for SDK 1.x and revisions up to 2025-11-25."""
import os
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession
from mcp.types import SamplingMessage, TextContent
from pydantic import BaseModel
import shop_calls # the shop's API, as chapter 3 calls it; not shown
server = FastMCP("shop-support", "Answers the support team's questions about orders, refunds and stock.",
port=8000, stateless_http=False)
quotes: dict[ServerSession, dict] = {} # each connection's open quote
class Release(BaseModel):
release: bool
@server.tool()
async def quote_refund(order_id: str, ctx: Context) -> dict:
quotes[ctx.session] = quote = shop_calls.quote(order_id)
await ctx.info(f"quoted {quote['amount']} for {order_id}")
return quote
@server.tool()
async def issue_refund(ctx: Context) -> dict:
quote = quotes.pop(ctx.session) # the quote this connection made last
if not quote["within_limit"]:
answer = await ctx.elicit(f"Refund {quote['amount']}? It is above the policy limit.", Release)
if answer.action != "accept" or not answer.data.release:
return shop_calls.hold(quote)
return shop_calls.refund(quote)
@server.tool()
async def triage_ticket(text: str, ctx: Context) -> str:
reply = await ctx.session.create_message(
[SamplingMessage(role="user", content=TextContent(type="text", text=text))],
max_tokens=20, system_prompt="Name the one shop tool this ticket needs.")
return reply.content.text
@server.tool()
async def save_restock_report(ctx: Context) -> str:
folder = (await ctx.session.list_roots()).roots[0].uri.path
return shop_calls.write_restock_report(folder)
server.run(transport="sse" if os.environ.get("SHOP_OLD_HOSTS") else "streamable-http") # HTTP+SSE: the 2024 transport
The listing is the 2025-era shop server, and a shared module for this chapter, lib.legacy, holds it as text with the host's OAuth setup and the rules for finding each dependency. It is shown and scanned, never run. SDK 1.x and SDK 2.x are both the mcp distribution, so one environment holds only one of them, and the examples' environment pins 2.x. Every example in the chapter runs the ported server on 2.x instead, or reads the listing. shop_calls stands for the calls to the shop's API that chapter 3's tools make; the listing doesn't show it.
Inventory what the old server depends on
A migration starts with a list, because the pinned revision treats the old features in three different ways, and each way sets a different deadline. Some were removed at revision 2026-07-28, with no window: the initialize handshake, protocol sessions and the Mcp-Session-Id header, requests a server sends to the client in the middle of a call, whatever they ask for, the HTTP GET stream, resources/subscribe, ping, logging/setLevel and stream resumability (changelog). A client at the pinned revision never uses them, so code that depends on them fails for that client the first time it runs.
Others are deprecated: roots, sampling, logging, the HTTP+SSE transport and Dynamic Client Registration. The changelog says that roots, sampling and logging stay "fully functional during the deprecation window" (same source), and the specification's registry gives each deprecated feature a migration path and an earliest removal (deprecated features). The earliest removal is when a feature becomes eligible to go, not when it goes. Removal is a decision the core maintainers make when they prepare a revision, and a feature may stay deprecated for much longer (feature lifecycle). The window is at least twelve months from the release of the revision that first marks the feature deprecated, and can be cut to ninety days for an active security risk, so the windows differ from feature to feature. Removal from the specification also doesn't oblige an SDK to drop the feature; each SDK's own policy decides that (same source).
The third kind isn't the protocol's at all. SDK 2.x renamed and moved parts of the 1.x API, so a 1.x server fails to start on 2.x even where the protocol didn't change (migration guide).
The example parses the server listing above and the host's OAuth setup, shown in Replace dynamic client registration, with Python's ast module and matches each node against lib.legacy.RULES. Each rule names a dependency, its status at the pinned revision, its replacement and, for a deprecated feature, the registry's earliest removal. The example then prints them grouped by status, with the function each one sits in.
1"""Scan the 2025-era shop server and its host's OAuth setup for every dependency that revision 2026-07-28 removes or2deprecates, and every SDK 1.x API that SDK 2.x renamed or moved."""3import ast45from lib import legacy67found: dict[str, list[str]] = {rule.feature: [] for rule in legacy.RULES}8910def where(tree: ast.Module, node: ast.AST, listing: str) -> str:11 """The function a node sits in, or the listing's setup when it sits in none."""12 for function in ast.walk(tree):13 if isinstance(function, ast.AsyncFunctionDef | ast.FunctionDef) and node in ast.walk(function):14 return function.name15 return f"{listing} setup"161718for listing, source in legacy.LISTINGS.items():19 tree = ast.parse(source) # parsed, never run: SDK 1.x can't install beside 2.x20 for node in ast.walk(tree):21 for rule in legacy.RULES:22 if rule.matches(node) and where(tree, node, listing) not in found[rule.feature]:23 found[rule.feature].append(where(tree, node, listing))2425print("written for:", legacy.WRITTEN_FOR)26for kind in ("removed", "deprecated", "sdk"):27 rules = [rule for rule in legacy.RULES if rule.kind == kind and found[rule.feature]]28 print(f"{kind} dependencies:", len(rules))29 for rule in rules:30 print(f" {rule.feature}: {', '.join(found[rule.feature])} -> {rule.replacement}")31 if rule.removal:32 print(f" {rule.feature} earliest removal: {rule.removal}")
python scan-a-2025-server.pywritten for: mcp==1.30.0
removed dependencies: 2
sessions: server setup, quote_refund, issue_refund -> a handle the server mints, passed back as a tool argument
pushed requests: triage_ticket, issue_refund, save_restock_report -> an input request in a Multi Round-Trip Request
deprecated dependencies: 5
sampling: triage_ticket -> a model call the host or the server makes itself
sampling earliest removal: first revision released on or after 2027-07-28
roots: save_restock_report -> a tool parameter, a resource URI or server configuration
roots earliest removal: first revision released on or after 2027-07-28
logging: quote_refund -> stderr on stdio, or OpenTelemetry
logging earliest removal: first revision released on or after 2027-07-28
HTTP+SSE transport: server setup -> Streamable HTTP
HTTP+SSE transport earliest removal: three months after SEP-2596 reaches Final
dynamic client registration: host setup -> a Client ID Metadata Document
dynamic client registration earliest removal: first revision released on or after 2027-07-28
sdk dependencies: 3
FastMCP: server setup -> mcp.server.mcpserver.MCPServer
positional instructions: server setup -> instructions= by keyword
transport settings on the constructor: server setup -> arguments to run() or streamable_http_app()
The scan found 2 removed dependencies, 5 deprecated ones and 3 SDK changes. The removed ones come first because they fail for every client at the pinned revision: the sessions that quote_refund and issue_refund share, and the requests that three tools push in the middle of a call, ctx.elicit in issue_refund, create_message in triage_ticket and list_roots in save_restock_report. So sampling and roots are on both lists. As features they are deprecated, and they still work through their window: for a 2025-era client as written, and for a client at the pinned revision only when the server asks for them through a round trip, as Replace sampling, roots and logging shows. Pushed mid-call, they fail for that client as ctx.elicit does; SDK 2.3.0 raises NoBackChannelError (mcp/server/_streamable_http_modern.py). Of sampling, roots and logging, only logging works as written for a client at the pinned revision, and only for a request whose _meta asks for a log level; for any other request the SDK drops the line (mcp/server/connection.py). Four of the deprecated features share one earliest removal, the first revision released on or after 2027-07-28. The HTTP+SSE transport's is different: three months after SEP-2596 reaches Final. It had already been deprecated for more than a year when that proposal set the policy, so it got a three-month grace period instead of twelve months (SEP-2596). The proposal's page marks it Final without a date, so the transport may already be eligible.
The SDK at 2.3.0 doesn't warn about all of these either. Its sampling, roots and logging APIs emit MCPDeprecationWarning, a UserWarning that prints by default (what's new). Its HTTP+SSE app and client and its registration code carry no deprecation marker: we searched mcp/server/sse.py, mcp/client/sse.py, mcp/server/auth/handlers/register.py and mcp/client/auth/oauth2.py for "deprecat" and found nothing. A server can run on 2.x without a single warning and still depend on two deprecated features.
Working rule: Inventory before you change anything, and sort the list by status. Fix every removed dependency before you let a client at the pinned revision connect, because there is no window for it, and count every request the server pushes mid-call as removed, even when the feature it asks for is only deprecated. Give each deprecated one a date of your own, earlier than the registry's earliest removal, and don't wait for warnings: run a scan like this one in CI, because the SDK is silent about some deprecated features.
Remove initialize and session IDs
The scan in Inventory what the old server depends on put sessions first. Under the 2025 revisions, a connection started with initialize, and a server on Streamable HTTP could assign a session ID in its answer. The client then had to send Mcp-Session-Id on every request, start a new session when the server answered 404, and end it with DELETE (2025-11-25 transports). A server could keep anything it liked per session.
The pinned revision removes the handshake and the sessions. Each request carries its protocol version and the client's capabilities in _meta, a client asks what a server supports with server/discover, which every server MUST implement, and a server that needs state across calls mints an explicit handle and has the client pass it back as an ordinary tool argument (changelog). Carry the version and capabilities on every request shows the _meta keys.
The 2025-era shop server keeps one piece of state per session. quote_refund stores the quote under ctx.session, the SDK's object for the connection, and issue_refund takes no argument and refunds whatever quote that connection made last. On SDK 1.x the same object serves every request of one connection (mcp/server/lowlevel/server.py in 1.x), so this worked. The example ports those two tools to 2.x unchanged, around chapter 3's quote and refund functions, and calls them once each from one client at the pinned revision. Then it does the same with chapter 3's server, whose quote_refund returns a quote_id that issue_refund takes.
1"""Port the 2025 server's per-connection quote to SDK 2.x unchanged, then the way the pinned revision asks: a handle."""2import asyncio3import os45from mcp import Client6from mcp.server.mcpserver import Context, MCPServer7from mcp.server.mcpserver.exceptions import ToolError89from lib import confirmations, loopback, shop_api, shop_tools1011chapter3 = confirmations.chapter3_tools() # quote_refund and issue_refund from lib.shop_tools12server = MCPServer("shop-support", version="0.12.0")13quotes = {} # the 2025 code: each connection's open quote, by its session14sessions_seen = set()151617@server.tool(description="Quote a refund for a whole order, for issue_refund to refund.")18def quote_refund(order_id: str, ctx: Context) -> str:19 sessions_seen.add(id(ctx.session))20 quotes[ctx.session] = chapter3["quote_refund"].fn(order_id, sku="KG-1050")21 return f"Quoted {quotes[ctx.session].amount}."222324@server.tool(description="Refund the quote this connection made last.")25def issue_refund(ctx: Context) -> shop_tools.Refund:26 sessions_seen.add(id(ctx.session))27 if ctx.session not in quotes:28 raise ToolError("There is no open quote in this session. Call quote_refund first.")29 return chapter3["issue_refund"].fn(quotes.pop(ctx.session).quote_id)303132async def main() -> None:33 with loopback.serve(shop_api.create_app()) as api_url:34 os.environ.update(shop_api.server_env(api_url)) # both servers run in this process35 with loopback.serve(server.streamable_http_app()) as old, \36 loopback.serve(shop_tools.create_server().streamable_http_app()) as new:37 async with Client(old + "/mcp") as client:38 await client.call_tool("quote_refund", {"order_id": "O-2415"})39 print("session-keyed issue_refund:", (await client.call_tool("issue_refund", {})).content[0].text)40 print("session objects in two calls:", len(sessions_seen))41 async with Client(new + "/mcp") as client:42 quote = await client.call_tool("quote_refund", {"order_id": "O-2415", "sku": "KG-1050"})43 refund = await client.call_tool("issue_refund", {"quote_id": quote.structured_content["quote_id"]})44 print("handle issue_refund:", refund.structured_content["status"], refund.structured_content["amount"])454647asyncio.run(main())
python stateless-after-migration.pysession-keyed issue_refund: Error executing tool issue_refund: There is no open quote in this session. Call quote_refund first.
session objects in two calls: 2
handle issue_refund: requested 23.98
The unchanged port failed on the second call: "Error executing tool issue_refund: There is no open quote in this session. Call quote_refund first." The two calls came from one client to one process, and they saw 2 different session objects. At the pinned revision there is no connection for a session to belong to, so the SDK gives each request its own. Nothing failed when the quote was stored, and the bug shows only when something reads it back. A server that keyed its state on the Mcp-Session-Id header would fare worse: at the pinned revision no client sends one, so every client's state would land under the same key, None.
With the handle, the refund went through: requested 23.98. This is the design that Shape tools around tasks, not endpoints chose for chapter 3, and the specification's guidance for such handles is that they be opaque and expire, and that a server with authentication check the caller's authorization against the handle on every call (tools). Keep the server from acting as a confused deputy binds the shop's quote IDs to the customer they were issued for. A handle in one process's memory still dies with the process, so once the server runs as replicas the handles go in shared storage, as Run replicas without sticky sessions does with SQLite.
Working rule: Search the old server for every use of the session, its ID and anything stored per connection, and turn each piece of state into a handle that one tool returns and the next takes as an argument. Make the handle opaque, give it an expiry that the creating tool's description states, check on every call that the caller may use it, and store it where every replica can read it. Keep nothing a later call needs in a dictionary keyed by the session, even for 2025-era clients that still have one.
Move from HTTP+SSE to Streamable HTTP
The sessions in Remove initialize and session IDs belong to Streamable HTTP's 2025 form. The shop's 2025-era server also speaks an older transport: its last line serves the HTTP+SSE transport, the one revision 2025-03-26 replaced, when SHOP_OLD_HOSTS is set. In that transport the client keeps an event stream open at one endpoint and posts its messages to a second (Streamable HTTP); SDK 1.x serves the pair at /sse and /messages/ (mcp/server/fastmcp/server.py in 1.x). Revision 2025-03-26 put Streamable HTTP in its place (2025-03-26 changelog), and the pinned revision brings it under the lifecycle policy, with a shorter window (changelog).
For the server, the move is a deletion. The ported shop server serves streamable_http_app(), or run(transport="streamable-http"), at one endpoint, as in Move the server from stdio to HTTP, and nothing in its tools changes. Every client at the pinned revision that connects over HTTP uses Streamable HTTP, so the transport isn't what blocks them. What decides when the old transport can go is the hosts that still connect over HTTP+SSE, and they are 2025-era clients by definition.
Those hosts have two ways forward. A server that must keep them can host the old SSE and POST endpoints alongside its Streamable HTTP endpoint, which the specification has allowed since revision 2025-03-26 (Streamable HTTP). SDK 2.3.0 still has MCPServer.sse_app(), with the same two paths as 1.x, and run(transport="sse") (mcp/server/mcpserver/server.py), and as Inventory what the old server depends on found, neither raises a deprecation warning. Or the hosts move to Streamable HTTP, which SDK 1.x's client already speaks (mcp/client/streamable_http.py in 1.x).
The registry's earliest removal for HTTP+SSE is three months after SEP-2596 reaches Final, which may already have passed. That doesn't mean the SDK drops sse_app() on that day, since each SDK decides for itself (feature lifecycle). It means a host can't count on the transport surviving the next revision, and neither can you.
The shop's support team chose a date. The old endpoints stay only as long as the server's logs show a host connecting to /sse, and the team tells each such host's owner the date when the endpoints go. A host that can't move by then can stay on SDK 1.x against a 1.x deployment of the old server, which Port the code from SDK 1.x to 2.x covers.
Working rule: Delete the HTTP+SSE transport from the server rather than port it, unless your logs show hosts that still use it. If they do, serve the old endpoints beside the Streamable HTTP endpoint, log every connection to them by client, and set a date for removing them that you tell each host's owner. Treat the transport as already eligible for removal, because its window was three months, not twelve.
Replace sampling, roots and logging
Three of the dependencies that Inventory what the old server depends on found have two changes stacked on them, and it pays to keep them apart. The first is a mechanism. A 2025 server sent its own request to the client in the middle of a call (elicitation/create, sampling/createMessage, roots/list) and waited for the answer. The pinned revision says that pattern "is no longer supported" and that this is a breaking change: a server asks through a Multi Round-Trip Request instead, returning an input request whose values may be an ElicitRequest, a CreateMessageRequest or a ListRootsRequest (Multi Round-Trip Requests). The second is the features themselves. Roots, sampling and logging are deprecated, with migration paths in the registry: roots to tool parameters, resource URIs or server configuration; sampling to integrating with a model provider's API directly; logging to stderr on stdio, or OpenTelemetry (deprecated features).
So a Multi Round-Trip Request replaces the push, not sampling. A server that moves its sampling/createMessage into an input request still depends on a deprecated feature. Elicitation is the one of the three that isn't deprecated, so it is the only one worth moving into the round trip and keeping.
The 2025-era issue_refund pushes its confirmation with ctx.elicit, which fails at the pinned revision, as See why a stateless server can't wait for an answer showed. The port asks with a resolver: a parameter declared Annotated[Release, Resolve(confirm)] is filled by running confirm before the tool, and when confirm returns Elicit(...), the SDK asks the person at the host (Ask for structured input with elicitation). confirm asks only when the shop's API holds the refund for confirmation. The example quotes order O-2289, whose refund is above the policy's limit, calls issue_refund once by hand to see the first round, and then lets Client run the rounds with the scripted lead from chapter 5, who says yes.
1"""The 2025 server's pushed confirmation becomes a resolver's question, and its log line goes to stderr."""2import asyncio3import logging4import os5from typing import Annotated67from mcp import Client8from mcp.server.mcpserver import Elicit, Resolve9from pydantic import BaseModel1011from lib import confirmations, loopback, shop_api, shop_tools1213server, held = confirmations.shop_server(version="0.12.0") # chapter 3's other tools; held is its issue_refund14log = logging.getLogger("shop-support") # to stderr, not to the client151617class Release(BaseModel):18 release: bool192021def confirm(quote_id: confirmations.QuoteId) -> Elicit[Release] | Release:22 refund = held.fn(quote_id) # the same quote always gives the same refund23 if refund.status != "needs_confirmation":24 return Release(release=False) # nothing held, so nothing to ask25 return Elicit(f"Refund {refund.amount} on order {refund.order_id}? It is above the policy limit.", Release)262728@server.tool(description=shop_tools.DESCRIPTIONS["issue_refund"], annotations=held.annotations)29def issue_refund(quote_id: confirmations.QuoteId, answer: Annotated[Release, Resolve(confirm)]) -> shop_tools.Refund:30 refund = held.fn(quote_id)31 if refund.status == "needs_confirmation" and answer.release:32 refund = confirmations.release(refund)33 log.info("refund %s is %s", refund.refund_id, refund.status)34 return refund353637async def main() -> None:38 with loopback.serve(shop_api.create_app()) as api_url:39 os.environ.update(shop_api.server_env(api_url)) # the server runs in this process40 with loopback.serve(server.streamable_http_app()) as url:41 async with Client(url + "/mcp", elicitation_callback=confirmations.say_yes) as client:42 quote = (await client.call_tool("quote_refund", {"order_id": "O-2289"})).structured_content43 first = await client.session.call_tool("issue_refund", {"quote_id": quote["quote_id"]},44 allow_input_required=True)45 print("first round:", first.result_type)46 print("first round asks:", ", ".join(r.method for r in first.input_requests.values()))47 refund = await client.call_tool("issue_refund", {"quote_id": quote["quote_id"]})48 print("refund after the lead's yes:", refund.structured_content["status"])495051asyncio.run(main())
python replace-pushed-requests.pyfirst round: input_required
first round asks: elicitation/create
refund after the lead's yes: requested
The first round returned input_required with one question, elicitation/create: the same request the 2025 server pushed, carried in a result instead. After the lead's yes, the refund's status was requested. The resolver also works for 2025-era clients, because the SDK sends its questions in whichever form the client's revision uses (mcp/server/mcpserver/resolve.py); Serve old and new clients through the window runs both.
The port's log line uses Python's logging, not ctx.info. MCPServer configures logging to stderr when it is built (mcp/server/mcpserver/utilities/logging.py), so the line goes to the server's own logs and never to the client. On stdio, stdout carries only the protocol's messages, and stderr is where a server's logs belong (stdio). Trace calls with OpenTelemetry covers the other path.
The 2025-era triage_ticket asked the host's model, through sampling, which tool a ticket needs. The port deletes it. The host's own model already reads every ticket and picks the tools (Work through the ticket queue), so the server was asking the host to do what the host does anyway. A server that needs a model for its own work calls a model provider with its own credentials, which also lets it choose and pay for the model.
save_restock_report asked the client for its roots and wrote to the first one. Its port takes the folder as a parameter and checks it against the folders the server's configuration allows, in SHOP_EXPORT_DIRS, after resolving the path, so that .. can't climb out.
1"""The 2025 server asked the client for its roots to find a folder; the ported tool takes the folder as a parameter2and checks it against the folders the server's configuration allows."""3import asyncio4import os5from pathlib import Path67from mcp import Client8from mcp.server.mcpserver import MCPServer9from mcp.server.mcpserver.exceptions import ToolError1011from lib import loopback, restock, shop_api1213server = MCPServer("shop-support", version="0.12.0")14EXPORT_DIRS = [Path(d).resolve() for d in os.environ.get("SHOP_EXPORT_DIRS", "exports").split(",")]151617@server.tool(description="Recount stock and save the restock report as CSV in export_dir, a folder the server allows.")18async def save_restock_report(export_dir: str) -> str:19 folder = Path(export_dir).resolve() # "exports/../private" resolves outside exports20 if folder not in EXPORT_DIRS:21 raise ToolError(f"{export_dir} isn't an export folder. Use one of: {', '.join(d.name for d in EXPORT_DIRS)}.")22 report = await restock.run(below=30, seconds=0)23 folder.mkdir(exist_ok=True)24 (folder / "restock.csv").write_text("sku,units\n" + "".join(f"{r.sku},{r.units}\n" for r in report.reorder))25 return f"Saved {len(report.reorder)} products to reorder in {export_dir}/restock.csv."262728async def main() -> None:29 with loopback.serve(shop_api.create_app()) as api_url:30 os.environ.update(shop_api.server_env(api_url)) # the server runs in this process31 async with Client(server) as client:32 for folder in ("exports", "exports/../private"):33 saved = await client.call_tool("save_restock_report", {"export_dir": folder})34 print(f"export to {folder}:", saved.content[0].text)353637asyncio.run(main())
python roots-to-a-parameter.pyexport to exports: Saved 5 products to reorder in exports/restock.csv.
export to exports/../private: Error executing tool save_restock_report: exports/../private isn't an export folder. Use one of: exports.
The allowed folder worked: "Saved 5 products to reorder in exports/restock.csv." The path that climbs out did not: "Error executing tool save_restock_report: exports/../private isn't an export folder. Use one of: exports." The host or the model now passes the folder, and the server, not the client, decides where it may write.
Working rule: Move every pushed elicitation into a resolver, which serves both eras. Delete sampling rather than move it: let the host's model do model work, or call a provider from the server with the server's own credentials. Turn roots into a parameter checked against an allow-list in the server's configuration, and send log lines to stderr and your traces, never to the client.
Replace dynamic client registration
The last deprecated dependency that the inventory found is in the host, not the server, but it decides whether the support team's host can get a token for the ported server at all. The 2025 host registered itself with each authorization server it met, through OAuth Dynamic Client Registration:
"""How the support team's 2025 host got tokens for shop-support: it registered itself with each authorization server."""
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import OAuthClientMetadata
auth = OAuthClientProvider(
server_url="https://mcp.fernhill.example/mcp",
client_metadata=OAuthClientMetadata(client_name="Support desk", redirect_uris=["http://127.0.0.1:3030/callback"]),
storage=token_store, redirect_handler=open_browser, callback_handler=wait_for_code,
)
SDK 1.x's OAuthClientProvider registers this way when it isn't given a client_metadata_url (mcp/client/auth/oauth2.py in 1.x). The pinned revision deprecates Dynamic Client Registration in favour of Client ID Metadata Documents, which revision 2025-11-25 had already recommended, and keeps it for backward compatibility (changelog; 2025-11-25 changelog). Its earliest removal is the first revision released on or after 2027-07-28.
A Client ID Metadata Document makes the client ID an https:// URL where the host publishes its own metadata, so no authorization server has to register it first. Identify the client with a metadata document builds one for the shop's host and shows the order the pinned revision ranks the options in: pre-registered credentials, then a metadata document, then registration.
The port is one argument. The host passes its document's URL as client_metadata_url, and SDK 2.x's OAuthClientProvider then uses the document when the authorization server advertises client_id_metadata_document_supported. When the server doesn't, the provider falls back to registration without saying so, as chapter 8's example showed, and the SDK's registration code at 2.3.0 raises no deprecation warning. So the authorization server is half of the port. It has to advertise metadata document support, and it should keep its registration endpoint only for the window and only for hosts that can't publish a document.
Two rules from the pinned revision matter while registration lingers. A client MUST key the credentials it stores by the authorization server's issuer and register again when the authorization server changes, and a client that registers MUST send an appropriate application_type, which SHOULD be native for a host that runs on the user's machine with a loopback redirect, like this one (changelog; client registration). A 2025 host that stored one client ID per MCP server's URL breaks the first rule the day the shop moves its authorization server.
Working rule: Publish a Client ID Metadata Document for every host and pass its URL as client_metadata_url. Make every authorization server you run advertise support for it, keep its registration endpoint only until the hosts that need it have moved, and log which kind of client ID each token was issued to, so that you can see when registration traffic has stopped.
Port the code from SDK 1.x to 2.x
The sections so far changed what the shop's server does. The scan in Inventory what the old server depends on also found SDK changes, which stop the old file from starting on 2.x at all. They are a separate job, and two facts shape it. The 1.x line is still maintained. The first 2.x release and a 1.x release both shipped on 2026-07-28, the day of the pinned revision, and the 1.x release on 2026-09-07, mcp==1.30.0, is a "maintenance release of the 1.x line" (PyPI; release notes). The migration guide says the 1.x line keeps receiving critical bug fixes and security patches, and nobody has published an end date (migration guide). And both lines are the one mcp distribution, so a deployment runs one or the other, never both.
The guide lists the breaks. Those that touch a server like the shop's: mcp.server.fastmcp.FastMCP is mcp.server.mcpserver.MCPServer; McpError is MCPError(code, message, data); fields are snake_case (input_schema, is_error); the HTTP client is httpx2, not httpx; streamablehttp_client is gone, in favour of streamable_http_client; host, port, stateless_http and json_response moved from the constructor to run() and streamable_http_app(); instructions is no longer the second positional argument; MCP_* environment variables are no longer read; the WebSocket transport is removed; and ctx.log takes data, not message (migration guide).
Most of these fail loudly, which is the kind you want. One doesn't. The example tries the 2025-era server's setup lines on 2.3.0: the import, the exception it raised, and the constructor. Then it builds the server twice, once with the arguments as written and once ported, and asks each what a client sees.
1"""Port the 2025 server's setup lines to SDK 2.x: two breaks that fail loudly, one that fails silently, and the fix."""2import asyncio34from mcp import Client5from mcp.server.mcpserver import MCPServer67INSTRUCTIONS = "Answers the support team's questions about orders, refunds and stock."89try:10 from mcp.server.fastmcp import FastMCP # the 2025 server's first import11except ModuleNotFoundError as error:12 print("import FastMCP:", str(error).split(". ", 1)[1].split("; see")[0])13try:14 from mcp.shared.exceptions import McpError # what 1.x tools raised for protocol errors15except ImportError as error:16 print("import McpError:", str(error).split(" (")[0])17try:18 MCPServer("shop-support", instructions=INSTRUCTIONS, port=8000, stateless_http=False)19except TypeError as error:20 print("port on the constructor:", error)2122as_written = MCPServer("shop-support", INSTRUCTIONS) # 1.x's second argument was instructions23ported = MCPServer("shop-support", instructions=INSTRUCTIONS, version="0.12.0")242526async def main() -> None:27 for label, server in (("as written", as_written), ("ported", ported)):28 async with Client(server) as client:29 print(f"{label} title:", client.server_info.title)30 print(f"{label} instructions:", client.instructions)313233asyncio.run(main())
python port-to-sdk-2.pyimport FastMCP: This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed
import McpError: cannot import name 'McpError' from 'mcp.shared.exceptions'
port on the constructor: MCPServer.__init__() got an unexpected keyword argument 'port'
as written title: Answers the support team's questions about orders, refunds and stock.
as written instructions: None
ported title: None
ported instructions: Answers the support team's questions about orders, refunds and stock.
The import failed with a message that names the rename and the import to use instead: "This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed" (mcp/server/fastmcp.py, which exists only to raise it). McpError failed with "cannot import name 'McpError' from 'mcp.shared.exceptions'", and the constructor with "MCPServer.init() got an unexpected keyword argument 'port'". Each points at the line to change.
The constructor as written didn't fail. In 2.x the second positional argument is title (mcp/server/mcpserver/server.py), so the server's instructions became its title: a client saw the title "Answers the support team's questions about orders, refunds and stock." and no instructions (None). A host that puts a server's instructions in the model's context would silently lose them. With instructions= by keyword, the ported server's title is None and its instructions arrive. The port number moves to the last line, as server.run(transport="streamable-http", port=8000).
The silent breaks share a shape: an argument that still type-checks, or a setting that is no longer read, like the MCP_* environment variables. Find them by reading the guide's list against the code, not by waiting for errors, and check what a client sees with a test like this one.
Working rule: Keep a 2025-era server pinned to mcp<2 until its port passes its tests, then port in one change: the imports and renames the errors point at, then each silent break from the migration guide, checked with a test against what a client sees. Pin the 2.x release exactly afterwards, and treat each 2.x release as a change to test, since the SDK's warnings and defaults move between releases.
Serve old and new clients through the window
The ported server from Replace sampling, roots and logging answers clients at the pinned revision. The support team's hosts don't all move on the same day, and a 2025-era host has no way to move forward on its own: it sends initialize and expects an answer. The pinned revision allows a dual-era server, which serves both on the same endpoint. An initialize request selects the 2025 semantics for that process on stdio or that session on HTTP, and a request with the pinned revision's _meta is served statelessly. A server that serves only the pinned revision SHOULD name the versions it supports in its error to initialize, because a 2025 client can't discover them any other way (versioning). On the other side, a 2.x Client in its default mode tries server/discover first and falls back to initialize (mcp/client/_probe.py; Call the tool from a client).
At 2.3.0, MCPServer.streamable_http_app() is dual-era with nothing to configure. It reads each request's MCP-Protocol-Version header: a request with no header, or with a version from 2024-11-05 to 2025-11-25, goes to the 2025 handler with its sessions, and anything else goes to the stateless handler (mcp/server/streamable_http_manager.py). The 2025 path keeps its sessions in the memory of the process that made them, so 2025-era clients keep their stickiness requirements (what's new).
The example serves the ported server's app as replica a, recorded with chapter 6's lib.wire.record_http, and a second copy of it as replica b. A client in the default mode and a client in mode="legacy", which speaks the 2025 handshake, each quote a refund above the policy's limit and issue it on replica a, with the scripted lead saying yes. Then the example sends the 2025 client's session ID to each replica.
1"""One app, one endpoint: a 2026-07-28 client and a 2025-11-25 client both get the ported refund confirmation. Then2the 2025 client's session ID reaches a second replica."""3import asyncio4import os56import httpx27from mcp import Client89from lib import confirmations, http_mcp, loopback, shop_api, wire101112LEGACY = {"MCP-Protocol-Version": "2025-11-25", "Accept": "application/json, text/event-stream"}131415def asked_how(exchanges: list[dict]) -> str:16 messages = [m for e in exchanges for m in e["messages"]]17 if any(m.get("method") == "elicitation/create" for m in messages):18 return "a request the server pushed mid-call"19 return "an input request in a result" if any(m.get("result", {}).get("resultType") == "input_required"20 for m in messages) else "never"212223async def main() -> None:24 server = http_mcp.load_server("replace-pushed-requests.py") # the ported issue_refund, with its resolver25 with loopback.serve(shop_api.create_app()) as api_url:26 os.environ.update(shop_api.server_env(api_url))27 with loopback.serve(wire.record_http(server.streamable_http_app(), "a.jsonl")) as a, \28 loopback.serve(http_mcp.load_server("replace-pushed-requests.py").streamable_http_app()) as b:29 for mode, order in (("auto", "O-2289"), ("legacy", "O-1085")): # both above the policy limit30 seen = len(wire.read_http_trace("a.jsonl"))31 async with Client(a + "/mcp", mode=mode, elicitation_callback=confirmations.say_yes) as client:32 quote = (await client.call_tool("quote_refund", {"order_id": order})).structured_content33 refund = await client.call_tool("issue_refund", {"quote_id": quote["quote_id"]})34 session = next((e["response_headers"]["mcp-session-id"] for e in wire.read_http_trace("a.jsonl")35 [seen:] if "mcp-session-id" in e["response_headers"]), None)36 print(f"{mode} revision:", client.protocol_version)37 print(f"{mode} session header:", "set" if session else "none")38 for replica, url in (("a", a), ("b", b)) if session else ():39 sent = httpx2.post(url + "/mcp", headers={"Mcp-Session-Id": session, **LEGACY},40 json={"jsonrpc": "2.0", "id": 9, "method": "tools/list"})41 listed = "tools listed" if "check_stock" in sent.text else sent.json()["error"]["message"]42 print(f"2025 session on replica {replica}: HTTP {sent.status_code},", listed)43 print(f"{mode} question came as:", asked_how(wire.read_http_trace("a.jsonl")[seen:])) # all logged44 print(f"{mode} refund:", refund.structured_content["status"])454647asyncio.run(main())
python serve-two-revisions.pyauto revision: 2026-07-28
auto session header: none
auto question came as: an input request in a result
auto refund: requested
legacy revision: 2025-11-25
legacy session header: set
2025 session on replica a: HTTP 200, tools listed
2025 session on replica b: HTTP 404, Session not found
legacy question came as: a request the server pushed mid-call
legacy refund: requested
The default client settled on 2026-07-28, got no session header (none), and its question came as an input request in a result. The 2025 client settled on 2025-11-25, got a session header (set), and its question came as a request the server pushed mid-call. The default client's refund ended requested, and so did the 2025 client's (requested). One resolver served both eras, and no code in issue_refund checks which one it is in, which is simpler than the check of ctx.protocol_version around ctx.elicit() that See why a stateless server can't wait for an answer allows. A hand-built input request, like chapter 5's, doesn't do that: a 2025 client receives it as an invalid result (What goes wrong in production in chapter 5).
The 2025 session worked on replica a, which answered HTTP 200, tools listed, and failed on replica b, which answered HTTP 404, Session not found. A 2026 client has no session to lose; what it needs from replicas is handles they all reach, as Run replicas without sticky sessions showed. Behind a load balancer, then, 2025-era traffic needs routing by Mcp-Session-Id to the replica that made the session, while the rest goes anywhere. The SDK's other choice, stateless_http=True, removes the sessions for 2025 clients but takes away their cancellation and their server-to-client requests (What goes wrong in production in chapter 6). For the shop that would break the pushed confirmation that the 2025 client had answered, so we recommend sticky routing for the 2025 path, kept as small as the remaining clients allow.
Working rule: Serve both eras from one streamable_http_app() through the window, write every question a tool asks as a resolver, and route requests that carry Mcp-Session-Id to the replica that minted it. Count requests by era. When the 2025 count has stayed at zero for as long as your slowest host takes to upgrade, serve the pinned revision only, and answer initialize with an error that names the versions you support, as the specification asks.
What goes wrong in production
A migration fails in the gaps between the two eras: one client sees the old server, a proxy sees the new one, a warning says nothing. These are the failures to watch for.
A 2025 session lands on another replica. The sign is 404 with "Session not found" from 2025-era clients only, after a deployment or a scale-out, as on replica b in Serve old and new clients through the window. A 2025 client that follows its revision starts a new session on 404 (2025-11-25 transports), so the symptom may be lost state rather than an error. Route by Mcp-Session-Id, and count 404s by client.
A proxy drops the version header. The SDK's app picks the era from MCP-Protocol-Version alone, and its source marks routing by the body as a later step (mcp/server/streamable_http_manager.py). A proxy that strips headers it doesn't know sends a 2026-era request down the 2025 path, which expects a handshake first. The sign is failures from clients at the pinned revision behind that proxy only. Make every proxy pass the MCP headers, as Read one HTTP exchange lists them.
Error codes moved. A missing resource is -32602 at the pinned revision, not -32002, though clients SHOULD still accept -32002 (resources). The revision adds -32020 for headers that disagree with the body, -32021 for a missing client capability and -32022 for an unsupported protocol version (changelog). A host that branches on the old codes treats the codes the pinned revision added as unknown ones. Search client code for each numeric code before the server moves.
Warnings that teach people to ignore warnings. MCPDeprecationWarning is a UserWarning, so it prints by default (what's new), and a server that still uses sampling, roots or logging writes it into its logs. Turned off wholesale, it also hides the next deprecation. Filter each warning by its message only once the feature is on your inventory with a date.
Silence about HTTP+SSE and registration. The SDK at 2.3.0 warns about neither, as the inventory found. A server can pass its tests with no warnings and still lose its oldest hosts when the transport goes. Keep the scan from Inventory what the old server depends on in CI, and treat a later SDK release that adds these warnings as a reason to recheck the dates.
Reading the earliest removal as the end date. A team that plans to move off sampling or roots on 2027-07-28 has no margin, because the first revision released on or after that date may remove them. A team that reads the date as the day they disappear may also drop a feature its SDK keeps supporting for longer. Plan by the clients you serve: the registry gives the earliest date, and your logs give the real one.
Working rule: Before the ported server takes traffic, test four things through the real proxy path: a 2025 client completes a confirmation, a 2026 client completes the same one, a 2025 session survives a deployment, and the version header reaches the server. Then watch requests by era, 404s by client and every deprecation warning, until the 2025 count reaches zero.
Decisions this chapter made
- The 2025-era server is a listing, not a running example.
lib.legacyholds it as text for SDK 1.x, and the examples run only the port on 2.x, because one environment can't hold both lines. - The inventory sorts dependencies into removed, deprecated and SDK changes, with the registry's earliest removal for each deprecated feature, read as the earliest date, never an end date.
- The scan runs in CI through the window, because the SDK warns about sampling, roots and logging but not about HTTP+SSE or registration.
- Per-session state becomes handles: the quote is chapter 3's
quote_id, passed back by the client, and stored where every replica reads it. - HTTP+SSE goes. The shop's server serves Streamable HTTP only, and keeps the old endpoints only while its logs show a host using them.
- Elicitation moves into a resolver, which serves both eras. Sampling is deleted: the host's model triages tickets. Roots become the
export_dirparameter, checked againstSHOP_EXPORT_DIRS. Logging goes to stderr and traces. - The host uses a Client ID Metadata Document through
client_metadata_url, and authorization servers keep registration only for hosts that can't publish one. - The port to 2.x is one change, behind
mcp<2until its tests pass, withinstructions=by keyword and transport settings onrun(). - One dual-era app serves both eras through the window, with sticky routing by
Mcp-Session-Idfor 2025 traffic and nostateless_http.
This chapter, in this edition
Edition 2026.11.5Examples passed Nov 4, 20266 of 6 examples in this chapter pass
Anyone can check this book or correct it. Found something wrong? Report it with the section's ID, the last part of the link beside each heading, or fix it yourself with a pull request.