FastMCP 改名后 MCP 服务怎么起:pip 装 2.3 实测

结论先说:现在执行 pip install mcp 装到的是 2.3.0,照 2025 年教程写的 from mcp.server.fastmcp import FastMCP 会直接报 ModuleNotFoundError,改两行导入就能跑起来,从装环境到握手成功大约十分钟。适合已经写过一半、卡在导入那一步的人。

下面所有数字都是本机实测:macOS,Python 3.13.12,mcp 2.3.0 与 mcp 1.30.0 两套虚拟环境并行跑,报文是直接往 stdio 发 JSON 拿到的原始回包,没有经过任何客户端包装。

第一步:先确认你装的是哪一代

很多人连自己装的是 1.x 还是 2.x 都不清楚,先量一下。

python3 -m venv .venv && source .venv/bin/activate
pip install mcp
python -c "import mcp, importlib.metadata as m; print(m.version('mcp'))"

本机输出 2.3.0。接着验证老写法还在不在。

python -c "from mcp.server.fastmcp import FastMCP"

实测报错原文如下,注意它自己带了迁移指引,不是单纯找不到模块。

ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer)
and other APIs changed; see the migration guide at
https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver
or pin 'mcp<2' to keep running v1 code.

所以分岔路口很清楚。想留在老代码上,把依赖钉死。

pip install "mcp<2"

本机装到 1.30.0,FastMCP 立刻可用。想上 2.x,就改导入路径,见下一节。

2.3 的最小服务端:两个工具,二十六行

存成 server.py,直接跑。

import os
from mcp.server import MCPServer

