cad_mcp_frame 的工具执行发生在 AutoCAD 主线程的窗口消息处理链上
(TcpServerLoop -> PostMessage -> WndProc -> ExecuteTaskInMainThread -> mcp_Tools::Call)。
插件 Execute 在参数类型不符时会抛 nlohmann::type_error(如 args.value("bulge", 0.5)
收到字符串、parsePoint 对 ["a","b"] 取 get<double>())。原实现全程无 try/catch,
异常逃逸出 WndProc 属未定义行为(实测表现为 CAD 崩溃),且会让 DLL 代理的文档写锁
无法释放。LLM 驱动的审图流程会生成这类错误参数,属日常会踩到的路径。
- mcp_ConfigParser.cpp: mcp_Tools::Call 用 try/catch 包住 Execute,
std::exception -> make_error(-32603)(上层映射为 result.isError=true),catch(...) 兜底。
- mcp_server.cpp: HandleToolsCall 入口校验 params/arguments 必须是对象、name 必须是
字符串,否则回 -32602(协议错误),从源头避免 value()/get<>() 抛异常;
ExecuteTaskInMainThread 增加兜底 try/catch,并补 !req.is_object() -> -32600。
- mcp_tool.cpp: mcp_Tool_DllProxy::Execute 的文档写锁改为 RAII 守卫
(ScopedDocumentWriteLock),异常展开时同样解锁,避免写锁泄漏。
验证(关 CAD -> 重建 -> 重启后实测):
- 新增 verify_mcp_fault_injection.py 22/22 PASS:
A 结构性错误(params/arguments/name 类型错、非对象请求、非法 JSON)分别回
-32602/-32600/-32700;B 工具内部类型异常被兜为 result.isError=true;
C 反复注入 10 次后 CAD 存活、写锁未泄漏(有效写操作仍成功)。
- 回归:verify_mcp_frame 4/4、verify_stdio_bridge 24/24、verify_annotate 9/9、
dwg_review --check 正常。
204 lines
8.1 KiB
Python
204 lines
8.1 KiB
Python
"""故障注入验证:畸形 / 类型错误的请求不得让 CAD 崩溃,应回协议错误或 result.isError。
|
||
|
||
背景
|
||
----
|
||
cad_mcp_frame 的工具执行发生在 AutoCAD 主线程的窗口消息处理链上
|
||
(TcpServerLoop -> PostMessage -> WndProc -> ExecuteTaskInMainThread -> mcp_Tools::Call)。
|
||
插件的 Execute 在参数类型不符时会抛 nlohmann::type_error,若异常逃逸出 WndProc
|
||
属未定义行为(实测表现为 CAD 崩溃),且会让 DLL 代理的文档写锁无法释放。
|
||
|
||
本脚本注入各类畸形参数,验证:
|
||
1. 结构性错误(params/arguments/name 类型不对、请求不是对象、非法 JSON)
|
||
-> 回协议错误(-32700 / -32600 / -32602),而不是崩溃;
|
||
2. 工具内部因类型不符抛出的异常 -> 被兜住并映射成 result.isError = true;
|
||
3. 全部注入之后 CAD 仍存活,且文档写锁未泄漏(有效写操作仍能成功)。
|
||
|
||
前置条件:CAD 已启动并加载 cad_mcp_frame.arx,监听 127.0.0.1:8080。
|
||
运行:python verify_mcp_fault_injection.py
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import socket
|
||
import sys
|
||
|
||
from dwg_review import config
|
||
|
||
PASS = 0
|
||
FAIL = 0
|
||
|
||
_MISSING = object()
|
||
|
||
|
||
def check(name, cond, detail=""):
|
||
global PASS, FAIL
|
||
if cond:
|
||
PASS += 1
|
||
print(f"[PASS] {name}")
|
||
else:
|
||
FAIL += 1
|
||
print(f"[FAIL] {name} {detail!r}")
|
||
|
||
|
||
def send_raw(line, expect_reply=True):
|
||
"""向基座发送一帧原始字节,返回解析后的响应 dict(无响应返回 None)。"""
|
||
sock = socket.create_connection((config.HOST, config.PORT), timeout=15)
|
||
try:
|
||
sock.sendall(line)
|
||
if not expect_reply:
|
||
return None
|
||
buf = b""
|
||
while b"\n" not in buf:
|
||
chunk = sock.recv(65536)
|
||
if not chunk:
|
||
break
|
||
buf += chunk
|
||
text = buf.decode("utf-8", errors="replace").strip()
|
||
return json.loads(text) if text else None
|
||
finally:
|
||
sock.close()
|
||
|
||
|
||
def rpc(method, params=_MISSING, rid=1):
|
||
msg = {"jsonrpc": "2.0", "id": rid, "method": method}
|
||
if params is not _MISSING:
|
||
msg["params"] = params
|
||
return send_raw(json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n")
|
||
|
||
|
||
def call_tool(name, arguments, rid=1):
|
||
return rpc("tools/call", {"name": name, "arguments": arguments}, rid=rid)
|
||
|
||
|
||
def err_code(resp):
|
||
return ((resp or {}).get("error") or {}).get("code")
|
||
|
||
|
||
def err_msg(resp):
|
||
return ((resp or {}).get("error") or {}).get("message", "")
|
||
|
||
|
||
def is_tool_error(resp):
|
||
result = (resp or {}).get("result") or {}
|
||
return result.get("isError") is True
|
||
|
||
|
||
def result_text(resp):
|
||
content = ((resp or {}).get("result") or {}).get("content") or []
|
||
for block in content:
|
||
if block.get("type") == "text":
|
||
return block.get("text", "")
|
||
return ""
|
||
|
||
|
||
def main():
|
||
print("=" * 68)
|
||
print(" 故障注入验证:畸形请求不得崩溃 CAD")
|
||
print("=" * 68)
|
||
|
||
# ---- A. 结构性错误:应为顶层协议错误 -------------------------------
|
||
r = rpc("tools/call", "abc")
|
||
check("A1 params 非对象 -> -32602", err_code(r) == -32602, r)
|
||
check("A1 消息说明 params 必须为对象", "params must be an object" in err_msg(r), r)
|
||
|
||
r = rpc("tools/call", {"name": "get_drawing_info", "arguments": "abc"})
|
||
check("A2 arguments 非对象 -> -32602", err_code(r) == -32602, r)
|
||
check("A2 消息说明 arguments 必须为对象", "arguments must be an object" in err_msg(r), r)
|
||
|
||
r = rpc("tools/call", {"name": 123, "arguments": {}})
|
||
check("A3 name 非字符串 -> -32602", err_code(r) == -32602, r)
|
||
check("A3 消息说明 name 必须为字符串", "name must be a string" in err_msg(r), r)
|
||
|
||
r = rpc("tools/call", {"name": "__no_such_tool__", "arguments": {}})
|
||
check("A4 未知工具 -> -32602", err_code(r) == -32602, r)
|
||
check("A4 消息为 Unknown tool", err_msg(r).startswith("Unknown tool"), r)
|
||
|
||
r = rpc("tools/call", {"arguments": {}})
|
||
check("A5 缺 name -> -32602", err_code(r) == -32602, r)
|
||
|
||
r = rpc("tools/call", {"name": "get_drawing_info", "arguments": []})
|
||
check("A6 arguments 为数组 -> -32602", err_code(r) == -32602, r)
|
||
|
||
r = send_raw(b"[1,2,3]\n")
|
||
check("A7 请求不是对象 -> -32600", err_code(r) == -32600, r)
|
||
|
||
r = send_raw(b"{not valid json\n")
|
||
check("A8 非法 JSON -> -32700", err_code(r) == -32700, r)
|
||
|
||
# ---- B. 工具内部类型异常:应被兜住为 result.isError -----------------
|
||
# B1 bulge 传字符串:args.value("bulge", 0.5) 在类型不符时抛 type_error
|
||
r = call_tool("draw_revision_cloud", {
|
||
"shape": "rect", "bulge": "x", "min_x": 0, "min_y": 0, "max_x": 10, "max_y": 10,
|
||
}, rid=20)
|
||
check("B1 draw_revision_cloud bulge='x' -> isError", is_tool_error(r), r)
|
||
check("B1 错误信息含类型提示", "type" in result_text(r).lower() or "execution failed" in result_text(r).lower(), result_text(r))
|
||
|
||
# B2 points 内层元素非数字:parsePoint 只查 is_array/size,取 get<double>() 时抛异常
|
||
r = call_tool("add_leader_annotation", {
|
||
"text": "t", "points": [["a", "b"], ["c", "d"]],
|
||
}, rid=21)
|
||
check("B2 add_leader_annotation points 内层非数字 -> isError", is_tool_error(r), r)
|
||
|
||
# B3 add_line 端点元素非数字
|
||
r = call_tool("add_line", {"start": ["a", "b"], "end": [0, 0]}, rid=22)
|
||
check("B3 add_line start 元素非数字 -> isError", is_tool_error(r), r)
|
||
|
||
# B4 对照:缺参数的优雅处理(本就走 kErrBadParam,非异常路径)
|
||
r = call_tool("add_leader_annotation", {"text": "t", "points": ["a", "b"]}, rid=23)
|
||
check("B4 对照:points 元素非数组 -> isError(优雅处理)", is_tool_error(r), r)
|
||
|
||
# B5 非对象 arguments 直接指向会 value() 的工具(校验层已挡下,应回 -32602)
|
||
r = call_tool("draw_revision_cloud", "abc", rid=24)
|
||
check("B5 会抛异常的工具 + arguments 非对象 -> -32602", err_code(r) == -32602, r)
|
||
|
||
# ---- C. 反复注入后再确认存活与写锁 ---------------------------------
|
||
for i in range(5):
|
||
call_tool("draw_revision_cloud", {"shape": "rect", "bulge": "x",
|
||
"min_x": 0, "min_y": 0, "max_x": 1, "max_y": 1}, rid=100 + i)
|
||
call_tool("add_line", {"start": ["a", "b"], "end": [0, 0]}, rid=200 + i)
|
||
|
||
r = rpc("tools/list", {}, rid=300)
|
||
tools = ((r or {}).get("result") or {}).get("tools") or []
|
||
check("C1 反复注入后 tools/list 仍正常(26 个工具)", len(tools) == 26, f"got {len(tools)}")
|
||
|
||
r = call_tool("get_drawing_info", {}, rid=301)
|
||
check("C2 反复注入后只读工具仍正常(CAD 存活)",
|
||
(r or {}).get("result") is not None and not is_tool_error(r), r)
|
||
|
||
# C3 写锁完整性:若前面某次异常泄漏了文档写锁,写操作会拿不到模型空间而失败
|
||
r = call_tool("add_line", {"start": [0.0, 0.0], "end": [10.0, 10.0]}, rid=302)
|
||
check("C3 反复注入后写操作仍成功(写锁未泄漏)",
|
||
(r or {}).get("result") is not None and not is_tool_error(r), r)
|
||
|
||
# 从返回文本中取出本次创建的实体 handle 并清理
|
||
created_handles = []
|
||
text = result_text(r)
|
||
try:
|
||
payload = json.loads(text)
|
||
created_handles = [item["handle"] for item in payload.get("created", []) if item.get("handle")]
|
||
except (ValueError, AttributeError, TypeError):
|
||
m = (re.search(r'"handle"\s*:\s*"([0-9A-Fa-f]+)"', text)
|
||
or re.search(r"handle[=:\s]+([0-9A-Fa-f]+)", text))
|
||
if m:
|
||
created_handles = [m.group(1)]
|
||
|
||
if created_handles:
|
||
cleanup = call_tool("delete_entities", {"handles": created_handles}, rid=303)
|
||
check(f"C4 清理测试实体 {created_handles}",
|
||
(cleanup or {}).get("result") is not None and not is_tool_error(cleanup), cleanup)
|
||
else:
|
||
print(f"[INFO] 未能从返回文本解析 handle,跳过清理:{text!r}")
|
||
|
||
# ---- 汇总 ----------------------------------------------------------
|
||
print("-" * 68)
|
||
print(f"通过 {PASS} / 失败 {FAIL}")
|
||
if FAIL:
|
||
print("结果:FAIL")
|
||
return 1
|
||
print("结果:全部通过")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|