# Migrate an MCP Server to SDK v2: 12 Steps, 75 Min [2026]

> Source: <https://shattered.io/migrate-mcp-server-sdk-v2-python-typescript-2026/>
> Published: 2026-10-26 10:31:45+00:00

A Python server that imports `mcp.server.fastmcp` breaks the moment a fresh install resolves the 2.x line of the official SDK. The current stable release is [mcp 2.3.0](https://github.com/modelcontextprotocol/python-sdk/actions/runs/37070302577/attempts/1), published October 2, 2026, and the TypeScript packages reached 2.4.0 on October 23. Both implement the 2026-07-28 revision of the Model Context Protocol, which the maintainers released on July 28 together with the new SDKs.

A small Bookshop server makes a good test subject for the move: two tools, one resource and one prompt, written once in Python and once in TypeScript. Plan on 75 minutes. The Python half renames `FastMCP` to `MCPServer` and pulls transport settings out of the constructor. The TypeScript half runs an official codemod, then swaps the single `@modelcontextprotocol/sdk` package for split server and client packages. The v1.x lines are not dead yet. TypeScript maintainers promise bug fixes and security updates for at least six months after the v2 release on July 27, 2026, and that window sets your deadline.

Teams that already wired servers into editors, like the setup in the [Cursor MCP servers guide](https://shattered.io/set-up-cursor-mcp-servers-2026/), feel the change first. Every host launches whatever command sits in its config file, so a server that crashes on import shows up as disconnected instead of throwing a traceback. Step 8 shows where the real error hides.

Don't miss new tech stories on Google

Add Shattered once in the Google app and our stories show up in your news suggestions.

[Add now](https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F)

## What Changed in MCP SDK v2 and the 2026-07-28 Spec

The protocol now has two eras. Servers released before 2026-07-28 open every connection with an `initialize` handshake, where the client proposes a version, the server counters and the client acknowledges. Servers at 2026-07-28 drop that exchange. A modern client sends one `server/discover` probe and gets the server identity, capabilities and supported versions back in a single reply. Sampling and elicitation, which used to push requests from server to client mid-call, change shape too. The server returns its question and the client retries the call with the answer.

The SDKs changed shape with the protocol. In Python, `FastMCP` became `MCPServer`, protocol types moved into a separate `mcp-types` package, and the HTTP client library switched from `httpx` to `httpx2`. In TypeScript, the monolithic package split into `@modelcontextprotocol/server`, `@modelcontextprotocol/client` and `@modelcontextprotocol/core`, with thin adapters for Node, Express, Hono and Fastify. The table below pins down the version lines as of October 26, 2026.

| Area | v1.x (maintenance) | v2 (current stable) | 
|---|---|---|
| Python package | `mcp` 1.x, security fixes from 1.30.0 | `mcp` 2.x, 2.3.0 released October 2, 2026 | 
| Python server class | `FastMCP` in`mcp.server.fastmcp` | `MCPServer` in`mcp.server` | 
| Python HTTP client library | `httpx` and`httpx-sse` | `httpx2` | 
| TypeScript packages | `@modelcontextprotocol/sdk` | `@modelcontextprotocol/server` and`/client` , 2.4.0 on October 23, 2026 | 
| TypeScript HTTP serving | `StreamableHTTPServerTransport` plus`connect()` | `createMcpHandler(factory)` | 
| TypeScript stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` | 
| Protocol revision | 2025-11-25 | 2026-07-28, released July 28, 2026 | 

Plenty stays put, and that is why the move is mostly mechanical. The three primitives are unchanged: tools are chosen by the model, resources are attached by the application and prompts are invoked by the user. Python decorators keep their arguments and handler signatures. Lifespan hooks, listing methods and the way a tool return value becomes structured output all behave as before. Hosts still read the same config keys and launch the same kind of command. A migration that respects those boundaries touches imports, constructors, transports and error handling, and leaves your business logic alone.

The ecosystem around the SDKs moved as well. Anthropic has donated MCP to the Agentic AI Foundation, a Linux Foundation fund, and says the governance model stays the same, according to [its announcement](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation). At the July 28 release, the project reported nearly half a billion monthly downloads across its Tier 1 SDKs, which are Python and TypeScript. The official reference servers followed the SDKs. Version 2.1.0 of the servers repository landed on October 25 and fixed eight vulnerabilities across the filesystem, everything and Python fetch servers, according to [a DEV Community report](https://dev.to/piekwerk/mcp-servers-v210-fixes-8-vulnerabilities-filesystem-writes-could-escape-allowed-folders-112h). The v2 TypeScript reference servers also need Node.js 24, as [Reveneau](https://reveneau.com/ainews/mcp-reference-servers-v2-0-0-node-24-floor-breaking-changes) noted.

Staying on v1 is a defensible choice for a few more months. Security fixes still reach it. On October 2, 2026, the Python project disclosed that HTTP client transports could follow cross-origin redirects while carrying custom headers and request bodies, and the fix shipped as mcp 1.30.0 for the 1.x line and 2.2.0 for the 2.x line. What v1 cannot do is speak the 2026-07-28 revision. That revision is the modern protocol baseline, so the modern era is the one you will live in for a while. Wider model and agent coverage lives in the [AI and machine learning](https://shattered.io/category/ai-machine-learning/) section, but the migration itself starts with a clean workspace.

## Prerequisites and Versions to Install

Install everything before the first step. A half-configured toolchain wastes more time than the migration itself, and the two halves of this project need different runtimes.

- **Python 3.11 or newer.** The v2 SDK requires it. The baseline uses the same interpreter so the diff stays about the SDK and nothing else.
- **uv, any current release.** It resolves the SDK into a clean environment and powers the launch command hosts use.
- **mcp 1.30.0 or later in 1.x, up to (not including) 2.0** for the baseline, then**a current 2.x release** as the target. The extras flag`[cli]` adds the`mcp` command for the Inspector.
- **Node.js 20 or newer.** The v2 TypeScript packages require 20. The v2 reference servers want 24, so pick 24 if you also run those.
- **@modelcontextprotocol/server 2.4.0** , plus`@modelcontextprotocol/client` for tests and`@modelcontextprotocol/node` for HTTP.
- **zod 4.2 or newer.** The v2 packages depend on`zod ^4.2.0` , and a second copy of zod in the tree causes type errors later.
- **tsx and TypeScript** , any current release, to run and type-check the TypeScript half without a build step.
- **An MCP host.** Claude Code, Cursor, or VS Code 1.99 or later with GitHub Copilot in Agent mode. Copilot Free is enough.
- **Git.** You will tag the baseline and need a rollback point.

Check the toolchain in one pass. The commands print versions only, so any error here is a missing install and not an SDK problem.

```
python3 --version      # expect 3.11 or newer
uv --version
node --version         # expect v20 or newer (v24 for the reference servers)
npm --version
git --version
npx --version          # the Inspector runs through npx
```

The finished project lives in one folder with the Python and TypeScript servers side by side. Both import plain domain code, which is the habit that makes the migration cheap.

```
bookshop-mcp/
  pyproject.toml
  catalog.py            # plain Python domain code, no MCP imports
  server.py             # MCPServer wiring
  tests/
    test_catalog.py
    test_server.py
  ts/
    package.json
    src/
      catalog.ts        # plain TypeScript domain code
      server.ts         # buildServer() factory
      stdio.ts          # serveStdio entry point
      http.ts           # createMcpHandler entry point
    test/
      server.test.ts
  scripts/
    check.sh            # the CI gate from Step 12
```

## Steps 1-2: Inventory Your Servers and Freeze a Baseline

**Step 1: find every place the SDK is used.** A migration that misses one server leaves it importing a module that no longer exists. Search the repository, then search the machines where hosts launch servers, because editors keep their own copies of launch commands.

``` python
# Python: imports and pins
grep -rn "mcp.server.fastmcp\|from mcp\|import mcp" --include="*.py" .
grep -rn "mcp" pyproject.toml requirements*.txt 2>/dev/null

# TypeScript: imports and manifests
grep -rn "@modelcontextprotocol/sdk" --include="*.ts" --include="*.tsx" --include="*.json" . 2>/dev/null | grep -v node_modules

# Host configs that launch servers
ls .cursor/mcp.json .vscode/mcp.json 2>/dev/null
```

Record four facts for each server. Which transport does it use, stdio or HTTP? Which host launches it? Does it call sampling, elicitation or roots, which the Python docs mark as deprecated under SEP-2577? And does the HTTP variant keep sessions? Sessions decide whether you can use the stateless handler later or need the legacy path. A server built around an existing script usually scores easy on all four, because it runs over stdio and keeps no session state.

**Step 2: freeze the baseline.** Tag the repository, and save what each server advertises today. The Inspector has a command-line mode that prints the tool list as JSON. Run it once against the v1 server and keep the file. After the migration you diff it against the new output and the diff becomes your review checklist.

```
git tag pre-mcp-v2
mkdir -p baseline

# After Step 3 exists, capture the v1 surface:
npx @modelcontextprotocol/inspector --cli uv run mcp run server.py --method tools/list > baseline/tools-v1.json
```

Expect small, honest differences in that diff. The server name, the version string and some schema titles may change, and none of them are bugs. Differences in argument names, required fields or tool names are bugs, because a host or a saved prompt may call those by name.

## Steps 3-4: Build the Python v1 Baseline and Pin Its Behavior

**Step 3: create the project and the v1 server.** Start with domain code that knows nothing about MCP. That file never changes during the migration, which makes it the anchor for your tests.

```
mkdir bookshop-mcp && cd bookshop-mcp
git init
uv init --python 3.11
rm -f main.py
uv add "mcp[cli]>=1.28,<2"
uv add --dev pytest
mkdir tests
# catalog.py
CATALOG = {
    "Dune": "Frank Herbert",
    "Neuromancer": "William Gibson",
    "The Left Hand of Darkness": "Ursula K. Le Guin",
}

def search(query: str, limit: int = 10) -> list[str]:
    needle = query.lower()
    hits = [t for t, a in CATALOG.items() if needle in t.lower() or needle in a.lower()]
    return hits[:limit]

def author_of(title: str) -> str:
    return CATALOG[title]

def titles() -> list[str]:
    return sorted(CATALOG)
```

The v1 server is a thin layer over that module. It uses the patterns most v1 servers share: a positional instructions string, transport options in the constructor and a plain exception for the failure case. Each of those three choices breaks or changes behavior in v2, which is why the baseline includes them.

```
# server.py  (v1 baseline, pinned to mcp>=1.28,<2)
from mcp.server.fastmcp import FastMCP

import catalog

mcp = FastMCP("Bookshop", "Search a small book catalog.", json_response=True, stateless_http=True)

@mcp.tool()
def search_books(query: str, limit: int = 10) -> list[str]:
    """Search the catalog by title or author."""
    return catalog.search(query, limit)

@mcp.tool()
def get_author(title: str) -> str:
    """Look up the author of a book in the catalog."""
    try:
        return catalog.author_of(title)
    except KeyError:
        raise ValueError(f"No book titled {title!r} in the catalog.")

@mcp.resource("catalog://titles")
def titles() -> str:
    """Every title in the catalog, one per line."""
    return "\n".join(catalog.titles())

@mcp.prompt()
def summarize(text: str) -> str:
    """Summarize a piece of text in one sentence."""
    return f"Summarize the following text in one sentence:\n\n{text}"

if __name__ == "__main__":
    mcp.run()
```

**Step 4: pin the behavior with tests that do not touch MCP.** These tests exercise `catalog.py` directly. They pass on v1, they pass on v2, and they prove the migration changed wiring and not logic. A Python tool that polls an HTTP API, such as the one in the [FACEIT level checker](https://shattered.io/faceit-level-checker-python-2026/), follows the same split, with the network client in plain code and the decorator on a three-line wrapper.

``` python
# tests/test_catalog.py
import catalog

def test_search_matches_title_and_author():
    assert catalog.search("dune") == ["Dune"]
    assert catalog.search("gibson") == ["Neuromancer"]

def test_search_respects_limit():
    assert len(catalog.search("e", limit=2)) == 2

def test_author_of_unknown_title_raises():
    try:
        catalog.author_of("Nothing")
    except KeyError:
        return
    raise AssertionError("expected KeyError")
uv run pytest -q
git add -A && git commit -m "bookshop: v1 baseline"
git tag pre-mcp-v2 -f
npx @modelcontextprotocol/inspector --cli uv run mcp run server.py --method tools/list > baseline/tools-v1.json
```

The pytest run prints three green dots and a summary line. If the Inspector command hangs, stop it and check that `npx` works, because the Inspector is a Node.js app even when your server is Python.

```
...                                                                      [100%]
3 passed
```

## Steps 5-6: Upgrade Python to MCPServer and Fix the Breaking Changes

**Step 5: bump the dependency and read the first failure.** Swap the pin, then run the server before touching any code. The first error tells you whether your imports or your pins are the problem.

```
uv remove mcp
uv add "mcp[cli]>=2,<3"
uv pip show mcp
uv run python server.py
Name: mcp
Version: 2.3.0
...
ModuleNotFoundError: No module named 'mcp.server.fastmcp'
```

Your version line may show a newer 2.x release, which is fine. Newer 2.x builds append a pointer to the migration guide under that traceback. The import fails because `FastMCP` no longer exists. It is now `MCPServer`, and the whole `mcp.server.fastmcp` package moved to `mcp.server.mcpserver` with the same internal layout. Decorators, tool return handling, the lifespan argument and the listing methods behave as they did in v1. The table lists the renames that bite most projects, with the symptom each produces when you miss it.

One packaging change sits behind that rename. Protocol types now live in the separate `mcp-types` package, which installs automatically and stays pinned to the exact SDK version. Code that imports `mcp.types` keeps working through an alias, so most files need no edit.

| Layer | v1 name | v2 name | First symptom if missed | 
|---|---|---|---|
| Python | `FastMCP` | `MCPServer` | `ModuleNotFoundError` on`mcp.server.fastmcp` | 
| Python | `McpError` | `MCPError` | `ImportError` on`McpError` from`mcp` | 
| Python | camelCase fields such as `inputSchema` | snake_case fields such as `input_schema` | `AttributeError` on a missing attribute | 
| Python | `httpx` imports and exception handlers | `httpx2` | `ModuleNotFoundError` for`httpx` , or an except clause that never matches | 
| Python | `ctx.fastmcp` | `ctx.mcp_server` | `AttributeError` inside a handler | 
| TypeScript | `@modelcontextprotocol/sdk/...` paths | `@modelcontextprotocol/server` ,`/client` ,`/core` | TS2307 cannot find module | 
| TypeScript | `server.tool()` | `server.registerTool()` | Type error on the old method | 
| TypeScript | `McpError` | `ProtocolError` | Missing export | 
| TypeScript | `extra` handler argument | `ctx` | Property errors on `extra.*` | 
| TypeScript | `SSEServerTransport` | Removed, frozen copy in `@modelcontextprotocol/server-legacy` | Missing export on the server package | 

**Step 6: fix the behavior changes the rename does not catch.** Several v2 changes compile cleanly and then act differently at runtime. Work through them in order and rewrite the server in the same pass.

- **The constructor signature.** v2 inserts`title` and`description` before`instructions` . A v1 call that passed instructions positionally still runs, but the text lands in the title slot and the instructions vanish from the initialize result. Pass everything except the name by keyword.
- **The reported version.** A server without a`version` now reports an empty string instead of the SDK version. Set your own.
- **The default name.** A nameless server used to report`FastMCP` and now reports`mcp-server` . Name yours explicitly, because allow-lists and dashboards key off it.
- **Transport options.**`host` ,`port` ,`json_response` ,`stateless_http` and the path settings left the constructor and now go to`run()` . Leave one behind and Python raises a`TypeError` before MCP is involved.
- **Tool errors.** Raise`ToolError` when the model should read the message. Any other exception counts as a crash, and the model learns only that the tool failed.
- **Worker threads.** Plain`def` handlers now run on a worker thread, so calling`asyncio.get_running_loop()` inside one raises a`RuntimeError` . Make the handler`async def` if it needs the loop.
- **Request limits.** Cap the size of the POST bodies your Streamable HTTP endpoint accepts, and raise the cap only if real messages exceed it.
- **Deprecations.** Roots, sampling and logging methods emit`MCPDeprecationWarning` under SEP-2577. Treat the warning as a ticket, not as noise.

Lifespan deserves a second look if your server holds a database pool or an HTTP client. Over Streamable HTTP, the lifespan now runs once when the session manager starts, and every session and request shares the result. Per-process state such as pools and caches benefits from that. Per-connection state breaks, so move any resource that belongs to a single caller into the handler body. Stdio servers see no difference, because one process serves one host.

Here is the Bookshop server after all eight changes. It keeps the same tool names and arguments, so hosts and saved prompts keep working. The additions are the tool titles, the read-only annotations, the argument bounds and the keyword constructor.

``` python
# server.py  (v2, mcp>=2,<3)
import os
from typing import Annotated

from pydantic import Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

import catalog

READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)

mcp = MCPServer(
    "Bookshop",
    instructions="Search a small book catalog.",
    version="1.1.0",
)

@mcp.tool(title="Search the catalog", annotations=READ_ONLY)
def search_books(
    query: Annotated[str, Field(description="Title or author to search for.")],
    limit: Annotated[int, Field(ge=1, le=50, description="Maximum number of results.")] = 10,
) -> list[str]:
    """Search the catalog by title or author."""
    return catalog.search(query, limit)

@mcp.tool(title="Find a book's author", annotations=READ_ONLY)
def get_author(title: str) -> str:
    """Look up the author of a book in the catalog."""
    try:
        return catalog.author_of(title)
    except KeyError:
        raise ToolError(f"No book titled {title!r} in the catalog.") from None

@mcp.resource("catalog://titles")
def titles() -> str:
    """Every title in the catalog, one per line."""
    return "\n".join(catalog.titles())

@mcp.prompt()
def summarize(text: str) -> str:
    """Summarize a piece of text in one sentence."""
    return f"Summarize the following text in one sentence:\n\n{text}"

if __name__ == "__main__":
    if os.environ.get("MCP_TRANSPORT") == "streamable-http":
        mcp.run(transport="streamable-http", host="127.0.0.1", port=int(os.environ.get("PORT", "3001")))
    else:
        mcp.run()
```

Annotations deserve one warning. They are hints that a polite host uses to decide whether to prompt the user, and nothing enforces them. Never rely on `read_only_hint` to keep a write tool safe.

## Step 7: Test the Python Server In Memory

The v2 `Client` accepts the server object itself. No subprocess, no port, no wire. That makes it a pytest fixture, and it lets one test file check both protocol eras, which matters because hosts on the 2025 revision will keep connecting for months. In `auto` mode the client probes with `server/discover` and lands on 2026-07-28 against your own server. In `legacy` mode it runs the classic handshake and lands on 2025-11-25.

``` python
# tests/test_server.py
import pytest

from mcp import Client

from server import mcp

EXPECTED_VERSION = {"auto": "2026-07-28", "legacy": "2025-11-25"}

@pytest.fixture
def anyio_backend():
    return "asyncio"

@pytest.fixture(params=["auto", "legacy"])
def mode(request):
    return request.param

@pytest.fixture
async def client(mode):
    async with Client(mcp, mode=mode) as c:
        yield c

@pytest.mark.anyio
async def test_protocol_version(client, mode):
    assert client.protocol_version == EXPECTED_VERSION[mode]

@pytest.mark.anyio
async def test_search_returns_structured_titles(client):
    result = await client.call_tool("search_books", {"query": "dune"})
    assert result.is_error is False
    assert result.structured_content == {"result": ["Dune"]}

@pytest.mark.anyio
async def test_unknown_title_is_a_tool_error(client):
    result = await client.call_tool("get_author", {"title": "Nothing"})
    assert result.is_error is True
    assert "No book titled" in result.content[0].text

@pytest.mark.anyio
async def test_limit_is_validated_before_the_tool_runs(client):
    result = await client.call_tool("search_books", {"query": "dune", "limit": 999})
    assert result.is_error is True
    assert "less than or equal to 50" in result.content[0].text
uv run pytest tests/test_server.py -q
........                                                                 [100%]
8 passed
```

Four tests times two modes gives eight results. A tool failure is a normal result with `is_error` set, so `call_tool` never raises for it. That is why the error test asserts on the result and has no `pytest.raises`. The fixture also skips `raise_exceptions=True`, which the docs advise dropping for legacy connections because a legacy session never sanitizes errors in the first place.

If the whole file fails at import with `ModuleNotFoundError: No module named 'server'`, run pytest from the project root so `server.py` sits on the path. A `conftest.py` that appends the root to `sys.path` works as well.

## Step 8: Inspect the Server and Connect a Real Host

Tests prove behavior. The Inspector proves what a human or a model will actually see. Launch it with the `mcp dev` command, open the URL it prints, and walk the three tabs. Tools shows both entries with their new titles. Resources shows the templated and fixed entries. Prompts shows `summarize` with one required argument.

```
uv run mcp dev server.py

# Compare against the Step 2 baseline
npx @modelcontextprotocol/inspector --cli uv run mcp run server.py --method tools/list > baseline/tools-v2.json
diff baseline/tools-v1.json baseline/tools-v2.json
```

Read the diff line by line. Expect the new titles, annotations, descriptions and bounds you added in Step 6, and the bumped version string. Nothing else should move. A renamed argument or a missing tool is a regression, and this is the cheapest place to catch it.

Now connect a host. Every host needs the same thing, which is the command that starts your server. Use absolute paths, because hosts launch servers from their own working directory, and a relative path is the most common reason a server never appears.

```
# Claude Code
claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

# Claude Desktop (writes the config entry for you)
uv run mcp install server.py --name "Bookshop"
// .cursor/mcp.json
{
  "mcpServers": {
    "bookshop": {
      "command": "uv",
      "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
    }
  }
}

// .vscode/mcp.json  (different wrapper key, and each entry declares its type)
{
  "servers": {
    "bookshop": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
    }
  }
}
```

Decide where each config lives before you hand the project to a team. A `.cursor/mcp.json` or `.vscode/mcp.json` file at the repository root travels with the code, so everyone gets the same server on checkout. User-level configs stay private, which suits servers that need personal API keys. Put secrets in the host’s environment settings and keep them out of committed files. Claude Desktop starts servers in its own process, so values from your shell are not there unless you record them with the install command’s variable flags.

Fully quit Claude Desktop and reopen it after any config edit, because hosts read their config once at launch. If a server shows as failed, run the launch command yourself. A healthy stdio server prints nothing and waits for input. A traceback or an instant exit is the real bug, and Claude Desktop keeps a per-server log named `mcp-server-NAME.log` next to its main `mcp.log`. Hosts spawn servers with a minimal `PATH`, so replace a bare `uv` with the absolute path from `which uv` when the host cannot find it.

## Step 9: Port the TypeScript Server with the Official Codemod

Create the TypeScript baseline next to the Python one. Pin the old package to the 1.x line explicitly, so the install does not drift while you work, and install zod alongside it.

```
mkdir ts && cd ts
npm init -y
npm pkg set type=module
npm install "@modelcontextprotocol/sdk@1" zod
npm install -D tsx typescript
mkdir src test
js
// src/catalog.ts
export const CATALOG: Record<string, string> = {
    'Dune': 'Frank Herbert',
    'Neuromancer': 'William Gibson',
    'The Left Hand of Darkness': 'Ursula K. Le Guin'
};

