dsh-mcp-connector 0.2.23 → 0.2.25

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,35 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.25] - 2026-08-27
8
+
9
+ ### Added
10
+
11
+ - 新增 Update Provider 适配层,将版本发现与安装执行解耦;UI 通过统一能力调用更新检查、任务、回滚和重启,为 DSH 原生或其他插件市场预留适配边界。
12
+ - 首个 DSH Market API v1 Provider 适配器:能力探测通过后可一键更新插件,并轮询展示安装进度、稳定失败码、兼容回滚结果及按 Web/Desktop 宿主能力提供的刷新或重启操作。
13
+
14
+ ### Compatibility
15
+
16
+ - Provider 未安装、协议不兼容、广告跨源/无效端点或版本检查不一致时不调用私有更新路由,继续使用现有“前往插件市场”/npm 页面降级路径。
17
+
18
+ ### Verification
19
+
20
+ - 125 项自动测试、lint、npm 发布包白名单/敏感内容扫描通过;已在 web profile 的 dshmarket 1.33.0 上验证 `/dsh-market/api/v1/capabilities` 与 Connector 版本检查响应。
21
+
22
+ ## [0.2.24] - 2026-08-27
23
+
24
+ ### Added
25
+
26
+ - MCP连接器标题显示当前插件版本,并由服务端缓存检查 npm `latest` 与 GitHub Release;发现新版本时引导用户前往 DSH 插件市场安全更新。
27
+
28
+ ### Changed
29
+
30
+ - 市场页原“刷新”按钮改为“刷新连接器目录”,明确该操作仅更新 Registry 卡片,不会升级插件本身。
31
+
32
+ ### Verification
33
+
34
+ - 123 项自动测试、lint、npm 发布包白名单/敏感内容扫描和 npm/GitHub 真实版本源验收通过。
35
+
7
36
  ## [0.2.23] - 2026-08-26
8
37
 
9
38
  ### Fixed
@@ -385,7 +414,9 @@
385
414
  - 外部 URL 与导入 Header 执行安全校验。
386
415
  - iframe 消息校验同源和消息来源。
387
416
 
388
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...HEAD
417
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...HEAD
418
+ [0.2.25]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.24...v0.2.25
419
+ [0.2.24]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...v0.2.24
389
420
  [0.2.23]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...v0.2.23
390
421
  [0.2.22]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.21...v0.2.22
391
422
  [0.2.21]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.20...v0.2.21
package/README.en.md CHANGED
@@ -28,6 +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 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.
31
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.
32
33
 
33
34
  <!-- catalog-stats:start -->
@@ -111,7 +112,7 @@ npm run dev:ui
111
112
 
112
113
  Every Registry merge regenerates `catalog-stats.json`; an hourly workflow in this repository synchronizes the Chinese and English product copy plus a local stats snapshot. The static npm README updates with package releases, while the live badges above read the Registry directly and therefore stay current without another npm release.
113
114
 
114
- The current public version is [`dsh-mcp-connector@0.2.22`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.22](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.22).
115
+ The current public version is [`dsh-mcp-connector@0.2.24`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.24](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.24).
115
116
 
116
117
  See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
117
118
 
package/README.md CHANGED
@@ -28,6 +28,7 @@
28
28
  - 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 自动刷新/退避恢复与撤销;同 issuer 卡片可共享一次授权,DCR 返回的客户端密钥与 Token 一同只保存在本机。
29
29
  - 目录运营:内置目录、远程 registry、本地覆盖,支持 `published` 上下架与 `featured` 精选。
30
30
  - 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
31
+ - 插件版本与一键更新:版本发现独立于安装来源;页面通过 Update Provider 适配层探测安全更新能力。DSH Market API v1 是首个适配器,支持进度、稳定失败码、回滚及按宿主能力提供的重启/刷新操作;无可用 Provider 时回退到当前插件市场或 npm。
31
32
  - Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
32
33
  - 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;检测到旧插件仍启用并管理同名 Server 时阻断重复连接,避免凭据相互覆盖。
33
34
  - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
@@ -106,7 +107,7 @@ npm run dev:ui
106
107
 
107
108
  公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
108
109
 
109
- 当前公开版本为 [`dsh-mcp-connector@0.2.22`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.22](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.22)。
110
+ 当前公开版本为 [`dsh-mcp-connector@0.2.24`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.24](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.24)。
110
111
 
111
112
  版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
112
113
  Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
@@ -0,0 +1,34 @@
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 返回的激活结论展示“刷新生效”或重启提示。Web 且 Provider 明确允许进程重启时才展示“立即重启”;Desktop 等由外壳管理生命周期的宿主只提示用户重启,不自行杀进程。
25
+ 5. Provider 保留恢复点时展示“回滚”。回滚仍由 Provider 执行,MCP连接器不直接修改 profile、lockfile 或 `node_modules`。
26
+
27
+ 失败结果使用稳定代码区分正在运行的 Agent、其他安装任务占用、镜像同步、版本未变化、超时和权限拒绝。可重试失败保留重试操作;Registry 刚发布但镜像仍未同步时,用户可在看到明确提示后选择强制重试。
28
+
29
+ ## 安全边界
30
+
31
+ - 所有变更请求均为浏览器同源 POST,并继续接受 Provider 自身的 Origin、环回地址和运行中任务保护。
32
+ - MCP连接器不执行 `npm`/`pnpm`、不保存回滚 Token,也不复制任何 Provider 的安装算法。
33
+ - 重启按钮完全服从能力响应;接口未声明支持时不得构造其他进程控制路径。
34
+ - 更新任务属于当前 Host 进程。Host 已更换后,旧页面的任务编号自然失效,避免把旧进度误认为新进程状态。
@@ -42,7 +42,8 @@ dsh web
42
42
  - **已安装**:查看已经保存到本机的连接,页签徽标显示连接数。
