dsh-mcp-connector 0.2.20 → 0.2.22
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 +27 -1
- package/README.en.md +8 -2
- package/README.md +8 -2
- package/docs/STDIO-SUPPORT.md +2 -0
- package/docs/USER-GUIDE.md +3 -3
- package/lib/constants.js +2 -0
- package/lib/index.js +299 -51
- package/lib/mcp-provision.js +5 -5
- package/lib/mcp-validation.js +25 -3
- package/package.json +3 -2
- package/ui/index.html +28 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.22] - 2026-08-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 首次连接改为等待 `dsh-mcp-client` 完成 MCP `initialize` 与首次 `tools/list`;启动失败或超时不再提前保存、增加已安装数量或显示“已连接”。
|
|
12
|
+
- stdio 健康状态与工具详情改用 Host 实际注册的 `mcp__<serverName>__*` 工具;未注册工具保持“已配置”并显示可重试诊断。
|
|
13
|
+
- 手动 HTTP、免鉴权市场连接与 JSON 导入统一在落库前执行连接校验;DNS、网络、鉴权或协议失败时保留原配置且不写入无效记录。
|
|
14
|
+
- 市场 Web 请求增加连接、健康检查与工具加载超时,避免详情页无限停留在“加载中”。
|
|
15
|
+
|
|
16
|
+
### Verification
|
|
17
|
+
|
|
18
|
+
- 新增无效 HTTP 不落库、stdio Host 启动失败回滚、Host 工具注册状态和前端有限超时回归测试。
|
|
19
|
+
|
|
20
|
+
## [0.2.21] - 2026-08-25
|
|
21
|
+
|
|
22
|
+
### Documentation
|
|
23
|
+
|
|
24
|
+
- 产品介绍同步为公共 Registry 78 条、去重后市场 82 张卡片;新增 Registry/市场实时数量徽标。
|
|
25
|
+
- 新增每小时 Registry 统计同步工作流,以 `catalog-stats.json` 为权威来源更新中英文 README 与本地快照,避免后续上架造成宣传数量过期。
|
|
26
|
+
|
|
27
|
+
### Verification
|
|
28
|
+
|
|
29
|
+
- 插件 100 项自动测试、lint、Registry 统计同步幂等性、npm 发布文件白名单/敏感内容扫描和 `git diff --check` 通过。
|
|
30
|
+
|
|
7
31
|
## [0.2.20] - 2026-08-25
|
|
8
32
|
|
|
9
33
|
### Changed
|
|
@@ -348,7 +372,9 @@
|
|
|
348
372
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
349
373
|
- iframe 消息校验同源和消息来源。
|
|
350
374
|
|
|
351
|
-
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.
|
|
375
|
+
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...HEAD
|
|
376
|
+
[0.2.22]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.21...v0.2.22
|
|
377
|
+
[0.2.21]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.20...v0.2.21
|
|
352
378
|
[0.2.20]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.19...v0.2.20
|
|
353
379
|
[0.2.19]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.18...v0.2.19
|
|
354
380
|
[0.2.18]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.17...v0.2.18
|
package/README.en.md
CHANGED
|
@@ -13,6 +13,8 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
|
|
|
13
13
|
[](https://github.com/duhu2000/dsh-mcp-connector/actions/workflows/ci.yml)
|
|
14
14
|
[](https://www.npmjs.com/package/dsh-mcp-connector)
|
|
15
15
|
[](LICENSE)
|
|
16
|
+
[](https://github.com/duhu2000/dsh-mcp-connector-registry/blob/main/catalog-stats.json)
|
|
17
|
+
[](https://github.com/duhu2000/dsh-mcp-connector-registry/blob/main/catalog-stats.json)
|
|
16
18
|
|
|
17
19
|
## Features
|
|
18
20
|
|
|
@@ -28,7 +30,9 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
|
|
|
28
30
|
- A standalone remote Registry, allowing new marketplace cards to appear after refresh without publishing a new npm version.
|
|
29
31
|
- Explicit, non-destructive migration of authorization from the two earlier Qichacha OAuth plugins.
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
<!-- catalog-stats:start -->
|
|
34
|
+
As of 2026-08-25, the public Registry publishes 78 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 82 cards across 9 business categories. Recommendations remain limited to the four Qichacha cards, PKULaw, and Wind, for 6 featured cards in total. The Registry evolves independently; the badge shown after a client refresh and the live badges above are the authoritative current counts.
|
|
35
|
+
<!-- catalog-stats:end -->
|
|
32
36
|
|
|
33
37
|
## Interface and demo
|
|
34
38
|
|
|
@@ -105,7 +109,9 @@ npm run dev:ui
|
|
|
105
109
|
|
|
106
110
|
`npm run check` performs syntax checks, automated tests, and an npm package allowlist/sensitive-content audit. `npm run market:check` tracks the external DSH marketplace PR and live directory. Tags matching `v*` trigger GitHub Actions; the tag must match `package.json`. npm releases use Trusted Publishing through GitHub OIDC and do not require a long-lived `NPM_TOKEN`.
|
|
107
111
|
|
|
108
|
-
|
|
112
|
+
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
|
+
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).
|
|
109
115
|
|
|
110
116
|
See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
|
|
111
117
|
|
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
[](https://github.com/duhu2000/dsh-mcp-connector/actions/workflows/ci.yml)
|
|
14
14
|
[](https://www.npmjs.com/package/dsh-mcp-connector)
|
|
15
15
|
[](LICENSE)
|
|
16
|
+
[](https://github.com/duhu2000/dsh-mcp-connector-registry/blob/main/catalog-stats.json)
|
|
17
|
+
[](https://github.com/duhu2000/dsh-mcp-connector-registry/blob/main/catalog-stats.json)
|
|
16
18
|
|
|
17
19
|
## 功能
|
|
18
20
|
|
|
@@ -30,7 +32,9 @@
|
|
|
30
32
|
- 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;不删除原插件或原凭据。
|
|
31
33
|
- 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
<!-- catalog-stats:start -->
|
|
36
|
+
截至 2026-08-25,公共 Registry 已发布 78 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 82 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具、其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标和上方实时统计徽标为准。
|
|
37
|
+
<!-- catalog-stats:end -->
|
|
34
38
|
|
|
35
39
|
## 界面与演示
|
|
36
40
|
|
|
@@ -100,7 +104,9 @@ npm run dev:ui
|
|
|
100
104
|
|
|
101
105
|
`check` 执行语法检查、自动测试和 npm 发布包白名单校验;`market:check` 检查外部 DSH 市场 PR 与线上目录;`dev:ui` 启动不含真实凭据的本地 mock 市场。CI 使用 `--legacy-peer-deps` 安装显式测试依赖,DSH 运行期 peer 仍由 Host 提供。`v*` Tag 会触发 GitHub Actions;Tag 必须与 `package.json` 版本一致。Release 通过 npm Trusted Publishing (GitHub OIDC) 发布,不依赖长期 `NPM_TOKEN`。
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
|
|
108
|
+
|
|
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)。
|
|
104
110
|
|
|
105
111
|
版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
106
112
|
Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
|
package/docs/STDIO-SUPPORT.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
> 结论:**低成本补齐**——`@deepseek-ai/dsh-mcp-client` 已原生支持 stdio,我们只需在 schema + provisioning 两层「开闸透传」。
|
|
5
5
|
|
|
6
6
|
> **实施状态(2026-08-23)**:本文方案已经落地。schema、手工配置、JSON 导入、provisioning、Registry Schema、目录安全审计、健康状态、详情提示和自动测试均已完成;下文保留实施前的代码位置与改动建议,作为架构决策记录。
|
|
7
|
+
>
|
|
8
|
+
> **就绪语义加固(2026-08-26)**:用户主动连接时显式启用 `failOnStartupError`,必须等待 Host 完成首次 `initialize + tools/list` 后才持久化;健康检查与详情页改读 Host 实际注册的 `mcp__<serverName>__*` 工具。启动失败、进程退出或超时不再提前显示“已连接”。依赖默认值仍为 `false`,下文对应代码仅作为依赖能力说明。
|
|
7
9
|
> 状态:已完成(v0.2.12)
|
|
8
10
|
|
|
9
11
|
---
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -69,7 +69,7 @@ dsh web
|
|
|
69
69
|
|
|
70
70
|
Bearer/API Key 连接器会先执行 MCP `initialize` 验证。所有 Server 通过后,凭据才会保存并出现在“已安装”中;验证失败不会写入新凭据。
|
|
71
71
|
|
|
72
|
-
stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token、区域或租户标识。卡片目录只声明字段名称及其环境变量映射;提交后,真实值仅保存到 DSH 本机连接记录,并由插件注入该 stdio 进程的 `env
|
|
72
|
+
stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token、区域或租户标识。卡片目录只声明字段名称及其环境变量映射;提交后,真实值仅保存到 DSH 本机连接记录,并由插件注入该 stdio 进程的 `env`。市场、状态页和日志不会返回这些值。插件会等待 Host 完成首次 MCP 初始化与工具同步后再保存连接;失败或超时不会增加已安装数量,卡片也不会提前显示“已连接”。
|
|
73
73
|
|
|
74
74
|
OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。
|
|
75
75
|
|
|
@@ -121,7 +121,7 @@ OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据
|
|
|
121
121
|
- **HTTP**:填写名称、HTTPS MCP URL、可选 Header 和传输方式。
|
|
122
122
|
- **stdio**:填写本机命令、参数、环境变量和可选工作目录。
|
|
123
123
|
|
|
124
|
-
stdio 进程由 `@deepseek-ai/dsh-mcp-client`
|
|
124
|
+
stdio 进程由 `@deepseek-ai/dsh-mcp-client` 管理,插件透传 `command`、`args`、`env`、`cwd`,并等待 Host 完成首次 MCP 初始化和工具同步。stdio 会以当前用户权限启动本机进程,只运行你信任的软件包和命令。手动 HTTP 配置也会在保存前执行 `initialize` 校验;验证失败时表单内容仍保留,且不会生成无效连接记录。
|
|
125
125
|
|
|
126
126
|
### 6.3 市场卡片 URL
|
|
127
127
|
|
|
@@ -165,7 +165,7 @@ OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端
|
|
|
165
165
|
|
|
166
166
|
### stdio 启动失败
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
页面会在有限时间内结束等待,并区分命令不存在、进程退出、初始化失败或启动超时。先在终端确认命令本身可执行、软件包可信、Node/运行时版本满足要求,并检查 `cwd`、参数和环境变量;随后查看 Host 日志并点击“重新检查”。不要把本机凭据写入公开 Registry descriptor。
|
|
169
169
|
|
|
170
170
|
### 如何反馈问题
|
|
171
171
|
|
package/lib/constants.js
CHANGED
|
@@ -27,6 +27,8 @@ export const DEFAULT_CATALOG_FALLBACK_URLS = [
|
|
|
27
27
|
|
|
28
28
|
/** 请求 / 刷新默认值 */
|
|
29
29
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
30
|
+
/** 首次启动 MCP Server 并完成 initialize + tools/list 的最长等待时间 */
|
|
31
|
+
export const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
|
|
30
32
|
export const DEFAULT_REFRESH_SKEW_MS = 300_000;
|
|
31
33
|
export const DEFAULT_CATALOG_TTL_MS = 3_600_000;
|
|
32
34
|
|
package/lib/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DEFAULT_CATALOG_URL,
|
|
16
16
|
DEFAULT_CATALOG_FALLBACK_URLS,
|
|
17
17
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
18
|
+
DEFAULT_STARTUP_TIMEOUT_MS,
|
|
18
19
|
DEFAULT_REFRESH_SKEW_MS,
|
|
19
20
|
DEFAULT_CATALOG_TTL_MS,
|
|
20
21
|
} from './constants.js';
|
|
@@ -52,6 +53,8 @@ export const Config = z.object({
|
|
|
52
53
|
callbackTimeoutMs: z.number().default(300_000),
|
|
53
54
|
/** 网络请求超时(ms) */
|
|
54
55
|
requestTimeoutMs: z.number().default(DEFAULT_REQUEST_TIMEOUT_MS),
|
|
56
|
+
/** 首次启动并完成 MCP initialize + tools/list 的超时(ms) */
|
|
57
|
+
startupTimeoutMs: z.number().default(DEFAULT_STARTUP_TIMEOUT_MS),
|
|
55
58
|
/** 提前刷新阈值(ms) */
|
|
56
59
|
refreshSkewMs: z.number().default(DEFAULT_REFRESH_SKEW_MS),
|
|
57
60
|
/** 是否自动打开浏览器(false = 仅打印授权 URL) */
|
|
@@ -251,18 +254,123 @@ export async function apply(ctx, config) {
|
|
|
251
254
|
|
|
252
255
|
/* ───────────────────────── 连接记录落库 / 挂载 ───────────────────────── */
|
|
253
256
|
|
|
257
|
+
function clearRecordHealth(record) {
|
|
258
|
+
if (record?.connectorId) state.health.delete(record.connectorId);
|
|
259
|
+
}
|
|
260
|
+
|
|
254
261
|
async function persistRecord(record) {
|
|
255
|
-
|
|
256
|
-
if (record.connectorId) state.health.delete(record.connectorId);
|
|
262
|
+
// 存储成功后才更新内存,避免落盘失败却在当前进程显示为“已安装”。
|
|
257
263
|
if (config.persistSecrets) await connectionStore.put(record);
|
|
264
|
+
state.connections.set(record.key, record);
|
|
265
|
+
clearRecordHealth(record);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function restorePersistedRecord(key, original) {
|
|
269
|
+
const current = state.connections.get(key);
|
|
270
|
+
if (config.persistSecrets) {
|
|
271
|
+
if (original) await connectionStore.put(original);
|
|
272
|
+
else await connectionStore.delete(key);
|
|
273
|
+
}
|
|
274
|
+
if (original) state.connections.set(key, original);
|
|
275
|
+
else state.connections.delete(key);
|
|
276
|
+
clearRecordHealth(current);
|
|
277
|
+
clearRecordHealth(original);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function restoreProvisionedEntry(record, original) {
|
|
281
|
+
if (original) await provision(ctx, config, original, grantMap(), { failOnStartupError: false });
|
|
282
|
+
else await remove(ctx, config, record);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function provisionUntilReady(record, original, { strictStartup = true } = {}) {
|
|
286
|
+
if (!strictStartup) {
|
|
287
|
+
return provision(ctx, config, record, grantMap(), { failOnStartupError: false });
|
|
288
|
+
}
|
|
289
|
+
const startupTimeoutMs = Math.max(1_000, Number(config.startupTimeoutMs) || DEFAULT_STARTUP_TIMEOUT_MS);
|
|
290
|
+
const timeoutError = Object.assign(
|
|
291
|
+
new Error(`MCP Server 启动与工具同步超过 ${Math.ceil(startupTimeoutMs / 1000)} 秒`),
|
|
292
|
+
{ code: 'ETIMEDOUT' },
|
|
293
|
+
);
|
|
294
|
+
let timer;
|
|
295
|
+
const task = provision(ctx, config, record, grantMap(), { failOnStartupError: true });
|
|
296
|
+
const deadline = new Promise((_, reject) => {
|
|
297
|
+
timer = setTimeout(() => reject(timeoutError), startupTimeoutMs);
|
|
298
|
+
timer.unref?.();
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
return await Promise.race([task, deadline]);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (error === timeoutError) {
|
|
304
|
+
// loader 可能仍在等待 SDK 请求;它最终成功时必须恢复旧条目/移除新条目,绝不迟到落库。
|
|
305
|
+
task.then(
|
|
306
|
+
() => restoreProvisionedEntry(record, original).catch((rollbackError) => {
|
|
307
|
+
logger.error(`late startup rollback "${record.key}" failed: ${rollbackError.message}`);
|
|
308
|
+
}),
|
|
309
|
+
() => {},
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
throw error;
|
|
313
|
+
} finally {
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function rollbackProvisionedRecords(records, originals) {
|
|
319
|
+
const failures = [];
|
|
320
|
+
for (const record of [...records].reverse()) {
|
|
321
|
+
try {
|
|
322
|
+
await restoreProvisionedEntry(record, originals.get(record.key));
|
|
323
|
+
} catch (error) {
|
|
324
|
+
failures.push(error);
|
|
325
|
+
logger.error(`connection rollback "${record.key}" failed: ${error.message}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return failures;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function commitConnectionRecords(records, { strictStartup = true } = {}) {
|
|
332
|
+
const originals = new Map(records.map((record) => [record.key, state.connections.get(record.key)]));
|
|
333
|
+
const staged = records.map((record) => {
|
|
334
|
+
const existing = originals.get(record.key);
|
|
335
|
+
return existing ? { ...existing, ...record, updatedAt: Date.now() } : record;
|
|
336
|
+
});
|
|
337
|
+
const provisioned = [];
|
|
338
|
+
let activeRecord;
|
|
339
|
+
try {
|
|
340
|
+
for (const record of staged) {
|
|
341
|
+
activeRecord = record;
|
|
342
|
+
await provisionUntilReady(record, originals.get(record.key), { strictStartup });
|
|
343
|
+
provisioned.push(record);
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
await rollbackProvisionedRecords(provisioned, originals);
|
|
347
|
+
const wrapped = new Error(`MCP Server "${activeRecord?.serverName ?? activeRecord?.key ?? 'unknown'}" 未完成启动`, { cause: error });
|
|
348
|
+
wrapped.connectionRecord = activeRecord;
|
|
349
|
+
throw wrapped;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
for (const record of staged) await persistRecord(record);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
const rollbackErrors = await rollbackProvisionedRecords(provisioned, originals);
|
|
356
|
+
for (const record of staged) {
|
|
357
|
+
try {
|
|
358
|
+
await restorePersistedRecord(record.key, originals.get(record.key));
|
|
359
|
+
} catch (rollbackError) {
|
|
360
|
+
rollbackErrors.push(rollbackError);
|
|
361
|
+
logger.error(`connection storage rollback "${record.key}" failed: ${rollbackError.message}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const wrapped = new Error('连接记录持久化失败,已回滚 Host 条目', { cause: error });
|
|
365
|
+
wrapped.connectionRecord = activeRecord;
|
|
366
|
+
wrapped.rollbackErrors = rollbackErrors;
|
|
367
|
+
throw wrapped;
|
|
368
|
+
}
|
|
369
|
+
return staged;
|
|
258
370
|
}
|
|
259
371
|
|
|
260
|
-
async function upsertRecord(record) {
|
|
261
|
-
|
|
262
|
-
const merged = existing ? { ...existing, ...record, updatedAt: Date.now() } : record;
|
|
263
|
-
await persistRecord(merged);
|
|
264
|
-
await provision(ctx, config, merged, grantMap());
|
|
265
|
-
return merged;
|
|
372
|
+
async function upsertRecord(record, options) {
|
|
373
|
+
return (await commitConnectionRecords([record], options))[0];
|
|
266
374
|
}
|
|
267
375
|
|
|
268
376
|
function grantFailure(record) {
|
|
@@ -278,16 +386,70 @@ export async function apply(ctx, config) {
|
|
|
278
386
|
return null;
|
|
279
387
|
}
|
|
280
388
|
|
|
389
|
+
function registeredStdioTools(record) {
|
|
390
|
+
if (typeof ctx.tools?.schemas !== 'function') {
|
|
391
|
+
return { supported: false, tools: [] };
|
|
392
|
+
}
|
|
393
|
+
const prefix = `mcp__${record.serverName}__`;
|
|
394
|
+
try {
|
|
395
|
+
const tools = ctx.tools.schemas()
|
|
396
|
+
.filter((schema) => typeof schema?.name === 'string' && schema.name.startsWith(prefix))
|
|
397
|
+
.map((schema) => ({
|
|
398
|
+
name: schema.name.slice(prefix.length),
|
|
399
|
+
publicName: schema.name,
|
|
400
|
+
title: schema.title || schema.name.slice(prefix.length),
|
|
401
|
+
description: schema.description || '',
|
|
402
|
+
}));
|
|
403
|
+
return { supported: true, tools };
|
|
404
|
+
} catch (error) {
|
|
405
|
+
logger.warn(`read registered tools for "${record.serverName}" failed: ${error.message}`);
|
|
406
|
+
return { supported: true, tools: [], error };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readinessResults(records, validationResults = []) {
|
|
411
|
+
return records.map((record, index) => {
|
|
412
|
+
const validated = validationResults[index];
|
|
413
|
+
return {
|
|
414
|
+
...(validated ?? {}),
|
|
415
|
+
ok: true,
|
|
416
|
+
kind: 'connected',
|
|
417
|
+
serverKey: record.serverKey,
|
|
418
|
+
serverName: record.serverName,
|
|
419
|
+
message: record.transport === 'stdio'
|
|
420
|
+
? 'Host 已完成 stdio MCP 初始化与首次工具同步'
|
|
421
|
+
: validated?.message,
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function connectionSetupFailure(error, prefix = '连接失败') {
|
|
427
|
+
const record = error?.connectionRecord;
|
|
428
|
+
const classified = classifyConnectionError(error, { transport: record?.transport });
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
message: `${prefix}:${classified.message}。未保存连接,请修正后重试。`,
|
|
432
|
+
detail: {
|
|
433
|
+
kind: classified.kind,
|
|
434
|
+
serverKey: record?.serverKey,
|
|
435
|
+
serverName: record?.serverName,
|
|
436
|
+
...(classified.exitCode !== undefined ? { exitCode: classified.exitCode } : {}),
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
281
441
|
function healthSummary(connectorId, records, results, checkedAt = Date.now()) {
|
|
282
442
|
const enabledRecords = records.filter((record) => record.enabled !== false);
|
|
283
|
-
const availableServers = results.filter((result) => result.ok).length;
|
|
284
|
-
const
|
|
443
|
+
const availableServers = results.filter((result) => result.ok && result.kind !== 'managed').length;
|
|
444
|
+
const pendingServers = results.filter((result) => result.ok && result.kind === 'managed').length;
|
|
445
|
+
const failedServers = results.filter((result) => !result.ok).length;
|
|
285
446
|
const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
|
|
286
447
|
let connectionState = 'configured';
|
|
287
448
|
if (records.length === 0) connectionState = 'disconnected';
|
|
288
449
|
else if (enabledRecords.length === 0) connectionState = 'disabled';
|
|
289
|
-
else if (failedServers === 0 && availableServers > 0) connectionState = 'healthy';
|
|
290
|
-
else if (availableServers > 0) connectionState = 'degraded';
|
|
450
|
+
else if (failedServers === 0 && pendingServers === 0 && availableServers > 0) connectionState = 'healthy';
|
|
451
|
+
else if (availableServers > 0 && (failedServers > 0 || pendingServers > 0)) connectionState = 'degraded';
|
|
452
|
+
else if (pendingServers > 0 && failedServers === 0) connectionState = 'configured';
|
|
291
453
|
else if (authFailures > 0) connectionState = 'reauth';
|
|
292
454
|
else if (failedServers > 0) connectionState = 'unavailable';
|
|
293
455
|
const labels = {
|
|
@@ -301,6 +463,7 @@ export async function apply(ctx, config) {
|
|
|
301
463
|
configuredServers: records.length,
|
|
302
464
|
enabledServers: enabledRecords.length,
|
|
303
465
|
availableServers,
|
|
466
|
+
pendingServers,
|
|
304
467
|
failedServers,
|
|
305
468
|
checkedAt,
|
|
306
469
|
results,
|
|
@@ -451,7 +614,7 @@ export async function apply(ctx, config) {
|
|
|
451
614
|
state.grants.set(grantKey, { grant, timer: undefined, refreshPromise: null, needsReauth: false });
|
|
452
615
|
if (config.persistSecrets) await grantStore.put(grant);
|
|
453
616
|
|
|
454
|
-
const
|
|
617
|
+
const records = [];
|
|
455
618
|
for (const sk of authz.grantedKeys) {
|
|
456
619
|
const server = connector.servers.find((s) => s.serverKey === sk);
|
|
457
620
|
if (!server) continue;
|
|
@@ -470,9 +633,11 @@ export async function apply(ctx, config) {
|
|
|
470
633
|
createdAt: Date.now(),
|
|
471
634
|
updatedAt: Date.now(),
|
|
472
635
|
};
|
|
473
|
-
|
|
474
|
-
created.push(record.key);
|
|
636
|
+
records.push(record);
|
|
475
637
|
}
|
|
638
|
+
const committed = await commitConnectionRecords(records);
|
|
639
|
+
const created = committed.map((record) => record.key);
|
|
640
|
+
cacheHealth(connector.id, committed, readinessResults(committed));
|
|
476
641
|
scheduleRefresh(grantKey);
|
|
477
642
|
let retiredGrantCount = 0;
|
|
478
643
|
for (const previousKey of previousGrantKeys) {
|
|
@@ -486,7 +651,9 @@ export async function apply(ctx, config) {
|
|
|
486
651
|
} catch (error) {
|
|
487
652
|
logger.error(`oauth connect failed: ${error.message}`);
|
|
488
653
|
await pruneOrphanGrants();
|
|
489
|
-
return
|
|
654
|
+
return error?.connectionRecord
|
|
655
|
+
? connectionSetupFailure(error)
|
|
656
|
+
: { ok: false, message: `连接失败: ${error.message}` };
|
|
490
657
|
}
|
|
491
658
|
})();
|
|
492
659
|
state.oauthConnects.set(connector.id, promise);
|
|
@@ -518,8 +685,15 @@ export async function apply(ctx, config) {
|
|
|
518
685
|
createdAt: Date.now(),
|
|
519
686
|
updatedAt: Date.now(),
|
|
520
687
|
};
|
|
521
|
-
await
|
|
522
|
-
return {
|
|
688
|
+
const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
|
|
689
|
+
if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
|
|
690
|
+
try {
|
|
691
|
+
const [committed] = await commitConnectionRecords([record]);
|
|
692
|
+
cacheHealth(connector.id, [committed], readinessResults([committed], validation.detail.results));
|
|
693
|
+
return { ok: true, message: `已连接 "${connector.name}"`, detail: { key: committed.key } };
|
|
694
|
+
} catch (error) {
|
|
695
|
+
return connectionSetupFailure(error);
|
|
696
|
+
}
|
|
523
697
|
}
|
|
524
698
|
|
|
525
699
|
// bearer / api-key 型:目录不含密钥,引导自定义配置
|
|
@@ -574,12 +748,9 @@ export async function apply(ctx, config) {
|
|
|
574
748
|
const validation = await validateConnectionRecords(records, { timeoutMs: config.requestTimeoutMs });
|
|
575
749
|
if (!validation.ok) return { ...validation, detail: { connectorId: connector.id, ...validation.detail } };
|
|
576
750
|
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
created.push(record.key);
|
|
581
|
-
}
|
|
582
|
-
cacheHealth(connector.id, records, validation.detail.results);
|
|
751
|
+
const committed = await commitConnectionRecords(records);
|
|
752
|
+
const created = committed.map((record) => record.key);
|
|
753
|
+
cacheHealth(connector.id, committed, readinessResults(committed, validation.detail.results));
|
|
583
754
|
return {
|
|
584
755
|
ok: true,
|
|
585
756
|
message: `已配置并连接 "${connector.name}"(${created.length} 个 MCP Server)`,
|
|
@@ -587,20 +758,39 @@ export async function apply(ctx, config) {
|
|
|
587
758
|
};
|
|
588
759
|
}
|
|
589
760
|
const record = buildManualRecord(params);
|
|
590
|
-
await
|
|
591
|
-
|
|
761
|
+
const validation = await validateConnectionRecords([record], { timeoutMs: config.requestTimeoutMs });
|
|
762
|
+
if (!validation.ok) return validation;
|
|
763
|
+
const [committed] = await commitConnectionRecords([record]);
|
|
764
|
+
cacheHealth(committed.connectorId, [committed], readinessResults([committed], validation.detail.results));
|
|
765
|
+
return { ok: true, message: `已配置并连接 "${committed.name}"(serverName=${committed.serverName})`, detail: { key: committed.key, serverName: committed.serverName } };
|
|
592
766
|
} catch (error) {
|
|
593
|
-
return
|
|
767
|
+
return error?.connectionRecord
|
|
768
|
+
? connectionSetupFailure(error, '配置失败')
|
|
769
|
+
: { ok: false, message: `配置失败: ${error.message}` };
|
|
594
770
|
}
|
|
595
771
|
},
|
|
596
772
|
|
|
597
773
|
async importJson(json) {
|
|
598
774
|
try {
|
|
599
775
|
const { records, skipped } = normalizeJsonImport(json);
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
776
|
+
const validation = await validateConnectionRecords(records, {
|
|
777
|
+
timeoutMs: config.requestTimeoutMs,
|
|
778
|
+
concurrency: 4,
|
|
779
|
+
});
|
|
780
|
+
if (!validation.ok) return validation;
|
|
781
|
+
const committed = await commitConnectionRecords(records);
|
|
782
|
+
const keys = committed.map((record) => record.key);
|
|
783
|
+
const grouped = new Map();
|
|
784
|
+
for (const record of committed) {
|
|
785
|
+
if (!grouped.has(record.connectorId)) grouped.set(record.connectorId, []);
|
|
786
|
+
grouped.get(record.connectorId).push(record);
|
|
787
|
+
}
|
|
788
|
+
for (const [connectorId, connectorRecords] of grouped) {
|
|
789
|
+
const results = connectorRecords.map((record) => {
|
|
790
|
+
const index = committed.findIndex((item) => item.key === record.key);
|
|
791
|
+
return readinessResults([record], [validation.detail.results[index]])[0];
|
|
792
|
+
});
|
|
793
|
+
cacheHealth(connectorId, connectorRecords, results);
|
|
604
794
|
}
|
|
605
795
|
const extra = skipped.length ? `;跳过:${skipped.join('、')}` : '';
|
|
606
796
|
return {
|
|
@@ -609,7 +799,9 @@ export async function apply(ctx, config) {
|
|
|
609
799
|
detail: { keys, skipped },
|
|
610
800
|
};
|
|
611
801
|
} catch (error) {
|
|
612
|
-
return
|
|
802
|
+
return error?.connectionRecord
|
|
803
|
+
? connectionSetupFailure(error, '导入失败')
|
|
804
|
+
: { ok: false, message: `导入失败: ${error.message}` };
|
|
613
805
|
}
|
|
614
806
|
},
|
|
615
807
|
|
|
@@ -667,7 +859,9 @@ export async function apply(ctx, config) {
|
|
|
667
859
|
authMode: record.auth?.mode ?? 'none',
|
|
668
860
|
grant: grantInfo,
|
|
669
861
|
lastError: record.lastError ?? null,
|
|
670
|
-
connectionState: recordHealth?.ok
|
|
862
|
+
connectionState: recordHealth?.ok && recordHealth.kind !== 'managed'
|
|
863
|
+
? 'healthy'
|
|
864
|
+
: recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
|
|
671
865
|
healthCheckedAt: health?.checkedAt ?? null,
|
|
672
866
|
healthMessage: recordHealth?.message ?? null,
|
|
673
867
|
});
|
|
@@ -694,7 +888,29 @@ export async function apply(ctx, config) {
|
|
|
694
888
|
for (const record of enabledRecords) {
|
|
695
889
|
const failure = grantFailure(record);
|
|
696
890
|
if (failure) immediateResults.push(failure);
|
|
697
|
-
else
|
|
891
|
+
else if (record.transport === 'stdio') {
|
|
892
|
+
const registered = registeredStdioTools(record);
|
|
893
|
+
if (registered.tools.length > 0) {
|
|
894
|
+
immediateResults.push({
|
|
895
|
+
ok: true,
|
|
896
|
+
kind: 'connected',
|
|
897
|
+
serverKey: record.serverKey,
|
|
898
|
+
serverName: record.serverName,
|
|
899
|
+
toolCount: registered.tools.length,
|
|
900
|
+
message: `Host 已注册 ${registered.tools.length} 个工具`,
|
|
901
|
+
});
|
|
902
|
+
} else {
|
|
903
|
+
immediateResults.push({
|
|
904
|
+
ok: true,
|
|
905
|
+
kind: 'managed',
|
|
906
|
+
serverKey: record.serverKey,
|
|
907
|
+
serverName: record.serverName,
|
|
908
|
+
message: registered.supported
|
|
909
|
+
? 'Host 尚未注册该 stdio Server 的工具'
|
|
910
|
+
: '当前 Host 无法读取 stdio 工具注册状态',
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
} else checkable.push(record);
|
|
698
914
|
}
|
|
699
915
|
let checkedResults = [];
|
|
700
916
|
if (checkable.length > 0) {
|
|
@@ -775,11 +991,26 @@ export async function apply(ctx, config) {
|
|
|
775
991
|
async setEnabled(key, enabled) {
|
|
776
992
|
const record = state.connections.get(key);
|
|
777
993
|
if (!record) return { ok: false, message: `连接 "${key}" 不存在` };
|
|
778
|
-
record
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
994
|
+
const next = { ...record, enabled: !!enabled, updatedAt: Date.now() };
|
|
995
|
+
if (next.enabled) {
|
|
996
|
+
try {
|
|
997
|
+
const [committed] = await commitConnectionRecords([next]);
|
|
998
|
+
if (committed.connectorId) cacheHealth(committed.connectorId, [committed], readinessResults([committed]));
|
|
999
|
+
return { ok: true, message: `已启用 "${committed.name}"` };
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
return connectionSetupFailure(error, '启用失败');
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
await disable(ctx, config, next);
|
|
1005
|
+
try {
|
|
1006
|
+
await persistRecord(next);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
await provision(ctx, config, record, grantMap(), { failOnStartupError: false }).catch((rollbackError) => {
|
|
1009
|
+
logger.error(`disable rollback "${record.key}" failed: ${rollbackError.message}`);
|
|
1010
|
+
});
|
|
1011
|
+
throw error;
|
|
1012
|
+
}
|
|
1013
|
+
return { ok: true, message: `已停用 "${next.name}"` };
|
|
783
1014
|
},
|
|
784
1015
|
|
|
785
1016
|
async disconnect(key, signal) {
|
|
@@ -887,19 +1118,30 @@ export async function apply(ctx, config) {
|
|
|
887
1118
|
const servers = [];
|
|
888
1119
|
for (const record of records) {
|
|
889
1120
|
if (record.transport === 'stdio') {
|
|
1121
|
+
const registered = registeredStdioTools(record);
|
|
890
1122
|
const snapshot = (connector.toolsSnapshot ?? []).find((item) =>
|
|
891
1123
|
item.serverKey === record.serverKey || item.serverName === record.serverName);
|
|
1124
|
+
const liveTools = registered.tools;
|
|
892
1125
|
servers.push({
|
|
893
1126
|
serverKey: record.serverKey || record.name,
|
|
894
1127
|
serverName: record.serverName,
|
|
895
|
-
ok:
|
|
1128
|
+
ok: liveTools.length > 0,
|
|
896
1129
|
managed: true,
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1130
|
+
live: liveTools.length > 0,
|
|
1131
|
+
preview: liveTools.length === 0 && Boolean(snapshot),
|
|
1132
|
+
tools: liveTools.length > 0
|
|
1133
|
+
? liveTools
|
|
1134
|
+
: (snapshot?.tools ?? []).map((tool) => ({
|
|
1135
|
+
name: tool.name,
|
|
1136
|
+
title: tool.title || tool.name,
|
|
1137
|
+
description: tool.description || '',
|
|
1138
|
+
})),
|
|
1139
|
+
...(liveTools.length === 0 ? {
|
|
1140
|
+
errorKind: registered.supported ? 'startup' : 'host-version',
|
|
1141
|
+
error: registered.supported
|
|
1142
|
+
? 'Host 尚未注册该 stdio Server 的工具,请检查启动日志后重试'
|
|
1143
|
+
: '当前 DSH Host 不支持读取工具注册状态,请升级 Host 后重试',
|
|
1144
|
+
} : {}),
|
|
903
1145
|
});
|
|
904
1146
|
continue;
|
|
905
1147
|
}
|
|
@@ -938,7 +1180,7 @@ export async function apply(ctx, config) {
|
|
|
938
1180
|
const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
|
|
939
1181
|
const availableServers = servers.filter((server) => server.ok).length;
|
|
940
1182
|
const failedServers = servers.length - availableServers;
|
|
941
|
-
const
|
|
1183
|
+
const pendingManagedServers = servers.filter((server) => server.managed && !server.ok).length;
|
|
942
1184
|
const health = cacheHealth(connectorId, records, servers.map((server) => server.ok
|
|
943
1185
|
? { ok: true, kind: 'connected', serverKey: server.serverKey, serverName: server.serverName }
|
|
944
1186
|
: { ok: false, kind: server.errorKind ?? 'network', serverKey: server.serverKey, serverName: server.serverName, message: server.error }));
|
|
@@ -948,10 +1190,16 @@ export async function apply(ctx, config) {
|
|
|
948
1190
|
ok: availableServers > 0,
|
|
949
1191
|
message: failedServers > 0
|
|
950
1192
|
? `工具加载失败:${failureSummary}${failedServers > 3 ? `;另有 ${failedServers - 3} 个 Server 不可用` : ''}${availableServers > 0 ? `;${availableServers} 个 Server 仍可用` : ''}`
|
|
951
|
-
:
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1193
|
+
: `共 ${servers.length} 个 server,${totalTools} 个工具`,
|
|
1194
|
+
detail: {
|
|
1195
|
+
connectorId,
|
|
1196
|
+
servers,
|
|
1197
|
+
totalTools,
|
|
1198
|
+
availableServers,
|
|
1199
|
+
failedServers,
|
|
1200
|
+
pendingManagedServers,
|
|
1201
|
+
connectionState: health.connectionState,
|
|
1202
|
+
},
|
|
955
1203
|
};
|
|
956
1204
|
},
|
|
957
1205
|
};
|
package/lib/mcp-provision.js
CHANGED
|
@@ -24,7 +24,7 @@ export function authHeaders(record, grants) {
|
|
|
24
24
|
return headers;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function buildEntryConfig(record, grants) {
|
|
27
|
+
export function buildEntryConfig(record, grants, { failOnStartupError = false } = {}) {
|
|
28
28
|
if (record.transport === 'stdio') {
|
|
29
29
|
return {
|
|
30
30
|
transport: 'stdio',
|
|
@@ -33,7 +33,7 @@ export function buildEntryConfig(record, grants) {
|
|
|
33
33
|
args: record.args ?? [],
|
|
34
34
|
env: record.env ?? {},
|
|
35
35
|
cwd: record.cwd || process.cwd(),
|
|
36
|
-
failOnStartupError
|
|
36
|
+
failOnStartupError,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
return {
|
|
@@ -41,7 +41,7 @@ export function buildEntryConfig(record, grants) {
|
|
|
41
41
|
serverName: record.serverName,
|
|
42
42
|
url: record.url,
|
|
43
43
|
headers: authHeaders(record, grants),
|
|
44
|
-
failOnStartupError
|
|
44
|
+
failOnStartupError,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -55,9 +55,9 @@ function hasEntry(ctx, id) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** 创建或更新一条 mcp-client 条目(幂等;serverName 不变则工具名不变) */
|
|
58
|
-
export async function provision(ctx, config, record, grants) {
|
|
58
|
+
export async function provision(ctx, config, record, grants, { failOnStartupError = false } = {}) {
|
|
59
59
|
const id = entryIdFor(config, record);
|
|
60
|
-
const entryConfig = buildEntryConfig(record, grants);
|
|
60
|
+
const entryConfig = buildEntryConfig(record, grants, { failOnStartupError });
|
|
61
61
|
const disabled = record.enabled === false;
|
|
62
62
|
if (hasEntry(ctx, id)) {
|
|
63
63
|
const existing = ctx.loader.resolve(id);
|
package/lib/mcp-validation.js
CHANGED
|
@@ -47,17 +47,22 @@ function parseMcpPayload(text, contentType = '') {
|
|
|
47
47
|
function errorChain(error) {
|
|
48
48
|
const parts = [];
|
|
49
49
|
let current = error;
|
|
50
|
-
for (let depth = 0; current && depth <
|
|
50
|
+
for (let depth = 0; current && depth < 8; depth += 1) {
|
|
51
51
|
parts.push(current.code, current.name, current.message);
|
|
52
52
|
current = current.cause;
|
|
53
53
|
}
|
|
54
54
|
return parts.filter(Boolean).join(' ');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function classifyConnectionError(error) {
|
|
57
|
+
export function classifyConnectionError(error, { transport } = {}) {
|
|
58
58
|
const text = errorChain(error);
|
|
59
59
|
if (/AbortError|timeout|timed out|ETIMEDOUT/i.test(text)) {
|
|
60
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
kind: 'timeout',
|
|
62
|
+
message: transport === 'stdio'
|
|
63
|
+
? '本地 MCP Server 启动或工具同步超时,请检查运行环境、命令和 Host 日志'
|
|
64
|
+
: '连接超时,请检查网络、VPN/专线或服务状态',
|
|
65
|
+
};
|
|
61
66
|
}
|
|
62
67
|
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(text)) {
|
|
63
68
|
return { kind: 'dns', message: '域名解析失败,请检查当前网络 DNS' };
|
|
@@ -68,6 +73,23 @@ export function classifyConnectionError(error) {
|
|
|
68
73
|
if (/ECONNREFUSED/i.test(text)) {
|
|
69
74
|
return { kind: 'refused', message: '目标服务拒绝连接,请检查 MCP URL 和服务状态' };
|
|
70
75
|
}
|
|
76
|
+
if (/ENOENT|command not found|spawn[^\n]*not found/i.test(text)) {
|
|
77
|
+
return { kind: 'process-not-found', message: '本地 MCP 启动命令不存在,请先安装所需运行时或检查 command 配置' };
|
|
78
|
+
}
|
|
79
|
+
if (/exit(?:ed)?\s+(?:with\s+)?code|process[^\n]*(?:closed|exited)|subprocess[^\n]*(?:closed|exited)/i.test(text)) {
|
|
80
|
+
const exitCode = text.match(/(?:exit(?:ed)?\s+(?:with\s+)?code|code)\s*[:=]?\s*(-?\d+)/i)?.[1];
|
|
81
|
+
return {
|
|
82
|
+
kind: 'process-exit',
|
|
83
|
+
message: `本地 MCP 进程在初始化期间退出${exitCode !== undefined ? `(退出码 ${exitCode})` : ''},请检查命令、参数和 Host 日志`,
|
|
84
|
+
...(exitCode !== undefined ? { exitCode: Number(exitCode) } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (/initial (?:connection|tool synchronization)|tool synchronization failed|initialize(?: handshake)? failed/i.test(text)) {
|
|
88
|
+
return { kind: 'startup', message: 'MCP 初始化或首次工具同步失败,请检查服务配置和 Host 日志' };
|
|
89
|
+
}
|
|
90
|
+
if (transport === 'stdio') {
|
|
91
|
+
return { kind: 'startup', message: '本地 MCP Server 启动或初始化失败,请检查命令、参数、运行环境和 Host 日志' };
|
|
92
|
+
}
|
|
71
93
|
return { kind: 'network', message: '无法连接 MCP 服务,请检查网络和 MCP URL' };
|
|
72
94
|
}
|
|
73
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"description": "General-purpose MCP connector, connection manager, plugin extension, and integration marketplace for DeepSeek Harness, initiated and maintained by Qichacha/QCC.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -33,11 +33,12 @@
|
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "node --test",
|
|
36
|
-
"lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs",
|
|
36
|
+
"lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/sync-registry-stats.mjs",
|
|
37
37
|
"registry:build": "node scripts/build-registry.mjs",
|
|
38
38
|
"registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
|
|
39
39
|
"dev:ui": "node scripts/ui-harness.mjs",
|
|
40
40
|
"market:check": "node scripts/check-market-registration.mjs",
|
|
41
|
+
"registry:stats:sync": "node scripts/sync-registry-stats.mjs",
|
|
41
42
|
"verify-pack": "node scripts/verify-pack.mjs",
|
|
42
43
|
"check": "npm run lint && npm test && npm run verify-pack",
|
|
43
44
|
"prepublishOnly": "npm run check"
|
package/ui/index.html
CHANGED
|
@@ -455,6 +455,16 @@
|
|
|
455
455
|
<script>
|
|
456
456
|
(function () {
|
|
457
457
|
const API = '/mcp-connector/api';
|
|
458
|
+
const API_TIMEOUTS = {
|
|
459
|
+
connect: 145_000,
|
|
460
|
+
configure: 145_000,
|
|
461
|
+
importJson: 145_000,
|
|
462
|
+
installFromUrl: 145_000,
|
|
463
|
+
setEnabled: 130_000,
|
|
464
|
+
toolsList: 20_000,
|
|
465
|
+
healthCheck: 10_000,
|
|
466
|
+
};
|
|
467
|
+
const DEFAULT_API_TIMEOUT_MS = 30_000;
|
|
458
468
|
const $ = (sel) => document.querySelector(sel);
|
|
459
469
|
const main = $('#main');
|
|
460
470
|
const toast = $('#toast');
|
|
@@ -505,18 +515,31 @@
|
|
|
505
515
|
let promptRequestTimer = null;
|
|
506
516
|
let initialHealthCheckStarted = false;
|
|
507
517
|
|
|
508
|
-
async function call(method, params) {
|
|
518
|
+
async function call(method, params, { timeoutMs: timeoutOverride } = {}) {
|
|
519
|
+
const controller = new AbortController();
|
|
520
|
+
const timeoutMs = timeoutOverride || API_TIMEOUTS[method] || DEFAULT_API_TIMEOUT_MS;
|
|
521
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
509
522
|
try {
|
|
510
523
|
const res = await fetch(API, {
|
|
511
524
|
method: 'POST',
|
|
512
525
|
headers: { 'content-type': 'application/json' },
|
|
513
526
|
body: JSON.stringify({ method, params }),
|
|
527
|
+
signal: controller.signal,
|
|
514
528
|
});
|
|
515
529
|
const data = await res.json();
|
|
516
530
|
if (!res.ok && data.ok !== false) return { ok: false, message: `请求失败(HTTP ${res.status})` };
|
|
517
531
|
return data;
|
|
518
532
|
} catch (error) {
|
|
533
|
+
if (controller.signal.aborted) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
message: `请求超时(${Math.ceil(timeoutMs / 1000)} 秒),请检查服务状态和 Host 日志后重试`,
|
|
537
|
+
detail: { kind: 'timeout' },
|
|
538
|
+
};
|
|
539
|
+
}
|
|
519
540
|
return { ok: false, message: `无法连接 MCP 连接器服务:${error?.message || error}` };
|
|
541
|
+
} finally {
|
|
542
|
+
clearTimeout(timer);
|
|
520
543
|
}
|
|
521
544
|
}
|
|
522
545
|
|
|
@@ -676,7 +699,7 @@
|
|
|
676
699
|
return `<div class="tool-item"><div class="tool-name-row"><div class="tool-name">${esc(tool.name || tool.title || '未命名工具')}</div>${deprecated ? '<span class="tool-deprecated">已弃用</span>' : ''}</div><div class="tool-description${description ? '' : ' empty'}">${esc(description || '暂无描述')}</div></div>`;
|
|
677
700
|
}).join('')
|
|
678
701
|
: server.managed
|
|
679
|
-
? '<div class="tools-loading">stdio
|
|
702
|
+
? '<div class="tools-loading">Host 已完成 stdio 初始化,但该 Server 当前没有可展示的工具。</div>'
|
|
680
703
|
: '<div class="tools-loading">暂无工具</div>';
|
|
681
704
|
return `<div class="tools-server"><div class="tools-server-header">${esc(label)} <span class="count">· 显示 ${tools.length} / ${matched.length} 个</span></div>${items}</div>`;
|
|
682
705
|
}).filter(Boolean);
|
|
@@ -1038,7 +1061,8 @@
|
|
|
1038
1061
|
|
|
1039
1062
|
window.__mcp = {
|
|
1040
1063
|
async connect(connectorId) {
|
|
1041
|
-
const
|
|
1064
|
+
const timeoutMs = marketItems[connectorId]?.authMode === 'oauth2-pkce' ? 310_000 : undefined;
|
|
1065
|
+
const r = await call('connect', { connectorId }, { timeoutMs });
|
|
1042
1066
|
showToast(r.message, r.ok);
|
|
1043
1067
|
if (r.ok) {
|
|
1044
1068
|
await call('healthCheck', { connectorId });
|
|
@@ -1320,7 +1344,7 @@
|
|
|
1320
1344
|
}
|
|
1321
1345
|
if (detailFailureState === 'reauth' && detailConnector?.authMode === 'oauth2-pkce') {
|
|
1322
1346
|
const id = detailConnector.id;
|
|
1323
|
-
const r = await call('connect', { connectorId: id });
|
|
1347
|
+
const r = await call('connect', { connectorId: id }, { timeoutMs: 310_000 });
|
|
1324
1348
|
showToast(r.message, r.ok);
|
|
1325
1349
|
if (r.ok) {
|
|
1326
1350
|
await call('healthCheck', { connectorId: id });
|