export function search(query: string, limit = 10): string[] {
    const needle = query.toLowerCase();
    return Object.entries(CATALOG)
        .filter(([title, author]) => title.toLowerCase().includes(needle) || author.toLowerCase().includes(needle))
        .map(([title]) => title)
        .slice(0, limit);
}

export function authorOf(title: string): string | undefined {
    return CATALOG[title];
}
js
// src/index.ts  (v1 baseline)
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { authorOf, search } from './catalog.js';

const server = new McpServer({ name: 'bookshop', version: '1.0.0' });

server.tool(
    'search_books',
    'Search the catalog by title or author',
    { query: z.string(), limit: z.number().int().min(1).max(50).default(10) },
    async ({ query, limit }) => ({
        content: [{ type: 'text', text: search(query, limit).join('\n') || 'No matches.' }]
    })
);

server.tool('get_author', 'Look up the author of a book', { title: z.string() }, async ({ title }) => {
    const author = authorOf(title);
    return author
        ? { content: [{ type: 'text', text: author }] }
        : { content: [{ type: 'text', text: `No book titled '${title}' in the catalog.` }], isError: true };
});

await server.connect(new StdioServerTransport());
```

Commit this state, then run the codemod from the package root. Run it at `.` and not at `./src`, because it also rewrites `package.json`, and tests or scripts outside `src` import the SDK too. A dry run first shows what it would touch. Tools that fetch a remote status page, like the [Fortnite server status alert bot](https://shattered.io/esports/fortnite-server-status-alert-bot-2026/), move through this step the same way, since the codemod only cares about SDK calls and not about what the handler does.

```
git add -A && git commit -m "ts: v1 baseline"

