> ## Content Index
> Fetch the complete content index at: https://www.hubwiz.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# MCP Python SDK v2 迁移指南
- URL: https://www.hubwiz.com/blog/mcp-python-sdk-v2-migration-guide/
- Published: 2026-08-09T12:41:03.000Z
- Updated: 2026-08-09T12:41:03.000Z
- Description: 当我把一个可用的 MCP 服务器迁移到 v2 时，真正坏掉的是什么——那些响亮的错误、沉默的错误，以及它们到达你的顺序。
- Author: admin
- Tags: MCP, #Import 2026-10-14 14:41

每个人谈论 7 月 28 日发布的版本时，都是从这件事开始的：**FastMCP** 现在叫 **MCPServer** 了。好吧。这是一行改动，而且这个改名在你第一次运行文件时会抛出一个 `ModuleNotFoundError`，所以你大约十秒钟就修好了，然后就把它忘了。

这从来就不是会花掉你一个下午的东西。真正造成问题的改动，是那些什么都不显示、或者在距离你需要修改的那一行三层之外才有所体现的改动。

上周末我迁移了两个服务器。一个是我在 Vercel 上用的简历匹配工具；另一个是我一直保留的临时服务器，它封装了几个本地脚本，方便我从客户端测试它们。两个都不大。两个都以变更日志里确实提到过的方式坏掉了，但我仍然没有预料到。**这就是我希望一开始就能读到的实战指南。**

## 1、在你做任何事之前

整个迁移中最重要的一件事，是在你今天不打算碰的项目里写下一行代码。

PyPI 上有一万个包使用了 mcp。维护 mcp 的人说，这些包中有 84% 没有设置上限，这意味着当有人不带具体版本号执行安装时，它们全部都会使用 mcp 版本 2。如果你要发布任何使用 mcp 的东西，你需要给 mcp 加上版本限制。

```
mcp>=1.28,<2

```

这不是以后再做的建议。一个没有上限的库，会在别人下次构建全新环境时，把自己的用户拖到 v2 上，然后他们会从你的代码里得到一个 traceback，却完全不知道为什么。先做这件事——在你拥有的每一个包里，在你迁移任何一行代码之前。

## 2、既然我们不得不改

这是每个人都会讲的部分。类移动了位置，并且换了个新名字：

```
# v1
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Demo")

# v2
from mcp.server.mcpserver import MCPServer, Context
mcp = MCPServer("Demo")

```

第一个症状：`ModuleNotFoundError: No module named 'mcp.server.fastmcp'`。`mcp.server.fastmcp.*` 下的每一个子模块都移到了 `mcp.server.mcpserver.*`，布局相同，所以你的 `Image`、`Audio`、`Message` 和异常导入都以同样的方式移位。装饰器没有变化。`@mcp.tool()`、`@mcp.resource()`、`@mcp.prompt()` 接受的参数和 handler 签名都相同。如果你的服务器只是一堆纯函数上的装饰器，那么这个改名加上导入移动，可能就完成了全部工作。

对我来说不是全部。

## 2、藏在你的 except 块里的改名

`McpError` 现在是 `MCPError`。相同的缩写，不同的大小写，和 `MCPServer` 改名一个路子。单独看，这又是一个快速的导入修复：

```
# v1
from mcp.shared.exceptions import McpError
# v2
from mcp.shared.exceptions import MCPError

```

构造函数也变了形状。在 v1 里你包裹一个 `ErrorData`。在 v2 里你直接传递各个部分：

```
# v1
raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input"))
# v2
raise MCPError(INVALID_REQUEST, "bad input")

```

到目前为止，都是机械操作。真正花了我不少时间的是抛出它之后会发生什么。在 v1 中，在工具 handler 内抛出的 `MCPError` 会被包装器捕获，并变成一个 `CallToolResult`，`isError=True`，消息被塞进 content 里。

调用方读取文本然后继续。在 v2 中，同样的 raise 会产生一个顶层 JSON-RPC 错误，客户端会把它重新抛出为 `MCPError`，code 和 data 原样保留。

这可能是更好的设计。但它也是一个行为变化，正好落在我错误处理所依赖的代码路径上，而这对一个"可能更好"来说，是个更糟糕的现身地点：

