@omdp/dsh-connector 0.2.6 → 0.3.1
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/README.md +12 -8
- package/client.js +129 -3
- package/index.js +158 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @omdp/dsh-connector
|
|
2
2
|
|
|
3
|
-
**MCP 服务器 + 用户 Skills + 魔搭市场浏览三合一设置页**(`v0.
|
|
3
|
+
**MCP 服务器 + 用户 Skills + 魔搭市场浏览三合一设置页**(`v0.3.0`)。适合需要在 DSH 里频繁增删改 MCP server / skills、又不想手改 `cordis.patch.yml` 的用户。
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- DeepSeek Harness 带 `web` profile GUI(`npx @deepseek-ai/dsh web`)
|
|
8
8
|
- Node.js `^22.19` 或 `>=24`
|
|
9
|
-
- `@deepseek-ai/cordis`
|
|
9
|
+
- `@deepseek-ai/cordis` `4.0.1` / `4.0.2`(已实测版本;插件无 peer 声明,唯一 DSH 硬依赖是 `ctx.webServer`)
|
|
10
10
|
|
|
11
11
|
## Overview
|
|
12
12
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
合并到 DSH Web UI 的同一个设置页(设置页标签:**Connector**)。
|
|
15
15
|
|
|
16
16
|
- **MCP**:读取/编辑 `profiles/web/cordis.patch.yml` 中的 `mcp-*` 块(结构化表单)。保存后**重启 `dsh` 生效**。
|
|
17
|
+
- **工具过滤(0.3.0 新增)**:每台 MCP server 卡片下可勾选放行的工具(`mcp__<server>__<raw>` 公开名按 `__` 切分回 raw 名)。规则存 `settings.yaml` 的 `connector.toolFilters`(`{<serverName>: {allow: [...]}}`),**无配置 = 全量放行**;保存后新会话即生效、无需重启。生效三件套:`systemPrompt.tools(provider)` 隐藏 schema + `ctx.tools.guard` 执行期硬拦截(做法参照 `hyqhyq3/dsh-mcp-manager`)。典型场景:tinyfish 这类 15 个工具只留 `search`/`fetch_content` 两个免费工具。
|
|
17
18
|
- **Skills**:列出/查看/编辑/删除 `~/.dsh/skills` 下的 `SKILL.md`。保存**即时生效**(filesystem provider 自动重新发现)。
|
|
18
19
|
- **市场探索(0.2.0 新增)**:只读浏览魔搭社区 [Skills 中心](https://modelscope.cn/skills) 与 [MCP 广场](https://modelscope.cn/mcp)(匿名 OpenAPI,无需密钥)。
|
|
19
20
|
- 列表/详情:名称、作者、分类、下载/浏览数、认证标识(Hosted 官方托管 / 已认证)
|
|
@@ -80,7 +81,7 @@ pnpm install --lockfile-only --offline # 按 link 依赖重写 lockfile
|
|
|
80
81
|
|
|
81
82
|
```jsonc
|
|
82
83
|
"dependencies": {
|
|
83
|
-
"@omdp/dsh-connector": "^0.
|
|
84
|
+
"@omdp/dsh-connector": "^0.3.0"
|
|
84
85
|
}
|
|
85
86
|
```
|
|
86
87
|
|
|
@@ -142,7 +143,8 @@ pnpm remove @omdp/dsh-connector
|
|
|
142
143
|
1. **MCP 服务器** 区:
|
|
143
144
|
- 列出当前 `cordis.patch.yml` 里的 `mcp-*` 服务器
|
|
144
145
|
- 「编辑」改名称/传输/URL/命令/参数/Header;「删除」移除;「+ 添加」新建
|
|
145
|
-
-
|
|
146
|
+
- 每台 server 卡片下有**工具过滤**多选(chips):勾选即放行,未勾选的工具模型不可见、调用被拒;「清除」回到全量放行
|
|
147
|
+
- 保存后提示**重启 dsh** 才会真正加载新的 MCP server(工具过滤规则除外:存 settings,**新会话即生效**)
|
|
146
148
|
2. **Skills** 区:
|
|
147
149
|
- 列出 `~/.dsh/skills` 下的用户技能
|
|
148
150
|
- 「编辑」改 frontmatter 与正文;「删除」移除目录;「+ 新建」创建
|
|
@@ -158,9 +160,10 @@ pnpm remove @omdp/dsh-connector
|
|
|
158
160
|
|
|
159
161
|
| 组成 | 机制 |
|
|
160
162
|
|---|---|
|
|
161
|
-
| 设置页 | client half 注册 `settings.section` 槽位("Connector"
|
|
163
|
+
| 设置页 | client half 注册 `settings.section` 槽位("Connector" 页签;client factory 须 `exports.inject = ['slots']`,否则 fiber 在 slots 就绪前跑 apply 会静默丢注册) |
|
|
162
164
|
| 跨边界调用 | client 用 `fetch('/connector/api/...')`,host 用 `ctx.webServer.register` 接收(安装包走 HTTP) |
|
|
163
165
|
| MCP 持久化 | 文本块级提取并替换 `cordis.patch.yml` 中含 `mcp-` 的 insert 块,**保留 `!!js` 表达式与 env 块原样**(preserve 桶) |
|
|
166
|
+
| 工具过滤 | 规则存 settings `connector` 命名空间(`toolFilters`);`systemPrompt.tools(provider)` 滤 schema + `tools.guard` 硬拦截;`dsh-mcp-client` Config 封闭,规则**不能**写进 mcp 行 config |
|
|
164
167
|
| Skill 持久化 | 直接读写 `~/.dsh/skills/<name>/SKILL.md` |
|
|
165
168
|
|
|
166
169
|
## 已知限制
|
|
@@ -177,7 +180,8 @@ pnpm remove @omdp/dsh-connector
|
|
|
177
180
|
|
|
178
181
|
| 问题 | 原因 / 解决 |
|
|
179
182
|
|---|---|
|
|
180
|
-
| 设置页看不到 Connector 标签 | bundle 未挂载:确认 `dsh.profile.bundles` 含 `@omdp/dsh-connector`,重启 dsh |
|
|
183
|
+
| 设置页看不到 Connector 标签 | bundle 未挂载:确认 `dsh.profile.bundles` 含 `@omdp/dsh-connector`,重启 dsh;仍无则检查 client.js 尾部 `exports.inject = ['slots']` 是否在(缺了会静默丢注册) |
|
|
184
|
+
| 工具过滤不生效(模型仍能看到/调用) | 过滤规则只对**新会话**生效(当前会话的 schema 已下发);确认 settings.yaml 有 `connector.toolFilters` 且 serverName 拼写与 patch 一致 |
|
|
181
185
|
| 保存 MCP 被拒(HTTP 400) | 配置不合法(transport/serverName/command/url 校验失败),按提示修正——插件不会写入坏配置 |
|
|
182
186
|
| MCP server 保存后不生效 | 需**重启 dsh**(`dsh-mcp-client` 静态加载) |
|
|
183
187
|
| `/connector/api/*` 404 | client/host 边界异常:确认插件 host 半边已加载(重启),浏览器强刷缓存 |
|
|
@@ -237,7 +241,7 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
|
|
|
237
241
|
|
|
238
242
|
本插件采用**抗崩溃架构**,DSH 更新时不会导致 DSH 崩溃(硬保证)。
|
|
239
243
|
|
|
240
|
-
- **纯静态依赖**:只 `import node:*` + `yaml`(唯一第三方依赖,版本 `^2.9.0`),**零 `@deepseek-ai/*`
|
|
244
|
+
- **纯静态依赖**:只 `import node:*` + `yaml`(唯一第三方依赖,版本 `^2.9.0`),**零 `@deepseek-ai/*` 依赖**(`@deepseek-ai/schemastery` 仅 peer 声明,供工具过滤的 settings schema 用,无则过滤静默全放行)。
|
|
241
245
|
- **唯一的 DSH 硬依赖**:`ctx.webServer`(`inject: ['webServer']`),用于注册 `/connector/api/*` HTTP 路由。
|
|
242
246
|
- **失败隔离**:webServer 不可用/变化时插件**干净失败不加载**,DSH 照常运行;内部多处 try/catch 防御。
|
|
243
247
|
|
|
@@ -250,4 +254,4 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
|
|
|
250
254
|
|
|
251
255
|
**最后验证**:DSH `0.1.0-rc.8`(2026-08-20);0.2.0 市场功能以 `node --check` +
|
|
252
256
|
真实 HTTP 集成测试通过(11 项:skills/mcp 列表与详情、证书/Hosted 标识、安装命令、
|
|
253
|
-
记录来源回写、更新判定),未改动 DSH 实例。当前 npm 版本 `0.
|
|
257
|
+
记录来源回写、更新判定),未改动 DSH 实例。当前 npm 版本 `0.3.0`。
|
package/client.js
CHANGED
|
@@ -103,7 +103,9 @@ window.__ModuleLoader__.load({
|
|
|
103
103
|
'.pm_chip{cursor:pointer;font:inherit;font-size:11.5px;padding:4px 12px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:transparent;color:var(--dsw-alias-label-secondary,#9aa);transition:all .12s}' +
|
|
104
104
|
'.pm_chip:hover{color:var(--dsw-alias-label-primary,#ddd);border-color:var(--dsw-alias-brand-primary,#5b8cff)}' +
|
|
105
105
|
'.pm_chip.active{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 26%,transparent);color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 60%,transparent)}' +
|
|
106
|
-
'.pm_chipToggle{color:var(--dsw-alias-brand-primary,#5b8cff);border-style:dashed}'
|
|
106
|
+
'.pm_chipToggle{color:var(--dsw-alias-brand-primary,#5b8cff);border-style:dashed}' +
|
|
107
|
+
'.pm_toolFilter{margin-top:10px;padding-top:10px;border-top:1px dashed var(--dsw-alias-border-l1,rgba(128,128,128,.22));display:flex;flex-direction:column;gap:8px}' +
|
|
108
|
+
'.pm_toolFilterHead{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#eee);display:flex;align-items:center;gap:8px;flex-wrap:wrap}'
|
|
107
109
|
|
|
108
110
|
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="@omdp/dsh-connector/section"]') === null) {
|
|
109
111
|
var tag = document.createElement('style')
|
|
@@ -236,6 +238,119 @@ window.__ModuleLoader__.load({
|
|
|
236
238
|
|
|
237
239
|
/* ─────────────────────────── MCP servers ─────────────────────────── */
|
|
238
240
|
|
|
241
|
+
/* ─────────────────── MCP 工具过滤(tool-filter)───────────────────
|
|
242
|
+
* 每个 server 一张卡片下的工具多选区:勾选 = allow 放行,未勾 = prompt 隐藏
|
|
243
|
+
* + 执行期硬拦截。无配置 = 全量放行。规则存 settings.yaml `connector:` 节,
|
|
244
|
+
* 保存后新会话即生效(无需重启;当前会话的已加载 schema 下次请求刷新)。
|
|
245
|
+
* 工具列表来自 host 的 GET /api/mcp/tools/:serverName(读 tools.schemas()
|
|
246
|
+
* 的实时注册);server 未连接时列表为空,显示提示而非多选框。
|
|
247
|
+
* ─────────────────────────────────────────────────────────────── */
|
|
248
|
+
|
|
249
|
+
// 全 server 的过滤规则缓存:key: serverName → { tools: [publicName], allow: [rawName] }。
|
|
250
|
+
// 注意:这是普通函数(非 Hook),由 McpPane 直接调用;内部的 useState/useEffect
|
|
251
|
+
// 之所以合法,是因为 McpPane 每次渲染都以相同顺序调用它(servers 为空时也调用,
|
|
252
|
+
// 只是 effect 内提前返回)。servers 变化才重拉。
|
|
253
|
+
function useToolFilters(servers) {
|
|
254
|
+
var _a = react.useState({})
|
|
255
|
+
var cache = _a[0]
|
|
256
|
+
var setCache = _a[1]
|
|
257
|
+
react.useEffect(function () {
|
|
258
|
+
var cancelled = false
|
|
259
|
+
var names = (servers || []).map(function (s) { return s.serverName }).filter(Boolean)
|
|
260
|
+
if (!names.length) { setCache({}); return }
|
|
261
|
+
Promise.all(names.map(function (n) {
|
|
262
|
+
return api('/mcp/tools/' + encodeURIComponent(n), { method: 'GET' })
|
|
263
|
+
.then(function (r) { return { name: n, data: r } })
|
|
264
|
+
.catch(function () { return { name: n, data: null } })
|
|
265
|
+
})).then(function (pairs) {
|
|
266
|
+
if (cancelled) return
|
|
267
|
+
var next = {}
|
|
268
|
+
pairs.forEach(function (p) {
|
|
269
|
+
if (p.data && !p.data.error) next[p.name] = { tools: p.data.tools || [], allow: p.data.allow || [] }
|
|
270
|
+
})
|
|
271
|
+
setCache(next)
|
|
272
|
+
})
|
|
273
|
+
return function () { cancelled = true }
|
|
274
|
+
}, [(servers || []).map(function (s) { return s.serverName }).join(',')])
|
|
275
|
+
return [cache, setCache]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ToolFilterBadge(props) {
|
|
279
|
+
// 卡片头徽标:有过滤规则才显示“已过滤 N/M”,无规则不占位。
|
|
280
|
+
var info = (props.cache || {})[props.serverName]
|
|
281
|
+
if (!info || !info.allow.length || !info.tools.length) return null
|
|
282
|
+
return badge('已过滤 ' + info.allow.length + '/' + info.tools.length, 'brand')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function ToolFilterBox(props) {
|
|
286
|
+
var info = (props.cache || {})[props.serverName]
|
|
287
|
+
var _a = react.useState(false)
|
|
288
|
+
var saving = _a[0]
|
|
289
|
+
var setSaving = _a[1]
|
|
290
|
+
var _b = react.useState('')
|
|
291
|
+
var err = _b[0]
|
|
292
|
+
var setErr = _b[1]
|
|
293
|
+
if (!props.serverName) return null
|
|
294
|
+
if (!info) return createElement('div', { className: 'pm_meta' }, '工具列表加载中…')
|
|
295
|
+
if (!info.tools.length) return createElement('div', { className: 'pm_meta' }, '该 server 暂无已注册工具(未连接或无需过滤)。')
|
|
296
|
+
var allow = info.allow
|
|
297
|
+
// raw 名 = 公开名去掉 `mcp__<server>__` 前缀。serverName 本身可含下划线
|
|
298
|
+
// 但公开名用双下划线分隔、且 server 是第一段,所以按 `__` 切分取第一段
|
|
299
|
+
// 校验后余下 join 回 raw(与 host 侧 splitPublicName 同逻辑)。
|
|
300
|
+
function rawOf(publicName) {
|
|
301
|
+
var parts = String(publicName).slice(5).split('__')
|
|
302
|
+
if (parts.length >= 2 && parts[0] === props.serverName) return parts.slice(1).join('__')
|
|
303
|
+
var prefix = 'mcp__' + props.serverName + '__'
|
|
304
|
+
if (String(publicName).indexOf(prefix) === 0) return String(publicName).slice(prefix.length)
|
|
305
|
+
return publicName
|
|
306
|
+
}
|
|
307
|
+
function toggle(raw) {
|
|
308
|
+
var next = allow.indexOf(raw) === -1 ? allow.concat([raw]) : allow.filter(function (t) { return t !== raw })
|
|
309
|
+
save(next)
|
|
310
|
+
}
|
|
311
|
+
function save(next) {
|
|
312
|
+
setSaving(true)
|
|
313
|
+
setErr('')
|
|
314
|
+
var body = { filters: {} }
|
|
315
|
+
// 读当前全量规则:只改这一个 server,其余保持(避免并发覆盖)。
|
|
316
|
+
api('/mcp/filters', { method: 'GET' }).then(function (r) {
|
|
317
|
+
var filters = (r && r.filters) || {}
|
|
318
|
+
if (!next.length) delete filters[props.serverName]
|
|
319
|
+
else filters[props.serverName] = { allow: next }
|
|
320
|
+
body.filters = filters
|
|
321
|
+
return api('/mcp/filters', { method: 'PUT', body: JSON.stringify(body) })
|
|
322
|
+
}).then(function (r) {
|
|
323
|
+
if (r && r.error) { setErr(r.error); return }
|
|
324
|
+
changed()
|
|
325
|
+
}).catch(function (e) {
|
|
326
|
+
setErr(String(e))
|
|
327
|
+
}).finally(function () { setSaving(false) })
|
|
328
|
+
}
|
|
329
|
+
return createElement('div', { className: 'pm_toolFilter' },
|
|
330
|
+
createElement('div', { className: 'pm_toolFilterHead' },
|
|
331
|
+
'工具过滤 Tool filter',
|
|
332
|
+
saving ? createElement('span', { className: 'pm_spinner' }) : null,
|
|
333
|
+
allow.length
|
|
334
|
+
? createElement('button', { className: 'pm_btn', onClick: function () { save([]) } }, '清除(全量放行)')
|
|
335
|
+
: createElement('span', { className: 'pm_meta' }, '未配置 = 全量放行'),
|
|
336
|
+
),
|
|
337
|
+
err ? createElement('div', { className: 'pm_err' }, err) : null,
|
|
338
|
+
createElement('div', { className: 'pm_chips' },
|
|
339
|
+
info.tools.map(function (pub) {
|
|
340
|
+
var raw = rawOf(pub)
|
|
341
|
+
var on = allow.indexOf(raw) !== -1
|
|
342
|
+
return createElement('button', {
|
|
343
|
+
key: pub,
|
|
344
|
+
className: 'pm_chip' + (on ? ' active' : ''),
|
|
345
|
+
title: pub,
|
|
346
|
+
onClick: function () { toggle(raw) },
|
|
347
|
+
}, (on ? '✓ ' : '') + raw)
|
|
348
|
+
}),
|
|
349
|
+
),
|
|
350
|
+
createElement('div', { className: 'pm_meta' }, '保存后新会话即生效,无需重启。未勾选的工具模型不可见、调用被拒。'),
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
239
354
|
function McpPane(props) {
|
|
240
355
|
var _a = react.useState(null)
|
|
241
356
|
var editing = _a[0]
|
|
@@ -243,6 +358,14 @@ window.__ModuleLoader__.load({
|
|
|
243
358
|
var _b = react.useState('')
|
|
244
359
|
var err = _b[0]
|
|
245
360
|
var setErr = _b[1]
|
|
361
|
+
var _c = useToolFilters(props.mcp.servers)
|
|
362
|
+
var filterCache = _c[0]
|
|
363
|
+
var setFilterCache = _c[1]
|
|
364
|
+
// 刷新后工具列表可能变化(server 重连/改名),清掉旧缓存重拉。
|
|
365
|
+
function changed() {
|
|
366
|
+
setFilterCache({})
|
|
367
|
+
props.onChanged()
|
|
368
|
+
}
|
|
246
369
|
|
|
247
370
|
function save(server) {
|
|
248
371
|
api('/mcp', {
|
|
@@ -252,7 +375,7 @@ window.__ModuleLoader__.load({
|
|
|
252
375
|
if (r && r.error) { setErr(r.error); return }
|
|
253
376
|
setErr('')
|
|
254
377
|
setEditing(null)
|
|
255
|
-
|
|
378
|
+
changed()
|
|
256
379
|
})
|
|
257
380
|
}
|
|
258
381
|
function remove(id) {
|
|
@@ -262,7 +385,7 @@ window.__ModuleLoader__.load({
|
|
|
262
385
|
}).then(function (r) {
|
|
263
386
|
if (r && r.error) { setErr(r.error); return }
|
|
264
387
|
setErr('')
|
|
265
|
-
|
|
388
|
+
changed()
|
|
266
389
|
})
|
|
267
390
|
}
|
|
268
391
|
|
|
@@ -271,12 +394,14 @@ window.__ModuleLoader__.load({
|
|
|
271
394
|
createElement('div', { className: 'pm_cardHead' },
|
|
272
395
|
createElement('span', { className: 'pm_name' }, s.name || s.id),
|
|
273
396
|
badge(s.transport || 'stdio', 'muted'),
|
|
397
|
+
createElement(ToolFilterBadge, { serverName: s.serverName, cache: filterCache }),
|
|
274
398
|
),
|
|
275
399
|
createElement('div', { className: 'pm_meta' }, s.url ? 'url: ' + s.url : (s.command ? 'cmd: ' + s.command : '')),
|
|
276
400
|
createElement('div', { className: 'pm_actions' },
|
|
277
401
|
createElement('button', { className: 'pm_btn', onClick: function () { setEditing(s) } }, '编辑 Edit'),
|
|
278
402
|
createElement('button', { className: 'pm_btn danger', onClick: function () { remove(s.id) } }, '删除 Delete'),
|
|
279
403
|
),
|
|
404
|
+
createElement(ToolFilterBox, { serverName: s.serverName, cache: filterCache, onChanged: changed }),
|
|
280
405
|
)
|
|
281
406
|
})
|
|
282
407
|
|
|
@@ -1033,6 +1158,7 @@ window.__ModuleLoader__.load({
|
|
|
1033
1158
|
}
|
|
1034
1159
|
|
|
1035
1160
|
exports.apply = apply
|
|
1161
|
+
exports.inject = ['slots']
|
|
1036
1162
|
return module.exports
|
|
1037
1163
|
},
|
|
1038
1164
|
})
|
package/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { homedir } from 'node:os'
|
|
|
27
27
|
import { join, dirname } from 'node:path'
|
|
28
28
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
29
29
|
import { JSDOM, VirtualConsole } from 'jsdom'
|
|
30
|
+
import z from '@deepseek-ai/schemastery'
|
|
30
31
|
|
|
31
32
|
const API_PREFIX = '/connector/api'
|
|
32
33
|
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
@@ -40,7 +41,55 @@ const TRANSPORTS = new Set(['stdio', 'streamable-http'])
|
|
|
40
41
|
// patch file itself (NUL, control chars). Tab/newline are already rejected by
|
|
41
42
|
// the single-token rules; this catches the rest defensively.
|
|
42
43
|
const CONTROL_CHARS = /[\x00-\x1f\x7f]/
|
|
43
|
-
export const inject = ['webServer']
|
|
44
|
+
export const inject = ['webServer', 'settings']
|
|
45
|
+
|
|
46
|
+
// ── MCP 工具过滤(tool-filter, 0.3.0 新增)────────────────────────────
|
|
47
|
+
// dsh-mcp-client 的 Config schema 是封闭的:未知键会拒收并导致下次启动失败,
|
|
48
|
+
// 所以过滤规则不能写进 mcp-* 行的 config,只能放在 connector 自己的 settings
|
|
49
|
+
// namespace(settings.yaml `connector:` 节)里:
|
|
50
|
+
// connector:
|
|
51
|
+
// toolFilters:
|
|
52
|
+
// <serverName>:
|
|
53
|
+
// allow: [<rawToolName>, ...] # 只放行这些工具;空/缺失 = 全量放行
|
|
54
|
+
//
|
|
55
|
+
// 生效三件套(抄 hyqhyq3/dsh-mcp-manager 的做法):
|
|
56
|
+
// 1. systemPrompt.tools(provider) —— 提示词里的 schema 列表先过滤,模型看不到被滤掉的工具;
|
|
57
|
+
// 2. ctx.tools.guard —— 执行期硬拦截(guard 返回 reason 即拒收),补 provider 漏网;
|
|
58
|
+
// 3. UI 多选框 —— 见 client.js ServerForm 的工具过滤区 + GET /api/mcp/tools/:serverName。
|
|
59
|
+
// 无配置 = 全量放行(零回归);guard 是同步函数,读的是 apply 时缓存 + watch 刷新的快照。
|
|
60
|
+
const CONNECTOR_SETTINGS_NS = 'connector'
|
|
61
|
+
const ToolFilterSchema = z.object({
|
|
62
|
+
toolFilters: z.dict(z.object({ allow: z.array(String).default([]) })).default({}),
|
|
63
|
+
})
|
|
64
|
+
function readToolFilters(ctx) {
|
|
65
|
+
try {
|
|
66
|
+
const settings = ctx.get('settings')
|
|
67
|
+
const value = settings ? settings.get(CONNECTOR_SETTINGS_NS) : undefined
|
|
68
|
+
const filters = value && typeof value === 'object' ? value.toolFilters : undefined
|
|
69
|
+
if (!filters || typeof filters !== 'object' || Array.isArray(filters)) return {}
|
|
70
|
+
const out = {}
|
|
71
|
+
for (const [server, rule] of Object.entries(filters)) {
|
|
72
|
+
const allow = rule && Array.isArray(rule.allow) ? rule.allow.filter((t) => typeof t === 'string' && t.length > 0) : []
|
|
73
|
+
out[server] = { allow }
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
} catch { return {} }
|
|
77
|
+
}
|
|
78
|
+
// 公开名 `mcp__<server>__<raw>` 反解回 (server, raw):注意 serverName 本身可含
|
|
79
|
+
// 下划线,所以按 `mcp__` 前缀 + __ 分段取“第一段”为 server,余下 join 回 raw。
|
|
80
|
+
function splitPublicName(publicName) {
|
|
81
|
+
if (typeof publicName !== 'string' || !publicName.startsWith('mcp__')) return null
|
|
82
|
+
const parts = publicName.slice(5).split('__')
|
|
83
|
+
if (parts.length < 2 || !parts[0]) return null
|
|
84
|
+
return { server: parts[0], raw: parts.slice(1).join('__') }
|
|
85
|
+
}
|
|
86
|
+
function isToolAllowed(filters, publicName) {
|
|
87
|
+
const split = splitPublicName(publicName)
|
|
88
|
+
if (!split) return true // 非 MCP 工具:不过滤
|
|
89
|
+
const rule = filters[split.server]
|
|
90
|
+
if (!rule || !rule.allow.length) return true // 该 server 无配置 = 全量放行
|
|
91
|
+
return rule.allow.includes(split.raw)
|
|
92
|
+
}
|
|
44
93
|
export { validateServer, commandResolvable }
|
|
45
94
|
|
|
46
95
|
function resolveHome() {
|
|
@@ -874,6 +923,54 @@ async function readBody(req) {
|
|
|
874
923
|
}
|
|
875
924
|
|
|
876
925
|
export function apply(ctx) {
|
|
926
|
+
// ── 工具过滤三件套之 (0):settings namespace 注册 + 快照缓存 ──
|
|
927
|
+
// settings 经声明式 inject 依赖保证就绪(见上方 export const inject),
|
|
928
|
+
// 这里同步拿服务;万一缺失不炸,过滤保持全放行(零回归)。
|
|
929
|
+
// 注:cordis 的 ctx.inject(deps, cb) 回调按 (ctx, config) 调用,不是服务展开,
|
|
930
|
+
// 不能拿回调参数当 service 用 —— 0.3.0 曾因此 register 从未落地。
|
|
931
|
+
const settings = ctx.get('settings')
|
|
932
|
+
let toolFilters = {}
|
|
933
|
+
const refreshFilters = () => { toolFilters = readToolFilters(ctx) }
|
|
934
|
+
if (settings) {
|
|
935
|
+
try {
|
|
936
|
+
const scope = settings.register(CONNECTOR_SETTINGS_NS, ToolFilterSchema, { base: { toolFilters: {} } })
|
|
937
|
+
refreshFilters()
|
|
938
|
+
ctx.effect(() => scope.watch(() => refreshFilters()), 'connector: watch tool filters')
|
|
939
|
+
} catch (error) {
|
|
940
|
+
console.error('[dsh-connector] register tool filter settings failed:', error?.message ?? error)
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ── 三件套之 (1):prompt 层过滤 —— 被滤掉的工具不进模型 schema ──
|
|
945
|
+
// tools provider 在每次 assembly 时求值,读最新快照;无 tools/systemPrompt
|
|
946
|
+
// 服务时跳过(ctx.get 可选读,失败隔离:connector 照常提供设置页)。
|
|
947
|
+
try {
|
|
948
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
949
|
+
if (systemPrompt && typeof systemPrompt.tools === 'function') {
|
|
950
|
+
ctx.effect(() => systemPrompt.tools(() => {
|
|
951
|
+
let schemas = []
|
|
952
|
+
try {
|
|
953
|
+
const tools = ctx.get('tools')
|
|
954
|
+
schemas = tools ? tools.schemas() : []
|
|
955
|
+
} catch { return { schemas: [] } }
|
|
956
|
+
const kept = schemas.filter((s) => isToolAllowed(toolFilters, s && s.name))
|
|
957
|
+
return { schemas: kept, knownNames: schemas.map((s) => s && s.name).filter(Boolean) }
|
|
958
|
+
}), 'connector: mcp tool filter provider')
|
|
959
|
+
}
|
|
960
|
+
} catch {}
|
|
961
|
+
// ── 三件套之 (2):执行期硬拦截 —— 补 provider 漏网(如 guard 注册时序)──
|
|
962
|
+
try {
|
|
963
|
+
const tools = ctx.get('tools')
|
|
964
|
+
if (tools && typeof tools.guard === 'function') {
|
|
965
|
+
ctx.effect(() => tools.guard((exec) => {
|
|
966
|
+
if (!exec || typeof exec.name !== 'string') return undefined
|
|
967
|
+
if (isToolAllowed(toolFilters, exec.name)) return undefined
|
|
968
|
+
const split = splitPublicName(exec.name)
|
|
969
|
+
return `connector: 工具 ${exec.name} 已被过滤(server "${split ? split.server : '?'}” 的 allow 列表未包含它)`
|
|
970
|
+
}), 'connector: mcp tool filter guard')
|
|
971
|
+
}
|
|
972
|
+
} catch {}
|
|
973
|
+
|
|
877
974
|
async function route(req, res) {
|
|
878
975
|
const url = new URL(req.url, 'http://localhost')
|
|
879
976
|
const path = url.pathname
|
|
@@ -947,6 +1044,66 @@ export function apply(ctx) {
|
|
|
947
1044
|
return json(res, 200, { ok: true })
|
|
948
1045
|
}
|
|
949
1046
|
|
|
1047
|
+
// GET /api/mcp/tools/:serverName — 该 server 当前注册的工具公开名
|
|
1048
|
+
// (供 UI 渲染 allow 多选框;server 未连接/无工具时返回空列表)。
|
|
1049
|
+
const toolsMatch = path.match(new RegExp('^' + API_PREFIX + '/mcp/tools/([^/]+)$'))
|
|
1050
|
+
if (req.method === 'GET' && toolsMatch) {
|
|
1051
|
+
const serverName = decodeURIComponent(toolsMatch[1])
|
|
1052
|
+
if (!SERVER_NAME_RE.test(serverName)) return json(res, 400, { error: 'invalid server name' })
|
|
1053
|
+
let names = []
|
|
1054
|
+
try {
|
|
1055
|
+
const tools = ctx.get('tools')
|
|
1056
|
+
const schemas = tools ? tools.schemas() : []
|
|
1057
|
+
names = schemas.map((s) => s && s.name).filter((n) => {
|
|
1058
|
+
const split = splitPublicName(n)
|
|
1059
|
+
return split && split.server === serverName
|
|
1060
|
+
})
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
return json(res, 500, { error: String(error?.message ?? error).slice(0, 200) })
|
|
1063
|
+
}
|
|
1064
|
+
const filters = readToolFilters(ctx)
|
|
1065
|
+
const rule = filters[serverName]
|
|
1066
|
+
return json(res, 200, { server: serverName, tools: names.sort(), allow: rule ? rule.allow : [] })
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// GET /api/mcp/filters — 全部 server 的过滤规则(UI 批量展示用)
|
|
1070
|
+
if (req.method === 'GET' && path === API_PREFIX + '/mcp/filters') {
|
|
1071
|
+
return json(res, 200, { filters: readToolFilters(ctx) })
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// PUT /api/mcp/filters — 保存过滤规则 → settings.yaml `connector:` 节
|
|
1075
|
+
// body: { filters: { <serverName>: { allow: [rawTool,...] } } }。
|
|
1076
|
+
// 空 allow = 全量放行(删除该 server 规则与显式空列表等价)。
|
|
1077
|
+
if (req.method === 'PUT' && path === API_PREFIX + '/mcp/filters') {
|
|
1078
|
+
const body = await readBody(req)
|
|
1079
|
+
const incoming = body && typeof body === 'object' && !Array.isArray(body) ? body.filters : undefined
|
|
1080
|
+
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
|
|
1081
|
+
return json(res, 400, { error: 'malformed request body: filters must be an object' })
|
|
1082
|
+
}
|
|
1083
|
+
const clean = {}
|
|
1084
|
+
for (const [server, rule] of Object.entries(incoming)) {
|
|
1085
|
+
if (!SERVER_NAME_RE.test(server)) {
|
|
1086
|
+
return json(res, 400, { error: `invalid server name ${JSON.stringify(server)}` })
|
|
1087
|
+
}
|
|
1088
|
+
const allow = rule && Array.isArray(rule.allow) ? rule.allow : []
|
|
1089
|
+
for (const t of allow) {
|
|
1090
|
+
if (typeof t !== 'string' || !t.length || CONTROL_CHARS.test(t) || /\s/.test(t)) {
|
|
1091
|
+
return json(res, 400, { error: `invalid tool name ${JSON.stringify(t)} for server ${JSON.stringify(server)}` })
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (allow.length) clean[server] = { allow: [...new Set(allow)].sort() }
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const settings = ctx.get('settings')
|
|
1098
|
+
if (!settings) return json(res, 503, { error: 'settings service unavailable' })
|
|
1099
|
+
await settings.update(CONNECTOR_SETTINGS_NS, { toolFilters: clean })
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
return json(res, 500, { error: String(error?.message ?? error).slice(0, 300) })
|
|
1102
|
+
}
|
|
1103
|
+
refreshFilters()
|
|
1104
|
+
return json(res, 200, { ok: true, filters: readToolFilters(ctx) })
|
|
1105
|
+
}
|
|
1106
|
+
|
|
950
1107
|
// GET /api/skills — list
|
|
951
1108
|
if (req.method === 'GET' && path === API_PREFIX + '/skills') {
|
|
952
1109
|
const skills = await listUserSkills()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omdp/dsh-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Unified DeepSeek Harness connector: edit MCP servers (cordis.patch.yml), user skills (~/.dsh/skills) and browse the ModelScope MCP/Skills marketplaces from one Web UI settings page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -44,5 +44,8 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"yaml": "^2.9.0",
|
|
46
46
|
"jsdom": "^24.1.3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@deepseek-ai/schemastery": "3.18.1 || 3.18.2"
|
|
47
50
|
}
|
|
48
51
|
}
|