npx @modelcontextprotocol/codemod@latest v1-to-v2 . --dry-run
npx @modelcontextprotocol/codemod@latest v1-to-v2 .

grep -rn '@mcp-codemod-error' .
npm install
npx tsc --noEmit
```

The codemod rewrites every `@modelcontextprotocol/sdk/...` import path, renames symbols such as `McpError` to `ProtocolError`, turns `.tool()` calls into `registerTool` calls, wraps raw zod shapes in `z.object()`, remaps `extra` to `ctx`, and swaps the dependency in `package.json`. Anything it recognized but could not rewrite safely gets an `@mcp-codemod-error` comment, which is why the grep matters. Format the touched files afterward, because the tool rewrites the syntax tree without reformatting. Expect `tsc` to report a few errors. Those are the manual items in the next step.

Review the result with `git diff --stat` before you fix anything by hand. A healthy run touches the manifest, every file under `src` and the test files, and little else. If it modified generated output or vendored code, revert those files and rerun with an ignore pattern. The codemod prints the exact formatter command at the end, so copy it from the terminal and not from memory. Commit the codemod output on its own, separate from your manual fixes, so a reviewer can tell mechanical changes from judgment calls.

## Step 10: Finish the TypeScript Port by Hand

The codemod gets the imports and the registration calls right. It does not choose a protocol era for you. A server that still ends with `server.connect(new StdioServerTransport())` serves 2025-era clients only, and a client pinned to 2026-07-28 cannot connect to it. Move the entry point to `serveStdio` with a factory function, and move the server construction into that factory. The handler then builds a fresh instance per connection, which is the shape every v2 transport expects.

``` js
// src/server.ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { authorOf, search } from './catalog.js';

