搞懂 MCP 协议:概念、3 种传输方式与握手全流程 (附 JSON 报文,一篇够面试)

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 双端点 (旧版)

形态: 双端点拆分双向通信:

sequenceDiagram participant C as Client participant S as Server C->>S: ① GET /sse(建 SSE 长连接) S-->>C: event: endpoint / data: /message?sessionId=abc(长连接保持,服务端"回信通道") C->>S: ② POST /message?sessionId=abc,body={jsonrpc, method}(客户端"发信通道") S-->>C: event: message / data: {jsonrpc, result}(响应从 SSE 推回,不在 POST body 里)

关键点:

  • 客户端必须 建 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:单端点云原生 (新版)

形态: 单端点,服务端按需选择响应方式:

sequenceDiagram participant C as Client participant S as Server C->>S: POST /mcp,Accept: application/json + text/event-stream,body={jsonrpc, method} alt 分支 A:短查询 S-->>C: 200 OK,Content-Type: application/json,{jsonrpc, result}(一次返回,普通 HTTP 响应) else 分支 B:长任务 S-->>C: 200 OK,Content-Type: text/event-stream(chunked SSE,流式响应,升级到 SSE) end

核心创新:

  • 只有一个 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 实际上是进程启动,没有协议级动作。

时序图

sequenceDiagram participant C as Client participant S as Server Note over C,S: Client = Host,Server = fork 出的子进程 C->>S: ① fork + 建立管道(Step 1:stdin/stdout 管道就绪) C->>S: ② 写 stdin:initialize 请求 S-->>C: ③ 读 stdout:initialize 响应 C->>S: ④ 写 stdin:notifications/initialized(Step 3,无响应) Note over C,S: ⑤ 后续业务调用(tools/list 等)

完整 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。

时序图

sequenceDiagram participant C as Client participant S as Server C->>S: ① GET /sse,Authorization: Bearer xxx,Accept: text/event-stream S-->>C: 200 OK,Content-Type: text/event-stream S-->>C: event: endpoint / data: /message?sessionId=abc-123(Step 1:拿到 POST 端点 + sessionId) Note over C,S: SSE 长连接保持打开 C->>S: ② POST /message?sessionId=abc-123,body=initialize S-->>C: 202 Accepted(无 body,真正响应走 SSE) S-->>C: event: message / data: {id:1, result}(Step 2 响应:通过 SSE 推回) C->>S: ③ POST /message?sessionId=abc-123,body=notifications/initialized S-->>C: 202 Accepted(Step 3:无响应推回) C->>S: ④ POST /message?sessionId=abc-123,body=tools/list(业务调用) S-->>C: 202 Accepted S-->>C: event: message / data: {result: {tools:[...]}}

完整 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 一个请求,直接拿响应。没有"先建长连接再发请求"的反直觉步骤。

时序图

sequenceDiagram participant C as Client participant S as Server C->>S: ① POST /mcp,Accept: application/json + text/event-stream,body=initialize(Step 1+2 合并,没有"先建通道") S-->>C: 200 OK,Content-Type: application/json,Mcp-Session-Id: xyz-789(可选),{id:1, result} C->>S: ② POST /mcp,Mcp-Session-Id: xyz-789(如果有),body=notifications/initialized S-->>C: 202 Accepted(Step 3:notification 无响应) C->>S: ③ POST /mcp,Mcp-Session-Id: xyz-789,body=tools/call alt 分支 A:服务端选纯 HTTP S-->>C: 200 OK,Content-Type: application/json,{result} else 分支 B:服务端选 SSE 流 S-->>C: 200 OK,Content-Type: text/event-stream,event: message/data: {progress},再 event: message/data: {result} end

完整 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/jsontext/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,服务端都维护这个状态机:

stateDiagram-v2 [*] --> NOT_INIT: transport 通道建立 NOT_INIT --> INIT_PENDING: 收到 initialize 请求 / 返回 initialize 响应 INIT_PENDING --> READY: 收到 notifications/initialized READY --> [*]: transport 断开 / 显式关闭 note right of NOT_INIT 此状态下,initialize 之外的方法全部拒绝 end note note right of INIT_PENDING 已发响应,等客户端 ACK,此时 tools/* 仍被拒 end note note right of READY 全部业务方法放行:tools/list, tools/call, resources/*, prompts/* end note

一句话记忆

  • 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 协议仍在快速演进,建议每次接入新版客户端 / 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"
    }
  }
}

运行时时序:

sequenceDiagram participant C as Client participant S as Server Note over C: Client 有 LLM Note over S: Server 无 LLM C->>S: tools/call: analyze_customer_data Note over S: 查了 DB 拿到数据,但需要 LLM 帮忙分析 S->>C: sampling/createMessage:"请分析这些客户数据的趋势" Note over C: 把请求丢给本地 LLM,生成分析结果 C->>S: sampling 响应:"数据显示Q3客户增长20%..." S-->>C: tools/call 最终响应:"分析完成 Q3客户增长20%..."

完整 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 只能建议不能强制。当前生态支持有限,属于协议层的设计远见。

相关推荐
沉默王二2 小时前
胡彦斌搞的 APP 上架了,这完成度,多少程序员都弄不出来啊。
人工智能·ai编程
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年5月29日
人工智能·python·信息可视化·自然语言处理·ai编程
Csvn2 小时前
AI 辅助知识管理与学习优化
人工智能·ai编程
0X782 小时前
Windows 上 Codex Desktop 的 Chrome 和 Computer Use 插件不可用:一次完整排查与修复
人工智能·chatgpt·ai编程
咖啡星人k2 小时前
MonkeyCode:打开浏览器就能用的AI编程平台
ai编程
jeffer_liu2 小时前
Spring AI 生产级实战:模型选择
java·人工智能·spring boot·后端·spring·语言模型·ai编程
人月神话Lee2 小时前
【图像处理】图像直方图——从"频率分布"到"智能决策"
ios·ai编程·图像识别
Bigger3 小时前
mini-cc 的技能系统:给 AI 装上“专业外挂”
前端·ai编程·claude