1. 从一次“对话失败”说起:为什么我们需要MCP?
最近在折腾一个AI代码助手插件,想让它在我的本地开发环境里也能调用一些内部工具,比如查询项目文档、运行特定的构建脚本。我按照常规的JSON-RPC思路,写了个服务端,客户端也按格式发了请求,但两边就是“鸡同鸭讲”,连接都建立不起来。折腾了半天才发现,问题出在“握手”上——我的服务端和客户端虽然都说着“JSON”这种语言,但彼此对第一句“问候语”该说什么、怎么回应,完全没有达成共识。
这让我意识到,在AI智能体(Agent)与工具(Tools)日益频繁的交互中,光有通信语言(如JSON-RPC)还不够,我们还需要一套更底层的“社交礼仪”来确保双方能成功“搭上话”,并明确彼此能做什么。这就是模型上下文协议(Model Context Protocol, MCP)要解决的核心问题。你可以把它理解为AI智能体世界的“USB协议”或“蓝牙配对协议”。它不关心你传输的具体数据内容(那是JSON-RPC的职责),它关心的是:两个实体如何发现彼此、如何建立连接、如何交换“能力清单”,以及如何优雅地结束对话。
网上关于MCP的讨论很多,但往往集中在某个具体的实现或工具上,比如“如何给Cursor添加Tavily搜索MCP服务器”。对于想深入理解其机制,或者在面试中被问到“MCP协议的三次握手流程是怎样的?”的开发者来说,总感觉隔着一层雾。今天,我们就抛开具体的客户端或服务器实现,彻底搞懂MCP协议本身:它的核心概念、三种不同的“搭线”方式(传输方式),以及最关键的、确保对话能开始的“握手”全流程。我会附上真实的JSON报文拆解,让你不仅能应对面试,更能真正理解这套协议的设计哲学。
2. MCP协议的核心定位:不是RPC,是“能力目录”与“会话管理”
在深入细节前,我们必须先厘清一个常见的误解:MCP不是另一个RPC(远程过程调用)框架。像gRPC、JSON-RPC这类协议,其核心目标是定义如何调用一个远程函数并获取结果,它们关注的是“调用”的语义。而MCP的关注点更前置,也更基础。
想象一下,你新买了一台多功能打印机(AI智能体),而你的电脑(MCP客户端)需要用它。首先,电脑需要知道这台打印机存在(发现),然后需要知道这台打印机具体能干什么——是只能打印,还是能扫描、复印、传真(能力交换)?最后,在每次执行打印任务(相当于一次RPC调用)前,还需要确保打印机就绪、纸张充足(会话状态管理)。MCP协议干的就是“发现”、“能力交换”和“会话管理”这些活儿。
具体来说,MCP协议主要定义了以下几件事:
- 初始化(Initialization):客户端与服务器建立连接后,进行的第一轮信息交换。这包括协议版本的协商、服务器根资源(root)的声明,以及最重要的——工具(Tools)和资源(Resources)列表的交换。你可以理解为双方交换“名片”和“产品说明书”。
- 工具(Tools):这是MCP的核心概念之一。一个工具代表服务器向客户端暴露的一个可执行操作。例如,一个搜索服务器可能暴露一个
search_web工具,一个数据库服务器可能暴露一个run_query工具。每个工具都有名称、描述、输入参数模式(JSON Schema)等信息。客户端拿到这个列表后,才知道能“命令”服务器做什么。 - 资源(Resources):这是另一个核心概念。资源代表服务器提供的一片只读或可订阅的数据。例如,一个服务器可能提供一个
project_status资源,其内容是一段JSON格式的项目状态报告。客户端可以读取(Read)或订阅(Subscribe)资源的内容。资源通过URI进行标识。 - 提示(Prompts):一些服务器还可以提供预定义的对话提示模板,客户端可以调用这些提示来引导用户交互或生成特定内容。
- 通知(Notifications):服务器可以主动向客户端推送信息,例如一个资源的内容更新了(
notifications/resources/updated),或者服务器端可用的工具列表发生了变化(notifications/tools/list_changed)。
所以,MCP协议建立了一个动态的、描述性的上下文层。智能体(客户端)通过MCP协议,不是去“硬编码”调用某个API,而是先动态地获取一个当前可用的“能力菜单”,再根据菜单来决定如何与服务器交互。这使得智能体能够适配各种不同的、甚至是在运行时才接入的工具服务器,极大地增强了其灵活性和扩展性。
3. 三种传输方式(Transport):MCP如何“搭上线”?
协议定义了“说什么”,而传输方式定义了“怎么传”。MCP协议设计上不绑定于任何一种具体的传输层,这给了它很大的灵活性。目前,实践中主要有三种常见的传输方式,它们各有不同的适用场景和握手细节。
3.1 Stdio(标准输入输出):最简单直接的“父子对话”
这是最经典、也是最易于理解和调试的方式。客户端(例如AI智能体进程)直接作为一个子进程,启动服务器进程。两者通过操作系统提供的标准输入(stdin)、标准输出(stdout)和标准错误(stderr)管道进行通信。
工作方式:
- 客户端启动:
client_process执行命令server_command。 - 操作系统创建子进程
server_process,并为父子进程之间建立三条管道:stdin(客户端写 -> 服务器读)、stdout(服务器写 -> 客户端读)、stderr(服务器写 -> 客户端读,通常用于日志)。 - 所有的MCP协议报文(JSON-RPC格式)都通过
stdin/stdout这两个管道进行双向传输。
为什么选择它?
- 简单性:无需处理网络端口、地址、防火墙。对于本地工具集成,这是最自然的方式。
- 安全性:服务器进程的生命周期完全由客户端控制,通常运行在相同的用户权限下,隔离性相对较弱但配置简单。
- 易于调试:你可以直接在命令行手动运行服务器,模拟客户端向其
stdin输入JSON,并观察stdout的输出,这对理解协议流程有巨大帮助。
JSON报文传输的实质:在这种模式下,每个完整的JSON-RPC报文(后面会详细讲格式)就是一个“文本块”。客户端将报文写成字符串,加上一个换行符,写入stdin管道。服务器从自己的stdin读取到这个字符串,解析为JSON。反之亦然。这里有一个关键细节:MCP over Stdio 通常要求每个报文独占一行,并以换行符\n分隔。这被称为“换行符分隔的JSON(JSON Lines)”。
一个简单的模拟:
# 假设我们有一个用Python写的MCP服务器脚本 mcp_server.py # 客户端(比如一个Node.js程序)会这样启动它: const { spawn } = require('child_process'); const serverProcess = spawn('python', ['mcp_server.py']); // 然后 client 向 serverProcess.stdin 写数据,从 serverProcess.stdout 读数据。3.2 SSE(Server-Sent Events):服务器主动“广播”的利器
SSE是一种基于HTTP的、允许服务器向客户端单向推送数据的技术。在MCP的语境下,它通常用于一种混合传输模式:客户端通过一个普通的HTTP端点向服务器发送请求(调用工具、读取资源),而服务器则通过一个独立的SSE连接,向客户端主动推送通知(例如资源更新)。
工作方式:
- 客户端首先通过某个方式(如配置)知道服务器的HTTP基础地址(例如
http://localhost:8080)。 - 客户端向服务器的某个特定端点(例如
POST /messages)发送JSON-RPC请求报文。 - 客户端同时建立一个到服务器SSE端点(例如
GET /sse)的长连接。 - 服务器处理完请求后,将响应报文通过普通的HTTP响应体返回给客户端。
- 当服务器有需要主动通知的事件(如工具列表变更)时,通过已经建立的SSE连接,以特定格式(
data: {...}\n\n)将通知报文推送给客户端。
为什么选择它?
- 支持服务器主动推送:这是SSE最大的优势。对于需要实时感知服务器状态变化的客户端(如一个资源内容监控器)非常有用。
- 基于HTTP:兼容现有的Web基础设施,易于在浏览器环境或通过反向代理使用。
- 单向流简化:相比WebSocket的双向通信,SSE的单向性在某些场景下模型更清晰。
注意:纯SSE模式在MCP中较少见,因为MCP需要双向通信。更常见的模式是HTTP + SSE:请求/响应走HTTP POST,通知走SSE。
3.3 WebSocket:全双工实时通信的“高速公路”
WebSocket提供了真正的全双工、长连接通信通道。一旦握手建立,客户端和服务器可以随时、任意地向对方发送消息。
工作方式:
- 客户端发起一个标准的HTTP Upgrade请求,请求将协议升级为WebSocket。
- 服务器同意升级,连接就此转变为WebSocket连接。
- 此后,双方通过WebSocket连接发送和接收数据帧。每一个MCP JSON-RPC报文都被封装在一个WebSocket数据帧(通常是文本帧)中进行传输。
为什么选择它?
- 真正的双向实时通信:无论是请求、响应还是通知,都通过同一个连接进行,延迟低,模型简洁。
- 高效:避免了HTTP的请求/响应循环开销,特别适合高频交互的场景。
- 广泛支持:现代编程语言和运行环境对WebSocket的支持都非常好。
选择哪种传输方式?
- 本地集成、简单CLI工具:首选Stdio。无依赖,零配置。
- 需要服务器主动推送、且处于HTTP环境(如浏览器插件):考虑HTTP + SSE。
- 需要低延迟、高频双向通信的独立服务:选择WebSocket。
实操心得:在开发调试阶段,强烈建议从Stdio模式开始。你可以用
cat、jq等命令行工具手动模拟客户端或服务器,或者写一个简单的“回显”脚本来观察原始报文,这对于理解协议底层细节有不可替代的作用。很多复杂的连接问题,在Stdio的简单模型下更容易被定位。
4. 握手流程全解析:从“Hello”到“Ready”
握手(Handshake)是MCP连接建立过程中最关键的阶段。它决定了客户端和服务器是否能成功“认识”对方,并进入正常工作状态。MCP的握手流程可以类比TCP的三次握手,但交换的是应用层的能力信息。我们以最常见的Stdio传输方式为例,详细拆解这个过程。
整个握手流程的核心是交换两个特殊的JSON-RPC请求/响应:initialize和initialized。
4.1 第零步:连接建立
对于Stdio方式,当客户端成功启动服务器子进程,并打开了stdin、stdout、stderr管道后,物理连接就建立了。此时,双方可以开始发送JSON-RPC报文。
4.2 第一步:客户端发起initialize请求
连接建立后,客户端必须首先发送一个initialize请求。这个请求就像是客户端伸出的“手”,说:“你好,我想初始化一个MCP会话,这是我的基本信息。”
客户端发出的initialize请求报文示例:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "clientInfo": { "name": "MyAwesomeAI", "version": "1.0.0" }, "capabilities": { // 客户端告诉服务器,它支持哪些MCP特性 "roots": { "listChanged": true // 客户端可以接收根资源列表变更的通知 }, "sampling": {} // 客户端支持采样功能(如果协议版本支持) } } }关键字段拆解:
jsonrpc:"2.0",表明底层使用JSON-RPC 2.0协议。id:1,请求的唯一标识符,用于匹配后续的响应。method:"initialize",固定方法名。params.protocolVersion:至关重要。指定客户端希望使用的MCP协议版本。服务器会根据此决定是否兼容。示例中"2024-11-05"是一个常见的版本标识。params.clientInfo: 客户端自我介绍。params.capabilities: 客户端声明自己支持哪些可选的协议能力。例如,roots.listChanged为true表示客户端可以处理服务器后续发送的根资源列表变更通知。这允许服务器在初始化后动态增删根资源。
4.3 第二步:服务器回复initialize响应
服务器收到initialize请求后,会进行兼容性检查(协议版本、能力等)。如果一切OK,则回复一个成功的响应,并附上自己的“名片”和“能力清单”。
服务器回复的initialize响应报文示例:
{ "jsonrpc": "2.0", "id": 1, // 必须与请求中的 id 一致 "result": { "protocolVersion": "2024-11-05", // 服务器确认使用的协议版本 "serverInfo": { "name": "ExampleSearchServer", "version": "0.2.1" }, "capabilities": { // 服务器声明自己支持哪些能力 "tools": { "listChanged": true // 服务器支持工具列表变更通知 }, "resources": { "listChanged": true, "subscribe": true // 服务器支持资源订阅功能 }, "prompts": {} // 服务器不支持提示功能 }, "initializationOptions": { /* 服务器特定的初始化选项 */ } } }关键字段拆解:
id:1,与客户端请求的id对应,表明这是对那个请求的响应。result: 初始化成功的结果。protocolVersion: 服务器确认将使用的协议版本,必须与客户端请求中的一致或协商一致。serverInfo: 服务器自我介绍。capabilities:核心部分。服务器详细说明自己支持哪些MCP功能。tools.listChanged:true表示服务器可能会在连接期间动态更改提供的工具列表,并会通过通知告知客户端。resources.subscribe:true表示服务器支持客户端对资源进行订阅(持续获取更新)。
initializationOptions: 一个可选字段,用于传递服务器需要的、非标准的初始化配置。其内容由服务器自行定义。
如果初始化失败,服务器会返回一个JSON-RPC错误响应:
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, // 例如,无效参数 "message": "Unsupported protocol version. Supported: ['2024-11-05']" } }此时,握手失败,连接通常会关闭。
4.4 第三步:客户端发送initialized通知
在收到服务器的成功initialize响应后,客户端必须发送一个initialized通知给服务器。这是一个JSON-RPC通知(没有id字段,因为不需要响应),标志着客户端已准备就绪,可以接收后续的请求和通知了。
客户端发出的initialized通知报文示例:
{ "jsonrpc": "2.0", "method": "initialized", "params": {} // 通常为空对象 }这个步骤类似于TCP握手最后的ACK确认。它告诉服务器:“你的初始化信息我已收到,我这边也准备好了,我们可以开始正式工作了。”
4.5 握手完成后的第一件事:交换“能力清单”
握手(initialize->initialize response->initialized)完成后,MCP会话就正式建立了。但此时客户端还不知道服务器具体能干什么。因此,紧接着,客户端会向服务器请求详细的“能力清单”。
这通常通过调用以下两个标准方法来完成(顺序不限):
tools/list:获取服务器提供的所有工具列表。resources/list:获取服务器声明的所有根资源列表。
客户端请求工具列表报文示例:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} // 通常为空 }服务器响应工具列表报文示例:
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "search_web", "description": "Search the web using a query", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "The search query" } }, "required": ["query"] } }, { "name": "get_weather", "description": "Get current weather for a city", "inputSchema": { "type": "object", "properties": { "city": { "type": "string", "description": "City name" } }, "required": ["city"] } } ] } }至此,客户端拿到了服务器的“功能菜单”,后续就可以根据这些信息,调用tools/call来执行工具,或者使用resources/read来读取资源了。
面试点睛:当被问到“MCP握手流程”时,不要只说“三次握手”。要清晰地指出:1. 客户端发
initialize(带版本和能力);2. 服务器回initialize响应(确认版本并声明自身能力);3. 客户端发initialized通知。并强调protocolVersion协商的重要性,以及握手后立即进行的tools/list/resources/list交换是实际工作的前提。
5. 深入报文:JSON-RPC 2.0 格式与MCP的“包装”
MCP的所有报文都基于JSON-RPC 2.0规范。理解这个包装格式,是读懂任何MCP通信的基础。JSON-RPC 2.0定义了三种类型的报文:
5.1 请求(Request)
用于调用一个远程方法。
{ "jsonrpc": "2.0", "id": 123, // 唯一标识符,可以是数字、字符串 "method": "tools/call", // 要调用的方法名 "params": { // 方法的参数(可选) "name": "search_web", "arguments": { "query": "MCP protocol" } } }5.2 响应(Response)
用于返回请求的结果或错误。成功响应:
{ "jsonrpc": "2.0", "id": 123, // 必须与对应请求的id一致 "result": { // 方法执行的成功结果 "content": [ { "type": "text", "text": "Search results for 'MCP protocol'..." } ] } }错误响应:
{ "jsonrpc": "2.0", "id": 123, "error": { // 方法执行失败 "code": -32601, // 错误码,-32601表示“方法未找到” "message": "Method not found" } }5.3 通知(Notification)
一种特殊的请求,没有id字段,表示客户端不期望得到响应。常用于事件推送。
{ "jsonrpc": "2.0", "method": "notifications/resources/updated", // 通知方法名 "params": { // 通知参数 "uri": "file:///project/status.json" } }MCP与JSON-RPC的关系:MCP协议定义了一系列标准的method名称(如initialize,tools/list,resources/read,notifications/...)和它们对应的params与result的数据结构。你可以把JSON-RPC看作信封和邮递规则,而MCP则是信封里具体的、格式化的信件内容。
避坑指南:在处理JSON-RPC报文时,最常见的两个坑是:1.
id字段的类型和唯一性:确保响应中的id与请求完全一致(包括类型,数字1和字符串"1"是不同的)。在异步处理中,管理好id的映射是关键。2.通知与请求的混淆:发送通知时忘了去掉id,或者错误地等待通知的响应,都会导致协议错误。务必清楚你发送的报文类型。
6. 从协议到实践:一个简单的MCP服务器骨架
理解了概念和报文,我们来看一个极简的、使用Stdio传输的MCP服务器实现思路(以Python为例)。这能帮你把前面的所有知识点串联起来。
#!/usr/bin/env python3 import sys import json import threading def read_message(): """从stdin读取一行JSON-RPC报文。""" line = sys.stdin.readline() if not line: return None return json.loads(line) def write_message(message): """向stdout写入一行JSON-RPC报文。""" json.dump(message, sys.stdout) sys.stdout.write('\n') sys.stdout.flush() def handle_initialize(params): """处理initialize请求。""" # 检查协议版本等 client_version = params.get("protocolVersion") if client_version != "2024-11-05": return {"code": -32602, "message": f"Unsupported version: {client_version}"} # 构建成功的响应 return { "protocolVersion": "2024-11-05", "serverInfo": {"name": "MySimpleServer", "version": "0.1.0"}, "capabilities": { "tools": {"listChanged": False}, "resources": {"listChanged": False, "subscribe": False}, } } def handle_tools_list(params): """处理tools/list请求。""" return { "tools": [ { "name": "echo", "description": "Echo back the input", "inputSchema": { "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"] } } ] } def handle_tools_call(params): """处理tools/call请求。""" tool_name = params.get("name") arguments = params.get("arguments", {}) if tool_name == "echo": return {"content": [{"type": "text", "text": arguments.get("message", "")}]} else: return {"code": -32601, "message": f"Tool not found: {tool_name}"} def main(): # 1. 等待客户端的initialize请求 init_request = read_message() if not init_request or init_request.get("method") != "initialize": sys.stderr.write("First message must be 'initialize'\n") return # 2. 回复initialize响应 init_result = handle_initialize(init_request.get("params", {})) if "code" in init_result: # 处理错误 write_message({ "jsonrpc": "2.0", "id": init_request["id"], "error": init_result }) return write_message({ "jsonrpc": "2.0", "id": init_request["id"], "result": init_result }) # 3. 等待客户端的initialized通知 initialized_notification = read_message() if not initialized_notification or initialized_notification.get("method") != "initialized": sys.stderr.write("Expected 'initialized' notification after initialize\n") return # 握手完成,进入主循环处理其他请求 while True: message = read_message() if message is None: break msg_id = message.get("id") method = message.get("method") params = message.get("params", {}) result = None if method == "tools/list": result = handle_tools_list(params) elif method == "tools/call": result = handle_tools_call(params) # ... 处理其他方法,如 resources/list, resources/read 等 # 如果是请求(有id),则需要回复响应 if msg_id is not None: response = {"jsonrpc": "2.0", "id": msg_id} if result and "code" in result: # 调用出错 response["error"] = result else: # 调用成功 response["result"] = result write_message(response) if __name__ == "__main__": main()这个骨架清晰地展示了:
- 读取/写入报文:遵循“一行一个JSON”的Stdio约定。
- 严格的握手顺序:先处理
initialize,再等待initialized。 - 请求路由:根据
method字段将请求分发给对应的处理函数。 - 响应构造:成功时返回
result,错误时返回error,并严格匹配id。
你可以通过运行这个脚本,并用另一个进程向它的stdin写入JSON报文来测试整个握手和调用流程,这是深入理解MCP最有效的方式。
7. 常见问题与排查思路
在实际集成或开发MCP组件时,你可能会遇到以下问题:
问题一:连接立即断开,无任何输出。
- 排查:这通常是传输层问题。检查服务器进程是否成功启动(如Python脚本是否有语法错误)。对于Stdio,确保客户端正确捕获了服务器的
stderr来查看启动错误日志。
问题二:客户端发送initialize后,收不到响应。
- 排查:
- 报文格式:确认发送的JSON是有效的,并且以换行符
\n结尾。 - 协议版本:检查客户端发送的
protocolVersion是否在服务器支持的范围内。这是最常见的握手失败原因。 - 服务器逻辑:在服务器代码中,确保在
initialize请求处理完成后,确实向stdout写入了响应报文,并刷新了缓冲区。
- 报文格式:确认发送的JSON是有效的,并且以换行符
问题三:握手成功,但调用tools/call时返回“Method not found”。
- 排查:
- 方法名拼写:确认调用的是
tools/call而不是tool/call。 - 服务器能力:确认服务器在
initialize响应中的capabilities里声明了支持tools。 - 工具名:确认
params.name中的工具名,与通过tools/list获取到的列表中的name完全一致(大小写敏感)。
- 方法名拼写:确认调用的是
问题四:如何调试复杂的MCP交互?
- 最有效的方法:记录原始报文。在客户端和服务器端,将收发到的每一条JSON-RPC报文(美化后)记录到日志文件中。对比发送和接收的报文,能立刻发现格式、字段或顺序上的问题。
- 使用中间代理:对于非Stdio传输(如WebSocket),可以考虑编写一个简单的代理,它位于客户端和服务器之间,将所有流量镜像并打印出来。
- 利用现有工具:一些MCP SDK或客户端(如官方TypeScript SDK)提供了详细的调试模式,可以开启。
理解MCP协议,不仅仅是记住几个方法和流程,更是理解一种设计思想:通过标准的握手和能力发现机制,让智能体与工具之间实现松耦合、动态的集成。下次当你配置一个AI助手插件,让它连接某个MCP服务器时,不妨想想背后这一整套优雅的协议正在默默工作。