export function buildServer(): McpServer {
    const server = new McpServer({ name: 'bookshop', version: '1.1.0' });

    server.registerTool(
        'search_books',
        {
            description: 'Search the catalog by title or author',
            inputSchema: z.object({
                query: z.string().describe('Title or author to search for'),
                limit: z.int().min(1).max(50).default(10).describe('Maximum number of results')
            }),
            outputSchema: z.object({ titles: z.array(z.string()) })
        },
        async ({ query, limit }) => {
            const titles = search(query, limit);
            return {
                content: [{ type: 'text', text: titles.join('\n') || 'No matches.' }],
                structuredContent: { titles }
            };
        }
    );

    server.registerTool(
        'get_author',
        {
            description: 'Look up the author of a book in the catalog',
            inputSchema: z.object({ title: z.string() })
        },
        async ({ title }) => {
            const author = authorOf(title);
            if (!author) {
                return { content: [{ type: 'text', text: `No book titled '${title}' in the catalog.` }], isError: true };
            }
            return { content: [{ type: 'text', text: author }] };
        }
    );

    return server;
}
js
// src/stdio.ts
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { buildServer } from './server.js';

void serveStdio(buildServer);
console.error('bookshop MCP server running on stdio');
```

Three details in that code come straight from migration traps. The stdio helper lives on the `/stdio` subpath, because the package root stays runtime-neutral for browsers and Workers. The log line uses `console.error`, since stdout carries the protocol and one stray `console.log` corrupts the stream. And the inline schema uses `z.object()` in place of a raw shape, which v2 deprecates. Add a minimal `tsconfig.json` and the scripts below, then delete the old `src/index.ts`.

```
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src", "test"]
}

