feat(mcp): 基座换行分帧与 isError 映射;新增 dwg_review 审查 Agent

基座(cad_mcp_frame):
- 新增 RecvOneFrame: 以 '\n' 为消息边界分帧,去掉单次 recv(2048) 限制(上限 8MB);
  用 select 短探测兼容不发 '\n' 的旧客户端;clientSocket 设 SO_RCVTIMEO 防卸载卡死
- JSON-RPC id 支持 string/number;无 id 的通知不回包
- isError 映射: 协议级错误保留顶层 error,工具执行错误转 result.isError=true
- 响应补 '\n' 作为消息边界

Agent(新增 dwg_review 包):
- mcp_client: 唯一 TCP/JSON-RPC 封装(单飞锁/分帧/重试/兼容 error 与 isError)
- cad_tools: 从 tools/list 动态建表,含 15 个只读白名单
- phases: planning -> evidence -> review(受控只读取证子循环)-> report
- annotator: 批注回写 AI_REVIEW 层,支持 dry-run 与撤销
- config: 配置全部走环境变量;Windows 下回退读注册表以读到新设的系统变量

其它: 新增 verify_mcp_frame.py 验证脚本;.gitignore 补 Python/AutoCAD 锁文件规则;
ai.memory 记录架构决策与实测结论
This commit is contained in:
gjm committed 2026-10-13 23:44:11 +08:00
1 parent 3b1cc88262
commit ec5292debe
20 files changed
+1502 -20

No files matched your search

