dsh-mcp-connector 0.2.47 → 0.2.48
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 +13 -1
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/docs/CLI-PROVIDERS.md +56 -0
- package/docs/STDIO-SUPPORT.md +4 -4
- package/lib/cli-providers.js +436 -0
- package/package.json +6 -3
- package/scripts/cli-bridge.mjs +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.48] - 2026-09-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 新增受控 CLI→MCP Provider 框架,首批把钉钉官方 `dws` 的通讯录、日程、待办、文档、OA 待审批和日志查询映射为 11 个只读 MCP 工具。
|
|
12
|
+
- 连接时通过官方 `dws auth status` 验证 OAuth 就绪状态,未登录、无权限或 Token 无法刷新时不误报“已连接”。
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- CLI 只能执行代码审核的命令与参数,不经 Shell;写入动词、未声明参数和控制字符均拒绝。子进程超时、输出大小受限,成功及失败输出中的 Token/Secret 都在返回 Host 前脱敏。
|
|
17
|
+
|
|
7
18
|
## [0.2.47] - 2026-09-14
|
|
8
19
|
|
|
9
20
|
### Changed
|
|
@@ -717,7 +728,8 @@
|
|
|
717
728
|
- 外部 URL 与导入 Header 执行安全校验。
|
|
718
729
|
- iframe 消息校验同源和消息来源。
|
|
719
730
|
|
|
720
|
-
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.
|
|
731
|
+
[Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.48...HEAD
|
|
732
|
+
[0.2.48]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.47...v0.2.48
|
|
721
733
|
[0.2.47]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.46...v0.2.47
|
|
722
734
|
[0.2.46]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.45...v0.2.46
|
|
723
735
|
[0.2.45]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.44...v0.2.45
|
package/README.en.md
CHANGED
|
@@ -190,7 +190,7 @@ npm run dev:ui
|
|
|
190
190
|
|
|
191
191
|
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.
|
|
192
192
|
|
|
193
|
-
The current public version is [`dsh-mcp-connector@0.2.
|
|
193
|
+
The current public version is [`dsh-mcp-connector@0.2.48`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.48](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.48).
|
|
194
194
|
|
|
195
195
|
See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
|
|
196
196
|
|
|
@@ -202,6 +202,7 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](d
|
|
|
202
202
|
- 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.
|
|
203
203
|
- 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.
|
|
204
204
|
- 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.
|
|
205
|
+
- Official CLIs that are not MCP servers can be exposed through a separate [controlled CLI Provider](docs/CLI-PROVIDERS.md). Only code-reviewed commands and arguments are available; the initial DingTalk `dws` provider is read-only.
|
|
205
206
|
- 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.
|
|
206
207
|
- OAuth DCR client secrets share the same local-only boundary as access and refresh tokens and are omitted from catalog/status responses and logs.
|
|
207
208
|
- 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.
|
package/README.md
CHANGED
|
@@ -185,7 +185,7 @@ npm run dev:ui
|
|
|
185
185
|
|
|
186
186
|
公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
|
|
187
187
|
|
|
188
|
-
当前公开版本为 [`dsh-mcp-connector@0.2.
|
|
188
|
+
当前公开版本为 [`dsh-mcp-connector@0.2.48`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.48](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.48)。
|
|
189
189
|
|
|
190
190
|
版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
191
191
|
Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
|
|
@@ -201,6 +201,7 @@ stdio 传输的架构、透传边界与安全约束见 [docs/STDIO-SUPPORT.md](d
|
|
|
201
201
|
- 外部 URL 默认仅允许 HTTPS,HTTP 默认仅允许回环地址。用户自建连接可在明确风险确认后放行 RFC1918 IPv4 / RFC4193 IPv6 ULA 字面量;域名、公网 HTTP、链路本地与云元数据地址仍拒绝。
|
|
202
202
|
- 远程目录/描述响应限制 2 MiB,Web API 请求限制 1 MiB;原始 JSON 在归一化前扫描凭据字段。
|
|
203
203
|
- 完整覆盖 Streamable HTTP 与 stdio;旧 `sse` 配置在导入/恢复时归一为 Streamable HTTP。stdio 的 `command/args/env/cwd` 原样交给 `@deepseek-ai/dsh-mcp-client`,插件本身不重复实现进程传输。
|
|
204
|
+
- 对本身不是 MCP Server 的官方 CLI,使用独立的[受控 CLI Provider](docs/CLI-PROVIDERS.md)桥接为 stdio MCP;只允许代码登记的命令与参数,首个钉钉 `dws` Provider 仅开放只读能力。
|
|
204
205
|
- stdio 会启动本机进程:仅导入或连接可信命令/软件包。市场目录只能用 `credentialFields` + `credentialBindings` 声明输入与 env 映射,不得携带真实 token/secret;用户填写值只写入本机连接记录并交给 Host。
|
|
205
206
|
- OAuth DCR 的 `client_secret` 与 Access/Refresh Token 采用相同的本机存储边界,不会进入市场 API、状态输出或日志。
|
|
206
207
|
- OAuth 连接失败会标明资源发现、服务发现、客户端注册、授权回调或 Token 换取阶段;DCR HTTP 403 表示服务商拒绝未准入客户端,不是用户未点击授权。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# 受控 CLI Provider
|
|
2
|
+
|
|
3
|
+
MCP 连接器原生支持 Streamable HTTP 和 stdio MCP。对于钉钉 `dws` 这类面向人类与 Agent、但本身不是 MCP Server 的官方 CLI,插件通过独立的 CLI→MCP 桥接进程暴露经过审核的结构化工具。
|
|
4
|
+
|
|
5
|
+
## 安全边界
|
|
6
|
+
|
|
7
|
+
- Provider 在代码中显式登记,市场卡片和工具参数均不能指定任意可执行文件。
|
|
8
|
+
- 子进程通过 `spawn(command, args, { shell: false })` 启动,不解析 Shell 表达式。
|
|
9
|
+
- 首个 `dingtalk-dws` Provider 仅登记只读命令;创建、发送、审批、拒绝、删除、更新和上传均不暴露。
|
|
10
|
+
- 每个工具只接受 JSON Schema 声明过的字段,参数作为独立 argv 传递。
|
|
11
|
+
- stdout/stderr 均受大小限制,执行有超时;错误中的 Token 和 Secret 在返回 Host 前脱敏。
|
|
12
|
+
- MCP 客户端枚举工具前执行官方 `dws auth status` 预检;未登录、权限不足或 Token 无法刷新时连接直接失败,不会误显示“已连接”。
|
|
13
|
+
- 登录、Token 刷新、企业管理员授权、租户隔离和 API 审计由官方 CLI 与开放平台负责。
|
|
14
|
+
|
|
15
|
+
## 钉钉前置条件
|
|
16
|
+
|
|
17
|
+
1. 市场卡片固定使用已审核的 `dingtalk-workspace-cli@1.0.61`,无需全局安装 `dws`。
|
|
18
|
+
2. 首次连接前,在普通终端执行 `npx -y dingtalk-workspace-cli@1.0.61 auth login`;无浏览器环境加 `--device`。
|
|
19
|
+
3. 企业管理员需在钉钉开发者平台开启 CLI 访问。
|
|
20
|
+
4. 多组织用户可在工具参数里传稳定的 `corpId:userId` profile;不传时使用当前 profile。
|
|
21
|
+
|
|
22
|
+
授权信息保存在钉钉官方 CLI 的用户配置和系统 Keychain 中,不写入 Connector 配置。后续升级 `dws` 需先复核官方命令、权限和输出 Schema,然后通过 Registry PR 更换固定版本。
|
|
23
|
+
|
|
24
|
+
启动 MCP 桥接:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"dingtalk-workspace": {
|
|
30
|
+
"command": "npx",
|
|
31
|
+
"args": [
|
|
32
|
+
"-y",
|
|
33
|
+
"--legacy-peer-deps",
|
|
34
|
+
"--package",
|
|
35
|
+
"dsh-mcp-connector@0.2.48",
|
|
36
|
+
"--package",
|
|
37
|
+
"dingtalk-workspace-cli@1.0.61",
|
|
38
|
+
"dsh-mcp-cli-bridge",
|
|
39
|
+
"--provider",
|
|
40
|
+
"dingtalk-dws"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 首批只读能力
|
|
48
|
+
|
|
49
|
+
- 当前用户、通讯录搜索与用户详情
|
|
50
|
+
- 日程列表与日程详情
|
|
51
|
+
- 待办列表与待办详情
|
|
52
|
+
- 文档搜索
|
|
53
|
+
- 待处理 OA 审批列表
|
|
54
|
+
- 收到的日志和日志模板
|
|
55
|
+
|
|
56
|
+
后续增加 Provider 或命令时,必须同时提供:官方来源、固定命令路径、输入 Schema、风险级别、只读/写入分类、脱敏策略和单元测试。写入命令不得混入只读 Provider。
|
package/docs/STDIO-SUPPORT.md
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
## 一、背景与目标
|
|
14
14
|
|
|
15
|
-
### 1.1
|
|
15
|
+
### 1.1 实施前的现状缺口(历史记录)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
以下内容记录实施前的缺口;当前版本已经支持 stdio。实施前 `dsh-mcp-connector` 的传输方式(`transport`)仅支持:
|
|
18
18
|
|
|
19
19
|
- `streamable-http`
|
|
20
20
|
- `sse`
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
| 传输方式 | WorkBuddy | TraeWork | Qwen Code | 我们 |
|
|
25
25
|
|---------|:--------:|:--------:|:--------:|:----:|
|
|
26
|
-
| stdio(本地进程) | ✅ | ✅ | ✅ |
|
|
26
|
+
| stdio(本地进程) | ✅ | ✅ | ✅ | ✅(当前已完成) |
|
|
27
27
|
| HTTP / streamable-http | ❓ | ✅ | ✅ | ✅ |
|
|
28
28
|
| SSE | ❓ | ❓ | ✅ | ✅ |
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
**历史影响**:缺失 stdio 时无法接入大量社区 stdio 型 MCP server(GitHub、filesystem、playwright、memory 等 npm/pip 包);该缺口现已关闭。
|
|
31
31
|
|
|
32
32
|
### 1.2 补齐目标
|
|
33
33
|
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
const WRITE_VERBS = new Set([
|
|
6
|
+
'add', 'approve', 'create', 'delete', 'disable', 'done', 'enable', 'insert',
|
|
7
|
+
'invite', 'move', 'recall', 'reject', 'remove', 'rename', 'reply', 'revoke',
|
|
8
|
+
'send', 'switch', 'update', 'upload',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function stringField(flag, options = {}) {
|
|
12
|
+
return { flag, type: 'string', ...options };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function integerField(flag, options = {}) {
|
|
16
|
+
return { flag, type: 'integer', ...options };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function booleanField(flag, options = {}) {
|
|
20
|
+
return { flag, type: 'boolean', ...options };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stringArrayField(flag, options = {}) {
|
|
24
|
+
return { flag, type: 'string-array', ...options };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const profileField = stringField('--profile', {
|
|
28
|
+
global: true,
|
|
29
|
+
description: '可选的钉钉账号 profile(建议使用 corpId:userId)。不填写时使用 dws 当前 profile。',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function readTool(name, description, command, fields = {}) {
|
|
33
|
+
return { name, description, command, fields, readOnly: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dingtalkDwsTools = [
|
|
37
|
+
readTool(
|
|
38
|
+
'dingtalk_get_current_user',
|
|
39
|
+
'读取当前钉钉 OAuth 身份的用户、组织和部门信息。',
|
|
40
|
+
['contact', 'user', 'get-self'],
|
|
41
|
+
{ profile: profileField },
|
|
42
|
+
),
|
|
43
|
+
readTool(
|
|
44
|
+
'dingtalk_search_users',
|
|
45
|
+
'按关键词只读搜索钉钉企业通讯录。多人同名时应展示候选,不得自动选择。',
|
|
46
|
+
['contact', 'user', 'search'],
|
|
47
|
+
{
|
|
48
|
+
profile: profileField,
|
|
49
|
+
query: stringField('--query', { required: true, description: '姓名、职位或通讯录关键词。' }),
|
|
50
|
+
},
|
|
51
|
+
),
|
|
52
|
+
readTool(
|
|
53
|
+
'dingtalk_get_users',
|
|
54
|
+
'按一个或多个 userId 批量读取钉钉用户资料。',
|
|
55
|
+
['contact', 'user', 'get'],
|
|
56
|
+
{
|
|
57
|
+
profile: profileField,
|
|
58
|
+
ids: stringArrayField('--ids', { required: true, description: '钉钉 userId 列表。' }),
|
|
59
|
+
},
|
|
60
|
+
),
|
|
61
|
+
readTool(
|
|
62
|
+
'dingtalk_list_calendar_events',
|
|
63
|
+
'只读列出当前用户在指定时间范围内的钉钉日程;不传时间时由 dws 使用默认范围。',
|
|
64
|
+
['calendar', 'event', 'list'],
|
|
65
|
+
{
|
|
66
|
+
profile: profileField,
|
|
67
|
+
start: stringField('--start', { description: 'ISO 8601 开始时间。' }),
|
|
68
|
+
end: stringField('--end', { description: 'ISO 8601 结束时间。' }),
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
readTool(
|
|
72
|
+
'dingtalk_get_calendar_event',
|
|
73
|
+
'按日程 ID 只读获取钉钉日程详情。',
|
|
74
|
+
['calendar', 'event', 'get'],
|
|
75
|
+
{
|
|
76
|
+
profile: profileField,
|
|
77
|
+
id: stringField('--id', { required: true, description: '日程 ID。' }),
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
readTool(
|
|
81
|
+
'dingtalk_list_todos',
|
|
82
|
+
'只读列出当前用户的钉钉待办。',
|
|
83
|
+
['todo', 'task', 'list'],
|
|
84
|
+
{
|
|
85
|
+
profile: profileField,
|
|
86
|
+
page: integerField('--page', { minimum: 1, maximum: 10000, description: '页码。' }),
|
|
87
|
+
size: integerField('--size', { minimum: 1, maximum: 100, description: '每页数量。' }),
|
|
88
|
+
completed: booleanField('--status', { description: 'true 仅已完成,false 仅未完成;不传则不过滤。' }),
|
|
89
|
+
},
|
|
90
|
+
),
|
|
91
|
+
readTool(
|
|
92
|
+
'dingtalk_get_todo',
|
|
93
|
+
'按待办 ID 只读获取钉钉待办详情。',
|
|
94
|
+
['todo', 'task', 'get'],
|
|
95
|
+
{
|
|
96
|
+
profile: profileField,
|
|
97
|
+
taskId: stringField('--task-id', { required: true, description: '待办任务 ID。' }),
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
readTool(
|
|
101
|
+
'dingtalk_search_documents',
|
|
102
|
+
'只读搜索当前用户有权访问的钉钉文件和文档。',
|
|
103
|
+
['drive', 'search'],
|
|
104
|
+
{
|
|
105
|
+
profile: profileField,
|
|
106
|
+
query: stringField('--query', { required: true, description: '文件或文档标题、内容关键词。' }),
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
readTool(
|
|
110
|
+
'dingtalk_list_pending_approvals',
|
|
111
|
+
'只读列出指定时间范围内当前用户待处理的钉钉 OA 审批实例;不会执行同意或拒绝。',
|
|
112
|
+
['oa', 'approval', 'list-pending'],
|
|
113
|
+
{
|
|
114
|
+
profile: profileField,
|
|
115
|
+
start: stringField('--start', { required: true, description: 'ISO 8601 开始时间。' }),
|
|
116
|
+
end: stringField('--end', { required: true, description: 'ISO 8601 结束时间。' }),
|
|
117
|
+
page: integerField('--page', { minimum: 1, maximum: 10000, description: '页码。' }),
|
|
118
|
+
limit: integerField('--limit', { minimum: 1, maximum: 100, description: '每页数量。' }),
|
|
119
|
+
},
|
|
120
|
+
),
|
|
121
|
+
readTool(
|
|
122
|
+
'dingtalk_list_reports',
|
|
123
|
+
'只读列出指定时间范围内当前用户收到的钉钉日志。',
|
|
124
|
+
['report', 'inbox', 'list'],
|
|
125
|
+
{
|
|
126
|
+
profile: profileField,
|
|
127
|
+
start: stringField('--start', { required: true, description: 'ISO 8601 开始时间。' }),
|
|
128
|
+
end: stringField('--end', { required: true, description: 'ISO 8601 结束时间。' }),
|
|
129
|
+
cursor: integerField('--cursor', { minimum: 0, description: '分页游标。' }),
|
|
130
|
+
size: integerField('--size', { minimum: 1, maximum: 20, description: '返回数量。' }),
|
|
131
|
+
},
|
|
132
|
+
),
|
|
133
|
+
readTool(
|
|
134
|
+
'dingtalk_list_report_templates',
|
|
135
|
+
'只读列出当前用户可用的钉钉日志模板。',
|
|
136
|
+
['report', 'template', 'list'],
|
|
137
|
+
{ profile: profileField },
|
|
138
|
+
),
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
export const CLI_PROVIDERS = Object.freeze({
|
|
142
|
+
'dingtalk-dws': Object.freeze({
|
|
143
|
+
id: 'dingtalk-dws',
|
|
144
|
+
name: '钉钉工作台 CLI',
|
|
145
|
+
executable: 'dws',
|
|
146
|
+
homepage: 'https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli',
|
|
147
|
+
preflight: Object.freeze(['auth', 'status']),
|
|
148
|
+
tools: Object.freeze(dingtalkDwsTools),
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function inputSchema(tool) {
|
|
153
|
+
const properties = {};
|
|
154
|
+
const required = [];
|
|
155
|
+
for (const [name, field] of Object.entries(tool.fields)) {
|
|
156
|
+
const schema = { description: field.description };
|
|
157
|
+
if (field.type === 'integer') {
|
|
158
|
+
schema.type = 'integer';
|
|
159
|
+
if (field.minimum !== undefined) schema.minimum = field.minimum;
|
|
160
|
+
if (field.maximum !== undefined) schema.maximum = field.maximum;
|
|
161
|
+
} else if (field.type === 'boolean') {
|
|
162
|
+
schema.type = 'boolean';
|
|
163
|
+
} else if (field.type === 'string-array') {
|
|
164
|
+
schema.type = 'array';
|
|
165
|
+
schema.items = { type: 'string', minLength: 1 };
|
|
166
|
+
schema.minItems = 1;
|
|
167
|
+
schema.maxItems = 100;
|
|
168
|
+
} else {
|
|
169
|
+
schema.type = 'string';
|
|
170
|
+
schema.minLength = 1;
|
|
171
|
+
}
|
|
172
|
+
properties[name] = schema;
|
|
173
|
+
if (field.required) required.push(name);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties,
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
...(required.length ? { required } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function listCliProviderTools(providerId) {
|
|
184
|
+
const provider = CLI_PROVIDERS[providerId];
|
|
185
|
+
if (!provider) throw new Error(`unknown CLI provider: ${providerId}`);
|
|
186
|
+
return provider.tools.map((tool) => ({
|
|
187
|
+
name: tool.name,
|
|
188
|
+
description: tool.description,
|
|
189
|
+
inputSchema: inputSchema(tool),
|
|
190
|
+
annotations: {
|
|
191
|
+
readOnlyHint: true,
|
|
192
|
+
destructiveHint: false,
|
|
193
|
+
idempotentHint: true,
|
|
194
|
+
openWorldHint: true,
|
|
195
|
+
},
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assertSafeExecutable(executable) {
|
|
200
|
+
const value = String(executable ?? '').trim();
|
|
201
|
+
if (!value || /[\0\r\n]/.test(value) || /\s/.test(value)) {
|
|
202
|
+
throw new Error('CLI executable must be one command name or an absolute path without whitespace');
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function validateField(name, value, field) {
|
|
208
|
+
if (value === undefined || value === null || value === '') {
|
|
209
|
+
if (field.required) throw new Error(`missing required argument: ${name}`);
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
if (field.type === 'string') {
|
|
213
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be a non-empty string`);
|
|
214
|
+
if (/[\0\r\n]/.test(value)) throw new Error(`${name} contains forbidden control characters`);
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
if (field.type === 'integer') {
|
|
218
|
+
if (!Number.isInteger(value)) throw new Error(`${name} must be an integer`);
|
|
219
|
+
if (field.minimum !== undefined && value < field.minimum) throw new Error(`${name} must be >= ${field.minimum}`);
|
|
220
|
+
if (field.maximum !== undefined && value > field.maximum) throw new Error(`${name} must be <= ${field.maximum}`);
|
|
221
|
+
return String(value);
|
|
222
|
+
}
|
|
223
|
+
if (field.type === 'boolean') {
|
|
224
|
+
if (typeof value !== 'boolean') throw new Error(`${name} must be a boolean`);
|
|
225
|
+
return String(value);
|
|
226
|
+
}
|
|
227
|
+
if (field.type === 'string-array') {
|
|
228
|
+
if (!Array.isArray(value) || !value.length || value.length > 100) {
|
|
229
|
+
throw new Error(`${name} must be an array containing 1-100 strings`);
|
|
230
|
+
}
|
|
231
|
+
const values = value.map((item) => {
|
|
232
|
+
if (typeof item !== 'string' || !item.trim() || /[\0\r\n,]/.test(item)) {
|
|
233
|
+
throw new Error(`${name} contains an invalid item`);
|
|
234
|
+
}
|
|
235
|
+
return item;
|
|
236
|
+
});
|
|
237
|
+
return values.join(',');
|
|
238
|
+
}
|
|
239
|
+
throw new Error(`unsupported field type for ${name}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function buildCliInvocation(providerId, toolName, input = {}, options = {}) {
|
|
243
|
+
const provider = CLI_PROVIDERS[providerId];
|
|
244
|
+
if (!provider) throw new Error(`unknown CLI provider: ${providerId}`);
|
|
245
|
+
const tool = provider.tools.find((candidate) => candidate.name === toolName);
|
|
246
|
+
if (!tool) throw new Error(`unknown tool for ${providerId}: ${toolName}`);
|
|
247
|
+
if (!tool.readOnly || tool.command.some((part) => WRITE_VERBS.has(part))) {
|
|
248
|
+
throw new Error(`CLI provider tool is not approved for read-only execution: ${toolName}`);
|
|
249
|
+
}
|
|
250
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
251
|
+
throw new Error('tool arguments must be an object');
|
|
252
|
+
}
|
|
253
|
+
const unknown = Object.keys(input).filter((key) => !Object.hasOwn(tool.fields, key));
|
|
254
|
+
if (unknown.length) throw new Error(`unknown tool arguments: ${unknown.join(', ')}`);
|
|
255
|
+
|
|
256
|
+
const globalArgs = [];
|
|
257
|
+
const commandArgs = [];
|
|
258
|
+
for (const [name, field] of Object.entries(tool.fields)) {
|
|
259
|
+
const value = validateField(name, input[name], field);
|
|
260
|
+
if (value === undefined) continue;
|
|
261
|
+
const target = field.global ? globalArgs : commandArgs;
|
|
262
|
+
target.push(field.flag, value);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
command: assertSafeExecutable(options.executable ?? process.env.DSH_MCP_DINGTALK_DWS_BIN ?? provider.executable),
|
|
266
|
+
args: [...globalArgs, ...tool.command, ...commandArgs, '--format', 'json'],
|
|
267
|
+
provider,
|
|
268
|
+
tool,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function buildCliPreflightInvocation(providerId, options = {}) {
|
|
273
|
+
const provider = CLI_PROVIDERS[providerId];
|
|
274
|
+
if (!provider) throw new Error(`unknown CLI provider: ${providerId}`);
|
|
275
|
+
if (!Array.isArray(provider.preflight) || !provider.preflight.length) return null;
|
|
276
|
+
if (provider.preflight.some((part) => WRITE_VERBS.has(part))) {
|
|
277
|
+
throw new Error(`CLI provider preflight is not approved for read-only execution: ${providerId}`);
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
command: assertSafeExecutable(options.executable ?? process.env.DSH_MCP_DINGTALK_DWS_BIN ?? provider.executable),
|
|
281
|
+
args: [...provider.preflight, '--format', 'json'],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function redactCliError(value) {
|
|
286
|
+
return String(value ?? '')
|
|
287
|
+
.replace(/(authorization\s*[:=]\s*bearer\s+)[^\s"']+/gi, '$1[REDACTED]')
|
|
288
|
+
.replace(/(["']?(?:(?:access|refresh)[_-]?token|client[_-]?secret|app[_-]?secret)["']?\s*[=:]\s*["']?)[^\s,"'}]+/gi, '$1[REDACTED]')
|
|
289
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]')
|
|
290
|
+
.trim();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function runCliProcess(command, args, options = {}) {
|
|
294
|
+
const timeoutMs = Number.isInteger(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
295
|
+
const maxOutputBytes = Number.isInteger(options.maxOutputBytes) ? options.maxOutputBytes : MAX_OUTPUT_BYTES;
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const child = spawn(command, args, {
|
|
298
|
+
shell: false,
|
|
299
|
+
windowsHide: true,
|
|
300
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
301
|
+
env: options.env ?? process.env,
|
|
302
|
+
});
|
|
303
|
+
const stdout = [];
|
|
304
|
+
const stderr = [];
|
|
305
|
+
let stdoutBytes = 0;
|
|
306
|
+
let stderrBytes = 0;
|
|
307
|
+
let settled = false;
|
|
308
|
+
const fail = (error) => {
|
|
309
|
+
if (settled) return;
|
|
310
|
+
settled = true;
|
|
311
|
+
clearTimeout(timer);
|
|
312
|
+
reject(error);
|
|
313
|
+
};
|
|
314
|
+
const timer = setTimeout(() => {
|
|
315
|
+
child.kill('SIGTERM');
|
|
316
|
+
fail(new Error(`CLI command timed out after ${timeoutMs}ms`));
|
|
317
|
+
}, Math.max(1000, Math.min(timeoutMs, 300_000)));
|
|
318
|
+
|
|
319
|
+
child.once('error', (error) => {
|
|
320
|
+
if (error?.code === 'ENOENT') {
|
|
321
|
+
fail(new Error(`未找到官方 CLI 命令“${command}”;请先完成安装和 OAuth 登录,再重启 DSH`));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
fail(error);
|
|
325
|
+
});
|
|
326
|
+
child.stdout.on('data', (chunk) => {
|
|
327
|
+
stdoutBytes += chunk.length;
|
|
328
|
+
if (stdoutBytes > maxOutputBytes) {
|
|
329
|
+
child.kill('SIGTERM');
|
|
330
|
+
fail(new Error(`CLI stdout exceeded ${maxOutputBytes} bytes`));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
stdout.push(chunk);
|
|
334
|
+
});
|
|
335
|
+
child.stderr.on('data', (chunk) => {
|
|
336
|
+
stderrBytes += chunk.length;
|
|
337
|
+
if (stderrBytes <= maxOutputBytes) stderr.push(chunk);
|
|
338
|
+
});
|
|
339
|
+
child.once('close', (code, signal) => {
|
|
340
|
+
if (settled) return;
|
|
341
|
+
settled = true;
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
const output = Buffer.concat(stdout).toString('utf8').trim();
|
|
344
|
+
const errorOutput = redactCliError(Buffer.concat(stderr).toString('utf8'));
|
|
345
|
+
if (code !== 0) {
|
|
346
|
+
const hint = {
|
|
347
|
+
2: '身份认证或授权失败;请运行 dws auth login,并确认企业管理员已开启 CLI 访问',
|
|
348
|
+
3: '参数未通过当前固定 dws 版本校验;请核对工具参数或升级 MCP 连接器目录',
|
|
349
|
+
4: '钉钉要求补充权限授权;请按官方授权提示完成后重试',
|
|
350
|
+
6: 'dws 命令或服务发现失败;请检查网络、登录状态与 MCP 连接器目录版本',
|
|
351
|
+
}[code] ?? '';
|
|
352
|
+
const details = [hint, errorOutput, signal ? `signal=${signal}` : ''].filter(Boolean).join(';');
|
|
353
|
+
reject(new Error(`CLI command failed with exit code ${code}${details ? `: ${details}` : ''}`));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
resolve(output);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function executeCliProviderTool(providerId, toolName, input = {}, options = {}) {
|
|
362
|
+
const invocation = buildCliInvocation(providerId, toolName, input, options);
|
|
363
|
+
const runner = options.runner ?? runCliProcess;
|
|
364
|
+
const output = await runner(invocation.command, invocation.args, options);
|
|
365
|
+
let normalized = redactCliError(output || '{"ok":true}');
|
|
366
|
+
try {
|
|
367
|
+
normalized = JSON.stringify(JSON.parse(normalized), null, 2);
|
|
368
|
+
} catch {
|
|
369
|
+
// Keep non-JSON success output readable; dws normally emits JSON with --format json.
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: 'text', text: normalized }],
|
|
373
|
+
isError: false,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function ensureCliProviderReady(providerId, options = {}) {
|
|
378
|
+
const invocation = buildCliPreflightInvocation(providerId, options);
|
|
379
|
+
if (!invocation) return;
|
|
380
|
+
const runner = options.runner ?? runCliProcess;
|
|
381
|
+
await runner(invocation.command, invocation.args, options);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function handleCliBridgeRequest(message, options = {}) {
|
|
385
|
+
const providerId = options.providerId ?? 'dingtalk-dws';
|
|
386
|
+
if (!message || typeof message !== 'object') throw new Error('invalid JSON-RPC message');
|
|
387
|
+
if (!Object.hasOwn(message, 'id')) return null;
|
|
388
|
+
const base = { jsonrpc: '2.0', id: message.id };
|
|
389
|
+
try {
|
|
390
|
+
if (message.method === 'initialize') {
|
|
391
|
+
return {
|
|
392
|
+
...base,
|
|
393
|
+
result: {
|
|
394
|
+
protocolVersion: message.params?.protocolVersion ?? '2025-06-18',
|
|
395
|
+
capabilities: { tools: { listChanged: false } },
|
|
396
|
+
serverInfo: { name: `dsh-mcp-cli-bridge/${providerId}`, version: '1.0.0' },
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (message.method === 'ping') return { ...base, result: {} };
|
|
401
|
+
if (message.method === 'tools/list') {
|
|
402
|
+
try {
|
|
403
|
+
const preflight = options.preflight ?? ensureCliProviderReady;
|
|
404
|
+
await preflight(providerId, options);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return {
|
|
407
|
+
...base,
|
|
408
|
+
error: {
|
|
409
|
+
code: -32001,
|
|
410
|
+
message: `CLI Provider 未就绪:${redactCliError(error?.message ?? error)}`,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return { ...base, result: { tools: listCliProviderTools(providerId) } };
|
|
415
|
+
}
|
|
416
|
+
if (message.method === 'tools/call') {
|
|
417
|
+
const name = message.params?.name;
|
|
418
|
+
const args = message.params?.arguments ?? {};
|
|
419
|
+
try {
|
|
420
|
+
const execute = options.execute ?? executeCliProviderTool;
|
|
421
|
+
return { ...base, result: await execute(providerId, name, args, options) };
|
|
422
|
+
} catch (error) {
|
|
423
|
+
return {
|
|
424
|
+
...base,
|
|
425
|
+
result: {
|
|
426
|
+
content: [{ type: 'text', text: redactCliError(error?.message ?? error) }],
|
|
427
|
+
isError: true,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return { ...base, error: { code: -32601, message: `Method not found: ${message.method}` } };
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return { ...base, error: { code: -32603, message: redactCliError(error?.message ?? error) } };
|
|
435
|
+
}
|
|
436
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.48",
|
|
4
4
|
"description": "DeepSeek Harness MCP Connector: connect servers, search tools across active connections, and troubleshoot discovery. Includes a continuously updated catalog; supports OAuth 2.0 PKCE, API keys, Streamable HTTP/stdio, and mcpServers JSON import.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
12
|
"bin": {
|
|
13
|
-
"dsh-mcp-connector-probe": "scripts/probe-connector.mjs"
|
|
13
|
+
"dsh-mcp-connector-probe": "scripts/probe-connector.mjs",
|
|
14
|
+
"dsh-mcp-cli-bridge": "scripts/cli-bridge.mjs"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"lib",
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"docs/TOOL-GOVERNANCE.md",
|
|
26
27
|
"docs/FIRST-CONTRIBUTION.md",
|
|
27
28
|
"docs/STDIO-SUPPORT.md",
|
|
29
|
+
"docs/CLI-PROVIDERS.md",
|
|
28
30
|
"docs/USER-GUIDE.md",
|
|
29
31
|
"docs/screenshots",
|
|
30
32
|
"cordis.patch.yml",
|
|
@@ -36,12 +38,13 @@
|
|
|
36
38
|
"LICENSE",
|
|
37
39
|
"install.sh",
|
|
38
40
|
"scripts/probe-connector.mjs",
|
|
41
|
+
"scripts/cli-bridge.mjs",
|
|
39
42
|
"scripts/build-registry.mjs",
|
|
40
43
|
"scripts/ui-harness.mjs"
|
|
41
44
|
],
|
|
42
45
|
"scripts": {
|
|
43
46
|
"test": "node --test",
|
|
44
|
-
"lint": "node --check lib/discovery-policy.js && node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/settings.js && node --check lib/version-status.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/diagnostics.js && node --check lib/connection-backup.js && node --check lib/connection-editor.js && node --check lib/connection-scopes.js && node --check lib/governance.js && node --check lib/tool-catalog-cache.js && node --check lib/status-events.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/grant-journal.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/check-distribution-sync.mjs && node --check scripts/check-readme-version.mjs && node --check scripts/check-marketing-metadata.mjs && node --check scripts/check-storefront-screenshots.mjs && node --check scripts/sync-registry-stats.mjs && node --check scripts/verify-pack.mjs",
|
|
47
|
+
"lint": "node --check lib/discovery-policy.js && node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/settings.js && node --check lib/version-status.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/diagnostics.js && node --check lib/connection-backup.js && node --check lib/connection-editor.js && node --check lib/connection-scopes.js && node --check lib/governance.js && node --check lib/tool-catalog-cache.js && node --check lib/status-events.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/grant-journal.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/cli-providers.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/cli-bridge.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/check-distribution-sync.mjs && node --check scripts/check-readme-version.mjs && node --check scripts/check-marketing-metadata.mjs && node --check scripts/check-storefront-screenshots.mjs && node --check scripts/sync-registry-stats.mjs && node --check scripts/verify-pack.mjs",
|
|
45
48
|
"registry:build": "node scripts/build-registry.mjs",
|
|
46
49
|
"registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
|
|
47
50
|
"dev:ui": "node scripts/ui-harness.mjs",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { handleCliBridgeRequest } from '../lib/cli-providers.js';
|
|
3
|
+
|
|
4
|
+
function providerFromArgs(argv) {
|
|
5
|
+
const index = argv.indexOf('--provider');
|
|
6
|
+
if (index < 0) return 'dingtalk-dws';
|
|
7
|
+
const value = argv[index + 1];
|
|
8
|
+
if (!value || value.startsWith('-')) throw new Error('--provider requires a value');
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const providerId = providerFromArgs(process.argv.slice(2));
|
|
13
|
+
let buffer = '';
|
|
14
|
+
|
|
15
|
+
function send(message) {
|
|
16
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process.stdin.setEncoding('utf8');
|
|
20
|
+
process.stdin.on('data', (chunk) => {
|
|
21
|
+
buffer += chunk;
|
|
22
|
+
while (buffer.includes('\n')) {
|
|
23
|
+
const index = buffer.indexOf('\n');
|
|
24
|
+
const line = buffer.slice(0, index).trim();
|
|
25
|
+
buffer = buffer.slice(index + 1);
|
|
26
|
+
if (!line) continue;
|
|
27
|
+
Promise.resolve()
|
|
28
|
+
.then(() => handleCliBridgeRequest(JSON.parse(line), { providerId }))
|
|
29
|
+
.then((response) => { if (response) send(response); })
|
|
30
|
+
.catch((error) => send({
|
|
31
|
+
jsonrpc: '2.0',
|
|
32
|
+
id: null,
|
|
33
|
+
error: { code: -32700, message: String(error?.message ?? error) },
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
});
|