dsh-mcp-connector 0.2.2 → 0.2.4
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 +30 -1
- package/README.en.md +14 -2
- package/README.md +15 -3
- package/lib/index.js +135 -4
- package/lib/mcp-validation.js +14 -3
- package/lib/tools.js +20 -2
- package/lib/web.js +1 -0
- package/package.json +1 -1
- package/scripts/ui-harness.mjs +10 -1
- package/ui/index.html +93 -62
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.4] - 2026-08-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 新增主动连接健康检查,支持 OAuth grant、Bearer/API Key 与无鉴权 MCP initialize 握手,并以 4 并发、5 秒单连接上限避免刷新风暴。
|
|
12
|
+
- 新增 `mcp_connector_health_check` 对话工具和同源 Web API,可检查单个或全部已配置连接器。
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- 市场卡片和“已安装”列表不再把“本机存在配置”等同于“当前可用”,改为展示已配置、已连接、需重新授权、部分异常、连接异常或已停用。
|
|
17
|
+
- 首次打开市场会在后台执行一次限流健康检查;用户点击“刷新”时同步刷新目录和连接状态。
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- OAuth 过期/缺失或历史 Key 失效后,不再继续显示“已连接”;详情页会暂停 Prompt 发送并提供重新授权/重新配置入口。
|
|
22
|
+
- 网络、DNS、TLS 或服务端异常与凭据异常分开呈现,避免误导用户重复录入 Key。
|
|
23
|
+
|
|
24
|
+
## [0.2.3] - 2026-08-21
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- 新增 4 张由无真实凭据 UI harness 生成的核心界面截图,以及约 30 秒的演示 GIF;中英文 README 均可直接预览市场、详情 Prompt、工具发现和 JSON 导入体验。
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- 面向中国市场将 MCP 连接器运行界面固定为中文,移除顶部 `EN` 按钮、英文 UI 字典、语言偏好持久化和切换事件;英文 README 继续作为项目文档保留。
|
|
33
|
+
|
|
7
34
|
## [0.2.2] - 2026-08-21
|
|
8
35
|
|
|
9
36
|
### Added
|
|
@@ -92,7 +119,9 @@
|
|
|
92
119
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
93
120
|
- iframe 消息校验同源和消息来源。
|
|
94
121
|
|
|
95
|
-
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.
|
|
122
|
+
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.4...HEAD
|
|
123
|
+
[0.2.4]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.3...v0.2.4
|
|
124
|
+
[0.2.3]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.2...v0.2.3
|
|
96
125
|
[0.2.2]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.1...v0.2.2
|
|
97
126
|
[0.2.1]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.0...v0.2.1
|
|
98
127
|
[0.2.0]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.1.0...v0.2.0
|
package/README.en.md
CHANGED
|
@@ -13,7 +13,7 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- A primary sidebar entry below New Conversation and above workspaces/conversations, with a public footer-slot fallback for incompatible DSH DOM versions.
|
|
16
|
-
- Searchable Marketplace and Installed views with
|
|
16
|
+
- Searchable Marketplace and Installed views with catalog refresh and active health checks that distinguish Configured, Connected, Reauthorization Required, Partially Unavailable, and Connection Error states.
|
|
17
17
|
- OAuth 2.0 Authorization Code with PKCE, API key/Bearer/unauthenticated URL configuration, and `mcpServers` JSON import.
|
|
18
18
|
- Installation from a credential-free connector descriptor URL.
|
|
19
19
|
- Credential and MCP initialize validation before API-key connectors are saved as installed.
|
|
@@ -26,6 +26,18 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
|
|
|
26
26
|
|
|
27
27
|
The first four bundled cards are Qichacha connectors. The public catalog also includes PKULaw and Wind connectors; the architecture is provider-neutral.
|
|
28
28
|
|
|
29
|
+
## Interface and demo
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
| Marketplace overview | Connector details and curated prompts |
|
|
34
|
+
|---|---|
|
|
35
|
+
|  |  |
|
|
36
|
+
| Tool discovery, descriptions, and scrolling | JSON import |
|
|
37
|
+
|  |  |
|
|
38
|
+
|
|
39
|
+
The assets are captured from the credential-free local UI harness. See [`docs/screenshots/README.md`](docs/screenshots/README.md) for provenance.
|
|
40
|
+
|
|
29
41
|
## Installation
|
|
30
42
|
|
|
31
43
|
Requirements: DeepSeek Harness Desktop or web profile, and Node.js 20 or later.
|
|
@@ -85,7 +97,7 @@ npm run dev:ui
|
|
|
85
97
|
|
|
86
98
|
`npm run check` performs syntax checks, automated tests, and an npm package allowlist/sensitive-content audit. 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`.
|
|
87
99
|
|
|
88
|
-
The current public version is [`dsh-mcp-connector@0.2.
|
|
100
|
+
The current public version is [`dsh-mcp-connector@0.2.4`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.4](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.4).
|
|
89
101
|
|
|
90
102
|
See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
|
|
91
103
|
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
## 功能
|
|
14
14
|
|
|
15
15
|
- 左侧主导航入口:目标位置为“新会话”下方、“工作区/会话列表”上方;若 DSH DOM 结构不兼容,自动回退到底部公开插槽。
|
|
16
|
-
-
|
|
16
|
+
- 图形化市场:市场/已安装、搜索、刷新与主动健康检查;区分“已配置”、“已连接”、“需重新授权”、“部分异常”和“连接异常”。
|
|
17
17
|
- 图形化添加:手动 URL/鉴权、`mcpServers` JSON、连接器描述 URL 三种入口,失败时保留表单并给出修复建议。
|
|
18
18
|
- 连接器详情:精选 Prompt 优先展示,点击可带入 DSH 新会话;工具按 Server 分组,支持描述、搜索和独立滚动。
|
|
19
19
|
- Prompt 模板:使用 `{{company}}` 等变量,发送前填写真实查询主体。
|
|
@@ -24,10 +24,22 @@
|
|
|
24
24
|
- 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
|
|
25
25
|
- Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
|
|
26
26
|
- 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;不删除原插件或原凭据。
|
|
27
|
-
- 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
|
|
27
|
+
- 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
|
|
28
28
|
|
|
29
29
|
当前市场前 4 张为企查查连接器,第 5 张为“北大法宝·法律检索”,第 6 张为“Wind·股票数据”;插件架构不限定厂商。
|
|
30
30
|
|
|
31
|
+
## 界面与演示
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
| 市场总览 | 连接器详情与精选 Prompt |
|
|
36
|
+
|---|---|
|
|
37
|
+
|  |  |
|
|
38
|
+
| 工具发现、描述与独立滚动 | JSON 导入 |
|
|
39
|
+
|  |  |
|
|
40
|
+
|
|
41
|
+
素材由无真实凭据的本地 UI harness 生成,详见 [`docs/screenshots/README.md`](docs/screenshots/README.md)。
|
|
42
|
+
|
|
31
43
|
## 安装
|
|
32
44
|
|
|
33
45
|
要求:DeepSeek Harness Desktop/web profile,Node.js 20 或更高版本。
|
|
@@ -81,7 +93,7 @@ npm run dev:ui
|
|
|
81
93
|
|
|
82
94
|
`check` 执行语法检查、自动测试和 npm 发布包白名单校验;`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`。
|
|
83
95
|
|
|
84
|
-
当前公开版本为 [`dsh-mcp-connector@0.2.
|
|
96
|
+
当前公开版本为 [`dsh-mcp-connector@0.2.4`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.4](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.4)。
|
|
85
97
|
|
|
86
98
|
版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
87
99
|
Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
|
package/lib/index.js
CHANGED
|
@@ -77,6 +77,7 @@ export async function apply(ctx, config) {
|
|
|
77
77
|
overrides: new Map(), // id -> { published?, featured? }
|
|
78
78
|
connections: new Map(), // key -> ConnectionRecord
|
|
79
79
|
grants: new Map(), // grantKey -> { grant, timer, refreshPromise, needsReauth }
|
|
80
|
+
health: new Map(), // connectorId -> 最近一次主动连通性检查摘要(不持久化)
|
|
80
81
|
};
|
|
81
82
|
|
|
82
83
|
/* ───────────────────────── 目录加载 ───────────────────────── */
|
|
@@ -195,6 +196,7 @@ export async function apply(ctx, config) {
|
|
|
195
196
|
|
|
196
197
|
async function persistRecord(record) {
|
|
197
198
|
state.connections.set(record.key, record);
|
|
199
|
+
if (record.connectorId) state.health.delete(record.connectorId);
|
|
198
200
|
if (config.persistSecrets) await connectionStore.put(record);
|
|
199
201
|
}
|
|
200
202
|
|
|
@@ -206,11 +208,73 @@ export async function apply(ctx, config) {
|
|
|
206
208
|
return merged;
|
|
207
209
|
}
|
|
208
210
|
|
|
211
|
+
function grantFailure(record) {
|
|
212
|
+
if (record.auth?.mode !== 'oauth') return null;
|
|
213
|
+
const grantKey = record.auth.grantKey;
|
|
214
|
+
const entry = grantKey ? state.grants.get(grantKey) : null;
|
|
215
|
+
if (!entry) return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权缺失,请重新授权' };
|
|
216
|
+
if (entry.needsReauth || entry.grant.accessTokenExpiresAt <= Date.now()) {
|
|
217
|
+
return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权已过期或刷新失败,请重新授权' };
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function healthSummary(connectorId, records, results, checkedAt = Date.now()) {
|
|
223
|
+
const enabledRecords = records.filter((record) => record.enabled !== false);
|
|
224
|
+
const availableServers = results.filter((result) => result.ok).length;
|
|
225
|
+
const failedServers = results.length - availableServers;
|
|
226
|
+
const authFailures = results.filter((result) => !result.ok && result.kind === 'auth').length;
|
|
227
|
+
let connectionState = 'configured';
|
|
228
|
+
if (records.length === 0) connectionState = 'disconnected';
|
|
229
|
+
else if (enabledRecords.length === 0) connectionState = 'disabled';
|
|
230
|
+
else if (failedServers === 0 && availableServers > 0) connectionState = 'healthy';
|
|
231
|
+
else if (availableServers > 0) connectionState = 'degraded';
|
|
232
|
+
else if (authFailures > 0) connectionState = 'reauth';
|
|
233
|
+
else if (failedServers > 0) connectionState = 'unavailable';
|
|
234
|
+
const labels = {
|
|
235
|
+
disconnected: '未连接', configured: '已配置', disabled: '已停用', healthy: '已连接',
|
|
236
|
+
degraded: '部分异常', reauth: '需重新授权', unavailable: '连接异常',
|
|
237
|
+
};
|
|
238
|
+
return {
|
|
239
|
+
connectorId,
|
|
240
|
+
connectionState,
|
|
241
|
+
label: labels[connectionState],
|
|
242
|
+
configuredServers: records.length,
|
|
243
|
+
enabledServers: enabledRecords.length,
|
|
244
|
+
availableServers,
|
|
245
|
+
failedServers,
|
|
246
|
+
checkedAt,
|
|
247
|
+
results,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function connectorHealth(connectorId) {
|
|
252
|
+
const records = [...state.connections.values()].filter((record) => record.connectorId === connectorId);
|
|
253
|
+
if (records.length === 0) return healthSummary(connectorId, records, [], null);
|
|
254
|
+
if (records.every((record) => record.enabled === false)) return healthSummary(connectorId, records, [], null);
|
|
255
|
+
const immediateFailures = records.filter((record) => record.enabled !== false).map(grantFailure).filter(Boolean);
|
|
256
|
+
if (immediateFailures.length > 0) return healthSummary(connectorId, records, immediateFailures, Date.now());
|
|
257
|
+
const cached = state.health.get(connectorId);
|
|
258
|
+
return cached ?? {
|
|
259
|
+
...healthSummary(connectorId, records, [], null),
|
|
260
|
+
connectionState: 'configured',
|
|
261
|
+
label: '已配置',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cacheHealth(connectorId, records, results) {
|
|
266
|
+
const summary = healthSummary(connectorId, records, results);
|
|
267
|
+
state.health.set(connectorId, summary);
|
|
268
|
+
return summary;
|
|
269
|
+
}
|
|
270
|
+
|
|
209
271
|
/* ───────────────────────── api 门面 ───────────────────────── */
|
|
210
272
|
|
|
211
273
|
const api = {
|
|
212
274
|
async catalog({ category, keyword } = {}) {
|
|
213
|
-
const items = listCatalog(state.merged, { category, keyword, publishedOnly: true }).map((d) =>
|
|
275
|
+
const items = listCatalog(state.merged, { category, keyword, publishedOnly: true }).map((d) => {
|
|
276
|
+
const health = connectorHealth(d.id);
|
|
277
|
+
return ({
|
|
214
278
|
id: d.id,
|
|
215
279
|
name: d.name,
|
|
216
280
|
vendor: d.vendor,
|
|
@@ -234,13 +298,19 @@ export async function apply(ctx, config) {
|
|
|
234
298
|
probeReportUrl: d.probeReportUrl,
|
|
235
299
|
toolsSnapshot: d.toolsSnapshot ?? [],
|
|
236
300
|
connected: [...state.connections.values()].filter((r) => r.connectorId === d.id).map((r) => r.key),
|
|
301
|
+
connectionState: health.connectionState,
|
|
302
|
+
connectionLabel: health.label,
|
|
303
|
+
healthCheckedAt: health.checkedAt,
|
|
304
|
+
availableServers: health.availableServers,
|
|
305
|
+
failedServers: health.failedServers,
|
|
237
306
|
servers: (d.servers ?? []).map((s) => ({
|
|
238
307
|
serverKey: s.serverKey,
|
|
239
308
|
url: s.url,
|
|
240
309
|
transport: s.transport,
|
|
241
310
|
serverName: s.serverName,
|
|
242
311
|
})),
|
|
243
|
-
|
|
312
|
+
});
|
|
313
|
+
});
|
|
244
314
|
return {
|
|
245
315
|
ok: true,
|
|
246
316
|
message: `共 ${items.length} 个已上架连接器`,
|
|
@@ -374,6 +444,7 @@ export async function apply(ctx, config) {
|
|
|
374
444
|
await upsertRecord(record);
|
|
375
445
|
created.push(record.key);
|
|
376
446
|
}
|
|
447
|
+
cacheHealth(connector.id, records, validation.detail.results);
|
|
377
448
|
return {
|
|
378
449
|
ok: true,
|
|
379
450
|
message: `已配置并连接 "${connector.name}"(${created.length} 个 MCP Server)`,
|
|
@@ -435,6 +506,8 @@ export async function apply(ctx, config) {
|
|
|
435
506
|
async status() {
|
|
436
507
|
const items = [];
|
|
437
508
|
for (const [key, record] of state.connections) {
|
|
509
|
+
const health = record.connectorId ? connectorHealth(record.connectorId) : null;
|
|
510
|
+
const recordHealth = health?.results?.find((result) => result.serverKey === record.serverKey || result.serverName === record.serverName);
|
|
438
511
|
let grantInfo = null;
|
|
439
512
|
if (record.auth?.grantKey) {
|
|
440
513
|
const g = state.grants.get(record.auth.grantKey);
|
|
@@ -457,11 +530,57 @@ export async function apply(ctx, config) {
|
|
|
457
530
|
authMode: record.auth?.mode ?? 'none',
|
|
458
531
|
grant: grantInfo,
|
|
459
532
|
lastError: record.lastError ?? null,
|
|
533
|
+
connectionState: recordHealth?.ok ? 'healthy' : recordHealth ? health.connectionState : health?.connectionState ?? 'configured',
|
|
534
|
+
healthCheckedAt: health?.checkedAt ?? null,
|
|
535
|
+
healthMessage: recordHealth?.message ?? null,
|
|
460
536
|
});
|
|
461
537
|
}
|
|
462
538
|
return { ok: true, message: `共 ${items.length} 条连接`, detail: { items } };
|
|
463
539
|
},
|
|
464
540
|
|
|
541
|
+
async healthCheck(connectorId) {
|
|
542
|
+
const targetIds = connectorId
|
|
543
|
+
? [String(connectorId)]
|
|
544
|
+
: [...new Set([...state.connections.values()].map((record) => record.connectorId).filter(Boolean))];
|
|
545
|
+
const summaries = [];
|
|
546
|
+
for (const id of targetIds) {
|
|
547
|
+
const records = [...state.connections.values()].filter((record) => record.connectorId === id);
|
|
548
|
+
const enabledRecords = records.filter((record) => record.enabled !== false);
|
|
549
|
+
if (records.length === 0 || enabledRecords.length === 0) {
|
|
550
|
+
const summary = healthSummary(id, records, [], Date.now());
|
|
551
|
+
state.health.set(id, summary);
|
|
552
|
+
summaries.push(summary);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const immediateResults = [];
|
|
556
|
+
const checkable = [];
|
|
557
|
+
for (const record of enabledRecords) {
|
|
558
|
+
const failure = grantFailure(record);
|
|
559
|
+
if (failure) immediateResults.push(failure);
|
|
560
|
+
else checkable.push(record);
|
|
561
|
+
}
|
|
562
|
+
let checkedResults = [];
|
|
563
|
+
if (checkable.length > 0) {
|
|
564
|
+
const validation = await validateConnectionRecords(checkable, {
|
|
565
|
+
timeoutMs: Math.min(config.requestTimeoutMs, 5_000),
|
|
566
|
+
concurrency: 4,
|
|
567
|
+
grants: grantMap(),
|
|
568
|
+
});
|
|
569
|
+
checkedResults = validation.detail.results;
|
|
570
|
+
}
|
|
571
|
+
summaries.push(cacheHealth(id, records, [...immediateResults, ...checkedResults]));
|
|
572
|
+
}
|
|
573
|
+
const healthy = summaries.filter((item) => item.connectionState === 'healthy').length;
|
|
574
|
+
const attention = summaries.filter((item) => ['reauth', 'degraded', 'unavailable'].includes(item.connectionState)).length;
|
|
575
|
+
return {
|
|
576
|
+
ok: true,
|
|
577
|
+
message: targetIds.length === 0
|
|
578
|
+
? '暂无已配置连接'
|
|
579
|
+
: `已检查 ${summaries.length} 个连接器:${healthy} 个正常${attention ? `,${attention} 个需处理` : ''}`,
|
|
580
|
+
detail: { items: summaries, healthy, attention },
|
|
581
|
+
};
|
|
582
|
+
},
|
|
583
|
+
|
|
465
584
|
async migrationPreview({ scanStored = false } = {}) {
|
|
466
585
|
const { candidates, warnings } = await readLegacyGrantCandidates(ctx.storageDomain, { openClosed: scanStored });
|
|
467
586
|
const plans = planLegacyMigration(candidates, state.merged, state.connections);
|
|
@@ -533,6 +652,7 @@ export async function apply(ctx, config) {
|
|
|
533
652
|
if (record.auth?.grantKey) {
|
|
534
653
|
await remove(ctx, config, record);
|
|
535
654
|
state.connections.delete(key);
|
|
655
|
+
if (record.connectorId) state.health.delete(record.connectorId);
|
|
536
656
|
if (config.persistSecrets) await connectionStore.delete(key).catch(() => {});
|
|
537
657
|
const stillUsed = [...state.connections.values()].some((r) => r.auth?.grantKey === record.auth.grantKey);
|
|
538
658
|
if (!stillUsed) {
|
|
@@ -558,6 +678,7 @@ export async function apply(ctx, config) {
|
|
|
558
678
|
|
|
559
679
|
await remove(ctx, config, record);
|
|
560
680
|
state.connections.delete(key);
|
|
681
|
+
if (record.connectorId) state.health.delete(record.connectorId);
|
|
561
682
|
if (config.persistSecrets) await connectionStore.delete(key).catch(() => {});
|
|
562
683
|
return { ok: true, message: `已断开 "${record.name}"` };
|
|
563
684
|
},
|
|
@@ -640,7 +761,14 @@ export async function apply(ctx, config) {
|
|
|
640
761
|
const error = response.status === 401 || response.status === 403
|
|
641
762
|
? `凭据无效、已过期或权限不足(HTTP ${response.status})`
|
|
642
763
|
: `HTTP ${response.status}`;
|
|
643
|
-
servers.push({
|
|
764
|
+
servers.push({
|
|
765
|
+
serverKey: record.serverKey || record.name,
|
|
766
|
+
serverName: record.serverName,
|
|
767
|
+
ok: false,
|
|
768
|
+
error,
|
|
769
|
+
errorKind: response.status === 401 || response.status === 403 ? 'auth' : 'http',
|
|
770
|
+
tools: [],
|
|
771
|
+
});
|
|
644
772
|
continue;
|
|
645
773
|
}
|
|
646
774
|
|
|
@@ -682,6 +810,9 @@ export async function apply(ctx, config) {
|
|
|
682
810
|
const totalTools = servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0);
|
|
683
811
|
const availableServers = servers.filter((server) => server.ok).length;
|
|
684
812
|
const failedServers = servers.length - availableServers;
|
|
813
|
+
const health = cacheHealth(connectorId, records, servers.map((server) => server.ok
|
|
814
|
+
? { ok: true, kind: 'connected', serverKey: server.serverKey, serverName: server.serverName }
|
|
815
|
+
: { ok: false, kind: server.errorKind ?? 'network', serverKey: server.serverKey, serverName: server.serverName, message: server.error }));
|
|
685
816
|
const failureSummary = servers.filter((server) => !server.ok).slice(0, 3)
|
|
686
817
|
.map((server) => `${server.serverName || server.serverKey}:${server.error}`).join(';');
|
|
687
818
|
return {
|
|
@@ -689,7 +820,7 @@ export async function apply(ctx, config) {
|
|
|
689
820
|
message: failedServers > 0
|
|
690
821
|
? `工具加载失败:${failureSummary}${failedServers > 3 ? `;另有 ${failedServers - 3} 个 Server 不可用` : ''}${availableServers > 0 ? `;${availableServers} 个 Server 仍可用` : ''}`
|
|
691
822
|
: `共 ${servers.length} 个 server,${totalTools} 个工具`,
|
|
692
|
-
detail: { connectorId, servers, totalTools, availableServers, failedServers },
|
|
823
|
+
detail: { connectorId, servers, totalTools, availableServers, failedServers, connectionState: health.connectionState },
|
|
693
824
|
};
|
|
694
825
|
},
|
|
695
826
|
};
|
package/lib/mcp-validation.js
CHANGED
|
@@ -82,7 +82,7 @@ function failed(record, kind, message, extra = {}) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export async function validateConnectionRecord(record, { timeoutMs = 15_000, fetchImpl = fetch } = {}) {
|
|
85
|
+
export async function validateConnectionRecord(record, { timeoutMs = 15_000, fetchImpl = fetch, grants = new Map() } = {}) {
|
|
86
86
|
const controller = new AbortController();
|
|
87
87
|
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
|
|
88
88
|
try {
|
|
@@ -91,7 +91,7 @@ export async function validateConnectionRecord(record, { timeoutMs = 15_000, fet
|
|
|
91
91
|
headers: {
|
|
92
92
|
'Content-Type': 'application/json',
|
|
93
93
|
Accept: 'application/json, text/event-stream',
|
|
94
|
-
...authHeaders(record,
|
|
94
|
+
...authHeaders(record, grants),
|
|
95
95
|
},
|
|
96
96
|
body: JSON.stringify({
|
|
97
97
|
jsonrpc: '2.0',
|
|
@@ -153,7 +153,18 @@ export async function validateConnectionRecord(record, { timeoutMs = 15_000, fet
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
export async function validateConnectionRecords(records, options = {}) {
|
|
156
|
-
const
|
|
156
|
+
const concurrency = Math.max(1, Math.min(Number(options.concurrency) || records.length || 1, records.length || 1));
|
|
157
|
+
const results = new Array(records.length);
|
|
158
|
+
let cursor = 0;
|
|
159
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
160
|
+
for (;;) {
|
|
161
|
+
const index = cursor;
|
|
162
|
+
cursor += 1;
|
|
163
|
+
if (index >= records.length) return;
|
|
164
|
+
results[index] = await validateConnectionRecord(records[index], options);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
await Promise.all(workers);
|
|
157
168
|
const failures = results.filter((result) => !result.ok);
|
|
158
169
|
if (failures.length === 0) {
|
|
159
170
|
return { ok: true, message: `已验证 ${results.length} 个 MCP Server`, detail: { results } };
|
package/lib/tools.js
CHANGED
|
@@ -29,7 +29,7 @@ const catalogOutput = {
|
|
|
29
29
|
lines.push('');
|
|
30
30
|
for (const it of items) {
|
|
31
31
|
const star = it.featured ? '⭐ ' : '';
|
|
32
|
-
const connected = it.connected?.length ?
|
|
32
|
+
const connected = it.connected?.length ? `(${it.connectionLabel || '已配置'} ${it.connected.length})` : '';
|
|
33
33
|
const summary = it.summary ? ` — ${it.summary}` : '';
|
|
34
34
|
lines.push(`- ${star}[${it.id}] ${it.name} · ${it.category} · ${it.authMode}${connected}${summary}`);
|
|
35
35
|
}
|
|
@@ -47,7 +47,8 @@ const statusOutput = {
|
|
|
47
47
|
if (items.length) {
|
|
48
48
|
lines.push('');
|
|
49
49
|
for (const it of items) {
|
|
50
|
-
const
|
|
50
|
+
const stateLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
|
|
51
|
+
const state = it.enabled ? (stateLabels[it.connectionState] || '已配置') : '停用';
|
|
51
52
|
const auth = it.authMode === 'oauth'
|
|
52
53
|
? it.grant?.needsReauth ? 'oauth(需重新授权)' : it.grant?.missing ? 'oauth(授权缺失)' : 'oauth'
|
|
53
54
|
: it.authMode;
|
|
@@ -180,6 +181,23 @@ export function registerTools(ctx, api) {
|
|
|
180
181
|
},
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
reg({
|
|
185
|
+
name: 'mcp_connector_health_check',
|
|
186
|
+
description: '主动检查已配置 MCP 连接的 initialize 握手、凭据和网络状态。可指定 connectorId,留空则检查全部已配置连接器。',
|
|
187
|
+
parameters: {
|
|
188
|
+
type: 'object',
|
|
189
|
+
properties: {
|
|
190
|
+
connectorId: { type: 'string', description: '连接器 id(可选)' },
|
|
191
|
+
},
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
output: resultOutput,
|
|
195
|
+
timeoutMs: 120_000,
|
|
196
|
+
async execute(args) {
|
|
197
|
+
return api.healthCheck(args?.connectorId);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
183
201
|
reg({
|
|
184
202
|
name: 'mcp_connector_migration_preview',
|
|
185
203
|
description:
|
package/lib/web.js
CHANGED
|
@@ -19,6 +19,7 @@ const UI_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
|
|
|
19
19
|
const API_WHITELIST = new Map([
|
|
20
20
|
['catalog', (api, p) => api.catalog(p ?? {})],
|
|
21
21
|
['status', (api) => api.status()],
|
|
22
|
+
['healthCheck', (api, p) => api.healthCheck(p?.connectorId)],
|
|
22
23
|
['migrationPreview', (api, p) => api.migrationPreview(p ?? {})],
|
|
23
24
|
['migrateLegacy', (api, p) => api.migrateLegacy(p?.candidateIds ?? [])],
|
|
24
25
|
['connect', (api, p) => api.connect(p?.connectorId, p?.serverKey)],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "General-purpose MCP Connector Marketplace for DeepSeek Harness with OAuth 2.0 PKCE, API key/URL, JSON import, tool discovery, prompt templates, and connection management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/scripts/ui-harness.mjs
CHANGED
|
@@ -11,6 +11,7 @@ const catalog = JSON.parse(await readFile(join(root, 'catalog/catalog.json'), 'u
|
|
|
11
11
|
.filter((item) => item.published !== false)
|
|
12
12
|
.map(normalizeConnectorDescriptor);
|
|
13
13
|
const connected = new Set(['qcc-company']);
|
|
14
|
+
const healthStates = new Map();
|
|
14
15
|
const bundledAssets = new Map([
|
|
15
16
|
['qcc-logo.svg', 'image/svg+xml'],
|
|
16
17
|
['pkulaw-logo.png', 'image/png'],
|
|
@@ -47,13 +48,21 @@ const server = createServer(async (req, res) => {
|
|
|
47
48
|
credentialDescription: item.auth.credentialDescription,
|
|
48
49
|
credentialHelpLabel: item.auth.credentialHelpLabel,
|
|
49
50
|
connected: connected.has(item.id) ? [`${item.id}-main`] : [],
|
|
51
|
+
connectionState: connected.has(item.id) ? (healthStates.get(item.id) ?? 'configured') : 'disconnected',
|
|
52
|
+
connectionLabel: healthStates.get(item.id) === 'healthy' ? '已连接' : connected.has(item.id) ? '已配置' : '未连接',
|
|
50
53
|
})) } }); return;
|
|
51
54
|
}
|
|
52
55
|
if (method === 'status') { json(res, { ok: true, detail: { items: [] } }); return; }
|
|
53
56
|
if (method === 'migrationPreview') { json(res, { ok: true, detail: { pendingCount: 0, items: [] } }); return; }
|
|
54
57
|
if (method === 'toolsList') {
|
|
55
58
|
const tools = Array.from({ length: 125 }, (_, index) => ({ name: `tool_${String(index + 1).padStart(3, '0')}`, title: `工具 ${index + 1}`, description: `用于验证分批渲染与搜索的第 ${index + 1} 个工具。` }));
|
|
56
|
-
|
|
59
|
+
healthStates.set(params.connectorId, 'healthy');
|
|
60
|
+
json(res, { ok: true, detail: { totalTools: tools.length, connectionState: 'healthy', servers: [{ serverKey: 'company', serverName: 'qcc-company', ok: true, tools }] } }); return;
|
|
61
|
+
}
|
|
62
|
+
if (method === 'healthCheck') {
|
|
63
|
+
const ids = params.connectorId ? [params.connectorId] : [...connected];
|
|
64
|
+
ids.forEach((id) => healthStates.set(id, 'healthy'));
|
|
65
|
+
json(res, { ok: true, message: `已检查 ${ids.length} 个连接器:${ids.length} 个正常`, detail: { items: ids.map((connectorId) => ({ connectorId, connectionState: 'healthy' })) } }); return;
|
|
57
66
|
}
|
|
58
67
|
if (method === 'connect') { connected.add(params.connectorId); json(res, { ok: true, message: 'Mock 连接成功' }); return; }
|
|
59
68
|
if (method === 'configure') {
|
package/ui/index.html
CHANGED
|
@@ -146,6 +146,10 @@
|
|
|
146
146
|
.btn.ghost:hover { border-color: var(--accent); color: var(--accent); }
|
|
147
147
|
.btn.danger { background: transparent; border: 1px solid #fecaca; color: var(--bad); }
|
|
148
148
|
.btn.danger:hover { border-color: var(--bad); background: var(--bad-bg); }
|
|
149
|
+
.btn.status-warning { background: var(--warn-bg); border: 1px solid #fde68a; color: #92400e; }
|
|
150
|
+
.btn.status-warning:hover { background: #fef3c7; border-color: var(--warn); }
|
|
151
|
+
.btn.status-bad { background: var(--bad-bg); border: 1px solid #fecaca; color: var(--bad); }
|
|
152
|
+
.btn.status-bad:hover { background: #fee2e2; border-color: var(--bad); }
|
|
149
153
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
150
154
|
|
|
151
155
|
/* Installed rows */
|
|
@@ -355,17 +359,16 @@
|
|
|
355
359
|
<div class="app">
|
|
356
360
|
<header>
|
|
357
361
|
<div class="tabs" id="tabs">
|
|
358
|
-
<button data-view="market"
|
|
359
|
-
<button data-view="installed"
|
|
362
|
+
<button data-view="market" class="active">市场</button>
|
|
363
|
+
<button data-view="installed">已安装</button>
|
|
360
364
|
</div>
|
|
361
365
|
<div class="header-spacer"></div>
|
|
362
366
|
<div class="search-box">
|
|
363
367
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted);flex-shrink:0"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
364
|
-
<input id="search" type="text" placeholder="搜索连接器…"
|
|
368
|
+
<input id="search" type="text" placeholder="搜索连接器…" />
|
|
365
369
|
</div>
|
|
366
|
-
<button class="icon-btn primary" id="add-connection" type="button"
|
|
367
|
-
<button class="icon-btn" id="
|
|
368
|
-
<button class="icon-btn" id="refresh" data-i18n="refresh">刷新</button>
|
|
370
|
+
<button class="icon-btn primary" id="add-connection" type="button">+ 添加连接</button>
|
|
371
|
+
<button class="icon-btn" id="refresh">刷新</button>
|
|
369
372
|
</header>
|
|
370
373
|
<main id="main"></main>
|
|
371
374
|
|
|
@@ -384,17 +387,17 @@
|
|
|
384
387
|
<div class="detail-desc" id="detail-desc"></div>
|
|
385
388
|
<div class="detail-section">
|
|
386
389
|
<div class="detail-section-head">
|
|
387
|
-
<div class="detail-section-title"><span class="emoji">💡</span> <span
|
|
390
|
+
<div class="detail-section-title"><span class="emoji">💡</span> <span>试试这样用</span> <span class="detail-section-meta" id="prompt-meta"></span></div>
|
|
388
391
|
<div class="detail-section-spacer"></div>
|
|
389
|
-
<button class="text-btn" id="prompt-next" type="button"
|
|
392
|
+
<button class="text-btn" id="prompt-next" type="button" hidden>换一批</button>
|
|
390
393
|
</div>
|
|
391
394
|
<div class="prompt-list" id="detail-prompts"></div>
|
|
392
395
|
</div>
|
|
393
396
|
<details class="tools-disclosure" id="tools-disclosure">
|
|
394
|
-
<summary><span>🧰 <span
|
|
397
|
+
<summary><span>🧰 <span>工具详情</span></span><span class="tools-summary" id="tools-summary">加载中…</span></summary>
|
|
395
398
|
<div class="tools-panel">
|
|
396
399
|
<div class="tools-toolbar">
|
|
397
|
-
<input class="tools-search" id="tools-search" type="search" placeholder="搜索工具名称或描述…"
|
|
400
|
+
<input class="tools-search" id="tools-search" type="search" placeholder="搜索工具名称或描述…" autocomplete="off" />
|
|
398
401
|
</div>
|
|
399
402
|
<div class="tools-list" id="detail-tools">
|
|
400
403
|
<div class="tools-loading">加载中...</div>
|
|
@@ -403,7 +406,7 @@
|
|
|
403
406
|
</details>
|
|
404
407
|
<div class="detail-actions">
|
|
405
408
|
<button class="btn-primary" id="detail-try-btn">💬 去试试</button>
|
|
406
|
-
<button class="btn-danger" id="detail-disconnect-btn"
|
|
409
|
+
<button class="btn-danger" id="detail-disconnect-btn">断开连接</button>
|
|
407
410
|
</div>
|
|
408
411
|
</div>
|
|
409
412
|
</div>
|
|
@@ -423,23 +426,14 @@
|
|
|
423
426
|
const main = $('#main');
|
|
424
427
|
const toast = $('#toast');
|
|
425
428
|
const searchInput = $('#search');
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
},
|
|
433
|
-
en: {
|
|
434
|
-
market: 'Marketplace', installed: 'Installed', search: 'Search connectors…', add: '+ Add connector', refresh: 'Refresh',
|
|
435
|
-
tryExamples: 'Try these prompts', next: 'Next', tools: 'Tool details', toolSearch: 'Search tool name or description…',
|
|
436
|
-
disconnect: 'Disconnect', details: 'Details', connect: 'Connect', connected: 'Connected', configure: 'Configure',
|
|
437
|
-
send: 'Send', fillSend: 'Fill & send', connectToUse: 'Connect to use', try: '💬 Try it', connectTry: '🔗 Connect to try', configureConnect: '🔑 Add key & connect', reconfigureCredential: '🔑 Reconfigure key', loading: 'Loading…',
|
|
438
|
-
},
|
|
429
|
+
const LABELS = {
|
|
430
|
+
details: '详情', connect: '连接', connected: '已连接', configured: '已配置', configure: '配置',
|
|
431
|
+
reauthorize: '需重新授权', connectionError: '连接异常', partialError: '部分异常', disabled: '已停用',
|
|
432
|
+
send: '发送', fillSend: '填写并发送', connectToUse: '连接后使用', try: '💬 去试试',
|
|
433
|
+
connectTry: '🔗 连接后试用', configureConnect: '🔑 录入凭据并连接',
|
|
434
|
+
reconfigureCredential: '🔑 重新配置凭据', loading: '加载中…',
|
|
439
435
|
};
|
|
440
|
-
|
|
441
|
-
try { locale = localStorage.getItem('mcp-connector:locale') === 'en' ? 'en' : 'zh'; } catch {}
|
|
442
|
-
const t = (key) => I18N[locale]?.[key] || I18N.zh[key] || key;
|
|
436
|
+
const t = (key) => LABELS[key] || key;
|
|
443
437
|
const MCP_JSON_EXAMPLE = JSON.stringify({
|
|
444
438
|
mcpServers: {
|
|
445
439
|
'my-server': {
|
|
@@ -450,12 +444,6 @@
|
|
|
450
444
|
},
|
|
451
445
|
}, null, 2);
|
|
452
446
|
|
|
453
|
-
function applyLocale() {
|
|
454
|
-
document.documentElement.lang = locale === 'en' ? 'en' : 'zh-CN';
|
|
455
|
-
document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); });
|
|
456
|
-
document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.i18nPlaceholder); });
|
|
457
|
-
$('#locale-toggle').textContent = locale === 'zh' ? 'EN' : '中';
|
|
458
|
-
}
|
|
459
447
|
let view = 'market';
|
|
460
448
|
let marketItems = {};
|
|
461
449
|
let searchQuery = '';
|
|
@@ -465,6 +453,7 @@
|
|
|
465
453
|
let detailConnector = null;
|
|
466
454
|
let detailToolServers = [];
|
|
467
455
|
let detailNeedsReconfigure = false;
|
|
456
|
+
let detailFailureState = '';
|
|
468
457
|
let toolRenderLimit = 50;
|
|
469
458
|
let detailLoadId = 0;
|
|
470
459
|
let catalogRenderLimit = 60;
|
|
@@ -474,6 +463,7 @@
|
|
|
474
463
|
const PROMPT_RESULT_TYPE = 'mcp-connector:start-session-result';
|
|
475
464
|
let promptRequestId = null;
|
|
476
465
|
let promptRequestTimer = null;
|
|
466
|
+
let initialHealthCheckStarted = false;
|
|
477
467
|
|
|
478
468
|
async function call(method, params) {
|
|
479
469
|
try {
|
|
@@ -510,7 +500,7 @@
|
|
|
510
500
|
button.disabled = pending;
|
|
511
501
|
if (pending) {
|
|
512
502
|
button.dataset.idleLabel = button.textContent;
|
|
513
|
-
button.textContent =
|
|
503
|
+
button.textContent = '正在发送…';
|
|
514
504
|
} else if (button.dataset.idleLabel) {
|
|
515
505
|
button.textContent = button.dataset.idleLabel;
|
|
516
506
|
delete button.dataset.idleLabel;
|
|
@@ -675,7 +665,7 @@
|
|
|
675
665
|
|| (d.tags || []).some(t => t.toLowerCase().includes(q));
|
|
676
666
|
}
|
|
677
667
|
|
|
678
|
-
async function loadMarket() {
|
|
668
|
+
async function loadMarket({ checkHealth = true } = {}) {
|
|
679
669
|
main.innerHTML = '<div class="empty">加载中…</div>';
|
|
680
670
|
const r = await call('catalog', {});
|
|
681
671
|
if (!r.ok) { main.innerHTML = `<div class="empty">${esc(r.message)}</div>`; return; }
|
|
@@ -687,14 +677,19 @@
|
|
|
687
677
|
const count = filtered.length;
|
|
688
678
|
const visible = filtered.slice(0, catalogRenderLimit);
|
|
689
679
|
main.innerHTML = `<div class="stats"><span class="count">共 <strong>${count}</strong> 个连接器${searchQuery ? ` · 搜索「${esc(searchQuery)}」` : ''} · 点击卡片查看能力,点击「连接」完成授权</span></div><div class="grid">${visible.map(cardHtml).join('')}</div>${visible.length < count ? `<button class="tools-more" id="catalog-more" type="button">再显示 ${Math.min(60, count - visible.length)} 个(剩余 ${count - visible.length})</button>` : ''}`;
|
|
680
|
+
if (checkHealth && !initialHealthCheckStarted && items.some((item) => item.connected?.length > 0)) {
|
|
681
|
+
initialHealthCheckStarted = true;
|
|
682
|
+
call('healthCheck', {}).then(() => {
|
|
683
|
+
if (view === 'market') loadMarket({ checkHealth: false });
|
|
684
|
+
});
|
|
685
|
+
}
|
|
690
686
|
}
|
|
691
687
|
|
|
692
688
|
function cardHtml(d) {
|
|
693
|
-
const connected = d.connected?.length > 0;
|
|
694
689
|
const icon = isImageIcon(d.icon)
|
|
695
690
|
? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`
|
|
696
691
|
: `<span>${esc(d.icon || '🔌')}</span>`;
|
|
697
|
-
const actions = actionHtml(d
|
|
692
|
+
const actions = actionHtml(d);
|
|
698
693
|
const probe = d.probeStatus && d.probeStatus !== 'unverified' ? `<span class="badge ${esc(d.probeStatus)}">${d.probeStatus}</span>` : '';
|
|
699
694
|
return `<div class="card ${d.featured ? 'featured' : ''}" data-id="${esc(d.id)}" role="button" tabindex="0" aria-label="查看 ${esc(d.name)} 详情" onclick="window.__mcp.showDetail('${esc(d.id)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.__mcp.showDetail('${esc(d.id)}')}">
|
|
700
695
|
<div class="icon">${icon}</div>
|
|
@@ -706,17 +701,24 @@
|
|
|
706
701
|
</div>`;
|
|
707
702
|
}
|
|
708
703
|
|
|
709
|
-
function actionHtml(d
|
|
704
|
+
function actionHtml(d) {
|
|
710
705
|
const id = esc(d.id);
|
|
706
|
+
const configured = d.connected?.length > 0;
|
|
707
|
+
const state = d.connectionState || (configured ? 'configured' : 'disconnected');
|
|
708
|
+
if (configured) {
|
|
709
|
+
if (state === 'healthy') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
|
|
710
|
+
if (state === 'configured') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('configured')}</button>`;
|
|
711
|
+
if (state === 'disabled') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('disabled')}</button>`;
|
|
712
|
+
if (state === 'reauth') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-warning" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('reauthorize')}</button>`;
|
|
713
|
+
if (state === 'degraded') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-warning" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('partialError')}</button>`;
|
|
714
|
+
if (state === 'unavailable') return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn status-bad" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('connectionError')}</button>`;
|
|
715
|
+
}
|
|
711
716
|
if (d.authMode === 'oauth2-pkce') {
|
|
712
|
-
if (connected) return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
|
|
713
717
|
return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn" type="button" onclick="event.stopPropagation();window.__mcp.connect('${id}')">${t('connect')}</button>`;
|
|
714
718
|
}
|
|
715
719
|
if (d.authMode === 'none') {
|
|
716
|
-
if (connected) return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
|
|
717
720
|
return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn" type="button" onclick="event.stopPropagation();window.__mcp.connect('${id}')">${t('connect')}</button>`;
|
|
718
721
|
}
|
|
719
|
-
if (connected) return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn ghost" disabled>${t('connected')}</button>`;
|
|
720
722
|
return `<button class="btn ghost" type="button" onclick="event.stopPropagation();window.__mcp.showDetail('${id}')">${t('details')}</button><button class="btn" type="button" onclick="event.stopPropagation();window.__mcp.openConfig('${id}')">${t('configure')}</button>`;
|
|
721
723
|
}
|
|
722
724
|
|
|
@@ -735,10 +737,13 @@
|
|
|
735
737
|
function rowHtml(r) {
|
|
736
738
|
const key = esc(r.key);
|
|
737
739
|
const authMode = r.authMode === 'oauth' ? '<span class="badge auth-oauth">OAuth</span>' : `<span class="badge">${esc(r.authMode || 'manual')}</span>`;
|
|
740
|
+
const healthLabels = { healthy: '已连接', configured: '已配置', reauth: '需重新授权', degraded: '部分异常', unavailable: '连接异常', disabled: '已停用' };
|
|
741
|
+
const healthClass = r.connectionState === 'healthy' ? 'pass' : ['reauth', 'degraded'].includes(r.connectionState) ? 'partial' : r.connectionState === 'unavailable' ? 'fail' : '';
|
|
742
|
+
const healthBadge = `<span class="badge ${healthClass}">${esc(healthLabels[r.connectionState] || '已配置')}</span>`;
|
|
738
743
|
const enabled = r.enabled !== false;
|
|
739
744
|
return `<div class="row" data-key="${key}">
|
|
740
745
|
<div class="info">
|
|
741
|
-
<div class="rname">${esc(r.name)} ${authMode}</div>
|
|
746
|
+
<div class="rname">${esc(r.name)} ${authMode} ${healthBadge}</div>
|
|
742
747
|
<div class="rmeta">${esc(r.serverName || '')} · ${esc(r.url || '')}</div>
|
|
743
748
|
</div>
|
|
744
749
|
<label class="switch" title="${enabled ? '停用' : '启用'}" aria-label="${enabled ? '停用' : '启用'} ${esc(r.name)}">
|
|
@@ -884,7 +889,10 @@
|
|
|
884
889
|
async connect(connectorId) {
|
|
885
890
|
const r = await call('connect', { connectorId });
|
|
886
891
|
showToast(r.message, r.ok);
|
|
887
|
-
if (r.ok)
|
|
892
|
+
if (r.ok) {
|
|
893
|
+
await call('healthCheck', { connectorId });
|
|
894
|
+
await refresh();
|
|
895
|
+
}
|
|
888
896
|
},
|
|
889
897
|
toggle(key, enabled) {
|
|
890
898
|
call('setEnabled', { key, enabled }).then((r) => showToast(r.message, r.ok));
|
|
@@ -1001,13 +1009,15 @@
|
|
|
1001
1009
|
const d = marketItems[connectorId];
|
|
1002
1010
|
if (!d) return;
|
|
1003
1011
|
detailConnector = d;
|
|
1004
|
-
|
|
1012
|
+
detailFailureState = d.connectionState || '';
|
|
1013
|
+
detailNeedsReconfigure = ['reauth', 'unavailable'].includes(detailFailureState);
|
|
1005
1014
|
const loadId = ++detailLoadId;
|
|
1006
1015
|
$('#detail-icon').innerHTML = isImageIcon(d.icon)
|
|
1007
1016
|
? `<img src="${esc(d.icon)}" alt="" onerror="this.parentElement.innerHTML='🔌';" />`
|
|
1008
1017
|
: `<span style="font-size:24px;">${esc(d.icon || '')}</span>`;
|
|
1009
1018
|
$('#detail-name').textContent = d.name || '';
|
|
1010
|
-
|
|
1019
|
+
const initialStateLabel = d.connectionLabel || (d.connected?.length ? '已配置' : '未连接');
|
|
1020
|
+
$('#detail-vendor').textContent = d.vendor ? `${d.vendor} · ${initialStateLabel} ${d.connected?.length || 0} 个服务` : `${initialStateLabel} ${d.connected?.length || 0} 个服务`;
|
|
1011
1021
|
$('#detail-desc').textContent = d.description || d.summary || '';
|
|
1012
1022
|
detailPrompts = (d.prompts || []).filter((prompt) => prompt?.text || prompt?.title);
|
|
1013
1023
|
promptPage = 0;
|
|
@@ -1021,20 +1031,29 @@
|
|
|
1021
1031
|
setDetailOpen(true);
|
|
1022
1032
|
$('#detail-disconnect-btn').style.display = d.connected?.length > 0 ? 'block' : 'none';
|
|
1023
1033
|
const needsCredential = ['bearer', 'api-key'].includes(d.authMode);
|
|
1024
|
-
$('#detail-try-btn').textContent =
|
|
1034
|
+
$('#detail-try-btn').textContent = detailNeedsReconfigure
|
|
1035
|
+
? d.authMode === 'oauth2-pkce' && detailFailureState === 'reauth' ? '🔐 重新授权' : needsCredential && detailFailureState === 'reauth' ? t('reconfigureCredential') : '🔄 重新检查'
|
|
1036
|
+
: d.connected?.length > 0 ? t('try') : needsCredential ? t('configureConnect') : t('connectTry');
|
|
1025
1037
|
const toolsResult = await call('toolsList', { connectorId });
|
|
1026
1038
|
if (loadId !== detailLoadId || !$('#detail-overlay').classList.contains('active')) return;
|
|
1027
1039
|
if (!toolsResult.ok) {
|
|
1040
|
+
detailFailureState = toolsResult.detail?.connectionState || detailFailureState || 'unavailable';
|
|
1041
|
+
detailNeedsReconfigure = true;
|
|
1028
1042
|
$('#tools-summary').textContent = '工具加载失败';
|
|
1029
1043
|
$('#detail-tools').innerHTML = `<div class="tools-error">${esc(toolsResult.message)}</div>`;
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1044
|
+
const failureLabel = detailFailureState === 'reauth' ? (d.authMode === 'oauth2-pkce' ? '授权已失效' : '凭据已失效') : '当前连接异常';
|
|
1045
|
+
$('#detail-vendor').textContent = d.vendor ? `${d.vendor} · ${failureLabel}` : failureLabel;
|
|
1046
|
+
$('#detail-try-btn').textContent = d.authMode === 'oauth2-pkce' && detailFailureState === 'reauth'
|
|
1047
|
+
? '🔐 重新授权'
|
|
1048
|
+
: needsCredential && detailFailureState === 'reauth' ? t('reconfigureCredential') : '🔄 重新检查';
|
|
1049
|
+
renderPromptPage();
|
|
1036
1050
|
return;
|
|
1037
1051
|
}
|
|
1052
|
+
detailFailureState = toolsResult.detail?.connectionState || 'healthy';
|
|
1053
|
+
detailNeedsReconfigure = false;
|
|
1054
|
+
$('#detail-vendor').textContent = d.vendor ? `${d.vendor} · ${detailFailureState === 'degraded' ? '部分服务可用' : '已连接'} ${d.connected?.length || 0} 个服务` : `${detailFailureState === 'degraded' ? '部分服务可用' : '已连接'} ${d.connected?.length || 0} 个服务`;
|
|
1055
|
+
$('#detail-try-btn').textContent = t('try');
|
|
1056
|
+
renderPromptPage();
|
|
1038
1057
|
detailToolServers = toolsResult.detail?.servers || [];
|
|
1039
1058
|
updateToolsSummary();
|
|
1040
1059
|
renderTools();
|
|
@@ -1114,8 +1133,24 @@
|
|
|
1114
1133
|
renderTools();
|
|
1115
1134
|
});
|
|
1116
1135
|
$('#detail-try-btn').addEventListener('click', async () => {
|
|
1117
|
-
if (detailNeedsReconfigure
|
|
1118
|
-
|
|
1136
|
+
if (detailNeedsReconfigure) {
|
|
1137
|
+
if (detailFailureState === 'reauth' && ['bearer', 'api-key'].includes(detailConnector?.authMode)) {
|
|
1138
|
+
openAddConnection('manual', detailConnector);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (detailFailureState === 'reauth' && detailConnector?.authMode === 'oauth2-pkce') {
|
|
1142
|
+
const id = detailConnector.id;
|
|
1143
|
+
const r = await call('connect', { connectorId: id });
|
|
1144
|
+
showToast(r.message, r.ok);
|
|
1145
|
+
if (r.ok) {
|
|
1146
|
+
await call('healthCheck', { connectorId: id });
|
|
1147
|
+
closeDetail();
|
|
1148
|
+
await refresh();
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const id = detailConnector?.id;
|
|
1153
|
+
if (id) await window.__mcp.showDetail(id);
|
|
1119
1154
|
return;
|
|
1120
1155
|
}
|
|
1121
1156
|
if (!detailConnector?.connected?.length) {
|
|
@@ -1162,13 +1197,6 @@
|
|
|
1162
1197
|
}
|
|
1163
1198
|
|
|
1164
1199
|
$('#add-connection').addEventListener('click', () => openAddConnection('json'));
|
|
1165
|
-
$('#locale-toggle').addEventListener('click', () => {
|
|
1166
|
-
locale = locale === 'zh' ? 'en' : 'zh';
|
|
1167
|
-
try { localStorage.setItem('mcp-connector:locale', locale); } catch {}
|
|
1168
|
-
applyLocale();
|
|
1169
|
-
refresh();
|
|
1170
|
-
if ($('#detail-overlay').classList.contains('active')) renderPromptPage();
|
|
1171
|
-
});
|
|
1172
1200
|
main.addEventListener('click', (event) => {
|
|
1173
1201
|
if (!event.target.closest('#catalog-more')) return;
|
|
1174
1202
|
catalogRenderLimit += 60;
|
|
@@ -1178,6 +1206,10 @@
|
|
|
1178
1206
|
$('#refresh').addEventListener('click', async () => {
|
|
1179
1207
|
const r = await call('refreshCatalog', {});
|
|
1180
1208
|
showToast(r.message, r.ok);
|
|
1209
|
+
if (r.ok) {
|
|
1210
|
+
const health = await call('healthCheck', {});
|
|
1211
|
+
showToast(`${r.message};${health.message}`, health.ok);
|
|
1212
|
+
}
|
|
1181
1213
|
await refresh();
|
|
1182
1214
|
});
|
|
1183
1215
|
|
|
@@ -1195,7 +1227,6 @@
|
|
|
1195
1227
|
refresh();
|
|
1196
1228
|
});
|
|
1197
1229
|
|
|
1198
|
-
applyLocale();
|
|
1199
1230
|
refresh();
|
|
1200
1231
|
})();
|
|
1201
1232
|
</script>
|