dsh-plugin-tool-management 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,1930 @@
1
+ // dsh-plugin-tool-management — host half, written in TypeScript to the DeepSeek Harness
2
+ // plugin development standard (https://deepseek-harness.github.io/deepseek-harness/develop/basic/):
3
+ // * object-form Cordis plugin: { name, inject, apply } (docs: "对象形式")
4
+ // * required services declared in `inject` — the framework guarantees they are
5
+ // ready before apply runs, and reloads the plugin if one disappears
6
+ // * agent-facing capability exposed as registered tools (ctx.tools.register +
7
+ // defineTool), the documented way to add model-callable abilities
8
+ // * UI-facing capability exposed via a webServer exact route (used by the
9
+ // client half), registered defensively
10
+ //
11
+ // Build: `tsc -p tsconfig.json` compiles this to lib/index.js (the shipped
12
+ // artifact — same convention as DSH's own packages, which ship compiled JS).
13
+ import { defineTool } from '@deepseek-ai/dsh-tools';
14
+ import { createRequire } from 'node:module';
15
+ import { createSkillsService } from './skills/service.js';
16
+ import { spawn } from 'node:child_process';
17
+ import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
18
+ export default {
19
+ name: 'dsh-plugin-tool-management-host',
20
+ inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions'],
21
+ apply(ctx, config) {
22
+ const fs = ctx.fs;
23
+ const settings = ctx.settings;
24
+ const sandboxPolicy = ctx.sandboxPolicy;
25
+ const webServer = ctx.webServer;
26
+ const tools = ctx.tools;
27
+ // pluginInventory is optional: probe at use time, degrade to no live info.
28
+ const pluginInventory = ctx.get('pluginInventory');
29
+ // Package version, surfaced in the Settings pages and the HTTP API. Read
30
+ // from the installed package.json so it always matches the release tag.
31
+ let PKG_VERSION = 'unknown';
32
+ try {
33
+ PKG_VERSION = createRequire(import.meta.url)('../package.json').version || 'unknown';
34
+ }
35
+ catch (e) { /* keep unknown */ }
36
+ // Optional access token (defense in depth for LAN exposure). Enabled by
37
+ // setting `config.token` on this plugin's loader row (profile
38
+ // cordis.patch.yml override) or the DSH_SKILL_MCP_MANAGER_TOKEN env var. When
39
+ // set, every state-changing op requires `x-dsh-token: <token>`. Read-only
40
+ // ops (plugin-version, mcpm-list, skill-state) stay open so the UI still
41
+ // renders; mcpm-export is guarded too because it leaks full configs.
42
+ // NOTE: the entry config arrives as the SECOND apply argument (Cordis
43
+ // calls `callback(ctx, config)`) — never read it off `ctx.config`, which
44
+ // is not an injected service and throws "cannot get property without
45
+ // inject" at boot.
46
+ const TOKEN = String(config?.token || process.env.DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN || '').trim();
47
+ const WRITE_OPS = new Set([
48
+ 'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
49
+ 'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
50
+ // mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
51
+ // token-gated like a write — on a LAN-exposed port the token must be the
52
+ // last line of defense for plaintext credentials too, not just writes.
53
+ 'mcpm-reveal',
54
+ 'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable',
55
+ 'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
56
+ 'skill-trash-restore', 'skill-trash-delete', 'skill-open',
57
+ 'skill-custom-add', 'skill-custom-remove',
58
+ ]);
59
+ const wait = (ms) => ctx.timeout(ms);
60
+ const message = (e) => String((e && e.message) || e);
61
+ let writeChain = Promise.resolve();
62
+ function withWriteLock(fn) {
63
+ const run = writeChain.then(() => fn(), () => fn());
64
+ writeChain = run.then(() => undefined, () => undefined);
65
+ return run;
66
+ }
67
+ // ---------- skills management (core service, ported from dsh-skills-manager) ----------
68
+ // 文件操作核心在 ./skills/core.js 与 ./skills/readonly-discovery.js(原样移植,
69
+ // 来源裁剪为 dsh/agents/codex/claude);服务层把 core 的结果适配成 { op, args }
70
+ // 协议,并在全局层与每个活动 agent 的 scope 内注册 manager provider:
71
+ // rank 覆盖实现不改源文件的启停;外部来源由 provider 接入。
72
+ const skillsService = createSkillsService(ctx);
73
+ try {
74
+ ctx.effect(() => skillsService.registerProviders(), 'dsh-plugin-tool-management: skills providers');
75
+ }
76
+ catch (e) {
77
+ console.error('[dsh-plugin-tool-management] skills provider setup failed:', message(e));
78
+ }
79
+ // ---------- path discovery ----------
80
+ // Known limitation: profile detection probes 'web' then 'headless' by
81
+ // presence of profiles/<name>/cordis.patch.yml, then falls back to any
82
+ // profile that has one, and finally to 'web'. A profile whose directory
83
+ // name matches none of these and has no patch file yet is not detected.
84
+ let cached = null;
85
+ async function ensurePaths() {
86
+ if (cached)
87
+ return cached;
88
+ let home = null;
89
+ try {
90
+ const doc = await settings.prepareDocument();
91
+ if (typeof doc === 'string' && doc) {
92
+ const i = Math.max(doc.lastIndexOf('\\'), doc.lastIndexOf('/'));
93
+ home = i > 0 ? doc.slice(0, i) : doc;
94
+ }
95
+ }
96
+ catch (e) { /* ignore */ }
97
+ if (!home)
98
+ throw new Error('无法确定 DSH 主目录(settings.prepareDocument 未返回路径)');
99
+ await migrateRenamedData(home);
100
+ const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
101
+ let profileDir = null;
102
+ let profileName = 'web';
103
+ for (const name of ['web', 'headless']) {
104
+ if (await exists(home + sep + 'profiles' + sep + name + sep + 'cordis.patch.yml')) {
105
+ profileDir = home + sep + 'profiles' + sep + name;
106
+ profileName = name;
107
+ break;
108
+ }
109
+ }
110
+ if (!profileDir) {
111
+ try {
112
+ const t = await fs.resolve(home + sep + 'profiles');
113
+ const entries = await fs.listDir(t);
114
+ for (const e of entries) {
115
+ if (e.name === 'node_modules')
116
+ continue;
117
+ if (await exists(home + sep + 'profiles' + sep + e.name + sep + 'cordis.patch.yml')) {
118
+ profileDir = home + sep + 'profiles' + sep + e.name;
119
+ profileName = e.name;
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ catch (e) { /* ignore */ }
125
+ }
126
+ if (!profileDir)
127
+ profileDir = home + sep + 'profiles' + sep + 'web';
128
+ cached = {
129
+ home,
130
+ profileDir,
131
+ profileName,
132
+ projectPatch: profileDir + sep + 'cordis.patch.yml',
133
+ globalPatch: home + sep + 'cordis.patch.yml',
134
+ };
135
+ return cached;
136
+ }
137
+ async function exists(abs) {
138
+ try {
139
+ const t = await fs.resolve(abs);
140
+ return (await fs.stat(t)) !== undefined;
141
+ }
142
+ catch (e) {
143
+ return false;
144
+ }
145
+ }
146
+ // ---------- one-time data migration (pre-rename sidecars & state) ----------
147
+ // The plugin used to ship under the name dsh-skill-mcp-manager: its sidecar
148
+ // JSON files and the skills state directory lived under the old prefix. On
149
+ // first boot pull them to the new names so an upgraded install keeps its
150
+ // data. Best effort — any failure just starts the new layout from scratch.
151
+ async function migrateRenamedData(home) {
152
+ const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
153
+ const pairs = [
154
+ ['skill-mcp-manager', 'dsh-plugin-tool-management'],
155
+ ['skill-mcp-manager-notes.json', 'dsh-plugin-tool-management-notes.json'],
156
+ ['skill-mcp-manager-settings.json', 'dsh-plugin-tool-management-settings.json'],
157
+ ['skill-mcp-manager-disabled-tools.json', 'dsh-plugin-tool-management-disabled-tools.json'],
158
+ ['skill-mcp-manager-export.json', 'dsh-plugin-tool-management-export.json'],
159
+ ];
160
+ for (const [oldName, newName] of pairs) {
161
+ try {
162
+ const oldAbs = home + sep + oldName;
163
+ const newAbs = home + sep + newName;
164
+ if (!(await exists(oldAbs)) || (await exists(newAbs)))
165
+ continue;
166
+ await rename(oldAbs, newAbs);
167
+ }
168
+ catch (e) { /* best effort */ }
169
+ }
170
+ }
171
+ async function readPatch(abs) {
172
+ try {
173
+ const t = await fs.resolve(abs);
174
+ return await fs.readText(t);
175
+ }
176
+ catch (e) {
177
+ if (String(e.code) === 'FS_NOT_FOUND')
178
+ return '';
179
+ throw e;
180
+ }
181
+ }
182
+ // Timestamped copies of the previous patch content. The template upstream
183
+ // learned this the hard way: a bad edit to cordis.patch.yml can stop DSH
184
+ // from booting, so the pre-write file must stay recoverable without git.
185
+ const KEEP_PATCH_BACKUPS = 5;
186
+ function backupStamp() {
187
+ const d = new Date();
188
+ const p = (n) => String(n).padStart(2, '0');
189
+ return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
190
+ }
191
+ async function backupPatchFile(abs, previous) {
192
+ const i = Math.max(abs.lastIndexOf('\\'), abs.lastIndexOf('/'));
193
+ if (i <= 0)
194
+ return;
195
+ const dir = abs.slice(0, i);
196
+ const base = abs.slice(i + 1);
197
+ await writeFile(abs + '.bak-' + backupStamp(), previous, 'utf8');
198
+ try {
199
+ const names = (await readdir(dir)).filter((name) => name.startsWith(base + '.bak-')).sort();
200
+ for (const stale of names.slice(0, Math.max(0, names.length - KEEP_PATCH_BACKUPS))) {
201
+ await unlink(dir + abs[i] + stale);
202
+ }
203
+ }
204
+ catch (e) { /* pruning is best-effort */ }
205
+ }
206
+ async function writePatch(abs, content) {
207
+ const t = await fs.resolve(abs);
208
+ const policy = await sandboxPolicy.resolve({ mode: 'danger-full-access' });
209
+ try {
210
+ const previous = await readPatch(abs);
211
+ if (previous && previous !== content)
212
+ await backupPatchFile(abs, previous);
213
+ }
214
+ catch (e) { /* a failed backup must never block the write */ }
215
+ await fs.writeText(t, content, undefined, undefined, policy);
216
+ }
217
+ // ---------- duplicate loader-id guard ----------
218
+ // Two rows with the same loader id make the plugin composition fail to
219
+ // boot (upstream hit exactly this after renaming an entry), so duplicates
220
+ // are reported on read and new ones are refused before any write.
221
+ function duplicateIdsOf(content) {
222
+ const counts = new Map();
223
+ for (const row of parseRows(content).rows) {
224
+ if (!row.id)
225
+ continue;
226
+ counts.set(row.id, (counts.get(row.id) || 0) + 1);
227
+ }
228
+ return [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
229
+ }
230
+ function duplicateGuard(before, after) {
231
+ const known = new Set(duplicateIdsOf(before));
232
+ const introduced = duplicateIdsOf(after).filter((id) => !known.has(id));
233
+ if (!introduced.length)
234
+ return null;
235
+ return { ok: false, error: '写入会产生重复的 loader id(重复 id 会导致 DSH 无法启动):' + introduced.join('、') };
236
+ }
237
+ // ---------- YAML generation ----------
238
+ function yq(v) { return typeof v === 'string' ? JSON.stringify(v) : String(v); }
239
+ function yplain(v) { return /^[A-Za-z0-9_.:@%+=/-]+$/.test(v) ? v : yq(v); }
240
+ function buildInsertBlock(row) {
241
+ const lines = [
242
+ '# dsh-plugin-tool-management:server:' + row.id,
243
+ '- insert:',
244
+ ' - id: ' + yplain(row.id),
245
+ " name: '@deepseek-ai/dsh-mcp-client'",
246
+ ' config:',
247
+ ' serverName: ' + yq(row.serverName),
248
+ ' transport: ' + yq(row.transport),
249
+ ];
250
+ if (row.transport === 'streamable-http') {
251
+ lines.push(' url: ' + yq(row.url || ''));
252
+ const headers = row.headers || {};
253
+ const hk = Object.keys(headers);
254
+ if (hk.length) {
255
+ lines.push(' headers:');
256
+ for (const k of hk)
257
+ lines.push(' ' + yq(k) + ': ' + yq(headers[k]));
258
+ }
259
+ }
260
+ else {
261
+ lines.push(' command: ' + yq(row.command || ''));
262
+ const args = row.args || [];
263
+ if (args.length) {
264
+ lines.push(' args:');
265
+ for (const a of args)
266
+ lines.push(' - ' + yq(a));
267
+ }
268
+ const env = row.env || {};
269
+ const ek = Object.keys(env);
270
+ if (ek.length) {
271
+ lines.push(' env:');
272
+ for (const k of ek)
273
+ lines.push(' ' + yq(k) + ': ' + yq(env[k]));
274
+ }
275
+ }
276
+ if (row.toolCallTimeoutMs)
277
+ lines.push(' toolCallTimeoutMs: ' + Number(row.toolCallTimeoutMs));
278
+ return lines.join('\n');
279
+ }
280
+ function buildDisableBlock(id, disabled) {
281
+ return [
282
+ '# dsh-plugin-tool-management:' + (disabled ? 'disable' : 'enable') + ':' + id,
283
+ '- id: ' + yplain(id),
284
+ " name: '@deepseek-ai/dsh-mcp-client'",
285
+ ' disabled: ' + (disabled ? 'true' : 'false'),
286
+ ].join('\n');
287
+ }
288
+ // ---------- YAML parsing (mini parser) ----------
289
+ // Known limitation: this hand-rolled parser assumes the exact indentation
290
+ // style that buildInsertBlock emits (config at 6 spaces, children at 8,
291
+ // nested maps/lists at 10+). Hand-edited patch files using different
292
+ // indentation may parse incorrectly — DSH itself only cares about the
293
+ // effective YAML it reads, and this parser exists purely for the UI.
294
+ function splitKV(text) {
295
+ const m = text.match(/^("(?:\\.|[^"])*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/);
296
+ if (!m)
297
+ return null;
298
+ return { key: unquote(m[1]), value: m[2] };
299
+ }
300
+ function unquote(v) {
301
+ if (v === undefined || v === null)
302
+ return v;
303
+ const s = String(v).trim();
304
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
305
+ try {
306
+ return JSON.parse(s);
307
+ }
308
+ catch (e) {
309
+ return s.slice(1, -1);
310
+ }
311
+ }
312
+ if (s.length >= 2 && s.startsWith("'") && s.endsWith("'"))
313
+ return s.slice(1, -1).replace(/''/g, "'");
314
+ if (/^\[.*\]$/.test(s))
315
+ return s.slice(1, -1).split(',').map((x) => unquote(x.trim())).filter((x) => x !== '');
316
+ if (s === 'true')
317
+ return true;
318
+ if (s === 'false')
319
+ return false;
320
+ if (/^-?\d+$/.test(s))
321
+ return Number(s);
322
+ return s;
323
+ }
324
+ function parseEntry(lines) {
325
+ const entry = { config: {} };
326
+ let inConfig = false;
327
+ let configIndent = 0;
328
+ let nested = null;
329
+ for (const line of lines) {
330
+ const trimmed = line.trim();
331
+ if (!trimmed || trimmed.startsWith('#'))
332
+ continue;
333
+ const indent = line.match(/^\s*/)[0].length;
334
+ let t = trimmed;
335
+ if (t.startsWith('- '))
336
+ t = t.slice(2).trim();
337
+ const kv = splitKV(t);
338
+ if (!kv) {
339
+ if (inConfig && nested && nested.type === 'list')
340
+ nested.current.push(unquote(t));
341
+ continue;
342
+ }
343
+ if (!inConfig) {
344
+ if (kv.key === 'config' && kv.value === '') {
345
+ inConfig = true;
346
+ configIndent = indent;
347
+ continue;
348
+ }
349
+ if (kv.key === 'id')
350
+ entry.id = unquote(kv.value);
351
+ else if (kv.key === 'name')
352
+ entry.name = unquote(kv.value);
353
+ else if (kv.key === 'disabled')
354
+ entry.disabled = kv.value === 'true';
355
+ continue;
356
+ }
357
+ if (indent <= configIndent) {
358
+ inConfig = false;
359
+ nested = null;
360
+ continue;
361
+ }
362
+ if (kv.value === '' && (kv.key === 'headers' || kv.key === 'env')) {
363
+ nested = { key: kv.key, indent, type: 'map', current: {} };
364
+ entry.config[kv.key] = nested.current;
365
+ continue;
366
+ }
367
+ if (kv.value === '' && kv.key === 'args') {
368
+ nested = { key: kv.key, indent, type: 'list', current: [] };
369
+ entry.config[kv.key] = nested.current;
370
+ continue;
371
+ }
372
+ if (nested && indent > nested.indent) {
373
+ if (nested.type === 'map')
374
+ nested.current[kv.key] = unquote(kv.value);
375
+ else if (nested.type === 'list')
376
+ nested.current.push(unquote(kv.value));
377
+ continue;
378
+ }
379
+ nested = null;
380
+ entry.config[kv.key] = unquote(kv.value);
381
+ }
382
+ return entry;
383
+ }
384
+ function parseRows(content) {
385
+ const lines = content.split(/\r?\n/);
386
+ const managedIds = new Set();
387
+ for (const line of lines) {
388
+ // Markers written by either this plugin or the template upstream count
389
+ // as managed (coexistence: both can edit the same patch file).
390
+ const m = line.match(/^# (?:dsh-plugin-tool-management|dsh-mcp-manager):server:(.+)$/);
391
+ if (m)
392
+ managedIds.add(m[1].trim());
393
+ }
394
+ const rows = [];
395
+ const overrides = [];
396
+ const blocks = [];
397
+ let current = null;
398
+ for (const line of lines) {
399
+ if (/^- /.test(line)) {
400
+ current = { text: line };
401
+ blocks.push(current);
402
+ }
403
+ else if (current) {
404
+ current.text += '\n' + line;
405
+ }
406
+ }
407
+ for (const block of blocks) {
408
+ const head = block.text.split('\n')[0];
409
+ if (/^- insert:/.test(head)) {
410
+ const parts = block.text.split('\n');
411
+ const children = [];
412
+ let j = 0;
413
+ while (j < parts.length) {
414
+ if (/^ - /.test(parts[j])) {
415
+ const child = { lines: [parts[j]] };
416
+ j++;
417
+ while (j < parts.length && !/^ - /.test(parts[j])) {
418
+ child.lines.push(parts[j]);
419
+ j++;
420
+ }
421
+ children.push(child);
422
+ }
423
+ else
424
+ j++;
425
+ }
426
+ for (const child of children) {
427
+ const entry = parseEntry(child.lines);
428
+ if (entry && entry.name === '@deepseek-ai/dsh-mcp-client') {
429
+ rows.push({ id: entry.id, name: entry.name, disabled: entry.disabled, config: entry.config, managed: managedIds.has(entry.id) });
430
+ }
431
+ }
432
+ }
433
+ else {
434
+ const entry = parseEntry(block.text.split('\n'));
435
+ if (entry && entry.name === '@deepseek-ai/dsh-mcp-client')
436
+ overrides.push({ id: entry.id, disabled: entry.disabled });
437
+ }
438
+ }
439
+ for (const o of overrides) {
440
+ const row = rows.find((r) => r.id === o.id);
441
+ if (row && o.disabled !== undefined)
442
+ row.disabled = o.disabled;
443
+ }
444
+ return { rows };
445
+ }
446
+ // ---------- line-based block editing ----------
447
+ function splitLines(content) { return content.split(/\r?\n/); }
448
+ function joinLines(lines) {
449
+ let res = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '\n');
450
+ if (!res.trim()) {
451
+ res = '[]\n';
452
+ }
453
+ else if (!/^- /m.test(res) && !/^\[\]\s*$/m.test(res)) {
454
+ // A patch file must stay a top-level YAML array: after removing the last
455
+ // entry, emit [] so loadOptionalPatches never throws on a comments-only file.
456
+ res = res.replace(/\n*$/, '\n[]\n');
457
+ }
458
+ return res;
459
+ }
460
+ function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
461
+ function markerRanges(lines, id, ops) {
462
+ const n = lines.length;
463
+ // Match this plugin's markers AND the template upstream's
464
+ // (`# dsh-mcp-manager:server|disable|enable:<id>`) so an entry written by
465
+ // either manager can be located and cleaned up without orphan blocks —
466
+ // the two plugins are designed to coexist.
467
+ const re = new RegExp('^# (?:dsh-plugin-tool-management|dsh-mcp-manager):(' + ops + '):' + escRe(id) + '$');
468
+ const ranges = [];
469
+ for (let i = 0; i < n; i++) {
470
+ if (!re.test(lines[i]))
471
+ continue;
472
+ let j = i + 1;
473
+ while (j < n && !/^- /.test(lines[j]))
474
+ j++;
475
+ let end = j;
476
+ if (j < n && /^- /.test(lines[j])) {
477
+ let k = j + 1;
478
+ while (k < n && !/^- /.test(lines[k]))
479
+ k++;
480
+ end = k;
481
+ }
482
+ ranges.push([i, end]);
483
+ }
484
+ return ranges;
485
+ }
486
+ function insertBlockRange(lines, id) {
487
+ const n = lines.length;
488
+ const entryRe = new RegExp('^\\s*- id: ' + escRe(id) + '\\s*$');
489
+ for (let i = 0; i < n; i++) {
490
+ if (!/^- insert:/.test(lines[i]))
491
+ continue;
492
+ let end = i + 1;
493
+ while (end < n && !/^- /.test(lines[end]))
494
+ end++;
495
+ if (lines.slice(i, end).some((l) => entryRe.test(l)))
496
+ return [i, end];
497
+ }
498
+ return null;
499
+ }
500
+ function bareOverrideRanges(lines, id) {
501
+ const n = lines.length;
502
+ const re = new RegExp('^- id: ' + escRe(id) + '\\s*$');
503
+ const ranges = [];
504
+ for (let i = 0; i < n; i++) {
505
+ if (!re.test(lines[i]))
506
+ continue;
507
+ let end = i + 1;
508
+ while (end < n && !/^- /.test(lines[end]))
509
+ end++;
510
+ ranges.push([i, end]);
511
+ }
512
+ return ranges;
513
+ }
514
+ function spliceRanges(lines, ranges) {
515
+ const remove = new Set();
516
+ for (const r of ranges)
517
+ for (let i = r[0]; i < r[1]; i++)
518
+ remove.add(i);
519
+ return joinLines(lines.filter((_, i) => !remove.has(i)));
520
+ }
521
+ function removeEntryAll(content, id) {
522
+ const lines = splitLines(content);
523
+ const ranges = markerRanges(lines, id, 'server|disable|enable');
524
+ const ib = insertBlockRange(lines, id);
525
+ if (ib)
526
+ ranges.push(ib);
527
+ ranges.push(...bareOverrideRanges(lines, id));
528
+ return spliceRanges(lines, ranges);
529
+ }
530
+ function removeMarked(content, id, op) {
531
+ return spliceRanges(splitLines(content), markerRanges(splitLines(content), id, op));
532
+ }
533
+ function appendBlock(content, block) {
534
+ let c = content;
535
+ if (/^\[\]\s*$/m.test(c))
536
+ c = c.replace(/^\[\]\s*$/m, block + '\n');
537
+ else
538
+ c = c.replace(/\s*$/, '\n' + block + '\n');
539
+ return c;
540
+ }
541
+ // ---------- shared state ----------
542
+ async function collectAll() {
543
+ const p = await ensurePaths();
544
+ const ids = new Set();
545
+ const serverNames = new Set();
546
+ const rows = [];
547
+ for (const level of ['project', 'global']) {
548
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
549
+ let content = '';
550
+ try {
551
+ content = await readPatch(abs);
552
+ }
553
+ catch (e) {
554
+ continue;
555
+ }
556
+ const { rows: fileRows } = parseRows(content);
557
+ for (const r of fileRows) {
558
+ ids.add(r.id);
559
+ const sn = r.config && r.config.serverName ? String(r.config.serverName) : r.id;
560
+ serverNames.add(sn);
561
+ rows.push({ id: r.id, serverName: sn, level, disabled: !!r.disabled });
562
+ }
563
+ }
564
+ return { ids, serverNames, rows };
565
+ }
566
+ const bareEntryId = (v) => { const s = String(v); const i = s.lastIndexOf(':'); return i >= 0 ? s.slice(i + 1) : s; };
567
+ async function liveEntry(id) {
568
+ if (!pluginInventory)
569
+ return null;
570
+ try {
571
+ const res = await pluginInventory.list();
572
+ return res.entries.find((e) => e.moduleName === '@deepseek-ai/dsh-mcp-client' && bareEntryId(e.entryId) === id) || null;
573
+ }
574
+ catch (e) {
575
+ return null;
576
+ }
577
+ }
578
+ async function waitFor(pred, timeoutMs, stepMs) {
579
+ const start = Date.now();
580
+ for (;;) {
581
+ const v = await pred();
582
+ if (v)
583
+ return true;
584
+ if (Date.now() - start > timeoutMs)
585
+ return false;
586
+ await wait(stepMs);
587
+ }
588
+ }
589
+ async function entryExists(id, level) {
590
+ const p = await ensurePaths();
591
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
592
+ let content = '';
593
+ try {
594
+ content = await readPatch(abs);
595
+ }
596
+ catch (e) {
597
+ return false;
598
+ }
599
+ const { rows } = parseRows(content);
600
+ if (rows.some((r) => r.id === id))
601
+ return true;
602
+ return (await liveEntry(id)) !== null;
603
+ }
604
+ function normalizeRow(r, level, abs) {
605
+ const cfg = r.config || {};
606
+ return {
607
+ id: r.id,
608
+ serverName: cfg.serverName || r.id,
609
+ transport: cfg.transport || null,
610
+ url: cfg.url || null,
611
+ command: cfg.command || null,
612
+ args: cfg.args || null,
613
+ env: cfg.env || null,
614
+ headers: cfg.headers || null,
615
+ level,
616
+ disabled: !!r.disabled,
617
+ managed: !!r.managed,
618
+ };
619
+ }
620
+ // ---------- import helpers ----------
621
+ function toStrMap(v) {
622
+ if (!v || typeof v !== 'object' || Array.isArray(v))
623
+ return {};
624
+ const out = {};
625
+ for (const k of Object.keys(v))
626
+ out[k] = String(v[k]);
627
+ return out;
628
+ }
629
+ function normalizeImportItem(item) {
630
+ if (!item || typeof item !== 'object' || Array.isArray(item))
631
+ return { ok: false, error: '条目不是对象' };
632
+ const it = item;
633
+ const serverName = String(it.serverName || '').trim();
634
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
635
+ return { ok: false, error: 'serverName 非法: ' + String(it.serverName) };
636
+ const transport = it.transport === 'stdio' ? 'stdio' : 'streamable-http';
637
+ const level = it.level === 'global' ? 'global' : 'project';
638
+ const baseId = 'mcp-' + serverName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
639
+ const rawId = String(it.id || '').trim();
640
+ const id = rawId && /^[A-Za-z0-9_.:@%+=/-]+$/.test(rawId) ? rawId : baseId;
641
+ const row = { id, serverName, transport, level, disabled: !!it.disabled };
642
+ if (transport === 'streamable-http') {
643
+ const url = String(it.url || '').trim();
644
+ if (!/^https?:\/\//.test(url))
645
+ return { ok: false, error: serverName + ': url 非法' };
646
+ row.url = url;
647
+ row.headers = toStrMap(it.headers);
648
+ }
649
+ else {
650
+ const command = String(it.command || '').trim();
651
+ if (!command)
652
+ return { ok: false, error: serverName + ': command 缺失' };
653
+ row.command = command;
654
+ row.args = Array.isArray(it.args) ? it.args.map(String) : [];
655
+ row.env = toStrMap(it.env);
656
+ }
657
+ return { ok: true, row };
658
+ }
659
+ // ---------- ops ----------
660
+ async function pluginVersion() {
661
+ return { ok: true, version: PKG_VERSION };
662
+ }
663
+ // Tool preview for one MCP server: project the model-facing schemas down to
664
+ // a name + description + parameter-summary list. The registry prefixes every
665
+ // MCP tool with `mcp__<serverName>__`; we filter on that and strip the prefix
666
+ // for display. Only the direct `properties` of the parameters object are
667
+ // summarized — nested object/array children are omitted (one level is enough
668
+ // for a preview, keeps the dialog readable).
669
+ // ---------- plugin-owned side-car data (notes / settings) ----------
670
+ // The patch file carries loader config only, so user-facing extras live in
671
+ // small JSON files under the DSH home, next to the patch files they describe.
672
+ function sidecarPath(home, name) {
673
+ return home + (home.indexOf('\\') >= 0 ? '\\' : '/') + name;
674
+ }
675
+ async function readJsonFile(abs) {
676
+ try {
677
+ return JSON.parse(await readFile(abs, 'utf8'));
678
+ }
679
+ catch (e) {
680
+ return null;
681
+ }
682
+ }
683
+ async function writeJsonFile(abs, data) {
684
+ await writeFile(abs, JSON.stringify(data, null, 2) + '\n', 'utf8');
685
+ }
686
+ // User notes are keyed by loader id: they survive renames of the server
687
+ // name and are never touched by config rewrites.
688
+ //
689
+ // Sidecar caches (notes / settings / disabled tools) are short-TTL: reads
690
+ // hit the cache, but every WRITE re-reads the file (force=true) and merges
691
+ // on top of the fresh on-disk state. Without the forced re-read, a manual
692
+ // edit of the sidecar file would be silently overwritten by a stale cache.
693
+ const SIDECAR_TTL_MS = 3000;
694
+ let notesCache = null;
695
+ async function readNotes(force = false) {
696
+ if (notesCache && !force && Date.now() - notesCache.at < SIDECAR_TTL_MS)
697
+ return notesCache.value;
698
+ const p = await ensurePaths();
699
+ const raw = await readJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'));
700
+ const out = {};
701
+ if (raw && typeof raw === 'object') {
702
+ for (const key of Object.keys(raw)) {
703
+ const value = raw[key];
704
+ if (typeof value === 'string' && value.trim())
705
+ out[key] = value;
706
+ }
707
+ }
708
+ notesCache = { at: Date.now(), value: out };
709
+ return out;
710
+ }
711
+ async function mcpmNote(args) {
712
+ const id = String((args && args.id) || '').trim();
713
+ if (!id)
714
+ return { ok: false, error: '缺少 id' };
715
+ const note = String((args && args.note) == null ? '' : args.note).trim();
716
+ const p = await ensurePaths();
717
+ return withWriteLock(async () => {
718
+ // Force re-read so an externally edited notes file is merged, not clobbered.
719
+ const map = Object.assign({}, await readNotes(true));
720
+ if (note)
721
+ map[id] = note;
722
+ else
723
+ delete map[id];
724
+ try {
725
+ await writeJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'), map);
726
+ }
727
+ catch (e) {
728
+ return { ok: false, error: '备注保存失败: ' + message(e) };
729
+ }
730
+ notesCache = { at: Date.now(), value: map };
731
+ return { ok: true, notes: map };
732
+ });
733
+ }
734
+ const SETTINGS_DEFAULTS = { pollIntervalMs: 5000, toolDescriptionMaxLength: 0 };
735
+ let pluginSettingsCache = null;
736
+ function clampInt(value, min, max, fallback) {
737
+ // Number(null) is 0 — treat missing/empty input as "use the default".
738
+ if (value === null || value === undefined || value === '')
739
+ return fallback;
740
+ const n = Number(value);
741
+ if (!Number.isFinite(n))
742
+ return fallback;
743
+ return Math.min(max, Math.max(min, Math.round(n)));
744
+ }
745
+ async function readPluginSettings(force = false) {
746
+ if (pluginSettingsCache && !force && Date.now() - pluginSettingsCache.at < SIDECAR_TTL_MS)
747
+ return pluginSettingsCache.value;
748
+ const p = await ensurePaths();
749
+ const raw = await readJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-settings.json'));
750
+ pluginSettingsCache = {
751
+ at: Date.now(),
752
+ value: {
753
+ pollIntervalMs: clampInt(raw && raw.pollIntervalMs, 2000, 60000, SETTINGS_DEFAULTS.pollIntervalMs),
754
+ // 0 = keep descriptions in full (the UI default); > 0 truncates.
755
+ toolDescriptionMaxLength: clampInt(raw && raw.toolDescriptionMaxLength, 0, 2000, SETTINGS_DEFAULTS.toolDescriptionMaxLength),
756
+ },
757
+ };
758
+ return pluginSettingsCache.value;
759
+ }
760
+ async function mcpmSettings(args) {
761
+ const current = await readPluginSettings();
762
+ if (!args || args.set !== true)
763
+ return { ok: true, settings: current };
764
+ const next = {
765
+ pollIntervalMs: clampInt(args.pollIntervalMs, 2000, 60000, current.pollIntervalMs),
766
+ toolDescriptionMaxLength: clampInt(args.toolDescriptionMaxLength, 0, 2000, current.toolDescriptionMaxLength),
767
+ };
768
+ const p = await ensurePaths();
769
+ return withWriteLock(async () => {
770
+ try {
771
+ await writeJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-settings.json'), next);
772
+ }
773
+ catch (e) {
774
+ return { ok: false, error: '设置保存失败: ' + message(e) };
775
+ }
776
+ pluginSettingsCache = { at: Date.now(), value: next };
777
+ return { ok: true, settings: next };
778
+ });
779
+ }
780
+ // ---------- per-tool enable/disable (execution + visibility boundary) ----------
781
+ // dsh-mcp-client has no per-tool config, but the DSH tool runtime exposes
782
+ // two official seams: `tools.restrict({ deny })` removes a global tool from
783
+ // every scope's model-visible schema list, and `tools.guard` denies the call
784
+ // before the body runs. Disabled tools therefore become invisible AND
785
+ // uncallable — no patch rewrite, no DSH restart.
786
+ const DISABLED_TOOLS_FILE = 'dsh-plugin-tool-management-disabled-tools.json';
787
+ let disabledToolsCache = null;
788
+ async function readDisabledTools(force = false) {
789
+ if (disabledToolsCache && !force && Date.now() - disabledToolsCache.at < SIDECAR_TTL_MS)
790
+ return disabledToolsCache.value;
791
+ const p = await ensurePaths();
792
+ const raw = await readJsonFile(sidecarPath(p.home, DISABLED_TOOLS_FILE));
793
+ const out = {};
794
+ if (raw && typeof raw === 'object') {
795
+ for (const serverName of Object.keys(raw)) {
796
+ const list = raw[serverName];
797
+ if (Array.isArray(list)) {
798
+ const names = list.map((name) => String(name)).filter((name) => /^[A-Za-z0-9_-]{1,128}$/.test(name));
799
+ if (names.length)
800
+ out[serverName] = names;
801
+ }
802
+ }
803
+ }
804
+ disabledToolsCache = { at: Date.now(), value: out };
805
+ return out;
806
+ }
807
+ /** Fully-qualified model-facing names (`mcp__<serverName>__<tool>`) of every disabled tool. */
808
+ function disabledToolNames(map) {
809
+ const out = [];
810
+ for (const serverName of Object.keys(map)) {
811
+ for (const tool of map[serverName])
812
+ out.push('mcp__' + serverName + '__' + tool);
813
+ }
814
+ return out;
815
+ }
816
+ async function mcpmToolEnabled(args) {
817
+ const serverName = String((args && args.serverName) || '').trim();
818
+ const tool = String((args && args.tool) || '').trim();
819
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
820
+ return { ok: false, error: 'serverName 不合法' };
821
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(tool))
822
+ return { ok: false, error: 'tool 名称不合法' };
823
+ const enabled = (args && args.enabled) !== false;
824
+ const p = await ensurePaths();
825
+ return withWriteLock(async () => {
826
+ // Force re-read so an externally edited disabled-tools file is merged, not clobbered.
827
+ const map = Object.assign({}, await readDisabledTools(true));
828
+ const disabled = new Set(map[serverName] || []);
829
+ if (enabled)
830
+ disabled.delete(tool);
831
+ else
832
+ disabled.add(tool);
833
+ if (disabled.size)
834
+ map[serverName] = [...disabled].sort();
835
+ else
836
+ delete map[serverName];
837
+ try {
838
+ await writeJsonFile(sidecarPath(p.home, DISABLED_TOOLS_FILE), map);
839
+ }
840
+ catch (e) {
841
+ return { ok: false, error: '保存失败: ' + message(e) };
842
+ }
843
+ disabledToolsCache = { at: Date.now(), value: map };
844
+ await applyToolRestrictions();
845
+ return { ok: true, serverName, disabled: map[serverName] || [] };
846
+ });
847
+ }
848
+ // Visibility seam: keep one active restriction, refreshed whenever the tool
849
+ // set or the disabled set changes. `restrict` fails on unknown names, so the
850
+ // deny list is always intersected with the currently registered tools.
851
+ let restrictDisposer = null;
852
+ let restrictTimer = null;
853
+ async function applyToolRestrictions() {
854
+ if (typeof tools.restrict !== 'function')
855
+ return;
856
+ // Force re-read: this runs on the tools/change path, which is rare, so a
857
+ // stale cache must not keep an externally edited deny list hidden.
858
+ const map = await readDisabledTools(true);
859
+ const wanted = disabledToolNames(map);
860
+ let registered;
861
+ try {
862
+ registered = new Set((await tools.schemas()).map((schema) => String(schema.name)));
863
+ }
864
+ catch (e) {
865
+ return;
866
+ }
867
+ const names = wanted.filter((name) => registered.has(name));
868
+ if (restrictDisposer) {
869
+ try {
870
+ restrictDisposer();
871
+ }
872
+ catch (e) { /* ignore */ }
873
+ restrictDisposer = null;
874
+ }
875
+ if (!names.length)
876
+ return;
877
+ try {
878
+ restrictDisposer = tools.restrict({ deny: names });
879
+ }
880
+ catch (e) { /* registry race: the next tools/change event retries */ }
881
+ }
882
+ function scheduleToolRestrictions() {
883
+ if (restrictTimer)
884
+ return;
885
+ restrictTimer = setTimeout(() => {
886
+ restrictTimer = null;
887
+ applyToolRestrictions().catch(() => { });
888
+ }, 300);
889
+ }
890
+ function truncateText(value, limit) {
891
+ if (!limit || value.length <= limit)
892
+ return value;
893
+ return value.slice(0, Math.max(1, limit - 1)).replace(/\s+$/, '') + '…';
894
+ }
895
+ // ---------- secret masking (UI view only) ----------
896
+ // Only sensitive-looking keys are masked; `$VAR` / `!!js` references are
897
+ // indirections rather than secrets, so they stay readable. URL query strings
898
+ // are redacted because MCP credentials often ride there.
899
+ const SENSITIVE_KEY_RE = /(token|secret|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private[_-]?key|cookie|session|signature|bearer)/i;
900
+ function maskSecretValue(value) {
901
+ const text = String(value == null ? '' : value);
902
+ if (text === '')
903
+ return '';
904
+ if (text.startsWith('$'))
905
+ return text;
906
+ if (/^!!js\s/.test(text))
907
+ return text;
908
+ return '••••••';
909
+ }
910
+ function maskValueMap(map, onlySensitiveKeys) {
911
+ if (!map || typeof map !== 'object')
912
+ return map;
913
+ const out = {};
914
+ for (const key of Object.keys(map)) {
915
+ out[key] = (!onlySensitiveKeys || SENSITIVE_KEY_RE.test(key)) ? maskSecretValue(map[key]) : String(map[key]);
916
+ }
917
+ return out;
918
+ }
919
+ function maskUrlQuery(url) {
920
+ if (!url)
921
+ return url;
922
+ try {
923
+ const parsed = new URL(url);
924
+ if (parsed.search)
925
+ parsed.search = '?<redacted>';
926
+ return parsed.toString();
927
+ }
928
+ catch (e) {
929
+ return url;
930
+ }
931
+ }
932
+ /** Shared UI rows: mcpmList plus notes, secrets masked unless `reveal`. */
933
+ async function mcpmRowsWithNotes(reveal) {
934
+ const result = await mcpmList();
935
+ if (!result || result.ok === false)
936
+ return result;
937
+ const notes = await readNotes();
938
+ const rows = (result.rows || []).map((row) => {
939
+ const view = Object.assign({}, row, { notes: notes[row.id] || '' });
940
+ if (!reveal) {
941
+ view.url = maskUrlQuery(row.url);
942
+ view.headers = maskValueMap(row.headers, true);
943
+ view.env = maskValueMap(row.env, true);
944
+ }
945
+ return view;
946
+ });
947
+ return Object.assign({}, result, { rows });
948
+ }
949
+ /** Default list view: secrets always masked. */
950
+ async function mcpmListView() {
951
+ return mcpmRowsWithNotes(false);
952
+ }
953
+ /**
954
+ * Plain-text view. Split from mcpm-list into its own op so it can live in
955
+ * WRITE_OPS: `mcpm-list` stays token-free for rendering, while revealing
956
+ * env/headers secrets requires `x-dsh-token` when a token is configured —
957
+ * otherwise a LAN-exposed port would leak credentials read-only.
958
+ */
959
+ async function mcpmReveal() {
960
+ return mcpmRowsWithNotes(true);
961
+ }
962
+ async function mcpmTools(args) {
963
+ const serverName = String(args && args.serverName || '').trim();
964
+ if (!serverName)
965
+ return { ok: false, error: 'serverName 不能为空' };
966
+ const prefix = 'mcp__' + serverName + '__';
967
+ let schemas = [];
968
+ try {
969
+ schemas = await tools.schemas();
970
+ }
971
+ catch (e) {
972
+ return { ok: false, error: message(e) };
973
+ }
974
+ const descriptionLimit = (await readPluginSettings()).toolDescriptionMaxLength;
975
+ const disabledHere = new Set((await readDisabledTools())[serverName] || []);
976
+ const toolsList = [];
977
+ for (const s of schemas) {
978
+ const fullName = String(s && s.name || '');
979
+ if (!fullName.startsWith(prefix))
980
+ continue;
981
+ const rawName = fullName.slice(prefix.length);
982
+ const params = [];
983
+ const props = s.parameters && typeof s.parameters === 'object' ? s.parameters.properties : null;
984
+ const requiredSet = new Set();
985
+ if (s.parameters && Array.isArray(s.parameters.required)) {
986
+ for (const k of s.parameters.required)
987
+ if (typeof k === 'string')
988
+ requiredSet.add(k);
989
+ }
990
+ if (props && typeof props === 'object') {
991
+ for (const [key, spec] of Object.entries(props)) {
992
+ const ps = (spec && typeof spec === 'object') ? spec : {};
993
+ params.push({
994
+ key,
995
+ required: requiredSet.has(key),
996
+ type: typeof ps.type === 'string' ? ps.type : 'any',
997
+ ...(typeof ps.description === 'string' ? { description: ps.description } : {}),
998
+ });
999
+ }
1000
+ }
1001
+ toolsList.push({
1002
+ name: rawName,
1003
+ description: truncateText(typeof s.description === 'string' ? s.description : '', descriptionLimit),
1004
+ enabled: !disabledHere.has(rawName),
1005
+ parameters: params,
1006
+ });
1007
+ }
1008
+ // Some dsh-tools builds strip restricted tools from schemas(); without
1009
+ // this merge a disabled tool would vanish from the dialog with no way to
1010
+ // re-enable it from the UI. Names come from the sidecar, so they always
1011
+ // stay reachable; the description is unavailable once the schema is gone.
1012
+ const listed = new Set(toolsList.map((t) => t.name));
1013
+ for (const name of disabledHere) {
1014
+ if (!listed.has(name)) {
1015
+ toolsList.push({ name, description: '(已停用;描述暂不可用)', enabled: false, parameters: [] });
1016
+ }
1017
+ }
1018
+ return { ok: true, tools: toolsList };
1019
+ }
1020
+ async function mcpmList() {
1021
+ const p = await ensurePaths();
1022
+ const rows = [];
1023
+ const errors = [];
1024
+ for (const level of ['project', 'global']) {
1025
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
1026
+ let content = '';
1027
+ try {
1028
+ content = await readPatch(abs);
1029
+ }
1030
+ catch (e) {
1031
+ errors.push(level + ': ' + message(e));
1032
+ continue;
1033
+ }
1034
+ const { rows: fileRows } = parseRows(content);
1035
+ for (const r of fileRows)
1036
+ rows.push(normalizeRow(r, level, abs));
1037
+ }
1038
+ const toolCounts = {};
1039
+ try {
1040
+ const schemas = await tools.schemas();
1041
+ const seen = new Set();
1042
+ for (const s of schemas) {
1043
+ const fullName = String(s && s.name || '');
1044
+ seen.add(fullName);
1045
+ const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__/);
1046
+ if (m)
1047
+ toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
1048
+ }
1049
+ // Restricted tools may be absent from schemas(); they still belong to
1050
+ // the per-server count so the UI does not show a phantom drop.
1051
+ for (const fullName of disabledToolNames(await readDisabledTools())) {
1052
+ if (seen.has(fullName))
1053
+ continue;
1054
+ const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__/);
1055
+ if (m)
1056
+ toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
1057
+ }
1058
+ }
1059
+ catch (e) { /* ignore */ }
1060
+ let live = [];
1061
+ if (pluginInventory) {
1062
+ try {
1063
+ const res = await pluginInventory.list();
1064
+ live = res.entries.filter((e) => e.moduleName === '@deepseek-ai/dsh-mcp-client');
1065
+ }
1066
+ catch (e) { /* ignore */ }
1067
+ }
1068
+ for (const e of live) {
1069
+ const bid = bareEntryId(e.entryId);
1070
+ const found = rows.find((r) => r.id === bid);
1071
+ if (found)
1072
+ found.live = { enabled: e.enabled, phase: e.fiberPhase };
1073
+ else
1074
+ rows.push({ id: e.entryId, serverName: e.entryId, transport: null, url: null, command: null, args: null, env: null, headers: null, level: 'loader', disabled: !e.enabled, managed: false, live: { enabled: e.enabled, phase: e.fiberPhase } });
1075
+ }
1076
+ for (const row of rows) {
1077
+ if (row.toolCount === undefined)
1078
+ row.toolCount = toolCounts[row.serverName] || 0;
1079
+ }
1080
+ // A loader id that appears twice (same id in both patch files, or twice in
1081
+ // one) makes the composition fail to boot. Surface it instead of hiding it.
1082
+ const idCounts = new Map();
1083
+ for (const row of rows)
1084
+ idCounts.set(String(row.id), (idCounts.get(String(row.id)) || 0) + 1);
1085
+ const duplicateIds = [...idCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
1086
+ for (const row of rows)
1087
+ row.duplicate = duplicateIds.indexOf(String(row.id)) >= 0;
1088
+ const warnings = [];
1089
+ if (duplicateIds.length)
1090
+ warnings.push('检测到重复的 loader id(会导致 DSH 无法启动,请手动清理补丁文件):' + duplicateIds.join('、'));
1091
+ return {
1092
+ ok: true,
1093
+ rows,
1094
+ paths: { project: p.projectPatch, global: p.globalPatch, home: p.home, profile: p.profileName },
1095
+ errors,
1096
+ warnings,
1097
+ };
1098
+ }
1099
+ async function mcpmAdd(args) {
1100
+ const p = await ensurePaths();
1101
+ const serverName = String(args.serverName || '').trim();
1102
+ const transport = args.transport === 'stdio' ? 'stdio' : 'streamable-http';
1103
+ const level = args.level === 'global' ? 'global' : 'project';
1104
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
1105
+ return { ok: false, error: 'serverName 需为 1-32 位 [A-Za-z0-9_-]' };
1106
+ const baseId = 'mcp-' + serverName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
1107
+ const existing = await collectAll();
1108
+ if (existing.serverNames.has(serverName))
1109
+ return { ok: false, error: 'serverName "' + serverName + '" 已存在' };
1110
+ let id = baseId;
1111
+ let n = 2;
1112
+ while (existing.ids.has(id)) {
1113
+ id = baseId + '-' + n;
1114
+ n++;
1115
+ }
1116
+ const row = { id, serverName, transport };
1117
+ if (transport === 'streamable-http') {
1118
+ const url = String(args.url || '').trim();
1119
+ if (!/^https?:\/\//.test(url))
1120
+ return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
1121
+ row.url = url;
1122
+ row.headers = parseKv(args.headers);
1123
+ }
1124
+ else {
1125
+ const command = String(args.command || '').trim();
1126
+ if (!command)
1127
+ return { ok: false, error: 'command 不能为空' };
1128
+ row.command = command;
1129
+ row.args = parseArgs(args.args);
1130
+ row.env = parseKv(args.env);
1131
+ }
1132
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1133
+ return withWriteLock(async () => {
1134
+ let content = '';
1135
+ try {
1136
+ content = await readPatch(abs);
1137
+ }
1138
+ catch (e) {
1139
+ return { ok: false, error: '读取补丁失败: ' + message(e) };
1140
+ }
1141
+ const before = content;
1142
+ content = appendBlock(content, buildInsertBlock(row));
1143
+ if (args.enabled === false)
1144
+ content = appendBlock(content, buildDisableBlock(id, true));
1145
+ const guard = duplicateGuard(before, content);
1146
+ if (guard)
1147
+ return guard;
1148
+ try {
1149
+ await writePatch(abs, content);
1150
+ }
1151
+ catch (e) {
1152
+ return { ok: false, error: '写入补丁失败: ' + message(e) };
1153
+ }
1154
+ return { ok: true, row: { ...row, level, disabled: args.enabled === false } };
1155
+ });
1156
+ }
1157
+ async function mcpmEdit(args) {
1158
+ const p = await ensurePaths();
1159
+ const id = String(args.id || '');
1160
+ const level = args.level === 'global' ? 'global' : 'project';
1161
+ if (!id)
1162
+ return { ok: false, error: '缺少 id' };
1163
+ const all = await collectAll();
1164
+ const cur = all.rows.find((r) => r.id === id);
1165
+ if (!cur)
1166
+ return { ok: false, error: '未找到条目 ' + id };
1167
+ const serverName = String(args.serverName || '').trim();
1168
+ const transport = args.transport === 'stdio' ? 'stdio' : 'streamable-http';
1169
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
1170
+ return { ok: false, error: 'serverName 需为 1-32 位 [A-Za-z0-9_-]' };
1171
+ if (serverName !== cur.serverName && all.serverNames.has(serverName))
1172
+ return { ok: false, error: 'serverName "' + serverName + '" 已被其他服务占用' };
1173
+ const row = { id, serverName, transport };
1174
+ if (transport === 'streamable-http') {
1175
+ const url = String(args.url || '').trim();
1176
+ if (!/^https?:\/\//.test(url))
1177
+ return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
1178
+ row.url = url;
1179
+ row.headers = parseKv(args.headers);
1180
+ }
1181
+ else {
1182
+ const command = String(args.command || '').trim();
1183
+ if (!command)
1184
+ return { ok: false, error: 'command 不能为空' };
1185
+ row.command = command;
1186
+ row.args = parseArgs(args.args);
1187
+ row.env = parseKv(args.env);
1188
+ }
1189
+ const oldAbs = cur.level === 'global' ? p.globalPatch : p.projectPatch;
1190
+ const newAbs = level === 'global' ? p.globalPatch : p.projectPatch;
1191
+ const block = buildInsertBlock(row);
1192
+ return withWriteLock(async () => {
1193
+ // Per-tool disable state is keyed by the serverName namespace: migrate
1194
+ // it when a rename moves the tools to a new prefix.
1195
+ if (serverName !== cur.serverName) {
1196
+ const toolMap = Object.assign({}, await readDisabledTools(true));
1197
+ if (toolMap[cur.serverName]) {
1198
+ toolMap[serverName] = toolMap[cur.serverName];
1199
+ delete toolMap[cur.serverName];
1200
+ try {
1201
+ await writeJsonFile(sidecarPath(p.home, DISABLED_TOOLS_FILE), toolMap);
1202
+ }
1203
+ catch (e) { /* non-fatal */ }
1204
+ disabledToolsCache = { at: Date.now(), value: toolMap };
1205
+ }
1206
+ }
1207
+ if (oldAbs !== newAbs) {
1208
+ // Level migration: remove from the old file, insert into the new one.
1209
+ // Not atomic, so keep the old content and restore it if the second
1210
+ // write fails — losing the entry is worse than a transient dup.
1211
+ const origOld = await readPatch(oldAbs);
1212
+ let c = origOld;
1213
+ c = removeEntryAll(c, id);
1214
+ try {
1215
+ await writePatch(oldAbs, c);
1216
+ }
1217
+ catch (e) {
1218
+ return { ok: false, error: '写入失败: ' + message(e) };
1219
+ }
1220
+ let c2 = await readPatch(newAbs);
1221
+ const beforeNew = c2;
1222
+ c2 = appendBlock(c2, block);
1223
+ if (cur.disabled)
1224
+ c2 = appendBlock(c2, buildDisableBlock(id, true));
1225
+ const migrationGuard = duplicateGuard(beforeNew, c2);
1226
+ if (migrationGuard) {
1227
+ try {
1228
+ await writePatch(oldAbs, origOld);
1229
+ }
1230
+ catch (e2) { /* best effort */ }
1231
+ return migrationGuard;
1232
+ }
1233
+ try {
1234
+ await writePatch(newAbs, c2);
1235
+ }
1236
+ catch (e) {
1237
+ try {
1238
+ await writePatch(oldAbs, origOld);
1239
+ }
1240
+ catch (e2) { /* best effort */ }
1241
+ return { ok: false, error: '写入失败(已回滚): ' + message(e) };
1242
+ }
1243
+ }
1244
+ else {
1245
+ let c = await readPatch(newAbs);
1246
+ const before = c;
1247
+ c = removeEntryAll(c, id);
1248
+ c = appendBlock(c, block);
1249
+ if (cur.disabled)
1250
+ c = appendBlock(c, buildDisableBlock(id, true));
1251
+ const guard = duplicateGuard(before, c);
1252
+ if (guard)
1253
+ return guard;
1254
+ await writePatch(newAbs, c);
1255
+ }
1256
+ return { ok: true };
1257
+ });
1258
+ }
1259
+ async function mcpmSetEnabled(args) {
1260
+ const p = await ensurePaths();
1261
+ const { id, level } = args;
1262
+ const enabled = !!args.enabled;
1263
+ if (!id || (level !== 'global' && level !== 'project'))
1264
+ return { ok: false, error: '缺少 id 或 level' };
1265
+ if (!(await entryExists(id, level)))
1266
+ return { ok: false, error: '未找到条目 ' + id };
1267
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1268
+ return withWriteLock(async () => {
1269
+ let c = await readPatch(abs);
1270
+ if (enabled) {
1271
+ // Drop every `disabled: true` override for this id. If the insert row
1272
+ // itself still says disabled (e.g. user hand-edited it), append an
1273
+ // explicit `disabled: false` override so the effective state flips.
1274
+ // Enable overrides are intentionally left in place — they are the
1275
+ // mechanism that lets a disabled-by-default row be turned on.
1276
+ c = removeMarked(c, id, 'disable');
1277
+ const { rows } = parseRows(c);
1278
+ const row = rows.find((r) => r.id === id);
1279
+ if (row && row.disabled)
1280
+ c = appendBlock(c, buildDisableBlock(id, false));
1281
+ }
1282
+ else {
1283
+ c = removeMarked(c, id, 'enable');
1284
+ c = appendBlock(c, buildDisableBlock(id, true));
1285
+ }
1286
+ await writePatch(abs, c);
1287
+ return { ok: true };
1288
+ });
1289
+ }
1290
+ // Bulk enable/disable for every patch-resident server row. Covers both
1291
+ // levels at once, or a single level via args.level. Loader-only rows
1292
+ // (never written to a patch file) are out of scope. Each file is rewritten
1293
+ // at most once; rows already in the target state are left untouched.
1294
+ async function mcpmSetAll(args) {
1295
+ const p = await ensurePaths();
1296
+ const enabled = !!args.enabled;
1297
+ const levelFilter = args.level === 'global' ? 'global' : args.level === 'project' ? 'project' : null;
1298
+ return withWriteLock(async () => {
1299
+ const changed = [];
1300
+ for (const level of ['project', 'global']) {
1301
+ if (levelFilter && levelFilter !== level)
1302
+ continue;
1303
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
1304
+ let c = '';
1305
+ try {
1306
+ c = await readPatch(abs);
1307
+ }
1308
+ catch (e) {
1309
+ continue;
1310
+ }
1311
+ const { rows } = parseRows(c);
1312
+ for (const row of rows) {
1313
+ if (!!row.disabled === !enabled)
1314
+ continue;
1315
+ changed.push(row.id);
1316
+ if (enabled) {
1317
+ c = removeMarked(c, row.id, 'disable');
1318
+ const { rows: after } = parseRows(c);
1319
+ const still = after.find((r) => r.id === row.id);
1320
+ if (still && still.disabled)
1321
+ c = appendBlock(c, buildDisableBlock(row.id, false));
1322
+ }
1323
+ else {
1324
+ c = removeMarked(c, row.id, 'enable');
1325
+ c = appendBlock(c, buildDisableBlock(row.id, true));
1326
+ }
1327
+ }
1328
+ await writePatch(abs, c);
1329
+ }
1330
+ return { ok: true, enabled, changed };
1331
+ });
1332
+ }
1333
+ /** Effective disabled state of a patch row (insert-row flag merged with override blocks). */
1334
+ async function isRowDisabled(id, level) {
1335
+ const p = await ensurePaths();
1336
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1337
+ try {
1338
+ const { rows } = parseRows(await readPatch(abs));
1339
+ const row = rows.find((r) => r.id === id);
1340
+ return row ? !!row.disabled : false;
1341
+ }
1342
+ catch (e) {
1343
+ return false;
1344
+ }
1345
+ }
1346
+ async function mcpmRestart(args) {
1347
+ const p = await ensurePaths();
1348
+ const { id, level } = args;
1349
+ if (!id || (level !== 'global' && level !== 'project'))
1350
+ return { ok: false, error: '缺少 id 或 level' };
1351
+ if (!(await entryExists(id, level)))
1352
+ return { ok: false, error: '未找到条目 ' + id };
1353
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1354
+ // A restart reconnects the server; it must NOT flip the enabled state.
1355
+ // The recovery write below used to strip every disable override, which
1356
+ // silently re-enabled servers the user had disabled on purpose — so the
1357
+ // pre-restart state is captured and restored.
1358
+ const wasDisabled = await isRowDisabled(id, level);
1359
+ const warnings = [];
1360
+ // Phase 1 write (short lock): force-disable. Stale disable overrides are
1361
+ // cleared first so duplicate blocks never accumulate.
1362
+ await withWriteLock(async () => {
1363
+ let c = await readPatch(abs);
1364
+ c = removeMarked(c, id, 'enable');
1365
+ c = removeMarked(c, id, 'disable');
1366
+ c = appendBlock(c, buildDisableBlock(id, true));
1367
+ await writePatch(abs, c);
1368
+ });
1369
+ if (pluginInventory) {
1370
+ const off = await waitFor(async () => {
1371
+ const e = await liveEntry(id);
1372
+ return e ? e.enabled === false : false;
1373
+ }, 5000, 300);
1374
+ if (!off)
1375
+ warnings.push('loader 未在 5 秒内停用该服务');
1376
+ }
1377
+ await wait(1000);
1378
+ // Phase 2 write (short lock): restore the pre-restart state. The polling
1379
+ // waits deliberately run OUTSIDE the write lock — holding the global
1380
+ // write lock for up to ~11s stalled every other write op.
1381
+ await withWriteLock(async () => {
1382
+ let c = await readPatch(abs);
1383
+ c = removeMarked(c, id, 'disable');
1384
+ if (wasDisabled)
1385
+ c = appendBlock(c, buildDisableBlock(id, true));
1386
+ await writePatch(abs, c);
1387
+ });
1388
+ if (pluginInventory) {
1389
+ if (!wasDisabled) {
1390
+ const on = await waitFor(async () => {
1391
+ const e = await liveEntry(id);
1392
+ return e ? e.enabled === true : false;
1393
+ }, 5000, 300);
1394
+ if (!on)
1395
+ warnings.push('loader 未在 5 秒内重新启用该服务');
1396
+ }
1397
+ }
1398
+ else
1399
+ await wait(1500);
1400
+ return warnings.length ? { ok: true, warning: warnings.join(';') } : { ok: true };
1401
+ }
1402
+ async function mcpmRemove(args) {
1403
+ const p = await ensurePaths();
1404
+ const { id, level } = args;
1405
+ if (!id || (level !== 'global' && level !== 'project'))
1406
+ return { ok: false, error: '缺少 id 或 level' };
1407
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1408
+ return withWriteLock(async () => {
1409
+ let c = await readPatch(abs);
1410
+ c = removeEntryAll(c, id);
1411
+ await writePatch(abs, c);
1412
+ return { ok: true };
1413
+ });
1414
+ }
1415
+ async function mcpmExport() {
1416
+ const p = await ensurePaths();
1417
+ const list = await mcpmList();
1418
+ const rows = (list.rows || []).filter((r) => r.level !== 'loader').map((r) => ({
1419
+ id: r.id,
1420
+ serverName: r.serverName,
1421
+ transport: r.transport,
1422
+ url: r.url || undefined,
1423
+ command: r.command || undefined,
1424
+ args: r.args || undefined,
1425
+ env: r.env || undefined,
1426
+ headers: r.headers || undefined,
1427
+ level: r.level,
1428
+ disabled: r.disabled,
1429
+ }));
1430
+ const json = JSON.stringify({ exportedAt: new Date().toISOString(), rows }, null, 2);
1431
+ let savedTo = null;
1432
+ try {
1433
+ const abs = p.home + (p.home.indexOf('\\') >= 0 ? '\\' : '/') + 'dsh-plugin-tool-management-export.json';
1434
+ await writePatch(abs, json);
1435
+ savedTo = abs;
1436
+ }
1437
+ catch (e) { /* non-fatal */ }
1438
+ return { ok: true, json, savedTo };
1439
+ }
1440
+ async function mcpmImport(args) {
1441
+ const p = await ensurePaths();
1442
+ const overwrite = !!(args && args.conflict === 'overwrite');
1443
+ let parsed = null;
1444
+ try {
1445
+ parsed = JSON.parse(String(args.json || ''));
1446
+ }
1447
+ catch (e) {
1448
+ return { ok: false, error: 'JSON 解析失败: ' + message(e) };
1449
+ }
1450
+ const entries = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.rows) ? parsed.rows : null);
1451
+ if (!entries)
1452
+ return { ok: false, error: '导入内容格式不正确:需要数组或 { rows: [...] }' };
1453
+ const added = [];
1454
+ const overwritten = [];
1455
+ const skipped = [];
1456
+ for (const item of entries) {
1457
+ const norm = normalizeImportItem(item);
1458
+ if (!norm.ok) {
1459
+ skipped.push({ id: (item && (item.id || item.serverName)) || '?', reason: norm.error });
1460
+ continue;
1461
+ }
1462
+ const row = norm.row;
1463
+ // Existence checks run INSIDE the write lock so two concurrent imports
1464
+ // (or an import racing an add) cannot both pass the same-id/same-name
1465
+ // check and duplicate rows (TOCTOU).
1466
+ const res = await withWriteLock(async () => {
1467
+ const existing = await collectAll();
1468
+ const idTaken = existing.ids.has(row.id);
1469
+ const nameTaken = existing.serverNames.has(row.serverName);
1470
+ // serverName is globally unique: a DIFFERENT id owning the name is
1471
+ // always skipped, even in overwrite mode.
1472
+ if (nameTaken && !idTaken)
1473
+ return { skipped: true, reason: 'serverName 已存在' };
1474
+ if (idTaken && !overwrite)
1475
+ return { skipped: true, reason: 'id 已存在' };
1476
+ if (idTaken && overwrite) {
1477
+ // Purge every trace of the id from BOTH patch files first, then
1478
+ // insert the imported row at its own level.
1479
+ for (const lvl of ['project', 'global']) {
1480
+ const lAbs = lvl === 'global' ? p.globalPatch : p.projectPatch;
1481
+ let lContent = '';
1482
+ try {
1483
+ lContent = await readPatch(lAbs);
1484
+ }
1485
+ catch (e) {
1486
+ continue;
1487
+ }
1488
+ lContent = removeEntryAll(lContent, row.id);
1489
+ try {
1490
+ await writePatch(lAbs, lContent);
1491
+ }
1492
+ catch (e) {
1493
+ return { skipped: true, reason: '覆盖旧条目失败: ' + message(e) };
1494
+ }
1495
+ }
1496
+ }
1497
+ const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
1498
+ let c = await readPatch(abs);
1499
+ const before = c;
1500
+ c = appendBlock(c, buildInsertBlock(row));
1501
+ if (row.disabled)
1502
+ c = appendBlock(c, buildDisableBlock(row.id, true));
1503
+ if (duplicateGuard(before, c))
1504
+ return { skipped: true, reason: '会产生重复的 loader id' };
1505
+ await writePatch(abs, c);
1506
+ return { added: true, wasOverwrite: idTaken && overwrite };
1507
+ });
1508
+ if (res.added) {
1509
+ added.push(row.id);
1510
+ if (res.wasOverwrite)
1511
+ overwritten.push(row.id);
1512
+ }
1513
+ else
1514
+ skipped.push({ id: row.id, reason: (res && res.reason) || '写入失败' });
1515
+ }
1516
+ return overwrite ? { ok: true, added, overwritten, skipped } : { ok: true, added, skipped };
1517
+ }
1518
+ function parseKv(text) {
1519
+ const out = {};
1520
+ String(text || '').split(/\r?\n/).forEach((line) => {
1521
+ const t = line.trim();
1522
+ if (!t || t.startsWith('#'))
1523
+ return;
1524
+ const i = t.indexOf('=');
1525
+ if (i <= 0)
1526
+ return;
1527
+ out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
1528
+ });
1529
+ return out;
1530
+ }
1531
+ function parseArgs(text) {
1532
+ return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter((s) => s !== '');
1533
+ }
1534
+ // ---------- open a skill source in the OS default editor ----------
1535
+ // Fire-and-forget: the launcher detaches, so a hanging editor never blocks
1536
+ // the request. Only existing Markdown source files are accepted — the route
1537
+ // is same-origin + token gated, but it must not become a "launch anything"
1538
+ // primitive.
1539
+ function openWithSystemEditor(abs) {
1540
+ try {
1541
+ const platform = process.platform;
1542
+ const command = platform === 'win32' ? 'explorer.exe' : platform === 'darwin' ? 'open' : 'xdg-open';
1543
+ const child = spawn(command, [abs], { detached: true, stdio: 'ignore', windowsHide: true });
1544
+ child.on('error', () => { });
1545
+ child.unref();
1546
+ return true;
1547
+ }
1548
+ catch (e) {
1549
+ return false;
1550
+ }
1551
+ }
1552
+ async function skillOpen(args) {
1553
+ const abs = String((args && args.path) || '').trim();
1554
+ if (!abs)
1555
+ return { ok: false, error: '缺少 path' };
1556
+ if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(abs))
1557
+ return { ok: false, error: '需要绝对路径' };
1558
+ if (!/\.(md|markdown|txt)$/i.test(abs))
1559
+ return { ok: false, error: '只支持打开 Markdown 源文件' };
1560
+ if (!(await exists(abs)))
1561
+ return { ok: false, error: '文件不存在:' + abs };
1562
+ if (!openWithSystemEditor(abs))
1563
+ return { ok: false, error: '无法调用系统默认打开方式' };
1564
+ return { ok: true, path: abs };
1565
+ }
1566
+ const handlers = {
1567
+ 'plugin-version': pluginVersion,
1568
+ 'mcpm-list': mcpmListView,
1569
+ 'mcpm-reveal': mcpmReveal,
1570
+ 'mcpm-note': mcpmNote,
1571
+ 'mcpm-settings': mcpmSettings,
1572
+ 'mcpm-tool-enabled': mcpmToolEnabled,
1573
+ 'skill-open': skillOpen,
1574
+ 'mcpm-tools': mcpmTools,
1575
+ 'mcpm-add': mcpmAdd,
1576
+ 'mcpm-edit': mcpmEdit,
1577
+ 'mcpm-set-enabled': mcpmSetEnabled,
1578
+ 'mcpm-set-all': mcpmSetAll,
1579
+ 'mcpm-restart': mcpmRestart,
1580
+ 'mcpm-remove': mcpmRemove,
1581
+ 'mcpm-export': mcpmExport,
1582
+ 'mcpm-import': mcpmImport,
1583
+ // 技能管理 ops(由 ./skills/service.js 提供):skill-state / skill-detail /
1584
+ // skill-browse / skill-enable / skill-disable / skill-source-enable /
1585
+ // skill-source-disable / skill-create / skill-import / skill-upload /
1586
+ // skill-delete / skill-trash-restore / skill-trash-delete
1587
+ // 成功返回 {ok:true, data};core 业务失败原样透传 {ok:false, error, code?, params?}。
1588
+ ...skillsService.ops,
1589
+ };
1590
+ // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
1591
+ const text = (value) => [{ type: 'text', text: value }];
1592
+ tools.register(defineTool({
1593
+ name: 'skill_mcp_manager_list',
1594
+ description: 'List all configured MCP servers (level, enabled state, live loader status, registered tool count).',
1595
+ parameters: {},
1596
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
1597
+ async execute() {
1598
+ const r = await mcpmList();
1599
+ if (!r.ok)
1600
+ throw new Error(r.error);
1601
+ const summary = (r.rows || []).map((x) => (x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
1602
+ (x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
1603
+ (typeof x.toolCount === 'number' ? ' | tools:' + x.toolCount : '')));
1604
+ return 'MCP servers:\n' + (summary.join('\n') || '(none)');
1605
+ },
1606
+ }));
1607
+ tools.register(defineTool({
1608
+ name: 'skill_mcp_manager_set_enabled',
1609
+ description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
1610
+ parameters: {
1611
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
1612
+ level: { type: 'string', required: true, description: 'project or global.' },
1613
+ enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
1614
+ },
1615
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
1616
+ async execute(args) {
1617
+ const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
1618
+ if (!r.ok)
1619
+ throw new Error(r.error);
1620
+ return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled');
1621
+ },
1622
+ }));
1623
+ tools.register(defineTool({
1624
+ name: 'skill_mcp_manager_restart',
1625
+ description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
1626
+ parameters: {
1627
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
1628
+ level: { type: 'string', required: true, description: 'project or global.' },
1629
+ },
1630
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
1631
+ async execute(args) {
1632
+ const r = await mcpmRestart({ id: args.id, level: args.level });
1633
+ if (!r.ok)
1634
+ throw new Error(r.error);
1635
+ return 'OK: ' + args.id + ' restarted';
1636
+ },
1637
+ }));
1638
+ tools.register(defineTool({
1639
+ name: 'skill_mcp_manager_add',
1640
+ description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
1641
+ parameters: {
1642
+ serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
1643
+ transport: { type: 'string', required: true, description: 'streamable-http or stdio.' },
1644
+ url: { type: 'string', description: 'Server URL (required for streamable-http).' },
1645
+ command: { type: 'string', description: 'Executable (required for stdio).' },
1646
+ args: { type: 'string', description: 'Arguments, space separated (stdio).' },
1647
+ headers: { type: 'string', description: 'Extra headers as key=value lines (streamable-http).' },
1648
+ env: { type: 'string', description: 'Extra env vars as key=value lines (stdio).' },
1649
+ level: { type: 'string', description: 'project or global (default project).' },
1650
+ },
1651
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
1652
+ async execute(args) {
1653
+ const r = await mcpmAdd(args);
1654
+ if (!r.ok)
1655
+ throw new Error(r.error);
1656
+ return 'OK: added ' + r.row.id + ' at ' + r.row.level;
1657
+ },
1658
+ }));
1659
+ // Skill-facing tools: list, toggle, create. create is gated by the
1660
+ // tools/pre-execute hook below (the model must ask before writing files).
1661
+ tools.register(defineTool({
1662
+ name: 'skill_manager_list',
1663
+ description: 'List DSH skills across all sources (dsh/agents/codex/claude user roots and project roots) with their enabled state.',
1664
+ parameters: {},
1665
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
1666
+ async execute() {
1667
+ const r = await skillsService.ops['skill-state']({});
1668
+ if (!r || r.ok === false)
1669
+ throw new Error((r && r.error) || 'skill-state failed');
1670
+ const data = r.data || {};
1671
+ const lines = [];
1672
+ for (const root of data.roots || []) {
1673
+ for (const skill of root.skills || []) {
1674
+ lines.push((skill.name || skill.declaredName || '') + ' | ' + (root.key || '') + ' | ' + (skill.enabled ? 'enabled' : 'disabled'));
1675
+ }
1676
+ }
1677
+ return 'Skills:\n' + (lines.join('\n') || '(none)');
1678
+ },
1679
+ }));
1680
+ tools.register(defineTool({
1681
+ name: 'skill_manager_set_enabled',
1682
+ description: 'Enable or disable one DSH skill (manager policy only; skill source files are never modified).',
1683
+ parameters: {
1684
+ name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
1685
+ enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
1686
+ root: { type: 'string', description: 'Source root key (dsh/agents/codex/claude or a project key); default dsh.' },
1687
+ },
1688
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
1689
+ async execute(args) {
1690
+ const op = args.enabled ? 'skill-enable' : 'skill-disable';
1691
+ const r = await skillsService.ops[op]({ name: args.name, root: args.root || 'dsh' });
1692
+ if (!r || r.ok === false)
1693
+ throw new Error((r && r.error) || 'skill toggle failed');
1694
+ return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled');
1695
+ },
1696
+ }));
1697
+ tools.register(defineTool({
1698
+ name: 'skill_manager_create',
1699
+ description: 'Create a new local DSH skill under DSH_HOME/skills. Use only when the user explicitly asks to create or save a reusable skill.',
1700
+ parameters: {
1701
+ name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
1702
+ description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
1703
+ body: { type: 'string', required: true, description: 'Markdown instructions that form the skill body.' },
1704
+ },
1705
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
1706
+ async execute(args) {
1707
+ const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body, root: 'dsh' });
1708
+ if (!r || r.ok === false)
1709
+ throw new Error((r && r.error) || 'skill create failed');
1710
+ const data = r.data || {};
1711
+ return 'Created DSH skill ' + (data.name || args.name) + ' at ' + (data.path || '(unknown)');
1712
+ },
1713
+ }));
1714
+ if (typeof ctx.on === 'function') {
1715
+ ;
1716
+ ctx.on('tools/pre-execute', (exec, next) => {
1717
+ if (exec && exec.name === 'skill_manager_create') {
1718
+ return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under DSH_HOME/skills' });
1719
+ }
1720
+ return next();
1721
+ });
1722
+ }
1723
+ // ---------- per-tool MCP switches: execution guard + visibility ----------
1724
+ try {
1725
+ readDisabledTools().then(() => applyToolRestrictions()).catch(() => { });
1726
+ }
1727
+ catch (e) { /* ignore */ }
1728
+ if (typeof tools.guard === 'function') {
1729
+ ctx.effect(() => tools.guard((exec) => {
1730
+ try {
1731
+ // Reads the TTL cache without I/O: the guard runs on the hot path.
1732
+ const value = disabledToolsCache ? disabledToolsCache.value : {};
1733
+ if (disabledToolNames(value).indexOf(String((exec && exec.name) || '')) >= 0) {
1734
+ return '该工具已在 MCP 管理页停用';
1735
+ }
1736
+ }
1737
+ catch (e) { /* fallthrough to allow */ }
1738
+ return undefined;
1739
+ }), 'dsh-plugin-tool-management: disabled mcp tool guard');
1740
+ }
1741
+ if (typeof ctx.on === 'function') {
1742
+ try {
1743
+ // MCP servers (de)register their tools as instances come and go; the
1744
+ // visible-set restriction must track that.
1745
+ ctx.effect(() => {
1746
+ const stop = ctx.on('tools/change', () => scheduleToolRestrictions());
1747
+ return typeof stop === 'function' ? stop : () => { };
1748
+ }, 'dsh-plugin-tool-management: tools/change listener');
1749
+ }
1750
+ catch (e) { /* ignore */ }
1751
+ }
1752
+ ctx.effect(() => () => {
1753
+ if (restrictTimer) {
1754
+ clearTimeout(restrictTimer);
1755
+ restrictTimer = null;
1756
+ }
1757
+ if (restrictDisposer) {
1758
+ try {
1759
+ restrictDisposer();
1760
+ }
1761
+ catch (e) { /* ignore */ }
1762
+ restrictDisposer = null;
1763
+ }
1764
+ }, 'dsh-plugin-tool-management: tool restriction cleanup');
1765
+ // ---------- HTTP API route (UI half), registered defensively ----------
1766
+ if (webServer) {
1767
+ // Cap request bodies (88 MiB): skill-upload carries Base64 folder/ZIP
1768
+ // payloads (64 MiB of raw content). The route is only reachable from
1769
+ // local/trusted origins (Host + CSRF header + optional token gate).
1770
+ // `config.maxBodyBytes` overrides the cap (tests use a small value).
1771
+ const configuredMaxBody = Number(config?.maxBodyBytes);
1772
+ const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : 88 * 1024 * 1024;
1773
+ const readBody = (req) => new Promise((resolve, reject) => {
1774
+ // Accumulate Buffers and decode ONCE at the end: decoding each chunk
1775
+ // separately corrupts multi-byte UTF-8 characters that straddle a
1776
+ // chunk boundary (e.g. long Chinese skill bodies).
1777
+ const chunks = [];
1778
+ let size = 0;
1779
+ req.on('data', (c) => {
1780
+ const buf = Buffer.isBuffer(c) ? c : Buffer.from(String(c));
1781
+ size += buf.length;
1782
+ if (size > MAX_BODY) {
1783
+ reject(new Error('request body too large'));
1784
+ return;
1785
+ }
1786
+ chunks.push(buf);
1787
+ });
1788
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
1789
+ req.on('error', reject);
1790
+ });
1791
+ try {
1792
+ // ctx.effect wires the route's disposer into this plugin's scope, so an
1793
+ // unload (HMR removal, disable, update) unregisters the route — the
1794
+ // documented cleanup contract (webServer.register does not auto-scope).
1795
+ ctx.effect(() => webServer.register({
1796
+ kind: 'exact',
1797
+ path: '/dsh-plugin-tool-management/api',
1798
+ handler: async (req, res) => {
1799
+ // Cross-site (CSRF) gate. This route mutates config files, so it
1800
+ // must only be reachable from the DSH web UI (same origin) or
1801
+ // local tooling. A browser cross-site request cannot attach a
1802
+ // custom header without a CORS preflight, and this route never
1803
+ // answers preflights — requiring `x-dsh-plugin` is the primary
1804
+ // gate; POST-only and the Origin check are defense in depth.
1805
+ const hdr = (name) => {
1806
+ const v = req.headers?.[name];
1807
+ return Array.isArray(v) ? v[0] ?? '' : v ?? '';
1808
+ };
1809
+ if (String(req.method || 'POST').toUpperCase() !== 'POST') {
1810
+ res.writeHead(405, { 'content-type': 'application/json' });
1811
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
1812
+ return;
1813
+ }
1814
+ if (hdr('x-dsh-plugin') !== 'dsh-plugin-tool-management') {
1815
+ res.writeHead(403, { 'content-type': 'application/json' });
1816
+ res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
1817
+ return;
1818
+ }
1819
+ const origin = hdr('origin');
1820
+ if (origin) {
1821
+ let sameOrigin = false;
1822
+ try {
1823
+ const u = new URL(origin);
1824
+ const hostHdr = hdr('host') || '';
1825
+ sameOrigin =
1826
+ /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(u.hostname) || u.host === hostHdr;
1827
+ }
1828
+ catch (e) { /* unparseable origin → rejected below */ }
1829
+ if (!sameOrigin) {
1830
+ res.writeHead(403, { 'content-type': 'application/json' });
1831
+ res.end(JSON.stringify({ ok: false, error: 'cross-origin request rejected' }));
1832
+ return;
1833
+ }
1834
+ }
1835
+ res.writeHead(200, { 'content-type': 'application/json' });
1836
+ try {
1837
+ let payload = {};
1838
+ try {
1839
+ payload = JSON.parse((await readBody(req)) || '{}');
1840
+ }
1841
+ catch (e) {
1842
+ if (String(e?.message).includes('body too large')) {
1843
+ res.end(JSON.stringify({ ok: false, error: '请求体过大' }));
1844
+ return;
1845
+ }
1846
+ /* otherwise fall through with {} */
1847
+ }
1848
+ const op = String(payload.op || '');
1849
+ if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
1850
+ res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
1851
+ return;
1852
+ }
1853
+ const fn = handlers[op];
1854
+ if (!fn) {
1855
+ res.end(JSON.stringify({ ok: false, error: '未知操作: ' + op }));
1856
+ return;
1857
+ }
1858
+ const result = await fn(payload.args || {});
1859
+ res.end(JSON.stringify(result === undefined ? { ok: true } : result));
1860
+ }
1861
+ catch (e) {
1862
+ res.end(JSON.stringify({ ok: false, error: message(e) }));
1863
+ }
1864
+ },
1865
+ }), 'dsh-plugin-tool-management: api route');
1866
+ }
1867
+ catch (e) {
1868
+ // A registration failure must never take down the whole entry: log and continue.
1869
+ console.error('[dsh-plugin-tool-management] webServer route registration failed:', message(e));
1870
+ }
1871
+ }
1872
+ // ---------- human chat commands (/mcp, /skills) ----------
1873
+ // The commands service is optional, so probe for it and skip silently when
1874
+ // it is missing (same defensive shape as pluginInventory above).
1875
+ try {
1876
+ const commands = (typeof ctx.get === 'function' ? ctx.get('commands') : undefined);
1877
+ if (commands && typeof commands.register === 'function') {
1878
+ ctx.effect(() => commands.register({
1879
+ name: 'mcp',
1880
+ description: '列出已配置的 MCP 服务(级别 / 启停 / loader 状态 / 工具数)',
1881
+ handler: async () => {
1882
+ const r = await mcpmList();
1883
+ if (!r || r.ok === false)
1884
+ return { kind: 'error', text: (r && r.error) || '读取 MCP 服务失败' };
1885
+ // 启用的在前,同组内按名称排序。
1886
+ const rows = (r.rows || []).slice().sort((a, b) => {
1887
+ if (!!a.disabled !== !!b.disabled)
1888
+ return a.disabled ? 1 : -1;
1889
+ return String(a.serverName).localeCompare(String(b.serverName));
1890
+ });
1891
+ if (!rows.length)
1892
+ return { kind: 'success', text: '未配置任何 MCP 服务。' };
1893
+ // Human-facing command: show localized levels, not the raw keys.
1894
+ const labels = { project: 'Profile 级', global: '全局', loader: '已加载' };
1895
+ const text = 'MCP 服务(' + rows.length + '):\n' + rows.map((row) => ('- ' + row.serverName + ' [' + (labels[row.level] || row.level) + '] ' + (row.disabled ? '已停用' : '已启用') +
1896
+ (row.live ? ' · loader:' + (row.live.enabled ? 'on' : 'off') + (row.live.phase ? '/' + row.live.phase : '') : '') +
1897
+ (typeof row.toolCount === 'number' ? ' · ' + row.toolCount + ' 工具' : ''))).join('\n');
1898
+ return { kind: 'success', text };
1899
+ },
1900
+ }), 'dsh-plugin-tool-management: /mcp command');
1901
+ ctx.effect(() => commands.register({
1902
+ name: 'skills',
1903
+ description: '列出各来源的技能及其启停状态',
1904
+ handler: async () => {
1905
+ const r = await skillsService.ops['skill-state']({});
1906
+ if (!r || r.ok === false)
1907
+ return { kind: 'error', text: (r && r.error) || '读取技能失败' };
1908
+ const data = r.data || {};
1909
+ const entries = [];
1910
+ for (const root of data.roots || []) {
1911
+ for (const skill of root.skills || []) {
1912
+ entries.push({
1913
+ line: '- ' + (skill.declaredName || skill.name) + ' [' + (root.label || root.key) + '] ' + (skill.enabled === false ? '已停用' : '已启用'),
1914
+ enabled: skill.enabled !== false,
1915
+ });
1916
+ }
1917
+ }
1918
+ // 启用的在前,同组内保持原有顺序(来源与名称序)。
1919
+ entries.sort((a, b) => (a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1));
1920
+ const lines = entries.map((entry) => entry.line);
1921
+ return { kind: 'success', text: lines.length ? '技能(' + lines.length + '):\n' + lines.join('\n') : '未发现任何技能。' };
1922
+ },
1923
+ }), 'dsh-plugin-tool-management: /skills command');
1924
+ }
1925
+ }
1926
+ catch (e) {
1927
+ console.error('[dsh-plugin-tool-management] command registration failed:', message(e));
1928
+ }
1929
+ },
1930
+ };