// package.json (relevant parts)
{
  "type": "module",
  "scripts": {
    "start": "tsx src/stdio.ts",
    "http": "tsx src/http.ts",
    "typecheck": "tsc --noEmit",
    "test": "tsx --test test/server.test.ts"
  },
  "dependencies": {
    "@modelcontextprotocol/server": "^2.4.0",
    "@modelcontextprotocol/node": "^2.1.2",
    "zod": "^4.2.0"
  }
}
```

The search tool now declares an output schema and returns `structuredContent` next to the text block. Hosts that understand structured output read the typed titles array. Hosts that do not still read the text. Keep both, because the text block is what a model sees in many clients today. The Python server does the same thing automatically from its `list[str]` return type, wrapping the value in a `result` key. That is why the Python test checks the `result` key while the TypeScript test checks the `titles` key. The two servers stay compatible at the text level and differ only in the structured envelope.

Install the client package as a dev dependency with `npm install -D @modelcontextprotocol/client` for the test file in Step 11. If `npm ls zod` lists more than one copy, align them now. Two copies cause the TS2589 excessive-depth error at an unrelated-looking call site.

## Step 11: Serve Over Streamable HTTP with Host, Origin, and Auth Checks

Stdio fits a server that runs on the same machine as its host. Anyone else needs a URL. Streamable HTTP is the transport for that job. The older SSE transport has been superseded, so build nothing new on it. The two languages handle the security basics differently, so each gets its own subsection.

### Python: run(), request limits and a TokenVerifier

Transport options go to `run()`, never to the constructor. The server binds to `127.0.0.1` by default and serves the endpoint at `/mcp`. DNS-rebinding protection switches on automatically when the host is a loopback address. Start the server over HTTP, then point a client at it.

```
MCP_TRANSPORT=streamable-http PORT=3001 uv run python server.py
php
# smoke.py
import anyio

