"""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 == -32602(未知工具)", code == -32602, 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())