feat(bridge): 新增 stdio↔TCP 桥,让标准 MCP 客户端可直连 CAD 基座 #8

Merged
gjm merged 2 commits from home-lenovo-legion-r9000/home-ai-dev AGit into main 2026-10-14 13:33:35 +08:00
11 changed files with 669 additions and 7 deletions

No files matched your search

+1
View File
@@ -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 路径、测试脚本
+2
View File
@@ -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(关键结论,非显而易见)**:
+65
View File
@@ -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` 用绝对路径,
避免依赖客户端的工作目录)。
+12 -2
View File
@@ -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:
+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)
+3 -2
View File
@@ -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
View File
@@ -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():
+263
View File
@@ -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())