协议版本与握手¶
当前协议版本: 0.2.3
A2C-SMCP 作为三方参与(Agent / Server / Computer)的协议,必须保证同一房间内的成员以兼容的协议版本通信。本规范定义协议版本号语义、兼容性判定规则,以及在 Socket.IO 连接的 HTTP 层完成的版本校验流程。
设计取向¶
A2C-SMCP 借鉴 Socket.IO 自身对 EIO 版本号的处理方式:把协议版本放在最底层、业务代码之前的传输层,确保校验行为与任何业务逻辑解耦。
- 每个运行中的 Server 实例在某一时刻仅支持一个协议版本(由 Server 实现者通过部署决定)
- 客户端(Agent / Computer)在 Socket.IO 连接时通过 URL query 参数 声明协议版本
- Server 在 HTTP 中间件层(Socket.IO 处理之前)完成校验;不兼容时返回 HTTP 400 拒绝连接
- 这带来传递性保证:同一房间内的 Agent 与 Computer 必然兼容,不需要 peer-level 的二次校验
需要多版本共存的部署场景(如灰度发布、分阶段迁移)通过部署拓扑(多 Server 实例、DNS 分流、负载均衡)实现,而非侵入协议。
为什么选 URL query¶
协议版本放在 URL query string 中传输,而不是 auth dict 或自定义 HTTP header。三方案权衡:
| 方案 | HTTP 层可见 | SDK 透传一致性 | 反代拦截 |
|---|---|---|---|
| URL query(采纳) | ✅ | ✅ 所有 socketio 客户端原生支持 | ✅ |
auth dict |
❌ 在 Socket.IO 业务层之后才可见 | ✅ | ⚠️ |
X-A2C-Protocol-Version header |
✅ | ⚠️ 各 SDK 透传 header API 一致性较弱 | ✅ |
核心理由:
- 校验时机硬约束——版本是"能不能说同一种话"(协议层),认证是"你是谁"(业务层)。
auth对象是 Socket.IO 为应用层身份认证提供的入口,其处理发生在用户编写的connecthandler 中;handler 异常、中间件拦截、auth 字段被改写等情况都可能让版本校验失效。协议层校验必须先于业务层——这要求载体在 HTTP 层可见 - SDK 一致性——URL query 是 socketio 客户端最一致的 HTTP 层透传方式(所有 SDK 在
connect()API 都原生支持 query);HTTP header 透传 API 各 SDK 不齐整(python-socketio 用headers=参数;某些 TS SDK 需要extraHeaders配置) a2c_version不是敏感信息——版本号出现在 access log 无安全风险;"URL query 出现在 access log" 的反对意见在版本号语义下不成立- 同时支持两种载体 = 协议复杂度膨胀——Server 必须 fallback 检查、SDK 必须双路径解析、版本演进时调和两种载体的语义会再爆雷
因此协议版本放在 URL query string 中,校验在 HTTP 层完成;身份相关字段(如 role)留在 auth 对象。两者职责清晰分离。
协议依赖与最低 SDK 版本¶
A2C-SMCP 协议在 v0.2 起把元数据下沉到 MCP 标准字段(Resource.annotations.priority / Resource.annotations.audience / Resource.annotations.lastModified / Resource._meta)。SDK 实现方在升级到 v0.2 时,必须确保所用的底层 MCP SDK 已完整支持上述字段——否则 resources/list 序列化/反序列化会丢失 A2C 元数据,导致 Computer 端 Desktop / Finder 组织错乱。
锚定的 MCP 规范版本¶
| 协议版本 | 锚定的 MCP 规范修订版 | 链接 |
|---|---|---|
| v0.2.0 | MCP 2025-06-18 修订版(含 annotations.priority / audience / lastModified / _meta) |
MCP Server Resources |
A2C-SMCP v0.2 的元数据语义以该 MCP 规范修订版为权威依据。MCP SDK 实现若使用更旧的修订版(如 2024-11-05)可能缺失上述字段,导致与 A2C 协议不兼容。
最低 MCP SDK 版本¶
| 协议版本 | Python mcp 包 |
Rust rmcp crate |
必需字段可用性 |
|---|---|---|---|
| v0.2.0 | mcp >= 1.15.0 |
rmcp >= 0.11.0 |
Resource.annotations.priority / audience / lastModified / Resource._meta 均原生可用 |
| v0.1.x | mcp >= 1.0.0 |
— | 仅依赖 Resource.uri / Resource.name / Resource.description;元数据走 URI query |
为什么 Rust 锚定 rmcp >= 0.11.0 而非更新的 1.x
rmcp 0.11.0 是 rust-sdk 当前生产版本,已验证完整支持上述字段。锚定在 0.11.0 而非 1.5.0+ 的考量:避免把"v0.2 协议升级"与"rmcp 1.x 大版本迁移"两个独立工程耦合,降低 rust-sdk 升级风险。rust-sdk 后续可独立评估升级到 rmcp 1.x,作为 v0.2.x SDK PATCH 内的实现优化(符合 SDK PATCH 自由原则)。
SDK 实现方在 pyproject.toml / Cargo.toml 声明依赖时MUST满足上述最低版本。低于该版本时,SDK 的 Resource 类型可能缺失上述字段,导致 v0.2 协议字段被静默丢弃。
如所选 MCP SDK 在 SDK 实现方目标语言生态中尚未支持上述字段,可考虑:(a) 等待 SDK 升级;(b) fork 维护私有补丁;(c) 自定义序列化层兜底——但不建议为这一兼容性裂缝向 A2C 协议要求降级(A2C 严格锚定 MCP 标准是设计取向)。
校验建议¶
SDK 启动时可执行一次轻量自检:
from mcp.types import Resource, Annotations
# 自检:v0.2 协议要求的字段是否可用
assert hasattr(Annotations, "priority"), \
"底层 mcp SDK 版本过低,缺失 Annotations.priority;请升级到 mcp>=1.15.0"
若自检失败,应在启动阶段 fail-fast 而非运行期才暴露。
版本号语义(MAJOR.MINOR.PATCH)¶
A2C-SMCP 协议版本号采用语义化版本(Semantic Versioning),格式为 MAJOR.MINOR.PATCH。每一位的含义与兼容性约束如下:
MAJOR¶
触发条件(任一即 MAJOR bump):
- 移除或重命名已有事件(如将
client:get_tools改名为client:list_tools之类) - 移除已有事件的必需字段
- 改变已有字段的语义(如
priority从 int [0,100] 变为 float [0,1]) - 改变已有字段的类型(如
list[str]改为dict) - 移除或改变已有错误码
- 改变事件的路由语义(如
client:*前缀的含义) - 删除或重命名 URI scheme(如
window://)、或改变 URI 语法
兼容性:
- 不同 MAJOR 完全不兼容
- 示例:
1.5.2与2.0.0无法互通
MINOR¶
触发条件(任一即 MINOR bump):
- 新增事件(新的
client:*/server:*/notify:*) - 新增事件可选字段(
NotRequired[...]) - 新增错误码
- 新增 URI scheme 或新的路径模式
- 新增非强制的 MCP Server 声明要求(如推荐但不强制的
_meta字段)
兼容性(见下方"兼容性判定规则"):
- 同 MAJOR 内,v1.0+ 向后兼容(较新的 MINOR 能接纳较旧 MINOR 的客户端)
- v0.x 阶段 MINOR 必须严格一致
PATCH¶
触发条件:
- Bug 修复(不改变协议语义)
- 文档澄清(不改变行为)
- 错误信息文案优化
- 性能/稳定性改进(对外行为不变)
强约束(wire format 兼容性):
PATCH 升级 MUST 保持 wire format 完全字节兼容——
| 严禁项 |
|---|
| 新增 / 删除 / 重命名任何字段(即使是可选字段) |
| 修改字段类型、值域、必需性 |
| 修改事件名、错误码取值或含义 |
| 修改默认值 |
PATCH 仅允许:实现 bug 修复、错误信息文案打磨、文档勘误、内部实现优化、性能改进。任何会让序列化输出/输入字节序列发生变化的改动MUST走 MINOR bump(v0.x 阶段也即破坏性升级)。
兼容性:
- 同 MAJOR.MINOR 内 PATCH 永远兼容(互发任意 PATCH 报文必定能解析)
- 示例:
0.2.0与0.2.3无需协商即可互通,且双方序列化输出可互相解析
兼容性判定规则¶
v0.x(MAJOR = 0,不稳定阶段)¶
SemVer 规范中 0.x.y 视为公开 API 不稳定。A2C-SMCP 在 v0.x 阶段任何 MINOR 都可能是破坏性变更(例如 0.1 → 0.2 的 URI 重构)。因此:
- MAJOR.MINOR 必须严格匹配(如
0.2.0仅兼容0.2.x) - PATCH 可自由差异
兼容性公式:
v1.0+(MAJOR ≥ 1,稳定阶段)¶
- MAJOR 必须严格匹配
- 在同一 MAJOR 内,Server MINOR 必须 ≥ Client MINOR
- PATCH 可自由差异
兼容性公式:
is_compatible(client, server) =
client.major == server.major
AND client.major >= 1
AND client.minor <= server.minor
说明:
- 较新的 Server 能接纳较旧的 Client(向后兼容)
- 较旧的 Server 拒绝较新的 Client(因为 Server 无法理解 Client 所用的新特性,可能在路由新事件时出错)
- 升级节奏:Server 先于 Client 升级
判定函数参考实现¶
from dataclasses import dataclass
@dataclass(frozen=True)
class ProtocolVersion:
major: int
minor: int
patch: int
@classmethod
def parse(cls, s: str) -> "ProtocolVersion":
parts = s.split(".")
if len(parts) != 3:
raise ValueError(f"Invalid version: {s}")
return cls(int(parts[0]), int(parts[1]), int(parts[2]))
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
def is_compatible(client: ProtocolVersion, server: ProtocolVersion) -> bool:
"""判定 Client 是否能连接到 Server。"""
if client.major != server.major:
return False
if client.major == 0:
# v0.x 严格匹配 MINOR
return client.minor == server.minor
# v1.0+ 向后兼容(Server MINOR >= Client MINOR)
return client.minor <= server.minor
连接握手流程¶
1. Client 在 URL 中声明版本¶
客户端(Agent / Computer)通过 Socket.IO 连接 Server 时,在 URL query string 中携带 a2c_version 参数:
客户端代码示例(Python reference impl):
import socketio
from a2c_smcp import PROTOCOL_VERSION
sio = socketio.AsyncClient()
await sio.connect(
f"wss://server.example.com?a2c_version={PROTOCOL_VERSION}",
socketio_path="/smcp",
auth={"role": "agent"}, # 业务层身份数据
transports=["polling", "websocket"], # MUST:首个握手必须走 HTTP polling,4008 body 可读(见 §5)
)
注:协议规范仅提供 Python reference impl。其他 SDK(Rust / TypeScript / Go 等)的连接示例由各 SDK 自行决定——A2C-SMCP 协议文档不堆砌多语言示例。
polling-first 是 MUST(v0.2.1 由 SHOULD 收紧)
客户端 MUST 令首个握手走 HTTP polling(transports 以 polling 起始或等效配置)。理由:版本校验依赖一个可承载 HTTP 400 + 4008 body 的响应阶段;直连 WebSocket 在 ASGI 下是 websocket scope、无常规 HTTP 响应阶段。SDK MUST NOT 默认 WebSocket-only。若调用方显式强制 WS-only,握手不被豁免——版本拒绝退化为 §5 定义的 WS 形态(服务端仍强制校验,保证传递性对任意客户端无条件成立)。
2. Server 在 HTTP 中间件层校验(实现无关的语义约束)¶
Server MUST 在任何 Socket.IO handler 执行之前,于 HTTP 层校验 URL query 中的 a2c_version。校验时机的强约束:
| 约束 | 说明 |
|---|---|
| MUST 在 Engine.IO 握手到达 Socket.IO 业务代码之前完成 | 确保 connect handler 即使有 bug 也无法绕过版本校验 |
| MUST 同时校验 polling 握手与直连 WebSocket 握手两条建连路径 | 二者 a2c_version 均在 query string 中;ASGI 下分别是 http 与 websocket scope。只校验 http scope 会让 transports=["websocket"] 客户端绕过版本闸门、击穿传递性保证。WS-only 握手的校验与拒绝形态见 §5 |
| 不强制 HTTP 拦截路径 | 拦截路径由实现层/部署决定(见下) |
HTTP 拦截路径由实现决定(非协议常量)¶
下列概念属于不同抽象层,协议层不规定具体 HTTP URL 前缀:
| 概念 | 是什么 | 在哪里 | 协议是否规定 |
|---|---|---|---|
Socket.IO Namespace /smcp |
Socket.IO 协议层命名空间路由标签 | 报文 payload 内 | ✅ 协议固定为 /smcp |
| Engine.IO HTTP URL 路径 | 传输层 HTTP 握手 URL | HTTP URL(如 /socket.io/ 或自定义) |
❌ 由 SDK / 部署决定 |
默认路径在不同 SDK 中不一致:
- Python
python-socketio:默认/socket.io/,可通过socketio_path="..."自定义(部分 python-sdk 实现使用socketio_path="/smcp"让 HTTP 路径与 namespace 同名) - Rust
socketioxide:默认/socket.io/(Engine.IO 标准) - Node.js
socket.io:默认/socket.io/
中间件 path 过滤逻辑MUST与所选 SDK 的实际 HTTP 挂载路径对齐——把"/smcp"硬编码到中间件 path 检查里在 Python 巧合可工作(当 socketio_path="/smcp"),但移植到 Rust 等 SDK 时会失效(Rust HTTP 路径仍是 /socket.io/,不会匹配 /smcp 前缀)。
实现示例:Python(python-socketio + Starlette)¶
# python-socketio + Starlette 示例
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount
from starlette.applications import Starlette
import socketio
SERVER_VERSION = ProtocolVersion(0, 2, 0)
# 此值与 socketio.ASGIApp(socketio_path=...) 保持一致——Python 实现选择,非协议常量
SMCP_HTTP_PATH = "/smcp"
class A2CVersionMiddleware(BaseHTTPMiddleware):
"""在 Socket.IO 处理前校验 a2c_version。"""
async def dispatch(self, request, call_next):
# 仅拦截 Socket.IO HTTP 流量;具体路径与 socketio_path 配置一致
if not request.url.path.startswith(SMCP_HTTP_PATH):
return await call_next(request)
client_ver_str = request.query_params.get("a2c_version")
if not client_ver_str:
return JSONResponse(
{"code": 400, "message": "Missing a2c_version query parameter"},
status_code=400,
)
try:
client_ver = ProtocolVersion.parse(client_ver_str)
except ValueError as e:
return JSONResponse(
{"code": 400, "message": f"Invalid a2c_version: {e}"},
status_code=400,
)
if not is_compatible(client_ver, SERVER_VERSION):
return JSONResponse(
{
"code": 4008,
"message": "Protocol version mismatch",
"server_version": str(SERVER_VERSION),
"client_version": str(client_ver),
"min_supported": str(SERVER_MIN_SUPPORTED),
"max_supported": str(SERVER_MAX_SUPPORTED),
},
status_code=400,
headers={"X-A2C-Error-Code": "4008"},
)
return await call_next(request)
sio = socketio.AsyncServer(async_mode="asgi")
sio_app = socketio.ASGIApp(sio, socketio_path="smcp")
app = Starlette(
middleware=[Middleware(A2CVersionMiddleware)],
routes=[Mount("/", sio_app)],
)
实现示例:Rust(socketioxide + axum / tower)¶
Rust SDK 默认 HTTP 路径是 Engine.IO 标准 /socket.io/(与 Python 默认一致,但与 Python 的"自定义 /smcp"用法不同)。中间件应拦截这个路径:
// rust-sdk (socketioxide + axum) 伪代码示例
use axum::{
http::{Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response, Json},
};
use serde_json::json;
const SERVER_VERSION: (u32, u32, u32) = (0, 2, 0);
// 与 SocketIo::builder() 实际 HTTP 挂载路径保持一致;socketioxide 默认 /socket.io/
const SMCP_HTTP_PATH: &str = "/socket.io/";
async fn a2c_version_middleware<B>(
req: Request<B>,
next: Next<B>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let path = req.uri().path();
if !path.starts_with(SMCP_HTTP_PATH) {
return Ok(next.run(req).await);
}
let query = req.uri().query().unwrap_or("");
let client_ver = parse_a2c_version_from_query(query)
.ok_or((StatusCode::BAD_REQUEST, Json(json!({
"code": 400,
"message": "Missing a2c_version query parameter"
}))))?;
if !is_compatible(client_ver, SERVER_VERSION) {
return Err((StatusCode::BAD_REQUEST, Json(json!({
"code": 4008,
"message": "Protocol version mismatch",
"server_version": format_version(SERVER_VERSION),
"client_version": format_version(client_ver),
}))));
}
Ok(next.run(req).await)
}
关键:无论实现层用哪种语言/框架,中间件必须早于 Socket.IO 业务 handler 执行——这是协议唯一规范的时机约束。版本不兼容的请求根本进不了 Socket.IO handler 层,即使 connect handler 有 bug 也无法绕过校验。
3. Client 处理 HTTP 400 错误¶
4008 是 HTTP body code,不是 WS close code
polling 握手路径上版本校验在 HTTP 层完成、发生在 WS 帧建立之前。4008 是 ErrorPayload.code 字段值,承载于 HTTP 400 响应 body 中——SDK 实现 MUST NOT 把 4008 与 WebSocket close code 混淆。WS-only 直连握手另有拒绝形态(首选 denial-response 仍回字节一致的 4008 body;栈不支持时回退到协议指定的、与 4008 取不同值的 WS close code),见 §5。完整的 4008 解析模式见 error-handling.md §协议版本不匹配(4008)。
Socket.IO 客户端遇到 HTTP 400 响应会触发 connect_error 事件,或在 connect() 调用时抛 ConnectionError。SDK 必须解析响应 body,识别 code: 4008 并抛出专属异常。响应 body 不可访问的罕见 transport 边角情况下,可从 X-A2C-Error-Code 响应 header 中识别错误类型作为冗余诊断辅助。
Python reference impl:
import json
import socketio
from a2c_smcp import PROTOCOL_VERSION
from a2c_smcp.exceptions import ProtocolVersionError
sio = socketio.AsyncClient()
try:
await sio.connect(
f"wss://server.example.com?a2c_version={PROTOCOL_VERSION}",
auth={"role": "agent"},
)
except socketio.exceptions.ConnectionError as e:
raw = str(e)
try:
body = json.loads(raw)
except json.JSONDecodeError:
raise # 非协议级错误,保持原异常
if body.get("code") == 4008:
raise ProtocolVersionError(
client_version=body.get("client_version"),
server_version=body.get("server_version"),
min_supported=body.get("min_supported"),
max_supported=body.get("max_supported"),
message=body.get("message", "Protocol version mismatch"),
)
raise
协议规范仅提供 Python reference impl。其他 SDK 的具体解析模式由各 SDK 自行决定——A2C-SMCP 协议文档不堆砌多语言示例。
4. Client SDK MUST 主动断开连接¶
收到 4008 后 SDK MUST 主动调用底层 Socket.IO 客户端的 disconnect()(或等效操作)显式断开连接,不依赖底层库默认行为——
| SDK | 必需操作 |
|---|---|
| python-sdk | await sio.disconnect() 后再抛 ProtocolVersionError |
| rust-sdk | socket.disconnect().await 后再返回 ProtocolVersionError |
| Node SDK | socket.disconnect() 后再 emit error |
理由:多数 Socket.IO 客户端库(python-socketio、rust rust_socketio、Node socket.io-client)默认开启自动重连。若 SDK 不主动 disconnect,库会在收到 connect_error 后立即发起重连请求,再次触发 4008,进入死循环烧 CPU + 烧 Server 资源。
MUST NOT:
- 不得静默重试(连接已确认不兼容)
- 不得依赖客户端库的"自动停止重连"启发式(不同库语义不一致)
- 不得让用户业务代码处理重连(这是 SDK 层职责)
正确处理流程:
connect_error(payload) → 解析 payload → 识别 code:4008
→ SDK 主动 disconnect()
→ 抛 ProtocolVersionError(server_version, client_version) 到用户业务代码
→ 用户决定如何应对(升级 SDK / 切换 Server 实例 / 报告给运维)
5. WebSocket-only 握手的版本拒绝形态¶
ASGI 把直连 WebSocket(客户端 transports=["websocket"],跳过 polling)呈现为 websocket scope(不是 http)。若服务端中间件只处理 http scope,这类客户端将绕过版本闸门——而 §设计取向 的传递性保证("同房间成员必然兼容,无需 peer 二次校验")依赖闸门对任意客户端无条件成立。本节定义该路径的强制行为,消除该缺口。
R1 客户端 polling-first(MUST)¶
见 §1:conforming 客户端首个握手 MUST 走 HTTP polling,故正常路径根本不会进入本节。本节仅约束非 conforming / 显式 WS-only 客户端,使闸门对它们仍然有效。
R2 服务端 MUST 校验 websocket scope¶
服务端版本中间件 MUST 在 ASGI websocket scope(直连 WS 握手)上执行与 http scope 相同的校验:a2c_version 同样位于 scope["query_string"],判定逻辑复用 is_compatible,不得因 scope 类型不同而跳过。
R3 拒绝形态(二选一,按运行栈能力)¶
| 运行栈 | 服务端 MUST 行为 | 客户端可见 |
|---|---|---|
| 支持 ASGI WebSocket Denial Response(uvicorn ≥0.21 / hypercorn / daphne 等) | 在 WS 打开之前返回 HTTP 400 + 与 polling 路径字节一致的 4008 flat ErrorPayload + X-A2C-Error-Code: 4008 |
与 polling 路径完全一致,4008 仍是单一事实源,SDK 复用同一解析路径 |
| 不支持 denial-response | 以协议指定的 WebSocket close code 4900 关闭握手 |
收到 close code 4900 |
为什么 close code 取 4900 而非 4008
4900 是 RFC 6455 私有段(4000–4999)的 WebSocket close code,不是 ErrorPayload.code,且有意取与 4008 不同的值——遵守"4008 是 HTTP body code,MUST NOT 与 WS close code 混淆"的既有铁律:不是禁止在 WS 路径拒绝,而是要求该路径用一个独立、可区分的标识。4900 选自当前 ErrorPayload.code 未占用区间(避免与 4006–4018 / 4101–4104 数值视觉混淆),仅表意"A2C 版本握手被拒",不携带结构化 body(WS close reason ≤123 字节,仅放简短文案)。
R4 客户端收到 close code 4900¶
- MUST NOT 自动重连(同 §4 死循环防御)
- SHOULD 用 polling-only transport 重连一次,取回权威 HTTP 400 + 4008 body,再抛
ProtocolVersionError(拿到完整server_version/client_version/min_supported/max_supported) - 若重连仍拿不到 body:直接以
4900抛ProtocolVersionError,message注明"WS-only 握手被服务端按版本不匹配拒绝;请改用 polling 重连以获取版本详情",版本字段可为空
净效果:传递性保证对任意客户端无条件成立——conforming 客户端走 polling 得标准 4008;非 conforming WS-only 客户端在支持 denial-response 的栈上同样得字节一致的 4008,否则得可判定的
4900。服务端在任何情形下都不会放行版本不兼容的连接。
错误码¶
4008 Protocol Version Mismatch¶
- 触发:Client 在 Socket.IO 连接请求的 URL query 中声明的
a2c_version与 Server 不兼容 - 作用:阻止连接建立(HTTP 层直接返回 400,未进入 Socket.IO)
- 响应结构(HTTP 400 +
X-A2C-Error-Code: 4008header + body):
{
"code": 4008,
"message": "Protocol version mismatch",
"server_version": "0.2.0",
"client_version": "0.1.5",
"min_supported": "0.2.0",
"max_supported": "0.2.999"
}
完整字段语义、SDK 解析示例(Python reference impl)见 error-handling.md §协议版本不匹配(4008)。Client SDK 必须将此错误转换为明确异常,而不是静默重试。
- 承载层:4008 始终是 HTTP 400 body 的
ErrorPayload.code。WS-only 直连握手在支持 denial-response 的运行栈上仍回字节一致的 4008 HTTP body;不支持时改用下面的4900。
4900 WS Version-Handshake Rejected(WebSocket close code,非 ErrorPayload.code)¶
- 触发:客户端违反 §1 polling-first MUST 强制直连 WebSocket,且服务端运行栈不支持 ASGI WebSocket Denial Response
- 作用:服务端按版本不匹配拒绝 WS 握手时关闭连接所用的 WebSocket close code(RFC 6455 私有段)
- 与 4008 的关系:
4900与4008是不同命名空间的不同值,MUST NOT 互相混淆或互换;4900不携带 body,客户端按 §5 R4 处理(SHOULD 改 polling 重连取回权威 4008) - 完整语义见 §5
版本号来源与发布流程¶
协议仓库(本仓库)¶
- 协议版本定义在
pyproject.toml的version字段(bump-my-version管理) - 每次变更应同步更新本文档顶部的 "当前协议版本" 字段
- CHANGELOG(若有)记录 MAJOR / MINOR 级变更
SDK 仓库¶
SDK 包版本号与协议版本号的关系遵循以下规则:
| 维度 | 约束 |
|---|---|
| PROTOCOL_VERSION 常量 | MUST 暴露——SDK 在代码中显式导出所实现的协议版本字符串。运行时握手以该常量为准(不是 SDK 包版本),握手层与包版本完全解耦 |
| SDK MAJOR.MINOR vs 协议 MAJOR.MINOR | SHOULD 对齐(推荐而非强制)——便于用户从包版本第一眼判断目标协议;SDK 自身可独立 SemVer 演进,不被协议 MINOR bump 强行绑住 |
| SDK PATCH | 自由——留给 SDK 实现层的 bug 修复、性能优化、依赖升级 |
| README / CHANGELOG 兼容矩阵 | SDK 仓库 MUST 维护一张"SDK 包版本 → 实现的协议版本"兼容矩阵;CHANGELOG 在影响协议字段映射时显式标注 |
为什么松绑 MAJOR.MINOR 强约束:
- 握手层(
a2c_versionURL query)传的是 SDK 暴露的PROTOCOL_VERSION常量,不是 SDK 包版本——强绑定不必要 - 强绑定剥夺 SDK 自身 maturity 演进空间(如 rust-sdk 内部要做较大 API 改进,会被协议 MINOR 绑住跳号)
- MCP / LSP / JSON-RPC 等客户端库均无此惯例
- v0.x 早期生态小、迭代快,更应给 SDK 演进留弹性
用户体验:
- 用户安装
pip install a2c-smcp==0.2.5→ SDK 暴露PROTOCOL_VERSION="0.2.0"→ 显式知晓目标协议 - 兼容矩阵在 README 一眼可见,无需翻代码
示例升级路径(首次实现 v0.2 时,SHOULD 但非 MUST):
rust-sdk 0.1.15 (协议 0.1.x) → 0.2.0 (协议 0.2.0) # 推荐 MAJOR.MINOR 对齐协议
rust-sdk 0.2.0 → 0.2.1 (协议仍 0.2.0) # SDK bug fix,PATCH 自由
rust-sdk 0.2.1 → 0.3.0 (协议仍 0.2.0) # SDK 自身 API 大改进,可独立跳 MINOR;
# README 兼容矩阵清楚标注:rust-sdk 0.3.x → 协议 0.2.x
python-sdk 0.1.5 (协议 0.1.x) → 0.2.0 (协议 0.2.0) # 同上推荐
PROTOCOL_VERSION 常量暴露(MUST):
具体的常量暴露位置由各 SDK 自决(python-sdk 在 a2c_smcp/__init__.py / rust-sdk 在 crate 根模块 / TS SDK 在 package main)——协议规范不规定具体路径。
MUST:客户端 SDK 在连接时自动从 PROTOCOL_VERSION 常量读取 a2c_version 拼入 URL,禁止让用户代码手动传入(避免漂移)。
SDK 兼容矩阵示例(README 推荐格式):
| SDK 版本 | 实现的协议版本 |
|---------|----------------|
| 0.2.0 - 0.2.x | 0.2.x |
| 0.3.0 - 0.3.x | 0.2.x (SDK 内部重构,协议未变) |
| 0.4.0 - 0.4.x | 0.3.x |
Server 实现¶
- Server 实现者(python-sdk 的 server 模块 / 用户自建 Server)在启动时必须声明自己的协议版本
- 建议暴露为命令行参数或环境变量,便于运维切换
多版本部署模式¶
需要同时支持多个协议版本客户端时,不要让单个 Server 实例支持多版本,而是:
模式 1:独立实例 + DNS 分流¶
v0.1 客户端 → wss://v01.server.example.com → Server v0.1 实例
v0.2 客户端 → wss://v02.server.example.com → Server v0.2 实例
模式 2:路径分流¶
v0.1 客户端 → wss://server.example.com/smcp/v0.1 → Server v0.1 实例
v0.2 客户端 → wss://server.example.com/smcp/v0.2 → Server v0.2 实例
模式 3:单实例滚动升级(仅 v1.0+)¶
在 v1.0+ 稳定阶段:
- 先升级所有 Server 实例到新 MINOR(兼容老 Client)
- 逐步升级 Client
- 完成后新旧 Client 共存于同一 Server
v0.x 阶段不适用该模式(因为 MINOR 也可能是破坏性),需走模式 1 或 2。
版本协商的非目标¶
本规范不包含以下设计,以控制复杂度:
- ❌ Capabilities / 特性发现:v0.x 阶段协议是一个整体,实现即完整实现;未来若需要特性级粒度(如"实现了 v1.2 但没实现可选特性 X"),届时再引入
- ❌ 自动协商降级(MCP 风格)——Client 和 Server 不会自动选择更低版本通信
- ❌ Server 同时支持多版本 Client——通过部署拓扑解决
- ❌ Peer-to-peer 版本协商——通过 "Server 锚点 + 传递性" 解决
这些在 v0.x 阶段被有意排除以降低实现复杂度。v1.0+ 进入稳定阶段后可根据实际需要再引入。
测试建议¶
SDK 测试应覆盖以下场景:
| 场景 | Client 版本 | Server 版本 | 预期 |
|---|---|---|---|
| 完全匹配 | 0.2.0 | 0.2.0 | ✅ 连接 |
| PATCH 差异 | 0.2.1 | 0.2.0 | ✅ 连接 |
| MINOR 差异(v0.x) | 0.2.0 | 0.3.0 | ❌ HTTP 400 / 4008 |
| MINOR 差异(v0.x 反向) | 0.3.0 | 0.2.0 | ❌ HTTP 400 / 4008 |
| MAJOR 差异 | 1.0.0 | 0.2.0 | ❌ HTTP 400 / 4008 |
| v1.0 向后兼容 | 1.0.0 | 1.2.0 | ✅ 连接 |
| v1.0 Client 更新 | 1.2.0 | 1.0.0 | ❌ HTTP 400 / 4008 |
| 缺失 a2c_version | — | 0.2.0 | ❌ HTTP 400 |
| 非法版本号 | "abc" | 0.2.0 | ❌ HTTP 400 |
| WS-only 兼容(直连 websocket) | 0.2.0 | 0.2.0 | ✅ 连接(websocket scope 同样被校验后放行) |
| WS-only 不兼容(栈支持 denial-response) | 0.3.0 | 0.2.0 | ❌ HTTP 400 / 4008(与 polling 路径字节一致) |
| WS-only 不兼容(栈无 denial-response) | 0.3.0 | 0.2.0 | ❌ WS close 4900;client SHOULD 改 polling 重连取 4008 |
| 中间件仅校验 http、漏 websocket scope | 0.3.0 | 0.2.0(WS-only client) | ❌ 实现缺陷:连接被错误放行(回归测试 MUST 覆盖此反例) |
| 中间件前置性 | connect handler 故意抛异常 | 0.2.0 | 版本校验仍正常工作(业务代码不影响协议校验) |