diff --git a/README.md b/README.md index 3736e37..3f9157f 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se **已对齐 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 跟进新协议后再全面实施。 +**路线图**:~~2026-07-28 新纪元~~ **已完成(2026-08-08,dual-era 上线)**:server 同时服务两个纪元——`initialize` 开场走 legacy(2024-11-05~2025-11-25 协商),带 modern `_meta` 信封的请求走 2026-07-28 无状态新协议(`server/discover` 探针、信封缺失 `-32602`、版本不符 `-32022` 带 supported 列表、result 盖 `resultType`/`serverInfo` 戳、list 结果带 `ttlMs`/`cacheScope`)。已通过官方 Python SDK v2.0.0 client 互操作实测(`session.discover()` 协商出 2026-07-28,tools/list、tools/call 全程新协议);zcode 0.16.1 的 auto 探测也会自动走新协议。MRTR/elicitation/tasks/subscriptions 按调研结论不实现(废弃或用不上)。 ## Skill(驱动说明书) diff --git a/packages/agent-help/zcode-agent-help b/packages/agent-help/zcode-agent-help index f9b1407..b1080cd 100755 --- a/packages/agent-help/zcode-agent-help +++ b/packages/agent-help/zcode-agent-help @@ -518,7 +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 评估中", + "protocol": "dual-era: legacy 协商 2024-11-05..2025-11-25 (回显) + modern 2026-07-28 (server/discover + _meta 信封)", "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 86a2cb6..43d4d88 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -46,10 +46,19 @@ SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-1 _SUPPORTED_PV_SET = set(SUPPORTED_PROTOCOL_VERSIONS) # 协商查表用 (list 保留给可读性/有序) PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0] # 我们支持的最高版本 +# 2026-07-28 新纪元 (无状态化): 每请求 _meta 信封 + server/discover 探针。 +# 双纪元路由在 main(): initialize → legacy; 带 modern 信封 → modern; +# server/discover 任意时刻可答 (它是 modern client 的时代探针)。 +MODERN_PROTOCOL_VERSIONS = ["2026-07-28"] +ALL_SUPPORTED_VERSIONS = MODERN_PROTOCOL_VERSIONS + SUPPORTED_PROTOCOL_VERSIONS +_META_PV = "io.modelcontextprotocol/protocolVersion" +_META_CAPS = "io.modelcontextprotocol/clientCapabilities" +_META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" + # 作为 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"} +SERVER_INFO = {"name": "zcode-mcp-server", "version": "1.4.0"} def log(msg): @@ -1264,6 +1273,90 @@ def make_error(msg_id, code, message, data=None): return {"jsonrpc": "2.0", "id": msg_id, "error": err} +# ============================================================ +# 2026-07-28 新纪元 (modern, 无状态) 请求处理 +# ------------------------------------------------------------ +# 与 legacy 的差异: 无 initialize 握手; 每个请求自带 _meta 信封 +# (protocolVersion + clientCapabilities 必填); 所有 result 带 resultType; +# list 类结果带 ttlMs/cacheScope; server/discover 为时代探针。 +# 纪元路由在 main() 的读循环里 (initialize → legacy, modern 信封 → modern)。 +# ============================================================ +def _modern_result(payload, cacheable=False): + """modern 纪元 result 封装: resultType 必填 + serverInfo 戳 (+缓存提示)。 + + 当前不支持 delta 流式分片, resultType 统一 "complete" (含 isError 的工具 + 执行错误, 那是完整结果不是中间态)。cacheable=True 仅用于静态清单 + (tools/list、discover); 若未来工具集变动态, 须改 cacheScope=private + 并调低 ttlMs。 + """ + result = dict(payload) + meta = dict(result.get("_meta") or {}) # 浅拷贝, 不就地改调用方对象 (P1-3) + meta[_META_SERVER_INFO] = dict(SERVER_INFO) + result["_meta"] = meta + result["resultType"] = "complete" + if cacheable: + result["ttlMs"] = 3_600_000 # 工具集/版本清单静态, 可缓存 1h + result["cacheScope"] = "public" # 无用户特定数据 (动态化时改 private) + return result + + +def _discover_result(): + """server/discover 应答: 支持版本 + 能力 + serverInfo (modern 时代探针)。""" + return _modern_result({ + "supportedVersions": ALL_SUPPORTED_VERSIONS, + "capabilities": {"tools": {"listChanged": False}}, + }, cacheable=True) + + +def handle_modern_request(req): + """处理一条 modern 纪元 (2026-07-28) 请求, 返回响应 dict 或 None。""" + msg_id = req.get("id") + method = req.get("method") + + # notification (无 id) 静默吞掉 (cancelled 等); 信封校验只针对 request + if msg_id is None: + return None + + # _meta 信封校验: 拆分报错, 缺哪个说哪个 (P1-2) + meta = (req.get("params") or {}).get("_meta") or {} + pv = meta.get(_META_PV) + caps = meta.get(_META_CAPS) + if not isinstance(pv, str) or not pv: + return make_error( + msg_id, -32602, + "Invalid params: _meta 缺 io.modelcontextprotocol/protocolVersion (非空字符串)") + if not isinstance(caps, dict): + return make_error( + msg_id, -32602, + "Invalid params: _meta 缺 io.modelcontextprotocol/clientCapabilities (对象)") + if pv not in MODERN_PROTOCOL_VERSIONS: + return make_error( + msg_id, -32022, f"Unsupported protocol version: {pv}", + data={"supported": ALL_SUPPORTED_VERSIONS, "requested": pv}) + + # 注: server/discover 不走这里 — main 循环把它当中立探针先行应答 + # (不定纪元、不校验信封), 两条路径行为一致 (P1-4)。 + if method == "tools/list": + return make_response(msg_id, _modern_result({"tools": TOOLS}, cacheable=True)) + if method == "tools/call": + params = req.get("params", {}) + tool_name = params.get("name") + arguments = params.get("arguments", {}) + if tool_name not in TOOL_HANDLERS: + return make_error(msg_id, -32601, f"未知 tool: {tool_name}") + try: + result = TOOL_HANDLERS[tool_name](arguments) + # isError 也是 complete (工具执行错误走 isError, 非协议错误) + return make_response(msg_id, _modern_result(result)) + except Exception as e: + log(f"tool {tool_name} 异常: {e}") + return make_error(msg_id, -32603, f"tool 执行异常: {e}") + + if method is not None: + return make_error(msg_id, -32601, f"未知 method: {method}") + return None + + def handle_request(req): """处理一条 JSON-RPC 请求,返回响应 dict (或 None 表示无需响应)""" msg_id = req.get("id") @@ -1288,6 +1381,11 @@ def handle_request(req): if method == "notifications/initialized": return None + # 其他 notification (无 id) 一律静默吞掉 — JSON-RPC 规定 notification + # 不应答 (回 id:null 的错误是协议违规, 会污染 client 响应队列) + if msg_id is None: + return None + # --- 工具发现 --- if method == "tools/list": return make_response(msg_id, {"tools": TOOLS}) @@ -1315,10 +1413,12 @@ def handle_request(req): def main(): - log(f"启动 zcode-mcp-server (stdio), protocol {PROTOCOL_VERSION}") + log(f"启动 zcode-mcp-server (stdio), dual-era: legacy " + f"{SUPPORTED_PROTOCOL_VERSIONS[-1]}..{PROTOCOL_VERSION} + modern " + f"{MODERN_PROTOCOL_VERSIONS[0]}") log(f"暴露 tools: {[t['name'] for t in TOOLS]}") - # 按行读取 stdin + # 按行读取 stdin (纪元逐请求判定, 无会话状态) for line in sys.stdin: line = line.strip() if not line: @@ -1332,7 +1432,6 @@ def main(): # 非对象消息拒收: 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( @@ -1340,8 +1439,27 @@ def main(): "Invalid Request: 仅接受单个 JSON-RPC 对象")) continue + # ---- 纪元路由 (dual-era, 逐请求判定, 无会话状态) ---- + # 狗食 review P0-1: 早期的会话级 era 变量会被"legacy 请求偶发带 _meta" + # 毒死整个会话, 且双向覆写会造成 result 形状反复横跳 — 改为逐请求判定: + # server/discover → 中立探针, 直接答 (不定纪元, 不校验信封); + # initialize → legacy 握手 (新纪元没有它); + # 带 io.modelcontextprotocol/protocolVersion 信封 → modern; + # 其余 → legacy (zcode auto 探测回落语义)。 + # 逐请求路由与 2026-07-28 的无状态哲学一致, 天然无互串。 + method = req.get("method") + meta = (req.get("params") or {}).get("_meta") or {} + is_notification = "id" not in req + try: - resp = handle_request(req) + if method == "server/discover": + if is_notification: + continue # P0-2: notification 不应答 (否则发 id:null 伪响应) + resp = make_response(req["id"], _discover_result()) + elif method == "initialize" or _META_PV not in meta: + resp = handle_request(req) # legacy 路径 + else: + resp = handle_modern_request(req) # modern 路径 (含信封校验) if resp is not None: send_message(resp) except Exception as e: diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index 4ba7eb7..5a428c3 100644 --- a/tests/test_mcp_protocol.py +++ b/tests/test_mcp_protocol.py @@ -106,9 +106,10 @@ def test_mr1_bare_value_rejected(self): self.assertEqual(responses[0]["error"]["code"], -32600) def test_mr2_unknown_method_32601(self): - """MR2: 未知 method → -32601 (zcode auto 探测的 legacy 回落信号, 勿改)""" + """MR2: 未知 method → -32601 (zcode auto 探测的 legacy 回落信号, 勿改。 + 注: server/discover 自 1.4.0 起正常应答, 不再属于'未知 method')""" responses = self._run_main([ - json.dumps({"jsonrpc": "2.0", "id": 9, "method": "server/discover"}), + json.dumps({"jsonrpc": "2.0", "id": 9, "method": "bogus/method"}), ]) self.assertEqual(responses[0]["error"]["code"], -32601) @@ -167,5 +168,168 @@ def test_tl3_tool_naming_convention(self): f"{t['name']} 不符合命名规范") +def _modern_req(method, rid=1, params=None, version="2026-07-28"): + """构造一条 modern 纪元请求 (带 _meta 信封)。""" + p = dict(params or {}) + p["_meta"] = { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientCapabilities": {}, + } + return json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": p}) + + +class TestModernEra(_MainLoopCase): + """2026-07-28 新纪元 (dual-era) 行为""" + + def test_de0_discover_probe(self): + """DE0: server/discover 探针 → supportedVersions 含 2026-07-28, 带缓存提示""" + responses = self._run_main([_modern_req("server/discover")]) + result = responses[0]["result"] + self.assertIn("2026-07-28", result["supportedVersions"]) + self.assertIn("2024-11-05", result["supportedVersions"]) + self.assertEqual(result["resultType"], "complete") + self.assertIn("ttlMs", result) + self.assertEqual(result["cacheScope"], "public") + self.assertIn("io.modelcontextprotocol/serverInfo", result["_meta"]) + + def test_de1_modern_tools_list(self): + """DE1: modern tools/list → resultType + ttlMs/cacheScope + serverInfo 戳""" + responses = self._run_main([_modern_req("tools/list")]) + result = responses[0]["result"] + self.assertEqual(len(result["tools"]), 4) + self.assertEqual(result["resultType"], "complete") + self.assertIn("ttlMs", result) + self.assertIn("cacheScope", result) + + def test_de2_modern_tools_call(self): + """DE2: modern tools/call → 结果带 resultType 与 serverInfo 戳""" + responses = self._run_main([_modern_req( + "tools/call", + params={"name": "get_zcode_capabilities", "arguments": {"section": "ecosystem"}})]) + result = responses[0]["result"] + self.assertEqual(result["resultType"], "complete") + self.assertIn("zcode-mcp-server", result["content"][0]["text"]) + self.assertIn("io.modelcontextprotocol/serverInfo", result["_meta"]) + + def test_de3_envelope_less_falls_to_legacy(self): + """DE3: 逐请求路由 (P0-1 修复): 无信封请求永远走 legacy, 不存在 + '会话被切到 modern 后无信封请求被 -32602 毒死'""" + responses = self._run_main([ + _modern_req("tools/list", rid=1), # modern + json.dumps({"jsonrpc": "2.0", "id": 2, # 无信封 → legacy 形状 + "method": "tools/list", "params": {}}), + _modern_req("tools/list", rid=3), # 再回 modern, 无横跳问题 + ]) + self.assertEqual(responses[0]["result"]["resultType"], "complete") + self.assertNotIn("resultType", responses[1]["result"]) + self.assertEqual(responses[2]["result"]["resultType"], "complete") + + def test_de3b_partial_envelope_32602(self): + """DE3b: 信装有 protocolVersion 但缺 clientCapabilities → -32602 精确文案""" + bad = json.dumps({"jsonrpc": "2.0", "id": 5, "method": "tools/list", + "params": {"_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}) + responses = self._run_main([bad]) + self.assertEqual(responses[0]["error"]["code"], -32602) + self.assertIn("clientCapabilities", responses[0]["error"]["message"]) + + def test_de3c_discover_notification_no_response(self): + """DE3c: server/discover 作 notification (无 id) → 不应答 (P0-2)""" + responses = self._run_main([ + json.dumps({"jsonrpc": "2.0", "method": "server/discover"}), + _modern_req("server/discover", rid=1), + ]) + self.assertEqual(len(responses), 1, "notification 形式的 discover 不应有响应") + self.assertIn("supportedVersions", responses[0]["result"]) + + def test_de3d_discover_bogus_version_still_answered(self): + """DE3d: discover 带非法版本信封也照答 (中立探针不校验信封, P1-4)""" + responses = self._run_main([_modern_req("server/discover", version="bogus")]) + self.assertIn("supportedVersions", responses[0]["result"]) + + def test_de9_sdk_wire_fixtures(self): + """DE9: 官方 Python SDK v2.0.0 真实抓包序列的回放契约测试 (P2-4)""" + sdk_discover = json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "server/discover", + "params": {"_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "mcp", "version": "0.1.0"}, + "io.modelcontextprotocol/clientCapabilities": {}}}}) + sdk_tools_list = json.dumps({ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", + "params": {"_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "mcp", "version": "0.1.0"}, + "io.modelcontextprotocol/clientCapabilities": {}}}}) + sdk_tools_call = json.dumps({ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "get_zcode_capabilities", + "arguments": {"section": "ecosystem"}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "mcp", "version": "0.1.0"}, + "io.modelcontextprotocol/clientCapabilities": {}}}}) + responses = self._run_main([sdk_discover, sdk_tools_list, sdk_tools_call]) + # 与 SDK 互操作实测时的期望形状逐点对齐 + r0 = responses[0]["result"] + self.assertIn("2026-07-28", r0["supportedVersions"]) + self.assertEqual(r0["resultType"], "complete") + self.assertEqual(r0["ttlMs"], 3600000) + self.assertEqual(r0["cacheScope"], "public") + self.assertEqual(r0["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "zcode-mcp-server") + self.assertEqual(len(responses[1]["result"]["tools"]), 4) + self.assertEqual(responses[2]["result"]["resultType"], "complete") + self.assertIn("zcode-mcp-server", + responses[2]["result"]["content"][0]["text"]) + + def test_de4_unsupported_version_32022(self): + """DE4: modern 信封里版本不认识 → -32022 带 supported/requested""" + responses = self._run_main([_modern_req("tools/list", version="2099-01-01")]) + err = responses[0]["error"] + self.assertEqual(err["code"], -32022) + self.assertIn("2026-07-28", err["data"]["supported"]) + self.assertEqual(err["data"]["requested"], "2099-01-01") + + def test_de5_probe_then_legacy_initialize(self): + """DE5: 先探 discover 再走 legacy initialize → 两纪元并存 (dual-era 灵活性)""" + responses = self._run_main([ + _modern_req("server/discover"), + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize", + "params": {"protocolVersion": "2025-11-25"}}), + json.dumps({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}), + ]) + self.assertIn("supportedVersions", responses[0]["result"]) + self.assertEqual(responses[1]["result"]["protocolVersion"], "2025-11-25") + # legacy tools/list 不带 resultType (老 client 预期旧形状) + self.assertNotIn("resultType", responses[2]["result"]) + + def test_de6_pure_legacy_flow_unaffected(self): + """DE6: 纯 legacy 流程 (initialize 开场) 完全不受 modern 影响""" + responses = self._run_main([ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18"}}), + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + ]) + self.assertEqual(responses[0]["result"]["protocolVersion"], "2025-06-18") + self.assertNotIn("resultType", responses[1]["result"]) + self.assertNotIn("ttlMs", responses[1]["result"]) + + def test_de7_modern_unknown_method_32601(self): + """DE7: modern 纪元未知 method → -32601""" + responses = self._run_main([_modern_req("resources/list")]) + self.assertEqual(responses[0]["error"]["code"], -32601) + + def test_de8_modern_notification_silent(self): + """DE8: modern notification (cancelled) 静默吞掉不应答""" + responses = self._run_main([ + _modern_req("tools/list"), + json.dumps({"jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": 1, "reason": "user"}}), + ]) + self.assertEqual(len(responses), 1, "notification 不应有响应") + + if __name__ == "__main__": unittest.main(verbosity=2)