协议背景

Streamable HTTP 是 MCP(Model Context Protocol)在 2025 年 3 月 26 日 引入的新传输协议,用于替代旧的 HTTP+SSE 传输方式

核心设计理念

Streamable HTTP 并不是传统意义上的"流式 HTTP",而是一种混合传输机制

普通 HTTP 请求 + 按需升级为 SSE 流 = Streamable HTTP

关键特性:

  • 基于标准 HTTP 协议(POST/GET/DELETE)
  • 服务端可按需将响应升级为 SSE 流
  • 无强制长连接要求
  • 支持有状态和无状态两种部署模式
  • 统一端点设计(无需单独的 /sse 端点)

协议工作原理

1. 统一端点设计

所有通信都通过一个 HTTP 端点完成(例如 /api/tools/mcp):

HTTP 方法 用途
POST 发送 JSON-RPC 请求
GET 建立 SSE 长连接(接收服务端推送)
DELETE 终止会话

2. 客户端 → 服务端(POST 请求)

客户端通过 POST 请求发送 JSON-RPC 消息:

请求格式:

POST /api/tools/mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json

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

响应方式(服务端可选):

场景 响应方式 Content-Type
简单请求(如 tools/list 直接返回 JSON application/json
复杂请求(需要流式输出) 升级为 SSE 流 text/event-stream
纯通知/响应(无返回) HTTP 202 Accepted 无响应体

代码实现(route.ts):

export async function POST(req: NextRequest) {
  // 1. 提取 sessionId(从 query 或 header)
  const sessionId = url.searchParams.get('sessionId') ||
                    req.headers.get('Mcp-Session-Id')

  // 2. 查找或创建 transport
  if (!transport) {
    // 新会话:创建 transport + server
    transport = new WebStandardStreamableHTTPServerTransport({
      sessionIdGenerator: () => crypto.randomUUID(),
      onsessioninitialized: (newId: string) => {
        saveTransport(newId, transport!)
      },
    })
    await server.connect(transport)
  }

  // 3. 处理请求(自动选择 JSON 或 SSE 响应)
  const webRes = await transport.handleRequest(webReq)
  return new NextResponse(webRes.body, {...})
}

3. 服务端 → 客户端(SSE 流)

客户端通过 GET 请求建立 SSE 长连接,接收服务端主动推送:

请求格式:

GET /api/tools/mcp?sessionId=xxx HTTP/1.1
Accept: text/event-stream
Mcp-Session-Id: xxx

SSE 流内容:

  • JSON-RPC 请求(服务端主动调用客户端能力)
  • JSON-RPC 通知(如进度通知、日志通知)

代码实现(route.ts):

export async function GET(req: NextRequest) {
  // 1. 验证 sessionId
  if (!sessionId) return NextResponse.json({error: 'Missing Mcp-Session-Id'}, {status: 400})

  // 2. 查找 transport
  const transport = getTransport(sessionId)
  if (!transport) return NextResponse.json({error: 'Session not found'}, {status: 404})

  // 3. 建立 SSE 流
  const webRes = await transport.handleRequest(webReq)
  return new NextResponse(webRes.body, {...})
}

为什么需要 GET 端点?POST 不是也能返回 SSE 流吗?

这是一个常见的疑问:POST 请求本身就能返回 SSE 事件流,为什么还需要额外的 GET 端点?

核心原因是两者的角色和时序完全不同

对比维度 POST(客户端→服务端) GET SSE(服务端→客户端)
通信方向 客户端主动发起请求 服务端主动推送消息
触发方式 客户端发请求 → 服务端响应 客户端先建立连接 → 服务端随时推送
生命周期 响应完当前请求即关闭 长连接,持续接收推送
典型内容 对特定请求的响应(result/error) 通知、进度、服务端发起的请求

GET SSE 解决的三个核心场景:

场景 1:服务端主动通知

服务端需要告诉客户端:"日志模块出问题了"
但客户端没有发请求,服务端无法主动连接客户端
→ 只能通过已建立的 GET SSE 长连接推送

在没有 GET SSE 的情况下,服务端只能被动响应,无法主动通知客户端任何事。

场景 2:多步异步操作

1. 客户端 POST 发起长时间任务(如大批量数据处理)
2. 服务端立即返回 202 Accepted(任务已接受)
3. 客户端通过 GET SSE 连接接收任务进度通知
4. 任务完成后,服务端通过 SSE 推送最终结果

此类场景中,POST 的响应已经结束,后续进度只能通过 GET SSE 接收。

场景 3:服务端反向调用客户端

服务端需要调用客户端能力(如 sampling / elicitation)
→ 服务端通过 GET SSE 通道向客户端发送 JSON-RPC 请求
→ 客户端处理后,通过 POST 返回结果

简单总结:

  • POST = 客户端说"我要…",服务端回答(请求-响应模型)
  • GET SSE = 服务端说"顺便告诉你…",主动推送(推送模型)
  • 两者是互补关系,不是替代关系。HTTP 本身是请求-响应协议,要让服务端能主动推送,必须在客户端预先建立一条长连接通道

4. 会话管理机制

会话 ID 生成:

  • 服务端在 initialize 阶段生成唯一会话 ID
  • 通过响应头 Mcp-Session-Id 返回给客户端
  • 后续请求必须携带此 ID(在 header 或 query 参数中)

会话存储(session-store.ts):

// 使用 globalThis 实现跨请求、跨热重载的全局单例
const globalForMcp = globalThis as unknown as {
  __mcpSessionStore?: SessionStore
}

// 保存 transport(每个会话一个 transport)
export function saveTransport(sessionId: string, transport: ...): void {
  getStore().transports.set(sessionId, transport)
}

// 获取 transport
export function getTransport(sessionId: string): ... | undefined {
  return getStore().transports.get(sessionId)
}

会话终止(route.ts):

export async function DELETE(req: NextRequest) {
  // 1. 验证 sessionId
  if (!sessionId) return NextResponse.json({error: 'Missing Mcp-Session-Id'}, {status: 400})

  // 2. 查找 transport
  const transport = getTransport(sessionId)
  if (!transport) return NextResponse.json({error: 'Session not found'}, {status: 404})

  // 3. 关闭 transport + 删除会话
  await transport.close()
  deleteTransport(sessionId)

  return NextResponse.json({ ok: true })
}

5. 完整交互流程

客户端与 MCP 服务端交互必须遵循以下步骤,不可跳过初始化阶段直接调用工具

┌──────────┐                           ┌──────────┐
│  Client  │                           │  Server  │
└────┬─────┘                           └────┬─────┘
     │                                      │
     │  (1) initialize ────────────────────►│  建立会话,获取 Mcp-Session-Id
     │◄── ServerCapabilities + sessionId ──│
     │                                      │
     │  (2) notifications/initialized ─────►│  通知服务端初始化完成
     │                                      │
     │  (3) tools/list ────────────────────►│  获取可用工具列表
     │◄── tools[] ─────────────────────────│
     │                                      │
     │  (4) tools/call ────────────────────►│  调用具体工具
     │◄── result / SSE stream ─────────────│
     │                                      │
     │  (5) DELETE ────────────────────────►│  终止会话
     │◄── { ok: true } ────────────────────│
     │                                      │

(1) initialize — 建立会话

客户端发送 initialize 请求建立会话:

curl --location 'http://localhost:19410/api/tools/mcp' \
  --header 'Accept: application/json, text/event-stream' \
  --header 'Content-Type: application/json' \
  --data '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-03-26",
      "capabilities": {},
      "clientInfo": { "name": "my-client", "version": "1.0.0" }
    }
  }'

