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
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用
| `workspace/removeModelProvider` | 移除模型供应商 | `{workspace, providerId, expectedWorkspaceRevision?}` |
| `workspace/updateProviderRegistry` | 批量更新供应商注册表 | `{workspace, registry, includeWorkspaceState?}` |

**prompt 级**(提示词增强,App 3.3.0+):

| 扩展方法 | 作用 | params |
|----------|------|--------|
| `prompt/enhance` | 同步增强提示词(阻塞返回增强后文本) | `{workspace, prompt, sessionId?, context?}` |
| `prompt/enhance/start` | 异步启动增强 job(阻塞等待结果) | `{workspace, prompt, requestId, sessionId?, context?}` |
| `prompt/enhance/cancel` | 取消进行中的增强 job | `{requestId}` |

> `prompt/enhance/start` 是异步 job 模式:start 立即返回 `{requestId, accepted}`,结果由 ZCode 推送 `prompt/enhance/result` 通知。bridge 把它转成阻塞语义——start 后内部等待结果通知(总超时 120s),收到后一次性返回 `{enhanced}`(completed)/ `{status:"cancelled"}`(cancelled)或 `-32603`(failed/超时)。`prompt/enhance/result` 本身是 server 推送通知,不是 client 可调方法。

> workspace 参数可三种方式传入:`workspace`(完整 dict)、`workspacePath`/`cwd`(路径字符串),或缺省时用 bridge 进程的 `cwd`。
> Provider 管理类方法(upsert/remove/updateRegistry)的 `provider`/`registry` 可能含 `apiKey`,bridge 仅透传、不读取/打印其明文。

Expand Down Expand Up @@ -229,14 +239,28 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se

