dsh-mcp-connector 0.2.17 → 0.2.19
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 +37 -1
- package/README.en.md +8 -7
- package/README.md +8 -7
- package/docs/MARKET-REGISTRATION.md +35 -2
- package/docs/STDIO-SUPPORT.md +10 -8
- package/docs/USER-GUIDE.md +5 -1
- package/lib/catalog.js +43 -3
- package/lib/connectors/oauth-connector.js +11 -0
- package/lib/index.js +66 -6
- package/lib/oauth.js +85 -12
- package/lib/schema.js +44 -2
- package/lib/tools.js +45 -2
- package/lib/web.js +17 -11
- package/package.json +1 -1
- package/registry/catalog.json +24 -2
- package/registry/connectors/stdio-credential.sample.json +50 -0
- package/registry/schema/connector.schema.json +21 -2
- package/ui/index.html +25 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.19] - 2026-08-24
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 所有对话工具结果在交给 DSH Host 前统一转换为 lossless JSON,清理 `undefined`、非有限数字、`BigInt`、稀疏数组和循环引用,修复 Windows DSH Desktop 的 `value is not lossless JSON` 错误。
|
|
12
|
+
- UI 静态文件路由不再使用平台相关的 `path.normalize` 处理 URL,Windows 下保持正斜杠语义并继续拒绝路径穿越,修复市场 iframe 返回 404。
|
|
13
|
+
|
|
14
|
+
### Documentation
|
|
15
|
+
|
|
16
|
+
- 中英文 README 更新为公共 Registry 61 条、与 4 张随包卡片合并后 65 张市场卡片的当前规模;重新采集市场总览并重建 16 秒演示 GIF。
|
|
17
|
+
|
|
18
|
+
### Verification
|
|
19
|
+
|
|
20
|
+
- 插件 94 项自动测试、lint、npm 发布文件白名单/敏感内容扫描和 `git diff --check` 通过。
|
|
21
|
+
- 手动执行最新 Registry `main` 的 61 项健康巡检:60 项通过、1 项部分通过;44 个远程 MCP Server 全部可达并识别成功,28 个 stdio Server 按安全策略未执行本地命令。
|
|
22
|
+
|
|
23
|
+
## [0.2.18] - 2026-08-24
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- 市场 stdio 连接器支持声明多个凭据输入字段,并将用户在本机填写的值安全绑定到指定环境变量;目录仍禁止携带真实凭据,Registry 探针仍不执行本地命令。
|
|
28
|
+
- OAuth 动态客户端注册支持 `client_secret_post` 与 `client_secret_basic`,客户端密钥随本机 Grant 用于授权码交换、刷新和撤销。
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- stdio 凭据值不进入 catalog/status/log;字段映射会校验环境变量名、未知引用、重复声明和未使用的必填字段。
|
|
33
|
+
- OAuth DCR 客户端密钥不进入公开输出,并支持服务端提供的到期时间检查。
|
|
34
|
+
|
|
7
35
|
## [0.2.17] - 2026-08-24
|
|
8
36
|
|
|
9
37
|
### Documentation
|
|
@@ -303,7 +331,15 @@
|
|
|
303
331
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
304
332
|
- iframe 消息校验同源和消息来源。
|
|
305
333
|
|
|
306
|
-
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.
|
|
334
|
+
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.19...HEAD
|
|
335
|
+
[0.2.19]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.18...v0.2.19
|
|
336
|
+
[0.2.18]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.17...v0.2.18
|
|
337
|
+
[0.2.17]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.16...v0.2.17
|
|
338
|
+
[0.2.16]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.15...v0.2.16
|
|
339
|
+
[0.2.15]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.14...v0.2.15
|
|
340
|
+
[0.2.14]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.13...v0.2.14
|
|
341
|
+
[0.2.13]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.12...v0.2.13
|
|
342
|
+
[0.2.12]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.11...v0.2.12
|
|
307
343
|
[0.2.11]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.10...v0.2.11
|
|
308
344
|
[0.2.10]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.9...v0.2.10
|
|
309
345
|
[0.2.9]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.8...v0.2.9
|
package/README.en.md
CHANGED
|
@@ -18,21 +18,21 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
|
|
|
18
18
|
|
|
19
19
|
- A primary sidebar entry below New Conversation and above workspaces/conversations, with a public footer-slot fallback for incompatible DSH DOM versions.
|
|
20
20
|
- Searchable Marketplace and Installed views; the default Marketplace groups cards into Featured plus nine business-category sections, previews four cards per section, keeps the category bar visible while scrolling, and shows every card when a single category is selected.
|
|
21
|
-
- OAuth 2.0 Authorization Code with PKCE, API key/Bearer/unauthenticated HTTP configuration, stdio local-process configuration, and `mcpServers` JSON import.
|
|
21
|
+
- OAuth 2.0 Authorization Code with PKCE, including DCR public clients and `client_secret_post` / `client_secret_basic`; API key/Bearer/unauthenticated HTTP configuration, stdio local-process configuration, and `mcpServers` JSON import.
|
|
22
22
|
- Installation from a credential-free connector descriptor URL.
|
|
23
|
-
- Credential and MCP initialize validation before API-key connectors are saved as installed.
|
|
23
|
+
- Credential and MCP initialize validation before HTTP API-key connectors are saved as installed, plus declarative multi-field credential-to-env bindings for marketplace stdio connectors.
|
|
24
24
|
- Dynamic tool discovery grouped by MCP server, including descriptions, search, batched rendering, and an independent scroll region.
|
|
25
25
|
- Curated prompt templates that can open a DSH conversation and prefill its draft; missing variables are requested before the prompt is sent.
|
|
26
|
-
- Persistent connection lifecycle management: restore on restart, enable/disable, disconnect, refresh OAuth tokens, and revoke authorization.
|
|
26
|
+
- Persistent connection lifecycle management: restore on restart, enable/disable, disconnect, refresh OAuth tokens, and revoke authorization. DCR client secrets are kept with the local grant only.
|
|
27
27
|
- Built-in, remote, and local catalogs with `published` and `featured` controls.
|
|
28
28
|
- A standalone remote Registry, allowing new marketplace cards to appear after refresh without publishing a new npm version.
|
|
29
29
|
- Explicit, non-destructive migration of authorization from the two earlier Qichacha OAuth plugins.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
As of August 24, 2026, the public Registry publishes 61 connector descriptors. After merging and deduplicating them with the four bundled Qichacha cards, the Marketplace exposes 65 cards across nine business categories. Recommendations remain limited to the four Qichacha cards, PKULaw, and Wind, for six featured cards in total. The Registry can evolve independently, so the badge shown after a client refresh is the authoritative current count.
|
|
32
32
|
|
|
33
33
|
## Interface and demo
|
|
34
34
|
|
|
35
|
-

