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 个工具)。

没有 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 应用都能用

MCP 之后:
┌─────────┐  ┌─────────┐  ┌─────────┐
│ Claude  │  │ Cline   │  │ Cursor  │  ← MCP Client (Host)
└────┬────┘  └────┬────┘  └────┬────┘
     └────────────┼────────────┘
                  │   MCP 协议
       ┌──────────┼──────────┐
       │          │          │
   ┌───┴───┐  ┌───┴───┐  ┌───┴───┐
   │工具 A │  │工具 B │  │工具 C │   ← MCP Server,只写一次
   └───────┘  └───────┘  └───────┘

1.3 核心概念:Host / Client / Server / Tool / Resource / Prompt

MCP 协议里有几个核心角色,刚学的时候容易混淆。一图说清:

┌─────────────────────────────────────────────────┐
│  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

三者对比

维度ToolResourcePrompt
谁发起调用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

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

2. Response (响应): 必须带和 Request 相同的 id,要么有 result 要么有 error

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": { "tools": [...] }
}

3. Notification (通知): 单向消息,没有 id,接收方不返回响应

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

记忆口诀: 有 id = 期待回信(Request/Response),没 id = 单向通知(Notification)。

MCP 在 JSON-RPC 之上定义了一组 method 命名空间:

Method类型说明
initializeRequest握手第一步,版本+能力协商
notifications/initializedNotification握手第三步,客户端 ACK
tools/listRequest列出所有可用工具
tools/callRequest调用某个工具
resources/listRequest列出所有可读资源
resources/readRequest读取某个资源
prompts/listRequest列出可用 Prompt 模板
prompts/getRequest获取某个 Prompt 模板内容
pingRequest心跳检测
notifications/tools/list_changedNotification服务端推送:工具列表变了
notifications/cancelledNotification客户端取消某个请求

1.5 Tool / Resource / Prompt 的协议报文

知道了上面 JSON-RPC 2.0 的消息格式,再看 Tool / Resource / Prompt 各自的协议报文就好懂了——每组都是 */list(列出)配合 调用 / 读取 / 获取 的请求和响应。

Tool 协议示例

tools/list — 列出所有工具:

// 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 — 调用某个工具:

// 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 — 列出所有可读资源:

// 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 — 读取某个资源:

// 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 模板:

// 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 — 获取填充后的模板内容:

// 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,告诉对方"我支持什么、不支持什么"。

服务端能力示例:

"capabilities": {
  "tools":     { "listChanged": true },   // 我提供工具,且会推送变更通知
  "resources": { "subscribe": true,
                 "listChanged": true },   // 我提供资源,支持订阅
  "prompts":   { "listChanged": false },  // 我提供 Prompt,不推送变更
  "logging":   {}                         // 我支持日志推送
}

客户端能力示例:

"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 总览对比

维度stdioHTTP+SSE (旧)Streamable HTTP (新)
协议版本自始就有2024-11-052025-03-26
通信介质进程 stdin/stdoutHTTP + Server-Sent EventsHTTP (可选升级 SSE)
端点数量n/a (管道)双端点(GET /sse + POST /message)单端点(POST /mcp)
session 标识无(单进程对单连接)URL query ?sessionId=xxxHTTP 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 分隔),不能跨行。

┌──────────────────┐
│   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 的协议层握手逻辑是一样的三步:

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: 启动子进程,无协议报文。示例命令:

node /path/to/mcp-server-filesystem.js /home/user

Step 2 Request (Client → Server,通过 stdin,单行):

{"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"}}}

格式化后看清楚:

{
  "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):

{
  "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):

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

注意没有 id 字段,服务端返回响应。

之后的业务调用(以 tools/list 为例):

// 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):

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/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):

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/1.1 202 Accepted

真正的响应通过 SSE 流推回:

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:

{
  "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):

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/1.1 202 Accepted

Notification 没有 id,服务端不通过 SSE 推任何东西回来。这步完成后服务端把 session 状态从 INIT_PENDING 切到 READY

之后的业务调用(以 tools/list 为例):

POST:

POST /message?sessionId=abc-123-def-456 HTTP/1.1
Content-Type: application/json

{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }

POST 立即响应:

HTTP/1.1 202 Accepted

通过 SSE 推回:

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):

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/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:

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/1.1 202 Accepted

之后的业务调用(以 tools/call 为例)

Request:

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/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/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 的握手并排放一起:

阶段stdioHTTP+SSEStreamable HTTP
Step 1 通道fork 子进程,管道就绪GET /sse 建长连接,服务端推 sessionId(无独立步骤,直接发 initialize)
Step 2 initialize 请求写 stdinPOST /message?sessionId=xxxPOST /mcp
Step 2 initialize 响应读 stdout从 SSE 流推回(POST 只回 202)直接在 POST 响应里
Step 3 客户端 ACK写 stdin (notifications/initialized)POST /message?sessionId=xxxPOST /mcp
session 标识无(1:1)URL ?sessionId=xxxHTTP header Mcp-Session-Id (可选)
业务调用响应路径stdoutSSE 流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 sessionnginx/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,但可以直接用管道:

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 直接打:

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:

# 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 官方的调试工具,可视化点点点跑握手 + 调工具:

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 推理。

正常流程:
  Host/Client  ──tools/call──►  Server

Sampling (反向):
  Host/Client  ◄──sampling/createMessage──  Server
       │
       ▼
     调 LLM 推理
       │
       ▼
  Host/Client  ──响应(LLM生成的文本)──►  Server

Server 本身没有 LLM——它只是提供工具/数据的服务。但某些场景下 Server 执行任务途中需要"AI 帮忙想一下"。

典型使用场景

场景Server 需要 LLM 做什么
数据清洗 tool拿到脏数据后让 LLM 分类/提取结构化信息
代码生成 toolServer 查到 DB schema 后让 LLM 生成 SQL
多步 agent 编排复杂工作流中间步骤需要 LLM 推理做决策
摘要/翻译Server 拿到长文档让 LLM 摘要后再返回

协议流程

前提:握手时 Client 声明支持 sampling

// 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 请求):

{
  "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 响应):

{
  "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-loopHost 可以弹窗让用户确认"Server 想调用你的 LLM,允许吗?"
嵌套是设计目标sampling 本就是为 agentic 设计的——spec 明确允许 LLM 调用嵌套在 server feature 内部;防失控不靠"禁止递归",而靠 human-in-the-loop + client 侧 rate limiting
上下文隔离sampling 的 messages 是 Server 自己构造的,不是用户的对话历史

modelPreferences 字段详解

Server 不直接指定模型名,而是用偏好维度表达需求:

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