diff --git a/README.md b/README.md index b7ed73b..3736e37 100644 --- a/README.md +++ b/README.md @@ -301,14 +301,16 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se ### MCP 规范兼容性说明 -本项目的 `zcode-mcp-server` 基于 **stdio 传输 + MCP 协议版本 `2024-11-05`**(最早的稳定规范),纯 Python 标准库手写,零第三方依赖。 +本项目的 `zcode-mcp-server` 基于 **stdio 传输**,纯 Python 标准库手写,零第三方依赖。协议版本走**逐请求协商**(2026-08-08 起):支持 `2024-11-05` / `2025-03-26` / `2025-06-18` / `2025-11-25` 全段,client 报什么版本我们认什么(在列表内回显,列表外回我们最高的 `2025-11-25`)。 -**这是有意识的选择,不是落后**: -- **stdio 是当前标准传输**。MCP 规范演进(`2025-03-26` → `2025-11-25` → 即将转正的 `2026-07-28` 无状态 RC)的核心红利——协议层无状态化、授权加固、Tasks、Elicitation——全部面向 **HTTP 远程 server / 多租户企业场景**(水平扩展、网关、OAuth)。我们是 **stdio 本地桥**,单连接、生命周期 = client 进程,这些特性的痛点一个都不存在。 -- **官方承诺向后兼容**。MCP 的版本号是**逐请求协商**的,所有 client(ZCode 自身、Claude Code、Cursor)都会降级到我们声明的 `2024-11-05` 正常对话。`2024-11-05` 的 HTTP+SSE 传输虽已弃用,但我们用的 **stdio 不在弃用范围**。 -- **零依赖 = 免疫 SDK breaking changes**。因为我们没用官方 Python/TS SDK(手写 JSON-RPC),SDK v2 beta 的 breaking changes(仍在迭代)对我们零影响。 +**这是有意识的路线选择**: +- **stdio 是当前标准传输**。MCP 规范演进(`2025-03-26` → `2025-11-25` → `2026-07-28` 无状态改版)的核心红利——协议层无状态化、授权加固、Tasks、Elicitation——全部面向 **HTTP 远程 server / 多租户企业场景**。我们是 **stdio 本地桥**,单连接、生命周期 = client 进程,这些特性的痛点一个都不存在。 +- **生态兼容性已实测**(2026-08-08,本机四 client 二进制验证):Claude Code / Kimi Code / Cursor 目前都是 legacy-only(最高认 `2025-11-25`),zcode 0.16.1 是双纪元(auto 探测 `server/discover` 失败会回落 legacy——我们对未知方法回 `-32601`,正好触发规范预期的回落路径)。 +- **零依赖 = 免疫 SDK breaking changes**。官方 Python SDK v2.0.0(2026-07-28 发布)虽自带双纪元,但 13 个直接依赖(含 HTTP 全家桶)对纯 stdio 单文件 server 得不偿失,故继续手搓。 -**什么时候会评估升级**:等 MCP v2 正式发布 + 官方出 v1→v2 迁移指南后重新评估。最低成本动作是把 `PROTOCOL_VERSION` 从 `2024-11-05` 提到 `2025-03-26`(几乎零代码改动),但这是主动需求驱动,不是被动追赶 Beta。 +**已对齐 2025-11-25 的义务**:拒收 JSON-RPC batch(2025-06-18 起规范移除,回 `-32600`)、tools/list 确定性顺序、tool `title` + `annotations`(`readOnlyHint` 等)元数据、输入校验错误走 `isError: true` 而非协议错误。 + +**路线图**:2026-07-28 新纪元(无握手无状态、`server/discover` 必实现、`resultType` 必填)将以 **dual-era** 形态评估接入——保留 initialize 旧路径服务存量 client,新增新协议路径(zcode 0.16.1 已是双纪元 client,可立即受益)。等 Claude Code / Kimi / Cursor 跟进新协议后再全面实施。 ## Skill(驱动说明书) diff --git a/packages/agent-help/zcode-agent-help b/packages/agent-help/zcode-agent-help index 8e8a22d..f9b1407 100755 --- a/packages/agent-help/zcode-agent-help +++ b/packages/agent-help/zcode-agent-help @@ -518,6 +518,7 @@ ECOSYSTEM = { "role": "把 zcode 能力暴露为标准 MCP server", "maturity": "stable", "transport": "MCP over stdio (JSON-RPC 2.0)", + "protocol": "legacy 协商 2024-11-05..2025-11-25 (回显 client 版本); 2026-07-28 新纪元 dual-era 评估中", "tools_exposed": [ {"name": "get_zcode_capabilities", "description": "返回 zcode 能力清单 (调 agent-help)"}, {"name": "zcode_review", "description": "调 zcode 审查代码 (yolo+写工具物理禁用: 免授权只读)"}, diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 3228a58..86a2cb6 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -37,9 +37,19 @@ _DEFAULT_AGENT_HELP = str(_REPO_AGENT_HELP) if _REPO_AGENT_HELP.exists() else st AGENT_HELP_BIN = os.environ.get("ZCODE_AGENT_HELP_BIN", _DEFAULT_AGENT_HELP) ZCODE_BIN = os.environ.get("ZCODE_BIN", "zcode") -# MCP 协议版本 -PROTOCOL_VERSION = "2024-11-05" -SERVER_INFO = {"name": "zcode-mcp-server", "version": "1.2.0"} +# MCP 协议版本 (2026-08-08: legacy 线从 2024-11-05 对齐到 2025-11-25)。 +# 协商规则 (legacy lifecycle): client 报它支持的最新版, 在我们列表里就回显, +# 否则回我们的最新版; client 不认会自行断开。四大 client (Claude Code 2.1.x / +# Kimi Code 0.34 / Cursor / zcode 0.16.1) 实测均兼容 2024-11-05..2025-11-25 全段。 +# 2026-07-28 新纪元 (无状态/server-discover) 见 README 路线图, 另行评估。 +SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] +_SUPPORTED_PV_SET = set(SUPPORTED_PROTOCOL_VERSIONS) # 协商查表用 (list 保留给可读性/有序) +PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0] # 我们支持的最高版本 + +# 作为 MCP client 调 mimosa 时用的版本: mimosa 1.0.3 实测讲 2024-11-05, +# 钉死该值 (它不回更高版; 我们不校验它的应答版本)。 +MIMOSA_CLIENT_PROTOCOL_VERSION = "2024-11-05" +SERVER_INFO = {"name": "zcode-mcp-server", "version": "1.3.0"} def log(msg): @@ -346,7 +356,7 @@ class MimosaMcpClient: def initialize(self): self._request("initialize", { - "protocolVersion": PROTOCOL_VERSION, + "protocolVersion": MIMOSA_CLIENT_PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": SERVER_INFO["name"], "version": SERVER_INFO["version"]}, }) @@ -629,6 +639,14 @@ def _validate_rev(repo, rev, what): TOOLS = [ { "name": "get_zcode_capabilities", + "title": "ZCode 能力清单", + "annotations": { + "title": "ZCode 能力清单", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, "description": ( "返回 headless 模式 ZCode (智谱 GLM 驱动的 coding agent CLI) 的完整能力清单。" "包含: 如何调用 (命令+环境变量)、headless 模式 (--prompt 单次; --target 或 prompt 内 /goal 设定长程目标)、" @@ -651,6 +669,14 @@ TOOLS = [ }, { "name": "zcode_review", + "title": "ZCode 代码审查", + "annotations": { + "title": "ZCode 代码审查", + "readOnlyHint": True, # 写/执行工具物理禁用, 改不了任何文件 + "destructiveHint": False, + "idempotentHint": False, # LLM 审查非幂等 + "openWorldHint": True, # 调外部 LLM API + }, "description": ( "调用 headless ZCode (GLM) 审查代码,返回审查结论。" "以 yolo+写/执行工具物理禁用运行: 全程免人工授权,可自由阅读代码但改不了任何文件,安全。" @@ -685,6 +711,14 @@ TOOLS = [ }, { "name": "zcode_security_review", + "title": "ZCode 安全专项审查", + "annotations": { + "title": "ZCode 安全专项审查", + "readOnlyHint": True, # 对被审项目只读 (mimosa 扫描历史写在 ~/.mimosa) + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": True, # 复核阶段调外部 LLM API: 数据外发且结果不可复现 + }, "description": ( "安全专项审查,两阶段: ① mimosa 确定性规则引擎扫描 (零 LLM 流量," "覆盖面兜底) → ② headless ZCode 拿着 findings 逐条核实 (确认真漏洞/" @@ -726,6 +760,14 @@ TOOLS = [ }, { "name": "zcode_pr_review", + "title": "ZCode PR 审查", + "annotations": { + "title": "ZCode PR 审查", + "readOnlyHint": True, # git/mimosa/zcode 全部只读路径 + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": True, # 复核阶段调外部 LLM API: 数据外发且结果不可复现 + }, "description": ( "PR 审查模式: 自动算 git diff (base...HEAD, merge-base 语义) 得到改动清单," "mimosa 全仓扫描 + 业务逻辑复核优先聚焦改动文件 (focus_files)," @@ -1229,9 +1271,16 @@ def handle_request(req): # --- 协议握手 --- if method == "initialize": + # 版本协商: client 请求的在我们支持列表里就原样回显, 否则回我们的最高版 + # (legacy lifecycle 规则; client 不认会自行断开) + params = req.get("params") or {} + requested = params.get("protocolVersion") + negotiated = requested if (isinstance(requested, str) + and requested in _SUPPORTED_PV_SET) \ + else PROTOCOL_VERSION return make_response(msg_id, { - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {"tools": {}}, + "protocolVersion": negotiated, + "capabilities": {"tools": {"listChanged": False}}, # 工具集静态, 明确声明 "serverInfo": SERVER_INFO, }) @@ -1281,6 +1330,16 @@ def main(): send_message(make_error(None, -32700, "Parse error")) continue + # 非对象消息拒收: batch 数组 (2025-03-26 加入、2025-06-18 移除, + # ≥2025-06-18 的 server 必须拒绝) 和其他非 dict 形态, 统一 -32600。 + # 注意: 对未知 method 回 -32601 是 zcode auto 探测依赖的回落信号, 别改。 + if not isinstance(req, dict): + log("收到非 JSON-RPC 对象消息 (batch?), 拒绝") + send_message(make_error( + None, -32600, + "Invalid Request: 仅接受单个 JSON-RPC 对象")) + continue + try: resp = handle_request(req) if resp is not None: diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py new file mode 100644 index 0000000..4ba7eb7 --- /dev/null +++ b/tests/test_mcp_protocol.py @@ -0,0 +1,171 @@ +""" +test_mcp_protocol.py — MCP 协议层单测 (2025-11-25 对齐, 2026-08-08) + +覆盖: + - initialize 版本协商: 支持列表内回显 / 列表外回最高版 / 无 params 回最高版 + - 主循环拒收非对象消息: batch 数组 (-32600) / 裸值 (-32600) + - 未知 method 回 -32601 (zcode auto 探测依赖的 legacy 回落信号, 勿改) + - tools/list: 四个 tool 带 title + annotations, 顺序稳定 + +运行: python3 tests/test_mcp_protocol.py +依赖: 仅 Python 标准库 + zcode-mcp-server 模块 +""" + +import io +import json +import os +import sys +import types +import unittest + +MCP_PATH = os.path.join( + os.path.dirname(__file__), "..", "packages", "mcp-server", "zcode-mcp-server" +) + + +def _load_mcp_module(): + mod = types.ModuleType("zcode_mcp_server") + mod.__file__ = MCP_PATH + with open(MCP_PATH) as f: + code = f.read() + code_no_main = code.split('if __name__ == "__main__":')[0] + exec(code_no_main, mod.__dict__) + return mod + + +class TestVersionNegotiation(unittest.TestCase): + """initialize 版本协商 (legacy lifecycle 规则)""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + def _initialize(self, requested=None): + params = {} if requested is None else {"protocolVersion": requested} + return self.mod.handle_request( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params}) + + def test_vn0_supported_version_echoed(self): + """VN0: 请求支持列表内的版本 → 原样回显""" + for v in ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"): + resp = self._initialize(v) + self.assertEqual(resp["result"]["protocolVersion"], v, + f"请求 {v} 应回显") + + def test_vn1_unsupported_falls_back_to_latest(self): + """VN1: 请求不认识的版本 → 回我们的最高版""" + resp = self._initialize("1999-01-01") + self.assertEqual(resp["result"]["protocolVersion"], "2025-11-25") + + def test_vn2_no_params_returns_latest(self): + """VN2: 无 params → 回最高版""" + resp = self._initialize(None) + self.assertEqual(resp["result"]["protocolVersion"], "2025-11-25") + + def test_vn3_future_version_falls_back(self): + """VN3: 请求比我们还新的版本 → 回我们的最高版 (client 自行决定断开)""" + resp = self._initialize("2026-07-28") + self.assertEqual(resp["result"]["protocolVersion"], "2025-11-25") + + +class _MainLoopCase(unittest.TestCase): + """驱动 main() 主循环: 喂 stdin 行, 收 stdout 响应。""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + def _run_main(self, lines): + mod = self.mod + saved_stdin, saved_stdout = sys.stdin, sys.stdout + sys.stdin = io.StringIO("".join(item + "\n" for item in lines)) + out = io.StringIO() + sys.stdout = out + try: + mod.main() + finally: + sys.stdin, sys.stdout = saved_stdin, saved_stdout + return [json.loads(x) for x in out.getvalue().splitlines() if x.strip()] + + +class TestMainLoopRejection(_MainLoopCase): + """非对象消息的拒收行为""" + + def test_mr0_batch_array_rejected(self): + """MR0: JSON-RPC batch 数组 → -32600 (2025-06-18 起 server 必须拒绝)""" + responses = self._run_main([ + json.dumps([{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}]), + ]) + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0]["error"]["code"], -32600) + + def test_mr1_bare_value_rejected(self): + """MR1: 裸值 (非 dict) → -32600""" + responses = self._run_main(["42"]) + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0]["error"]["code"], -32600) + + def test_mr2_unknown_method_32601(self): + """MR2: 未知 method → -32601 (zcode auto 探测的 legacy 回落信号, 勿改)""" + responses = self._run_main([ + json.dumps({"jsonrpc": "2.0", "id": 9, "method": "server/discover"}), + ]) + self.assertEqual(responses[0]["error"]["code"], -32601) + + def test_mr3_malformed_json_32700(self): + """MR3: 非法 JSON → -32700 Parse error""" + responses = self._run_main(["{not json"]) + self.assertEqual(responses[0]["error"]["code"], -32700) + + def test_mr4_normal_flow_unaffected(self): + """MR4: 正常 initialize + tools/list 流程不受拒收逻辑影响""" + responses = self._run_main([ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-11-25"}}), + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + ]) + self.assertEqual(len(responses), 2, "notification 不应有响应") + self.assertEqual(responses[0]["result"]["protocolVersion"], "2025-11-25") + self.assertEqual(len(responses[1]["result"]["tools"]), 4) + + +class TestToolsListShape(_MainLoopCase): + """tools/list 的元数据与确定性""" + + def test_tl0_all_tools_have_title_and_annotations(self): + """TL0: 每个 tool 都有 title + annotations 五要素 (2025-03-26/06-18 增量)""" + for t in self.mod.TOOLS: + self.assertIn("title", t, f"{t['name']} 缺 title") + ann = t.get("annotations", {}) + for key in ("title", "readOnlyHint", "destructiveHint", + "idempotentHint", "openWorldHint"): + self.assertIn(key, ann, f"{t['name']} annotations 缺 {key}") + + def test_tl1_review_tools_read_only(self): + """TL1: 三个 review tool 必须标 readOnlyHint (只读是卖点, 标错即事故)""" + for t in self.mod.TOOLS: + if t["name"].startswith("zcode_"): + self.assertTrue(t["annotations"]["readOnlyHint"], + f"{t['name']} readOnlyHint 应为 True") + self.assertFalse(t["annotations"]["destructiveHint"]) + + def test_tl2_order_deterministic(self): + """TL2: 两次 tools/list 响应顺序一致 (走完整主循环路径, 2025-11-25 SHOULD 级)""" + req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + r1 = self._run_main([req]) + r2 = self._run_main([req]) + n1 = [t["name"] for t in r1[0]["result"]["tools"]] + n2 = [t["name"] for t in r2[0]["result"]["tools"]] + self.assertEqual(n1, n2) + self.assertEqual(len(n1), len(set(n1)), "tool 名不得重复") + + def test_tl3_tool_naming_convention(self): + """TL3: tool 名符合 2025-11-25 命名规范 (1-128 字符, A-Za-z0-9_-.)""" + for t in self.mod.TOOLS: + self.assertRegex(t["name"], r"^[A-Za-z0-9_\-.]{1,128}$", + f"{t['name']} 不符合命名规范") + + +if __name__ == "__main__": + unittest.main(verbosity=2)