dsh-mcp-connector 0.2.22 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.23] - 2026-08-26
8
+
9
+ ### Fixed
10
+
11
+ - OAuth Grant 现在保存 Access Token 的真实过期时间,并在提前刷新遇到网络、发现端点或服务端暂时故障时采用有上限的指数退避自动恢复;只有 `invalid_grant`、`invalid_client` 等明确不可恢复错误才显示“需重新授权”。
12
+ - 启动恢复不再吞掉 Token 刷新异常;脱敏日志会记录阶段、OAuth 错误码、HTTP 状态与永久/暂时分类,且不会输出 Access Token、Refresh Token 或客户端密钥。
13
+ - `grantSharing: "issuer"` 正式生效:同账号、同 issuer/scope 的卡片共享动态客户端与 Grant,后续连接同组卡片不再重复打开授权页;重新授权会把同组已连接卡片迁移到新 Grant。
14
+ - 启动和连接前检测仍启用的 `qcc-mcp-oauth` / `qcc-legal-mcp-oauth` 及其残留 mcp-client 条目;发现同名 Server 时阻断新连接,避免旧 Token 覆盖新连接器凭据。
15
+
16
+ ### Verification
17
+
18
+ - 新增 OAuth 刷新失败分类/脱敏/退避、暂时故障自动恢复、永久失效重新授权、同 issuer 顺序与并发共享授权、旧插件冲突阻断回归测试。
19
+
7
20
  ## [0.2.22] - 2026-08-26
8
21
 
9
22
  ### Fixed
@@ -372,7 +385,8 @@
372
385
  - 外部 URL 与导入 Header 执行安全校验。
373
386
  - iframe 消息校验同源和消息来源。
374
387
 
375
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...HEAD
388
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...HEAD
389
+ [0.2.23]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...v0.2.23
376
390
  [0.2.22]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.21...v0.2.22
377
391
  [0.2.21]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.20...v0.2.21
378
392
  [0.2.20]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.19...v0.2.20
package/README.en.md CHANGED
@@ -25,10 +25,10 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
25
25
  - Credential and MCP initialize validation before HTTP API-key connectors are saved as installed, plus declarative multi-field credential-to-env bindings for marketplace stdio connectors.
26
26
  - Dynamic tool discovery grouped by MCP server, including descriptions, search, batched rendering, and an independent scroll region.
27
27
  - Curated prompt templates that can open a DSH conversation and prefill its draft; missing variables are requested before the prompt is sent.
28
- - Persistent connection lifecycle management: restore on restart, enable/disable, disconnect, refresh OAuth tokens, and revoke authorization. DCR client secrets are kept with the local grant only.
28
+ - Persistent connection lifecycle management: restore on restart, enable/disable, disconnect, retry transient OAuth refresh failures with bounded backoff, and revoke authorization. Cards declaring issuer-level sharing reuse one grant; DCR client secrets remain local to that grant.
29
29
  - Built-in, remote, and local catalogs with `published` and `featured` controls.
30
30
  - A standalone remote Registry, allowing new marketplace cards to appear after refresh without publishing a new npm version.
31
- - Explicit, non-destructive migration of authorization from the two earlier Qichacha OAuth plugins.
31
+ - Explicit, non-destructive migration from the two earlier Qichacha OAuth plugins, plus active-plugin conflict detection that blocks duplicate server management and credential overwrites.
32
32
 
33
33
  <!-- catalog-stats:start -->
34
34
  As of 2026-08-25, the public Registry publishes 78 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 82 cards across 9 business categories. Recommendations remain limited to the four Qichacha cards, PKULaw, and Wind, for 6 featured cards in total. The Registry evolves independently; the badge shown after a client refresh and the live badges above are the authoritative current counts.
package/README.md CHANGED
@@ -25,11 +25,11 @@
25
25
  - Prompt 模板:使用 `{{company}}` 等变量,发送前填写真实查询主体。
26
26
  - 三种接入:OAuth 2.0 PKCE、自定义 HTTP/stdio、导入 `mcpServers` JSON;也支持从连接器描述 URL 安装。OAuth 动态注册兼容公共客户端以及 `client_secret_post` / `client_secret_basic` 机密客户端。
