Merge pull request 'feat(bridge): 新增 stdio↔TCP 桥,让标准 MCP 客户端可直连 CAD 基座' (#8) from home-lenovo-legion-r9000/home-ai-dev into main
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
commit
4ab591ad23
11 files changed
+669
-7
No files matched your search
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
- [CADProject 图纸审查 Agent 工作](project_dwg_review_agent.md) — 基于 MCP 框架的图纸审查工具集与逐图框审查流程
|
- [CADProject 图纸审查 Agent 工作](project_dwg_review_agent.md) — 基于 MCP 框架的图纸审查工具集与逐图框审查流程
|
||||||
- [LLM 识图审图 Agent 架构决策](project_review_agent_impl.md) — 混合式自研 agent、基座 B 最小集、标准化先走 stdio 桥;含 dwg_review 包结构
|
- [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 只加载一次
|
- [MCP 基座 c++_dll 加载与执行的坑](project_mcp_frame_dll_loader.md) — acrxGetApiVersion 有状态不可比对、写库需文档锁、同一 DLL 只加载一次
|
||||||
- [用户协作偏好](feedback_preferences.md) — 调试用 Debug/R230、模型 deepseek-flash、DLL 集中在 cad_mcp_plugins 等
|
- [用户协作偏好](feedback_preferences.md) — 调试用 Debug/R230、模型 deepseek-flash、DLL 集中在 cad_mcp_plugins 等
|
||||||
- [环境与外部资源](reference_environment.md) — 构建/运行要点、CAD SDK 路径、测试脚本
|
- [环境与外部资源](reference_environment.md) — 构建/运行要点、CAD SDK 路径、测试脚本
|
||||||
@@ -16,6 +16,8 @@ type: project
|
|||||||
4. **基座改造:只做「B 最小集」**——换行分帧(`\n` 为消息边界)、去掉单次 `recv(2048)` 限制、
|
4. **基座改造:只做「B 最小集」**——换行分帧(`\n` 为消息边界)、去掉单次 `recv(2048)` 限制、
|
||||||
`isError` 映射、`id` 支持 string/number + 通知不回包;**不做** `initialize`/`ping` 握手。
|
`isError` 映射、`id` 支持 string/number + 通知不回包;**不做** `initialize`/`ping` 握手。
|
||||||
5. **标准化路线**:先自研 agent 跑通 → 再做 stdio↔TCP 桥 → 远期才考虑 Streamable HTTP。
|
5. **标准化路线**:先自研 agent 跑通 → 再做 stdio↔TCP 桥 → 远期才考虑 Streamable HTTP。
|
||||||
|
(**2026-10-14 更新**:stdio 桥**已实现并验证通过**,见 `project_stdio_bridge.md`。
|
||||||
|
远期仅剩 Streamable HTTP。)
|
||||||
|
|
||||||
**Why(关键结论,非显而易见)**:
|
**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
|
# 高层 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
|
retries = config.CALL_RETRIES if retries is None else retries
|
||||||
if is_write:
|
if is_write:
|
||||||
retries = 0 # 写类工具不重试,避免重复副作用
|
retries = 0 # 写类工具不重试,避免重复副作用
|
||||||
@@ -126,7 +127,8 @@ class McpClient:
|
|||||||
if params is not None:
|
if params is not None:
|
||||||
payload["params"] = params
|
payload["params"] = params
|
||||||
try:
|
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):
|
except (McpError, McpToolError):
|
||||||
raise # 协议/工具错误是确定性的,不重试
|
raise # 协议/工具错误是确定性的,不重试
|
||||||
except (OSError, socket.timeout) as exc:
|
except (OSError, socket.timeout) as exc:
|
||||||
@@ -156,6 +158,14 @@ class McpClient:
|
|||||||
params = {"name": name, "arguments": arguments or {}}
|
params = {"name": name, "arguments": arguments or {}}
|
||||||
return self._rpc("tools/call", params, timeout=timeout, retries=retries, is_write=is_write)
|
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):
|
def list_tools(self, *, use_cache=True):
|
||||||
"""返回 tools/list 的 tools 数组。"""
|
"""返回 tools/list 的 tools 数组。"""
|
||||||
if use_cache and self._tools_cache is not None:
|
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);
|
return it->second->Execute(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找不到工具时,返回标准的 JSON-RPC 错误对象
|
// 找不到工具时,返回标准的 JSON-RPC 协议错误。
|
||||||
return mcp_Tool_Utility::make_error(-32601, L"Specified tool not found: " + mcp_utils::utf8ToWString(name));
|
// 依 MCP 规范,未知工具属于「协议错误」,示例码为 -32602 (Invalid params)。
|
||||||
|
return mcp_Tool_Utility::make_error(-32602, L"Unknown tool: " + mcp_utils::utf8ToWString(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
void mcp_Tools::print()
|
void mcp_Tools::print()
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ def test_string_id():
|
|||||||
|
|
||||||
|
|
||||||
def test_large_request():
|
def test_large_request():
|
||||||
"""构造 >2KB 的 arguments;旧基座会截断成 -32700,新基座应正常返回 -32601。"""
|
"""构造 >2KB 的 arguments;旧基座会截断成 -32700,新基座应正常返回 -32602。"""
|
||||||
big = {"blob": "x" * 5000}
|
big = {"blob": "x" * 5000}
|
||||||
resp = json.loads(
|
resp = json.loads(
|
||||||
_call(
|
_call(
|
||||||
@@ -62,8 +62,8 @@ def test_large_request():
|
|||||||
).decode()
|
).decode()
|
||||||
)
|
)
|
||||||
code = (resp.get("error") or {}).get("code")
|
code = (resp.get("error") or {}).get("code")
|
||||||
assert code == -32601, f"期望 -32601(工具不存在),实际 {resp}"
|
assert code == -32602, f"期望 -32602(未知工具,协议错误),实际 {resp}"
|
||||||
print("[PASS] >2KB 请求未被截断(返回 -32601 而非 -32700)")
|
print("[PASS] >2KB 请求未被截断(返回 -32602 而非 -32700)")
|
||||||
|
|
||||||
|
|
||||||
def test_notification_no_reply():
|
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