成功响应中会返回服务端能力信息,响应头 Mcp-Session-Id 中包含本次会话 ID。

(2) notifications/initialized — 通知初始化完成

客户端必须发送一个无 id 字段的通知

curl 'http://localhost:19410/api/tools/mcp' \
  --header 'Content-Type: application/json' \
  --header 'Mcp-Session-Id: <SESSION_ID>' \
  --data '{
    "jsonrpc": "2.0",
    "method": "notifications/initialized"
  }'

注意:跳过此步骤直接调用 tools/list 或 tools/call 会得到 -32000 "Server not initialized" 错误。

(3) tools/list — 获取工具列表

curl 'http://localhost:19410/api/tools/mcp' \
  --header 'Content-Type: application/json' \
  --header 'Mcp-Session-Id: <SESSION_ID>' \
  --data '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list",
    "params": {}
  }'

(4) tools/call — 调用工具

tools/call 的 params 必须包含 name 和 arguments 两个字段:

参数 类型 必填 说明
name string 要调用的工具名称
arguments object 工具所需的参数键值对

正确格式:

curl 'http://localhost:19410/api/tools/mcp' \
  --header 'Accept: application/json, text/event-stream' \
  --header 'Content-Type: application/json' \
  --header 'Mcp-Session-Id: <SESSION_ID>' \
  --data '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
      "name": "<工具名称>",
      "arguments": {
        "key1": "value1",
        "key2": "value2"
      }
    }
  }'

