MCP Python SDK v2 迁移指南
当我把一个可用的 MCP 服务器迁移到 v2 时,真正坏掉的是什么——那些响亮的错误、沉默的错误,以及它们到达你的顺序。
AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo
每个人谈论 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
汇智网翻译整理,转载请标明出处