dsh-mcp-connector 0.2.26 → 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 +13 -1
- package/README.en.md +4 -4
- package/README.md +3 -3
- package/docs/PLUGIN-UPDATE.md +1 -1
- package/docs/USER-GUIDE.md +2 -2
- package/lib/client.js +10 -5
- package/lib/grant-journal.js +192 -0
- package/lib/index.js +35 -4
- package/lib/stores.js +29 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
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
|
+
|
|
7
18
|
## [0.2.26] - 2026-08-27
|
|
8
19
|
|
|
9
20
|
### Fixed
|
|
@@ -426,7 +437,8 @@
|
|
|
426
437
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
427
438
|
- iframe 消息校验同源和消息来源。
|
|
428
439
|
|
|
429
|
-
[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
|
|
430
442
|
[0.2.26]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...v0.2.26
|
|
431
443
|
[0.2.25]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.24...v0.2.25
|
|
432
444
|
[0.2.24]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...v0.2.24
|
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,7 +14,7 @@ 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
|
|
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 页面。升级时会验证并自动归并旧版本留下的同 issuer 多份 Grant
|
|
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
|
@@ -440,7 +440,8 @@ window.__ModuleLoader__.load({
|
|
|
440
440
|
|
|
441
441
|
/**
|
|
442
442
|
* DSH 暂未公开“打开指定设置分区”服务:只依赖稳定的 sidebar.settings
|
|
443
|
-
* slot
|
|
443
|
+
* slot 和可访问文本打开插件市场。Desktop 可能根本没有市场分区,
|
|
444
|
+
* 轮询结束后必须回退到 npm 安装说明,不能把用户留在普通设置页。
|
|
444
445
|
*/
|
|
445
446
|
function openDshPluginMarket(actions) {
|
|
446
447
|
const settingsHost = document.querySelector('[data-slot="sidebar.settings"]');
|
|
@@ -463,7 +464,11 @@ window.__ModuleLoader__.load({
|
|
|
463
464
|
return;
|
|
464
465
|
}
|
|
465
466
|
attempts += 1;
|
|
466
|
-
if (attempts < 20)
|
|
467
|
+
if (attempts < 20) {
|
|
468
|
+
window.setTimeout(selectMarket, 50);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
window.open(NPM_PACKAGE_URL, "_blank", "noopener,noreferrer");
|
|
467
472
|
};
|
|
468
473
|
window.requestAnimationFrame(selectMarket);
|
|
469
474
|
}, 0);
|
|
@@ -793,7 +798,7 @@ window.__ModuleLoader__.load({
|
|
|
793
798
|
type: "button",
|
|
794
799
|
className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
|
|
795
800
|
onClick: () => openDshPluginMarket(actions),
|
|
796
|
-
children: "
|
|
801
|
+
children: "查看更新方式"
|
|
797
802
|
})
|
|
798
803
|
]
|
|
799
804
|
});
|
|
@@ -860,7 +865,7 @@ window.__ModuleLoader__.load({
|
|
|
860
865
|
type: "button",
|
|
861
866
|
className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
|
|
862
867
|
onClick: () => openDshPluginMarket(actions),
|
|
863
|
-
children: "
|
|
868
|
+
children: "查看更新方式"
|
|
864
869
|
})
|
|
865
870
|
]
|
|
866
871
|
});
|
|
@@ -887,7 +892,7 @@ window.__ModuleLoader__.load({
|
|
|
887
892
|
className: "mcpConnectorUpdateButton",
|
|
888
893
|
title: `npm 已发布 v${versionStatus?.latestVersion}`,
|
|
889
894
|
onClick: () => openDshPluginMarket(actions),
|
|
890
|
-
children:
|
|
895
|
+
children: `查看 v${versionStatus?.latestVersion} 更新方式`
|
|
891
896
|
});
|
|
892
897
|
};
|
|
893
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 = {
|
|
@@ -382,7 +387,27 @@ export async function apply(ctx, config) {
|
|
|
382
387
|
async function refreshGrantWithRecovery(grantKey, phase) {
|
|
383
388
|
const observed = state.grants.get(grantKey)?.grant;
|
|
384
389
|
try {
|
|
385
|
-
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) });
|
|
386
411
|
} catch (error) {
|
|
387
412
|
let finalError = error;
|
|
388
413
|
const initialFailure = classifyRefreshFailure(error);
|
|
@@ -405,7 +430,13 @@ export async function apply(ctx, config) {
|
|
|
405
430
|
entry.lastRefreshError = null;
|
|
406
431
|
logger.info(`OAuth grant ${grantKey} adopted a newer persisted token after a concurrent rotation`);
|
|
407
432
|
try {
|
|
408
|
-
|
|
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
|
+
});
|
|
409
440
|
} catch (retryError) {
|
|
410
441
|
finalError = retryError;
|
|
411
442
|
}
|
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",
|