Agent 工程体系 · 第 36/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

MCP 传输:stdio、Streamable HTTP、会话、重连和代理

MCP 的**传输(transport)**不是工具调用语义,也不是能力模型,而是把 JSON-RPC 消息从 MCP Client 送到 MCP Server、再把响应送回来的绑定层。它规定消息如何分帧、如何建立连接、如何携带元数据、如何取消请求以及如何处理终止;同一个 tools/callresources/readprompts/get 请求,在不同传输上具有相同的协议语义。2026-07-28 版本定义了两个标准传输:stdio 和 Streamable HTTP。(modelcontextprotocol.io)

本文以 2026-07-28 规范为基线,重点解释五个容易混淆的概念:

  1. stdio 如何利用子进程标准流传递 JSON-RPC;
  2. Streamable HTTP 如何把每条消息映射为 HTTP POST;
  3. MCP 中“连接”“会话”和“状态”分别意味着什么;
  4. 断线后哪些请求可以重试,哪些请求不能盲目重放;
  5. 反向代理、负载均衡器和 SSE 中间件为什么会改变故障表现。

一、先分清三层:协议、传输和业务状态

一个 MCP 调用至少涉及三层。

1. JSON-RPC 消息层

MCP 消息使用 JSON-RPC 2.0 编码。请求包含:

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": {
      "location": "Hangzhou"
    }
  }
}

其中:

  • id 用于把响应和请求关联起来;
  • method 表示协议操作;
  • params 携带参数;
  • 通知消息没有 id,也不需要响应;
  • 响应必须复用对应请求的 id

MCP 还要求请求携带协议版本和客户端能力等元数据。2026-07-28 规范将这些字段放在请求的 _meta 中,例如:

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/list",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": {
        "name": "example-host",
        "version": "1.0.0"
      }
    }
  }
}

这意味着服务端不能仅凭“之前在同一条连接上收到过什么”来推断当前请求的版本、能力或会话归属。(modelcontextprotocol.io)

2. 传输绑定层

传输层负责回答以下问题:

  • 一条消息从哪里开始、到哪里结束?
  • Client 和 Server 是否需要保持一条长连接?
  • Server 的响应是一个 JSON 对象还是一个事件流?
  • 如何取消正在执行的请求?
  • 连接断开时,双方如何清理资源?

例如:

  • stdio 使用换行分隔的 JSON;
  • Streamable HTTP 使用 HTTP POST 发送消息;
  • Streamable HTTP 的响应可以是单个 JSON 对象,也可以是请求范围内的 SSE 流。

传输层不改变 tools/call 的含义,只改变消息的承载方式。(modelcontextprotocol.io)

3. 应用状态层

应用可能存在需要跨多个请求保存的状态,例如:

  • 一个长时间运行任务的句柄;
  • 一个文件编辑事务;
  • 一个外部系统的分页游标;
  • 一个订阅关系;
  • 一个用户授权后的访问令牌。

这些状态不能隐式绑定到 TCP 连接、HTTP 连接、stdio 子进程或某个 Host 进程。需要跨请求保持的状态,必须有显式标识,并由后续请求再次携带。2026-07-28 规范明确要求服务端不要把同一连接当成会话,也不要依赖请求之间的隐式上下文。(modelcontextprotocol.io)

可以用下面的关系理解:

flowchart LR
    A[JSON-RPC 消息语义] --> B[传输绑定]
    B --> C1[stdio]
    B --> C2[Streamable HTTP]
    A --> D[显式业务状态]
    D --> E[任务句柄]
    D --> F[订阅标识]
    D --> G[分页游标]

连接是通信路径,会话是应用概念,状态是业务数据。三者不能互相替代。


二、stdio:Client 启动 Server 的本地进程通道

2.1 基本模型

stdio 传输中,MCP Client 启动 MCP Server 作为子进程,然后使用子进程的标准输入和标准输出通信:

