From f33d42afe9aa2e591690dd93d422ee663f128e8a Mon Sep 17 00:00:00 2001 From: LittleGuo Date: Wed, 14 Oct 2026 00:44:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(bridge):=20=E6=96=B0=E5=A2=9E=20stdio?= =?UTF-8?q?=E2=86=94TCP=20=E6=A1=A5=EF=BC=8C=E8=AE=A9=E6=A0=87=E5=87=86=20?= =?UTF-8?q?MCP=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=8F=AF=E7=9B=B4=E8=BF=9E=20?= =?UTF-8?q?CAD=20=E5=9F=BA=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 标准 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 正常。 --- ai.memory/MEMORY.md | 1 + ai.memory/project_review_agent_impl.md | 2 + ai.memory/project_stdio_bridge.md | 62 ++++++ dwg_review/mcp_client.py | 14 +- mcp_bridge/__init__.py | 16 ++ mcp_bridge/__main__.py | 69 +++++++ mcp_bridge/protocol.py | 67 +++++++ mcp_bridge/server.py | 168 ++++++++++++++++ verify_stdio_bridge.py | 263 +++++++++++++++++++++++++ 9 files changed, 660 insertions(+), 2 deletions(-) create mode 100644 ai.memory/project_stdio_bridge.md create mode 100644 mcp_bridge/__init__.py create mode 100644 mcp_bridge/__main__.py create mode 100644 mcp_bridge/protocol.py create mode 100644 mcp_bridge/server.py create mode 100644 verify_stdio_bridge.py diff --git a/ai.memory/MEMORY.md b/ai.memory/MEMORY.md index 4afbb1f..ba6012c 100644 --- a/ai.memory/MEMORY.md +++ b/ai.memory/MEMORY.md @@ -6,6 +6,7 @@ - [CADProject 图纸审查 Agent 工作](project_dwg_review_agent.md) — 基于 MCP 框架的图纸审查工具集与逐图框审查流程 - [LLM 识图审图 Agent 架构决策](project_review_agent_impl.md) — 混合式自研 agent、基座 B 最小集、标准化先走 stdio 桥;含 dwg_review 包结构 +- [stdio↔TCP 桥(MCP 标准化)](project_stdio_bridge.md) — mcp_bridge 包:基座协议缺口、读线程+单工作线程设计、MCP Inspector 验证方法与踩坑 - [MCP 基座 c++_dll 加载与执行的坑](project_mcp_frame_dll_loader.md) — acrxGetApiVersion 有状态不可比对、写库需文档锁、同一 DLL 只加载一次 - [用户协作偏好](feedback_preferences.md) — 调试用 Debug/R230、模型 deepseek-flash、DLL 集中在 cad_mcp_plugins 等 - [环境与外部资源](reference_environment.md) — 构建/运行要点、CAD SDK 路径、测试脚本 diff --git a/ai.memory/project_review_agent_impl.md b/ai.memory/project_review_agent_impl.md index d466d38..ed907f0 100644 --- a/ai.memory/project_review_agent_impl.md +++ b/ai.memory/project_review_agent_impl.md @@ -16,6 +16,8 @@ type: project 4. **基座改造:只做「B 最小集」**——换行分帧(`\n` 为消息边界)、去掉单次 `recv(2048)` 限制、 `isError` 映射、`id` 支持 string/number + 通知不回包;**不做** `initialize`/`ping` 握手。 5. **标准化路线**:先自研 agent 跑通 → 再做 stdio↔TCP 桥 → 远期才考虑 Streamable HTTP。 + (**2026-10-14 更新**:stdio 桥**已实现并验证通过**,见 `project_stdio_bridge.md`。 + 远期仅剩 Streamable HTTP。) **Why(关键结论,非显而易见)**: diff --git a/ai.memory/project_stdio_bridge.md b/ai.memory/project_stdio_bridge.md new file mode 100644 index 0000000..d77ad2a --- /dev/null +++ b/ai.memory/project_stdio_bridge.md @@ -0,0 +1,62 @@ +--- +name: stdio↔TCP 桥(MCP 标准化) +description: mcp_bridge 包——把标准 MCP 客户端的 stdio 桥接到 CAD 基座裸 TCP;含基座协议缺口、设计决策与用 MCP Inspector 验证的方法 +type: project +--- + +# stdio ↔ TCP 桥(`mcp_bridge/`) + +**状态:已实现并验证通过**(2026-10-14)。这是「MCP 标准化」路线的第二步 +(第一步是基座 B 最小集,见 `project_review_agent_impl.md`)。 + +## 为什么需要它(非显而易见) + +基座是**裸 TCP 自定义传输**,标准 MCP 客户端(Claude Desktop / CodeBuddy / MCP Inspector) +只实现 **stdio** 与 **Streamable HTTP**,因此**无法直连基座**。桥在客户端与基座之间做协议适配, +**CAD 侧零改动**。 + +## 基座的协议缺口(实测,决定桥要补什么) + +| 项 | 事实 | +| --- | --- | +| 连接模型 | 一个连接**只处理一帧**,响应后立即 `closesocket`(`TaskData` 析构)→ 桥每次请求新建连接 | +| 支持方法 | **只有** `tools/list` / `tools/call`;其余回 `-32601` | +| 生命周期 | **没有** `initialize` / `initialized` / `ping` → **必须由桥合成** | +| 通知 | 无 `id` 即不回包 | +| `tools/list` | 26 个工具,`name`/`description`/`inputSchema(type=object)` 已是标准 MCP 形状 → **可原样转发** | +| 未知工具 | 回**顶层** `error:-32601`(不是 `result.isError`)→ 桥忠实透传 | + +## 关键设计决策 + +1. **位置**:顶层包 `mcp_bridge/`;TCP 复用 `dwg_review.mcp_client` 的 `raw_rpc()` + (新增:`_rpc(normalize=False)` 返回基座**原始响应 dict**,不做 error/isError 归一化, + 这样桥才能把 `result` 与顶层 `error` 原样透传)。桥调用时 `retries=0`: + "发完即超时"语义有歧义,重试可能**重复执行写类工具**。 +2. **并发**:**读线程 + 单工作线程队列**。`initialize`/`ping` 在读线程即时应答, + 不会被慢工具阻塞;`tools/list`/`tools/call` 由单工作线程**串行**转发,保证调用顺序。 +3. **stdout 纯净性**:只用 `sys.stdout.buffer.write(... + b"\n")` + `flush()`(加锁)。 + 用 `buffer` 而非文本流,**规避 Windows 的 `\n`→`\r\n` 翻译**;所有日志只走 stderr。 +4. **CAD 未启动**:桥照常启动,`initialize`/`ping` 正常,`tools/list` 回 `-32603`(可重试)。 +5. **不擅自改写语义**:基座对未知工具回顶层 `-32601`("Method not found")语义上偏 JSON-RPC + 方法层,严格说应是 `-32602` 或 `result.isError`。桥**不改写**;要修应改基座 + `mcp_server.cpp: HandleToolsCall` 的码值分类。 + +## 验证方法(重要,含踩坑) + +- `verify_stdio_bridge.py`(仓库根):子进程拉起桥,24 项断言全绿(版本协商、通知不回包、 + ping、`tools/list` 与基座**等价**、`tools/call`、错误透传、`-32700`、string id、 + stdout 纯净性、死端口降级)。 +- **用真实标准客户端(MCP Inspector)验证**——这是"标准化"的终极证明: + ```bash + npx --yes @modelcontextprotocol/inspector --cli \ + --config <配置文件> --server cad-autocad --method tools/list --strict + ``` + - 实测:`tools/list` 拿到 26 个工具;`--strict` 退出码 **0**(无 schema 可移植性问题); + `--method tools/call --tool-name get_drawing_info` 返回标准 `content` + `isError:false`。 + - **踩坑**:Inspector CLI 在 Windows 上**参数解析很别扭**——用 `-- python -m mcp_bridge` + 会把 `--method` 一起当成 target 传给 python(argparse 报错 → "Connection closed"); + 不用 `--` 又报 "Target is required"。 + **可靠做法:写一个 `{"mcpServers":{...}}` 配置文件,用 `--config` + `--server`**。 + 这也顺带验证了用户实际要粘贴的配置格式。 + - `python -m mcp_bridge --print-config` 可直接生成该配置片段(`PYTHONPATH` 用绝对路径, + 避免依赖客户端的工作目录)。 diff --git a/dwg_review/mcp_client.py b/dwg_review/mcp_client.py index f47ecbc..8211ed4 100644 --- a/dwg_review/mcp_client.py +++ b/dwg_review/mcp_client.py @@ -112,7 +112,8 @@ class McpClient: # ------------------------------------------------------------------ # 高层 RPC # ------------------------------------------------------------------ - def _rpc(self, method, params=None, *, timeout=None, retries=None, is_write=False): + 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 # 写类工具不重试,避免重复副作用 @@ -126,7 +127,8 @@ class McpClient: if params is not None: payload["params"] = params try: - return self._normalize(self._send_frame(payload, timeout)) + resp = self._send_frame(payload, timeout) + return self._normalize(resp) if normalize else resp except (McpError, McpToolError): raise # 协议/工具错误是确定性的,不重试 except (OSError, socket.timeout) as exc: @@ -156,6 +158,14 @@ class McpClient: 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: diff --git a/mcp_bridge/__init__.py b/mcp_bridge/__init__.py new file mode 100644 index 0000000..726dd9f --- /dev/null +++ b/mcp_bridge/__init__.py @@ -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" diff --git a/mcp_bridge/__main__.py b/mcp_bridge/__main__.py new file mode 100644 index 0000000..0a0c4d6 --- /dev/null +++ b/mcp_bridge/__main__.py @@ -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()) diff --git a/mcp_bridge/protocol.py b/mcp_bridge/protocol.py new file mode 100644 index 0000000..e85168e --- /dev/null +++ b/mcp_bridge/protocol.py @@ -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 已启动且已加载插件时可用;未启动时调用会返回连接错误。" + ), + } diff --git a/mcp_bridge/server.py b/mcp_bridge/server.py new file mode 100644 index 0000000..02c0d16 --- /dev/null +++ b/mcp_bridge/server.py @@ -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) diff --git a/verify_stdio_bridge.py b/verify_stdio_bridge.py new file mode 100644 index 0000000..9806f2b --- /dev/null +++ b/verify_stdio_bridge.py @@ -0,0 +1,263 @@ +"""mcp_bridge(stdio ↔ TCP 桥)验证脚本。 + +以子进程方式拉起桥,走真实的 stdin/stdout 协议,覆盖: + 版本协商、通知不回包、ping、tools/list 等价性、tools/call、 + 错误透传、-32700、string 型 id、stdout 纯净性、CAD 不可达时的降级。 + +前置:CAD 已启动并加载插件(默认 127.0.0.1:8080)。 +用法:python verify_stdio_bridge.py +""" + +import json +import os +import queue +import subprocess +import sys +import threading +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + +from dwg_review import config # noqa: E402 +from dwg_review.mcp_client import McpClient # noqa: E402 + +RESULTS = [] + + +def check(name, cond, detail=""): + RESULTS.append((name, bool(cond), detail)) + mark = "PASS" if cond else "FAIL" + line = f" [{mark}] {name}" + if detail and not cond: + line += f" <- {detail}" + print(line, flush=True) + return bool(cond) + + +class Bridge: + """把 mcp_bridge 当子进程拉起,提供 send/recv。""" + + def __init__(self, env_overrides=None): + env = os.environ.copy() + env["PYTHONPATH"] = str(ROOT) + env["PYTHONUNBUFFERED"] = "1" + if env_overrides: + env.update(env_overrides) + + self.proc = subprocess.Popen( + [sys.executable, "-m", "mcp_bridge", "--verbose"], + cwd=str(ROOT), + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self._lines = queue.Queue() + self._stderr = [] + self._bad_lines = [] + self._closed = False + threading.Thread(target=self._pump_stdout, daemon=True).start() + threading.Thread(target=self._pump_stderr, daemon=True).start() + + def _pump_stdout(self): + for raw in self.proc.stdout: + text = raw.decode("utf-8", errors="replace").strip() + if not text: + continue + try: + self._lines.put(json.loads(text)) + except json.JSONDecodeError: + self._bad_lines.append(text) + + def _pump_stderr(self): + for raw in self.proc.stderr: + self._stderr.append(raw.decode("utf-8", errors="replace").rstrip()) + + def send_raw(self, payload): + self.proc.stdin.write(payload if isinstance(payload, bytes) else payload.encode("utf-8")) + self.proc.stdin.flush() + + def send(self, obj): + self.send_raw(json.dumps(obj, ensure_ascii=False) + "\n") + + def recv(self, timeout=25.0): + try: + return self._lines.get(timeout=timeout) + except queue.Empty: + return None + + def expect_none(self, timeout=1.5): + """确认在 timeout 内没有新报文。""" + try: + return self._lines.get(timeout=timeout) + except queue.Empty: + return None + + def close(self): + if self._closed: + return + self._closed = True + try: + self.proc.stdin.close() + except OSError: + pass + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=5) + + @property + def bad_lines(self): + return self._bad_lines + + +def main(): + print("=== mcp_bridge stdio 桥验证 ===\n") + + if not McpClient().is_available(): + print(f"[X] CAD 基座 {config.HOST}:{config.PORT} 未监听,请先启动已加载插件的 CAD。") + return 1 + direct_tools = McpClient().list_tools() + direct_names = {t["name"] for t in direct_tools} + print(f"基座直连:{len(direct_names)} 个工具\n") + + bridge = Bridge() + try: + # ---- 1. initialize 版本协商 ---- + print("[1] initialize / 版本协商") + bridge.send({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2024-11-05", + "capabilities": {}, "clientInfo": {"name": "verify", "version": "0"}}}) + r = bridge.recv() + res = (r or {}).get("result") or {} + check("请求 2024-11-05 → 原样回显", res.get("protocolVersion") == "2024-11-05", + f"got {res.get('protocolVersion')!r}") + check("capabilities.tools 已宣告", "tools" in (res.get("capabilities") or {}), + f"got {res.get('capabilities')!r}") + check("serverInfo.name 正确", (res.get("serverInfo") or {}).get("name") == "cad-mcp-bridge", + f"got {res.get('serverInfo')!r}") + check("id 原样回显", (r or {}).get("id") == 1, f"got {(r or {}).get('id')!r}") + + # ---- 2. 未知版本 → 回退默认 ---- + print("[2] initialize / 未知版本回退") + bridge.send({"jsonrpc": "2.0", "id": "ver-2", "method": "initialize", + "params": {"protocolVersion": "1999-01-01"}}) + r = bridge.recv() + got = ((r or {}).get("result") or {}).get("protocolVersion") + check("未知版本 → DEFAULT_VERSION", got == "2025-06-18", f"got {got!r}") + check("string 型 id 原样回显", (r or {}).get("id") == "ver-2", f"got {(r or {}).get('id')!r}") + + # ---- 3. 通知不回包 ---- + print("[3] notifications/initialized 不回包") + bridge.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + bridge.send({"jsonrpc": "2.0", "id": 3, "method": "ping"}) + r = bridge.recv() + check("下一条即 ping 响应(证明通知未回包)", (r or {}).get("id") == 3, + f"got {(r or {}).get('id')!r}") + + # ---- 4. ping ---- + print("[4] ping") + check("ping → result == {}", (r or {}).get("result") == {}, f"got {(r or {}).get('result')!r}") + + # ---- 5. tools/list 与基座等价 ---- + print("[5] tools/list") + bridge.send({"jsonrpc": "2.0", "id": 5, "method": "tools/list"}) + r = bridge.recv() + tools = ((r or {}).get("result") or {}).get("tools") or [] + names = {t.get("name") for t in tools} + check(f"工具数与基座一致({len(direct_names)})", names == direct_names, + f"桥 {len(names)} vs 基座 {len(direct_names)}") + bad = [t.get("name") for t in tools + if not {"name", "description", "inputSchema"} <= set(t.keys()) + or not isinstance(t.get("inputSchema"), dict) + or t["inputSchema"].get("type") != "object"] + check("所有工具具备标准 MCP 字段", not bad, f"异常工具: {bad}") + + # ---- 6. tools/call 正常路径(string id) ---- + print("[6] tools/call / 正常路径") + bridge.send({"jsonrpc": "2.0", "id": "call-1", "method": "tools/call", + "params": {"name": "get_drawing_info", "arguments": {}}}) + r = bridge.recv() + res = (r or {}).get("result") or {} + check("result.content 非空", bool(res.get("content")), f"got {res!r}") + check("isError == false", res.get("isError") is False, f"got {res.get('isError')!r}") + check("string 型 id 经 worker 改写后原样回显", (r or {}).get("id") == "call-1", + f"got {(r or {}).get('id')!r}") + + # ---- 7. 未知工具:基座顶层 error 被透传 ---- + print("[7] tools/call / 未知工具") + bridge.send({"jsonrpc": "2.0", "id": 7, "method": "tools/call", + "params": {"name": "__no_such_tool__", "arguments": {}}}) + r = bridge.recv() + code = ((r or {}).get("error") or {}).get("code") + check("透传顶层 error.code == -32601", code == -32601, f"got {r!r}") + check("error 响应的 id 正确", (r or {}).get("id") == 7, f"got {(r or {}).get('id')!r}") + + # ---- 8. 未知方法 ---- + print("[8] 未知方法") + bridge.send({"jsonrpc": "2.0", "id": 8, "method": "resources/list"}) + r = bridge.recv() + check("resources/list → -32601", ((r or {}).get("error") or {}).get("code") == -32601, + f"got {r!r}") + + # ---- 9. 非法 JSON ---- + print("[9] 非法 JSON") + bridge.send_raw(b"{ this is not json }\n") + r = bridge.recv() + check("非法 JSON → -32700", ((r or {}).get("error") or {}).get("code") == -32700, + f"got {r!r}") + check("Parse error 的 id 为 null", (r or {}).get("id") is None, f"got {(r or {}).get('id')!r}") + + # ---- 10. 通知带 id 时不应悬挂 ---- + print("[10] 通知误带 id") + bridge.send({"jsonrpc": "2.0", "id": 10, "method": "notifications/cancelled", + "params": {"requestId": 1}}) + r = bridge.recv() + check("带 id 的通知 → -32601(不悬挂)", ((r or {}).get("error") or {}).get("code") == -32601, + f"got {r!r}") + + # ---- 11. stdout 纯净性 ---- + print("[11] stdout 纯净性") + check("stdout 无非 JSON 行", not bridge.bad_lines, + f"异常行: {bridge.bad_lines[:3]}") + finally: + bridge.close() + + # ---- 12. CAD 不可达时的降级 ---- + print("[12] CAD 不可达(指向死端口)") + dead = Bridge(env_overrides={"CAD_MCP_PORT": "59999"}) + try: + dead.send({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18"}}) + r = dead.recv(timeout=15) + check("initialize 仍成功", ((r or {}).get("result") or {}).get("protocolVersion") == "2025-06-18", + f"got {r!r}") + dead.send({"jsonrpc": "2.0", "id": 2, "method": "ping"}) + r = dead.recv(timeout=15) + check("ping 仍成功", (r or {}).get("result") == {}, f"got {r!r}") + dead.send({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}) + r = dead.recv(timeout=30) + code = ((r or {}).get("error") or {}).get("code") + check("tools/list → -32603(连接错误)", code == -32603, f"got {r!r}") + check("错误信息含地址", "59999" in json.dumps(r, ensure_ascii=False), f"got {r!r}") + finally: + dead.close() + + # ---- 汇总 ---- + passed = sum(1 for _, ok_, _ in RESULTS if ok_) + total = len(RESULTS) + print(f"\n=== {passed}/{total} PASS ===") + if passed != total: + print("\n失败项:") + for name, ok_, detail in RESULTS: + if not ok_: + print(f" - {name}: {detail}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3bd77d950e5e88456350dec4e29e3cbe1b74a1e9 Mon Sep 17 00:00:00 2001 From: LittleGuo Date: Wed, 14 Oct 2026 06:21:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(frame):=20=E6=9C=AA=E7=9F=A5=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=94=99=E8=AF=AF=E7=A0=81=20-32601=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20-32602=EF=BC=8C=E7=AC=A6=E5=90=88=20MCP=20=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP 规范《Tools / Error Handling》把「未知工具」归为**协议错误**, 示例即为 {"code": -32602, "message": "Unknown tool: invalid_tool_name"}。 基座原先回 -32601(Method not found),语义偏 JSON-RPC 方法层。 - mcp_configParser.cpp: mcp_Tools::Call 改 -32601 → -32602, 消息改为 "Unknown tool: "。 - 无需改 mcp_server.cpp 的 HandleToolsCall:其协议错误白名单 {-32700,-32600,-32601,-32602} 已含 -32602,分类逻辑不变。 - 同步更新 verify_mcp_frame.py 与 verify_stdio_bridge.py 的断言 (未知「方法」仍是 -32601,语义正确,不动)。 验证(重建基座 + 重启 CAD 后实测): - 直连 TCP:{"error":{"code":-32602,"message":"Unknown tool: __no_such_tool__"}} - verify_mcp_frame.py 4/4 PASS;verify_stdio_bridge.py 24/24 PASS - 回归:verify_annotate.py 9/9、dwg_review --check 正常 - MCP Inspector 冒烟:tools/list 26 个工具、tools/call get_drawing_info 正常 --- ai.memory/project_stdio_bridge.md | 11 +++++++---- src/cad_mcp_frame/mcp_ConfigParser.cpp | 5 +++-- verify_mcp_frame.py | 6 +++--- verify_stdio_bridge.py | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ai.memory/project_stdio_bridge.md b/ai.memory/project_stdio_bridge.md index d77ad2a..a134ecc 100644 --- a/ai.memory/project_stdio_bridge.md +++ b/ai.memory/project_stdio_bridge.md @@ -24,7 +24,7 @@ type: project | 生命周期 | **没有** `initialize` / `initialized` / `ping` → **必须由桥合成** | | 通知 | 无 `id` 即不回包 | | `tools/list` | 26 个工具,`name`/`description`/`inputSchema(type=object)` 已是标准 MCP 形状 → **可原样转发** | -| 未知工具 | 回**顶层** `error:-32601`(不是 `result.isError`)→ 桥忠实透传 | +| 未知工具 | 回**顶层** `error:-32602`(`"Unknown tool: "`)→ 桥忠实透传 | ## 关键设计决策 @@ -37,9 +37,12 @@ type: project 3. **stdout 纯净性**:只用 `sys.stdout.buffer.write(... + b"\n")` + `flush()`(加锁)。 用 `buffer` 而非文本流,**规避 Windows 的 `\n`→`\r\n` 翻译**;所有日志只走 stderr。 4. **CAD 未启动**:桥照常启动,`initialize`/`ping` 正常,`tools/list` 回 `-32603`(可重试)。 -5. **不擅自改写语义**:基座对未知工具回顶层 `-32601`("Method not found")语义上偏 JSON-RPC - 方法层,严格说应是 `-32602` 或 `result.isError`。桥**不改写**;要修应改基座 - `mcp_server.cpp: HandleToolsCall` 的码值分类。 +5. **不擅自改写语义**:桥只做忠实透传,错误码一律由基座决定。 + (**2026-10-14 修正**:基座原先对未知工具回 `-32601`,语义偏 JSON-RPC 方法层; + 按 MCP 规范「未知工具属于**协议错误**,示例码 `-32602`」已改为 `-32602` + + `"Unknown tool: "`。改动点:`mcp_configParser.cpp: mcp_Tools::Call` + —— 只需改码值,`mcp_server.cpp: HandleToolsCall` 的协议错误白名单 + `{-32700,-32600,-32601,-32602}` 已含 `-32602`,分类逻辑无需动。) ## 验证方法(重要,含踩坑) diff --git a/src/cad_mcp_frame/mcp_ConfigParser.cpp b/src/cad_mcp_frame/mcp_ConfigParser.cpp index d95b9a3..a681ba4 100644 --- a/src/cad_mcp_frame/mcp_ConfigParser.cpp +++ b/src/cad_mcp_frame/mcp_ConfigParser.cpp @@ -255,8 +255,9 @@ nlohmann::json mcp_Tools::Call(const std::string &name, const nlohmann::json &ar return it->second->Execute(args); } - // 找不到工具时,返回标准的 JSON-RPC 错误对象 - return mcp_Tool_Utility::make_error(-32601, L"Specified tool not found: " + mcp_utils::utf8ToWString(name)); + // 找不到工具时,返回标准的 JSON-RPC 协议错误。 + // 依 MCP 规范,未知工具属于「协议错误」,示例码为 -32602 (Invalid params)。 + return mcp_Tool_Utility::make_error(-32602, L"Unknown tool: " + mcp_utils::utf8ToWString(name)); } void mcp_Tools::print() diff --git a/verify_mcp_frame.py b/verify_mcp_frame.py index 0e921f1..da1f7bc 100644 --- a/verify_mcp_frame.py +++ b/verify_mcp_frame.py @@ -49,7 +49,7 @@ def test_string_id(): def test_large_request(): - """构造 >2KB 的 arguments;旧基座会截断成 -32700,新基座应正常返回 -32601。""" + """构造 >2KB 的 arguments;旧基座会截断成 -32700,新基座应正常返回 -32602。""" big = {"blob": "x" * 5000} resp = json.loads( _call( @@ -62,8 +62,8 @@ def test_large_request(): ).decode() ) code = (resp.get("error") or {}).get("code") - assert code == -32601, f"期望 -32601(工具不存在),实际 {resp}" - print("[PASS] >2KB 请求未被截断(返回 -32601 而非 -32700)") + assert code == -32602, f"期望 -32602(未知工具,协议错误),实际 {resp}" + print("[PASS] >2KB 请求未被截断(返回 -32602 而非 -32700)") def test_notification_no_reply(): diff --git a/verify_stdio_bridge.py b/verify_stdio_bridge.py index 9806f2b..805f248 100644 --- a/verify_stdio_bridge.py +++ b/verify_stdio_bridge.py @@ -193,7 +193,7 @@ def main(): "params": {"name": "__no_such_tool__", "arguments": {}}}) r = bridge.recv() code = ((r or {}).get("error") or {}).get("code") - check("透传顶层 error.code == -32601", code == -32601, f"got {r!r}") + check("透传顶层 error.code == -32602(未知工具)", code == -32602, f"got {r!r}") check("error 响应的 id 正确", (r or {}).get("id") == 7, f"got {(r or {}).get('id')!r}") # ---- 8. 未知方法 ----