迭代18: QMT Bridge MCP 能力内建(R-020)—— 动态挂载 dsh-mcp-client + 状态出口(设置页/会话头部灯)
This commit is contained in:
+2
-2
@@ -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
@@ -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/迭代18:QMT MCP 状态(会话头部指示灯)
|
||||
};
|
||||
}
|
||||
case 'sync-now': {
|
||||
|
||||
@@ -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/迭代18:QMT 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/迭代18:MCP 状态(缓存秒回;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' });
|
||||
}
|
||||
|
||||
@@ -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/迭代18:MCP 状态
|
||||
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>}
|
||||
|
||||
|
||||
@@ -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
@@ -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/迭代18:QMT 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/迭代18:QMT Bridge MCP 客户端管理(动态挂载官方 dsh-mcp-client;随激活连接挂载/切换)
|
||||
const mcpManager = new QmtMcpManager({ ctx, runtime: { settings, dataSource }, logger });
|
||||
mcpManager.start(); // 启动即按激活连接挂载 + 探测(内部 catch 不阻塞)
|
||||
|
||||
// S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/history;R-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] 插件已释放');
|
||||
};
|
||||
|
||||
@@ -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_router(ctx.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} 当前挂载的 fiber(ctx.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 url(baseUrl + /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;
|
||||
Reference in New Issue
Block a user