dsh-mcp-connector 0.2.21 → 0.2.23

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/lib/index.js CHANGED
@@ -15,7 +15,10 @@ 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,
20
+ DEFAULT_REFRESH_RETRY_BASE_MS,
21
+ DEFAULT_REFRESH_RETRY_MAX_MS,
19
22
  DEFAULT_CATALOG_TTL_MS,
20
23
  } from './constants.js';
21
24
  import { defineConnectorDomain, ConnectionStore, GrantStore, CatalogStore } from './stores.js';
@@ -24,7 +27,7 @@ import { loadBundledCatalog, fetchRemoteCatalogWithFallback, mergeCatalog, listC
24
27
  import { oauthAuthorize } from './connectors/oauth-connector.js';
25
28
  import { buildManualRecord } from './connectors/manual-connector.js';
26
29
  import { normalizeJsonImport } from './connectors/json-connector.js';
27
- import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken } from './oauth.js';
30
+ import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken, extractTokenResources } from './oauth.js';
28
31
  import { provision, disable, remove, entryIdFor } from './mcp-provision.js';
29
32
  import { classifyConnectionError, validateConnectionRecords } from './mcp-validation.js';
30
33
  import { listMcpTools, McpHttpError } from './mcp-http.js';
@@ -32,9 +35,21 @@ import { registerTools } from './tools.js';
32
35
  import { mountWebRoutes } from './web.js';
33
36
  import { assertSafeUrl } from './util.js';
34
37
  import { readLegacyGrantCandidates, planLegacyMigration, toConnectorGrant, toConnectionRecords } from './migration.js';
38
+ import { classifyRefreshFailure, refreshRetryDelay } from './grant-lifecycle.js';
35
39
 
36
40
  export const name = 'mcp-connector';
37
41
 
42
+ const LEGACY_OAUTH_PLUGINS = [
43
+ {
44
+ id: 'qcc-mcp-oauth',
45
+ servers: ['qcc-company', 'qcc-risk', 'qcc-ipr', 'qcc-operation', 'qcc-history', 'qcc-executive'],
46
+ },
47
+ {
48
+ id: 'qcc-legal-mcp-oauth',
49
+ servers: ['qcc-regulation', 'qcc-case'],
50
+ },
51
+ ];
52
+
38
53
  export const inject = ['tools', 'storageDomain', 'loader'];
39
54
 
