@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 +3 -0
- package/README_EN.md +3 -0
- package/lib/client.js +28 -2
- package/lib/index.js +90 -21
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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 {
|
|
983
|
+
return { added: true };
|
|
937
984
|
});
|
|
938
|
-
if (res
|
|
985
|
+
if (res.added)
|
|
939
986
|
added.push(row.id);
|
|
940
987
|
else
|
|
941
|
-
skipped.push({ id: row.id, reason: (res && res.
|
|
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
|
-
|
|
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) {
|
|
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.
|
|
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",
|