迭代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
+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;