dsh-mcp-connector 0.2.21 → 0.2.22

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,19 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.22] - 2026-08-26
8
+
9
+ ### Fixed
10
+
11
+ - 首次连接改为等待 `dsh-mcp-client` 完成 MCP `initialize` 与首次 `tools/list`;启动失败或超时不再提前保存、增加已安装数量或显示“已连接”。
12
+ - stdio 健康状态与工具详情改用 Host 实际注册的 `mcp__<serverName>__*` 工具;未注册工具保持“已配置”并显示可重试诊断。
13
+ - 手动 HTTP、免鉴权市场连接与 JSON 导入统一在落库前执行连接校验;DNS、网络、鉴权或协议失败时保留原配置且不写入无效记录。
14
+ - 市场 Web 请求增加连接、健康检查与工具加载超时,避免详情页无限停留在“加载中”。
15
+
16
+ ### Verification
17
+
18
+ - 新增无效 HTTP 不落库、stdio Host 启动失败回滚、Host 工具注册状态和前端有限超时回归测试。
19
+
7
20
  ## [0.2.21] - 2026-08-25
8
21
 
9
22
  ### Documentation
@@ -359,7 +372,9 @@
359
372
  - 外部 URL 与导入 Header 执行安全校验。
360
373
  - iframe 消息校验同源和消息来源。
361
374
 
362
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.20...HEAD
375
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...HEAD
376
+ [0.2.22]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.21...v0.2.22
377
+ [0.2.21]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.20...v0.2.21
363
378
  [0.2.20]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.19...v0.2.20
364
379
  [0.2.19]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.18...v0.2.19
365
380
  [0.2.18]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.17...v0.2.18
package/README.en.md CHANGED
@@ -111,7 +111,7 @@ npm run dev:ui
111
111
 
112
112
  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.
113
113
 
114
- The current public version is [`dsh-mcp-connector@0.2.21`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.21](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.21).
114
+ The current public version is [`dsh-mcp-connector@0.2.22`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.22](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.22).
115
115
 
116
116
  See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
117
117
 
package/README.md CHANGED
@@ -106,7 +106,7 @@ npm run dev:ui
106
106
 
107
107
  公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
108
108
 
109
- 当前公开版本为 [`dsh-mcp-connector@0.2.21`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.21](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.21)。
109
+ 当前公开版本为 [`dsh-mcp-connector@0.2.22`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.22](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.22)。
110
110
 
111
111
  版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
112
112
  Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
@@ -4,6 +4,8 @@
4
4
  > 结论:**低成本补齐**——`@deepseek-ai/dsh-mcp-client` 已原生支持 stdio,我们只需在 schema + provisioning 两层「开闸透传」。
5
5
 
6
6
  > **实施状态(2026-08-23)**:本文方案已经落地。schema、手工配置、JSON 导入、provisioning、Registry Schema、目录安全审计、健康状态、详情提示和自动测试均已完成;下文保留实施前的代码位置与改动建议,作为架构决策记录。
7
+ >
8
+ > **就绪语义加固(2026-08-26)**:用户主动连接时显式启用 `failOnStartupError`,必须等待 Host 完成首次 `initialize + tools/list` 后才持久化;健康检查与详情页改读 Host 实际注册的 `mcp__<serverName>__*` 工具。启动失败、进程退出或超时不再提前显示“已连接”。依赖默认值仍为 `false`,下文对应代码仅作为依赖能力说明。
7
9
  > 状态:已完成(v0.2.12)
8
10
 
9
11
  ---
@@ -69,7 +69,7 @@ dsh web
69
69
 
70
70
  Bearer/API Key 连接器会先执行 MCP `initialize` 验证。所有 Server 通过后,凭据才会保存并出现在“已安装”中;验证失败不会写入新凭据。
71
71
 
72
- stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token、区域或租户标识。卡片目录只声明字段名称及其环境变量映射;提交后,真实值仅保存到 DSH 本机连接记录,并由插件注入该 stdio 进程的 `env`。市场、状态页和日志不会返回这些值。stdio Host 托管,配置成功表示命令已注册;最终工具可用性仍以本机进程启动结果为准。
72
+ stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token、区域或租户标识。卡片目录只声明字段名称及其环境变量映射;提交后,真实值仅保存到 DSH 本机连接记录,并由插件注入该 stdio 进程的 `env`。市场、状态页和日志不会返回这些值。插件会等待 Host 完成首次 MCP 初始化与工具同步后再保存连接;失败或超时不会增加已安装数量,卡片也不会提前显示“已连接”。
73
73
 
74
74
  OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。
75
75
 
@@ -121,7 +121,7 @@ OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据
121
121
  - **HTTP**:填写名称、HTTPS MCP URL、可选 Header 和传输方式。
122
122
  - **stdio**:填写本机命令、参数、环境变量和可选工作目录。
123
123
 
