Agent 工程体系 · 第 36/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
MCP 传输:stdio、Streamable HTTP、会话、重连和代理
MCP 的**传输(transport)**不是工具调用语义,也不是能力模型,而是把 JSON-RPC 消息从 MCP Client 送到 MCP Server、再把响应送回来的绑定层。它规定消息如何分帧、如何建立连接、如何携带元数据、如何取消请求以及如何处理终止;同一个 tools/call、resources/read 或 prompts/get 请求,在不同传输上具有相同的协议语义。2026-07-28 版本定义了两个标准传输:stdio 和 Streamable HTTP。(modelcontextprotocol.io)
本文以 2026-07-28 规范为基线,重点解释五个容易混淆的概念:
stdio如何利用子进程标准流传递 JSON-RPC;- Streamable HTTP 如何把每条消息映射为 HTTP POST;
- MCP 中“连接”“会话”和“状态”分别意味着什么;
- 断线后哪些请求可以重试,哪些请求不能盲目重放;
- 反向代理、负载均衡器和 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 上可能混合出现:
- 普通请求的响应;
- 与某个请求相关的进度通知;
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 应:
- 关闭 Server 的 stdin;
- 等待 Server 退出;
- 超时后再强制终止。
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 Request 和 HeaderMismatch 错误。(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/progress 和 notifications/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)
五、重连不等于恢复:需要区分三类对象
断线后最容易犯的错误是:
连接断了 → 重新连接 → 自动继续所有事情
实际上至少要分别处理:
- 已完成请求;
- 在途请求;
- 长期订阅或显式业务任务。
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
正确的重试条件可以形式化为:
其中:
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 就无法恢复它。
此时有三种设计:
- 共享状态存储:实例之间共享任务、订阅或游标;
- 显式路由键:通过代理将特定请求路由到持有状态的实例;
- 无状态重建:每次请求都能根据显式参数重新计算状态。
粘性会话只能解决路由问题,不能把隐式连接状态变成可靠的业务状态。它还会带来实例故障、扩缩容和负载不均等问题。
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 可能有多种原因:
- URL 路径根本不是 MCP endpoint;
- 代理路由错误;
- Server 不支持请求的方法;
- 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 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:MCP Prompts:参数、消息模板、发现、版本和信任边界
- 下一篇:MCP 认证与授权:OAuth、客户端身份、Scope、令牌和代理风险
- 延伸:MCP 架构深解:Host、Client、Server、能力协商和生命周期
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论