mcp = MCPServer("demotools", version="0.1.0")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers.

    Args:
        a: first number
        b: second number
    """
    return a + b

@mcp.tool()
def disk_usage(path: str) -> str:
    """Report total and free space of a path.

    Args:
        path: absolute path to inspect
    """
    s = os.statvfs(path)
    total = s.f_blocks * s.f_frsize / 1024**3
    free = s.f_bavail * s.f_frsize / 1024**3
    return f"total={total:.1f}GiB free={free:.1f}GiB"

if __name__ == "__main__":
    mcp.run(transport="stdio")

和 1.x 的差别只有两处。导入从 mcp.server.fastmcp 换成 mcp.server,类名从 FastMCP 换成 MCPServer。装饰器、类型注解、docstring 的用法完全没变。

有一个小陷阱:1.30.0 的 FastMCP 不接受 version 参数,传了直接 TypeError: FastMCP.__init__() got an unexpected keyword argument ‘version’。2.3.0 的 MCPServer 接受 version,但不传的话 serverInfo.version 是空字符串,客户端界面上那一栏就是空白。想显示版本号,两个版本都得按各自语法写。

不起客户端也能验:手发四帧报文

stdio 传输的帧格式是一行一个 JSON,以换行结尾。这一点和 LSP 不一样,没有 Content-Length 头,所以 pipe 进去就能测。四个帧依次发。

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"raw","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":40}}}

initialize 的回包决定后面能不能继续。

{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"protocolVersion":"2025-06-18","serverInfo":{"name":"demotools","version":"0.1.0"}}}

tools/call 的回包是教程里最该先看一眼的东西。

{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"42","type":"text"}],"isError":false,"structuredContent":{"result":42}}}

四帧里最容易漏掉的是第二帧 notifications/initialized。它没有 id,也没有回包,但缺了它,部分客户端会一直停在握手中状态。用 curl 直接打 HTTP 端点的人不会遇到这个问题,用 stdio 的人常踩。

两代 SDK 的实测差异

同一份逻辑写两遍,逐项比对,差异比文档写的多。

对比项 mcp 1.30.0 mcp 2.3.0
导入写法 from mcp.server.fastmcp import FastMCP from mcp.server import MCPServer
构造参数 FastMCP 名,无 version MCPServer 名,可加 version
serverInfo.version 自动填 SDK 版本 1.30.0 用你给的值,不给就是空串
initialize 最高协商 2025-11-25 2025-11-25
2026-07-28 无状态信封 不支持,discover 报 -32602 支持,带 _meta 可拿到版本清单
工具内 print 去向 直接进 stdout,混在帧之间 改道到 stderr,协议流安全
默认请求日志 每请求一行 Processing request 静音

表里最值得注意的一行是协议协商上限。两代都是 2025-11-25,也就是说,不管你把依赖升到哪一版,通过 initialize 握手这条路最多只能谈到这里。想真正说 2026-07-28,必须换另一条通道,后面单独讲。

返回注解决定你有没有结构化输出

这一块文档写得很轻,实测差别却很大。同样一个工具,只要返回注解写法不同,客户端拿到的东西完全不同。

返回注解 outputSchema structuredContent content 文本
-> int 或 -> str 有,包一层 result {“result”: 值} 一个 text 块
-> dict 或 -> list 或 -> set 无 无 JSON 文本,list 会拆成多个块
-> dict[str, float] 有,additionalProperties 原样字典,不包 result 一条 JSON 文本
-> pydantic 模型 有,逐字段 properties 原样对象 一条 JSON 文本

最坑的是第二行。写 -> dict 看起来最宽松,实际会静默丢掉 outputSchema 和 structuredContent,客户端拿到的只是一段格式化过的 JSON 字符串。返回 -> list 更麻烦,一个两元素的列表会被拆成两个 text 块,模型可能把它们当成两段独立信息读。

想让结构化输出真正生效,把类型写具体。

class Disk(BaseModel):
    total_gib: float
    free_gib: float

@mcp.tool()
def model_out() -> Disk:
    """Return a pydantic model."""
    return Disk(total_gib=12.5, free_gib=3.2)

实测这个工具生成的 outputSchema 是逐字段的 properties,structuredContent 直接是 {“total_gib”: 12.5, “free_gib”: 3.2},没有多余的 result 包装。

踩坑:四个只在实机才暴露的失败点

第一个是 print。所有 2025 年的教程都在开头警告别用 print,理由是会把 JSON-RPC 流搞脏。这句话在 1.30.0 上依然成立,实测在工具里 print 一行,这行裸文本就夹在两个 JSON 帧中间输出,按行解析的客户端当场崩。但在 2.3.0 上已经改了:stdio 传输启动时把 fd 1 改指到 fd 2,连 os.write(1, ...) 这种绕过 Python 的写法都会落到 stderr。所以照老教程在 2.x 上小心翼翼地躲 print,其实是白躲。

不过 2.x 有个新问题。print 的内容进了 stderr,但管道是块缓冲,服务被 SIGTERM 或客户端强杀时缓冲区整段丢掉。实测不加 flush 的那一行在 stderr 里完全找不到,加了 flush 才出现。调服务端要写 print(..., file=sys.stderr, flush=True),或者直接用 logging。

第二个是异常。工具里 raise 一个 ValueError,客户端收到的只有这一句。

{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"Error executing tool boom","type":"text"}],"isError":true}}

真正的 ValueError 只出现在服务端 stderr 的 traceback 里。也就是说,用户看到的是没有信息量的失败,你要定位必须去翻服务端日志。工具里该自己 catch,把原因写进返回值,别指望框架帮你转达。

第三个是参数说明。docstring 里写了 Args 段,但实测那几行只进了工具的整体 description,inputSchema 里每个字段仍然是光秃秃的 title 和 type,客户端表单上看不到任何说明。两代行为一致。想要字段级说明,得用 Annotated 配 Field。

from typing import Annotated
from pydantic import Field

@mcp.tool()
def add(
    a: Annotated[int, Field(description="first number")],
    b: Annotated[int, Field(description="second number")] = 1,
) -> int:
    """Add two integers."""
    return a + b

改完 inputSchema 里就有了 description,带默认值的 b 还会自动带上 default 并从 required 里移出。

第四个是发现阶段。想手动探一下现代协议,直接发 server/discover,2.3.0 回的是 -32601 Method not found,1.30.0 回的是 -32602 Invalid request parameters。两个错误码看着像不支持,其实只是缺了一个字段,下一节说。

协议版本怎么对齐

先看 initialize 这条老路怎么谈版本。把 protocolVersion 换成不同值,看服务端回什么。

客户端报的版本 服务端回的版本
2024-11-05 2024-11-05
2025-03-26 2025-03-26
2025-06-18 2025-06-18
2025-11-25 2025-11-25
2026-07-28 2025-11-25
9999-01-01 2025-11-25

只要客户端报的版本高于服务端能说的,服务端就一路降到 2025-11-25,而且不报错。这个降级是静默的,客户端如果不校验回包里的版本号,会以为自己谈成了 2026-07-28。

真要走 2026-07-28,得用无状态信封:不带 initialize,每个请求在 params._meta 里自己声明版本。键名是 io.modelcontextprotocol/protocolVersion,另外还要带 clientInfo 与 clientCapabilities。

{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
  "io.modelcontextprotocol/protocolVersion":"2026-07-28",
  "io.modelcontextprotocol/clientInfo":{"name":"raw","version":"0"},
  "io.modelcontextprotocol/clientCapabilities":{}}}}

这一次就通了,回包里 supportedVersions 是 [“2026-07-28”],还带 cacheScope 与 ttlMs。同一套 _meta 直接发 tools/list 也行,不需要先 initialize。1.30.0 没有这条路,所以老版本服务端碰到只想讲现代协议的客户端,只能靠客户端自己回退。

接到客户端之前先确认三件事

命令必须是绝对路径。客户端拉起的子进程不继承你的 shell 环境,venv 没激活就找不到 mcp 包,报错还是那句 ModuleNotFoundError,容易误判成版本问题。把解释器路径写全,例如 /Users/你/项目/.venv/bin/python。

工具的 description 不能空。实测函数不写 docstring 时 description 是空字符串,客户端照样能列出这个工具,但模型没有任何依据判断该不该调它,表现就是明明有工具却总是自己硬答。工具名和描述写清楚,比多写三个工具更有用。

改完服务端要重启客户端。多数宿主只在启动时拉一次 stdio 子进程,代码保存后不重启,看到的还是旧工具列表。这一点和本地 HTTP 服务不同,那边刷新就行。

宿主怎么发现技能与工具、配置面有哪些可被投毒的位置,可以对照 DeepSeek Harness 技能手动安装的四个失效点 一起看。客户端一侧的配置流程可参考 Cherry Studio 知识库配嵌入的实测,那里讲的本地地址与端口填法,接自建 MCP 服务时同样适用。至于工具描述被改写带来的风险,AGENTS.md 投毒自查清单 里有一份可以照着核对的清单。本地接口返回 200 却拿不到东西这类静默失败,在 ComfyUI API 批量出图的五个坑 里有一整套判断方法,思路可以直接搬过来。

常见问题

pip install mcp 之后 from mcp.server.fastmcp 为什么报 ModuleNotFoundError

因为 2.x 把 FastMCP 改名成了 MCPServer,模块路径也从 mcp.server.fastmcp 变成 mcp.server。报错信息里其实给了迁移链接,很多人只看了第一行就以为是环境坏了。两个选择:改导入,或者 pip install “mcp<2” 钉回 1.30.0。

一定要升到 2.x 吗

不一定。两代通过 initialize 都能谈到 2025-11-25,绝大多数客户端走的就是这条路,1.x 服务端能正常用。只有当对端是只讲 2026-07-28 现代协议的客户端时,才必须上 2.x,因为 1.x 完全没有无状态信封那条通道。已经写好的 1.x 代码没有明确需求就别动。

服务端起来了,客户端连上却看不到工具

按三个顺序查。先看服务端有没有真的收到 tools/list,加一行 flush 的 stderr 日志就能确认。再看工具函数的 docstring 是不是空的,空描述不会报错但会让模型忽略它。最后确认客户端进程是用绝对路径拉起解释器的,venv 没生效时子进程会以退出码 1 结束,界面上通常只显示连接失败。