Migrate an MCP Server to SDK v2: 12 Steps, 75 Min [2026] The Model Context Protocol's official Python SDK reached stable version 2.3.0 on October 2, 2026, and the TypeScript packages reached 2.4.0 on October 23, both implementing the 2026-07-28 protocol revision that replaces the `initialize` handshake with a single `server/discover` probe. The migration renames Python's `FastMCP` to `MCPServer`, moves protocol types into a separate `mcp-types` package, swaps the `httpx` HTTP client for `httpx2`, and splits the monolithic TypeScript `@modelcontextprotocol/sdk` package into `@modelcontextprotocol/server`, `/client` and `/core`, with `createMcpHandler(factory)` and `serveStdio(factory)` replacing the older transport calls. TypeScript maintainers promise bug fixes and security updates for the v1.x line for at least six months after the v2 release on July 27, 2026, setting that window as the deadline for existing servers. 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