124
- stdio 进程由 `@deepseek-ai/dsh-mcp-client` 管理,插件只透传 `command`、`args`、`env`、`cwd`。stdio 会以当前用户权限启动本机进程,只运行你信任的软件包和命令。
124
+ stdio 进程由 `@deepseek-ai/dsh-mcp-client` 管理,插件透传 `command`、`args`、`env`、`cwd`,并等待 Host 完成首次 MCP 初始化和工具同步。stdio 会以当前用户权限启动本机进程,只运行你信任的软件包和命令。手动 HTTP 配置也会在保存前执行 `initialize` 校验;验证失败时表单内容仍保留,且不会生成无效连接记录。
125
125
 
126
126
  ### 6.3 市场卡片 URL
127
127
 
@@ -165,7 +165,7 @@ OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端
165
165
 
166
166
  ### stdio 启动失败
167
167
 
168
- 先在终端确认命令本身可执行、软件包可信、Node/运行时版本满足要求,并检查 `cwd` 和环境变量。不要把本机凭据写入公开 Registry descriptor。
168
+ 页面会在有限时间内结束等待,并区分命令不存在、进程退出、初始化失败或启动超时。先在终端确认命令本身可执行、软件包可信、Node/运行时版本满足要求,并检查 `cwd`、参数和环境变量;随后查看 Host 日志并点击“重新检查”。不要把本机凭据写入公开 Registry descriptor。
169
169
 
170
170
  ### 如何反馈问题
171
171
 
package/lib/constants.js CHANGED
@@ -27,6 +27,8 @@ export const DEFAULT_CATALOG_FALLBACK_URLS = [
27
27
 
28
28
  /** 请求 / 刷新默认值 */
29
29
  export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
30
+ /** 首次启动 MCP Server 并完成 initialize + tools/list 的最长等待时间 */
31
+ export const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
30
32
  export const DEFAULT_REFRESH_SKEW_MS = 300_000;
31
33
  export const DEFAULT_CATALOG_TTL_MS = 3_600_000;
32
34
 
package/lib/index.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  DEFAULT_CATALOG_URL,
16
16
  DEFAULT_CATALOG_FALLBACK_URLS,
17
17
  DEFAULT_REQUEST_TIMEOUT_MS,
18
+ DEFAULT_STARTUP_TIMEOUT_MS,
18
19
  DEFAULT_REFRESH_SKEW_MS,
19
20
  DEFAULT_CATALOG_TTL_MS,
20
21
  } from './constants.js';
