dsh-mcp-connector 0.2.3 → 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,23 @@
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
+
7
24
  ## [0.2.3] - 2026-08-21
8
25
 
9
26
  ### Added
@@ -102,7 +119,8 @@
102
119
  - 外部 URL 与导入 Header 执行安全校验。
103
120
  - iframe 消息校验同源和消息来源。
104
121
 
105
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.3...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
106
124
  [0.2.3]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.2...v0.2.3
107
125
  [0.2.2]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.1...v0.2.2
108
126
  [0.2.1]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.0...v0.2.1
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.
@@ -97,7 +97,7 @@ npm run dev:ui
97
97
 
98
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`.
99
99
 
100
- 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).
101
101
 
102
102
  See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
103
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,7 +24,7 @@
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
 
@@ -93,7 +93,7 @@ npm run dev:ui
93
93
 
94
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`。
95
95
 
96
- 当前公开版本为 [`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)。
97
97
 
98
98
  版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
99
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.3",
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 */
@@ -423,7 +427,8 @@
423
427
  const toast = $('#toast');
424
428
  const searchInput = $('#search');
425
429
  const LABELS = {
426
- details: '详情', connect: '连接', connected: '已连接', configure: '配置',
430
+ details: '详情', connect: '连接', connected: '已连接', configured: '已配置', configure: '配置',
431
+ reauthorize: '需重新授权', connectionError: '连接异常', partialError: '部分异常', disabled: '已停用',
427
432
  send: '发送', fillSend: '填写并发送', connectToUse: '连接后使用', try: '💬 去试试',
428
433
  connectTry: '🔗 连接后试用', configureConnect: '🔑 录入凭据并连接',
429
434
  reconfigureCredential: '🔑 重新配置凭据', loading: '加载中…',
@@ -448,6 +453,7 @@
448
453
  let detailConnector = null;
449
454
  let detailToolServers = [];
450
455
  let detailNeedsReconfigure = false;
456
+ let detailFailureState = '';
451
457
  let toolRenderLimit = 50;
452
458
  let detailLoadId = 0;
453
459
  let catalogRenderLimit = 60;
@@ -457,6 +463,7 @@
457
463
  const PROMPT_RESULT_TYPE = 'mcp-connector:start-session-result';
458
464
  let promptRequestId = null;
459
465
  let promptRequestTimer = null;
466
+ let initialHealthCheckStarted = false;
460
467
 
461
468
  async function call(method, params) {
462
469
  try {
@@ -658,7 +665,7 @@
658
665
  || (d.tags || []).some(t => t.toLowerCase().includes(q));
659
666
  }
660
667
 
661
- async function loadMarket() {
668
+ async function loadMarket({ checkHealth = true } = {}) {
662
669
  main.innerHTML = '<div class="empty">加载中…</div>';
663
670
  const r = await call('catalog', {});
664
671
  if (!r.ok) { main.innerHTML = `<div class="empty">${esc(r.message)}</div>`; return; }
@@ -670,14 +677,19 @@
670
677
  const count = filtered.length;
671
678
  const visible = filtered.slice(0, catalogRenderLimit);
672
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
+ }
673
686
  }
674
687
 
675
688
  function cardHtml(d) {
676
- const connected = d.connected?.length > 0;
677
689
  const icon = isImageIcon(d.icon)
678
690
  ? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`
679
691
  : `<span>${esc(d.icon || '🔌')}</span>`;
680
- const actions = actionHtml(d, connected);
692
+ const actions = actionHtml(d);
681
693
  const probe = d.probeStatus && d.probeStatus !== 'unverified' ? `<span class="badge ${esc(d.probeStatus)}">${d.probeStatus}</span>` : '';
682
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)}')}">
683
695
  <div class="icon">${icon}</div>
@@ -689,17 +701,24 @@
689
701
  </div>`;
690
702
  }
691
703
 
692
- function actionHtml(d, connected) {
704
+ function actionHtml(d) {
693
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
+ }
694
716
  if (d.authMode === 'oauth2-pkce') {
695
- 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>`;
696
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>`;
697
718
  }
698
719
  if (d.authMode === 'none') {
699
- 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>`;
700
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>`;
701
721
  }
702
- 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>`;
703
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>`;
704
723
  }
705
724
 
@@ -718,10 +737,13 @@
718
737
  function rowHtml(r) {
719
738
  const key = esc(r.key);
720
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>`;
721
743
  const enabled = r.enabled !== false;
