dsh-mcp-connector 0.2.44 → 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 CHANGED
@@ -4,6 +4,25 @@
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
+
7
26
  ## [0.2.44] - 2026-09-12
8
27
 
9
28
  ### 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
  [![CI](https://github.com/duhu2000/dsh-mcp-connector/actions/workflows/ci.yml/badge.svg)](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.44`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.44](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.44).
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
  [![CI](https://github.com/duhu2000/dsh-mcp-connector/actions/workflows/ci.yml/badge.svg)](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.44`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.44](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.44)。
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,
@@ -170,6 +172,10 @@ export async function apply(ctx, config) {
170
172
  };
171
173
  const statusEvents = new StatusEventHub();
172
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();
173
179
 
174
180
  const hostGovernance = createHostGovernanceController(ctx, {
175
181
  getRules: () => governancePolicies.snapshot().rules,
@@ -1008,6 +1014,8 @@ export async function apply(ctx, config) {
1008
1014
  }
1009
1015
 
1010
1016
  async function deleteLastSuccessfulTools(key) {
1017
+ discoveryPlans.delete(key);
1018
+ connectionChecks.delete(key);
1011
1019
  state.toolViews.delete(key);
1012
1020
  state.toolCatalogCache.delete(key);
1013
1021
  if (config.persistSecrets) {
@@ -1038,6 +1046,38 @@ export async function apply(ctx, config) {
1038
1046
  return entries;
1039
1047
  }
1040
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
+
1041
1081
  function readinessResults(records, validationResults = []) {
1042
1082
  return records.map((record, index) => {
1043
1083
  const validated = validationResults[index];
@@ -2311,13 +2351,17 @@ export async function apply(ctx, config) {
2311
2351
  };
2312
2352
  },
2313
2353
 
2314
- 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
+ }
2315
2358
  const targetIds = connectorId
2316
2359
  ? [String(connectorId)]
2317
2360
  : [...new Set([...state.connections.values()].map((record) => record.connectorId).filter(Boolean))];
2318
2361
  const summaries = [];
2319
2362
  for (const id of targetIds) {
2320
- 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));
2321
2365
  const enabledRecords = records.filter((record) => record.enabled !== false);
2322
2366
  if (records.length === 0 || enabledRecords.length === 0) {
2323
2367
  const summary = healthSummary(id, records, [], Date.now());
@@ -2369,7 +2413,19 @@ export async function apply(ctx, config) {
2369
2413
  });
2370
2414
  checkedResults = validation.detail.results;
2371
2415
  }
2372
- summaries.push(cacheHealth(id, records, [...immediateResults, ...checkedResults]));
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);
2373
2429
  }
2374
2430
  const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
2375
2431
  const unknown = summaries.filter((item) => item.connectionState === 'unknown').length;
@@ -2654,6 +2710,48 @@ export async function apply(ctx, config) {
2654
2710
  return { ok: true, message: `已将 "${connector.name}" ${published ? '上架' : '下架'}` };
2655
2711
  },
2656
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
+
2657
2755
  async toolSearch(input = {}) {
2658
2756
  try {
2659
2757
  const connectorId = String(input.connectorId ?? '').trim() || undefined;
@@ -2678,7 +2776,6 @@ export async function apply(ctx, config) {
2678
2776
  const view = state.toolViews.get(record.key);
2679
2777
  return view?.record === record && !view.server.ok;
2680
2778
  }).length;
2681
- if (pendingServers > 0) scheduleDiscovery();
2682
2779
  return {
2683
2780
  ok: true,
2684
2781
  message: `最后成功工具缓存中找到 ${items.length} 个匹配项${staleCount ? `,其中 ${staleCount} 个已超过 24 小时` : ''}${pendingServers ? `;${pendingServers} 个服务正在后台发现` : ''}${failedServers ? `;${failedServers} 个服务最近读取失败` : ''}`,
@@ -2894,6 +2991,7 @@ export async function apply(ctx, config) {
2894
2991
  ok: false,
2895
2992
  error: error.message,
2896
2993
  errorKind: error.kind,
2994
+ retryAfterMs: error.retryAfterMs,
2897
2995
  policy: serverPolicy(record),
2898
2996
  ...cachedFields,
2899
2997
  });
@@ -2909,7 +3007,10 @@ export async function apply(ctx, config) {
2909
3007
  }
2910
3008
  records.forEach((record, index) => {
2911
3009
  if (state.connections.get(record.key) === record && record.enabled !== false) {
2912
- state.toolViews.set(record.key, { record, server: servers[index] });
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) });
2913
3014
  }
2914
3015
  });
2915
3016
  const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
@@ -3057,15 +3158,19 @@ export async function apply(ctx, config) {
3057
3158
  discoveryRunning = true;
3058
3159
  discoveryPending = false;
3059
3160
  try {
3060
- const ids = [...new Set([...state.connections.values()].filter((record) => record.enabled !== false).map((record) => record.connectorId))];
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
+ });
3061
3165
  let cursor = 0;
3062
- await Promise.all(Array.from({ length: Math.min(2, ids.length) }, async () => {
3063
- while (cursor < ids.length && !discoveryDisposed) {
3064
- const id = ids[cursor++];
3065
- await api.toolsList(id).catch(() => {});
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');
3066
3172
  }
3067
3173
  }));
3068
- if (!discoveryDisposed) statusEvents.publish('tools');
3069
3174
  } finally {
3070
3175
  discoveryRunning = false;
3071
3176
  if (discoveryPending) scheduleDiscovery();
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 });
@@ -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
@@ -167,14 +167,19 @@ function validLimit(limit) {
167
167
  }
168
168
 
169
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) {
170
175
  const normalizedQuery = normalizeToolSearchText(query);
171
- if (!normalizedQuery) throw new Error('工具搜索关键词不能为空');
172
176
  const results = [];
173
177
  for (const entry of entries ?? []) {
174
178
  if (connectorId && entry.connectorId !== connectorId) continue;
179
+ if (serverName && entry.serverName !== serverName) continue;
175
180
  const cache = toolCatalogCacheView(entry, now, ttlMs);
176
181
  for (const tool of entry.tools ?? []) {
177
- const score = toolSearchScore(tool, normalizedQuery);
182
+ const score = normalizedQuery ? toolSearchScore(tool, normalizedQuery) : 0;
178
183
  if (score < 0) continue;
179
184
  results.push({
180
185
  connectorId: entry.connectorId,
@@ -191,12 +196,11 @@ export function searchToolCatalog(entries, { query, connectorId, limit = 20 } =
191
196
  });
192
197
  }
193
198
  }
194
- return results
195
- .sort((left, right) => right.score - left.score
199
+ results.sort((left, right) => right.score - left.score
196
200
  || left.name.localeCompare(right.name)
197
- || left.serverName.localeCompare(right.serverName))
198
- .slice(0, validLimit(limit))
199
- .map(({ score, ...item }) => item);
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) };
200
204
  }
