跳到主要内容

传输层与连接状态

本章讲 Dispatcher 底下真正搬字节的东西:三种传输,以及「一个连接」在服务端对应的状态对象。

1. 统一契约:SessionMessage 读写流

不管哪种传输,对上层暴露的都是同一样东西:一对 anyio 内存流,里面流动 SessionMessage(shared/message.py)。JSONRPCDispatcher 只认这个契约(文件头:「over the SessionMessage stream contract all transports speak」)。

传输负责: 外部字节 ⇄ SessionMessage 读写流
Dispatcher 负责: SessionMessage 读写流 ⇄ (method, params) 语义

于是加一种新传输,只要能把它变成「一对 SessionMessage 流」即可,Dispatcher 及以上完全不用动。

2. stdio:最简单的传输

server/stdio.py:34 stdio_server 是一个 async 上下文管理器,产出 (read_stream, write_stream):

  • 读:一个后台协程逐行读 stdin,每行 jsonrpc_message_adapter.validate_json 解析成 SessionMessage 送进读流;解析失败把异常本身送进流(让上层决定)。
  • 写:把 SessionMessage 序列化成一行 JSON 写 stdout。

有个平台细节:故意不用上下文管理器关 stdin/stdout(不想关进程句柄),并重新包一层 TextIOWrapper(..., encoding="utf-8") 保证跨平台 UTF-8(Windows 尤其麻烦)——server/stdio.py:40 附近注释。

stdio 是单一双工流:一个连接、一条管道、天然有反向通道。适合「host 把 server 当子进程拉起来」。

3. Streamable HTTP:多请求、会话、可恢复

HTTP 比 stdio 复杂得多,因为一个「会话」要横跨多个 HTTP 请求。这部分由 StreamableHTTPSessionManager(server/streamable_http_manager.py:38)统筹。

会话 id 串起多个 HTTP 请求

首个请求(initialize)由 manager 生成一个会话 id(uuid4),通过 MCP_SESSION_ID_HEADER(Mcp-Session-Id)头回给客户端;后续请求带这个头,manager 据此找回同一个连接状态。

有状态 vs 无状态

streamable_http_app(..., stateless_http=...)(server/lowlevel/server.py:708)给两种模式:

模式行为适合
有状态(默认)会话 id 绑一个长活连接,能推服务端通知、支持 SSE 流。需要 server 主动推送、订阅。
无状态(stateless_http=True)每个 HTTP 请求独立处理,无跨请求状态。水平扩展、Serverless、负载均衡后端。

可恢复流(resumption)

可选的 EventStore(server/streamable_http.py EventStore)让 SSE 流可恢复:每个发出的事件存起来并带 id;客户端断线后带 Last-Event-ID 重连,server 重放丢失的事件。CallOptions 里的 resumption_token / on_resumption_token(shared/dispatcher.py)就是客户端侧的对应把手——仅 streamable-HTTP、仅 2025-11-25 及更早版本;注释明确说 SSE 流恢复在下一个协议修订被移除。

本地默认开 DNS 重绑定保护

streamable_http_app 里,若 host 是 127.0.0.1/localhost/::1,自动开启 TransportSecuritySettings 的 DNS 重绑定保护(校验 Host/Origin 头,server/lowlevel/server.py:708 内)。这是防「浏览器里的恶意网页偷偷打你本地 MCP server」。

安全默认:auth 接线

同一个 streamable_http_app 按传入的 auth / token_verifier / auth_server_provider 组装 Bearer 认证中间件和 OAuth 路由(server/lowlevel/server.py:730 往后)。OAuth 相关实现在 server/auth/client/auth/

4. 双纪元收循环:握手 vs 无握手

Server 的 run(server/lowlevel/server.py:679)进 lifespan 后调 serve_dual_era_loop(server/runner.py:539)。为什么「双纪元」?因为协议正在换代:

纪元特征版本
握手纪元(legacy)initializeinitialized 握手,再收请求。≤ 2025-11-25
无握手纪元(modern)每个请求自带 envelope,首条即业务,无独立握手。2026-07-28

serve_dual_era_loop 同时服务两种:第一条能区分纪元的消息一旦成功,就锁定这个连接的纪元(runner.py:539 docstring:「the first era-distinctive message to succeed locks the connection」)。无握手路径的入站入口是 modern_on_request(runner.py:732)。

拥有自己 lifespan 的传输(streamable-HTTP manager)不走 run,而是直接调 serve_loop(runner.py:408)/serve_connection(runner.py:385)。

5. Connection:每连接状态与反向通道

server/connection.py:136 Connection 是「一个客户端连接」的状态袋,永远存在(哪怕无状态部署也不是 None)。它持有:

持有物用途
对端信息 client_paramsinitialize 协商出的客户端能力
protocol_version本连接协商定的协议版本(内核按它校验)
每连接 state + exit_stack用户可挂的连接级 scratch 和清理钩子
standalone Outbound「独立通道」:HTTP 里是 SSE GET 流,stdio 里是那条唯一双工流

两个工厂对应两个纪元(connection.py:189 from_envelope / connection.py:226 for_loop):无握手纪元「一出生就绪、无反向通道」;握手纪元由握手驱动。

反向通道语义很讲究:

  • notify 尽力而为、绝不抛:没独立通道就 debug 日志丢弃——服务端通知本就是建议性的(connection.py 文件头)。
  • send_raw_request 没通道时抛 NoBackChannelError;ping 是唯一被协议允许的独立请求。

6. ServerSession:请求级 vs 连接级的两条外发路

内核 _make_context(runner.py:301)为每个请求造一个 ServerSession(dctx, connection)(server/session.py)。这里有个精细区分:

外发一条 server→client 消息,走哪条路?

· 请求级通道 = dctx(DispatchContext)
HTTP 下会自动带上本入站请求的 request_id,
把响应/进度路由回「发起那个请求的 HTTP 响应流」

· 连接级通道 = connection.outbound(standalone)
走那条独立的 SSE GET 流,与任何单个请求无关

公共 API 上用 related_request_id 选择走哪条(runner.py:301 注释)。这解决了 HTTP 的一个真实难题:server 想给某个正在处理的请求推进度,得让它落到那个请求自己的响应流上,而不是乱发到别处。

7. 本章要点

  • 所有传输归一到「SessionMessage 读写流」,Dispatcher 只认这个。
  • stdio = 单双工流;Streamable HTTP = 会话 id 串多请求 + 可选可恢复流 + 有/无状态两模式;内存直连 = 零传输。
  • 协议正换代:serve_dual_era_loop 同时服务「握手纪元」和「2026 无握手纪元」,首条区分性消息锁定纪元。
  • Connection 持每连接状态;请求级通道(带 request_id)和连接级独立通道分开,靠 related_request_id 选。

→ 下一章:换到客户端视角,以及底下的 mcp-types 线格层和版本协商。