|
|
36
36
|
|
|
37
37
|
| Marketplace overview | Connector details and curated prompts |
|
|
38
38
|
|---|---|
|
|
@@ -105,7 +105,7 @@ npm run dev:ui
|
|
|
105
105
|
|
|
106
106
|
`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
107
|
|
|
108
|
-
The current public version is [`dsh-mcp-connector@0.2.
|
|
108
|
+
The current public version is [`dsh-mcp-connector@0.2.19`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.19](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.19).
|
|
109
109
|
|
|
110
110
|
See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
|
|
111
111
|
|
|
@@ -116,7 +116,8 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](d
|
|
|
116
116
|
- External URLs must use HTTPS; HTTP is allowed only for loopback development.
|
|
117
117
|
- Remote descriptors and catalogs are limited to 2 MiB, Web API requests to 1 MiB, and imported JSON is scanned for credential fields before normalization.
|
|
118
118
|
- Streamable HTTP and stdio are supported end to end. Legacy `sse` entries are normalized to Streamable HTTP. The connector passes stdio `command/args/env/cwd` to `@deepseek-ai/dsh-mcp-client` instead of reimplementing process transport.
|
|
119
|
-
- stdio starts a local process. Import or connect only trusted commands and packages
|
|
119
|
+
- stdio starts a local process. Import or connect only trusted commands and packages. Catalog descriptors may declare `credentialFields` and `credentialBindings`, but may never contain actual token/secret values; user input is injected only into the local Host process environment.
|
|
120
|
+
- OAuth DCR client secrets share the same local-only boundary as access and refresh tokens and are omitted from catalog/status responses and logs.
|
|
120
121
|
- The primary sidebar placement uses the stable DSH `data-slot` marker and falls back to the footer if that marker is removed.
|
|
121
122
|
|
|
122
123
|
## License
|
package/README.md
CHANGED
|
@@ -21,20 +21,20 @@
|
|
|
21
21
|
- 图形化添加:手动 HTTP/stdio、`mcpServers` JSON、连接器描述 URL 三种入口,失败时保留表单并给出修复建议。
|
|
22
22
|
- 连接器详情:精选 Prompt 优先展示,点击可带入 DSH 新会话;工具按 Server 分组,支持描述、搜索和独立滚动。
|
|
23
23
|
- Prompt 模板:使用 `{{company}}` 等变量,发送前填写真实查询主体。
|
|
24
|
-
- 三种接入:OAuth 2.0 PKCE、自定义 HTTP/stdio、导入 `mcpServers` JSON;也支持从连接器描述 URL 安装。
|
|
25
|
-
- 市场 Bearer/API Key 连接器先执行 MCP initialize 连通性与凭据校验,全部 Server
|
|
26
|
-
- 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth
|
|
24
|
+
- 三种接入:OAuth 2.0 PKCE、自定义 HTTP/stdio、导入 `mcpServers` JSON;也支持从连接器描述 URL 安装。OAuth 动态注册兼容公共客户端以及 `client_secret_post` / `client_secret_basic` 机密客户端。
|
|
25
|
+
- 市场 Bearer/API Key 连接器先执行 MCP initialize 连通性与凭据校验,全部 HTTP Server 通过后才持久化凭据并进入“已安装”;stdio 卡片可声明多个本机凭据字段及其环境变量映射。
|
|
26
|
+
- 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 刷新与撤销;DCR 返回的客户端密钥与 Token 一同只保存在本机。
|
|
27
27
|
- 目录运营:内置目录、远程 registry、本地覆盖,支持 `published` 上下架与 `featured` 精选。
|
|
28
28
|
- 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
|
|
29
29
|
- Registry 工具链:Schema/唯一性/密钥审计、MCP/OAuth 无凭据探针、每周健康巡检。
|
|
30
30
|
- 平滑迁移:显式扫描并复制两个旧企查查 OAuth 插件授权;不删除原插件或原凭据。
|
|
31
31
|
- 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
截至 2026-08-24,公共 Registry 已发布 61 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 65 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具和其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标为准。
|
|
34
34
|
|
|
35
35
|
## 界面与演示
|
|
36
36
|
|
|
37
|
-

|
|
38
38
|
|
|
39
39
|
| 市场总览 | 连接器详情与精选 Prompt |
|
|
40
40
|
|---|---|
|
|
@@ -100,7 +100,7 @@ npm run dev:ui
|
|
|
100
100
|
|
|
101
101
|
`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
102
|
|
|
103
|
-
当前公开版本为 [`dsh-mcp-connector@0.2.
|
|
103
|
+
当前公开版本为 [`dsh-mcp-connector@0.2.19`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.19](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.19)。
|
|
104
104
|
|
|
105
105
|
版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
106
106
|
Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
|
|
@@ -115,7 +115,8 @@ stdio 传输的架构、透传边界与安全约束见 [docs/STDIO-SUPPORT.md](d
|
|
|
115
115
|
- 外部 URL 仅允许 HTTPS,HTTP 仅允许回环地址;导入配置会校验 URL 与 Header。
|
|
116
116
|
- 远程目录/描述响应限制 2 MiB,Web API 请求限制 1 MiB;原始 JSON 在归一化前扫描凭据字段。
|
|
117
117
|
- 完整覆盖 Streamable HTTP 与 stdio;旧 `sse` 配置在导入/恢复时归一为 Streamable HTTP。stdio 的 `command/args/env/cwd` 原样交给 `@deepseek-ai/dsh-mcp-client`,插件本身不重复实现进程传输。
|
|
118
|
-
- stdio
|
|
118
|
+
- stdio 会启动本机进程:仅导入或连接可信命令/软件包。市场目录只能用 `credentialFields` + `credentialBindings` 声明输入与 env 映射,不得携带真实 token/secret;用户填写值只写入本机连接记录并交给 Host。
|
|
119
|
+
- OAuth DCR 的 `client_secret` 与 Access/Refresh Token 采用相同的本机存储边界,不会进入市场 API、状态输出或日志。
|
|
119
120
|
- 顶部入口通过 DSH 稳定 `data-slot` 定位并使用 React Portal;DSH 若移除该标记,入口会回退到底部,不影响连接器功能。
|
|
120
121
|
- 旧授权迁移必须显式确认,只复制不删除;确认新连接可用后再手动停用旧插件。
|
|
121
122
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
3. 在插件中选择「添加连接 → 市场卡片」,粘贴 URL。
|
|
17
17
|
4. Schema/密钥审计通过后,卡片会持久化到本机市场。
|
|
18
18
|
|
|
19
|
-
Bearer/API Key 型连接器可在卡片上点「配置」,一次填写凭据后批量连接该卡片的所有 Server。
|
|
19
|
+
Bearer/API Key 型连接器可在卡片上点「配置」,一次填写凭据后批量连接该卡片的所有 Server。stdio 卡片可通过 `auth.credentialFields` 声明多个输入,并用 `servers[].credentialBindings` 映射到本地进程环境变量;描述文件仍不得包含真实值。
|
|
20
20
|
|
|
21
21
|
## 3. 提交公共市场
|
|
22
22
|
|
|
@@ -47,8 +47,11 @@ Bearer/API Key 型连接器可在卡片上点「配置」,一次填写凭据
|
|
|
47
47
|
- Authorization Server Metadata(RFC 8414);
|
|
48
48
|
- `authorization_endpoint`、`token_endpoint`、`registration_endpoint`;`revocation_endpoint` 建议提供,但按标准属于可选能力;
|
|
49
49
|
- Dynamic Client Registration,且支持 loopback callback URI;
|
|
50
|
+
- DCR 的 `token_endpoint_auth_method` 可为 `none`、`client_secret_post` 或 `client_secret_basic`;后两者的注册响应必须返回 `client_secret`;
|
|
50
51
|
- Refresh Token;如提供撤销端点,插件会在断开/清理授权时撤销 Refresh Token,否则只删除 DSH 本机授权记录。
|
|
51
52
|
|
|
53
|
+
DCR 返回的 `client_secret` 由插件与 Access/Refresh Token 一同保存在 DSH 本机 Grant 中,只用于 Token 交换、刷新和撤销,不会出现在目录、状态输出或日志。描述文件只填写服务端支持的 `tokenEndpointAuthMethod`,不得预置客户端密钥。
|
|
54
|
+
|
|
52
55
|
插件优先读取 RFC 8414 Authorization Server Metadata;若动态注册或撤销端点缺失,会再读取 OIDC Discovery 并仅补齐缺失字段。OAuth 元数据中的授权、Token 等标准端点始终优先。
|
|
53
56
|
|
|
54
57
|
符合上述条件的最小描述:
|
|
@@ -59,7 +62,7 @@ Bearer/API Key 型连接器可在卡片上点「配置」,一次填写凭据
|
|
|
59
62
|
"id": "vendor-legal",
|
|
60
63
|
"name": "厂商·法律数据",
|
|
61
64
|
"vendor": "厂商名称",
|
|
62
|
-
"category": "
|
|
65
|
+
"category": "法律合规",
|
|
63
66
|
"summary": "法规与案例检索",
|
|
64
67
|
"published": true,
|
|
65
68
|
"auth": {
|
|
@@ -80,6 +83,36 @@ Bearer/API Key 型连接器可在卡片上点「配置」,一次填写凭据
|
|
|
80
83
|
}
|
|
81
84
|
```
|
|
82
85
|
|
|
86
|
+
### 5.1 需要本机环境变量的 stdio 卡片
|
|
87
|
+
|
|
88
|
+
目录只声明字段和映射,示例见 `registry/connectors/stdio-credential.sample.json`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"auth": {
|
|
93
|
+
"mode": "api-key",
|
|
94
|
+
"credentialFields": [
|
|
95
|
+
{ "key": "apiToken", "label": "API Token", "required": true, "secret": true },
|
|
96
|
+
{ "key": "region", "label": "区域", "required": true, "secret": false }
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"servers": [{
|
|
100
|
+
"serverKey": "main",
|
|
101
|
+
"serverName": "vendor-local-service",
|
|
102
|
+
"transport": "stdio",
|
|
103
|
+
"command": "npx",
|
|
104
|
+
"args": ["-y", "@vendor/example-mcp-server"],
|
|
105
|
+
"env": { "LOG_LEVEL": "info" },
|
|
106
|
+
"credentialBindings": {
|
|
107
|
+
"VENDOR_API_TOKEN": "apiToken",
|
|
108
|
+
"VENDOR_REGION": "region"
|
|
109
|
+
}
|
|
110
|
+
}]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`credentialBindings` 的 key 必须是合法环境变量名,value 必须引用已声明的字段。不要在 `env`、`credentialFields` 或其他目录字段中填写 Token、Secret、API Key、密码或 Cookie。
|
|
115
|
+
|
|
83
116
|
## 6. 北大法宝当前适配结论
|
|
84
117
|
|
|
85
118
|
北大法宝当前公开文档的主路径是在控制台生成 Access Token,然后通过 `Authorization: Bearer ...` 访问多个 MCP Server。因此:
|
package/docs/STDIO-SUPPORT.md
CHANGED
|
@@ -146,12 +146,13 @@ const Config = z.union([
|
|
|
146
146
|
|
|
147
147
|
采用「宽松 schema + normalize 阶段显式校验」风格(与现有代码注释「宽松校验、显式报错」一致)。
|
|
148
148
|
|
|
149
|
-
### 决策 3:stdio
|
|
149
|
+
### 决策 3:stdio 凭据使用声明式字段绑定,真实值仅在本机注入 `env`
|
|
150
150
|
|
|
151
|
-
- stdio
|
|
152
|
-
-
|
|
153
|
-
-
|
|
154
|
-
-
|
|
151
|
+
- stdio 是本地进程,密钥最终仍通过环境变量传递(如 `GITHUB_TOKEN`、`OPENAI_API_KEY`)。
|
|
152
|
+
- 无凭据的 stdio 连接器使用 `auth.mode: "none"`;需要用户输入的卡片使用 `bearer` 或 `api-key`,并在 `auth.credentialFields` 声明一个或多个输入字段。
|
|
153
|
+
- `servers[].credentialBindings` 只保存“环境变量名 → 凭据字段 key”的映射;`servers[].env` 只能包含非敏感默认值。
|
|
154
|
+
- 用户输入存入本机 `ConnectionRecord.env` 并透传给 `dsh-mcp-client`,不回写目录,也不出现在 catalog/status/log 输出。
|
|
155
|
+
- Registry 探针只校验声明与命令形状,绝不执行本地 stdio 命令。
|
|
155
156
|
|
|
156
157
|
### 决策 4:mcp-provision 按 transport 分支透传
|
|
157
158
|
|
|
@@ -611,9 +612,8 @@ cwd: { type: 'string', description: 'transport=stdio 时的工作目录,默认
|
|
|
611
612
|
| stdio `command` 可执行任意本地命令 | command 来自(a)维护者审核的 catalog,或(b)用户主动 configure,风险可控;DSH 运行时沙箱机制兜底 |
|
|
612
613
|
| `env` 里的密钥泄露到日志 | dsh-mcp-client 的 `buildChildEnv` 已用 `scrubbedParentEnv()` 清理敏感父环境变量;我们持久化 env 到 storage domain 与现有 headers 鉴权一致,无新增泄露面 |
|
|
613
614
|
| `cwd` 空字符串导致 spawn 异常 | provisioning 层显式 `record.cwd \|\| process.cwd()` |
|
|
614
|
-
| 目录夹带密钥 |
|
|
615
|
-
|
|
616
|
-
> ⚠️ **需补充**:`catalog.js` 的 `auditDescriptor` / `auditRawDescriptor` 目前审计 `servers[].headers` 的密钥类字段,需确认 `servers[].env` 也被审计(避免目录里 stdio 的 env 夹带 token)。
|
|
615
|
+
| 目录夹带密钥 | `auditRawDescriptor` 拒绝真实凭据字段,`auditDescriptor` 拒绝 `env` 中的 token/secret/API Key/password 类变量;只允许 `credentialBindings` 引用已声明字段 |
|
|
616
|
+
| 凭据映射错误或未使用 | Schema 与目录审计检查字段 key、env 名、重复映射、未知引用及未被任何 HTTP/stdio Server 使用的必填字段 |
|
|
617
617
|
|
|
618
618
|
---
|
|
619
619
|
|
|
@@ -649,6 +649,8 @@ cwd: { type: 'string', description: 'transport=stdio 时的工作目录,默认
|
|
|
649
649
|
- `npm run check`(lint + test + verify-pack)全绿
|
|
650
650
|
4. **安全**:
|
|
651
651
|
- 目录里 stdio 连接器的 `env` 含 token/secret 字段时被 `auditRawDescriptor` 拦截
|
|
652
|
+
- 多字段 `credentialFields` / `credentialBindings` 通过校验,未知引用与缺失必填值被拒绝
|
|
653
|
+
- 真实值只进入本机连接记录与 Host env,catalog/status/log 均不返回
|
|
652
654
|
|
|
653
655
|
---
|
|
654
656
|
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -69,7 +69,9 @@ dsh web
|
|
|
69
69
|
|
|
70
70
|
Bearer/API Key 连接器会先执行 MCP `initialize` 验证。所有 Server 通过后,凭据才会保存并出现在“已安装”中;验证失败不会写入新凭据。
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token、区域或租户标识。卡片目录只声明字段名称及其环境变量映射;提交后,真实值仅保存到 DSH 本机连接记录,并由插件注入该 stdio 进程的 `env`。市场、状态页和日志不会返回这些值。stdio 由 Host 托管,配置成功表示命令已注册;最终工具可用性仍以本机进程启动结果为准。
|
|
73
|
+
|
|
74
|
+
OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。
|
|
73
75
|
|
|
74
76
|
## 5. 查看详情、Prompt 与工具
|
|
75
77
|
|
|
@@ -140,6 +142,8 @@ OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端
|
|
|
140
142
|
## 8. 安全边界
|
|
141
143
|
|
|
142
144
|
- 凭据仅保存在 DSH storage domain,不进入市场目录、Git 仓库或对话历史。
|
|
145
|
+
- stdio 目录只能声明凭据字段与 env 映射,不能给出真实值;Registry 探针不会执行目录中的本地命令。
|
|
146
|
+
- OAuth 动态注册返回的 `client_secret` 不出现在目录、连接状态或日志中;断开连接时与 Refresh Token 一起尽力撤销并删除本机记录。
|
|
143
147
|
- 外部 HTTP 地址必须使用 HTTPS;仅本机回环开发地址允许 HTTP。
|
|
144
148
|
- Registry 健康探针不持有用户凭据,也绝不会执行目录里的 stdio 命令。
|
|
145
149
|
- 连接器能看到的数据和能执行的操作取决于你授予的账户权限;优先使用最小权限 Token/API Key。
|
package/lib/catalog.js
CHANGED
|
@@ -16,6 +16,11 @@ export function auditRawDescriptor(raw, path = 'connector') {
|
|
|
16
16
|
if (!raw || typeof raw !== 'object') return raw;
|
|
17
17
|
for (const [key, value] of Object.entries(raw)) {
|
|
18
18
|
const fieldPath = `${path}.${key}`;
|
|
19
|
+
// credentialBindings 的 key 是待注入的环境变量名,value 只能是凭据字段引用;
|
|
20
|
+
// 真实值会在本机表单提交后写入 ConnectionRecord,不允许出现在目录中。
|
|
21
|
+
if (key === 'credentialBindings') continue;
|
|
22
|
+
// credentialFields[].secret 只是“以密码框显示”的布尔声明,不是密钥值。
|
|
23
|
+
if (key === 'secret' && typeof value === 'boolean' && /\.auth\.credentialFields\.\d+\.secret$/.test(fieldPath)) continue;
|
|
19
24
|
if (SECRET_KEY_RE.test(key) && value !== undefined && value !== null && String(value).trim() !== '') {
|
|
20
25
|
throw new Error(`${fieldPath} 禁止携带凭证或密钥`);
|
|
21
26
|
}
|
|
@@ -38,16 +43,42 @@ export function loadBundledCatalog() {
|
|
|
38
43
|
|
|
39
44
|
/** 安全审计:目录禁止携带密钥、URL 协议白名单、header 名白名单。 */
|
|
40
45
|
export function auditDescriptor(descriptor) {
|
|
46
|
+
const credentialFields = descriptor.auth.credentialFields ?? [];
|
|
47
|
+
const credentialKeys = new Set();
|
|
48
|
+
for (const field of credentialFields) {
|
|
49
|
+
if (credentialKeys.has(field.key)) throw new Error(`connector "${descriptor.id}" auth.credentialFields key 重复: ${field.key}`);
|
|
50
|
+
credentialKeys.add(field.key);
|
|
51
|
+
}
|
|
52
|
+
const referencedCredentials = new Set();
|
|
41
53
|
for (const server of descriptor.servers) {
|
|
42
54
|
if (server.transport === 'stdio') {
|
|
43
|
-
if (descriptor.auth.mode !== 'none') {
|
|
44
|
-
throw new Error(`connector "${descriptor.id}" 的 stdio server 只能使用 auth.mode=none;凭据应由用户本机 env 配置`);
|
|
45
|
-
}
|
|
46
55
|
for (const name of Object.keys(server.env ?? {})) {
|
|
47
56
|
if (SECRET_ENV_RE.test(name)) {
|
|
48
57
|
throw new Error(`connector "${descriptor.id}" servers[].env 禁止携带密钥类变量: ${name}`);
|
|
49
58
|
}
|
|
50
59
|
}
|
|
60
|
+
const bindings = server.credentialBindings ?? {};
|
|
61
|
+
if (descriptor.auth.mode === 'oauth2-pkce') {
|
|
62
|
+
throw new Error(`connector "${descriptor.id}" 的 stdio server 不支持 auth.mode=oauth2-pkce`);
|
|
63
|
+
}
|
|
64
|
+
if (descriptor.auth.mode === 'none' && Object.keys(bindings).length > 0) {
|
|
65
|
+
throw new Error(`connector "${descriptor.id}" 的 stdio credentialBindings 需要 bearer/api-key 凭据定义`);
|
|
66
|
+
}
|
|
67
|
+
if (['bearer', 'api-key'].includes(descriptor.auth.mode) && Object.keys(bindings).length === 0) {
|
|
68
|
+
throw new Error(`connector "${descriptor.id}" 的凭据型 stdio server 必须声明 credentialBindings`);
|
|
69
|
+
}
|
|
70
|
+
for (const [envName, credentialKey] of Object.entries(bindings)) {
|
|
71
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) {
|
|
72
|
+
throw new Error(`connector "${descriptor.id}" credentialBindings 环境变量名非法: ${envName}`);
|
|
73
|
+
}
|
|
74
|
+
if (Object.hasOwn(server.env ?? {}, envName)) {
|
|
75
|
+
throw new Error(`connector "${descriptor.id}" credentialBindings 与 servers[].env 重复声明: ${envName}`);
|
|
76
|
+
}
|
|
77
|
+
if (!credentialKeys.has(credentialKey)) {
|
|
78
|
+
throw new Error(`connector "${descriptor.id}" credentialBindings 引用了未知凭据字段: ${credentialKey}`);
|
|
79
|
+
}
|
|
80
|
+
referencedCredentials.add(credentialKey);
|
|
81
|
+
}
|
|
51
82
|
continue;
|
|
52
83
|
}
|
|
53
84
|
assertSafeUrl(server.url);
|
|
@@ -58,6 +89,15 @@ export function auditDescriptor(descriptor) {
|
|
|
58
89
|
}
|
|
59
90
|
}
|
|
60
91
|
}
|
|
92
|
+
if (['bearer', 'api-key'].includes(descriptor.auth.mode)) {
|
|
93
|
+
const httpServers = descriptor.servers.filter((server) => server.transport === 'streamable-http');
|
|
94
|
+
if (httpServers.length > 0 && credentialFields[0]) referencedCredentials.add(credentialFields[0].key);
|
|
95
|
+
for (const field of credentialFields) {
|
|
96
|
+
if (field.required && !referencedCredentials.has(field.key)) {
|
|
97
|
+
throw new Error(`connector "${descriptor.id}" 必填凭据字段未被 HTTP 或 stdio 使用: ${field.key}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
61
101
|
return descriptor;
|
|
62
102
|
}
|
|
63
103
|
|
|
@@ -31,11 +31,17 @@ export async function oauthAuthorize({ connector, config, logger, signal }) {
|
|
|
31
31
|
if (!issuer) throw new Error(`connector "${connector.id}" missing issuer`);
|
|
32
32
|
|
|
33
33
|
const metadata = await discoverServerMetadata(issuer, config.requestTimeoutMs);
|
|
34
|
+
const requestedAuthMethod = connector.auth.tokenEndpointAuthMethod ?? 'none';
|
|
35
|
+
if (metadata.tokenEndpointAuthMethodsSupported.length > 0
|
|
36
|
+
&& !metadata.tokenEndpointAuthMethodsSupported.includes(requestedAuthMethod)) {
|
|
37
|
+
throw new Error(`OAuth 服务不支持 token_endpoint_auth_method=${requestedAuthMethod}`);
|
|
38
|
+
}
|
|
34
39
|
const callback = await startCallbackServer({ path: '/callback', timeoutMs: config.callbackTimeoutMs, signal });
|
|
35
40
|
const registration = await registerClient(metadata.registrationEndpoint, {
|
|
36
41
|
clientName: connector.auth.clientName,
|
|
37
42
|
redirectUris: [callback.url],
|
|
38
43
|
scope: connector.auth.scope,
|
|
44
|
+
tokenEndpointAuthMethod: requestedAuthMethod,
|
|
39
45
|
timeoutMs: config.requestTimeoutMs,
|
|
40
46
|
});
|
|
41
47
|
|
|
@@ -61,6 +67,8 @@ export async function oauthAuthorize({ connector, config, logger, signal }) {
|
|
|
61
67
|
|
|
62
68
|
const token = await exchangeCode(metadata.tokenEndpoint, {
|
|
63
69
|
clientId: registration.clientId,
|
|
70
|
+
clientSecret: registration.clientSecret,
|
|
71
|
+
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
|
|
64
72
|
code,
|
|
65
73
|
redirectUri: callback.url,
|
|
66
74
|
codeVerifier: verifier,
|
|
@@ -83,6 +91,9 @@ export async function oauthAuthorize({ connector, config, logger, signal }) {
|
|
|
83
91
|
return {
|
|
84
92
|
issuer,
|
|
85
93
|
clientId: registration.clientId,
|
|
94
|
+
clientSecret: registration.clientSecret,
|
|
95
|
+
clientSecretExpiresAt: registration.clientSecretExpiresAt,
|
|
96
|
+
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
|
|
86
97
|
clientName: connector.auth.clientName,
|
|
87
98
|
scope: connector.auth.scope,
|
|
88
99
|
token,
|
package/lib/index.js
CHANGED
|
@@ -147,11 +147,17 @@ export async function apply(ctx, config) {
|
|
|
147
147
|
async function refreshGrant(grantKey) {
|
|
148
148
|
const entry = state.grants.get(grantKey);
|
|
149
149
|
if (!entry || !entry.grant.refreshToken) throw new Error('no refresh token available');
|
|
150
|
+
if (entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
|
|
151
|
+
&& entry.grant.clientSecretExpiresAt * 1000 <= Date.now()) {
|
|
152
|
+
throw new Error('OAuth dynamic client secret expired');
|
|
153
|
+
}
|
|
150
154
|
if (entry.refreshPromise) return entry.refreshPromise;
|
|
151
155
|
entry.refreshPromise = (async () => {
|
|
152
156
|
const metadata = await discoverServerMetadata(entry.grant.issuer, config.requestTimeoutMs);
|
|
153
157
|
const token = await refreshAccessToken(metadata.tokenEndpoint, {
|
|
154
158
|
clientId: entry.grant.clientId,
|
|
159
|
+
clientSecret: entry.grant.clientSecret,
|
|
160
|
+
tokenEndpointAuthMethod: entry.grant.tokenEndpointAuthMethod ?? 'none',
|
|
155
161
|
refreshToken: entry.grant.refreshToken,
|
|
156
162
|
resource: entry.grant.authorizedResources[0],
|
|
157
163
|
scope: entry.grant.scope,
|
|
@@ -204,6 +210,8 @@ export async function apply(ctx, config) {
|
|
|
204
210
|
if (metadata.revocationEndpoint) {
|
|
205
211
|
await revokeRefreshToken(metadata.revocationEndpoint, {
|
|
206
212
|
clientId: entry.grant.clientId,
|
|
213
|
+
clientSecret: entry.grant.clientSecret,
|
|
214
|
+
tokenEndpointAuthMethod: entry.grant.tokenEndpointAuthMethod ?? 'none',
|
|
207
215
|
refreshToken: entry.grant.refreshToken,
|
|
208
216
|
timeoutMs: config.requestTimeoutMs,
|
|
209
217
|
});
|
|
@@ -247,7 +255,9 @@ export async function apply(ctx, config) {
|
|
|
247
255
|
const grantKey = record.auth.grantKey;
|
|
248
256
|
const entry = grantKey ? state.grants.get(grantKey) : null;
|
|
249
257
|
if (!entry) return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权缺失,请重新授权' };
|
|
250
|
-
|
|
258
|
+
const clientSecretExpired = entry.grant.clientSecret && entry.grant.clientSecretExpiresAt > 0
|
|
259
|
+
&& entry.grant.clientSecretExpiresAt * 1000 <= Date.now();
|
|
260
|
+
if (entry.needsReauth || clientSecretExpired || entry.grant.accessTokenExpiresAt <= Date.now()) {
|
|
251
261
|
return { ok: false, kind: 'auth', serverKey: record.serverKey, serverName: record.serverName, message: 'OAuth 授权已过期或刷新失败,请重新授权' };
|
|
252
262
|
}
|
|
253
263
|
return null;
|
|
@@ -302,6 +312,35 @@ export async function apply(ctx, config) {
|
|
|
302
312
|
return summary;
|
|
303
313
|
}
|
|
304
314
|
|
|
315
|
+
function resolveCredentialValues(connector, params = {}) {
|
|
316
|
+
const raw = params.credentialValues && typeof params.credentialValues === 'object' && !Array.isArray(params.credentialValues)
|
|
317
|
+
? params.credentialValues
|
|
318
|
+
: {};
|
|
319
|
+
const values = {};
|
|
320
|
+
const fields = connector.auth.credentialFields ?? [];
|
|
321
|
+
for (const [index, field] of fields.entries()) {
|
|
322
|
+
let value = raw[field.key];
|
|
323
|
+
// 兼容旧调用方:单一/首个凭据仍可通过 bearerToken 或 apiKeyValue 传入。
|
|
324
|
+
if ((value === undefined || value === '') && (field.key === 'credential' || index === 0)) {
|
|
325
|
+
value = connector.auth.mode === 'bearer' ? params.bearerToken : params.apiKeyValue;
|
|
326
|
+
}
|
|
327
|
+
if (field.required && (value === undefined || String(value).trim() === '')) {
|
|
328
|
+
throw new Error(`${field.label} 必填`);
|
|
329
|
+
}
|
|
330
|
+
if (value !== undefined && String(value) !== '') values[field.key] = String(value);
|
|
331
|
+
}
|
|
332
|
+
return values;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function bindStdioCredentials(server, credentialValues) {
|
|
336
|
+
const env = { ...(server.env ?? {}) };
|
|
337
|
+
for (const [envName, credentialKey] of Object.entries(server.credentialBindings ?? {})) {
|
|
338
|
+
const value = credentialValues[credentialKey];
|
|
339
|
+
if (value !== undefined && value !== '') env[envName] = value;
|
|
340
|
+
}
|
|
341
|
+
return env;
|
|
342
|
+
}
|
|
343
|
+
|
|
305
344
|
/* ───────────────────────── api 门面 ───────────────────────── */
|
|
306
345
|
|
|
307
346
|
const api = {
|
|
@@ -326,6 +365,7 @@ export async function apply(ctx, config) {
|
|
|
326
365
|
credentialPlaceholder: d.auth.credentialPlaceholder,
|
|
327
366
|
credentialDescription: d.auth.credentialDescription,
|
|
328
367
|
credentialHelpLabel: d.auth.credentialHelpLabel,
|
|
368
|
+
credentialFields: d.auth.credentialFields ?? [],
|
|
329
369
|
prompts: d.prompts ?? [],
|
|
330
370
|
probeStatus: d.probeStatus,
|
|
331
371
|
probeCheckedAt: d.probeCheckedAt,
|
|
@@ -340,6 +380,9 @@ export async function apply(ctx, config) {
|
|
|
340
380
|
servers: (d.servers ?? []).map((s) => ({
|
|
341
381
|
serverKey: s.serverKey,
|
|
342
382
|
url: s.url,
|
|
383
|
+
command: s.command,
|
|
384
|
+
args: s.args,
|
|
385
|
+
credentialBindings: s.credentialBindings,
|
|
343
386
|
transport: s.transport,
|
|
344
387
|
serverName: s.serverName,
|
|
345
388
|
})),
|
|
@@ -373,6 +416,9 @@ export async function apply(ctx, config) {
|
|
|
373
416
|
key: grantKey,
|
|
374
417
|
issuer: authz.issuer,
|
|
375
418
|
clientId: authz.clientId,
|
|
419
|
+
clientSecret: authz.clientSecret,
|
|
420
|
+
clientSecretExpiresAt: authz.clientSecretExpiresAt,
|
|
421
|
+
tokenEndpointAuthMethod: authz.tokenEndpointAuthMethod ?? 'none',
|
|
376
422
|
clientName: authz.clientName,
|
|
377
423
|
scope: authz.scope,
|
|
378
424
|
account: config.account,
|
|
@@ -466,8 +512,14 @@ export async function apply(ctx, config) {
|
|
|
466
512
|
ok: false,
|
|
467
513
|
message:
|
|
468
514
|
`连接器 "${connector.name}" 需要凭据(${connector.auth.mode}),目录不含密钥。` +
|
|
469
|
-
|
|
470
|
-
detail: {
|
|
515
|
+
'请用 mcp_connector_configure 在本机填写目录声明的凭据字段。',
|
|
516
|
+
detail: {
|
|
517
|
+
transport: connector.servers[0].transport,
|
|
518
|
+
url: connector.servers[0].url,
|
|
519
|
+
command: connector.servers[0].command,
|
|
520
|
+
serverName: connector.servers[0].serverName,
|
|
521
|
+
credentialFields: connector.auth.credentialFields ?? [],
|
|
522
|
+
},
|
|
471
523
|
};
|
|
472
524
|
},
|
|
473
525
|
|
|
@@ -481,17 +533,23 @@ export async function apply(ctx, config) {
|
|
|
481
533
|
throw new Error(`连接器 "${connector.name}" 不是 Bearer/API Key 凭据型`);
|
|
482
534
|
}
|
|
483
535
|
const records = [];
|
|
536
|
+
const credentialValues = resolveCredentialValues(connector, params);
|
|
537
|
+
const primaryCredential = credentialValues[connector.auth.credentialFields?.[0]?.key ?? 'credential'];
|
|
484
538
|
for (const server of connector.servers) {
|
|
485
539
|
const record = buildManualRecord({
|
|
486
540
|
name: connector.servers.length > 1 ? `${connector.name}·${server.serverKey}` : connector.name,
|
|
487
541
|
url: server.url,
|
|
488
542
|
serverName: server.serverName,
|
|
489
543
|
transport: server.transport,
|
|
544
|
+
command: server.command,
|
|
545
|
+
args: server.args,
|
|
546
|
+
envJson: server.transport === 'stdio' ? bindStdioCredentials(server, credentialValues) : undefined,
|
|
547
|
+
cwd: server.cwd,
|
|
490
548
|
headersJson: server.headers,
|
|
491
|
-
authMode: connector.auth.mode,
|
|
492
|
-
bearerToken: params.bearerToken,
|
|
549
|
+
authMode: server.transport === 'stdio' ? 'none' : connector.auth.mode,
|
|
550
|
+
bearerToken: params.bearerToken ?? primaryCredential,
|
|
493
551
|
apiKeyHeader: params.apiKeyHeader || connector.auth.apiKeyHeader,
|
|
494
|
-
apiKeyValue: params.apiKeyValue,
|
|
552
|
+
apiKeyValue: params.apiKeyValue ?? primaryCredential,
|
|
495
553
|
});
|
|
496
554
|
record.key = `${connector.id}-${server.serverKey}`;
|
|
497
555
|
record.connectorId = connector.id;
|
|
@@ -727,6 +785,8 @@ export async function apply(ctx, config) {
|
|
|
727
785
|
if (metadata.revocationEndpoint) {
|
|
728
786
|
await revokeRefreshToken(metadata.revocationEndpoint, {
|
|
729
787
|
clientId: g.grant.clientId,
|
|
788
|
+
clientSecret: g.grant.clientSecret,
|
|
789
|
+
tokenEndpointAuthMethod: g.grant.tokenEndpointAuthMethod ?? 'none',
|
|
730
790
|
refreshToken: g.grant.refreshToken,
|
|
731
791
|
timeoutMs: config.requestTimeoutMs,
|
|
732
792
|
});
|
package/lib/oauth.js
CHANGED
|
@@ -184,7 +184,13 @@ export async function discoverServerMetadata(issuer, timeoutMs) {
|
|
|
184
184
|
|
|
185
185
|
/* ─────────────────────────── 阶段三:动态注册客户端 ─────────────────────────── */
|
|
186
186
|
|
|
187
|
-
export async function registerClient(registrationEndpoint, {
|
|
187
|
+
export async function registerClient(registrationEndpoint, {
|
|
188
|
+
clientName,
|
|
189
|
+
redirectUris,
|
|
190
|
+
scope = DEFAULT_SCOPE,
|
|
191
|
+
tokenEndpointAuthMethod = 'none',
|
|
192
|
+
timeoutMs,
|
|
193
|
+
}) {
|
|
188
194
|
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
|
|
189
195
|
throw new OAuthError('invalid_client_metadata', 'redirect_uris is required');
|
|
190
196
|
}
|
|
@@ -198,7 +204,7 @@ export async function registerClient(registrationEndpoint, { clientName, redirec
|
|
|
198
204
|
redirect_uris: redirectUris,
|
|
199
205
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
200
206
|
response_types: ['code'],
|
|
201
|
-
token_endpoint_auth_method:
|
|
207
|
+
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
|
202
208
|
scope,
|
|
203
209
|
}),
|
|
204
210
|
},
|
|
@@ -207,7 +213,25 @@ export async function registerClient(registrationEndpoint, { clientName, redirec
|
|
|
207
213
|
if (typeof body.client_id !== 'string' || body.client_id.length === 0) {
|
|
208
214
|
throw new OAuthError('invalid_client_metadata', 'registration response missing client_id');
|
|
209
215
|
}
|
|
210
|
-
|
|
216
|
+
const resolvedAuthMethod = body.token_endpoint_auth_method ?? tokenEndpointAuthMethod;
|
|
217
|
+
if (!['none', 'client_secret_post', 'client_secret_basic'].includes(resolvedAuthMethod)) {
|
|
218
|
+
throw new OAuthError('invalid_client_metadata', `unsupported token_endpoint_auth_method: ${resolvedAuthMethod}`);
|
|
219
|
+
}
|
|
220
|
+
const clientSecret = typeof body.client_secret === 'string' && body.client_secret.length > 0
|
|
221
|
+
? body.client_secret
|
|
222
|
+
: undefined;
|
|
223
|
+
if (resolvedAuthMethod !== 'none' && !clientSecret) {
|
|
224
|
+
throw new OAuthError('invalid_client_metadata', `registration response missing client_secret for ${resolvedAuthMethod}`);
|
|
225
|
+
}
|
|
226
|
+
const expiresAt = Number(body.client_secret_expires_at);
|
|
227
|
+
return {
|
|
228
|
+
clientId: body.client_id,
|
|
229
|
+
clientSecret,
|
|
230
|
+
clientSecretExpiresAt: Number.isFinite(expiresAt) ? expiresAt : undefined,
|
|
231
|
+
tokenEndpointAuthMethod: resolvedAuthMethod,
|
|
232
|
+
clientIdIssuedAt: body.client_id_issued_at,
|
|
233
|
+
clientName: body.client_name,
|
|
234
|
+
};
|
|
211
235
|
}
|
|
212
236
|
|
|
213
237
|
/* ─────────────────────────── PKCE ─────────────────────────── */
|
|
@@ -250,10 +274,43 @@ export function buildAuthorizeUrl(metadata, { clientId, redirectUri, state, chal
|
|
|
250
274
|
|
|
251
275
|
/* ─────────────────────────── 阶段五:换 token / 刷新 / 撤销 ─────────────────────────── */
|
|
252
276
|
|
|
253
|
-
|
|
277
|
+
function formComponent(value) {
|
|
278
|
+
return new URLSearchParams({ value: String(value) }).toString().slice('value='.length);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function clientAuthentication({ clientId, clientSecret, tokenEndpointAuthMethod = 'none' }) {
|
|
282
|
+
if (!clientId) throw new OAuthError('invalid_client', 'client_id is required');
|
|
283
|
+
if (tokenEndpointAuthMethod === 'none') {
|
|
284
|
+
return { params: { client_id: clientId }, headers: {} };
|
|
285
|
+
}
|
|
286
|
+
if (!clientSecret) {
|
|
287
|
+
throw new OAuthError('invalid_client', `client_secret is required for ${tokenEndpointAuthMethod}`);
|
|
288
|
+
}
|
|
289
|
+
if (tokenEndpointAuthMethod === 'client_secret_post') {
|
|
290
|
+
return { params: { client_id: clientId, client_secret: clientSecret }, headers: {} };
|
|
291
|
+
}
|
|
292
|
+
if (tokenEndpointAuthMethod === 'client_secret_basic') {
|
|
293
|
+
const encoded = Buffer.from(`${formComponent(clientId)}:${formComponent(clientSecret)}`).toString('base64');
|
|
294
|
+
return { params: {}, headers: { Authorization: `Basic ${encoded}` } };
|
|
295
|
+
}
|
|
296
|
+
throw new OAuthError('invalid_client', `unsupported token endpoint auth method: ${tokenEndpointAuthMethod}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function exchangeCode(tokenEndpoint, {
|
|
300
|
+
clientId,
|
|
301
|
+
clientSecret,
|
|
302
|
+
tokenEndpointAuthMethod = 'none',
|
|
303
|
+
code,
|
|
304
|
+
redirectUri,
|
|
305
|
+
codeVerifier,
|
|
306
|
+
resource,
|
|
307
|
+
scope,
|
|
308
|
+
timeoutMs,
|
|
309
|
+
}) {
|
|
310
|
+
const clientAuth = clientAuthentication({ clientId, clientSecret, tokenEndpointAuthMethod });
|
|
254
311
|
const params = {
|
|
255
312
|
grant_type: 'authorization_code',
|
|
256
|
-
|
|
313
|
+
...clientAuth.params,
|
|
257
314
|
code,
|
|
258
315
|
redirect_uri: redirectUri,
|
|
259
316
|
code_verifier: codeVerifier,
|
|
@@ -262,37 +319,53 @@ export async function exchangeCode(tokenEndpoint, { clientId, code, redirectUri,
|
|
|
262
319
|
if (scope) params.scope = scope;
|
|
263
320
|
const body = await fetchJson(
|
|
264
321
|
tokenEndpoint,
|
|
265
|
-
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formEncode(params) },
|
|
322
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...clientAuth.headers }, body: formEncode(params) },
|
|
266
323
|
timeoutMs,
|
|
267
324
|
);
|
|
268
325
|
return parseTokenResponse(body, scope);
|
|
269
326
|
}
|
|
270
327
|
|
|
271
|
-
export async function refreshAccessToken(tokenEndpoint, {
|
|
328
|
+
export async function refreshAccessToken(tokenEndpoint, {
|
|
329
|
+
clientId,
|
|
330
|
+
clientSecret,
|
|
331
|
+
tokenEndpointAuthMethod = 'none',
|
|
332
|
+
refreshToken,
|
|
333
|
+
resource,
|
|
334
|
+
scope,
|
|
335
|
+
timeoutMs,
|
|
336
|
+
}) {
|
|
337
|
+
const clientAuth = clientAuthentication({ clientId, clientSecret, tokenEndpointAuthMethod });
|
|
272
338
|
const params = {
|
|
273
339
|
grant_type: 'refresh_token',
|
|
274
|
-
|
|
340
|
+
...clientAuth.params,
|
|
275
341
|
refresh_token: refreshToken,
|
|
276
342
|
};
|
|
277
343
|
if (resource) params.resource = resource;
|
|
278
344
|
if (scope) params.scope = scope;
|
|
279
345
|
const body = await fetchJson(
|
|
280
346
|
tokenEndpoint,
|
|
281
|
-
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formEncode(params) },
|
|
347
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...clientAuth.headers }, body: formEncode(params) },
|
|
282
348
|
timeoutMs,
|
|
283
349
|
);
|
|
284
350
|
return parseTokenResponse(body, scope);
|
|
285
351
|
}
|
|
286
352
|
|
|
287
|
-
export async function revokeRefreshToken(revocationEndpoint, {
|
|
353
|
+
export async function revokeRefreshToken(revocationEndpoint, {
|
|
354
|
+
clientId,
|
|
355
|
+
clientSecret,
|
|
356
|
+
tokenEndpointAuthMethod = 'none',
|
|
357
|
+
refreshToken,
|
|
358
|
+
timeoutMs,
|
|
359
|
+
}) {
|
|
360
|
+
const clientAuth = clientAuthentication({ clientId, clientSecret, tokenEndpointAuthMethod });
|
|
288
361
|
assertSafeUrl(revocationEndpoint);
|
|
289
362
|
const controller = new AbortController();
|
|
290
363
|
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
291
364
|
try {
|
|
292
365
|
const response = await fetch(revocationEndpoint, {
|
|
293
366
|
method: 'POST',
|
|
294
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
295
|
-
body: formEncode({
|
|
367
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...clientAuth.headers },
|
|
368
|
+
body: formEncode({ ...clientAuth.params, token: refreshToken, token_type_hint: 'refresh_token' }),
|
|
296
369
|
signal: controller.signal,
|
|
297
370
|
});
|
|
298
371
|
if (response.ok) return true;
|
package/lib/schema.js
CHANGED
|
@@ -11,12 +11,24 @@ export const serverRefSchema = z.object({
|
|
|
11
11
|
command: z.string().min(1).optional(),
|
|
12
12
|
args: z.array(z.string()).optional(),
|
|
13
13
|
env: z.record(z.string()).optional(),
|
|
14
|
+
credentialBindings: z.record(z.string()).optional(),
|
|
14
15
|
cwd: z.string().optional(),
|
|
15
16
|
serverName: z.string().min(1),
|
|
16
17
|
transport: z.enum(['streamable-http', 'sse', 'stdio']).optional(),
|
|
17
18
|
headers: z.record(z.string()).optional(),
|
|
18
19
|
});
|
|
19
20
|
|
|
21
|
+
/** 市场凭据输入定义;只描述表单,绝不包含真实值。 */
|
|
22
|
+
export const credentialFieldSchema = z.object({
|
|
23
|
+
key: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]*$/, '仅允许字母开头及字母/数字/_/-'),
|
|
24
|
+
label: z.string().min(1),
|
|
25
|
+
placeholder: z.string().optional(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
helpLabel: z.string().optional(),
|
|
28
|
+
required: z.boolean().optional(),
|
|
29
|
+
secret: z.boolean().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
20
32
|
/** Prompt 模板变量;目录只描述表单,不保存用户输入。 */
|
|
21
33
|
export const promptVariableSchema = z.object({
|
|
22
34
|
name: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]*$/, '仅允许字母开头及字母/数字/_/-'),
|
|
@@ -71,13 +83,14 @@ export const connectorDescriptorSchema = z.object({
|
|
|
71
83
|
issuer: z.string().optional(),
|
|
72
84
|
scope: z.string().optional(),
|
|
73
85
|
clientName: z.string().optional(),
|
|
74
|
-
tokenEndpointAuthMethod: z.
|
|
86
|
+
tokenEndpointAuthMethod: z.enum(['none', 'client_secret_post', 'client_secret_basic']).optional(),
|
|
75
87
|
apiKeyHeader: z.string().optional(),
|
|
76
88
|
grantSharing: z.string().optional(),
|
|
77
89
|
credentialName: z.string().optional(),
|
|
78
90
|
credentialPlaceholder: z.string().optional(),
|
|
79
91
|
credentialDescription: z.string().optional(),
|
|
80
92
|
credentialHelpLabel: z.string().optional(),
|
|
93
|
+
credentialFields: z.array(credentialFieldSchema).optional(),
|
|
81
94
|
})
|
|
82
95
|
.optional(),
|
|
83
96
|
servers: z.array(serverRefSchema).min(1),
|
|
@@ -118,6 +131,9 @@ export const grantRecordSchema = z.object({
|
|
|
118
131
|
key: z.string().min(1),
|
|
119
132
|
issuer: z.string().min(1),
|
|
120
133
|
clientId: z.string().min(1),
|
|
134
|
+
clientSecret: z.string().optional(),
|
|
135
|
+
clientSecretExpiresAt: z.number().optional(),
|
|
136
|
+
tokenEndpointAuthMethod: z.enum(['none', 'client_secret_post', 'client_secret_basic']).optional(),
|
|
121
137
|
clientName: z.string().optional(),
|
|
122
138
|
scope: z.string(),
|
|
123
139
|
account: z.string(),
|
|
@@ -160,12 +176,37 @@ export function normalizeConnectorDescriptor(raw) {
|
|
|
160
176
|
command: transport === 'stdio' ? s.command : undefined,
|
|
161
177
|
args: transport === 'stdio' ? (s.args ?? []) : undefined,
|
|
162
178
|
env: transport === 'stdio' ? (s.env ?? {}) : undefined,
|
|
179
|
+
credentialBindings: transport === 'stdio' ? (s.credentialBindings ?? {}) : undefined,
|
|
163
180
|
cwd: transport === 'stdio' ? (s.cwd ?? '') : undefined,
|
|
164
181
|
serverName: s.serverName,
|
|
165
182
|
transport,
|
|
166
183
|
headers: transport === 'streamable-http' ? (s.headers ?? {}) : {},
|
|
167
184
|
};
|
|
168
185
|
});
|
|
186
|
+
const authMode = c.auth?.mode ?? 'none';
|
|
187
|
+
const fallbackCredentialLabel = c.auth?.credentialName
|
|
188
|
+
|| (authMode === 'bearer' ? 'Bearer Token' : authMode === 'api-key' ? 'API Key' : '凭据');
|
|
189
|
+
const credentialFields = c.auth?.credentialFields?.length
|
|
190
|
+
? c.auth.credentialFields.map((field) => ({
|
|
191
|
+
key: field.key,
|
|
192
|
+
label: field.label,
|
|
193
|
+
placeholder: field.placeholder ?? '',
|
|
194
|
+
description: field.description ?? '',
|
|
195
|
+
helpLabel: field.helpLabel ?? '',
|
|
196
|
+
required: field.required !== false,
|
|
197
|
+
secret: field.secret !== false,
|
|
198
|
+
}))
|
|
199
|
+
: ['bearer', 'api-key'].includes(authMode)
|
|
200
|
+
? [{
|
|
201
|
+
key: 'credential',
|
|
202
|
+
label: fallbackCredentialLabel,
|
|
203
|
+
placeholder: c.auth?.credentialPlaceholder ?? '',
|
|
204
|
+
description: c.auth?.credentialDescription ?? '',
|
|
205
|
+
helpLabel: c.auth?.credentialHelpLabel ?? '',
|
|
206
|
+
required: true,
|
|
207
|
+
secret: true,
|
|
208
|
+
}]
|
|
209
|
+
: [];
|
|
169
210
|
return {
|
|
170
211
|
schemaVersion: c.schemaVersion ?? 1,
|
|
171
212
|
id: c.id,
|
|
@@ -186,7 +227,7 @@ export function normalizeConnectorDescriptor(raw) {
|
|
|
186
227
|
probeReportUrl: c.probeReportUrl ?? '',
|
|
187
228
|
toolsSnapshot: c.toolsSnapshot ?? [],
|
|
188
229
|
auth: {
|
|
189
|
-
mode:
|
|
230
|
+
mode: authMode,
|
|
190
231
|
issuer: c.auth?.issuer ?? '',
|
|
191
232
|
scope: c.auth?.scope ?? 'mcp:tools',
|
|
192
233
|
clientName: c.auth?.clientName ?? 'DeepSeek Harness - MCP 连接器',
|
|
@@ -197,6 +238,7 @@ export function normalizeConnectorDescriptor(raw) {
|
|
|
197
238
|
credentialPlaceholder: c.auth?.credentialPlaceholder ?? '',
|
|
198
239
|
credentialDescription: c.auth?.credentialDescription ?? '',
|
|
199
240
|
credentialHelpLabel: c.auth?.credentialHelpLabel ?? '',
|
|
241
|
+
credentialFields,
|
|
200
242
|
},
|
|
201
243
|
servers,
|
|
202
244
|
};
|
package/lib/tools.js
CHANGED
|
@@ -61,9 +61,46 @@ const statusOutput = {
|
|
|
61
61
|
|
|
62
62
|
const noArgs = { type: 'object', properties: {}, additionalProperties: false };
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* DSH tools require a lossless JSON value at the execute boundary. Normalize
|
|
66
|
+
* values that JSON.stringify would otherwise drop or rewrite, and always emit
|
|
67
|
+
* plain objects so transport-specific undefined fields cannot reject a tool
|
|
68
|
+
* result on stricter Desktop hosts.
|
|
69
|
+
*/
|
|
70
|
+
export function toLossless(value, seen = new Set()) {
|
|
71
|
+
if (value === null) return null;
|
|
72
|
+
const type = typeof value;
|
|
73
|
+
if (type === 'string' || type === 'boolean') return value;
|
|
74
|
+
if (type === 'number') {
|
|
75
|
+
if (!Number.isFinite(value)) return null;
|
|
76
|
+
return Object.is(value, -0) ? 0 : value;
|
|
77
|
+
}
|
|
78
|
+
if (type === 'undefined' || type === 'function' || type === 'symbol' || type === 'bigint') return null;
|
|
79
|
+
if (type !== 'object' || seen.has(value)) return null;
|
|
80
|
+
|
|
81
|
+
seen.add(value);
|
|
82
|
+
let output;
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
output = [];
|
|
85
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
86
|
+
output[index] = Object.hasOwn(value, index) ? toLossless(value[index], seen) : null;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
output = {};
|
|
90
|
+
for (const key of Object.keys(value)) output[key] = toLossless(value[key], seen);
|
|
91
|
+
}
|
|
92
|
+
seen.delete(value);
|
|
93
|
+
return output;
|
|
94
|
+
}
|
|
95
|
+
|
|
64
96
|
export function registerTools(ctx, api) {
|
|
65
97
|
const disposers = [];
|
|
66
|
-
const reg = (def) => disposers.push(ctx.tools.register(
|
|
98
|
+
const reg = (def) => disposers.push(ctx.tools.register({
|
|
99
|
+
...def,
|
|
100
|
+
async execute(args, exec) {
|
|
101
|
+
return toLossless(await def.execute(args, exec));
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
67
104
|
|
|
68
105
|
reg({
|
|
69
106
|
name: 'mcp_connector_catalog',
|
|
@@ -109,7 +146,8 @@ export function registerTools(ctx, api) {
|
|
|
109
146
|
name: 'mcp_connector_configure',
|
|
110
147
|
description:
|
|
111
148
|
'自定义配置一个外部 MCP Server 连接:HTTP 可填写 URL/鉴权;stdio 可填写 command/args/env/cwd。' +
|
|
112
|
-
'也可对市场中的 Bearer/API Key 连接器按 connectorId 一次配置全部 Server
|
|
149
|
+
'也可对市场中的 Bearer/API Key 连接器按 connectorId 一次配置全部 Server;stdio 凭据会按目录声明安全注入本机环境变量。' +
|
|
150
|
+
'市场连接器会先验证或交由 Host 托管,通过后才保存。',
|
|
113
151
|
parameters: {
|
|
114
152
|
type: 'object',
|
|
115
153
|
properties: {
|
|
@@ -126,6 +164,11 @@ export function registerTools(ctx, api) {
|
|
|
126
164
|
bearerToken: { type: 'string', description: 'authMode=bearer 时的 token' },
|
|
127
165
|
apiKeyHeader: { type: 'string', description: 'authMode=api-key 时的头名,默认 X-Api-Key' },
|
|
128
166
|
apiKeyValue: { type: 'string', description: 'authMode=api-key 时的值' },
|
|
167
|
+
credentialValues: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
additionalProperties: { type: 'string' },
|
|
170
|
+
description: '市场 stdio 连接器声明的凭据字段值;只保存在本机,不写入目录',
|
|
171
|
+
},
|
|
129
172
|
headersJson: { type: 'string', description: '额外静态头(JSON 对象字符串)' },
|
|
130
173
|
},
|
|
131
174
|
anyOf: [{ required: ['connectorId'] }, { required: ['name', 'url'] }, { required: ['name', 'transport', 'command'] }],
|
package/lib/web.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 本模块只依赖 ctx.webServer / ctx.webRuntime;在非 web 部署(无这两个服务)时
|
|
10
10
|
* 调用方应跳过挂载,保证插件仍可用于纯对话工具场景。
|
|
11
11
|
*/
|
|
12
|
-
import { dirname,
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
13
13
|
import { readFile } from 'node:fs/promises';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
|
|
@@ -131,20 +131,26 @@ function extOf(pathname) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/** 把 URL pathname 安全地映射到 ui 目录内的文件(防目录穿越)。 */
|
|
134
|
-
function
|
|
135
|
-
let
|
|
134
|
+
export function splitUiPath(pathname) {
|
|
135
|
+
let decoded;
|
|
136
136
|
try {
|
|
137
|
-
|
|
137
|
+
decoded = decodeURIComponent(pathname);
|
|
138
138
|
} catch {
|
|
139
|
-
return
|
|
139
|
+
return undefined;
|
|
140
140
|
}
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
const segments = decoded.replace(/\\/g, '/').split('/').filter((segment) => segment !== '' && segment !== '.');
|
|
142
|
+
if (segments.includes('..')) return undefined;
|
|
143
|
+
return segments;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolveUiFile(pathname) {
|
|
147
|
+
const segments = splitUiPath(pathname);
|
|
148
|
+
if (segments === undefined) return { ok: false };
|
|
149
|
+
if (segments.length === 0) return { ok: true, path: join(UI_DIR, 'index.html'), rel: 'index.html' };
|
|
150
|
+
const abs = resolve(UI_DIR, ...segments);
|
|
145
151
|
const relAbs = relative(UI_DIR, abs);
|
|
146
|
-
if (relAbs.startsWith(
|
|
147
|
-
return { ok: true, path: abs, rel:
|
|
152
|
+
if (relAbs === '..' || relAbs.startsWith(`..${sep}`) || isAbsolute(relAbs) || relAbs === '') return { ok: false };
|
|
153
|
+
return { ok: true, path: abs, rel: segments.join('/') };
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
/* ───────────────────────── 路由挂载 ───────────────────────── */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19",
|
|
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",
|
package/registry/catalog.json
CHANGED
|
@@ -79,7 +79,18 @@
|
|
|
79
79
|
"credentialName": "北大法宝 Access Token",
|
|
80
80
|
"credentialPlaceholder": "请输入您的北大法宝 Access Token",
|
|
81
81
|
"credentialDescription": "用于访问北大法宝 MCP;仅保存在 DSH 本机。",
|
|
82
|
-
"credentialHelpLabel": "如何获取北大法宝 Token?"
|
|
82
|
+
"credentialHelpLabel": "如何获取北大法宝 Token?",
|
|
83
|
+
"credentialFields": [
|
|
84
|
+
{
|
|
85
|
+
"key": "credential",
|
|
86
|
+
"label": "北大法宝 Access Token",
|
|
87
|
+
"placeholder": "请输入您的北大法宝 Access Token",
|
|
88
|
+
"description": "用于访问北大法宝 MCP;仅保存在 DSH 本机。",
|
|
89
|
+
"helpLabel": "如何获取北大法宝 Token?",
|
|
90
|
+
"required": true,
|
|
91
|
+
"secret": true
|
|
92
|
+
}
|
|
93
|
+
]
|
|
83
94
|
},
|
|
84
95
|
"servers": [
|
|
85
96
|
{
|
|
@@ -211,7 +222,18 @@
|
|
|
211
222
|
"credentialName": "Wind API Key(个人密钥)",
|
|
212
223
|
"credentialPlaceholder": "请输入您的个人 Wind API Key",
|
|
213
224
|
"credentialDescription": "用于访问 Wind MCP 的个人访问令牌;仅保存在 DSH 本机。",
|
|
214
|
-
"credentialHelpLabel": "如何获取 Wind API Key?"
|
|
225
|
+
"credentialHelpLabel": "如何获取 Wind API Key?",
|
|
226
|
+
"credentialFields": [
|
|
227
|
+
{
|
|
228
|
+
"key": "credential",
|
|
229
|
+
"label": "Wind API Key(个人密钥)",
|
|
230
|
+
"placeholder": "请输入您的个人 Wind API Key",
|
|
231
|
+
"description": "用于访问 Wind MCP 的个人访问令牌;仅保存在 DSH 本机。",
|
|
232
|
+
"helpLabel": "如何获取 Wind API Key?",
|
|
233
|
+
"required": true,
|
|
234
|
+
"secret": true
|
|
235
|
+
}
|
|
236
|
+
]
|
|
215
237
|
},
|
|
216
238
|
"servers": [
|
|
217
239
|
{
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "vendor-local-service",
|
|
4
|
+
"name": "厂商·本地 MCP 示例",
|
|
5
|
+
"vendor": "厂商名称",
|
|
6
|
+
"icon": "🔐",
|
|
7
|
+
"category": "开发工具",
|
|
8
|
+
"summary": "演示市场凭据如何安全注入 stdio 环境变量",
|
|
9
|
+
"description": "目录只声明输入字段和环境变量映射;用户填写的真实值仅保存在 DSH 本机。",
|
|
10
|
+
"published": false,
|
|
11
|
+
"featured": false,
|
|
12
|
+
"homepage": "https://example.com/mcp",
|
|
13
|
+
"probeStatus": "unverified",
|
|
14
|
+
"auth": {
|
|
15
|
+
"mode": "api-key",
|
|
16
|
+
"credentialFields": [
|
|
17
|
+
{
|
|
18
|
+
"key": "apiToken",
|
|
19
|
+
"label": "服务 API Token",
|
|
20
|
+
"placeholder": "仅保存在 DSH 本机",
|
|
21
|
+
"description": "从服务控制台创建,不得提交到目录。",
|
|
22
|
+
"required": true,
|
|
23
|
+
"secret": true
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"key": "region",
|
|
27
|
+
"label": "区域",
|
|
28
|
+
"placeholder": "例如 cn-east-1",
|
|
29
|
+
"required": true,
|
|
30
|
+
"secret": false
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"servers": [
|
|
35
|
+
{
|
|
36
|
+
"serverKey": "main",
|
|
37
|
+
"serverName": "vendor-local-service",
|
|
38
|
+
"transport": "stdio",
|
|
39
|
+
"command": "npx",
|
|
40
|
+
"args": ["-y", "@vendor/example-mcp-server"],
|
|
41
|
+
"env": {
|
|
42
|
+
"LOG_LEVEL": "info"
|
|
43
|
+
},
|
|
44
|
+
"credentialBindings": {
|
|
45
|
+
"VENDOR_API_TOKEN": "apiToken",
|
|
46
|
+
"VENDOR_REGION": "region"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
@@ -30,13 +30,17 @@
|
|
|
30
30
|
"issuer": { "type": "string" },
|
|
31
31
|
"scope": { "type": "string" },
|
|
32
32
|
"clientName": { "type": "string" },
|
|
33
|
-
"tokenEndpointAuthMethod": { "
|
|
33
|
+
"tokenEndpointAuthMethod": { "enum": ["none", "client_secret_post", "client_secret_basic"] },
|
|
34
34
|
"apiKeyHeader": { "type": "string" },
|
|
35
35
|
"grantSharing": { "type": "string" },
|
|
36
36
|
"credentialName": { "type": "string" },
|
|
37
37
|
"credentialPlaceholder": { "type": "string" },
|
|
38
38
|
"credentialDescription": { "type": "string" },
|
|
39
|
-
"credentialHelpLabel": { "type": "string" }
|
|
39
|
+
"credentialHelpLabel": { "type": "string" },
|
|
40
|
+
"credentialFields": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": { "$ref": "#/$defs/credentialField" }
|
|
43
|
+
}
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"servers": {
|
|
@@ -52,6 +56,7 @@
|
|
|
52
56
|
"command": { "type": "string", "minLength": 1 },
|
|
53
57
|
"args": { "type": "array", "items": { "type": "string" } },
|
|
54
58
|
"env": { "type": "object", "additionalProperties": { "type": "string" } },
|
|
59
|
+
"credentialBindings": { "type": "object", "additionalProperties": { "type": "string" } },
|
|
55
60
|
"cwd": { "type": "string" },
|
|
56
61
|
"serverName": { "type": "string", "minLength": 1 },
|
|
57
62
|
"transport": { "enum": ["streamable-http", "stdio"] },
|
|
@@ -106,6 +111,20 @@
|
|
|
106
111
|
}
|
|
107
112
|
},
|
|
108
113
|
"$defs": {
|
|
114
|
+
"credentialField": {
|
|
115
|
+
"type": "object",
|
|
116
|
+
"additionalProperties": false,
|
|
117
|
+
"required": ["key", "label"],
|
|
118
|
+
"properties": {
|
|
119
|
+
"key": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" },
|
|
120
|
+
"label": { "type": "string", "minLength": 1 },
|
|
121
|
+
"placeholder": { "type": "string" },
|
|
122
|
+
"description": { "type": "string" },
|
|
123
|
+
"helpLabel": { "type": "string" },
|
|
124
|
+
"required": { "type": "boolean" },
|
|
125
|
+
"secret": { "type": "boolean" }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
109
128
|
"variables": {
|
|
110
129
|
"type": "array",
|
|
111
130
|
"items": {
|
package/ui/index.html
CHANGED
|
@@ -924,15 +924,31 @@
|
|
|
924
924
|
function openCatalogCredentialForm(preset) {
|
|
925
925
|
const authMode = preset.authMode === 'api-key' ? 'api-key' : 'bearer';
|
|
926
926
|
const servers = preset.servers || [];
|
|
927
|
+
const usesStdio = servers.some((server) => server.transport === 'stdio');
|
|
927
928
|
const homepage = /^https:\/\//i.test(preset.homepage || '') ? preset.homepage : '';
|
|
928
929
|
const credentialName = preset.credentialName || (authMode === 'bearer' ? 'Bearer Token' : 'API Key');
|
|
929
930
|
const credentialPlaceholder = preset.credentialPlaceholder || '仅保存在 DSH 本机';
|
|
930
931
|
const credentialDescription = preset.credentialDescription || '凭据仅保存在 DSH 本机,不会写入市场目录或对话。';
|
|
931
932
|
const credentialHelpLabel = preset.credentialHelpLabel || `前往 ${preset.vendor || preset.name} 官网获取`;
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
:
|
|
935
|
-
|
|
933
|
+
const declaredFields = preset.credentialFields?.length ? preset.credentialFields : [{
|
|
934
|
+
key: 'credential', label: credentialName, placeholder: credentialPlaceholder,
|
|
935
|
+
description: credentialDescription, required: true, secret: true,
|
|
936
|
+
}];
|
|
937
|
+
const stdioCredentialFields = declaredFields.map((field) => {
|
|
938
|
+
const id = `cfg-credential-${field.key}`;
|
|
939
|
+
return `<label for="${esc(id)}">${esc(field.label)}${field.required === false ? '(可选)' : '(必填)'}</label><input id="${esc(id)}" data-credential-key="${esc(field.key)}" type="${field.secret === false ? 'text' : 'password'}" autocomplete="off" placeholder="${esc(field.placeholder || '仅保存在 DSH 本机')}" />${field.description ? `<div class="field-help">${esc(field.description)}</div>` : ''}`;
|
|
940
|
+
}).join('');
|
|
941
|
+
const credentialFields = usesStdio
|
|
942
|
+
? stdioCredentialFields
|
|
943
|
+
: authMode === 'bearer'
|
|
944
|
+
? `<label for="cfg-token">${esc(credentialName)}(必填)</label><input id="cfg-token" type="password" autocomplete="off" placeholder="${esc(credentialPlaceholder)}" /><div class="field-help">${esc(credentialDescription)}</div>`
|
|
945
|
+
: `<div class="form-row"><div><label for="cfg-key-header">API Key Header</label><input id="cfg-key-header" value="${esc(preset.apiKeyHeader || 'X-Api-Key')}" /></div><div><label for="cfg-key-value">${esc(credentialName)}(必填)</label><input id="cfg-key-value" type="password" autocomplete="off" placeholder="${esc(credentialPlaceholder)}" /></div></div><div class="field-help">${esc(credentialDescription)}</div>`;
|
|
946
|
+
const serverPreview = servers.map((server) => {
|
|
947
|
+
const endpoint = server.transport === 'stdio'
|
|
948
|
+
? `stdio · ${server.command || ''} ${(server.args || []).join(' ')}`.trim()
|
|
949
|
+
: server.url;
|
|
950
|
+
return `<div>${esc(server.serverName)} · ${esc(endpoint)}</div>`;
|
|
951
|
+
}).join('');
|
|
936
952
|
openModal(`配置 ${preset.name}`, `
|
|
937
953
|
<input id="cfg-connector-id" type="hidden" value="${esc(preset.id)}" />
|
|
938
954
|
<input id="cfg-auth" type="hidden" value="${esc(authMode)}" />
|
|
@@ -1067,10 +1083,15 @@
|
|
|
1067
1083
|
const connectorId = $('#cfg-connector-id')?.value.trim();
|
|
1068
1084
|
const authMode = $('#cfg-auth').value;
|
|
1069
1085
|
if (connectorId) {
|
|
1086
|
+
const credentialValues = {};
|
|
1087
|
+
document.querySelectorAll('[data-credential-key]').forEach((input) => {
|
|
1088
|
+
credentialValues[input.dataset.credentialKey] = input.value;
|
|
1089
|
+
});
|
|
1070
1090
|
setModalBusy(true, '正在验证凭据与连接…');
|
|
1071
1091
|
const r = await call('configure', {
|
|
1072
1092
|
connectorId,
|
|
1073
1093
|
authMode,
|
|
1094
|
+
credentialValues,
|
|
1074
1095
|
bearerToken: $('#cfg-token')?.value || undefined,
|
|
1075
1096
|
apiKeyHeader: $('#cfg-key-header')?.value || undefined,
|
|
1076
1097
|
apiKeyValue: $('#cfg-key-value')?.value || undefined,
|