@xxxyz/dsh-mcp-manager 2.1.2 → 2.1.4

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
@@ -51,7 +51,9 @@ const factory = (require) => {
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
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}'
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}'
55
57
 
56
58
  function ensureCss() {
57
59
  if (typeof document === 'undefined') return
@@ -64,13 +66,26 @@ const factory = (require) => {
64
66
  document.head.appendChild(tag)
65
67
  }
66
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
+
67
80
  function apiCall(op, args) {
68
81
  // `x-dsh-plugin` is the cross-site (CSRF) gate header the host half
69
82
  // requires on every request; a cross-origin page cannot attach it
70
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
71
86
  return fetch('/dsh-mcp-manager/api', {
72
87
  method: 'POST',
73
- headers: { 'content-type': 'application/json', 'x-dsh-plugin': 'dsh-mcp-manager' },
88
+ headers,
74
89
  body: JSON.stringify({ op, args: args || {} }),
75
90
  }).then((r) => r.json()).catch((e) => ({ ok: false, error: String((e && e.message) || e) }))
76
91
  }
@@ -88,6 +103,15 @@ const factory = (require) => {
88
103
  return v ? React.createElement('span', { className: 'mcpm-version' }, 'v' + v) : null
89
104
  }
90
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
+
91
115
  module.exports = {
92
116
  name: 'dsh-mcp-manager-client',
93
117
  inject: ['timer'],
@@ -233,6 +257,7 @@ const factory = (require) => {
233
257
 
234
258
  return React.createElement('div', { className: 'mcpm-wrap' },
235
259
  React.createElement('h2', null, 'MCP 服务管理', React.createElement(VersionBadge, null)),
260
+ React.createElement(TokenRow, null),
236
261
  React.createElement('div', { className: 'mcpm-sub' },
237
262
  '管理 dsh-mcp-client 服务:写入 项目级(web profile) / 全局(home) 的 cordis.patch.yml,经 HMR 实时生效;重启 DSH 后由 Loader 自动加载。'),
238
263
  restartInfo && React.createElement('div', { className: 'mcpm-msg info' },
@@ -355,6 +380,7 @@ const factory = (require) => {
355
380
  const visible = state.skills.filter((s) => !query || (s.name + ' ' + (s.description || '')).toLowerCase().includes(query))
356
381
  return React.createElement('div', { className: 'mcpm-wrap' },
357
382
  React.createElement('h2', null, 'Skills 管理', React.createElement(VersionBadge, null)),
383
+ React.createElement(TokenRow, null),
358
384
  React.createElement('div', { className: 'mcpm-sub' }, '查看与启停 DSH 技能(按层级分组,禁用即时生效,无需重启)'),
359
385
  msg && React.createElement('div', { className: 'mcpm-msg ' + msg.kind }, msg.text),
360
386
  React.createElement('input', { className: 'skm-search', placeholder: '搜索技能名称或描述…', value: q, onChange: (e) => setQ(e.target.value) }),
package/lib/index.js CHANGED
@@ -15,7 +15,7 @@ import { createRequire } from 'node:module';
15
15
  export default {
16
16
  name: 'dsh-mcp-manager-host',
17
17
  inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills'],
18
- apply(ctx) {
18
+ apply(ctx, config) {
19
19
  const fs = ctx.fs;
20
20
  const settings = ctx.settings;
21
21
  const sandboxPolicy = ctx.sandboxPolicy;
@@ -30,6 +30,21 @@ export default {
30
30
  PKG_VERSION = createRequire(import.meta.url)('../package.json').version || 'unknown';
31
31
  }
32
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
+ // NOTE: the entry config arrives as the SECOND apply argument (Cordis
40
+ // calls `callback(ctx, config)`) — never read it off `ctx.config`, which
41
+ // is not an injected service and throws "cannot get property without
42
+ // inject" at boot.
43
+ const TOKEN = String(config?.token || process.env.DSH_MCP_MANAGER_TOKEN || '').trim();
44
+ const WRITE_OPS = new Set([
45
+ 'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-restart',
46
+ 'mcpm-export', 'mcpm-import', 'skill-toggle',
47
+ ]);
33
48
  const wait = (ms) => ctx.timeout(ms);
34
49
  const message = (e) => String((e && e.message) || e);
35
50
  let writeChain = Promise.resolve();
@@ -145,6 +160,10 @@ export default {
145
160
  });
146
161
  }
147
162
  // ---------- path discovery ----------
163
+ // Known limitation: profile detection probes 'web' then 'headless' by
164
+ // presence of profiles/<name>/cordis.patch.yml, then falls back to any
165
+ // profile that has one, and finally to 'web'. A profile whose directory
166
+ // name matches none of these and has no patch file yet is not detected.
148
167
  let cached = null;
149
168
  async function ensurePaths() {
150
169
  if (cached)
@@ -274,6 +293,11 @@ export default {
274
293
  ].join('\n');
275
294
  }
276
295
  // ---------- YAML parsing (mini parser) ----------
296
+ // Known limitation: this hand-rolled parser assumes the exact indentation
297
+ // style that buildInsertBlock emits (config at 6 spaces, children at 8,
298
+ // nested maps/lists at 10+). Hand-edited patch files using different
299
+ // indentation may parse incorrectly — DSH itself only cares about the
300
+ // effective YAML it reads, and this parser exists purely for the UI.
277
301
  function splitKV(text) {
278
302
  const m = text.match(/^("(?:\\.|[^"])*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/);
279
303
  if (!m)
@@ -778,14 +802,32 @@ export default {
778
802
  const block = buildInsertBlock(row);
779
803
  return withWriteLock(async () => {
780
804
  if (oldAbs !== newAbs) {
781
- let c = await readPatch(oldAbs);
805
+ // Level migration: remove from the old file, insert into the new one.
806
+ // Not atomic, so keep the old content and restore it if the second
807
+ // write fails — losing the entry is worse than a transient dup.
808
+ const origOld = await readPatch(oldAbs);
809
+ let c = origOld;
782
810
  c = removeEntryAll(c, id);
783
- await writePatch(oldAbs, c);
811
+ try {
812
+ await writePatch(oldAbs, c);
813
+ }
814
+ catch (e) {
815
+ return { ok: false, error: '写入失败: ' + message(e) };
816
+ }
784
817
  let c2 = await readPatch(newAbs);
785
818
  c2 = appendBlock(c2, block);
786
819
  if (cur.disabled)
787
820
  c2 = appendBlock(c2, buildDisableBlock(id, true));
788
- await writePatch(newAbs, c2);
821
+ try {
822
+ await writePatch(newAbs, c2);
823
+ }
824
+ catch (e) {
825
+ try {
826
+ await writePatch(oldAbs, origOld);
827
+ }
828
+ catch (e2) { /* best effort */ }
829
+ return { ok: false, error: '写入失败(已回滚): ' + message(e) };
830
+ }
789
831
  }
790
832
  else {
791
833
  let c = await readPatch(newAbs);
@@ -810,6 +852,11 @@ export default {
810
852
  return withWriteLock(async () => {
811
853
  let c = await readPatch(abs);
812
854
  if (enabled) {
855
+ // Drop every `disabled: true` override for this id. If the insert row
856
+ // itself still says disabled (e.g. user hand-edited it), append an
857
+ // explicit `disabled: false` override so the effective state flips.
858
+ // Enable overrides are intentionally left in place — they are the
859
+ // mechanism that lets a disabled-by-default row be turned on.
813
860
  c = removeMarked(c, id, 'disable');
814
861
  const { rows } = parseRows(c);
815
862
  const row = rows.find((r) => r.id === id);
@@ -837,25 +884,30 @@ export default {
837
884
  c = removeMarked(c, id, 'enable');
838
885
  c = appendBlock(c, buildDisableBlock(id, true));
839
886
  await writePatch(abs, c);
887
+ const warnings = [];
840
888
  if (pluginInventory) {
841
- await waitFor(async () => {
889
+ const off = await waitFor(async () => {
842
890
  const e = await liveEntry(id);
843
891
  return e ? e.enabled === false : false;
844
892
  }, 5000, 300);
893
+ if (!off)
894
+ warnings.push('loader 未在 5 秒内停用该服务');
845
895
  }
846
896
  await wait(1000);
847
897
  c = await readPatch(abs);
848
898
  c = removeMarked(c, id, 'disable');
849
899
  await writePatch(abs, c);
850
900
  if (pluginInventory) {
851
- await waitFor(async () => {
901
+ const on = await waitFor(async () => {
852
902
  const e = await liveEntry(id);
853
903
  return e ? e.enabled === true : false;
854
904
  }, 5000, 300);
905
+ if (!on)
906
+ warnings.push('loader 未在 5 秒内重新启用该服务');
855
907
  }
856
908
  else
857
909
  await wait(1500);
858
- return { ok: true };
910
+ return warnings.length ? { ok: true, warning: warnings.join(';') } : { ok: true };
859
911
  });
860
912
  }
861
913
  async function mcpmRemove(args) {
@@ -917,28 +969,27 @@ export default {
917
969
  continue;
918
970
  }
919
971
  const row = norm.row;
920
- const existing = await collectAll();
921
- if (existing.ids.has(row.id)) {
922
- skipped.push({ id: row.id, reason: 'id 已存在' });
923
- continue;
924
- }
925
- if (existing.serverNames.has(row.serverName)) {
926
- skipped.push({ id: row.id, reason: 'serverName 已存在' });
927
- continue;
928
- }
929
- const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
972
+ // Existence checks run INSIDE the write lock so two concurrent imports
973
+ // (or an import racing an add) cannot both pass the same-id/same-name
974
+ // check and duplicate rows (TOCTOU).
930
975
  const res = await withWriteLock(async () => {
976
+ const existing = await collectAll();
977
+ if (existing.ids.has(row.id))
978
+ return { skipped: true, reason: 'id 已存在' };
979
+ if (existing.serverNames.has(row.serverName))
980
+ return { skipped: true, reason: 'serverName 已存在' };
981
+ const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
931
982
  let c = await readPatch(abs);
932
983
  c = appendBlock(c, buildInsertBlock(row));
933
984
  if (row.disabled)
934
985
  c = appendBlock(c, buildDisableBlock(row.id, true));
935
986
  await writePatch(abs, c);
936
- return { ok: true };
987
+ return { added: true };
937
988
  });
938
- if (res && res.ok)
989
+ if (res.added)
939
990
  added.push(row.id);
940
991
  else
941
- skipped.push({ id: row.id, reason: (res && res.error) || '写入失败' });
992
+ skipped.push({ id: row.id, reason: (res && res.reason) || '写入失败' });
942
993
  }
943
994
  return { ok: true, added, skipped };
944
995
  }
@@ -1042,9 +1093,21 @@ export default {
1042
1093
  }));
1043
1094
  // ---------- HTTP API route (UI half), registered defensively ----------
1044
1095
  if (webServer) {
1096
+ // Cap request bodies (1 MiB) — the API has no legitimate large payloads,
1097
+ // and unbounded buffering would let a local attacker exhaust memory.
1098
+ const MAX_BODY = 1024 * 1024;
1045
1099
  const readBody = (req) => new Promise((resolve, reject) => {
1046
1100
  const chunks = [];
1047
- req.on('data', (c) => chunks.push(String(c)));
1101
+ let size = 0;
1102
+ req.on('data', (c) => {
1103
+ const s = String(c);
1104
+ size += s.length;
1105
+ if (size > MAX_BODY) {
1106
+ reject(new Error('request body too large'));
1107
+ return;
1108
+ }
1109
+ chunks.push(s);
1110
+ });
1048
1111
  req.on('end', () => resolve(chunks.join('')));
1049
1112
  req.on('error', reject);
1050
1113
  });
@@ -1098,8 +1161,18 @@ export default {
1098
1161
  try {
1099
1162
  payload = JSON.parse((await readBody(req)) || '{}');
1100
1163
  }
1101
- catch (e) { /* fallthrough */ }
1164
+ catch (e) {
1165
+ if (String(e?.message).includes('body too large')) {
1166
+ res.end(JSON.stringify({ ok: false, error: '请求体过大' }));
1167
+ return;
1168
+ }
1169
+ /* otherwise fall through with {} */
1170
+ }
1102
1171
  const op = String(payload.op || '');
1172
+ if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
1173
+ res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
1174
+ return;
1175
+ }
1103
1176
  const fn = handlers[op];
1104
1177
  if (!fn) {
1105
1178
  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.2",
3
+ "version": "2.1.4",
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",