201
205
 
202
206
  export function findToolDetails(entries, { toolName, connectorId, serverName } = {}, now = Date.now(), ttlMs = TOOL_CACHE_TTL_MS) {
package/lib/web.js CHANGED
@@ -50,6 +50,9 @@ const API_WHITELIST = new Map([
50
50
  ['refreshCatalog', (api) => api.refreshCatalog()],
51
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.44",
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",
@@ -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: '公开检索词' } }, required: ['query'] },
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) refresh();
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) {
@@ -965,15 +990,42 @@
965
990
  return;
966
991
  }
967
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) {
968
1018
  const observed = tool.observedAt ? new Date(tool.observedAt).toLocaleString('zh-CN', { hour12: false }) : '未知';
969
1019
  const schema = tool.inputSchema ? JSON.stringify(tool.inputSchema, null, 2) : '未缓存输入参数 schema';
970
1020
  openModal(`工具参数 · ${tool.name || toolName}`, `
971
1021
  <p class="hint">${esc(tool.serverName || serverName)} · 最后成功 ${esc(observed)}${tool.stale ? ' · 缓存已超过 24 小时' : ''}${tool.schemaTruncated ? ' · schema 已安全裁剪' : ''}</p>
972
1022
  <p>${esc(tool.description || '暂无描述')}</p>
973
- <pre class="tool-schema">${esc(schema)}</pre>
1023
+ ${schemaSummary(tool.inputSchema)}
1024
+ <details><summary>原始 Schema(安全缓存)</summary><pre class="tool-schema">${esc(schema)}</pre></details>
974
1025
  <div class="m-actions"><button class="btn ghost" type="button" onclick="window.__mcp.closeModal()">关闭</button></div>`);
975
1026
  }
