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 正常。
This commit is contained in:
gjm committed 2026-10-14 00:44:15 +08:00
1 parent e56ce09caf
commit f33d42afe9
9 files changed
+660 -2

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
"""mcp_bridge —— 把标准 MCP 客户端的 stdio 桥接到 CAD 基座的裸 TCP。
标准 MCP 客户端(Claude Desktop / CodeBuddy / MCP Inspector)只实现
stdio 与 Streamable HTTP 两种传输,无法直连 cad_mcp_frame 的自定义 TCP。
本桥在客户端与基座之间做协议适配:
- 合成基座缺失的生命周期(initialize / ping)
- 把 tools/list、tools/call 原样转发给基座(默认 127.0.0.1:8080)
- 其余方法按 JSON-RPC / MCP 规范应答
入口:
python -m mcp_bridge --help
python -m mcp_bridge --print-config # 打印可粘进客户端的配置片段
"""
__version__ = "0.1.0"
+69
View File
@@ -0,0 +1,69 @@
"""入口:python -m mcp_bridge [options]"""
import argparse
import json
import sys
from dwg_review import config
from . import __version__
from .server import StdioBridge
def build_parser():
parser = argparse.ArgumentParser(
prog="mcp_bridge",
description="把标准 MCP 客户端(stdio)桥接到 CAD 基座(裸 TCP)。",
)
parser.add_argument("--host", default=None, help=f"CAD 基座主机(默认 {config.HOST})")
parser.add_argument("--port", type=int, default=None,
help=f"CAD 基座端口(默认 {config.PORT})")
parser.add_argument("--timeout", type=float, default=None,
help=f"单次调用超时秒数(默认 {config.CALL_TIMEOUT})")
parser.add_argument("--verbose", action="store_true", help="把诊断信息打到 stderr")
parser.add_argument("--print-config", action="store_true",
help="打印可直接粘进 MCP 客户端的配置片段后退出")
return parser
def print_config(args):
host = args.host or config.HOST
port = args.port if args.port is not None else config.PORT
snippet = {
"mcpServers": {
"cad-autocad": {
"command": sys.executable,
"args": ["-m", "mcp_bridge"],
"env": {
# 用绝对路径,避免依赖客户端的工作目录
"PYTHONPATH": str(config.PROJECT_ROOT),
"CAD_MCP_HOST": host,
"CAD_MCP_PORT": str(port),
},
}
}
}
print(json.dumps(snippet, ensure_ascii=False, indent=2))
return 0
def main(argv=None):
args = build_parser().parse_args(argv)
if args.print_config:
return print_config(args)
bridge = StdioBridge(
host=args.host, port=args.port, timeout=args.timeout, verbose=args.verbose
)
if args.verbose:
print(
f"[mcp_bridge] v{__version__} 转发到 {bridge.host}:{bridge.port},"
f"单次超时 {bridge.timeout}s",
file=sys.stderr, flush=True,
)
bridge.run()
return 0
if __name__ == "__main__":
sys.exit(main())
+67
View File
@@ -0,0 +1,67 @@
"""MCP 协议常量与纯函数。
本模块**不含任何 IO**,只做版本协商、信封构造与方法路由判定,便于单独测试。
"""
from . import __version__ as SERVER_VERSION
# 本桥支持的协议版本,从新到旧。initialize 时按客户端请求协商。
PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
DEFAULT_VERSION = PROTOCOL_VERSIONS[0]
SERVER_NAME = "cad-mcp-bridge"
# classify() 的四种路由结果
LOCAL = "local" # 由桥自己应答(基座没有实现)
FORWARD = "forward" # 原样转发给 CAD 基座
NOTIFY = "notify" # 通知,按规范不回包
UNKNOWN = "unknown" # 未知方法,回 -32601
_LOCAL_METHODS = frozenset({"initialize", "ping"})
_FORWARD_METHODS = frozenset({"tools/list", "tools/call"})
def negotiate_version(requested):
"""协议版本协商:客户端请求的版本受支持则回显,否则回本桥支持的最新版本。
依据 MCP 规范:服务端支持的版本必须原样返回;不支持时回退到自己支持的版本,
由客户端决定是否继续。
"""
return requested if requested in PROTOCOL_VERSIONS else DEFAULT_VERSION
def classify(method):
"""把方法名映射到路由动作。"""
if method in _LOCAL_METHODS:
return LOCAL
if method in _FORWARD_METHODS:
return FORWARD
if method.startswith("notifications/"):
return NOTIFY
return UNKNOWN
def ok(msg_id, result):
"""构造 JSON-RPC 成功响应。"""
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
def err(msg_id, code, message):
"""构造 JSON-RPC 错误响应。"""
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
def initialize_result(params):
"""合成 initialize 的 result(基座未实现生命周期,由桥代答)。"""
requested = ""
if isinstance(params, dict):
requested = params.get("protocolVersion") or ""
return {
"protocolVersion": negotiate_version(requested),
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
"instructions": (
"本服务器通过 stdio 桥接本机 AutoCAD 的 cad_mcp_frame 基座。"
"工具仅在 CAD 已启动且已加载插件时可用;未启动时调用会返回连接错误。"
),
}
+168
View File
@@ -0,0 +1,168 @@
"""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)