40
55
  export const Config = z.object({
@@ -52,8 +67,14 @@ export const Config = z.object({
52
67
  callbackTimeoutMs: z.number().default(300_000),
53
68
  /** 网络请求超时(ms) */
54
69
  requestTimeoutMs: z.number().default(DEFAULT_REQUEST_TIMEOUT_MS),
70
+ /** 首次启动并完成 MCP initialize + tools/list 的超时(ms) */
71
+ startupTimeoutMs: z.number().default(DEFAULT_STARTUP_TIMEOUT_MS),
55
72
  /** 提前刷新阈值(ms) */
56
73
  refreshSkewMs: z.number().default(DEFAULT_REFRESH_SKEW_MS),
74
+ /** 刷新暂时失败后的首次重试间隔(ms) */
75
+ refreshRetryBaseMs: z.number().default(DEFAULT_REFRESH_RETRY_BASE_MS),
76
+ /** 刷新暂时失败后的最大重试间隔(ms) */
77
+ refreshRetryMaxMs: z.number().default(DEFAULT_REFRESH_RETRY_MAX_MS),
57
78
  /** 是否自动打开浏览器(false = 仅打印授权 URL) */
58
79
  openBrowser: z.boolean().default(true),
59
80
  /** 授权账号标识(预留多账号) */
@@ -78,9 +99,10 @@ export async function apply(ctx, config) {
78
99
  dynamic: new Map(), // URL 安装的临时连接器
79
100
  overrides: new Map(), // id -> { published?, featured? }
80
101
  connections: new Map(), // key -> ConnectionRecord
81
- grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth }
102
+ grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth, refreshFailure* }
82
103
  health: new Map(), // connectorId -> 最近一次主动连通性检查摘要(不持久化)
83
- oauthConnects: new Map(), // connectorId -> 正在进行的 OAuth 授权 Promise(防重复弹窗/重复 grant)
104
+ oauthConnects: new Map(), // sharingKey -> { promise, requestedConnectorIds }(防同 issuer 重复弹窗)
105
+ warnedLegacyConflicts: new Set(),
84
106
  };
85
107
 
86
108
  /* ───────────────────────── 目录加载 ───────────────────────── */
@@ -151,8 +173,91 @@ export async function apply(ctx, config) {
151
173
  await recomputeMerged();
152
174
  }
153
175
 
176
+ /* ───────────────────────── 旧 OAuth 插件冲突 ───────────────────────── */
177
+
178
+ function loaderEntryActive(id) {
179
+ try {
180
+ const entry = ctx.loader.resolve(id);
181
+ return !!entry && entry.disabled !== true && entry.options?.disabled !== true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ function activeLegacyPlugin(definition) {
188
+ if (loaderEntryActive(definition.id)) return definition.id;
189
+ // 兼容旧插件本体已经移除、但其动态 mcp-client 条目仍残留的配置树。
190
+ return definition.servers.some((serverName) => loaderEntryActive(`mcp-${serverName}`))
191
+ ? definition.id
192
+ : null;
193
+ }
194
+
195
+ function legacyConflictForServer(serverName) {
196
+ for (const definition of LEGACY_OAUTH_PLUGINS) {
197
+ if (definition.servers.includes(serverName) && activeLegacyPlugin(definition)) return definition.id;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function legacyConflictForRecord(record) {
203
+ const pluginId = legacyConflictForServer(record?.serverName);
204
+ if (!pluginId) return null;
205
+ return {
206
+ pluginId,
207
+ serverName: record.serverName,
208
+ message: `检测到旧插件 ${pluginId} 仍在管理同名服务 ${record.serverName};请停用旧插件并重启 DSH`,
209
+ };
210
+ }
211
+
212
+ function legacyConflictForConnector(connector) {
213
+ for (const server of connector?.servers ?? []) {
214
+ const pluginId = legacyConflictForServer(server.serverName);
215
+ if (pluginId) {
216
+ return {
217
+ pluginId,
218
+ serverName: server.serverName,
219
+ message: `检测到旧插件 ${pluginId} 仍在管理同名服务 ${server.serverName};为避免凭据覆盖,请停用旧插件并重启 DSH 后再连接`,
220
+ };
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function warnLegacyConflict(conflict) {
227
+ if (!conflict || state.warnedLegacyConflicts.has(conflict.pluginId)) return;
228
+ state.warnedLegacyConflicts.add(conflict.pluginId);
229
+ logger.warn(`legacy OAuth plugin conflict: ${conflict.message}`);
230
+ }
231
+
232
+ function detectLegacyPluginConflicts() {
233
+ const conflicts = [];
234
+ for (const definition of LEGACY_OAUTH_PLUGINS) {
235
+ if (!activeLegacyPlugin(definition)) continue;
236
+ const conflict = {
237
+ pluginId: definition.id,
238
+ message: `检测到旧插件 ${definition.id} 仍处于启用状态;它会与 MCP连接器重复管理企查查 Server`,
239
+ };
240
+ conflicts.push(conflict);
241
+ warnLegacyConflict(conflict);
242
+ }
243
+ return conflicts;
244
+ }
245
+
154
246
  /* ───────────────────────── grant 刷新 ───────────────────────── */
155
247
 
248
+ function grantRuntime(grant) {
249
+ return {
250
+ grant,
251
+ timer: undefined,
252
+ refreshPromise: null,
253
+ needsReauth: false,
254
+ refreshFailureKind: null,
255
+ refreshFailureCount: 0,
256
+ lastRefreshError: null,
257
+ refreshRetryAt: null,
258
+ };
259
+ }
260
+
156
261
  function grantMap() {
157
262
  const map = new Map();
158
263
  for (const [key, g] of state.grants) map.set(key, g.grant);
@@ -161,10 +266,16 @@ export async function apply(ctx, config) {
161
266
 
162
267
  async function refreshGrant(grantKey) {
163
268
  const entry = state.grants.get(grantKey);
164
- if (!entry || !entry.grant.refreshToken) throw new Error('no refresh token available');
269
+ if (!entry || !entry.grant.refreshToken) {
270
+ const error = new Error('no refresh token available');
271
+ error.code = 'missing_refresh_token';
272
+ throw error;
273
+ }
165
274
  if (entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
166
275
  && entry.grant.clientSecretExpiresAt * 1000 <= Date.now()) {
167
- throw new Error('OAuth dynamic client secret expired');
276
+ const error = new Error('OAuth dynamic client secret expired');
277
+ error.code = 'client_secret_expired';
278
+ throw error;
168
279
  }
169
280
  if (entry.refreshPromise) return entry.refreshPromise;
170
281
  entry.refreshPromise = (async () => {
@@ -174,22 +285,39 @@ export async function apply(ctx, config) {
174
285
  clientSecret: entry.grant.clientSecret,
175
286
  tokenEndpointAuthMethod: entry.grant.tokenEndpointAuthMethod ?? 'none',
176
287
  refreshToken: entry.grant.refreshToken,
177
- resource: entry.grant.authorizedResources[0],
288
+ // 多资源共享 Grant 不应在刷新时被第一个 resource 意外收窄。
289
+ resource: entry.grant.authorizedResources.length === 1 ? entry.grant.authorizedResources[0] : undefined,
178
290
  scope: entry.grant.scope,
179
291
  timeoutMs: config.requestTimeoutMs,
180
292
  });
293
+ const refreshedResources = extractTokenResources(token.accessToken);
181
294
  const next = {
182
295
  ...entry.grant,
183
296
  accessToken: token.accessToken,
184
- accessTokenExpiresAt: Date.now() + token.expiresIn * 1000 - config.refreshSkewMs,
297
+ // 持久化真实过期时间;提前刷新只用于定时调度,不能冒充 Token 已过期。
298
+ accessTokenExpiresAt: Date.now() + token.expiresIn * 1000,
185
299
  refreshToken: token.refreshToken ?? entry.grant.refreshToken,
300
+ authorizedResources: refreshedResources?.length ? refreshedResources : entry.grant.authorizedResources,
301
+ updatedAt: Date.now(),
186
302
  };
187
303
  entry.grant = next;
188
304
  entry.needsReauth = false;
305
+ entry.refreshFailureKind = null;
306
+ entry.refreshFailureCount = 0;
307
+ entry.lastRefreshError = null;
308
+ entry.refreshRetryAt = null;
189
309
  state.grants.set(grantKey, entry);
190
310
  if (config.persistSecrets) await grantStore.put(next);
191
311
  for (const [key, record] of state.connections) {
192
- if (record.auth?.grantKey === grantKey) await provision(ctx, config, record, grantMap());
312
+ if (record.auth?.grantKey !== grantKey || record.enabled === false) continue;
313
+ const conflict = legacyConflictForRecord(record);
314
+ if (conflict) {
315
+ warnLegacyConflict(conflict);
316
+ continue;
317
+ }
318
+ await provision(ctx, config, record, grantMap()).catch((error) => {
319
+ logger.warn(`re-provision "${key}" after OAuth refresh failed: ${error.message}`);
320
+ });
193
321
  }
194
322
  scheduleRefresh(grantKey);
195
323
  return next;
@@ -201,20 +329,61 @@ export async function apply(ctx, config) {
201
329
  }
202
330
  }
203
331
 
204
- function scheduleRefresh(grantKey) {
332
+ function scheduleRefresh(grantKey, { delayMs } = {}) {
205
333
  const entry = state.grants.get(grantKey);
206
334
  if (!entry) return;
207
335
  if (entry.timer) clearTimeout(entry.timer);
208
- const delay = Math.max(0, entry.grant.accessTokenExpiresAt - Date.now());
336
+ const tokenLifetime = Math.max(0, entry.grant.accessTokenExpiresAt - (entry.grant.updatedAt || Date.now()));
337
+ const effectiveSkew = Math.min(
338
+ Math.max(0, config.refreshSkewMs),
339
+ Math.max(1_000, Math.floor(tokenLifetime * 0.1)),
340
+ );
341
+ const delay = delayMs === undefined
342
+ ? Math.max(0, entry.grant.accessTokenExpiresAt - effectiveSkew - Date.now())
343
+ : Math.max(0, delayMs);
209
344
  entry.timer = setTimeout(() => {
210
- refreshGrant(grantKey).catch((error) => {
211
- entry.needsReauth = true;
212
- logger.warn(`grant ${grantKey} refresh failed: ${error.message}`);
213
- });
345
+ entry.timer = undefined;
346
+ refreshGrantWithRecovery(grantKey, 'scheduled').catch(() => {});
214
347
  }, delay);
215
348
  entry.timer.unref?.();
216
349
  }
217
350
 
351
+ function recordRefreshFailure(grantKey, error, phase) {
352
+ const entry = state.grants.get(grantKey);
353
+ if (!entry) return classifyRefreshFailure(error);
354
+ const failure = classifyRefreshFailure(error);
355
+ entry.refreshFailureCount = (entry.refreshFailureCount ?? 0) + 1;
356
+ entry.refreshFailureKind = failure.kind;
357
+ entry.lastRefreshError = failure.message;
358
+ entry.needsReauth = failure.permanent;
359
+ entry.refreshRetryAt = null;
360
+
361
+ const status = failure.httpStatus ? ` http=${failure.httpStatus}` : '';
362
+ const action = failure.permanent ? 'reauthorization required' : 'automatic retry scheduled';
363
+ logger.warn(`OAuth grant refresh failed phase=${phase} grant=${grantKey} code=${failure.code}${status} classification=${failure.kind}: ${failure.message}; ${action}`);
364
+
365
+ if (!failure.permanent) {
366
+ const delay = refreshRetryDelay(
367
+ entry.refreshFailureCount,
368
+ config.refreshRetryBaseMs,
369
+ config.refreshRetryMaxMs,
370
+ );
371
+ entry.refreshRetryAt = Date.now() + delay;
372
+ scheduleRefresh(grantKey, { delayMs: delay });
373
+ }
374
+ return failure;
375
+ }
376
+
377
+ async function refreshGrantWithRecovery(grantKey, phase) {
378
+ try {
379
+ return await refreshGrant(grantKey);
380
+ } catch (error) {
381
+ // 多个调用方可能等待同一个 refreshPromise;同一次失败只能记一次并调度一个重试。
382
+ if (!error.refreshFailure) error.refreshFailure = recordRefreshFailure(grantKey, error, phase);
383
+ throw error;
384
+ }
385
+ }
386
+
218
387
  async function retireGrantIfUnused(grantKey, { revoke = false } = {}) {
219
388
  if ([...state.connections.values()].some((record) => record.auth?.grantKey === grantKey)) return false;
220
389
  const entry = state.grants.get(grantKey);
@@ -251,48 +420,225 @@ export async function apply(ctx, config) {
251
420
 
252
421
  /* ───────────────────────── 连接记录落库 / 挂载 ───────────────────────── */
253
422
 
423
+ function clearRecordHealth(record) {
424
+ if (record?.connectorId) state.health.delete(record.connectorId);
425
+ }
426
+
254
427
  async function persistRecord(record) {
255
- state.connections.set(record.key, record);
256
- if (record.connectorId) state.health.delete(record.connectorId);
428
+ // 存储成功后才更新内存,避免落盘失败却在当前进程显示为“已安装”。
257
429
  if (config.persistSecrets) await connectionStore.put(record);
430
+ state.connections.set(record.key, record);
431
+ clearRecordHealth(record);
258
432
  }
259
433
 
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;
434
+ async function restorePersistedRecord(key, original) {
435
+ const current = state.connections.get(key);
436
+ if (config.persistSecrets) {
437
+ if (original) await connectionStore.put(original);
438
+ else await connectionStore.delete(key);
439
+ }
440
+ if (original) state.connections.set(key, original);
441
+ else state.connections.delete(key);
442
+ clearRecordHealth(current);
443
+ clearRecordHealth(original);
444
+ }
445
+
446
+ async function restoreProvisionedEntry(record, original) {
447
+ if (original) await provision(ctx, config, original, grantMap(), { failOnStartupError: false });
448
+ else await remove(ctx, config, record);
449
+ }
450
+
451
+ async function provisionUntilReady(record, original, { strictStartup = true } = {}) {
452
+ if (!strictStartup) {
453
+ return provision(ctx, config, record, grantMap(), { failOnStartupError: false });
454
+ }
455
+ const startupTimeoutMs = Math.max(1_000, Number(config.startupTimeoutMs) || DEFAULT_STARTUP_TIMEOUT_MS);
456
+ const timeoutError = Object.assign(
457
+ new Error(`MCP Server 启动与工具同步超过 ${Math.ceil(startupTimeoutMs / 1000)} 秒`),
458
+ { code: 'ETIMEDOUT' },
459
+ );
460
+ let timer;
461
+ const task = provision(ctx, config, record, grantMap(), { failOnStartupError: true });
462
+ const deadline = new Promise((_, reject) => {
463
+ timer = setTimeout(() => reject(timeoutError), startupTimeoutMs);
464
+ timer.unref?.();
465
+ });
466
+ try {
467
+ return await Promise.race([task, deadline]);
468
+ } catch (error) {
469
+ if (error === timeoutError) {
470
+ // loader 可能仍在等待 SDK 请求;它最终成功时必须恢复旧条目/移除新条目,绝不迟到落库。
471
+ task.then(
472
+ () => restoreProvisionedEntry(record, original).catch((rollbackError) => {
473
+ logger.error(`late startup rollback "${record.key}" failed: ${rollbackError.message}`);
474
+ }),
475
+ () => {},
476
+ );
477
+ }
478
+ throw error;
479
+ } finally {
480
+ clearTimeout(timer);
481
+ }
482
+ }
483
+
484
+ async function rollbackProvisionedRecords(records, originals) {
485
+ const failures = [];
486
+ for (const record of [...records].reverse()) {
487
+ try {
488
+ await restoreProvisionedEntry(record, originals.get(record.key));
489
+ } catch (error) {
490
+ failures.push(error);
491
+ logger.error(`connection rollback "${record.key}" failed: ${error.message}`);
492
+ }
493
+ }
494
+ return failures;
495
+ }
496
+
497
+ async function commitConnectionRecords(records, { strictStartup = true } = {}) {
498
+ const originals = new Map(records.map((record) => [record.key, state.connections.get(record.key)]));
499
+ const staged = records.map((record) => {
500
+ const existing = originals.get(record.key);
501
+ return existing ? { ...existing, ...record, updatedAt: Date.now() } : record;
502
+ });
503
+ const provisioned = [];
504
+ let activeRecord;
505
+ try {
506
+ for (const record of staged) {
507
+ activeRecord = record;
508
+ await provisionUntilReady(record, originals.get(record.key), { strictStartup });
509
+ provisioned.push(record);
510
+ }
511
+ } catch (error) {
512
+ await rollbackProvisionedRecords(provisioned, originals);
513
+ const wrapped = new Error(`MCP Server "${activeRecord?.serverName ?? activeRecord?.key ?? 'unknown'}" 未完成启动`, { cause: error });
514
+ wrapped.connectionRecord = activeRecord;
515
+ throw wrapped;
516
+ }
517
+
518
+ try {
519
+ for (const record of staged) await persistRecord(record);
520
+ } catch (error) {
521
+ const rollbackErrors = await rollbackProvisionedRecords(provisioned, originals);
522
+ for (const record of staged) {
523
+ try {
524
+ await restorePersistedRecord(record.key, originals.get(record.key));
525
+ } catch (rollbackError) {
526
+ rollbackErrors.push(rollbackError);
527
+ logger.error(`connection storage rollback "${record.key}" failed: ${rollbackError.message}`);
528
+ }
529
+ }
530
+ const wrapped = new Error('连接记录持久化失败,已回滚 Host 条目', { cause: error });
531
+ wrapped.connectionRecord = activeRecord;
532
+ wrapped.rollbackErrors = rollbackErrors;
533
+ throw wrapped;
534
+ }
535
+ return staged;
536
+ }
537
+
538
+ async function upsertRecord(record, options) {
539
+ return (await commitConnectionRecords([record], options))[0];
266
540
  }
267
541
 
268
542
  function grantFailure(record) {
269
543
  if (record.auth?.mode !== 'oauth') return null;
544
+ const conflict = legacyConflictForRecord(record);
545
+ if (conflict) {
546
+ warnLegacyConflict(conflict);
547
+ return { ok: false, kind: 'conflict', serverKey: record.serverKey, serverName: record.serverName, message: conflict.message };
548
+ }
270
549
  const grantKey = record.auth.grantKey;
271
550
  const entry = grantKey ? state.grants.get(grantKey) : null;
272
551
  if (!entry) return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权缺失,请重新授权' };
273
552
  const clientSecretExpired = entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
274
553
  && entry.grant.clientSecretExpiresAt * 1000 <= Date.now();
275
- if (entry.needsReauth || clientSecretExpired || entry.grant.accessTokenExpiresAt <= Date.now()) {
554
+ if (entry.needsReauth || clientSecretExpired) {
276
555
  return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权已过期或刷新失败,请重新授权' };
277
556
  }
557
+ if (entry.grant.accessTokenExpiresAt <= Date.now()) {
558
+ return {
559
+ ok: false,
560
+ kind: 'refresh',
561
+ serverKey: record.serverKey,
562
+ serverName: record.serverName,
563
+ message: entry.refreshPromise
564
+ ? 'OAuth Access Token 已到期,正在自动刷新'
565
+ : `OAuth 刷新暂时失败,正在自动重试${entry.lastRefreshError ? `:${entry.lastRefreshError}` : ''}`,
566
+ };
567
+ }
278
568
  return null;
279
569
  }
280
570
 
571
+ function registeredStdioTools(record) {
572
+ if (typeof ctx.tools?.schemas !== 'function') {
573
+ return { supported: false, tools: [] };
574
+ }
575
+ const prefix = `mcp__${record.serverName}__`;
576
+ try {
577
+ const tools = ctx.tools.schemas()
578
+ .filter((schema) => typeof schema?.name === 'string' && schema.name.startsWith(prefix))
579
+ .map((schema) => ({
580
+ name: schema.name.slice(prefix.length),
581
+ publicName: schema.name,
582
+ title: schema.title || schema.name.slice(prefix.length),
583
+ description: schema.description || '',
584
+ }));
585
+ return { supported: true, tools };
586
+ } catch (error) {
587
+ logger.warn(`read registered tools for "${record.serverName}" failed: ${error.message}`);
588
+ return { supported: true, tools: [], error };
589
+ }
590
+ }
591
+
592
+ function readinessResults(records, validationResults = []) {
593
+ return records.map((record, index) => {
594
+ const validated = validationResults[index];
595
+ return {
596
+ ...(validated ?? {}),
597
+ ok: true,
598
+ kind: 'connected',
599
+ serverKey: record.serverKey,
600
+ serverName: record.serverName,
601
+ message: record.transport === 'stdio'
602
+ ? 'Host 已完成 stdio MCP 初始化与首次工具同步'
603
+ : validated?.message,
604
+ };
605
+ });
606
+ }
607
+
608
+ function connectionSetupFailure(error, prefix = '连接失败') {
609
+ const record = error?.connectionRecord;
610
+ const classified = classifyConnectionError(error, { transport: record?.transport });
611
+ return {
612
+ ok: false,
613
+ message: `${prefix}:${classified.message}。未保存连接,请修正后重试。`,
614
+ detail: {
615
+ kind: classified.kind,
616
+ serverKey: record?.serverKey,
617
+ serverName: record?.serverName,
618
+ ...(classified.exitCode !== undefined ? { exitCode: classified.exitCode } : {}),
619
+ },
620
+ };
621
+ }
622
+
281
623
  function healthSummary(connectorId, records, results, checkedAt = Date.now()) {
282
624
  const enabledRecords = records.filter((record) => record.enabled !== false);
283
- const availableServers = results.filter((result) => result.ok).length;
284
- const failedServers = results.length - availableServers;
625
+ const availableServers = results.filter((result) => result.ok && result.kind !== 'managed').length;
626
+ const pendingServers = results.filter((result) => result.ok && result.kind === 'managed').length;
627
+ const failedServers = results.filter((result) => !result.ok).length;
285
628
  const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
629
+ const refreshFailures = results.filter((result) => !result.ok && result.kind === 'refresh').length;
286
630
  let connectionState = 'configured';
287
631
  if (records.length === 0) connectionState = 'disconnected';
288
632
  else if (enabledRecords.length === 0) connectionState = 'disabled';
289
- else if (failedServers === 0 && availableServers > 0) connectionState = 'healthy';
290
- else if (availableServers > 0) connectionState = 'degraded';
633
+ else if (failedServers === 0 && pendingServers === 0 && availableServers > 0) connectionState = 'healthy';
634
+ else if (availableServers > 0 && (failedServers > 0 || pendingServers > 0)) connectionState = 'degraded';
635
+ else if (pendingServers > 0 && failedServers === 0) connectionState = 'configured';
291
636
  else if (authFailures > 0) connectionState = 'reauth';
637
+ else if (refreshFailures > 0) connectionState = 'recovering';
292
638
  else if (failedServers > 0) connectionState = 'unavailable';
293
639
  const labels = {
294
640
  disconnected: '未连接', configured: '已配置', disabled: '已停用', healthy: '已连接',
295
- degraded: '部分异常', reauth: '需重新授权', unavailable: '连接异常',
641
+ degraded: '部分异常', reauth: '需重新授权', recovering: '自动重试中', unavailable: '连接异常',
296
642
  };
297
643
  return {
298
644
  connectorId,
@@ -301,6 +647,7 @@ export async function apply(ctx, config) {
301
647
  configuredServers: records.length,
302
648
  enabledServers: enabledRecords.length,
303
649
  availableServers,
650
+ pendingServers,
304
651
  failedServers,
305
652
  checkedAt,
306
653
  results,
@@ -356,6 +703,156 @@ export async function apply(ctx, config) {
356
703
  return env;
357
704
  }
358
705
 
706
+ /* ───────────────────────── OAuth Grant 共享 ───────────────────────── */
707
+
708
+ function canonicalIssuer(value) {
709
+ try {
710
+ const url = new URL(value);
711
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
712
+ } catch {
713
+ return String(value ?? '').replace(/\/+$/, '');
714
+ }
715
+ }
716
+
717
+ function oauthResourceOrigins(connector) {
718
+ return [...new Set((connector.servers ?? []).map((server) => {
719
+ try { return new URL(server.url).origin; } catch { return ''; }
720
+ }).filter(Boolean))].sort();
721
+ }
722
+
723
+ function oauthSharingConnectors(connector) {
724
+ if (connector.auth?.grantSharing !== 'issuer' || !connector.auth.issuer) return [connector];
725
+ const issuer = canonicalIssuer(connector.auth.issuer);
726
+ const resourceOrigins = new Set(oauthResourceOrigins(connector));
727
+ const matches = state.merged.filter((candidate) => (
728
+ candidate.published !== false
729
+ && candidate.auth?.mode === 'oauth2-pkce'
730
+ && candidate.auth.grantSharing === 'issuer'
731
+ && canonicalIssuer(candidate.auth.issuer) === issuer
732
+ && candidate.auth.scope === connector.auth.scope
733
+ && candidate.auth.tokenEndpointAuthMethod === connector.auth.tokenEndpointAuthMethod
734
+ // 防止同 issuer 的第三方描述把共享 Bearer Token 聚合到其他资源域名。
735
+ && oauthResourceOrigins(candidate).every((origin) => resourceOrigins.has(origin))
736
+ ));
737
+ // 授权请求的 resource 必须优先使用用户点击的卡片;其余同组 Server 只用于识别共享范围。
738
+ return [connector, ...matches.filter((candidate) => candidate.id !== connector.id)];
739
+ }
740
+
741
+ function oauthSharingKey(connector) {
742
+ if (connector.auth?.grantSharing !== 'issuer' || !connector.auth.issuer) return `connector:${connector.id}`;
743
+ return `issuer:${config.account}:${canonicalIssuer(connector.auth.issuer)}:${connector.auth.scope}:${connector.auth.tokenEndpointAuthMethod}:${oauthResourceOrigins(connector).join(',')}`;
744
+ }
745
+
746
+ function oauthAuthorizationDescriptor(connector, sharingConnectors) {
747
+ if (sharingConnectors.length === 1) return connector;
748
+ const seen = new Set();
749
+ const servers = [];
750
+ for (const candidate of sharingConnectors) {
751
+ for (const server of candidate.servers) {
752
+ if (seen.has(server.url)) continue;
753
+ seen.add(server.url);
754
+ servers.push({ ...server, serverKey: `${candidate.id}:${server.serverKey}` });
755
+ }
756
+ }
757
+ return { ...connector, servers };
758
+ }
759
+
760
+ function oauthRecordsFor(connectors, authorizedResources, grantKey) {
761
+ const granted = new Set(authorizedResources);
762
+ const now = Date.now();
763
+ const records = [];
764
+ for (const connector of connectors) {
765
+ for (const server of connector.servers.filter((candidate) => granted.has(candidate.url))) {
766
+ records.push({
767
+ key: `${connector.id}-${server.serverKey}`,
768
+ connectorId: connector.id,
769
+ kind: 'oauth',
770
+ name: connector.servers.length > 1 ? `${connector.name}·${server.serverKey}` : connector.name,
771
+ serverKey: server.serverKey,
772
+ transport: server.transport,
773
+ url: server.url,
774
+ serverName: server.serverName,
775
+ headers: server.headers,
776
+ auth: { mode: 'oauth', grantKey },
777
+ enabled: true,
778
+ createdAt: now,
779
+ updatedAt: now,
780
+ });
781
+ }
782
+ }
783
+ return records;
784
+ }
785
+
786
+ function ensureRequestedConnectorsAuthorized(requestedIds, records) {
787
+ const connectedIds = new Set(records.map((record) => record.connectorId));
788
+ const missing = [...requestedIds].filter((id) => !connectedIds.has(id));
789
+ if (missing.length === 0) return;
790
+ throw new Error(`本次授权未包含连接器所需的 MCP 资源:${missing.join(', ')}`);
791
+ }
792
+
793
+ async function findReusableSharedGrant(connector) {
794
+ if (connector.auth?.grantSharing !== 'issuer' || !connector.auth.issuer) return null;
795
+ const issuer = canonicalIssuer(connector.auth.issuer);
796
+ for (const [grantKey, entry] of state.grants) {
797
+ const grant = entry.grant;
798
+ if (entry.needsReauth
799
+ || grant.account !== config.account
800
+ || grant.scope !== connector.auth.scope
801
+ || (grant.tokenEndpointAuthMethod ?? 'none') !== connector.auth.tokenEndpointAuthMethod
802
+ || canonicalIssuer(grant.issuer) !== issuer) continue;
803
+
804
+ if (grant.accessTokenExpiresAt <= Date.now()) {
805
+ try {
806
+ await refreshGrantWithRecovery(grantKey, 'shared-connect');
807
+ } catch (error) {
808
+ if (error.refreshFailure?.kind === 'transient') {
809
+ return { temporaryFailure: error.refreshFailure };
810
+ }
811
+ continue;
812
+ }
813
+ }
814
+ if (connector.servers.some((server) => entry.grant.authorizedResources.includes(server.url))) {
815
+ return { grantKey, entry };
816
+ }
817
+ }
818
+ return null;
819
+ }
820
+
821
+ async function attachConnectorsToGrant(grantKey, entry, connectors, requestedIds) {
822
+ const usableConnectors = connectors.filter((connector) => {
823
+ const conflict = legacyConflictForConnector(connector);
824
+ if (!conflict) return true;
825
+ warnLegacyConflict(conflict);
826
+ return false;
827
+ });
828
+ const records = oauthRecordsFor(usableConnectors, entry.grant.authorizedResources, grantKey);
829
+ ensureRequestedConnectorsAuthorized(requestedIds, records);
830
+ const connectedConnectorIds = [...new Set(records.map((record) => record.connectorId))];
831
+ const previous = entry.grant;
832
+ const next = {
833
+ ...previous,
834
+ connectorIds: [...new Set([...(previous.connectorIds ?? []), ...connectedConnectorIds])],
835
+ updatedAt: Date.now(),
836
+ };
837
+ entry.grant = next;
838
+ if (config.persistSecrets) await grantStore.put(next);
839
+ try {
840
+ const committed = await commitConnectionRecords(records);
841
+ for (const connector of usableConnectors) {
842
+ const own = committed.filter((record) => record.connectorId === connector.id);
843
+ if (own.length > 0) cacheHealth(connector.id, own, readinessResults(own));
844
+ }
845
+ scheduleRefresh(grantKey);
846
+ return committed;
847
+ } catch (error) {
848
+ entry.grant = previous;
849
+ if (config.persistSecrets) await grantStore.put(previous).catch((rollbackError) => {
850
+ logger.error(`shared grant rollback ${grantKey} failed: ${rollbackError.message}`);
851
+ });
852
+ throw error;
853
+ }
854
+ }
855
+
359
856
  /* ───────────────────────── api 门面 ───────────────────────── */
360
857
 
361
858
  const api = {
@@ -415,18 +912,67 @@ export async function apply(ctx, config) {
415
912
  if (!connector) return { ok: false, message: `连接器 "${connectorId}" 不存在(可先 mcp_connector_refresh_catalog 或 mcp_connector_catalog 查看)` };
416
913
 
417
914
  if (connector.auth.mode === 'oauth2-pkce') {
418
- const inFlight = state.oauthConnects.get(connector.id);
419
- if (inFlight) return inFlight;
915
+ const conflict = legacyConflictForConnector(connector);
916
+ if (conflict) {
917
+ warnLegacyConflict(conflict);
918
+ return { ok: false, message: conflict.message, detail: { kind: 'plugin-conflict', pluginId: conflict.pluginId, serverName: conflict.serverName } };
919
+ }
920
+
921
+ const sharingKey = oauthSharingKey(connector);
922
+ const inFlight = state.oauthConnects.get(sharingKey);
923
+ if (inFlight) {
924
+ inFlight.requestedConnectorIds.add(connector.id);
925
+ return inFlight.promise;
926
+ }
927
+ const requestedConnectorIds = new Set([connector.id]);
420
928
  const promise = (async () => {
421
929
  try {
930
+ const sharingConnectors = oauthSharingConnectors(connector);
931
+ const reusable = await findReusableSharedGrant(connector);
932
+ if (reusable?.temporaryFailure) {
933
+ return {
934
+ ok: false,
935
+ message: `OAuth 授权刷新暂时失败(${reusable.temporaryFailure.code}),插件将自动重试,无需重新授权`,
936
+ detail: { kind: 'refresh-retry', retrying: true },
937
+ };
938
+ }
939
+ if (reusable?.entry) {
940
+ const requestedConnectors = sharingConnectors.filter((item) => requestedConnectorIds.has(item.id));
941
+ const committed = await attachConnectorsToGrant(
942
+ reusable.grantKey,
943
+ reusable.entry,
944
+ requestedConnectors,
945
+ requestedConnectorIds,
946
+ );
947
+ const created = committed.map((record) => record.key);
948
+ return {
949
+ ok: true,
950
+ message: `已复用同账号 OAuth 授权连接 ${requestedConnectors.length} 个连接器(${created.length} 个 MCP server),无需再次授权`,
951
+ detail: { keys: created, grantKey: reusable.grantKey, reusedGrant: true, retiredGrantCount: 0 },
952
+ };
953
+ }
954
+
955
+ const sharingConnectorIds = new Set(sharingConnectors.map((item) => item.id));
422
956
  const previousGrantKeys = new Set(
423
957
  [...state.grants]
424
- .filter(([, entry]) => entry.grant.connectorIds?.includes(connector.id))
958
+ .filter(([, entry]) => entry.grant.connectorIds?.some((id) => sharingConnectorIds.has(id)))
425
959
  .map(([key]) => key),
426
960
  );
427
- const authz = await oauthAuthorize({ connector, config, logger, signal });
961
+ const authorizationConnector = oauthAuthorizationDescriptor(connector, sharingConnectors);
962
+ const authz = await oauthAuthorize({ connector: authorizationConnector, config, logger, signal });
428
963
  const grantKey = grantStore.keyFor(config.account, authz.issuer, authz.clientId, authz.scope);
429
964
  const entryResource = authz.entryResource;
965
+ const authorizedResources = authz.grantedResources?.length ? authz.grantedResources : [entryResource];
966
+ const alreadyConnectedIds = new Set(
967
+ [...state.connections.values()]
968
+ .filter((record) => sharingConnectorIds.has(record.connectorId))
969
+ .map((record) => record.connectorId),
970
+ );
971
+ const targetIds = new Set([...alreadyConnectedIds, ...requestedConnectorIds]);
972
+ const targetConnectors = sharingConnectors.filter((item) => targetIds.has(item.id));
973
+ const previewRecords = oauthRecordsFor(targetConnectors, authorizedResources, grantKey);
974
+ ensureRequestedConnectorsAuthorized(requestedConnectorIds, previewRecords);
975
+ const grantedConnectorIds = [...new Set(previewRecords.map((record) => record.connectorId))];
430
976
  const grant = {
431
977
  key: grantKey,
432
978
  issuer: authz.issuer,
@@ -438,62 +984,41 @@ export async function apply(ctx, config) {
438
984
  scope: authz.scope,
439
985
  account: config.account,
440
986
  accessToken: authz.token.accessToken,
441
- accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000 - config.refreshSkewMs,
987
+ accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000,
442
988
  refreshToken: authz.token.refreshToken ?? '',
443
- authorizedResources: connector.servers
444
- .filter((s) => authz.grantedKeys.includes(s.serverKey))
445
- .map((s) => s.url).length
446
- ? connector.servers.filter((s) => authz.grantedKeys.includes(s.serverKey)).map((s) => s.url)
447
- : [entryResource],
448
- connectorIds: [connector.id],
989
+ authorizedResources,
990
+ connectorIds: grantedConnectorIds,
449
991
  updatedAt: Date.now(),
450
992
  };
451
- state.grants.set(grantKey, { grant, timer: undefined, refreshPromise: null, needsReauth: false });
993
+ const runtime = grantRuntime(grant);
994
+ state.grants.set(grantKey, runtime);
452
995
  if (config.persistSecrets) await grantStore.put(grant);
453
-
454
- const created = [];
455
- for (const sk of authz.grantedKeys) {
456
- const server = connector.servers.find((s) => s.serverKey === sk);
457
- if (!server) continue;
458
- const record = {
459
- key: `${connector.id}-${sk}`,
460
- connectorId: connector.id,
461
- kind: 'oauth',
462
- name: connector.servers.length > 1 ? `${connector.name}·${sk}` : connector.name,
463
- serverKey: sk,
464
- transport: server.transport,
465
- url: server.url,
466
- serverName: server.serverName,
467
- headers: server.headers,
468
- auth: { mode: 'oauth', grantKey },
469
- enabled: true,
470
- createdAt: Date.now(),
471
- updatedAt: Date.now(),
472
- };
473
- await upsertRecord(record);
474
- created.push(record.key);
475
- }
476
- scheduleRefresh(grantKey);
996
+ const committed = await attachConnectorsToGrant(grantKey, runtime, targetConnectors, requestedConnectorIds);
997
+ const created = committed.map((record) => record.key);
477
998
  let retiredGrantCount = 0;
478
999
  for (const previousKey of previousGrantKeys) {
479
1000
  if (previousKey !== grantKey && await retireGrantIfUnused(previousKey, { revoke: true })) retiredGrantCount += 1;
480
1001
  }
481
1002
  return {
482
1003
  ok: true,
483
- message: `已连接 "${connector.name}"(${created.length} 个 MCP server),共 ${created.length} 条`,
484
- detail: { keys: created, grantKey, retiredGrantCount },
1004
+ message: sharingConnectors.length > 1
1005
+ ? `OAuth 授权成功;已为同一账号保存共享授权并连接 ${targetConnectors.length} 个连接器(${created.length} 个 MCP server)`
1006
+ : `已连接 "${connector.name}"(${created.length} 个 MCP server),共 ${created.length} 条`,
1007
+ detail: { keys: created, grantKey, sharedGrant: sharingConnectors.length > 1, retiredGrantCount },
485
1008
  };
486
1009
  } catch (error) {
487
1010
  logger.error(`oauth connect failed: ${error.message}`);
488
1011
  await pruneOrphanGrants();
489
- return { ok: false, message: `连接失败: ${error.message}` };
1012
+ return error?.connectionRecord
1013
+ ? connectionSetupFailure(error)
1014
+ : { ok: false, message: `连接失败: ${error.message}` };
490
1015
  }
491
1016
  })();
492
- state.oauthConnects.set(connector.id, promise);
1017
+ state.oauthConnects.set(sharingKey, { promise, requestedConnectorIds });
493
1018
  try {
494
1019
  return await promise;
495
1020
  } finally {
496
- if (state.oauthConnects.get(connector.id) === promise) state.oauthConnects.delete(connector.id);
1021
+ if (state.oauthConnects.get(sharingKey)?.promise === promise) state.oauthConnects.delete(sharingKey);
497
1022
  }
498
1023
  }
499
1024
 
@@ -518,8 +1043,15 @@ export async function apply(ctx, config) {
518
1043
  createdAt: Date.now(),
519
1044
  updatedAt: Date.now(),
520
1045
  };
521
- await upsertRecord(record);
522
- return { ok: true, message: `已连接 "${connector.name}"`, detail: { key: record.key } };
1046
+ const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
1047
+ if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
1048
+ try {
1049
+ const [committed] = await commitConnectionRecords([record]);
1050
+ cacheHealth(connector.id, [committed], readinessResults([committed], validation.detail.results));
1051
+ return { ok: true, message: `已连接 "${connector.name}"`, detail: { key: committed.key } };
1052
+ } catch (error) {
1053
+ return connectionSetupFailure(error);
1054
+ }
523
1055
  }
524
1056
 
525
1057
  // bearer / api-key 型:目录不含密钥,引导自定义配置
@@ -574,12 +1106,9 @@ export async function apply(ctx, config) {
574
1106
  const validation = await validateConnectionRecords(records, { timeoutMs: config.requestTimeoutMs });
575
1107
  if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
576
1108
 
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);
1109
+ const committed = await commitConnectionRecords(records);
1110
+ const created = committed.map((record) => record.key);
1111
+ cacheHealth(connector.id, committed, readinessResults(committed, validation.detail.results));
583
1112
  return {
584
1113
  ok: true,
585
1114
  message: `已配置并连接 "${connector.name}"(${created.length} 个 MCP Server)`,
@@ -587,20 +1116,39 @@ export async function apply(ctx, config) {
587
1116
  };
588
1117
  }
589
1118
  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 } };
1119
+ const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
1120
+ if (!validation.ok) return validation;
1121
+ const [committed] = await commitConnectionRecords([record]);
1122
+ cacheHealth(committed.connectorId, [committed], readinessResults([committed], validation.detail.results));
1123
+ return { ok: true, message: `已配置并连接 "${committed.name}"(serverName=${committed.serverName})`, detail: { key: committed.key, serverName: committed.serverName } };
592
1124
  } catch (error) {
593
- return { ok: false, message: `配置失败: ${error.message}` };
1125
+ return error?.connectionRecord
1126
+ ? connectionSetupFailure(error, '配置失败')
1127
+ : { ok: false, message: `配置失败: ${error.message}` };
594
1128
  }
595
1129
  },
596
1130
 
597
1131
  async importJson(json) {
598
1132
  try {
599
1133
  const { records, skipped } = normalizeJsonImport(json);
600
- const keys = [];
601
- for (const record of records) {
602
- await upsertRecord(record);
603
- keys.push(record.key);
1134
+ const validation = await validateConnectionRecords(records, {
1135
+ timeoutMs: config.requestTimeoutMs,
1136
+ concurrency: 4,
1137
+ });
1138
+ if (!validation.ok) return validation;
1139
+ const committed = await commitConnectionRecords(records);
1140
+ const keys = committed.map((record) => record.key);
1141
+ const grouped = new Map();
1142
+ for (const record of committed) {
1143
+ if (!grouped.has(record.connectorId)) grouped.set(record.connectorId, []);
1144
+ grouped.get(record.connectorId).push(record);
1145
+ }
1146
+ for (const [connectorId, connectorRecords] of grouped) {
1147
+ const results = connectorRecords.map((record) => {
1148
+ const index = committed.findIndex((item) => item.key === record.key);
1149
+ return readinessResults([record], [validation.detail.results[index]])[0];
1150
+ });
1151
+ cacheHealth(connectorId, connectorRecords, results);
604
1152
  }
605
1153
  const extra = skipped.length ? `;跳过:${skipped.join('、')}` : '';
606
1154
  return {
@@ -609,7 +1157,9 @@ export async function apply(ctx, config) {
609
1157
  detail: { keys, skipped },
610
1158
  };
611
1159
  } catch (error) {
612
- return { ok: false, message: `导入失败: ${error.message}` };
1160
+ return error?.connectionRecord
1161
+ ? connectionSetupFailure(error, '导入失败')
1162
+ : { ok: false, message: `导入失败: ${error.message}` };
613
1163
  }
614
1164
  },
615
1165
 
@@ -651,6 +1201,9 @@ export async function apply(ctx, config) {
651
1201
  grantKey: record.auth.grantKey,
652
1202
  accessTokenExpiresAt: g.grant.accessTokenExpiresAt,
653
1203
  needsReauth: !!g.needsReauth,
1204
+ refreshFailureKind: g.refreshFailureKind,
1205
+ lastRefreshError: g.lastRefreshError,
1206
+ refreshRetryAt: g.refreshRetryAt,
654
1207
  }
655
1208
  : { grantKey: record.auth.grantKey, missing: true };
656
1209
  }
@@ -667,7 +1220,9 @@ export async function apply(ctx, config) {
667
1220
  authMode: record.auth?.mode ?? 'none',
668
1221
  grant: grantInfo,
669
1222
  lastError: record.lastError ?? null,
670
- connectionState: recordHealth?.ok ? 'healthy' : recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
1223
+ connectionState: recordHealth?.ok && recordHealth.kind !== 'managed'
1224
+ ? 'healthy'
1225
+ : recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
671
1226
  healthCheckedAt: health?.checkedAt ?? null,
672
1227
  healthMessage: recordHealth?.message ?? null,
673
1228
  });
@@ -694,7 +1249,29 @@ export async function apply(ctx, config) {
694
1249
  for (const record of enabledRecords) {
695
1250
  const failure = grantFailure(record);
696
1251
  if (failure) immediateResults.push(failure);
697
- else checkable.push(record);
1252
+ else if (record.transport === 'stdio') {
1253
+ const registered = registeredStdioTools(record);
1254
+ if (registered.tools.length > 0) {
1255
+ immediateResults.push({
1256
+ ok: true,
1257
+ kind: 'connected',
1258
+ serverKey: record.serverKey,
1259
+ serverName: record.serverName,
1260
+ toolCount: registered.tools.length,
1261
+ message: `Host 已注册 ${registered.tools.length} 个工具`,
1262
+ });
1263
+ } else {
1264
+ immediateResults.push({
1265
+ ok: true,
1266
+ kind: 'managed',
1267
+ serverKey: record.serverKey,
1268
+ serverName: record.serverName,
1269
+ message: registered.supported
1270
+ ? 'Host 尚未注册该 stdio Server 的工具'
1271
+ : '当前 Host 无法读取 stdio 工具注册状态',
1272
+ });
1273
+ }
1274
+ } else checkable.push(record);
698
1275
  }
699
1276
  let checkedResults = [];
700
1277
  if (checkable.length > 0) {
@@ -708,7 +1285,7 @@ export async function apply(ctx, config) {
708
1285
  summaries.push(cacheHealth(id, records, [...immediateResults, ...checkedResults]));
709
1286
  }
710
1287
  const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
711
- const attention = summaries.filter((item) => ['reauth', 'degraded', 'unavailable'].includes(item.connectionState)).length;
1288
+ const attention = summaries.filter((item) => ['reauth', 'recovering', 'degraded', 'unavailable'].includes(item.connectionState)).length;
712
1289
  return {
713
1290
  ok: true,
714
1291
  message: targetIds.length === 0
@@ -741,24 +1318,34 @@ export async function apply(ctx, config) {
741
1318
  if (plan.summary.alreadyMigrated) { skipped.push(`${plan.summary.connectorName}(已迁移)`); continue; }
742
1319
  const grantKey = grantStore.keyFor(config.account, plan.candidate.grant.issuer, plan.candidate.grant.clientId, plan.candidate.grant.scope);
743
1320
  const grant = toConnectorGrant(plan.candidate, { key: grantKey, account: config.account, connectorIds: [plan.connector.id] });
744
- state.grants.set(grantKey, { grant, timer: undefined, refreshPromise: null, needsReauth: false });
1321
+ state.grants.set(grantKey, grantRuntime(grant));
745
1322
  if (config.persistSecrets) await grantStore.put(grant);
746
1323
 
747
1324
  let enabled = true;
1325
+ let provisionNow = true;
748
1326
  let lastError;
1327
+ const legacyConflict = legacyConflictForConnector(plan.connector);
1328
+ if (legacyConflict) {
1329
+ warnLegacyConflict(legacyConflict);
1330
+ provisionNow = false;
1331
+ lastError = `${legacyConflict.message};授权已复制,重启后将由 MCP连接器恢复`;
1332
+ }
749
1333
  if (grant.accessTokenExpiresAt <= Date.now()) {
750
- try { await refreshGrant(grantKey); }
1334
+ try { await refreshGrantWithRecovery(grantKey, 'legacy-migration'); }
751
1335
  catch (error) {
752
- enabled = false;
753
- lastError = `旧授权已过期且刷新失败,请重新连接:${error instanceof Error ? error.message : String(error)}`;
754
- state.grants.get(grantKey).needsReauth = true;
1336
+ const permanent = error.refreshFailure?.permanent === true;
1337
+ enabled = !permanent;
1338
+ provisionNow = false;
1339
+ lastError = permanent
1340
+ ? `旧授权已失效,请重新连接:${error.refreshFailure?.message ?? error.message}`
1341
+ : `旧授权刷新暂时失败,正在自动重试:${error.refreshFailure?.message ?? error.message}`;
755
1342
  }
756
1343
  } else {
757
1344
  scheduleRefresh(grantKey);
758
1345
  }
759
1346
  const records = toConnectionRecords(plan, grantKey, { enabled, lastError });
760
1347
  for (const record of records) {
761
- if (enabled) await upsertRecord(record);
1348
+ if (enabled && provisionNow) await upsertRecord(record);
762
1349
  else await persistRecord(record);
763
1350
  }
764
1351
  migrated.push({ connectorId: plan.connector.id, keys: records.map((record) => record.key), enabled });
@@ -775,11 +1362,26 @@ export async function apply(ctx, config) {
775
1362
  async setEnabled(key, enabled) {
776
1363
  const record = state.connections.get(key);
777
1364
  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}"` };
1365
+ const next = { ...record, enabled: !!enabled, updatedAt: Date.now() };
1366
+ if (next.enabled) {
1367
+ try {
1368
+ const [committed] = await commitConnectionRecords([next]);
1369
+ if (committed.connectorId) cacheHealth(committed.connectorId, [committed], readinessResults([committed]));
1370
+ return { ok: true, message: `已启用 "${committed.name}"` };
1371
+ } catch (error) {
1372
+ return connectionSetupFailure(error, '启用失败');
1373
+ }
1374
+ }
1375
+ await disable(ctx, config, next);
1376
+ try {
1377
+ await persistRecord(next);
1378
+ } catch (error) {
1379
+ await provision(ctx, config, record, grantMap(), { failOnStartupError: false }).catch((rollbackError) => {
1380
+ logger.error(`disable rollback "${record.key}" failed: ${rollbackError.message}`);
1381
+ });
1382
+ throw error;
1383
+ }
1384
+ return { ok: true, message: `已停用 "${next.name}"` };
783
1385
  },
784
1386
 
785
1387
  async disconnect(key, signal) {
@@ -887,19 +1489,30 @@ export async function apply(ctx, config) {
887
1489
  const servers = [];
888
1490
  for (const record of records) {
889
1491
  if (record.transport === 'stdio') {
1492
+ const registered = registeredStdioTools(record);
890
1493
  const snapshot = (connector.toolsSnapshot ?? []).find((item) =>
891
1494
  item.serverKey === record.serverKey || item.serverName === record.serverName);
1495
+ const liveTools = registered.tools;
892
1496
  servers.push({
893
1497
  serverKey: record.serverKey || record.name,
894
1498
  serverName: record.serverName,
895
- ok: true,
1499
+ ok: liveTools.length > 0,
896
1500
  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
- })),
1501
+ live: liveTools.length > 0,
1502
+ preview: liveTools.length === 0 && Boolean(snapshot),
1503
+ tools: liveTools.length > 0
1504
+ ? liveTools
1505
+ : (snapshot?.tools ?? []).map((tool) => ({
1506
+ name: tool.name,
1507
+ title: tool.title || tool.name,
1508
+ description: tool.description || '',
1509
+ })),
1510
+ ...(liveTools.length === 0 ? {
1511
+ errorKind: registered.supported ? 'startup' : 'host-version',
1512
+ error: registered.supported
1513
+ ? 'Host 尚未注册该 stdio Server 的工具,请检查启动日志后重试'
1514
+ : '当前 DSH Host 不支持读取工具注册状态,请升级 Host 后重试',
1515
+ } : {}),
903
1516
  });
904
1517
  continue;
905
1518
  }
@@ -938,7 +1551,7 @@ export async function apply(ctx, config) {
938
1551
  const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
939
1552
  const availableServers = servers.filter((server) => server.ok).length;
940
1553
  const failedServers = servers.length - availableServers;
941
- const managedWithoutSnapshot = servers.filter((server) => server.managed && !server.preview).length;
1554
+ const pendingManagedServers = servers.filter((server) => server.managed && !server.ok).length;
942
1555
  const health = cacheHealth(connectorId, records, servers.map((server) => server.ok
943
1556
  ? { ok: true, kind: 'connected', serverKey: server.serverKey, serverName: server.serverName }
944
1557
  : { ok: false, kind: server.errorKind ?? 'network', serverKey: server.serverKey, serverName: server.serverName, message: server.error }));
@@ -948,10 +1561,16 @@ export async function apply(ctx, config) {
948
1561
  ok: availableServers > 0,
949
1562
  message: failedServers > 0
950
1563
  ? `工具加载失败:${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 },
1564
+ : `共 ${servers.length} 个 server,${totalTools} 个工具`,
1565
+ detail: {
1566
+ connectorId,
1567
+ servers,
1568
+ totalTools,
1569
+ availableServers,
1570
+ failedServers,
1571
+ pendingManagedServers,
1572
+ connectionState: health.connectionState,
1573
+ },
955
1574
  };
956
1575
  },
957
1576
  };
@@ -959,6 +1578,7 @@ export async function apply(ctx, config) {
959
1578
  /* ───────────────────────── 启动恢复 ───────────────────────── */
960
1579
 
961
1580
  await loadCatalog();
1581
+ detectLegacyPluginConflicts();
962
1582
 
963
1583
  for (const [key, raw] of await connectionStore.entries().catch(() => [])) {
964
1584
  try {
@@ -970,7 +1590,7 @@ export async function apply(ctx, config) {
970
1590
  }
971
1591
  for (const [key, raw] of await grantStore.entries().catch(() => [])) {
972
1592
  try {
973
- state.grants.set(key, { grant: raw, timer: undefined, refreshPromise: null, needsReauth: false });
1593
+ state.grants.set(key, grantRuntime(raw));
974
1594
  } catch (error) {
975
1595
  logger.warn(`skip invalid stored grant "${key}": ${error.message}`);
976
1596
  }
@@ -982,16 +1602,19 @@ export async function apply(ctx, config) {
982
1602
  for (const [grantKey, g] of state.grants) {
983
1603
  if (g.grant.accessTokenExpiresAt <= Date.now()) {
984
1604
  try {
985
- await refreshGrant(grantKey);
986
- } catch {
987
- g.needsReauth = true;
988
- }
1605
+ await refreshGrantWithRecovery(grantKey, 'startup');
1606
+ } catch {}
989
1607
  } else {
990
1608
  scheduleRefresh(grantKey);
991
1609
  }
992
1610
  }
993
1611
  for (const [key, record] of state.connections) {
994
1612
  if (record.enabled !== false) {
1613
+ const failure = grantFailure(record);
1614
+ if (failure) {
1615
+ logger.warn(`skip provision "${key}" at startup: ${failure.message}`);
1616
+ continue;
1617
+ }
995
1618
  await provision(ctx, config, record, grantMap()).catch((error) => {
996
1619
  logger.warn(`provision "${key}" failed: ${error.message}`);
997
1620
  });