Files
cad-agent/mcp_bridge/protocol.py
T
gjm f33d42afe9 feat(bridge): 新增 stdio↔TCP 桥,让标准 MCP 客户端可直连 CAD 基座
标准 MCP 客户端(Claude Desktop / CodeBuddy / MCP Inspector)只实现 stdio 与
Streamable HTTP,无法直连 cad_mcp_frame 的自定义裸 TCP。新增 mcp_bridge 包在
客户端与基座之间做协议适配,CAD 侧零改动:

- 合成基座缺失的生命周期:initialize(含协议版本协商、capabilities、serverInfo)
  与 ping;notifications/* 按规范不回包。
- tools/list、tools/call 原样转发到 127.0.0.1:8080,result 与顶层 error 忠实透传,
  仅把 id 改写回客户端原始 id。
- 并发:读线程即时应答 initialize/ping,单工作线程串行转发工具调用(保证顺序、
  慢工具不阻塞 ping)。
- stdout 只走 sys.stdout.buffer 并加锁,规避 Windows 的 \n→\r\n 翻译;
  诊断信息一律走 stderr。
- CAD 未启动时桥照常启动,工具调用回 -32603(可重试)。
- 入口:python -m mcp_bridge,支持 --host/--port/--timeout/--verbose/--print-config。

dwg_review/mcp_client.py 增加 raw_rpc()(_rpc 新增 normalize 开关),
返回基座原始响应 dict 供桥透传,避免重复实现 TCP 封装。

验证:
- verify_stdio_bridge.py 24/24 PASS(版本协商、通知不回包、ping、tools/list 与基座
  等价、tools/call、错误透传、-32700、string id、stdout 纯净性、死端口降级)。
- 真实标准客户端 MCP Inspector 冒烟通过:tools/list 拿到 26 个工具,
  tools/list --strict 退出码 0(无 schema 可移植性问题),
  tools/call get_drawing_info 返回标准 content + isError:false。
- 回归:verify_mcp_frame.py 4/4、verify_annotate.py 9/9、dwg_review --list-tools/--check 正常。
2026-10-14 00:44:15 +08:00

68 lines
2.4 KiB
Python

"""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 已启动且已加载插件时可用;未启动时调用会返回连接错误。"
),
}