dsh-mcp-connector 0.2.2 → 0.2.4

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,33 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.4] - 2026-08-21
8
+
9
+ ### Added
10
+
11
+ - 新增主动连接健康检查,支持 OAuth grant、Bearer/API Key 与无鉴权 MCP initialize 握手,并以 4 并发、5 秒单连接上限避免刷新风暴。
12
+ - 新增 `mcp_connector_health_check` 对话工具和同源 Web API,可检查单个或全部已配置连接器。
13
+
14
+ ### Changed
15
+
16
+ - 市场卡片和“已安装”列表不再把“本机存在配置”等同于“当前可用”,改为展示已配置、已连接、需重新授权、部分异常、连接异常或已停用。
17
+ - 首次打开市场会在后台执行一次限流健康检查;用户点击“刷新”时同步刷新目录和连接状态。
18
+
19
+ ### Fixed
20
+
21
+ - OAuth 过期/缺失或历史 Key 失效后,不再继续显示“已连接”;详情页会暂停 Prompt 发送并提供重新授权/重新配置入口。
22
+ - 网络、DNS、TLS 或服务端异常与凭据异常分开呈现,避免误导用户重复录入 Key。
23
+
24
+ ## [0.2.3] - 2026-08-21
25
+
26
+ ### Added
27
+
28
+ - 新增 4 张由无真实凭据 UI harness 生成的核心界面截图,以及约 30 秒的演示 GIF;中英文 README 均可直接预览市场、详情 Prompt、工具发现和 JSON 导入体验。
29
+
30
+ ### Changed
31
+
32
+ - 面向中国市场将 MCP 连接器运行界面固定为中文,移除顶部 `EN` 按钮、英文 UI 字典、语言偏好持久化和切换事件;英文 README 继续作为项目文档保留。
33
+
7
34
  ## [0.2.2] - 2026-08-21
8
35
 
9
36
  ### Added
@@ -92,7 +119,9 @@
92
119
  - 外部 URL 与导入 Header 执行安全校验。
93
120
  - iframe 消息校验同源和消息来源。
94
121
 
95
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.2...HEAD
122
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.4...HEAD
123
+ [0.2.4]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.3...v0.2.4
124
+ [0.2.3]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.2...v0.2.3
96
125
  [0.2.2]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.1...v0.2.2
97
126
  [0.2.1]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.0...v0.2.1
98
127
  [0.2.0]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.1.0...v0.2.0
package/README.en.md CHANGED
@@ -13,7 +13,7 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
13
13
  ## Features
14
14
 
15
15
  - A primary sidebar entry below New Conversation and above workspaces/conversations, with a public footer-slot fallback for incompatible DSH DOM versions.
16
- - Searchable Marketplace and Installed views with connection status and catalog refresh.
16
+ - Searchable Marketplace and Installed views with catalog refresh and active health checks that distinguish Configured, Connected, Reauthorization Required, Partially Unavailable, and Connection Error states.
17
17
  - OAuth 2.0 Authorization Code with PKCE, API key/Bearer/unauthenticated URL configuration, and `mcpServers` JSON import.
18
18
  - Installation from a credential-free connector descriptor URL.
19
19
  - Credential and MCP initialize validation before API-key connectors are saved as installed.
@@ -26,6 +26,18 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
26
26
 
27
27
  The first four bundled cards are Qichacha connectors. The public catalog also includes PKULaw and Wind connectors; the architecture is provider-neutral.
28
28
 
29
+ ## Interface and demo
30
+
31
+ ![30-second MCP Connector walkthrough](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/demo.gif)
32
+
33
+ | Marketplace overview | Connector details and curated prompts |
34
+ |---|---|
35
+ | ![Marketplace overview](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/01-market-overview.jpg) | ![Connector details](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/02-connector-detail.jpg) |
36
+ | Tool discovery, descriptions, and scrolling | JSON import |
37
+ | ![Tool discovery](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/03-tool-discovery.jpg) | ![JSON import](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/04-json-import.jpg) |
38
+
39
+ The assets are captured from the credential-free local UI harness. See [`docs/screenshots/README.md`](docs/screenshots/README.md) for provenance.
40
+
29
41
  ## Installation
30
42
 
31
43
  Requirements: DeepSeek Harness Desktop or web profile, and Node.js 20 or later.
@@ -85,7 +97,7 @@ npm run dev:ui
85
97
 
86
98
  `npm run check` performs syntax checks, automated tests, and an npm package allowlist/sensitive-content audit. Tags matching `v*` trigger GitHub Actions; the tag must match `package.json`. npm releases use Trusted Publishing through GitHub OIDC and do not require a long-lived `NPM_TOKEN`.
87
99
 
88
- The current public version is [`dsh-mcp-connector@0.2.2`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.2](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.2).
100
+ The current public version is [`dsh-mcp-connector@0.2.4`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.4](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.4).
89
101
 
90
102
  See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
91
103
 
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  ## 功能
14
14
 
15
15
  - 左侧主导航入口:目标位置为“新会话”下方、“工作区/会话列表”上方;若 DSH DOM 结构不兼容,自动回退到底部公开插槽。
16
- - 图形化市场:市场/已安装、搜索、连接状态与刷新。
16
+ - 图形化市场:市场/已安装、搜索、刷新与主动健康检查;区分“已配置”、“已连接”、“需重新授权”、“部分异常”和“连接异常”。
17
17
  - 图形化添加:手动 URL/鉴权、`mcpServers` JSON、连接器描述 URL 三种入口,失败时保留表单并给出修复建议。
18
18
  - 连接器详情:精选 Prompt 优先展示,点击可带入 DSH 新会话;工具按 Server 分组,支持描述、搜索和独立滚动。
19
19
  - Prompt 模板:使用 `{{company}}` 等变量,发送前填写真实查询主体。
@@ -24,10 +24,22 @@
24
24
  - 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
25
25
  - Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
26
26
  - 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;不删除原插件或原凭据。
27
- - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
27
+ - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
28
28
 
29
29
  当前市场前 4 张为企查查连接器,第 5 张为“北大法宝·法律检索”,第 6 张为“Wind·股票数据”;插件架构不限定厂商。
30
30
 
