fix(frame): 工具执行加异常保护,畸形参数不再崩溃 CAD / 泄漏写锁

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 正常。
This commit is contained in:
gjm committed 2026-10-14 15:40:20 +08:00
1 parent 5d83d3fa76
commit 48229c28b0
4 files changed
+317 -32

No files matched your search

+18 -1
View File
@@ -10,6 +10,7 @@
#include <map>
#include <algorithm>
#include <cwctype>
#include <exception>
namespace
{
@@ -252,7 +253,23 @@ nlohmann::json mcp_Tools::Call(const std::string &name, const nlohmann::json &ar
auto it = m_tools.find(name);
if (it != m_tools.end())
{
return it->second->Execute(args);
// 工具由插件 DLL 实现,可能因参数类型不符等原因抛异常(如 nlohmann::type_error)。
// 本函数运行在 AutoCAD 主线程的窗口消息处理链上,异常若逃逸出 WndProc 属未定义行为
// (实际表现为 CAD 崩溃),且会让 DLL 代理的文档写锁无法释放。故在此统一兜住,
// 转换为「工具执行错误」(-32603),由上层映射成 result.isError = true。
try
{
return it->second->Execute(args);
}
catch (const std::exception &e)
{
return mcp_Tool_Utility::make_error(-32603,
L"Tool execution failed: " + mcp_utils::utf8ToWString(std::string(e.what())));
}
catch (...)
{
return mcp_Tool_Utility::make_error(-32603, L"Tool execution failed: unknown exception");
}
}
// 找不到工具时,返回标准的 JSON-RPC 协议错误。
+59 -11
View File
@@ -4,6 +4,7 @@
#include <ws2tcpip.h>
#include <thread>
#include <atomic>
#include <exception>
#include <nlohmann/json.hpp>
#include "mcp_ConfigParser.h"
#include "mcp_utils.h"
@@ -62,9 +63,25 @@ namespace
resp["jsonrpc"] = "2.0";
resp["id"] = msgId;
// 参数结构校验:本函数运行在 AutoCAD 主线程的 WndProc 调用链上,nlohmann 的
// value()/get<>() 在类型不符时会抛异常,异常逃逸出 WndProc 会导致 CAD 崩溃。
// 故在入口处把结构性问题统一挡下,按 MCP 规范回 -32602(协议错误)。
auto invalidParams = [&resp](const char *message) {
resp["error"] = { { "code", -32602 }, { "message", message } };
return resp.dump();
};
if (!params.is_object())
return invalidParams("Invalid params: params must be an object");
if (params.contains("name") && !params["name"].is_string())
return invalidParams("Invalid params: name must be a string");
std::string toolName = params.value("name", "");
nlohmann::json args = params.value("arguments", nlohmann::json::object());
if (!args.is_object())
return invalidParams("Invalid params: arguments must be an object");
// 让工具集去执行,返回 payload 可能是 {"result": ...} 也可能是 {"error": ...}
nlohmann::json payload = g_mcpTools.Call(toolName, args);
@@ -115,29 +132,60 @@ namespace
// 非法 JSON 报文
responseStr = R"({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null})";
}
else if (!req.is_object())
{
// 合法 JSON 但不是对象(数组/标量):非法请求
nlohmann::json err;
err["jsonrpc"] = "2.0";
err["id"] = nullptr;
err["error"] = { {"code", -32600}, {"message", "Invalid Request"} };
responseStr = err.dump();
}
else
{
std::string method = req.value("method", "");
// id 允许 string 或 number;缺失 id 表示这是一条"通知",按规范不应产生任何响应
bool isNotification = !req.contains("id");
nlohmann::json idVal = isNotification ? nlohmann::json(nullptr) : req["id"];
if (method == "tools/list")
// 兜底:本函数由 WndProc 直接调用,任何异常都不允许逃逸(逃逸属未定义行为,
// 实际表现为 CAD 崩溃)。工具自身的异常已在 mcp_Tools::Call 内转为 isError,
// 这里再兜住参数解析等更外层的问题。
try
{
responseStr = HandleToolsList(idVal);
std::string method = req.value("method", "");
if (method == "tools/list")
{
responseStr = HandleToolsList(idVal);
}
else if (method == "tools/call")
{
responseStr = HandleToolsCall(idVal, req.value("params", nlohmann::json::object()));
}
else
{
// 找不到方法
nlohmann::json err;
err["jsonrpc"] = "2.0";
err["id"] = idVal;
err["error"] = { {"code", -32601}, {"message", "Method not found"} };
responseStr = err.dump();
}
}
else if (method == "tools/call")
catch (const std::exception &e)
{
responseStr = HandleToolsCall(idVal, req.value("params", nlohmann::json::object()));
}
else
{
// 找不到方法
nlohmann::json err;
err["jsonrpc"] = "2.0";
err["id"] = idVal;
err["error"] = { {"code", -32601}, {"message", "Method not found"} };
err["error"] = { {"code", -32603}, {"message", std::string("Internal error: ") + e.what()} };
responseStr = err.dump();
}
catch (...)
{
nlohmann::json err;
err["jsonrpc"] = "2.0";
err["id"] = idVal;
err["error"] = { {"code", -32603}, {"message", "Internal error: unknown exception"} };
responseStr = err.dump();
}
+37 -20
View File
@@ -37,6 +37,32 @@ namespace
outScript = mcp_utils::utf8ToWString(tpl);
return true;
}
// 文档写锁的 RAII 守卫:插件 Execute 可能抛异常,异常展开时也必须解锁,
// 否则文档会停留在 kWrite 锁定状态,导致后续所有写操作失败。
class ScopedDocumentWriteLock
{
public:
ScopedDocumentWriteLock()
{
m_pDoc = (acDocManager != nullptr) ? acDocManager->curDocument() : nullptr;
if (m_pDoc != nullptr && acDocManager->lockDocument(m_pDoc, AcAp::kWrite) == Acad::eOk)
m_locked = true;
}
~ScopedDocumentWriteLock()
{
if (m_locked && m_pDoc != nullptr)
acDocManager->unlockDocument(m_pDoc);
}
ScopedDocumentWriteLock(const ScopedDocumentWriteLock &) = delete;
ScopedDocumentWriteLock &operator=(const ScopedDocumentWriteLock &) = delete;
private:
AcApDocument *m_pDoc = nullptr;
bool m_locked = false;
};
}
#pragma region LispInline
@@ -101,28 +127,19 @@ mcp_Tool_DllProxy::~mcp_Tool_DllProxy()
nlohmann::json mcp_Tool_DllProxy::Execute(const nlohmann::json &args)
{
if (m_pRealTool)
{
// DLL 工具通常会写数据库(创建/修改实体、图层等)。基座运行在 AutoCAD 主线程的
// 窗口消息处理中,此时处于「应用上下文」,直接对数据库做 kForWrite 打开会返回
// eLockViolation(表现为 openWriteSpace 拿不到模型空间)。因此统一在此加文档写锁,
// 工具执行完立即解锁。只读工具被锁也无副作用。
AcApDocument* pDoc = acDocManager ? acDocManager->curDocument() : nullptr;
const bool locked = (pDoc != nullptr &&
acDocManager->lockDocument(pDoc, AcAp::kWrite) == Acad::eOk);
if (!m_pRealTool)
return mcp_Tool_Utility::make_error(-32603, L"Underlying DLL tool instance is invalid");
std::string argStr = args.dump();
//return m_pRealTool->Execute(args); // 完美转发
const char* retStr = m_pRealTool->ExecuteAbiSafe(argStr.c_str());
nlohmann::json result = nlohmann::json::parse(retStr);
// DLL 工具通常会写数据库(创建/修改实体、图层等)。基座运行在 AutoCAD 主线程的
// 窗口消息处理中,此时处于「应用上下文」,直接对数据库做 kForWrite 打开会返回
// eLockViolation(表现为 openWriteSpace 拿不到模型空间)。因此统一在此加文档写锁,
// 工具执行完立即解锁。只读工具被锁也无副作用。
// 用 RAII 守卫:插件 Execute 可能抛异常,异常展开时同样必须解锁,否则写锁会泄漏。
ScopedDocumentWriteLock docWriteLock;
if (locked)
{
acDocManager->unlockDocument(pDoc);
}
return result;
}
return mcp_Tool_Utility::make_error(-32603, L"Underlying DLL tool instance is invalid");
std::string argStr = args.dump();
const char* retStr = m_pRealTool->ExecuteAbiSafe(argStr.c_str());
return nlohmann::json::parse(retStr);
}
#pragma endregion DllProxy
+203
View File
@@ -0,0 +1,203 @@
"""故障注入验证:畸形 / 类型错误的请求不得让 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())