MCP这个词在圈子里火起来的时候,我就知道很大一部分人其实被"协议"两个字吓住了。我第一次接到需求要"用FastMCP开发MCP应用"时,第一反应也是去翻JSON-RPC规范,结果被initialize、notification、tools/call这一串术语绕得头大。后来真正用FastMCP把项目落地,才意识到这玩意儿本质上就是把一个个普通的Python函数变成AI能调用、能感知的"外接能力",传输层的复杂度被框架悄悄吞掉了。今天这篇我不讲空话,直接从环境到实战再到排坑,把我用FastMCP开发MCP应用的全过程摊开写,你能照着跑通,也能理解背后发生了什么。
1. 别再自己手撕JSON-RPC了:MCP开发最省力的切入点
1.1 MCP到底在解决什么问题
先花点时间把MCP(Model Context Protocol)的位置说清楚。大模型本身是"有脑子但没手"的,训练数据截止到某个时间点,也没有办法主动访问你本地的数据库、浏览器、企业系统。以前想让AI做点真实操作,要么让它猜,要么用各种私有插件API去拼接,每家一个接口格式,想换模型就得重写一遍。MCP就是在这个混战里冒出来的"usb接口",它给AI和外部工具定了一套统一的通信规则:服务端把能力暴露成工具(Tools)、资源(Resources)、提示词(Prompts),客户端负责把模型发起的消息翻译成标准的JSON-RPC调用,再传给服务端执行。
换句话说,MCP是一个中间层协议,不负责算计逻辑,也不管你是用Python还是Node实现,它只定义双方怎么握手、怎么发现能力、怎么传参数、怎么回结果。理解了这一点,你就明白了为什么开发MCP应用时选对框架很关键:协议细节太琐碎,硬手写一遍纯属浪费生命,而FastMCP就是帮我们把规约折叠成装饰器的那层奶油。
1.2 FastMCP如何把协议封装成人话
FastMCP是MCP生态里面向Python的快速开发封装。如果你没用它,要自己处理的东西远比想象中多:服务端要响应initialize握手,维护会话状态,然后在客户端请求tools/list时把所有工具函数的信息转成JSON Schema返回,还得在tools/call被调用时做参数校验、分发到具体函数、把异常映射成协议错误码。一套下来没有几百行代码打不住,还特别容易在子协议细节上翻车。
FastMCP的做法非常直白,你在代码里写一个普通函数,加一个装饰器,它就自动完成了剩余的所有事。比如:
from mcp.server.fastmcp import FastMCP mcp = FastMCP("Demo") @mcp.tool() def add(a: int, b: int) -> int: """把两个整数相加""" return a + b if __name__ == "__main__": mcp.run()这段程序跑起来就是一个完整的MCP Server,AI客户端可以列出来一个叫add的工具,传到好的参数并拿到结果。框架自动生成函数签名对应的Schema,自动处理JSON-RPC的请求分发、序列化和错误包装,你真正要投入的注意力只用放在"业务函数怎么写"上。
1.3 官方SDK的FastMCP和社区版fastmcp怎么选
这里必须先说一个坑:目前市面上有两个名叫FastMCP的东西。一个是官方MCP Python SDK里的mcp.server.fastmcp.FastMCP,另一个是社区个人维护的fastmcp包,两者API长得很像,但开发节奏和使用场景有差异。如果你在搜索引擎里查资料,看到from fastmcp import FastMCP,那是社区版;看到from mcp.server.fastmcp import FastMCP,那是官方SDK内置的。
怎么选?我给个实际建议:正式项目或长期维护的工具优先用官方SDK,它跟MCP规范保持同步,该废弃的接口会及时调整,以后遇到客户端升级兼容问题少一些。如果只是做个快速原型、或者想看到最简的人性化文档,社区版也很好用,但要注意它和官方SDK在传递密钥、传输方式上有些微差别。下面所有示例我统一用官方SDK,因为它更接近"规范风向标",你学会了迁移到别的语言、框架也不费劲。
2. 环境准备与最小Demo:30分钟让AI调用你的第一个工具
2.1 安装与版本选择
先说环境要求:Python建议3.10以上,因为FastMCP的很多类型注解和异步特性都要依赖新版本。我习惯用虚拟环境隔离每个MCP项目,不然装了一堆全局包,以后升级第三方依赖时很容易造成"明明代码一样,别人能跑你不能跑"的诡异问题。
python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install "mcp[cli]"注意安装包名是mcp,不是fastmcp,装完后你可以用pip show mcp确认版本。mcp[cli]这个写法会额外装一些命令行工具,方便后面用mcp命令调试。装完后也可以跑一句python -c "import mcp; print(mcp.__version__)"检查能不能正常引入。
2.2 写一个最小加法工具
新建一个server.py,把刚才的加法示例放进去,然后终端运行python server.py。你会发现程序看起来"卡住"了,没有任何输出,这不是bug,而是stdio transport正在等待客户端通过标准输入传递请求。MCP的本地模式大多采用stdio——客户端把JSON-RPC消息写进服务器进程的stdin,服务器把响应写到stdout,整个过程不需要起端口,非常适合本地开发场景。
第一次写MCP Server的新手最容易在这里疑惑:我明明启动了服务,为什么什么都没发生?这是因为AI模型还不在场,你需要一个"中间人"发起会话。后面第3节我会讲用MCP Inspector来扮演这个角色,到时候你就能看到自己的工具真正被远程AI发现和调用的完整链路。
2.3 用MCP Inspector可视化调试
官方给了一个非常好用的调试工具:MCP Inspector。如果你的电脑有Node环境,一条命令就能把它启动,然后把你的MCP Server挂上去:
npx @modelcontextprotocol/inspector python server.py浏览器会自动弹出调试面板,或者你可以手动打开http://localhost:6277。在页面的工具列表里,你会看到add这个tool,填入{"a": 1, "b": 2},点调用,会返回{"content": [{"type": "text", "text": "3"}]}。到这里,你已经完整走通了一个MCP应用的"开发-暴露-调用"闭环。
我自己习惯在写任何新工具之前,都会先用Inspector验证一遍函数行为,而不是直接丢给Claude去试。因为Inspector可以直观看参数格式化的问题,你还能在调用结果里检查返回内容是不是AI能理解的结构,这比反复调Agent省钱省时间得多。
2.4 底层到底发生了什么
虽然FastMCP包装得很好,但理解底层消息长什么样,对排查问题特别有帮助。MCP是基于JSON-RPC 2.0的,当客户端调用add工具时,会往服务器发大致这样一段消息:
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":1,"b":2}}}FastMCP解析到method是tools/call后,会根据name找到注册过的add函数,把arguments展开成函数参数执行,然后把返回值打包成MCP定义好的结构回给客户端:
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"3"}]}}不难吧?之所以强调这一点,是因为后面一旦出现"工具存在但AI调用报错"的情况,十有八九是参数类型不匹配,比如AI传了一个字符串"1",而你的Python函数注释是int,这时看JSON-RPC里的arguments,几个来回就能定位问题。
3. 把工具、资源、提示词全部用起来:FastMCP三件套实战
3.1 Tool:函数即工具,参数Schema全靠类型注解
在FastMCP里,@mcp.tool()装饰器是核心,它把任意函数变成一个可被AI发现和调用的工具。但有个容易被忽略的细节:AI调不调得对,很大程度上取决于你的"函数说明书"写得好不好。函数名要尽量用小写蛇形、动词开头,比如get_weather、add_todo;参数需要完整类型注解,因为FastMCP会把这些注解转成JSON Schema,AI就是看着这个Schema决定怎么传参的。
docstring更是不能含糊,这是给AI看的"使用手册"。对比一下这两个写法:
# 反例:模型大概率不知道要传什么 @mcp.tool() def deal(a, b): return a + b # 正例:模型能明白参数含义和边界 @mcp.tool() def add(a: int, b: int) -> int: """将两个整数相加并返回结果。a和b都必须是整数,不要传小数。""" return a + b如果你有更复杂的数据结构,比如一个订单对象、一段配置信息,可以定义一个Pydantic模型作为参数类型,FastMCP会把它展示给AI的字段描述。这相当于在"AI看得懂"和"Python函数用得舒服"之间搭了一座桥。
3.2 Resource:让AI主动读取上下文,而不是只有工具
工具适合做"动作",比如增删改查;但很多场景AI更需要的是"数据"。MCP里的Resource概念就是为此设计的,它类似REST里的GET接口,但返回的是可以被当作上下文使用的数据块。用@mcp.resource()装饰器定义一个URI。
@mcp.resource("todos://list") def get_todos() -> list[dict]: """返回当前全部待办事项列表""" return todos这里我建议URI不要乱起,尽量用类似scheme://module/name的清晰结构,比如config://app_settings、data://orders。当客户端或AI需要了解当前有什么待办时,可以直接请求这个资源,而不需要AI先猜"是不是应该调某个tool"。MCP的哲学是:能通过资源给模型更多上下文,就少写几个机械工具。
3.3 Prompt:把常用套路固化成模板
Prompt这个能力很多人第一次看会忽略,但对实际项目体验提升特别大。它的作用是让开发者预定义一个提示词模板,用户可以一键选用。比如你可以做一个"任务拆解员"模板,让AI一听就知道要做什么:
@mcp.prompt() def todo_plan(task: str) -> str: return f"请帮我把任务拆解为待办事项,然后用待办工具逐项录入系统,任务内容:{task}"这样AI拿到这个prompt后,就会按你预设的路径工作,而且模板里可以直接写"请使用待办工具录入",引导模型调用你暴露的tools。说白了,Prompt就是给你一个把"人+AI+MCP服务"三者协作流程固化下来的机会。
3.4 一个实用Demo:待办事项MCP服务
把三件套串起来,我写了一个待办事项服务,代码不长但基本覆盖了开发所需的全部分类:
from mcp.server.fastmcp import FastMCP mcp = FastMCP("Todos") todos: list[dict] = [] @mcp.resource("todos://list") def get_todos() -> list[dict]: """返回当前全部待办事项列表""" return todos @mcp.tool() def add_todo(title: str, due: str | None = None) -> dict: """添加一个待办事项。title是任务名,due是可选截止时间,格式写成YYYY-MM-DD。""" item = {"id": len(todos) + 1, "title": title, "due": due, "done": False} todos.append(item) return item @mcp.tool() def complete_todo(todo_id: int) -> dict: """根据id把某个待办标记为完成,不存在时返回错误信息。""" for t in todos: if t["id"] == todo_id: t["done"] = True return t return {"error": f"todo {todo_id} not found"} @mcp.prompt() def todo_plan(task: str) -> str: return f"请帮我把任务拆解为待办事项,然后用待办工具逐项录入系统,任务内容:{task}" if __name__ == "__main__": mcp.run()这段代码我在Inspector里全部调用过:添加两条待办,标记其中一条完成,再看资源里的列表,数据完全正确。你可以看到,资源、工具、提示词三者互相配合:资源回答"现在有什么",工具负责"新增/变更",提示词则帮AI一步步操作,这样的服务对模型来说非常友好。
4. 从本地到远端:transport模式与鉴权踩坑记录
4.1 stdio vs Streamable HTTP,本地和远程的界限要分清
从这节开始,我们讲点进阶内容。很多教程跑完本地Demo就直接让你部署,但没告诉你MCP有两种大不相同的传输通道。stdio是最简单的本地模式,进程间通过标准输入/输出通信,适合个人电脑上让Claude Desktop、Cursor这类客户端直接拉起Python进程。但stdio没法跨机器访问,你不可能让别人的电脑通过stdin连到你的电脑。
远程部署现在推荐使用streamable-http,这是一种基于HTTP的MCP transport,取代了早期不太灵活的SSE方案。使用方式也很简单:
if __name__ == "__main__": mcp.run(transport="streamable-http")运行后就起了一个HTTP服务(默认监听在本地端口),你可以用指定的endpoint让远程的MCP client来连接。如果一个项目打算同时兼顾本地和远程,我建议代码里用环境变量控制transport,这样一份代码两处复用,部署时只改配置,不用改业务逻辑。
4.2 远程HTTP服务如何做鉴权与超时
把MCP Server暴露到公网前,必须先考虑一个问题:任何能够连接到这个端点的人,可能都会让你的工具执行一堆危险操作。默认情况下,FastMCP的HTTP服务没有任何鉴权,所以绝对不要让它裸奔在公网IP上。我的做法通常是在前面加一层网关做Token校验,MCP客户端的请求头里带上Authorization: Bearer <token>,网关校验通过后才把请求转发到FastMCP进程。
还有一个容易踩的坑是超时。很多MCP客户端在调用工具时会有自己的timeout设定,如果你的工具执行了耗时操作(比如调用第三方API、查大表),客户端那边可能先超时了。针对这个情况,要么把耗时的操作拆成两步:先创建任务拿到task_id,再轮询查询结果;要么在工具里引入异步能力,把真正阻塞的操作丢给后台线程执行,及时返回"已受理"的提示,避免整个会话卡死。
4.3 工具不是越多越好:拆服务比堆接口重要
我见过一个团队把一个MCP Server里塞了四五十个工具,结果AI经常"选择困难",有时候调用错工具,有时候干脆忽略一部分。原因很简单:模型在有限上下文里理解工具列表的能力也是有限度的,工具描述越长、数量越多,每一份被注意到的概率就会越低。
我的经验是:把相关工具按领域拆到不同的MCP Server中,比如todo-server只负责待办事务,web-server只负责页面浏览和抓取,db-server只负责数据库操作。客户端可以按需挂载多个服务,AI在进入某个场景时只要看到该场景的工具就行。这比一个大而全的"神级Server"要稳得多。
5. 对接客户端:从Claude Desktop到自定义客户端
5.1 在Claude Desktop中挂载你的服务
写好了FastMCP服务,最直观的验证方式就是把它挂到Claude Desktop上。以macOS为例,配置文件在~/Library/Application Support/Claude/claude_desktop_config.json,Windows在%APPDATA%\Claude\claude_desktop_config.json。你需要在里面声明一个MCP Server:
{ "mcpServers": { "todos": { "command": "python", "args": ["/absolute/path/to/server.py"], "env": {} } } }这里有个常见坑:command里写python不一定能用,因为Claude进程可能没有继承你终端里激活虚拟环境后的PATH。所以我更推荐直接用虚拟环境里的绝对路径,比如/home/me/.venv/bin/python,或者用uv这类工具来包一层,确保拉起服务时能import到mcp包。
配置保存后重启Claude Desktop,对话界面的工具列表里就会出现你定义的add_todo、complete_todo等工具。这时你尝试对它说"帮我把写博客这件事记成待办",它就会自己去搜索并调用你的MCP Server,整个流程跟打开系统插件一样自然。
5.2 用Python写一个轻量MCP客户端自测
不是所有场景都适合用桌面软件调试,比如写自动化测试、跑批处理时,你需要一个程序里直接调用MCP服务。MCP Python SDK也提供了客户端封装,配合stdio连接最快的路径是这么写:
import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): server = StdioServerParameters( command="/home/me/.venv/bin/python", args=["server.py"] ) async with stdio_client(server) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print([t.name for t in tools]) result = await session.call_tool("add_todo", {"title": "写博客"}) print(result.content) asyncio.run(main())虽然这段代码是异步写法,但你细看会发现它只是把stdio的读写抽象成了read和write两个流,ClientSession在中间完成协议协商。理解了这个模式,后面自己封装RPC客户端、做自动化回归测试就都不难了。
5.3 常见调试姿势:日志、stdout污染与重连
说到调试,本地stdio模式有一个特别反直觉的坑:你绝对不能在你的工具函数里用print()输出任何日志,因为MCP Server是通过stdout返回协议响应的。如果你在函数里print了一行调试信息,客户端解析协议时就会收到一条非法的JSON,直接报错。
正确的做法是把调试信息写到sys.stderr或日志文件里。好在Python的logging模块默认就是输出到stderr,所以我在MCP项目里都会加一句:
import logging logging.basicConfig(level=logging.INFO)这样你既能在终端看到FastMCP内部的调试日志,又不会污染协议通道。遇到连接失败时,我的排查顺序是:先看进程能不能手动启动、import是否正常,再看客户端日志里的具体报错,最后用Inspector单独连一次,判断问题出在客户端配置还是服务端逻辑。
6. 我踩过的坑与经验总结
6.1 工具描述写不好,AI就是不会调
这绝对是我排在第一位的高频事故。很多人写完工具只留一句"把两个数相加",结果AI传了一个字符串参数,函数报TypeError,AI愣在那里不知道怎么修正。后来我养成了一个习惯:docstring里写明参数的含义、单位、取值范围、常见异常,甚至给一个使用示例。你写清楚,AI的调用成功率会从"听天由命"变成"稳如老狗"。
6.2 异步函数里的阻塞调用,会把整个server卡死
FastMCP支持async def工具,但有一个隐藏陷阱:如果你在异步函数里用了requests.get这类同步IO,就会阻塞事件循环。当一个耗时请求卡住时,服务器就无法再响应其他客户的调用。我的解决方法是:异步代码里用httpx.AsyncClient,同步代码里如果非要表阻塞操作,就用await asyncio.to_thread(func, ...)把任务丢到线程池。
6.3 返回类型尽量保持JSON友好,别给模型喂"野结构"
MCP返回给模型的内容需要能转成结构化文本,所以我一般要求所有工具函数返回Python内置类型或Pydantic模型。如果返回一个自定义对象,FastMCP虽然有时候能转成字符串,但那个字符串可能长得很乱,模型根本没法用。你可以在函数里先model_dump()转成字典,或者返回一个格式明确的{"status": "...", "data": ...}结构,模型跟你配合起来会顺畅很多。
6.4 安全边界:不要给AI配一把万能钥匙
最后一个也是最重要的提醒:MCP工具是一次真实的远程或本地调用,不是模拟世界里的玩具按钮。如果你提供了执行任意shell命令、读取任意路径文件、删除数据库记录的工具,那AI一旦被诱骗或误操作,后果非常直接。我的原则是:每个工具的能力范围必须极窄,路径、URL、命令都用白名单或前缀限制;密钥等敏感信息一律通过环境变量注入,不要写进工具参数或返回结果里;高危工具在前端加一层人机确认,宁可麻烦一点,也不能把系统裸奔在AI手上。
我个人做完这个待办事项Demo后,最大的感受是FastMCP把MCP应用的开发门槛降到了"写函数+加装饰器"级别,但真正决定项目能不能用好MCP的,还是你对AI调用习惯的理解。工具命名要直观,描述要精确,返回结构要稳定,这些看起来“非功能”的细节才是AI能顺畅使用你的服务的基石。所以不管未来MCP规范怎么演进,把工具设计成清晰、安全、单一职责的小模块,这条路永远不会错。