@xxxyz/dsh-mcp-manager 2.0.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,967 @@
1
+ // dsh-mcp-manager — 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
+ export default {
15
+ name: 'dsh-mcp-manager-host',
16
+ inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools'],
17
+ apply(ctx) {
18
+ const fs = ctx.fs;
19
+ const settings = ctx.settings;
20
+ const sandboxPolicy = ctx.sandboxPolicy;
21
+ const webServer = ctx.webServer;
22
+ const tools = ctx.tools;
23
+ // pluginInventory is optional: probe at use time, degrade to no live info.
24
+ const pluginInventory = ctx.get('pluginInventory');
25
+ const wait = (ms) => ctx.timeout(ms);
26
+ const message = (e) => String((e && e.message) || e);
27
+ let writeChain = Promise.resolve();
28
+ function withWriteLock(fn) {
29
+ const run = writeChain.then(() => fn(), () => fn());
30
+ writeChain = run.then(() => undefined, () => undefined);
31
+ return run;
32
+ }
33
+ // ---------- path discovery ----------
34
+ let cached = null;
35
+ async function ensurePaths() {
36
+ if (cached)
37
+ return cached;
38
+ let home = null;
39
+ try {
40
+ const doc = await settings.prepareDocument();
41
+ if (typeof doc === 'string' && doc) {
42
+ const i = Math.max(doc.lastIndexOf('\\'), doc.lastIndexOf('/'));
43
+ home = i > 0 ? doc.slice(0, i) : doc;
44
+ }
45
+ }
46
+ catch (e) { /* ignore */ }
47
+ if (!home)
48
+ throw new Error('无法确定 DSH 主目录(settings.prepareDocument 未返回路径)');
49
+ const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
50
+ let profileDir = null;
51
+ let profileName = 'web';
52
+ for (const name of ['web', 'headless']) {
53
+ if (await exists(home + sep + 'profiles' + sep + name + sep + 'cordis.patch.yml')) {
54
+ profileDir = home + sep + 'profiles' + sep + name;
55
+ profileName = name;
56
+ break;
57
+ }
58
+ }
59
+ if (!profileDir) {
60
+ try {
61
+ const t = await fs.resolve(home + sep + 'profiles');
62
+ const entries = await fs.listDir(t);
63
+ for (const e of entries) {
64
+ if (e.name === 'node_modules')
65
+ continue;
66
+ if (await exists(home + sep + 'profiles' + sep + e.name + sep + 'cordis.patch.yml')) {
67
+ profileDir = home + sep + 'profiles' + sep + e.name;
68
+ profileName = e.name;
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ catch (e) { /* ignore */ }
74
+ }
75
+ if (!profileDir)
76
+ profileDir = home + sep + 'profiles' + sep + 'web';
77
+ cached = {
78
+ home,
79
+ profileDir,
80
+ profileName,
81
+ projectPatch: profileDir + sep + 'cordis.patch.yml',
82
+ globalPatch: home + sep + 'cordis.patch.yml',
83
+ };
84
+ return cached;
85
+ }
86
+ async function exists(abs) {
87
+ try {
88
+ const t = await fs.resolve(abs);
89
+ return (await fs.stat(t)) !== undefined;
90
+ }
91
+ catch (e) {
92
+ return false;
93
+ }
94
+ }
95
+ async function readPatch(abs) {
96
+ try {
97
+ const t = await fs.resolve(abs);
98
+ return await fs.readText(t);
99
+ }
100
+ catch (e) {
101
+ if (String(e.code) === 'FS_NOT_FOUND')
102
+ return '';
103
+ throw e;
104
+ }
105
+ }
106
+ async function writePatch(abs, content) {
107
+ const t = await fs.resolve(abs);
108
+ const policy = await sandboxPolicy.resolve({ mode: 'danger-full-access' });
109
+ await fs.writeText(t, content, undefined, undefined, policy);
110
+ }
111
+ // ---------- YAML generation ----------
112
+ function yq(v) { return typeof v === 'string' ? JSON.stringify(v) : String(v); }
113
+ function yplain(v) { return /^[A-Za-z0-9_.:@%+=/-]+$/.test(v) ? v : yq(v); }
114
+ function buildInsertBlock(row) {
115
+ const lines = [
116
+ '# dsh-mcp-manager:server:' + row.id,
117
+ '- insert:',
118
+ ' - id: ' + yplain(row.id),
119
+ " name: '@deepseek-ai/dsh-mcp-client'",
120
+ ' config:',
121
+ ' serverName: ' + yq(row.serverName),
122
+ ' transport: ' + yq(row.transport),
123
+ ];
124
+ if (row.transport === 'streamable-http') {
125
+ lines.push(' url: ' + yq(row.url || ''));
126
+ const headers = row.headers || {};
127
+ const hk = Object.keys(headers);
128
+ if (hk.length) {
129
+ lines.push(' headers:');
130
+ for (const k of hk)
131
+ lines.push(' ' + yq(k) + ': ' + yq(headers[k]));
132
+ }
133
+ }
134
+ else {
135
+ lines.push(' command: ' + yq(row.command || ''));
136
+ const args = row.args || [];
137
+ if (args.length) {
138
+ lines.push(' args:');
139
+ for (const a of args)
140
+ lines.push(' - ' + yq(a));
141
+ }
142
+ const env = row.env || {};
143
+ const ek = Object.keys(env);
144
+ if (ek.length) {
145
+ lines.push(' env:');
146
+ for (const k of ek)
147
+ lines.push(' ' + yq(k) + ': ' + yq(env[k]));
148
+ }
149
+ }
150
+ if (row.toolCallTimeoutMs)
151
+ lines.push(' toolCallTimeoutMs: ' + Number(row.toolCallTimeoutMs));
152
+ return lines.join('\n');
153
+ }
154
+ function buildDisableBlock(id, disabled) {
155
+ return [
156
+ '# dsh-mcp-manager:' + (disabled ? 'disable' : 'enable') + ':' + id,
157
+ '- id: ' + yplain(id),
158
+ " name: '@deepseek-ai/dsh-mcp-client'",
159
+ ' disabled: ' + (disabled ? 'true' : 'false'),
160
+ ].join('\n');
161
+ }
162
+ // ---------- YAML parsing (mini parser) ----------
163
+ function splitKV(text) {
164
+ const m = text.match(/^("(?:\\.|[^"])*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/);
165
+ if (!m)
166
+ return null;
167
+ return { key: unquote(m[1]), value: m[2] };
168
+ }
169
+ function unquote(v) {
170
+ if (v === undefined || v === null)
171
+ return v;
172
+ const s = String(v).trim();
173
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
174
+ try {
175
+ return JSON.parse(s);
176
+ }
177
+ catch (e) {
178
+ return s.slice(1, -1);
179
+ }
180
+ }
181
+ if (s.length >= 2 && s.startsWith("'") && s.endsWith("'"))
182
+ return s.slice(1, -1).replace(/''/g, "'");
183
+ if (/^\[.*\]$/.test(s))
184
+ return s.slice(1, -1).split(',').map((x) => unquote(x.trim())).filter((x) => x !== '');
185
+ if (s === 'true')
186
+ return true;
187
+ if (s === 'false')
188
+ return false;
189
+ if (/^-?\d+$/.test(s))
190
+ return Number(s);
191
+ return s;
192
+ }
193
+ function parseEntry(lines) {
194
+ const entry = { config: {} };
195
+ let inConfig = false;
196
+ let configIndent = 0;
197
+ let nested = null;
198
+ for (const line of lines) {
199
+ const trimmed = line.trim();
200
+ if (!trimmed || trimmed.startsWith('#'))
201
+ continue;
202
+ const indent = line.match(/^\s*/)[0].length;
203
+ let t = trimmed;
204
+ if (t.startsWith('- '))
205
+ t = t.slice(2).trim();
206
+ const kv = splitKV(t);
207
+ if (!kv) {
208
+ if (inConfig && nested && nested.type === 'list')
209
+ nested.current.push(unquote(t));
210
+ continue;
211
+ }
212
+ if (!inConfig) {
213
+ if (kv.key === 'config' && kv.value === '') {
214
+ inConfig = true;
215
+ configIndent = indent;
216
+ continue;
217
+ }
218
+ if (kv.key === 'id')
219
+ entry.id = unquote(kv.value);
220
+ else if (kv.key === 'name')
221
+ entry.name = unquote(kv.value);
222
+ else if (kv.key === 'disabled')
223
+ entry.disabled = kv.value === 'true';
224
+ continue;
225
+ }
226
+ if (indent <= configIndent) {
227
+ inConfig = false;
228
+ nested = null;
229
+ continue;
230
+ }
231
+ if (kv.value === '' && (kv.key === 'headers' || kv.key === 'env')) {
232
+ nested = { key: kv.key, indent, type: 'map', current: {} };
233
+ entry.config[kv.key] = nested.current;
234
+ continue;
235
+ }
236
+ if (kv.value === '' && kv.key === 'args') {
237
+ nested = { key: kv.key, indent, type: 'list', current: [] };
238
+ entry.config[kv.key] = nested.current;
239
+ continue;
240
+ }
241
+ if (nested && indent > nested.indent) {
242
+ if (nested.type === 'map')
243
+ nested.current[kv.key] = unquote(kv.value);
244
+ else if (nested.type === 'list')
245
+ nested.current.push(unquote(kv.value));
246
+ continue;
247
+ }
248
+ nested = null;
249
+ entry.config[kv.key] = unquote(kv.value);
250
+ }
251
+ return entry;
252
+ }
253
+ function parseRows(content) {
254
+ const lines = content.split(/\r?\n/);
255
+ const managedIds = new Set();
256
+ for (const line of lines) {
257
+ const m = line.match(/^# dsh-mcp-manager:server:(.+)$/);
258
+ if (m)
259
+ managedIds.add(m[1].trim());
260
+ }
261
+ const rows = [];
262
+ const overrides = [];
263
+ const blocks = [];
264
+ let current = null;
265
+ for (const line of lines) {
266
+ if (/^- /.test(line)) {
267
+ current = { text: line };
268
+ blocks.push(current);
269
+ }
270
+ else if (current) {
271
+ current.text += '\n' + line;
272
+ }
273
+ }
274
+ for (const block of blocks) {
275
+ const head = block.text.split('\n')[0];
276
+ if (/^- insert:/.test(head)) {
277
+ const parts = block.text.split('\n');
278
+ const children = [];
279
+ let j = 0;
280
+ while (j < parts.length) {
281
+ if (/^ - /.test(parts[j])) {
282
+ const child = { lines: [parts[j]] };
283
+ j++;
284
+ while (j < parts.length && !/^ - /.test(parts[j])) {
285
+ child.lines.push(parts[j]);
286
+ j++;
287
+ }
288
+ children.push(child);
289
+ }
290
+ else
291
+ j++;
292
+ }
293
+ for (const child of children) {
294
+ const entry = parseEntry(child.lines);
295
+ if (entry && entry.name === '@deepseek-ai/dsh-mcp-client') {
296
+ rows.push({ id: entry.id, name: entry.name, disabled: entry.disabled, config: entry.config, managed: managedIds.has(entry.id) });
297
+ }
298
+ }
299
+ }
300
+ else {
301
+ const entry = parseEntry(block.text.split('\n'));
302
+ if (entry && entry.name === '@deepseek-ai/dsh-mcp-client')
303
+ overrides.push({ id: entry.id, disabled: entry.disabled });
304
+ }
305
+ }
306
+ for (const o of overrides) {
307
+ const row = rows.find((r) => r.id === o.id);
308
+ if (row && o.disabled !== undefined)
309
+ row.disabled = o.disabled;
310
+ }
311
+ return { rows };
312
+ }
313
+ // ---------- line-based block editing ----------
314
+ function splitLines(content) { return content.split(/\r?\n/); }
315
+ function joinLines(lines) {
316
+ let res = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '\n');
317
+ if (!res.trim()) {
318
+ res = '[]\n';
319
+ }
320
+ else if (!/^- /m.test(res) && !/^\[\]\s*$/m.test(res)) {
321
+ // A patch file must stay a top-level YAML array: after removing the last
322
+ // entry, emit [] so loadOptionalPatches never throws on a comments-only file.
323
+ res = res.replace(/\n*$/, '\n[]\n');
324
+ }
325
+ return res;
326
+ }
327
+ function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
328
+ function markerRanges(lines, id, ops) {
329
+ const n = lines.length;
330
+ const re = new RegExp('^# dsh-mcp-manager:(' + ops + '):' + escRe(id) + '$');
331
+ const ranges = [];
332
+ for (let i = 0; i < n; i++) {
333
+ if (!re.test(lines[i]))
334
+ continue;
335
+ let j = i + 1;
336
+ while (j < n && !/^- /.test(lines[j]))
337
+ j++;
338
+ let end = j;
339
+ if (j < n && /^- /.test(lines[j])) {
340
+ let k = j + 1;
341
+ while (k < n && !/^- /.test(lines[k]))
342
+ k++;
343
+ end = k;
344
+ }
345
+ ranges.push([i, end]);
346
+ }
347
+ return ranges;
348
+ }
349
+ function insertBlockRange(lines, id) {
350
+ const n = lines.length;
351
+ const entryRe = new RegExp('^\\s*- id: ' + escRe(id) + '\\s*$');
352
+ for (let i = 0; i < n; i++) {
353
+ if (!/^- insert:/.test(lines[i]))
354
+ continue;
355
+ let end = i + 1;
356
+ while (end < n && !/^- /.test(lines[end]))
357
+ end++;
358
+ if (lines.slice(i, end).some((l) => entryRe.test(l)))
359
+ return [i, end];
360
+ }
361
+ return null;
362
+ }
363
+ function bareOverrideRanges(lines, id) {
364
+ const n = lines.length;
365
+ const re = new RegExp('^- id: ' + escRe(id) + '\\s*$');
366
+ const ranges = [];
367
+ for (let i = 0; i < n; i++) {
368
+ if (!re.test(lines[i]))
369
+ continue;
370
+ let end = i + 1;
371
+ while (end < n && !/^- /.test(lines[end]))
372
+ end++;
373
+ ranges.push([i, end]);
374
+ }
375
+ return ranges;
376
+ }
377
+ function spliceRanges(lines, ranges) {
378
+ const remove = new Set();
379
+ for (const r of ranges)
380
+ for (let i = r[0]; i < r[1]; i++)
381
+ remove.add(i);
382
+ return joinLines(lines.filter((_, i) => !remove.has(i)));
383
+ }
384
+ function removeEntryAll(content, id) {
385
+ const lines = splitLines(content);
386
+ const ranges = markerRanges(lines, id, 'server|disable|enable');
387
+ const ib = insertBlockRange(lines, id);
388
+ if (ib)
389
+ ranges.push(ib);
390
+ ranges.push(...bareOverrideRanges(lines, id));
391
+ return spliceRanges(lines, ranges);
392
+ }
393
+ function removeMarked(content, id, op) {
394
+ return spliceRanges(splitLines(content), markerRanges(splitLines(content), id, op));
395
+ }
396
+ function appendBlock(content, block) {
397
+ let c = content;
398
+ if (/^\[\]\s*$/m.test(c))
399
+ c = c.replace(/^\[\]\s*$/m, block + '\n');
400
+ else
401
+ c = c.replace(/\s*$/, '\n' + block + '\n');
402
+ return c;
403
+ }
404
+ // ---------- shared state ----------
405
+ async function collectAll() {
406
+ const p = await ensurePaths();
407
+ const ids = new Set();
408
+ const serverNames = new Set();
409
+ const rows = [];
410
+ for (const level of ['project', 'global']) {
411
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
412
+ let content = '';
413
+ try {
414
+ content = await readPatch(abs);
415
+ }
416
+ catch (e) {
417
+ continue;
418
+ }
419
+ const { rows: fileRows } = parseRows(content);
420
+ for (const r of fileRows) {
421
+ ids.add(r.id);
422
+ const sn = r.config && r.config.serverName ? String(r.config.serverName) : r.id;
423
+ serverNames.add(sn);
424
+ rows.push({ id: r.id, serverName: sn, level, disabled: !!r.disabled });
425
+ }
426
+ }
427
+ return { ids, serverNames, rows };
428
+ }
429
+ const bareEntryId = (v) => { const s = String(v); const i = s.lastIndexOf(':'); return i >= 0 ? s.slice(i + 1) : s; };
430
+ async function liveEntry(id) {
431
+ if (!pluginInventory)
432
+ return null;
433
+ try {
434
+ const res = await pluginInventory.list();
435
+ return res.entries.find((e) => e.moduleName === '@deepseek-ai/dsh-mcp-client' && bareEntryId(e.entryId) === id) || null;
436
+ }
437
+ catch (e) {
438
+ return null;
439
+ }
440
+ }
441
+ async function waitFor(pred, timeoutMs, stepMs) {
442
+ const start = Date.now();
443
+ for (;;) {
444
+ const v = await pred();
445
+ if (v)
446
+ return true;
447
+ if (Date.now() - start > timeoutMs)
448
+ return false;
449
+ await wait(stepMs);
450
+ }
451
+ }
452
+ async function entryExists(id, level) {
453
+ const p = await ensurePaths();
454
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
455
+ let content = '';
456
+ try {
457
+ content = await readPatch(abs);
458
+ }
459
+ catch (e) {
460
+ return false;
461
+ }
462
+ const { rows } = parseRows(content);
463
+ if (rows.some((r) => r.id === id))
464
+ return true;
465
+ return (await liveEntry(id)) !== null;
466
+ }
467
+ function normalizeRow(r, level, abs) {
468
+ const cfg = r.config || {};
469
+ return {
470
+ id: r.id,
471
+ serverName: cfg.serverName || r.id,
472
+ transport: cfg.transport || null,
473
+ url: cfg.url || null,
474
+ command: cfg.command || null,
475
+ args: cfg.args || null,
476
+ env: cfg.env || null,
477
+ headers: cfg.headers || null,
478
+ level,
479
+ disabled: !!r.disabled,
480
+ managed: !!r.managed,
481
+ };
482
+ }
483
+ // ---------- import helpers ----------
484
+ function toStrMap(v) {
485
+ if (!v || typeof v !== 'object' || Array.isArray(v))
486
+ return {};
487
+ const out = {};
488
+ for (const k of Object.keys(v))
489
+ out[k] = String(v[k]);
490
+ return out;
491
+ }
492
+ function normalizeImportItem(item) {
493
+ if (!item || typeof item !== 'object' || Array.isArray(item))
494
+ return { ok: false, error: '条目不是对象' };
495
+ const it = item;
496
+ const serverName = String(it.serverName || '').trim();
497
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
498
+ return { ok: false, error: 'serverName 非法: ' + String(it.serverName) };
499
+ const transport = it.transport === 'stdio' ? 'stdio' : 'streamable-http';
500
+ const level = it.level === 'global' ? 'global' : 'project';
501
+ const baseId = 'mcp-' + serverName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
502
+ const rawId = String(it.id || '').trim();
503
+ const id = rawId && /^[A-Za-z0-9_.:@%+=/-]+$/.test(rawId) ? rawId : baseId;
504
+ const row = { id, serverName, transport, level, disabled: !!it.disabled };
505
+ if (transport === 'streamable-http') {
506
+ const url = String(it.url || '').trim();
507
+ if (!/^https?:\/\//.test(url))
508
+ return { ok: false, error: serverName + ': url 非法' };
509
+ row.url = url;
510
+ row.headers = toStrMap(it.headers);
511
+ }
512
+ else {
513
+ const command = String(it.command || '').trim();
514
+ if (!command)
515
+ return { ok: false, error: serverName + ': command 缺失' };
516
+ row.command = command;
517
+ row.args = Array.isArray(it.args) ? it.args.map(String) : [];
518
+ row.env = toStrMap(it.env);
519
+ }
520
+ return { ok: true, row };
521
+ }
522
+ // ---------- ops ----------
523
+ async function mcpmList() {
524
+ const p = await ensurePaths();
525
+ const rows = [];
526
+ const errors = [];
527
+ for (const level of ['project', 'global']) {
528
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
529
+ let content = '';
530
+ try {
531
+ content = await readPatch(abs);
532
+ }
533
+ catch (e) {
534
+ errors.push(level + ': ' + message(e));
535
+ continue;
536
+ }
537
+ const { rows: fileRows } = parseRows(content);
538
+ for (const r of fileRows)
539
+ rows.push(normalizeRow(r, level, abs));
540
+ }
541
+ const toolCounts = {};
542
+ try {
543
+ const schemas = await tools.schemas();
544
+ for (const s of schemas) {
545
+ const m = String(s && s.name || '').match(/^mcp__([A-Za-z0-9_-]+)__/);
546
+ if (m)
547
+ toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
548
+ }
549
+ }
550
+ catch (e) { /* ignore */ }
551
+ let live = [];
552
+ if (pluginInventory) {
553
+ try {
554
+ const res = await pluginInventory.list();
555
+ live = res.entries.filter((e) => e.moduleName === '@deepseek-ai/dsh-mcp-client');
556
+ }
557
+ catch (e) { /* ignore */ }
558
+ }
559
+ for (const e of live) {
560
+ const bid = bareEntryId(e.entryId);
561
+ const found = rows.find((r) => r.id === bid);
562
+ if (found)
563
+ found.live = { enabled: e.enabled, phase: e.fiberPhase };
564
+ else
565
+ 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 } });
566
+ }
567
+ for (const row of rows) {
568
+ if (row.toolCount === undefined)
569
+ row.toolCount = toolCounts[row.serverName] || 0;
570
+ }
571
+ return { ok: true, rows, paths: { project: p.projectPatch, global: p.globalPatch, home: p.home, profile: p.profileName }, errors };
572
+ }
573
+ async function mcpmAdd(args) {
574
+ const p = await ensurePaths();
575
+ const serverName = String(args.serverName || '').trim();
576
+ const transport = args.transport === 'stdio' ? 'stdio' : 'streamable-http';
577
+ const level = args.level === 'global' ? 'global' : 'project';
578
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
579
+ return { ok: false, error: 'serverName 需为 1-32 位 [A-Za-z0-9_-]' };
580
+ const baseId = 'mcp-' + serverName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
581
+ const existing = await collectAll();
582
+ if (existing.serverNames.has(serverName))
583
+ return { ok: false, error: 'serverName "' + serverName + '" 已存在' };
584
+ let id = baseId;
585
+ let n = 2;
586
+ while (existing.ids.has(id)) {
587
+ id = baseId + '-' + n;
588
+ n++;
589
+ }
590
+ const row = { id, serverName, transport };
591
+ if (transport === 'streamable-http') {
592
+ const url = String(args.url || '').trim();
593
+ if (!/^https?:\/\//.test(url))
594
+ return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
595
+ row.url = url;
596
+ row.headers = parseKv(args.headers);
597
+ }
598
+ else {
599
+ const command = String(args.command || '').trim();
600
+ if (!command)
601
+ return { ok: false, error: 'command 不能为空' };
602
+ row.command = command;
603
+ row.args = parseArgs(args.args);
604
+ row.env = parseKv(args.env);
605
+ }
606
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
607
+ return withWriteLock(async () => {
608
+ let content = '';
609
+ try {
610
+ content = await readPatch(abs);
611
+ }
612
+ catch (e) {
613
+ return { ok: false, error: '读取补丁失败: ' + message(e) };
614
+ }
615
+ content = appendBlock(content, buildInsertBlock(row));
616
+ if (args.enabled === false)
617
+ content = appendBlock(content, buildDisableBlock(id, true));
618
+ try {
619
+ await writePatch(abs, content);
620
+ }
621
+ catch (e) {
622
+ return { ok: false, error: '写入补丁失败: ' + message(e) };
623
+ }
624
+ return { ok: true, row: { ...row, level, disabled: args.enabled === false } };
625
+ });
626
+ }
627
+ async function mcpmEdit(args) {
628
+ const p = await ensurePaths();
629
+ const id = String(args.id || '');
630
+ const level = args.level === 'global' ? 'global' : 'project';
631
+ if (!id)
632
+ return { ok: false, error: '缺少 id' };
633
+ const all = await collectAll();
634
+ const cur = all.rows.find((r) => r.id === id);
635
+ if (!cur)
636
+ return { ok: false, error: '未找到条目 ' + id };
637
+ const serverName = String(args.serverName || '').trim();
638
+ const transport = args.transport === 'stdio' ? 'stdio' : 'streamable-http';
639
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
640
+ return { ok: false, error: 'serverName 需为 1-32 位 [A-Za-z0-9_-]' };
641
+ if (serverName !== cur.serverName && all.serverNames.has(serverName))
642
+ return { ok: false, error: 'serverName "' + serverName + '" 已被其他服务占用' };
643
+ const row = { id, serverName, transport };
644
+ if (transport === 'streamable-http') {
645
+ const url = String(args.url || '').trim();
646
+ if (!/^https?:\/\//.test(url))
647
+ return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
648
+ row.url = url;
649
+ row.headers = parseKv(args.headers);
650
+ }
651
+ else {
652
+ const command = String(args.command || '').trim();
653
+ if (!command)
654
+ return { ok: false, error: 'command 不能为空' };
655
+ row.command = command;
656
+ row.args = parseArgs(args.args);
657
+ row.env = parseKv(args.env);
658
+ }
659
+ const oldAbs = cur.level === 'global' ? p.globalPatch : p.projectPatch;
660
+ const newAbs = level === 'global' ? p.globalPatch : p.projectPatch;
661
+ const block = buildInsertBlock(row);
662
+ return withWriteLock(async () => {
663
+ if (oldAbs !== newAbs) {
664
+ let c = await readPatch(oldAbs);
665
+ c = removeEntryAll(c, id);
666
+ await writePatch(oldAbs, c);
667
+ let c2 = await readPatch(newAbs);
668
+ c2 = appendBlock(c2, block);
669
+ if (cur.disabled)
670
+ c2 = appendBlock(c2, buildDisableBlock(id, true));
671
+ await writePatch(newAbs, c2);
672
+ }
673
+ else {
674
+ let c = await readPatch(newAbs);
675
+ c = removeEntryAll(c, id);
676
+ c = appendBlock(c, block);
677
+ if (cur.disabled)
678
+ c = appendBlock(c, buildDisableBlock(id, true));
679
+ await writePatch(newAbs, c);
680
+ }
681
+ return { ok: true };
682
+ });
683
+ }
684
+ async function mcpmSetEnabled(args) {
685
+ const p = await ensurePaths();
686
+ const { id, level } = args;
687
+ const enabled = !!args.enabled;
688
+ if (!id || (level !== 'global' && level !== 'project'))
689
+ return { ok: false, error: '缺少 id 或 level' };
690
+ if (!(await entryExists(id, level)))
691
+ return { ok: false, error: '未找到条目 ' + id };
692
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
693
+ return withWriteLock(async () => {
694
+ let c = await readPatch(abs);
695
+ if (enabled) {
696
+ c = removeMarked(c, id, 'disable');
697
+ const { rows } = parseRows(c);
698
+ const row = rows.find((r) => r.id === id);
699
+ if (row && row.disabled)
700
+ c = appendBlock(c, buildDisableBlock(id, false));
701
+ }
702
+ else {
703
+ c = removeMarked(c, id, 'enable');
704
+ c = appendBlock(c, buildDisableBlock(id, true));
705
+ }
706
+ await writePatch(abs, c);
707
+ return { ok: true };
708
+ });
709
+ }
710
+ async function mcpmRestart(args) {
711
+ const p = await ensurePaths();
712
+ const { id, level } = args;
713
+ if (!id || (level !== 'global' && level !== 'project'))
714
+ return { ok: false, error: '缺少 id 或 level' };
715
+ if (!(await entryExists(id, level)))
716
+ return { ok: false, error: '未找到条目 ' + id };
717
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
718
+ return withWriteLock(async () => {
719
+ let c = await readPatch(abs);
720
+ c = removeMarked(c, id, 'enable');
721
+ c = appendBlock(c, buildDisableBlock(id, true));
722
+ await writePatch(abs, c);
723
+ if (pluginInventory) {
724
+ await waitFor(async () => {
725
+ const e = await liveEntry(id);
726
+ return e ? e.enabled === false : false;
727
+ }, 5000, 300);
728
+ }
729
+ await wait(1000);
730
+ c = await readPatch(abs);
731
+ c = removeMarked(c, id, 'disable');
732
+ await writePatch(abs, c);
733
+ if (pluginInventory) {
734
+ await waitFor(async () => {
735
+ const e = await liveEntry(id);
736
+ return e ? e.enabled === true : false;
737
+ }, 5000, 300);
738
+ }
739
+ else
740
+ await wait(1500);
741
+ return { ok: true };
742
+ });
743
+ }
744
+ async function mcpmRemove(args) {
745
+ const p = await ensurePaths();
746
+ const { id, level } = args;
747
+ if (!id || (level !== 'global' && level !== 'project'))
748
+ return { ok: false, error: '缺少 id 或 level' };
749
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
750
+ return withWriteLock(async () => {
751
+ let c = await readPatch(abs);
752
+ c = removeEntryAll(c, id);
753
+ await writePatch(abs, c);
754
+ return { ok: true };
755
+ });
756
+ }
757
+ async function mcpmExport() {
758
+ const p = await ensurePaths();
759
+ const list = await mcpmList();
760
+ const rows = (list.rows || []).filter((r) => r.level !== 'loader').map((r) => ({
761
+ id: r.id,
762
+ serverName: r.serverName,
763
+ transport: r.transport,
764
+ url: r.url || undefined,
765
+ command: r.command || undefined,
766
+ args: r.args || undefined,
767
+ env: r.env || undefined,
768
+ headers: r.headers || undefined,
769
+ level: r.level,
770
+ disabled: r.disabled,
771
+ }));
772
+ const json = JSON.stringify({ exportedAt: new Date().toISOString(), rows }, null, 2);
773
+ let savedTo = null;
774
+ try {
775
+ const abs = p.home + (p.home.indexOf('\\') >= 0 ? '\\' : '/') + 'mcp-manager-export.json';
776
+ await writePatch(abs, json);
777
+ savedTo = abs;
778
+ }
779
+ catch (e) { /* non-fatal */ }
780
+ return { ok: true, json, savedTo };
781
+ }
782
+ async function mcpmImport(args) {
783
+ const p = await ensurePaths();
784
+ let parsed = null;
785
+ try {
786
+ parsed = JSON.parse(String(args.json || ''));
787
+ }
788
+ catch (e) {
789
+ return { ok: false, error: 'JSON 解析失败: ' + message(e) };
790
+ }
791
+ const entries = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.rows) ? parsed.rows : null);
792
+ if (!entries)
793
+ return { ok: false, error: '导入内容格式不正确:需要数组或 { rows: [...] }' };
794
+ const added = [];
795
+ const skipped = [];
796
+ for (const item of entries) {
797
+ const norm = normalizeImportItem(item);
798
+ if (!norm.ok) {
799
+ skipped.push({ id: (item && (item.id || item.serverName)) || '?', reason: norm.error });
800
+ continue;
801
+ }
802
+ const row = norm.row;
803
+ const existing = await collectAll();
804
+ if (existing.ids.has(row.id)) {
805
+ skipped.push({ id: row.id, reason: 'id 已存在' });
806
+ continue;
807
+ }
808
+ if (existing.serverNames.has(row.serverName)) {
809
+ skipped.push({ id: row.id, reason: 'serverName 已存在' });
810
+ continue;
811
+ }
812
+ const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
813
+ const res = await withWriteLock(async () => {
814
+ let c = await readPatch(abs);
815
+ c = appendBlock(c, buildInsertBlock(row));
816
+ if (row.disabled)
817
+ c = appendBlock(c, buildDisableBlock(row.id, true));
818
+ await writePatch(abs, c);
819
+ return { ok: true };
820
+ });
821
+ if (res && res.ok)
822
+ added.push(row.id);
823
+ else
824
+ skipped.push({ id: row.id, reason: (res && res.error) || '写入失败' });
825
+ }
826
+ return { ok: true, added, skipped };
827
+ }
828
+ function parseKv(text) {
829
+ const out = {};
830
+ String(text || '').split(/\r?\n/).forEach((line) => {
831
+ const t = line.trim();
832
+ if (!t || t.startsWith('#'))
833
+ return;
834
+ const i = t.indexOf('=');
835
+ if (i <= 0)
836
+ return;
837
+ out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
838
+ });
839
+ return out;
840
+ }
841
+ function parseArgs(text) {
842
+ return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter((s) => s !== '');
843
+ }
844
+ const handlers = {
845
+ 'mcpm-list': mcpmList,
846
+ 'mcpm-add': mcpmAdd,
847
+ 'mcpm-edit': mcpmEdit,
848
+ 'mcpm-set-enabled': mcpmSetEnabled,
849
+ 'mcpm-restart': mcpmRestart,
850
+ 'mcpm-remove': mcpmRemove,
851
+ 'mcpm-export': mcpmExport,
852
+ 'mcpm-import': mcpmImport,
853
+ };
854
+ // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
855
+ const text = (value) => [{ type: 'text', text: value }];
856
+ tools.register(defineTool({
857
+ name: 'mcp_manager_list',
858
+ description: 'List all configured MCP servers (level, enabled state, live loader status, registered tool count).',
859
+ parameters: {},
860
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
861
+ async execute() {
862
+ const r = await mcpmList();
863
+ if (!r.ok)
864
+ throw new Error(r.error);
865
+ const summary = (r.rows || []).map((x) => (x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
866
+ (x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
867
+ (typeof x.toolCount === 'number' ? ' | tools:' + x.toolCount : '')));
868
+ return 'MCP servers:\n' + (summary.join('\n') || '(none)');
869
+ },
870
+ }));
871
+ tools.register(defineTool({
872
+ name: 'mcp_manager_set_enabled',
873
+ description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
874
+ parameters: {
875
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
876
+ level: { type: 'string', required: true, description: 'project or global.' },
877
+ enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
878
+ },
879
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
880
+ async execute(args) {
881
+ const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
882
+ if (!r.ok)
883
+ throw new Error(r.error);
884
+ return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled');
885
+ },
886
+ }));
887
+ tools.register(defineTool({
888
+ name: 'mcp_manager_restart',
889
+ description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
890
+ parameters: {
891
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
892
+ level: { type: 'string', required: true, description: 'project or global.' },
893
+ },
894
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
895
+ async execute(args) {
896
+ const r = await mcpmRestart({ id: args.id, level: args.level });
897
+ if (!r.ok)
898
+ throw new Error(r.error);
899
+ return 'OK: ' + args.id + ' restarted';
900
+ },
901
+ }));
902
+ tools.register(defineTool({
903
+ name: 'mcp_manager_add',
904
+ description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
905
+ parameters: {
906
+ serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
907
+ transport: { type: 'string', required: true, description: 'streamable-http or stdio.' },
908
+ url: { type: 'string', description: 'Server URL (required for streamable-http).' },
909
+ command: { type: 'string', description: 'Executable (required for stdio).' },
910
+ args: { type: 'string', description: 'Arguments, space separated (stdio).' },
911
+ headers: { type: 'string', description: 'Extra headers as key=value lines (streamable-http).' },
912
+ env: { type: 'string', description: 'Extra env vars as key=value lines (stdio).' },
913
+ level: { type: 'string', description: 'project or global (default project).' },
914
+ },
915
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
916
+ async execute(args) {
917
+ const r = await mcpmAdd(args);
918
+ if (!r.ok)
919
+ throw new Error(r.error);
920
+ return 'OK: added ' + r.row.id + ' at ' + r.row.level;
921
+ },
922
+ }));
923
+ // ---------- HTTP API route (UI half), registered defensively ----------
924
+ if (webServer) {
925
+ const readBody = (req) => new Promise((resolve, reject) => {
926
+ const chunks = [];
927
+ req.on('data', (c) => chunks.push(String(c)));
928
+ req.on('end', () => resolve(chunks.join('')));
929
+ req.on('error', reject);
930
+ });
931
+ try {
932
+ // ctx.effect wires the route's disposer into this plugin's scope, so an
933
+ // unload (HMR removal, disable, update) unregisters the route — the
934
+ // documented cleanup contract (webServer.register does not auto-scope).
935
+ ctx.effect(() => webServer.register({
936
+ kind: 'exact',
937
+ path: '/dsh-mcp-manager/api',
938
+ handler: async (req, res) => {
939
+ res.writeHead(200, { 'content-type': 'application/json' });
940
+ try {
941
+ let payload = {};
942
+ try {
943
+ payload = JSON.parse((await readBody(req)) || '{}');
944
+ }
945
+ catch (e) { /* fallthrough */ }
946
+ const op = String(payload.op || '');
947
+ const fn = handlers[op];
948
+ if (!fn) {
949
+ res.end(JSON.stringify({ ok: false, error: '未知操作: ' + op }));
950
+ return;
951
+ }
952
+ const result = await fn(payload.args || {});
953
+ res.end(JSON.stringify(result === undefined ? { ok: true } : result));
954
+ }
955
+ catch (e) {
956
+ res.end(JSON.stringify({ ok: false, error: message(e) }));
957
+ }
958
+ },
959
+ }), 'dsh-mcp-manager: api route');
960
+ }
961
+ catch (e) {
962
+ // A registration failure must never take down the whole entry: log and continue.
963
+ console.error('[dsh-mcp-manager] webServer route registration failed:', message(e));
964
+ }
965
+ }
966
+ },
967
+ };