31
+ ## 界面与演示
32
+
33
+ ![MCP 连接器 30 秒演示](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/demo.gif)
34
+
35
+ | 市场总览 | 连接器详情与精选 Prompt |
36
+ |---|---|
37
+ | ![市场总览](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/01-market-overview.jpg) | ![连接器详情](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/02-connector-detail.jpg) |
38
+ | 工具发现、描述与独立滚动 | JSON 导入 |
39
+ | ![工具发现](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/03-tool-discovery.jpg) | ![JSON 导入](https://raw.githubusercontent.com/duhu2000/dsh-mcp-connector/main/docs/screenshots/04-json-import.jpg) |
40
+
41
+ 素材由无真实凭据的本地 UI harness 生成,详见 [`docs/screenshots/README.md`](docs/screenshots/README.md)。
42
+
31
43
  ## 安装
32
44
 
33
45
  要求:DeepSeek Harness Desktop/web profile,Node.js 20 或更高版本。
@@ -81,7 +93,7 @@ npm run dev:ui
81
93
 
82
94
  `check` 执行语法检查、自动测试和 npm 发布包白名单校验;`dev:ui` 启动不含真实凭据的本地 mock 市场。CI 使用 `--legacy-peer-deps` 安装显式测试依赖,DSH 运行期 peer 仍由 Host 提供。`v*` Tag 会触发 GitHub Actions;Tag 必须与 `package.json` 版本一致。Release 通过 npm Trusted Publishing (GitHub OIDC) 发布,不依赖长期 `NPM_TOKEN`。
83
95
 
84
- 当前公开版本为 [`dsh-mcp-connector@0.2.2`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.2](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.2)。
96
+ 当前公开版本为 [`dsh-mcp-connector@0.2.4`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.4](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.4)。
85
97
 
86
98
  版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
87
99
  Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
package/lib/index.js CHANGED
@@ -77,6 +77,7 @@ export async function apply(ctx, config) {
77
77
  overrides: new Map(), // id -> { published?, featured? }
78
78
  connections: new Map(), // key -> ConnectionRecord
79
79
  grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth }
80
+ health: new Map(), // connectorId -> 最近一次主动连通性检查摘要(不持久化)
80
81
  };
81
82
 
82
83
  /* ───────────────────────── 目录加载 ───────────────────────── */