43
43
  - **搜索**:按名称、服务商、简介和标签查找。
44
44
  - **添加连接**:导入 JSON、手动配置 HTTP/stdio,或从市场描述 URL 安装。
45
- - **刷新**:重新拉取目录,并更新连接健康状态。
45
+ - **版本与更新提示**:标题旁显示当前插件版本,版本检查不依赖安装所用的插件市场。检测到 npm 有新版本且当前宿主存在兼容 Update Provider 时,可在当前页面一键更新并查看进度、失败原因和可用回滚。DSH Market API v1 是当前首个 Provider;无可用 Provider 时自动回退为“前往插件市场”或 npm 页面。
46
+ - **刷新连接器目录**:重新拉取 Registry 卡片,并更新连接健康状态;该操作不会升级 MCP连接器插件本身。
46
47
 
47
48
  ## 3. 浏览市场与分类
48
49
 
@@ -64,7 +65,7 @@ dsh web
64
65
  | `配置` | 需要 Bearer Token 或 API Key | 录入服务商签发的凭据并验证 |
65
66
  | `需重新授权` | OAuth 授权过期、被撤销或不可用 | 点击后重新完成 OAuth 授权 |
66
67
  | `自动重试中` | OAuth Token 刷新遇到网络或服务端暂时故障 | 无需重新授权;保持 DSH 运行,插件会按退避策略自动恢复 |
67
- | `已配置` | 本机已有配置,尚未完成本轮健康确认 | 点击“刷新”或进入详情检查 |
68
+ | `已配置` | 本机已有配置,尚未完成本轮健康确认 | 点击“刷新连接器目录”或进入详情检查 |
68
69
  | `已连接` | 最近一次健康检查通过 | 可直接在会话中使用对应 MCP 工具 |
69
70
  | `部分异常` / `连接异常` | 一个或多个 Server 未通过检查 | 打开详情查看提示,核对网络、权限和服务状态 |
70
71
 
@@ -140,7 +141,7 @@ stdio 进程由 `@deepseek-ai/dsh-mcp-client` 管理,插件透传 `command`、
140
141
  - 执行健康检查;
141
142
  - 断开连接并删除该连接的本机配置。
142
143
 
143
- OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端点时仍会删除 DSH 本机授权记录。连接状态发生变化后,可点击“刷新”重新检查。
144
+ OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端点时仍会删除 DSH 本机授权记录。连接状态发生变化后,可点击“刷新连接器目录”重新检查。
144
145
 
145
146
  ## 8. 安全边界
146
147
 
@@ -160,7 +161,7 @@ OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端
160
161
 
161
162
  ### 市场里没有最新卡片
162
163
 
163
- 点击“刷新”。如果远程 Registry 暂时不可用,页面会继续显示缓存或内置目录;稍后恢复网络再刷新即可。
164
+ 点击“刷新连接器目录”。如果远程 Registry 暂时不可用,页面会继续显示缓存或内置目录;稍后恢复网络再刷新即可。
164
165
 
165
166
  ### Token/API Key 一直验证失败
166
167
 
package/lib/client.js CHANGED
@@ -19,6 +19,20 @@ window.__ModuleLoader__.load({
19
19
  ];
20
20
  const PROMPT_REQUEST_TYPE = "mcp-connector:start-session";
21
21
  const PROMPT_RESULT_TYPE = "mcp-connector:start-session-result";
22
+ const VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
23
+ const VERSION_CHECK_RETRY_MS = 5 * 60 * 1e3;
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
+ ]);
22
36
 
