Files
cad-agent/verify_stdio_bridge.py
T
gjm f33d42afe9 feat(bridge): 新增 stdio↔TCP 桥,让标准 MCP 客户端可直连 CAD 基座
标准 MCP 客户端(Claude Desktop / CodeBuddy / MCP Inspector)只实现 stdio 与
Streamable HTTP,无法直连 cad_mcp_frame 的自定义裸 TCP。新增 mcp_bridge 包在
客户端与基座之间做协议适配,CAD 侧零改动:

- 合成基座缺失的生命周期:initialize(含协议版本协商、capabilities、serverInfo)
  与 ping;notifications/* 按规范不回包。
- tools/list、tools/call 原样转发到 127.0.0.1:8080,result 与顶层 error 忠实透传,
  仅把 id 改写回客户端原始 id。
- 并发:读线程即时应答 initialize/ping,单工作线程串行转发工具调用(保证顺序、
  慢工具不阻塞 ping)。
- stdout 只走 sys.stdout.buffer 并加锁,规避 Windows 的 \n→\r\n 翻译;
  诊断信息一律走 stderr。
- CAD 未启动时桥照常启动,工具调用回 -32603(可重试)。
- 入口:python -m mcp_bridge,支持 --host/--port/--timeout/--verbose/--print-config。

dwg_review/mcp_client.py 增加 raw_rpc()(_rpc 新增 normalize 开关),
返回基座原始响应 dict 供桥透传,避免重复实现 TCP 封装。

验证:
- verify_stdio_bridge.py 24/24 PASS(版本协商、通知不回包、ping、tools/list 与基座
  等价、tools/call、错误透传、-32700、string id、stdout 纯净性、死端口降级)。
- 真实标准客户端 MCP Inspector 冒烟通过:tools/list 拿到 26 个工具,
  tools/list --strict 退出码 0(无 schema 可移植性问题),
  tools/call get_drawing_info 返回标准 content + isError:false。
- 回归:verify_mcp_frame.py 4/4、verify_annotate.py 9/9、dwg_review --list-tools/--check 正常。
2026-10-14 00:44:15 +08:00

264 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""mcp_bridge(stdio ↔ TCP 桥)验证脚本。
以子进程方式拉起桥,走真实的 stdin/stdout 协议,覆盖:
版本协商、通知不回包、ping、tools/list 等价性、tools/call、
错误透传、-32700、string 型 id、stdout 纯净性、CAD 不可达时的降级。
前置:CAD 已启动并加载插件(默认 127.0.0.1:8080)。
用法:python verify_stdio_bridge.py
"""
import json
import os
import queue
import subprocess
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
from dwg_review import config # noqa: E402
from dwg_review.mcp_client import McpClient # noqa: E402
RESULTS = []
def check(name, cond, detail=""):
RESULTS.append((name, bool(cond), detail))
mark = "PASS" if cond else "FAIL"
line = f" [{mark}] {name}"
if detail and not cond:
line += f" <- {detail}"
print(line, flush=True)
return bool(cond)
class Bridge:
"""把 mcp_bridge 当子进程拉起,提供 send/recv。"""
def __init__(self, env_overrides=None):
env = os.environ.copy()
env["PYTHONPATH"] = str(ROOT)
env["PYTHONUNBUFFERED"] = "1"
if env_overrides:
env.update(env_overrides)
self.proc = subprocess.Popen(
[sys.executable, "-m", "mcp_bridge", "--verbose"],
cwd=str(ROOT),
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self._lines = queue.Queue()
self._stderr = []
self._bad_lines = []
self._closed = False
threading.Thread(target=self._pump_stdout, daemon=True).start()
threading.Thread(target=self._pump_stderr, daemon=True).start()
def _pump_stdout(self):
for raw in self.proc.stdout:
text = raw.decode("utf-8", errors="replace").strip()
if not text:
continue
try:
self._lines.put(json.loads(text))
except json.JSONDecodeError:
self._bad_lines.append(text)
def _pump_stderr(self):
for raw in self.proc.stderr:
self._stderr.append(raw.decode("utf-8", errors="replace").rstrip())
def send_raw(self, payload):
self.proc.stdin.write(payload if isinstance(payload, bytes) else payload.encode("utf-8"))
self.proc.stdin.flush()
def send(self, obj):
self.send_raw(json.dumps(obj, ensure_ascii=False) + "\n")
def recv(self, timeout=25.0):
try:
return self._lines.get(timeout=timeout)
except queue.Empty:
return None
def expect_none(self, timeout=1.5):
"""确认在 timeout 内没有新报文。"""
try:
return self._lines.get(timeout=timeout)
except queue.Empty:
return None
def close(self):
if self._closed:
return
self._closed = True
try:
self.proc.stdin.close()
except OSError:
pass
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=5)
@property
def bad_lines(self):
return self._bad_lines
def main():
print("=== mcp_bridge stdio 桥验证 ===\n")
if not McpClient().is_available():
print(f"[X] CAD 基座 {config.HOST}:{config.PORT} 未监听,请先启动已加载插件的 CAD。")
return 1
direct_tools = McpClient().list_tools()
direct_names = {t["name"] for t in direct_tools}
print(f"基座直连:{len(direct_names)} 个工具\n")
bridge = Bridge()
try:
# ---- 1. initialize 版本协商 ----
print("[1] initialize / 版本协商")
bridge.send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05",
"capabilities": {}, "clientInfo": {"name": "verify", "version": "0"}}})
r = bridge.recv()
res = (r or {}).get("result") or {}
check("请求 2024-11-05 → 原样回显", res.get("protocolVersion") == "2024-11-05",
f"got {res.get('protocolVersion')!r}")
check("capabilities.tools 已宣告", "tools" in (res.get("capabilities") or {}),
f"got {res.get('capabilities')!r}")
check("serverInfo.name 正确", (res.get("serverInfo") or {}).get("name") == "cad-mcp-bridge",
f"got {res.get('serverInfo')!r}")
check("id 原样回显", (r or {}).get("id") == 1, f"got {(r or {}).get('id')!r}")
# ---- 2. 未知版本 → 回退默认 ----
print("[2] initialize / 未知版本回退")
bridge.send({"jsonrpc": "2.0", "id": "ver-2", "method": "initialize",
"params": {"protocolVersion": "1999-01-01"}})
r = bridge.recv()
got = ((r or {}).get("result") or {}).get("protocolVersion")
check("未知版本 → DEFAULT_VERSION", got == "2025-06-18", f"got {got!r}")
check("string 型 id 原样回显", (r or {}).get("id") == "ver-2", f"got {(r or {}).get('id')!r}")
# ---- 3. 通知不回包 ----
print("[3] notifications/initialized 不回包")
bridge.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
bridge.send({"jsonrpc": "2.0", "id": 3, "method": "ping"})
r = bridge.recv()
check("下一条即 ping 响应(证明通知未回包)", (r or {}).get("id") == 3,
f"got {(r or {}).get('id')!r}")
# ---- 4. ping ----
print("[4] ping")
check("ping → result == {}", (r or {}).get("result") == {}, f"got {(r or {}).get('result')!r}")
# ---- 5. tools/list 与基座等价 ----
print("[5] tools/list")
bridge.send({"jsonrpc": "2.0", "id": 5, "method": "tools/list"})
r = bridge.recv()
tools = ((r or {}).get("result") or {}).get("tools") or []
names = {t.get("name") for t in tools}
check(f"工具数与基座一致({len(direct_names)})", names == direct_names,
f"桥 {len(names)} vs 基座 {len(direct_names)}")
bad = [t.get("name") for t in tools
if not {"name", "description", "inputSchema"} <= set(t.keys())
or not isinstance(t.get("inputSchema"), dict)
or t["inputSchema"].get("type") != "object"]
check("所有工具具备标准 MCP 字段", not bad, f"异常工具: {bad}")
# ---- 6. tools/call 正常路径(string id) ----
print("[6] tools/call / 正常路径")
bridge.send({"jsonrpc": "2.0", "id": "call-1", "method": "tools/call",
"params": {"name": "get_drawing_info", "arguments": {}}})
r = bridge.recv()
res = (r or {}).get("result") or {}
check("result.content 非空", bool(res.get("content")), f"got {res!r}")
check("isError == false", res.get("isError") is False, f"got {res.get('isError')!r}")
check("string 型 id 经 worker 改写后原样回显", (r or {}).get("id") == "call-1",
f"got {(r or {}).get('id')!r}")
# ---- 7. 未知工具:基座顶层 error 被透传 ----
print("[7] tools/call / 未知工具")
bridge.send({"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "__no_such_tool__", "arguments": {}}})
r = bridge.recv()
code = ((r or {}).get("error") or {}).get("code")
check("透传顶层 error.code == -32601", code == -32601, f"got {r!r}")
check("error 响应的 id 正确", (r or {}).get("id") == 7, f"got {(r or {}).get('id')!r}")
# ---- 8. 未知方法 ----
print("[8] 未知方法")
bridge.send({"jsonrpc": "2.0", "id": 8, "method": "resources/list"})
r = bridge.recv()
check("resources/list → -32601", ((r or {}).get("error") or {}).get("code") == -32601,
f"got {r!r}")
# ---- 9. 非法 JSON ----
print("[9] 非法 JSON")
bridge.send_raw(b"{ this is not json }\n")
r = bridge.recv()
check("非法 JSON → -32700", ((r or {}).get("error") or {}).get("code") == -32700,
f"got {r!r}")
check("Parse error 的 id 为 null", (r or {}).get("id") is None, f"got {(r or {}).get('id')!r}")
# ---- 10. 通知带 id 时不应悬挂 ----
print("[10] 通知误带 id")
bridge.send({"jsonrpc": "2.0", "id": 10, "method": "notifications/cancelled",
"params": {"requestId": 1}})
r = bridge.recv()
check("带 id 的通知 → -32601(不悬挂)", ((r or {}).get("error") or {}).get("code") == -32601,
f"got {r!r}")
# ---- 11. stdout 纯净性 ----
print("[11] stdout 纯净性")
check("stdout 无非 JSON 行", not bridge.bad_lines,
f"异常行: {bridge.bad_lines[:3]}")
finally:
bridge.close()
# ---- 12. CAD 不可达时的降级 ----
print("[12] CAD 不可达(指向死端口)")
dead = Bridge(env_overrides={"CAD_MCP_PORT": "59999"})
try:
dead.send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18"}})
r = dead.recv(timeout=15)
check("initialize 仍成功", ((r or {}).get("result") or {}).get("protocolVersion") == "2025-06-18",
f"got {r!r}")
dead.send({"jsonrpc": "2.0", "id": 2, "method": "ping"})
r = dead.recv(timeout=15)
check("ping 仍成功", (r or {}).get("result") == {}, f"got {r!r}")
dead.send({"jsonrpc": "2.0", "id": 3, "method": "tools/list"})
r = dead.recv(timeout=30)
code = ((r or {}).get("error") or {}).get("code")
check("tools/list → -32603(连接错误)", code == -32603, f"got {r!r}")
check("错误信息含地址", "59999" in json.dumps(r, ensure_ascii=False), f"got {r!r}")
finally:
dead.close()
# ---- 汇总 ----
passed = sum(1 for _, ok_, _ in RESULTS if ok_)
total = len(RESULTS)
print(f"\n=== {passed}/{total} PASS ===")
if passed != total:
print("\n失败项:")
for name, ok_, detail in RESULTS:
if not ok_:
print(f" - {name}: {detail}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())