263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""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())
|