@@ -52,6 +53,8 @@ export const Config = z.object({
52
53
  callbackTimeoutMs: z.number().default(300_000),
53
54
  /** 网络请求超时(ms) */
54
55
  requestTimeoutMs: z.number().default(DEFAULT_REQUEST_TIMEOUT_MS),
56
+ /** 首次启动并完成 MCP initialize + tools/list 的超时(ms) */
57
+ startupTimeoutMs: z.number().default(DEFAULT_STARTUP_TIMEOUT_MS),
55
58
  /** 提前刷新阈值(ms) */
56
59
  refreshSkewMs: z.number().default(DEFAULT_REFRESH_SKEW_MS),
57
60
  /** 是否自动打开浏览器(false = 仅打印授权 URL) */
@@ -251,18 +254,123 @@ export async function apply(ctx, config) {
251
254
 
252
255
  /* ───────────────────────── 连接记录落库 / 挂载 ───────────────────────── */
253
256
 
257
+ function clearRecordHealth(record) {
258
+ if (record?.connectorId) state.health.delete(record.connectorId);
259
+ }
260
+
254
261
  async function persistRecord(record) {
255
- state.connections.set(record.key, record);
256
- if (record.connectorId) state.health.delete(record.connectorId);
262
+ // 存储成功后才更新内存,避免落盘失败却在当前进程显示为“已安装”。
257
263
  if (config.persistSecrets) await connectionStore.put(record);
264
+ state.connections.set(record.key, record);
265
+ clearRecordHealth(record);
266
+ }
267
+
268
+ async function restorePersistedRecord(key, original) {
269
+ const current = state.connections.get(key);
270
+ if (config.persistSecrets) {
271
+ if (original) await connectionStore.put(original);
272
+ else await connectionStore.delete(key);
273
+ }
274
+ if (original) state.connections.set(key, original);
275
+ else state.connections.delete(key);
276
+ clearRecordHealth(current);
277
+ clearRecordHealth(original);
278
+ }
279
+
280
+ async function restoreProvisionedEntry(record, original) {
281
+ if (original) await provision(ctx, config, original, grantMap(), { failOnStartupError: false });
282
+ else await remove(ctx, config, record);
283
+ }
284
+
285
+ async function provisionUntilReady(record, original, { strictStartup = true } = {}) {
286
+ if (!strictStartup) {
287
+ return provision(ctx, config, record, grantMap(), { failOnStartupError: false });
288
+ }
289
+ const startupTimeoutMs = Math.max(1_000, Number(config.startupTimeoutMs) || DEFAULT_STARTUP_TIMEOUT_MS);
290
+ const timeoutError = Object.assign(
291
+ new Error(`MCP Server 启动与工具同步超过 ${Math.ceil(startupTimeoutMs / 1000)} 秒`),
292
+ { code: 'ETIMEDOUT' },
293
+ );
294
+ let timer;
295
+ const task = provision(ctx, config, record, grantMap(), { failOnStartupError: true });
296
+ const deadline = new Promise((_, reject) => {
297
+ timer = setTimeout(() => reject(timeoutError), startupTimeoutMs);
298
+ timer.unref?.();
299
+ });
300
+ try {
301
+ return await Promise.race([task, deadline]);
302
+ } catch (error) {
303
+ if (error === timeoutError) {
304
+ // loader 可能仍在等待 SDK 请求;它最终成功时必须恢复旧条目/移除新条目,绝不迟到落库。
305
+ task.then(
306
+ () => restoreProvisionedEntry(record, original).catch((rollbackError) => {
307
+ logger.error(`late startup rollback "${record.key}" failed: ${rollbackError.message}`);
308
+ }),
309
+ () => {},
310
+ );
311
+ }
312
+ throw error;
313
+ } finally {
314
+ clearTimeout(timer);
315
+ }
316
+ }
317
+
318
+ async function rollbackProvisionedRecords(records, originals) {
319
+ const failures = [];
320
+ for (const record of [...records].reverse()) {
321
+ try {
322
+ await restoreProvisionedEntry(record, originals.get(record.key));
323
+ } catch (error) {
324
+ failures.push(error);
325
+ logger.error(`connection rollback "${record.key}" failed: ${error.message}`);
326
+ }
327
+ }
328
+ return failures;
329
+ }
330
+
331
+ async function commitConnectionRecords(records, { strictStartup = true } = {}) {
332
+ const originals = new Map(records.map((record) => [record.key, state.connections.get(record.key)]));
333
+ const staged = records.map((record) => {
334
+ const existing = originals.get(record.key);
335
+ return existing ? { ...existing, ...record, updatedAt: Date.now() } : record;
336
+ });
337
+ const provisioned = [];
338
+ let activeRecord;
339
+ try {
340
+ for (const record of staged) {
341
+ activeRecord = record;
342
+ await provisionUntilReady(record, originals.get(record.key), { strictStartup });
343
+ provisioned.push(record);
344
+ }
345
+ } catch (error) {
346
+ await rollbackProvisionedRecords(provisioned, originals);
347
+ const wrapped = new Error(`MCP Server "${activeRecord?.serverName ?? activeRecord?.key ?? 'unknown'}" 未完成启动`, { cause: error });
348
+ wrapped.connectionRecord = activeRecord;
349
+ throw wrapped;
350
+ }
351
+
352
+ try {
353
+ for (const record of staged) await persistRecord(record);
354
+ } catch (error) {
355
+ const rollbackErrors = await rollbackProvisionedRecords(provisioned, originals);
356
+ for (const record of staged) {
357
+ try {
358
+ await restorePersistedRecord(record.key, originals.get(record.key));
359
+ } catch (rollbackError) {
360
+ rollbackErrors.push(rollbackError);
361
+ logger.error(`connection storage rollback "${record.key}" failed: ${rollbackError.message}`);
362
+ }
363
+ }
364
+ const wrapped = new Error('连接记录持久化失败,已回滚 Host 条目', { cause: error });
365
+ wrapped.connectionRecord = activeRecord;
366
+ wrapped.rollbackErrors = rollbackErrors;
367
+ throw wrapped;
368
+ }
369
+ return staged;
258
370
  }
259
371
 
260
- async function upsertRecord(record) {
261
- const existing = state.connections.get(record.key);
262
- const merged = existing ? { ...existing, ...record, updatedAt: Date.now() } : record;
263
- await persistRecord(merged);
264
- await provision(ctx, config, merged, grantMap());
265
- return merged;
372
+ async function upsertRecord(record, options) {
373
+ return (await commitConnectionRecords([record], options))[0];
266
374
  }
267
375
 
268
376
  function grantFailure(record) {
@@ -278,16 +386,70 @@ export async function apply(ctx, config) {
278
386
  return null;
279
387
  }
280
388
 
389
+ function registeredStdioTools(record) {
390
+ if (typeof ctx.tools?.schemas !== 'function') {
391
+ return { supported: false, tools: [] };
392
+ }
393
+ const prefix = `mcp__${record.serverName}__`;
394
+ try {
395
+ const tools = ctx.tools.schemas()
396
+ .filter((schema) => typeof schema?.name === 'string' && schema.name.startsWith(prefix))
397
+ .map((schema) => ({
398
+ name: schema.name.slice(prefix.length),
399
+ publicName: schema.name,
400
+ title: schema.title || schema.name.slice(prefix.length),
401
+ description: schema.description || '',
402
+ }));
403
+ return { supported: true, tools };
404
+ } catch (error) {
405
+ logger.warn(`read registered tools for "${record.serverName}" failed: ${error.message}`);
406
+ return { supported: true, tools: [], error };
407
+ }
408
+ }
409
+
410
+ function readinessResults(records, validationResults = []) {
411
+ return records.map((record, index) => {
412
+ const validated = validationResults[index];
413
+ return {
414
+ ...(validated ?? {}),
415
+ ok: true,
416
+ kind: 'connected',
417
+ serverKey: record.serverKey,
418
+ serverName: record.serverName,
419
+ message: record.transport === 'stdio'
420
+ ? 'Host 已完成 stdio MCP 初始化与首次工具同步'
421
+ : validated?.message,
422
+ };
423
+ });
424
+ }
425
+
426
+ function connectionSetupFailure(error, prefix = '连接失败') {
427
+ const record = error?.connectionRecord;
428
+ const classified = classifyConnectionError(error, { transport: record?.transport });
429
+ return {
430
+ ok: false,
431
+ message: `${prefix}:${classified.message}。未保存连接,请修正后重试。`,
432
+ detail: {
433
+ kind: classified.kind,
434
+ serverKey: record?.serverKey,
435
+ serverName: record?.serverName,
436
+ ...(classified.exitCode !== undefined ? { exitCode: classified.exitCode } : {}),
437
+ },
438
+ };
439
+ }
440
+
281
441
  function healthSummary(connectorId, records, results, checkedAt = Date.now()) {
282
442
  const enabledRecords = records.filter((record) => record.enabled !== false);
283
- const availableServers = results.filter((result) => result.ok).length;
284
- const failedServers = results.length - availableServers;
443
+ const availableServers = results.filter((result) => result.ok && result.kind !== 'managed').length;
444
+ const pendingServers = results.filter((result) => result.ok && result.kind === 'managed').length;
445
+ const failedServers = results.filter((result) => !result.ok).length;
285
446
  const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
286
447
  let connectionState = 'configured';
287
448
  if (records.length === 0) connectionState = 'disconnected';
288
449
  else if (enabledRecords.length === 0) connectionState = 'disabled';
289
- else if (failedServers === 0 && availableServers > 0) connectionState = 'healthy';
290
- else if (availableServers > 0) connectionState = 'degraded';
450
+ else if (failedServers === 0 && pendingServers === 0 && availableServers > 0) connectionState = 'healthy';
451
+ else if (availableServers > 0 && (failedServers > 0 || pendingServers > 0)) connectionState = 'degraded';
452
+ else if (pendingServers > 0 && failedServers === 0) connectionState = 'configured';
291
453
  else if (authFailures > 0) connectionState = 'reauth';
292
454
  else if (failedServers > 0) connectionState = 'unavailable';
293
455
  const labels = {
@@ -301,6 +463,7 @@ export async function apply(ctx, config) {
301
463
  configuredServers: records.length,
302
464
  enabledServers: enabledRecords.length,
303
465
  availableServers,
466
+ pendingServers,
304
467
  failedServers,
305
468
  checkedAt,
306
469
  results,
@@ -451,7 +614,7 @@ export async function apply(ctx, config) {
451
614
  state.grants.set(grantKey, { grant, timer: undefined, refreshPromise: null, needsReauth: false });
452
615
  if (config.persistSecrets) await grantStore.put(grant);
453
616
 
454
- const created = [];
617
+ const records = [];
455
618
  for (const sk of authz.grantedKeys) {
456
619
  const server = connector.servers.find((s) => s.serverKey === sk);
457
620
  if (!server) continue;
@@ -470,9 +633,11 @@ export async function apply(ctx, config) {
470
633
  createdAt: Date.now(),
471
634
  updatedAt: Date.now(),
472
635
  };
473
- await upsertRecord(record);
474
- created.push(record.key);
636
+ records.push(record);
475
637
  }
638
+ const committed = await commitConnectionRecords(records);
639
+ const created = committed.map((record) => record.key);
640
+ cacheHealth(connector.id, committed, readinessResults(committed));
476
641
  scheduleRefresh(grantKey);
477
642
  let retiredGrantCount = 0;
478
643
  for (const previousKey of previousGrantKeys) {
@@ -486,7 +651,9 @@ export async function apply(ctx, config) {
486
651
  } catch (error) {
487
652
  logger.error(`oauth connect failed: ${error.message}`);
488
653
  await pruneOrphanGrants();
489
- return { ok: false, message: `连接失败: ${error.message}` };
654
+ return error?.connectionRecord
655
+ ? connectionSetupFailure(error)
656
+ : { ok: false, message: `连接失败: ${error.message}` };
490
657
  }
491
658
  })();
492
659
  state.oauthConnects.set(connector.id, promise);
@@ -518,8 +685,15 @@ export async function apply(ctx, config) {
518
685
  createdAt: Date.now(),
519
686
  updatedAt: Date.now(),
520
687
  };
521
- await upsertRecord(record);
522
- return { ok: true, message: `已连接 "${connector.name}"`, detail: { key: record.key } };
688
+ const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
689
+ if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
690
+ try {
691
+ const [committed] = await commitConnectionRecords([record]);
692
+ cacheHealth(connector.id, [committed], readinessResults([committed], validation.detail.results));
693
+ return { ok: true, message: `已连接 "${connector.name}"`, detail: { key: committed.key } };
694
+ } catch (error) {
695
+ return connectionSetupFailure(error);
696
+ }
523
697
  }
524
698
 
525
699
  // bearer / api-key 型:目录不含密钥,引导自定义配置
@@ -574,12 +748,9 @@ export async function apply(ctx, config) {
574
748
  const validation = await validateConnectionRecords(records, { timeoutMs: config.requestTimeoutMs });
575
749
  if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
576
750
 
577
- const created = [];
578
- for (const record of records) {
579
- await upsertRecord(record);
580
- created.push(record.key);
581
- }
582
- cacheHealth(connector.id, records, validation.detail.results);
751
+ const committed = await commitConnectionRecords(records);
752
+ const created = committed.map((record) => record.key);
753
+ cacheHealth(connector.id, committed, readinessResults(committed, validation.detail.results));
583
754
  return {
584
755
  ok: true,
585
756
  message: `已配置并连接 "${connector.name}"(${created.length} 个 MCP Server)`,
@@ -587,20 +758,39 @@ export async function apply(ctx, config) {
587
758
  };
588
759
  }
589
760
  const record = buildManualRecord(params);
590
- await upsertRecord(record);
591
- return { ok: true, message: `已配置并连接 "${record.name}"(serverName=${record.serverName})`, detail: { key: record.key, serverName: record.serverName } };
761
+ const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
762
+ if (!validation.ok) return validation;
763
+ const [committed] = await commitConnectionRecords([record]);
764
+ cacheHealth(committed.connectorId, [committed], readinessResults([committed], validation.detail.results));
765
+ return { ok: true, message: `已配置并连接 "${committed.name}"(serverName=${committed.serverName})`, detail: { key: committed.key, serverName: committed.serverName } };
592
766
  } catch (error) {
593
- return { ok: false, message: `配置失败: ${error.message}` };
767
+ return error?.connectionRecord
768
+ ? connectionSetupFailure(error, '配置失败')
769
+ : { ok: false, message: `配置失败: ${error.message}` };
594
770
  }
595
771
  },
596
772
 
597
773
  async importJson(json) {
598
774
  try {
599
775
  const { records, skipped } = normalizeJsonImport(json);
600
- const keys = [];
601
- for (const record of records) {
602
- await upsertRecord(record);
603
- keys.push(record.key);
776
+ const validation = await validateConnectionRecords(records, {
777
+ timeoutMs: config.requestTimeoutMs,
778
+ concurrency: 4,
779
+ });
780
+ if (!validation.ok) return validation;
781
+ const committed = await commitConnectionRecords(records);
782
+ const keys = committed.map((record) => record.key);
783
+ const grouped = new Map();
784
+ for (const record of committed) {
785
+ if (!grouped.has(record.connectorId)) grouped.set(record.connectorId, []);
786
+ grouped.get(record.connectorId).push(record);
787
+ }
788
+ for (const [connectorId, connectorRecords] of grouped) {
789
+ const results = connectorRecords.map((record) => {
790
+ const index = committed.findIndex((item) => item.key === record.key);
791
+ return readinessResults([record], [validation.detail.results[index]])[0];
792
+ });
793
+ cacheHealth(connectorId, connectorRecords, results);
604
794
  }
605
795
  const extra = skipped.length ? `;跳过:${skipped.join('、')}` : '';
606
796
  return {
@@ -609,7 +799,9 @@ export async function apply(ctx, config) {
609
799
  detail: { keys, skipped },
610
800
  };
611
801
  } catch (error) {
612
- return { ok: false, message: `导入失败: ${error.message}` };
802
+ return error?.connectionRecord
803
+ ? connectionSetupFailure(error, '导入失败')
804
+ : { ok: false, message: `导入失败: ${error.message}` };
613
805
  }
614
806
  },
615
807
 
@@ -667,7 +859,9 @@ export async function apply(ctx, config) {
667
859
  authMode: record.auth?.mode ?? 'none',
668
860
  grant: grantInfo,
669
861
  lastError: record.lastError ?? null,
670
- connectionState: recordHealth?.ok ? 'healthy' : recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
862
+ connectionState: recordHealth?.ok && recordHealth.kind !== 'managed'
863
+ ? 'healthy'
864
+ : recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
671
865
  healthCheckedAt: health?.checkedAt ?? null,
672
866
  healthMessage: recordHealth?.message ?? null,
673
867
  });
@@ -694,7 +888,29 @@ export async function apply(ctx, config) {
694
888
  for (const record of enabledRecords) {
695
889
  const failure = grantFailure(record);
696
890
  if (failure) immediateResults.push(failure);
697
- else checkable.push(record);
891
+ else if (record.transport === 'stdio') {
892
+ const registered = registeredStdioTools(record);
893
+ if (registered.tools.length > 0) {
894
+ immediateResults.push({
895
+ ok: true,
896
+ kind: 'connected',
897
+ serverKey: record.serverKey,
898
+ serverName: record.serverName,
899
+ toolCount: registered.tools.length,
900
+ message: `Host 已注册 ${registered.tools.length} 个工具`,
901
+ });
902
+ } else {
903
+ immediateResults.push({
904
+ ok: true,
905
+ kind: 'managed',
906
+ serverKey: record.serverKey,
907
+ serverName: record.serverName,
908
+ message: registered.supported
909
+ ? 'Host 尚未注册该 stdio Server 的工具'
910
+ : '当前 Host 无法读取 stdio 工具注册状态',
911
+ });
912
+ }
913
+ } else checkable.push(record);
698
914
  }
699
915
  let checkedResults = [];
700
916
  if (checkable.length > 0) {
@@ -775,11 +991,26 @@ export async function apply(ctx, config) {
775
991
  async setEnabled(key, enabled) {
776
992
  const record = state.connections.get(key);
777
993
  if (!record) return { ok: false, message: `连接 "${key}" 不存在` };
778
- record.enabled = !!enabled;
779
- await persistRecord(record);
780
- if (record.enabled) await provision(ctx, config, record, grantMap());
781
- else await disable(ctx, config, record);
782
- return { ok: true, message: `已${record.enabled ? '启用' : '停用'} "${record.name}"` };
994
+ const next = { ...record, enabled: !!enabled, updatedAt: Date.now() };
995
+ if (next.enabled) {
996
+ try {
997
+ const [committed] = await commitConnectionRecords([next]);
998
+ if (committed.connectorId) cacheHealth(committed.connectorId, [committed], readinessResults([committed]));
999
+ return { ok: true, message: `已启用 "${committed.name}"` };
1000
+ } catch (error) {
1001
+ return connectionSetupFailure(error, '启用失败');
1002
+ }
1003
+ }
1004
+ await disable(ctx, config, next);
1005
+ try {
1006
+ await persistRecord(next);
1007
+ } catch (error) {
1008
+ await provision(ctx, config, record, grantMap(), { failOnStartupError: false }).catch((rollbackError) => {
1009
+ logger.error(`disable rollback "${record.key}" failed: ${rollbackError.message}`);
1010
+ });
1011
+ throw error;
1012
+ }
1013
+ return { ok: true, message: `已停用 "${next.name}"` };
783
1014
  },
784
1015
 
785
1016
  async disconnect(key, signal) {
@@ -887,19 +1118,30 @@ export async function apply(ctx, config) {
887
1118
  const servers = [];
888
1119
  for (const record of records) {
889
1120
  if (record.transport === 'stdio') {
1121
+ const registered = registeredStdioTools(record);
890
1122
  const snapshot = (connector.toolsSnapshot ?? []).find((item) =>
891
1123
  item.serverKey === record.serverKey || item.serverName === record.serverName);
1124
+ const liveTools = registered.tools;
892
1125
  servers.push({
893
1126
  serverKey: record.serverKey || record.name,
894
1127
  serverName: record.serverName,
895
- ok: true,
1128
+ ok: liveTools.length > 0,
896
1129
  managed: true,
897
- preview: Boolean(snapshot),
898
- tools: (snapshot?.tools ?? []).map((tool) => ({
899
- name: tool.name,
900
- title: tool.title || tool.name,
901
- description: tool.description || '',
902
- })),
1130
+ live: liveTools.length > 0,
1131
+ preview: liveTools.length === 0 && Boolean(snapshot),
1132
+ tools: liveTools.length > 0
1133
+ ? liveTools
1134
+ : (snapshot?.tools ?? []).map((tool) => ({
1135
+ name: tool.name,
1136
+ title: tool.title || tool.name,
1137
+ description: tool.description || '',
1138
+ })),
1139
+ ...(liveTools.length === 0 ? {
1140
+ errorKind: registered.supported ? 'startup' : 'host-version',
1141
+ error: registered.supported
1142
+ ? 'Host 尚未注册该 stdio Server 的工具,请检查启动日志后重试'
1143
+ : '当前 DSH Host 不支持读取工具注册状态,请升级 Host 后重试',
1144
+ } : {}),
903
1145
  });
904
1146
  continue;
905
1147
  }
@@ -938,7 +1180,7 @@ export async function apply(ctx, config) {
938
1180
  const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
939
1181
  const availableServers = servers.filter((server) => server.ok).length;
940
1182
  const failedServers = servers.length - availableServers;
941
- const managedWithoutSnapshot = servers.filter((server) => server.managed && !server.preview).length;
1183
+ const pendingManagedServers = servers.filter((server) => server.managed && !server.ok).length;
942
1184
  const health = cacheHealth(connectorId, records, servers.map((server) => server.ok
943
1185
  ? { ok: true, kind: 'connected', serverKey: server.serverKey, serverName: server.serverName }
944
1186
  : { ok: false, kind: server.errorKind ?? 'network', serverKey: server.serverKey, serverName: server.serverName, message: server.error }));
@@ -948,10 +1190,16 @@ export async function apply(ctx, config) {
948
1190
  ok: availableServers > 0,
949
1191
  message: failedServers > 0
950
1192
  ? `工具加载失败:${failureSummary}${failedServers > 3 ? `;另有 ${failedServers - 3} 个 Server 不可用` : ''}${availableServers > 0 ? `;${availableServers} 个 Server 仍可用` : ''}`
951
- : managedWithoutSnapshot > 0
952
- ? `共 ${servers.length} 个 server;${managedWithoutSnapshot} 个 stdio Server 的工具由 DSH Host 注册,当前目录无工具快照`
953
- : `共 ${servers.length} 个 server,${totalTools} 个工具`,
954
- detail: { connectorId, servers, totalTools, availableServers, failedServers, managedWithoutSnapshot, connectionState: health.connectionState },
1193
+ : `共 ${servers.length} 个 server,${totalTools} 个工具`,
1194
+ detail: {
1195
+ connectorId,
1196
+ servers,
1197
+ totalTools,
1198
+ availableServers,
1199
+ failedServers,
1200
+ pendingManagedServers,
1201
+ connectionState: health.connectionState,
1202
+ },
955
1203
  };
956
1204
  },
957
1205
  };
@@ -24,7 +24,7 @@ export function authHeaders(record, grants) {
24
24
  return headers;
25
25
  }
26
26
 
27
- export function buildEntryConfig(record, grants) {
27
+ export function buildEntryConfig(record, grants, { failOnStartupError = false } = {}) {
28
28
  if (record.transport === 'stdio') {
29
29
  return {
30
30
  transport: 'stdio',
@@ -33,7 +33,7 @@ export function buildEntryConfig(record, grants) {
33
33
  args: record.args ?? [],
34
34
  env: record.env ?? {},
35
35
  cwd: record.cwd || process.cwd(),
36
- failOnStartupError: false,
36
+ failOnStartupError,
37
37
  };
38
38
  }
39
39
  return {
@@ -41,7 +41,7 @@ export function buildEntryConfig(record, grants) {
41
41
  serverName: record.serverName,
42
42
  url: record.url,
43
43
  headers: authHeaders(record, grants),
44
- failOnStartupError: false,
44
+ failOnStartupError,
45
45
  };
46
46
  }
47
47
 
@@ -55,9 +55,9 @@ function hasEntry(ctx, id) {
55
55
  }
56
56
 
57
57
  /** 创建或更新一条 mcp-client 条目(幂等;serverName 不变则工具名不变) */
58
- export async function provision(ctx, config, record, grants) {
58
+ export async function provision(ctx, config, record, grants, { failOnStartupError = false } = {}) {
59
59
  const id = entryIdFor(config, record);
60
- const entryConfig = buildEntryConfig(record, grants);
60
+ const entryConfig = buildEntryConfig(record, grants, { failOnStartupError });
61
61
  const disabled = record.enabled === false;
62
62
  if (hasEntry(ctx, id)) {
63
63
  const existing = ctx.loader.resolve(id);
@@ -47,17 +47,22 @@ function parseMcpPayload(text, contentType = '') {
47
47
  function errorChain(error) {
48
48
  const parts = [];
49
49
  let current = error;
50
- for (let depth = 0; current && depth < 4; depth += 1) {
50
+ for (let depth = 0; current && depth < 8; depth += 1) {
51
51
  parts.push(current.code, current.name, current.message);
52
52
  current = current.cause;
53
53
  }
54
54
  return parts.filter(Boolean).join(' ');
55
55
  }
56
56
 
57
- export function classifyConnectionError(error) {
57
+ export function classifyConnectionError(error, { transport } = {}) {
58
58
  const text = errorChain(error);
59
59
  if (/AbortError|timeout|timed out|ETIMEDOUT/i.test(text)) {
60
- return { kind: 'timeout', message: '连接超时,请检查网络、VPN/专线或服务状态' };
60
+ return {
61
+ kind: 'timeout',
62
+ message: transport === 'stdio'
63
+ ? '本地 MCP Server 启动或工具同步超时,请检查运行环境、命令和 Host 日志'
64
+ : '连接超时,请检查网络、VPN/专线或服务状态',
65
+ };
61
66
  }
62
67
  if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(text)) {
63
68
  return { kind: 'dns', message: '域名解析失败,请检查当前网络 DNS' };
@@ -68,6 +73,23 @@ export function classifyConnectionError(error) {
68
73
  if (/ECONNREFUSED/i.test(text)) {
69
74
  return { kind: 'refused', message: '目标服务拒绝连接,请检查 MCP URL 和服务状态' };
70
75
  }
76
+ if (/ENOENT|command not found|spawn[^\n]*not found/i.test(text)) {
77
+ return { kind: 'process-not-found', message: '本地 MCP 启动命令不存在,请先安装所需运行时或检查 command 配置' };
78
+ }
79
+ if (/exit(?:ed)?\s+(?:with\s+)?code|process[^\n]*(?:closed|exited)|subprocess[^\n]*(?:closed|exited)/i.test(text)) {
80
+ const exitCode = text.match(/(?:exit(?:ed)?\s+(?:with\s+)?code|code)\s*[:=]?\s*(-?\d+)/i)?.[1];
81
+ return {
82
+ kind: 'process-exit',
83
+ message: `本地 MCP 进程在初始化期间退出${exitCode !== undefined ? `(退出码 ${exitCode})` : ''},请检查命令、参数和 Host 日志`,
84
+ ...(exitCode !== undefined ? { exitCode: Number(exitCode) } : {}),
85
+ };
86
+ }
87
+ if (/initial (?:connection|tool synchronization)|tool synchronization failed|initialize(?: handshake)? failed/i.test(text)) {
88
+ return { kind: 'startup', message: 'MCP 初始化或首次工具同步失败,请检查服务配置和 Host 日志' };
89
+ }
90
+ if (transport === 'stdio') {
91
+ return { kind: 'startup', message: '本地 MCP Server 启动或初始化失败,请检查命令、参数、运行环境和 Host 日志' };
92
+ }
71
93
  return { kind: 'network', message: '无法连接 MCP 服务,请检查网络和 MCP URL' };
72
94
  }
73
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "General-purpose MCP connector, connection manager, plugin extension, and integration marketplace for DeepSeek Harness, initiated and maintained by Qichacha/QCC.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/ui/index.html CHANGED
@@ -455,6 +455,16 @@
455
455
  <script>
456
456
  (function () {
457
457
  const API = '/mcp-connector/api';
458
+ const API_TIMEOUTS = {
459
+ connect: 145_000,
460
+ configure: 145_000,
461
+ importJson: 145_000,
462
+ installFromUrl: 145_000,
463
+ setEnabled: 130_000,
464
+ toolsList: 20_000,
465
+ healthCheck: 10_000,
466
+ };
467
+ const DEFAULT_API_TIMEOUT_MS = 30_000;
458
468
  const $ = (sel) => document.querySelector(sel);
459
469
  const main = $('#main');
460
470
  const toast = $('#toast');
@@ -505,18 +515,31 @@
505
515
  let promptRequestTimer = null;
506
516
  let initialHealthCheckStarted = false;
507
517
 
508
- async function call(method, params) {
518
+ async function call(method, params, { timeoutMs: timeoutOverride } = {}) {
519
+ const controller = new AbortController();
520
+ const timeoutMs = timeoutOverride || API_TIMEOUTS[method] || DEFAULT_API_TIMEOUT_MS;
521
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
509
522
  try {
510
523
  const res = await fetch(API, {
511
524
  method: 'POST',
512
525
  headers: { 'content-type': 'application/json' },
513
526
  body: JSON.stringify({ method, params }),
527
+ signal: controller.signal,
514
528
  });
515
529
  const data = await res.json();
516
530
  if (!res.ok && data.ok !== false) return { ok: false, message: `请求失败(HTTP ${res.status})` };
517
531
  return data;
518
532
  } catch (error) {
533
+ if (controller.signal.aborted) {
534
+ return {
535
+ ok: false,
536
+ message: `请求超时(${Math.ceil(timeoutMs / 1000)} 秒),请检查服务状态和 Host 日志后重试`,
537
+ detail: { kind: 'timeout' },
538
+ };
539
+ }
519
540
  return { ok: false, message: `无法连接 MCP 连接器服务:${error?.message || error}` };
541
+ } finally {
542
+ clearTimeout(timer);
520
543
  }
521
544
  }
522
545
 
@@ -676,7 +699,7 @@
676
699
  return `<div class="tool-item"><div class="tool-name-row"><div class="tool-name">${esc(tool.name || tool.title || '未命名工具')}</div>${deprecated ? '<span class="tool-deprecated">已弃用</span>' : ''}</div><div class="tool-description${description ? '' : ' empty'}">${esc(description || '暂无描述')}</div></div>`;
677
700
  }).join('')
678
701
  : server.managed
679
- ? '<div class="tools-loading">stdio 工具已由 DSH Host 注册;当前市场详情没有目录快照,可直接在对话中调用。</div>'
702
+ ? '<div class="tools-loading">Host 已完成 stdio 初始化,但该 Server 当前没有可展示的工具。</div>'
680
703
  : '<div class="tools-loading">暂无工具</div>';
681
704
  return `<div class="tools-server"><div class="tools-server-header">${esc(label)} <span class="count">· 显示 ${tools.length} / ${matched.length} 个</span></div>${items}</div>`;
682
705
  }).filter(Boolean);
@@ -1038,7 +1061,8 @@
1038
1061
 
1039
1062
  window.__mcp = {
1040
1063
  async connect(connectorId) {
1041
- const r = await call('connect', { connectorId });
1064
+ const timeoutMs = marketItems[connectorId]?.authMode === 'oauth2-pkce' ? 310_000 : undefined;
1065
+ const r = await call('connect', { connectorId }, { timeoutMs });
1042
1066
  showToast(r.message, r.ok);
1043
1067
  if (r.ok) {
1044
1068
  await call('healthCheck', { connectorId });
@@ -1320,7 +1344,7 @@
1320
1344
  }
1321
1345
  if (detailFailureState === 'reauth' && detailConnector?.authMode === 'oauth2-pkce') {
1322
1346
  const id = detailConnector.id;
1323
- const r = await call('connect', { connectorId: id });
1347
+ const r = await call('connect', { connectorId: id }, { timeoutMs: 310_000 });
1324
1348
  showToast(r.message, r.ok);
1325
1349
  if (r.ok) {
1326
1350
  await call('healthCheck', { connectorId: id });