+118 -16
View File
@@ -42,8 +42,8 @@ namespace
std::string rawJson;
};
// 处理工具查询请求
std::string HandleToolsList(int msgId)
// 处理工具查询请求(msgId 允许 string 或 number)
std::string HandleToolsList(const nlohmann::json &msgId)
{
nlohmann::json resp;
resp["jsonrpc"] = "2.0";
@@ -55,8 +55,8 @@ namespace
return resp.dump();
}
// 处理工具调用请求
std::string HandleToolsCall(int msgId, const nlohmann::json &params)
// 处理工具调用请求(msgId 允许 string 或 number)
std::string HandleToolsCall(const nlohmann::json &msgId, const nlohmann::json &params)
{
nlohmann::json resp;
resp["jsonrpc"] = "2.0";
@@ -68,8 +68,34 @@ namespace
// 让工具集去执行,返回 payload 可能是 {"result": ...} 也可能是 {"error": ...}
nlohmann::json payload = g_mcpTools.Call(toolName, args);
// 将 payload 注入到外层信封中
resp.update(payload);
if (payload.contains("error") && payload["error"].is_object())
{
int code = payload["error"].value("code", -32603);
std::string message = payload["error"].value("message", "");
// 协议级错误(JSON-RPC 保留码:解析/请求/方法/参数)保持为顶层 error;
// 工具执行错误按 MCP 规范改为 result.isError = true,错误信息进 content[0].text。
if (code == -32700 || code == -32600 || code == -32601 || code == -32602)
{
resp.update(payload);
}
else
{
resp["result"] = {
{ "isError", true },
{ "content", { { { "type", "text" }, { "text", message } } } }
};
}
}
else
{
// 正常结果:将 payload({"result": ...})注入到外层信封中
resp.update(payload);
// 显式标注 isError=false(MCP 规范字段)
if (resp.contains("result") && resp["result"].is_object())
resp["result"]["isError"] = false;
}
return resp.dump();
}
@@ -92,31 +118,40 @@ namespace
else
{
std::string method = req.value("method", "");
int msgId = req.value("id", 0);
// id 允许 string 或 number;缺失 id 表示这是一条"通知",按规范不应产生任何响应
bool isNotification = !req.contains("id");
nlohmann::json idVal = isNotification ? nlohmann::json(nullptr) : req["id"];
if (method == "tools/list")
{
responseStr = HandleToolsList(msgId);
responseStr = HandleToolsList(idVal);
}
else if (method == "tools/call")
{
responseStr = HandleToolsCall(msgId, req.value("params", nlohmann::json::object()));
responseStr = HandleToolsCall(idVal, req.value("params", nlohmann::json::object()));
}
else
{
// 找不到方法
nlohmann::json err;
err["jsonrpc"] = "2.0";
err["id"] = msgId;
err["id"] = idVal;
err["error"] = { {"code", -32601}, {"message", "Method not found"} };
responseStr = err.dump();
}
// 通知不回包(send 处对空串已有保护)
if (isNotification)
responseStr.clear();
}
// 通过传递过来的 Socket 将结果发送回大模型端
if (pTask->clientSocket != INVALID_SOCKET)
// 响应同样以 '\n' 结尾作为消息边界(json.loads 容忍尾部换行,旧客户端不受影响)
if (pTask->clientSocket != INVALID_SOCKET && !responseStr.empty())
{
send(pTask->clientSocket, responseStr.c_str(), responseStr.length(), 0);
responseStr.push_back('\n');
send(pTask->clientSocket, responseStr.c_str(), (int)responseStr.length(), 0);
}
}
@@ -136,6 +171,63 @@ namespace
return DefWindowProc(hWnd, message, wParam, lParam);
}
// 从 socket 读取一整帧 JSON-RPC 报文。
// 消息边界规则:优先以 '\n' 作为帧结束标记(新客户端);
// 若客户端不发 '\n'(旧脚本),则在收到数据后用 select 短暂探测是否还有后续数据,
// 无后续数据即视为整帧,从而保持向后兼容。
// 返回 true 表示成功取到一帧(内容写入 out);false 表示无数据或出错。
// 注意:本函数运行在后台 TCP 线程,只做字节累积,不解析 JSON、不触碰任何 ARX API。
bool RecvOneFrame(SOCKET sock, std::string &out)
{
const size_t kMaxFrameBytes = 8 * 1024 * 1024; // 8MB 上限,防御异常/恶意大报文
char buf[4096];
out.clear();
for (;;)
{
int n = recv(sock, buf, sizeof(buf), 0);
if (n > 0)
{
out.append(buf, n);
// 命中换行符即成帧(去掉 '\n' 及之后内容,并兼容 CRLF)
size_t pos = out.find('\n');
if (pos != std::string::npos)
{
out.resize(pos);
if (!out.empty() && out.back() == '\r')
out.pop_back();
return true;
}
if (out.size() > kMaxFrameBytes)
return false; // 超过上限,判为非法报文
// 尚未见到 '\n':短暂探测是否还有后续分片。
// 无后续数据 => 判定为"整帧已到"(兼容不发 '\n' 的旧客户端)。
fd_set probe;
FD_ZERO(&probe);
FD_SET(sock, &probe);
timeval probeTimeout = { 0, 150000 }; // 150ms
int pr = select(0, &probe, nullptr, nullptr, &probeTimeout);
if (pr <= 0)
return true;
}
else if (n == 0)
{
// 对端正常关闭:若已收到数据则视为整帧
return !out.empty();
}
else
{
int err = WSAGetLastError();
if ((err == WSAETIMEDOUT || err == WSAEWOULDBLOCK) && !out.empty())
return true; // 超时但已有数据:视为整帧
return false;
}
}
}
// TCP 监听后台线程
void TcpServerLoop()
{
@@ -166,14 +258,19 @@ namespace
SOCKET clientSocket = accept(g_listenSocket, nullptr, nullptr);
if (clientSocket != INVALID_SOCKET)
{
char recvBuf[2048] = { 0 };
int bytesReceived = recv(clientSocket, recvBuf, sizeof(recvBuf) - 1, 0);
if (bytesReceived > 0)
// 设置接收超时:保证插件卸载时后台线程能及时退出,不会永久阻塞在 recv 上
DWORD recvTimeoutMs = 5000;
setsockopt(clientSocket, SOL_SOCKET, SO_RCVTIMEO,
(const char *)&recvTimeoutMs, sizeof(recvTimeoutMs));
// 按消息边界读取一整帧(不再受 2048 字节单次 recv 限制)
std::string frame;
if (RecvOneFrame(clientSocket, frame) && !frame.empty())
{
// 将数据拷贝到堆内存,准备派发给主线程
TaskData *pTask = new TaskData;
pTask->clientSocket = clientSocket; // 记录 Socket
pTask->rawJson = recvBuf;
pTask->rawJson = frame;
// 关键转变:通过 PostMessage 发送给主线程拥有的隐式窗口
if (g_hMsgWnd != NULL)
@@ -185,6 +282,11 @@ namespace
delete pTask;
}
}
else
{
// 空帧或读取失败:直接关闭连接
closesocket(clientSocket);
}
}
else
{