@xxxyz/dsh-mcp-manager 2.1.2 → 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
@@ -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
@@ -30,6 +30,17 @@ 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
+ 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
+ ]);
33
44
  const wait = (ms) => ctx.timeout(ms);
34
45
  const message = (e) => String((e && e.message) || e);
35
46
  let writeChain = Promise.resolve();
@@ -145,6 +156,10 @@ export default {
145
156
  });
146
157
  }
147
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.
148
163
  let cached = null;
149
164
  async function ensurePaths() {
150
165
  if (cached)
@@ -274,6 +289,11 @@ export default {
274
289
  ].join('\n');
275
290
  }
276
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.
277
297
  function splitKV(text) {
278
298
  const m = text.match(/^("(?:\\.|[^"])*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/);
279
299
  if (!m)
@@ -778,14 +798,32 @@ export default {
778
798
  const block = buildInsertBlock(row);
779
799
  return withWriteLock(async () => {
780
800
  if (oldAbs !== newAbs) {
781
- 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;
782
806
  c = removeEntryAll(c, id);
783
- await writePatch(oldAbs, c);
807
+ try {
808
+ await writePatch(oldAbs, c);
809
+ }
810
+ catch (e) {
811
+ return { ok: false, error: '写入失败: ' + message(e) };
812
+ }
784
813
  let c2 = await readPatch(newAbs);
785
814
  c2 = appendBlock(c2, block);
786
815
  if (cur.disabled)
787
816
  c2 = appendBlock(c2, buildDisableBlock(id, true));
788
- 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
+ }
789
827
  }
790
828
  else {
791
829
  let c = await readPatch(newAbs);
@@ -810,6 +848,11 @@ export default {
810
848
  return withWriteLock(async () => {
811
849
  let c = await readPatch(abs);
812
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.
813
856
  c = removeMarked(c, id, 'disable');
814
857
  const { rows } = parseRows(c);
815
858
  const row = rows.find((r) => r.id === id);
@@ -837,25 +880,30 @@ export default {
837
880
  c = removeMarked(c, id, 'enable');
838
881
  c = appendBlock(c, buildDisableBlock(id, true));
839
882
  await writePatch(abs, c);
883
+ const warnings = [];
840
884
  if (pluginInventory) {
841
- await waitFor(async () => {
885
+ const off = await waitFor(async () => {
842
886
  const e = await liveEntry(id);
843
887
  return e ? e.enabled === false : false;
844
888
  }, 5000, 300);
889
+ if (!off)
890
+ warnings.push('loader 未在 5 秒内停用该服务');
845
891
  }
846
892
  await wait(1000);
847
893
  c = await readPatch(abs);
848
894
  c = removeMarked(c, id, 'disable');
849
895
  await writePatch(abs, c);
850
896
  if (pluginInventory) {
851
- await waitFor(async () => {
897
+ const on = await waitFor(async () => {
852
898
  const e = await liveEntry(id);
853
899
  return e ? e.enabled === true : false;
854
900
  }, 5000, 300);
901
+ if (!on)
902
+ warnings.push('loader 未在 5 秒内重新启用该服务');
855
903
  }
856
904
  else
857
905
  await wait(1500);
858
- return { ok: true };
906
+ return warnings.length ? { ok: true, warning: warnings.join(';') } : { ok: true };
859
907
  });
860
908
  }
861
909
  async function mcpmRemove(args) {
@@ -917,28 +965,27 @@ export default {
917
965
  continue;
918
966
  }
919
967
  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;
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).
930
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;
931
978
  let c = await readPatch(abs);
932
979
  c = appendBlock(c, buildInsertBlock(row));
933
980
  if (row.disabled)
934
981
  c = appendBlock(c, buildDisableBlock(row.id, true));
935
982
  await writePatch(abs, c);
936
- return { ok: true };
983
+ return { added: true };
937
984
  });
938
- if (res && res.ok)
985
+ if (res.added)
939
986
  added.push(row.id);
940
987
  else
941
- skipped.push({ id: row.id, reason: (res && res.error) || '写入失败' });
988
+ skipped.push({ id: row.id, reason: (res && res.reason) || '写入失败' });
942
989
  }
943
990
  return { ok: true, added, skipped };
944
991
  }
@@ -1042,9 +1089,21 @@ export default {
1042
1089
  }));
1043
1090
  // ---------- HTTP API route (UI half), registered defensively ----------
1044
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;
1045
1095
  const readBody = (req) => new Promise((resolve, reject) => {
1046
1096
  const chunks = [];
1047
- 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
+ });
1048
1107
  req.on('end', () => resolve(chunks.join('')));
1049
1108
  req.on('error', reject);
1050
1109
  });
@@ -1098,8 +1157,18 @@ export default {
1098
1157
  try {
1099
1158
  payload = JSON.parse((await readBody(req)) || '{}');
1100
1159
  }
1101
- 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
+ }
1102
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
+ }
1103
1172
  const fn = handlers[op];
1104
1173
  if (!fn) {
1105
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.2",
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",