Host
 └── MCP Client
      ├── 启动 MCP Server 子进程
      ├── 写入 Server.stdin
      └── 读取 Server.stdout

规范定义了以下方向:

  • Server 从 stdin 读取 JSON-RPC 消息;
  • Server 向 stdout 写入 JSON-RPC 消息;
  • 每条消息占一行;
  • 消息必须是 UTF-8;
  • JSON 中不能包含未转义的换行;
  • Server 可以把日志写到 stderr
  • Server 不能向 stdout 写入普通日志或调试文本。(modelcontextprotocol.io)

因此,下面的输出是错误的:

Starting server...
{"jsonrpc":"2.0","id":1,"result":{...}}

因为第一行不是 MCP 消息,会导致 Client 的 JSON 解码器失败。正确做法是:

stderr:
Starting server...

stdout:
{"jsonrpc":"2.0","id":1,"result":{...}}

2.2 消息分帧

stdio 的分帧规则很简单:

一行 = 一个完整 JSON-RPC 消息

例如,下面两条消息合法:

{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
{"jsonrpc":"2.0","id":2,"method":"resources/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}

下面的形式不合法:

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

即使 JSON 语义正确,只要它跨越多行,就不符合 stdio 传输的消息边界规则。

一个最小的 Python stdio Server 可以这样实现:

#!/usr/bin/env python3
import json
import sys

def send(message):
    text = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
    sys.stdout.write(text + "\n")
    sys.stdout.flush()

def log(message):
    print(message, file=sys.stderr, flush=True)

for line in sys.stdin:
    line = line.rstrip("\n")

    if not line:
        continue

    try:
        request = json.loads(line)
    except json.JSONDecodeError as exc:
        log(f"invalid JSON: {exc}")
        continue

    log(f"received method={request.get('method')}")

    if request.get("method") == "tools/list":
        send({
            "jsonrpc": "2.0",
            "id": request.get("id"),
            "result": {
                "resultType": "complete",
                "tools": []
            }
        })
    else:
        send({
            "jsonrpc": "2.0",
            "id": request.get("id"),
            "error": {
                "code": -32601,
                "message": "Method not found"
            }
        })

启动:

chmod +x server.py
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
| ./server.py

预期输出类似:

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

这个示例只演示传输层,不是完整 MCP Server。生产实现还需要校验请求结构、处理能力声明、管理并发请求、返回正确的协议错误,并根据 SDK 的生命周期接口启动和关闭 Server。

2.3 stdio 的并发模型

stdio 只有一条共享的双向字节流,但这不代表请求必须串行执行。

Client 可以依次发送:

请求 A,id=1
请求 B,id=2
请求 C,id=3

Server 可能按照以下顺序响应:

响应 B,id=2
响应 C,id=3
响应 A,id=1

Client 必须使用 id 关联响应,而不能假设响应顺序等于发送顺序。

此外,stdout 上可能混合出现:

  1. 普通请求的响应;
  2. 与某个请求相关的进度通知;
  3. subscriptions/listen 产生的长期通知。

所以 stdio 读取器不能简单地写成“发送一个请求,然后读取下一行作为它的响应”。正确实现需要:

读取循环
 ├── 解析每一行 JSON
 ├── 有 id:交给对应 pending request
 ├── 无 id:交给 notification handler
 └── 按 subscriptionId 分发订阅通知

规范要求订阅通知使用 _meta.io.modelcontextprotocol/subscriptionId 与原始订阅请求关联。(modelcontextprotocol.io)

2.4 stderr 不是错误通道

stderr 仅是日志通道,不等价于失败通道。Server 可以把调试信息、启动信息和错误信息都写入 stderr;Client 也可以捕获、转发或忽略它,不能根据“stderr 有内容”就判定 Server 出错。(modelcontextprotocol.io)

诊断时应区分:

stdout 解析失败

和:

stderr 出现 ERROR

前者说明传输协议已经被破坏,后者可能只是正常日志。真正的进程失败应结合:

  • 子进程退出码;
  • stdout 是否提前 EOF;
  • stderr 中的错误;
  • Client 是否有未完成请求;
  • Server 是否在启动阶段就退出。

2.5 stdio 的关闭与异常终止

优雅关闭时,Client 应:

  1. 关闭 Server 的 stdin;
  2. 等待 Server 退出;
  3. 超时后再强制终止。

Server 应在 stdin EOF 或读取流关闭后尽快退出。(modelcontextprotocol.io)

如果 Server 意外退出,Client 通常应启动新进程。由于现代 MCP 按请求携带必要的协议元数据,旧进程中正在执行的请求不会自动转移到新进程;在途请求已经丢失,Client 只能根据请求语义决定是否重试。活动中的订阅流也必须重新建立。(modelcontextprotocol.io)


三、Streamable HTTP:每条消息都是一个 POST

3.1 为什么不是“HTTP 版 stdio”

Streamable HTTP 不是把 stdio 的每一行简单塞进 HTTP,而是重新定义了消息和响应的映射关系。

一个 MCP HTTP Server 暴露一个 MCP endpoint,例如:

https://mcp.example.com/mcp

Client 发送请求时:

每条 JSON-RPC 消息 → 一个独立的 HTTP POST

Server 返回请求结果时,可以选择:

application/json     → 一个完整 JSON 响应
text/event-stream    → 一个请求范围内的 SSE 响应流

Streamable HTTP 于 2025-03-26 引入,用于替代早期的 HTTP+SSE 传输。2026-07-28 又移除了 GET 流端点和协议级会话,因此不能把当前版本与早期“建立 SSE 会话、再通过会话发送消息”的实现混为一谈。(modelcontextprotocol.io)

3.2 一次普通请求的生命周期

tools/list 为例:

sequenceDiagram
    participant C as MCP Client
    participant P as 反向代理
    participant S as MCP Server

    C->>P: POST /mcp<br/>JSON-RPC tools/list
    P->>S: 转发 POST
    S-->>P: application/json 或 text/event-stream
    P-->>C: 转发响应
    C->>C: 按 JSON-RPC id 完成请求

请求至少需要满足以下传输要求:

POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list

请求体仍然必须包含 JSON-RPC 消息,以及 _meta.io.modelcontextprotocol/protocolVersion。HTTP 头部只是对部分消息字段的镜像,消息体仍是协议事实来源。MCP-Protocol-Version 必须与 body 中的协议版本一致,否则服务端应返回 400 Bad RequestHeaderMismatch 错误。(modelcontextprotocol.io)

一个可观察请求可以写成:

curl --http1.1 -i https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  --data-binary @- <<'JSON'
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": {
        "name": "curl-client",
        "version": "1.0.0"
      }
    }
  }
}
JSON