27
27
  - 市场 Bearer/API Key 连接器先执行 MCP initialize 连通性与凭据校验,全部 HTTP Server 通过后才持久化凭据并进入“已安装”;stdio 卡片可声明多个本机凭据字段及其环境变量映射。
28
- - 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 刷新与撤销;DCR 返回的客户端密钥与 Token 一同只保存在本机。
28
+ - 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 自动刷新/退避恢复与撤销;同 issuer 卡片可共享一次授权,DCR 返回的客户端密钥与 Token 一同只保存在本机。
29
29
  - 目录运营:内置目录、远程 registry、本地覆盖,支持 `published` 上下架与 `featured` 精选。
30
30
  - 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
31
31
  - Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
32
- - 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;不删除原插件或原凭据。
32
+ - 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;检测到旧插件仍启用并管理同名 Server 时阻断重复连接,避免凭据相互覆盖。
33
33
  - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
34
34
 
35
35
  <!-- catalog-stats:start -->
@@ -63,6 +63,7 @@ dsh web
63
63
  | `连接` | OAuth 或免密连接器尚未连接 | 点击后按页面提示完成授权或连通性检查 |
64
64
  | `配置` | 需要 Bearer Token 或 API Key | 录入服务商签发的凭据并验证 |
65
65
  | `需重新授权` | OAuth 授权过期、被撤销或不可用 | 点击后重新完成 OAuth 授权 |
66
+ | `自动重试中` | OAuth Token 刷新遇到网络或服务端暂时故障 | 无需重新授权;保持 DSH 运行,插件会按退避策略自动恢复 |
66
67
  | `已配置` | 本机已有配置,尚未完成本轮健康确认 | 点击“刷新”或进入详情检查 |
67
68
  | `已连接` | 最近一次健康检查通过 | 可直接在会话中使用对应 MCP 工具 |
68
69
  | `部分异常` / `连接异常` | 一个或多个 Server 未通过检查 | 打开详情查看提示,核对网络、权限和服务状态 |
@@ -73,6 +74,8 @@ stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token
73
74
 
74
75
  OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。
75
76
 
77
+ 当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”,明确收到 Refresh Token/客户端失效错误时才进入“需重新授权”。
78
+
76
79
  ## 5. 查看详情、Prompt 与工具
77
80
 
78
81
  点击卡片或“详情”可打开连接器详情:
@@ -80,12 +80,19 @@ export async function oauthAuthorize({ connector, config, logger, signal }) {
80
80
  // 按 token 实际授权范围过滤 server;非 JWT / 无 resource claim 时 fallback 全部
81
81
  const grantedUrls = extractTokenResources(token.accessToken);
82
82
  let grantedKeys;
83
+ let grantedResources;
83
84
  if (grantedUrls) {
84
85
  const granted = new Set(grantedUrls);
85
- grantedKeys = connector.servers.filter((s) => granted.has(s.url)).map((s) => s.serverKey);
86
- if (grantedKeys.length === 0) grantedKeys = [entryServer.serverKey];
86
+ const grantedServers = connector.servers.filter((s) => granted.has(s.url));
87
+ grantedKeys = grantedServers.map((s) => s.serverKey);
88
+ grantedResources = grantedServers.map((s) => s.url);
89
+ if (grantedKeys.length === 0) {
90
+ grantedKeys = [entryServer.serverKey];
91
+ grantedResources = [entryServer.url];
92
+ }
87
93
  } else {
88
94
  grantedKeys = connector.servers.map((s) => s.serverKey);
95
+ grantedResources = connector.servers.map((s) => s.url);
89
96
  }
90
97
 
91
98
  return {
@@ -98,6 +105,7 @@ export async function oauthAuthorize({ connector, config, logger, signal }) {
98
105
  scope: connector.auth.scope,
99
106
  token,
100
107
  grantedKeys,
108
+ grantedResources,
101
109
  entryResource,
102
110
  };
103
111
  }
package/lib/constants.js CHANGED
@@ -30,6 +30,9 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
30
30
  /** 首次启动 MCP Server 并完成 initialize + tools/list 的最长等待时间 */
31
31
  export const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
32
32
  export const DEFAULT_REFRESH_SKEW_MS = 300_000;
33
+ /** OAuth 刷新暂时失败后的指数退避范围。 */
34
+ export const DEFAULT_REFRESH_RETRY_BASE_MS = 30_000;
35
+ export const DEFAULT_REFRESH_RETRY_MAX_MS = 300_000;
33
36
  export const DEFAULT_CATALOG_TTL_MS = 3_600_000;
34
37
 
35
38
  /** PKCE 约束(RFC 7636) */
@@ -0,0 +1,47 @@
1
+ /** OAuth Grant 刷新失败分类、日志脱敏与重试退避。 */
2
+
3
+ const PERMANENT_REFRESH_CODES = new Set([
4
+ 'invalid_grant',
5
+ 'invalid_token',
6
+ 'invalid_client',
7
+ 'unauthorized_client',
8
+ 'invalid_scope',
9
+ 'client_secret_expired',
10
+ 'missing_refresh_token',
11
+ ]);
12
+
13
+ /** 防止 OAuth 服务端错误描述意外回显凭据。 */
14
+ export function redactOAuthDetail(value) {
15
+ return String(value ?? '')
16
+ .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
17
+ .replace(/([?&](?:access_token|refresh_token|client_secret|code)=)[^&#\s]*/gi, '$1[REDACTED]')
18
+ .replace(/((?:access_token|refresh_token|client_secret)\s*[=:]\s*)[^\s,;&}]+/gi, '$1[REDACTED]')
19
+ .slice(0, 500);
20
+ }
21
+
22
+ /**
23
+ * 只有明确表示 Refresh Token / 动态客户端不可恢复的 OAuth 错误才要求重新授权。
24
+ * 网络、发现端点、5xx、超时和未知服务端错误都按暂时故障自动重试。
25
+ */
26
+ export function classifyRefreshFailure(error) {
27
+ const code = typeof error?.code === 'string' && error.code
28
+ ? redactOAuthDetail(error.code).slice(0, 80)
29
+ : error?.name === 'OAuthNetworkError' ? 'network_error' : 'refresh_failed';
30
+ const httpStatus = Number.isInteger(error?.httpStatus) ? error.httpStatus : undefined;
31
+ const permanent = PERMANENT_REFRESH_CODES.has(code) || httpStatus === 401 || httpStatus === 403;
32
+ return {
33
+ permanent,
34
+ kind: permanent ? 'permanent' : 'transient',
35
+ code,
36
+ httpStatus,
37
+ message: redactOAuthDetail(error?.message || code),
38
+ };
39
+ }
40
+
41
+ /** 指数退避:base、2×、4×……,封顶 max。failureCount 从 1 开始。 */
42
+ export function refreshRetryDelay(failureCount, baseMs, maxMs) {
43
+ const base = Math.max(1_000, Number(baseMs) || 30_000);
44
+ const max = Math.max(base, Number(maxMs) || 300_000);
45
+ const exponent = Math.max(0, Math.min(20, Number(failureCount || 1) - 1));
46
+ return Math.min(max, base * (2 ** exponent));
47
+ }
package/lib/index.js CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  DEFAULT_REQUEST_TIMEOUT_MS,
18
18
  DEFAULT_STARTUP_TIMEOUT_MS,
19
19
  DEFAULT_REFRESH_SKEW_MS,
20
+ DEFAULT_REFRESH_RETRY_BASE_MS,
21
+ DEFAULT_REFRESH_RETRY_MAX_MS,
20
22
  DEFAULT_CATALOG_TTL_MS,
21
23
  } from './constants.js';
22
24
  import { defineConnectorDomain, ConnectionStore, GrantStore, CatalogStore } from './stores.js';
@@ -25,7 +27,7 @@ import { loadBundledCatalog, fetchRemoteCatalogWithFallback, mergeCatalog, listC
25
27
  import { oauthAuthorize } from './connectors/oauth-connector.js';
26
28
  import { buildManualRecord } from './connectors/manual-connector.js';
27
29
  import { normalizeJsonImport } from './connectors/json-connector.js';
28
- import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken } from './oauth.js';
30
+ import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken, extractTokenResources } from './oauth.js';
29
31
  import { provision, disable, remove, entryIdFor } from './mcp-provision.js';
30
32
  import { classifyConnectionError, validateConnectionRecords } from './mcp-validation.js';
31
33
  import { listMcpTools, McpHttpError } from './mcp-http.js';
@@ -33,9 +35,21 @@ import { registerTools } from './tools.js';
33
35
  import { mountWebRoutes } from './web.js';