23
37
  /**
24
38
  * Desktop 内置的社区插件市场也注册在 sidebar.footer.action。
@@ -86,6 +100,62 @@ window.__ModuleLoader__.load({
86
100
  .mcpConnectorMarketPanel { background: #ffffff; color: #111827; }
87
101
  .mcpConnectorMarketHeader { border-color: #e5e7eb; }
88
102
  .mcpConnectorMarketTitle { color: #111827; }
103
+ .mcpConnectorVersion {
104
+ color: #6b7280;
105
+ background: #f3f4f6;
106
+ border-radius: 999px;
107
+ padding: 2px 8px;
108
+ font-size: 12px;
109
+ font-weight: 600;
110
+ }
111
+ .mcpConnectorUpdateButton {
112
+ color: #4338ca;
113
+ background: #eef2ff;
114
+ border: 1px solid #c7d2fe;
115
+ border-radius: 8px;
116
+ padding: 5px 9px;
117
+ font: inherit;
118
+ font-size: 12px;
119
+ font-weight: 600;
120
+ cursor: pointer;
121
+ }
122
+ .mcpConnectorUpdateButton:hover,
123
+ .mcpConnectorUpdateButton:focus-visible {
124
+ background: #e0e7ff;
125
+ outline: 2px solid #6366f1;
126
+ outline-offset: 1px;
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
+ }
89
159
  .mcpConnectorMarketClose { color: #6b7280; }
90
160
  .mcpConnectorMarketClose:hover,
91
161
  .mcpConnectorMarketClose:focus-visible {
@@ -99,6 +169,13 @@ window.__ModuleLoader__.load({
99
169
  .mcpConnectorMarketPanel { background: #1b1e25; color: #f3f4f6; }
100
170
  .mcpConnectorMarketHeader { border-color: #30343d; }
101
171
  .mcpConnectorMarketTitle { color: #f3f4f6; }
172
+ .mcpConnectorVersion { color: #d1d5db; background: #30343d; }
173
+ .mcpConnectorUpdateButton { color: #c7d2fe; background: #312e81; border-color: #4f46e5; }
174
+ .mcpConnectorUpdateButton:hover,
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; }
102
179
  .mcpConnectorMarketClose { color: #c4c8d0; }
103
180
  .mcpConnectorMarketClose:hover,
104
181
  .mcpConnectorMarketClose:focus-visible { background: #30343d !important; color: #ffffff; }
@@ -135,6 +212,195 @@ window.__ModuleLoader__.load({
135
212
  return mount;
136
213
  }
137
214
 
215
+ async function fetchVersionStatus() {
216
+ const response = await window.fetch("/mcp-connector/api", {
217
+ method: "POST",
218
+ headers: { "content-type": "application/json" },
219
+ body: JSON.stringify({ method: "versionStatus", params: {} })
220
+ });
221
+ const result = await response.json();
222
+ if (!response.ok || result.ok !== true || result.detail === void 0) {
223
+ throw new Error(result.message ?? `HTTP ${response.status}`);
224
+ }
225
+ return result.detail;
226
+ }
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
+ UPDATE_TIMEOUT: "更新超时",
367
+ UPDATE_FORBIDDEN: "更新请求被拒绝",
368
+ UPDATE_REJECTED: "无法执行本次更新"
369
+ };
370
+ return labels[failure?.code] ?? failure?.message ?? "更新失败";
371
+ }
372
+
373
+ /**
374
+ * DSH 暂未公开“打开指定设置分区”服务:只依赖稳定的 sidebar.settings
375
+ * slot 和可访问文本打开插件市场,不依赖构建生成的 CSS 类名。
376
+ */
377
+ function openDshPluginMarket(actions) {
378
+ const settingsHost = document.querySelector('[data-slot="sidebar.settings"]');
379
+ const settingsTrigger = settingsHost?.querySelector?.('button[aria-haspopup="dialog"]');
380
+ if (settingsTrigger === null || settingsTrigger === void 0) {
381
+ window.open(NPM_PACKAGE_URL, "_blank", "noopener,noreferrer");
382
+ return;
383
+ }
384
+ actions.close();
385
+ window.setTimeout(() => {
386
+ settingsTrigger.click();
387
+ let attempts = 0;
388
+ const selectMarket = () => {
389
+ const marketButton = [...document.querySelectorAll('[role="dialog"] nav button')].find((button) => {
390
+ const label = button.textContent?.trim() ?? "";
391
+ return /^(\u63d2\u4ef6\u5e02\u573a|Plugin Market|Plugin Marketplace)$/i.test(label);
392
+ });
393
+ if (marketButton !== void 0) {
394
+ marketButton.click();
395
+ return;
396
+ }
397
+ attempts += 1;
398
+ if (attempts < 20) window.setTimeout(selectMarket, 50);
399
+ };
400
+ window.requestAnimationFrame(selectMarket);
401
+ }, 0);
402
+ }
403
+
138
404
  /** 创建弹框 store(使用 DSH defineStore,与社区插件市场一致)。 */
