dsh-mcp-connector 0.2.24 → 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.26] - 2026-08-27
8
+
9
+ ### Fixed
10
+
11
+ - 升级启动时自动发现并归并历史版本为同 issuer 卡片保存的多份 OAuth Grant;归并前先验证过期 Grant 是否仍可刷新,避免用较新但已失效的凭据覆盖可恢复授权。
12
+ - 多个 DSH 进程并发刷新同一组轮换式 Refresh Token 时,收到 `invalid_grant` 后会短暂重读本机持久化 Grant;发现其他进程已保存新 Token 时自动采用并重试,不再误报“需重新授权”。
13
+ - 页面不再盲信 Update Provider 的 `succeeded`;独立校验更新前、预期与实际版本,检测降级、解析版本偏差或无效结果时拒绝重启,并在 Provider 保留恢复点时自动回滚。
14
+
15
+ ### Verification
16
+
17
+ - 129 项自动测试、lint、npm 发布包白名单与敏感内容扫描通过;新增历史 Grant 安全归并、失效候选回退、跨进程 Token 轮换恢复和 Provider 版本完整性回归测试。
18
+
19
+ ## [0.2.25] - 2026-08-27
20
+
21
+ ### Added
22
+
23
+ - 新增 Update Provider 适配层,将版本发现与安装执行解耦;UI 通过统一能力调用更新检查、任务、回滚和重启,为 DSH 原生或其他插件市场预留适配边界。
24
+ - 首个 DSH Market API v1 Provider 适配器:能力探测通过后可一键更新插件,并轮询展示安装进度、稳定失败码、兼容回滚结果及按 Web/Desktop 宿主能力提供的刷新或重启操作。
25
+
26
+ ### Compatibility
27
+
28
+ - Provider 未安装、协议不兼容、广告跨源/无效端点或版本检查不一致时不调用私有更新路由,继续使用现有“前往插件市场”/npm 页面降级路径。
29
+
30
+ ### Verification
31
+
32
+ - 125 项自动测试、lint、npm 发布包白名单/敏感内容扫描通过;已在 web profile 的 dshmarket 1.33.0 上验证 `/dsh-market/api/v1/capabilities` 与 Connector 版本检查响应。
33
+
7
34
  ## [0.2.24] - 2026-08-27
8
35
 
9
36
  ### Added
@@ -399,7 +426,9 @@
399
426
  - 外部 URL 与导入 Header 执行安全校验。
400
427
  - iframe 消息校验同源和消息来源。
401
428
 
402
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.24...HEAD
429
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.26...HEAD
430
+ [0.2.26]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...v0.2.26
431
+ [0.2.25]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.24...v0.2.25
403
432
  [0.2.24]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...v0.2.24
404
433
  [0.2.23]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...v0.2.23
405
434
  [0.2.22]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.21...v0.2.22
package/README.en.md CHANGED
@@ -28,7 +28,7 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
28
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
- - Plugin version awareness: the MCP Connector title shows the installed version, while a server-side cached check compares npm `latest` with GitHub Releases and routes available updates through the DSH plugin marketplace.
31
+ - Plugin version and one-click updates: version discovery is independent of the installation source, while an Update Provider adapter layer negotiates safe mutation capabilities. DSH Market API v1 is the first adapter and supports progress, normalized failures, rollback, and capability-gated restart/refresh actions; without a compatible provider, the UI falls back to the current plugin marketplace or npm.
32
32
  - Explicit, non-destructive migration from the two earlier Qichacha OAuth plugins, plus active-plugin conflict detection that blocks duplicate server management and credential overwrites.
33
33
 
34
34
  <!-- catalog-stats:start -->
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
  - 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 自动刷新/退避恢复与撤销;同 issuer 卡片可共享一次授权,DCR 返回的客户端密钥与 Token 一同只保存在本机。
29
29
  - 目录运营:内置目录、远程 registry、本地覆盖,支持 `published` 上下架与 `featured` 精选。
30
30
  - 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
31
- - 插件版本感知:MCP连接器标题显示当前版本,服务端缓存检查 npm GitHub Release;发现 npm 新版本时引导到 DSH 插件市场更新。
31
+ - 插件版本与一键更新:版本发现独立于安装来源;页面通过 Update Provider 适配层探测安全更新能力。DSH Market API v1 是首个适配器,支持进度、稳定失败码、回滚及按宿主能力提供的重启/刷新操作;无可用 Provider 时回退到当前插件市场或 npm。
32
32
  - Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
33
33
  - 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;检测到旧插件仍启用并管理同名 Server 时阻断重复连接,避免凭据相互覆盖。
34
34
  - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
