feat(bridge): 新增 stdio↔TCP 桥,让标准 MCP 客户端可直连 CAD 基座 #8
No files matched your search
@@ -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 路径、测试脚本
|
||||
@@ -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(关键结论,非显而易见)**:
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
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:-32602`(`"Unknown tool: <name>"`)→ 桥忠实透传 |
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
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. **不擅自改写语义**:桥只做忠实透传,错误码一律由基座决定。
|
||||
(**2026-10-14 修正**:基座原先对未知工具回 `-32601`,语义偏 JSON-RPC 方法层;
|
||||
按 MCP 规范「未知工具属于**协议错误**,示例码 `-32602`」已改为 `-32602` +
|
||||
`"Unknown tool: <name>"`。改动点:`mcp_configParser.cpp: mcp_Tools::Call`
|
||||
—— 只需改码值,`mcp_server.cpp: HandleToolsCall` 的协议错误白名单
|
||||
`{-32700,-32600,-32601,-32602}` 已含 `-32602`,分类逻辑无需动。)
|
||||
|
||||
## 验证方法(重要,含踩坑)
|
||||
|
||||
- `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` 用绝对路径,
|
||||
避免依赖客户端的工作目录)。
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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 已启动且已加载插件时可用;未启动时调用会返回连接错误。"
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
+3
-3
@@ -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():
|
||||
|
||||
@@ -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 == -32602(未知工具)", code == -32602, 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())
|
||||
Reference in new issue
Block a user