How to migrate an MCP server to the stateless 2026 spec (mcp v2 and TypeScript SDK v2)
`pip install mcp` now gives you mcp 2.x and your FastMCP import breaks. Move a working MCP server to the stateless 2026-07-28 spec in Python and TypeScript, without locking out the clients that still use the old protocol.
The 2026-07-28 revision of the Model Context Protocol made the biggest change
the spec has had so far: MCP is now stateless. Sessions and the
initialize handshake are gone, and a server can no longer send requests to
the client in the middle of a tool call. The Python and TypeScript SDKs both
shipped new major versions for it. In Python, a plain pip install mcp now
gives you the new major version, and old code fails on its first import.
You don’t need to rewrite your server. This tutorial takes a small server that works today, pins it so it keeps working, and then moves it to the new SDKs one change at a time. After each step you test it against a new client and an old one. The goal is not “modern only”. The goal is one server that modern and older clients can both use, because many clients you care about still use the old protocol.
Am I affected?
Tick any of these and this tutorial is for you:
- Your Python server fails at startup with
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer ...That means a fresh install picked up mcp 2.x.pip install mcpnow installs 2.3.0. - Your TypeScript server fails with
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@modelcontextprotocol/sdk'after someone switched the dependency to@modelcontextprotocol/server. - Your tool errors now reach the model as a bare
Error executing tool get_authorwith no detail (mcp 2.1+ hides unexpected exceptions). - A tool that asks the user to confirm now fails with
Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.(Python) orServer-to-client requests are not available on protocol revision 2026-07-28(TypeScript). - You upgraded to the TypeScript v2 packages and assumed you were on 2026-07-28. You aren’t: v2 clients default to the old protocol.
None of these? If you pinned mcp<2 or @modelcontextprotocol/sdk@1, your
server still runs. Both 1.x lines still get fixes (Python 1.30.0, TypeScript
1.32.1 as of October 2026), so you can migrate when you’re ready instead of
during an outage.
What changed in the protocol, in plain terms
| 2025 protocol (handshake era) | 2026-07-28 (stateless) |
|---|---|
Client opens with initialize, then reuses the session | No handshake. Every request carries its protocol version and client capabilities in _meta. Clients discover the server with server/discover. |
Mcp-Session-Id ties requests together | No sessions. Any server replica can answer any request. |
Server can call the client mid-tool (elicitation/create, sampling/createMessage, roots/list) | The server returns an input_required result listing its questions. The client answers them and retries the same call. This is called MRTR (multi round-trip requests). |
| Long-running work via Tasks in the core spec | Tasks moved to an optional extension (io.modelcontextprotocol/tasks) |
| Roots, Sampling, Logging | Still work, but deprecated |
| Dynamic Client Registration for OAuth | Deprecated in favour of Client ID Metadata Documents (CIMD) |
The MRTR change is the one that reshapes code. Here is a confirmation prompt drawn as the new loop:
%% caption: Asking the user mid-call under the 2026 spec: the server returns input_required, the client retries
sequenceDiagram
autonumber
participant C as Client
participant S as Server (any replica)
C->>S: tools/call reserve_copy {title: "Dune"}
S-->>C: resultType "input_required"<br/>inputRequests: {confirm: elicit form}
Note over C: Show the form to the user
C->>S: tools/call reserve_copy again (new id)<br/>+ inputResponses {confirm: accept}
S-->>C: resultType "complete"<br/>"Reserved Dune. 2 left."The server holds nothing between those two calls. Anything it needs to pick up where it left off comes back with the retry.
Step 1 — Start from a working mcp 1.x server
This is a typical 2025-era FastMCP server: two tools, one resource, and a
tool that asks the user to confirm using ctx.elicit. If you already have a
server, read along and apply each step to your own code.
mkdir bookshop && cd bookshop
python -m venv .venv-old && .venv-old/bin/pip install "mcp[cli]<2"
python -m venv .venv-new && .venv-new/bin/pip install "mcp[cli]"
You’ll keep two virtual environments for the whole tutorial. .venv-old
plays the role of every client that hasn’t upgraded yet. .venv-new is
where your migrated server lives. (On Windows the paths are
.venv-old\Scripts\python.)
Create server_v1.py:
# server_v1.py -- runs on mcp 1.x (pip install "mcp[cli]<2")
from mcp.server.fastmcp import Context, FastMCP
from pydantic import BaseModel
mcp = FastMCP("bookshop")
CATALOG = {
"Dune": {"author": "Frank Herbert", "stock": 3},
"Neuromancer": {"author": "William Gibson", "stock": 1},
}
class Confirm(BaseModel):
confirm: bool
@mcp.tool()
def get_author(title: str) -> str:
"""Return the author of a book in the catalog."""
if title not in CATALOG:
raise ValueError(f"No book titled {title!r}. Read catalog://books for exact titles.")
return CATALOG[title]["author"]
@mcp.tool()
async def reserve_copy(title: str, ctx: Context) -> str:
"""Reserve one copy of a book, after the user confirms."""
if title not in CATALOG:
raise ValueError(f"No book titled {title!r}.")
answer = await ctx.elicit(f"Reserve one copy of {title}?", schema=Confirm)
if answer.action != "accept" or not answer.data.confirm:
return "Reservation cancelled."
CATALOG[title]["stock"] -= 1
return f"Reserved {title}. {CATALOG[title]['stock']} left."
@mcp.resource("catalog://books")
def list_books() -> str:
"""Every title in the catalog, one per line."""
return "\n".join(CATALOG)
if __name__ == "__main__":
mcp.run() # stdio
Step 2 — Pin it and prove it works with a 2025-era client
Before you change anything, save a client that checks every feature. This
one is written against mcp 1.x, so it behaves like the many clients still
using the handshake. It answers the confirmation prompt automatically so the
test can run unattended. Create client_v1.py:
# client_v1.py -- a 2025-era client (mcp 1.x): initialize handshake, push elicitation
import asyncio
import sys
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
async def on_elicit(context, params: types.ElicitRequestParams) -> types.ElicitResult:
print(f" [server asks] {params.message}")
return types.ElicitResult(action="accept", content={"confirm": True})
async def main(command: str, *args: str) -> None:
params = StdioServerParameters(command=command, args=list(args))
async with stdio_client(params) as (read, write):
async with ClientSession(read, write, elicitation_callback=on_elicit) as session:
init = await session.initialize()
print("protocol:", init.protocolVersion)
tools = await session.list_tools()
print("tools:", [t.name for t in tools.tools])
books = await session.read_resource("catalog://books")
print("catalog:", books.contents[0].text.split("\n"))
r = await session.call_tool("get_author", {"title": "Dune"})
print("get_author:", r.content[0].text)
r = await session.call_tool("get_author", {"title": "Dune II"})
print("get_author (bad title):", r.isError, r.content[0].text)
r = await session.call_tool("reserve_copy", {"title": "Dune"})
print("reserve_copy:", r.content[0].text)
asyncio.run(main(*sys.argv[1:])) # e.g. python client_v1.py python server_v1.py
.venv-old/bin/python client_v1.py .venv-old/bin/python server_v1.py 2>/dev/null
protocol: 2025-11-25
tools: ['get_author', 'reserve_copy']
catalog: ['Dune', 'Neuromancer']
get_author: Frank Herbert
get_author (bad title): True Error executing tool get_author: No book titled 'Dune II'. Read catalog://books for exact titles.
[server asks] Reserve one copy of Dune?
reserve_copy: Reserved Dune. 2 left.
That’s your baseline. Now run the same server on the new SDK to see the break:
.venv-new/bin/python server_v1.py
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver or pin 'mcp<2' to keep running v1 code.
If you’re not migrating today, stop here and pin. Put mcp[cli]<2 in
requirements.txt or pyproject.toml so the next fresh install (a new
laptop, a CI cache miss, a container rebuild) doesn’t pick up 2.x.
Step 3 — Migrate the Python server to mcp 2.x
Here is the same server on mcp 2.x. Save it as server_v2.py:
# server_v2.py -- runs on mcp 2.x (pip install "mcp[cli]")
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Elicit, Resolve
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("bookshop")
CATALOG = {
"Dune": {"author": "Frank Herbert", "stock": 3},
"Neuromancer": {"author": "William Gibson", "stock": 1},
}
class Confirm(BaseModel):
confirm: bool
@mcp.tool()
def get_author(title: str) -> str:
"""Return the author of a book in the catalog."""
if title not in CATALOG:
# ToolError text reaches the model; a plain ValueError would not on mcp 2.1+.
raise ToolError(f"No book titled {title!r}. Read catalog://books for exact titles.")
return CATALOG[title]["author"]
def ask_to_confirm(title: str) -> Elicit[Confirm]:
"""Resolver: the SDK turns this into an input request on the right wire."""
return Elicit(f"Reserve one copy of {title}?", Confirm)
@mcp.tool()
def reserve_copy(title: str, answer: Annotated[Confirm, Resolve(ask_to_confirm)]) -> str:
"""Reserve one copy of a book, after the user confirms."""
if title not in CATALOG:
raise ToolError(f"No book titled {title!r}.")
if not answer.confirm:
return "Reservation cancelled."
CATALOG[title]["stock"] -= 1
return f"Reserved {title}. {CATALOG[title]['stock']} left."
@mcp.resource("catalog://books")
def list_books() -> str:
"""Every title in the catalog, one per line."""
return "\n".join(CATALOG)
if __name__ == "__main__":
mcp.run() # stdio; serves 2026-07-28 and handshake-era clients
Three changes, in order of how often you’ll hit them:
- The rename.
FastMCPis nowMCPServer, imported frommcp.server(ormcp.server.mcpserver, which also exportsContext). The decorators (@mcp.tool(),@mcp.resource(),@mcp.prompt()) are the same. - Errors meant for the model raise
ToolError. Since mcp 2.1.0, an unexpected exception reaches the client only asError executing tool <name>. The real message goes to your server log. That’s safer, because stack details no longer leak, but aValueErrorthe model was supposed to read and act on is now silent. A useful rule: if a smarter model could have avoided the error, raiseToolError. - The confirmation prompt is a resolver, not an
await.ctx.elicit()still exists, but it sends a request to the client mid-call, and a 2026-07-28 connection doesn’t allow that. A resolver declares the question instead: the tool parameteransweris filled by runningask_to_confirm, and that resolver returns anElicitmarker. The SDK then picks the right wire format for each request. On 2026-07-28 it returns aninput_requiredresult and resumes when the client retries. On a 2025 connection it sends a normalelicitation/createrequest. If the user declines or cancels, the call stops before your tool body runs. (AnnotateanswerasElicitationResult[Confirm]if you’d rather handle declines yourself.)
Now the mcp 2.x client. It looks almost the same, but its first argument
picks the protocol mode. Save it as client_v2.py:
# client_v2.py -- mcp 2.x client. First arg is the mode: auto, legacy, or 2026-07-28
import asyncio
import sys
from mcp import Client, StdioServerParameters
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
print(f" [server asks] {params.message}")
return ElicitResult(action="accept", content={"confirm": True})
async def main(mode: str, command: str, *args: str) -> None:
server = StdioServerParameters(command=command, args=list(args))
async with Client(server, mode=mode, elicitation_callback=on_elicit) as client:
print("protocol:", client.protocol_version)
tools = await client.list_tools()
print("tools:", [t.name for t in tools.tools])
books = await client.read_resource("catalog://books")
print("catalog:", books.contents[0].text.split("\n"))
r = await client.call_tool("get_author", {"title": "Dune"})
print("get_author:", r.content[0].text)
r = await client.call_tool("get_author", {"title": "Dune II"})
print("get_author (bad title):", r.is_error, r.content[0].text)
r = await client.call_tool("reserve_copy", {"title": "Dune"})
print("reserve_copy:", r.content[0].text)
asyncio.run(main(*sys.argv[1:])) # e.g. python client_v2.py auto python server_v2.py
Notice the client-side renames too: Client replaces the
stdio_client + ClientSession + initialize() boilerplate, and result
fields are snake_case (is_error, not isError). The same
elicitation_callback handles both the old pushed request and the new
input_required loop. Client.call_tool retries for you, up to 10 rounds by
default.
Run the test matrix. Every row below was run for this tutorial:
# new client, new server (auto = probe server/discover, fall back if needed)
.venv-new/bin/python client_v2.py auto .venv-new/bin/python server_v2.py 2>/dev/null
# new client forced onto the old handshake
.venv-new/bin/python client_v2.py legacy .venv-new/bin/python server_v2.py 2>/dev/null
# OLD mcp 1.x client against the NEW server: the compatibility check that matters
.venv-old/bin/python client_v1.py .venv-new/bin/python server_v2.py 2>/dev/null
# new client against the OLD server: auto falls back to the handshake
.venv-new/bin/python client_v2.py auto .venv-old/bin/python server_v1.py 2>/dev/null
| Client | Server | Negotiated | Result |
|---|---|---|---|
mcp 2.3.0, auto | server_v2.py | 2026-07-28 | all checks pass, confirmation via input_required |
mcp 2.3.0, legacy | server_v2.py | 2025-11-25 | all checks pass, confirmation via pushed elicitation/create |
| mcp 1.30.0 | server_v2.py | 2025-11-25 | all checks pass |
mcp 2.3.0, auto | server_v1.py | 2025-11-25 | all checks pass (fallback) |
mcp 2.3.0, 2026-07-28 (pinned) | server_v1.py | none | MCPError: Invalid request parameters (an old server can’t serve a pinned client) |
The first row prints:
protocol: 2026-07-28
tools: ['get_author', 'reserve_copy']
catalog: ['Dune', 'Neuromancer']
get_author: Frank Herbert
get_author (bad title): True Error executing tool get_author: No book titled 'Dune II'. Read catalog://books for exact titles.
[server asks] Reserve one copy of Dune?
reserve_copy: Reserved Dune. 2 left.
That is the core of the migration. One server_v2.py serves both eras with
no extra code. The SDK works out which era each client speaks, and your
tool code doesn’t change.
You can check both eras with the official MCP Inspector (v2, Node 22.19+). Its
default era is legacy, so pass the flag explicitly or you’ll see
initialize traffic and think the migration didn’t work:
npx @modelcontextprotocol/inspector --cli .venv-new/bin/python server_v2.py --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli .venv-new/bin/python server_v2.py --protocol-era legacy --method tools/list
Step 4 — The same migration in TypeScript
The TypeScript SDK split into packages in v2. The old @modelcontextprotocol/sdk
is the v1 line. It is still the npm latest (1.32.1), so
npm i @modelcontextprotocol/sdk still installs v1. v2 lives in
@modelcontextprotocol/server and @modelcontextprotocol/client (plus a
shared @modelcontextprotocol/core).
The v1 server (npm i @modelcontextprotocol/sdk zod tsx, "type": "module"
in package.json):
// server.ts -- TypeScript SDK v1 (npm i @modelcontextprotocol/sdk zod)
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const CATALOG: Record<string, { author: string; stock: number }> = {
Dune: { author: 'Frank Herbert', stock: 3 },
Neuromancer: { author: 'William Gibson', stock: 1 },
};
const server = new McpServer({ name: 'bookshop', version: '1.0.0' });
server.registerTool(
'get_author',
{ description: 'Return the author of a book in the catalog.', inputSchema: { title: z.string() } },
async ({ title }) => {
const book = CATALOG[title];
if (!book) {
return { isError: true, content: [{ type: 'text', text: `No book titled "${title}".` }] };
}
return { content: [{ type: 'text', text: book.author }] };
},
);
server.registerTool(
'reserve_copy',
{ description: 'Reserve one copy of a book, after the user confirms.', inputSchema: { title: z.string() } },
async ({ title }) => {
// v1: a server-to-client request, pushed mid-call
const answer = await server.server.elicitInput({
message: `Reserve one copy of ${title}?`,
requestedSchema: {
type: 'object',
properties: { confirm: { type: 'boolean' } },
required: ['confirm'],
},
});
if (answer.action !== 'accept' || answer.content?.confirm !== true) {
return { content: [{ type: 'text', text: 'Reservation cancelled.' }] };
}
CATALOG[title].stock -= 1;
return { content: [{ type: 'text', text: `Reserved ${title}. ${CATALOG[title].stock} left.` }] };
},
);
server.registerResource(
'books',
'catalog://books',
{ description: 'Every title in the catalog, one per line.' },
async (uri) => ({ contents: [{ uri: uri.href, text: Object.keys(CATALOG).join('\n') }] }),
);
await server.connect(new StdioServerTransport());
The v2 server (npm i @modelcontextprotocol/server zod tsx). The SDK ships a
codemod (npx @modelcontextprotocol/codemod@latest v1-to-v2 ., run from the
package root) that handles the import rewrites. The MRTR change is yours to
make:
// server.ts -- TypeScript SDK v2 (npm i @modelcontextprotocol/server zod)
import { McpServer, acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const CATALOG: Record<string, { author: string; stock: number }> = {
Dune: { author: 'Frank Herbert', stock: 3 },
Neuromancer: { author: 'William Gibson', stock: 1 },
};
const Confirm = z.object({ confirm: z.boolean() });
// A factory, not a singleton: the SDK builds an instance per connection.
function buildServer(): McpServer {
const server = new McpServer({ name: 'bookshop', version: '2.0.0' });
server.registerTool(
'get_author',
{ description: 'Return the author of a book in the catalog.', inputSchema: z.object({ title: z.string() }) },
async ({ title }) => {
const book = CATALOG[title];
if (!book) {
return { isError: true, content: [{ type: 'text', text: `No book titled "${title}".` }] };
}
return { content: [{ type: 'text', text: book.author }] };
},
);
server.registerTool(
'reserve_copy',
{ description: 'Reserve one copy of a book, after the user confirms.', inputSchema: z.object({ title: z.string() }) },
async ({ title }, ctx) => {
const reply = inputResponse(ctx.mcpReq.inputResponses, 'confirm');
if (reply.kind === 'missing') {
// Round 1: return the question instead of pushing a request to the client.
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({ message: `Reserve one copy of ${title}?`, requestedSchema: Confirm }),
},
});
}
// Round 2: the client retried the same call with the answer attached.
const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', Confirm);
if (answer?.confirm !== true) {
return { content: [{ type: 'text', text: 'Reservation cancelled.' }] };
}
CATALOG[title].stock -= 1;
return { content: [{ type: 'text', text: `Reserved ${title}. ${CATALOG[title].stock} left.` }] };
},
);
server.registerResource(
'books',
'catalog://books',
{ description: 'Every title in the catalog, one per line.' },
async (uri) => ({ contents: [{ uri: uri.href, text: Object.keys(CATALOG).join('\n') }] }),
);
return server;
}
serveStdio(buildServer); // serves 2026-07-28 and 2025-era clients
What changed:
- Imports:
@modelcontextprotocol/sdk/server/mcp.js→@modelcontextprotocol/server; stdio pieces come from the/stdiosubpath. - Schemas:
inputSchematakes a whole schema object (z.object({...}), Zod 4.2+ or any Standard Schema library) instead of a raw shape. Zod 3 is no longer supported. - Entry point:
serveStdio(factory)replacesserver.connect(new StdioServerTransport()). The factory matters: the SDK creates one server instance per connection and works out the protocol era from the opening message. Pass{ legacy: 'reject' }only when you want to turn old clients away. For HTTP the equivalent iscreateMcpHandler(factory), whose defaultlegacy: 'stateless'serves 2025-era traffic too. - Confirmation: the handler returns
inputRequired(...)in round one and readsctx.mcpReq.inputResponseswhen the client retries. Old clients still work because a built-in compatibility layer (on by default) turns that return value into a realelicitation/createrequest and calls your handler again with the answer. Validate answers withacceptedContent(..., schema), because the SDK doesn’t re-check them.
Test it with the same Python clients. They don’t care what language the server is written in:
cd bookshop-ts # the folder with the v2 server.ts
../bookshop/.venv-new/bin/python ../bookshop/client_v2.py auto npx tsx server.ts 2>/dev/null # protocol: 2026-07-28
../bookshop/.venv-old/bin/python ../bookshop/client_v1.py npx tsx server.ts 2>/dev/null # protocol: 2025-11-25
Both runs print the full checklist, ending in reserve_copy: Reserved Dune. 2 left.
On the TypeScript client side, there’s a trap. A v2 Client speaks the
2025 protocol unless you opt in. In the SDK’s words, “nothing in v2 puts a
2026-07-28 byte on the wire by default.” Here’s a v2 client that opts in
(npm i @modelcontextprotocol/client):
// client.ts -- TypeScript SDK v2 client: npx tsx client.ts <command> [args...]
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const [command, ...args] = process.argv.slice(2);
const client = new Client(
{ name: 'bookshop-check', version: '1.0.0' },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: { mode: 'auto' }, // without this, v2 clients speak 2025-era only
},
);
client.setRequestHandler('elicitation/create', async (request) => {
console.log(` [server asks] ${request.params.message}`);
return { action: 'accept', content: { confirm: true } };
});
await client.connect(new StdioClientTransport({ command, args }));
console.log('era:', client.getProtocolEra());
const { tools } = await client.listTools();
console.log('tools:', tools.map((t) => t.name));
const r = await client.callTool({ name: 'reserve_copy', arguments: { title: 'Dune' } });
console.log('reserve_copy:', r.content[0].type === 'text' ? r.content[0].text : r.content);
await client.close();
npx tsx client.ts npx tsx server.ts
era: modern
tools: [ 'get_author', 'reserve_copy' ]
[server asks] Reserve one copy of Dune?
reserve_copy: Reserved Dune. 2 left.
Remove the versionNegotiation line and the same run prints era: legacy.
'auto' against the v1 server also prints era: legacy (fallback), while
versionNegotiation: { mode: { pin: '2026-07-28' } } against it fails with
SdkError: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode).
Step 5 — Where session state goes now
The bookshop has no per-user state, so it migrated cleanly. Servers that do, such as “the current project”, “the logged-in user’s cart”, or “the document we opened earlier”, need one more change. The spec now says a server must not use earlier requests on the same connection as context, even over stdio. State that spans calls has to be named explicitly in every request.
The usual fix is a handle. One tool creates the state and returns an ID, and every later call passes that ID back as a normal argument:
# cart_v2.py -- state travels as a handle, not as "the current session"
import uuid
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("cart")
CARTS: dict[str, list[str]] = {} # stand-in for Redis/Postgres when you run replicas
@mcp.tool()
def start_cart() -> str:
"""Create a cart. Pass the returned cart_id to every other cart tool."""
cart_id = f"cart_{uuid.uuid4().hex[:8]}"
CARTS[cart_id] = []
return cart_id
@mcp.tool()
def add_to_cart(cart_id: str, title: str) -> str:
"""Add a book to the cart identified by cart_id."""
if cart_id not in CARTS:
raise ToolError(f"Unknown cart_id {cart_id!r}. Call start_cart first.")
CARTS[cart_id].append(title)
return f"{cart_id}: {', '.join(CARTS[cart_id])}"
if __name__ == "__main__":
mcp.run()
The model is good at carrying IDs between calls; that’s what the tool descriptions above are telling it to do. Three rules of thumb:
- Shared domain data (the catalog, the stock count) belongs in your database, as it always did. A module-level dict works for one process but not for two replicas.
- Per-conversation state becomes a handle backed by a store. Don’t key it on the connection.
- Mid-call state (where you were when you asked the user something)
travels in MRTR’s
requestState, which the client sends back unchanged. Python’sMCPServerencrypts it for you (AES-GCM, 10-minute TTL) with a key that exists only in that one process. That’s fine for stdio or a single worker. If you run several replicas, pass a shared key:MCPServer("bookshop", request_state_security=RequestStateSecurity(keys=[secret])). In TypeScript, sign it withcreateRequestStateCodecand treat it as untrusted input.
Step 6 — Keep old clients working (and know who’s still old)
Both SDKs make dual-era the default for servers, so the main risk is someone deciding the migration is “done” and turning legacy off. Before you do, check who connects to you. As of October 2026:
- Claude Code negotiates 2026-07-28 by default (users can opt out with
MCP_PROTOCOL_NEGOTIATION=legacy). Claude Desktop, claude.ai on the web and the Claude API connector have not publicly confirmed 2026-07-28 support. - Agent frameworks are split. The OpenAI Agents SDK (0.20.0+), Google ADK
(2.10.0+), Pydantic AI and LangChain’s new built-in
langchain[mcp]accept mcp 2.x. CrewAI still pinsmcp<2, and the now-archivedlangchain-mcp-adapters0.3.2 does too. Those connect to you as 2025-era clients. - SDK-based clients in Java, Kotlin and Swift still use the handshake. Go
serves 2026-07-28 over HTTP only with
StreamableHTTPOptions.Stateless = true. - The MCP Inspector defaults to the legacy era.
Over HTTP the Python server is dual-era out of the box too. Swapping the last
line of server_v2.py for mcp.run(transport="streamable-http", port=8765)
and connecting with all three clients gave 2026-07-28 (mcp 2.x auto),
2025-11-25 (mcp 2.x legacy) and 2025-11-25 (mcp 1.30.0
streamablehttp_client). The confirmation prompt worked in all three. One
behaviour change to know about: since mcp 2.2.0, legacy stateful HTTP
sessions expire after 30 idle minutes, with a cap of 10,000. Pass
session_idle_timeout=None, max_sessions=None to run() if you need the old
behaviour.
Before and after at a glance
| mcp 1.x / TS SDK v1 | mcp 2.x / TS SDK v2 | |
|---|---|---|
| Python install | pip install "mcp[cli]<2" | pip install "mcp[cli]" (2.3.0) |
| Python server class | from mcp.server.fastmcp import FastMCP | from mcp.server import MCPServer |
| Python context | mcp.get_context() or ctx: Context | ctx: Context parameter only (get_context() removed) |
| Model-facing tool error | raise ValueError(...) | raise ToolError(...) |
| Ask the user mid-tool | await ctx.elicit(...) (push) | resolver returning Elicit(...), or return InputRequiredResult |
| HTTP options | FastMCP(..., stateless_http=True) | mcp.run(transport="streamable-http", stateless_http=True) |
| Python client | stdio_client + ClientSession + initialize() | Client(StdioServerParameters(...), mode="auto") |
| Result fields | isError, inputSchema | is_error, input_schema |
| TS packages | @modelcontextprotocol/sdk | @modelcontextprotocol/server, /client, /core |
| TS schemas | raw shape { title: z.string() } | z.object({ title: z.string() }) (Zod 4.2+) |
| TS stdio entry | server.connect(new StdioServerTransport()) | serveStdio(() => buildServer()) |
| TS ask the user | server.server.elicitInput(...) | return inputRequired({ inputRequests: { ... } }) |
| TS client default | 2025 protocol | still 2025 protocol unless versionNegotiation is set |
| Protocol | initialize, sessions, server→client requests | server/discover, per-request _meta, MRTR |
Troubleshooting
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x ...
A fresh install picked up mcp 2.x. Either pin mcp[cli]<2 in your
dependencies or do Step 3. Check what you have with pip show mcp.
The model only sees Error executing tool get_author
On mcp 2.1+, an unexpected exception is logged on the server
(Tool 'get_author' raised an unexpected exception with a traceback on
stderr) and the client gets only the generic text. Raise ToolError
from mcp.server.mcpserver.exceptions for anything the model should
read and act on.
MCPError -32600 Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
You kept await ctx.elicit(...) and a 2026-07-28 client connected. It
still works for legacy clients, which is why it slips through tests that
only use an old client. Move the question into a resolver that returns
Elicit(...) (Step 3) so both eras work.
MCPError -32021 Client did not declare the form elicitation capability required by resolver ...
The client didn’t say it can show forms. In the Python Client, passing
elicitation_callback= is what declares it. Clients that genuinely
can’t ask the user will always fail this tool, so give it a fallback
(for example a confirm: bool argument) if you need to support them.
TypeScript: Server-to-client requests are not available on protocol revision 2026-07-28: 'elicitation/create' cannot be sent ...
ctx.mcpReq.elicitInput() (or server.elicitInput()) was called while
handling a modern request. Return inputRequired({ inputRequests: { ... } })
instead and read ctx.mcpReq.inputResponses on the retry. The error
reaches the client as a tool result with isError, so check the text,
not just whether an exception was thrown.
TypeScript client prints era: legacy against a server you just migrated
v2 clients default to the 2025 protocol. Add
versionNegotiation: { mode: 'auto' } to the client options. The
official Inspector has the same default; pass --protocol-era modern
or auto.
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@modelcontextprotocol/sdk'
The project depends on the v2 packages but still has v1 imports. Run
npx @modelcontextprotocol/codemod@latest v1-to-v2 . from the package
root, then grep -rn '@mcp-codemod-error' . for anything it couldn’t
rewrite.
AttributeError: 'MCPServer' object has no attribute 'get_context' or TypeError: MCPServer.__init__() got an unexpected keyword argument 'stateless_http'
Two more v1 holdovers. Take ctx: Context as a tool parameter instead
of calling get_context(), and move transport options (stateless_http,
json_response, host, port) from the constructor to mcp.run(...).
Version negotiation failed: the server did not offer pinned protocol version 2026-07-28
A client pinned to 2026-07-28 hit a server that hasn’t migrated. Pinned
mode never falls back. Use auto (Python mode="auto", TypeScript
versionNegotiation: { mode: 'auto' }) unless you specifically want to
refuse old servers.
Where to go next
If you’re starting a new server rather than migrating one,
build your first MCP server is
written for mcp 2.x from the start. To see the same patterns on a real
API, wrap a REST API in MCP. If your
tools run for minutes rather than seconds, the Tasks extension and its
alternatives are compared in
long-running agent tasks. For the full
protocol detail, including _meta keys, requestState integrity, caching
hints and the deprecation calendar, our sister site has a
deep dive on migrating 2025 servers.
Was this guide useful?
Thanks — noted. It shapes what gets written next.
newsletter
One practical agentic-AI guide in your inbox. No news, no hype.
Tutorials and decision frameworks as they ship. Unsubscribe anytime.