34
36
  import { assertSafeUrl } from './util.js';
35
37
  import { readLegacyGrantCandidates, planLegacyMigration, toConnectorGrant, toConnectionRecords } from './migration.js';
38
+ import { classifyRefreshFailure, refreshRetryDelay } from './grant-lifecycle.js';
36
39
 
37
40
  export const name = 'mcp-connector';
38
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
+
39
53
  export const inject = ['tools', 'storageDomain', 'loader'];
40
54
 
41
55
  export const Config = z.object({
@@ -57,6 +71,10 @@ export const Config = z.object({
57
71
  startupTimeoutMs: z.number().default(DEFAULT_STARTUP_TIMEOUT_MS),
58
72
  /** 提前刷新阈值(ms) */
59
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),
60
78
  /** 是否自动打开浏览器(false = 仅打印授权 URL) */
61
79
  openBrowser: z.boolean().default(true),
62
80
  /** 授权账号标识(预留多账号) */
@@ -81,9 +99,10 @@ export async function apply(ctx, config) {
81
99
  dynamic: new Map(), // URL 安装的临时连接器
82
100
  overrides: new Map(), // id -> { published?, featured? }
83
101
  connections: new Map(), // key -> ConnectionRecord
84
- grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth }
102
+ grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth, refreshFailure* }
85
103
  health: new Map(), // connectorId -> 最近一次主动连通性检查摘要(不持久化)
86
- oauthConnects: new Map(), // connectorId -> 正在进行的 OAuth 授权 Promise(防重复弹窗/重复 grant)
104
+ oauthConnects: new Map(), // sharingKey -> { promise, requestedConnectorIds }(防同 issuer 重复弹窗)
105
+ warnedLegacyConflicts: new Set(),
87
106
  };
88
107
 
89
108
  /* ───────────────────────── 目录加载 ───────────────────────── */
@@ -154,8 +173,91 @@ export async function apply(ctx, config) {
154
173
  await recomputeMerged();
155
174
  }
156
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
+
157
246
  /* ───────────────────────── grant 刷新 ───────────────────────── */
158
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
+
159
261
  function grantMap() {
160
262
  const map = new Map();
161
263
  for (const [key, g] of state.grants) map.set(key, g.grant);
@@ -164,10 +266,16 @@ export async function apply(ctx, config) {
164
266
 
165
267
  async function refreshGrant(grantKey) {
166
268
  const entry = state.grants.get(grantKey);
167
- 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
+ }
168
274
  if (entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
169
275
  && entry.grant.clientSecretExpiresAt * 1000 <= Date.now()) {
170
- 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;
171
279
  }
172
280
  if (entry.refreshPromise) return entry.refreshPromise;
173
281
  entry.refreshPromise = (async () => {
@@ -177,22 +285,39 @@ export async function apply(ctx, config) {
177
285
  clientSecret: entry.grant.clientSecret,
178
286
  tokenEndpointAuthMethod: entry.grant.tokenEndpointAuthMethod ?? 'none',
179
287
  refreshToken: entry.grant.refreshToken,
180
- resource: entry.grant.authorizedResources[0],
288
+ // 多资源共享 Grant 不应在刷新时被第一个 resource 意外收窄。
289
+ resource: entry.grant.authorizedResources.length === 1 ? entry.grant.authorizedResources[0] : undefined,
181
290
  scope: entry.grant.scope,
182
291
  timeoutMs: config.requestTimeoutMs,
183
292
  });
293
+ const refreshedResources = extractTokenResources(token.accessToken);
184
294
  const next = {
185
295
  ...entry.grant,
186
296
  accessToken: token.accessToken,
187
- accessTokenExpiresAt: Date.now() + token.expiresIn * 1000 - config.refreshSkewMs,
297
+ // 持久化真实过期时间;提前刷新只用于定时调度,不能冒充 Token 已过期。
298
+ accessTokenExpiresAt: Date.now() + token.expiresIn * 1000,
188
299
  refreshToken: token.refreshToken ?? entry.grant.refreshToken,
300
+ authorizedResources: refreshedResources?.length ? refreshedResources : entry.grant.authorizedResources,
301
+ updatedAt: Date.now(),
189
302
  };
190
303
  entry.grant = next;
191
304
  entry.needsReauth = false;
305
+ entry.refreshFailureKind = null;
306
+ entry.refreshFailureCount = 0;
307
+ entry.lastRefreshError = null;
308
+ entry.refreshRetryAt = null;
192
309
  state.grants.set(grantKey, entry);
193
310
  if (config.persistSecrets) await grantStore.put(next);
194
311
  for (const [key, record] of state.connections) {
195
- 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
+ });
196
321
  }
197
322
  scheduleRefresh(grantKey);
198
323
  return next;
@@ -204,20 +329,61 @@ export async function apply(ctx, config) {
204
329
  }
205
330
  }
206
331
 
207
- function scheduleRefresh(grantKey) {
332
+ function scheduleRefresh(grantKey, { delayMs } = {}) {
208
333
  const entry = state.grants.get(grantKey);
209
334
  if (!entry) return;
210
335
  if (entry.timer) clearTimeout(entry.timer);
211
- 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);
212
344
  entry.timer = setTimeout(() => {
213
- refreshGrant(grantKey).catch((error) => {
214
- entry.needsReauth = true;
215
- logger.warn(`grant ${grantKey} refresh failed: ${error.message}`);
216
- });
345
+ entry.timer = undefined;
346
+ refreshGrantWithRecovery(grantKey, 'scheduled').catch(() => {});
217
347
  }, delay);
218
348
  entry.timer.unref?.();
219
349
  }
220
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
+
221
387
  async function retireGrantIfUnused(grantKey, { revoke = false } = {}) {
222
388
  if ([...state.connections.values()].some((record) => record.auth?.grantKey === grantKey)) return false;
223
389
  const entry = state.grants.get(grantKey);
@@ -375,14 +541,30 @@ export async function apply(ctx, config) {
375
541
 
376
542
  function grantFailure(record) {
377
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
+ }
378
549
  const grantKey = record.auth.grantKey;
379
550
  const entry = grantKey ? state.grants.get(grantKey) : null;
380
551
  if (!entry) return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权缺失,请重新授权' };
381
552
  const clientSecretExpired = entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
382
553
  && entry.grant.clientSecretExpiresAt * 1000 <= Date.now();
383
- if (entry.needsReauth || clientSecretExpired || entry.grant.accessTokenExpiresAt <= Date.now()) {
554
+ if (entry.needsReauth || clientSecretExpired) {
384
555
  return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权已过期或刷新失败,请重新授权' };
385
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
+ }
386
568
  return null;
387
569
  }
388
570
 
@@ -444,6 +626,7 @@ export async function apply(ctx, config) {
444
626
  const pendingServers = results.filter((result) => result.ok && result.kind === 'managed').length;
445
627
  const failedServers = results.filter((result) => !result.ok).length;
446
628
  const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
629
+ const refreshFailures = results.filter((result) => !result.ok && result.kind === 'refresh').length;
447
630
  let connectionState = 'configured';
448
631
  if (records.length === 0) connectionState = 'disconnected';
449
632
  else if (enabledRecords.length === 0) connectionState = 'disabled';
@@ -451,10 +634,11 @@ export async function apply(ctx, config) {
451
634
  else if (availableServers > 0 && (failedServers > 0 || pendingServers > 0)) connectionState = 'degraded';
452
635
  else if (pendingServers > 0 && failedServers === 0) connectionState = 'configured';
453
636
  else if (authFailures > 0) connectionState = 'reauth';
637
+ else if (refreshFailures > 0) connectionState = 'recovering';
454
638
  else if (failedServers > 0) connectionState = 'unavailable';
455
639
  const labels = {
456
640
  disconnected: '未连接', configured: '已配置', disabled: '已停用', healthy: '已连接',
457
- degraded: '部分异常', reauth: '需重新授权', unavailable: '连接异常',
641
+ degraded: '部分异常', reauth: '需重新授权', recovering: '自动重试中', unavailable: '连接异常',
458
642
  };
459
643
  return {
460
644
  connectorId,
@@ -519,6 +703,156 @@ export async function apply(ctx, config) {
519
703
  return env;
520
704
  }
521
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
+
522
856
  /* ───────────────────────── api 门面 ───────────────────────── */
523
857
 
524
858
  const api = {
@@ -578,18 +912,67 @@ export async function apply(ctx, config) {
578
912
  if (!connector) return { ok: false, message: `连接器 "${connectorId}" 不存在(可先 mcp_connector_refresh_catalog 或 mcp_connector_catalog 查看)` };
579
913
 
580
914
  if (connector.auth.mode === 'oauth2-pkce') {
581
- const inFlight = state.oauthConnects.get(connector.id);
582
- 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]);
583
928
  const promise = (async () => {
584
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));
585
956
  const previousGrantKeys = new Set(
586
957
  [...state.grants]
587
- .filter(([, entry]) => entry.grant.connectorIds?.includes(connector.id))
958
+ .filter(([, entry]) => entry.grant.connectorIds?.some((id) => sharingConnectorIds.has(id)))
588
959
  .map(([key]) => key),
589
960
  );
590
- const authz = await oauthAuthorize({ connector, config, logger, signal });
961
+ const authorizationConnector = oauthAuthorizationDescriptor(connector, sharingConnectors);
962
+ const authz = await oauthAuthorize({ connector: authorizationConnector, config, logger, signal });
591
963
  const grantKey = grantStore.keyFor(config.account, authz.issuer, authz.clientId, authz.scope);
592
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))];
593
976
  const grant = {
594
977
  key: grantKey,
595
978
  issuer: authz.issuer,
@@ -601,52 +984,27 @@ export async function apply(ctx, config) {
601
984
  scope: authz.scope,
602
985
  account: config.account,
603
986
  accessToken: authz.token.accessToken,
604
- accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000 - config.refreshSkewMs,
987
+ accessTokenExpiresAt: Date.now() + authz.token.expiresIn * 1000,
605
988
  refreshToken: authz.token.refreshToken ?? '',
606
- authorizedResources: connector.servers
607
- .filter((s) => authz.grantedKeys.includes(s.serverKey))
608
- .map((s) => s.url).length
609
- ? connector.servers.filter((s) => authz.grantedKeys.includes(s.serverKey)).map((s) => s.url)
610
- : [entryResource],
611
- connectorIds: [connector.id],
989
+ authorizedResources,
990
+ connectorIds: grantedConnectorIds,
612
991
  updatedAt: Date.now(),
613
992
  };
614
- state.grants.set(grantKey, { grant, timer: undefined, refreshPromise: null, needsReauth: false });
993
+ const runtime = grantRuntime(grant);
994
+ state.grants.set(grantKey, runtime);
615
995
  if (config.persistSecrets) await grantStore.put(grant);
616
-
617
- const records = [];
618
- for (const sk of authz.grantedKeys) {
619
- const server = connector.servers.find((s) => s.serverKey === sk);
620
- if (!server) continue;
621
- const record = {
622
- key: `${connector.id}-${sk}`,
623
- connectorId: connector.id,
624
- kind: 'oauth',
625
- name: connector.servers.length > 1 ? `${connector.name}·${sk}` : connector.name,
626
- serverKey: sk,
627
- transport: server.transport,
628
- url: server.url,
629
- serverName: server.serverName,
630
- headers: server.headers,
631
- auth: { mode: 'oauth', grantKey },
632
- enabled: true,
633
- createdAt: Date.now(),
634
- updatedAt: Date.now(),
635
- };
636
- records.push(record);
637
- }
638
- const committed = await commitConnectionRecords(records);
996
+ const committed = await attachConnectorsToGrant(grantKey, runtime, targetConnectors, requestedConnectorIds);
639
997
  const created = committed.map((record) => record.key);
640
- cacheHealth(connector.id, committed, readinessResults(committed));
641
- scheduleRefresh(grantKey);
642
998
  let retiredGrantCount = 0;
643
999
  for (const previousKey of previousGrantKeys) {
644
1000
  if (previousKey !== grantKey && await retireGrantIfUnused(previousKey, { revoke: true })) retiredGrantCount += 1;
645
1001
  }
646
1002
  return {
647
1003
  ok: true,
648
- message: `已连接 "${connector.name}"(${created.length} MCP server),共 ${created.length} 条`,
649
- 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 },
650
1008
  };
651
1009
  } catch (error) {
652
1010
  logger.error(`oauth connect failed: ${error.message}`);
@@ -656,11 +1014,11 @@ export async function apply(ctx, config) {
656
1014
  : { ok: false, message: `连接失败: ${error.message}` };
657
1015
  }
658
1016
  })();
659
- state.oauthConnects.set(connector.id, promise);
1017
+ state.oauthConnects.set(sharingKey, { promise, requestedConnectorIds });
660
1018
  try {
661
1019
  return await promise;
662
1020
  } finally {
663
- if (state.oauthConnects.get(connector.id) === promise) state.oauthConnects.delete(connector.id);
1021
+ if (state.oauthConnects.get(sharingKey)?.promise === promise) state.oauthConnects.delete(sharingKey);
664
1022
  }
665
1023
  }
666
1024
 
@@ -843,6 +1201,9 @@ export async function apply(ctx, config) {
843
1201
  grantKey: record.auth.grantKey,
844
1202
  accessTokenExpiresAt: g.grant.accessTokenExpiresAt,
845
1203
  needsReauth: !!g.needsReauth,
1204
+ refreshFailureKind: g.refreshFailureKind,
1205
+ lastRefreshError: g.lastRefreshError,
1206
+ refreshRetryAt: g.refreshRetryAt,
846
1207
  }
847
1208
  : { grantKey: record.auth.grantKey, missing: true };
848
1209
  }