```
# v1
result = await session.call_tool("match", {"resume": blob})
if result.isError:
    handle(result.content)

# v2
try:
    result = await client.call_tool("match", {"resume": blob})
except MCPError as e:
    handle(e.code, e.message, e.data)

```

在那个片段里有两件事坑了我。异常现在会逃逸出来，而不是以返回值的形式到达；而且 `result.isError` 不再是一个字段了。第二件事原来是那个产生全天最令人困惑的失败的变化的开端。

## 3、藏在下面的序列化陷阱

协议类型上的每个字段都从 camelCase 变成了 snake\_case，用于 Python 属性访问。`isError` 变成了 `is_error`。`inputSchema` 变成了 `input_schema`。`nextCursor`、`mimeType`、`structuredContent`、`serverInfo`、`protocolVersion`：全都是。

第一个症状响亮且容易：`AttributeError: 'Tool' object has no attribute 'inputSchema'`。你读到消息，你重命名访问方式，你继续。

> 第二个症状是沉默的，而它才是会浪费你一整个晚上的那个。

线上的 JSON 仍然是 camelCase。SDK 通过 Pydantic 别名正确地发送它。但一旦你自己调用 `model_dump()`，v2 就会输出 snake\_case 键，因为字段本身现在就是 snake\_case 了。没有错误。输出只是安静地变成了一种其他任何 MCP 实现都不认识的形状。

```
tool.model_dump()                            # {"input_schema": ...}  wrong on the wire
tool.model_dump(by_alias=True, mode="json")  # {"inputSchema": ...}   correct

```

如果你的任何代码手动序列化一个协议类型——为了缓存、为了日志、为了传给另一个服务——你需要 `by_alias=True`，而且如果你忘了，没有任何东西会警告你。解析则无所谓；`model_validate()` 仍然接受两种写法。只有你自己的 dump 会翻转。

**相关，而且同样安静：** 额外字段不再往返了。在 v1 中，类型允许未知键并会重新序列化它们。在 v2 中，它们会在校验期间被丢弃，悄无声息。如果你曾经通过附加属性在 MCP 类型里走私自定义数据，那么这些数据现在会毫无错误地消失。支持的存放位置是 `_meta`，它会被保留。

## 4、那个从不提 mcp 的失败

这是我最希望在开始之前就知道的变化。

SDK 从 `httpx` 和 `httpx-sse` 迁移到了 `httpx2`。如果你自己的代码导入 `httpx`，并悄悄地依赖 SDK 来安装它，那么这个导入现在会以 `ModuleNotFoundError: No module named 'httpx'` 失败，而且 traceback 里完全没有 `mcp` 这个词。你会先到错误的地方去找。我就是。

`httpx2` 是 API 兼容的，所以大多数时候导入名是唯一的改动：

```
# v1
import httpx
http_client = httpx.AsyncClient(follow_redirects=True)
# v2
import httpx2
http_client = httpx2.AsyncClient(follow_redirects=True)

```

更阴险的版本是你的异常处理器。SDK 现在抛出 `httpx2` 的异常。如果 `httpx` 仍然安装在你依赖树的某个地方，一个旧的 `except httpx.ConnectError:` 块会继续导入而没有任何抱怨，然后只是永远不再匹配。没有任何东西告诉你这个块已经失效了。你的重试逻辑只是不再捕获它原本要捕获的东西。

**把三处地方放在一起审查：** 你的 `import httpx` 行、你的 `except httpx.*` 子句，以及你的测试 fixtures。一个 `pytest.raises(httpx.ConnectError)` 或一个 `httpx.MockTransport`，在被测代码迁移到 `httpx2` 的那一刻，就指向了错误的类型。而且这些对象在运行时是不可互换的：把 `httpx.AsyncClient` 递给 SDK 而它想要的是 `httpx2` 的客户端，你不会得到异常，你会得到一个服务器发起的消息悄悄停止到达的客户端。至少响亮的版本是响亮的：`httpx.AsyncClient(auth=provider)` 会抛出 `TypeError: Invalid "auth" argument`，因为 SDK 的 auth providers 现在继承自 `httpx2.Auth`。

这里还藏着一个 TLS 的小坑。`httpx` 是对照打包的 `certifi` 列表验证的；`httpx2` 对照操作系统信任库验证。在一个没有可用系统 CA 存储的最小容器里，以前能用的连接现在会失败。如果你遇到这种情况，把 `SSL_CERT_FILE` 指向一个 bundle。

