dsh-mcp-connector 0.2.25 → 0.2.27
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 +26 -1
- package/README.en.md +4 -4
- package/README.md +3 -3
- package/docs/PLUGIN-UPDATE.md +5 -4
- package/docs/USER-GUIDE.md +2 -2
- package/lib/client.js +114 -7
- package/lib/grant-journal.js +192 -0
- package/lib/index.js +148 -5
- package/lib/stores.js +29 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.27] - 2026-08-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- OAuth Refresh Token 轮换不再依赖 DSH JSON storage 的进程内缓存;新增每 Grant 独立原子日志和跨进程锁,Desktop 与 `dsh web` 同时运行时只轮换一次 Token,等待方直接采用最新凭据。
|
|
12
|
+
- DSH Desktop 没有插件市场设置分区时,更新入口不再停留在普通设置页;改为中性的“查看更新方式”,并在确认无市场分区后打开 npm 安装说明。
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- 跨进程 Grant journal 位于 `$DSH_HOME/storages/mcp_connector_grants_v1`,采用 0700 目录、0600 文件和同目录原子替换;Token 不进入运行日志、页面或 Git 仓库。
|
|
17
|
+
|
|
18
|
+
## [0.2.26] - 2026-08-27
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- 升级启动时自动发现并归并历史版本为同 issuer 卡片保存的多份 OAuth Grant;归并前先验证过期 Grant 是否仍可刷新,避免用较新但已失效的凭据覆盖可恢复授权。
|
|
23
|
+
- 多个 DSH 进程并发刷新同一组轮换式 Refresh Token 时,收到 `invalid_grant` 后会短暂重读本机持久化 Grant;发现其他进程已保存新 Token 时自动采用并重试,不再误报“需重新授权”。
|
|
24
|
+
- 页面不再盲信 Update Provider 的 `succeeded`;独立校验更新前、预期与实际版本,检测降级、解析版本偏差或无效结果时拒绝重启,并在 Provider 保留恢复点时自动回滚。
|
|
25
|
+
|
|
26
|
+
### Verification
|
|
27
|
+
|
|
28
|
+
- 129 项自动测试、lint、npm 发布包白名单与敏感内容扫描通过;新增历史 Grant 安全归并、失效候选回退、跨进程 Token 轮换恢复和 Provider 版本完整性回归测试。
|
|
29
|
+
|
|
7
30
|
## [0.2.25] - 2026-08-27
|
|
8
31
|
|
|
9
32
|
### Added
|
|
@@ -414,7 +437,9 @@
|
|
|
414
437
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
415
438
|
- iframe 消息校验同源和消息来源。
|
|
416
439
|
|
|
417
|
-
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.
|
|
440
|
+
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.27...HEAD
|
|
441
|
+
[0.2.27]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.26...v0.2.27
|
|
442
|
+
[0.2.26]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...v0.2.26
|
|
418
443
|
[0.2.25]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.24...v0.2.25
|
|
419
444
|
[0.2.24]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...v0.2.24
|
|
420
445
|
[0.2.23]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...v0.2.23
|
package/README.en.md
CHANGED
|
@@ -25,14 +25,14 @@ 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, retry transient OAuth refresh failures with bounded backoff, and revoke authorization. Cards declaring issuer-level sharing reuse one grant;
|
|
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; a cross-process lock and per-grant atomic journal prevent Desktop and Web hosts from consuming or overwriting the same rotating refresh token.
|
|
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 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
|
|
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 offers update instructions; if Desktop has no plugin-market section, it opens the npm package page instead of leaving the user on generic Settings.
|
|
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 -->
|
|
35
|
-
As of 2026-08-
|
|
35
|
+
As of 2026-08-27, the public Registry publishes 79 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 83 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.
|
|
36
36
|
<!-- catalog-stats:end -->
|
|
37
37
|
|
|
38
38
|
## Interface and demo
|
|
@@ -118,7 +118,7 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](d
|
|
|
118
118
|
|
|
119
119
|
## Security and limitations
|
|
120
120
|
|
|
121
|
-
- Credentials
|
|
121
|
+
- Credentials stay inside the local DSH storage boundary: connection records use the storage domain, while rotating OAuth credentials are also saved in `$DSH_HOME/storages/mcp_connector_grants_v1` so stale host snapshots cannot overwrite them. The directory is mode 0700 and records are mode 0600; credentials are never written to the catalog, Git repository, page, logs, or conversation history.
|
|
122
122
|
- Failed API key/token validation is not persisted; authentication, timeout, DNS, and TLS/network errors are reported separately.
|
|
123
123
|
- External URLs must use HTTPS; HTTP is allowed only for loopback development.
|
|
124
124
|
- Remote descriptors and catalogs are limited to 2 MiB, Web API requests to 1 MiB, and imported JSON is scanned for credential fields before normalization.
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
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 自动刷新/退避恢复与撤销;同 issuer
|
|
28
|
+
- 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 自动刷新/退避恢复与撤销;同 issuer 卡片共享一次授权,跨进程锁与独立原子 Grant journal 防止 Desktop/Web 并行时重复消耗 Refresh Token。
|
|
29
29
|
- 目录运营:内置目录、远程 registry、本地覆盖,支持 `published` 上下架与 `featured` 精选。
|
|
30
30
|
- 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
|
|
31
31
|
- 插件版本与一键更新:版本发现独立于安装来源;页面通过 Update Provider 适配层探测安全更新能力。DSH Market API v1 是首个适配器,支持进度、稳定失败码、回滚及按宿主能力提供的重启/刷新操作;无可用 Provider 时回退到当前插件市场或 npm。
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
- 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
|
|
35
35
|
|
|
36
36
|
<!-- catalog-stats:start -->
|
|
37
|
-
截至 2026-08-
|
|
37
|
+
截至 2026-08-27,公共 Registry 已发布 79 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 83 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具、其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标和上方实时统计徽标为准。
|
|
38
38
|
<!-- catalog-stats:end -->
|
|
39
39
|
|
|
40
40
|
## 界面与演示
|
|
@@ -117,7 +117,7 @@ stdio 传输的架构、透传边界与安全约束见 [docs/STDIO-SUPPORT.md](d
|
|
|
117
117
|
|
|
118
118
|
## 安全与限制
|
|
119
119
|
|
|
120
|
-
-
|
|
120
|
+
- 凭证只持久化在本机 DSH storage 边界:连接记录使用 storage domain,OAuth 轮换凭据同步保存到 `$DSH_HOME/storages/mcp_connector_grants_v1`。该目录为 0700、文件为 0600;凭证不进入市场目录、Git 仓库、页面、日志或对话历史。
|
|
121
121
|
- 市场 Key/Token 校验失败时不写入 storage domain;鉴权、超时、DNS、TLS/网络错误会分类提示。
|
|
122
122
|
- 外部 URL 仅允许 HTTPS,HTTP 仅允许回环地址;导入配置会校验 URL 与 Header。
|
|
123
123
|
- 远程目录/描述响应限制 2 MiB,Web API 请求限制 1 MiB;原始 JSON 在归一化前扫描凭据字段。
|
package/docs/PLUGIN-UPDATE.md
CHANGED
|
@@ -14,17 +14,18 @@ MCP连接器把“刷新连接器目录”和“升级插件代码”视为两
|
|
|
14
14
|
|
|
15
15
|
每个 Provider 适配器只硬编码自己的 capabilities 入口,其余更新、任务、回滚和重启端点均从能力响应中读取。客户端只接受当前 DSH 页面的相对同源端点,拒绝跨域 URL、协议不匹配、缺少必要端点或未声明 `features.update` 的 Provider。
|
|
16
16
|
|
|
17
|
-
DSH Market 适配器从 `GET /dsh-market/api/v1/capabilities` 开始,要求 `schema: dsh-market/update-api/v1` 与 `apiVersion: 1`。未安装任何可用 Provider、接口返回 404
|
|
17
|
+
DSH Market 适配器从 `GET /dsh-market/api/v1/capabilities` 开始,要求 `schema: dsh-market/update-api/v1` 与 `apiVersion: 1`。未安装任何可用 Provider、接口返回 404、协议不兼容或检查失败时,页面不会尝试私有更新路由,而是显示“查看更新方式”。页面会先尝试打开当前宿主的插件市场;设置入口不可用,或 DSH Desktop 设置中实际没有插件市场分区时,打开 npm 包页面与安装命令说明。
|
|
18
18
|
|
|
19
19
|
## 更新流程
|
|
20
20
|
|
|
21
21
|
1. 插件自己的服务端缓存检查 npm `latest` 与 GitHub Release,只在 npm 已有可安装新版本时提示升级。
|
|
22
22
|
2. 选中的 Provider 再检查本 profile 中 `dsh-mcp-connector` 的实际安装版本和目标版本。
|
|
23
23
|
3. 用户点击“一键更新”后,Provider 立即返回 `operationId`;页面通过适配器轮询任务状态,展示排队、解析、下载和安装进度。
|
|
24
|
-
4.
|
|
25
|
-
5. Provider
|
|
24
|
+
4. Provider 报告成功后,页面仍会独立校验 `beforeVersion`、点击时的预期版本与 `installedVersion`。降级、目标不一致或无效结果会被拒绝;Provider 保留恢复点时自动回滚,不向用户展示重启。
|
|
25
|
+
5. 完整性校验通过后,按 Provider 返回的激活结论展示“刷新生效”或重启提示。Web 且 Provider 明确允许进程重启时才展示“立即重启”;Desktop 等由外壳管理生命周期的宿主只提示用户重启,不自行杀进程。
|
|
26
|
+
6. Provider 保留恢复点时展示“回滚”。回滚仍由 Provider 执行,MCP连接器不直接修改 profile、lockfile 或 `node_modules`。
|
|
26
27
|
|
|
27
|
-
失败结果使用稳定代码区分正在运行的 Agent
|
|
28
|
+
失败结果使用稳定代码区分正在运行的 Agent、其他安装任务占用、镜像同步、版本未变化、超时和权限拒绝,以及 `DOWNGRADE_DETECTED`、`RESOLVED_VERSION_MISMATCH`、`INVALID_UPDATE_RESULT` 三类完整性故障。可重试失败保留重试操作;Registry 刚发布但镜像仍未同步时,用户可在看到明确提示后选择强制重试。
|
|
28
29
|
|
|
29
30
|
## 安全边界
|
|
30
31
|
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -42,7 +42,7 @@ dsh web
|
|
|
42
42
|
- **已安装**:查看已经保存到本机的连接,页签徽标显示连接数。
|
|
43
43
|
- **搜索**:按名称、服务商、简介和标签查找。
|
|
44
44
|
- **添加连接**:导入 JSON、手动配置 HTTP/stdio,或从市场描述 URL 安装。
|
|
45
|
-
- **版本与更新提示**:标题旁显示当前插件版本,版本检查不依赖安装所用的插件市场。检测到 npm 有新版本且当前宿主存在兼容 Update Provider 时,可在当前页面一键更新并查看进度、失败原因和可用回滚。DSH Market API v1 是当前首个 Provider;无可用 Provider
|
|
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
|
|
78
|
+
当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。升级时会验证并自动归并旧版本留下的同 issuer 多份 Grant;Desktop 与 `dsh web` 并发时,插件使用跨进程锁串行轮换,并从每 Grant 独立原子日志读取最新 Token,避免 DSH 整文件 storage 的旧进程快照覆盖新凭据。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”;确认 journal 中也没有更新 Token,且明确收到 Refresh Token/客户端失效错误时,才进入“需重新授权”。
|
|
79
79
|
|
|
80
80
|
## 5. 查看详情、Prompt 与工具
|
|
81
81
|
|
package/lib/client.js
CHANGED
|
@@ -363,6 +363,9 @@ window.__ModuleLoader__.load({
|
|
|
363
363
|
OPERATION_BUSY: "插件市场正在处理其他任务",
|
|
364
364
|
RELEASE_TOO_FRESH: "新版本仍在镜像同步",
|
|
365
365
|
VERSION_UNCHANGED: "下载后版本未变化",
|
|
366
|
+
DOWNGRADE_DETECTED: "已阻止版本降级",
|
|
367
|
+
RESOLVED_VERSION_MISMATCH: "下载版本与预期不一致",
|
|
368
|
+
INVALID_UPDATE_RESULT: "更新服务未返回有效版本",
|
|
366
369
|
UPDATE_TIMEOUT: "更新超时",
|
|
367
370
|
UPDATE_FORBIDDEN: "更新请求被拒绝",
|
|
368
371
|
UPDATE_REJECTED: "无法执行本次更新"
|
|
@@ -370,9 +373,75 @@ window.__ModuleLoader__.load({
|
|
|
370
373
|
return labels[failure?.code] ?? failure?.message ?? "更新失败";
|
|
371
374
|
}
|
|
372
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
|
+
|
|
373
441
|
/**
|
|
374
442
|
* DSH 暂未公开“打开指定设置分区”服务:只依赖稳定的 sidebar.settings
|
|
375
|
-
* slot
|
|
443
|
+
* slot 和可访问文本打开插件市场。Desktop 可能根本没有市场分区,
|
|
444
|
+
* 轮询结束后必须回退到 npm 安装说明,不能把用户留在普通设置页。
|
|
376
445
|
*/
|
|
377
446
|
function openDshPluginMarket(actions) {
|
|
378
447
|
const settingsHost = document.querySelector('[data-slot="sidebar.settings"]');
|
|
@@ -395,7 +464,11 @@ window.__ModuleLoader__.load({
|
|
|
395
464
|
return;
|
|
396
465
|
}
|
|
397
466
|
attempts += 1;
|
|
398
|
-
if (attempts < 20)
|
|
467
|
+
if (attempts < 20) {
|
|
468
|
+
window.setTimeout(selectMarket, 50);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
window.open(NPM_PACKAGE_URL, "_blank", "noopener,noreferrer");
|
|
399
472
|
};
|
|
400
473
|
window.requestAnimationFrame(selectMarket);
|
|
401
474
|
}, 0);
|
|
@@ -451,6 +524,7 @@ window.__ModuleLoader__.load({
|
|
|
451
524
|
const [updateProviderState, setUpdateProviderState] = (0, react.useState)("checking");
|
|
452
525
|
const [updateProviderSelection, setUpdateProviderSelection] = (0, react.useState)(null);
|
|
453
526
|
const [providerUpdateCheck, setProviderUpdateCheck] = (0, react.useState)(null);
|
|
527
|
+
const [expectedUpdateVersion, setExpectedUpdateVersion] = (0, react.useState)(null);
|
|
454
528
|
const [updateOperation, setUpdateOperation] = (0, react.useState)(null);
|
|
455
529
|
const [updateError, setUpdateError] = (0, react.useState)(null);
|
|
456
530
|
const [updateStarting, setUpdateStarting] = (0, react.useState)(false);
|
|
@@ -564,6 +638,34 @@ window.__ModuleLoader__.load({
|
|
|
564
638
|
provider.operation(capabilities, operationId).then((operation) => {
|
|
565
639
|
if (disposed) return;
|
|
566
640
|
setUpdateError(null);
|
|
641
|
+
const integrityFailure = completedUpdateIntegrityFailure(operation, expectedUpdateVersion);
|
|
642
|
+
if (integrityFailure !== null) {
|
|
643
|
+
const failedOperation = { ...operation, state: "failed", failure: integrityFailure };
|
|
644
|
+
setUpdateOperation(failedOperation);
|
|
645
|
+
const canRollback = capabilities.features?.rollback === true
|
|
646
|
+
&& operation.outcome?.rollback?.available === true
|
|
647
|
+
&& capabilities.endpoints.rollback !== null;
|
|
648
|
+
if (!canRollback) return;
|
|
649
|
+
setRollbackRunning(true);
|
|
650
|
+
provider.rollback(capabilities, operationId).then((rolledBackOperation) => {
|
|
651
|
+
if (disposed) return;
|
|
652
|
+
setRollbackRunning(false);
|
|
653
|
+
setUpdateOperation({ ...rolledBackOperation, integrityFailure });
|
|
654
|
+
}, (error) => {
|
|
655
|
+
console.error("[mcp-connector] automatic rollback failed:", error);
|
|
656
|
+
if (disposed) return;
|
|
657
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
658
|
+
setRollbackRunning(false);
|
|
659
|
+
setUpdateOperation({
|
|
660
|
+
...failedOperation,
|
|
661
|
+
outcome: {
|
|
662
|
+
...failedOperation.outcome,
|
|
663
|
+
rollback: { available: true, state: "failed", detail }
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
567
669
|
setUpdateOperation(operation);
|
|
568
670
|
if (["queued", "running"].includes(operation.state)) {
|
|
569
671
|
timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS);
|
|
@@ -584,7 +686,7 @@ window.__ModuleLoader__.load({
|
|
|
584
686
|
disposed = true;
|
|
585
687
|
window.clearTimeout(timer);
|
|
586
688
|
};
|
|
587
|
-
}, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id]);
|
|
689
|
+
}, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id, expectedUpdateVersion]);
|
|
588
690
|
(0, react.useEffect)(() => {
|
|
589
691
|
if (!open) return void 0;
|
|
590
692
|
closeRef.current?.focus?.();
|
|
@@ -600,6 +702,7 @@ window.__ModuleLoader__.load({
|
|
|
600
702
|
if (updateRunning || provider === void 0 || capabilities === void 0) return;
|
|
601
703
|
setUpdateError(null);
|
|
602
704
|
setUpdateStarting(true);
|
|
705
|
+
setExpectedUpdateVersion(providerUpdateCheck?.latestVersion ?? versionStatus?.latestVersion ?? null);
|
|
603
706
|
provider.start(capabilities, PLUGIN_PACKAGE_NAME, force).then((operation) => {
|
|
604
707
|
setUpdateStarting(false);
|
|
605
708
|
setUpdateOperation(operation);
|
|
@@ -687,11 +790,15 @@ window.__ModuleLoader__.load({
|
|
|
687
790
|
onClick: () => startUpdate(force),
|
|
688
791
|
children: force ? "强制重试" : "重试更新"
|
|
689
792
|
}),
|
|
793
|
+
rollbackRunning && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
794
|
+
className: "mcpConnectorUpdateStatus",
|
|
795
|
+
children: "检测到异常版本,正在自动回滚…"
|
|
796
|
+
}),
|
|
690
797
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
691
798
|
type: "button",
|
|
692
799
|
className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
|
|
693
800
|
onClick: () => openDshPluginMarket(actions),
|
|
694
|
-
children: "
|
|
801
|
+
children: "查看更新方式"
|
|
695
802
|
})
|
|
696
803
|
]
|
|
697
804
|
});
|
|
@@ -707,7 +814,7 @@ window.__ModuleLoader__.load({
|
|
|
707
814
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
708
815
|
className: "mcpConnectorUpdateStatus",
|
|
709
816
|
children: rolledBack
|
|
710
|
-
? "已回滚"
|
|
817
|
+
? updateOperation.integrityFailure === void 0 ? "已回滚" : "已阻止异常版本并回滚"
|
|
711
818
|
: `已安装 v${updateOperation.installedVersion ?? versionStatus?.latestVersion}`
|
|
712
819
|
}),
|
|
713
820
|
rollbackFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
@@ -758,7 +865,7 @@ window.__ModuleLoader__.load({
|
|
|
758
865
|
type: "button",
|
|
759
866
|
className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
|
|
760
867
|
onClick: () => openDshPluginMarket(actions),
|
|
761
|
-
children: "
|
|
868
|
+
children: "查看更新方式"
|
|
762
869
|
})
|
|
763
870
|
]
|
|
764
871
|
});
|
|
@@ -785,7 +892,7 @@ window.__ModuleLoader__.load({
|
|
|
785
892
|
className: "mcpConnectorUpdateButton",
|
|
786
893
|
title: `npm 已发布 v${versionStatus?.latestVersion}`,
|
|
787
894
|
onClick: () => openDshPluginMarket(actions),
|
|
788
|
-
children:
|
|
895
|
+
children: `查看 v${versionStatus?.latestVersion} 更新方式`
|
|
789
896
|
});
|
|
790
897
|
};
|
|
791
898
|
const src = window.location.origin + "/mcp-connector/ui/";
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Grant 跨进程轮换日志。
|
|
3
|
+
*
|
|
4
|
+
* DSH JSON storage 以“每进程内存状态 + 整文件原子替换”实现,并不保证两个
|
|
5
|
+
* DSH Host 同时打开同一 domain 时的写入一致性。Refresh Token 又通常每次刷新都
|
|
6
|
+
* 会轮换,所以 Desktop 与 dsh web 并行时必须额外做跨进程串行化。
|
|
7
|
+
*
|
|
8
|
+
* 每个 Grant 独立文件,避免一个过期进程覆盖其他 Grant;文件与锁均位于
|
|
9
|
+
* $DSH_HOME/storages/mcp_connector_grants_v1,目录 0700、文件 0600。
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
12
|
+
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { grantRecordSchema } from './schema.js';
|
|
16
|
+
|
|
17
|
+
const JOURNAL_VERSION = 1;
|
|
18
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
|
19
|
+
const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
|
|
20
|
+
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fileId(grantKey) {
|
|
26
|
+
return createHash('sha256').update(grantKey).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defaultGrantJournalDir() {
|
|
30
|
+
if (process.env.NODE_TEST_CONTEXT) return null;
|
|
31
|
+
const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
|
|
32
|
+
return resolve(dshHome, 'storages', 'mcp_connector_grants_v1');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeAtomic(path, document) {
|
|
36
|
+
const temp = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
37
|
+
try {
|
|
38
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
39
|
+
try {
|
|
40
|
+
await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
|
41
|
+
await handle.sync();
|
|
42
|
+
} finally {
|
|
43
|
+
await handle.close();
|
|
44
|
+
}
|
|
45
|
+
await rename(temp, path);
|
|
46
|
+
await chmod(path, 0o600);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await rm(temp, { force: true }).catch(() => {});
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseDocument(text, path) {
|
|
54
|
+
let document;
|
|
55
|
+
try {
|
|
56
|
+
document = JSON.parse(text);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new Error(`OAuth grant journal is malformed: ${path}`, { cause: error });
|
|
59
|
+
}
|
|
60
|
+
if (document?.version !== JOURNAL_VERSION) {
|
|
61
|
+
throw new Error(`OAuth grant journal version is unsupported: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
const parsed = grantRecordSchema.safeParse(document.grant);
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
throw new Error(`OAuth grant journal record is invalid: ${path}`);
|
|
66
|
+
}
|
|
67
|
+
return parsed.data;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class GrantJournal {
|
|
71
|
+
constructor({ rootDir = defaultGrantJournalDir(), logger } = {}) {
|
|
72
|
+
this.rootDir = rootDir ? resolve(rootDir) : null;
|
|
73
|
+
this.logger = logger;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get enabled() {
|
|
77
|
+
return this.rootDir !== null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async ensureRoot() {
|
|
81
|
+
if (!this.rootDir) return;
|
|
82
|
+
await mkdir(this.rootDir, { recursive: true, mode: 0o700 });
|
|
83
|
+
await chmod(this.rootDir, 0o700);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
recordPath(grantKey) {
|
|
87
|
+
return join(this.rootDir, `${fileId(grantKey)}.json`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lockPath(grantKey) {
|
|
91
|
+
return join(this.rootDir, `${fileId(grantKey)}.lock`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async get(grantKey) {
|
|
95
|
+
if (!this.rootDir) return undefined;
|
|
96
|
+
try {
|
|
97
|
+
return parseDocument(await readFile(this.recordPath(grantKey), 'utf8'), this.recordPath(grantKey));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error?.code === 'ENOENT') return undefined;
|
|
100
|
+
this.logger?.warn?.(`skip unreadable OAuth grant journal record ${fileId(grantKey)}: ${error.message}`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async entries() {
|
|
106
|
+
if (!this.rootDir) return [];
|
|
107
|
+
await this.ensureRoot();
|
|
108
|
+
const names = await readdir(this.rootDir).catch((error) => {
|
|
109
|
+
if (error?.code === 'ENOENT') return [];
|
|
110
|
+
throw error;
|
|
111
|
+
});
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const name of names.filter((item) => item.endsWith('.json'))) {
|
|
114
|
+
const path = join(this.rootDir, name);
|
|
115
|
+
try {
|
|
116
|
+
const grant = parseDocument(await readFile(path, 'utf8'), path);
|
|
117
|
+
out.push([grant.key, grant]);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
this.logger?.warn?.(`skip unreadable OAuth grant journal record ${name}: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async put(grant) {
|
|
126
|
+
if (!this.rootDir) return grant;
|
|
127
|
+
await this.ensureRoot();
|
|
128
|
+
const parsed = grantRecordSchema.parse(grant);
|
|
129
|
+
await writeAtomic(this.recordPath(parsed.key), { version: JOURNAL_VERSION, grant: parsed });
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async delete(grantKey) {
|
|
134
|
+
if (!this.rootDir) return false;
|
|
135
|
+
try {
|
|
136
|
+
await rm(this.recordPath(grantKey));
|
|
137
|
+
return true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error?.code === 'ENOENT') return false;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async withLock(grantKey, task, {
|
|
145
|
+
timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
|
|
146
|
+
staleLockMs = DEFAULT_STALE_LOCK_MS,
|
|
147
|
+
} = {}) {
|
|
148
|
+
if (!this.rootDir) return task();
|
|
149
|
+
await this.ensureRoot();
|
|
150
|
+
const path = this.lockPath(grantKey);
|
|
151
|
+
const startedAt = Date.now();
|
|
152
|
+
const owner = randomUUID();
|
|
153
|
+
let handle;
|
|
154
|
+
for (;;) {
|
|
155
|
+
try {
|
|
156
|
+
handle = await open(path, 'wx', 0o600);
|
|
157
|
+
try {
|
|
158
|
+
await handle.writeFile(JSON.stringify({ owner, pid: process.pid, createdAt: Date.now() }), 'utf8');
|
|
159
|
+
await handle.sync();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await handle.close().catch(() => {});
|
|
162
|
+
await rm(path, { force: true }).catch(() => {});
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
168
|
+
const lockStat = await stat(path).catch(() => null);
|
|
169
|
+
if (lockStat && Date.now() - lockStat.mtimeMs > staleLockMs) {
|
|
170
|
+
await rm(path, { force: true });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
174
|
+
const timeout = new Error('timed out waiting for another DSH process to finish OAuth refresh');
|
|
175
|
+
timeout.code = 'refresh_lock_timeout';
|
|
176
|
+
throw timeout;
|
|
177
|
+
}
|
|
178
|
+
await sleep(100);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return await task();
|
|
183
|
+
} finally {
|
|
184
|
+
await handle?.close().catch(() => {});
|
|
185
|
+
// 避免极端超时下旧持有者删掉已被新进程接管的锁(ABA)。
|
|
186
|
+
const current = await readFile(path, 'utf8').catch(() => '');
|
|
187
|
+
if (current.includes(`"owner":"${owner}"`)) {
|
|
188
|
+
await rm(path, { force: true }).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 能力:
|
|
5
5
|
* - 连接器目录(内置 + 远程 registry + config 注入 + 本地上下架覆盖)
|
|
6
6
|
* - 三通道连接:通用 OAuth(PKCE) / 自定义配置 / 粘贴 JSON / URL 安装
|
|
7
|
-
* - 连接记录与 OAuth grant
|
|
7
|
+
* - 连接记录与 OAuth grant 持久化,重启恢复与跨 Host Token 轮换
|
|
8
8
|
* - 通过 ctx.loader 动态配置 @deepseek-ai/dsh-mcp-client 条目
|
|
9
9
|
* - 对话工具:mcp_connector_catalog / connect / configure / import_json /
|
|
10
10
|
* install_from_url / status / set_enabled / disconnect / refresh_catalog / publish
|
|
@@ -95,7 +95,12 @@ export async function apply(ctx, config) {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
const connectionStore = new ConnectionStore(domain);
|
|
98
|
-
const grantStore = new GrantStore(domain
|
|
98
|
+
const grantStore = new GrantStore(domain, {
|
|
99
|
+
// 只有真实持久化模式才启用跨进程 Grant journal。测试可通过内部字段
|
|
100
|
+
// 传入临时目录,不让 fake storage 触碰用户真实 $DSH_HOME。
|
|
101
|
+
journalDir: config.persistSecrets ? config.__grantJournalDir : null,
|
|
102
|
+
logger,
|
|
103
|
+
});
|
|
99
104
|
const catalogStore = new CatalogStore(domain);
|
|
100
105
|
|
|
101
106
|
const state = {
|
|
@@ -380,12 +385,67 @@ export async function apply(ctx, config) {
|
|
|
380
385
|
}
|
|
381
386
|
|
|
382
387
|
async function refreshGrantWithRecovery(grantKey, phase) {
|
|
388
|
+
const observed = state.grants.get(grantKey)?.grant;
|
|
383
389
|
try {
|
|
384
|
-
return await
|
|
390
|
+
return await grantStore.withRefreshLock(grantKey, async () => {
|
|
391
|
+
// 锁内必须从独立 journal 重读,不能依赖 DSH domain 的进程内缓存。
|
|
392
|
+
// 如果另一 Host 已完成轮换,直接采用新 Grant,避免再次消耗
|
|
393
|
+
// 一次性 Refresh Token。
|
|
394
|
+
const persisted = await grantStore.get(grantKey).catch(() => undefined);
|
|
395
|
+
const entry = state.grants.get(grantKey);
|
|
396
|
+
if (entry && persisted
|
|
397
|
+
&& persisted.updatedAt > entry.grant.updatedAt
|
|
398
|
+
&& persisted.refreshToken !== entry.grant.refreshToken) {
|
|
399
|
+
entry.grant = persisted;
|
|
400
|
+
entry.needsReauth = false;
|
|
401
|
+
entry.refreshFailureKind = null;
|
|
402
|
+
entry.lastRefreshError = null;
|
|
403
|
+
logger.info(`OAuth grant ${grantKey} adopted a newer cross-process journal token`);
|
|
404
|
+
if (persisted.accessTokenExpiresAt > Date.now()) {
|
|
405
|
+
scheduleRefresh(grantKey);
|
|
406
|
+
return persisted;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return refreshGrant(grantKey);
|
|
410
|
+
}, { timeoutMs: Math.max(30_000, config.requestTimeoutMs * 2) });
|
|
385
411
|
} catch (error) {
|
|
412
|
+
let finalError = error;
|
|
413
|
+
const initialFailure = classifyRefreshFailure(error);
|
|
414
|
+
// Refresh tokens are commonly rotated. If another DSH process refreshed
|
|
415
|
+
// the same persisted grant first, this process still holds the old token
|
|
416
|
+
// and receives invalid_grant. Re-read storage before declaring the user
|
|
417
|
+
// logged out; a newer token is authoritative and can be retried once.
|
|
418
|
+
if (config.persistSecrets && observed
|
|
419
|
+
&& ['invalid_grant', 'invalid_token'].includes(initialFailure.code)) {
|
|
420
|
+
for (const delayMs of [0, 100, 250]) {
|
|
421
|
+
if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
422
|
+
const persisted = await grantStore.get(grantKey).catch(() => undefined);
|
|
423
|
+
const entry = state.grants.get(grantKey);
|
|
424
|
+
if (!entry || !persisted
|
|
425
|
+
|| persisted.updatedAt <= observed.updatedAt
|
|
426
|
+
|| persisted.refreshToken === observed.refreshToken) continue;
|
|
427
|
+
entry.grant = persisted;
|
|
428
|
+
entry.needsReauth = false;
|
|
429
|
+
entry.refreshFailureKind = null;
|
|
430
|
+
entry.lastRefreshError = null;
|
|
431
|
+
logger.info(`OAuth grant ${grantKey} adopted a newer persisted token after a concurrent rotation`);
|
|
432
|
+
try {
|
|
433
|
+
if (persisted.accessTokenExpiresAt > Date.now()) {
|
|
434
|
+
scheduleRefresh(grantKey);
|
|
435
|
+
return persisted;
|
|
436
|
+
}
|
|
437
|
+
return await grantStore.withRefreshLock(grantKey, () => refreshGrant(grantKey), {
|
|
438
|
+
timeoutMs: Math.max(30_000, config.requestTimeoutMs * 2),
|
|
439
|
+
});
|
|
440
|
+
} catch (retryError) {
|
|
441
|
+
finalError = retryError;
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
386
446
|
// 多个调用方可能等待同一个 refreshPromise;同一次失败只能记一次并调度一个重试。
|
|
387
|
-
if (!
|
|
388
|
-
throw
|
|
447
|
+
if (!finalError.refreshFailure) finalError.refreshFailure = recordRefreshFailure(grantKey, finalError, phase);
|
|
448
|
+
throw finalError;
|
|
389
449
|
}
|
|
390
450
|
}
|
|
391
451
|
|
|
@@ -858,6 +918,86 @@ export async function apply(ctx, config) {
|
|
|
858
918
|
}
|
|
859
919
|
}
|
|
860
920
|
|
|
921
|
+
/**
|
|
922
|
+
* v0.2.22 及更早版本会让四张企查查卡片分别保存动态客户端和 Grant。
|
|
923
|
+
* 新版升级时把同 issuer、同资源域且授权范围完整的历史记录归并为一组,
|
|
924
|
+
* 这样启动只刷新一个 refresh token,之后任意一张卡片重新授权也会修复整组。
|
|
925
|
+
*/
|
|
926
|
+
async function consolidatePersistedSharedGrants() {
|
|
927
|
+
const groups = new Map();
|
|
928
|
+
for (const record of state.connections.values()) {
|
|
929
|
+
if (record.auth?.mode !== 'oauth' || !record.auth.grantKey) continue;
|
|
930
|
+
const connector = state.merged.find((item) => item.id === record.connectorId);
|
|
931
|
+
if (connector?.auth?.grantSharing !== 'issuer') continue;
|
|
932
|
+
const groupKey = oauthSharingKey(connector);
|
|
933
|
+
const group = groups.get(groupKey) ?? { records: [], grantKeys: new Set() };
|
|
934
|
+
group.records.push(record);
|
|
935
|
+
group.grantKeys.add(record.auth.grantKey);
|
|
936
|
+
groups.set(groupKey, group);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let consolidated = 0;
|
|
940
|
+
for (const group of groups.values()) {
|
|
941
|
+
if (group.grantKeys.size <= 1) continue;
|
|
942
|
+
const requiredResources = new Set(group.records.map((record) => record.url).filter(Boolean));
|
|
943
|
+
const candidates = [...group.grantKeys]
|
|
944
|
+
.map((key) => [key, state.grants.get(key)])
|
|
945
|
+
.filter(([, entry]) => entry
|
|
946
|
+
&& [...requiredResources].every((resource) => entry.grant.authorizedResources.includes(resource)))
|
|
947
|
+
.sort((left, right) => (
|
|
948
|
+
(right[1].grant.updatedAt ?? 0) - (left[1].grant.updatedAt ?? 0)
|
|
949
|
+
|| (right[1].grant.accessTokenExpiresAt ?? 0) - (left[1].grant.accessTokenExpiresAt ?? 0)
|
|
950
|
+
));
|
|
951
|
+
if (candidates.length === 0) continue;
|
|
952
|
+
|
|
953
|
+
// Do not retire three recoverable historical grants in favour of a newer
|
|
954
|
+
// but already-revoked refresh token. Validate expired candidates in
|
|
955
|
+
// recency order and only consolidate after one grant is demonstrably
|
|
956
|
+
// usable. If none is usable, preserve every grant so one subsequent
|
|
957
|
+
// authorization can replace the group without destroying recovery data.
|
|
958
|
+
let winnerCandidate;
|
|
959
|
+
for (const candidate of candidates) {
|
|
960
|
+
const [candidateKey, candidateEntry] = candidate;
|
|
961
|
+
if (candidateEntry.needsReauth) continue;
|
|
962
|
+
if (candidateEntry.grant.accessTokenExpiresAt <= Date.now()) {
|
|
963
|
+
try {
|
|
964
|
+
await refreshGrantWithRecovery(candidateKey, 'startup-consolidation');
|
|
965
|
+
} catch {
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
winnerCandidate = candidate;
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
if (winnerCandidate === undefined) {
|
|
973
|
+
logger.warn(`preserved ${group.grantKeys.size} historical issuer-shared OAuth grants because none could be refreshed`);
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const [winnerKey, winnerEntry] = winnerCandidate;
|
|
978
|
+
const connectorIds = [...new Set([
|
|
979
|
+
...(winnerEntry.grant.connectorIds ?? []),
|
|
980
|
+
...group.records.map((record) => record.connectorId),
|
|
981
|
+
])];
|
|
982
|
+
const winner = { ...winnerEntry.grant, connectorIds, updatedAt: Date.now() };
|
|
983
|
+
winnerEntry.grant = winner;
|
|
984
|
+
if (config.persistSecrets) await grantStore.put(winner);
|
|
985
|
+
|
|
986
|
+
for (const record of group.records) {
|
|
987
|
+
if (record.auth.grantKey === winnerKey) continue;
|
|
988
|
+
const next = { ...record, auth: { ...record.auth, grantKey: winnerKey }, updatedAt: Date.now() };
|
|
989
|
+
const persisted = config.persistSecrets ? await connectionStore.put(next) : next;
|
|
990
|
+
state.connections.set(record.key, persisted);
|
|
991
|
+
}
|
|
992
|
+
for (const losingKey of group.grantKeys) {
|
|
993
|
+
if (losingKey !== winnerKey) await retireGrantIfUnused(losingKey);
|
|
994
|
+
}
|
|
995
|
+
consolidated += group.grantKeys.size - 1;
|
|
996
|
+
logger.info(`consolidated ${group.grantKeys.size} issuer-shared OAuth grants into ${winnerKey}`);
|
|
997
|
+
}
|
|
998
|
+
return consolidated;
|
|
999
|
+
}
|
|
1000
|
+
|
|
861
1001
|
/* ───────────────────────── api 门面 ───────────────────────── */
|
|
862
1002
|
|
|
863
1003
|
const api = {
|
|
@@ -1612,6 +1752,9 @@ export async function apply(ctx, config) {
|
|
|
1612
1752
|
}
|
|
1613
1753
|
}
|
|
1614
1754
|
|
|
1755
|
+
const consolidatedGrantCount = await consolidatePersistedSharedGrants();
|
|
1756
|
+
if (consolidatedGrantCount > 0) logger.info(`consolidated ${consolidatedGrantCount} historical OAuth grant(s) during startup`);
|
|
1757
|
+
|
|
1615
1758
|
const prunedGrantCount = await pruneOrphanGrants();
|
|
1616
1759
|
if (prunedGrantCount > 0) logger.info(`removed ${prunedGrantCount} unreferenced OAuth grant(s) from local storage`);
|
|
1617
1760
|
|
package/lib/stores.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 存储域封装:一个 domain 三张表(connections / grants / catalog)。
|
|
3
|
-
*
|
|
3
|
+
* OAuth grant 同步写入本机受限权限 journal,防止多 Host 的整文件
|
|
4
|
+
* last-write-wins 覆盖新轮换 Token;凭证不进入 loader 配置树或日志。
|
|
4
5
|
*/
|
|
5
6
|
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
|
|
6
7
|
import { connectionRecordSchema, grantRecordSchema, catalogRecordSchema } from './schema.js';
|
|
7
8
|
import { DEFAULT_ACCOUNT } from './constants.js';
|
|
8
9
|
import { shortHash } from './util.js';
|
|
10
|
+
import { GrantJournal } from './grant-journal.js';
|
|
9
11
|
|
|
10
12
|
export function defineConnectorDomain() {
|
|
11
13
|
return defineDomain({
|
|
@@ -49,9 +51,10 @@ export class ConnectionStore {
|
|
|
49
51
|
|
|
50
52
|
/** OAuth 授权表(通用多 issuer / 多账号) */
|
|
51
53
|
export class GrantStore {
|
|
52
|
-
constructor(domain) {
|
|
54
|
+
constructor(domain, { journalDir, journal, logger } = {}) {
|
|
53
55
|
this.table = domain.tables.get('grants');
|
|
54
56
|
if (!this.table) throw new Error('mcp-connector: domain table "grants" not found');
|
|
57
|
+
this.journal = journal ?? new GrantJournal({ rootDir: journalDir, logger });
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
keyFor(account, issuer, clientId, scope) {
|
|
@@ -59,23 +62,42 @@ export class GrantStore {
|
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
async get(key) {
|
|
62
|
-
|
|
65
|
+
const legacy = await this.table.get(key);
|
|
66
|
+
const journal = await this.journal.get(key);
|
|
67
|
+
if (!legacy) return journal;
|
|
68
|
+
if (!journal) return legacy;
|
|
69
|
+
return journal.updatedAt >= legacy.updatedAt ? journal : legacy;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
async put(grant) {
|
|
66
73
|
const next = { ...grant, updatedAt: Date.now() };
|
|
74
|
+
// Journal 先落盘:即使 DSH JSON domain 被另一进程的旧内存整文件覆盖,
|
|
75
|
+
// 下次刷新仍能从独立记录取回最新的轮换 Token。
|
|
76
|
+
await this.journal.put(next);
|
|
67
77
|
await this.table.put(grant.key, next);
|
|
68
78
|
return next;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
async delete(key) {
|
|
72
|
-
|
|
82
|
+
const [journalDeleted, tableDeleted] = await Promise.all([
|
|
83
|
+
this.journal.delete(key),
|
|
84
|
+
this.table.delete(key),
|
|
85
|
+
]);
|
|
86
|
+
return journalDeleted || tableDeleted;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
async entries() {
|
|
76
|
-
const
|
|
77
|
-
for (const [key, value] of this.table.entries())
|
|
78
|
-
|
|
90
|
+
const merged = new Map();
|
|
91
|
+
for (const [key, value] of this.table.entries()) merged.set(key, value);
|
|
92
|
+
for (const [key, value] of await this.journal.entries()) {
|
|
93
|
+
const current = merged.get(key);
|
|
94
|
+
if (!current || value.updatedAt >= current.updatedAt) merged.set(key, value);
|
|
95
|
+
}
|
|
96
|
+
return [...merged.entries()];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async withRefreshLock(key, task, options) {
|
|
100
|
+
return this.journal.withLock(key, task, options);
|
|
79
101
|
}
|
|
80
102
|
}
|
|
81
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "node --test",
|
|
37
|
-
"lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/version-status.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
|
+
"lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/version-status.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/grant-journal.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",
|
|
38
38
|
"registry:build": "node scripts/build-registry.mjs",
|
|
39
39
|
"registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
|
|
40
40
|
"dev:ui": "node scripts/ui-harness.mjs",
|