Files
cad-agent/verify_stdio_bridge.py
T
gjm 3bd77d950e fix(frame): 未知工具错误码 -32601 改为 -32602,符合 MCP 规范
MCP 规范《Tools / Error Handling》把「未知工具」归为**协议错误**,
示例即为 {"code": -32602, "message": "Unknown tool: invalid_tool_name"}。
基座原先回 -32601(Method not found),语义偏 JSON-RPC 方法层。

- mcp_configParser.cpp: mcp_Tools::Call 改 -32601 → -32602,
  消息改为 "Unknown tool: <name>"。
- 无需改 mcp_server.cpp 的 HandleToolsCall:其协议错误白名单
  {-32700,-32600,-32601,-32602} 已含 -32602,分类逻辑不变。
- 同步更新 verify_mcp_frame.py 与 verify_stdio_bridge.py 的断言
  (未知「方法」仍是 -32601,语义正确,不动)。

验证(重建基座 + 重启 CAD 后实测):
- 直连 TCP:{"error":{"code":-32602,"message":"Unknown tool: __no_such_tool__"}}
- verify_mcp_frame.py 4/4 PASS;verify_stdio_bridge.py 24/24 PASS
- 回归:verify_annotate.py 9/9、dwg_review --check 正常
- MCP Inspector 冒烟:tools/list 26 个工具、tools/call get_drawing_info 正常
2026-10-14 06:21:46 +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 == -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())