139
405
  function createMarketViewStore() {
140
406
  return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
@@ -181,6 +447,15 @@ window.__ModuleLoader__.load({
181
447
  const { wide, useStore, actions, startPromptSession: launchPromptSession } = props;
182
448
  const open = useStore((state) => state.open);
183
449
  const detailOpen = useStore((state) => state.detailOpen);
450
+ const [versionStatus, setVersionStatus] = (0, react.useState)(null);
451
+ const [updateProviderState, setUpdateProviderState] = (0, react.useState)("checking");
452
+ const [updateProviderSelection, setUpdateProviderSelection] = (0, react.useState)(null);
453
+ const [providerUpdateCheck, setProviderUpdateCheck] = (0, react.useState)(null);
454
+ const [updateOperation, setUpdateOperation] = (0, react.useState)(null);
455
+ const [updateError, setUpdateError] = (0, react.useState)(null);
456
+ const [updateStarting, setUpdateStarting] = (0, react.useState)(false);
457
+ const [rollbackRunning, setRollbackRunning] = (0, react.useState)(false);
458
+ const [restarting, setRestarting] = (0, react.useState)(false);
184
459
  const frameRef = (0, react.useRef)(null);
185
460
  const closeRef = (0, react.useRef)(null);
186
461
  (0, react.useEffect)(() => {
@@ -219,6 +494,97 @@ window.__ModuleLoader__.load({
219
494
  window.addEventListener("message", onMessage);
220
495
  return () => { window.removeEventListener("message", onMessage); };
221
496
  }, [actions, launchPromptSession]);
497
+ (0, react.useEffect)(() => {
498
+ if (!open) return void 0;
499
+ let disposed = false;
500
+ let pollTimer;
501
+ const schedule = (delayMs) => {
502
+ window.clearTimeout(pollTimer);
503
+ pollTimer = window.setTimeout(check, Math.max(1e3, delayMs));
504
+ };
505
+ const check = () => {
506
+ fetchVersionStatus().then((status) => {
507
+ if (disposed) return;
508
+ setVersionStatus(status);
509
+ if (status.checking) {
510
+ schedule(1e3);
511
+ return;
512
+ }
513
+ const nextCheckAt = Date.parse(status.nextCheckAt ?? "");
514
+ const delay = Number.isFinite(nextCheckAt)
515
+ ? Math.min(VERSION_CHECK_INTERVAL_MS, nextCheckAt - Date.now())
516
+ : VERSION_CHECK_INTERVAL_MS;
517
+ schedule(delay);
518
+ }, (error) => {
519
+ console.warn("[mcp-connector] plugin update check failed:", error);
520
+ if (!disposed) schedule(VERSION_CHECK_RETRY_MS);
521
+ });
522
+ };
523
+ check();
524
+ return () => {
525
+ disposed = true;
526
+ window.clearTimeout(pollTimer);
527
+ };
528
+ }, [open]);
529
+ (0, react.useEffect)(() => {
530
+ if (!open) return void 0;
531
+ let disposed = false;
532
+ setUpdateProviderState("checking");
533
+ discoverUpdateProvider(PLUGIN_PACKAGE_NAME, true).then((selection) => {
534
+ if (disposed) return;
535
+ if (selection === null) {
536
+ setUpdateProviderSelection(null);
537
+ setProviderUpdateCheck(null);
538
+ setUpdateProviderState("unavailable");
539
+ return;
540
+ }
541
+ setUpdateProviderSelection(selection);
542
+ setProviderUpdateCheck(selection.update);
543
+ setUpdateProviderState("ready");
544
+ }).catch((error) => {
545
+ console.warn("[mcp-connector] update provider discovery failed:", error);
546
+ if (disposed) return;
547
+ setUpdateProviderSelection(null);
548
+ setProviderUpdateCheck(null);
549
+ setUpdateProviderState("unavailable");
550
+ });
551
+ return () => { disposed = true; };
552
+ }, [open]);
553
+ (0, react.useEffect)(() => {
554
+ const operationId = updateOperation?.operationId;
555
+ const provider = updateProviderSelection?.provider;
556
+ const capabilities = updateProviderSelection?.capabilities;
557
+ if (!open || provider === void 0 || capabilities === void 0
558
+ || typeof operationId !== "string" || !["queued", "running"].includes(updateOperation.state)) {
559
+ return void 0;
560
+ }
561
+ let disposed = false;
562
+ let timer;
563
+ const poll = () => {
564
+ provider.operation(capabilities, operationId).then((operation) => {
565
+ if (disposed) return;
566
+ setUpdateError(null);
567
+ setUpdateOperation(operation);
568
+ if (["queued", "running"].includes(operation.state)) {
569
+ timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS);
570
+ return;
571
+ }
572
+ if (operation.state === "succeeded") {
573
+ provider.check(capabilities, PLUGIN_PACKAGE_NAME, true).then(setProviderUpdateCheck, () => {});
574
+ }
575
+ }, (error) => {
576
+ console.warn("[mcp-connector] update operation polling failed:", error);
577
+ if (disposed) return;
578
+ setUpdateError(error instanceof Error ? error.message : String(error));
579
+ timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS * 2);
580
+ });
581
+ };
582
+ timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS);
583
+ return () => {
584
+ disposed = true;
585
+ window.clearTimeout(timer);
586
+ };
587
+ }, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id]);
222
588
  (0, react.useEffect)(() => {
223
589
  if (!open) return void 0;
224
590
  closeRef.current?.focus?.();
@@ -227,6 +593,201 @@ window.__ModuleLoader__.load({
227
593
  return () => { window.removeEventListener("keydown", onKeyDown); };
228
594
  }, [open, actions]);
229
595
  if (!open) return null;
596
+ const updateRunning = updateStarting || updateOperation !== null && ["queued", "running"].includes(updateOperation.state);
597
+ const startUpdate = (force = false) => {
598
+ const provider = updateProviderSelection?.provider;
599
+ const capabilities = updateProviderSelection?.capabilities;
600
+ if (updateRunning || provider === void 0 || capabilities === void 0) return;
601
+ setUpdateError(null);
602
+ setUpdateStarting(true);
603
+ provider.start(capabilities, PLUGIN_PACKAGE_NAME, force).then((operation) => {
604
+ setUpdateStarting(false);
605
+ setUpdateOperation(operation);
606
+ }, (error) => {
607
+ console.error("[mcp-connector] update start failed:", error);
608
+ setUpdateStarting(false);
609
+ if (error?.retryable === true) {
610
+ setUpdateOperation({
611
+ state: "failed",
612
+ failure: {
613
+ code: error.code ?? "UPDATE_FAILED",
614
+ message: error instanceof Error ? error.message : String(error),
615
+ retryable: true
616
+ },
617
+ outcome: { rollback: { available: false, state: "unavailable" } }
618
+ });
619
+ } else {
620
+ setUpdateError(error instanceof Error ? error.message : String(error));
621
+ }
622
+ });
623
+ };
624
+ const rollbackUpdate = () => {
625
+ const provider = updateProviderSelection?.provider;
626
+ const capabilities = updateProviderSelection?.capabilities;
627
+ if (rollbackRunning || provider === void 0 || capabilities === void 0
628
+ || typeof updateOperation?.operationId !== "string") return;
629
+ setUpdateError(null);
630
+ setRollbackRunning(true);
631
+ provider.rollback(capabilities, updateOperation.operationId).then((operation) => {
632
+ setRollbackRunning(false);
633
+ setUpdateOperation(operation);
634
+ }, (error) => {
635
+ console.error("[mcp-connector] rollback failed:", error);
636
+ setRollbackRunning(false);
637
+ setUpdateError(error instanceof Error ? error.message : String(error));
638
+ });
639
+ };
640
+ const restartHost = () => {
641
+ const provider = updateProviderSelection?.provider;
642
+ const capabilities = updateProviderSelection?.capabilities;
643
+ if (restarting || provider === void 0 || capabilities === void 0) return;
644
+ setRestarting(true);
645
+ setUpdateError(null);
646
+ provider.restart(capabilities).catch((error) => {
647
+ console.error("[mcp-connector] restart failed:", error);
648
+ setRestarting(false);
649
+ setUpdateError(error instanceof Error ? error.message : String(error));
650
+ });
651
+ };
652
+ const renderUpdateControls = () => {
653
+ if (!versionStatus?.updateAvailable && updateOperation === null && updateError === null) return null;
654
+ if (updateRunning) {
655
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
656
+ className: "mcpConnectorUpdateControls",
657
+ children: [
658
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
659
+ type: "button",
660
+ className: "mcpConnectorUpdateButton",
661
+ disabled: true,
662
+ children: updateStarting ? "正在启动更新…" : updateProgressLabel(updateOperation)
663
+ }),
664
+ updateError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
665
+ className: "mcpConnectorUpdateStatus",
666
+ "data-tone": "error",
667
+ title: updateError,
668
+ children: "暂时无法读取进度,正在重试"
669
+ })
670
+ ]
671
+ });
672
+ }
673
+ if (updateOperation?.state === "failed") {
674
+ const force = ["RELEASE_TOO_FRESH", "VERSION_UNCHANGED"].includes(updateOperation.failure?.code);
675
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
676
+ className: "mcpConnectorUpdateControls",
677
+ children: [
678
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
679
+ className: "mcpConnectorUpdateStatus",
680
+ "data-tone": "error",
681
+ title: updateOperation.failure?.message ?? "更新失败",
682
+ children: updateFailureLabel(updateOperation.failure)
683
+ }),
684
+ updateOperation.failure?.retryable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
685
+ type: "button",
686
+ className: "mcpConnectorUpdateButton",
687
+ onClick: () => startUpdate(force),
688
+ children: force ? "强制重试" : "重试更新"
689
+ }),
690
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
691
+ type: "button",
692
+ className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
693
+ onClick: () => openDshPluginMarket(actions),
694
+ children: "打开插件市场"
695
+ })
696
+ ]
697
+ });
698
+ }
699
+ if (["succeeded", "rolled-back"].includes(updateOperation?.state)) {
700
+ const rolledBack = updateOperation.state === "rolled-back";
701
+ const restartRequired = updateOperation.outcome?.restartRequired === true;
702
+ const refreshRequired = updateOperation.outcome?.refreshRequired === true;
703
+ const rollbackFailed = updateOperation.outcome?.rollback?.state === "failed";
704
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
705
+ className: "mcpConnectorUpdateControls",
706
+ children: [
707
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
708
+ className: "mcpConnectorUpdateStatus",
709
+ children: rolledBack
710
+ ? "已回滚"
711
+ : `已安装 v${updateOperation.installedVersion ?? versionStatus?.latestVersion}`
712
+ }),
713
+ rollbackFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
714
+ className: "mcpConnectorUpdateStatus",
715
+ "data-tone": "error",
716
+ title: updateOperation.outcome.rollback.detail ?? "回滚失败",
717
+ children: "回滚失败"
718
+ }),
719
+ restartRequired && updateProviderSelection?.capabilities?.restart?.supported === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
720
+ type: "button",
721
+ className: "mcpConnectorUpdateButton",
722
+ disabled: restarting,
723
+ onClick: restartHost,
724
+ children: restarting ? "正在重启…" : "立即重启"
725
+ }),
726
+ restartRequired && updateProviderSelection?.capabilities?.restart?.supported !== true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
727
+ className: "mcpConnectorUpdateStatus",
728
+ title: `重启由 ${updateProviderSelection?.capabilities?.restart?.managedBy ?? "宿主"} 管理`,
729
+ children: updateProviderSelection?.capabilities?.runtime === "desktop" ? "请重启 DSH Desktop" : "请重启 DSH"
730
+ }),
731
+ !restartRequired && refreshRequired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
732
+ type: "button",
733
+ className: "mcpConnectorUpdateButton",
734
+ onClick: () => window.location.reload(),
735
+ children: "刷新生效"
736
+ }),
737
+ updateOperation.outcome?.rollback?.available === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
738
+ type: "button",
739
+ className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
740
+ disabled: rollbackRunning,
741
+ onClick: rollbackUpdate,
742
+ children: rollbackRunning ? "正在回滚…" : rollbackFailed ? "重试回滚" : "回滚"
743
+ })
744
+ ]
745
+ });
746
+ }
747
+ if (updateError !== null) {
748
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
749
+ className: "mcpConnectorUpdateControls",
750
+ children: [
751
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
752
+ className: "mcpConnectorUpdateStatus",
753
+ "data-tone": "error",
754
+ title: updateError,
755
+ children: "更新服务调用失败"
756
+ }),
757
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
758
+ type: "button",
759
+ className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
760
+ onClick: () => openDshPluginMarket(actions),
761
+ children: "打开插件市场"
762
+ })
763
+ ]
764
+ });
765
+ }
766
+ if (updateProviderState === "checking") {
767
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
768
+ type: "button",
769
+ className: "mcpConnectorUpdateButton",
770
+ disabled: true,
771
+ children: "正在准备更新…"
772
+ });
773
+ }
774
+ if (updateProviderState === "ready" && providerUpdateCheck?.updateAvailable === true) {
775
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
776
+ type: "button",
777
+ className: "mcpConnectorUpdateButton",
778
+ title: `由 ${updateProviderSelection?.provider?.label ?? "更新服务"} 安全更新到 v${providerUpdateCheck.latestVersion}`,
779
+ onClick: () => startUpdate(false),
780
+ children: `一键更新到 v${providerUpdateCheck.latestVersion}`
781
+ });
782
+ }
783
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
784
+ type: "button",
785
+ className: "mcpConnectorUpdateButton",
786
+ title: `npm 已发布 v${versionStatus?.latestVersion}`,
787
+ onClick: () => openDshPluginMarket(actions),
788
+ children: `前往插件市场更新到 v${versionStatus?.latestVersion}`
789
+ });
790
+ };
230
791
  const src = window.location.origin + "/mcp-connector/ui/";
