迭代18: QMT Bridge MCP 能力内建(R-020)—— 动态挂载 dsh-mcp-client + 状态出口(设置页/会话头部灯)

This commit is contained in:
2026-09-09 08:58:49 +08:00
parent 9382ace3a0
commit c700f2f760
16 changed files with 1402 additions and 9 deletions
@@ -0,0 +1,92 @@
# 计划:QMT Bridge MCP 能力内建(神之一手自注册 MCP,去三方插件依赖)(阶段航点)
> 编号:PLAN-018 | 粒度:阶段航点 创建:2026-09-08 | 状态:待实施(拟迭代 18)
> 派生自终极目标:目标-001(DSH 插件形态·复用宿主 MCP 能力)、目标-008(真实交易系统接入)
> 依据需求:**R-020(已定稿,2026-09-08Q1-Q6 老师拍板)** —— 符合入范围门槛
> 设计约束:技术约束-003(REST 直连不变)、技术约束-008(连接配置存储/热切换)、产品约束-012(会话头部指示灯体系)、UI约束-002(连接卡片);沿用技术约束-004/005/006/007
## 目标
让神之一手插件自带 QMT Bridge 的 MCP 能力:插件在自己的 apply 内按 QMT 连接配置动态挂载 **DSH 官方 @deepseek-ai/dsh-mcp-client**(url 自动派生自激活连接 baseUrl + /mcpserverName 固定 QMT_Bridge_MCP,工具以 mcp__QMT_Bridge_MCP__* 注册给模型;挂载/切换/卸载/状态均由神之一手在插件内管理);连接 CRUD/激活切换与 MCP 实例全生命周期联动;**卸载三方 dsh-skill-mcp-panel 并移除其宿主 cordis.patch.yml 受管块**,不再依赖三方面板加入 MCP 能力。REST 直连架构不变(技术约束-003)。
## 范围
**做**(对应 R-020 定稿边界,决策编号 Q1-Q6):
1. **依赖与可行性前置(R-020 计划阶段待办 #1,已实证 2026-09-08**@deepseek-ai/dsh-mcp-client@0.1.1-rc.2 已加为 one-divine-lot dependencies,从插件 lib 可 import(),模块导出 {name:'mcp-client', inject:['tools'], apply, Config},可用 ctx.plugin() 动态挂载(实证通过);
2. **服务端 MCP 管理器(新增 src/mcp/QmtMcpManager.js**
- 唯一 dsh-mcp-client 实例,serverName 固定 `QMT_Bridge_MCP`url = 激活连接 baseUrl + `/mcp`(自动派生,Q3);
- 启动:解析启动连接(默认→上次激活→第一条,空表回退 cordis qmtBaseUrl)后挂载(对齐 R-004 resolveStartupConnection);
- 生命周期编排:activate/update(地址变)/remove(激活被删切默认)→ 先卸后挂(对齐 qmt-connections API 现有热切换点,Q1 仅激活连接挂载);
- 失败语义(Q5):failOnStartupError:false + reconnect(对齐宿主现有受管块配置:initial 500ms / max 30s / maxAttempts 10);
- 状态缓存:{ state: connected|connecting|disconnected|disabled, serverName, url, toolCount, error, checkedAt },供状态端点与指示灯读取;
- 释放:ctx.effect dispose 时卸载实例;
3. **服务端状态出口(api/qmt-connections.js 或 market.js 扩展)**
- mcp-status 端点(含 refresh 即时重查)→ 设置页连接卡片 MCP 状态行 + 会话头部指示灯读取;
- sync-status 增加 mcp 域(Q4:头部指示灯数据源合并一处);
4. **设置页「QMT 连接配置」卡片 MCP 状态(UI约束-002 形态内扩展)**:卡片显示 MCP 状态(绿=已连接 + N 工具 / 红=失败原因 / 灰=未启用),「检查 MCP」按钮(手动触发,Q4);
5. **会话头部指示灯新增 MCP 状态灯(产品约束-012 体系扩展)**SyncIndicators 三灯 → 四灯(QMT连接|持仓数据|行情数据|MCP),MCP 灯三态对齐(绿=已连接 / 黄=重连中 / 红=断开 / 灰=未启用),点击=refreshQ4);
6. **移除三方 dsh-skill-mcp-panelQ22026-09-08 老师澄清)**dsh plugin remove dsh-skill-mcp-panel(连带其「技能 / MCP」设置菜单);编辑 ~/.dsh/profiles/web/cordis.patch.yml 删除其受管块(QMT_Bridge_MCP 条目,块外内容逐字节保留)——属宿主组成变更,按 editing-cordis-compositions 技能流程执行 + 老师确认;
7. **构建安装与验收**
**不做**(本期,R-020 边界):通用 MCP 服务器管理面板(Q6,含自建任意 MCP 管理 UI);插件 UI/tab 数据源改走 MCP(技术约束-003);多 serverName 并存;自研 MCP 协议客户端(老师定:官方 dsh-mcp-client 可依赖,不重造);认证/鉴权增强(QMT Bridge 无鉴权)。
## 程序结构(新增/改动)
```
src/
├── index.js # 服务端入口(改):注入 mcpManager,启动挂载 + 释放卸载;把 mcpManager 传入 registerApi
├── mcp/ # (新增域目录,技术约束-016 语义:无合适域先讨论,此处讨论定 = 新增 mcp 域)
│ └── QmtMcpManager.js # MCP 实例管理(官方 dsh-mcp-client 动态挂载:唯一实例 + 生命周期编排 + 状态缓存)
├── settings.js # (小改):暴露当前激活连接解析辅助(已有 getActiveQmtConnection 可复用,必要时补暴露)
├── api/
│ ├── index.js # (改):runtime 增 mcpManager,注册 mcp-status 方法分发
│ ├── qmt-connections.js # (改):热切换点(activate/update/remove)联动 mcpManager 重挂;新增 mcp-status 处理
│ └── market.js # (改):sync-status 增加 mcp 域
└── client/views/
├── QmtConnectionChip.jsx # (改):内置 SyncIndicators 传 mcp 状态读取
├── SyncIndicators.jsx # (改):三灯 → 四灯(+MCP),轮询 sync-status 读 mcp 域,点击 refresh
└── SettingsSection.jsx # (改):QmtConnectionCard 增 MCP 状态行 + 「检查 MCP」按钮
package.json # (改,已完成):dependencies + @deepseek-ai/dsh-mcp-client@0.1.1-rc.2 + @modelcontextprotocol/sdk@1.30.0
~/.dsh/profiles/web/cordis.patch.yml # (改):移除 dsh-skill-mcp-panel 受管块(Q2,宿主配置,独立步骤;插件本体 dsh plugin remove
```
## 实现步骤(建议顺序)
1. **依赖可行性实证**(任务 1 决策门):声明 peer + 构建后在宿主实测 import 解析;不可解析则停在这里与老师讨论替代通道;
2. **服务端 mcpManager**QmtMcpManager 骨架 + 启动挂载/卸载 + 状态缓存(先不接 API,日志验证挂载与工具注册);
3. **热切换联动**qmt-connections activate/update/remove 编排点追加 mcpManager.resync()(复用现有 dataSource.setBaseUrl 同点);
4. **状态端点**mcp-status + sync-status.mcp 域;本地 curl/HTTP 验证;
5. **设置页卡片**QmtConnectionCard 增 MCP 状态 + 检查按钮(复用测试连接交互模式与 Toast);
6. **会话头部 MCP 灯**SyncIndicators 四灯改造;验证与三灯并存样式一致(对齐 IndicatorDot/sep 布局与主题 token);
7. **宿主受管块移除**:编辑 cordis.patch.ymlediting-cordis-compositions 流程)→ 重载宿主 → 验证 mcp__QMT_Bridge_MCP__* 工具仍可用(这次来自神之一手);
8. **构建安装验收**:服务端+客户端构建,dsh plugin add / 重载;逐条核对验收标准。
## 验收标准
1. 移除宿主 cordis.patch.yml QMT_Bridge_MCP 受管块后,模型仍能使用 mcp__QMT_Bridge_MCP__qmt_* 工具(来源=神之一手动态挂载实例),工具集与移除前一致(同一 serverName/url);
2. 激活连接切换 → MCP 实例 url 跟随切换(工具可用目标随激活配置变化,观察工具调用返回目标数据源变化);
3. 编辑激活连接地址、删除激活连接(自动切默认)→ MCP 实例同步重挂,无残留旧连接工具;
4. 连接列表为空(无激活)→ 不挂 MCP 实例(mcp-status=disabled),插件其它功能不受影响;
5. QMT Bridge 不可达 → MCP 灯=红(断开),插件不崩、其余灯正常;恢复可达后自动重连至绿灯(Q5 语义);
6. 设置页连接卡片显示 MCP 状态(已连接 + N 工具 / 失败原因 / 未启用),「检查 MCP」可手动触发并刷新;
7. 会话头部出现第四个指示灯「MCP」:绿=已连接 / 黄=重连中 / 红=断开 / 灰=未启用,点击触发即时检查(Q4);
8. 现有功能(分仓/策略/交易/设置子 tab/三灯)无回归;typecheck + build 通过。
## 验收方法
- 移除受管块前先完成 1-6 步并在旧块存在时验证(同名冲突预期:两实例同 serverName 后加载报错——因此**验证顺序必须先移除受管块再加载新实例**,步骤 7 与 1-6 的顺序需在实施时按 HMR 重载节奏小心编排,见实施时核查点);
- 用 qmt 工具的 listTools/实际调用观察目标切换;断开 QMT 观察灯态与自动恢复;
- settings 卡片与头部灯截图/人工核验。
> **实施注意(同名冲突时序)**:宿主现有受管块与神之一手新实例同用 serverName QMT_Bridge_MCP,存活实例重复会报错。安全顺序:① 插件侧完整实现并构建 → ② 移除宿主受管块并重载(此窗口工具短暂消失可接受)→ ③ 插件实例接管(工具恢复,来源变为神之一手)。禁止两实例并存重载。
## 关联
- 需求:R-020(已定稿,2026-09-08)| 参考:mcp_router 动态挂载实证、dsh-skill-mcp-panel 受管块现状
- 设计约束新增(定稿时补充):技术约束:「插件内建 MCP 客户端注册规范」(唯一实例 serverName 固定 + 随激活连接 url 派生 + 生命周期联动);产品约束:「MCP 状态出口(设置卡片 + 会话头部 MCP 灯)」
## 记录
| 日期 | 变更 |
|---|---|
| 2026-09-08 | 依据 R-020 定稿(Q1-Q6)创建 PLAN-018 |
@@ -0,0 +1,68 @@
# 迭代 18 技术实现方案:QMT Bridge MCP 能力内建
> 依据:R-0202026-09-08 定稿,Q1-Q6 + 老师澄清:移除三方 dsh-skill-mcp-panel、官方 dsh-mcp-client 可依赖)| PLAN-018
> 日期:2026-09-08
## 目标架构
神之一手在 apply 内**动态挂载 DSH 官方 @deepseek-ai/dsh-mcp-client**(依赖官方库、管理在插件内),把 QMT Bridge 的 MCP 工具注册给模型;MCP 实例生命周期(挂/切/卸)随 QMT 连接配置的增删改与激活切换联动;状态出口 = 设置页 + 会话头部指示灯。REST 直连架构不变(技术约束-003)。
```
神之一手 apply(ctx)
├─ settingsQMT 连接配置:list + activeId + defaultId [已有 R-004]
├─ dataSourceREST 直连,baseUrl 随激活热切换) [已有]
└─ QmtMcpManager(新增 src/mcp/QmtMcpManager.js
├─ 动态 import(@deepseek-ai/dsh-mcp-client) → ctx.plugin(module, config)
│ config = { serverName: QMT_Bridge_MCP, transport: streamable-http,
│ url: 激活 baseUrl + /mcp, failOnStartupError:false,
│ reconnect: {enabled, 500ms→30s, 10次} } ← 与宿主现受管块同参(Q5)
├─ 生命周期:start(启动挂载激活连接)/ resync(url)(激活·编辑地址·删除联动)
├─ 状态:probe()SDK listTools 握手:connected + 工具数 + 延迟 / error
│ getStatus() → { state, serverName, url, mounted, toolCount, error, latencyMs, checkedAt }
└─ dispose:卸载 fiberctx.effect 释放)
```
## 关键事实(已实证)
- `@deepseek-ai/dsh-mcp-client@0.1.1-rc.2` 已加入 dependencies;从插件 lib 可 import,模块导出 {name:mcp-client, inject:[tools], apply, Config},可 ctx.plugin() 动态挂载(mcp_router 同款实证);
- serverName 存活实例内必须唯一 → 移三方受管块前插件实例与宿主实例不可并存(实施时序见 PLAN-018 注意事项);
- dsh-mcp-client 不暴露连接状态查询 API → 状态用**独立 SDK 探测**(@modelcontextprotocol/sdk Clientinitialize + tools/list → 工具数/延迟),与 dsh-mcp-client 自管连接互不干扰;
- 宿主现 QMT_Bridge_MCP 受管块 = 三方 dsh-skill-mcp-panel 写入,连接与注册实为官方 dsh-mcp-client。
## 改动清单
| 文件 | 改动 |
|---|---|
| package.json | dependencies + @deepseek-ai/dsh-mcp-client@0.1.1-rc.2 + @modelcontextprotocol/sdk@1.30.0(已加) |
| src/mcp/QmtMcpManager.js(新增) | MCP 实例管理:动态挂载/卸载 + resync + probe/getStatus + dispose |
| src/index.js | 实例化 QmtMcpManager、start、ctx.effect 释放、传入 registerApi runtime |
| src/api/index.js | runtime 增 mcpManager(分发 handleQmt/handleMarket |
| src/api/qmt-connections.js | METHODS + mcp-statusactivate/update(地址变)/remove 热切换点 + mcpManager.resync(url) |
| src/api/market.js | sync-status 增加 mcp 域(读 mcpManager.getStatus |
| src/client/views/SyncIndicators.jsx | 三灯 → 四灯:+「MCP」(绿=已连接 / 黄=重连中 / 红=断开 / 灰=未启用;点击刷新) |
| src/client/views/SettingsSection.jsx | QMT 连接配置子 tab 顶部 MCP 状态条 + 「检查 MCP」按钮(手动触发 mcp-status refresh |
## MCP 状态语义(对齐产品约束-012 指示灯体系)
| state | 灯色 | 说明 |
|---|---|---|
| connected | 绿 | 已连接 + N 工具(probe listTools 成功) |
| connecting | 黄 | 挂载中 / 重连中 |
| error | 红 | 不可达 / 失败(含原因) |
| disabled | 灰 | 无激活连接(未启用) |
## 实现步骤
1. QmtMcpManager(挂/卸/resync/probe/getStatus/dispose);
2. index.js 接线 + registerApi runtime
3. apimcp-status + sync-status.mcp + 热切换联动;
4. 客户端:SyncIndicators 四灯 + 设置页 MCP 状态条;
5. 构建(pnpm build)验证;
6. 宿主迁移(独立步骤,需老师确认 + editing-cordis-compositions 流程):卸载 dsh-skill-mcp-panel + 移除 cordis.patch.yml 受管块 → 重载 → 验证工具来自神之一手;
7. 验收。
## 风险与注意
- 同 serverName 冲突:插件实例与宿主受管块不可并存(先插件后移除,或先移除后插件接管,二选一窗口);
- ctx.plugin 子 fiber 需要 tools 服务:one-divine-lot inject 列表评估(对齐 mcp_router inject [webServer, tools]);
- dsh-mcp-client 初始连接失败不抛(failOnStartupError:false),但**重复 serverName 会抛** → 挂载错误必须 catch,插件其余功能不受影响。
@@ -0,0 +1,19 @@
# 迭代 18QMT Bridge MCP 能力内建(去三方插件依赖)
## 目标(一句话)
神之一手插件在 apply 内动态挂载 DSH 官方 dsh-mcp-clientserverName 固定 QMT_Bridge_MCPurl 自动派生自激活连接 baseUrl+/mcp,挂载/切换/卸载/状态均由神之一手插件内管理),MCP 能力不再依赖三方 dsh-skill-mcp-panel(卸载其插件 + 移除宿主 cordis.patch.yml 受管块)。
## 目标描述
- 依据 R-0202026-09-08 定稿,Q1-Q6+ PLAN-018
- Q1 只挂激活连接;Q2 移除三方块由神之一手管理;Q3 url 自动派生;Q4 状态出口=设置页卡片检查 + 会话头部 MCP 指示灯;Q5 失败不崩+重连;Q6 不做通用 MCP 管理;
- REST 直连架构不变(技术约束-003)。
## 讨论过程
| 日期 | 轮次 | 要点 |
|---|---|---|
| 2026-09-08 | 需求讨论 Q1-Q6 | R-020 定稿(见 R-020.md |
| 2026-09-08 | 立项 | 老师:「启动,这个不是一个大需求」→ 快速迭代 |
| 2026-09-08 | 指令澄清 | 老师修正:不要的是三方 dsh-skill-mcp-panel;依赖官方 @deepseek-ai/dsh-mcp-client 没问题 → 方案定为官方库动态挂载 + 移除三方面板 |
@@ -0,0 +1,10 @@
# 迭代 18 验收标准:QMT Bridge MCP 能力内建
> 验收方法/验收目标见 PLAN-018「验收标准 / 验收方法」逐条。
1. 移除宿主 cordis.patch.yml QMT_Bridge_MCP 受管块后,模型仍能用 mcp__QMT_Bridge_MCP__qmt_* 工具(来源=神之一手动态挂载,工具集一致);
2. 激活连接切换 → MCP 实例 url 跟随;编辑/删除激活连接 → 同步重挂无残留;
3. 连接列表为空 → 不挂实例(mcp-status=disabled),其余功能不受影响;
4. QMT 不可达 → 插件不崩、MCP 灯红、恢复后自动重连绿灯;
5. 设置页连接卡片 MCP 状态 + 检查按钮;会话头部 MCP 指示灯四灯齐全;
6. 现有功能无回归;typecheck + build 通过。
+57
View File
@@ -0,0 +1,57 @@
# 需求:R-020 QMT Bridge MCP 能力内建(神之一手自注册 MCP,去三方插件依赖)· 已定稿
> 登记:2026-09-08 来源:老师指令(2026-09-08 新需求)| 状态:**已定稿(2026-09-08Q1-Q6 老师逐项确认)**
> 归属:待立项(拟迭代 18)| 计划:待出 | 实现状态:未开始
## 需求描述(老师原始诉求)
> **老师原话(2026-09-08)**:当前我们给 DSH 配置了一个 MCP,用于连接 QMT bridge,这个配置是在另一个插件中配置的(三方插件 dsh-skill-mcp-panel 在 profile cordis.patch.yml 受管块里插入 QMT_Bridge_MCP 实例——管理 UI + 写宿主 patch;真正连接的是 DSH 官方 dsh-mcp-client)。我希望可以在神之一手插件的配置 tab 中,添加了 QMTBridge 的配置后,可以**自动检查 MCP 连接信息**,这样我们**不需要依赖三方插件来加入 MCP 的能力**。
>
> **2026-09-08 老师澄清(指令修正)**:不要的是**第三方 dsh-skill-mcp-panel**(设置菜单「技能 / MCP」+ 它写的宿主 patch 受管块);**依赖 DSH 官方 @deepseek-ai/dsh-mcp-client 是允许的**。实现 = 神之一手 apply 内动态挂载官方 dsh-mcp-client(参照 mcp_router 实证),**移除三方 dsh-skill-mcp-panel 及其受管块**。
## 定稿语义
- **MCP 能力内建**:神之一手在自己的 apply 内按 QMT 连接配置动态挂载 DSH 官方 @deepseek-ai/dsh-mcp-client(插件内管理:挂载/切换/卸载/状态都由神之一手控制),QMT Bridge 的 MCP 工具由神之一手自己接入;不再依赖三方 dsh-skill-mcp-panel 的面板与宿主 patch 受管块;
- **REST 直连不变**(技术约束-003):插件 UI/tab 数据仍走 RESTMCP 是插件自带的 Agent 侧能力;
- **MCP url 自动派生**:激活连接 baseUrl + /mcp,零新增配置字段。
## 讨论记录(2026-09-08,第一轮,Q1-Q6 老师逐项拍板 → 定稿)
| # | 问题 | 结论(老师拍板) |
|---|---|---|
| Q1 | MCP 客户端挂载粒度 | **只使用当前激活的那个连接**:仅激活连接挂 1 个 dsh-mcp-client 实例(serverName 固定 QMT_Bridge_MCP,切换激活即热切换 MCP url,工具名 mcp__QMT_Bridge_MCP__* 保持稳定);不逐连接各挂实例 |
| Q2 | 现有三方条目处置 | **现在的移除(2026-09-08 老师澄清:移除对象 = 第三方 dsh-skill-mcp-panel**:① 卸载/停用三方 dsh-skill-mcp-panel(其「技能 / MCP」设置菜单与宿主 patch 管理职责一并移除);② 删除 cordis.patch.yml 中其受管块(QMT_Bridge_MCP 不再由三方写入);**QMT 的 MCP 连接通过神之一手插件自己管理**——插件 apply 内动态挂载**官方 @deepseek-ai/dsh-mcp-client**(依赖官方库、管理在我们插件内),含连接 CRUD/激活切换的全生命周期联动 |
| Q3 | MCP url 与字段 | **自动来**:默认派生 baseUrl + /mcp,不需要每连接 mcpUrl 覆盖字段 |
| Q4 | 「自动检查 MCP 连接信息」口径 | **两处状态出口**:① 设置页「QMT 连接配置」连接卡片可检查(手动触发);② **对话窗口顶端状态指示灯区新增一个 MCP 状态**(与现有持仓/盘口同步指示灯并列) |
| Q5 | 失败与重连语义 | **采纳建议**:对齐 failOnStartupError:false + reconnect(初始连不上不拒绝插件激活;连接丢失自动指数退避重连;期间保留断开状态不崩插件) |
| Q6 | 范围确认 | **不做通用 MCP 管理**,只做 QMT 的 MCP 内建;通用任意 MCP 服务器管理面板不做(该域属 mcp-router / dsh-skill-mcp-panel,另议) |
## 定稿边界
**做**
1. 神之一手 apply 内动态挂载/卸载 dsh-mcp-client(唯一实例,serverName=QMT_Bridge_MCPurl=激活连接 baseUrl+/mcp);
2. 生命周期联动:启动(激活默认/上次连接)→ 挂载;添加/编辑/删除/激活连接 → MCP 挂/切/卸(对齐现有 qmt-connections API 热切换编排);激活连接被删自动切默认并重挂;
3. MCP 状态出口 ×2:设置页连接卡片 MCP 检查/状态;对话窗口顶端状态指示灯区新增 MCP 状态(三态风格对齐产品约束-012 指示灯体系);
4. 移除三方 dsh-skill-mcp-panel:卸载其插件 + 移除宿主 cordis.patch.yml 受管块(Q2;属宿主配置变更,按 editing-cordis-compositions 技能流程操作 + 老师确认);
5. 失败/重连语义按 Q5failOnStartupError:false + reconnect)。
**不做(本期)**:通用 MCP 服务器管理面板(Q6,含自建「任意 MCP 管理 UI」——Q6 明确不做);插件 UI/tab 数据源改走 MCP(技术约束-003 REST 直连不变);多 serverName 并存;自研 MCP 协议客户端(老师定:依赖官方 dsh-mcp-client 即可,不重造轮子)。
## 计划阶段待办(定稿后第一件事:技术可行性调研)
1. **依赖解析实证**one-divine-lot 运行时能否 import(@deepseek-ai/dsh-mcp-client)(宿主 CLI 依赖树 / profile node_modules 解析路径核实,对照 mcp_router 的 peerDependencies 声明与日志实证;确认后在 package.json 补 peer 依赖);
2. **动态挂载生命周期设计**add/update/remove/activate 连接 → MCP 实例 挂/切/卸 编排(对齐 qmt-connections API 现有热切换逻辑,MCP url 随激活 baseUrl);
3. **MCP 状态查询与展示**:服务端 MCP 状态端点(已连接/断开 + 工具数 + 错误/重连信息)+ 设置页卡片检查 + 会话头部指示灯新增 MCP 状态(对齐产品约束-012/UI约束的指示灯体系);
4. **宿主受管块迁移**cordis.patch.yml 移除 QMT_Bridge_MCP 块的精确操作步骤(保留块外内容逐字节不动);
5. 前端连接卡片表单是否需提示「MCP 将自动随激活连接启用」(Q4 落点 UI 文案)。
## 关联
- 终极目标:目标-001(DSH 插件形态·复用宿主 MCP 能力)、目标-008(真实交易系统接入)
- 既有需求:R-004(QMT 连接配置,本需求数据基础:settings.qmtConnections + 激活/默认语义 + 热切换编排);R-015(会话头部指示灯体系,MCP 状态灯并入同一区)
- 设计约束:技术约束-003(REST 直连不变)、技术约束-008(连接配置存储/热切换);定稿后需补新约束条目(技术约束:「插件内建 MCP 客户端注册规范」;产品约束:「MCP 状态出口(设置卡片 + 会话头部指示灯)」)
- 参考实现:mcp_routerds-plugins/mcp_router)——动态 ctx.plugin(dsh-mcp-client) 实证
## 验收
**(立项后写入迭代验收标准.md**
+3
View File
@@ -31,6 +31,9 @@
| R-017 | 委托归属候选纳入近期清仓持仓 | 交易记录 tab 归属候选只回当前持仓——清仓后当日委托失去下拉锚点无法补关联(R-016 关联观察立项,2026-09-07 大连热电实际发生:盘中归属后被清为未关联,下拉已无做T候选)。**2026-09-07 定稿(老师指令确认方向)**:候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记,选项文本「已清仓」后缀),下拉 value 改 holdingId 键控(同策略多轮不撞值),排序当前在前/清仓按 closed_at DESC;当日数据修复经 set-attribution 通道完成(非固化功能)。**2026-09-08 R-018 取代标注:7 天清仓候选退役(R-018 T2 拍板)**——归属候选改策略级 attribution-targets(迭代 16 实施),本需求「清仓后补关联」痛点根因(幽灵清仓抢先归档)已由数据域分界消除;holdingId 键控/占位安全逻辑保留。详见 R-017.md | 老师指令(2026-09-07 | P1 | 已定稿 | 2026-09-07 | 15-归属候选纳入清仓持仓 | 已实现(被 R-018 语义取代) |
| R-018 | 交易关联驱动持仓份额动态调整(归属 = 账本写操作) | 从交易记录出发给委托关联策略/持仓时自动同步调整该策略下持仓份额(关联即账本写操作):策略级候选 + 统一「份额分配器」(一笔委托拆 0..N 段 × 策略×量,余额默认全量可改小,允许部分关联)+ 买入加仓/建新仓(不复活旧行)、卖出减仓/归零清仓、仓不足自动截断续分、无仓关联失败;撤段逆操作(撤建仓买入=持仓作废第三态不产生假清仓历史、撤加仓=减回归零作废、撤卖出=恢复活动仓+份额加回,粒度=段、改归属原子);**数据域分界(老师拍板):幽灵清仓等同步机制只作用于「全部持仓」对账单域,不再写 strategy_holdings 账本**——账本生命周期只由交易关联 + 手动份额操作驱动,账本转历史唯一途径=卖出单关联 closeHoldingR-017 7 天清仓候选退役(holdingId 键控/占位保留);漏关联软提示(账本有活动行+QMT 无此 code)。影响既有定稿:技术约束-017/产品约束-011/R-014/迭代12 幽灵清仓条款修订。详见 R-018.md | 老师指令(2026-09-08 新优化需求讨论) | P1 | 已定稿 | 2026-09-08 | 16-交易关联驱动持仓份额动态调整 | **已实现(已归档至 已完成/R-018.md2026-09-08 验收通过)** |
| R-019 | 策略会话:会话以策略当前数据为依据做复盘/分析讨论 | 让 AI 会话以策略为 Workspace(老师原话)→ **已定稿(2026-09-08F1/F2 拍板)**:改造现有 DSH 对话窗口,对话视图首条消息前提供「讨论策略」选择(仿 DSH workspace chip Choose 体验:依据:做T ▾),选策略即把该策略**当前数据摘要**预注入会话上下文开始聊,会话中可换策略/刷新再注入;**不用 DSH 宿主工作区**(R3);注入 = 当前持仓明细 + 全历史关联交易聚合摘要(含已清仓,F2);只读不开放账本写(Q4=A);产物不落盘(R2-Q4,复盘管理/落库归未来 T-002)。宿主注入通道等技术调研列计划阶段待办。详见 R-019.md | 老师指令(2026-09-08 | P1 | 已定稿 | 2026-09-08 | 17-策略会话 | 已立项(迭代 17 设计阶段) |
| R-020 | QMT Bridge MCP 能力内建(神之一手自注册 MCP,去三方插件依赖) | 当前 DSH 的 QMT Bridge MCP 由三方插件 dsh-skill-mcp-panel 写入 profile cordis.patch.yml 受管块。**2026-09-08 定稿(Q1-Q6 老师逐项拍板)**:神之一手 apply 内动态挂载 **DSH 官方 dsh-mcp-client**(**只挂当前激活连接**serverName 固定 QMT_Bridge_MCPurl 自动派生 baseUrl+/mcp;挂载/切换/卸载/状态由神之一手插件内管理),MCP 能力内建;**移除对象 = 三方 dsh-skill-mcp-panel**2026-09-08 老师澄清:卸载其插件 + 移除其宿主 cordis.patch.yml 受管块,官方 dsh-mcp-client 可依赖);MCP 生命周期随连接 CRUD/激活切换联动;状态出口 = 设置页连接卡片检查 + **会话头部指示灯区新增 MCP 状态灯**;失败重连对齐 failOnStartupError:false+reconnect**不做通用 MCP 管理**。REST 直连不变(技术约束-003)。详见 R-020.md | 老师指令(2026-09-08 | P1 | 已定稿 | 2026-09-08 | -(拟迭代 18 | 未开始 |
## 渐进明细规划素材
> 2026-08-27 老师确立「渐进明细」:前期目标笼统,先实现周边工具,做中学逐步明确计划。以下周边工具候选为规划素材,**状态为「起草」级**(未定稿,不进入计划/迭代范围),待讨论与实践中逐步明确并转入正式需求。
+3 -1
View File
@@ -33,7 +33,9 @@
}
},
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
"@deepseek-ai/dsh-mcp-client": "0.1.1-rc.2",
"@deepseek-ai/schemastery": "^3.18.1",
"@modelcontextprotocol/sdk": "1.30.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
+797
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -60,8 +60,8 @@ const HANDLERS = [
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {object} runtime { manager, settings, dataSource, marketCache }
*/
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution };
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution, mcpManager }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution, mcpManager };
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
+2 -1
View File
@@ -42,7 +42,7 @@ function projectQuote(hub, code, snap) {
* @param {object} args
* @param {object} runtime { settings, dataSource, marketHub, marketFeed(=QuoteSync), qmtHealthMonitor, positionSync }
*/
export async function handleMarket(method, args, { settings, dataSource, marketHub, marketFeed: marketSync, qmtHealthMonitor, positionSync }) {
export async function handleMarket(method, args, { settings, dataSource, marketHub, marketFeed: marketSync, qmtHealthMonitor, positionSync, mcpManager }) {
switch (method) {
case 'market-snapshot': {
const codes = Array.isArray(args.codes) ? args.codes : [];
@@ -86,6 +86,7 @@ export async function handleMarket(method, args, { settings, dataSource, marketH
? { ...marketSync.getStatus(), state: state(marketSync.getStatus()) }
: null,
qmt: qmtHealthMonitor ? qmtHealthMonitor.getStatus() : null,
mcp: mcpManager ? mcpManager.getStatus() : null, // R-020/迭代18QMT MCP 状态(会话头部指示灯)
};
}
case 'sync-now': {
+14 -1
View File
@@ -28,6 +28,7 @@ export const QMT_METHODS = new Set([
'qmt-connections/set-default',
'qmt-connections/test',
'qmt-connections/active-host',
'qmt-connections/mcp-status', // R-020/迭代18QMT MCP 状态(probe / 缓存)
]);
/** 服务端代理测试连接:GET {baseUrl}/health(浏览器跨域规避,Q3 手动测试) */
@@ -63,7 +64,7 @@ async function testQmtConnection(ctx, args) {
* @param {object} args
* @param {object} runtime { manager, settings, dataSource, marketCache }
*/
export async function handleQmt(method, args, { ctx, settings, dataSource, marketFeed, qmtHealthMonitor }) {
export async function handleQmt(method, args, { ctx, settings, dataSource, marketFeed, qmtHealthMonitor, mcpManager }) {
switch (method) {
case 'qmt-connections': {
const state = getQmtConnections(settings);
@@ -80,6 +81,8 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
marketFeed?.setBaseUrl(value.baseUrl);
qmtHealthMonitor?.probe(); // 切换后立即补探健康
ctx?.logger?.info('[one-divine-lot] 激活配置地址已变更,数据源热切换 → ' + value.baseUrl);
// R-020/迭代18:MCP 目标地址随激活地址变更重挂
mcpManager?.resync(value.baseUrl).catch(() => {});
}
return value;
}
@@ -94,6 +97,8 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
qmtHealthMonitor?.probe(); // 切换后立即补探健康
ctx?.logger?.info('[one-divine-lot] 激活配置已删除,自动切换 → ' + active.name + ' (' + active.baseUrl + ')');
}
// R-020/迭代18:激活已变(或列表清空)→ MCP 跟随新激活 / 卸载
mcpManager?.resync(active?.baseUrl).catch(() => {});
return { ...afterState, active, removedWasActive: result.removedWasActive, removedWasDefault: result.removedWasDefault };
}
case 'qmt-connections/activate': {
@@ -105,6 +110,8 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
qmtHealthMonitor?.probe(); // 切换后立即补探健康
ctx?.logger?.info('[one-divine-lot] QMT 连接激活: ' + active.name + ' (' + active.baseUrl + ')');
}
// R-020/迭代18:激活切换 → MCP 实例跟随(卸旧挂新,url=新激活 baseUrl+/mcp
mcpManager?.resync(active?.baseUrl).catch(() => {});
return { ...state, active };
}
case 'qmt-connections/set-default':
@@ -132,6 +139,12 @@ export async function handleQmt(method, args, { ctx, settings, dataSource, marke
} catch { /* baseUrl 非法时返回空 */ }
return { name: active.name, baseUrl, host, wsUrl, activeId: state.activeId ?? '' };
}
case 'qmt-connections/mcp-status': {
// R-020/迭代18MCP 状态(缓存秒回;refresh=true 即时探测)
if (!mcpManager) return { state: 'disabled', error: 'mcpManager 不可用', checkedAt: 0 };
if (args.refresh) return await mcpManager.probe();
return mcpManager.getStatus();
}
default:
throw Object.assign(new Error('unknown qmt method: ' + method), { code: 'not-found' });
}
+46 -1
View File
@@ -520,6 +520,8 @@ function QmtConnectionsSettings() {
const [editingId, setEditingId] = useState(null);
const [formName, setFormName] = useState('');
const [formUrl, setFormUrl] = useState('');
const [mcpStatus, setMcpStatus] = useState(null); // R-020/迭代18MCP 状态
const [mcpChecking, setMcpChecking] = useState(false);
const call = useRpc();
const toast = useToast();
@@ -533,7 +535,14 @@ function QmtConnectionsSettings() {
}
}, [call]);
useEffect(() => { load(); }, [load]);
const loadMcp = useCallback(async (refresh) => {
try {
const res = await call('one-divine-lot/qmt-connections/mcp-status', { args: { refresh: !!refresh } });
if (res && res.ok) setMcpStatus(res.value);
} catch { /* 静默 */ }
}, [call]);
useEffect(() => { load(); loadMcp(false); }, [load, loadMcp]);
const openAdd = () => { setEditingId(null); setFormName(''); setFormUrl(''); setFormOpen(true); setTestResult(null); };
const openEdit = (c) => { setEditingId(c.id); setFormName(c.name); setFormUrl(c.baseUrl); setFormOpen(true); setTestResult(null); };
@@ -576,6 +585,7 @@ function QmtConnectionsSettings() {
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: c.id } });
if (res && res.ok) {
setState(res.value);
loadMcp(false); // R-020/迭代18:激活切换后刷新 MCP 状态
toast.success('已激活:' + c.name + '(立即生效)');
} else {
toast.error((res && res.error && res.error.message) || '激活失败');
@@ -679,6 +689,41 @@ function QmtConnectionsSettings() {
</button>
</div>
{/* R-020/迭代18:MCP 连接状态条(随激活连接;绿=已连接+N工具 / 黄=连接中 / 红=断开 / 灰=未启用) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, minWidth: 0, fontSize: 12 }}>
<span style={{ flex: 'none', fontWeight: 600 }}>MCP 连接</span>
<span style={{
flex: 'none', width: 10, height: 10, borderRadius: '50%',
background: mcpStatus?.state === 'connected'
? 'var(--dsw-alias-state-success-primary, #2e7d32)'
: mcpStatus?.state === 'connecting'
? 'var(--dsw-alias-state-warn-primary, #f9a825)'
: mcpStatus?.state === 'error'
? 'var(--dsw-alias-state-error-primary, #c62828)'
: 'var(--dsw-alias-label-tertiary, #bbb)',
}} />
<EllipsisText
text={mcpStatus?.state === 'connected'
? '已连接' + (mcpStatus.toolCount != null ? '' + mcpStatus.toolCount + ' 个工具)' : '') + ' · ' + (mcpStatus.url || '')
: mcpStatus?.state === 'connecting' ? '连接中…'
: mcpStatus?.state === 'error' ? '断开 · ' + String(mcpStatus.error || '').slice(0, 80)
: mcpStatus ? '未启用(无激活连接)' : '检测中…'}
maxWidth={520}
title={mcpStatus?.error ? String(mcpStatus.error) : (mcpStatus?.url || '')}
/>
<button
onClick={async () => {
if (mcpChecking) return;
setMcpChecking(true);
try { await loadMcp(true); } finally { setMcpChecking(false); }
}}
disabled={mcpChecking}
style={{ flex: 'none', padding: '2px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}
>
{mcpChecking ? '检测中…' : '检测 MCP'}
</button>
</div>
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
{!state && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
+37 -1
View File
@@ -2,7 +2,7 @@
* SyncIndicators —— 数据同步指示灯组(R-015 / 迭代 13;2026-09-03 老师调整:三灯带全称标签)
*
* 位置:会话头部(QmtConnectionChip 内,QMT 下拉框右侧)。
* 形态:QMT连接(灯)|持仓数据(灯)|行情数据(灯)——统一圆点 + 全称标签。
* 形态:QMT连接(灯)|持仓数据(灯)|行情数据(灯)MCP(灯,R-020/迭代18——统一圆点 + 全称标签。
*
* 状态(产品约束-012):
* - QMT 连接:绿=健康 / 红=异常 / 灰=未知(qmt-health 缓存)
@@ -87,6 +87,19 @@ export function SyncIndicators() {
}
}, [call, healthRefreshing, load]);
// R-020/迭代18:MCP 状态即时刷新(点灯触发)
const [mcpRefreshing, setMcpRefreshing] = useState(false);
const refreshMcp = useCallback(async () => {
if (mcpRefreshing) return;
setMcpRefreshing(true);
try {
await call('one-divine-lot/qmt-connections/mcp-status', { args: { refresh: true } });
} catch { /* 静默 */ } finally {
setMcpRefreshing(false);
load();
}
}, [call, mcpRefreshing, load]);
if (!status) return null;
// QMT 连接灯:绿=健康 / 红=异常 / 灰=未知
@@ -115,6 +128,27 @@ export function SyncIndicators() {
return lines.filter(Boolean).join(' | ');
};
// MCP 灯(R-020/迭代18):绿=已连接(connected) / 黄=连接中(connecting) / 红=断开(error) / 灰=未启用(disabled/缺失)
const mcp = status.mcp;
const mcpColor = mcp?.state === 'connected'
? 'var(--dsw-alias-state-success-primary, #2e7d32)'
: mcp?.state === 'connecting'
? 'var(--dsw-alias-state-warn-primary, #f9a825)'
: mcp?.state === 'error'
? 'var(--dsw-alias-state-error-primary, #d32f2f)'
: 'var(--dsw-alias-label-tertiary, #bbb)';
const mcpTitleLines = [
'MCP' + ({
connected: '已连接' + (mcp?.toolCount != null ? '' + mcp.toolCount + ' 个工具)' : ''),
connecting: '连接中',
error: '断开' + (mcp?.error ? '' + String(mcp.error).slice(0, 60) : ''),
disabled: '未启用(无激活连接)',
}[mcp?.state] ?? '未知'),
mcp?.url ? mcp.url : null,
mcp?.checkedAt ? '检测于 ' + new Date(mcp.checkedAt).toLocaleTimeString() : null,
'点击重新检测',
].filter(Boolean).join(' | ');
const sep = <span style={{ color: 'var(--dsw-alias-border-l2, #ddd)', fontSize: 12, flex: 'none' }}></span>;
return (
@@ -138,6 +172,8 @@ export function SyncIndicators() {
onClick={() => syncNow('quote')}
dimmed={syncingDomain === 'quote'}
/>
{sep}
<IndicatorDot label="MCP" color={mcpColor} glow={mcp?.state === 'connected'} title={mcpTitleLines} onClick={refreshMcp} dimmed={mcpRefreshing} />
</span>
);
}
+6 -1
View File
@@ -24,6 +24,7 @@ import { QuoteSync } from './market/QuoteSync.js';
import { QmtHealthMonitor } from './data-source/QmtHealthMonitor.js';
import { TradeSync } from './trades/TradeSync.js';
import { AttributionService } from './trades/AttributionService.js';
import { QmtMcpManager } from './mcp/QmtMcpManager.js'; // R-020/迭代18QMT Bridge MCP 能力内建
const name = 'one-divine-lot';
@@ -90,9 +91,12 @@ async function apply(ctx, config) {
// R-018 迭代 16:归属服务(关联 = 账本写操作;经 SqliteStore 段表/生命周期同步接口)
const attribution = new AttributionService({ store: storage.sqlite });
// R-020/迭代18QMT Bridge MCP 客户端管理(动态挂载官方 dsh-mcp-client;随激活连接挂载/切换)
const mcpManager = new QmtMcpManager({ ctx, runtime: { settings, dataSource }, logger });
mcpManager.start(); // 启动即按激活连接挂载 + 探测(内部 catch 不阻塞)
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/historyR-018:注入 attribution
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution });
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution, mcpManager });
// 启动时可用性检查(日志,不阻塞)
dataSource.isAvailable().then((ok) => {
@@ -108,6 +112,7 @@ async function apply(ctx, config) {
qmtHealthMonitor.stop();
positionSync.stop(); // 停止持仓快照定时同步(2026-09-02;内存快照保留供收尾读取)
tradeSync.stop(); // 停止交易记录定时同步(迭代 07)
mcpManager.dispose(); // R-020/迭代18:卸载 QMT MCP 实例
storage.close(); // 关闭 SQLite 连接(迭代 06
logger.info('[one-divine-lot] 插件已释放');
};
+245
View File
@@ -0,0 +1,245 @@
/**
* QmtMcpManager —— QMT Bridge MCP 客户端管理(R-020 / 迭代 18)
*
* 职责:在神之一手插件 apply 内动态挂载 DSH 官方 @deepseek-ai/dsh-mcp-client
* 把 QMT Bridge /mcp 的工具以 mcp__QMT_Bridge_MCP__* 注册给模型;管理 MCP 实例的
* 挂载 / 切换 / 卸载(随 QMT 连接配置激活联动),并提供连接状态(SDK 独立探测)。
*
* 设计约束(R-020 Q1-Q6):
* - Q1 只挂当前激活连接:唯一实例,serverName 固定 QMT_Bridge_MCP
* - Q3 MCP url 自动派生:激活连接 baseUrl + /mcp
* - Q5 失败不崩:failOnStartupError:false + reconnect(对齐宿主受管块同参);
* - Q4 状态出口:mcp-status 端点 + sync-status.mcp 域(设置页 + 会话头部指示灯)。
*
* 实现参照:mcp_routerctx.plugin 动态挂载 dsh-mcp-client 实证);
* 挂载错误必须 catch(同 serverName 重复 / 依赖缺失),不影响插件其余功能。
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { resolveActiveBaseUrl } from '../api/common.js';
const SERVER_NAME = 'QMT_Bridge_MCP';
const PROBE_TIMEOUT_MS = 8000;
const MOUNT_CONFIG_BASE = {
serverName: SERVER_NAME,
transport: 'streamable-http',
headers: {},
toolCallTimeoutMs: 60000,
failOnStartupError: false,
reconnect: { enabled: true, initialDelayMs: 500, maxDelayMs: 30000, maxAttempts: 10 },
};
export class QmtMcpManager {
/**
* @param {object} opts
* @param {object} opts.ctx Cordis 宿主 ctx
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl
* @param {object} [opts.logger]
*/
constructor({ ctx, runtime, logger } = {}) {
this.ctx = ctx;
this.runtime = runtime;
this.logger = logger ?? console;
/** @type {Promise<object>|null} dsh-mcp-client 模块(惰性加载) */
this._modulePromise = null;
/** @type {object|null} 当前挂载的 fiberctx.plugin 返回值,含 dispose */
this._fiber = null;
/** @type {string} 当前已挂载的 mcp url'' = 未挂载) */
this._currentUrl = '';
/** 状态缓存:{ state, serverName, url, mounted, toolCount, error, latencyMs, checkedAt } */
this.status = this._disabledStatus();
}
_disabledStatus() {
return {
state: 'disabled', serverName: SERVER_NAME, url: '', mounted: false,
toolCount: 0, error: null, latencyMs: 0, checkedAt: 0,
};
}
/** 当前激活连接对应的 mcp urlbaseUrl + /mcp);无激活连接返回 '' */
_currentMcpUrl() {
const base = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
return base ? String(base).replace(/\/+$/, '') + '/mcp' : '';
}
/** 惰性加载 dsh-mcp-client 模块(失败不抛,返回 null) */
async _loadModule() {
if (!this._modulePromise) {
this._modulePromise = import('@deepseek-ai/dsh-mcp-client')
.catch((err) => {
this.logger.warn?.('[one-divine-lot] 无法加载 @deepseek-ai/dsh-mcp-client: ' + (err?.message ?? err));
return null;
});
}
return this._modulePromise;
}
/** 启动:解析当前激活 baseUrl → 挂载 + 探测(非阻塞,内部 catch) */
async start() {
try {
const url = this._currentMcpUrl();
if (url) {
await this._mount(url);
} else {
this.status = this._disabledStatus();
}
await this.probe();
} catch (err) {
this.logger.warn?.('[one-divine-lot] QMT MCP 启动异常: ' + (err?.message ?? err));
}
}
/**
* 挂载 dsh-mcp-client 到指定 mcp url(先卸旧再挂新;同址幂等)。
* @param {string} url
*/
async _mount(url) {
const cleanUrl = String(url ?? '').trim().replace(/\/+$/, '');
if (!cleanUrl) {
await this.unmount();
return;
}
if (this._currentUrl === cleanUrl && this._fiber) return; // 已挂同址
this._unmountFiber();
const mod = await this._loadModule();
if (!mod) {
this._currentUrl = cleanUrl;
this.status = {
state: 'error', serverName: SERVER_NAME, url: cleanUrl, mounted: false,
toolCount: 0, error: '@deepseek-ai/dsh-mcp-client 依赖不可用', latencyMs: 0, checkedAt: Date.now(),
};
return;
}
const config = { ...MOUNT_CONFIG_BASE, url: cleanUrl };
try {
const fiber = this.ctx.plugin(mod, config);
this._fiber = fiber;
this._currentUrl = cleanUrl;
this.status = {
...this._disabledStatus(), state: 'connecting', url: cleanUrl, mounted: true, checkedAt: Date.now(),
};
// 激活结果(连接 + 工具同步)非致命:成功/失败都记录,不抛给插件主体
fiber.then(
() => this.logger.info?.('[one-divine-lot] QMT MCP 已挂载: ' + SERVER_NAME + ' (' + cleanUrl + ')'),
(err) => {
this.logger.warn?.('[one-divine-lot] QMT MCP 挂载异常: ' + (err?.message ?? err));
// 挂载失败(如同名 serverName 冲突 / 连接失败)→ 状态标 error,不崩插件
this.status = {
...this.status, state: 'error',
error: (err?.message ?? String(err)).slice(0, 300), checkedAt: Date.now(),
};
},
);
} catch (err) {
this.logger.warn?.('[one-divine-lot] QMT MCP 挂载抛出: ' + (err?.message ?? err));
this._fiber = null;
this._currentUrl = cleanUrl;
this.status = {
...this._disabledStatus(), state: 'error', url: cleanUrl, mounted: false,
error: (err?.message ?? String(err)).slice(0, 300), checkedAt: Date.now(),
};
}
}
/** 卸载当前 fiber(内部 dispose,失败静默) */
_unmountFiber() {
const fiber = this._fiber;
this._fiber = null;
if (fiber) {
try { fiber.dispose?.(); } catch { /* already disposed */ }
}
}
/** 显式卸载(置 disabled 状态) */
async unmount() {
this._unmountFiber();
this._currentUrl = '';
this.status = this._disabledStatus();
}
/**
* 连接配置热切换联动(activate / update 地址 / remove 激活后调用)。
* baseUrl 变 → 卸旧挂新;不变 → 仅刷新探测。
* @param {string} baseUrl 新的激活连接 baseUrl(可能为空 = 无激活)
*/
async resync(baseUrl) {
try {
const next = baseUrl ? String(baseUrl).replace(/\/+$/, '') + '/mcp' : '';
if (!next) {
await this.unmount();
return this.status;
}
if (next === this._currentUrl && this._fiber) {
await this.probe();
return this.status;
}
await this._mount(next);
await this.probe();
return this.status;
} catch (err) {
this.logger.warn?.('[one-divine-lot] QMT MCP resync 异常: ' + (err?.message ?? err));
return this.status;
}
}
/**
* 独立探测:SDK Client 连 {url} → initialize + tools/list → 工具数/延迟。
* 与 dsh-mcp-client 自管连接互不干扰(只读检查,用完即关)。
* @param {string} [url] 缺省用当前激活派生 url
*/
async probe(url) {
const target = String(url ?? this._currentMcpUrl() ?? '').trim().replace(/\/+$/, '');
if (!target) {
this.status = this._disabledStatus();
return this.status;
}
const startedAt = Date.now();
let client = null;
try {
client = new Client({ name: 'one-divine-lot-mcp-probe', version: '0.1.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(target), {
requestInit: { headers: {} },
});
await Promise.race([
client.connect(transport),
new Promise((_, rej) => setTimeout(() => rej(new Error('探测超时 (' + PROBE_TIMEOUT_MS + 'ms)')), PROBE_TIMEOUT_MS)),
]);
const list = await Promise.race([
client.listTools(),
new Promise((_, rej) => setTimeout(() => rej(new Error('探测超时 (' + PROBE_TIMEOUT_MS + 'ms)')), PROBE_TIMEOUT_MS)),
]);
const tools = Array.isArray(list?.tools) ? list.tools : [];
this.status = {
state: 'connected', serverName: SERVER_NAME, url: target,
mounted: !!this._fiber && this._currentUrl === target,
toolCount: tools.length, error: null,
latencyMs: Date.now() - startedAt, checkedAt: Date.now(),
};
try { await client.close(); } catch { /* ignore */ }
} catch (err) {
this.status = {
state: 'error', serverName: SERVER_NAME, url: target,
mounted: !!this._fiber, toolCount: 0,
error: (err?.message ?? String(err)).slice(0, 300),
latencyMs: Date.now() - startedAt, checkedAt: Date.now(),
};
try { await client?.close(); } catch { /* ignore */ }
}
return this.status;
}
/** 读取缓存状态(前端秒回) */
getStatus() {
return { ...this.status };
}
/** 释放(插件 dispose 时调用) */
async dispose() {
await this.unmount();
}
}
export default QmtMcpManager;
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: ['src/index.js', 'src/api/index.js', 'src/settings.js', 'src/data-source/*.js', 'src/storage/*.js', 'src/position/*.js', 'src/market/*.js', 'src/trades/*.js'],
entry: ['src/index.js', 'src/api/index.js', 'src/settings.js', 'src/data-source/*.js', 'src/storage/*.js', 'src/position/*.js', 'src/market/*.js', 'src/trades/*.js', 'src/mcp/*.js'], // R-020/迭代18: mcp 域
format: ['esm'],
outDir: 'lib',
clean: true,