前置条件是 endpoint 已经部署,并且 Server 接受匿名请求或你已经补充了合法的 Authorization 头。

可能得到两种结果。

单对象响应:

HTTP/1.1 200 OK
Content-Type: application/json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "tools": []
  }
}

SSE 响应:

HTTP/1.1 200 OK
Content-Type: text/event-stream
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{...}}

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","tools":[]}}

Server 只能在最终响应前发送与当前请求有关的进度或日志通知。最终 JSON-RPC 响应通常结束该请求流。(modelcontextprotocol.io)

3.3 通知 POST 与请求 POST 不同

如果 POST body 是 JSON-RPC 通知而不是请求:

  • Server 接受通知时返回 202 Accepted
  • 响应没有 body;
  • Server 拒绝通知时返回 HTTP 错误状态。

当前 2026-07-28 核心协议没有定义通过 Streamable HTTP 发送客户端通知的通用机制;stdio 使用的 notifications/cancelled 不应机械地搬到 HTTP 上。HTTP 中关闭对应的响应流就是取消信号。(modelcontextprotocol.io)

3.4 HTTP 中的长连接实际上是“请求范围流”

Streamable HTTP 存在两种不同的时间尺度:

短请求流

例如一个工具调用:

POST tools/call
 ├── 进度通知
 ├── 日志通知
 └── 最终响应

