Files
cad-agent/dwg_review/mcp_client.py
T
gjm ec5292debe 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 记录架构决策与实测结论
2026-10-13 23:44:11 +08:00

177 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""唯一的 TCP / JSON-RPC 封装。
要点:
- 换行分帧:请求与响应均以 '\\n' 结尾(新基座按 '\\n' 分帧;旧基座读到即止,兼容)。
- 单飞锁:CAD 侧单线程,所有调用串行,避免并发踩踏主线程派发链。
- 错误归一化:同时识别 JSON-RPC 顶层 error 与 MCP 的 result.isError。
"""
import json
import socket
import threading
import time
from . import config
class McpError(Exception):
"""协议级错误(JSON-RPC error:-32700/-32600/-32601/-32602 等)。"""
def __init__(self, code, message):
super().__init__(f"[{code}] {message}")
self.code = code
self.message = message
class McpToolError(Exception):
"""工具执行错误(MCP result.isError == true)。"""
def __init__(self, message, code=None):
super().__init__(message)
self.code = code
self.message = message
def result_texts(resp):
"""取出 result.content 中所有 text 内容。"""
result = (resp or {}).get("result") or {}
return [i.get("text", "") for i in result.get("content", []) if i.get("type") == "text"]
def result_images(resp):
"""取出 result.content 中所有图片,返回 [(base64, mimeType), ...]。"""
result = (resp or {}).get("result") or {}
return [
(i.get("data"), i.get("mimeType", "image/png"))
for i in result.get("content", [])
if i.get("type") == "image"
]
def result_json(resp, default=None):
"""取第一个 text 内容并尝试按 JSON 解析;解析失败则返回原始字符串。"""
texts = result_texts(resp)
if not texts:
return default
text = texts[0]
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return text
class McpClient:
"""与 CAD MCP 基座通信的唯一入口。"""
def __init__(self, host=None, port=None, timeout=None):
self.host = host or config.HOST
self.port = port or config.PORT
self.timeout = timeout if timeout is not None else config.CALL_TIMEOUT
self._lock = threading.Lock() # 单飞:CAD 单线程,必须串行
self._next_id = 1
self._tools_cache = None
# ------------------------------------------------------------------
# 底层收发
# ------------------------------------------------------------------
def _connect(self, timeout=None):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(self.timeout if timeout is None else timeout)
sock.connect((self.host, self.port))
return sock
def _send_frame(self, payload, timeout=None):
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
if len(raw) > config.MAX_FRAME_BYTES:
raise McpError(-32600, f"请求帧过大: {len(raw)} > {config.MAX_FRAME_BYTES} 字节")
sock = self._connect(timeout)
try:
sock.sendall(raw + b"\n")
data = b""
while True:
chunk = sock.recv(65536)
if not chunk:
break
data += chunk
if b"\n" in data:
break
if len(data) > config.MAX_FRAME_BYTES:
raise McpError(-32603, "响应帧过大")
finally:
sock.close()
text = data.decode("utf-8", errors="replace").strip()
if not text:
raise McpError(-32700, "空响应")
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise McpError(-32700, f"响应解析失败: {exc}") from exc
# ------------------------------------------------------------------
# 高层 RPC
# ------------------------------------------------------------------
def _rpc(self, method, params=None, *, timeout=None, retries=None, is_write=False):
retries = config.CALL_RETRIES if retries is None else retries
if is_write:
retries = 0 # 写类工具不重试,避免重复副作用
with self._lock:
last_exc = None
for attempt in range(retries + 1):
req_id = self._next_id
self._next_id += 1
payload = {"jsonrpc": "2.0", "id": req_id, "method": method}
if params is not None:
payload["params"] = params
try:
return self._normalize(self._send_frame(payload, timeout))
except (McpError, McpToolError):
raise # 协议/工具错误是确定性的,不重试
except (OSError, socket.timeout) as exc:
last_exc = exc
if attempt < retries:
time.sleep(0.3 * (2 ** attempt))
continue
raise McpError(-32603, f"连接 CAD 基座失败: {exc}") from exc
raise McpError(-32603, f"连接 CAD 基座失败: {last_exc}")
@staticmethod
def _normalize(resp):
if isinstance(resp, dict) and "error" in resp:
err = resp.get("error") or {}
raise McpError(err.get("code", -32603), err.get("message", "unknown error"))
result = resp.get("result") if isinstance(resp, dict) else None
if isinstance(result, dict) and result.get("isError"):
texts = result_texts(resp)
raise McpToolError(texts[0] if texts else "tool error")
return resp
# ------------------------------------------------------------------
# 对外 API
# ------------------------------------------------------------------
def call_tool(self, name, arguments=None, *, timeout=None, retries=None, is_write=False):
"""调用一个工具;成功返回归一化后的响应(含 result.content)。"""
params = {"name": name, "arguments": arguments or {}}
return self._rpc("tools/call", params, timeout=timeout, retries=retries, is_write=is_write)
def list_tools(self, *, use_cache=True):
"""返回 tools/list 的 tools 数组。"""
if use_cache and self._tools_cache is not None:
return self._tools_cache
resp = self._rpc("tools/list")
tools = (resp.get("result") or {}).get("tools", [])
self._tools_cache = tools
return tools
def is_available(self, timeout=1.5):
"""探测基座是否在监听(仅建立连接,不发请求)。"""
try:
with self._lock:
sock = self._connect(timeout)
sock.close()
return True
except OSError:
return False