| ZCode CLI 版本 | 支持情况 | ACP bridge 流式 | 扩展方法 |
|:--------------:|:--------:|:---------------:|:--------:|
| **0.15.0+**(App 3.2.0+) | ✅ 完整 | **真流式**(事件驱动) | ✅ 全部(含 workspace/*、setThoughtLevel 等) |
| **0.15.0+**(App 3.3.0+) | ✅ 完整 | **真流式**(事件驱动) | ✅ 全部(含 workspace/*、setThoughtLevel、**prompt/enhance** 等) |
| **0.15.0**(App 3.2.0 ~ 3.2.5) | ✅ 完整 | **真流式**(事件驱动) | ✅ session/* + workspace/*(无 prompt/enhance) |
| **0.14.8**(App 3.1.4) | ✅ 完整 | **真流式**(事件驱动) | ✅ fork/rewind/goal/compact/steer |
| **0.14.5 ~ 0.14.7** | ✅ 兼容 | 伪流式(自动降级轮询) | ❌(旧版协议未实现) |
| **< 0.14.5** | ⚠️ 未测 | — | — |

> 注:CLI 版本自 0.15.0 起未再升,但 App 持续更新。`prompt/enhance` 是 App 3.3.0 引入的新协议方法——CLI 仍是 0.15.0,仅 App 3.3.0+ 的 app-server 才支持。CLI 版本号相同不代表协议面相同,判断 prompt/* 可用性以 App ≥ 3.3.0 为准。

**降级行为**(自动,无需手动配置):
- ACP bridge 检测到 `session/subscribe` 不可用时,自动切换到轮询 `session/read`(伪流式)
- 扩展方法在旧版 ZCode 上会透传后端错误(`-32603 zcode <method> failed: ...`),不影响标准 ACP 方法(new/prompt/cancel/list/resume)。例如在 0.14.8 上调用 `workspace/*` 或 `session/setThoughtLevel`(0.15.0 新增)会得到 `-32603`,调用方应据此做版本判断。
- 扩展方法在旧版 ZCode 上会透传后端错误(`-32603 zcode <method> failed: ...`),不影响标准 ACP 方法(new/prompt/cancel/list/resume)。例如在 App 3.2.x 上调用 `prompt/enhance`(3.3.0 新增)会得到 `-32603`,调用方应据此做版本判断。

### MCP 规范兼容性说明

本项目的 `zcode-mcp-server` 基于 **stdio 传输 + MCP 协议版本 `2024-11-05`**(最早的稳定规范),纯 Python 标准库手写,零第三方依赖。

**这是有意识的选择,不是落后**:
- **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(仍在迭代)对我们零影响。

**什么时候会评估升级**:等 MCP v2 正式发布 + 官方出 v1→v2 迁移指南后重新评估。最低成本动作是把 `PROTOCOL_VERSION` 从 `2024-11-05` 提到 `2025-03-26`(几乎零代码改动),但这是主动需求驱动,不是被动追赶 Beta。

## Skill(驱动说明书)

Expand Down
215 changes: 215 additions & 0 deletions packages/acp-bridge/zcode-acp-bridge
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ zcode-acp-bridge — 把 headless ZCode 桥接为 ACP (Agent Client Protocol) Ag
cancelBackgroundTask/rewindCascade/setModel/setMode
✅ workspace/* 方法 (0.15.0+): readState/generateText/setDefault{Model,Mode,ThoughtLevel}/
upsertModelProvider/removeModelProvider/updateProviderRegistry
✅ prompt/* 方法 (App 3.3.0+): enhance (同步) / enhance/start+result+cancel (异步 job)

协议: ACP (JSON-RPC 2.0 over stdio) 对外; ZCode Protocol (无 jsonrpc 字段) 对内
依赖: 仅 Python3 标准库
Expand Down Expand Up @@ -178,6 +179,11 @@ class ZCodeBackend:
# 当前事件监听器: {zcode_sid: EventStreamListener}
self._event_listeners = {}
self._listeners_lock = threading.Lock()
# prompt/enhance 异步 job 的 result 通知路由: requestId → Queue
# (3.3.0+): server 完成 enhance job 后推送 prompt/enhance/result notification,
# reader 按 requestId 路由到对应等待中的 start 调用 (仿 _response_queues 模式)
self._enhance_result_queues = {}
self._enhance_lock = threading.Lock()
# reader 线程健康标志: 退出后设 True, request() 快速失败而非慢速假死
self._reader_dead = False
self._reader_stop = False
Expand Down Expand Up @@ -221,6 +227,18 @@ class ZCodeBackend:
else:
# 没人监听: 忽略 (订阅是按需的)
pass
elif method == "prompt/enhance/result":
# (3.3.0+) enhance job 完成 notification: 按 requestId 路由到等待中的 start
params = msg.get("params", {})
rid = params.get("requestId")
with self._enhance_lock:
eq = self._enhance_result_queues.pop(rid, None)
if eq is not None:
eq.put(params)
else:
# 没人等 (start 未注册/已超时退出/已取消): 丢弃避免无界积压
# (Codex review P1-3: _notification_queue 无消费者, 积压会泄漏)
log(f"prompt/enhance/result (requestId={rid}) 无等待者, 丢弃")
elif method is not None:
# 其他 notification (如 state.updated) → 通用队列
self._notification_queue.put(msg)
Expand All @@ -240,6 +258,12 @@ class ZCodeBackend:
for q in self._response_queues.values():
q.put({"error": {"message": "zcode backend reader exited"}})
self._response_queues.clear()
# 同样清理等待中的 enhance result 队列, 防止 start 调用永久挂起
with self._enhance_lock:
for q in self._enhance_result_queues.values():
q.put({"requestId": None, "status": "failed",
"errorMessage": "zcode backend reader exited"})
self._enhance_result_queues.clear()
log("reader 线程退出 (backend 已标记为 dead)")

def register_event_listener(self, zcode_sid, listener):
Expand All @@ -252,6 +276,27 @@ class ZCodeBackend:
with self._listeners_lock:
self._event_listeners.pop(zcode_sid, None)

def register_enhance_listener(self, request_id):
"""注册 prompt/enhance result 监听, 返回用于等待 result 的 Queue。

必须在发 prompt/enhance/start 之前调用 (时序: 先注册, 再 start),
否则 start 返回与 result 推送之间到达的通知会进兜底队列被丢弃。

返回 (queue, error): error 非 None 表示该 requestId 已有 pending listener
(重复提交), 调用方应拒绝第二个 start (Codex review P1-2: 避免覆盖孤立旧等待者)。
"""
q = queue.Queue()
with self._enhance_lock:
if request_id in self._enhance_result_queues:
return None, f"duplicate requestId {request_id} (已有 pending enhance job)"
self._enhance_result_queues[request_id] = q
return q, None

def unregister_enhance_listener(self, request_id):
"""注销 enhance result 监听 (超时/取消时清理)。"""
with self._enhance_lock:
self._enhance_result_queues.pop(request_id, None)

def send(self, msg):
"""发一条消息给 zcode (无 jsonrpc 字段)"""
with self._send_lock:
Expand Down Expand Up @@ -851,6 +896,15 @@ class ACPBridge:
if method == "workspace/updateProviderRegistry":
return self._on_workspace_update_provider_registry(msg_id, req.get("params", {}))

# ----- prompt/* 方法 (App 3.3.0+, 提示词增强) -----
# prompt/enhance/result 是 server 推送通知 (非 client 可调), 故不在此分发
if method == "prompt/enhance":
return self._on_prompt_enhance(msg_id, req.get("params", {}))
if method == "prompt/enhance/start":
return self._on_prompt_enhance_start(msg_id, req.get("params", {}))
if method == "prompt/enhance/cancel":
return self._on_prompt_enhance_cancel(msg_id, req.get("params", {}))

# 未实现的 method
if method and msg_id is not None:
return self._error(msg_id, -32601, f"Method not supported: {method}")
Expand Down Expand Up @@ -1348,6 +1402,167 @@ class ACPBridge:
log(f"workspace/generateText → ok (text {len(result.get('text', ''))} chars)")
return {"jsonrpc": "2.0", "id": msg_id, "result": result}

# ---------- prompt/* 方法 (App 3.3.0+): 提示词增强 ----------
def _on_prompt_enhance(self, msg_id, params):
"""prompt/enhance → 同名: 同步增强提示词 (阻塞返回增强后文本)。

params: {workspace:{workspacePath,workspaceKey}, sessionId?, prompt, context?}
- prompt: 待增强的原始提示词 (必填, 非空)
- sessionId?: 复用已有 session (可选); 不传则 zcode 按 workspaceKey 匹配/临时建
- context?: 历史对话 [{role, content}] (可选, 有助于上下文相关增强)
返回: {enhanced: string}
注意: 底层是模型 generateText 调用, timeout 设 90s (比 generateText 的 60s 宽裕,
因 enhance 还含 prompt 构造 + 清洗)。
"""
self.ensure_backend()
ws, err = self._resolve_workspace(msg_id, params)
if err:
return err
prompt = params.get("prompt")
if not prompt:
return self._error(msg_id, -32602, "prompt/enhance 需要 prompt")
zc_params = {"workspace": ws, "prompt": prompt}
if params.get("sessionId") is not None:
zc_params["sessionId"] = params["sessionId"]
if params.get("context") is not None:
zc_params["context"] = params["context"]
zc_id = self._next_id()
resp, _ = self.backend.request(zc_id, "prompt/enhance", zc_params, timeout=90)
if "error" in resp:
return self._error(msg_id, -32603,
f"zcode enhance failed: {resp['error'].get('message', '')}")
result = resp.get("result", {})
log(f"prompt/enhance → ok (enhanced {len(result.get('enhanced', ''))} chars)")
return {"jsonrpc": "2.0", "id": msg_id, "result": result}

def _on_prompt_enhance_start(self, msg_id, params):
"""prompt/enhance/start → 同名: 异步启动增强 job (阻塞等待 result 通知)。

params: {workspace, sessionId?, prompt, context?, requestId}
返回: {enhanced} (completed) / {status:"cancelled"} (被取消)
失败 (failed) → -32603 带 errorMessage; 超时未收到 result → -32603。

时序关键: 必须先注册 listener 再发 start (参考 session/prompt "先 subscribe 再 send"),
否则 start 返回与 result 推送之间的通知会丢。阻塞期间主循环串行不取下条消息,
故每轮 _drain_enhance_inbox 把 inbox 里的 cancel 转发后端, 使 cancel 在等待期可用。
"""
self.ensure_backend()
ws, err = self._resolve_workspace(msg_id, params)
if err:
return err
prompt = params.get("prompt")
if not prompt:
return self._error(msg_id, -32602, "prompt/enhance/start 需要 prompt")
request_id = params.get("requestId")
if not request_id:
return self._error(msg_id, -32602, "prompt/enhance/start 需要 requestId")
zc_params = {"workspace": ws, "prompt": prompt, "requestId": request_id}
if params.get("sessionId") is not None:
zc_params["sessionId"] = params["sessionId"]
if params.get("context") is not None:
zc_params["context"] = params["context"]
# 时序: 先注册 listener (确保不丢 result 通知), 再发 start
result_q, dup_err = self.backend.register_enhance_listener(request_id)
if dup_err:
return self._error(msg_id, -32602, f"prompt/enhance/start: {dup_err}")
zc_id = self._next_id()
resp, _ = self.backend.request(zc_id, "prompt/enhance/start", zc_params, timeout=15)
if "error" in resp:
self.backend.unregister_enhance_listener(request_id)
return self._error(msg_id, -32603,
f"zcode enhance start failed: {resp['error'].get('message', '')}")
start_result = resp.get("result", {})
if not start_result.get("accepted"):
# zcode 拒绝了 (理论上幂等会返回 accepted:true; 此处兜底)
self.backend.unregister_enhance_listener(request_id)
return self._error(msg_id, -32603,
f"zcode enhance start not accepted: {start_result}")
# 短轮询等待 result 通知。每轮 drain inbox 让中途的 cancel 可用 (主循环串行,
# 不 drain 则 cancel 卡在 inbox)。总超时默认 120s, ZCODE_ENHANCE_TIMEOUT 可覆盖。
try:
enhance_timeout = int(os.environ.get("ZCODE_ENHANCE_TIMEOUT", "120"))
except ValueError:
enhance_timeout = 120
deadline = time.time() + enhance_timeout
result_params = None
while time.time() < deadline:
self._drain_enhance_inbox(request_id)
try:
result_params = result_q.get(timeout=0.5)
break
except queue.Empty:
continue
self.backend.unregister_enhance_listener(request_id)
if result_params is None:
return self._error(msg_id, -32603,
f"prompt/enhance result 等待超时 ({enhance_timeout}s)")
status = result_params.get("status")
if status == "completed":
enhanced = result_params.get("enhanced", "")
log(f"prompt/enhance/start → completed (enhanced {len(enhanced)} chars)")
return {"jsonrpc": "2.0", "id": msg_id,
"result": {"enhanced": enhanced}}
if status == "cancelled":
log("prompt/enhance/start → cancelled")
return {"jsonrpc": "2.0", "id": msg_id,
"result": {"status": "cancelled"}}
# failed (或未知 status)
err_msg = result_params.get("errorMessage", f"enhance job failed (status={status})")
log(f"prompt/enhance/start → failed: {err_msg}")
return self._error(msg_id, -32603, f"zcode enhance failed: {err_msg}")

def _drain_enhance_inbox(self, request_id):
"""阻塞等待 enhance result 期间, 把 inbox 里的 cancel 转发给后端。

主循环串行, _on_prompt_enhance_start 阻塞时 client 发的 cancel 会卡在 inbox。
本方法每轮短轮询调用: 匹配 requestId 的 cancel 转发后端并写响应到 stdout,
其他消息放回 inbox 尾部不吞掉。
"""
while True:
try:
line = self._inbox.get_nowait()
except queue.Empty:
break
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
continue
if (req.get("method") == "prompt/enhance/cancel"
and req.get("params", {}).get("requestId") == request_id):
cancel_id = req.get("id")
cancel_resp = self._on_prompt_enhance_cancel(cancel_id, req.get("params", {}))
if cancel_id is not None:
sys.stdout.write(json.dumps(cancel_resp, ensure_ascii=False) + "\n")
sys.stdout.flush()
continue
# 非 cancel: 放回尾部, 不吞掉其他带外消息 (顺序保持)
self._inbox.put(line)

def _on_prompt_enhance_cancel(self, msg_id, params):
"""prompt/enhance/cancel → 同名: 取消进行中的增强 job。

params: {requestId}
返回: {requestId, cancelled: bool}
- cancelled:true 表示当时有在跑的 job 已被中止 (zcode 随后推送 cancelled 通知)
- cancelled:false 表示 job 不存在或已自然结束
"""
self.ensure_backend()
request_id = params.get("requestId")
if not request_id:
return self._error(msg_id, -32602, "prompt/enhance/cancel 需要 requestId")
zc_id = self._next_id()
resp, _ = self.backend.request(
zc_id, "prompt/enhance/cancel", {"requestId": request_id}, timeout=10)
if "error" in resp:
return self._error(msg_id, -32603,
f"zcode enhance cancel failed: {resp['error'].get('message', '')}")
result = resp.get("result", {})
log(f"prompt/enhance/cancel → cancelled={result.get('cancelled')}")
return {"jsonrpc": "2.0", "id": msg_id, "result": result}

def _on_workspace_set_default_model(self, msg_id, params):
"""workspace/setDefaultModel → 同名: 设置工作区默认模型 (持久化)。

Expand Down
Loading
Loading