hubagenticai

Tutorials Intermediate · 3/12

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.

updated 2026-10-08 ⏱ 45 min

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 mcp now 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_author with 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) or Server-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 sessionNo handshake. Every request carries its protocol version and client capabilities in _meta. Clients discover the server with server/discover.
Mcp-Session-Id ties requests togetherNo 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 specTasks moved to an optional extension (io.modelcontextprotocol/tasks)
Roots, Sampling, LoggingStill work, but deprecated
Dynamic Client Registration for OAuthDeprecated 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:

diagram
Asking the user mid-call under the 2026 spec: the server returns input_required, the client retries
%% 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:

  1. The rename. FastMCP is now MCPServer, imported from mcp.server (or mcp.server.mcpserver, which also exports Context). The decorators (@mcp.tool(), @mcp.resource(), @mcp.prompt()) are the same.
  2. Errors meant for the model raise ToolError. Since mcp 2.1.0, an unexpected exception reaches the client only as Error executing tool <name>. The real message goes to your server log. That’s safer, because stack details no longer leak, but a ValueError the model was supposed to read and act on is now silent. A useful rule: if a smarter model could have avoided the error, raise ToolError.
  3. 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 parameter answer is filled by running ask_to_confirm, and that resolver returns an Elicit marker. The SDK then picks the right wire format for each request. On 2026-07-28 it returns an input_required result and resumes when the client retries. On a 2025 connection it sends a normal elicitation/create request. If the user declines or cancels, the call stops before your tool body runs. (Annotate answer as ElicitationResult[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
ClientServerNegotiatedResult
mcp 2.3.0, autoserver_v2.py2026-07-28all checks pass, confirmation via input_required
mcp 2.3.0, legacyserver_v2.py2025-11-25all checks pass, confirmation via pushed elicitation/create
mcp 1.30.0server_v2.py2025-11-25all checks pass
mcp 2.3.0, autoserver_v1.py2025-11-25all checks pass (fallback)
mcp 2.3.0, 2026-07-28 (pinned)server_v1.pynoneMCPError: 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 /stdio subpath.
  • Schemas: inputSchema takes 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) replaces server.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 is createMcpHandler(factory), whose default legacy: 'stateless' serves 2025-era traffic too.
  • Confirmation: the handler returns inputRequired(...) in round one and reads ctx.mcpReq.inputResponses when the client retries. Old clients still work because a built-in compatibility layer (on by default) turns that return value into a real elicitation/create request and calls your handler again with the answer. Validate answers with acceptedContent(..., 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’s MCPServer encrypts 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 with createRequestStateCodec and 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 pins mcp<2, and the now-archived langchain-mcp-adapters 0.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 v1mcp 2.x / TS SDK v2
Python installpip install "mcp[cli]<2"pip install "mcp[cli]" (2.3.0)
Python server classfrom mcp.server.fastmcp import FastMCPfrom mcp.server import MCPServer
Python contextmcp.get_context() or ctx: Contextctx: Context parameter only (get_context() removed)
Model-facing tool errorraise ValueError(...)raise ToolError(...)
Ask the user mid-toolawait ctx.elicit(...) (push)resolver returning Elicit(...), or return InputRequiredResult
HTTP optionsFastMCP(..., stateless_http=True)mcp.run(transport="streamable-http", stateless_http=True)
Python clientstdio_client + ClientSession + initialize()Client(StdioServerParameters(...), mode="auto")
Result fieldsisError, inputSchemais_error, input_schema
TS packages@modelcontextprotocol/sdk@modelcontextprotocol/server, /client, /core
TS schemasraw shape { title: z.string() }z.object({ title: z.string() }) (Zod 4.2+)
TS stdio entryserver.connect(new StdioServerTransport())serveStdio(() => buildServer())
TS ask the userserver.server.elicitInput(...)return inputRequired({ inputRequests: { ... } })
TS client default2025 protocolstill 2025 protocol unless versionNegotiation is set
Protocolinitialize, sessions, server→client requestsserver/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.

newsletter

One practical agentic-AI guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.

Quick Jump: