diff --git a/src/cad_mcp_frame/mcp_ConfigParser.cpp b/src/cad_mcp_frame/mcp_ConfigParser.cpp index a681ba4..2e70797 100644 --- a/src/cad_mcp_frame/mcp_ConfigParser.cpp +++ b/src/cad_mcp_frame/mcp_ConfigParser.cpp @@ -10,6 +10,7 @@ #include #include #include +#include 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 协议错误。 diff --git a/src/cad_mcp_frame/mcp_server.cpp b/src/cad_mcp_frame/mcp_server.cpp index cc9c8bd..8d674c6 100644 --- a/src/cad_mcp_frame/mcp_server.cpp +++ b/src/cad_mcp_frame/mcp_server.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #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(); } diff --git a/src/cad_mcp_frame/mcp_tool.cpp b/src/cad_mcp_frame/mcp_tool.cpp index c3ab37d..c5071d4 100644 --- a/src/cad_mcp_frame/mcp_tool.cpp +++ b/src/cad_mcp_frame/mcp_tool.cpp @@ -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 diff --git a/verify_mcp_fault_injection.py b/verify_mcp_fault_injection.py new file mode 100644 index 0000000..f4b1130 --- /dev/null +++ b/verify_mcp_fault_injection.py @@ -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() 时抛异常 + 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())