MCP (Model Context Protocol) 的系统总结:从"为什么需要它",到三种传输方式的取舍,再到每种 transport 的握手时序 + 真实 JSON 报文。可当学习资料,也能直接拿去做面试白板题素材。
怎么读:
- 新手: 顺序读 §1 → §2 → §3,先建心智模型再抠协议细节
- 有 RPC 基础: 跳过 §1.1,直接看 §2 的 transport 对比表和 §3 握手
- 面试准备: 重点啃 §3 各 transport 的握手时序 + JSON 报文,能默写基本就稳
1. MCP 是什么、为什么需要它
1.1 问题:大模型怎么调外部工具
LLM (如 Claude / GPT) 本质上是一个纯函数 :输入文本,输出文本。它不能:
- 读你电脑上的文件
- 查你公司的数据库
- 调内网 API
- 操作 Git 仓库
但实际产品场景里,我们希望大模型"能干这些事"。怎么解决?
早期做法 (Function Calling) : 每家厂商自己定一套"工具调用"规范。OpenAI 有 OpenAI Function Calling,Anthropic 有 Anthropic Tool Use,Google 有 Google Function Calling,三家互不兼容。
结果就是,如果你开发一个"读本地文件"的工具,要分别对接三家 SDK,写三套适配。生态被割裂成 N×M 的笛卡尔积(N 个模型 × M 个工具)。
less
没有 MCP 之前:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Claude │ │ GPT-4 │ │ Gemini │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│工具A│ │工具A│ │工具A│ ← 同一个工具要写 3 套适配
│工具B│ │工具B│ │工具B│
│工具C│ │工具C│ │工具C│
└─────┘ └─────┘ └─────┘
1.2 MCP 的定义和定位
MCP (Model Context Protocol) 是 Anthropic 在 2024-11 提出、并开源给社区的开放标准,目标是统一"大模型与外部上下文/工具之间的通信协议"。
可以把 MCP 类比为 "AI 应用界的 USB-C":
- USB-C 之前:每个设备一套接口(Lightning / micro-USB / mini-USB / Type-A...)
- USB-C 之后:一根线插所有设备
MCP 想做的事一样:一次实现工具,所有支持 MCP 的 AI 应用都能用。
less
有 MCP 之后:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Claude │ │ Cline │ │ Cursor │ ← MCP Client (Host)
└────┬────┘ └────┬────┘ └────┬────┘
└────────────┼────────────┘
│ MCP 协议
┌──────────┼──────────┐
│ │ │
┌───┴───┐ ┌───┴───┐ ┌───┴───┐
│工具 A │ │工具 B │ │工具 C │ ← MCP Server,只写一次
└───────┘ └───────┘ └───────┘
1.3 核心概念:Host / Client / Server / Tool / Resource / Prompt
MCP 协议里有几个核心角色,刚学的时候容易混淆。一图说清:
scss
┌─────────────────────────────────────────────────┐
│ Host (宿主应用,如 Claude Desktop / Cursor) │
│ ┌───────────────────────────────────────────┐ │
│ │ MCP Client (协议客户端,Host 内部组件) │ │
│ └────────────────┬──────────────────────────┘ │
└───────────────────┼─────────────────────────────┘
│ MCP 协议
▼
┌───────────────────────┐
│ MCP Server │
│ ├─ Tools (动作) │ ← 让 LLM 主动调用,如"查订单"
│ ├─ Resources (数据) │ ← 让 LLM 读取上下文,如文件内容
│ └─ Prompts (模板) │ ← 预定义对话模板
└───────────────────────┘
- Host: 用户面对的应用(Claude Desktop、Cursor、你公司开发的 AI 应用)
- Client : Host 内部的 MCP 协议客户端实现,一个 Host 可以同时连多个 Server
- Server: 提供能力的服务,可以是本地进程也可以是远程服务
- Tool : Server 暴露的"可调用动作",LLM 可主动决定调用(类似 RPC 方法)。例:
query_order(order_id) - Resource : Server 暴露的"可读数据",由 Host / 应用按需读取并注入为上下文(类似 GET)。例:
file:///etc/config.json - Prompt: Server 预定义的对话/任务模板,用户可以选用(类似快捷指令)
Tool vs Resource 怎么区分 :Tool 是 LLM 主动调用的"动作"(可带参数,可能 有副作用);Resource 是按 URI 寻址的"只读数据"(无参数,当上下文用)。简单记:动词(动作)是 Tool,名词(数据)是 Resource。
三者对比
| 维度 | Tool | Resource | Prompt |
|---|---|---|---|
| 谁发起调用 | LLM 自己决定 | 应用 / Host 决定(部分客户端才让 LLM 选) | 用户从 UI 主动选 |
| 副作用 | 可能有(写/调用 API) | 无(只读) | 无(只是文本模板) |
| 参数 | 有(由 LLM 填) | 无(URI 即标识) | 有(由用户填) |
| 返回内容 | 工具执行结果 | 资源原始数据 | 拼好的对话 messages |
| 典型类比 | RPC 方法 / 函数调用 | HTTP GET / 文件读取 | 快捷指令 / 邮件模板 |
1.4 协议基础:JSON-RPC 2.0
MCP 不重新发明轮子,底层用 JSON-RPC 2.0 作为消息格式。所有 MCP 消息都是合法的 JSON-RPC 2.0 报文。
JSON-RPC 2.0 只有三种消息类型:
1. Request (请求) : 期待响应,必须带 id
json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
2. Response (响应) : 必须带和 Request 相同的 id,要么有 result 要么有 error
json
{
"jsonrpc": "2.0",
"id": 1,
"result": { "tools": [...] }
}
3. Notification (通知) : 单向消息,没有 id,接收方不返回响应
json
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
记忆口诀: 有 id = 期待回信(Request/Response),没 id = 单向通知(Notification)。
MCP 在 JSON-RPC 之上定义了一组 method 命名空间:
| Method | 类型 | 说明 |
|---|---|---|
initialize |
Request | 握手第一步,版本+能力协商 |
notifications/initialized |
Notification | 握手第三步,客户端 ACK |
tools/list |
Request | 列出所有可用工具 |
tools/call |
Request | 调用某个工具 |
resources/list |
Request | 列出所有可读资源 |
resources/read |
Request | 读取某个资源 |
prompts/list |
Request | 列出可用 Prompt 模板 |
prompts/get |
Request | 获取某个 Prompt 模板内容 |
ping |
Request | 心跳检测 |
notifications/tools/list_changed |
Notification | 服务端推送:工具列表变了 |
notifications/cancelled |
Notification | 客户端取消某个请求 |
1.5 Tool / Resource / Prompt 的协议报文
知道了上面 JSON-RPC 2.0 的消息格式,再看 Tool / Resource / Prompt 各自的协议报文就好懂了------每组都是 */list(列出)配合 调用 / 读取 / 获取 的请求和响应。
Tool 协议示例
tools/list --- 列出所有工具:
json
// Request
{ "jsonrpc": "2.0", "id": 10, "method": "tools/list" }
// Response
{
"jsonrpc": "2.0",
"id": 10,
"result": {
"tools": [
{
"name": "query_order",
"description": "根据订单号查询订单状态",
"inputSchema": {
"type": "object",
"properties": {
"order_id": { "type": "string", "description": "订单号" }
},
"required": ["order_id"]
}
}
]
}
}
tools/call --- 调用某个工具:
json
// Request
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "query_order",
"arguments": { "order_id": "ORD-12345" }
}
}
// Response
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"content": [
{ "type": "text", "text": "订单 ORD-12345 状态:已发货,预计 3 天后到达" }
],
"isError": false
}
}
Resource 协议示例
Resource 通过 URI 标识,可以是任何 scheme:file://、http://、自定义 scheme(如 db://orders/schema)等。
resources/list --- 列出所有可读资源:
json
// Request
{ "jsonrpc": "2.0", "id": 20, "method": "resources/list" }
// Response
{
"jsonrpc": "2.0",
"id": 20,
"result": {
"resources": [
{
"uri": "file:///etc/myapp/config.json",
"name": "应用配置",
"description": "当前服务的运行时配置",
"mimeType": "application/json"
},
{
"uri": "db://orders/schema",
"name": "订单表结构",
"description": "orders 表的 DDL 定义",
"mimeType": "text/plain"
}
]
}
}
resources/read --- 读取某个资源:
json
// Request
{
"jsonrpc": "2.0",
"id": 21,
"method": "resources/read",
"params": {
"uri": "file:///etc/myapp/config.json"
}
}
// Response
{
"jsonrpc": "2.0",
"id": 21,
"result": {
"contents": [
{
"uri": "file:///etc/myapp/config.json",
"mimeType": "application/json",
"text": "{\n \"db_host\": \"localhost\",\n \"db_port\": 5432\n}"
}
]
}
}
二进制资源(图片、PDF 等)用
blob字段返回 Base64 编码:{ "uri": "...", "mimeType": "image/png", "blob": "iVBORw0KGgo..." }
Prompt 协议示例
Prompt 是 Server 预定义的带参数的对话模板,用户从 Host UI 里选用(在 Claude Desktop / Cursor 里通常表现为 "/" 触发的快捷指令菜单)。模板里的占位符由用户填,生成完整的对话上下文塞给 LLM。
prompts/list --- 列出所有 Prompt 模板:
json
// Request
{ "jsonrpc": "2.0", "id": 30, "method": "prompts/list" }
// Response
{
"jsonrpc": "2.0",
"id": 30,
"result": {
"prompts": [
{
"name": "review_code",
"description": "对一段代码做 code review",
"arguments": [
{ "name": "language", "description": "编程语言", "required": true },
{ "name": "code", "description": "待审查的代码", "required": true }
]
}
]
}
}
prompts/get --- 获取填充后的模板内容:
json
// Request
{
"jsonrpc": "2.0",
"id": 31,
"method": "prompts/get",
"params": {
"name": "review_code",
"arguments": {
"language": "Python",
"code": "def foo(x):\n return x+1"
}
}
}
// Response
{
"jsonrpc": "2.0",
"id": 31,
"result": {
"description": "对一段代码做 code review",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "请对以下 Python 代码做 code review,从可读性、健壮性、性能三个维度给出建议:\n\ndef foo(x):\n return x+1"
}
}
]
}
}
Host 拿到 messages 数组后,直接作为对话上下文丢给 LLM。
1.6 Capability 协商机制
MCP 没有"必须实现所有方法"的硬性要求。客户端和服务端在握手时交换 capabilities,告诉对方"我支持什么、不支持什么"。
服务端能力示例:
json
"capabilities": {
"tools": { "listChanged": true }, // 我提供工具,且会推送变更通知
"resources": { "subscribe": true,
"listChanged": true }, // 我提供资源,支持订阅
"prompts": { "listChanged": false }, // 我提供 Prompt,不推送变更
"logging": {} // 我支持日志推送
}
客户端能力示例:
json
"capabilities": {
"sampling": {}, // 我支持被服务端反过来调 LLM(详见 §5.2 Sampling)
"roots": { "listChanged": true } // 我能告诉服务端"工作目录有哪些"
}
握手完成后,客户端就知道"这个 server 没有 prompts/list 方法,不能调",服务端也知道"这个 client 不支持 sampling,我不能反过来调客户端的模型"。对称、显式、可扩展。
2. 三种传输协议 (Transport)
版本基准:本文以 MCP 2025-03-26 规范为基准梳理。更新的 2025-06-18 / 2025-11-25 在此之后发布,三种 transport 的核心机制不变;后文出现的"最新 / 官方推荐"按此基准理解。
JSON-RPC 只规定了消息长什么样 ,没规定怎么送到对端。这就是 Transport 层要解决的问题。
MCP 官方目前定义了三种 transport:
2.1 总览对比
| 维度 | stdio | HTTP+SSE (旧) | Streamable HTTP (新) |
|---|---|---|---|
| 协议版本 | 自始就有 | 2024-11-05 | 2025-03-26 |
| 通信介质 | 进程 stdin/stdout | HTTP + Server-Sent Events | HTTP (可选升级 SSE) |
| 端点数量 | n/a (管道) | 双端点 (GET /sse + POST /message) |
单端点 (POST /mcp) |
| session 标识 | 无(单进程对单连接) | URL query ?sessionId=xxx |
HTTP header Mcp-Session-Id (可选) |
| 跨机器 | ✗ | ✓ | ✓ |
| 多客户端 | ✗ (1:1) | ✓ | ✓ |
| 认证 | 父进程信任 | 标准 HTTP (Bearer / mTLS) | 标准 HTTP (Bearer / mTLS) |
| 负载均衡 | n/a | 需 sticky session | 短查询无状态友好 |
| 代理穿透 | n/a | 差(SSE 长连接易被截) | 好(短查询纯 HTTP) |
| 典型场景 | Claude Desktop 启的本地工具 | 早期 Web 部署 | 云原生 SaaS |
| 现状 | 主流 | 已被官方标记 deprecated | 官方推荐 |
2.2 stdio:本地进程间通信
形态 : Host 应用 fork 一个 server 子进程,通过子进程的 stdin/stdout 收发 JSON-RPC 消息。每条消息一行(JSON Lines / \n 分隔),不能跨行。
c
┌──────────────────┐
│ Host (父进程) │
│ │
│ ┌────────────┐ │
│ │ MCP Client │ │
│ └─────┬──────┘ │
└────────┼─────────┘
│ fork + pipe
▼
┌──────────────────┐
│ MCP Server │
│ stdin ◄── 请求 │
│ stdout ──► 响应 │
│ stderr ──► 日志 │ ← stderr 给人看,不参与协议
└──────────────────┘
为什么 stderr 单独走:协议消息只走 stdout。Server 想打日志、调试信息、报错原因,都打到 stderr,避免污染协议流。这是 stdio transport 的硬性约定。
优点:
- 启动快,无网络栈
- 无需认证(子进程天然信任父进程)
- 进程退出即清理所有资源
- 协议层简单(无 session、无并发隔离)
缺点:
- 只能本地用
- 一个 Server 进程对应一个 Client(1:1),不能多客户端共享
- 跑服务的机器要装好 Server 的运行时(Node / Python / 二进制)
典型场景 : Claude Desktop / Cursor / Cline 启的本地工具,比如 filesystem (操作本机文件)、git (操作本地仓库)、sqlite (查本地数据库)。
2.3 HTTP+SSE:Web 双端点 (旧版)
形态: 双端点拆分双向通信:
关键点:
- 客户端必须先 建 SSE 长连接,再发 POST 请求
- POST 的 URL 必须带
?sessionId=xxx,服务端用这个找到对应的 SSE 流推响应 - 响应不在 POST body 里,而是通过 SSE 推回(这是 HTTP+SSE 模式最反直觉的地方)
优点:
- HTTP 基础设施成熟,Bearer Token / mTLS / WAF / CORS 都能直接用
- 服务端能主动推送(notifications)
- 比 stdio 多机器、多客户端
缺点:
- 必须 sticky session:sessionId 映射在某个实例的内存里,LB 必须把后续 POST 路由到同一实例
- 代理不友好:SSE 长连接容易被反向代理 / CDN 截断 (超时、buffer 不刷)
- 两步握手:必须先 GET 拿 sessionId,再 POST,RTT 多一次
- 响应路径绕:POST 发请求响应却从 GET 流回来,客户端实现复杂
现状 : MCP 2024-11-05 规范引入,2025-03-26 已被官方标记 deprecated。新项目不建议用,老项目可保留兼容。
2.4 Streamable HTTP:单端点云原生 (新版)
形态: 单端点,服务端按需选择响应方式:
核心创新:
- 只有一个 endpoint
- 服务端通过
Content-Type决定响应方式:application/json→ 普通 HTTP 响应,一次返回text/event-stream→ 升级到 SSE,流式响应
- 客户端在
Accept头里同时声明两种都能处理
session 处理 : 服务端可选在 initialize 响应里返回 Mcp-Session-Id HTTP header,后续请求客户端带这个 header。没强制要求 session,服务端可以做成完全无状态。
优点:
- 单端点:握手简化,客户端实现简单
- Stateless 友好:短查询完全无状态,LB 随便分发,多实例部署天然支持
- 代理穿透稳:短查询是普通 POST,所有代理都能过
- 向下兼容 SSE 的能力:长任务还能流式推送进度
缺点:
- 协议较新,2025 才进 spec,中间件 / 客户端生态还在追
- 长查询路径仍走 SSE,sticky 问题在这条路径上没消除(但短查询消除了)
现状 : MCP 2025-03-26 规范引入,官方推荐的新标准。Anthropic SDK / Claude Desktop / 主流 IDE 客户端都已支持。
3. 三种协议的握手步骤详解
3.1 通用握手三步走
无论哪种 transport,MCP 的协议层握手逻辑是一样的三步:
ruby
Step 1 建立通信链路 ← transport 层
Step 2 initialize 请求/响应 ← 协议层版本+能力协商
Step 3 notifications/initialized ← 时序层客户端 ACK
─────────────────────────────────
之后才允许:tools/* / resources/* / prompts/* / ...
三步分别解决三个层次的问题:
| 步骤 | 层次 | 解决的问题 |
|---|---|---|
| Step 1 | 传输层 | 拉通"消息能送到对面"的通道 |
| Step 2 | 协议层 | 双方协议版本 + 能力对齐,看能不能合作 |
| Step 3 | 时序层 | 客户端确认就绪,服务端解锁业务方法 |
少任何一步,后续业务调用都会被拒。把三步合并一步会留隐患:
- 跳过 Step 1:没通道,服务端响应没法送回
- 跳过 Step 2:版本/能力没对齐,后续报文格式可能不兼容
- 跳过 Step 3:服务端不知道客户端处理完 initialize 没,抢跑可能导致状态错乱
下面分三种 transport 详细看具体怎么走。
3.2 stdio 握手
stdio 最简单,因为"通道"就是 fork 出的管道,Step 1 实际上是进程启动,没有协议级动作。
时序图
完整 JSON 报文
Step 1: 启动子进程,无协议报文。示例命令:
bash
node /path/to/mcp-server-filesystem.js /home/user
Step 2 Request (Client → Server,通过 stdin,单行):
json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"roots":{"listChanged":true},"sampling":{}},"clientInfo":{"name":"claude-desktop","version":"0.7.2"}}}
格式化后看清楚:
json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {}
},
"clientInfo": {
"name": "claude-desktop",
"version": "0.7.2"
}
}
}
Step 2 Response (Server → Client,通过 stdout):
json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"prompts": { "listChanged": false },
"logging": {}
},
"serverInfo": {
"name": "mcp-server-filesystem",
"version": "1.0.0"
}
}
}
Step 3 Notification (Client → Server,通过 stdin):
json
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
注意没有 id 字段,服务端不返回响应。
之后的业务调用 (以 tools/list 为例):
json
// Request
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
// Response
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "read_file",
"description": "读取文件内容",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "文件路径" }
},
"required": ["path"]
}
}
]
}
}
关键细节
- 逐行分隔 :每条消息必须是合法 JSON + 换行符(
\n),不能跨行(否则解析器无法知道边界) - stderr 单独走 :Server 的日志、警告、调试信息都打到 stderr,绝不能混进 stdout
- 进程退出 = session 结束:没有显式 close,父进程关掉管道即可
3.3 HTTP+SSE 握手
HTTP+SSE 多了一步------必须先建 SSE 长连接才能拿到 sessionId。所以实际上是四步:建 SSE → initialize → 响应通过 SSE 推回 → notifications/initialized。
时序图
完整 JSON 报文
Step 1 --- 建立 SSE 长连接
Request (Client → Server):
http
GET /sse HTTP/1.1
Host: api.example.com
Authorization: Bearer your-token
Accept: text/event-stream
Cache-Control: no-cache
Response (Server → Client,保持连接,持续推送):
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
event: endpoint
data: /message?sessionId=abc-123-def-456
SSE 报文格式说明:
- 每个事件是
event: <name>\ndata: <payload>\n\n(两个换行结尾)data是 UTF-8 字符串,可以是任何文本(通常是 JSON)event名字客户端用来分发处理逻辑- SSE 是单向的:服务端 → 客户端,客户端不能往这条连接写
Step 2 --- initialize 请求
Request (Client → Server,通过 POST):
http
POST /message?sessionId=abc-123-def-456 HTTP/1.1
Host: api.example.com
Authorization: Bearer your-token
Content-Type: application/json
Content-Length: ...
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {}
},
"clientInfo": {
"name": "my-mcp-client",
"version": "1.0.0"
}
}
}
POST 的立即响应(注意没 body):
http
HTTP/1.1 202 Accepted
真正的响应通过 SSE 流推回:
csharp
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true},"resources":null,"prompts":null},"serverInfo":{"name":"my-mcp-server","version":"1.0.0"}}}
格式化看清楚 SSE data 里的 JSON:
json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": { "listChanged": true },
"resources": null,
"prompts": null
},
"serverInfo": {
"name": "my-mcp-server",
"version": "1.0.0"
}
}
}
Step 3 --- notifications/initialized
Request (Client → Server):
http
POST /message?sessionId=abc-123-def-456 HTTP/1.1
Authorization: Bearer your-token
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
Response:
http
HTTP/1.1 202 Accepted
Notification 没有 id,服务端不通过 SSE 推任何东西回来。这步完成后服务端把 session 状态从 INIT_PENDING 切到 READY。
之后的业务调用 (以 tools/list 为例):
POST:
http
POST /message?sessionId=abc-123-def-456 HTTP/1.1
Content-Type: application/json
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
POST 立即响应:
http
HTTP/1.1 202 Accepted
通过 SSE 推回:
vbnet
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"query_order",...}]}}
关键细节
- POST 不直接返响应 :这是最大的认知坑。POST 永远只回
202 Accepted,实际响应通过 SSE 推回。所以客户端实现要同时 维护两条逻辑:发 POST + 收 SSE,然后按id关联起来 - sessionId 是必须的:每个 POST 都要带,没带的话服务端不知道往哪条 SSE 流推响应
- sticky session:多实例部署时,LB 必须把同 sessionId 的 POST 路由到拥有那条 SSE 连接的实例,否则响应推不到客户端
- 断线重连:SSE 断了 sessionId 就失效,要重新走整套握手
3.4 Streamable HTTP 握手
Streamable HTTP 的握手最接近经典 HTTP------POST 一个请求,直接拿响应。没有"先建长连接再发请求"的反直觉步骤。
时序图
完整 JSON 报文
Step 1+2 --- initialize (合并)
Request (Client → Server):
http
POST /mcp HTTP/1.1
Host: api.example.com
Authorization: Bearer your-token
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {}
},
"clientInfo": {
"name": "my-mcp-client",
"version": "2.0.0"
}
}
}
注意 Accept 头:同时声明 application/json 和 text/event-stream,告诉服务端"两种响应方式我都能处理,你自己选"。
Response (Server → Client,直接在 POST 响应里):
http
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: xyz-789-abc-456
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"prompts": { "listChanged": false }
},
"serverInfo": {
"name": "my-mcp-server",
"version": "1.0.0"
}
}
}
Mcp-Session-Id 是可选的 HTTP header。服务端如果想做有状态 session,就在这里下发,客户端后续请求带上;服务端如果做无状态,就不给,客户端也不用带。
Step 3 --- notifications/initialized
Request:
http
POST /mcp HTTP/1.1
Authorization: Bearer your-token
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: xyz-789-abc-456
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
Response (notification 无响应体):
http
HTTP/1.1 202 Accepted
之后的业务调用 (以 tools/call 为例)
Request:
http
POST /mcp HTTP/1.1
Authorization: Bearer your-token
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: xyz-789-abc-456
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "query_order",
"arguments": { "order_id": "12345" }
}
}
分支 A:服务端返普通 JSON (短查询):
http
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: xyz-789-abc-456
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{ "type": "text", "text": "订单 12345 状态: 已发货" }
]
}
}
分支 B:服务端升级为 SSE (长任务,推送进度):
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Mcp-Session-Id: xyz-789-abc-456
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"abc","progress":0.3}}
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"abc","progress":0.7}}
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"订单 12345 状态: 已发货"}]}}
最后一条事件携带最终 result,流就结束了。
关键细节
- 单端点
POST /mcp:无论 initialize 还是业务调用,都打到同一个 URL Accept头声明两种 :application/json, text/event-stream,服务端按需选Mcp-Session-Id是可选的:有就有状态(可关联进度推送),没有就纯无状态- 响应方式由服务端决定:同一个 endpoint 可以为短查询返普通 JSON、为长任务返 SSE 流,client 不需要预先选择
- 无状态模式下 LB 任意分发:这是 Streamable HTTP 相比 SSE 的核心收益
3.5 三种握手对比
把三种 transport 的握手并排放一起:
| 阶段 | stdio | HTTP+SSE | Streamable HTTP |
|---|---|---|---|
| Step 1 通道 | fork 子进程,管道就绪 | GET /sse 建长连接,服务端推 sessionId | (无独立步骤,直接发 initialize) |
| Step 2 initialize 请求 | 写 stdin | POST /message?sessionId=xxx | POST /mcp |
| Step 2 initialize 响应 | 读 stdout | 从 SSE 流推回(POST 只回 202) | 直接在 POST 响应里 |
| Step 3 客户端 ACK | 写 stdin (notifications/initialized) | POST /message?sessionId=xxx | POST /mcp |
| session 标识 | 无(1:1) | URL ?sessionId=xxx |
HTTP header Mcp-Session-Id (可选) |
| 业务调用响应路径 | stdout | SSE 流 | POST 响应或 SSE 流(服务端选) |
服务端状态机(三者通用)
无论哪种 transport,服务端都维护这个状态机:
一句话记忆
- stdio: 管道里逐行 JSON,简单粗暴
- HTTP+SSE : 双端点拆双向,响应从 SSE 推回(最反直觉)
- Streamable HTTP : 单端点,像普通 HTTP,需要流式才升级 SSE
4. 常见错误与排查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
调 tools/list 报 initialization 相关错误(具体错误码因 SDK 而异,非 spec 固定值) |
跳过了 Step 3 (notifications/initialized) | 在 initialize 响应后补发 notifications/initialized,再调业务方法 |
| stdio Server 启动后无响应 | Server 把日志写到了 stdout 污染协议流 | 把日志全部改写到 stderr,stdout 只准放 JSON-RPC |
| HTTP+SSE 拿到 sessionId 但 POST 后无响应 | 客户端没监听 SSE 流 / sessionId 拼写错 | 用 curl -N 单独跑 GET /sse,观察是否有 event: message 推回 |
| HTTP+SSE 多实例随机失败 | LB 没配 sticky session | nginx/k8s 按 sessionId 或客户端 IP 哈希 |
Streamable HTTP 返回 406 Not Acceptable |
客户端 Accept 头没声明 text/event-stream |
改成 Accept: application/json, text/event-stream |
| Streamable HTTP 跨实例失败 | 服务端给了 session 但状态没共享 | 要么做 stateless 不给 session,要么 session 状态存 Redis |
| protocolVersion 不匹配 | 客户端和服务端的 MCP 规范版本对不上 | 双方都升到最新 spec,或客户端 fallback 到 server 支持的旧版本 |
调试技巧
1. 用 curl 手动跑握手
stdio 没法用 curl,但可以直接用管道:
bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | node mcp-server.js
Streamable HTTP 用 curl 直接打:
bash
curl -X POST http://localhost:8080/mcp \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
HTTP+SSE 需要开两个 terminal:
bash
# Terminal 1: 监听 SSE
curl -N -H "Authorization: Bearer your-token" http://localhost:8080/sse
# Terminal 2: 用 Terminal 1 拿到的 sessionId 发 POST
curl -X POST "http://localhost:8080/message?sessionId=abc-123" \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",...}'
2. 用 MCP Inspector
Anthropic 官方的调试工具,可视化点点点跑握手 + 调工具:
bash
npx @modelcontextprotocol/inspector
打开后填 transport 类型、地址、token,自动跑完三步握手,然后 GUI 调用 tools/list / tools/call,排查最方便。
3. 看 Wireshark / 浏览器 DevTools
Streamable HTTP / HTTP+SSE 都是 HTTP,用浏览器 DevTools 的 Network 面板就能看完整请求响应。SSE 事件流会一条条实时显示。
参考资料
- MCP 官方规范 --- 协议权威定义
- MCP GitHub 组织 --- 官方 SDK (TypeScript / Python / Java / C#)
- JSON-RPC 2.0 规范 --- 底层消息格式定义
- Anthropic MCP 发布博客 --- 2024-11 首发的设计动机
- MCP 2025-03-26 Spec 更新 --- Streamable HTTP 详细规范
写在最后:
- MCP 协议仍在快速演进,建议每次接入新版客户端 / SDK 时回头确认 protocolVersion 是否变化
- 三种 transport 中,新项目优先选 Streamable HTTP,老项目无痛迁移再考虑
- 握手报文示例都是协议级真实样子,可直接拿去做面试白板题素材
5. 问题整理
5.1 Tool vs Resource:读取表 schema 该用哪个?
问题:要让 LLM 获取某张表的 schema,有两种方案:
- 方案 1:写一个 Tool
get_table_schema(table_name),LLM 主动调用 - 方案 2:把表 schema 注册为 Resource(
db://orders/schema),Host 预加载到上下文
Resource 有什么优势?什么时候值得用?
Resource 方案的优势
| 优势 | 说明 |
|---|---|
| 省推理轮次 | Host 在对话开始时自动 resources/read 注入上下文,LLM 开口就知道表结构,不需要先 call 一次 tool |
| 不占 Tool 决策预算 | Tool 列表越长 LLM 选择噪声越大,把辅助性只读操作从 Tool 列表里移走,LLM 选择准确度更高 |
| Host 可缓存/订阅 | Resource 支持 subscribe,schema 变化时 Host 收到通知自动刷新,不依赖 LLM 记得重查 |
| 语义更清晰 | Host UI 可以把 Resource 展示为"可浏览数据面板",用户手动选择注入哪些上下文 |
什么时候直接做 Tool 更实际
- Host 不支持 Resource(当前大多数 Host 对 Resource 支持不完善)
- 表很多,schema 是按需动态查的(需要传参指定表名)→ 天然是 Tool
- 不想依赖 Host 的预加载行为,想让 LLM 主动控制何时读
决策表
| 判断维度 | 选 Resource | 选 Tool |
|---|---|---|
| 内容变化频率 | 极低(DDL 很少改) | 经常变 |
| 是否需要参数 | 不需要(固定几张表) | 需要(传表名) |
| Host 是否支持自动注入 | 支持 | 不支持 |
| 想减少 LLM 调用轮次 | 是 | 无所谓 |
| 表数量 | 少(3-5 张,列得出来) | 多(几十张,按需查) |
关键结论
Resource 的核心价值场景是 LLM 自动生成 SQL------LLM 必须先知道表结构(列名、类型、关系)才能写出正确的 SQL。此时 schema 作为 Resource 预注入,省掉一轮 tool call,有实际收益。
如果 SQL 是预写好的模板 (后端把查询固定成模板),LLM 只负责选 tool + 填参数,根本不需要知道底层表结构------它看到的是 tool 的 inputSchema(哪些参数要填),不是 DB schema。这种场景下 schema Resource 没有意义。
一句话记忆:Resource 的价值 = Host 能在 LLM 开始推理前把"必要背景知识"塞进去。如果 LLM 的任务不需要这个背景知识,Resource 就是多余的。
5.2 Sampling:服务端如何反向调用 LLM?
问题 :客户端 capabilities 里声明 "sampling": {} 表示"支持被服务端反过来调 LLM"。这个反向调用是怎么工作的?
核心概念
正常流程是 Client → Server (调 tool / 读 resource)。Sampling 反过来:Server → Client,请求 Client 帮忙调一次 LLM 推理。
vbscript
正常流程:
Host/Client ──tools/call──► Server
Sampling (反向):
Host/Client ◄──sampling/createMessage── Server
│
▼
调 LLM 推理
│
▼
Host/Client ──响应(LLM生成的文本)──► Server
Server 本身没有 LLM------它只是提供工具/数据的服务。但某些场景下 Server 执行任务途中需要"AI 帮忙想一下"。
典型使用场景
| 场景 | Server 需要 LLM 做什么 |
|---|---|
| 数据清洗 tool | 拿到脏数据后让 LLM 分类/提取结构化信息 |
| 代码生成 tool | Server 查到 DB schema 后让 LLM 生成 SQL |
| 多步 agent 编排 | 复杂工作流中间步骤需要 LLM 推理做决策 |
| 摘要/翻译 | Server 拿到长文档让 LLM 摘要后再返回 |
协议流程
前提:握手时 Client 声明支持 sampling
json
// Client → Server (initialize 请求)
{
"params": {
"capabilities": {
"sampling": {} // ← 告诉 Server "你可以反过来调我的 LLM"
}
}
}
运行时时序:
完整 JSON 报文
Server → Client(sampling 请求):
json
{
"jsonrpc": "2.0",
"id": 100,
"method": "sampling/createMessage",
"params": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "以下是某商家最近3个月的销售数据,请分析趋势并给出建议:\n\n月份 | 销售额\n1月 | 52000\n2月 | 48000\n3月 | 61000"
}
}
],
"modelPreferences": {
"hints": [
{ "name": "claude-sonnet-4-20250514" }
],
"intelligencePriority": 0.8,
"speedPriority": 0.5
},
"systemPrompt": "你是一个数据分析师,用中文简洁回答",
"maxTokens": 500
}
}
Client → Server(sampling 响应):
json
{
"jsonrpc": "2.0",
"id": 100,
"result": {
"role": "assistant",
"content": {
"type": "text",
"text": "趋势分析:2月环比下降7.7%,3月强势反弹27%。整体Q1呈V型走势,3月创新高。建议:关注2月下降原因(季节性?),巩固3月增长动力。"
},
"model": "claude-sonnet-4-20250514",
"stopReason": "endTurn"
}
}
关键设计点
| 维度 | 说明 |
|---|---|
| 谁有决定权 | Client/Host 有最终控制权------可以拒绝、修改、审批 sampling 请求(安全边界) |
| 模型选择 | Server 只能"建议"(modelPreferences.hints),最终用哪个模型由 Client 决定 |
| Human-in-the-loop | Host 可以弹窗让用户确认"Server 想调用你的 LLM,允许吗?" |
| 嵌套是设计目标 | sampling 本就是为 agentic 设计的------spec 明确允许 LLM 调用嵌套在 server feature 内部;防失控不靠"禁止递归",而靠 human-in-the-loop + client 侧 rate limiting |
| 上下文隔离 | sampling 的 messages 是 Server 自己构造的,不是用户的对话历史 |
modelPreferences 字段详解
Server 不直接指定模型名,而是用偏好维度表达需求:
json
"modelPreferences": {
"hints": [
{ "name": "claude-sonnet-4-20250514" },
{ "name": "claude-3-haiku" }
],
"costPriority": 0.3, // 0~1,越高越倾向便宜模型
"speedPriority": 0.8, // 0~1,越高越倾向快模型
"intelligencePriority": 0.5 // 0~1,越高越倾向强模型
}
Client 综合这些偏好 + 自身可用模型,自己决定最终用哪个。
现实状态
- 主流 Host 目前基本都未支持 sampling ------ Claude Desktop、Claude Code 都还没落地(社区有 feature request 在推进,如 claude-code #1785),Cursor / Cline 等第三方 Host 同样尚未实现
- 实际生态中用 sampling 的 MCP Server 还很少
- 更常见的做法是 Server 自己内嵌 LLM SDK 调用(但这样就不走 MCP 协议了)
一句话记忆:Sampling 是 MCP 为 multi-agent 场景预留的"Server 借用 Client 的 LLM 能力"通道。Client 有最终控制权,Server 只能建议不能强制。当前生态支持有限,属于协议层的设计远见。