MCP(Model Context Protocol)这个教程,我前前后后做了整整96个小时。不是标题党——当时我刻意开着计时器,把查文档、读源码、跑Demo、踩坑、推翻重来的时间全部记了下来,过程中还让智能体帮我整理资料、生成验证代码,但每一个结论我都亲手复现过一遍。今天这篇不是"三分钟看懂MCP"的速食内容,而是把这96小时里彻底搞明白的东西,按一条可以直接照做的路径写出来:MCP到底解决什么问题、架构怎么拆、30分钟跑通一个自己的Server、以及真实项目里最容易在哪几处翻车。适合正在做Agent应用、或者打算把AI接进自己已有系统的开发者,也适合纯粹想搞懂"为什么AI圈突然都在聊MCP"的读者。
1. 为什么2024年底开始,AI工具链全在谈MCP:根子在于"集成爆炸"
1.1 没有协议之前:点对点集成为什么走不通
MCP一句话概括:给AI应用连接外部数据和工具用的开放协议。这句话信息密度很高,得拆开看。在它出现之前,一个AI应用要访问外部系统,基本是"一对一硬连":文件系统有文件系统的SDK,数据库有数据库的驱动,要对接某个内部协作平台又得单独写一套REST客户端。每连一个来源都要写胶水代码,而且这套代码只对当前这个AI应用有效,换个客户端全部报废。
我做过几个Agent类的项目,对这个痛点体会很深。系统里本来就有PostgreSQL、一堆CSV报表、内部监控面板,还有项目管理系统。想让AI助手能查报表、读工单、调监控数据,就得给每个来源写一套"封装+鉴权+返回格式化"的代码。更麻烦的是,同一个后端能力被不同的Agent复用时,每一套封装都要重新对接,因为没有一个统一的能力描述方式和调用入口。
这套模式的复杂度用公式看最直观:假设有M个AI应用、N个数据源、P个工具服务,全互联就需要M×N×P条点对点集成。M、N、P随便哪个超过10,维护矩阵直接失控。当时我为了解决"同一个工具被两个Agent复用"的问题,不得不把工具单独拆成一个HTTP服务,再给每个Agent配各自的SDK适配层。一个人维护这种矩阵,早晚被耗死。
1.2 MCP给的标准答案:把连接动作拆成三件套
MCP的定位很像USB-C:USB-C没有规定你充电、传数据、输出视频的具体业务,它只是把"物理连接"这件事标准化了。MCP也没有规定AI该怎么思考、怎么决策,它只标准化了AI应用与外部能力之间的三个基本动作:发现对方有哪些能力、描述清楚每次调用的入参结构、把调用结果用统一格式返回。
在MCP的世界里,AI应用是Host,外部能力提供方是Server。两边通过一组统一的消息格式通信,底层是基于JSON-RPC 2.0的。这个选择很务实,JSON-RPC足够轻量,生态里现成实现多,不用重新发明轮子。真正干活的人不用关心对面是文件系统、数据库还是某个SaaS服务,只要它实现了MCP协议,Host这边就能用同一套逻辑去连它。
1.3 谁在真正受益:Agent开发者的成本结构变化
很多人忽略了一点:MCP真正改变的,是Agent开发者的成本结构。过去连接AI和已有系统要拆成三块工作:认证鉴权、数据格式化、工具调用封装。没有协议时,这三块在每个集成里都要重写一遍;有了MCP,第一块落在传输层和Server的能力声明里,第二块落在协议规定的Resource模型里,第三块落在Tool原语里。
结果就是你只需要写一次Server,所有支持MCP的客户端都能复用。我96小时里有相当一部分时间就是在验证这句话到底真不真——最后实测下来,确实不是宣传话术。同一个文件搜索Server,我在两个不同的AI客户端里分别接入,配好之后两边都能直接调用,一行业务代码都没改。这个"写一次、处处跑"的特性,在集成成本上的节省是立竿见影的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MCP的架构拆解:一次工具调用的完整旅程
2.1 Host、Client、Server三层各管什么
MCP的架构严格分成三层,很多人一开始会混淆Client和Host,这是理解协议的第一个坎。
Host是AI应用本身,也就是用户直接面对的那一层。它负责管理用户会话,处理用户授权决策,协调多个外部能力。比如你正在用的AI助手的桌面端或者Agent框架,就是Host。Client不是"用户端"的意思,它活在Host内部,与一个Server保持1:1的连接。一个Host里可以同时存在多个Client,每个Client对应一个Server。Server则是能力提供方,它把自己能做的事情通过协议暴露出去。
用一个不太严谨但很形象的说法:Host是"电器",Client是"插头",Server是"插座后面的设备"。一个电器可以有多个插头,每个插头对应一台设备;设备本身不需要知道电器内部是怎么工作的,它只负责响应插头上传来的信号。
这种分层设计的好处是边界清晰。Server不用考虑Host的用户界面长什么样,也不用操心Host内部是if-else逻辑还是大模型驱动;它只需要忠实地暴露能力、响应调用请求。反过来,Host也不需要知道每个Server背后的实现细节,只要按协议发消息就行。
2.2 从握手到调用:一个请求的完整生命周期
任何Client和Server建立连接后,第一步都是握手。Client发送initialize请求,携带自己支持的协议版本和客户端能力列表;Server回应自己支持的协议版本和服务端能力列表。两边协商出一个双方都接受的版本,然后Client发送一条初始化完成的通知,连接正式进入可用状态。
这之后才是真正的业务交互。要调用某个工具,典型的消息流长这样:Client先发一条tools/list请求,问Server"你有哪些工具可用",Server返回工具名字、描述、入参的JSON Schema;然后Client根据模型的选择,发送tools/call请求,带上具体参数;Server执行完把结果返回。这中间如果工具需要用户授权,Host会先弹确认,用户同意后才继续。
我特意把交互协议画成一串实际报文看过,好理解程度远超预期。请求和响应都遵循JSON-RPC 2.0的格式,id用于配对,method是动作名,params是参数。比如一个简单的工具列表请求就是:
json复制{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
Server返回的则是一个包含工具清单的响应,每个工具都带名字、说明和schema。这套格式统一之后,调试就变得很舒服:不管你连的是什么Server,抓包看消息的结构都是同一套。
2.3 四个原语:Tool、Resource、Prompt、Sampling
MCP协议定义了四种核心原语,分别对应不同的能力类型。我一开始只盯着Tool看,后来才发现另外三个在真实场景里同样重要。
Tool是"可执行的动作",比如发邮件、搜索文件、调外部API。它有明确的输入输出,执行前通常需要用户确认。Resource是"只读的数据",比如一个文件的完整内容、一条数据库记录、一个配置项。它有URI和MIME类型,语义上像"读文件",不产生副作用。Prompt是"可复用的提示词模板",Server可以用它告诉Host"针对我这个系统,正确提问的方式是这样的",把领域经验固化下来。Sampling则比较特殊,方向是反的:它允许Server反过来请求Host调用模型完成一次补全,把"让AI判断"的能力扩展给了Server。
四者的关系可以用一个类比说清楚:Tool是手,能做事;Resource是眼睛,能读东西;Prompt是记忆,告诉对方这套系统该怎么问;Sampling是脑子的一部分,让外部服务也能借用模型能力。真实项目里最常用的是Tool和Resource,但Prompt的价值被很多人低估了——它能让AI客户端第一次对接你的系统时,就不用瞎猜提问方式。
3. 30分钟手写一个可用的MCP Server:完整记录
3.1 选型:为什么先用FastMCP这个封装库
理论讲完必须上手。MCP官方提供TypeScript和Python两套SDK,但直接裸写SDK要处理不少协议细节,比如能力协商、请求分派、传输层适配。对一个想快速验证想法的人来说,用社区封装好的FastMCP更合适。
FastMCP这个名字不复杂,它是官方Python SDK上面的一个轻量封装,把"注册一个工具/资源"这件事简化成了写一个普通Python函数,再加一行装饰器。它内部帮你处理了tools/list、resources/list这些协议细节,你只需要关心业务逻辑。选它的另一个理由是文档和示例比较多,踩坑时有参照。
当然,正式项目如果要深度定制,可能还是得直接基于官方SDK做。FastMCP适合的场景是:快速搭建原型、验证一个工具是否真的能用、给内部系统做轻量接入。我整个96小时里,从零到跑通第一个Server用的就是它,节约的时间非常可观。
3.2 首个工具:目录内文本搜索的完整实现
我先写了一个最实用的工具:在指定目录下递归搜索包含某个关键词的文本文件。这个工具足够简单,又覆盖了"输入参数声明""执行逻辑""返回结构化结果"三个关键环节,适合当作模板。
python复制# 需要先安装依赖:pip install "mcp[cli]"
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("text-surfer")
@mcp.tool()
def search_in_dir(directory: str, keyword: str, extension: str = ".txt") -> list[str]:
"""在 directory 下递归查找内容中包含 keyword 的文件完整路径,最多返回 50 条。
Args:
directory: 要搜索的根目录绝对路径
keyword: 需要匹配的关键词
extension: 只搜索指定扩展名的文件,默认 .txt
"""
import os
hits = []
for root, dirs, files in os.walk(directory):
for name in files:
if not name.endswith(extension):
continue
abs_path = os.path.join(root, name)
try:
with open(abs_path, "r", encoding="utf-8", errors="ignore") as f:
if keyword in f.read():
hits.append(abs_path)
if len(hits) >= 50:
return hits
except OSError:
continue
return hits
if __name__ == "__main__":
mcp.run()
这段代码有几个细节值得说。函数的三行Docstring非常重要,因为模型的工具选择机制就是靠函数名字和描述来匹配的,描述越清楚,模型选对的概率越高。参数列表里我给extension设了默认值,对应的JSON Schema会把它标记为可选参数,模型在调用时才会知道该传什么、可以不传什么。返回类型是list[str],协议层会自动把结果序列化成JSON数组,Host那侧拿到的就是一个结构化的结果,而不是一段格式随意的文本。
跑起来只需要在终端执行python server.py,默认走stdio传输。如果你在终端里直接执行,程序会一直等着从标准输入读消息——这就是后面客户端要连进来的通道。
3.3 两种接入方式:stdio与Streamable HTTP
用stdio方式接入AI客户端是最常见的第一步。在支持MCP的桌面AI客户端配置里,填一条JSON就能连上:
json复制{
"mcpServers": {
"text-surfer": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}
这里的command和args会被拼成一个完整的本地进程启动命令,Host会把这个进程的标准输入输出当作MCP消息通道。我实测下来,最关键的一步是args里的脚本路径必须用绝对路径。填相对路径时,Host因为工作目录不确定,经常出现进程起不来或者连上了立刻断掉的问题。
如果需要远程接入,MCP新版协议推荐用Streamable HTTP传输。FastMCP里改一行就能切换:
python复制if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
启动后这个Server会暴露一个HTTP端点,任何支持MCP的客户端都能通过HTTP请求连接。这种模式的典型场景是"一个Server被多台机器上的多个Host共享",但代价是你需要自己处理认证、HTTPS、访问控制,本地stdio模式这些基本不用操心。建议新手上手时先从stdio开始,跑通了再考虑远程。
4. 踩坑实录:我在这96小时里翻过最狠的几辆车
4.1 stdio连接失败:先查环境,再查启动参数
stdio模式第一个坑,是"Host里显示连不上,但终端里手动跑脚本完全正常"。我排查过整整一个下午,最后发现是环境变量问题。桌面AI客户端通常不会继承你shell里的PATH,如果你用pyenv或者conda管理Python,客户端启动python子进程时根本找不到你配置的解释器,报错往往是"command not found"。
排查链路其实很固定:先在配置里把command和args拆出来,自己在终端原样执行一遍,看是否能正常启动;如果终端能跑,说明脚本本身没问题,问题出在环境;然后把command改成解释器的绝对路径,比如/usr/local/bin/python,再去掉脚本路径里的所有相对依赖。我最终的做法是把Python解释器路径和项目依赖都固定到虚拟环境,配置文件里直接用虚拟环境里的python绝对路径,之后再没出过这类问题。
4.2 Schema声明与返回类型不一致:模型是在按你的话猜
第二个坑藏在返回值里。MCP工具的入参声明和返回结果都是结构化JSON,模型会根据函数描述和参数说明来决定"要不要调用、传什么参数"。但如果你在Docstring里把某个参数说成是"以毫秒为单位的时间戳",实际函数体却按秒计算,模型传进来的数值就会差一个数量级。这个不是协议问题,是描述与实现不一致导致的。
反过来,返回类型也要严格匹配声明。我试过一个工具在声明里写list[int],结果Python函数在异常分支返回了字符串,客户端那侧直接解析失败,整个调用被视为错误。所以写工具的时候,函数返回路径一定要收敛:所有分支都要返回统一类型,必要时用try/except兜底。这类问题在本地调试时通常不容易暴露,因为你是直接调函数,不走序列化;一旦走MCP协议,类型不匹配就会被放大成调用失败。
4.3 授权边界:不该让AI替你做的决定
第三个坑涉及安全问题,比前两个都重要。MCP的客户端通常会在工具调用前请求用户确认,但具体是否确认、能不能批量放行,取决于Host的实现和Server的工具声明。如果一个工具声明了高权限操作,比如删除文件、批量发消息、修改线上数据,而Host配置成了"自动批准",效果就相当于给模型开了一个不需要确认的后门。
我自己的做法是给工具分等级:默认所有工具只读,写操作一律在函数内部显式加一个dry_run参数,默认传True。真要执行破坏性操作时,必须显式传false,而且我会在函数开头打印一行日志记录谁调用了它、什么时候调用的。这个习惯在接入真实业务系统时尤其重要,模型不可控性再低,也架不住一个权限过大的工具加一个宽松的确认策略。安全边界这种事,宁可过度设计,也不要事后补救。
5. 再往前走一步:多Server编排与远程MCP的实测体会
5.1 多Server并存:命名空间与工具冲突
当你同时接入多个Server,第一个要面对的问题是工具重名。我同时挂了文件搜索、数据库查询、时报读取三个Server,其中文件搜索和数据库查询各自都注册了一个叫statistics的工具。结果在AI客户端里,两个工具的显示名都带上了Server前缀才区分开,但模型在自动决策时偶尔会选错目标。
解决思路有两个方向:一是在Server内部把工具命名做得足够特异,比如file_statistics和db_statistics,从源头避免歧义;二是利用Host的会话隔离机制,不同会话挂不同的Server组合,让单次会话里尽量不出现语义重叠的工具。多Server的价值在于"按需组合",不是"一次全挂"。我后来把Server按业务域拆细,由Agent运行时动态决定挂载哪些Server,冲突率明显下降。
5.2 什么时候该把Server部署到远程
本地stdio模式适合单机调试,但如果你想在多个机器上复用同一个Server,或者让多个Host共享同一份数据访问逻辑,就必须走远程部署。Streamable HTTP模式可以把Server变成一个标准的HTTP服务,请求方只需要知道URL和认证信息。
实测远程模式要注意两点。一是网络延迟,MCP调用是多次往返,每个工具调用都要经过HTTP握手和数据传输,比本地stdio慢不少,所以只建议把真正需要共享的Server放远程,低频工具留在本地。二是认证逻辑要靠自己实现,协议本身只负责消息交换,Bearer Token这类东西需要你在Server层和Host配置里共同配合。我的建议是先在内网环境跑通,再考虑暴露到公网,公网场景的防护复杂度会再上一个台阶。
5.3 MCP、函数调用与插件体系的关系
最后把这个关键概念说清楚,因为它困扰了我很久。现在主流AI平台都有"函数调用"和"插件"机制,它们和MCP是互相替代还是互相补充?实测下来,更准确的说法是三者处于不同层级。
函数调用是模型供应商内部的机制,它把外部能力描述给模型,然后模型按格式返回调用意图。MCP是跨供应商的协议层,它可以把函数调用包装成对Server能力的统一访问。插件体系通常绑定特定客户端生态,而MCP的Server可以实现一次、跨Host复用。对你的业务来说,最实际的判断标准是:如果只需要服务某一个模型平台,函数调用大概率够用;如果希望同一套能力被多个AI客户端、多个Agent框架复用,MCP是更省事的选择。它们不是零和博弈,MCP的Server内部完全可以再调用某个平台原生的函数接口。
最后说说这96小时让我最意外的一点:这套协议的门槛比我想象中低很多。真正繁琐的不是协议本身,而是"从文档到亲手跑通"中间那一堆环境、权限、命名细节。如果你也想动手,我给三个务实建议:第一个Demo一定选本地stdio模式,工具选最简单的只读操作;绝对路径写死,别相信任何相对路径;给函数写清楚Docstring,这条价值在模型调用时会被放大十倍。把这三件事做对,你大概率会在一个小时内跑通第一个MCP Server,然后就会开始认真琢磨怎么把更多现有系统接进来了。