常见错误:

错误码 原因 示例
-32000 / Server not initialized 未先执行 initialize + initialized 直接调 tools/list
-32603 / invalid_type params 缺少 name 或 arguments 把工具参数直接放在 params 下

(5) DELETE — 终止会话

curl -X DELETE 'http://localhost:19410/api/tools/mcp' \
  --header 'Mcp-Session-Id: <SESSION_ID>'

断点续传与可恢复性

Streamable HTTP 支持连接中断后恢复

服务端:

  • 为 SSE 事件添加 id 字段(全局唯一)
  • 客户端重连时,通过 Last-Event-ID 头告知最后接收的事件 ID
  • 服务端从该位置重放消息

示例:

GET /api/tools/mcp HTTP/1.1
Accept: text/event-stream
Last-Event-ID: event-12345

与旧协议(HTTP+SSE)对比

对比维度 旧协议(HTTP+SSE) 新协议(Streamable HTTP)
端点设计 需要单独的 /sse 端点 统一使用 /mcp 端点
连接要求 强制维持 SSE 长连接 无强制长连接,按需升级
状态支持 依赖长连接维持状态 支持无状态 + 有状态(会话 ID)
连接恢复 中断后无法恢复 支持断点续传
服务端推送 仅能通过 /sse 通道推送 可在响应中升级为 SSE 流
基础设施兼容性 需要 SSE 支持 完全基于标准 HTTP

三种典型部署场景

1. 纯无状态服务器(最简单)

  • 所有请求返回标准 JSON-RPC 响应
  • 无需维持会话状态
  • 适合轻量级工具服务

2. 支持流式输出的无状态服务器

  • 收到请求后,通过 SSE 流返回进度和结果
  • 适合需要流式输出的场景(如长时间任务)

3. 有状态服务器(本项目的实现)

  • 客户端首次请求时生成会话 ID
  • 后续请求携带会话 ID
  • 支持服务端主动推送通知/请求
  • 适合复杂交互场景

安全要求

  1. 验证 Origin 头:防范 DNS 重绑定攻击
  2. 本地服务绑定 127.0.0.1:不能绑定 0.0.0.0
  3. 所有连接必须认证
  4. 会话 ID 必须加密安全:使用 UUID、JWT 等

本项目实现对照

route.ts 实现了 Streamable HTTP 规范的完整功能:

特性 实现状态
POST 方法 - JSON-RPC 请求处理 已实现
GET 方法 - SSE 长连接 已实现
DELETE 方法 - 会话终止 已实现
会话管理 - session-store.ts 已实现
Web Standard API 兼容 使用 WebStandardStreamableHTTPServerTransport

核心优势总结

  1. 更灵活的部署:支持无状态和有状态模式
  2. 更好的兼容性:基于标准 HTTP,兼容所有 HTTP 基础设施
  3. 更低的服务端压力:无强制长连接要求
  4. 更强的恢复能力:支持断点续传

 

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