流在最终响应后结束。

长期订阅流

如果 Client 需要接收资源更新或工具列表变更,应发起 subscriptions/listen 请求。该请求的响应本身可以长期保持为 SSE 流:

POST subscriptions/listen
 └── 持续接收订阅范围内的通知

请求范围内的 notifications/progressnotifications/message 不应混入这个订阅流;它们仍属于触发它们的原始请求。(modelcontextprotocol.io)

这一区分很重要。错误的实现经常把所有服务器通知都放进一条“全局 SSE 连接”,然后依赖连接状态推断消息归属。当前规范要求按请求和订阅标识进行关联,而不是按“某条全局连接”猜测。


四、“会话”到底是什么:连接不是会话

4.1 现代 MCP 的协议模型是无状态的

2026-07-28 规范将 MCP 描述为无状态协议:

处理单个请求所需的信息应包含在该请求本身中。

服务端不得依赖以下信息:

  • 之前在同一 TCP 连接上收到的请求;
  • 之前在同一个 SSE 流中出现的消息;
  • 某个 stdio 子进程的身份;
  • 某次初始化请求中保存的隐式能力;
  • 某个 HTTP keep-alive 连接是否仍然存在。

如果状态需要跨请求存在,Client 必须传递显式标识。例如:

{
  "method": "tasks/get",
  "params": {
    "taskId": "task_01J...",
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

这里 taskId 是应用状态的标识;HTTP 连接本身不是 taskId,stdio 进程也不是 taskId。(modelcontextprotocol.io)

4.2 旧实现为什么会出现 session ID

早期 MCP 版本以及早期 HTTP+SSE 实现曾经使用连接范围的初始化和会话机制。当前规范保留了向旧版本兼容的说明,但 2026-07-28 的 Streamable HTTP 已移除协议级会话。(modelcontextprotocol.io)

因此,工程上会遇到三种东西:

名称 含义 是否等价于现代 MCP 会话
TCP/HTTP 连接 网络承载路径
stdio 子进程 本地 Server 运行实例
业务句柄 任务、订阅、事务等显式状态标识 才可能代表业务状态

如果某个 SDK 或网关仍暴露 sessionId,必须确认它属于哪一层:

  • 是旧版 MCP 协议字段;
  • 是 SDK 内部连接管理字段;
  • 是代理自己的粘性路由键;
  • 还是业务层任务 ID。

不能因为名字叫 sessionId,就认为它可以替代请求中的业务状态标识。

4.3 能力协商也不能只存于连接

当前规范要求客户端请求携带协议版本和客户端能力。服务端还可以通过 server/discover 获取服务端能力。客户端和服务端必须只使用已经声明或协商过的能力。(modelcontextprotocol.io)

因此下面的服务端设计是不安全的:

第一次请求:
    记录 clientCapabilities = { sampling: {} }

第二次请求:
    假设 sampling 永远可用

更可靠的设计是:

每次请求到达
 ├── 解析协议版本
 ├── 解析 clientCapabilities
 ├── 校验当前方法所需能力
 └── 决定是否执行

如果请求依赖客户端未声明的能力,服务端应返回缺少客户端能力的错误,而不是尝试执行后再崩溃。(modelcontextprotocol.io)


五、重连不等于恢复:需要区分三类对象

断线后最容易犯的错误是:

连接断了 → 重新连接 → 自动继续所有事情

实际上至少要分别处理:

  1. 已完成请求;
  2. 在途请求;
  3. 长期订阅或显式业务任务。

5.1 已完成请求

如果 Client 已经收到合法的 JSON-RPC 响应,那么请求已经完成。之后发生连接断开,不应再次执行同一个请求。

Client 发送 id=10
Server 执行成功
Client 收到 id=10 的响应
连接断开

此时不能因为断线而自动再次调用 id=10

5.2 在途请求

如果 Client 发送请求后,在收到响应前连接断开,Client 通常无法知道 Server 是否已经执行:

Client ──请求──> Server
Client <──?──── Server

存在两种可能:

情况 A:Server 尚未收到请求
情况 B:Server 已执行成功,但响应在网络中丢失

因此,重试存在重复执行风险。

对于查询类操作,重复执行通常影响较小:

resources/list
resources/read
tools/list

但对写操作不能默认安全:

tools/call: create_order
tools/call: charge_card
tools/call: delete_file
tools/call: send_email

正确的重试条件可以形式化为:

Retry=TransportFailureRequestNotCompletedOperationSafeToReplay\text{Retry} = \text{TransportFailure} \land \text{RequestNotCompleted} \land \text{OperationSafeToReplay}

其中:

  • TransportFailure:连接关闭、超时、代理返回 502 等;
  • RequestNotCompleted:客户端没有收到可信的最终响应;
  • OperationSafeToReplay:重复执行不会造成不可接受的副作用,或服务端支持幂等键。

如果工具调用产生副作用,应引入业务幂等键,例如:

{
  "name": "create_order",
  "arguments": {
    "productId": "p-100",
    "quantity": 1,
    "idempotencyKey": "host-request-8f3c..."
  }
}

但这里有一个边界:idempotencyKey 只有在 Server 真正实现去重时才有意义。仅仅把它放进 arguments,并不会自动让 MCP 或 HTTP 具备幂等性。

5.3 新连接不应继承旧连接的隐式状态

stdio Server 重启后,Client 可以把未完成请求重新发送到新进程,但新进程不能假设自己知道旧进程已经完成了什么。规范对异常终止的描述也是:在途请求丢失,Client 可以向新进程重试;活动订阅必须重新建立。(modelcontextprotocol.io)

一个合理的 Client 状态机如下:

stateDiagram-v2
    [*] --> Disconnected
    Disconnected --> Connecting: 建立进程或 HTTP 连接
    Connecting --> Ready: 能力与版本可用
    Connecting --> Backoff: 连接失败
    Ready --> InFlight: 发送请求
    InFlight --> Ready: 收到最终响应
    InFlight --> RetryDecision: 超时或断线
    RetryDecision --> Ready: 可安全重试
    RetryDecision --> Failed: 不可安全重试
    Ready --> ReestablishSubscriptions: 连接断开
    ReestablishSubscriptions --> Ready: 订阅恢复
    Ready --> Disconnected: 主动关闭
    Backoff --> Connecting: 退避结束

Ready 表示“可以发送新的请求”,不表示“拥有旧连接的所有隐式上下文”。重连后应重新确认:

  • endpoint 是否仍然可用;
  • 协议版本是否仍然兼容;
  • 访问令牌是否仍然有效;
  • Server 能力是否发生变化;
  • 订阅是否需要重新注册;
  • 未完成请求是否允许重试。

5.4 订阅重连

当前 Streamable HTTP 不支持通过 Last-Event-ID 恢复 SSE 流。也就是说,断线后不能仅凭 SSE 的事件 ID 从中间位置继续消费。(modelcontextprotocol.io)

因此订阅恢复通常需要:

1. 检测 SSE EOF、连接错误或读取超时
2. 等待退避时间
3. 重新发送 subscriptions/listen
4. 重新传递订阅目标
5. 记录“可能丢失的通知窗口”
6. 必要时主动执行一次全量同步

如果业务要求“一个更新都不能丢”,仅依赖通知流是不够的。应把通知设计成“有变化提示”,再通过资源版本号、游标或全量读取校正状态。


六、代理:HTTP 能转发,不代表能正确承载 MCP

6.1 代理位于什么位置

典型部署如下:

flowchart LR
    H[Host / MCP Client]
    G[API Gateway 或反向代理]
    L[负载均衡器]
    S1[MCP Server 实例 1]
    S2[MCP Server 实例 2]
    A[OAuth Authorization Server]

    H --> G
    G --> L
    L --> S1
    L --> S2
    H -. 获取令牌 .-> A
    H -. Bearer Token .-> G

代理可能承担:

  • TLS 终止;
  • 身份验证;
  • 路由;
  • 访问日志;
  • 限流;
  • 超时控制;
  • SSE 流转发;
  • 多实例负载均衡。

这些功能都可能影响 MCP,尤其是 SSE、请求超时、请求重试和认证头转发。

6.2 SSE 不能被普通响应缓冲破坏

对于普通 JSON 响应,代理通常可以等后端响应完整后再转发。但对于 SSE,代理必须及时转发每个事件。

如果代理启用了响应缓冲,后端已经发送了进度事件,Client 却迟迟收不到,表现可能是:

Server 日志:已经发送 progress
代理日志:连接正常
Client:长时间没有任何事件,最终超时

规范建议 SSE 响应包含:

X-Accel-Buffering: no

以要求 nginx 等反向代理关闭响应缓冲;长时间没有业务事件时,还应发送 SSE 注释作为 keep-alive。(modelcontextprotocol.io)

典型响应头可以是:

Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no

代理还需要确认:

  • 不修改 Content-Type
  • 不把 SSE 压缩成需要完整缓冲的格式;
  • 不因为响应体暂时为空而关闭连接;
  • 不设置短于业务执行时间的 read timeout;
  • 不把 SSE 当成普通 HTTP 请求自动重试。

6.3 代理重试可能造成重复工具调用

假设 Client 发送:

POST /mcp tools/call create_order

Server 已完成创建订单,但代理在读取响应时与上游断开。若代理因为 502 自动重试 POST,Server 可能再次创建订单。

这是一个危险的组合:

POST + 有副作用 + 自动代理重试 = 可能重复执行

因此,代理层一般不应对 MCP POST 做无条件自动重试,尤其是:

  • tools/call
  • 删除、写入、支付、发送等副作用操作;
  • 长时间运行的 SSE 响应;
  • 已经收到上游部分响应的请求。

重试策略应由理解 MCP 请求语义的 Client 或业务网关负责。即使代理能解析 Mcp-Method,也不能仅凭方法名判断一个工具是否幂等,因为真正的副作用取决于工具定义和参数。

6.4 负载均衡与“粘性会话”

现代 MCP 不要求把同一个 Client 永远路由到同一个 Server 实例。请求应自包含,跨请求状态应通过显式 ID 传递。理论上,以下两个请求可以被路由到不同实例:

POST /mcp  tasks/create
        ↓
Server 1 返回 taskId=task-abc

POST /mcp  tasks/get(taskId=task-abc)
        ↓
Server 2 查询共享任务存储

但这要求任务状态位于共享存储、可复制状态层或一致的外部服务中。如果 taskId 只存在 Server 1 的内存里,Server 2 就无法恢复它。

此时有三种设计:

  1. 共享状态存储:实例之间共享任务、订阅或游标;
  2. 显式路由键:通过代理将特定请求路由到持有状态的实例;
  3. 无状态重建:每次请求都能根据显式参数重新计算状态。

粘性会话只能解决路由问题,不能把隐式连接状态变成可靠的业务状态。它还会带来实例故障、扩缩容和负载不均等问题。

6.5 HTTP 头部镜像与代理篡改

Streamable HTTP 要求把部分 body 字段镜像到头部,例如:

MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

其中 header 与 body 必须一致。代理如果重写、删除或缓存这些头部,可能导致:

body:
io.modelcontextprotocol/protocolVersion = 2026-07-28

header:
MCP-Protocol-Version = 2025-03-26

服务端应拒绝这种请求,而不是选择其中一个值继续执行。(modelcontextprotocol.io)

Mcp-Name 也可能来自工具名、资源 URI 或提示名称。当值包含非 ASCII 字符、控制字符或首尾空格时,Client 必须按规范进行 Base64 哨兵格式编码,避免直接放入不安全的 HTTP 头部。(modelcontextprotocol.io)

代理不应自行根据未经验证的头部放行权限。头部是路由和可观测性辅助信息,真正的请求参数仍在 JSON body 中,权限判断必须基于服务端解析和校验后的请求。


七、Streamable HTTP 的认证边界

MCP 的标准授权框架针对 HTTP 传输;stdio 不应照搬这套 HTTP OAuth 流程,而通常从环境变量或本地安全配置中获取凭证。(modelcontextprotocol.io)

HTTP 请求应使用:

Authorization: Bearer <access-token>

而不是:

GET /mcp?access_token=...

访问令牌不得放在 URL 查询字符串中。服务端还必须验证令牌确实是为当前 MCP Server 作为目标资源签发的,不能仅验证签名正确就接受。(modelcontextprotocol.io)

在代理架构中,应明确令牌的终止位置:

方案 A:代理验证令牌,向后端传递已验证身份
方案 B:代理只转发 Authorization,MCP Server 自己验证
方案 C:代理和 MCP Server 都验证

最危险的是“代理验证后把原始令牌记录到日志”或“代理把一个 Server 的令牌转发给另一个 Server”。MCP 授权规范要求客户端不要把非目标 Server 签发的令牌发送给当前 Server,Server 也不能接受或转发不属于自己的令牌。(modelcontextprotocol.io)

当令牌无效或过期时,通常返回:

401 Unauthorized

当令牌有效但 Scope 不足时,返回:

403 Forbidden

例如:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
                  scope="files:write",
                  resource_metadata="..."

Client 可以根据 WWW-Authenticate 发起增量授权,但应把原有 Scope 与新增 Scope 合并,否则重新授权可能意外丢失之前已经获得的权限。(modelcontextprotocol.io)


八、常见失败表现与诊断路径

8.1 stdio Server 启动后立即失败

现象:

JSON decode error at stdout line 1

优先检查:

1. Server 是否把启动日志写到了 stdout
2. 是否输出了多行 JSON
3. 是否使用 UTF-8
4. 是否每条消息后 flush
5. 是否有 shell wrapper 打印欢迎语

可以直接观察 stdout 和 stderr:

./server.py >stdout.log 2>stderr.log

要求:

  • stdout.log 只能出现合法 MCP 消息;
  • stderr.log 可以包含普通日志;
  • 不要把两个文件合并后再作为协议流读取。

8.2 HTTP 返回 400 HeaderMismatch

检查请求的两个位置:

HTTP Header:
MCP-Protocol-Version

JSON Body:
_meta.io.modelcontextprotocol/protocolVersion

两者必须完全一致。还要检查代理是否:

  • 删除了自定义头;
  • 修改了大小写或值;
  • 只转发了 body;
  • 把旧版本 Header 注入到现代请求。

8.3 HTTP 返回 404

现代 Streamable HTTP 中,404 可能有多种原因:

  1. URL 路径根本不是 MCP endpoint;
  2. 代理路由错误;
  3. Server 不支持请求的方法;
  4. Client 仍在访问旧 HTTP+SSE 的 GET 端点。

需要同时检查:

HTTP 状态码
Content-Type
JSON-RPC error.code
响应 body

如果 Server 找到了 MCP endpoint,但方法不存在,现代规范要求返回 404,并在 JSON-RPC 错误中使用 -32601。(modelcontextprotocol.io)

8.4 SSE 建立成功但收不到事件

检查顺序:

1. Server 是否真的返回 text/event-stream
2. 是否启用了代理响应缓冲
3. 是否设置了 X-Accel-Buffering: no
4. 代理 read timeout 是否过短
5. 是否发送 keep-alive 注释
6. Client 是否错误地等待 data 字段而忽略注释行
7. 是否把请求范围通知误当成订阅通知

SSE 注释行例如:

:

它不是 JSON,也不是错误;Client 应忽略它。规范明确建议长期流定期发送此类 keep-alive。(modelcontextprotocol.io)

8.5 重连后工具调用重复执行

如果发生:

请求已发出
Client 超时
Client 自动重试
业务数据出现两份

不能先假设是 Server 执行了两次,也不能先假设 Server 一次都没执行。需要关联:

  • Client request ID;
  • 业务幂等键;
  • Server 执行日志;
  • 代理上游请求日志;
  • 代理是否自动重试;
  • 响应是否已经部分写出;
  • 数据库提交时间。

JSON-RPC 的 id 只用于消息相关性,不是业务幂等键。即使重试时复用同一个 JSON-RPC id,也不能保证服务端只执行一次。


九、stdio 与 Streamable HTTP 的取舍

维度 stdio Streamable HTTP
典型场景 本地工具、IDE、桌面 Host 远程 Server、集中部署、跨网络访问
进程管理 Client 启动和回收 Server Server 独立运行
消息分帧 换行分隔 JSON HTTP POST + JSON 或 SSE
认证 通常使用环境或本地配置 可使用 MCP HTTP 授权框架
代理依赖 无 HTTP 代理 依赖网关、负载均衡和 SSE 正确转发
断线恢复 重启子进程,重试可重放请求 重建 HTTP 请求或订阅流
多实例 通常一个子进程一个实例 需要处理共享状态和负载均衡
主要风险 stdout 污染、子进程泄漏、僵尸进程 超时、缓冲、错误重试、令牌转发

选择传输时,首先看信任边界和部署边界,而不是只看开发便利性。

本地 Host 启动一个受控 MCP Server 时,stdio 的进程隔离和低部署复杂度通常更直接。需要跨主机访问、集中认证、横向扩展或由多个 Host 共享服务时,Streamable HTTP 更合适,但必须把代理、SSE、超时、授权和重连视为协议链路的一部分。


十、实现时应固定下来的不变量

无论使用哪种传输,都应保持以下不变量。

不变量一:消息语义不能依赖传输

同一个 JSON-RPC 请求在 stdio 和 HTTP 上应产生相同的协议结果。传输可以改变封装和取消方式,但不应改变工具参数、错误语义或能力检查。

不变量二:响应只按 JSON-RPC ID 关联

不能按发送顺序、HTTP 连接、SSE 流顺序或 stdio 进程身份关联响应。唯一可靠的请求相关性字段是 JSON-RPC id;长期订阅还需要使用规范定义的订阅标识。

不变量三:业务状态必须显式化

任务、分页、事务、订阅和幂等控制都应通过显式标识传递。不要把以下对象当业务状态:

socket
HTTP keep-alive connection
SSE stream
stdio process
load balancer sticky cookie

它们都可能在正常运行中消失。

不变量四:重试必须基于执行语义

连接错误只说明“Client 没有得到结果”,不说明“Server 没有执行”。查询可以较容易重试;副作用操作需要幂等键、状态查询或人工确认。

不变量五:代理不能被当成透明管道

HTTP 代理会改变:

  • 超时;
  • 缓冲;
  • 头部;
  • 重试;
  • TLS 和认证边界;
  • 路由;
  • 连接生命周期。

因此,MCP 的端到端验证必须经过真实代理链路,而不能只在 Client 与 Server 直连时测试通过。


MCP 传输的核心不是“stdio 还是 HTTP”这个二选一,而是正确理解边界:JSON-RPC 定义消息,传输定义承载,业务协议定义状态。stdio 通过受控子进程提供本地可靠字节流;Streamable HTTP 通过独立 POST 和请求范围 SSE 提供远程访问;现代 MCP 不再把连接隐式升级为协议会话;重连只能恢复通信路径,不能自动恢复已经丢失的执行事实;代理则必须正确处理流式响应、请求超时、头部一致性、认证转发和重试语义。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。