from mcp import Client

async def main() -> None:
    async with Client("http://127.0.0.1:3001/mcp") as client:
        print(client.protocol_version)
        print(client.server_info.name if client.server_info else "unknown")
        result = await client.call_tool("get_author", {"title": "Dune"})
        print(result.content[0].text)

anyio.run(main)
2026-07-28
Bookshop
Frank Herbert
```

Deploying under a real hostname changes the picture. The loopback default no longer applies, so a request that carries your public hostname in its `Host` header gets HTTP 421 with the body `Invalid Host header`. Allow-list the names you serve. An entry is an exact string, so list both the bare name and the wildcard-port form.

``` python
from mcp.server.transport_security import TransportSecuritySettings

mcp.run(
    transport="streamable-http",
    host="0.0.0.0",
    port=8080,
    transport_security=TransportSecuritySettings(
        allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
        allowed_origins=["https://app.example.com"],
    ),
)
```

Authorization lives in HTTP headers, so it exists only on HTTP transports. Your server acts as an OAuth 2.1 resource server. It verifies bearer tokens, advertises where to get them and refuses everything else. It never issues tokens. Implement `TokenVerifier` with one async method, and pass it to the constructor together with `AuthSettings`. The two arguments always travel as a pair, and passing only one raises a `ValueError` at construction.

``` python
from pydantic import AnyHttpUrl

from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings

RESOURCE = "http://127.0.0.1:3001/mcp"

# Development only. A real verifier checks a JWT signature or calls the
# authorization server's token introspection endpoint.
KNOWN = {"dev-token": AccessToken(token="dev-token", client_id="dev", scopes=["books:read"], resource=RESOURCE)}

class StaticTokenVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        return KNOWN.get(token)

mcp = MCPServer(
    "Bookshop",
    instructions="Search a small book catalog.",
    version="1.1.0",
    token_verifier=StaticTokenVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.example.com"),
        resource_server_url=AnyHttpUrl(RESOURCE),
        required_scopes=["books:read"],
        validate_token_resource=True,
    ),
)
```

An unauthenticated call now stops at the door with HTTP 401 and a `WWW-Authenticate` header that points at the metadata document under `/.well-known/oauth-protected-resource/mcp`. That pointer is how a client that has never heard of your server finds the authorization server. Keep `validate_token_resource` on whenever your authorization server binds tokens to the requested resource. The project published an advisory on October 5, 2026 about server bearer-token authentication accepting tokens issued for a different context, so audience checking is not optional decoration. Read that advisory for the patched release before you ship. Note also that none of this protects stdio, where the launching process is the security boundary. Hardening beyond transport, including input validation, least-privilege scoping and tool poisoning, gets full treatment in the [Secure MCP Server for Claude build](https://shattered.io/build-secure-mcp-server-claude-2026/).

Put a reverse proxy in front of the server for TLS, and forward the original `Host` header so the allow-list sees the public name. Size limits matter too. Cap the request body size before any parsing happens, and for a catalog lookup set the smallest value that works. Raise it only when real messages, such as large resource payloads, exceed it. Session settings apply only to legacy sessions.

### TypeScript: createMcpHandler behind Host and Origin guards

The TypeScript handler takes a factory and returns an object with a web-standard `fetch` method. The factory runs once per HTTP request, so register tools inside it and keep expensive resources such as connection pools at module scope. The handler validates no `Host` header, no `Origin` header and no token. Mount those checks in front of it, which the Node helpers make a two-line job.

``` js
// src/http.ts
import { createServer } from 'node:http';

import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { buildServer } from './server.js';

const handler = createMcpHandler(buildServer);
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

const http = createServer((req, res) => {
    if (!validateHost(req, res) || !validateOrigin(req, res)) return;
    void nodeHandler(req, res);
});

http.listen(3000, '127.0.0.1', () => console.error('bookshop MCP on http://127.0.0.1:3000/mcp'));

process.on('SIGINT', async () => {
    await handler.close();
    process.exit(0);
});
```

For authentication, verify the bearer token before the handler runs and pass the result as the second argument to `handler.fetch(request, { authInfo })`. The factory receives `authInfo`, so it can build an instance for one caller. The Express adapter package ships `requireBearerAuth` for the verification step. Now add the test file. It drives the handler in process through a real client, so no port opens.

``` python
// test/server.test.ts
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';

import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler } from '@modelcontextprotocol/server';

import { buildServer } from '../src/server.js';

const handler = createMcpHandler(buildServer);
const client = new Client({ name: 'test-harness', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });

