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, 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, 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.
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 inmcp.server.fastmcp |
MCPServer inmcp.server |
| Python HTTP client library | httpx andhttpx-sse |
httpx2 |
| TypeScript packages | @modelcontextprotocol/sdk |
@modelcontextprotocol/server and/client , 2.4.0 on October 23, 2026 |
| TypeScript HTTP serving | StreamableHTTPServerTransport plusconnect() |
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. 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. The v2 TypeScript reference servers also need Node.js 24, as Reveneau 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 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, thena current 2.x release as the target. The extras flag
[cli]adds themcpcommand 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/clientfor tests and@modelcontextprotocol/nodefor 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.
grep -rn "mcp.server.fastmcp\|from mcp\|import mcp" --include="*.py" .
grep -rn "mcp" pyproject.toml requirements*.txt 2>/dev/null
grep -rn "@modelcontextprotocol/sdk" --include="*.ts" --include="*.tsx" --include="*.json" . 2>/dev/null | grep -v node_modules
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
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 = {
"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.
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, follows the same split, with the network client in plain code and the decorator on a three-line wrapper.
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 onmcp.server.fastmcp |
| Python | McpError |
MCPError |
ImportError onMcpError frommcp |
| 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 forhttpx , 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
titleanddescriptionbeforeinstructions. 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
versionnow reports an empty string instead of the SDK version. Set your own. - The default name. A nameless server used to report
FastMCPand now reportsmcp-server. Name yours explicitly, because allow-lists and dashboards key off it. - Transport options.
host,port,json_response,stateless_httpand the path settings left the constructor and now go torun(). Leave one behind and Python raises aTypeErrorbefore MCP is involved. - Tool errors. Raise
ToolErrorwhen 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
defhandlers now run on a worker thread, so callingasyncio.get_running_loop()inside one raises aRuntimeError. Make the handlerasync defif 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
MCPDeprecationWarningunder 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.
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.
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
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 mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
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, 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.
// 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
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.
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.
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"
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.
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.
// 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.
// 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.
#!/usr/bin/env bash
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.
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
cd ts && npm install
npm run typecheck && npm test # 2 passed
npm start # stdio
npm run http # http://127.0.0.1:3000/mcp
./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
mcpwithout an upper bound resolves to 2.x on its next install. Keepmcp>=1.28,<2until 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.toolwithout()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()orconsole.logbreaks 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
instanceofand nominal type checks. Cross only at process boundaries. - Missing an httpx handler. The SDK raises
httpx2exceptions now. An oldexcept httpx.ConnectErrorblock 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 frommcp.server , or pinmcp<2 for now. |
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' |
Transport options left the constructor. | Pass host ,port ,json_response andstateless_http torun() . |
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 tohttpx2 . |
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 withInvalid Host header |
The DNS-rebinding guard rejected your public hostname. | Pass TransportSecuritySettings withallowed_hosts , including thehost:* 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 withoverrides 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-headerannotation. TypeScript 2.4.0 rejects the annotation on a number parameter, and the server answers a call to such a tool with error -32020. Usez.int()or drop the annotation. Python raisesInvalidSignatureat import for an invalid annotation. - Try Skills over MCP. TypeScript 2.4.0 adds
loadSkills(folder),skills.attach(server)and aSkillsClient, and it works on both protocol revisions. The release marksattachexperimental, so expect it to change in a minor version. - Cache the negotiation. After an
autoconnection, saveclient.session.discover_result. Reconnect with a version pin set through themodeargument, plusprior_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 ofcreateMcpHandler(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-runfirst, and use--ignoreglobs 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 atmcptest.io , 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 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.