976
1027
  function closeModal() {
1028
+ explorerDetailSequence++;
977
1029
  $('#modalMask').classList.remove('show');
978
1030
  $('#modalMask').setAttribute('aria-hidden', 'true');
979
1031
  pendingPrompt = null;
@@ -1153,6 +1205,68 @@
1153
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>`;
1154
1206
  }
1155
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
+
1156
1270
  async function loadInstalled() {
1157
1271
  syncMarketNav();
1158
1272
  main.innerHTML = '<div class="empty">加载中…</div>';
@@ -2034,7 +2148,8 @@
2034
2148
  statusRefreshRunning = true;
2035
2149
  statusRefreshPending = false;
2036
2150
  try {
2037
- if (view === 'market') await loadMarket({ checkHealth: false, preserveScroll: true });
2151
+ if (view === 'tools') await loadToolExplorer();
2152
+ else if (view === 'market') await loadMarket({ checkHealth: false, preserveScroll: true });
2038
2153
  else await loadInstalled();
2039
2154
  await refreshOpenToolDetail();
2040
2155
  } finally {
@@ -2073,8 +2188,11 @@
2073
2188
  statusEventSource = null;
2074
2189
  });
2075
2190
 
2076
- function refresh() { return view === 'market' ? loadMarket() : loadInstalled(); }
2191
+ function refresh() { return view === 'tools' ? loadToolExplorer() : view === 'market' ? loadMarket() : loadInstalled(); }
2077
2192
  function syncTabs() {
2193
+ searchInput.placeholder = view === 'tools' ? '搜索工具名称或描述…' : '搜索连接器…';
2194
+ $('#refresh').textContent = view === 'tools' ? '刷新工具视图' : '刷新连接器目录';
2195
+ $('#refresh').title = $('#refresh').textContent;
2078
2196
  document.querySelectorAll('#tabs button').forEach((button) => button.classList.toggle('active', button.dataset.view === view));
2079
2197
  syncMarketNav();
2080
2198
  }
@@ -2097,6 +2215,7 @@
2097
2215
  }
2098
2216
  });
2099
2217
  $('#refresh').addEventListener('click', async () => {
2218
+ if (view === 'tools') { await loadToolExplorer(); return; }
2100
2219
  const r = await call('refreshCatalog', {});
2101
2220
  showToast(r.message, r.ok);
2102
2221
  if (r.ok) {
@@ -2110,12 +2229,16 @@
2110
2229
  const btn = e.target.closest('button[data-view]');
2111
2230
  if (!btn) return;
2112
2231
  view = btn.dataset.view;
2232
+ explorerSequence++; explorerDetailSequence++;
2113
2233
  syncTabs();
2114
2234
  refresh();
2115
2235
  });
2116
2236
 
2117
2237
  searchInput.addEventListener('input', (e) => {
2118
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; }
2119
2242
  expandedMarketSections.clear();
2120
2243
  refresh();
2121
2244
  });