@@ -0,0 +1,35 @@
1
+ # MCP连接器插件更新协议
2
+
3
+ MCP连接器把“刷新连接器目录”和“升级插件代码”视为两个独立动作:前者只重新拉取 Registry 卡片,后者必须由当前 DSH 宿主中可用的安全 Update Provider 执行。
4
+
5
+ ## 分层与 Provider 适配
6
+
7
+ 版本发现与更新执行彻底分离:
8
+
9
+ - MCP连接器自己的 Host 检查 npm `latest` 与 GitHub Release,所有安装方式都可显示已安装版本和新版本提示。
10
+ - 客户端通过 `UPDATE_PROVIDER_ADAPTERS` 按顺序探测 Provider。UI 只调用统一的 `probe` / `check` / `start` / `operation` / `rollback` / `restart` 能力,不持有市场私有路由。
11
+ - DSH Market API v1 是首个 Provider 适配器。后续可在不改动更新 UI 状态机的前提下增加 DSH 原生能力或其他插件市场的版本化协议适配器。
12
+
13
+ ## 能力协商
14
+
15
+ 每个 Provider 适配器只硬编码自己的 capabilities 入口,其余更新、任务、回滚和重启端点均从能力响应中读取。客户端只接受当前 DSH 页面的相对同源端点,拒绝跨域 URL、协议不匹配、缺少必要端点或未声明 `features.update` 的 Provider。
16
+
17
+ DSH Market 适配器从 `GET /dsh-market/api/v1/capabilities` 开始,要求 `schema: dsh-market/update-api/v1` 与 `apiVersion: 1`。未安装任何可用 Provider、接口返回 404、协议不兼容或检查失败时,页面不会尝试私有更新路由,而是回退到“前往插件市场”;设置入口不可用时再打开 npm 包页面。
18
+
19
+ ## 更新流程
20
+
21
+ 1. 插件自己的服务端缓存检查 npm `latest` 与 GitHub Release,只在 npm 已有可安装新版本时提示升级。
22
+ 2. 选中的 Provider 再检查本 profile 中 `dsh-mcp-connector` 的实际安装版本和目标版本。
23
+ 3. 用户点击“一键更新”后,Provider 立即返回 `operationId`;页面通过适配器轮询任务状态,展示排队、解析、下载和安装进度。
24
+ 4. Provider 报告成功后,页面仍会独立校验 `beforeVersion`、点击时的预期版本与 `installedVersion`。降级、目标不一致或无效结果会被拒绝;Provider 保留恢复点时自动回滚,不向用户展示重启。
25
+ 5. 完整性校验通过后,按 Provider 返回的激活结论展示“刷新生效”或重启提示。Web 且 Provider 明确允许进程重启时才展示“立即重启”;Desktop 等由外壳管理生命周期的宿主只提示用户重启,不自行杀进程。
26
+ 6. Provider 保留恢复点时展示“回滚”。回滚仍由 Provider 执行,MCP连接器不直接修改 profile、lockfile 或 `node_modules`。
27
+
28
+ 失败结果使用稳定代码区分正在运行的 Agent、其他安装任务占用、镜像同步、版本未变化、超时和权限拒绝,以及 `DOWNGRADE_DETECTED`、`RESOLVED_VERSION_MISMATCH`、`INVALID_UPDATE_RESULT` 三类完整性故障。可重试失败保留重试操作;Registry 刚发布但镜像仍未同步时,用户可在看到明确提示后选择强制重试。
29
+
30
+ ## 安全边界
31
+
32
+ - 所有变更请求均为浏览器同源 POST,并继续接受 Provider 自身的 Origin、环回地址和运行中任务保护。
33
+ - MCP连接器不执行 `npm`/`pnpm`、不保存回滚 Token,也不复制任何 Provider 的安装算法。
34
+ - 重启按钮完全服从能力响应;接口未声明支持时不得构造其他进程控制路径。
35
+ - 更新任务属于当前 Host 进程。Host 已更换后,旧页面的任务编号自然失效,避免把旧进度误认为新进程状态。
@@ -42,7 +42,7 @@ dsh web
42
42
  - **已安装**:查看已经保存到本机的连接,页签徽标显示连接数。
43
43
  - **搜索**:按名称、服务商、简介和标签查找。
44
44
  - **添加连接**:导入 JSON、手动配置 HTTP/stdio,或从市场描述 URL 安装。
45
- - **版本与更新提示**:标题旁显示当前插件版本。检测到 npm 有新版本时,点击更新提示前往 DSH 插件市场执行安全升级;升级后按页面提示完全重启 DSH。
45
+ - **版本与更新提示**:标题旁显示当前插件版本,版本检查不依赖安装所用的插件市场。检测到 npm 有新版本且当前宿主存在兼容 Update Provider 时,可在当前页面一键更新并查看进度、失败原因和可用回滚。DSH Market API v1 是当前首个 Provider;无可用 Provider 时自动回退为“前往插件市场”或 npm 页面。
46
46
  - **刷新连接器目录**:重新拉取 Registry 卡片,并更新连接健康状态;该操作不会升级 MCP连接器插件本身。
47
47
 
48
48
  ## 3. 浏览市场与分类
@@ -75,7 +75,7 @@ stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token
75
75
 
76
76
  OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。
77
77
 
78
- 当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”,明确收到 Refresh Token/客户端失效错误时才进入“需重新授权”。
78
+ 当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。升级时会验证并自动归并旧版本留下的同 issuer 多份 Grant;多个 DSH 进程并发轮换 Refresh Token 时也会重读较新的本机持久化凭据后自动恢复。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”,确认本机也没有其他进程保存的新 Token,且明确收到 Refresh Token/客户端失效错误时,才进入“需重新授权”。
79
79
 
80
80
  ## 5. 查看详情、Prompt 与工具
81
81
 
