@omdp/dsh-connector 0.2.6 → 0.3.0

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.
Files changed (4) hide show
  1. package/README.md +12 -8
  2. package/client.js +129 -3
  3. package/index.js +149 -0
  4. package/package.json +4 -1
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @omdp/dsh-connector
2
2
 
3
- **MCP 服务器 + 用户 Skills + 魔搭市场浏览三合一设置页**(`v0.2.6`)。适合需要在 DSH 里频繁增删改 MCP server / skills、又不想手改 `cordis.patch.yml` 的用户。
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` `^4.0.1`(peer;唯一 DSH 硬依赖是 `ctx.webServer`)
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.2.6"
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
- - 保存后提示**重启 dsh** 才会真正加载新的 MCP server
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.2.6`。
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
- props.onChanged()
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
- props.onChanged()
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]+)*$/
@@ -41,6 +42,54 @@ const TRANSPORTS = new Set(['stdio', 'streamable-http'])
41
42
  // the single-token rules; this catches the rest defensively.
42
43
  const CONTROL_CHARS = /[\x00-\x1f\x7f]/
43
44
  export const inject = ['webServer']
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,46 @@ async function readBody(req) {
874
923
  }
875
924
 
876
925
  export function apply(ctx) {
926
+ // ── 工具过滤三件套之 (0):settings namespace 注册 + 快照缓存 ──
927
+ // settings 服务可选(无 provider 时 ctx.inject 回调不跑,过滤保持全放行)。
928
+ let toolFilters = {}
929
+ const refreshFilters = () => { toolFilters = readToolFilters(ctx) }
930
+ ctx.inject(['settings'], (settingsCtx) => {
931
+ settingsCtx.settings.register(CONNECTOR_SETTINGS_NS, ToolFilterSchema, { base: { toolFilters: {} } })
932
+ refreshFilters()
933
+ settingsCtx.effect(() => settingsCtx.settings.watch(() => refreshFilters()), 'connector: watch tool filters')
934
+ })
935
+
936
+ // ── 三件套之 (1):prompt 层过滤 —— 被滤掉的工具不进模型 schema ──
937
+ // tools provider 在每次 assembly 时求值,读最新快照;无 tools/systemPrompt
938
+ // 服务时跳过(ctx.get 可选读,失败隔离:connector 照常提供设置页)。
939
+ try {
940
+ const systemPrompt = ctx.get('systemPrompt')
941
+ if (systemPrompt && typeof systemPrompt.tools === 'function') {
942
+ ctx.effect(() => systemPrompt.tools(() => {
943
+ let schemas = []
944
+ try {
945
+ const tools = ctx.get('tools')
946
+ schemas = tools ? tools.schemas() : []
947
+ } catch { return { schemas: [] } }
948
+ const kept = schemas.filter((s) => isToolAllowed(toolFilters, s && s.name))
949
+ return { schemas: kept, knownNames: schemas.map((s) => s && s.name).filter(Boolean) }
950
+ }), 'connector: mcp tool filter provider')
951
+ }
952
+ } catch {}
953
+ // ── 三件套之 (2):执行期硬拦截 —— 补 provider 漏网(如 guard 注册时序)──
954
+ try {
955
+ const tools = ctx.get('tools')
956
+ if (tools && typeof tools.guard === 'function') {
957
+ ctx.effect(() => tools.guard((exec) => {
958
+ if (!exec || typeof exec.name !== 'string') return undefined
959
+ if (isToolAllowed(toolFilters, exec.name)) return undefined
960
+ const split = splitPublicName(exec.name)
961
+ return `connector: 工具 ${exec.name} 已被过滤(server "${split ? split.server : '?'}” 的 allow 列表未包含它)`
962
+ }), 'connector: mcp tool filter guard')
963
+ }
964
+ } catch {}
965
+
877
966
  async function route(req, res) {
878
967
  const url = new URL(req.url, 'http://localhost')
879
968
  const path = url.pathname
@@ -947,6 +1036,66 @@ export function apply(ctx) {
947
1036
  return json(res, 200, { ok: true })
948
1037
  }
949
1038
 
1039
+ // GET /api/mcp/tools/:serverName — 该 server 当前注册的工具公开名
1040
+ // (供 UI 渲染 allow 多选框;server 未连接/无工具时返回空列表)。
1041
+ const toolsMatch = path.match(new RegExp('^' + API_PREFIX + '/mcp/tools/([^/]+)$'))
1042
+ if (req.method === 'GET' && toolsMatch) {
1043
+ const serverName = decodeURIComponent(toolsMatch[1])
1044
+ if (!SERVER_NAME_RE.test(serverName)) return json(res, 400, { error: 'invalid server name' })
1045
+ let names = []
1046
+ try {
1047
+ const tools = ctx.get('tools')
1048
+ const schemas = tools ? tools.schemas() : []
1049
+ names = schemas.map((s) => s && s.name).filter((n) => {
1050
+ const split = splitPublicName(n)
1051
+ return split && split.server === serverName
1052
+ })
1053
+ } catch (error) {
1054
+ return json(res, 500, { error: String(error?.message ?? error).slice(0, 200) })
1055
+ }
1056
+ const filters = readToolFilters(ctx)
1057
+ const rule = filters[serverName]
1058
+ return json(res, 200, { server: serverName, tools: names.sort(), allow: rule ? rule.allow : [] })
1059
+ }
1060
+
1061
+ // GET /api/mcp/filters — 全部 server 的过滤规则(UI 批量展示用)
1062
+ if (req.method === 'GET' && path === API_PREFIX + '/mcp/filters') {
1063
+ return json(res, 200, { filters: readToolFilters(ctx) })
1064
+ }
1065
+
1066
+ // PUT /api/mcp/filters — 保存过滤规则 → settings.yaml `connector:` 节
1067
+ // body: { filters: { <serverName>: { allow: [rawTool,...] } } }。
1068
+ // 空 allow = 全量放行(删除该 server 规则与显式空列表等价)。
1069
+ if (req.method === 'PUT' && path === API_PREFIX + '/mcp/filters') {
1070
+ const body = await readBody(req)
1071
+ const incoming = body && typeof body === 'object' && !Array.isArray(body) ? body.filters : undefined
1072
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
1073
+ return json(res, 400, { error: 'malformed request body: filters must be an object' })
1074
+ }
1075
+ const clean = {}
1076
+ for (const [server, rule] of Object.entries(incoming)) {
1077
+ if (!SERVER_NAME_RE.test(server)) {
1078
+ return json(res, 400, { error: `invalid server name ${JSON.stringify(server)}` })
1079
+ }
1080
+ const allow = rule && Array.isArray(rule.allow) ? rule.allow : []
1081
+ for (const t of allow) {
1082
+ if (typeof t !== 'string' || !t.length || CONTROL_CHARS.test(t) || /\s/.test(t)) {
1083
+ return json(res, 400, { error: `invalid tool name ${JSON.stringify(t)} for server ${JSON.stringify(server)}` })
1084
+ }
1085
+ }
1086
+ if (allow.length) clean[server] = { allow: [...new Set(allow)].sort() }
1087
+ }
1088
+ try {
1089
+ const settings = ctx.get('settings')
1090
+ if (!settings) return json(res, 503, { error: 'settings service unavailable' })
1091
+ await settings.update(CONNECTOR_SETTINGS_NS, { toolFilters: clean })
1092
+ } catch (error) {
1093
+ return json(res, 500, { error: String(error?.message ?? error).slice(0, 300) })
1094
+ }
1095
+ refreshFilters()
1096
+ return json(res, 200, { ok: true, filters: readToolFilters(ctx) })
1097
+ }
1098
+
950
1099
  // GET /api/skills — list
951
1100
  if (req.method === 'GET' && path === API_PREFIX + '/skills') {
952
1101
  const skills = await listUserSkills()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omdp/dsh-connector",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
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
  }