@xxxyz/dsh-mcp-manager 2.1.1 → 2.1.3

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 CHANGED
@@ -126,6 +126,7 @@ dsh plugin --profile web remove @xxxyz/dsh-mcp-manager
126
126
  | 字段 | 说明 |
127
127
  |---|---|
128
128
  | `version` | loader 行 `config.version`,仅用于触发 HMR 重应用;官方通道安装下由 bundle 自动管理,无需手动修改。 |
129
+ | `token` | **可选**访问令牌(写操作鉴权,纵深防御)。设置后写操作(增删改/启停/重启/导入导出/技能停用)须带 `x-dsh-token: <token>` 头;设置页提供令牌输入框(保存在浏览器 localStorage)。也可用环境变量 `DSH_MCP_MANAGER_TOKEN` 配置。默认关闭。 |
129
130
 
130
131
  loader 行必须为 **`insert` 块**形式(DSH patch 方言中普通 `- id:` 行只是对已存在条目的覆盖,无法新增插件):
131
132
 
@@ -133,6 +134,8 @@ loader 行必须为 **`insert` 块**形式(DSH patch 方言中普通 `- id:`
133
134
  - insert:
134
135
  - id: dsh-mcp-manager
135
136
  name: '@xxxyz/dsh-mcp-manager'
137
+ config:
138
+ token: 你的访问令牌 # 可选:开启写操作鉴权
136
139
  ```
137
140
 
138
141
  > 无需手动写这行——`dsh plugin add` 的 bundle patch 会自动插入(见 `cordis.patch.yml`)。
package/README_EN.md CHANGED
@@ -127,6 +127,7 @@ Configuration of the plugin itself on its loader row:
127
127
  | Field | Description |
128
128
  |---|---|
129
129
  | `version` | The loader row's `config.version`, only used to trigger an HMR re-apply; auto-managed by the bundle channel — no manual edits needed. |
130
+ | `token` | **Optional** access token (write-op auth, defense in depth). When set, state-changing ops (add/edit/remove/enable/restart/import/export/skill-toggle) require the `x-dsh-token: <token>` header; the settings pages provide a token input (stored in browser localStorage). Can also be set via the `DSH_MCP_MANAGER_TOKEN` env var. Off by default. |
130
131
 
131
132
  The loader row must be an **`insert` block** (in DSH's patch dialect a plain `- id:` row only overrides existing entries and can never add a new plugin):
132
133
 
@@ -134,6 +135,8 @@ The loader row must be an **`insert` block** (in DSH's patch dialect a plain `-
134
135
  - insert:
135
136
  - id: dsh-mcp-manager
136
137
  name: '@xxxyz/dsh-mcp-manager'
138
+ config:
139
+ token: your-access-token # optional: enable write-op auth
137
140
  ```
138
141
 
139
142
  > You don't need to write this row manually — `dsh plugin add` inserts it automatically via the bundle patch (see `cordis.patch.yml`).
package/lib/client.js CHANGED
@@ -50,7 +50,10 @@ const factory = (require) => {
50
50
  '.mcpm-dialog-actions{display:flex;justify-content:flex-end;gap:8px}' +
51
51
  '.skm-search{font-size:12px;padding:5px 8px;border-radius:6px;border:1px solid rgba(128,128,128,.5);background:transparent;color:inherit;width:100%;box-sizing:border-box}' +
52
52
  '.skm-group-title{font-size:12px;font-weight:600;opacity:.85;margin-top:4px;padding-bottom:2px;border-bottom:1px solid rgba(128,128,128,.25)}' +
53
- '.skm-provider-title{font-size:12px;font-weight:600;cursor:pointer;user-select:none;margin-top:4px;opacity:.9}'
53
+ '.skm-provider-title{font-size:12px;font-weight:600;cursor:pointer;user-select:none;margin-top:4px;opacity:.9}' +
54
+ '.mcpm-version{margin-left:8px;font-size:11px;font-weight:500;opacity:.55;letter-spacing:.3px;vertical-align:middle}' +
55
+ '.mcpm-token{display:flex;align-items:center;gap:8px;font-size:12px;opacity:.85}' +
56
+ '.mcpm-token input{font-size:12px;padding:3px 6px;border-radius:5px;border:1px solid rgba(128,128,128,.5);background:transparent;color:inherit;flex:1;max-width:260px}'
54
57
 
55
58
  function ensureCss() {
56
59
  if (typeof document === 'undefined') return
@@ -63,17 +66,52 @@ const factory = (require) => {
63
66
  document.head.appendChild(tag)
64
67
  }
65
68
 
69
+ // Optional access token for write ops (host config.token or
70
+ // DSH_MCP_MANAGER_TOKEN). Kept in localStorage so the user enters it once;
71
+ // sent as `x-dsh-token` on every request (host ignores it when unset).
72
+ let TOKEN = ''
73
+ try { TOKEN = window.localStorage.getItem('dsh-mcp-manager-token') || '' } catch (e) { /* storage unavailable */ }
74
+ function getToken() { return TOKEN }
75
+ function setToken(v) {
76
+ TOKEN = String(v || '').trim()
77
+ try { if (TOKEN) window.localStorage.setItem('dsh-mcp-manager-token', TOKEN); else window.localStorage.removeItem('dsh-mcp-manager-token') } catch (e) { /* ignore */ }
78
+ }
79
+
66
80
  function apiCall(op, args) {
67
81
  // `x-dsh-plugin` is the cross-site (CSRF) gate header the host half
68
82
  // requires on every request; a cross-origin page cannot attach it
69
83
  // without a CORS preflight that this route never answers.
84
+ const headers = { 'content-type': 'application/json', 'x-dsh-plugin': 'dsh-mcp-manager' }
85
+ if (TOKEN) headers['x-dsh-token'] = TOKEN
70
86
  return fetch('/dsh-mcp-manager/api', {
71
87
  method: 'POST',
72
- headers: { 'content-type': 'application/json', 'x-dsh-plugin': 'dsh-mcp-manager' },
88
+ headers,
73
89
  body: JSON.stringify({ op, args: args || {} }),
74
90
  }).then((r) => r.json()).catch((e) => ({ ok: false, error: String((e && e.message) || e) }))
75
91
  }
76
92
 
93
+ // Small version badge shown next to each settings-page title; reads the
94
+ // package version from the host (plugin-version op) so it always matches
95
+ // the installed release.
96
+ function VersionBadge() {
97
+ const [v, setV] = React.useState(null)
98
+ React.useEffect(() => {
99
+ let alive = true
100
+ apiCall('plugin-version', {}).then((r) => { if (alive && r && r.ok) setV(r.version) }).catch(() => {})
101
+ return () => { alive = false }
102
+ }, [])
103
+ return v ? React.createElement('span', { className: 'mcpm-version' }, 'v' + v) : null
104
+ }
105
+
106
+ // Optional access-token row: shown on both settings pages; only needed
107
+ // when the host has token auth enabled (write ops would 401 otherwise).
108
+ function TokenRow() {
109
+ const [val, setVal] = React.useState(getToken())
110
+ return React.createElement('label', { className: 'mcpm-token' },
111
+ '访问令牌(可选,写操作鉴权)',
112
+ React.createElement('input', { value: val, onChange: (e) => { setVal(e.target.value); setToken(e.target.value) }, placeholder: '留空 = 不鉴权' }))
113
+ }
114
+
77
115
  module.exports = {
78
116
  name: 'dsh-mcp-manager-client',
79
117
  inject: ['timer'],
@@ -218,7 +256,8 @@ const factory = (require) => {
218
256
  }
219
257
 
220
258
  return React.createElement('div', { className: 'mcpm-wrap' },
221
- React.createElement('h2', null, 'MCP 服务管理'),
259
+ React.createElement('h2', null, 'MCP 服务管理', React.createElement(VersionBadge, null)),
260
+ React.createElement(TokenRow, null),
222
261
  React.createElement('div', { className: 'mcpm-sub' },
223
262
  '管理 dsh-mcp-client 服务:写入 项目级(web profile) / 全局(home) 的 cordis.patch.yml,经 HMR 实时生效;重启 DSH 后由 Loader 自动加载。'),
224
263
  restartInfo && React.createElement('div', { className: 'mcpm-msg info' },
@@ -340,7 +379,8 @@ const factory = (require) => {
340
379
  const query = q.trim().toLowerCase()
341
380
  const visible = state.skills.filter((s) => !query || (s.name + ' ' + (s.description || '')).toLowerCase().includes(query))
342
381
  return React.createElement('div', { className: 'mcpm-wrap' },
343
- React.createElement('h2', null, 'Skills 管理'),
382
+ React.createElement('h2', null, 'Skills 管理', React.createElement(VersionBadge, null)),
383
+ React.createElement(TokenRow, null),
344
384
  React.createElement('div', { className: 'mcpm-sub' }, '查看与启停 DSH 技能(按层级分组,禁用即时生效,无需重启)'),
345
385
  msg && React.createElement('div', { className: 'mcpm-msg ' + msg.kind }, msg.text),
346
386
  React.createElement('input', { className: 'skm-search', placeholder: '搜索技能名称或描述…', value: q, onChange: (e) => setQ(e.target.value) }),
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // Build: `tsc -p tsconfig.json` compiles this to lib/index.js (the shipped
12
12
  // artifact — same convention as DSH's own packages, which ship compiled JS).
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools';
14
+ import { createRequire } from 'node:module';
14
15
  export default {
15
16
  name: 'dsh-mcp-manager-host',
16
17
  inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills'],
@@ -22,6 +23,24 @@ export default {
22
23
  const tools = ctx.tools;
23
24
  // pluginInventory is optional: probe at use time, degrade to no live info.
24
25
  const pluginInventory = ctx.get('pluginInventory');
26
+ // Package version, surfaced in the Settings pages and the HTTP API. Read
27
+ // from the installed package.json so it always matches the release tag.
28
+ let PKG_VERSION = 'unknown';
29
+ try {
30
+ PKG_VERSION = createRequire(import.meta.url)('../package.json').version || 'unknown';
31
+ }
32
+ catch (e) { /* keep unknown */ }
33
+ // Optional access token (defense in depth for LAN exposure). Enabled by
34
+ // setting `config.token` on this plugin's loader row (profile
35
+ // cordis.patch.yml override) or the DSH_MCP_MANAGER_TOKEN env var. When
36
+ // set, every state-changing op requires `x-dsh-token: <token>`. Read-only
37
+ // ops (plugin-version, mcpm-list, skill-list) stay open so the UI still
38
+ // renders; mcpm-export is guarded too because it leaks full configs.
39
+ const TOKEN = String(ctx.config?.token || process.env.DSH_MCP_MANAGER_TOKEN || '').trim();
40
+ const WRITE_OPS = new Set([
41
+ 'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-restart',
42
+ 'mcpm-export', 'mcpm-import', 'skill-toggle',
43
+ ]);
25
44
  const wait = (ms) => ctx.timeout(ms);
26
45
  const message = (e) => String((e && e.message) || e);
27
46
  let writeChain = Promise.resolve();
@@ -137,6 +156,10 @@ export default {
137
156
  });
138
157
  }
139
158
  // ---------- path discovery ----------
159
+ // Known limitation: profile detection probes 'web' then 'headless' by
160
+ // presence of profiles/<name>/cordis.patch.yml, then falls back to any
161
+ // profile that has one, and finally to 'web'. A profile whose directory
162
+ // name matches none of these and has no patch file yet is not detected.
140
163
  let cached = null;
141
164
  async function ensurePaths() {
142
165
  if (cached)
@@ -266,6 +289,11 @@ export default {
266
289
  ].join('\n');
267
290
  }
268
291
  // ---------- YAML parsing (mini parser) ----------
292
+ // Known limitation: this hand-rolled parser assumes the exact indentation
293
+ // style that buildInsertBlock emits (config at 6 spaces, children at 8,
294
+ // nested maps/lists at 10+). Hand-edited patch files using different
295
+ // indentation may parse incorrectly — DSH itself only cares about the
296
+ // effective YAML it reads, and this parser exists purely for the UI.
269
297
  function splitKV(text) {
270
298
  const m = text.match(/^("(?:\\.|[^"])*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/);
271
299
  if (!m)
@@ -626,6 +654,9 @@ export default {
626
654
  return { ok: true, row };
627
655
  }
628
656
  // ---------- ops ----------
657
+ async function pluginVersion() {
658
+ return { ok: true, version: PKG_VERSION };
659
+ }
629
660
  async function mcpmList() {
630
661
  const p = await ensurePaths();
631
662
  const rows = [];
@@ -767,14 +798,32 @@ export default {
767
798
  const block = buildInsertBlock(row);
768
799
  return withWriteLock(async () => {
769
800
  if (oldAbs !== newAbs) {
770
- let c = await readPatch(oldAbs);
801
+ // Level migration: remove from the old file, insert into the new one.
802
+ // Not atomic, so keep the old content and restore it if the second
803
+ // write fails — losing the entry is worse than a transient dup.
804
+ const origOld = await readPatch(oldAbs);
805
+ let c = origOld;
771
806
  c = removeEntryAll(c, id);
772
- await writePatch(oldAbs, c);
807
+ try {
808
+ await writePatch(oldAbs, c);
809
+ }
810
+ catch (e) {
811
+ return { ok: false, error: '写入失败: ' + message(e) };
812
+ }
773
813
  let c2 = await readPatch(newAbs);
774
814
  c2 = appendBlock(c2, block);
775
815
  if (cur.disabled)
776
816
  c2 = appendBlock(c2, buildDisableBlock(id, true));
777
- await writePatch(newAbs, c2);
817
+ try {
818
+ await writePatch(newAbs, c2);
819
+ }
820
+ catch (e) {
821
+ try {
822
+ await writePatch(oldAbs, origOld);
823
+ }
824
+ catch (e2) { /* best effort */ }
825
+ return { ok: false, error: '写入失败(已回滚): ' + message(e) };
826
+ }
778
827
  }
779
828
  else {
780
829
  let c = await readPatch(newAbs);
@@ -799,6 +848,11 @@ export default {
799
848
  return withWriteLock(async () => {
800
849
  let c = await readPatch(abs);
801
850
  if (enabled) {
851
+ // Drop every `disabled: true` override for this id. If the insert row
852
+ // itself still says disabled (e.g. user hand-edited it), append an
853
+ // explicit `disabled: false` override so the effective state flips.
854
+ // Enable overrides are intentionally left in place — they are the
855
+ // mechanism that lets a disabled-by-default row be turned on.
802
856
  c = removeMarked(c, id, 'disable');
803
857
  const { rows } = parseRows(c);
804
858
  const row = rows.find((r) => r.id === id);
@@ -826,25 +880,30 @@ export default {
826
880
  c = removeMarked(c, id, 'enable');
827
881
  c = appendBlock(c, buildDisableBlock(id, true));
828
882
  await writePatch(abs, c);
883
+ const warnings = [];
829
884
  if (pluginInventory) {
830
- await waitFor(async () => {
885
+ const off = await waitFor(async () => {
831
886
  const e = await liveEntry(id);
832
887
  return e ? e.enabled === false : false;
833
888
  }, 5000, 300);
889
+ if (!off)
890
+ warnings.push('loader 未在 5 秒内停用该服务');
834
891
  }
835
892
  await wait(1000);
836
893
  c = await readPatch(abs);
837
894
  c = removeMarked(c, id, 'disable');
838
895
  await writePatch(abs, c);
839
896
  if (pluginInventory) {
840
- await waitFor(async () => {
897
+ const on = await waitFor(async () => {
841
898
  const e = await liveEntry(id);
842
899
  return e ? e.enabled === true : false;
843
900
  }, 5000, 300);
901
+ if (!on)
902
+ warnings.push('loader 未在 5 秒内重新启用该服务');
844
903
  }
845
904
  else
846
905
  await wait(1500);
847
- return { ok: true };
906
+ return warnings.length ? { ok: true, warning: warnings.join(';') } : { ok: true };
848
907
  });
849
908
  }
850
909
  async function mcpmRemove(args) {
@@ -906,28 +965,27 @@ export default {
906
965
  continue;
907
966
  }
908
967
  const row = norm.row;
909
- const existing = await collectAll();
910
- if (existing.ids.has(row.id)) {
911
- skipped.push({ id: row.id, reason: 'id 已存在' });
912
- continue;
913
- }
914
- if (existing.serverNames.has(row.serverName)) {
915
- skipped.push({ id: row.id, reason: 'serverName 已存在' });
916
- continue;
917
- }
918
- const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
968
+ // Existence checks run INSIDE the write lock so two concurrent imports
969
+ // (or an import racing an add) cannot both pass the same-id/same-name
970
+ // check and duplicate rows (TOCTOU).
919
971
  const res = await withWriteLock(async () => {
972
+ const existing = await collectAll();
973
+ if (existing.ids.has(row.id))
974
+ return { skipped: true, reason: 'id 已存在' };
975
+ if (existing.serverNames.has(row.serverName))
976
+ return { skipped: true, reason: 'serverName 已存在' };
977
+ const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
920
978
  let c = await readPatch(abs);
921
979
  c = appendBlock(c, buildInsertBlock(row));
922
980
  if (row.disabled)
923
981
  c = appendBlock(c, buildDisableBlock(row.id, true));
924
982
  await writePatch(abs, c);
925
- return { ok: true };
983
+ return { added: true };
926
984
  });
927
- if (res && res.ok)
985
+ if (res.added)
928
986
  added.push(row.id);
929
987
  else
930
- skipped.push({ id: row.id, reason: (res && res.error) || '写入失败' });
988
+ skipped.push({ id: row.id, reason: (res && res.reason) || '写入失败' });
931
989
  }
932
990
  return { ok: true, added, skipped };
933
991
  }
@@ -948,6 +1006,7 @@ export default {
948
1006
  return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter((s) => s !== '');
949
1007
  }
950
1008
  const handlers = {
1009
+ 'plugin-version': pluginVersion,
951
1010
  'mcpm-list': mcpmList,
952
1011
  'mcpm-add': mcpmAdd,
953
1012
  'mcpm-edit': mcpmEdit,
@@ -1030,9 +1089,21 @@ export default {
1030
1089
  }));
1031
1090
  // ---------- HTTP API route (UI half), registered defensively ----------
1032
1091
  if (webServer) {
1092
+ // Cap request bodies (1 MiB) — the API has no legitimate large payloads,
1093
+ // and unbounded buffering would let a local attacker exhaust memory.
1094
+ const MAX_BODY = 1024 * 1024;
1033
1095
  const readBody = (req) => new Promise((resolve, reject) => {
1034
1096
  const chunks = [];
1035
- req.on('data', (c) => chunks.push(String(c)));
1097
+ let size = 0;
1098
+ req.on('data', (c) => {
1099
+ const s = String(c);
1100
+ size += s.length;
1101
+ if (size > MAX_BODY) {
1102
+ reject(new Error('request body too large'));
1103
+ return;
1104
+ }
1105
+ chunks.push(s);
1106
+ });
1036
1107
  req.on('end', () => resolve(chunks.join('')));
1037
1108
  req.on('error', reject);
1038
1109
  });
@@ -1086,8 +1157,18 @@ export default {
1086
1157
  try {
1087
1158
  payload = JSON.parse((await readBody(req)) || '{}');
1088
1159
  }
1089
- catch (e) { /* fallthrough */ }
1160
+ catch (e) {
1161
+ if (String(e?.message).includes('body too large')) {
1162
+ res.end(JSON.stringify({ ok: false, error: '请求体过大' }));
1163
+ return;
1164
+ }
1165
+ /* otherwise fall through with {} */
1166
+ }
1090
1167
  const op = String(payload.op || '');
1168
+ if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
1169
+ res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
1170
+ return;
1171
+ }
1091
1172
  const fn = handlers[op];
1092
1173
  if (!fn) {
1093
1174
  res.end(JSON.stringify({ ok: false, error: '未知操作: ' + op }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xxxyz/dsh-mcp-manager",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "DSH-standard MCP manager plugin: Settings UI + HTTP API + model-facing mcp_manager_* tools. Install with one command: dsh plugin --profile web add @xxxyz/dsh-mcp-manager@latest",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -27,7 +27,8 @@
27
27
  "scripts": {
28
28
  "build": "tsc -p tsconfig.json",
29
29
  "test": "npm run build && node --test",
30
- "prepublishOnly": "npm run build"
30
+ "prepublishOnly": "npm run build",
31
+ "lint": "node --check lib/client.js && node --check lib/index.js"
31
32
  },
32
33
  "keywords": [
33
34
  "dsh",