dsh-mcp-connector 0.2.33 → 0.2.35
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 +26 -1
- package/README.en.md +4 -3
- package/README.md +4 -3
- package/docs/USER-GUIDE.md +4 -4
- package/lib/callback-server.js +6 -2
- package/lib/connection-backup.js +1 -0
- package/lib/connectors/json-connector.js +16 -3
- package/lib/connectors/manual-connector.js +10 -3
- package/lib/connectors/oauth-connector.js +156 -70
- package/lib/index.js +5 -3
- package/lib/schema.js +10 -1
- package/lib/tools.js +7 -2
- package/lib/util.js +50 -3
- package/package.json +1 -1
- package/ui/index.html +25 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.35] - 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 市场详情内完成免密连接、凭据配置或 OAuth 重新授权后,现在会原地重新读取连接状态与工具清单,并同步启用 Prompt 和试用按钮;用户在刷新期间主动关闭或切换详情时不会被强制重新打开。
|
|
12
|
+
|
|
13
|
+
### Verification
|
|
14
|
+
|
|
15
|
+
- 195 项自动测试、lint、版本/营销元数据/商店截图门禁和 npm 发布包白名单/敏感内容扫描全部通过。
|
|
16
|
+
|
|
17
|
+
## [0.2.34] - 2026-09-04
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- 用户自建连接可对 RFC1918 IPv4 / RFC4193 IPv6 ULA 字面量显式授权明文 HTTP;默认仍拒绝,公网 HTTP、域名、链路本地与元数据地址不受该开关影响。
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- OAuth 一键连接现在标记资源发现、服务发现、回调监听、客户端注册、授权回调和 Token 换取阶段;DCR HTTP 403 会明确说明服务商客户端准入限制,不再误导为用户授权或凭据问题。
|
|
26
|
+
- OAuth 在动态客户端注册等浏览器打开前的阶段失败时,会立即关闭本机回调监听,避免遗留端口与超时任务。
|
|
27
|
+
|
|
7
28
|
## [0.2.33] - 2026-09-04
|
|
8
29
|
|
|
9
30
|
### Added
|
|
@@ -527,7 +548,11 @@
|
|
|
527
548
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
528
549
|
- iframe 消息校验同源和消息来源。
|
|
529
550
|
|
|
530
|
-
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.
|
|
551
|
+
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.35...HEAD
|
|
552
|
+
[0.2.35]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.34...v0.2.35
|
|
553
|
+
[0.2.34]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.33...v0.2.34
|
|
554
|
+
[0.2.33]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.32...v0.2.33
|
|
555
|
+
[0.2.32]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.31...v0.2.32
|
|
531
556
|
[0.2.31]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.30...v0.2.31
|
|
532
557
|
[0.2.30]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.29...v0.2.30
|
|
533
558
|
[0.2.29]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.28...v0.2.29
|
package/README.en.md
CHANGED
|
@@ -66,7 +66,7 @@ If the plugin helps you connect an MCP server faster, consider [starring the rep
|
|
|
66
66
|
- Explicit, non-destructive migration from the two earlier Qichacha OAuth plugins, plus active-plugin conflict detection that blocks duplicate server management and credential overwrites.
|
|
67
67
|
|
|
68
68
|
<!-- catalog-stats:start -->
|
|
69
|
-
As of 2026-09-
|
|
69
|
+
As of 2026-09-04, the public Registry publishes 100 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 104 cards across 9 business categories. Recommendations remain limited to the four Qichacha cards, PKULaw, and Wind, for 6 featured cards in total. The Registry evolves independently; the badge shown after a client refresh and the live badges above are the authoritative current counts.
|
|
70
70
|
<!-- catalog-stats:end -->
|
|
71
71
|
|
|
72
72
|
## Interface and demo
|
|
@@ -171,7 +171,7 @@ npm run dev:ui
|
|
|
171
171
|
|
|
172
172
|
Every Registry merge regenerates `catalog-stats.json`; an hourly workflow in this repository synchronizes the Chinese and English product copy plus a local stats snapshot. The static npm README updates with package releases, while the live badges above read the Registry directly and therefore stay current without another npm release.
|
|
173
173
|
|
|
174
|
-
The current public version is [`dsh-mcp-connector@0.2.
|
|
174
|
+
The current public version is [`dsh-mcp-connector@0.2.35`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.35](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.35).
|
|
175
175
|
|
|
176
176
|
See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
|
|
177
177
|
|
|
@@ -180,11 +180,12 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](d
|
|
|
180
180
|
- Credentials stay inside the local DSH storage boundary: connection records use the storage domain, while rotating OAuth credentials are also saved in `$DSH_HOME/storages/mcp_connector_grants_v1` so stale host snapshots cannot overwrite them. The directory is mode 0700 and records are mode 0600; credentials are never written to the catalog, Git repository, page, logs, or conversation history.
|
|
181
181
|
- Failed API key/token validation is not persisted; authentication, timeout, DNS, and TLS/network errors are reported separately.
|
|
182
182
|
- Unchecked or Host-unobservable connections are reported as `unknown`, never as healthy. Health history and last-success timestamps are currently process-local.
|
|
183
|
-
- External URLs
|
|
183
|
+
- External URLs use HTTPS by default, and HTTP is allowed for loopback development. A user-created connection may explicitly opt in to plaintext HTTP for literal RFC1918 IPv4 or RFC4193 IPv6 ULA addresses; hostnames, public HTTP, link-local addresses, and cloud metadata addresses remain blocked.
|
|
184
184
|
- 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.
|
|
185
185
|
- 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.
|
|
186
186
|
- 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.
|
|
187
187
|
- OAuth DCR client secrets share the same local-only boundary as access and refresh tokens and are omitted from catalog/status responses and logs.
|
|
188
|
+
- OAuth failures identify resource discovery, server discovery, client registration, browser callback, or token exchange. A DCR HTTP 403 is reported as provider-side client admission rejection rather than a missing user authorization.
|
|
188
189
|
- Redacted exports replace tokens, API keys, static header/env values, stdio arguments, local directories, and URLs containing query/user information with explicit placeholders. OAuth entries contain only a reconnect reference; full snapshots remain inside the current profile's storage domain.
|
|
189
190
|
- The primary sidebar placement uses the stable DSH `data-slot` marker and falls back to the footer if that marker is removed.
|
|
190
191
|
- Project/global scopes apply only inside the current DSH profile and do not synchronize across profiles. If a Workspace is deleted, its project-only bindings remain fail-closed until moved to another project or global.
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ dsh plugin --profile web add dsh-mcp-connector
|
|
|
68
68
|
- 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`export_config`、`snapshot`、`install_from_url`、`status`、`scope`、`health_check`、`policy`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
|
|
69
69
|
|
|
70
70
|
<!-- catalog-stats:start -->
|
|
71
|
-
截至 2026-09-
|
|
71
|
+
截至 2026-09-04,公共 Registry 已发布 100 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 104 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具、其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标和上方实时统计徽标为准。
|
|
72
72
|
<!-- catalog-stats:end -->
|
|
73
73
|
|
|
74
74
|
## 界面与演示
|
|
@@ -167,7 +167,7 @@ npm run dev:ui
|
|
|
167
167
|
|
|
168
168
|
公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
|
|
169
169
|
|
|
170
|
-
当前公开版本为 [`dsh-mcp-connector@0.2.
|
|
170
|
+
当前公开版本为 [`dsh-mcp-connector@0.2.35`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.35](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.35)。
|
|
171
171
|
|
|
172
172
|
版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
173
173
|
Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
|
|
@@ -180,11 +180,12 @@ stdio 传输的架构、透传边界与安全约束见 [docs/STDIO-SUPPORT.md](d
|
|
|
180
180
|
- 凭证只持久化在本机 DSH storage 边界:连接记录使用 storage domain,OAuth 轮换凭据同步保存到 `$DSH_HOME/storages/mcp_connector_grants_v1`。该目录为 0700、文件为 0600;凭证不进入市场目录、Git 仓库、页面、日志或对话历史。
|
|
181
181
|
- 市场 Key/Token 校验失败时不写入 storage domain;鉴权、超时、DNS、TLS/网络错误会分类提示。
|
|
182
182
|
- 未检查或 Host 状态不可见时显示“状态未知”,不会冒充健康;健康摘要与最近成功时间当前只保留在插件进程内。
|
|
183
|
-
- 外部 URL
|
|
183
|
+
- 外部 URL 默认仅允许 HTTPS,HTTP 默认仅允许回环地址。用户自建连接可在明确风险确认后放行 RFC1918 IPv4 / RFC4193 IPv6 ULA 字面量;域名、公网 HTTP、链路本地与云元数据地址仍拒绝。
|
|
184
184
|
- 远程目录/描述响应限制 2 MiB,Web API 请求限制 1 MiB;原始 JSON 在归一化前扫描凭据字段。
|
|
185
185
|
- 完整覆盖 Streamable HTTP 与 stdio;旧 `sse` 配置在导入/恢复时归一为 Streamable HTTP。stdio 的 `command/args/env/cwd` 原样交给 `@deepseek-ai/dsh-mcp-client`,插件本身不重复实现进程传输。
|
|
186
186
|
- stdio 会启动本机进程:仅导入或连接可信命令/软件包。市场目录只能用 `credentialFields` + `credentialBindings` 声明输入与 env 映射,不得携带真实 token/secret;用户填写值只写入本机连接记录并交给 Host。
|
|
187
187
|
- OAuth DCR 的 `client_secret` 与 Access/Refresh Token 采用相同的本机存储边界,不会进入市场 API、状态输出或日志。
|
|
188
|
+
- OAuth 连接失败会标明资源发现、服务发现、客户端注册、授权回调或 Token 换取阶段;DCR HTTP 403 表示服务商拒绝未准入客户端,不是用户未点击授权。
|
|
188
189
|
- 脱敏导出把 Token/API Key、静态 Header/env 值、stdio 参数、本地目录及带查询参数的 URL 替换为占位符;OAuth 只保留重新授权引用。快照中的完整配置仍只在当前 profile 的 storage domain 内。
|
|
189
190
|
- 顶部入口通过 DSH 稳定 `data-slot` 定位并使用 React Portal;DSH 若移除该标记,入口会回退到底部,不影响连接器功能。
|
|
190
191
|
- 旧授权迁移必须显式确认,只复制不删除;确认新连接可用后再手动停用旧插件。
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -75,7 +75,7 @@ Bearer/API Key 连接器会先执行 MCP `initialize` 验证。所有 Server 通
|
|
|
75
75
|
|
|
76
76
|
stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token、区域或租户标识。卡片目录只声明字段名称及其环境变量映射;提交后,真实值仅保存到 DSH 本机连接记录,并由插件注入该 stdio 进程的 `env`。市场、状态页和日志不会返回这些值。插件会等待 Host 完成首次 MCP 初始化与工具同步后再保存连接;失败或超时不会增加已安装数量,卡片也不会提前显示“已连接”。
|
|
77
77
|
|
|
78
|
-
OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token
|
|
78
|
+
OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。如服务商在动态客户端注册阶段返回 HTTP 403,页面会明确提示“客户端注册被拒绝、尚未进入浏览器授权”;这通常需要服务商审核或登记客户端,重试用户授权无法绕过。
|
|
79
79
|
|
|
80
80
|
当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。升级时会验证并自动归并旧版本留下的同 issuer 多份 Grant;Desktop 与 `dsh web` 并发时,插件使用跨进程锁串行轮换,并从每 Grant 独立原子日志读取最新 Token,避免 DSH 整文件 storage 的旧进程快照覆盖新凭据。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”;确认 journal 中也没有更新 Token,且明确收到 Refresh Token/客户端失效错误时,才进入“需重新授权”。
|
|
81
81
|
|
|
@@ -120,11 +120,11 @@ OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据
|
|
|
120
120
|
}
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
导入前请把示例占位符替换为自己的值。凭据只应在本机导入,不要把含真实 Token、API Key、密码或 Cookie 的 JSON 提交到 Git、Issue
|
|
123
|
+
导入前请把示例占位符替换为自己的值。凭据只应在本机导入,不要把含真实 Token、API Key、密码或 Cookie 的 JSON 提交到 Git、Issue 或聊天。导入私有 IP 的明文 HTTP 端点时,必须在对应 Server 条目中显式增加 `"allowInsecurePrivateNetwork": true`;该标记会跟随脱敏导出保留。
|
|
124
124
|
|
|
125
125
|
### 6.2 手动配置
|
|
126
126
|
|
|
127
|
-
- **HTTP**:填写名称、HTTPS MCP URL、可选 Header
|
|
127
|
+
- **HTTP**:填写名称、HTTPS MCP URL、可选 Header 和传输方式。回环 HTTP 可直接使用;局域网私有 IP 明文 HTTP 需勾选风险确认。
|
|
128
128
|
- **stdio**:填写本机命令、参数、环境变量和可选工作目录。
|
|
129
129
|
|
|
130
130
|
stdio 进程由 `@deepseek-ai/dsh-mcp-client` 管理,插件透传 `command`、`args`、`env`、`cwd`,并等待 Host 完成首次 MCP 初始化和工具同步。stdio 会以当前用户权限启动本机进程,只运行你信任的软件包和命令。手动 HTTP 配置也会在保存前执行 `initialize` 校验;验证失败时表单内容仍保留,且不会生成无效连接记录。
|
|
@@ -220,7 +220,7 @@ OAuth 断开时,插件会尽力调用服务商的撤销端点;无撤销端
|
|
|
220
220
|
- 凭据仅保存在 DSH storage domain,不进入市场目录、Git 仓库或对话历史。
|
|
221
221
|
- stdio 目录只能声明凭据字段与 env 映射,不能给出真实值;Registry 探针不会执行目录中的本地命令。
|
|
222
222
|
- OAuth 动态注册返回的 `client_secret` 不出现在目录、连接状态或日志中;断开连接时与 Refresh Token 一起尽力撤销并删除本机记录。
|
|
223
|
-
-
|
|
223
|
+
- 外部地址默认必须使用 HTTPS,本机回环地址允许 HTTP。用户自建连接只有在显式确认风险后,才允许 RFC1918 IPv4(`10/8`、`172.16/12`、`192.168/16`)或 RFC4193 IPv6 ULA 字面量使用明文 HTTP。域名、公网 HTTP、链路本地与云元数据地址仍拒绝;开启后凭据和工具数据可能被同网段监听或篡改。
|
|
224
224
|
- Registry 健康探针不持有用户凭据,也绝不会执行目录里的 stdio 命令。
|
|
225
225
|
- 连接器能看到的数据和能执行的操作取决于你授予的账户权限;优先使用最小权限 Token/API Key。
|
|
226
226
|
- 生成、付费、写入、发布、删除、停止任务等有副作用的工具,应在执行前再次确认参数和影响。
|
package/lib/callback-server.js
CHANGED
|
@@ -73,12 +73,16 @@ export async function startCallbackServer({ path = '/callback', port = 0, host =
|
|
|
73
73
|
const actualPort = server.address().port;
|
|
74
74
|
const url = `http://${host}:${actualPort}${path}`;
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
let closed = false;
|
|
77
|
+
const close = () => {
|
|
78
|
+
if (closed) return Promise.resolve();
|
|
79
|
+
closed = true;
|
|
80
|
+
return new Promise((resolve) => {
|
|
78
81
|
clearTimeout(timer);
|
|
79
82
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
80
83
|
server.close(() => resolve());
|
|
81
84
|
});
|
|
85
|
+
};
|
|
82
86
|
|
|
83
87
|
const waitForCallback = async () => {
|
|
84
88
|
try {
|
package/lib/connection-backup.js
CHANGED
|
@@ -77,6 +77,7 @@ function exportPortableConnection(record) {
|
|
|
77
77
|
} else {
|
|
78
78
|
const url = safeUrl(record.url);
|
|
79
79
|
base.url = url.value;
|
|
80
|
+
if (record.allowInsecurePrivateNetwork === true) base.allowInsecurePrivateNetwork = true;
|
|
80
81
|
if (url.redacted) redactedFields.push('url');
|
|
81
82
|
if (Object.keys(record.headers ?? {}).length) {
|
|
82
83
|
base.headers = redactedObjectValues(record.headers);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 批量预校验,支持 HTTP/SSE 与 stdio(command/args/env/cwd)。
|
|
4
4
|
*/
|
|
5
5
|
import { JSON_CONNECTOR_ID } from '../constants.js';
|
|
6
|
-
import {
|
|
6
|
+
import { assertConnectionUrl, assertSafeHeaderName, isPrivateNetworkHost, slugServerName } from '../util.js';
|
|
7
7
|
import { unresolvedRedactions } from '../connection-backup.js';
|
|
8
8
|
|
|
9
9
|
function parseInput(input) {
|
|
@@ -39,6 +39,17 @@ function normalizeTransport(value, hasCommand = false) {
|
|
|
39
39
|
throw new Error(`unsupported transport: ${raw}`);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function normalizeHttpEndpoint(rawUrl, allowInsecurePrivateNetwork) {
|
|
43
|
+
const approved = allowInsecurePrivateNetwork === true;
|
|
44
|
+
const url = assertConnectionUrl(rawUrl, { allowInsecurePrivateNetwork: approved });
|
|
45
|
+
return {
|
|
46
|
+
url: url.toString(),
|
|
47
|
+
...(approved && url.protocol === 'http:' && isPrivateNetworkHost(url.hostname)
|
|
48
|
+
? { allowInsecurePrivateNetwork: true }
|
|
49
|
+
: {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
function stdioRecord(name, cfg) {
|
|
43
54
|
const command = String(cfg.command ?? '').trim();
|
|
44
55
|
if (!command) return null;
|
|
@@ -129,13 +140,14 @@ export function normalizeJsonImport(input) {
|
|
|
129
140
|
continue;
|
|
130
141
|
}
|
|
131
142
|
const { auth, headers } = extractAuth(cfg.headers ?? cfg.httpHeaders);
|
|
143
|
+
const endpoint = normalizeHttpEndpoint(cfg.url, cfg.allowInsecurePrivateNetwork);
|
|
132
144
|
records.push({
|
|
133
145
|
key: `json-${slugServerName(cfg.serverName ?? name)}`,
|
|
134
146
|
connectorId: JSON_CONNECTOR_ID,
|
|
135
147
|
kind: 'json',
|
|
136
148
|
name: cfg.name ?? name,
|
|
137
149
|
transport,
|
|
138
|
-
|
|
150
|
+
...endpoint,
|
|
139
151
|
serverName: slugServerName(cfg.serverName ?? name),
|
|
140
152
|
headers,
|
|
141
153
|
auth,
|
|
@@ -166,13 +178,14 @@ export function normalizeJsonImport(input) {
|
|
|
166
178
|
skipped.push(`${name}(缺 url)`);
|
|
167
179
|
continue;
|
|
168
180
|
}
|
|
181
|
+
const endpoint = normalizeHttpEndpoint(item.url, item.allowInsecurePrivateNetwork);
|
|
169
182
|
records.push({
|
|
170
183
|
key: `json-${slugServerName(item.serverName ?? name)}`,
|
|
171
184
|
connectorId: JSON_CONNECTOR_ID,
|
|
172
185
|
kind: 'json',
|
|
173
186
|
name,
|
|
174
187
|
transport,
|
|
175
|
-
|
|
188
|
+
...endpoint,
|
|
176
189
|
serverName: slugServerName(item.serverName ?? name),
|
|
177
190
|
headers: coerceHeaders(item.headers),
|
|
178
191
|
auth: authFromFields(item),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 通道二:自定义配置连接(表单式填 transport/url/serverName/鉴权)。
|
|
3
3
|
*/
|
|
4
4
|
import { CUSTOM_CONNECTOR_ID } from '../constants.js';
|
|
5
|
-
import {
|
|
5
|
+
import { assertConnectionUrl, assertSafeHeaderName, isPrivateNetworkHost, slugServerName } from '../util.js';
|
|
6
6
|
|
|
7
7
|
function parseHeadersJson(headersJson) {
|
|
8
8
|
if (headersJson === undefined || headersJson === null || headersJson === '') return {};
|
|
@@ -44,7 +44,7 @@ function parseEnvJson(envJson) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* @param {object} params { name, url, transport?, serverName?, authMode?, bearerToken?, apiKeyHeader?, apiKeyValue?, headersJson? }
|
|
47
|
+
* @param {object} params { name, url, transport?, serverName?, authMode?, bearerToken?, apiKeyHeader?, apiKeyValue?, headersJson?, allowInsecurePrivateNetwork? }
|
|
48
48
|
*/
|
|
49
49
|
export function buildManualRecord(params = {}) {
|
|
50
50
|
const name = String(params.name ?? '').trim();
|
|
@@ -80,7 +80,13 @@ export function buildManualRecord(params = {}) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const
|
|
83
|
+
const parsedUrl = assertConnectionUrl(params.url, {
|
|
84
|
+
allowInsecurePrivateNetwork: params.allowInsecurePrivateNetwork === true,
|
|
85
|
+
});
|
|
86
|
+
const url = parsedUrl.toString();
|
|
87
|
+
const allowInsecurePrivateNetwork = parsedUrl.protocol === 'http:'
|
|
88
|
+
&& isPrivateNetworkHost(parsedUrl.hostname)
|
|
89
|
+
&& params.allowInsecurePrivateNetwork === true;
|
|
84
90
|
const headers = parseHeadersJson(params.headersJson);
|
|
85
91
|
|
|
86
92
|
const authMode = params.authMode ?? 'none';
|
|
@@ -103,6 +109,7 @@ export function buildManualRecord(params = {}) {
|
|
|
103
109
|
name,
|
|
104
110
|
transport,
|
|
105
111
|
url,
|
|
112
|
+
...(allowInsecurePrivateNetwork ? { allowInsecurePrivateNetwork: true } : {}),
|
|
106
113
|
serverName,
|
|
107
114
|
headers,
|
|
108
115
|
auth,
|
|
@@ -15,6 +15,71 @@ import {
|
|
|
15
15
|
import { startCallbackServer } from '../callback-server.js';
|
|
16
16
|
import { openBrowser } from '../util.js';
|
|
17
17
|
|
|
18
|
+
const OAUTH_STAGE_LABELS = Object.freeze({
|
|
19
|
+
'resource-discovery': '受保护资源发现',
|
|
20
|
+
'server-metadata': 'OAuth 服务发现',
|
|
21
|
+
'callback-listener': '本机回调监听',
|
|
22
|
+
'client-registration': 'OAuth 客户端注册',
|
|
23
|
+
'authorization-callback': '浏览器授权回调',
|
|
24
|
+
'token-exchange': 'OAuth Token 换取',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function atOAuthStage(stage, action) {
|
|
28
|
+
try {
|
|
29
|
+
return await action();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error && typeof error === 'object' && !error.oauthStage) {
|
|
32
|
+
error.oauthStage = stage;
|
|
33
|
+
error.oauthStageLabel = OAUTH_STAGE_LABELS[stage] ?? stage;
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stagedError(stage, message) {
|
|
40
|
+
const error = new Error(message);
|
|
41
|
+
error.oauthStage = stage;
|
|
42
|
+
error.oauthStageLabel = OAUTH_STAGE_LABELS[stage] ?? stage;
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 把 OAuth 底层异常收敛为可操作、不泄露响应体的用户诊断。 */
|
|
47
|
+
export function describeOAuthAuthorizationError(error) {
|
|
48
|
+
const stage = error?.oauthStage ?? 'unknown';
|
|
49
|
+
const stageLabel = error?.oauthStageLabel ?? OAUTH_STAGE_LABELS[stage] ?? 'OAuth 授权';
|
|
50
|
+
const httpStatus = Number.isInteger(error?.httpStatus) ? error.httpStatus : undefined;
|
|
51
|
+
const upstreamCode = typeof error?.code === 'string' ? error.code : undefined;
|
|
52
|
+
if (stage === 'client-registration' && httpStatus === 403) {
|
|
53
|
+
return {
|
|
54
|
+
message: 'OAuth 客户端注册被服务商拒绝(HTTP 403),尚未进入浏览器授权。该服务可能只允许已审核客户端;请查看服务商的 MCP 客户端准入说明,或等待连接器完成官方登记。',
|
|
55
|
+
detail: {
|
|
56
|
+
kind: 'oauth-client-registration-rejected',
|
|
57
|
+
stage,
|
|
58
|
+
stageLabel,
|
|
59
|
+
code: 'oauth-dcr-forbidden',
|
|
60
|
+
httpStatus,
|
|
61
|
+
upstreamCode,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const safeReason = httpStatus
|
|
66
|
+
? (upstreamCode && upstreamCode !== 'http_error' ? upstreamCode : '服务端拒绝请求')
|
|
67
|
+
: String(error?.message ?? '未知错误')
|
|
68
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
69
|
+
.replace(/\s{2,}/g, ' ')
|
|
70
|
+
.slice(0, 240);
|
|
71
|
+
return {
|
|
72
|
+
message: `${stageLabel}失败${httpStatus ? `(HTTP ${httpStatus})` : ''}:${safeReason}`,
|
|
73
|
+
detail: {
|
|
74
|
+
kind: 'oauth-authorization',
|
|
75
|
+
stage,
|
|
76
|
+
stageLabel,
|
|
77
|
+
code: upstreamCode ?? 'oauth-error',
|
|
78
|
+
...(httpStatus ? { httpStatus } : {}),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
18
83
|
/**
|
|
19
84
|
* @param {object} args
|
|
20
85
|
* @param {object} args.connector 归一化后的连接器描述(auth.mode = oauth2-pkce)
|
|
@@ -24,88 +89,109 @@ import { openBrowser } from '../util.js';
|
|
|
24
89
|
*/
|
|
25
90
|
export async function oauthAuthorize({ connector, config, logger, signal }) {
|
|
26
91
|
const entryServer = connector.servers[0];
|
|
27
|
-
if (!entryServer) throw
|
|
92
|
+
if (!entryServer) throw stagedError('resource-discovery', `connector "${connector.id}" has no servers`);
|
|
28
93
|
|
|
29
|
-
const protectedResource = await
|
|
94
|
+
const protectedResource = await atOAuthStage(
|
|
95
|
+
'resource-discovery',
|
|
96
|
+
() => discoverProtectedResource(entryServer.url, config.requestTimeoutMs),
|
|
97
|
+
);
|
|
30
98
|
const issuer = protectedResource.authorizationServers[0] ?? connector.auth.issuer;
|
|
31
|
-
if (!issuer) throw
|
|
99
|
+
if (!issuer) throw stagedError('resource-discovery', `connector "${connector.id}" missing issuer`);
|
|
32
100
|
|
|
33
|
-
const metadata = await
|
|
101
|
+
const metadata = await atOAuthStage(
|
|
102
|
+
'server-metadata',
|
|
103
|
+
() => discoverServerMetadata(issuer, config.requestTimeoutMs),
|
|
104
|
+
);
|
|
34
105
|
const requestedAuthMethod = connector.auth.tokenEndpointAuthMethod ?? 'none';
|
|
35
106
|
if (metadata.tokenEndpointAuthMethodsSupported.length > 0
|
|
36
107
|
&& !metadata.tokenEndpointAuthMethodsSupported.includes(requestedAuthMethod)) {
|
|
37
|
-
throw
|
|
108
|
+
throw stagedError('server-metadata', `OAuth 服务不支持 token_endpoint_auth_method=${requestedAuthMethod}`);
|
|
38
109
|
}
|
|
39
|
-
const callback = await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
110
|
+
const callback = await atOAuthStage(
|
|
111
|
+
'callback-listener',
|
|
112
|
+
() => startCallbackServer({ path: '/callback', timeoutMs: config.callbackTimeoutMs, signal }),
|
|
113
|
+
);
|
|
114
|
+
try {
|
|
115
|
+
const registration = await atOAuthStage('client-registration', () => registerClient(metadata.registrationEndpoint, {
|
|
116
|
+
clientName: connector.auth.clientName,
|
|
117
|
+
redirectUris: [callback.url],
|
|
118
|
+
scope: connector.auth.scope,
|
|
119
|
+
tokenEndpointAuthMethod: requestedAuthMethod,
|
|
120
|
+
timeoutMs: config.requestTimeoutMs,
|
|
121
|
+
}));
|
|
47
122
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
123
|
+
const { verifier, challenge, method } = pkcePair();
|
|
124
|
+
const state = generateState();
|
|
125
|
+
const entryResource = protectedResource.resource ?? entryServer.url;
|
|
126
|
+
const authorizeUrl = buildAuthorizeUrl(metadata, {
|
|
127
|
+
clientId: registration.clientId,
|
|
128
|
+
redirectUri: callback.url,
|
|
129
|
+
state,
|
|
130
|
+
challenge,
|
|
131
|
+
challengeMethod: method,
|
|
132
|
+
resource: entryResource,
|
|
133
|
+
scope: connector.auth.scope,
|
|
134
|
+
});
|
|
60
135
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
136
|
+
logger.info(`opening authorization page: ${authorizeUrl}`);
|
|
137
|
+
if (config.openBrowser) openBrowser(authorizeUrl, logger);
|
|
138
|
+
else logger.info('openBrowser disabled — please open the URL above manually to authorize');
|
|
64
139
|
|
|
65
|
-
|
|
66
|
-
|
|
140
|
+
const { code, state: returnedState } = await atOAuthStage(
|
|
141
|
+
'authorization-callback',
|
|
142
|
+
() => callback.waitForCallback(),
|
|
143
|
+
);
|
|
144
|
+
if (returnedState !== state) {
|
|
145
|
+
const error = new Error('OAuth callback state mismatch — aborting (possible CSRF)');
|
|
146
|
+
error.oauthStage = 'authorization-callback';
|
|
147
|
+
error.oauthStageLabel = OAUTH_STAGE_LABELS['authorization-callback'];
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
67
150
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
151
|
+
const token = await atOAuthStage('token-exchange', () => exchangeCode(metadata.tokenEndpoint, {
|
|
152
|
+
clientId: registration.clientId,
|
|
153
|
+
clientSecret: registration.clientSecret,
|
|
154
|
+
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
|
|
155
|
+
code,
|
|
156
|
+
redirectUri: callback.url,
|
|
157
|
+
codeVerifier: verifier,
|
|
158
|
+
resource: entryResource,
|
|
159
|
+
scope: connector.auth.scope,
|
|
160
|
+
timeoutMs: config.requestTimeoutMs,
|
|
161
|
+
}));
|
|
79
162
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
163
|
+
// 按 token 实际授权范围过滤 server;非 JWT / 无 resource claim 时 fallback 全部
|
|
164
|
+
const grantedUrls = extractTokenResources(token.accessToken);
|
|
165
|
+
let grantedKeys;
|
|
166
|
+
let grantedResources;
|
|
167
|
+
if (grantedUrls) {
|
|
168
|
+
const granted = new Set(grantedUrls);
|
|
169
|
+
const grantedServers = connector.servers.filter((s) => granted.has(s.url));
|
|
170
|
+
grantedKeys = grantedServers.map((s) => s.serverKey);
|
|
171
|
+
grantedResources = grantedServers.map((s) => s.url);
|
|
172
|
+
if (grantedKeys.length === 0) {
|
|
173
|
+
grantedKeys = [entryServer.serverKey];
|
|
174
|
+
grantedResources = [entryServer.url];
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
grantedKeys = connector.servers.map((s) => s.serverKey);
|
|
178
|
+
grantedResources = connector.servers.map((s) => s.url);
|
|
92
179
|
}
|
|
93
|
-
} else {
|
|
94
|
-
grantedKeys = connector.servers.map((s) => s.serverKey);
|
|
95
|
-
grantedResources = connector.servers.map((s) => s.url);
|
|
96
|
-
}
|
|
97
180
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
181
|
+
return {
|
|
182
|
+
issuer,
|
|
183
|
+
clientId: registration.clientId,
|
|
184
|
+
clientSecret: registration.clientSecret,
|
|
185
|
+
clientSecretExpiresAt: registration.clientSecretExpiresAt,
|
|
186
|
+
tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
|
|
187
|
+
clientName: connector.auth.clientName,
|
|
188
|
+
scope: connector.auth.scope,
|
|
189
|
+
token,
|
|
190
|
+
grantedKeys,
|
|
191
|
+
grantedResources,
|
|
192
|
+
entryResource,
|
|
193
|
+
};
|
|
194
|
+
} finally {
|
|
195
|
+
await callback.close().catch((error) => logger?.warn(`close OAuth callback listener failed: ${error.message}`));
|
|
196
|
+
}
|
|
111
197
|
}
|
package/lib/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
import { defineConnectorDomain, ConnectionStore, GrantStore, CatalogStore, SnapshotStore, GovernanceStore, ConnectionScopeStore } from './stores.js';
|
|
27
27
|
import { normalizeConnectorDescriptor, normalizeConnectionRecord } from './schema.js';
|
|
28
28
|
import { loadBundledCatalog, fetchRemoteCatalogWithFallback, mergeCatalog, listCatalog, auditDescriptor, auditRawDescriptor, readLimitedJson } from './catalog.js';
|
|
29
|
-
import { oauthAuthorize } from './connectors/oauth-connector.js';
|
|
29
|
+
import { describeOAuthAuthorizationError, oauthAuthorize } from './connectors/oauth-connector.js';
|
|
30
30
|
import { buildManualRecord } from './connectors/manual-connector.js';
|
|
31
31
|
import { normalizeJsonImport } from './connectors/json-connector.js';
|
|
32
32
|
import { discoverServerMetadata, refreshAccessToken, revokeRefreshToken, extractTokenResources } from './oauth.js';
|
|
@@ -1876,11 +1876,12 @@ export async function apply(ctx, config) {
|
|
|
1876
1876
|
detail: { keys: created, grantKey, sharedGrant: sharingConnectors.length > 1, retiredGrantCount, snapshotId: snapshot?.key ?? null, scopeRevision, scopeRollbackRevision },
|
|
1877
1877
|
};
|
|
1878
1878
|
} catch (error) {
|
|
1879
|
-
|
|
1879
|
+
const failure = describeOAuthAuthorizationError(error);
|
|
1880
|
+
logger.error(`oauth connect failed [${failure.detail.stage}/${failure.detail.code}]: ${failure.message}`);
|
|
1880
1881
|
await pruneOrphanGrants();
|
|
1881
1882
|
return error?.connectionRecord
|
|
1882
1883
|
? connectionSetupFailure(error)
|
|
1883
|
-
: { ok: false, message: `连接失败: ${
|
|
1884
|
+
: { ok: false, message: `连接失败: ${failure.message}`, detail: failure.detail };
|
|
1884
1885
|
}
|
|
1885
1886
|
})();
|
|
1886
1887
|
state.oauthConnects.set(sharingKey, { promise, requestedConnectorIds });
|
|
@@ -2182,6 +2183,7 @@ export async function apply(ctx, config) {
|
|
|
2182
2183
|
url: record.url,
|
|
2183
2184
|
transport: record.transport,
|
|
2184
2185
|
endpoint: record.transport === 'stdio' ? `stdio · ${record.command}` : record.url,
|
|
2186
|
+
insecurePrivateNetwork: record.allowInsecurePrivateNetwork === true,
|
|
2185
2187
|
enabled: record.enabled !== false,
|
|
2186
2188
|
authMode: record.auth?.mode ?? 'none',
|
|
2187
2189
|
grant: grantInfo,
|
package/lib/schema.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* 使用 zod v4;字段默认值在 normalize 阶段补齐,schema 只做形状校验(宽松校验、显式报错)。
|
|
4
4
|
*/
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
import { assertConnectionUrl, isPrivateNetworkHost } from './util.js';
|
|
6
7
|
|
|
7
8
|
/** 连接器目录里单个 MCP server 的描述 */
|
|
8
9
|
export const serverRefSchema = z.object({
|
|
@@ -105,6 +106,7 @@ export const connectionRecordSchema = z.object({
|
|
|
105
106
|
serverKey: z.string().min(1).optional(),
|
|
106
107
|
transport: z.enum(['streamable-http', 'sse', 'stdio']),
|
|
107
108
|
url: z.string().min(1).optional(),
|
|
109
|
+
allowInsecurePrivateNetwork: z.boolean().optional(),
|
|
108
110
|
command: z.string().min(1).optional(),
|
|
109
111
|
args: z.array(z.string()).optional(),
|
|
110
112
|
env: z.record(z.string()).optional(),
|
|
@@ -318,10 +320,17 @@ export function normalizeConnectionRecord(raw) {
|
|
|
318
320
|
if (transport === 'streamable-http' && !data.url) {
|
|
319
321
|
throw new Error('invalid connection record: transport=streamable-http 时 url 必填');
|
|
320
322
|
}
|
|
323
|
+
const parsedUrl = transport === 'streamable-http'
|
|
324
|
+
? assertConnectionUrl(data.url, { allowInsecurePrivateNetwork: data.allowInsecurePrivateNetwork === true })
|
|
325
|
+
: null;
|
|
326
|
+
const allowInsecurePrivateNetwork = parsedUrl?.protocol === 'http:'
|
|
327
|
+
&& isPrivateNetworkHost(parsedUrl.hostname)
|
|
328
|
+
&& data.allowInsecurePrivateNetwork === true;
|
|
321
329
|
return {
|
|
322
330
|
...data,
|
|
323
331
|
transport,
|
|
324
|
-
url:
|
|
332
|
+
url: parsedUrl?.toString(),
|
|
333
|
+
allowInsecurePrivateNetwork: allowInsecurePrivateNetwork || undefined,
|
|
325
334
|
command: transport === 'stdio' ? data.command : undefined,
|
|
326
335
|
args: transport === 'stdio' ? (data.args ?? []) : undefined,
|
|
327
336
|
env: transport === 'stdio' ? (data.env ?? {}) : undefined,
|
package/lib/tools.js
CHANGED
|
@@ -215,7 +215,11 @@ export function registerTools(ctx, api) {
|
|
|
215
215
|
properties: {
|
|
216
216
|
connectorId: { type: 'string', description: '市场凭据型连接器 id;提供时无需 name/url' },
|
|
217
217
|
name: { type: 'string', description: '连接展示名' },
|
|
218
|
-
url: { type: 'string', description: 'MCP 端点 URL(https 或回环 http
|
|
218
|
+
url: { type: 'string', description: 'MCP 端点 URL(https 或回环 http;私有 IP 的 http 需显式授权)' },
|
|
219
|
+
allowInsecurePrivateNetwork: {
|
|
220
|
+
type: 'boolean',
|
|
221
|
+
description: '仅用于用户自建连接:显式授权 RFC1918 IPv4 / RFC4193 IPv6 ULA 字面量使用明文 HTTP;公网 HTTP 与域名仍拒绝',
|
|
222
|
+
},
|
|
219
223
|
transport: { type: 'string', enum: ['streamable-http', 'stdio', 'sse'], description: '传输方式;sse 会兼容归一为 streamable-http' },
|
|
220
224
|
command: { type: 'string', description: 'transport=stdio 时的启动命令(如 npx、uvx)' },
|
|
221
225
|
args: { type: 'array', items: { type: 'string' }, description: 'transport=stdio 时的参数列表' },
|
|
@@ -248,7 +252,8 @@ export function registerTools(ctx, api) {
|
|
|
248
252
|
name: 'mcp_connector_import_json',
|
|
249
253
|
description:
|
|
250
254
|
'粘贴 JSON 批量导入外部 MCP 连接。支持 HTTP/SSE 与 stdio(command/args/env/cwd):{ "mcpServers": { "name": {...} } } ' +
|
|
251
|
-
'或 { "connections": [ {...} ] }
|
|
255
|
+
'或 { "connections": [ {...} ] }。局域网明文 HTTP 需在对应条目显式填写 allowInsecurePrivateNetwork=true。' +
|
|
256
|
+
'整体预校验,任一非法整体拒绝。',
|
|
252
257
|
parameters: {
|
|
253
258
|
type: 'object',
|
|
254
259
|
properties: {
|
package/lib/util.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { isIP } from 'node:net';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 按平台构建打开命令(纯函数,便于单测)。
|
|
@@ -51,19 +52,65 @@ export function slugServerName(input) {
|
|
|
51
52
|
return slug;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
export function assertSafeUrl(rawUrl) {
|
|
55
|
+
function parseUrl(rawUrl) {
|
|
56
56
|
let url;
|
|
57
57
|
try {
|
|
58
58
|
url = new URL(rawUrl);
|
|
59
59
|
} catch {
|
|
60
60
|
throw new Error(`invalid url: ${rawUrl}`);
|
|
61
61
|
}
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizedHostname(hostname) {
|
|
66
|
+
return String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 只接受字面量 RFC1918 IPv4 或 RFC4193 IPv6 ULA,不解析域名,避免 DNS rebinding。 */
|
|
70
|
+
export function isPrivateNetworkHost(hostname) {
|
|
71
|
+
const host = normalizedHostname(hostname);
|
|
72
|
+
const version = isIP(host);
|
|
73
|
+
if (version === 4) {
|
|
74
|
+
const octets = host.split('.').map(Number);
|
|
75
|
+
return octets[0] === 10
|
|
76
|
+
|| (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
|
|
77
|
+
|| (octets[0] === 192 && octets[1] === 168);
|
|
78
|
+
}
|
|
79
|
+
if (version === 6) {
|
|
80
|
+
const firstHextet = Number.parseInt(host.split(':', 1)[0], 16);
|
|
81
|
+
return Number.isFinite(firstHextet) && (firstHextet & 0xfe00) === 0xfc00;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isLoopbackHost(hostname) {
|
|
87
|
+
return ['127.0.0.1', 'localhost', '::1'].includes(normalizedHostname(hostname));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** URL 协议白名单:仅 https / 回环 http。用于目录、OAuth 和其他公共边界。 */
|
|
91
|
+
export function assertSafeUrl(rawUrl) {
|
|
92
|
+
const url = parseUrl(rawUrl);
|
|
62
93
|
if (url.protocol === 'https:') return url;
|
|
63
|
-
if (url.protocol === 'http:' &&
|
|
94
|
+
if (url.protocol === 'http:' && isLoopbackHost(url.hostname)) return url;
|
|
64
95
|
throw new Error(`url protocol not allowed (only https or loopback http): ${rawUrl}`);
|
|
65
96
|
}
|
|
66
97
|
|
|
98
|
+
/**
|
|
99
|
+
* 用户自建 MCP 连接的窄范围例外:默认仍拒绝局域网 HTTP,只在用户显式确认且
|
|
100
|
+
* host 为私有 IP 字面量时放行。公网 HTTP、域名、链路本地和元数据地址仍拒绝。
|
|
101
|
+
*/
|
|
102
|
+
export function assertConnectionUrl(rawUrl, { allowInsecurePrivateNetwork = false } = {}) {
|
|
103
|
+
const url = parseUrl(rawUrl);
|
|
104
|
+
if (url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHost(url.hostname))) return url;
|
|
105
|
+
if (url.protocol === 'http:' && isPrivateNetworkHost(url.hostname)) {
|
|
106
|
+
if (allowInsecurePrivateNetwork === true) return url;
|
|
107
|
+
const error = new Error(`insecure private-network HTTP requires explicit confirmation: ${rawUrl}`);
|
|
108
|
+
error.code = 'INSECURE_PRIVATE_NETWORK_REQUIRES_CONFIRMATION';
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`url protocol not allowed (only https, loopback http, or explicitly approved private-network http): ${rawUrl}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
67
114
|
/** header 名白名单 */
|
|
68
115
|
export function assertSafeHeaderName(name) {
|
|
69
116
|
if (!/^[A-Za-z0-9_-]+$/.test(String(name))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.35",
|
|
4
4
|
"description": "DeepSeek Harness MCP Connector and MCP Server marketplace with over one hundred MCP connectors, continuously updated. Discover, authorize, and manage connections in one place; supports OAuth 2.0 PKCE, API keys, stdio/HTTP, mcpServers JSON import, and tool and prompt discovery. Maintained by Qichacha/QCC.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/ui/index.html
CHANGED
|
@@ -386,6 +386,8 @@
|
|
|
386
386
|
.field-help a:hover { text-decoration: underline; }
|
|
387
387
|
.form-note { color: var(--text-2); background: var(--accent-light); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 12px 0; font-size: 11px; line-height: 1.55; }
|
|
388
388
|
.form-note strong { display: block; color: var(--text); font-size: 12px; margin-bottom: 2px; }
|
|
389
|
+
.modal .insecure-network-consent { display: flex; align-items: flex-start; gap: 8px; color: var(--warn); line-height: 1.45; }
|
|
390
|
+
.modal .insecure-network-consent input { width: auto; margin: 2px 0 0; padding: 0; flex: 0 0 auto; }
|
|
389
391
|
.server-preview { max-height: 132px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; margin-top: 10px; }
|
|
390
392
|
.server-preview div { padding: 7px 10px; border-top: 1px solid var(--line); color: var(--text-2); font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; overflow-wrap: anywhere; }
|
|
391
393
|
.server-preview div:first-child { border-top: 0; }
|
|
@@ -694,6 +696,18 @@
|
|
|
694
696
|
setDetailOpen(false);
|
|
695
697
|
}
|
|
696
698
|
|
|
699
|
+
function isDetailOpenFor(connectorId) {
|
|
700
|
+
return detailConnector?.id === connectorId && $('#detail-overlay').classList.contains('active');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function refreshConnectionUi(connectorId) {
|
|
704
|
+
const reloadOpenDetail = isDetailOpenFor(connectorId);
|
|
705
|
+
const openDetailLoadId = detailLoadId;
|
|
706
|
+
await refresh();
|
|
707
|
+
if (!reloadOpenDetail || detailLoadId !== openDetailLoadId || !isDetailOpenFor(connectorId)) return;
|
|
708
|
+
await window.__mcp.showDetail(connectorId);
|
|
709
|
+
}
|
|
710
|
+
|
|
697
711
|
function promptVariableNames(text) {
|
|
698
712
|
const names = [];
|
|
699
713
|
const seen = new Set();
|
|
@@ -1091,13 +1105,14 @@
|
|
|
1091
1105
|
const healthClass = r.connectionState === 'healthy' ? 'pass' : ['reauth', 'recovering', 'degraded'].includes(r.connectionState) ? 'partial' : r.connectionState === 'unavailable' ? 'fail' : '';
|
|
1092
1106
|
const healthBadge = `<span class="badge ${healthClass}">${esc(healthLabels[r.connectionState] || '状态未知')}</span>`;
|
|
1093
1107
|
const scopeBadge = `<span class="badge">${esc(r.scope?.label || '全局')}</span>`;
|
|
1108
|
+
const insecureNetworkBadge = r.insecurePrivateNetwork === true ? '<span class="badge partial">局域网 HTTP 风险已确认</span>' : '';
|
|
1094
1109
|
const enabled = r.enabled !== false;
|
|
1095
1110
|
const diagnostic = r.diagnostic
|
|
1096
1111
|
? `<div class="rdiag">${esc(r.diagnostic.stageLabel || r.diagnostic.stage)} · ${esc(r.diagnostic.code)}:${esc(r.diagnostic.message)};建议:${esc(r.diagnostic.action)}${r.lastSuccessfulAt ? `;最近成功:${esc(new Date(r.lastSuccessfulAt).toLocaleString('zh-CN', { hour12: false }))}` : ''}</div>`
|
|
1097
1112
|
: '';
|
|
1098
1113
|
return `<div class="row" data-key="${key}">
|
|
1099
1114
|
<div class="info">
|
|
1100
|
-
<div class="rname">${esc(r.name)} ${authMode} ${healthBadge} ${scopeBadge}</div>
|
|
1115
|
+
<div class="rname">${esc(r.name)} ${authMode} ${healthBadge} ${scopeBadge} ${insecureNetworkBadge}</div>
|
|
1101
1116
|
<div class="rmeta">${esc(r.serverName || '')} · ${esc(r.endpoint || r.url || '')}</div>
|
|
1102
1117
|
${diagnostic}
|
|
1103
1118
|
</div>
|
|
@@ -1113,7 +1128,8 @@
|
|
|
1113
1128
|
function guidanceFor(message) {
|
|
1114
1129
|
const text = String(message || '未知错误');
|
|
1115
1130
|
const withAdvice = (advice) => `${text.replace(/[。!?.!?\s]+$/, '')}。${advice}`;
|
|
1116
|
-
if (/
|
|
1131
|
+
if (/private-network HTTP requires explicit confirmation/i.test(text)) return withAdvice('如目标是可信的局域网私有 IP,请勾选明文 HTTP 风险确认;请勿对公网或不可信服务开启。');
|
|
1132
|
+
if (/protocol not allowed|only https/i.test(text)) return withAdvice('公网地址必须使用 HTTPS;HTTP 只允许回环地址,或经显式确认的私有 IP 字面量。');
|
|
1117
1133
|
if (/JSON/i.test(text)) return withAdvice('请检查引号、逗号,并确认顶层是 mcpServers 或 connections。');
|
|
1118
1134
|
if (/401|403|token|凭据|api.?key/i.test(text)) return withAdvice('请核对鉴权方式、Token/API Key 是否有效以及 Header 名。');
|
|
1119
1135
|
if (/timeout|timed out|fetch|连接|network/i.test(text)) return withAdvice('请检查网络、端点是否可达以及 MCP URL 是否完整。');
|
|
@@ -1224,7 +1240,7 @@
|
|
|
1224
1240
|
openModal('添加 MCP 连接', `${addTabs(mode)}
|
|
1225
1241
|
<div class="field-label-row"><label for="import-json">粘贴 MCP JSON</label><button class="text-btn" type="button" onclick="window.__mcp.formatImportJson(false)">格式化 JSON</button></div>
|
|
1226
1242
|
<textarea id="import-json" rows="8" spellcheck="false" placeholder="${esc(MCP_JSON_EXAMPLE)}" onpaste="setTimeout(() => window.__mcp.formatImportJson(true), 0)"></textarea>
|
|
1227
|
-
<div class="field-help">支持 <code>mcpServers</code> 的 Streamable HTTP、SSE 与 stdio(command/args/env/cwd),配置和凭据仅保存在 DSH
|
|
1243
|
+
<div class="field-help">支持 <code>mcpServers</code> 的 Streamable HTTP、SSE 与 stdio(command/args/env/cwd),配置和凭据仅保存在 DSH 本机。私有 IP 明文 HTTP 必须在对应条目显式设置 <code>"allowInsecurePrivateNetwork": true</code>。</div>
|
|
1228
1244
|
${scopeFieldHtml()}
|
|
1229
1245
|
<div class="inline-error" id="modal-error" tabindex="-1"></div>
|
|
1230
1246
|
<div class="m-actions"><button class="btn ghost" type="button" onclick="window.__mcp.closeModal()">取消</button><button class="btn" type="button" onclick="window.__mcp.submitImport()">校验并导入</button></div>`);
|
|
@@ -1250,6 +1266,8 @@
|
|
|
1250
1266
|
<label for="cfg-transport">传输方式</label><select id="cfg-transport" onchange="window.__mcp.syncTransportFields()"><option value="streamable-http">Streamable HTTP(兼容 SSE)</option><option value="stdio">stdio(本地进程)</option></select>
|
|
1251
1267
|
<div id="cfg-http-fields">
|
|
1252
1268
|
<label for="cfg-url">MCP URL(必填)</label><input id="cfg-url" type="url" value="${esc(server.url || '')}" placeholder="https://example.com/mcp" />
|
|
1269
|
+
<label class="insecure-network-consent" for="cfg-allow-insecure-lan"><input id="cfg-allow-insecure-lan" type="checkbox" />我确认这是可信的局域网私有 IP,并接受 HTTP 传输可被同网段监听或篡改的风险。</label>
|
|
1270
|
+
<div class="field-help">仅放行 10/8、172.16/12、192.168/16 和 IPv6 ULA 字面量;域名、公网 HTTP、链路本地与云元数据地址仍会被拒绝。</div>
|
|
1253
1271
|
<label for="cfg-auth">鉴权方式</label><select id="cfg-auth" onchange="window.__mcp.syncAuthFields()"><option value="none" ${suggestedAuth === 'none' ? 'selected' : ''}>无鉴权</option><option value="bearer" ${suggestedAuth === 'bearer' ? 'selected' : ''}>Bearer Token</option><option value="api-key" ${suggestedAuth === 'api-key' ? 'selected' : ''}>API Key</option></select>
|
|
1254
1272
|
<div id="cfg-bearer"><label for="cfg-token">Bearer Token(必填)</label><input id="cfg-token" type="password" autocomplete="off" placeholder="仅保存在本机" /></div>
|
|
1255
1273
|
<div id="cfg-apikey" class="form-row"><div><label for="cfg-key-header">API Key Header</label><input id="cfg-key-header" value="${esc(preset.authMode === 'api-key' ? 'X-Api-Key' : '')}" placeholder="X-Api-Key" /></div><div><label for="cfg-key-value">API Key(必填)</label><input id="cfg-key-value" type="password" autocomplete="off" placeholder="仅保存在本机" /></div></div>
|
|
@@ -1331,7 +1349,7 @@
|
|
|
1331
1349
|
markCommunityCtaEligible();
|
|
1332
1350
|
closeModal();
|
|
1333
1351
|
await call('healthCheck', { connectorId });
|
|
1334
|
-
await
|
|
1352
|
+
await refreshConnectionUi(connectorId);
|
|
1335
1353
|
}
|
|
1336
1354
|
},
|
|
1337
1355
|
confirmConnect() {
|
|
@@ -1478,11 +1496,9 @@
|
|
|
1478
1496
|
});
|
|
1479
1497
|
setModalBusy(false);
|
|
1480
1498
|
if (!r.ok) { showModalError(r.message); return; }
|
|
1481
|
-
const configuredFromDetail = detailConnector?.id === connectorId && $('#detail-overlay').classList.contains('active');
|
|
1482
1499
|
closeModal();
|
|
1483
|
-
if (configuredFromDetail) closeDetail();
|
|
1484
1500
|
markCommunityCtaEligible();
|
|
1485
|
-
showToast(r.message, true); await
|
|
1501
|
+
showToast(r.message, true); await refreshConnectionUi(connectorId);
|
|
1486
1502
|
return;
|
|
1487
1503
|
}
|
|
1488
1504
|
const name = $('#cfg-name').value.trim();
|
|
@@ -1509,6 +1525,7 @@
|
|
|
1509
1525
|
name, url: url || undefined, serverName, headersJson,
|
|
1510
1526
|
transport, command: command || undefined, args, envJson, cwd: $('#cfg-cwd')?.value.trim() || undefined,
|
|
1511
1527
|
authMode: transport === 'stdio' ? 'none' : authMode,
|
|
1528
|
+
allowInsecurePrivateNetwork: transport !== 'stdio' && $('#cfg-allow-insecure-lan')?.checked === true,
|
|
1512
1529
|
bearerToken: $('#cfg-token')?.value || undefined,
|
|
1513
1530
|
apiKeyHeader: $('#cfg-key-header')?.value || undefined,
|
|
1514
1531
|
apiKeyValue: $('#cfg-key-value')?.value || undefined,
|
|
@@ -1742,8 +1759,7 @@
|
|
|
1742
1759
|
if (r.ok) {
|
|
1743
1760
|
markCommunityCtaEligible();
|
|
1744
1761
|
await call('healthCheck', { connectorId: id });
|
|
1745
|
-
|
|
1746
|
-
await refresh();
|
|
1762
|
+
await refreshConnectionUi(id);
|
|
1747
1763
|
}
|
|
1748
1764
|
return;
|
|
1749
1765
|
}
|