package/lib/client.js CHANGED
@@ -22,6 +22,17 @@ window.__ModuleLoader__.load({
22
22
  const VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
23
23
  const VERSION_CHECK_RETRY_MS = 5 * 60 * 1e3;
24
24
  const NPM_PACKAGE_URL = "https://www.npmjs.com/package/dsh-mcp-connector";
25
+ const PLUGIN_PACKAGE_NAME = "dsh-mcp-connector";
26
+ const UPDATE_PROVIDER_OPERATION_POLL_MS = 1e3;
27
+ const UPDATE_PROVIDER_ADAPTERS = Object.freeze([
28
+ createHttpUpdateProviderAdapter({
29
+ id: "dsh-market-v1",
30
+ label: "DSH Market",
31
+ schema: "dsh-market/update-api/v1",
32
+ apiVersion: 1,
33
+ capabilitiesUrl: "/dsh-market/api/v1/capabilities"
34
+ })
35
+ ]);
25
36
 
26
37
  /**
27
38
  * Desktop 内置的社区插件市场也注册在 sidebar.footer.action。
@@ -114,6 +125,37 @@ window.__ModuleLoader__.load({
114
125
  outline: 2px solid #6366f1;
115
126
  outline-offset: 1px;
116
127
  }
128
+ .mcpConnectorUpdateButton:disabled {
129
+ opacity: 0.65;
130
+ cursor: wait;
131
+ }
132
+ .mcpConnectorUpdateControls {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 6px;
136
+ min-width: 0;
137
+ }
138
+ .mcpConnectorUpdateStatus {
139
+ max-width: 240px;
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ white-space: nowrap;
143
+ color: #166534;
144
+ background: #f0fdf4;
145
+ border-radius: 999px;
146
+ padding: 3px 8px;
147
+ font-size: 12px;
148
+ font-weight: 600;
149
+ }
150
+ .mcpConnectorUpdateStatus[data-tone="error"] {
151
+ color: #b91c1c;
152
+ background: #fef2f2;
153
+ }
154
+ .mcpConnectorUpdateSecondary {
155
+ color: #4b5563;
156
+ background: transparent;
157
+ border-color: #d1d5db;
158
+ }
117
159
  .mcpConnectorMarketClose { color: #6b7280; }
118
160
  .mcpConnectorMarketClose:hover,
119
161
  .mcpConnectorMarketClose:focus-visible {
@@ -131,6 +173,9 @@ window.__ModuleLoader__.load({
131
173
  .mcpConnectorUpdateButton { color: #c7d2fe; background: #312e81; border-color: #4f46e5; }
132
174
  .mcpConnectorUpdateButton:hover,
133
175
  .mcpConnectorUpdateButton:focus-visible { background: #3730a3; }
176
+ .mcpConnectorUpdateStatus { color: #bbf7d0; background: #14532d; }
177
+ .mcpConnectorUpdateStatus[data-tone="error"] { color: #fecaca; background: #7f1d1d; }
178
+ .mcpConnectorUpdateSecondary { color: #d1d5db; background: transparent; border-color: #4b5563; }
134
179
  .mcpConnectorMarketClose { color: #c4c8d0; }
135
180
  .mcpConnectorMarketClose:hover,
136
181
  .mcpConnectorMarketClose:focus-visible { background: #30343d !important; color: #ffffff; }
@@ -180,6 +225,219 @@ window.__ModuleLoader__.load({
180
225
  return result.detail;
181
226
  }
182
227
 
228
+ /**
229
+ * 更新 Provider 只能广告当前 DSH 页面的同源端点。这个边界既避免
230
+ * Connector 跨域传递安装指令,也使后续市场适配器可复用同一客户端。
231
+ */
232
+ function sameOriginProviderEndpoint(value) {
233
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
234
+ try {
235
+ const url = new URL(value, window.location.origin);
236
+ if (url.origin !== window.location.origin || url.search !== "" || url.hash !== "") return null;
237
+ return url.pathname;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 把一个版本化的同源 HTTP 更新协议适配为 Connector 内部通用能力。
245
+ * UI 不接触供应商路由或响应封装;新市场只需新增适配器。
246
+ */
247
+ function createHttpUpdateProviderAdapter(options) {
248
+ const { id, label, schema, apiVersion, capabilitiesUrl } = options;
249
+ async function readResponse(response) {
250
+ let result;
251
+ try {
252
+ result = await response.json();
253
+ } catch {
254
+ result = null;
255
+ }
256
+ if (!response.ok) {
257
+ const error = new Error(result?.error ?? result?.failure?.message ?? `HTTP ${response.status}`);
258
+ error.code = result?.failure?.code ?? `HTTP_${response.status}`;
259
+ error.retryable = result?.failure?.retryable === true;
260
+ throw error;
261
+ }
262
+ if (result?.schema !== schema) throw new Error(`${label} 更新接口版本不兼容`);
263
+ return result;
264
+ }
265
+
266
+ async function post(path, body) {
267
+ return readResponse(await window.fetch(path, {
268
+ method: "POST",
269
+ headers: { accept: "application/json", "content-type": "application/json" },
270
+ body: JSON.stringify(body)
271
+ }));
272
+ }
273
+
274
+ return Object.freeze({
275
+ id,
276
+ label,
277
+ async probe() {
278
+ const response = await window.fetch(capabilitiesUrl, { headers: { accept: "application/json" } });
279
+ if (response.status === 404) return null;
280
+ const result = await readResponse(response);
281
+ if (result.apiVersion !== apiVersion || result.features?.update !== true) return null;
282
+ const endpoints = {
283
+ updates: sameOriginProviderEndpoint(result.endpoints?.updates),
284
+ operations: sameOriginProviderEndpoint(result.endpoints?.operations),
285
+ rollback: sameOriginProviderEndpoint(result.endpoints?.rollback),
286
+ restart: sameOriginProviderEndpoint(result.endpoints?.restart)
287
+ };
288
+ if (endpoints.updates === null || endpoints.operations === null) return null;
289
+ if (result.features?.rollback === true && endpoints.rollback === null) return null;
290
+ if (result.restart?.supported === true && endpoints.restart === null) return null;
291
+ return {
292
+ providerId: id,
293
+ providerLabel: label,
294
+ runtime: result.runtime,
295
+ features: result.features,
296
+ restart: result.restart,
297
+ endpoints
298
+ };
299
+ },
300
+ async check(capabilities, packageName, force = false) {
301
+ const query = new URLSearchParams({ name: packageName });
302
+ if (force) query.set("force", "1");
303
+ const result = await readResponse(await window.fetch(`${capabilities.endpoints.updates}?${query}`, {
304
+ headers: { accept: "application/json" }
305
+ }));
306
+ return result.package;
307
+ },
308
+ async start(capabilities, packageName, force = false) {
309
+ const result = await post(capabilities.endpoints.updates, {
310
+ packageName,
311
+ ...(force ? { force: true } : {})
312
+ });
313
+ if (typeof result.operation?.operationId !== "string") {
314
+ throw new Error(`${label} 未返回更新任务编号`);
315
+ }
316
+ return result.operation;
317
+ },
318
+ async operation(capabilities, operationId) {
319
+ const query = new URLSearchParams({ operationId });
320
+ const result = await readResponse(await window.fetch(`${capabilities.endpoints.operations}?${query}`, {
321
+ headers: { accept: "application/json" }
322
+ }));
323
+ return result.operation;
324
+ },
325
+ async rollback(capabilities, operationId) {
326
+ if (capabilities.endpoints.rollback === null) throw new Error(`${label} 不支持回滚`);
327
+ const result = await post(capabilities.endpoints.rollback, { operationId });
328
+ return result.operation;
329
+ },
330
+ async restart(capabilities) {
331
+ if (capabilities.endpoints.restart === null) throw new Error(`${label} 不支持重启`);
332
+ return post(capabilities.endpoints.restart, {});
333
+ }
334
+ });
335
+ }
336
+
337
+ async function discoverUpdateProvider(packageName, force = false) {
338
+ for (const provider of UPDATE_PROVIDER_ADAPTERS) {
339
+ try {
340
+ const capabilities = await provider.probe();
341
+ if (capabilities === null) continue;
342
+ const update = await provider.check(capabilities, packageName, force);
343
+ return { provider, capabilities, update };
344
+ } catch (error) {
345
+ console.warn(`[mcp-connector] ${provider.id} update provider unavailable:`, error);
346
+ }
347
+ }
348
+ return null;
349
+ }
350
+
351
+ function updateProgressLabel(operation) {
352
+ if (operation?.state === "queued") return "更新任务排队中…";
353
+ const percent = operation?.progress?.percent;
354
+ if (typeof percent === "number") return `正在更新 ${percent}%`;
355
+ const phase = operation?.progress?.phase;
356
+ if (typeof phase === "string" && phase !== "") return `正在更新:${phase}`;
357
+ return "正在更新…";
358
+ }
359
+
360
+ function updateFailureLabel(failure) {
361
+ const labels = {
362
+ AGENTS_RUNNING: "有任务正在运行,请结束后重试",
363
+ OPERATION_BUSY: "插件市场正在处理其他任务",
364
+ RELEASE_TOO_FRESH: "新版本仍在镜像同步",
365
+ VERSION_UNCHANGED: "下载后版本未变化",
366
+ DOWNGRADE_DETECTED: "已阻止版本降级",
367
+ RESOLVED_VERSION_MISMATCH: "下载版本与预期不一致",
368
+ INVALID_UPDATE_RESULT: "更新服务未返回有效版本",
369
+ UPDATE_TIMEOUT: "更新超时",
370
+ UPDATE_FORBIDDEN: "更新请求被拒绝",
371
+ UPDATE_REJECTED: "无法执行本次更新"
372
+ };
373
+ return labels[failure?.code] ?? failure?.message ?? "更新失败";
374
+ }
375
+
376
+ function parseClientVersion(value) {
377
+ if (typeof value !== "string") return null;
378
+ const normalized = value.trim().replace(/^v/i, "");
379
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized)) return null;
380
+ const [withoutBuild] = normalized.split("+");
381
+ const [core, prerelease] = withoutBuild.split("-", 2);
382
+ return { normalized, core: core.split(".").map(Number), prerelease: prerelease?.split(".") ?? [] };
383
+ }
384
+
385
+ function compareClientVersions(left, right) {
386
+ const a = parseClientVersion(left);
387
+ const b = parseClientVersion(right);
388
+ if (a === null || b === null) return null;
389
+ for (let index = 0; index < 3; index += 1) {
390
+ if (a.core[index] !== b.core[index]) return a.core[index] - b.core[index];
391
+ }
392
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1;
393
+ if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1;
394
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
395
+ for (let index = 0; index < length; index += 1) {
396
+ const aPart = a.prerelease[index];
397
+ const bPart = b.prerelease[index];
398
+ if (aPart === void 0) return -1;
399
+ if (bPart === void 0) return 1;
400
+ if (aPart === bPart) continue;
401
+ const aNumeric = /^\d+$/.test(aPart);
402
+ const bNumeric = /^\d+$/.test(bPart);
403
+ if (aNumeric && bNumeric) return Number(aPart) - Number(bPart);
404
+ if (aNumeric) return -1;
405
+ if (bNumeric) return 1;
406
+ return aPart.localeCompare(bPart);
407
+ }
408
+ return 0;
409
+ }
410
+
411
+ /** Independently verify a provider's terminal success before asking the user to restart. */
412
+ function completedUpdateIntegrityFailure(operation, expectedVersion) {
413
+ if (operation?.state !== "succeeded") return null;
414
+ const installed = parseClientVersion(operation.installedVersion);
415
+ if (installed === null) {
416
+ return {
417
+ code: "INVALID_UPDATE_RESULT",
418
+ message: "更新服务报告成功,但未返回有效的已安装版本",
419
+ retryable: false
420
+ };
421
+ }
422
+ const before = parseClientVersion(operation.beforeVersion);
423
+ if (before !== null && compareClientVersions(installed.normalized, before.normalized) < 0) {
424
+ return {
425
+ code: "DOWNGRADE_DETECTED",
426
+ message: `更新服务将插件从 v${before.normalized} 降级到 v${installed.normalized}`,
427
+ retryable: false
428
+ };
429
+ }
430
+ const expected = parseClientVersion(expectedVersion);
431
+ if (expected !== null && compareClientVersions(installed.normalized, expected.normalized) !== 0) {
432
+ return {
433
+ code: "RESOLVED_VERSION_MISMATCH",
434
+ message: `预期安装 v${expected.normalized},更新服务实际安装了 v${installed.normalized}`,
435
+ retryable: true
436
+ };
437
+ }
438
+ return null;
439
+ }
440
+
183
441
  /**
184
442
  * DSH 暂未公开“打开指定设置分区”服务:只依赖稳定的 sidebar.settings
185
443
  * slot 和可访问文本打开插件市场,不依赖构建生成的 CSS 类名。
@@ -258,6 +516,15 @@ window.__ModuleLoader__.load({
258
516
  const open = useStore((state) => state.open);
259
517
  const detailOpen = useStore((state) => state.detailOpen);
260
518
  const [versionStatus, setVersionStatus] = (0, react.useState)(null);
519
+ const [updateProviderState, setUpdateProviderState] = (0, react.useState)("checking");
520
+ const [updateProviderSelection, setUpdateProviderSelection] = (0, react.useState)(null);
521
+ const [providerUpdateCheck, setProviderUpdateCheck] = (0, react.useState)(null);
522
+ const [expectedUpdateVersion, setExpectedUpdateVersion] = (0, react.useState)(null);
523
+ const [updateOperation, setUpdateOperation] = (0, react.useState)(null);
524
+ const [updateError, setUpdateError] = (0, react.useState)(null);
525
+ const [updateStarting, setUpdateStarting] = (0, react.useState)(false);
526
+ const [rollbackRunning, setRollbackRunning] = (0, react.useState)(false);
527
+ const [restarting, setRestarting] = (0, react.useState)(false);
261
528
  const frameRef = (0, react.useRef)(null);
262
529
  const closeRef = (0, react.useRef)(null);
263
530
  (0, react.useEffect)(() => {
@@ -328,6 +595,93 @@ window.__ModuleLoader__.load({
328
595
  window.clearTimeout(pollTimer);
329
596
  };
330
597
  }, [open]);
598
+ (0, react.useEffect)(() => {
599
+ if (!open) return void 0;
600
+ let disposed = false;
601
+ setUpdateProviderState("checking");
602
+ discoverUpdateProvider(PLUGIN_PACKAGE_NAME, true).then((selection) => {
603
+ if (disposed) return;
604
+ if (selection === null) {
605
+ setUpdateProviderSelection(null);
606
+ setProviderUpdateCheck(null);
607
+ setUpdateProviderState("unavailable");
608
+ return;
609
+ }
610
+ setUpdateProviderSelection(selection);
611
+ setProviderUpdateCheck(selection.update);
612
+ setUpdateProviderState("ready");
613
+ }).catch((error) => {
614
+ console.warn("[mcp-connector] update provider discovery failed:", error);
615
+ if (disposed) return;
616
+ setUpdateProviderSelection(null);
617
+ setProviderUpdateCheck(null);
618
+ setUpdateProviderState("unavailable");
619
+ });
620
+ return () => { disposed = true; };
621
+ }, [open]);
622
+ (0, react.useEffect)(() => {
623
+ const operationId = updateOperation?.operationId;
624
+ const provider = updateProviderSelection?.provider;
625
+ const capabilities = updateProviderSelection?.capabilities;
626
+ if (!open || provider === void 0 || capabilities === void 0
627
+ || typeof operationId !== "string" || !["queued", "running"].includes(updateOperation.state)) {
628
+ return void 0;
629
+ }
630
+ let disposed = false;
631
+ let timer;
632
+ const poll = () => {
633
+ provider.operation(capabilities, operationId).then((operation) => {
634
+ if (disposed) return;
635
+ setUpdateError(null);
636
+ const integrityFailure = completedUpdateIntegrityFailure(operation, expectedUpdateVersion);
637
+ if (integrityFailure !== null) {
638
+ const failedOperation = { ...operation, state: "failed", failure: integrityFailure };
639
+ setUpdateOperation(failedOperation);
640
+ const canRollback = capabilities.features?.rollback === true
641
+ && operation.outcome?.rollback?.available === true
642
+ && capabilities.endpoints.rollback !== null;
643
+ if (!canRollback) return;
644
+ setRollbackRunning(true);
645
+ provider.rollback(capabilities, operationId).then((rolledBackOperation) => {
646
+ if (disposed) return;
647
+ setRollbackRunning(false);
648
+ setUpdateOperation({ ...rolledBackOperation, integrityFailure });
649
+ }, (error) => {
650
+ console.error("[mcp-connector] automatic rollback failed:", error);
651
+ if (disposed) return;
652
+ const detail = error instanceof Error ? error.message : String(error);
653
+ setRollbackRunning(false);
654
+ setUpdateOperation({
655
+ ...failedOperation,
656
+ outcome: {
657
+ ...failedOperation.outcome,
658
+ rollback: { available: true, state: "failed", detail }
659
+ }
660
+ });
661
+ });
662
+ return;
663
+ }
664
+ setUpdateOperation(operation);
665
+ if (["queued", "running"].includes(operation.state)) {
666
+ timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS);
667
+ return;
668
+ }
669
+ if (operation.state === "succeeded") {
670
+ provider.check(capabilities, PLUGIN_PACKAGE_NAME, true).then(setProviderUpdateCheck, () => {});
671
+ }
672
+ }, (error) => {
673
+ console.warn("[mcp-connector] update operation polling failed:", error);
674
+ if (disposed) return;
675
+ setUpdateError(error instanceof Error ? error.message : String(error));
676
+ timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS * 2);
677
+ });
678
+ };
679
+ timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS);
680
+ return () => {
681
+ disposed = true;
682
+ window.clearTimeout(timer);
683
+ };
684
+ }, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id, expectedUpdateVersion]);
331
685
  (0, react.useEffect)(() => {
332
686
  if (!open) return void 0;
333
687
  closeRef.current?.focus?.();
@@ -336,6 +690,206 @@ window.__ModuleLoader__.load({
336
690
  return () => { window.removeEventListener("keydown", onKeyDown); };
337
691
  }, [open, actions]);
338
692
  if (!open) return null;
693
+ const updateRunning = updateStarting || updateOperation !== null && ["queued", "running"].includes(updateOperation.state);
694
+ const startUpdate = (force = false) => {
695
+ const provider = updateProviderSelection?.provider;
696
+ const capabilities = updateProviderSelection?.capabilities;
697
+ if (updateRunning || provider === void 0 || capabilities === void 0) return;
698
+ setUpdateError(null);
699
+ setUpdateStarting(true);
700
+ setExpectedUpdateVersion(providerUpdateCheck?.latestVersion ?? versionStatus?.latestVersion ?? null);
701
+ provider.start(capabilities, PLUGIN_PACKAGE_NAME, force).then((operation) => {
702
+ setUpdateStarting(false);
703
+ setUpdateOperation(operation);
704
+ }, (error) => {
705
+ console.error("[mcp-connector] update start failed:", error);
706
+ setUpdateStarting(false);
707
+ if (error?.retryable === true) {
708
+ setUpdateOperation({
709
+ state: "failed",
710
+ failure: {
711
+ code: error.code ?? "UPDATE_FAILED",
712
+ message: error instanceof Error ? error.message : String(error),
713
+ retryable: true
714
+ },
715
+ outcome: { rollback: { available: false, state: "unavailable" } }
716
+ });
717
+ } else {
718
+ setUpdateError(error instanceof Error ? error.message : String(error));
719
+ }
720
+ });
721
+ };
722
+ const rollbackUpdate = () => {
723
+ const provider = updateProviderSelection?.provider;
724
+ const capabilities = updateProviderSelection?.capabilities;
725
+ if (rollbackRunning || provider === void 0 || capabilities === void 0
726
+ || typeof updateOperation?.operationId !== "string") return;
727
+ setUpdateError(null);
728
+ setRollbackRunning(true);
729
+ provider.rollback(capabilities, updateOperation.operationId).then((operation) => {
730
+ setRollbackRunning(false);
731
+ setUpdateOperation(operation);
732
+ }, (error) => {
733
+ console.error("[mcp-connector] rollback failed:", error);
734
+ setRollbackRunning(false);
735
+ setUpdateError(error instanceof Error ? error.message : String(error));
736
+ });
737
+ };
738
+ const restartHost = () => {
739
+ const provider = updateProviderSelection?.provider;
740
+ const capabilities = updateProviderSelection?.capabilities;
741
+ if (restarting || provider === void 0 || capabilities === void 0) return;
742
+ setRestarting(true);
743
+ setUpdateError(null);
744
+ provider.restart(capabilities).catch((error) => {
745
+ console.error("[mcp-connector] restart failed:", error);
746
+ setRestarting(false);
747
+ setUpdateError(error instanceof Error ? error.message : String(error));
748
+ });
749
+ };
750
+ const renderUpdateControls = () => {
751
+ if (!versionStatus?.updateAvailable && updateOperation === null && updateError === null) return null;
752
+ if (updateRunning) {
753
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
754
+ className: "mcpConnectorUpdateControls",
755
+ children: [
756
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
757
+ type: "button",
758
+ className: "mcpConnectorUpdateButton",
759
+ disabled: true,
760
+ children: updateStarting ? "正在启动更新…" : updateProgressLabel(updateOperation)
761
+ }),
762
+ updateError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
763
+ className: "mcpConnectorUpdateStatus",
764
+ "data-tone": "error",
765
+ title: updateError,
766
+ children: "暂时无法读取进度,正在重试"
767
+ })
768
+ ]
769
+ });
770
+ }
771
+ if (updateOperation?.state === "failed") {
772
+ const force = ["RELEASE_TOO_FRESH", "VERSION_UNCHANGED"].includes(updateOperation.failure?.code);
773
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
774
+ className: "mcpConnectorUpdateControls",
775
+ children: [
776
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
777
+ className: "mcpConnectorUpdateStatus",
778
+ "data-tone": "error",
779
+ title: updateOperation.failure?.message ?? "更新失败",
780
+ children: updateFailureLabel(updateOperation.failure)
781
+ }),
782
+ updateOperation.failure?.retryable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
783
+ type: "button",
784
+ className: "mcpConnectorUpdateButton",
785
+ onClick: () => startUpdate(force),
786
+ children: force ? "强制重试" : "重试更新"
787
+ }),
788
+ rollbackRunning && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
789
+ className: "mcpConnectorUpdateStatus",
790
+ children: "检测到异常版本,正在自动回滚…"
791
+ }),
792
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
793
+ type: "button",
794
+ className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
795
+ onClick: () => openDshPluginMarket(actions),
796
+ children: "打开插件市场"
797
+ })
798
+ ]
799
+ });
800
+ }
801
+ if (["succeeded", "rolled-back"].includes(updateOperation?.state)) {
802
+ const rolledBack = updateOperation.state === "rolled-back";
803
+ const restartRequired = updateOperation.outcome?.restartRequired === true;
804
+ const refreshRequired = updateOperation.outcome?.refreshRequired === true;
805
+ const rollbackFailed = updateOperation.outcome?.rollback?.state === "failed";
806
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
807
+ className: "mcpConnectorUpdateControls",
808
+ children: [
809
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
810
+ className: "mcpConnectorUpdateStatus",
811
+ children: rolledBack
812
+ ? updateOperation.integrityFailure === void 0 ? "已回滚" : "已阻止异常版本并回滚"
813
+ : `已安装 v${updateOperation.installedVersion ?? versionStatus?.latestVersion}`
814
+ }),
815
+ rollbackFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
816
+ className: "mcpConnectorUpdateStatus",
817
+ "data-tone": "error",
818
+ title: updateOperation.outcome.rollback.detail ?? "回滚失败",
819
+ children: "回滚失败"
820
+ }),
821
+ restartRequired && updateProviderSelection?.capabilities?.restart?.supported === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
822
+ type: "button",
823
+ className: "mcpConnectorUpdateButton",
824
+ disabled: restarting,
825
+ onClick: restartHost,
826
+ children: restarting ? "正在重启…" : "立即重启"
827
+ }),
828
+ restartRequired && updateProviderSelection?.capabilities?.restart?.supported !== true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
829
+ className: "mcpConnectorUpdateStatus",
830
+ title: `重启由 ${updateProviderSelection?.capabilities?.restart?.managedBy ?? "宿主"} 管理`,
831
+ children: updateProviderSelection?.capabilities?.runtime === "desktop" ? "请重启 DSH Desktop" : "请重启 DSH"
832
+ }),
833
+ !restartRequired && refreshRequired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
834
+ type: "button",
835
+ className: "mcpConnectorUpdateButton",
836
+ onClick: () => window.location.reload(),
837
+ children: "刷新生效"
838
+ }),
839
+ updateOperation.outcome?.rollback?.available === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
840
+ type: "button",
841
+ className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
842
+ disabled: rollbackRunning,
843
+ onClick: rollbackUpdate,
844
+ children: rollbackRunning ? "正在回滚…" : rollbackFailed ? "重试回滚" : "回滚"
845
+ })
846
+ ]
847
+ });
848
+ }
849
+ if (updateError !== null) {
850
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
851
+ className: "mcpConnectorUpdateControls",
852
+ children: [
853
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
854
+ className: "mcpConnectorUpdateStatus",
855
+ "data-tone": "error",
856
+ title: updateError,
857
+ children: "更新服务调用失败"
858
+ }),
859
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
860
+ type: "button",
861
+ className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
862
+ onClick: () => openDshPluginMarket(actions),
863
+ children: "打开插件市场"
864
+ })
865
+ ]
866
+ });
867
+ }
868
+ if (updateProviderState === "checking") {
869
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
870
+ type: "button",
871
+ className: "mcpConnectorUpdateButton",
872
+ disabled: true,
873
+ children: "正在准备更新…"
874
+ });
875
+ }
876
+ if (updateProviderState === "ready" && providerUpdateCheck?.updateAvailable === true) {
877
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
878
+ type: "button",
879
+ className: "mcpConnectorUpdateButton",
880
+ title: `由 ${updateProviderSelection?.provider?.label ?? "更新服务"} 安全更新到 v${providerUpdateCheck.latestVersion}`,
881
+ onClick: () => startUpdate(false),
882
+ children: `一键更新到 v${providerUpdateCheck.latestVersion}`
883
+ });
884
+ }
885
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
886
+ type: "button",
887
+ className: "mcpConnectorUpdateButton",
888
+ title: `npm 已发布 v${versionStatus?.latestVersion}`,
889
+ onClick: () => openDshPluginMarket(actions),
890
+ children: `前往插件市场更新到 v${versionStatus?.latestVersion}`
891
+ });
892
+ };
339
893
  const src = window.location.origin + "/mcp-connector/ui/";
340
894
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
341
895
  style: {
@@ -379,7 +933,7 @@ window.__ModuleLoader__.load({
379
933
  },
380
934
  children: [
381
935
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
382
- style: { display: "flex", alignItems: "center", gap: 10 },
936
+ style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", minWidth: 0, flex: 1 },
383
937
  children: [
384
938
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { fontSize: 22 }, children: "\u{1F9E9}" }),
385
939
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { id: "mcp-connector-market-title", className: "mcpConnectorMarketTitle", style: { fontSize: 18, fontWeight: 600 }, children: "MCP连接器" }),
@@ -388,13 +942,7 @@ window.__ModuleLoader__.load({
388
942
  title: versionStatus === null ? "正在检查插件版本" : `当前版本 v${versionStatus.installedVersion}`,
389
943
  children: versionStatus === null ? "v…" : `v${versionStatus.installedVersion}`
390
944
  }),
391
- versionStatus?.updateAvailable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
392
- type: "button",
393
- className: "mcpConnectorUpdateButton",
394
- title: `npm 已发布 v${versionStatus.latestVersion}`,
395
- onClick: () => openDshPluginMarket(actions),
396
- children: `前往插件市场更新到 v${versionStatus.latestVersion}`
397
- }),
945
+ renderUpdateControls(),
398
946
  versionStatus?.releasePending && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
399
947
  className: "mcpConnectorVersion",
400
948
  title: "GitHub Release 已发布,等待 npm 同步后即可更新",
package/lib/index.js CHANGED
@@ -380,12 +380,41 @@ export async function apply(ctx, config) {
380
380
  }
381
381
 
382
382
  async function refreshGrantWithRecovery(grantKey, phase) {
383
+ const observed = state.grants.get(grantKey)?.grant;
383
384
  try {
384
385
  return await refreshGrant(grantKey);
385
386
  } catch (error) {
387
+ let finalError = error;
388
+ const initialFailure = classifyRefreshFailure(error);
389
+ // Refresh tokens are commonly rotated. If another DSH process refreshed
390
+ // the same persisted grant first, this process still holds the old token
391
+ // and receives invalid_grant. Re-read storage before declaring the user
392
+ // logged out; a newer token is authoritative and can be retried once.
393
+ if (config.persistSecrets && observed
394
+ && ['invalid_grant', 'invalid_token'].includes(initialFailure.code)) {
395
+ for (const delayMs of [0, 100, 250]) {
396
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
397
+ const persisted = await grantStore.get(grantKey).catch(() => undefined);
398
+ const entry = state.grants.get(grantKey);
399
+ if (!entry || !persisted
400
+ || persisted.updatedAt <= observed.updatedAt
401
+ || persisted.refreshToken === observed.refreshToken) continue;
402
+ entry.grant = persisted;
403
+ entry.needsReauth = false;
404
+ entry.refreshFailureKind = null;
405
+ entry.lastRefreshError = null;
406
+ logger.info(`OAuth grant ${grantKey} adopted a newer persisted token after a concurrent rotation`);
407
+ try {
408
+ return await refreshGrant(grantKey);
409
+ } catch (retryError) {
410
+ finalError = retryError;
411
+ }
412
+ break;
413
+ }
414
+ }
386
415
  // 多个调用方可能等待同一个 refreshPromise;同一次失败只能记一次并调度一个重试。
387
- if (!error.refreshFailure) error.refreshFailure = recordRefreshFailure(grantKey, error, phase);
388
- throw error;
416
+ if (!finalError.refreshFailure) finalError.refreshFailure = recordRefreshFailure(grantKey, finalError, phase);
417
+ throw finalError;
389
418
  }
390
419
  }
391
420
 
@@ -858,6 +887,86 @@ export async function apply(ctx, config) {
858
887
  }
859
888
  }
860
889
 
890
+ /**
891
+ * v0.2.22 及更早版本会让四张企查查卡片分别保存动态客户端和 Grant。
892
+ * 新版升级时把同 issuer、同资源域且授权范围完整的历史记录归并为一组,
893
+ * 这样启动只刷新一个 refresh token,之后任意一张卡片重新授权也会修复整组。
894
+ */
895
+ async function consolidatePersistedSharedGrants() {
896
+ const groups = new Map();
897
+ for (const record of state.connections.values()) {
898
+ if (record.auth?.mode !== 'oauth' || !record.auth.grantKey) continue;
899
+ const connector = state.merged.find((item) => item.id === record.connectorId);
900
+ if (connector?.auth?.grantSharing !== 'issuer') continue;
901
+ const groupKey = oauthSharingKey(connector);
902
+ const group = groups.get(groupKey) ?? { records: [], grantKeys: new Set() };
903
+ group.records.push(record);
904
+ group.grantKeys.add(record.auth.grantKey);
905
+ groups.set(groupKey, group);
906
+ }
907
+
908
+ let consolidated = 0;
909
+ for (const group of groups.values()) {
910
+ if (group.grantKeys.size <= 1) continue;
911
+ const requiredResources = new Set(group.records.map((record) => record.url).filter(Boolean));
912
+ const candidates = [...group.grantKeys]
913
+ .map((key) => [key, state.grants.get(key)])
914
+ .filter(([, entry]) => entry
915
+ && [...requiredResources].every((resource) => entry.grant.authorizedResources.includes(resource)))
916
+ .sort((left, right) => (
917
+ (right[1].grant.updatedAt ?? 0) - (left[1].grant.updatedAt ?? 0)
918
+ || (right[1].grant.accessTokenExpiresAt ?? 0) - (left[1].grant.accessTokenExpiresAt ?? 0)
919
+ ));
920
+ if (candidates.length === 0) continue;
921
+
922
+ // Do not retire three recoverable historical grants in favour of a newer
923
+ // but already-revoked refresh token. Validate expired candidates in
924
+ // recency order and only consolidate after one grant is demonstrably
925
+ // usable. If none is usable, preserve every grant so one subsequent
926
+ // authorization can replace the group without destroying recovery data.
927
+ let winnerCandidate;
928
+ for (const candidate of candidates) {
929
+ const [candidateKey, candidateEntry] = candidate;
930
+ if (candidateEntry.needsReauth) continue;
931
+ if (candidateEntry.grant.accessTokenExpiresAt <= Date.now()) {
932
+ try {
933
+ await refreshGrantWithRecovery(candidateKey, 'startup-consolidation');
934
+ } catch {
935
+ continue;
936
+ }
937
+ }
938
+ winnerCandidate = candidate;
939
+ break;
940
+ }
941
+ if (winnerCandidate === undefined) {
942
+ logger.warn(`preserved ${group.grantKeys.size} historical issuer-shared OAuth grants because none could be refreshed`);
943
+ continue;
944
+ }
945
+
946
+ const [winnerKey, winnerEntry] = winnerCandidate;
947
+ const connectorIds = [...new Set([
948
+ ...(winnerEntry.grant.connectorIds ?? []),
949
+ ...group.records.map((record) => record.connectorId),
950
+ ])];
951
+ const winner = { ...winnerEntry.grant, connectorIds, updatedAt: Date.now() };
952
+ winnerEntry.grant = winner;
953
+ if (config.persistSecrets) await grantStore.put(winner);
954
+
955
+ for (const record of group.records) {
956
+ if (record.auth.grantKey === winnerKey) continue;
957
+ const next = { ...record, auth: { ...record.auth, grantKey: winnerKey }, updatedAt: Date.now() };
958
+ const persisted = config.persistSecrets ? await connectionStore.put(next) : next;
959
+ state.connections.set(record.key, persisted);
960
+ }
961
+ for (const losingKey of group.grantKeys) {
962
+ if (losingKey !== winnerKey) await retireGrantIfUnused(losingKey);
963
+ }
964
+ consolidated += group.grantKeys.size - 1;
965
+ logger.info(`consolidated ${group.grantKeys.size} issuer-shared OAuth grants into ${winnerKey}`);
966
+ }
967
+ return consolidated;
968
+ }
969
+
861
970
  /* ───────────────────────── api 门面 ───────────────────────── */
862
971
 
863
972
  const api = {
@@ -1612,6 +1721,9 @@ export async function apply(ctx, config) {
1612
1721
  }
1613
1722
  }
1614
1723
 
1724
+ const consolidatedGrantCount = await consolidatePersistedSharedGrants();
1725
+ if (consolidatedGrantCount > 0) logger.info(`consolidated ${consolidatedGrantCount} historical OAuth grant(s) during startup`);
1726
+
1615
1727
  const prunedGrantCount = await pruneOrphanGrants();
1616
1728
  if (prunedGrantCount > 0) logger.info(`removed ${prunedGrantCount} unreferenced OAuth grant(s) from local storage`);
1617
1729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
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",
@@ -19,6 +19,7 @@
19
19
  "registry",
20
20
  "docs/DESKTOP-E2E.md",
21
21
  "docs/MARKET-REGISTRATION.md",
22
+ "docs/PLUGIN-UPDATE.md",
22
23
  "docs/STDIO-SUPPORT.md",
23
24
  "docs/USER-GUIDE.md",
24
25
  "cordis.patch.yml",