231
792
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
232
793
  style: {
@@ -270,10 +831,21 @@ window.__ModuleLoader__.load({
270
831
  },
271
832
  children: [
272
833
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
273
- style: { display: "flex", alignItems: "center", gap: 10 },
834
+ style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", minWidth: 0, flex: 1 },
274
835
  children: [
275
836
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { fontSize: 22 }, children: "\u{1F9E9}" }),
276
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { id: "mcp-connector-market-title", className: "mcpConnectorMarketTitle", style: { fontSize: 18, fontWeight: 600 }, children: "MCP连接器" })
837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { id: "mcp-connector-market-title", className: "mcpConnectorMarketTitle", style: { fontSize: 18, fontWeight: 600 }, children: "MCP连接器" }),
838
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
839
+ className: "mcpConnectorVersion",
840
+ title: versionStatus === null ? "正在检查插件版本" : `当前版本 v${versionStatus.installedVersion}`,
841
+ children: versionStatus === null ? "v…" : `v${versionStatus.installedVersion}`
842
+ }),
843
+ renderUpdateControls(),
844
+ versionStatus?.releasePending && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
845
+ className: "mcpConnectorVersion",
846
+ title: "GitHub Release 已发布,等待 npm 同步后即可更新",
847
+ children: `v${versionStatus.release.version} 正在同步`
848
+ })
277
849
  ]