before(async () => {
    const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
        fetch: (url, init) => handler.fetch(new Request(url, init))
    });
    await client.connect(transport);
});

after(async () => {
    await client.close();
    await handler.close();
});

test('search_books returns structured titles', async () => {
    const result = await client.callTool({ name: 'search_books', arguments: { query: 'dune' } });
    assert.deepStrictEqual(result.structuredContent, { titles: ['Dune'] });
});

test('get_author reports a missing title as a tool error', async () => {
    const result = await client.callTool({ name: 'get_author', arguments: { title: 'Nothing' } });
    assert.equal(result.isError, true);
});
npm run typecheck
npm test
✔ search_books returns structured titles
✔ get_author reports a missing title as a tool error
ℹ tests 2
ℹ pass 2
ℹ fail 0
```

## Step 12: Gate the Migration in CI and Plan the Rollback

A migration that nobody guards drifts back. Someone copies an old snippet from a wiki, and a v1 import returns to the tree. One script catches that, runs both test suites and fails the build on leftovers. Wire it into whatever CI you use, and run it locally before every push.

``` bash
#!/usr/bin/env bash
# scripts/check.sh
set -euo pipefail

echo "== leftovers =="
if grep -rn "mcp.server.fastmcp\|FastMCP" --include="*.py" --exclude-dir=.venv .; then
  echo "v1 Python imports remain"; exit 1
fi
if grep -rn "@modelcontextprotocol/sdk" ts/src ts/test ts/package.json; then
  echo "v1 TypeScript package remains"; exit 1
fi
if grep -rn "@mcp-codemod-error" ts/src; then
  echo "unresolved codemod markers"; exit 1
fi

echo "== python =="
uv run pytest -q

echo "== typescript =="
(cd ts && npm run typecheck && npm test)

echo "ok"
```

The rollback plan has three layers, and you want all of them written down before the merge. First, the `pre-mcp-v2` tag restores the exact baseline. Second, keep the old pins ready: `mcp>=1.30,<2` for Python, because 1.30.0 carries the redirect fix, and `@modelcontextprotocol/sdk@1` for TypeScript. Third, remember that the two TypeScript generations have different package names, so they coexist in one manifest. That allows a staged move. Add the v2 packages first, rewrite directory by directory, and drop the v1 package only when no file imports it. Swap the manifest first and every unconverted import fails module resolution at once.

One staging rule matters more than the rest. Objects from the v1 and v2 modules must never cross. An error class or client built by one generation fails `instanceof` checks in the other. Split along process or transport boundaries, where the two sides share only the wire format. They negotiate through the ordinary 2025-era handshake and settle on the newest revision both support. The TypeScript maintainers promise v1 fixes for at least six months after the v2 release on July 27, 2026. That is a minimum and not a target, so plan your migration well inside it.

Define done before you merge. The migration is finished when the check script passes on a clean clone, both test suites cover the 2025 and 2026-07-28 eras, the Inspector diff shows only intended changes, and every host config in the repository launches the new server. Then tell the people who use the servers. A teammate with a stale editor config will see a disconnected server and assume the network is down, so a one-line note about the new launch command saves a round of questions.

### Complete project layout and run commands

The finished project matches the tree from the prerequisites section: `catalog.py`, `server.py` and two test files at the root, a `ts/` folder with `catalog.ts`, `server.ts`, `stdio.ts`, `http.ts` and one test file, and `scripts/check.sh`. Every command below works from a clean checkout once the two installs are done.

```
# Python half
uv sync
uv run pytest -q                                   # 11 passed (3 catalog + 8 server)
uv run mcp dev server.py                           # Inspector over stdio
MCP_TRANSPORT=streamable-http uv run python server.py

# TypeScript half
cd ts && npm install
npm run typecheck && npm test                      # 2 passed
npm start                                          # stdio
npm run http                                       # http://127.0.0.1:3000/mcp

# Whole project
./scripts/check.sh
```

## Common Pitfalls When You Migrate an MCP Server

These mistakes account for most lost afternoons. Several of them compile or import cleanly, which is what makes them expensive.

- **Leaving a dependency unbounded.** A package that depends on`mcp` without an upper bound resolves to 2.x on its next install. Keep`mcp>=1.28,<2` until you have migrated.
- **Passing instructions positionally.** The call still runs, but the text lands in the title slot and the model never sees the instructions. Use keywords.
- **Forgetting the parentheses.**`@mcp.tool` without`()` raises at import time. A host then reports the server as failed and not as connected with zero tools.
- **Writing to stdout.** On stdio, stdout is the wire. A stray`print()` or`console.log` breaks the host parser. Log to stderr.
- **Swapping the TypeScript manifest first.** Every old import then fails with TS2307 until you rewrite it. Let the codemod change both in one pass.
- **Mixing generations.** A v1 error or client handed to v2 code fails`instanceof` and nominal type checks. Cross only at process boundaries.
- **Missing an httpx handler.** The SDK raises`httpx2` exceptions now. An old`except httpx.ConnectError` block keeps importing and simply never matches.
- **Assuming the upgrade serves the new revision.** A TypeScript server that still calls`connect()` with a stdio transport serves 2025-era clients only.
- **Registering duplicate tool names.** The first registration wins and the second vanishes, with one warning line in the server log as the only signal.
- **Trusting annotations.** Read-only and destructive hints guide polite hosts. They enforce nothing.

## Troubleshooting: 13 Errors and Their Fixes

Search this table for the exact text of your error. Each row names the cause and the one-move fix.

| Error or symptom | Cause | Fix | 
|---|---|---|
| `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` | The class was renamed and the package moved. | Import `MCPServer` from`mcp.server` , or pin`mcp<2` for now. | 
| `TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'` | Transport options left the constructor. | Pass `host` ,`port` ,`json_response` and`stateless_http` to`run()` . | 
| `ImportError: cannot import name 'McpError' from 'mcp'` | The class is now `MCPError` . | Rename the import and every `except` clause. | 
| `AttributeError: 'Tool' object has no attribute 'inputSchema'` | Fields moved from camelCase to snake_case. | Use `input_schema` and the other snake_case names. | 
| `ModuleNotFoundError: No module named 'httpx'` | The SDK no longer installs `httpx` . | Add `httpx` to your own dependencies, or port your calls to`httpx2` . | 
| `RuntimeError: Task group is not initialized. Make sure to use run().` | A mounted sub-app never runs its own lifespan. | Enter `mcp.session_manager.run()` in the host app lifespan. | 
| `421 Misdirected Request` with`Invalid Host header` | The DNS-rebinding guard rejected your public hostname. | Pass `TransportSecuritySettings` with`allowed_hosts` , including the`host:*` form. | 
| `TypeError: The @tool decorator was used incorrectly` | Decorator written without parentheses. | Write `@mcp.tool()` . Same for resource and prompt. | 
| `SyntaxError: Unexpected token ... is not valid JSON` in the host | A stray line reached stdout. | Log to stderr and remove stdout writes from dependencies. | 
| Missing export `SSEServerTransport` from the server package | Server-side SSE was removed from v2. | Move to Streamable HTTP, or use `@modelcontextprotocol/server-legacy/sse` as a temporary bridge. | 
| `TS2589: Type instantiation is excessively deep` | Two copies of zod in the tree. | Run `npm ls zod` and force one version with`overrides` set to`^4.2.0` . | 
| `SdkError: ERA_NEGOTIATION_FAILED` | A client pinned a revision the server does not offer. | Use `mode: 'auto'` , or serve the pinned revision. | 
| `ReferenceError: crypto is not defined` | The runtime is Node.js 18 or older. | Upgrade to Node.js 20 or newer. | 

