From ec5f62670d2029fdee5b3e29a75d8611938d9357 Mon Sep 17 00:00:00 2001 From: LittleGuo Date: Fri, 9 Oct 2026 22:31:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(mcp=5Fframe):=20=E4=BF=AE=E5=A4=8D=20c++=5F?= =?UTF-8?q?dll=20=E5=B7=A5=E5=85=B7=E5=A4=A7=E9=87=8F=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8E=E5=86=99=E5=BA=93=E8=A2=AB=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=94=81=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 版本校验:基座侧改用编译期常量 (ARX<<16)|SUB_ARX,不再调用运行时的 acrxGetApiVersion()。该函数由 rxapi.lib 的 acrxGetApiVersionImpl 提供, 是有状态实现(读写进程内全局),返回值随调用次数变化;AutoCAD 加载 ARX 时已调用过一次,导致基座值与 DLL 值不等,绝大多数 DLL 工具被拒绝注册。 - 新增 DLL 模块缓存:同一 DLL 全进程只 LoadLibrary 一次,后续工具复用句柄 并跳过版本校验。 - 文档锁:mcp_Tool_DllProxy::Execute 统一 lockDocument/unlockDocument, 修复 DLL 工具在 AutoCAD 应用上下文写数据库返回 eLockViolation (表现为 openWriteSpace 拿不到模型空间)的问题。 - 代理类不再 FreeLibrary 模块(改由模块缓存统一卸载),避免同一 DLL 的多个 工具重复释放、引用计数被打穿。 - 诊断日志精简为仅记录 arx_version/dll_version,且每次加载清空重写。 - 新增 verify_annotate.py:批注工具集运行时验证脚本(绘制/校验/改属性/清理)。 --- CODEBUDDY.md | 2 +- src/cad_mcp_frame/mcp_ConfigParser.cpp | 151 +++++++++++++------ src/cad_mcp_frame/mcp_tool.cpp | 23 ++- src/cad_mcp_frame/mcp_tool.h | 9 +- verify_annotate.py | 200 +++++++++++++++++++++++++ 5 files changed, 327 insertions(+), 58 deletions(-) create mode 100644 verify_annotate.py diff --git a/CODEBUDDY.md b/CODEBUDDY.md index fee7a85..108aa4a 100644 --- a/CODEBUDDY.md +++ b/CODEBUDDY.md @@ -61,7 +61,7 @@ python agent_loop.py # 完整 Agent 循环(多回合 截图/缩放/结 - **`lisp_inline`**:把 `script_template` 中的 `{占位符}` 替换为调用参数,`sendStringToExecute` 到命令行(异步)。布尔值转 `T`/`nil`。见 `mcp_tool.cpp:43`。 - **`lisp_file`**:先 `(load "文件" nil)` 再执行 `call_template`,`file_name` 相对于配置中的 `lisp_directory` 解析。见 `mcp_tool.cpp:61`。 -- **`c++_dll`**:`LoadLibrary` 第三方 DLL,用 `acrxGetApiVersion` 校验 ARX 大版本一致(不匹配则拒绝加载防崩溃),取 `factory_function` 与 `Destroy_` 两个导出函数创建/销毁工具,用 `mcp_Tool_DllProxy` 托管生命周期(RAII)。跨 DLL 通过 `SetConfigAbiSafe` / `ExecuteAbiSafe` 传 C 字符串,JSON 序列化全部在 DLL 内部完成。见 `mcp_configParser.cpp:55`、`mcp_tool.cpp:105`。 +- **`c++_dll`**:`LoadLibrary` 第三方 DLL。**同一 DLL 全进程只加载一次**(句柄由 `mcp_configParser.cpp` 的模块缓存 `g_loadedDllModules` 持有,`mcp_Tools::clear()` 时统一 `FreeLibrary`),仅**首次加载**时校验 ARX 大版本:用**编译期常量** `(ARX<<16)|SUB_ARX` 与 DLL 导出的 `acrxGetApiVersion` 比对,不匹配则拒绝加载防崩溃。注意运行时的 `acrxGetApiVersion`(`rxapi.lib` 的 `acrxGetApiVersionImpl`)是**有状态实现**,返回值会随调用次数变化,不能用作基座侧版本。取 `factory_function` 与 `Destroy_` 创建/销毁工具,用 `mcp_Tool_DllProxy` 托管工具对象生命周期(RAII,**不再 FreeLibrary 模块**)。跨 DLL 通过 `SetConfigAbiSafe` / `ExecuteAbiSafe` 传 C 字符串,JSON 序列化全部在 DLL 内部完成。见 `mcp_configParser.cpp`、`mcp_tool.cpp`。 ### 插件 ABI(`inc/mcp_plugin_api.h`) diff --git a/src/cad_mcp_frame/mcp_ConfigParser.cpp b/src/cad_mcp_frame/mcp_ConfigParser.cpp index f85c822..dcc111a 100644 --- a/src/cad_mcp_frame/mcp_ConfigParser.cpp +++ b/src/cad_mcp_frame/mcp_ConfigParser.cpp @@ -5,12 +5,52 @@ #include "mcp_utils.h" #include "ghc/filesystem.hpp" #include "rxregsvc.h" +#include +#include +#include +#include +#include + +namespace +{ + // 已加载的插件 DLL 缓存:规范化路径 -> 模块句柄。 + // 同一个 DLL 只 LoadLibrary 一次;后续工具直接复用句柄,并跳过版本校验。 + std::map g_loadedDllModules; + + // 统一卸载缓存中的所有 DLL。必须在所有 mcp_Tool_DllProxy 销毁之后再调用, + // 否则 DLL 已卸载而代理仍持有其工具对象指针,析构时会崩溃。 + void unloadAllDllModules() + { + for (auto &kv : g_loadedDllModules) + { + if (kv.second) { FreeLibrary(kv.second); } + } + g_loadedDllModules.clear(); + } + + // 诊断日志:仅记录版本校验时的两个数值(排查版本不一致问题) + // 每次重新加载工具时清空一次,便于只观察本次的结果。 + void mcpLoadReset() + { + std::wstring lp = (mcp_utils::getModelDirPath() / L"mcp_load.log").wstring(); + std::ofstream ofs(lp.c_str(), std::ios::trunc); + } + + void mcpLoadLog(const std::string &msg) + { + std::wstring lp = (mcp_utils::getModelDirPath() / L"mcp_load.log").wstring(); + std::ofstream ofs(lp.c_str(), std::ios::app); + if (ofs.is_open()) { ofs << msg << "\n"; } + } +} #pragma region mcp_ConfigParser bool mcp_ConfigParser::LoadAndRegister(const std::wstring &jsonFilePath, mcp_Tools ®istry) { + mcpLoadReset(); + std::ifstream ifs(jsonFilePath); - if (!ifs.is_open()) return false; + if (!ifs.is_open()) { return false; } nlohmann::json config; try { ifs >> config; } @@ -51,7 +91,6 @@ bool mcp_ConfigParser::LoadAndRegister(const std::wstring &jsonFilePath, mcp_Too return true; } -extern "C" int acrxGetApiVersion(); void mcp_ConfigParser::LoadDllTool(const nlohmann::json &toolConfig, const std::wstring &pluginDir, mcp_Tools ®istry) { fs::path dllPath @@ -65,48 +104,72 @@ void mcp_ConfigParser::LoadDllTool(const nlohmann::json &toolConfig, const std:: return; } - HMODULE hMod = LoadLibrary(dllPath.c_str()); - - CString verStr; verStr.Format(_T("Major ver:%d, Minor ver:%d"), ARX, SUB_ARX); CString ver_errMsg; - ver_errMsg.Format( - CString(_T("[MCP Server]Error: Failed to load the DLL. ")) - + CString(_T("Please check whether the version of the ObjectARX SDK used by ")) - + CString("the DLL matches the current ARX version(%s).\n"), verStr ); + ver_errMsg.Format( + CString(_T("[MCP Server]Error: Failed to load the DLL. ")) + + CString(_T("Please check whether the version of the ObjectARX SDK used by ")) + + CString("the DLL matches the current ARX version(%s).\n"), verStr); - if (!hMod) + // 规范化 key(绝对路径,统一小写),用于判断该 DLL 是否已经加载过 + std::wstring key = dllPath.wstring(); + std::transform(key.begin(), key.end(), key.begin(), + [](wchar_t c) { return (wchar_t)::towlower(c); }); + + HMODULE hMod = NULL; + bool firstLoad = false; + auto it = g_loadedDllModules.find(key); + if (it != g_loadedDllModules.end()) { - AfxMessageBox(ver_errMsg); - return; + // 该 DLL 已加载:直接复用句柄,不再重复 LoadLibrary,也不再做版本校验 + hMod = it->second; + } + else + { + hMod = LoadLibrary(dllPath.c_str()); + if (!hMod) + { + AfxMessageBox(ver_errMsg); + return; + } + g_loadedDllModules[key] = hMod; + firstLoad = true; } - // 1. 定义版本校验函数指针 - typedef int (*GetApiVersionFunc)(); - GetApiVersionFunc getVerFn = (GetApiVersionFunc)GetProcAddress(hMod, "acrxGetApiVersion"); - - // 2. 核心拦截逻辑 - if (!getVerFn) + // 版本校验:仅在该 DLL 首次加载时执行一次 + if (firstLoad) { - // 连这个函数都没导出,说明根本不是合法的 ObjectARX/MCP 插件 - AfxMessageBox(CString( - CString(_T("The ObjectARX version of the DLL could not be determined.")) - + CString(_T("Please ensure that the acrxGetApiVersion function is exported.")) - )); - FreeLibrary(hMod); - return; - } + typedef int (*GetApiVersionFunc)(); + GetApiVersionFunc getVerFn = (GetApiVersionFunc)GetProcAddress(hMod, "acrxGetApiVersion"); - // 拿 DLL 编译时的 SDK 版本,和基座当前运行的 SDK 版本进行比对 - int arx_version(acrxGetApiVersion()); - int dll_version(getVerFn()); - if (arx_version != dll_version) - { - // 平台大版本不匹配,强行加载必定闪崩,果断踢掉! - FreeLibrary(hMod); - AfxMessageBox(ver_errMsg); - return; + if (!getVerFn) + { + // 连这个函数都没导出,说明根本不是合法的 ObjectARX/MCP 插件 + AfxMessageBox(CString( + CString(_T("The ObjectARX version of the DLL could not be determined.")) + + CString(_T("Please ensure that the acrxGetApiVersion function is exported.")) + )); + FreeLibrary(hMod); + g_loadedDllModules.erase(key); + return; + } + + // 基座自身版本取「编译期常量」,它稳定可靠; + // 不能依赖运行时的 acrxGetApiVersion()——该函数由 rxapi.lib 中的有状态实现提供, + // 其返回值会随调用次数变化(首次为正确值,之后被进程内全局污染),无法用于比对。 + int arx_version = (ARX << 16) | SUB_ARX; + int dll_version = getVerFn(); + mcpLoadLog("arx_version=" + std::to_string(arx_version) + " dll_version=" + std::to_string(dll_version)); + + if (arx_version != dll_version) + { + // 平台大版本不匹配,强行加载必定闪崩,果断踢掉整个模块! + FreeLibrary(hMod); + g_loadedDllModules.erase(key); + AfxMessageBox(ver_errMsg); + return; + } } typedef mcp_Tool *(*CreateFunc)(); @@ -124,25 +187,18 @@ void mcp_ConfigParser::LoadDllTool(const nlohmann::json &toolConfig, const std:: // 给真正的 DLL 实例也注入配置,以防第三方需要在内部读取 std::string cfgStr = toolConfig.dump(); pRealTool->SetConfigAbiSafe(cfgStr.c_str()); - //pRealTool->SetConfig(toolConfig); - // 实例化代理类,接管生命周期 - auto pProxy = std::make_shared(hMod, pRealTool, destroyFn); + // 实例化代理类,接管工具对象的生命周期(模块句柄由 g_loadedDllModules 统一管理) + auto pProxy = std::make_shared(pRealTool, destroyFn); // 代理类继承自基类,也需要配置来处理 GetName 和 ToLLM pProxy->SetConfig(toolConfig); registry.Register(pProxy); } - else - { - FreeLibrary(hMod); - } - } - else - { - FreeLibrary(hMod); // 导出函数找不到,直接卸载 + // 工厂返回空:只跳过本工具,不卸载模块(同一个 DLL 的其它工具可能仍然可用) } + // 工厂/销毁函数缺失:同样只跳过本工具,不卸载模块 } #pragma endregion mcp_ConfigParser @@ -191,7 +247,8 @@ void mcp_Tools::print() void mcp_Tools::clear() { - m_tools.clear(); + m_tools.clear(); // 先销毁所有代理(代理不再 FreeLibrary,仅释放 DLL 内部工具对象) + unloadAllDllModules(); // 所有代理销毁完毕后,再统一卸载缓存的 DLL 模块 } #pragma endregion mcp_Tools diff --git a/src/cad_mcp_frame/mcp_tool.cpp b/src/cad_mcp_frame/mcp_tool.cpp index 806a4b9..c3ab37d 100644 --- a/src/cad_mcp_frame/mcp_tool.cpp +++ b/src/cad_mcp_frame/mcp_tool.cpp @@ -89,27 +89,38 @@ nlohmann::json mcp_Tool_LispFile::Execute(const nlohmann::json &args) #pragma endregion LispFile #pragma region DllProxy -// RAII:基座销毁工具时,自动通知 DLL 释放内存,并卸载模块 +// RAII:基座销毁工具时,通知 DLL 释放工具对象(模块句柄由模块缓存统一管理) mcp_Tool_DllProxy::~mcp_Tool_DllProxy() { if (m_pRealTool && m_destroyFunc) { m_destroyFunc(m_pRealTool); } - if (m_hModule) - { - FreeLibrary(m_hModule); - } + // DLL 模块由 mcp_ConfigParser 的模块缓存统一卸载(mcp_Tools::clear 中),此处不再 FreeLibrary } 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); + std::string argStr = args.dump(); //return m_pRealTool->Execute(args); // 完美转发 const char* retStr = m_pRealTool->ExecuteAbiSafe(argStr.c_str()); - return nlohmann::json::parse(retStr); + nlohmann::json result = nlohmann::json::parse(retStr); + + if (locked) + { + acDocManager->unlockDocument(pDoc); + } + return result; } return mcp_Tool_Utility::make_error(-32603, L"Underlying DLL tool instance is invalid"); } diff --git a/src/cad_mcp_frame/mcp_tool.h b/src/cad_mcp_frame/mcp_tool.h index 06b6525..009f0d3 100644 --- a/src/cad_mcp_frame/mcp_tool.h +++ b/src/cad_mcp_frame/mcp_tool.h @@ -24,17 +24,18 @@ public: class mcp_Tool_DllProxy : public mcp_Tool { private: - HMODULE m_hModule; mcp_Tool* m_pRealTool; // 真正由第三方 DLL 创建的工具对象 typedef void (*DestroyFunc)(mcp_Tool*); DestroyFunc m_destroyFunc; public: - mcp_Tool_DllProxy(HMODULE hMod, mcp_Tool* pReal, DestroyFunc pDestroy) - : m_hModule(hMod), m_pRealTool(pReal), m_destroyFunc(pDestroy) {} + mcp_Tool_DllProxy(mcp_Tool* pReal, DestroyFunc pDestroy) + : m_pRealTool(pReal), m_destroyFunc(pDestroy) {} - // RAII:基座销毁工具时,自动通知 DLL 释放内存,并卸载模块 + // RAII:基座销毁工具时,通知 DLL 释放工具对象。 + // 注意:DLL 模块句柄由 mcp_ConfigParser 的模块缓存统一管理(同一 DLL 只加载一次), + // 这里不再 FreeLibrary,否则多个工具会重复释放同一模块导致引用计数被打穿。 virtual ~mcp_Tool_DllProxy(); virtual nlohmann::json Execute(const nlohmann::json &args) override; diff --git a/verify_annotate.py b/verify_annotate.py new file mode 100644 index 0000000..1f53915 --- /dev/null +++ b/verify_annotate.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +""" +verify_annotate.py +批注工具集运行时验证脚本。 + +前提:CAD 已打开并加载 cad_mcp_frame.arx(插件 DLL),127.0.0.1:8080 在监听。 + +用法: + python verify_annotate.py # 绘制 -> 校验 -> 清理(默认清理) + python verify_annotate.py --keep # 保留批注,便于肉眼观察 + +覆盖工具: + draw_revision_cloud / add_text / add_mtext / add_polyline / add_line / + add_leader_annotation / set_entity_properties / delete_entities +""" +import socket +import json +import sys + +HOST, PORT = "127.0.0.1", 8080 +ANNOT_LAYER = "AI_REVIEW" + + +# ---------------------------------------------------------------- MCP 通信 +def rpc(method, params=None, timeout=20): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((HOST, PORT)) + req = {"jsonrpc": "2.0", "id": 1, "method": method} + if params is not None: + req["params"] = params + s.sendall(json.dumps(req).encode("utf-8")) + data = b"" + while True: + chunk = s.recv(8192) + if not chunk: + break + data += chunk + if len(chunk) < 8192: + break + s.close() + return json.loads(data.decode("utf-8")) + + +def call_tool(name, arguments): + return rpc("tools/call", {"name": name, "arguments": arguments}) + + +def payload(resp): + """从 tools/call 响应里取出数据类工具返回的内层 JSON。""" + if resp is None: + return False, "no response" + if "error" in resp: + return False, resp["error"] + try: + for item in resp["result"]["content"]: + if item.get("type") == "text": + return True, json.loads(item["text"]) + except Exception as e: # noqa: BLE001 + return False, "parse error: %s" % e + return False, "no text content" + + +# ---------------------------------------------------------------- 测试驱动 +RESULTS = [] + + +def record(name, ok, detail): + RESULTS.append((name, ok, detail)) + print(" [%s] %-24s %s" % ("PASS" if ok else "FAIL", name, detail)) + + +def expect_created(name, resp, want_type): + """校验工具返回 ok 且 created[0].type == want_type,返回 handle 列表。""" + ok, obj = payload(resp) + if not ok: + record(name, False, "调用失败: %s" % obj) + return [] + if not obj.get("ok"): + record(name, False, "返回非 ok: %s" % obj) + return [] + created = obj.get("created", []) + handles = [c.get("handle", "") for c in created] + types = [c.get("type", "") for c in created] + layers = [c.get("layer", "") for c in created] + if want_type not in types: + record(name, False, "类型不符: 期望 %s 实际 %s" % (want_type, types)) + return handles + bad_layer = [l for l in layers if l != ANNOT_LAYER] + if bad_layer: + record(name, False, "图层不符: %s" % layers) + return handles + record(name, True, "created=%s layer=%s" % (types, layers)) + return handles + + +def main(): + keep = "--keep" in sys.argv + + print("=== 批注工具集运行时验证 ===") + print("目标: %s:%d\n" % (HOST, PORT)) + + # 0) 连接 & 工具清单 + try: + tools_resp = rpc("tools/list") + except Exception as e: # noqa: BLE001 + print("[错误] 无法连接 MCP 服务: %s" % e) + print("请确认 CAD 已打开、插件已加载、8080 端口在监听。") + return 2 + + names = [t["name"] for t in tools_resp.get("result", {}).get("tools", [])] + want = ["draw_revision_cloud", "add_text", "add_mtext", "add_polyline", + "add_line", "add_leader_annotation", "delete_entities", + "set_entity_properties"] + missing = [w for w in want if w not in names] + print("工具总数: %d" % len(names)) + if missing: + print("[FAIL] 未注册的工具: %s" % missing) + return 1 + print("[PASS] 8 个批注工具均已注册\n") + + created_handles = [] + + # 1) 云线(矩形) + print("1) 绘制工具") + h = expect_created("draw_revision_cloud", call_tool("draw_revision_cloud", { + "shape": "rect", "min_x": 0, "min_y": 0, "max_x": 100, "max_y": 60, + }), "LWPOLYLINE") + created_handles += h + + # 2) 单行文字 + h = expect_created("add_text", call_tool("add_text", { + "text": "AI批注-单行", "position": [0, 80], "height": 5, + }), "TEXT") + created_handles += h + + # 3) 多行文字 + h = expect_created("add_mtext", call_tool("add_mtext", { + "text": "AI批注-多行\n第二行", "position": [0, 100], "height": 5, + }), "MTEXT") + created_handles += h + + # 4) 多段线 + h = expect_created("add_polyline", call_tool("add_polyline", { + "points": [[0, 130], [60, 130], [60, 170]], "closed": False, + }), "LWPOLYLINE") + created_handles += h + + # 5) 直线 + h = expect_created("add_line", call_tool("add_line", { + "start": [0, 190], "end": [100, 190], + }), "LINE") + created_handles += h + + # 6) 引线批注(返回 MTEXT + LEADER 两个) + h = expect_created("add_leader_annotation", call_tool("add_leader_annotation", { + "text": "引线批注", "points": [[300, 200], [200, 150]], "height": 5, + }), "LEADER") + created_handles += h + + # 7) 下钻校验:取第一个实体详情,确认图层 + print("\n2) 校验") + if created_handles: + ok, obj = payload(call_tool("get_entity_detail", {"handle": created_handles[0]})) + if ok and obj.get("layer") == ANNOT_LAYER: + record("get_entity_detail", True, "handle=%s layer=%s" % (created_handles[0], obj.get("layer"))) + else: + record("get_entity_detail", False, "%s" % obj) + else: + record("get_entity_detail", False, "无可用 handle") + + # 8) 修改实体属性(把第一个实体改红) + if created_handles: + ok, obj = payload(call_tool("set_entity_properties", { + "handle": created_handles[0], "color": 1, + })) + record("set_entity_properties", bool(ok and obj.get("count") == 1), "%s" % obj) + + # 9) 清理 + print("\n3) 清理") + if keep: + record("delete_entities", True, "已跳过(--keep);保留 handle: %s" % created_handles) + elif created_handles: + ok, obj = payload(call_tool("delete_entities", {"handles": created_handles})) + good = ok and obj.get("count") == len(created_handles) + record("delete_entities", good, "删除 %s / 共 %s" % (obj.get("count"), len(created_handles))) + else: + record("delete_entities", False, "无 handle 可删") + + # 汇总 + print("\n=== 结果汇总 ===") + passed = sum(1 for _, ok, _ in RESULTS if ok) + for name, ok, detail in RESULTS: + print(" %-4s %-24s %s" % ("OK" if ok else "NG", name, detail)) + print("\n通过 %d / %d" % (passed, len(RESULTS))) + return 0 if passed == len(RESULTS) else 1 + + +if __name__ == "__main__": + sys.exit(main())