MCP这几个字母,今年在技术社区里出现的频率高得有点吓人。从Claude Desktop开始支持MCP,到Cursor、Cline、Codex陆续跟进,几乎每个主流AI编程工具都在做同一件事:让模型可以调用外部工具。MCP的全称是Model Context Protocol,模型上下文协议,它解决的是AI应用与外部工具、数据源之间的标准化连接问题。你可以把它理解成AI世界的USB-C接口:接口统一了,设备才能互联互通。而FastMCP,是目前把MCP服务端开发效率拉得最高的Python框架,核心用法简单到夸张——一个装饰器,就能把一个普通Python函数变成AI可以调用的工具。这篇内容我会从协议基础讲起,结合我实际开发中踩过的坑,把用FastMCP从零搭建一个MCP应用的全过程拆开来说。不管你是刚接触MCP的新人,还是已经写过官方SDK想提升效率的老手,都建议往下看。
1. 先理解MCP在解决什么问题
1.1 从Function Call到统一协议
AI调用工具这件事本身不算新鲜。OpenAI的Function Calling大家早就用过,你的代码给模型声明几个函数,模型根据对话内容生成一个JSON调用请求,你再执行并把结果喂回去。问题在于,这种调用方式是“各家写各家的”:不同客户端有不同工具注册方式、不同上下文注入方式、不同通信格式。你给Claude写的工具,没法直接给Cursor用;你在本地跑的工具,也没法直接让远端模型调用。
MCP做的事情,就是把“AI应用如何发现工具、如何描述工具、如何调用工具、如何读取数据”这一整套流程标准化。对开发者来说,你不再需要为每个AI客户端单独适配,只需要按MCP协议实现一个服务端,任何支持MCP的客户端都能直接用。以前写Function Calling像是给每种设备定制充电线,接口不同、协议不同;MCP就是USB-C,一根线解决大部分问题,生态里的设备越多,这套标准的价值越大。
1.2 MCP协议里的三个关键原语
在MCP协议中,服务端对外暴露三类能力。第一类是Tools,工具,由AI在对话过程中按需调用,适合一次“动作”,比如查询订单、执行计算、写数据库;第二类是Resources,资源,由客户端按URI读取,适合把“数据”一次性交给AI做上下文,比如报表、配置、文档;第三类是Prompts,提示模板,由服务端预置一些有固定结构的提示词,让AI按照模板工作,比如“SQL生成助手”“代码审查助手”。
刚开始写MCP服务时,不少人会纠结“这个需求该用Tool还是Resource”。我自己的判断方式是:需要模型主动决定“要不要调”的场景用Tool;需要无条件把数据喂给模型的场景用Resource;需要把一套提示词规范化复用的场景用Prompt。三者之间没有绝对边界,但设计时想清楚,会让服务的行为更可预期。
1.3 传输层与架构角色的基本认识
MCP的架构模型很简单,两端:Client和Server。AI应用是Client,你的工具服务是Server。当前主要的传输方式有两种:stdio和Streamable HTTP。stdio模式下,客户端直接以子进程方式启动你的服务端脚本,两者通过标准输入输出通信,适合本地开发、单机内网场景;HTTP模式下,服务端是一个独立的HTTP端点,适合跨机器、多客户端共享的场景。FastMCP对这两种传输模式都是一行参数切换,后面章节会专门展开。
另外要记住,MCP协议设计的是双向消息机制。客户端可以发请求给服务端,服务端也可以向客户端推送数据,比如通过采样请求让模型补充信息。这个特性在复杂场景下非常有用,比如服务端发现数据不完整时,反向请求模型提供更多内容,设计得当的话能做出很灵活的应用。不过对大多数业务场景,先用好工具、资源、模板这三板斧就够了。
2. 为什么是FastMCP:对比与选型思考
2.1 官方SDK与FastMCP的效率差距
我在团队里第一次写MCP服务用的是官方Python SDK,实现的流程大致是:定义一个server类、初始化transport、处理initialize、tools/list、tools/call这些方法分发、手动写每个工具的JSON Schema、自己做参数校验和错误转换。一个能用的服务,光协议样板代码就写了两三百行,业务逻辑反而只占一小部分。后来用FastMCP重写同样的服务,代码量压缩到十分之一,核心只剩业务逻辑本身。
我简单整理了一个对比,能直观看出差距在哪里:
| 环节 | 官方SDK | FastMCP |
|---|---|---|
| 工具注册 | 手动维护工具列表,实现list/call分发 | 函数加@mcp.tool()装饰器即可 |
| JSON Schema生成 | 手工写type、properties、required | 从函数类型注解自动生成,Pydantic校验 |
| 传输层 | 手动建立stdio或HTTP管道 | mcp.run()一行切换 |
| 参数校验 | 自己写if/else判断 | Pydantic模型自动完成 |
| 错误反馈 | 需要手动包装错误结果 | 异常自动转成结构化错误返回 |
这个差异不是“锦上添花”,而是开发体验的质变。尤其是参数校验和Schema生成这两项,FastMCP等于帮你把MCP服务端最繁琐的部分全部包掉了。我从官方SDK切到FastMCP之后,最大的感受是终于可以把时间花在工具本身的业务逻辑上,而不是花在和协议细节搏斗上。
2.2 FastMCP的设计哲学:装饰器即一切
FastMCP的源码并不复杂,核心想法和FastAPI非常像:用Python的类型系统承载约定。你在函数上打一个@mcp.tool()装饰器,它会在注册时读取函数参数类型、默认值、docstring,基于这些信息生成MCP规范要求的工具定义JSON。AI客户端看到的工具描述,就是你函数签名和docstring的“翻译结果”。
这带来一个很重要的认知:你在函数上写的docstring,最终就是给大模型看的说明书。写得好不好,直接决定模型能不能正确调用。比如“获取当前服务器时间”这种工具,docstring如果只写“获取时间”,模型很可能在需要格式化时间时也来调用它;如果写明“返回当前服务器本地时间,格式为YYYY-MM-DD HH:MM:SS”,模型就能准确判断什么场景下该用。别小看这几个字的差异,LLM对工具的理解几乎完全依赖这段描述。
2.3 选型建议:什么时候用FastMCP,什么时候别用
先说不适合的场景。如果你要深度定制MCP协议本身,比如自研一套专属传输握手、要支持移动端弱网下的断线重传、或者要对MCP报文做底层封包解析,用官方协议SDK更稳妥,因为FastMCP的封装会挡住一部分底层控制权。另外,如果你纯粹想学习MCP协议内部机制,我也不建议直接用FastMCP,先用官方SDK手写一遍,对wire format有了感性认识,再回到FastMCP会事半功倍。
适合用FastMCP的场景就宽了:内部效率工具(把工单系统、数据库查询、CI/CD操作包装成AI可调用的服务)、快速原型验证、给团队统一搭MCP网关、把已有Python函数直接暴露成工具给AI调用。它属于业务层框架,绝大多数业务型需求都能被覆盖。我自己的项目里,除了一个需要深度定制握手的平台级服务还在用官方SDK之外,其余全部切到了FastMCP。
3. 5分钟跑通一个FastMCP服务
3.1 环境准备与安装
先说版本要求,Python建议3.10及以上,我本地用的是3.12。安装FastMCP非常简单:
pip install "fastmcp"如果你用uv管理Python项目——我强烈推荐,因为后面配置客户端时会省很多麻烦——可以这样初始化:
uv init mcp-demo cd mcp-demo uv add fastmcp为什么推荐uv而不是裸pip?关键在于MCP客户端的启动命令。Claude Desktop、Cursor这类客户端启动你的服务时,实际上是按配置文件里的command去执行一条命令。如果你用裸python作为command,那个解释器里必须已经装好了fastmcp;而用uv run,它会自动解析项目目录里的依赖并执行,避开了环境不一致的坑。这个点我会在客户端配置小节再强调一遍,因为真的太多人卡在这里。
3.2 写第一个可用的服务:时间助手
在项目目录下建一个server.py,代码如下:
from datetime import date, datetime from fastmcp import FastMCP mcp = FastMCP("daily-tools") @mcp.tool() def get_current_time() -> str: """获取当前服务器本地时间,返回格式为 YYYY-MM-DD HH:MM:SS。""" return datetime.now().strftime("%Y-%m-%d %H:%M:%S") @mcp.tool() def days_between(start: str, end: str) -> int: """计算两个日期之间相差的天数。 Args: start: 开始日期,格式 YYYY-MM-DD。 end: 结束日期,格式 YYYY-MM-DD。 """ return (date.fromisoformat(end) - date.fromisoformat(start)).days if __name__ == "__main__": mcp.run()代码很简单,但每一行都值得琢磨。FastMCP("daily-tools")注册了一个名为daily-tools的MCP服务,服务名会出现在客户端的工具列表里;两个@mcp.tool()装饰器把普通Python函数注册成工具;mcp.run()默认以stdio传输模式启动,保持前台运行等待客户端调用。如果你直接执行python server.py,服务会一直挂在那里,看起来什么都没发生,其实它正在标准输入输出上等着。
3.3 用MCP Inspector完成本地调试
写完代码怎么验证?最快的方式是用官方提供的MCP Inspector,一条命令就能起来:
npx -y @modelcontextprotocol/inspector uv run python server.py首次执行时npx会下载Inspector并启动一个本地Web界面,你可以在界面上查看服务端暴露的工具列表、点击某个工具、填参数、模拟一次AI调用,立刻看到返回结果。这个流程比直接开客户端测试快得多,我平时开发每个工具,都会先在Inspector里验证一遍参数边界和异常情况,再接入真实客户端。
如果你希望更贴近开发模式,FastMCP也自带了fastmcp dev命令,在项目目录执行fastmcp dev server.py,同样会拉起调试界面。它最大的好处是自动监听文件变更,改完代码保存后会自动重载服务,开发体验非常顺。我在写工具期间基本就挂着这个命令,写完一点刷新,新工具立刻就能在调试界面上看到,省去了重复启动的等待时间。
3.4 验证服务时发现的一个典型坑
第一次跑这个示例时,我的服务在Inspector里只显示了一个工具,另一个怎么都看不到。排查下来发现,问题出在函数定义被放在了ifname== "main"块里面,装饰器根本没有被执行。这是个非常典型的坑:FastMCP的注册行为发生在模块导入阶段,你把带装饰器的函数写在主入口后面,服务启动时那段代码不会运行,工具自然不会被注册。
正确做法是让所有带装饰器的函数都放在模块顶层,ifname== "main"块里只保留mcp.run()调用。这个规则和Flask、FastAPI的注册方式类似,但新同学容易忽略。以后一旦遇到“工具列表少一个”的问题,第一反应就去检查装饰器有没有被执行,基本都能找到答案。
4. 核心功能开发细节:把MCP服务写“好”
4.1 用类型注解与docstring控制工具质量
写MCP工具和写普通函数最大的区别是:函数的“调用方”是LLM,而不是人。人对模糊参数能靠常识理解,LLM只能依赖你给出的函数签名、参数类型和docstring。所以我每写一个工具都会自检两点:第一,类型注解要尽量细;第二,docstring要说人话。
类型注解层面,除了基础类型,两个很好用的工具是Literal和Annotated。Literal能把参数值约束成枚举,比如op参数只能取"add"、"subtract";Annotated可以在类型上附加字段说明,让LLM看到的JSON Schema更友好:
from typing import Annotated, Literal from fastmcp import FastMCP mcp = FastMCP("calculator") @mcp.tool() def calculate( a: Annotated[float, "第一个操作数"], b: Annotated[float, "第二个操作数"], op: Literal["add", "subtract", "multiply", "divide"] ) -> float: """执行四则运算。 Args: a: 第一个操作数。 b: 第二个操作数。 op: 运算符,只能是 add、subtract、multiply、divide 之一。 """ if op == "add": return a + b if op == "subtract": return a - b if op == "multiply": return a * b if op == "divide": if b == 0: raise ValueError("除数不能为0") return a / b raise ValueError(f"不支持的运算符: {op}")注意这个例子里的几个细节。Literal直接约束了op的可选值,模型传了无关参数时会被Pydantic校验直接拦下;docstring里对每个参数的解释会作为工具描述的一部分展示给模型;工具内部主动抛出带明确信息的异常,FastMCP会把异常转成结构化错误返回给客户端。模型读到“除数不能为0”这种提示后,通常会自行修正参数再调用一轮。
4.2 用Resource暴露数据而非动作
如果你的MCP服务需要给AI提供“数据”而不是“动作”,用Resource。一个常见写法是注册URI模板,路径里带变量:
@mcp.resource("weather://{city}") def get_weather(city: str) -> str: """返回指定城市的天气摘要。""" return f"{city}:晴,气温25℃,东南风2级"这个资源的URI是weather://{city},客户端可以用weather://北京把“北京”作为city参数传入。数据语义和动作语义的区别在于调用方式:AI判断“是否需要数据”时会更主动地读取Resource,而且是先读数据再决定怎么回答;Tool则更偏向“执行某个操作”。如果你的服务是从内部数据库取报表给AI做分析,用Resource天然比用Tool更贴合。
Resource本质上可以理解成“把服务端的内容看成一组URI”,思路和REST世界里的资源模型一致。你只需要规划好URI的层级关系,FastMCP会按模板自动匹配名称参数。复杂一点的服务里,还可以直接在Resource里读取本地文件、数据库记录或者调用内部API,总之任何你能表达成“可寻址内容”的东西,都可以用Resource暴露。
4.3 用Prompt模板把提示词固化下来
服务端还可以预置Prompt,让客户端“一键引用”模板。适合的场景是:你希望AI以固定角色或固定结构输出内容,并且要把这个模板分发给多个客户端共用。我在给团队做统一代码审查服务时,就用这样的模板:
from fastmcp import FastMCP, Prompt mcp = FastMCP("prompt-server") @mcp.prompt() def sql_expert(table_schema: str) -> str: """SQL专家提示词模板。""" return ( "你是一名资深SQL工程师。请基于以下表结构,写出满足用户需求的SQL查询语句," "尽量使用索引友好写法,并为关键查询添加注释。\n\n" f"表结构如下:\n{table_schema}" )Prompt和Tool的本质区别是:Prompt不执行任何业务函数,它只是返回一段文本,引导模型如何表现;Tool是真实执行一段代码。如果AI的行为模式比较固定,用Prompt替换“每次手动敲提示词”能显著减少沟通成本,同时保证输出风格一致。多个客户端接入同一个MCP服务时,Prompt模板还能起到“统一口径”的作用,团队协作时价值很明显。
4.4 异步函数与并发处理
FastMCP对async def函数的支持和普通函数一样好。如果某个工具需要调用外部HTTP接口、访问数据库或者做IO密集型处理,我建议直接用异步版本,配合asyncio能明显提升并发表现:
@mcp.tool() async def fetch_stock_price(code: str) -> str: """获取指定股票代码的实时价格。""" async with httpx.AsyncClient() as client: resp = await client.get(f"https://api.example.com/stock/{code}") data = resp.json() return f"{code} 最新价: {data['price']}"有一点要留意:FastMCP会在主线程的事件循环里调度异步函数,不要在异步工具里使用阻塞型requests,否则整个服务的并发会被卡住。正确姿势是全程使用httpx.AsyncClient、async SQL驱动这类异步客户端。我实际踩过这个坑,当时一个工具里顺手用了requests,工具列表一切正常,但一旦并发调用两个工具,第二个请求就明显卡顿,切到异步客户端后问题立刻消失。
5. 把MCP服务接入真实客户端:配置细节与运行模式
5.1 各客户端配置JSON的差异
服务写好了,最终要接进AI客户端使用。不同客户端的配置位置略有不同,但配置结构大同小异,核心都是mcpServers:
| 客户端 | 配置文件位置 | 配置形式 |
|---|---|---|
| Claude Desktop | claude_desktop_config.json | command + args |
| Cursor | .cursor/mcp.json 或项目.mcp.json | command + args |
| Cline/Roo Code | VSCode设置里的MCP栏 | command + args |
我自己最常用的一套配置写法如下:
{ "mcpServers": { "daily-tools": { "command": "uv", "args": [ "run", "--project", "/absolute/path/to/mcp-demo", "python", "server.py" ] } } }这里最关键的是三个细节。一是command用uv而不是python,原因前面说过,目的是让项目依赖自动被识别;二是--project参数指向项目目录的绝对路径,如果用相对路径,客户端从不同工作目录启动时很可能找不到项目;三是args里的server.py同样建议用绝对路径。配置完之后重启客户端,工具列表里就能看到daily-tools下的两个工具。
Windows用户还要注意路径格式。Windows路径带反斜杠和盘符,在JSON里转义很痛苦,我建议统一用正斜杠的绝对路径,多数客户端都能正确识别。如果涉及虚拟环境,也可以把command直接指向venv里的python解释器,比如C:/Users/xxx/.venv/Scripts/python.exe,这样同样能避开依赖问题。
5.2 stdio与Streamable HTTP两种模式
本地开发的默认模式是stdio,客户端启动你的命令后,通过标准输入输出跟服务通信。优点是隔离性好、配置简单、天然随客户端生命周期管理。但如果你希望多个客户端共享同一个服务,或者服务部署在远程机器上,就需要用HTTP模式。FastMCP里切换只需改一行:
if __name__ == "__main__": mcp.run(transport="streamable-http")这种模式下,FastMCP会启动一个HTTP服务,默认监听8000端口。客户端配置变成URL形式:
{ "mcpServers": { "daily-tools-remote": { "url": "http://localhost:8000/mcp" } } }注意端点路径是/mcp。远程模式带来的好处是,MCP服务变成了常规后端服务,可以部署到内网服务器、套一层反向代理、做负载均衡。AI客户端只要网络可达就能调用远程工具,这让MCP可以应用在团队共享、服务器自动化等更复杂的场景里。
5.3 部署到服务器的三个注意点
远程部署时,第一个注意点是认证。MCP服务一旦对外暴露,本质上就是一个“能执行工具”的API端点,如果没有认证,任何人都可以调用你的工具。直接的做法是在应用层写一个中间件,校验每个请求头里的token,HTTP层再配合反向代理统一鉴权。本地stdio模式风险相对可控,但远程HTTP模式必须加认证。
第二个注意点是进程托管。本地调试可以前台跑,生产环境一定要用systemd(Linux)或任务计划程序(Windows)把服务进程托管起来,这样进程崩溃后可以自动拉起。日志要重定向到固定文件,方便事后排查。我吃过一次亏,服务在半夜挂掉,第二天上班才发现,后来乖乖配了systemd单元文件。
第三个注意点是超时设置。部分客户端对MCP工具调用有超时时间限制,比如默认30秒就报timed out。如果某个工具需要跑比较长时间,比如几十秒的批处理任务,客户端超时后可能直接报错。我的解决办法是拆细任务,把长耗时操作改成“提交任务返回任务ID,再用另一个工具查询结果”的异步模式,或者直接在客户端配置里调整超时参数。
6. 调试、日志与高频问题排查
6.1 日志配置:别等出问题再找日志
FastMCP本身会输出不少运行时日志,包括收到请求、调用工具、返回结果等。开发调试阶段,最简单的办法是把logging级别调到DEBUG:
import logging logging.basicConfig(level=logging.DEBUG)生产环境我习惯用自定义格式,把时间、级别、logger名和消息串起来,同时输出到文件和控制台:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[ logging.FileHandler("mcp-server.log", encoding="utf-8"), logging.StreamHandler() ] ) logger = logging.getLogger("mcp-server")然后在每个工具函数里加必要的日志记录。我用logging.exception而不是print,因为异常堆栈会完整写到日志文件里,排查问题的效率会高很多。一个比较实用的做法是记录每次工具调用的入参和出参,出问题时可以回放“模型刚才到底传了什么东西进来”,这个信息在调试AI行为时价值极高。
6.2 高频问题速查表
我整理了实际开发中遇到频率最高的几个问题,做成速查表:
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| ModuleNotFoundError: No module named 'fastmcp' | 客户端用的python解释器不是项目环境 | command改uv run,或指向venv解释器绝对路径 |
| 客户端提示tools timed out after 30 seconds | 服务启动慢、工具执行耗时太长、网络延迟 | 检查服务日志;拆长任务为异步模式;调整超时 |
| 工具列表里看不到某个工具 | 装饰器未执行,函数放在if __name__块内 | 把工具函数移到模块顶层 |
| 服务启动后立刻退出 | 传输模式配置错误,stdio客户端配了url | 确认客户端配置与mcp.run(transport=...)匹配 |
| 参数校验一直失败 | Schema描述不清晰导致模型乱传参 | 加强类型注解、Annotated描述,缩小枚举范围 |
| 中文乱码 | Windows控制台编码不是UTF-8 | 启动命令加PYTHONIOENCODING=utf-8环境变量 |
| 客户端无法连接远程服务 | 防火墙、反向代理路径不对、服务未启动 | 用curl先测HTTP端点,再排查代理配置 |
上面的每一个问题我都真实遇到过。其中超时问题最值得重视,如果工具内部有慢SQL或者外部API调用,首轮调用很容易触发客户端的30秒超时。我给团队定了一个规矩:MCP工具函数内部禁止做超过5秒的同步操作,耗时长的任务一律提交后返回任务ID,再用查询接口拿结果,客户端永远不会被动超时。
6.3 性能与并发方面的小经验
MCP服务的并发模型和普通Web服务不太一样,AI客户端可能会在短时间内并发调用多个工具,也可能反复调用同一个工具。最关键的经验是让工具保持“无状态、轻量、快速”。无状态指的是不要在每个工具函数里依赖全局可变变量,否则并发调用时容易出问题;轻量指的是一个工具只做一件事,别把“查数据+写报表+发通知”塞进同一个工具。拆成三个工具反而更容易被AI理解和调用,各工具之间的职责边界也更清晰。
缓存是另一个提升性能的利器。数据库查询、外部API调用这些热点数据,可以在服务进程内用functools.lru_cache做一层缓存,过期时间控制在几十秒到几分钟。注意,对时间敏感的数据不要做长缓存,不然AI拿到的信息可能已经失真。实测下来,加了缓存之后,重复调用同一工具的响应时间能从几百毫秒降到几毫秒,体感非常明显。
6.4 安全底线:防注入与权限收敛
MCP服务本质上是“暴露给AI的API”,所以以前做Web安全的那套底线思维全都要用上。第一,所有输入参数都要校验,尤其是字符串型参数,别让模型传进来的内容直接拼进SQL或动态命令里,这跟防止注入攻击是同一个道理。第二,工具权限要收敛,尽可能用最小权限:只读数据库的就不要给写权限,能查单条记录的就不要给全表扫描的入口。本地stdio模式风险相对可控,但HTTP模式必须加token。第三,远程部署时认证不能省,尤其当服务要对接公网时,认证和限流都要安排上。
还有一个容易被忽略的点:AI可能在多轮对话后被精心构造的提示词诱导去调用不该调的工具。比如一个“查询用户信息”的工具,在prompt注入下可能被用来批量遍历用户数据。防御思路是把工具设计成“做的事情尽量窄”,并且在代码层面对参数范围做硬限制,不能完全信任模型给出的参数。安全不是框架能替你解决的,FastMCP只负责把协议做好,业务上的安全边界要靠开发者的设计来守住。
6.5 一点小技巧:多服务组合与后续扩展
MCP服务可以不止一个。你可以在一个项目里同时启动多个FastMCP实例,比如一个负责数据查询,一个负责运维操作,客户端里可以同时配置多个server,工具列表会自动聚合。后续如果要扩展,把已有Python函数转成MCP工具的成本非常低,只需要加上装饰器并调整docstring,几乎就是零重构迁移。对团队来说,这意味着“把存量工具暴露给AI”不是重写系统,而是做一层薄薄的适配。
最后分享一个小经验:我一般会给每个MCP服务配一个.env文件,把URL、token、日志级别都放进去,用os.getenv读取。这样同样的代码,本地调试用stdio,部署到内网就切HTTP,只改环境变量,不动代码。另一个小技巧是客户端配置里command优先用uv run而不是裸python,这能少踩很多环境坑——这两点是我在实战中反复受益的做法,写在这里供你参考。