Two habits shorten every debugging session. Run the exact launch command by hand before you blame the host. And read the last line of a Python traceback, because async errors arrive wrapped in an `ExceptionGroup` that hides the real message at the bottom.

## Advanced Tips for Production MCP Servers

- **Mark header parameters carefully.** Only string, integer and boolean arguments can carry an`x-mcp-header` annotation. TypeScript 2.4.0 rejects the annotation on a number parameter, and the server answers a call to such a tool with error -32020. Use`z.int()` or drop the annotation. Python raises`InvalidSignature` at import for an invalid annotation.
- **Try Skills over MCP.** TypeScript 2.4.0 adds`loadSkills(folder)` ,`skills.attach(server)` and a`SkillsClient` , and it works on both protocol revisions. The release marks`attach` experimental, so expect it to change in a minor version.
- **Cache the negotiation.** After an`auto` connection, save`client.session.discover_result` . Reconnect with a version pin set through the`mode` argument, plus`prior_discover` , to skip the probe and still know the server identity.
- **Keep the factory cheap.** The TypeScript factory runs per request. Create pools and caches once at module scope, and let the factory only register tools.
- **Serve both eras deliberately.** The TypeScript handler serves 2025-era clients statelessly from the same factory by default. A server that depends on HTTP sessions keeps its transport for older clients and routes to it with`isLegacyRequest` , in front of`createMcpHandler(factory, { legacy: 'reject' })` .
- **Add tracing for free.** The Python SDK depends on the lightweight OpenTelemetry API only, so its tracing middleware costs nothing until you install an OpenTelemetry SDK and an exporter.
- **Preview big codebases.** Run the codemod with`--dry-run` first, and use`--ignore` globs for files that must stay on v1 because a third-party package compiles against the host copy.
- **Watch the changelog.** Releases land weekly. A running October 2026 summary sits at[mcptest.io](https://mcptest.io/learn/mcp-changes-2026-10/) , and the official blog tags every release.

Pair a migrated server with a real data source once the tests pass. A polling script such as the [Fortnite downtime alert script](https://shattered.io/fortnite-downtime-local-time-alert-script-2026/) turns into a resource that reports the next maintenance window, and the same catalog pattern from Step 3 keeps the fetch logic testable without any MCP imports.

## FAQ: MCP Server Migration to SDK v2

**Do I have to migrate to SDK v2 right now?**

No. The v1.x lines keep receiving bug fixes and security updates, and TypeScript maintainers promise that for at least six months after the July 27, 2026 release. You need v2 to speak the 2026-07-28 protocol revision, so plan the move within that window.

**What replaces FastMCP in the Python SDK?**

`MCPServer`, imported from `mcp.server`. The decorator API stayed the same, so `@mcp.tool()`, `@mcp.resource()` and `@mcp.prompt()` keep their arguments. Constructor positions, transport options and the default server name did change.

**Does the TypeScript codemod finish the job?**

It handles import paths, symbol renames, the move from `.tool()` to `registerTool` and the `extra` to `ctx` rename. It marks everything it cannot rewrite with an `@mcp-codemod-error` comment. You still choose the entry point, header handling, error classes and OAuth changes by hand.

**Can one server serve both 2025-era and 2026-07-28 clients?**

Yes. The Python client negotiates by probing first and falling back to the handshake, and the TypeScript handler serves 2025-era clients statelessly from the same factory by default. Servers that rely on HTTP sessions need the legacy routing described in the advanced tips.

**Which transport should a production server use?**

Streamable HTTP for anything remote, stdio for anything the host launches locally. SSE has been superseded, so build nothing new on it.

**Does OAuth protect my stdio server?**

No. A pipe has no Authorization header, so token verification never runs there. For stdio, the process that launches the server is the security boundary, and the in-memory test client skips authorization too.

**How do I test a server without a host?**

In Python, pass the server object to `Client` and test in memory. In TypeScript, drive `handler.fetch` through a real client transport. Neither opens a port, and both can check each protocol era.

**Which runtime versions do I need?**

Python 3.11 or newer for the SDK, and Node.js 20 or newer for the TypeScript packages. The v2 TypeScript reference servers require Node.js 24.
