跳转至

协议版本与握手

当前协议版本: 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 一致性较弱

核心理由

  1. 校验时机硬约束——版本是"能不能说同一种话"(协议层),认证是"你是谁"(业务层)。auth 对象是 Socket.IO 为应用层身份认证提供的入口,其处理发生在用户编写的 connect handler 中;handler 异常、中间件拦截、auth 字段被改写等情况都可能让版本校验失效。协议层校验必须先于业务层——这要求载体在 HTTP 层可见
  2. SDK 一致性——URL query 是 socketio 客户端最一致的 HTTP 层透传方式(所有 SDK 在 connect() API 都原生支持 query);HTTP header 透传 API 各 SDK 不齐整(python-socketio 用 headers= 参数;某些 TS SDK 需要 extraHeaders 配置)
  3. a2c_version 不是敏感信息——版本号出现在 access log 无安全风险;"URL query 出现在 access log" 的反对意见在版本号语义下不成立
  4. 同时支持两种载体 = 协议复杂度膨胀——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.22.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.00.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 可自由差异

兼容性公式:

is_compatible(client, server) =
    client.major == server.major
    AND client.minor == server.minor

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 参数:

wss://server.example.com/smcp/?a2c_version=0.2.0

客户端代码示例(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(transportspolling 起始或等效配置)。理由:版本校验依赖一个可承载 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 下分别是 httpwebsocket 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 死循环防御)
  • SHOULDpolling-only transport 重连一次,取回权威 HTTP 400 + 4008 body,再抛 ProtocolVersionError(拿到完整 server_version/client_version/min_supported/max_supported
  • 若重连仍拿不到 body:直接以 4900ProtocolVersionErrormessage 注明"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: 4008 header + 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 bodyErrorPayload.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 的关系49004008 是不同命名空间的不同值MUST NOT 互相混淆或互换;4900 不携带 body,客户端按 §5 R4 处理(SHOULD 改 polling 重连取回权威 4008)
  • 完整语义见 §5

版本号来源与发布流程

协议仓库(本仓库)

  • 协议版本定义在 pyproject.tomlversion 字段(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_version URL 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):

# python-sdk reference impl
from a2c_smcp import PROTOCOL_VERSION  # "0.2.0"

具体的常量暴露位置由各 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+ 稳定阶段:

  1. 先升级所有 Server 实例到新 MINOR(兼容老 Client)
  2. 逐步升级 Client
  3. 完成后新旧 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 版本校验仍正常工作(业务代码不影响协议校验)

参考