Files
cad-agent/mcp_bridge/server.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

169 lines
6.5 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.
"""stdio ↔ TCP 桥的核心:读线程 + 单工作线程队列。
线程模型:
- reader:逐行读 stdin。initialize / ping 在**读线程即时应答**,因此不会被
慢工具调用阻塞;tools/list、tools/call 入队交给 worker。
- worker:**单线程串行**转发到 CAD 基座,保证工具调用顺序(基座本身也是单线程执行)。
两个线程写 stdout 时共用一把锁,报文永不交叠。stdout 只承载协议报文,
所有诊断信息一律走 stderr。
"""
import json
import os
import queue
import sys
import threading
from dwg_review import config
from dwg_review.mcp_client import McpClient, McpError
from . import protocol
class StdioBridge:
"""把 stdio 上的 MCP 报文转发到 CAD 基座的 TCP 服务。"""
def __init__(self, host=None, port=None, timeout=None, verbose=False):
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.verbose = verbose
self._client = McpClient(host=self.host, port=self.port, timeout=self.timeout)
self._queue = queue.Queue()
self._out_lock = threading.Lock()
self._stop = object() # 投毒丸哨兵
# ------------------------------------------------------------------
# 生命周期
# ------------------------------------------------------------------
def run(self):
"""阻塞运行,直到客户端关闭 stdin。"""
reader = threading.Thread(target=self._read_loop, name="bridge-reader", daemon=True)
worker = threading.Thread(target=self._work_loop, name="bridge-worker", daemon=True)
worker.start()
reader.start()
try:
reader.join()
except KeyboardInterrupt:
self._log("收到中断信号")
self._queue.put(self._stop)
worker.join()
# ------------------------------------------------------------------
# reader
# ------------------------------------------------------------------
def _read_loop(self):
stream = sys.stdin.buffer
while True:
try:
line = stream.readline()
except (OSError, ValueError):
break
if not line:
break # EOF:客户端已退出
line = line.strip()
if line:
self._handle_line(line)
def _handle_line(self, line):
if len(line) > config.MAX_FRAME_BYTES:
self._send(protocol.err(None, -32700, f"报文过大(>{config.MAX_FRAME_BYTES} 字节)"))
return
try:
msg = json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
self._send(protocol.err(None, -32700, "Parse error"))
return
if not isinstance(msg, dict):
self._send(protocol.err(None, -32600, "Invalid Request:报文必须是 JSON 对象"))
return
msg_id = msg.get("id")
if msg.get("jsonrpc") != "2.0":
self._send(protocol.err(msg_id, -32600, "Invalid Request:jsonrpc 必须为 \"2.0\""))
return
method = msg.get("method")
if not isinstance(method, str) or not method:
self._send(protocol.err(msg_id, -32600, "Invalid Request:method 缺失或非字符串"))
return
has_id = "id" in msg
params = msg.get("params")
kind = protocol.classify(method)
if kind == protocol.LOCAL:
result = protocol.initialize_result(params) if method == "initialize" else {}
if has_id:
self._send(protocol.ok(msg_id, result))
elif kind == protocol.FORWARD:
if has_id:
self._queue.put((msg_id, method, params))
else:
self._log(f"忽略无 id 的 {method} 请求")
elif kind == protocol.NOTIFY:
# 通知按规范不回包;若客户端误带 id,回 -32601 以免它一直等待
if has_id:
self._send(protocol.err(msg_id, -32601, f"Method not found: {method}"))
else:
if has_id:
self._send(protocol.err(msg_id, -32601, f"Method not found: {method}"))
# ------------------------------------------------------------------
# worker
# ------------------------------------------------------------------
def _work_loop(self):
while True:
item = self._queue.get()
if item is self._stop:
break
msg_id, method, params = item
try:
# retries=0:桥只发一次,避免"发完即超时"被重试而重复执行写类工具
resp = self._client.raw_rpc(method, params, timeout=self.timeout, retries=0)
except McpError as exc:
self._send(protocol.err(
msg_id, -32603,
f"无法连接 CAD 基座 {self.host}:{self.port}:{exc.message}"))
continue
except Exception as exc: # noqa: BLE001
self._send(protocol.err(msg_id, -32603, f"桥内部错误:{exc}"))
continue
if not isinstance(resp, dict) or not ({"result", "error"} & set(resp)):
self._send(protocol.err(msg_id, -32603, "CAD 基座返回了非法响应"))
continue
# 忠实透传 result / 顶层 error,只把 id 改写回客户端的原始 id
resp["jsonrpc"] = "2.0"
resp["id"] = msg_id
self._send(resp)
# ------------------------------------------------------------------
# IO
# ------------------------------------------------------------------
def _send(self, obj):
data = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
try:
with self._out_lock:
sys.stdout.buffer.write(data + b"\n")
sys.stdout.buffer.flush()
except (BrokenPipeError, ValueError):
# 客户端已关闭管道,桥无事可做,直接退出(os._exit 避免解释器收尾时再次 flush 报错)
self._warn("stdout 已关闭,桥退出")
os._exit(0)
def _log(self, message):
"""诊断信息(仅 --verbose)。"""
if self.verbose:
print(f"[mcp_bridge] {message}", file=sys.stderr, flush=True)
def _warn(self, message):
"""警告,始终输出到 stderr(stderr 不属于协议通道)。"""
print(f"[mcp_bridge] {message}", file=sys.stderr, flush=True)