278
850
  }),
279
851
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
package/lib/index.js CHANGED
@@ -33,6 +33,7 @@ import { classifyConnectionError, validateConnectionRecords } from './mcp-valida
33
33
  import { listMcpTools, McpHttpError } from './mcp-http.js';
34
34
  import { registerTools } from './tools.js';
35
35
  import { mountWebRoutes } from './web.js';
36
+ import { createVersionStatusService } from './version-status.js';
36
37
  import { assertSafeUrl } from './util.js';
37
38
  import { readLegacyGrantCandidates, planLegacyMigration, toConnectorGrant, toConnectionRecords } from './migration.js';
38
39
  import { classifyRefreshFailure, refreshRetryDelay } from './grant-lifecycle.js';
@@ -83,6 +84,10 @@ export const Config = z.object({
83
84
 
84
85
  export async function apply(ctx, config) {
85
86
  const logger = ctx.logger('mcp-connector');
87
+ const versionStatusService = createVersionStatusService({
88
+ timeoutMs: config.requestTimeoutMs,
89
+ logger,
90
+ });
86
91
 
87
92
  const domain = await ctx.storageDomain.open(defineConnectorDomain());
88
93
  ctx.effect(() => () => {
@@ -856,6 +861,17 @@ export async function apply(ctx, config) {
856
861
  /* ───────────────────────── api 门面 ───────────────────────── */
857
862
 
858
863
  const api = {
864
+ async versionStatus(force = false) {
865
+ const detail = versionStatusService.status({ force });
866
+ let message = `当前版本 v${detail.installedVersion}`;
867
+ if (detail.checking) message += ',正在检查更新';
868
+ else if (detail.updateAvailable) message += `,可更新到 v${detail.latestVersion}`;
869
+ else if (detail.releasePending) message += ',新版本正在同步到 npm';
870
+ else if (detail.status === 'unavailable') message += ',暂时无法检查更新';
871
+ else message += ',已是 npm 最新版';
872
+ return { ok: true, message, detail };
873
+ },
874
+
859
875
  async catalog({ category, keyword } = {}) {
860
876
  const items = listCatalog(state.merged, { category, keyword, publishedOnly: true }).map((d) => {
861
877
  const health = connectorHealth(d.id);
@@ -1640,6 +1656,7 @@ export async function apply(ctx, config) {
1640
1656
  ctx.effect(() => () => {
1641
1657
  disposeTools();
1642
1658
  disposeWeb?.();
1659
+ versionStatusService.dispose();
1643
1660
  for (const g of state.grants.values()) if (g.timer) clearTimeout(g.timer);
1644
1661
  });
1645
1662
 
@@ -0,0 +1,216 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4
+
5
+ export const INSTALLED_PLUGIN_VERSION = packageJson.version;
6
+ export const NPM_LATEST_URL = 'https://registry.npmjs.org/dsh-mcp-connector/latest';
7
+ export const NPM_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-mcp-connector';
8
+ export const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/duhu2000/dsh-mcp-connector/releases/latest';
9
+ export const GITHUB_RELEASES_URL = 'https://github.com/duhu2000/dsh-mcp-connector/releases';
10
+ export const VERSION_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
11
+ export const VERSION_CHECK_FAILURE_TTL_MS = 5 * 60 * 1000;
12
+
13
+ function normalizeVersion(value) {
14
+ if (typeof value !== 'string') return undefined;
15
+ const normalized = value.trim().replace(/^v/i, '');
16
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized)
17
+ ? normalized
18
+ : undefined;
19
+ }
20
+
21
+ function parseVersion(value) {
22
+ const normalized = normalizeVersion(value);
23
+ if (!normalized) return undefined;
24
+ const [withoutBuild] = normalized.split('+');
25
+ const [core, prerelease] = withoutBuild.split('-', 2);
26
+ return {
27
+ normalized,
28
+ core: core.split('.').map(Number),
29
+ prerelease: prerelease?.split('.') ?? [],
30
+ };
31
+ }
32
+
33
+ /** Compare two semantic versions. Returns a positive number when `left` is newer. */
34
+ export function compareVersions(left, right) {
35
+ const a = parseVersion(left);
36
+ const b = parseVersion(right);
37
+ if (!a || !b) return 0;
38
+ for (let index = 0; index < 3; index += 1) {
39
+ if (a.core[index] !== b.core[index]) return a.core[index] - b.core[index];
40
+ }
41
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1;
42
+ if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1;
43
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
44
+ for (let index = 0; index < length; index += 1) {
45
+ const aPart = a.prerelease[index];
46
+ const bPart = b.prerelease[index];
47
+ if (aPart === undefined) return -1;
48
+ if (bPart === undefined) return 1;
49
+ if (aPart === bPart) continue;
50
+ const aNumeric = /^\d+$/.test(aPart);
51
+ const bNumeric = /^\d+$/.test(bPart);
52
+ if (aNumeric && bNumeric) return Number(aPart) - Number(bPart);
53
+ if (aNumeric) return -1;
54
+ if (bNumeric) return 1;
55
+ return aPart.localeCompare(bPart);
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ export function isVersionNewer(candidate, installed) {
61
+ return compareVersions(candidate, installed) > 0;
62
+ }
63
+
64
+ function errorMessage(error) {
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
67
+
68
+ async function fetchJson(fetchImpl, url, { timeoutMs, headers, activeControllers }) {
69
+ const controller = new AbortController();
70
+ activeControllers.add(controller);
71
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
72
+ try {
73
+ const response = await fetchImpl(url, {
74
+ method: 'GET',
75
+ headers: {
76
+ accept: 'application/json',
77
+ 'user-agent': `dsh-mcp-connector/${INSTALLED_PLUGIN_VERSION}`,
78
+ ...headers,
79
+ },
80
+ signal: controller.signal,
81
+ });
82
+ if (!response?.ok) throw new Error(`HTTP ${response?.status ?? 'unknown'}`);
83
+ return await response.json();
84
+ } finally {
85
+ clearTimeout(timer);
86
+ activeControllers.delete(controller);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Check npm and GitHub without exposing those origins to the iframe CSP.
92
+ * npm `latest` is the update authority; GitHub is supplemental release metadata.
93
+ */
94
+ export function createVersionStatusService({
95
+ installedVersion = INSTALLED_PLUGIN_VERSION,
96
+ fetchImpl = globalThis.fetch,
97
+ timeoutMs = 15_000,
98
+ cacheTtlMs = VERSION_CHECK_TTL_MS,
99
+ failureTtlMs = VERSION_CHECK_FAILURE_TTL_MS,
100
+ now = () => Date.now(),
101
+ logger,
102
+ } = {}) {
103
+ let cached;
104
+ let expiresAt = 0;
105
+ let inFlight;
106
+ const activeControllers = new Set();
107
+
108
+ async function query() {
109
+ const checkedAtMs = now();
110
+ const [npmResult, githubResult] = await Promise.allSettled([
111
+ fetchJson(fetchImpl, NPM_LATEST_URL, { timeoutMs, activeControllers }),
112
+ fetchJson(fetchImpl, GITHUB_LATEST_RELEASE_URL, {
113
+ timeoutMs,
114
+ activeControllers,
115
+ headers: { accept: 'application/vnd.github+json' },
116
+ }),
117
+ ]);
118
+
119
+ const npmVersion = npmResult.status === 'fulfilled' ? normalizeVersion(npmResult.value?.version) : undefined;
120
+ const githubVersion = githubResult.status === 'fulfilled' ? normalizeVersion(githubResult.value?.tag_name) : undefined;
121
+ const npmOk = npmVersion !== undefined;
122
+ const githubOk = githubVersion !== undefined;
123
+ const errors = [];
124
+ if (!npmOk) errors.push(`npm: ${npmResult.status === 'rejected' ? errorMessage(npmResult.reason) : '返回了无效版本'}`);
125
+ if (!githubOk) errors.push(`GitHub: ${githubResult.status === 'rejected' ? errorMessage(githubResult.reason) : '返回了无效版本'}`);
126
+ if (errors.length > 0) logger?.warn?.(`plugin update check partial failure: ${errors.join('; ')}`);
127
+
128
+ const updateAvailable = npmOk && isVersionNewer(npmVersion, installedVersion);
129
+ const releasePending = githubOk && isVersionNewer(githubVersion, installedVersion)
130
+ && (!npmOk || isVersionNewer(githubVersion, npmVersion));
131
+ const status = npmOk && githubOk ? 'ok' : (npmOk || githubOk ? 'partial' : 'unavailable');
132
+ // npm is authoritative for an actionable update. A GitHub-only failure must not
133
+ // turn into a five-minute retry loop against the unauthenticated GitHub API.
134
+ const resultTtlMs = npmOk ? cacheTtlMs : failureTtlMs;
135
+ const result = {
136
+ installedVersion,
137
+ latestVersion: npmVersion ?? null,
138
+ updateAvailable,
139
+ releasePending,
140
+ checking: false,
141
+ status,
142
+ checkedAt: new Date(checkedAtMs).toISOString(),
143
+ nextCheckAt: new Date(checkedAtMs + resultTtlMs).toISOString(),
144
+ npmPackageUrl: NPM_PACKAGE_URL,
145
+ releasesUrl: GITHUB_RELEASES_URL,
146
+ release: githubOk ? {
147
+ version: githubVersion,
148
+ tagName: githubResult.value.tag_name,
149
+ url: typeof githubResult.value.html_url === 'string' ? githubResult.value.html_url : GITHUB_RELEASES_URL,
150
+ publishedAt: githubResult.value.published_at ?? null,
151
+ } : null,
152
+ sources: {
153
+ npm: npmOk ? { ok: true, version: npmVersion } : { ok: false },
154
+ github: githubOk ? { ok: true, version: githubVersion } : { ok: false },
155
+ },
156
+ };
157
+ expiresAt = checkedAtMs + resultTtlMs;
158
+ cached = result;
159
+ return result;
160
+ }
161
+
162
+ async function check({ force = false } = {}) {
163
+ if (!force && cached && now() < expiresAt) return cached;
164
+ if (inFlight) return inFlight;
165
+ if (typeof fetchImpl !== 'function') {
166
+ cached = {
167
+ installedVersion,
168
+ latestVersion: null,
169
+ updateAvailable: false,
170
+ releasePending: false,
171
+ checking: false,
172
+ status: 'unavailable',
173
+ checkedAt: new Date(now()).toISOString(),
174
+ nextCheckAt: new Date(now() + failureTtlMs).toISOString(),
175
+ npmPackageUrl: NPM_PACKAGE_URL,
176
+ releasesUrl: GITHUB_RELEASES_URL,
177
+ release: null,
178
+ sources: { npm: { ok: false }, github: { ok: false } },
179
+ };
180
+ expiresAt = now() + failureTtlMs;
181
+ return cached;
182
+ }
183
+ inFlight = query().finally(() => { inFlight = undefined; });
184
+ return inFlight;
185
+ }
186
+
187
+ function status({ force = false } = {}) {
188
+ const stale = !cached || now() >= expiresAt;
189
+ if ((force || stale) && !inFlight) {
190
+ void check({ force: true }).catch((error) => {
191
+ logger?.warn?.(`plugin update check failed: ${errorMessage(error)}`);
192
+ });
193
+ }
194
+ const current = cached ?? {
195
+ installedVersion,
196
+ latestVersion: null,
197
+ updateAvailable: false,
198
+ releasePending: false,
199
+ status: 'checking',
200
+ checkedAt: null,
201
+ nextCheckAt: null,
202
+ npmPackageUrl: NPM_PACKAGE_URL,
203
+ releasesUrl: GITHUB_RELEASES_URL,
204
+ release: null,
205
+ sources: { npm: { ok: false }, github: { ok: false } },
206
+ };
207
+ return { ...current, checking: inFlight !== undefined };
208
+ }
209
+
210
+ function dispose() {
211
+ for (const controller of activeControllers) controller.abort();
212
+ activeControllers.clear();
213
+ }
214
+
215
+ return { status, check, dispose };
216
+ }
package/lib/web.js CHANGED
@@ -17,6 +17,7 @@ const UI_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
17
17
 
18
18
  /** GET /mcp-connector/api 允许调度的方法 → api 门面(白名单,杜绝任意反射调用)。 */
19
19
  const API_WHITELIST = new Map([
20
+ ['versionStatus', (api, p) => api.versionStatus(p?.force === true)],
20
21
  ['catalog', (api, p) => api.catalog(p ?? {})],
21
22
  ['status', (api) => api.status()],
22
23
  ['healthCheck', (api, p) => api.healthCheck(p?.connectorId)],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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",
@@ -33,7 +34,7 @@
33
34
  ],
34
35
  "scripts": {
35
36
  "test": "node --test",
36
- "lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/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/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
38
  "registry:build": "node scripts/build-registry.mjs",
38
39
  "registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
39
40
  "dev:ui": "node scripts/ui-harness.mjs",
package/ui/index.html CHANGED
@@ -400,7 +400,7 @@
400
400
  <input id="search" type="text" placeholder="搜索连接器…" />
401
401
  </div>
402
402
  <button class="icon-btn primary" id="add-connection" type="button">+ 添加连接</button>
403
- <button class="icon-btn" id="refresh">刷新</button>
403
+ <button class="icon-btn" id="refresh" title="刷新连接器目录">刷新连接器目录</button>
404
404
  <div class="market-nav" id="market-nav" aria-label="市场分类导航"></div>
405
405
  </header>
406
406
  <main id="main"></main>