## 5、同步 handler 移出了事件循环

在 v1 中，同步的 `def` 工具内联运行在事件循环上。如果它阻塞，就会拖住每一个其他在途请求。在 v2 中，SDK 通过 `anyio` 把同步 handler 运行在工作线程上。大多数时候这是一个免费并发收益，你什么都不用做。

陷阱既狭窄又具体：在 `def` handler 里调用 `asyncio.get_running_loop()` 现在会抛出 `RuntimeError`，因为工作线程上没有循环。

如果一个同步 handler 之前会去够运行中的循环，或者触碰只有事件循环线程才该触碰的状态，那么那个假设已经不存在了。修复方法通常是声明 handler 为 `async def`，让它留在循环上。

## 6、现在是客户端先开口

`Client` 在 v2 中默认 `mode='auto'`。可见的症状完全不在你这一侧：服务器开始记录一个以前从未见过的意外 `server/discover` 请求。那是客户端在探测服务器在无状态协议下能做什么。这是预期行为。如果你运营服务器并对未知方法设置告警，这一个会在你知道要预期它之前，白白呼你一次。

## 7、如果你遇到这种情况，把这段读两遍

高层路径主要是装饰器和改名。低层 `Server` 被重构了。基于装饰器的 handler 注册没有了；handler 现在是构造函数的 `on_*` 参数：

```
# v1
server = Server("my-server")

@server.list_tools()
async def handle_list_tools():
    return [Tool(name="my_tool", description="A tool", inputSchema={})]

# v2
async def handle_list_tools(ctx, params):
    return ListToolsResult(
        tools=[Tool(name="my_tool", description="A tool",
                    input_schema={"type": "object"})]
    )

server = Server("my-server", on_list_tools=handle_list_tools)

```

如果你错过了，第一个症状：`AttributeError: 'Server' object has no attribute 'list_tools'`。handler 现在接收 `(ctx, params)` 而不是解包后的请求，并且它们返回完全构造好的结果类型。自动包装没有了，所以裸的 list 或 dict 返回值会失败结果校验，而不是被方便地装箱给你。而且低层工具异常不再变成 `isError: true` 的结果；它们以 JSON-RPC 错误的形式浮出水面，这意味着以前会读取你的错误文本的客户端，现在会抛出异常。

## 8、帮我省下时间的东西

**做完这一切之后，最不疼的做法：**

先锁定你的依赖，并修复 mcp CLI 的用法。然后一次性完成改名和导入移动——那些会在导入时抛错的，这样文件至少能加载。然后移植服务器表面，再移植客户端。然后更新传输层和认证。

然后运行你的测试。把每一个新失败都当作对更严格校验部分的一次查询，因为 v2 现在会同时针对协议 schema 校验 handler 结果和入站流量，v1 放行过去的东西在这里会失败。弃用警告放最后。

官方迁移指南里有一个表格，把每个症状映射到对应的章节，这是其中最有用的一件东西：当某个东西坏掉时，你搜索错误字符串，而不是概念。我就是靠那个表格找到上面一半内容的。

## 9、值得吗

对于一个你实际在运行的服务器——值得，尽管不是为任何一个单一功能。版本一现在处于维护模式，只有安全修复。留在原地不会让你爆炸。这是一个带着倒计时的决定。这一切之下的无状态协议才是回报。

没有会话，没有粘性会话 ID，任何请求都可以落在任何实例上，一个简单的轮询负载均衡器就能胜任。如果你运行多个进程，放在一个会删除粘性会话管道的代理后面，你以前需要照看的东西就没有了。

但要带着这样的认识进去：改名只是其中一部分。真正花费你时间的变化是这些：不报错就翻转形状的序列化、悄悄停止匹配的 except 块、从不指名是哪个库引起的导入失败。这些都不会出现在标题里。它们全都出现在你的日志里。

---

原文链接: [A Field Guide to the MCP Python SDK v2 Migration](https://medium.com/python-in-plain-english/a-field-guide-to-the-mcp-python-sdk-v2-migration-43bbe88b1e79?ref=hubwiz.com)

汇智网翻译整理，转载请标明出处