@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 +3 -0
- package/README_EN.md +3 -0
- package/lib/client.js +28 -2
- package/lib/index.js +95 -22
- package/package.json +3 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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 {
|
|
987
|
+
return { added: true };
|
|
937
988
|
});
|
|
938
|
-
if (res
|
|
989
|
+
if (res.added)
|
|
939
990
|
added.push(row.id);
|
|
940
991
|
else
|
|
941
|
-
skipped.push({ id: row.id, reason: (res && res.
|
|
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
|
-
|
|
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) {
|
|
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.
|
|
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",
|