dsh-mcp-connector 0.2.43 → 0.2.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.en.md +7 -1
- package/README.md +7 -1
- package/lib/discovery-policy.js +21 -0
- package/lib/index.js +235 -16
- package/lib/mcp-http.js +4 -2
- package/lib/mcp-validation.js +2 -1
- package/lib/status-events.js +1 -1
- package/lib/tool-catalog-cache.js +15 -10
- package/lib/tools.js +4 -1
- package/lib/web.js +4 -1
- package/package.json +2 -2
- package/scripts/ui-harness.mjs +30 -2
- package/ui/index.html +223 -16
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.45] - 2026-09-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 新增“工具”页:跨连接统一搜索、精确工具名优先、连接/服务/发现状态筛选及分页;搜索与筛选仅读取缓存,不访问远端服务。
|
|
12
|
+
- 结果展示连接与服务来源、最后成功缓存时间及最近发现状态;故障连接保留缓存,并提供单连接“重新发现工具”和“检查连接”入口及已有诊断建议。
|
|
13
|
+
- 参数详情增加类型、必填、枚举、约束和可展开嵌套结构;复杂或超大结构提示摘要限制,同时保留原始安全缓存 Schema。
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 后台最多并发两条连接;健康连接五分钟到期,普通故障从三十秒指数退避至十五分钟,429 遵守 Retry-After,鉴权失败暂停自动重试。
|
|
18
|
+
- 手动故障处理合并同连接并发请求、设置五秒冷却,不能绕过限流等待。未选择 Workspace 的工具页仅展示全局连接。
|
|
19
|
+
|
|
20
|
+
### Verification
|
|
21
|
+
|
|
22
|
+
- 248 项自动测试通过,覆盖工作区隔离、精确搜索、分页、只读搜索零远端请求、单连接诊断及 429 等待;72 个发布文件通过白名单与敏感内容扫描。
|
|
23
|
+
- 本机无凭据模拟页面验证多服务汇总、搜索筛选、参数详情及恢复后状态同步;未执行目标工具,未更改真实 web profile。
|
|
24
|
+
- 确定性调度模型中,六条健康连接持续查看十分钟从 126 次降至 18 次发现任务;此为策略模型验证,不是生产性能基准。
|
|
25
|
+
|
|
26
|
+
## [0.2.44] - 2026-09-12
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- 启动恢复及连接成功后后台发现工具,无需先打开详情;后台最多并行发现两个连接器,同一连接的并发 HTTP 发现合并。
|
|
31
|
+
- 手动、JSON 导入和目录连接统一支持工具列表、搜索与详情,可按 connectionKey 精确定位;已安装页增加工具入口。
|
|
32
|
+
- SSE 订阅期间每 30 秒重新发现以恢复断线状态;详情读取只读快照、重连后补同步,不形成事件刷新循环。
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- 页面与 API 使用一致的搜索评分,精确工具名优先;后台发现中、读取失败与成功空列表分别展示。
|
|
37
|
+
- Schema 清理区分参数名称与关键字,保留名为 default、const、examples 的合法参数;敏感默认值及示例仍不缓存。
|
|
38
|
+
- 连接配置变更后失效旧缓存,忽略断开或重配期间返回的过期发现结果;查询继续遵守工作区可见范围。
|
|
39
|
+
|
|
40
|
+
本次不新增目标工具执行、跨连接器搜索页面或凭据分享能力。
|
|
41
|
+
|
|
7
42
|
## [0.2.43] - 2026-09-12
|
|
8
43
|
|
|
9
44
|
### Added
|
package/README.en.md
CHANGED
|
@@ -8,6 +8,12 @@ Manage MCP connections from different providers in one place inside DeepSeek Har
|
|
|
8
8
|
|
|
9
9
|
[简体中文](README.md)
|
|
10
10
|
|
|
11
|
+
### Tool discovery and troubleshooting
|
|
12
|
+
|
|
13
|
+
The Tools tab searches cached tools across connections, with connection, service and discovery-status filters. Results show their source and last successful cache time. Readable parameter summaries retain access to the original sanitized schema. Browsing never executes tools; cached availability does not guarantee a live call will succeed.
|
|
14
|
+
|
|
15
|
+
Expand connection diagnostics to check a connection or rediscover its tools. Healthy background discovery is due every five minutes; failures back off, rate limits honor Retry-After, and authentication failures pause automatic retries. Without a selected workspace, only global connections appear.
|
|
16
|
+
|
|
11
17
|
[Chinese user guide](docs/USER-GUIDE.md) · [Connector onboarding](https://github.com/duhu2000/dsh-mcp-connector-registry/blob/main/docs/ONBOARDING.md) · [First contribution](docs/FIRST-CONTRIBUTION.md) · [Contributing](CONTRIBUTING.md) · [Issues](https://github.com/duhu2000/dsh-mcp-connector/issues)
|
|
12
18
|
|
|
13
19
|
[](https://github.com/duhu2000/dsh-mcp-connector/actions/workflows/ci.yml)
|
|
@@ -178,7 +184,7 @@ npm run dev:ui
|
|
|
178
184
|
|
|
179
185
|
Every Registry merge regenerates `catalog-stats.json`; an hourly workflow in this repository synchronizes the Chinese and English product copy plus a local stats snapshot. The static npm README updates with package releases, while the live badges above read the Registry directly and therefore stay current without another npm release.
|
|
180
186
|
|
|
181
|
-
The current public version is [`dsh-mcp-connector@0.2.
|
|
187
|
+
The current public version is [`dsh-mcp-connector@0.2.45`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.45](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.45).
|
|
182
188
|
|
|
183
189
|
See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
|
|
184
190
|
|
package/README.md
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
[English](README.en.md)
|
|
10
10
|
|
|
11
|
+
### 工具查找与故障处理
|
|
12
|
+
|
|
13
|
+
打开“工具”页可跨连接搜索工具,并按连接、服务和最近发现状态筛选。结果标注来源与最后成功缓存时间;缓存可查不代表服务当前可调用。参数详情提供易读摘要与原始安全 Schema,不执行工具。
|
|
14
|
+
|
|
15
|
+
遇到故障时展开“连接状态与故障处理”,按诊断建议使用“检查连接”或“重新发现工具”。后台健康连接每五分钟到期,失败指数退避;限流遵守 Retry-After,鉴权失败暂停自动重试。未选择工作区时仅显示全局连接。
|
|
16
|
+
|
|
11
17
|
[用户手册](docs/USER-GUIDE.md) · [第三方连接器上架指南](https://github.com/duhu2000/dsh-mcp-connector-registry/blob/main/docs/ONBOARDING.md) · [首次贡献](docs/FIRST-CONTRIBUTION.md) · [参与贡献](CONTRIBUTING.md) · [问题反馈](https://github.com/duhu2000/dsh-mcp-connector/issues)
|
|
12
18
|
|
|
13
19
|
[](https://github.com/duhu2000/dsh-mcp-connector/actions/workflows/ci.yml)
|
|
@@ -173,7 +179,7 @@ npm run dev:ui
|
|
|
173
179
|
|
|
174
180
|
公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
|
|
175
181
|
|
|
176
|
-
当前公开版本为 [`dsh-mcp-connector@0.2.
|
|
182
|
+
当前公开版本为 [`dsh-mcp-connector@0.2.45`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.45](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.45)。
|
|
177
183
|
|
|
178
184
|
版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
179
185
|
Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Pure scheduling policy. No credentials, timers or network operations.
|
|
2
|
+
export function nextDiscovery(previous, outcome, now = Date.now()) {
|
|
3
|
+
const failures = outcome.ok ? 0 : Math.min((previous?.failures || 0) + 1, 10);
|
|
4
|
+
if (outcome.errorKind === 'auth') return { failures, nextAt: null, reason: 'auth' };
|
|
5
|
+
const rateLimited = outcome.errorKind === 'rate-limit';
|
|
6
|
+
const delay = outcome.ok ? 300_000 : Math.min((rateLimited ? 60_000 : 30_000) * 2 ** (failures - 1), 900_000);
|
|
7
|
+
const retry = Number.isFinite(outcome.retryAfterMs) ? Math.max(0, outcome.retryAfterMs) : 0;
|
|
8
|
+
return { failures, nextAt: now + Math.max(delay, retry), reason: outcome.ok ? 'success' : rateLimited ? 'rate-limit' : 'retry' };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function discoveryDue(plan, now = Date.now()) {
|
|
12
|
+
return !plan || (plan.nextAt !== null && plan.nextAt <= now);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseRetryAfter(value, now = Date.now()) {
|
|
16
|
+
if (!value) return 0;
|
|
17
|
+
const seconds = Number(value);
|
|
18
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - now;
|
|
19
|
+
// Bound untrusted headers; malformed/negative values fall back to local backoff.
|
|
20
|
+
return Number.isFinite(delay) ? Math.max(0, Math.min(delay, 86_400_000)) : 0;
|
|
21
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -69,8 +69,10 @@ import {
|
|
|
69
69
|
scopeTargets,
|
|
70
70
|
} from './connection-scopes.js';
|
|
71
71
|
import { installConnectorSettings } from './settings.js';
|
|
72
|
+
import { nextDiscovery, discoveryDue } from './discovery-policy.js';
|
|
72
73
|
import {
|
|
73
74
|
connectionToolCacheSignature,
|
|
75
|
+
browseToolCatalog,
|
|
74
76
|
createToolCatalogRecord,
|
|
75
77
|
findToolDetails,
|
|
76
78
|
searchToolCatalog,
|
|
@@ -166,8 +168,14 @@ export async function apply(ctx, config) {
|
|
|
166
168
|
warnedLegacyConflicts: new Set(),
|
|
167
169
|
toolInventories: new Map(), // serverName -> { observed, observedAt, tools: Set<rawName> }
|
|
168
170
|
toolCatalogCache: new Map(), // connection key -> 最近成功、安全裁剪后的工具元数据
|
|
171
|
+
toolViews: new Map(), // connection key -> last discovery outcome (memory only)
|
|
169
172
|
};
|
|
170
173
|
const statusEvents = new StatusEventHub();
|
|
174
|
+
const toolRequests = new Map();
|
|
175
|
+
const discoveryPlans = new Map();
|
|
176
|
+
const connectionChecks = new Map();
|
|
177
|
+
const explorerActions = new Map();
|
|
178
|
+
const explorerActionTimes = new Map();
|
|
171
179
|
|
|
172
180
|
const hostGovernance = createHostGovernanceController(ctx, {
|
|
173
181
|
getRules: () => governancePolicies.snapshot().rules,
|
|
@@ -603,6 +611,13 @@ export async function apply(ctx, config) {
|
|
|
603
611
|
async function persistRecord(record) {
|
|
604
612
|
// 存储成功后才更新内存,避免落盘失败却在当前进程显示为“已安装”。
|
|
605
613
|
if (config.persistSecrets) await connectionStore.put(record);
|
|
614
|
+
const previous = state.connections.get(record.key);
|
|
615
|
+
if (previous && (connectionToolCacheSignature(previous) !== connectionToolCacheSignature(record)
|
|
616
|
+
|| JSON.stringify(previous.auth) !== JSON.stringify(record.auth)
|
|
617
|
+
|| JSON.stringify(previous.headers) !== JSON.stringify(record.headers)
|
|
618
|
+
|| JSON.stringify(previous.env) !== JSON.stringify(record.env))) {
|
|
619
|
+
await deleteLastSuccessfulTools(record.key);
|
|
620
|
+
}
|
|
606
621
|
state.connections.set(record.key, record);
|
|
607
622
|
clearRecordHealth(record);
|
|
608
623
|
}
|
|
@@ -984,6 +999,8 @@ export async function apply(ctx, config) {
|
|
|
984
999
|
}
|
|
985
1000
|
|
|
986
1001
|
async function cacheLastSuccessfulTools(record, tools) {
|
|
1002
|
+
// A request may finish after disconnect/reconfigure. Never resurrect old metadata.
|
|
1003
|
+
if (state.connections.get(record.key) !== record || record.enabled === false) return null;
|
|
987
1004
|
const entry = createToolCatalogRecord(record, tools);
|
|
988
1005
|
state.toolCatalogCache.set(record.key, entry);
|
|
989
1006
|
if (config.persistSecrets) {
|
|
@@ -997,6 +1014,9 @@ export async function apply(ctx, config) {
|
|
|
997
1014
|
}
|
|
998
1015
|
|
|
999
1016
|
async function deleteLastSuccessfulTools(key) {
|
|
1017
|
+
discoveryPlans.delete(key);
|
|
1018
|
+
connectionChecks.delete(key);
|
|
1019
|
+
state.toolViews.delete(key);
|
|
1000
1020
|
state.toolCatalogCache.delete(key);
|
|
1001
1021
|
if (config.persistSecrets) {
|
|
1002
1022
|
await toolCatalogStore.delete(key).catch((error) => {
|
|
@@ -1014,9 +1034,10 @@ export async function apply(ctx, config) {
|
|
|
1014
1034
|
return { entry, ...toolCatalogCacheView(entry) };
|
|
1015
1035
|
}
|
|
1016
1036
|
|
|
1017
|
-
function visibleToolCatalogEntries(workspaceId) {
|
|
1037
|
+
function visibleToolCatalogEntries(workspaceId, connectionKey) {
|
|
1018
1038
|
const entries = [];
|
|
1019
1039
|
for (const record of state.connections.values()) {
|
|
1040
|
+
if (connectionKey && record.key !== connectionKey) continue;
|
|
1020
1041
|
if (record.enabled === false) continue;
|
|
1021
1042
|
if (workspaceId && !connectionVisibleInWorkspace(connectionScopes.binding(record.key), workspaceId)) continue;
|
|
1022
1043
|
const cached = cachedToolsForRecord(record);
|
|
@@ -1025,6 +1046,38 @@ export async function apply(ctx, config) {
|
|
|
1025
1046
|
return entries;
|
|
1026
1047
|
}
|
|
1027
1048
|
|
|
1049
|
+
function explorerRecords(workspaceId) {
|
|
1050
|
+
// Unlike the legacy management overview, no Workspace means global-only.
|
|
1051
|
+
return [...state.connections.values()].filter((record) => record.enabled !== false
|
|
1052
|
+
&& connectionVisibleInWorkspace(connectionScopes.binding(record.key), workspaceId));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function explorerConnection(record) {
|
|
1056
|
+
const view = state.toolViews.get(record.key);
|
|
1057
|
+
const server = view?.record === record ? view.server : null;
|
|
1058
|
+
const cache = cachedToolsForRecord(record);
|
|
1059
|
+
const plan = discoveryPlans.get(record.key);
|
|
1060
|
+
const currentPlan = plan?.record === record ? plan : null;
|
|
1061
|
+
const check = connectionChecks.get(record.key);
|
|
1062
|
+
const diagnostic = check?.record === record && (!view || check.at >= view.at)
|
|
1063
|
+
? check.summary.diagnostic
|
|
1064
|
+
: server ? buildHealthSummary({ connectorId: record.connectorId, records: [record], results: [{
|
|
1065
|
+
ok: server.ok || server.unknown, kind: server.unknown ? 'managed' : server.ok ? 'connected' : server.errorKind || 'network',
|
|
1066
|
+
code: server.diagnosticCode, serverName: record.serverName, serverKey: record.serverKey, message: server.error,
|
|
1067
|
+
}], checkedAt: view.at }).diagnostic : null;
|
|
1068
|
+
const status = !server ? 'pending' : server.unknown ? 'unknown' : server.ok ? 'success' : 'failed';
|
|
1069
|
+
return {
|
|
1070
|
+
connectionKey: record.key, connectionName: record.name, connectorId: record.connectorId,
|
|
1071
|
+
connectorName: state.merged.find((item) => item.id === record.connectorId)?.name || record.name,
|
|
1072
|
+
serverName: record.serverName, transport: record.transport, status,
|
|
1073
|
+
checkedAt: view?.record === record ? view.at : null,
|
|
1074
|
+
observedAt: cache?.observedAt || null, stale: cache?.stale || false,
|
|
1075
|
+
toolCount: cache?.entry.tools.length || 0, cacheTruncated: cache?.entry.truncated === true,
|
|
1076
|
+
diagnostic, nextDiscoveryAt: currentPlan?.nextAt ?? null,
|
|
1077
|
+
retryReason: currentPlan?.reason || null,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1028
1081
|
function readinessResults(records, validationResults = []) {
|
|
1029
1082
|
return records.map((record, index) => {
|
|
1030
1083
|
const validated = validationResults[index];
|
|
@@ -2298,13 +2351,17 @@ export async function apply(ctx, config) {
|
|
|
2298
2351
|
};
|
|
2299
2352
|
},
|
|
2300
2353
|
|
|
2301
|
-
async healthCheck(connectorId) {
|
|
2354
|
+
async healthCheck(connectorId, workspaceId, connectionKey) {
|
|
2355
|
+
if (connectionKey && !explorerRecords(workspaceId).some((record) => record.key === connectionKey && record.connectorId === connectorId)) {
|
|
2356
|
+
return { ok: false, message: '连接不存在、已停用或当前工作区不可见' };
|
|
2357
|
+
}
|
|
2302
2358
|
const targetIds = connectorId
|
|
2303
2359
|
? [String(connectorId)]
|
|
2304
2360
|
: [...new Set([...state.connections.values()].map((record) => record.connectorId).filter(Boolean))];
|
|
2305
2361
|
const summaries = [];
|
|
2306
2362
|
for (const id of targetIds) {
|
|
2307
|
-
const records = [...state.connections.values()].filter((record) => record.connectorId === id
|
|
2363
|
+
const records = [...state.connections.values()].filter((record) => record.connectorId === id
|
|
2364
|
+
&& (!connectionKey || record.key === connectionKey));
|
|
2308
2365
|
const enabledRecords = records.filter((record) => record.enabled !== false);
|
|
2309
2366
|
if (records.length === 0 || enabledRecords.length === 0) {
|
|
2310
2367
|
const summary = healthSummary(id, records, [], Date.now());
|
|
@@ -2356,7 +2413,19 @@ export async function apply(ctx, config) {
|
|
|
2356
2413
|
});
|
|
2357
2414
|
checkedResults = validation.detail.results;
|
|
2358
2415
|
}
|
|
2359
|
-
|
|
2416
|
+
const summary = connectionKey ? healthSummary(id, records, [...immediateResults, ...checkedResults], Date.now())
|
|
2417
|
+
: cacheHealth(id, records, [...immediateResults, ...checkedResults]);
|
|
2418
|
+
if (connectionKey && state.connections.get(connectionKey) === records[0]) {
|
|
2419
|
+
connectionChecks.set(connectionKey, { record: records[0], summary, at: Date.now() });
|
|
2420
|
+
const outcome = [...immediateResults, ...checkedResults][0];
|
|
2421
|
+
if (outcome && !outcome.ok) {
|
|
2422
|
+
const prior = discoveryPlans.get(connectionKey);
|
|
2423
|
+
discoveryPlans.set(connectionKey, { record: records[0], ...nextDiscovery(prior?.record === records[0] ? prior : null, { ok: false, errorKind: outcome.kind, retryAfterMs: outcome.retryAfterMs }) });
|
|
2424
|
+
} else if (discoveryPlans.get(connectionKey)?.reason === 'auth') {
|
|
2425
|
+
discoveryPlans.delete(connectionKey); scheduleDiscovery();
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
summaries.push(summary);
|
|
2360
2429
|
}
|
|
2361
2430
|
const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
|
|
2362
2431
|
const unknown = summaries.filter((item) => item.connectionState === 'unknown').length;
|
|
@@ -2641,26 +2710,82 @@ export async function apply(ctx, config) {
|
|
|
2641
2710
|
return { ok: true, message: `已将 "${connector.name}" ${published ? '上架' : '下架'}` };
|
|
2642
2711
|
},
|
|
2643
2712
|
|
|
2713
|
+
async toolExplorer(input = {}) {
|
|
2714
|
+
const records = explorerRecords(input.workspaceId);
|
|
2715
|
+
const connections = records.map(explorerConnection);
|
|
2716
|
+
const selected = records.filter((record) => (!input.connectionKey || record.key === input.connectionKey)
|
|
2717
|
+
&& (!input.serverName || record.serverName === input.serverName)
|
|
2718
|
+
&& (!input.status || explorerConnection(record).status === input.status));
|
|
2719
|
+
const entries = selected.map((record) => cachedToolsForRecord(record)?.entry).filter(Boolean);
|
|
2720
|
+
const result = browseToolCatalog(entries, { query: input.query, offset: input.offset, limit: input.limit });
|
|
2721
|
+
const byKey = new Map(connections.map((item) => [item.connectionKey, item]));
|
|
2722
|
+
return { ok: true, message: `找到 ${result.total} 个工具`, detail: {
|
|
2723
|
+
...result, items: result.items.map((item) => ({ ...item, connection: byKey.get(item.connectionKey) })),
|
|
2724
|
+
connections, selectedConnections: selected.map(explorerConnection), discoveryOnly: true,
|
|
2725
|
+
scopeLabel: input.workspaceId ? '当前工作区可见连接' : '全局连接(未选择工作区)',
|
|
2726
|
+
} };
|
|
2727
|
+
},
|
|
2728
|
+
|
|
2729
|
+
async toolExplorerDetail(input = {}) {
|
|
2730
|
+
if (!explorerRecords(input.workspaceId).some((record) => record.key === input.connectionKey)) {
|
|
2731
|
+
return { ok: false, message: '连接不存在、已停用或当前工作区不可见' };
|
|
2732
|
+
}
|
|
2733
|
+
return api.toolDetail(input);
|
|
2734
|
+
},
|
|
2735
|
+
|
|
2736
|
+
async toolExplorerAction(input = {}) {
|
|
2737
|
+
const record = explorerRecords(input.workspaceId).find((item) => item.key === input.connectionKey);
|
|
2738
|
+
if (!record || !['discover', 'check'].includes(input.action)) return { ok: false, message: '操作无效或连接当前不可见' };
|
|
2739
|
+
const pending = explorerActions.get(record);
|
|
2740
|
+
if (pending) return pending;
|
|
2741
|
+
const plan = discoveryPlans.get(record.key);
|
|
2742
|
+
if (plan?.record === record && plan.reason === 'rate-limit' && !discoveryDue(plan)) {
|
|
2743
|
+
return { ok: false, message: '服务正在限流,请等待下一次允许重试时间', detail: { retryAt: plan.nextAt } };
|
|
2744
|
+
}
|
|
2745
|
+
if (Date.now() - (explorerActionTimes.get(record.key) || 0) < 5_000) return { ok: false, message: '操作过于频繁,请稍后重试' };
|
|
2746
|
+
explorerActionTimes.set(record.key, Date.now());
|
|
2747
|
+
const request = input.action === 'discover'
|
|
2748
|
+
? api.toolsList(record.connectorId, input.workspaceId, false, record.key)
|
|
2749
|
+
: api.healthCheck(record.connectorId, input.workspaceId, record.key);
|
|
2750
|
+
explorerActions.set(record, request);
|
|
2751
|
+
try { return await request; }
|
|
2752
|
+
finally { explorerActions.delete(record); statusEvents.publish(input.action === 'discover' ? 'tools' : 'status'); }
|
|
2753
|
+
},
|
|
2754
|
+
|
|
2644
2755
|
async toolSearch(input = {}) {
|
|
2645
2756
|
try {
|
|
2646
2757
|
const connectorId = String(input.connectorId ?? '').trim() || undefined;
|
|
2647
|
-
if (connectorId && !state.merged.some((connector) => connector.id === connectorId)
|
|
2758
|
+
if (connectorId && !state.merged.some((connector) => connector.id === connectorId)
|
|
2759
|
+
&& ![...state.connections.values()].some((record) => record.connectorId === connectorId
|
|
2760
|
+
&& record.enabled !== false && (!input.workspaceId || connectionVisibleInWorkspace(connectionScopes.binding(record.key), input.workspaceId)))) {
|
|
2648
2761
|
return { ok: false, message: `连接器 "${connectorId}" 不存在` };
|
|
2649
2762
|
}
|
|
2650
|
-
const items = searchToolCatalog(visibleToolCatalogEntries(input.workspaceId), {
|
|
2763
|
+
const items = searchToolCatalog(visibleToolCatalogEntries(input.workspaceId, input.connectionKey), {
|
|
2651
2764
|
query: input.query,
|
|
2652
2765
|
connectorId,
|
|
2653
2766
|
limit: input.limit,
|
|
2654
2767
|
});
|
|
2655
2768
|
const staleCount = items.filter((item) => item.stale).length;
|
|
2769
|
+
const visibleRecords = [...state.connections.values()].filter((record) => record.enabled !== false
|
|
2770
|
+
&& (!connectorId || record.connectorId === connectorId)
|
|
2771
|
+
&& (!input.connectionKey || record.key === input.connectionKey)
|
|
2772
|
+
&& (!input.workspaceId || connectionVisibleInWorkspace(connectionScopes.binding(record.key), input.workspaceId)));
|
|
2773
|
+
const pendingServers = visibleRecords.filter((record) => !cachedToolsForRecord(record)
|
|
2774
|
+
&& state.toolViews.get(record.key)?.record !== record).length;
|
|
2775
|
+
const failedServers = visibleRecords.filter((record) => {
|
|
2776
|
+
const view = state.toolViews.get(record.key);
|
|
2777
|
+
return view?.record === record && !view.server.ok;
|
|
2778
|
+
}).length;
|
|
2656
2779
|
return {
|
|
2657
2780
|
ok: true,
|
|
2658
|
-
message: `最后成功工具缓存中找到 ${items.length} 个匹配项${staleCount ? `,其中 ${staleCount} 个已超过 24 小时` : ''}`,
|
|
2781
|
+
message: `最后成功工具缓存中找到 ${items.length} 个匹配项${staleCount ? `,其中 ${staleCount} 个已超过 24 小时` : ''}${pendingServers ? `;${pendingServers} 个服务正在后台发现` : ''}${failedServers ? `;${failedServers} 个服务最近读取失败` : ''}`,
|
|
2659
2782
|
detail: {
|
|
2660
2783
|
items,
|
|
2661
2784
|
source: 'last-success-cache',
|
|
2662
2785
|
discoveryOnly: true,
|
|
2663
2786
|
credentialsStored: false,
|
|
2787
|
+
pendingServers,
|
|
2788
|
+
failedServers,
|
|
2664
2789
|
},
|
|
2665
2790
|
};
|
|
2666
2791
|
} catch (error) {
|
|
@@ -2672,7 +2797,7 @@ export async function apply(ctx, config) {
|
|
|
2672
2797
|
try {
|
|
2673
2798
|
const connectorId = String(input.connectorId ?? '').trim() || undefined;
|
|
2674
2799
|
const serverName = String(input.serverName ?? '').trim() || undefined;
|
|
2675
|
-
const matches = findToolDetails(visibleToolCatalogEntries(input.workspaceId), {
|
|
2800
|
+
const matches = findToolDetails(visibleToolCatalogEntries(input.workspaceId, input.connectionKey), {
|
|
2676
2801
|
toolName: input.toolName,
|
|
2677
2802
|
connectorId,
|
|
2678
2803
|
serverName,
|
|
@@ -2709,15 +2834,40 @@ export async function apply(ctx, config) {
|
|
|
2709
2834
|
* 从已连接的 MCP server 动态获取工具清单
|
|
2710
2835
|
* 通过 MCP JSON-RPC 协议调用 tools/list 方法
|
|
2711
2836
|
*/
|
|
2712
|
-
async toolsList(connectorId, workspaceId) {
|
|
2837
|
+
async toolsList(connectorId, workspaceId, cachedOnly = false, connectionKey) {
|
|
2713
2838
|
const connector = state.merged.find((d) => d.id === connectorId);
|
|
2714
|
-
if (!connector) return { ok: false, message: `连接器 "${connectorId}" 不存在` };
|
|
2715
2839
|
|
|
2716
2840
|
const records = [...state.connections.values()].filter((record) => (
|
|
2717
2841
|
record.connectorId === connectorId
|
|
2842
|
+
&& (!connectionKey || record.key === connectionKey)
|
|
2718
2843
|
&& record.enabled !== false
|
|
2719
2844
|
&& (!workspaceId || connectionVisibleInWorkspace(connectionScopes.binding(record.key), workspaceId))
|
|
2720
2845
|
));
|
|
2846
|
+
if (connectionKey && !records.length) return { ok: false, message: '连接不存在、已停用或当前工作区不可见' };
|
|
2847
|
+
if (!connector && !records.length) return { ok: false, message: `连接器 "${connectorId}" 不存在或当前工作区不可见` };
|
|
2848
|
+
if (cachedOnly && records.length) {
|
|
2849
|
+
const servers = records.map((record) => {
|
|
2850
|
+
const cached = cachedToolsForRecord(record);
|
|
2851
|
+
const view = state.toolViews.get(record.key);
|
|
2852
|
+
const current = view?.record === record ? view.server : null;
|
|
2853
|
+
return {
|
|
2854
|
+
serverKey: record.serverKey || record.name, serverName: record.serverName,
|
|
2855
|
+
ok: current?.ok === true, cached: Boolean(cached),
|
|
2856
|
+
unknown: !current || current.unknown === true,
|
|
2857
|
+
pending: !current && !cached,
|
|
2858
|
+
error: current?.error || (!cached && !current ? '正在后台发现工具,请稍候' : undefined),
|
|
2859
|
+
policy: serverPolicy(record),
|
|
2860
|
+
...(cached ? { cacheObservedAt: cached.observedAt, cacheAgeMs: cached.cacheAgeMs, stale: cached.stale } : {}),
|
|
2861
|
+
tools: (cached?.entry.tools || []).map((tool) => decorateToolPolicy(record, {
|
|
2862
|
+
name: tool.name, title: tool.title || tool.name, description: tool.description || '',
|
|
2863
|
+
})),
|
|
2864
|
+
};
|
|
2865
|
+
});
|
|
2866
|
+
return { ok: servers.some((server) => server.ok), message: '最近工具发现状态', detail: {
|
|
2867
|
+
connectorId, servers, totalTools: servers.reduce((sum, server) => sum + server.tools.length, 0),
|
|
2868
|
+
source: 'last-success-cache',
|
|
2869
|
+
} };
|
|
2870
|
+
}
|
|
2721
2871
|
if (!records.length) {
|
|
2722
2872
|
const servers = (connector.toolsSnapshot ?? []).map((snapshot) => ({
|
|
2723
2873
|
serverKey: snapshot.serverKey,
|
|
@@ -2740,7 +2890,7 @@ export async function apply(ctx, config) {
|
|
|
2740
2890
|
for (const record of records) {
|
|
2741
2891
|
if (record.transport === 'stdio') {
|
|
2742
2892
|
const registered = registeredStdioTools(record);
|
|
2743
|
-
const snapshot = (connector
|
|
2893
|
+
const snapshot = (connector?.toolsSnapshot ?? []).find((item) =>
|
|
2744
2894
|
item.serverKey === record.serverKey || item.serverName === record.serverName);
|
|
2745
2895
|
const liveTools = registered.tools;
|
|
2746
2896
|
let cached = cachedToolsForRecord(record);
|
|
@@ -2795,10 +2945,14 @@ export async function apply(ctx, config) {
|
|
|
2795
2945
|
continue;
|
|
2796
2946
|
}
|
|
2797
2947
|
try {
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
grants: grantMap()
|
|
2801
|
-
|
|
2948
|
+
let request = toolRequests.get(record);
|
|
2949
|
+
if (!request) {
|
|
2950
|
+
request = listMcpTools(record, { timeoutMs: config.requestTimeoutMs, grants: grantMap() });
|
|
2951
|
+
toolRequests.set(record, request);
|
|
2952
|
+
}
|
|
2953
|
+
let listed;
|
|
2954
|
+
try { listed = await request; }
|
|
2955
|
+
finally { if (toolRequests.get(record) === request) toolRequests.delete(record); }
|
|
2802
2956
|
state.toolInventories.set(record.serverName, {
|
|
2803
2957
|
observed: true,
|
|
2804
2958
|
observedAt: Date.now(),
|
|
@@ -2837,6 +2991,7 @@ export async function apply(ctx, config) {
|
|
|
2837
2991
|
ok: false,
|
|
2838
2992
|
error: error.message,
|
|
2839
2993
|
errorKind: error.kind,
|
|
2994
|
+
retryAfterMs: error.retryAfterMs,
|
|
2840
2995
|
policy: serverPolicy(record),
|
|
2841
2996
|
...cachedFields,
|
|
2842
2997
|
});
|
|
@@ -2847,6 +3002,17 @@ export async function apply(ctx, config) {
|
|
|
2847
3002
|
}
|
|
2848
3003
|
}
|
|
2849
3004
|
|
|
3005
|
+
if (records.some((record) => state.connections.get(record.key) !== record || record.enabled === false)) {
|
|
3006
|
+
return { ok: false, message: '连接已变更,请重新读取工具' };
|
|
3007
|
+
}
|
|
3008
|
+
records.forEach((record, index) => {
|
|
3009
|
+
if (state.connections.get(record.key) === record && record.enabled !== false) {
|
|
3010
|
+
const at = Date.now();
|
|
3011
|
+
state.toolViews.set(record.key, { record, server: servers[index], at });
|
|
3012
|
+
const prior = discoveryPlans.get(record.key);
|
|
3013
|
+
discoveryPlans.set(record.key, { record, ...nextDiscovery(prior?.record === record ? prior : null, servers[index], at) });
|
|
3014
|
+
}
|
|
3015
|
+
});
|
|
2850
3016
|
const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
|
|
2851
3017
|
const availableServers = servers.filter((server) => server.ok).length;
|
|
2852
3018
|
const unknownServers = servers.filter((server) => server.unknown).length;
|
|
@@ -2982,7 +3148,57 @@ export async function apply(ctx, config) {
|
|
|
2982
3148
|
hostGovernance.refresh();
|
|
2983
3149
|
hostScopes.refresh();
|
|
2984
3150
|
|
|
2985
|
-
|
|
3151
|
+
// One background pass at a time; UI event refreshes read snapshots, never tools/list.
|
|
3152
|
+
let discoveryDisposed = false;
|
|
3153
|
+
let discoveryRunning = false;
|
|
3154
|
+
let discoveryPending = false;
|
|
3155
|
+
let discoveryTimer = null;
|
|
3156
|
+
async function discoverTools() {
|
|
3157
|
+
if (discoveryDisposed || discoveryRunning) return;
|
|
3158
|
+
discoveryRunning = true;
|
|
3159
|
+
discoveryPending = false;
|
|
3160
|
+
try {
|
|
3161
|
+
const records = [...state.connections.values()].filter((record) => {
|
|
3162
|
+
const plan = discoveryPlans.get(record.key);
|
|
3163
|
+
return record.enabled !== false && discoveryDue(plan?.record === record ? plan : null);
|
|
3164
|
+
});
|
|
3165
|
+
let cursor = 0;
|
|
3166
|
+
await Promise.all(Array.from({ length: Math.min(2, records.length) }, async () => {
|
|
3167
|
+
while (cursor < records.length && !discoveryDisposed) {
|
|
3168
|
+
const record = records[cursor++];
|
|
3169
|
+
if (state.connections.get(record.key) !== record || record.enabled === false) continue;
|
|
3170
|
+
await api.toolsList(record.connectorId, undefined, false, record.key).catch(() => {});
|
|
3171
|
+
if (!discoveryDisposed) statusEvents.publish('tools');
|
|
3172
|
+
}
|
|
3173
|
+
}));
|
|
3174
|
+
} finally {
|
|
3175
|
+
discoveryRunning = false;
|
|
3176
|
+
if (discoveryPending) scheduleDiscovery();
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
function scheduleDiscovery() {
|
|
3180
|
+
if (discoveryDisposed) return;
|
|
3181
|
+
discoveryPending = true;
|
|
3182
|
+
if (discoveryRunning || discoveryTimer) return;
|
|
3183
|
+
discoveryTimer = setTimeout(() => { discoveryTimer = null; void discoverTools(); }, 100);
|
|
3184
|
+
discoveryTimer.unref?.();
|
|
3185
|
+
}
|
|
3186
|
+
const discoveryMutations = new Set(['connect', 'configure', 'importJson', 'installFromUrl', 'reconfigureConnection', 'setEnabled', 'restoreSnapshot', 'migrateLegacy']);
|
|
3187
|
+
const discoveryApi = new Proxy(api, { get(target, property) {
|
|
3188
|
+
const value = target[property];
|
|
3189
|
+
if (!discoveryMutations.has(property)) return value;
|
|
3190
|
+
return async (...args) => {
|
|
3191
|
+
const result = await value.apply(target, args);
|
|
3192
|
+
if (result?.ok) scheduleDiscovery();
|
|
3193
|
+
return result;
|
|
3194
|
+
};
|
|
3195
|
+
} });
|
|
3196
|
+
const observableApi = observeStatusEvents(discoveryApi, statusEvents);
|
|
3197
|
+
scheduleDiscovery();
|
|
3198
|
+
const discoveryInterval = setInterval(() => {
|
|
3199
|
+
if (statusEvents.clients.size > 0) scheduleDiscovery();
|
|
3200
|
+
}, 30_000);
|
|
3201
|
+
discoveryInterval.unref?.();
|
|
2986
3202
|
const disposeTools = registerTools(ctx, observableApi);
|
|
2987
3203
|
|
|
2988
3204
|
/* ───────────────────────── web 半区(图形化市场)──────────────────────── */
|
|
@@ -3000,6 +3216,9 @@ export async function apply(ctx, config) {
|
|
|
3000
3216
|
}
|
|
3001
3217
|
|
|
3002
3218
|
ctx.effect(() => () => {
|
|
3219
|
+
discoveryDisposed = true;
|
|
3220
|
+
clearTimeout(discoveryTimer);
|
|
3221
|
+
clearInterval(discoveryInterval);
|
|
3003
3222
|
disposeTools();
|
|
3004
3223
|
disposeWeb?.();
|
|
3005
3224
|
hostGovernance.dispose();
|
package/lib/mcp-http.js
CHANGED
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { MCP_PROTOCOL_VERSION } from './constants.js';
|
|
9
9
|
import { authHeaders } from './mcp-provision.js';
|
|
10
|
+
import { parseRetryAfter } from './discovery-policy.js';
|
|
10
11
|
|
|
11
12
|
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
12
13
|
const MAX_TOOL_PAGES = 100;
|
|
13
14
|
const MAX_TOOLS = 10_000;
|
|
14
15
|
|
|
15
16
|
export class McpHttpError extends Error {
|
|
16
|
-
constructor(message, { kind = 'protocol', status } = {}) {
|
|
17
|
+
constructor(message, { kind = 'protocol', status, retryAfterMs } = {}) {
|
|
17
18
|
super(message);
|
|
18
19
|
this.name = 'McpHttpError';
|
|
19
20
|
this.kind = kind;
|
|
20
21
|
this.status = status;
|
|
22
|
+
this.retryAfterMs = retryAfterMs;
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -64,7 +66,7 @@ function responseError(response, payload, text) {
|
|
|
64
66
|
return new McpHttpError(`凭据无效、已过期或权限不足(HTTP ${response.status})`, { kind: 'auth', status: response.status });
|
|
65
67
|
}
|
|
66
68
|
if (response.status === 429) {
|
|
67
|
-
return new McpHttpError('服务请求过于频繁(HTTP 429),请稍后重试', { kind: 'rate-limit', status: response.status });
|
|
69
|
+
return new McpHttpError('服务请求过于频繁(HTTP 429),请稍后重试', { kind: 'rate-limit', status: response.status, retryAfterMs: parseRetryAfter(response.headers.get('retry-after')) });
|
|
68
70
|
}
|
|
69
71
|
const suffix = rawMessage ? `:${rawMessage}` : '';
|
|
70
72
|
return new McpHttpError(`MCP 请求失败(HTTP ${response.status})${suffix}`, { kind: 'http', status: response.status });
|
package/lib/mcp-validation.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { MCP_PROTOCOL_VERSION } from './constants.js';
|
|
6
6
|
import { authHeaders } from './mcp-provision.js';
|
|
7
|
+
import { parseRetryAfter } from './discovery-policy.js';
|
|
7
8
|
|
|
8
9
|
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
9
10
|
|
|
@@ -142,7 +143,7 @@ export async function validateConnectionRecord(record, { timeoutMs = 15_000, fet
|
|
|
142
143
|
return failed(record, 'auth', `Key/Token 无效、已过期或当前账号没有该 Server 权限(HTTP ${response.status})`, { httpStatus: response.status });
|
|
143
144
|
}
|
|
144
145
|
if (response.status === 429) {
|
|
145
|
-
return failed(record, 'rate-limit', '服务请求过于频繁(HTTP 429),请稍后重试', { httpStatus: response.status });
|
|
146
|
+
return failed(record, 'rate-limit', '服务请求过于频繁(HTTP 429),请稍后重试', { httpStatus: response.status, retryAfterMs: parseRetryAfter(response.headers.get('retry-after')) });
|
|
146
147
|
}
|
|
147
148
|
if (!response.ok) {
|
|
148
149
|
const message = response.status >= 500
|
package/lib/status-events.js
CHANGED
|
@@ -111,7 +111,7 @@ export function observeStatusEvents(api, eventHub, eventTypes = DEFAULT_EVENT_TY
|
|
|
111
111
|
try {
|
|
112
112
|
return await Reflect.apply(value, target, args);
|
|
113
113
|
} finally {
|
|
114
|
-
eventHub.publish(eventType);
|
|
114
|
+
if (!(property === 'toolsList' && args[2] === true)) eventHub.publish(eventType);
|
|
115
115
|
}
|
|
116
116
|
};
|
|
117
117
|
},
|
|
@@ -23,7 +23,7 @@ function truncateUtf8(value, maxBytes) {
|
|
|
23
23
|
return output;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function sanitizeSchemaValue(value, state, depth = 0) {
|
|
26
|
+
function sanitizeSchemaValue(value, state, depth = 0, propertyMap = false) {
|
|
27
27
|
if (state.nodes >= 2_048 || depth > 10) {
|
|
28
28
|
state.truncated = true;
|
|
29
29
|
return undefined;
|
|
@@ -47,12 +47,13 @@ function sanitizeSchemaValue(value, state, depth = 0) {
|
|
|
47
47
|
const entries = Object.entries(value);
|
|
48
48
|
if (entries.length > 128) state.truncated = true;
|
|
49
49
|
for (const [rawKey, rawValue] of entries.slice(0, 128)) {
|
|
50
|
-
if (OMITTED_SCHEMA_KEYS.has(rawKey) || UNSAFE_KEYS.has(rawKey)) {
|
|
50
|
+
if ((!propertyMap && OMITTED_SCHEMA_KEYS.has(rawKey)) || UNSAFE_KEYS.has(rawKey)) {
|
|
51
51
|
if (OMITTED_SCHEMA_KEYS.has(rawKey)) state.truncated = true;
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
54
|
const key = truncateUtf8(rawKey, 256);
|
|
55
|
-
const child = sanitizeSchemaValue(rawValue, state, depth + 1
|
|
55
|
+
const child = sanitizeSchemaValue(rawValue, state, depth + 1,
|
|
56
|
+
!propertyMap && ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas'].includes(rawKey));
|
|
56
57
|
if (key && child !== undefined) output[key] = child;
|
|
57
58
|
}
|
|
58
59
|
return output;
|
|
@@ -166,14 +167,19 @@ function validLimit(limit) {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
export function searchToolCatalog(entries, { query, connectorId, limit = 20 } = {}, now = Date.now(), ttlMs = TOOL_CACHE_TTL_MS) {
|
|
170
|
+
if (!normalizeToolSearchText(query)) throw new Error('工具搜索关键词不能为空');
|
|
171
|
+
return browseToolCatalog(entries, { query, connectorId, limit }, now, ttlMs).items;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function browseToolCatalog(entries, { query, connectorId, serverName, limit = 20, offset = 0 } = {}, now = Date.now(), ttlMs = TOOL_CACHE_TTL_MS) {
|
|
169
175
|
const normalizedQuery = normalizeToolSearchText(query);
|
|
170
|
-
if (!normalizedQuery) throw new Error('工具搜索关键词不能为空');
|
|
171
176
|
const results = [];
|
|
172
177
|
for (const entry of entries ?? []) {
|
|
173
178
|
if (connectorId && entry.connectorId !== connectorId) continue;
|
|
179
|
+
if (serverName && entry.serverName !== serverName) continue;
|
|
174
180
|
const cache = toolCatalogCacheView(entry, now, ttlMs);
|
|
175
181
|
for (const tool of entry.tools ?? []) {
|
|
176
|
-
const score = toolSearchScore(tool, normalizedQuery);
|
|
182
|
+
const score = normalizedQuery ? toolSearchScore(tool, normalizedQuery) : 0;
|
|
177
183
|
if (score < 0) continue;
|
|
178
184
|
results.push({
|
|
179
185
|
connectorId: entry.connectorId,
|
|
@@ -190,12 +196,11 @@ export function searchToolCatalog(entries, { query, connectorId, limit = 20 } =
|
|
|
190
196
|
});
|
|
191
197
|
}
|
|
192
198
|
}
|
|
193
|
-
|
|
194
|
-
.sort((left, right) => right.score - left.score
|
|
199
|
+
results.sort((left, right) => right.score - left.score
|
|
195
200
|
|| left.name.localeCompare(right.name)
|
|
196
|
-
|| left.serverName.localeCompare(right.serverName))
|
|
197
|
-
|
|
198
|
-
|
|
201
|
+
|| left.serverName.localeCompare(right.serverName) || left.connectionKey.localeCompare(right.connectionKey));
|
|
202
|
+
const start = Number.isFinite(Number(offset)) ? Math.max(0, Math.trunc(Number(offset))) : 0;
|
|
203
|
+
return { total: results.length, items: results.slice(start, start + validLimit(limit)).map(({ score, ...item }) => item) };
|
|
199
204
|
}
|
|
200
205
|
|
|
201
206
|
export function findToolDetails(entries, { toolName, connectorId, serverName } = {}, now = Date.now(), ttlMs = TOOL_CACHE_TTL_MS) {
|
package/lib/tools.js
CHANGED
|
@@ -607,6 +607,7 @@ export function registerTools(ctx, api) {
|
|
|
607
607
|
properties: {
|
|
608
608
|
connectorId: { type: 'string', description: '连接器 id(来自 mcp_connector_catalog)' },
|
|
609
609
|
workspaceId: { type: 'string', description: '可选;只加载在该 Workspace 可见的已连接 Server' },
|
|
610
|
+
connectionKey: { type: 'string', description: '可选;来自 status 的连接 key,限定手动、JSON 或目录安装的单条连接' },
|
|
610
611
|
},
|
|
611
612
|
required: ['connectorId'],
|
|
612
613
|
additionalProperties: false,
|
|
@@ -614,7 +615,7 @@ export function registerTools(ctx, api) {
|
|
|
614
615
|
output: toolsListOutput,
|
|
615
616
|
timeoutMs: 30_000,
|
|
616
617
|
async execute(args) {
|
|
617
|
-
return api.toolsList(args.connectorId, args.workspaceId);
|
|
618
|
+
return api.toolsList(args.connectorId, args.workspaceId, false, args.connectionKey);
|
|
618
619
|
},
|
|
619
620
|
});
|
|
620
621
|
|
|
@@ -640,6 +641,7 @@ export function registerTools(ctx, api) {
|
|
|
640
641
|
type: 'object',
|
|
641
642
|
properties: {
|
|
642
643
|
query: { type: 'string', description: '工具名称、标题或描述关键词' },
|
|
644
|
+
connectionKey: { type: 'string', description: '可选;限定单条连接 key' },
|
|
643
645
|
connectorId: { type: 'string', description: '可选;限定连接器 id' },
|
|
644
646
|
workspaceId: { type: 'string', description: '可选;仅搜索该 Workspace 可见的连接' },
|
|
645
647
|
limit: { type: 'number', minimum: 1, maximum: 100, description: '最多返回数量,默认 20' },
|
|
@@ -672,6 +674,7 @@ export function registerTools(ctx, api) {
|
|
|
672
674
|
type: 'object',
|
|
673
675
|
properties: {
|
|
674
676
|
toolName: { type: 'string', description: '精确工具名(来自 mcp_connector_tool_search)' },
|
|
677
|
+
connectionKey: { type: 'string', description: '可选;限定单条连接 key' },
|
|
675
678
|
connectorId: { type: 'string', description: '可选;用于消除同名歧义' },
|
|
676
679
|
serverName: { type: 'string', description: '可选;用于消除同名歧义' },
|
|
677
680
|
workspaceId: { type: 'string', description: '可选;仅查看该 Workspace 可见的连接' },
|
package/lib/web.js
CHANGED
|
@@ -48,8 +48,11 @@ const API_WHITELIST = new Map([
|
|
|
48
48
|
['disconnect', (api, p) => api.disconnect(p?.key)],
|
|
49
49
|
['setEnabled', (api, p) => api.setEnabled(p?.key, p?.enabled !== false)],
|
|
50
50
|
['refreshCatalog', (api) => api.refreshCatalog()],
|
|
51
|
-
['toolsList', (api, p) => api.toolsList(p?.connectorId, p?.workspaceId)],
|
|
51
|
+
['toolsList', (api, p) => api.toolsList(p?.connectorId, p?.workspaceId, p?.cachedOnly === true, p?.connectionKey)],
|
|
52
52
|
['toolSearch', (api, p) => api.toolSearch(p ?? {})],
|
|
53
|
+
['toolExplorer', (api, p) => api.toolExplorer(p ?? {})],
|
|
54
|
+
['toolExplorerDetail', (api, p) => api.toolExplorerDetail(p ?? {})],
|
|
55
|
+
['toolExplorerAction', (api, p) => api.toolExplorerAction(p ?? {})],
|
|
53
56
|
['toolDetail', (api, p) => api.toolDetail(p ?? {})],
|
|
54
57
|
]);
|
|
55
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.45",
|
|
4
4
|
"description": "DeepSeek Harness MCP Connector and MCP Server marketplace with over one hundred MCP connectors, continuously updated. Discover, authorize, and manage connections in one place; supports OAuth 2.0 PKCE, API keys, stdio/HTTP, mcpServers JSON import, and tool and prompt discovery. Maintained by Qichacha/QCC.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
43
|
"test": "node --test",
|
|
44
|
-
"lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/settings.js && node --check lib/version-status.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/diagnostics.js && node --check lib/connection-backup.js && node --check lib/connection-editor.js && node --check lib/connection-scopes.js && node --check lib/governance.js && node --check lib/tool-catalog-cache.js && node --check lib/status-events.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/grant-journal.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/check-distribution-sync.mjs && node --check scripts/check-readme-version.mjs && node --check scripts/check-marketing-metadata.mjs && node --check scripts/check-storefront-screenshots.mjs && node --check scripts/sync-registry-stats.mjs && node --check scripts/verify-pack.mjs",
|
|
44
|
+
"lint": "node --check lib/discovery-policy.js && node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/settings.js && node --check lib/version-status.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/diagnostics.js && node --check lib/connection-backup.js && node --check lib/connection-editor.js && node --check lib/connection-scopes.js && node --check lib/governance.js && node --check lib/tool-catalog-cache.js && node --check lib/status-events.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/grant-journal.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/check-distribution-sync.mjs && node --check scripts/check-readme-version.mjs && node --check scripts/check-marketing-metadata.mjs && node --check scripts/check-storefront-screenshots.mjs && node --check scripts/sync-registry-stats.mjs && node --check scripts/verify-pack.mjs",
|
|
45
45
|
"registry:build": "node scripts/build-registry.mjs",
|
|
46
46
|
"registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
|
|
47
47
|
"dev:ui": "node scripts/ui-harness.mjs",
|
package/scripts/ui-harness.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeConnectorDescriptor } from '../lib/schema.js';
|
|
|
8
8
|
import { auditDescriptor, auditRawDescriptor, mergeCatalog } from '../lib/catalog.js';
|
|
9
9
|
import { normalizeGovernanceMutation, publicToolName, resolveGovernancePolicy } from '../lib/governance.js';
|
|
10
10
|
import { ConnectionScopeService, bindingForConnection, scopeLabel } from '../lib/connection-scopes.js';
|
|
11
|
+
import { browseToolCatalog, createToolCatalogRecord } from '../lib/tool-catalog-cache.js';
|
|
11
12
|
|
|
12
13
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
14
|
const packageJson = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
@@ -43,6 +44,8 @@ const bundledAssets = new Map([
|
|
|
43
44
|
['wind-logo.png', 'image/png'],
|
|
44
45
|
]);
|
|
45
46
|
const eventClients = new Set();
|
|
47
|
+
const mockExplorerRecovered = new Set();
|
|
48
|
+
const mockObservedAt = Date.now();
|
|
46
49
|
let eventSequence = 0;
|
|
47
50
|
|
|
48
51
|
function publishStatus(type) {
|
|
@@ -359,6 +362,28 @@ const server = createServer(async (req, res) => {
|
|
|
359
362
|
json(res, { ok: false, message: `Mock 作用域回滚失败: ${error.message}` }); return;
|
|
360
363
|
}
|
|
361
364
|
}
|
|
365
|
+
if (method === 'toolExplorer') {
|
|
366
|
+
const connections = catalog.filter((item) => connected.has(item.id)).flatMap((item) => item.servers.map((server, index) => {
|
|
367
|
+
const key = `${item.id}:${server.serverKey}`;
|
|
368
|
+
const failed = index === 1 && !mockExplorerRecovered.has(key);
|
|
369
|
+
return { connectionKey: key, connectorId: item.id, connectorName: item.name, connectionName: `${item.name} · ${server.serverKey}`,
|
|
370
|
+
serverName: server.serverName, status: failed ? 'failed' : 'success', checkedAt: mockObservedAt, observedAt: mockObservedAt,
|
|
371
|
+
toolCount: 125, diagnostic: failed ? { stageLabel: '网络与传输', message: '模拟连接超时(非真实服务故障)', action: '检查网络后重新发现工具' } : null };
|
|
372
|
+
}));
|
|
373
|
+
const selectedConnections = connections.filter((c) => (!params.connectionKey || c.connectionKey === params.connectionKey)
|
|
374
|
+
&& (!params.serverName || c.serverName === params.serverName) && (!params.status || c.status === params.status));
|
|
375
|
+
const entries = selectedConnections.map((c) => createToolCatalogRecord({ key: c.connectionKey, connectorId: c.connectorId, serverName: c.serverName, transport: 'stdio' },
|
|
376
|
+
Array.from({ length: 125 }, (_, i) => ({ name: `mock_tool_${String(i + 1).padStart(3, '0')}`, title: `Mock 工具 ${i + 1}`, description: '本机无凭据测试数据,不执行真实工具。' })), mockObservedAt));
|
|
377
|
+
const result = browseToolCatalog(entries, params);
|
|
378
|
+
json(res, { ok: true, message: 'Mock 工具浏览', detail: { ...result,
|
|
379
|
+
items: result.items.map((item) => ({ ...item, connection: connections.find((c) => c.connectionKey === item.connectionKey) })),
|
|
380
|
+
connections, selectedConnections, scopeLabel: '本机无凭据模拟连接' } }); return;
|
|
381
|
+
}
|
|
382
|
+
if (method === 'toolExplorerAction') {
|
|
383
|
+
mockExplorerRecovered.add(params.connectionKey);
|
|
384
|
+
publishStatus('tools');
|
|
385
|
+
json(res, { ok: true, message: '模拟操作完成:未访问真实服务' }); return;
|
|
386
|
+
}
|
|
362
387
|
if (method === 'toolsList') {
|
|
363
388
|
const connector = catalog.find((item) => item.id === params.connectorId);
|
|
364
389
|
const server = connector?.servers?.[0] ?? { serverKey: 'mock', serverName: 'mock-server' };
|
|
@@ -430,14 +455,17 @@ const server = createServer(async (req, res) => {
|
|
|
430
455
|
}).filter((tool) => `${tool.name} ${tool.title} ${tool.description}`.toLowerCase().includes(query)).slice(0, params.limit || 20);
|
|
431
456
|
json(res, { ok: true, message: `Mock 找到 ${items.length} 个工具`, detail: { items, source: 'last-success-cache', discoveryOnly: true } }); return;
|
|
432
457
|
}
|
|
433
|
-
if (method === 'toolDetail') {
|
|
458
|
+
if (method === 'toolDetail' || method === 'toolExplorerDetail') {
|
|
434
459
|
json(res, { ok: true, message: `Mock 工具详情:${params.toolName}`, detail: { tool: {
|
|
435
460
|
connectorId: params.connectorId,
|
|
436
461
|
serverName: params.serverName,
|
|
437
462
|
name: params.toolName,
|
|
438
463
|
title: params.toolName,
|
|
439
464
|
description: '本地无凭据 UI 验收数据;不会执行真实 MCP 工具。',
|
|
440
|
-
inputSchema: { type: 'object', properties: { query: { type: 'string', description: '公开检索词'
|
|
465
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string', description: '公开检索词', minLength: 1 },
|
|
466
|
+
mode: { type: 'string', enum: ['basic', 'full'], description: '返回详细程度' },
|
|
467
|
+
filters: { type: 'object', properties: { regions: { type: 'array', items: { type: 'string' } } } },
|
|
468
|
+
target: { oneOf: [{ type: 'string' }, { type: 'number' }] } }, required: ['query'] },
|
|
441
469
|
observedAt: Date.now(),
|
|
442
470
|
stale: false,
|
|
443
471
|
schemaTruncated: false,
|
package/ui/index.html
CHANGED
|
@@ -331,6 +331,16 @@
|
|
|
331
331
|
.tool-deprecated { color: var(--warn); background: var(--warn-bg); border-radius: 999px; padding: 1px 6px; font-size: 10px; line-height: 1.5; flex: 0 0 auto; }
|
|
332
332
|
.tool-description { color: var(--text-2); font-size: 12px; line-height: 1.55; margin-top: 4px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
333
333
|
.tool-description.empty { color: var(--muted); font-style: italic; }
|
|
334
|
+
.explorer-filters { display:flex; flex-wrap:wrap; gap:10px; align-items:end; }
|
|
335
|
+
.explorer-filters label { display:flex; flex:1 1 150px; flex-direction:column; gap:5px; font-size:12px; }
|
|
336
|
+
.explorer-filters select { width:100%; min-width:0; padding:8px; border:1px solid var(--line); border-radius:8px; background:var(--bg); color:var(--text); }
|
|
337
|
+
.explorer-card { border:1px solid var(--line); padding:14px; border-radius:10px; margin:10px 0; overflow-wrap:anywhere; }
|
|
338
|
+
.explorer-card p { margin:8px 0; white-space:pre-wrap; }
|
|
339
|
+
.parameter-scroll { overflow-x:auto; }
|
|
340
|
+
.parameter-table { width:100%; border-collapse:collapse; font-size:12px; }
|
|
341
|
+
.parameter-table th, .parameter-table td { border-bottom:1px solid var(--line); padding:8px; text-align:left; vertical-align:top; overflow-wrap:anywhere; }
|
|
342
|
+
.parameter-table th { white-space:nowrap; }
|
|
343
|
+
.parameter-table details { margin-top:6px; }
|
|
334
344
|
.tools-loading, .tools-error { font-size: 13px; color: var(--muted); padding: 8px; }
|
|
335
345
|
.tools-error { color: var(--bad); }
|
|
336
346
|
.tools-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 8px; }
|
|
@@ -429,6 +439,7 @@
|
|
|
429
439
|
<div class="tabs" id="tabs">
|
|
430
440
|
<button data-view="market" class="active">市场 <span class="tab-count" id="market-tab-count"></span></button>
|
|
431
441
|
<button data-view="installed">已安装 <span class="tab-count" id="installed-tab-count"></span></button>
|
|
442
|
+
<button data-view="tools">工具</button>
|
|
432
443
|
</div>
|
|
433
444
|
<div class="header-spacer"></div>
|
|
434
445
|
<div class="search-box">
|
|
@@ -546,6 +557,13 @@
|
|
|
546
557
|
let view = 'market';
|
|
547
558
|
let marketItems = {};
|
|
548
559
|
let searchQuery = '';
|
|
560
|
+
let explorerFilter = { connectionKey: '', serverName: '', status: '' };
|
|
561
|
+
let explorerOffset = 0;
|
|
562
|
+
let explorerSequence = 0;
|
|
563
|
+
let explorerDetailSequence = 0;
|
|
564
|
+
let explorerData = null;
|
|
565
|
+
let searchTimer = null;
|
|
566
|
+
const explorerBusy = new Set();
|
|
549
567
|
let marketCategory = '';
|
|
550
568
|
const PROMPT_PAGE_SIZE = 4;
|
|
551
569
|
let detailPrompts = [];
|
|
@@ -649,7 +667,14 @@
|
|
|
649
667
|
currentWorkspace = workspace && typeof workspace.workspaceId === 'string'
|
|
650
668
|
? { workspaceId: workspace.workspaceId, title: String(workspace.title || workspace.workspaceId) }
|
|
651
669
|
: null;
|
|
652
|
-
if ((currentWorkspace?.workspaceId ?? null) !== previousWorkspaceId)
|
|
670
|
+
if ((currentWorkspace?.workspaceId ?? null) !== previousWorkspaceId) {
|
|
671
|
+
explorerSequence++; explorerDetailSequence++;
|
|
672
|
+
explorerData = null; explorerOffset = 0;
|
|
673
|
+
explorerFilter = { connectionKey: '', serverName: '', status: '' };
|
|
674
|
+
closeModal(); closeDetail();
|
|
675
|
+
if (view === 'tools') main.innerHTML = '';
|
|
676
|
+
refresh();
|
|
677
|
+
}
|
|
653
678
|
return;
|
|
654
679
|
}
|
|
655
680
|
if (event.data?.type === PROMPT_RESULT_TYPE) {
|
|
@@ -782,7 +807,7 @@
|
|
|
782
807
|
}
|
|
783
808
|
|
|
784
809
|
function policySelect(scope, current, serverName = '', toolName = '', publicName = '', label = '') {
|
|
785
|
-
if (!detailConnector?.connected?.length) return '';
|
|
810
|
+
if (!detailConnector?.connected?.length || detailConnector.connectionKey) return '';
|
|
786
811
|
return `<select class="policy-select" data-policy-scope="${esc(scope)}" data-current-effect="${esc(current || 'inherit')}" data-server-name="${esc(serverName)}" data-tool-name="${esc(toolName)}" data-public-name="${esc(publicName)}" aria-label="${esc(label || `${scope} 策略`)}">${policyOptions(current)}</select>`;
|
|
787
812
|
}
|
|
788
813
|
|
|
@@ -799,7 +824,7 @@
|
|
|
799
824
|
|
|
800
825
|
function syncGovernanceBar() {
|
|
801
826
|
const bar = $('#governance-bar');
|
|
802
|
-
const connected = detailConnector?.connected?.length > 0;
|
|
827
|
+
const connected = detailConnector?.connected?.length > 0 && !detailConnector.connectionKey;
|
|
803
828
|
bar.hidden = !connected;
|
|
804
829
|
if (!connected) return;
|
|
805
830
|
const connectionEffect = detailGovernance?.connectionPolicy || 'inherit';
|
|
@@ -861,8 +886,34 @@
|
|
|
861
886
|
if (result.ok && detailConnector) await window.__mcp.showDetail(detailConnector.id);
|
|
862
887
|
}
|
|
863
888
|
|
|
889
|
+
function normalizeToolSearchText(value) {
|
|
890
|
+
return String(value ?? '').normalize('NFKC')
|
|
891
|
+
.replace(/([\p{Script=Han}])([A-Za-z0-9])/gu, '$1 $2')
|
|
892
|
+
.replace(/([A-Za-z0-9])([\p{Script=Han}])/gu, '$1 $2')
|
|
893
|
+
.toLocaleLowerCase('zh-CN').replace(/[^\p{Letter}\p{Number}]+/gu, ' ').trim().replace(/\s+/g, ' ');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function toolSearchScore(tool, query) {
|
|
897
|
+
const terms = query.split(' ').filter(Boolean);
|
|
898
|
+
const fields = [tool.name, tool.title, tool.description].map(normalizeToolSearchText);
|
|
899
|
+
const compactQuery = query.replace(/ /g, '');
|
|
900
|
+
const compactFields = fields.map((field) => field.replace(/ /g, ''));
|
|
901
|
+
const fieldIndex = fields.findIndex((field, index) => (
|
|
902
|
+
terms.every((term) => field.includes(term)) || compactFields[index].includes(compactQuery)
|
|
903
|
+
));
|
|
904
|
+
if (fieldIndex < 0) return -1;
|
|
905
|
+
const field = fields[fieldIndex];
|
|
906
|
+
const compact = compactFields[fieldIndex];
|
|
907
|
+
let score = fieldIndex === 0 ? 300 : fieldIndex === 1 ? 180 : 80;
|
|
908
|
+
if (field === query || compact === compactQuery) score += 1_000;
|
|
909
|
+
else if (field.startsWith(query) || compact.startsWith(compactQuery)) score += 500;
|
|
910
|
+
else if (field.includes(query) || compact.includes(compactQuery)) score += 250;
|
|
911
|
+
score -= Math.min(field.length, 200) / 1_000;
|
|
912
|
+
return score;
|
|
913
|
+
}
|
|
914
|
+
|
|
864
915
|
function renderTools() {
|
|
865
|
-
const query = $('#tools-search').value
|
|
916
|
+
const query = normalizeToolSearchText($('#tools-search').value);
|
|
866
917
|
if (detailToolServers.length === 0) {
|
|
867
918
|
updateToolsSummary();
|
|
868
919
|
$('#detail-tools').innerHTML = '<div class="tools-empty">暂无可用工具</div>';
|
|
@@ -871,17 +922,22 @@
|
|
|
871
922
|
let matchedTools = 0;
|
|
872
923
|
let renderedTools = 0;
|
|
873
924
|
let remaining = toolRenderLimit;
|
|
874
|
-
const
|
|
925
|
+
const rankedServers = query ? [...detailToolServers].sort((a, b) =>
|
|
926
|
+
Math.max(-1, ...(b.tools || []).map((tool) => toolSearchScore(tool, query)))
|
|
927
|
+
- Math.max(-1, ...(a.tools || []).map((tool) => toolSearchScore(tool, query)))) : detailToolServers;
|
|
928
|
+
const groups = rankedServers.map((server) => {
|
|
875
929
|
const label = server.serverName || server.serverKey || 'MCP Server';
|
|
876
930
|
const serverSelect = policySelect('server', server.policy?.configuredEffect, server.serverName, '', '', `${label} Server 策略`);
|
|
877
931
|
if (!server.ok && !server.cached) {
|
|
878
932
|
if (query) return '';
|
|
879
|
-
return `<div class="tools-server"><div class="tools-server-header"><span class="tools-server-copy">${esc(label)} <span class="count">·
|
|
933
|
+
return `<div class="tools-server"><div class="tools-server-header"><span class="tools-server-copy">${esc(label)} <span class="count">· ${server.pending ? '正在发现' : '加载失败'}</span></span>${serverSelect}</div><div class="tools-error">${esc(server.error || '未知错误')}</div></div>`;
|
|
880
934
|
}
|
|
881
935
|
const matched = (server.tools || []).filter((tool) => {
|
|
882
936
|
if (!query) return true;
|
|
883
|
-
return
|
|
884
|
-
}).sort((left, right) =>
|
|
937
|
+
return toolSearchScore(tool, query) >= 0;
|
|
938
|
+
}).sort((left, right) => query
|
|
939
|
+
? toolSearchScore(right, query) - toolSearchScore(left, query) || left.name.localeCompare(right.name)
|
|
940
|
+
: Number(isDeprecatedTool(left)) - Number(isDeprecatedTool(right)));
|
|
885
941
|
if (query && matched.length === 0) return '';
|
|
886
942
|
matchedTools += matched.length;
|
|
887
943
|
const tools = matched.slice(0, Math.max(0, remaining));
|
|
@@ -923,6 +979,7 @@
|
|
|
923
979
|
async function openToolDetail(serverName, toolName) {
|
|
924
980
|
if (!detailConnector || !toolName) return;
|
|
925
981
|
const result = await call('toolDetail', {
|
|
982
|
+
connectionKey: detailConnector.connectionKey,
|
|
926
983
|
connectorId: detailConnector.id,
|
|
927
984
|
serverName,
|
|
928
985
|
toolName,
|
|
@@ -933,15 +990,42 @@
|
|
|
933
990
|
return;
|
|
934
991
|
}
|
|
935
992
|
const tool = result.detail?.tool || {};
|
|
993
|
+
showToolParameters(tool, toolName, serverName);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function schemaSummary(schema, depth = 0, budget = { remaining: 150 }) {
|
|
997
|
+
if (depth > 5 || --budget.remaining < 0) return '<p class="hint">结构较大,请查看原始 Schema。</p>';
|
|
998
|
+
if (typeof schema === 'boolean') return `<p>${schema ? '允许任意输入' : '不允许任何输入'}</p>`;
|
|
999
|
+
if (!schema || typeof schema !== 'object') return '<p class="hint">暂无参数定义。</p>';
|
|
1000
|
+
const complex = ['$ref', 'oneOf', 'anyOf', 'allOf', 'if', 'then', 'else', 'not'].some((key) => key in schema);
|
|
1001
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
1002
|
+
const allEntries = Object.entries(schema.properties || {});
|
|
1003
|
+
const entries = allEntries.slice(0, 100);
|
|
1004
|
+
const rows = entries.map(([key, raw]) => {
|
|
1005
|
+
if (--budget.remaining < 0) return '';
|
|
1006
|
+
const field = raw && typeof raw === 'object' ? raw : {};
|
|
1007
|
+
const type = typeof raw === 'boolean' ? (raw ? '任意类型' : '禁止输入') : Array.isArray(field.type) ? field.type.join(' / ') : field.type || (field.properties ? 'object' : '未声明');
|
|
1008
|
+
const constraints = ['enum', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'pattern'].filter((name) => field[name] !== undefined)
|
|
1009
|
+
.map((name) => `${name}: ${JSON.stringify(field[name])}`).join(';');
|
|
1010
|
+
const nested = field.properties || field.items || ['$ref', 'oneOf', 'anyOf', 'allOf'].some((name) => name in field);
|
|
1011
|
+
return `<tr><td><code>${esc(key)}</code></td><td>${esc(type)}</td><td>${required.has(key) ? '必填' : '可选'}</td><td>${esc(field.description || '暂无说明')}${constraints ? `<div class="hint">${esc(constraints)}</div>` : ''}${nested ? `<details><summary>展开结构</summary>${schemaSummary(field, depth + 1, budget)}</details>` : ''}</td></tr>`;
|
|
1012
|
+
}).join('');
|
|
1013
|
+
const items = (allEntries.length > 100 || budget.remaining < 0 ? '<p class="hint">摘要已截断,请查看原始 Schema 中的其余字段。</p>' : '') + (schema.items !== undefined ? `<details><summary>数组元素</summary>${schemaSummary(schema.items, depth + 1, budget)}</details>` : '');
|
|
1014
|
+
return `${complex ? '<p class="hint">包含引用或组合约束,摘要不代表完整规则,请同时查看原始 Schema。</p>' : ''}${rows ? `<div class="parameter-scroll"><table class="parameter-table"><thead><tr><th>参数</th><th>类型</th><th>要求</th><th>说明与约束</th></tr></thead><tbody>${rows}</tbody></table></div>` : `<p class="hint">类型:${esc(Array.isArray(schema.type) ? schema.type.join(' / ') : schema.type || '未声明')};无可展开的具名参数,请查看原始 Schema。</p>`}${items}`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function showToolParameters(tool, toolName, serverName) {
|
|
936
1018
|
const observed = tool.observedAt ? new Date(tool.observedAt).toLocaleString('zh-CN', { hour12: false }) : '未知';
|
|
937
1019
|
const schema = tool.inputSchema ? JSON.stringify(tool.inputSchema, null, 2) : '未缓存输入参数 schema';
|
|
938
1020
|
openModal(`工具参数 · ${tool.name || toolName}`, `
|
|
939
1021
|
<p class="hint">${esc(tool.serverName || serverName)} · 最后成功 ${esc(observed)}${tool.stale ? ' · 缓存已超过 24 小时' : ''}${tool.schemaTruncated ? ' · schema 已安全裁剪' : ''}</p>
|
|
940
1022
|
<p>${esc(tool.description || '暂无描述')}</p>
|
|
941
|
-
|
|
1023
|
+
${schemaSummary(tool.inputSchema)}
|
|
1024
|
+
<details><summary>原始 Schema(安全缓存)</summary><pre class="tool-schema">${esc(schema)}</pre></details>
|
|
942
1025
|
<div class="m-actions"><button class="btn ghost" type="button" onclick="window.__mcp.closeModal()">关闭</button></div>`);
|
|
943
1026
|
}
|
|
944
1027
|
function closeModal() {
|
|
1028
|
+
explorerDetailSequence++;
|
|
945
1029
|
$('#modalMask').classList.remove('show');
|
|
946
1030
|
$('#modalMask').setAttribute('aria-hidden', 'true');
|
|
947
1031
|
pendingPrompt = null;
|
|
@@ -1121,6 +1205,68 @@
|
|
|
1121
1205
|
return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn" type="button" onclick="event.stopPropagation();window.__mcp.openConfig('${id}')">${t('configure')}</button>`;
|
|
1122
1206
|
}
|
|
1123
1207
|
|
|
1208
|
+
const EXPLORER_STATUS = { pending: '尚未发现', unknown: 'Host 状态未知', success: '最近发现成功', failed: '最近发现失败' };
|
|
1209
|
+
const formatObserved = (value) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚无记录';
|
|
1210
|
+
|
|
1211
|
+
async function loadToolExplorer() {
|
|
1212
|
+
const sequence = ++explorerSequence;
|
|
1213
|
+
const workspaceId = currentWorkspace?.workspaceId;
|
|
1214
|
+
syncMarketNav();
|
|
1215
|
+
if (!$('#tool-explorer')) {
|
|
1216
|
+
main.innerHTML = `<section id="tool-explorer"><p class="hint">搜索当前范围内已启用连接的工具缓存;不会执行工具。最近发现成功不代表每次调用均可成功。</p><div class="explorer-filters"><label>连接<select id="explorer-connection" aria-label="按连接筛选"></select></label><label>服务<select id="explorer-server" aria-label="按服务筛选"></select></label><label>发现状态<select id="explorer-status" aria-label="按发现状态筛选"><option value="">全部状态</option>${Object.entries(EXPLORER_STATUS).map(([key, label]) => `<option value="${key}">${label}</option>`).join('')}</select></label><button class="btn ghost" data-explorer-clear>清除筛选</button></div><p id="explorer-summary" role="status">正在读取缓存…</p><div id="explorer-connections"></div><div id="explorer-results"></div><div id="explorer-pages" class="m-actions"></div></section>`;
|
|
1217
|
+
}
|
|
1218
|
+
const response = await call('toolExplorer', { ...workspaceParams(), ...explorerFilter, query: searchQuery, offset: explorerOffset, limit: 20 });
|
|
1219
|
+
if (sequence !== explorerSequence || view !== 'tools' || workspaceId !== currentWorkspace?.workspaceId) return;
|
|
1220
|
+
if (!response.ok) { $('#explorer-summary').textContent = response.message; return; }
|
|
1221
|
+
explorerData = response.detail;
|
|
1222
|
+
const { connections = [], selectedConnections = [], items = [], total = 0 } = explorerData;
|
|
1223
|
+
if (explorerOffset >= total && explorerOffset > 0) { explorerOffset = Math.max(0, Math.floor((total - 1) / 20) * 20); return loadToolExplorer(); }
|
|
1224
|
+
$('#explorer-connection').innerHTML = '<option value="">全部连接</option>' + connections.map((c) => `<option value="${esc(c.connectionKey)}">${esc(c.connectionName)} · ${esc(c.serverName)}</option>`).join('');
|
|
1225
|
+
const services = [...new Set(connections.filter((c) => !explorerFilter.connectionKey || c.connectionKey === explorerFilter.connectionKey).map((c) => c.serverName))];
|
|
1226
|
+
$('#explorer-server').innerHTML = '<option value="">全部服务</option>' + services.map((name) => `<option value="${esc(name)}">${esc(name)}</option>`).join('');
|
|
1227
|
+
$('#explorer-connection').value = explorerFilter.connectionKey;
|
|
1228
|
+
$('#explorer-server').value = explorerFilter.serverName;
|
|
1229
|
+
$('#explorer-status').value = explorerFilter.status;
|
|
1230
|
+
$('#explorer-summary').textContent = `${explorerData.scopeLabel} · ${connections.length} 条连接 · 匹配 ${total} 个工具 · 显示 ${items.length} 个`;
|
|
1231
|
+
$('#explorer-connections').innerHTML = `<details ${explorerFilter.connectionKey || !total ? 'open' : ''}><summary>连接状态与故障处理(${selectedConnections.length})</summary>${selectedConnections.map((c) => `<div class="explorer-card"><strong>${esc(c.connectionName)}</strong><div>${esc(c.serverName)} · ${esc(EXPLORER_STATUS[c.status] || c.status)} · 缓存 ${c.toolCount} 个工具</div><div class="hint">最近发现:${esc(formatObserved(c.checkedAt))};最后成功:${esc(formatObserved(c.observedAt))}${c.stale ? ' · 缓存超过 24 小时' : ''}${c.cacheTruncated ? ' · 缓存清单已截断' : ''}</div>${c.diagnostic ? `<div>${esc(c.diagnostic.stageLabel)}:${esc(c.diagnostic.message)}<br>建议:${esc(c.diagnostic.action)}</div>` : ''}${c.retryReason === 'auth' ? '<p>鉴权失败已暂停后台重试,请到已安装页更新凭据或重新授权。</p>' : c.nextDiscoveryAt ? `<p class="hint">下次后台发现不早于:${esc(formatObserved(c.nextDiscoveryAt))}(页面保持连接时)</p>` : ''}<div class="m-actions"><button class="btn ghost" data-explorer-action="discover" data-key="${esc(c.connectionKey)}" ${explorerBusy.has(c.connectionKey) ? 'disabled' : ''}>重新发现工具</button><button class="btn ghost" data-explorer-action="check" data-key="${esc(c.connectionKey)}" ${explorerBusy.has(c.connectionKey) ? 'disabled' : ''}>检查连接</button></div></div>`).join('') || '<p>当前筛选下没有连接。</p>'}</details>`;
|
|
1232
|
+
$('#explorer-results').innerHTML = items.map((item, index) => `<article class="explorer-card"><strong>${esc(item.name)}</strong>${item.title !== item.name ? `<div>${esc(item.title)}</div>` : ''}<p>${esc(item.description || '暂无描述')}</p><div class="hint">来源:${esc(item.connection.connectorName)} / ${esc(item.connection.connectionName)} / ${esc(item.serverName)}<br>${esc(EXPLORER_STATUS[item.connection.status])} · 最后成功缓存 ${esc(formatObserved(item.observedAt))}${item.stale ? ' · 已超过 24 小时' : ''}</div><button class="btn ghost" data-explorer-detail="${index}">参数详情</button></article>`).join('') || `<p class="empty">${connections.length ? '没有匹配工具。请调整关键词或筛选;也可展开连接状态检查是否尚未发现或读取失败。' : '当前范围没有已启用连接。请先添加连接,或选择对应工作区。'}</p>`;
|
|
1233
|
+
$('#explorer-pages').innerHTML = `<button class="btn ghost" data-explorer-page="prev" ${explorerOffset === 0 ? 'disabled' : ''}>上一页</button><span>${Math.floor(explorerOffset / 20) + 1} / ${Math.max(1, Math.ceil(total / 20))}</span><button class="btn ghost" data-explorer-page="next" ${explorerOffset + 20 >= total ? 'disabled' : ''}>下一页</button>`;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
main.addEventListener('change', (event) => {
|
|
1237
|
+
const fields = { 'explorer-connection': 'connectionKey', 'explorer-server': 'serverName', 'explorer-status': 'status' };
|
|
1238
|
+
const field = fields[event.target.id];
|
|
1239
|
+
if (!field) return;
|
|
1240
|
+
explorerFilter[field] = event.target.value;
|
|
1241
|
+
if (field === 'connectionKey') explorerFilter.serverName = '';
|
|
1242
|
+
explorerOffset = 0; void loadToolExplorer();
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
main.addEventListener('click', async (event) => {
|
|
1246
|
+
const button = event.target.closest('button');
|
|
1247
|
+
if (!button || view !== 'tools') return;
|
|
1248
|
+
if (button.hasAttribute('data-explorer-clear')) {
|
|
1249
|
+
explorerFilter = { connectionKey: '', serverName: '', status: '' }; explorerOffset = 0; await loadToolExplorer();
|
|
1250
|
+
} else if (button.dataset.explorerPage) {
|
|
1251
|
+
explorerOffset = Math.max(0, explorerOffset + (button.dataset.explorerPage === 'next' ? 20 : -20)); await loadToolExplorer();
|
|
1252
|
+
} else if (button.dataset.explorerDetail !== undefined) {
|
|
1253
|
+
const item = explorerData?.items[Number(button.dataset.explorerDetail)];
|
|
1254
|
+
if (!item) return;
|
|
1255
|
+
const requestId = ++explorerDetailSequence;
|
|
1256
|
+
const result = await call('toolExplorerDetail', { connectionKey: item.connectionKey, serverName: item.serverName, toolName: item.name, ...workspaceParams() });
|
|
1257
|
+
if (requestId !== explorerDetailSequence || view !== 'tools') return;
|
|
1258
|
+
if (result.ok) showToolParameters(result.detail.tool, item.name, item.serverName);
|
|
1259
|
+
else showToast(result.message, false);
|
|
1260
|
+
} else if (button.dataset.explorerAction) {
|
|
1261
|
+
const key = button.dataset.key;
|
|
1262
|
+
if (explorerBusy.has(key)) return;
|
|
1263
|
+
explorerBusy.add(key); button.disabled = true;
|
|
1264
|
+
const result = await call('toolExplorerAction', { connectionKey: key, action: button.dataset.explorerAction, ...workspaceParams() });
|
|
1265
|
+
explorerBusy.delete(key); showToast(result.message, result.ok);
|
|
1266
|
+
if (view === 'tools') await loadToolExplorer();
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1124
1270
|
async function loadInstalled() {
|
|
1125
1271
|
syncMarketNav();
|
|
1126
1272
|
main.innerHTML = '<div class="empty">加载中…</div>';
|
|
@@ -1166,6 +1312,7 @@
|
|
|
1166
1312
|
</label>
|
|
1167
1313
|
${canEditConfiguration ? `<button class="btn ghost" type="button" onclick="window.__mcp.openEditor('${key}')">编辑配置</button>` : ''}
|
|
1168
1314
|
${canRename ? `<button class="btn ghost" type="button" onclick="window.__mcp.openRename('${key}')">重命名</button>` : ''}
|
|
1315
|
+
${enabled ? `<button class="btn ghost" type="button" onclick="window.__mcp.showConnectionTools('${key}')">工具</button>` : ''}
|
|
1169
1316
|
<button class="btn ghost" type="button" onclick="window.__mcp.openScope('${key}')">范围</button>
|
|
1170
1317
|
<button class="btn danger" onclick="window.__mcp.disconnect('${key}')">断开</button>
|
|
1171
1318
|
</div>`;
|
|
@@ -1708,8 +1855,17 @@
|
|
|
1708
1855
|
showToast(result.message, result.ok);
|
|
1709
1856
|
await loadInstalled();
|
|
1710
1857
|
},
|
|
1711
|
-
async
|
|
1712
|
-
const
|
|
1858
|
+
async showConnectionTools(key) {
|
|
1859
|
+
const record = installedItems.find((item) => item.key === key);
|
|
1860
|
+
if (!record) return;
|
|
1861
|
+
await window.__mcp.showDetail(record.connectorId, {
|
|
1862
|
+
id: record.connectorId, connectionKey: key, name: record.name, icon: '🔌',
|
|
1863
|
+
authMode: record.authMode, connected: [record], connectionState: record.connectionState,
|
|
1864
|
+
description: '本机连接的工具发现与参数详情;不会执行工具。',
|
|
1865
|
+
});
|
|
1866
|
+
},
|
|
1867
|
+
async showDetail(connectorId, connectionDescriptor) {
|
|
1868
|
+
const d = connectionDescriptor || marketItems[connectorId];
|
|
1713
1869
|
if (!d) return;
|
|
1714
1870
|
detailConnector = d;
|
|
1715
1871
|
const incomplete = d.connected?.length > 0 && d.connectionComplete === false;
|
|
@@ -1738,6 +1894,8 @@
|
|
|
1738
1894
|
syncGovernanceBar();
|
|
1739
1895
|
setDetailOpen(true);
|
|
1740
1896
|
$('#detail-disconnect-btn').style.display = d.connected?.length > 0 ? 'block' : 'none';
|
|
1897
|
+
if (d.connectionKey) $('#detail-disconnect-btn').style.display = 'none';
|
|
1898
|
+
$('#detail-try-btn').style.display = d.connectionKey ? 'none' : '';
|
|
1741
1899
|
const needsCredential = ['bearer', 'api-key'].includes(d.authMode);
|
|
1742
1900
|
$('#detail-try-btn').textContent = detailNeedsReconfigure
|
|
1743
1901
|
? detailFailureState === 'incomplete' ? (d.authMode === 'oauth2-pkce' ? t('upgradeOauth') : t('completeSetup')) : d.authMode === 'oauth2-pkce' && detailFailureState === 'reauth' ? '🔐 重新授权' : needsCredential && detailFailureState === 'reauth' ? t('reconfigureCredential') : '🔄 重新检查'
|
|
@@ -1749,7 +1907,7 @@
|
|
|
1749
1907
|
return;
|
|
1750
1908
|
}
|
|
1751
1909
|
const [toolsResult, governanceResult] = await Promise.all([
|
|
1752
|
-
call('toolsList', { connectorId, ...workspaceParams() }),
|
|
1910
|
+
call('toolsList', { connectorId, connectionKey: d.connectionKey, ...workspaceParams() }),
|
|
1753
1911
|
call('governance', {}),
|
|
1754
1912
|
]);
|
|
1755
1913
|
if (loadId !== detailLoadId || !$('#detail-overlay').classList.contains('active')) return;
|
|
@@ -1951,13 +2109,53 @@
|
|
|
1951
2109
|
});
|
|
1952
2110
|
let statusEventSource = null;
|
|
1953
2111
|
let statusRefreshTimer = null;
|
|
2112
|
+
let statusRefreshRunning = false;
|
|
2113
|
+
let statusRefreshPending = false;
|
|
2114
|
+
|
|
2115
|
+
async function refreshOpenToolDetail() {
|
|
2116
|
+
if (!detailConnector || !$('#detail-overlay').classList.contains('active')) return;
|
|
2117
|
+
const id = detailLoadId;
|
|
2118
|
+
const result = await call('toolsList', { connectorId: detailConnector.id, connectionKey: detailConnector.connectionKey, ...workspaceParams(), cachedOnly: true });
|
|
2119
|
+
if (id !== detailLoadId || !$('#detail-overlay').classList.contains('active')) return;
|
|
2120
|
+
if (!result.detail?.servers) {
|
|
2121
|
+
detailToolServers = [];
|
|
2122
|
+
renderTools();
|
|
2123
|
+
$('#tools-summary').textContent = result.message || '连接当前不可用';
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
if (result.detail?.servers) {
|
|
2127
|
+
detailToolServers = result.detail.servers;
|
|
2128
|
+
renderTools();
|
|
2129
|
+
const pending = detailToolServers.some((server) => server.pending);
|
|
2130
|
+
const unknown = detailToolServers.some((server) => server.unknown);
|
|
2131
|
+
const available = detailToolServers.filter((server) => server.ok).length;
|
|
2132
|
+
const label = pending ? '正在后台发现工具' : unknown && available === 0 ? '工具状态尚未确认'
|
|
2133
|
+
: available === detailToolServers.length ? '已连接'
|
|
2134
|
+
: available > 0 ? '部分服务可用' : '当前连接异常';
|
|
2135
|
+
detailFailureState = pending ? 'unknown' : available > 0 ? 'healthy' : 'unavailable';
|
|
2136
|
+
detailNeedsReconfigure = !pending && available === 0;
|
|
2137
|
+
$('#detail-vendor').textContent = detailConnector.vendor ? `${detailConnector.vendor} · ${label}` : label;
|
|
2138
|
+
$('#detail-try-btn').textContent = detailNeedsReconfigure ? '🔄 重新检查' : t('try');
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
1954
2141
|
|
|
1955
2142
|
function scheduleStatusRefresh() {
|
|
1956
2143
|
if (document.hidden) return;
|
|
2144
|
+
statusRefreshPending = true;
|
|
2145
|
+
if (statusRefreshRunning) return;
|
|
1957
2146
|
clearTimeout(statusRefreshTimer);
|
|
1958
|
-
statusRefreshTimer = setTimeout(() => {
|
|
1959
|
-
|
|
1960
|
-
|
|
2147
|
+
statusRefreshTimer = setTimeout(async () => {
|
|
2148
|
+
statusRefreshRunning = true;
|
|
2149
|
+
statusRefreshPending = false;
|
|
2150
|
+
try {
|
|
2151
|
+
if (view === 'tools') await loadToolExplorer();
|
|
2152
|
+
else if (view === 'market') await loadMarket({ checkHealth: false, preserveScroll: true });
|
|
2153
|
+
else await loadInstalled();
|
|
2154
|
+
await refreshOpenToolDetail();
|
|
2155
|
+
} finally {
|
|
2156
|
+
statusRefreshRunning = false;
|
|
2157
|
+
if (statusRefreshPending) scheduleStatusRefresh();
|
|
2158
|
+
}
|
|
1961
2159
|
}, 150);
|
|
1962
2160
|
}
|
|
1963
2161
|
|
|
@@ -1965,6 +2163,7 @@
|
|
|
1965
2163
|
if (typeof EventSource !== 'function' || statusEventSource) return;
|
|
1966
2164
|
const source = new EventSource('/mcp-connector/events');
|
|
1967
2165
|
statusEventSource = source;
|
|
2166
|
+
source.addEventListener('ready', scheduleStatusRefresh);
|
|
1968
2167
|
source.addEventListener('status', (event) => {
|
|
1969
2168
|
try {
|
|
1970
2169
|
const payload = JSON.parse(event.data);
|
|
@@ -1989,8 +2188,11 @@
|
|
|
1989
2188
|
statusEventSource = null;
|
|
1990
2189
|
});
|
|
1991
2190
|
|
|
1992
|
-
function refresh() { return view === 'market' ? loadMarket() : loadInstalled(); }
|
|
2191
|
+
function refresh() { return view === 'tools' ? loadToolExplorer() : view === 'market' ? loadMarket() : loadInstalled(); }
|
|
1993
2192
|
function syncTabs() {
|
|
2193
|
+
searchInput.placeholder = view === 'tools' ? '搜索工具名称或描述…' : '搜索连接器…';
|
|
2194
|
+
$('#refresh').textContent = view === 'tools' ? '刷新工具视图' : '刷新连接器目录';
|
|
2195
|
+
$('#refresh').title = $('#refresh').textContent;
|
|
1994
2196
|
document.querySelectorAll('#tabs button').forEach((button) => button.classList.toggle('active', button.dataset.view === view));
|
|
1995
2197
|
syncMarketNav();
|
|
1996
2198
|
}
|
|
@@ -2013,6 +2215,7 @@
|
|
|
2013
2215
|
}
|
|
2014
2216
|
});
|
|
2015
2217
|
$('#refresh').addEventListener('click', async () => {
|
|
2218
|
+
if (view === 'tools') { await loadToolExplorer(); return; }
|
|
2016
2219
|
const r = await call('refreshCatalog', {});
|
|
2017
2220
|
showToast(r.message, r.ok);
|
|
2018
2221
|
if (r.ok) {
|
|
@@ -2026,12 +2229,16 @@
|
|
|
2026
2229
|
const btn = e.target.closest('button[data-view]');
|
|
2027
2230
|
if (!btn) return;
|
|
2028
2231
|
view = btn.dataset.view;
|
|
2232
|
+
explorerSequence++; explorerDetailSequence++;
|
|
2029
2233
|
syncTabs();
|
|
2030
2234
|
refresh();
|
|
2031
2235
|
});
|
|
2032
2236
|
|
|
2033
2237
|
searchInput.addEventListener('input', (e) => {
|
|
2034
2238
|
searchQuery = e.target.value.trim();
|
|
2239
|
+
explorerSequence++; explorerOffset = 0;
|
|
2240
|
+
clearTimeout(searchTimer);
|
|
2241
|
+
if (view === 'tools') { searchTimer = setTimeout(() => { if (view === 'tools') void loadToolExplorer(); }, 200); return; }
|
|
2035
2242
|
expandedMarketSections.clear();
|
|
2036
2243
|
refresh();
|
|
2037
2244
|
});
|