Files
cad-agent/dwg_review/mcp_client.py
T
gjm f33d42afe9 feat(bridge): 新增 stdio↔TCP 桥,让标准 MCP 客户端可直连 CAD 基座
标准 MCP 客户端(Claude Desktop / CodeBuddy / MCP Inspector)只实现 stdio 与
Streamable HTTP,无法直连 cad_mcp_frame 的自定义裸 TCP。新增 mcp_bridge 包在
客户端与基座之间做协议适配,CAD 侧零改动:

- 合成基座缺失的生命周期:initialize(含协议版本协商、capabilities、serverInfo)
  与 ping;notifications/* 按规范不回包。
- tools/list、tools/call 原样转发到 127.0.0.1:8080,result 与顶层 error 忠实透传,
  仅把 id 改写回客户端原始 id。
- 并发:读线程即时应答 initialize/ping,单工作线程串行转发工具调用(保证顺序、
  慢工具不阻塞 ping)。
- stdout 只走 sys.stdout.buffer 并加锁,规避 Windows 的 \n→\r\n 翻译;
  诊断信息一律走 stderr。
- CAD 未启动时桥照常启动,工具调用回 -32603(可重试)。
- 入口:python -m mcp_bridge,支持 --host/--port/--timeout/--verbose/--print-config。

dwg_review/mcp_client.py 增加 raw_rpc()(_rpc 新增 normalize 开关),
返回基座原始响应 dict 供桥透传,避免重复实现 TCP 封装。

验证:
- verify_stdio_bridge.py 24/24 PASS(版本协商、通知不回包、ping、tools/list 与基座
  等价、tools/call、错误透传、-32700、string id、stdout 纯净性、死端口降级)。
- 真实标准客户端 MCP Inspector 冒烟通过:tools/list 拿到 26 个工具,
  tools/list --strict 退出码 0(无 schema 可移植性问题),
  tools/call get_drawing_info 返回标准 content + isError:false。
- 回归:verify_mcp_frame.py 4/4、verify_annotate.py 9/9、dwg_review --list-tools/--check 正常。
2026-10-14 00:44:15 +08:00

187 lines
7.1 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,
normalize=True):
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:
resp = self._send_frame(payload, timeout)
return self._normalize(resp) if normalize else resp
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 raw_rpc(self, method, params=None, *, timeout=None, retries=None):
"""转发式调用:返回基座**原始响应 dict**,不做 error / isError 归一化。
供 stdio 桥使用——桥需要把基座的 `result` 与顶层 `error` 原样透传给客户端,
因此不能走 `_normalize`(它会把两者都变成异常)。
"""
return self._rpc(method, params, timeout=timeout, retries=retries, normalize=False)
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