Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 CodeCursor)都会降级到我们声明的 `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(驱动说明书)

Expand Down
1 change: 1 addition & 0 deletions packages/agent-help/zcode-agent-help
Original file line number Diff line number Diff line change
Expand Up @@ -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+写工具物理禁用: 免授权只读)"},
Expand Down
71 changes: 65 additions & 6 deletions packages/mcp-server/zcode-mcp-server
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"]},
})
Expand Down Expand Up @@ -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 设定长程目标)、"
Expand All @@ -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+写/执行工具物理禁用运行: 全程免人工授权,可自由阅读代码但改不了任何文件,安全。"
Expand Down Expand Up @@ -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 逐条核实 (确认真漏洞/"
Expand Down Expand Up @@ -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),"
Expand Down Expand Up @@ -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,
})

Expand Down Expand Up @@ -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:
Expand Down
171 changes: 171 additions & 0 deletions tests/test_mcp_protocol.py
Original file line number Diff line number Diff line change
@@ -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)
Loading