722
744
  return `<div class="row" data-key="${key}">
723
745
  <div class="info">
724
- <div class="rname">${esc(r.name)} ${authMode}</div>
746
+ <div class="rname">${esc(r.name)} ${authMode} ${healthBadge}</div>
725
747
  <div class="rmeta">${esc(r.serverName || '')} · ${esc(r.url || '')}</div>
726
748
  </div>
727
749
  <label class="switch" title="${enabled ? '停用' : '启用'}" aria-label="${enabled ? '停用' : '启用'} ${esc(r.name)}">
@@ -867,7 +889,10 @@
867
889
  async connect(connectorId) {
868
890
  const r = await call('connect', { connectorId });
869
891
  showToast(r.message, r.ok);
870
- if (r.ok) await refresh();
892
+ if (r.ok) {
893
+ await call('healthCheck', { connectorId });
894
+ await refresh();
895
+ }
871
896
  },
872
897
  toggle(key, enabled) {
873
898
  call('setEnabled', { key, enabled }).then((r) => showToast(r.message, r.ok));
@@ -984,13 +1009,15 @@
984
1009
  const d = marketItems[connectorId];
985
1010
  if (!d) return;
986
1011
  detailConnector = d;
987
- detailNeedsReconfigure = false;
1012
+ detailFailureState = d.connectionState || '';
1013
+ detailNeedsReconfigure = ['reauth', 'unavailable'].includes(detailFailureState);
988
1014
  const loadId = ++detailLoadId;
989
1015
  $('#detail-icon').innerHTML = isImageIcon(d.icon)
990
1016
  ? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`
991
1017
  : `<span style="font-size:24px;">${esc(d.icon || '')}</span>`;
992
1018
  $('#detail-name').textContent = d.name || '';
993
- $('#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} 个服务`;
994
1021
  $('#detail-desc').textContent = d.description || d.summary || '';
995
1022
  detailPrompts = (d.prompts || []).filter((prompt) => prompt?.text || prompt?.title);
996
1023
  promptPage = 0;
@@ -1004,20 +1031,29 @@
1004
1031
  setDetailOpen(true);
1005
1032
  $('#detail-disconnect-btn').style.display = d.connected?.length > 0 ? 'block' : 'none';
1006
1033
  const needsCredential = ['bearer', 'api-key'].includes(d.authMode);
1007
- $('#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');
1008
1037
  const toolsResult = await call('toolsList', { connectorId });
1009
1038
  if (loadId !== detailLoadId || !$('#detail-overlay').classList.contains('active')) return;
1010
1039
  if (!toolsResult.ok) {
1040
+ detailFailureState = toolsResult.detail?.connectionState || detailFailureState || 'unavailable';
1041
+ detailNeedsReconfigure = true;
1011
1042
  $('#tools-summary').textContent = '工具加载失败';
1012
1043
  $('#detail-tools').innerHTML = `<div class="tools-error">${esc(toolsResult.message)}</div>`;
1013
- if (needsCredential && d.connected?.length > 0) {
1014
- detailNeedsReconfigure = true;
1015
- $('#detail-vendor').textContent = d.vendor ? `${d.vendor} · 当前连接不可用` : '当前连接不可用';
1016
- $('#detail-try-btn').textContent = t('reconfigureCredential');
1017
- renderPromptPage();
1018
- }
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();
1019
1050
  return;
1020
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();
1021
1057
  detailToolServers = toolsResult.detail?.servers || [];
1022
1058
  updateToolsSummary();
1023
1059
  renderTools();
@@ -1097,8 +1133,24 @@
1097
1133
  renderTools();
1098
1134
  });
1099
1135
  $('#detail-try-btn').addEventListener('click', async () => {
1100
- if (detailNeedsReconfigure && ['bearer', 'api-key'].includes(detailConnector?.authMode)) {
1101
- 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);
1102
1154
  return;
1103
1155
  }
1104
1156
  if (!detailConnector?.connected?.length) {
@@ -1154,6 +1206,10 @@
1154
1206
  $('#refresh').addEventListener('click', async () => {
1155
1207
  const r = await call('refreshCatalog', {});
1156
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
+ }
1157
1213
  await refresh();
1158
1214
  });
1159
1215