@@ -924,7 +1285,7 @@ export async function apply(ctx, config) {
924
1285
  summaries.push(cacheHealth(id, records, [...immediateResults, ...checkedResults]));
925
1286
  }
926
1287
  const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
927
- 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;
928
1289
  return {
929
1290
  ok: true,
930
1291
  message: targetIds.length === 0
@@ -957,24 +1318,34 @@ export async function apply(ctx, config) {
957
1318
  if (plan.summary.alreadyMigrated) { skipped.push(`${plan.summary.connectorName}(已迁移)`); continue; }
958
1319
  const grantKey = grantStore.keyFor(config.account, plan.candidate.grant.issuer, plan.candidate.grant.clientId, plan.candidate.grant.scope);
959
1320
  const grant = toConnectorGrant(plan.candidate, { key: grantKey, account: config.account, connectorIds: [plan.connector.id] });
960
- state.grants.set(grantKey, { grant, timer: undefined, refreshPromise: null, needsReauth: false });
1321
+ state.grants.set(grantKey, grantRuntime(grant));
961
1322
  if (config.persistSecrets) await grantStore.put(grant);
962
1323
 
963
1324
  let enabled = true;
1325
+ let provisionNow = true;
964
1326
  let lastError;
1327
+ const legacyConflict = legacyConflictForConnector(plan.connector);
1328
+ if (legacyConflict) {
1329
+ warnLegacyConflict(legacyConflict);
1330
+ provisionNow = false;
1331
+ lastError = `${legacyConflict.message};授权已复制,重启后将由 MCP连接器恢复`;
1332
+ }
965
1333
  if (grant.accessTokenExpiresAt <= Date.now()) {
966
- try { await refreshGrant(grantKey); }
1334
+ try { await refreshGrantWithRecovery(grantKey, 'legacy-migration'); }
967
1335
  catch (error) {
968
- enabled = false;
969
- lastError = `旧授权已过期且刷新失败,请重新连接:${error instanceof Error ? error.message : String(error)}`;
970
- 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}`;
971
1342
  }
972
1343
  } else {
973
1344
  scheduleRefresh(grantKey);
974
1345
  }
975
1346
  const records = toConnectionRecords(plan, grantKey, { enabled, lastError });
976
1347
  for (const record of records) {
977
- if (enabled) await upsertRecord(record);
1348
+ if (enabled && provisionNow) await upsertRecord(record);
978
1349
  else await persistRecord(record);
979
1350
  }
980
1351
  migrated.push({ connectorId: plan.connector.id, keys: records.map((record) => record.key), enabled });
@@ -1207,6 +1578,7 @@ export async function apply(ctx, config) {
1207
1578
  /* ───────────────────────── 启动恢复 ───────────────────────── */
1208
1579
 
1209
1580
  await loadCatalog();
1581
+ detectLegacyPluginConflicts();
1210
1582
 
1211
1583
  for (const [key, raw] of await connectionStore.entries().catch(() => [])) {
1212
1584
  try {
@@ -1218,7 +1590,7 @@ export async function apply(ctx, config) {
1218
1590
  }
1219
1591
  for (const [key, raw] of await grantStore.entries().catch(() => [])) {
1220
1592
  try {
1221
- state.grants.set(key, { grant: raw, timer: undefined, refreshPromise: null, needsReauth: false });
1593
+ state.grants.set(key, grantRuntime(raw));
1222
1594
  } catch (error) {
1223
1595
  logger.warn(`skip invalid stored grant "${key}": ${error.message}`);
1224
1596
  }
@@ -1230,16 +1602,19 @@ export async function apply(ctx, config) {
1230
1602
  for (const [grantKey, g] of state.grants) {
1231
1603
  if (g.grant.accessTokenExpiresAt <= Date.now()) {
1232
1604
  try {
1233
- await refreshGrant(grantKey);
1234
- } catch {
1235
- g.needsReauth = true;
1236
- }
1605
+ await refreshGrantWithRecovery(grantKey, 'startup');
1606
+ } catch {}
1237
1607
  } else {
1238
1608
  scheduleRefresh(grantKey);
1239
1609
  }
1240
1610
  }
1241
1611
  for (const [key, record] of state.connections) {
1242
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
+ }
1243
1618
  await provision(ctx, config, record, grantMap()).catch((error) => {
1244
1619
  logger.warn(`provision "${key}" failed: ${error.message}`);
1245
1620
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
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",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "scripts": {
35
35
  "test": "node --test",
36
- "lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/sync-registry-stats.mjs",
36
+ "lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/sync-registry-stats.mjs",
37
37
  "registry:build": "node scripts/build-registry.mjs",
38
38
  "registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
39
39
  "dev:ui": "node scripts/ui-harness.mjs",
package/ui/index.html CHANGED
@@ -471,7 +471,7 @@
471
471
  const searchInput = $('#search');
472
472
  const LABELS = {
473
473
  details: '详情', connect: '连接', connected: '已连接', configured: '已配置', configure: '配置',
474
- reauthorize: '需重新授权', connectionError: '连接异常', partialError: '部分异常', disabled: '已停用',
474
+ reauthorize: '需重新授权', recovering: '自动重试中', connectionError: '连接异常', partialError: '部分异常', disabled: '已停用',
475
475
  send: '发送', fillSend: '填写并发送', connectToUse: '连接后使用', try: '💬 去试试',
476
476
  connectTry: '🔗 连接后试用', configureConnect: '🔑 录入凭据并连接',
477
477
  reconfigureCredential: '🔑 重新配置凭据', loading: '加载中…',
@@ -857,6 +857,7 @@
857
857
  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>`;
858
858
  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>`;
859
859
  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>`;
860
+ if (state === 'recovering') 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('recovering')}</button>`;
860
861
  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>`;
861
862
  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>`;
862
863
  }
@@ -886,8 +887,8 @@
886
887
  function rowHtml(r) {
887
888
  const key = esc(r.key);
888
889
  const authMode = r.authMode === 'oauth' ? '<span class="badge auth-oauth">OAuth</span>' : `<span class="badge">${esc(r.authMode || 'manual')}</span>`;
889
- const healthLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
890
- const healthClass = r.connectionState === 'healthy' ? 'pass' : ['reauth', 'degraded'].includes(r.connectionState) ? 'partial' : r.connectionState === 'unavailable' ? 'fail' : '';
890
+ const healthLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', recovering: '自动重试中', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
891
+ const healthClass = r.connectionState === 'healthy' ? 'pass' : ['reauth', 'recovering', 'degraded'].includes(r.connectionState) ? 'partial' : r.connectionState === 'unavailable' ? 'fail' : '';
891
892
  const healthBadge = `<span class="badge ${healthClass}">${esc(healthLabels[r.connectionState] || '已配置')}</span>`;
892
893
  const enabled = r.enabled !== false;
893
894
  return `<div class="row" data-key="${key}">
@@ -1208,7 +1209,7 @@
1208
1209
  if (!d) return;
1209
1210
  detailConnector = d;
1210
1211
  detailFailureState = d.connectionState || '';
1211
- detailNeedsReconfigure = ['reauth', 'unavailable'].includes(detailFailureState);
1212
+ detailNeedsReconfigure = ['reauth', 'recovering', 'unavailable'].includes(detailFailureState);
1212
1213
  const loadId = ++detailLoadId;
1213
1214
  $('#detail-icon').innerHTML = isImageIcon(d.icon)
1214
1215
  ? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`