@@ -195,6 +196,7 @@ export async function apply(ctx, config) {
195
196
 
196
197
  async function persistRecord(record) {
197
198
  state.connections.set(record.key, record);
199
+ if (record.connectorId) state.health.delete(record.connectorId);
198
200
  if (config.persistSecrets) await connectionStore.put(record);
199
201
  }
200
202
 
@@ -206,11 +208,73 @@ export async function apply(ctx, config) {
206
208
  return merged;
207
209
  }
208
210
 
211
+ function grantFailure(record) {
212
+ if (record.auth?.mode !== 'oauth') return null;
213
+ const grantKey = record.auth.grantKey;
214
+ const entry = grantKey ? state.grants.get(grantKey) : null;
215
+ if (!entry) return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权缺失,请重新授权' };
216
+ if (entry.needsReauth || entry.grant.accessTokenExpiresAt <= Date.now()) {
217
+ return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权已过期或刷新失败,请重新授权' };
218
+ }
219
+ return null;
220
+ }
221
+
222
+ function healthSummary(connectorId, records, results, checkedAt = Date.now()) {
223
+ const enabledRecords = records.filter((record) => record.enabled !== false);
224
+ const availableServers = results.filter((result) => result.ok).length;
225
+ const failedServers = results.length - availableServers;
226
+ const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
227
+ let connectionState = 'configured';
228
+ if (records.length === 0) connectionState = 'disconnected';
229
+ else if (enabledRecords.length === 0) connectionState = 'disabled';
230
+ else if (failedServers === 0 && availableServers > 0) connectionState = 'healthy';
231
+ else if (availableServers > 0) connectionState = 'degraded';
232
+ else if (authFailures > 0) connectionState = 'reauth';
233
+ else if (failedServers > 0) connectionState = 'unavailable';
234
+ const labels = {
235
+ disconnected: '未连接', configured: '已配置', disabled: '已停用', healthy: '已连接',
236
+ degraded: '部分异常', reauth: '需重新授权', unavailable: '连接异常',
237
+ };
238
+ return {
239
+ connectorId,
240
+ connectionState,
241
+ label: labels[connectionState],
242
+ configuredServers: records.length,
243
+ enabledServers: enabledRecords.length,
244
+ availableServers,
245
+ failedServers,
246
+ checkedAt,
247
+ results,
248
+ };
249
+ }
250
+
251
+ function connectorHealth(connectorId) {
252
+ const records = [...state.connections.values()].filter((record) => record.connectorId === connectorId);
253
+ if (records.length === 0) return healthSummary(connectorId, records, [], null);
254
+ if (records.every((record) => record.enabled === false)) return healthSummary(connectorId, records, [], null);
255
+ const immediateFailures = records.filter((record) => record.enabled !== false).map(grantFailure).filter(Boolean);
256
+ if (immediateFailures.length > 0) return healthSummary(connectorId, records, immediateFailures, Date.now());
257
+ const cached = state.health.get(connectorId);
258
+ return cached ?? {
259
+ ...healthSummary(connectorId, records, [], null),
260
+ connectionState: 'configured',
261
+ label: '已配置',
262
+ };
263
+ }
264
+
265
+ function cacheHealth(connectorId, records, results) {
266
+ const summary = healthSummary(connectorId, records, results);
267
+ state.health.set(connectorId, summary);
268
+ return summary;
269
+ }
270
+
209
271
  /* ───────────────────────── api 门面 ───────────────────────── */
210
272
 
211
273
  const api = {
212
274
  async catalog({ category, keyword } = {}) {
213
- const items = listCatalog(state.merged, { category, keyword, publishedOnly: true }).map((d) => ({
275
+ const items = listCatalog(state.merged, { category, keyword, publishedOnly: true }).map((d) => {
276
+ const health = connectorHealth(d.id);
277
+ return ({
214
278
  id: d.id,
215
279
  name: d.name,
216
280
  vendor: d.vendor,
@@ -234,13 +298,19 @@ export async function apply(ctx, config) {
234
298
  probeReportUrl: d.probeReportUrl,
235
299
  toolsSnapshot: d.toolsSnapshot ?? [],
236
300
  connected: [...state.connections.values()].filter((r) => r.connectorId === d.id).map((r) => r.key),
301
+ connectionState: health.connectionState,
302
+ connectionLabel: health.label,
303
+ healthCheckedAt: health.checkedAt,
304
+ availableServers: health.availableServers,
305
+ failedServers: health.failedServers,
237
306
  servers: (d.servers ?? []).map((s) => ({
238
307
  serverKey: s.serverKey,
239
308
  url: s.url,
240
309
  transport: s.transport,
241
310
  serverName: s.serverName,
242
311
  })),
243
- }));
312
+ });
313
+ });
244
314
  return {
245
315
  ok: true,
246
316
  message: `共 ${items.length} 个已上架连接器`,
@@ -374,6 +444,7 @@ export async function apply(ctx, config) {
374
444
  await upsertRecord(record);
375
445
  created.push(record.key);
376
446
  }
447
+ cacheHealth(connector.id, records, validation.detail.results);
377
448
  return {
378
449
  ok: true,
379
450
  message: `已配置并连接 "${connector.name}"(${created.length} 个 MCP Server)`,
@@ -435,6 +506,8 @@ export async function apply(ctx, config) {
435
506
  async status() {
436
507
  const items = [];
437
508
  for (const [key, record] of state.connections) {
509
+ const health = record.connectorId ? connectorHealth(record.connectorId) : null;
510
+ const recordHealth = health?.results?.find((result) => result.serverKey === record.serverKey || result.serverName === record.serverName);
438
511
  let grantInfo = null;
439
512
  if (record.auth?.grantKey) {
440
513
  const g = state.grants.get(record.auth.grantKey);
@@ -457,11 +530,57 @@ export async function apply(ctx, config) {
457
530
  authMode: record.auth?.mode ?? 'none',
458
531
  grant: grantInfo,
459
532
  lastError: record.lastError ?? null,
533
+ connectionState: recordHealth?.ok ? 'healthy' : recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
534
+ healthCheckedAt: health?.checkedAt ?? null,
535
+ healthMessage: recordHealth?.message ?? null,
460
536
  });
461
537
  }
462
538
  return { ok: true, message: `共 ${items.length} 条连接`, detail: { items } };
463
539
  },
464
540
 
541
+ async healthCheck(connectorId) {
542
+ const targetIds = connectorId
543
+ ? [String(connectorId)]
544
+ : [...new Set([...state.connections.values()].map((record) => record.connectorId).filter(Boolean))];
545
+ const summaries = [];
546
+ for (const id of targetIds) {
547
+ const records = [...state.connections.values()].filter((record) => record.connectorId === id);
548
+ const enabledRecords = records.filter((record) => record.enabled !== false);
549
+ if (records.length === 0 || enabledRecords.length === 0) {
550
+ const summary = healthSummary(id, records, [], Date.now());
551
+ state.health.set(id, summary);
552
+ summaries.push(summary);
553
+ continue;
554
+ }
555
+ const immediateResults = [];
556
+ const checkable = [];
557
+ for (const record of enabledRecords) {
558
+ const failure = grantFailure(record);
559
+ if (failure) immediateResults.push(failure);
560
+ else checkable.push(record);
561
+ }
562
+ let checkedResults = [];
563
+ if (checkable.length > 0) {
564
+ const validation = await validateConnectionRecords(checkable, {
565
+ timeoutMs: Math.min(config.requestTimeoutMs, 5_000),
566
+ concurrency: 4,
567
+ grants: grantMap(),
568
+ });
569
+ checkedResults = validation.detail.results;
570
+ }
571
+ summaries.push(cacheHealth(id, records, [...immediateResults, ...checkedResults]));
572
+ }
573
+ const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
574
+ const attention = summaries.filter((item) => ['reauth', 'degraded', 'unavailable'].includes(item.connectionState)).length;
575
+ return {
576
+ ok: true,
577
+ message: targetIds.length === 0
578
+ ? '暂无已配置连接'
579
+ : `已检查 ${summaries.length} 个连接器:${healthy} 个正常${attention ? `,${attention} 个需处理` : ''}`,
580
+ detail: { items: summaries, healthy, attention },
581
+ };
582
+ },
583
+
465
584
  async migrationPreview({ scanStored = false } = {}) {
466
585
  const { candidates, warnings } = await readLegacyGrantCandidates(ctx.storageDomain, { openClosed: scanStored });
467
586
  const plans = planLegacyMigration(candidates, state.merged, state.connections);
@@ -533,6 +652,7 @@ export async function apply(ctx, config) {
533
652
  if (record.auth?.grantKey) {
534
653
  await remove(ctx, config, record);
535
654
  state.connections.delete(key);
655
+ if (record.connectorId) state.health.delete(record.connectorId);
536
656
  if (config.persistSecrets) await connectionStore.delete(key).catch(() => {});
537
657
  const stillUsed = [...state.connections.values()].some((r) => r.auth?.grantKey === record.auth.grantKey);
538
658
  if (!stillUsed) {
@@ -558,6 +678,7 @@ export async function apply(ctx, config) {
558
678
 
559
679
  await remove(ctx, config, record);
560
680
  state.connections.delete(key);
681
+ if (record.connectorId) state.health.delete(record.connectorId);
561
682
  if (config.persistSecrets) await connectionStore.delete(key).catch(() => {});
562
683
  return { ok: true, message: `已断开 "${record.name}"` };
563
684
  },
@@ -640,7 +761,14 @@ export async function apply(ctx, config) {
640
761
  const error = response.status === 401 || response.status === 403
641
762
  ? `凭据无效、已过期或权限不足(HTTP ${response.status})`
642
763
  : `HTTP ${response.status}`;
643
- servers.push({ serverKey: record.serverKey || record.name, serverName: record.serverName, ok: false, error, tools: [] });
764
+ servers.push({
765
+ serverKey: record.serverKey || record.name,
766
+ serverName: record.serverName,
767
+ ok: false,
768
+ error,
769
+ errorKind: response.status === 401 || response.status === 403 ? 'auth' : 'http',
770
+ tools: [],
771
+ });
644
772
  continue;
645
773
  }
646
774
 
@@ -682,6 +810,9 @@ export async function apply(ctx, config) {
682
810
  const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
683
811
  const availableServers = servers.filter((server) => server.ok).length;
684
812
  const failedServers = servers.length - availableServers;
813
+ const health = cacheHealth(connectorId, records, servers.map((server) => server.ok
814
+ ? { ok: true, kind: 'connected', serverKey: server.serverKey, serverName: server.serverName }
815
+ : { ok: false, kind: server.errorKind ?? 'network', serverKey: server.serverKey, serverName: server.serverName, message: server.error }));
685
816
  const failureSummary = servers.filter((server) => !server.ok).slice(0, 3)
686
817
  .map((server) => `${server.serverName || server.serverKey}:${server.error}`).join(';');
687
818
  return {
@@ -689,7 +820,7 @@ export async function apply(ctx, config) {
689
820
  message: failedServers > 0
690
821
  ? `工具加载失败:${failureSummary}${failedServers > 3 ? `;另有 ${failedServers - 3} 个 Server 不可用` : ''}${availableServers > 0 ? `;${availableServers} 个 Server 仍可用` : ''}`
691
822
  : `共 ${servers.length} 个 server,${totalTools} 个工具`,
692
- detail: { connectorId, servers, totalTools, availableServers, failedServers },
823
+ detail: { connectorId, servers, totalTools, availableServers, failedServers, connectionState: health.connectionState },
693
824
  };
694
825
  },
695
826
  };
@@ -82,7 +82,7 @@ function failed(record, kind, message, extra = {}) {
82
82
  };
83
83
  }
84
84
 
85
- export async function validateConnectionRecord(record, { timeoutMs = 15_000, fetchImpl = fetch } = {}) {
85
+ export async function validateConnectionRecord(record, { timeoutMs = 15_000, fetchImpl = fetch, grants = new Map() } = {}) {
86
86
  const controller = new AbortController();
87
87
  const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
88
88
  try {
@@ -91,7 +91,7 @@ export async function validateConnectionRecord(record, { timeoutMs = 15_000, fet
91
91
  headers: {
92
92
  'Content-Type': 'application/json',
93
93
  Accept: 'application/json, text/event-stream',
94
- ...authHeaders(record, new Map()),
94
+ ...authHeaders(record, grants),
95
95
  },
96
96
  body: JSON.stringify({
97
97
  jsonrpc: '2.0',
@@ -153,7 +153,18 @@ export async function validateConnectionRecord(record, { timeoutMs = 15_000, fet
153
153
  }
154
154
 
155
155
  export async function validateConnectionRecords(records, options = {}) {
156
- const results = await Promise.all(records.map((record) => validateConnectionRecord(record, options)));
156
+ const concurrency = Math.max(1, Math.min(Number(options.concurrency) || records.length || 1, records.length || 1));
157
+ const results = new Array(records.length);
158
+ let cursor = 0;
159
+ const workers = Array.from({ length: concurrency }, async () => {
160
+ for (;;) {
161
+ const index = cursor;
162
+ cursor += 1;
163
+ if (index >= records.length) return;
164
+ results[index] = await validateConnectionRecord(records[index], options);
165
+ }
166
+ });
167
+ await Promise.all(workers);
157
168
  const failures = results.filter((result) => !result.ok);
158
169
  if (failures.length === 0) {
159
170
  return { ok: true, message: `已验证 ${results.length} 个 MCP Server`, detail: { results } };
package/lib/tools.js CHANGED
@@ -29,7 +29,7 @@ const catalogOutput = {
29
29
  lines.push('');
30
30
  for (const it of items) {
31
31
  const star = it.featured ? '⭐ ' : '';
32
- const connected = it.connected?.length ? `(已连接 ${it.connected.length})` : '';
32
+ const connected = it.connected?.length ? `(${it.connectionLabel || '已配置'} ${it.connected.length})` : '';
33
33
  const summary = it.summary ? ` — ${it.summary}` : '';
34
34
  lines.push(`- ${star}[${it.id}] ${it.name} · ${it.category} · ${it.authMode}${connected}${summary}`);
35
35
  }
@@ -47,7 +47,8 @@ const statusOutput = {
47
47
  if (items.length) {
48
48
  lines.push('');
49
49
  for (const it of items) {
50
- const state = it.enabled ? '启用' : '停用';
50
+ const stateLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
51
+ const state = it.enabled ? (stateLabels[it.connectionState] || '已配置') : '停用';
51
52
  const auth = it.authMode === 'oauth'
52
53
  ? it.grant?.needsReauth ? 'oauth(需重新授权)' : it.grant?.missing ? 'oauth(授权缺失)' : 'oauth'
53
54
  : it.authMode;
@@ -180,6 +181,23 @@ export function registerTools(ctx, api) {
180
181
  },
181
182
  });
182
183
 
184
+ reg({
185
+ name: 'mcp_connector_health_check',
186
+ description: '主动检查已配置 MCP 连接的 initialize 握手、凭据和网络状态。可指定 connectorId,留空则检查全部已配置连接器。',
187
+ parameters: {
188
+ type: 'object',
189
+ properties: {
190
+ connectorId: { type: 'string', description: '连接器 id(可选)' },
191
+ },
192
+ additionalProperties: false,
193
+ },
194
+ output: resultOutput,
195
+ timeoutMs: 120_000,
196
+ async execute(args) {
197
+ return api.healthCheck(args?.connectorId);
198
+ },
199
+ });
200
+
183
201
  reg({
184
202
  name: 'mcp_connector_migration_preview',
185
203
  description:
package/lib/web.js CHANGED
@@ -19,6 +19,7 @@ const UI_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
19
19
  const API_WHITELIST = new Map([
20
20
  ['catalog', (api, p) => api.catalog(p ?? {})],
21
21
  ['status', (api) => api.status()],
22
+ ['healthCheck', (api, p) => api.healthCheck(p?.connectorId)],
22
23
  ['migrationPreview', (api, p) => api.migrationPreview(p ?? {})],
23
24
  ['migrateLegacy', (api, p) => api.migrateLegacy(p?.candidateIds ?? [])],
24
25
  ['connect', (api, p) => api.connect(p?.connectorId, p?.serverKey)],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "General-purpose MCP Connector Marketplace for DeepSeek Harness with OAuth 2.0 PKCE, API key/URL, JSON import, tool discovery, prompt templates, and connection management.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,6 +11,7 @@ const catalog = JSON.parse(await readFile(join(root, 'catalog/catalog.json'), 'u
11
11
  .filter((item) => item.published !== false)
12
12
  .map(normalizeConnectorDescriptor);
13
13
  const connected = new Set(['qcc-company']);
14
+ const healthStates = new Map();
14
15
  const bundledAssets = new Map([
15
16
  ['qcc-logo.svg', 'image/svg+xml'],
16
17
  ['pkulaw-logo.png', 'image/png'],
@@ -47,13 +48,21 @@ const server = createServer(async (req, res) => {
47
48
  credentialDescription: item.auth.credentialDescription,
48
49
  credentialHelpLabel: item.auth.credentialHelpLabel,
49
50
  connected: connected.has(item.id) ? [`${item.id}-main`] : [],
51
+ connectionState: connected.has(item.id) ? (healthStates.get(item.id) ?? 'configured') : 'disconnected',
52
+ connectionLabel: healthStates.get(item.id) === 'healthy' ? '已连接' : connected.has(item.id) ? '已配置' : '未连接',
50
53
  })) } }); return;
51
54
  }
52
55
  if (method === 'status') { json(res, { ok: true, detail: { items: [] } }); return; }
53
56
  if (method === 'migrationPreview') { json(res, { ok: true, detail: { pendingCount: 0, items: [] } }); return; }
54
57
  if (method === 'toolsList') {
55
58
  const tools = Array.from({ length: 125 }, (_, index) => ({ name: `tool_${String(index + 1).padStart(3, '0')}`, title: `工具 ${index + 1}`, description: `用于验证分批渲染与搜索的第 ${index + 1} 个工具。` }));
56
- json(res, { ok: true, detail: { totalTools: tools.length, servers: [{ serverKey: 'company', serverName: 'qcc-company', ok: true, tools }] } }); return;
59
+ healthStates.set(params.connectorId, 'healthy');
60
+ json(res, { ok: true, detail: { totalTools: tools.length, connectionState: 'healthy', servers: [{ serverKey: 'company', serverName: 'qcc-company', ok: true, tools }] } }); return;
61
+ }
62
+ if (method === 'healthCheck') {
63
+ const ids = params.connectorId ? [params.connectorId] : [...connected];
64
+ ids.forEach((id) => healthStates.set(id, 'healthy'));
65
+ json(res, { ok: true, message: `已检查 ${ids.length} 个连接器:${ids.length} 个正常`, detail: { items: ids.map((connectorId) => ({ connectorId, connectionState: 'healthy' })) } }); return;
57
66
  }
58
67
  if (method === 'connect') { connected.add(params.connectorId); json(res, { ok: true, message: 'Mock 连接成功' }); return; }
59
68
  if (method === 'configure') {
package/ui/index.html CHANGED
@@ -146,6 +146,10 @@
146
146
  .btn.ghost:hover { border-color: var(--accent); color: var(--accent); }
147
147
  .btn.danger { background: transparent; border: 1px solid #fecaca; color: var(--bad); }
148
148
  .btn.danger:hover { border-color: var(--bad); background: var(--bad-bg); }
149
+ .btn.status-warning { background: var(--warn-bg); border: 1px solid #fde68a; color: #92400e; }
150
+ .btn.status-warning:hover { background: #fef3c7; border-color: var(--warn); }
151
+ .btn.status-bad { background: var(--bad-bg); border: 1px solid #fecaca; color: var(--bad); }
152
+ .btn.status-bad:hover { background: #fee2e2; border-color: var(--bad); }
149
153
  .btn:disabled { opacity: .5; cursor: not-allowed; }
150
154
 
151
155
  /* Installed rows */
@@ -355,17 +359,16 @@
355
359
  <div class="app">
356
360
  <header>
357
361
  <div class="tabs" id="tabs">
358
- <button data-view="market" data-i18n="market" class="active">市场</button>
359
- <button data-view="installed" data-i18n="installed">已安装</button>
362
+ <button data-view="market" class="active">市场</button>
363
+ <button data-view="installed">已安装</button>
360
364
  </div>
361
365
  <div class="header-spacer"></div>
362
366
  <div class="search-box">
363
367
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted);flex-shrink:0"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
364
- <input id="search" type="text" placeholder="搜索连接器…" data-i18n-placeholder="search" />
368
+ <input id="search" type="text" placeholder="搜索连接器…" />
365
369
  </div>
366
- <button class="icon-btn primary" id="add-connection" type="button" data-i18n="add">+ 添加连接</button>
367
- <button class="icon-btn" id="locale-toggle" type="button" aria-label="Switch language">EN</button>
368
- <button class="icon-btn" id="refresh" data-i18n="refresh">刷新</button>
370
+ <button class="icon-btn primary" id="add-connection" type="button">+ 添加连接</button>
371
+ <button class="icon-btn" id="refresh">刷新</button>
369
372
  </header>
370
373
  <main id="main"></main>
371
374
 
@@ -384,17 +387,17 @@
384
387
  <div class="detail-desc" id="detail-desc"></div>
385
388
  <div class="detail-section">
386
389
  <div class="detail-section-head">
387
- <div class="detail-section-title"><span class="emoji">💡</span> <span data-i18n="tryExamples">试试这样用</span> <span class="detail-section-meta" id="prompt-meta"></span></div>
390
+ <div class="detail-section-title"><span class="emoji">💡</span> <span>试试这样用</span> <span class="detail-section-meta" id="prompt-meta"></span></div>
388
391
  <div class="detail-section-spacer"></div>
389
- <button class="text-btn" id="prompt-next" type="button" data-i18n="next" hidden>换一批</button>
392
+ <button class="text-btn" id="prompt-next" type="button" hidden>换一批</button>
390
393
  </div>
391
394
  <div class="prompt-list" id="detail-prompts"></div>
392
395
  </div>
393
396
  <details class="tools-disclosure" id="tools-disclosure">
394
- <summary><span>🧰 <span data-i18n="tools">工具详情</span></span><span class="tools-summary" id="tools-summary">加载中…</span></summary>
397
+ <summary><span>🧰 <span>工具详情</span></span><span class="tools-summary" id="tools-summary">加载中…</span></summary>
395
398
  <div class="tools-panel">
396
399
  <div class="tools-toolbar">
397
- <input class="tools-search" id="tools-search" type="search" placeholder="搜索工具名称或描述…" data-i18n-placeholder="toolSearch" autocomplete="off" />
400
+ <input class="tools-search" id="tools-search" type="search" placeholder="搜索工具名称或描述…" autocomplete="off" />
398
401
  </div>
399
402
  <div class="tools-list" id="detail-tools">
400
403
  <div class="tools-loading">加载中...</div>
@@ -403,7 +406,7 @@
403
406
  </details>
404
407
  <div class="detail-actions">
405
408
  <button class="btn-primary" id="detail-try-btn">💬 去试试</button>
406
- <button class="btn-danger" id="detail-disconnect-btn" data-i18n="disconnect">断开连接</button>
409
+ <button class="btn-danger" id="detail-disconnect-btn">断开连接</button>
407
410
  </div>
408
411
  </div>
409
412
  </div>
@@ -423,23 +426,14 @@
423
426
  const main = $('#main');
424
427
  const toast = $('#toast');
425
428
  const searchInput = $('#search');
426
- const I18N = {
427
- zh: {
428
- market: '市场', installed: '已安装', search: '搜索连接器…', add: '+ 添加连接', refresh: '刷新',
429
- tryExamples: '试试这样用', next: '换一批', tools: '工具详情', toolSearch: '搜索工具名称或描述…',
430
- disconnect: '断开连接', details: '详情', connect: '连接', connected: '已连接', configure: '配置',
431
- send: '发送', fillSend: '填写并发送', connectToUse: '连接后使用', try: '💬 去试试', connectTry: '🔗 连接后试用', configureConnect: '🔑 录入凭据并连接', reconfigureCredential: '🔑 重新配置凭据', loading: '加载中…',
432
- },
433
- en: {
434
- market: 'Marketplace', installed: 'Installed', search: 'Search connectors…', add: '+ Add connector', refresh: 'Refresh',
435
- tryExamples: 'Try these prompts', next: 'Next', tools: 'Tool details', toolSearch: 'Search tool name or description…',
436
- disconnect: 'Disconnect', details: 'Details', connect: 'Connect', connected: 'Connected', configure: 'Configure',
437
- send: 'Send', fillSend: 'Fill & send', connectToUse: 'Connect to use', try: '💬 Try it', connectTry: '🔗 Connect to try', configureConnect: '🔑 Add key & connect', reconfigureCredential: '🔑 Reconfigure key', loading: 'Loading…',
438
- },
429
+ const LABELS = {
430
+ details: '详情', connect: '连接', connected: '已连接', configured: '已配置', configure: '配置',
431
+ reauthorize: '需重新授权', connectionError: '连接异常', partialError: '部分异常', disabled: '已停用',
432
+ send: '发送', fillSend: '填写并发送', connectToUse: '连接后使用', try: '💬 去试试',
433
+ connectTry: '🔗 连接后试用', configureConnect: '🔑 录入凭据并连接',
434
+ reconfigureCredential: '🔑 重新配置凭据', loading: '加载中…',
439
435
  };
440
- let locale = 'zh';
441
- try { locale = localStorage.getItem('mcp-connector:locale') === 'en' ? 'en' : 'zh'; } catch {}
442
- const t = (key) => I18N[locale]?.[key] || I18N.zh[key] || key;
436
+ const t = (key) => LABELS[key] || key;
443
437
  const MCP_JSON_EXAMPLE = JSON.stringify({
444
438
  mcpServers: {
445
439
  'my-server': {
@@ -450,12 +444,6 @@
450
444
  },
451
445
  }, null, 2);
452
446
 
453
- function applyLocale() {
454
- document.documentElement.lang = locale === 'en' ? 'en' : 'zh-CN';
455
- document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); });
456
- document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.i18nPlaceholder); });
457
- $('#locale-toggle').textContent = locale === 'zh' ? 'EN' : '中';
458
- }
459
447
  let view = 'market';
460
448
  let marketItems = {};
461
449
  let searchQuery = '';
@@ -465,6 +453,7 @@
465
453
  let detailConnector = null;
466
454
  let detailToolServers = [];
467
455
  let detailNeedsReconfigure = false;
456
+ let detailFailureState = '';
468
457
  let toolRenderLimit = 50;
469
458
  let detailLoadId = 0;
470
459
  let catalogRenderLimit = 60;
@@ -474,6 +463,7 @@
474
463
  const PROMPT_RESULT_TYPE = 'mcp-connector:start-session-result';
475
464
  let promptRequestId = null;
476
465
  let promptRequestTimer = null;
466
+ let initialHealthCheckStarted = false;
477
467
 
478
468
  async function call(method, params) {
479
469
  try {
@@ -510,7 +500,7 @@
510
500
  button.disabled = pending;
511
501
  if (pending) {
512
502
  button.dataset.idleLabel = button.textContent;
513
- button.textContent = locale === 'en' ? 'Sending…' : '正在发送…';
503
+ button.textContent = '正在发送…';
514
504
  } else if (button.dataset.idleLabel) {
515
505
  button.textContent = button.dataset.idleLabel;
516
506
  delete button.dataset.idleLabel;
@@ -675,7 +665,7 @@
675
665
  || (d.tags || []).some(t => t.toLowerCase().includes(q));
676
666
  }
677
667
 
678
- async function loadMarket() {
668
+ async function loadMarket({ checkHealth = true } = {}) {
679
669
  main.innerHTML = '<div class="empty">加载中…</div>';
680
670
  const r = await call('catalog', {});
681
671
  if (!r.ok) { main.innerHTML = `<div class="empty">${esc(r.message)}</div>`; return; }
@@ -687,14 +677,19 @@
687
677
  const count = filtered.length;
688
678
  const visible = filtered.slice(0, catalogRenderLimit);
689
679
  main.innerHTML = `<div class="stats"><span class="count">共 <strong>${count}</strong> 个连接器${searchQuery ? ` · 搜索「${esc(searchQuery)}」` : ''} · 点击卡片查看能力,点击「连接」完成授权</span></div><div class="grid">${visible.map(cardHtml).join('')}</div>${visible.length < count ? `<button class="tools-more" id="catalog-more" type="button">再显示 ${Math.min(60, count - visible.length)} 个(剩余 ${count - visible.length})</button>` : ''}`;
680
+ if (checkHealth && !initialHealthCheckStarted && items.some((item) => item.connected?.length > 0)) {
681
+ initialHealthCheckStarted = true;
682
+ call('healthCheck', {}).then(() => {
683
+ if (view === 'market') loadMarket({ checkHealth: false });
684
+ });
685
+ }
690
686
  }
691
687
 
692
688
  function cardHtml(d) {
693
- const connected = d.connected?.length > 0;
694
689
  const icon = isImageIcon(d.icon)
695
690
  ? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`
696
691
  : `<span>${esc(d.icon || '🔌')}</span>`;
697
- const actions = actionHtml(d, connected);
692
+ const actions = actionHtml(d);
698
693
  const probe = d.probeStatus && d.probeStatus !== 'unverified' ? `<span class="badge ${esc(d.probeStatus)}">${d.probeStatus}</span>` : '';
699
694
  return `<div class="card ${d.featured ? 'featured' : ''}" data-id="${esc(d.id)}" role="button" tabindex="0" aria-label="查看 ${esc(d.name)} 详情" onclick="window.__mcp.showDetail('${esc(d.id)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.__mcp.showDetail('${esc(d.id)}')}">
700
695
  <div class="icon">${icon}</div>
@@ -706,17 +701,24 @@
706
701
  </div>`;
707
702
  }
708
703
 
709
- function actionHtml(d, connected) {
704
+ function actionHtml(d) {
710
705
  const id = esc(d.id);
706
+ const configured = d.connected?.length > 0;
707
+ const state = d.connectionState || (configured ? 'configured' : 'disconnected');
708
+ if (configured) {
709
+ if (state === 'healthy') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
710
+ if (state === 'configured') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('configured')}</button>`;
711
+ if (state === 'disabled') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('disabled')}</button>`;
712
+ if (state === 'reauth') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-warning" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('reauthorize')}</button>`;
713
+ if (state === 'degraded') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-warning" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('partialError')}</button>`;
714
+ if (state === 'unavailable') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-bad" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('connectionError')}</button>`;
715
+ }
711
716
  if (d.authMode === 'oauth2-pkce') {
712
- if (connected) return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
713
717
  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.connect('${id}')">${t('connect')}</button>`;
714
718
  }
715
719
  if (d.authMode === 'none') {
716
- if (connected) return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
717
720
  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.connect('${id}')">${t('connect')}</button>`;
718
721
  }
719
- if (connected) return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
720
722
  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>`;
721
723
  }
722
724
 
@@ -735,10 +737,13 @@
735
737
  function rowHtml(r) {
736
738
  const key = esc(r.key);
737
739
  const authMode = r.authMode === 'oauth' ? '<span class="badge auth-oauth">OAuth</span>' : `<span class="badge">${esc(r.authMode || 'manual')}</span>`;
740
+ const healthLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
741
+ const healthClass = r.connectionState === 'healthy' ? 'pass' : ['reauth', 'degraded'].includes(r.connectionState) ? 'partial' : r.connectionState === 'unavailable' ? 'fail' : '';
742
+ const healthBadge = `<span class="badge ${healthClass}">${esc(healthLabels[r.connectionState] || '已配置')}</span>`;
738
743
  const enabled = r.enabled !== false;
739
744
  return `<div class="row" data-key="${key}">
740
745
  <div class="info">
741
- <div class="rname">${esc(r.name)} ${authMode}</div>
746
+ <div class="rname">${esc(r.name)} ${authMode} ${healthBadge}</div>
742
747
  <div class="rmeta">${esc(r.serverName || '')} · ${esc(r.url || '')}</div>
743
748
  </div>
744
749
  <label class="switch" title="${enabled ? '停用' : '启用'}" aria-label="${enabled ? '停用' : '启用'} ${esc(r.name)}">
@@ -884,7 +889,10 @@
884
889
  async connect(connectorId) {
885
890
  const r = await call('connect', { connectorId });
886
891
  showToast(r.message, r.ok);
887
- if (r.ok) await refresh();
892
+ if (r.ok) {
893
+ await call('healthCheck', { connectorId });
894
+ await refresh();
895
+ }
888
896
  },
889
897
  toggle(key, enabled) {
890
898
  call('setEnabled', { key, enabled }).then((r) => showToast(r.message, r.ok));
@@ -1001,13 +1009,15 @@
1001
1009
  const d = marketItems[connectorId];
1002
1010
  if (!d) return;
1003
1011
  detailConnector = d;
1004
- detailNeedsReconfigure = false;
1012
+ detailFailureState = d.connectionState || '';
1013
+ detailNeedsReconfigure = ['reauth', 'unavailable'].includes(detailFailureState);
1005
1014
  const loadId = ++detailLoadId;
1006
1015
  $('#detail-icon').innerHTML = isImageIcon(d.icon)
1007
1016
  ? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`
1008
1017
  : `<span style="font-size:24px;">${esc(d.icon || '')}</span>`;
1009
1018
  $('#detail-name').textContent = d.name || '';
1010
- $('#detail-vendor').textContent = d.vendor ? `${d.vendor} · 已连接 ${d.connected?.length || 0} 个服务` : `已连接 ${d.connected?.length || 0} 个服务`;
1019
+ const initialStateLabel = d.connectionLabel || (d.connected?.length ? '已配置' : '未连接');
1020
+ $('#detail-vendor').textContent = d.vendor ? `${d.vendor} · ${initialStateLabel} ${d.connected?.length || 0} 个服务` : `${initialStateLabel} ${d.connected?.length || 0} 个服务`;
1011
1021
  $('#detail-desc').textContent = d.description || d.summary || '';
1012
1022
  detailPrompts = (d.prompts || []).filter((prompt) => prompt?.text || prompt?.title);
1013
1023
  promptPage = 0;
@@ -1021,20 +1031,29 @@
1021
1031
  setDetailOpen(true);
1022
1032
  $('#detail-disconnect-btn').style.display = d.connected?.length > 0 ? 'block' : 'none';
1023
1033
  const needsCredential = ['bearer', 'api-key'].includes(d.authMode);
1024
- $('#detail-try-btn').textContent = d.connected?.length > 0 ? t('try') : needsCredential ? t('configureConnect') : t('connectTry');
1034
+ $('#detail-try-btn').textContent = detailNeedsReconfigure
1035
+ ? d.authMode === 'oauth2-pkce' && detailFailureState === 'reauth' ? '🔐 重新授权' : needsCredential && detailFailureState === 'reauth' ? t('reconfigureCredential') : '🔄 重新检查'
1036
+ : d.connected?.length > 0 ? t('try') : needsCredential ? t('configureConnect') : t('connectTry');
1025
1037
  const toolsResult = await call('toolsList', { connectorId });
1026
1038
  if (loadId !== detailLoadId || !$('#detail-overlay').classList.contains('active')) return;
1027
1039
  if (!toolsResult.ok) {
1040
+ detailFailureState = toolsResult.detail?.connectionState || detailFailureState || 'unavailable';
1041
+ detailNeedsReconfigure = true;
1028
1042
  $('#tools-summary').textContent = '工具加载失败';
1029
1043
  $('#detail-tools').innerHTML = `<div class="tools-error">${esc(toolsResult.message)}</div>`;
1030
- if (needsCredential && d.connected?.length > 0) {
1031
- detailNeedsReconfigure = true;
1032
- $('#detail-vendor').textContent = d.vendor ? `${d.vendor} · 当前连接不可用` : '当前连接不可用';
1033
- $('#detail-try-btn').textContent = t('reconfigureCredential');
1034
- renderPromptPage();
1035
- }
1044
+ const failureLabel = detailFailureState === 'reauth' ? (d.authMode === 'oauth2-pkce' ? '授权已失效' : '凭据已失效') : '当前连接异常';
1045
+ $('#detail-vendor').textContent = d.vendor ? `${d.vendor} · ${failureLabel}` : failureLabel;
1046
+ $('#detail-try-btn').textContent = d.authMode === 'oauth2-pkce' && detailFailureState === 'reauth'
1047
+ ? '🔐 重新授权'
1048
+ : needsCredential && detailFailureState === 'reauth' ? t('reconfigureCredential') : '🔄 重新检查';
1049
+ renderPromptPage();
1036
1050
  return;
1037
1051
  }
1052
+ detailFailureState = toolsResult.detail?.connectionState || 'healthy';
1053
+ detailNeedsReconfigure = false;
1054
+ $('#detail-vendor').textContent = d.vendor ? `${d.vendor} · ${detailFailureState === 'degraded' ? '部分服务可用' : '已连接'} ${d.connected?.length || 0} 个服务` : `${detailFailureState === 'degraded' ? '部分服务可用' : '已连接'} ${d.connected?.length || 0} 个服务`;
1055
+ $('#detail-try-btn').textContent = t('try');
1056
+ renderPromptPage();
1038
1057
  detailToolServers = toolsResult.detail?.servers || [];
1039
1058
  updateToolsSummary();
1040
1059
  renderTools();
@@ -1114,8 +1133,24 @@
1114
1133
  renderTools();
1115
1134
  });
1116
1135
  $('#detail-try-btn').addEventListener('click', async () => {
1117
- if (detailNeedsReconfigure && ['bearer', 'api-key'].includes(detailConnector?.authMode)) {
1118
- openAddConnection('manual', detailConnector);
1136
+ if (detailNeedsReconfigure) {
1137
+ if (detailFailureState === 'reauth' && ['bearer', 'api-key'].includes(detailConnector?.authMode)) {
1138
+ openAddConnection('manual', detailConnector);
1139
+ return;
1140
+ }
1141
+ if (detailFailureState === 'reauth' && detailConnector?.authMode === 'oauth2-pkce') {
1142
+ const id = detailConnector.id;
1143
+ const r = await call('connect', { connectorId: id });
1144
+ showToast(r.message, r.ok);
1145
+ if (r.ok) {
1146
+ await call('healthCheck', { connectorId: id });
1147
+ closeDetail();
1148
+ await refresh();
1149
+ }
1150
+ return;
1151
+ }
1152
+ const id = detailConnector?.id;
1153
+ if (id) await window.__mcp.showDetail(id);
1119
1154
  return;
1120
1155
  }
1121
1156
  if (!detailConnector?.connected?.length) {
@@ -1162,13 +1197,6 @@
1162
1197
  }
1163
1198
 
1164
1199
  $('#add-connection').addEventListener('click', () => openAddConnection('json'));
1165
- $('#locale-toggle').addEventListener('click', () => {
1166
- locale = locale === 'zh' ? 'en' : 'zh';
1167
- try { localStorage.setItem('mcp-connector:locale', locale); } catch {}
1168
- applyLocale();
1169
- refresh();
1170
- if ($('#detail-overlay').classList.contains('active')) renderPromptPage();
1171
- });
1172
1200
  main.addEventListener('click', (event) => {
1173
1201
  if (!event.target.closest('#catalog-more')) return;
1174
1202
  catalogRenderLimit += 60;
@@ -1178,6 +1206,10 @@
1178
1206
  $('#refresh').addEventListener('click', async () => {
1179
1207
  const r = await call('refreshCatalog', {});
1180
1208
  showToast(r.message, r.ok);
1209
+ if (r.ok) {
1210
+ const health = await call('healthCheck', {});
1211
+ showToast(`${r.message};${health.message}`, health.ok);
1212
+ }
1181
1213
  await refresh();
1182
1214
  });
1183
1215
 
@@ -1195,7 +1227,6 @@
1195
1227
  refresh();
1196
1228
  });
1197
1229
 
1198
- applyLocale();
1199
1230
  refresh();
1200
1231
  })();
1201
1232
  </script>