dsh-plugin-tool-management 0.10.0 → 0.12.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +67 -50
  3. package/README_EN.md +61 -38
  4. package/cordis.patch.yml +10 -1
  5. package/docs/images/1-EN.png +0 -0
  6. package/docs/images/1.png +0 -0
  7. package/docs/images/2-EN.png +0 -0
  8. package/docs/images/2.png +0 -0
  9. package/docs/images/3-EN.png +0 -0
  10. package/docs/images/3.png +0 -0
  11. package/docs/images/4-EN.png +0 -0
  12. package/docs/images/4.png +0 -0
  13. package/docs/images/5-EN.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6-EN.png +0 -0
  16. package/docs/images/6.png +0 -0
  17. package/docs/images/7-EN.png +0 -0
  18. package/docs/images/7.png +0 -0
  19. package/docs/images/8-EN.png +0 -0
  20. package/docs/images/8.png +0 -0
  21. package/docs/update.md +132 -12
  22. package/lib/client.js +2826 -547
  23. package/lib/compat/patch-dialect.js +173 -0
  24. package/lib/compat/preset-reach.js +1 -10
  25. package/lib/compat/probe.js +205 -24
  26. package/lib/compat/runtime-notes.js +25 -0
  27. package/lib/context-inject.js +83 -9
  28. package/lib/host-names.js +12 -0
  29. package/lib/http-fence.js +35 -15
  30. package/lib/hub.js +28 -2
  31. package/lib/imports/parsers.js +15 -9
  32. package/lib/imports/upload.js +43 -4
  33. package/lib/index.js +927 -3897
  34. package/lib/mcp/loader-token.js +238 -0
  35. package/lib/mcp/manager.js +1769 -0
  36. package/lib/mcp/override-blocks.js +10 -3
  37. package/lib/mcp/patch-yaml.js +351 -0
  38. package/lib/mcp/secret-guard.js +145 -0
  39. package/lib/{rules → memories}/archive-engine.js +1 -1
  40. package/lib/{rules → memories}/archive.js +1 -1
  41. package/lib/memories/constants.js +128 -0
  42. package/lib/memories/index-io.js +330 -0
  43. package/lib/memories/projection.js +280 -0
  44. package/lib/memories/service.js +686 -0
  45. package/lib/memories/snapshot.js +672 -0
  46. package/lib/ops/candidates.js +64 -0
  47. package/lib/ops/compat.js +226 -0
  48. package/lib/ops/ctx.js +9 -0
  49. package/lib/ops/memory.js +678 -0
  50. package/lib/ops/prompts.js +107 -0
  51. package/lib/ops/scene-records.js +460 -0
  52. package/lib/ops/scene-sync.js +17 -0
  53. package/lib/ops/sessions.js +603 -0
  54. package/lib/ops/trash.js +140 -0
  55. package/lib/paths.js +103 -0
  56. package/lib/prompts/preset-id.js +49 -0
  57. package/lib/{agents-md → prompts}/service.js +1 -1
  58. package/lib/request-gate.js +320 -0
  59. package/lib/scene-prompt-sync.js +4 -4
  60. package/lib/scenes/candidates.js +344 -0
  61. package/lib/{history → sessions}/bridge.js +124 -36
  62. package/lib/sessions/history.js +323 -0
  63. package/lib/{history → sessions}/tombstone.js +1 -1
  64. package/lib/{history → sessions}/workspace.js +151 -50
  65. package/lib/skills/core.js +74 -39
  66. package/lib/skills/readonly-discovery.js +4 -1
  67. package/lib/skills/service.js +98 -13
  68. package/lib/subagents/service.js +199 -55
  69. package/lib/tools/deps.js +8 -0
  70. package/lib/tools/mcp.js +110 -0
  71. package/lib/tools/memory.js +87 -0
  72. package/lib/tools/prompt.js +70 -0
  73. package/lib/tools/skills.js +139 -0
  74. package/lib/tools/subagent.js +40 -0
  75. package/package.json +13 -10
  76. package/lib/agents-md/preset-id.js +0 -49
  77. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,1769 @@
1
+ // MCP 管理域 —— 侧车读写(备注 / 设置 / 停用表)+ 补丁文件行编辑 + 16 个 op 的实现。
2
+ //
3
+ // 2026-09-19 从 index.ts 的 apply 闭包原样抽出(块 A 1795-1913 + 块 B 1920-3100)。
4
+ // 为什么单独一个文件:这 1300 行只做一件事 —— 把「MCP 配置」翻译成补丁文件的改动与
5
+ // 界面看到的一份状态。它此前夹在注入通道、令牌门禁与场景档案之间,改一处要在 4000 行里穿行。
6
+ //
7
+ // 边界(两处刻意保留在 index.ts):
8
+ // * \`pluginVersion\` —— 通用 op,与 MCP 无关;
9
+ // * \`readJsonFile\` / \`writeJsonFile\` —— 通用侧车 JSON 读写,注入设置也在用,
10
+ // 所以留在 index.ts 并经 deps 传进来,避免为两个 6 行函数再造一层。
11
+ //
12
+ // **反向依赖**(本文件被外部调用,共 12 个出口,见 McpManager):
13
+ // 注入通道要 \`mcpmRowsWithNotes(false)\`(打码后那份)、场景档案引擎要读写停用表与备注、
14
+ // 工具门禁要读 TTL 缓存判断某工具是否被停用、审批策略要读插件设置。
15
+ // 这些以前靠闭包隐式共享,现在全部经返回的 McpManager 显式暴露 —— 谁用了什么一眼可见。
16
+ //
17
+ // **正向依赖**(本文件调用外部):补丁行解析用 ./patch-yaml.ts、打码判据用 ./secret-guard.ts,
18
+ // 其余 14 项(ensurePaths / withWriteLock / readPatch / pluginInventory / memoriesService 等)
19
+ // 由 deps 显式传入。
20
+ import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
21
+ import { MCP_CLIENT_MODULE } from '../host-names.js';
22
+ import { hubPath } from '../hub.js';
23
+ import { planOverrideCompaction } from './override-blocks.js';
24
+ import { createMcpStateCatalog } from './state-section.js';
25
+ import { appendBlock, buildDisableBlock, buildInsertBlock, parseRows, removeEntryAll, removeMarked, spliceRanges, splitLines, } from './patch-yaml.js';
26
+ import { describeMaskedOutcome, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
27
+ /**
28
+ * 归一化「已知工具」侧车的一行值。
29
+ *
30
+ * 兼容两种格式:旧版 `string[]`(只存名字,v0.8.5 及以前)与新版 `Array<{name, description?}>`。
31
+ * 非法名字、重复名字一律丢掉并去重后按名字排序 —— 排序保证写盘内容稳定(不会因为枚举顺序
32
+ * 变化而反复重写侧车)。导出仅为测试接缝(与 client 的 `memDefaultPickIds` 同一用意)。
33
+ */
34
+ export function normalizeKnownTools(raw) {
35
+ if (!Array.isArray(raw))
36
+ return [];
37
+ const out = [];
38
+ const seen = new Set();
39
+ for (const item of raw) {
40
+ const name = typeof item === 'string'
41
+ ? item
42
+ : String(item?.name ?? '');
43
+ if (!name || !/^[A-Za-z0-9_-]{1,128}$/.test(name) || seen.has(name))
44
+ continue;
45
+ seen.add(name);
46
+ const description = typeof item === 'object' && item !== null && typeof item.description === 'string'
47
+ ? String(item.description)
48
+ : undefined;
49
+ out.push({ name, ...(description ? { description } : {}) });
50
+ }
51
+ return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
52
+ }
53
+ /**
54
+ * 「当前可用」工具数:已知工具名里未被停用表扣减的个数;`*` = 整台停用 → 0。
55
+ *
56
+ * 场景档案收窄与手动逐工具开关写的是同一张停用表,所以**段、页面、`mcp_manager_list`
57
+ * 三处的「N 个工具」都必须是这个数** —— 用扣减前的总数会让模型看到自己 schema 里
58
+ * 并不存在的工具(数字与能调的工具对不上),页面也会和详情页的开关自相矛盾。
59
+ * 导出仅为测试接缝(与 `normalizeKnownTools` 同一用意)。
60
+ */
61
+ export function countEnabledTools(known, disabled) {
62
+ const off = Array.isArray(disabled) ? disabled : [];
63
+ if (off.indexOf('*') >= 0)
64
+ return 0;
65
+ let n = 0;
66
+ for (const tool of known)
67
+ if (off.indexOf(tool) < 0)
68
+ n++;
69
+ return n;
70
+ }
71
+ export function createMcpManager(deps) {
72
+ // 外部能力一次解构成局部名:块内代码是逐字搬来的,保持原样最不容易出错。
73
+ const { readJsonFile, writeJsonFile, ensurePaths, withWriteLock, message, tools, pluginInventory, settings, readPatch, writePatch, memoriesService, wait, } = deps;
74
+ // MCP 状态段(注入通道的数据源)由本域自己建:它要的 rows 就是本域的 mcpmRowsWithNotes(false),
75
+ // 让 index.ts 建再传进来会形成 index ↔ manager 的循环引用。
76
+ const mcpStateCatalog = createMcpStateCatalog({ rows: () => mcpmRowsWithNotes(false) });
77
+ // ---------- duplicate loader-id guard ----------
78
+ // Two rows with the same loader id make the plugin composition fail to
79
+ // boot (upstream hit exactly this after renaming an entry), so duplicates
80
+ // are reported on read and new ones are refused before any write.
81
+ function duplicateIdsOf(content) {
82
+ const counts = new Map();
83
+ for (const row of parseRows(content).rows) {
84
+ if (!row.id)
85
+ continue;
86
+ counts.set(row.id, (counts.get(row.id) || 0) + 1);
87
+ }
88
+ return [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
89
+ }
90
+ function duplicateGuard(before, after) {
91
+ const known = new Set(duplicateIdsOf(before));
92
+ const introduced = duplicateIdsOf(after).filter((id) => !known.has(id));
93
+ if (!introduced.length)
94
+ return null;
95
+ return { ok: false, error: '写入会产生重复的 loader id(重复 id 会导致 DSH 无法启动):' + introduced.join('、') };
96
+ }
97
+ // ---------- shared state ----------
98
+ async function collectAll() {
99
+ const p = await ensurePaths();
100
+ const ids = new Set();
101
+ const serverNames = new Set();
102
+ const rows = [];
103
+ for (const level of ['project', 'global']) {
104
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
105
+ let content = '';
106
+ try {
107
+ content = await readPatch(abs);
108
+ }
109
+ catch (e) {
110
+ continue;
111
+ }
112
+ const { rows: fileRows } = parseRows(content);
113
+ for (const r of fileRows) {
114
+ ids.add(r.id);
115
+ const sn = r.config && r.config.serverName ? String(r.config.serverName) : r.id;
116
+ serverNames.add(sn);
117
+ rows.push({ id: r.id, serverName: sn, level, disabled: !!r.disabled });
118
+ }
119
+ }
120
+ return { ids, serverNames, rows };
121
+ }
122
+ const bareEntryId = (v) => { const s = String(v); const i = s.lastIndexOf(':'); return i >= 0 ? s.slice(i + 1) : s; };
123
+ async function liveEntry(id) {
124
+ if (!pluginInventory)
125
+ return null;
126
+ try {
127
+ const res = await pluginInventory.list();
128
+ return res.entries.find((e) => e.moduleName === MCP_CLIENT_MODULE && bareEntryId(e.entryId) === id) || null;
129
+ }
130
+ catch (e) {
131
+ return null;
132
+ }
133
+ }
134
+ async function waitFor(pred, timeoutMs, stepMs) {
135
+ const start = Date.now();
136
+ for (;;) {
137
+ const v = await pred();
138
+ if (v)
139
+ return true;
140
+ if (Date.now() - start > timeoutMs)
141
+ return false;
142
+ await wait(stepMs);
143
+ }
144
+ }
145
+ async function entryExists(id, level) {
146
+ const p = await ensurePaths();
147
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
148
+ let content = '';
149
+ try {
150
+ content = await readPatch(abs);
151
+ }
152
+ catch (e) {
153
+ return false;
154
+ }
155
+ const { rows } = parseRows(content);
156
+ if (rows.some((r) => r.id === id))
157
+ return true;
158
+ return (await liveEntry(id)) !== null;
159
+ }
160
+ function normalizeRow(r, level, abs) {
161
+ const cfg = r.config || {};
162
+ return {
163
+ id: r.id,
164
+ serverName: cfg.serverName || r.id,
165
+ transport: cfg.transport || null,
166
+ url: cfg.url || null,
167
+ command: cfg.command || null,
168
+ args: cfg.args || null,
169
+ env: cfg.env || null,
170
+ headers: cfg.headers || null,
171
+ level,
172
+ disabled: !!r.disabled,
173
+ managed: !!r.managed,
174
+ };
175
+ }
176
+ // ---------- import helpers ----------
177
+ function toStrMap(v) {
178
+ if (!v || typeof v !== 'object' || Array.isArray(v))
179
+ return {};
180
+ const out = {};
181
+ for (const k of Object.keys(v))
182
+ out[k] = String(v[k]);
183
+ return out;
184
+ }
185
+ function normalizeImportItem(item) {
186
+ if (!item || typeof item !== 'object' || Array.isArray(item))
187
+ return { ok: false, error: '条目不是对象' };
188
+ const it = item;
189
+ const serverName = String(it.serverName || '').trim();
190
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
191
+ return { ok: false, error: 'serverName 非法: ' + String(it.serverName) };
192
+ const transport = it.transport === 'stdio' ? 'stdio' : 'streamable-http';
193
+ const level = it.level === 'global' ? 'global' : 'project';
194
+ const baseId = 'mcp-' + serverName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
195
+ const rawId = String(it.id || '').trim();
196
+ const id = rawId && /^[A-Za-z0-9_.:@%+=/-]+$/.test(rawId) ? rawId : baseId;
197
+ const row = { id, serverName, transport, level, disabled: !!it.disabled };
198
+ if (transport === 'streamable-http') {
199
+ const url = String(it.url || '').trim();
200
+ if (!/^https?:\/\//.test(url))
201
+ return { ok: false, error: serverName + ': url 非法' };
202
+ row.url = url;
203
+ row.headers = toStrMap(it.headers);
204
+ }
205
+ else {
206
+ const command = String(it.command || '').trim();
207
+ if (!command)
208
+ return { ok: false, error: serverName + ': command 缺失' };
209
+ row.command = command;
210
+ row.args = Array.isArray(it.args) ? it.args.map(String) : [];
211
+ row.env = toStrMap(it.env);
212
+ }
213
+ return { ok: true, row };
214
+ }
215
+ // User notes are keyed by loader id: they survive renames of the server
216
+ // name and are never touched by config rewrites.
217
+ //
218
+ // Sidecar caches (notes / settings / disabled tools) are short-TTL: reads
219
+ // hit the cache, but every WRITE re-reads the file (force=true) and merges
220
+ // on top of the fresh on-disk state. Without the forced re-read, a manual
221
+ // edit of the sidecar file would be silently overwritten by a stale cache.
222
+ //
223
+ // 落点:**hub 内**(`~/.dsh/tool-management/`),文件名按域取(`mcp-*`)。
224
+ // 它们曾经躺在 `$DSH_HOME` 根下、还带着插件名前缀(`dsh-plugin-tool-management-notes.json`),
225
+ // 2026-09-16 用户要求「插件产生的文件全部收进 hub、名字与当前代码一致」——
226
+ // 旧文件由 hub.ts 的 `migrateHubLayoutSync()` 在启动时搬进来(见 apply())。
227
+ const SIDECAR_TTL_MS = 3000;
228
+ const MCP_NOTES_FILE = 'mcp-notes.json';
229
+ const MCP_SETTINGS_FILE = 'mcp-settings.json';
230
+ const MCP_DISABLED_TOOLS_FILE = 'mcp-disabled-tools.json';
231
+ const MCP_KNOWN_TOOLS_FILE = 'mcp-known-tools.json';
232
+ const MCP_EXPORT_FILE = 'mcp-export.json';
233
+ /** MCP 侧车绝对路径(都在 hub 内)。 */
234
+ const mcpSidecar = (name) => hubPath(name);
235
+ let notesCache = null;
236
+ async function readNotes(force = false) {
237
+ if (notesCache && !force && Date.now() - notesCache.at < SIDECAR_TTL_MS)
238
+ return notesCache.value;
239
+ const raw = await readJsonFile(mcpSidecar(MCP_NOTES_FILE));
240
+ const out = {};
241
+ if (raw && typeof raw === 'object') {
242
+ for (const key of Object.keys(raw)) {
243
+ const value = raw[key];
244
+ if (typeof value === 'string' && value.trim())
245
+ out[key] = value;
246
+ }
247
+ }
248
+ notesCache = { at: Date.now(), value: out };
249
+ return out;
250
+ }
251
+ async function mcpmNote(args) {
252
+ const id = String((args && args.id) || '').trim();
253
+ if (!id)
254
+ return { ok: false, error: '缺少 id' };
255
+ const note = String((args && args.note) == null ? '' : args.note).trim();
256
+ const p = await ensurePaths();
257
+ return withWriteLock(async () => {
258
+ // Force re-read so an externally edited notes file is merged, not clobbered.
259
+ const map = Object.assign({}, await readNotes(true));
260
+ if (note)
261
+ map[id] = note;
262
+ else
263
+ delete map[id];
264
+ try {
265
+ await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
266
+ }
267
+ catch (e) {
268
+ return { ok: false, error: '备注保存失败: ' + message(e) };
269
+ }
270
+ notesCache = { at: Date.now(), value: map };
271
+ return { ok: true, notes: map };
272
+ });
273
+ }
274
+ // 界面轮询间隔的合法区间:再快没有意义(列表刷新本身要读文件),再慢就失去"自动跟上"的
275
+ // 意义。两侧都夹住,避免手改侧车文件时写进一个 0 或无穷大把界面刷爆。
276
+ const POLL_INTERVAL_MIN_MS = 2000;
277
+ const POLL_INTERVAL_MAX_MS = 60_000;
278
+ const SETTINGS_DEFAULTS = { pollIntervalMs: 5000, toolDescriptionMaxLength: 0, requireConfirmForModelRuleWrite: true, requireConfirmForModelSubagentRun: true };
279
+ let pluginSettingsCache = null;
280
+ function clampInt(value, min, max, fallback) {
281
+ // Number(null) is 0 — treat missing/empty input as "use the default".
282
+ if (value === null || value === undefined || value === '')
283
+ return fallback;
284
+ const n = Number(value);
285
+ if (!Number.isFinite(n))
286
+ return fallback;
287
+ return Math.min(max, Math.max(min, Math.round(n)));
288
+ }
289
+ async function readPluginSettings(force = false) {
290
+ if (pluginSettingsCache && !force && Date.now() - pluginSettingsCache.at < SIDECAR_TTL_MS)
291
+ return pluginSettingsCache.value;
292
+ const p = await ensurePaths();
293
+ const raw = await readJsonFile(mcpSidecar(MCP_SETTINGS_FILE));
294
+ pluginSettingsCache = {
295
+ at: Date.now(),
296
+ value: {
297
+ pollIntervalMs: clampInt(raw && raw.pollIntervalMs, POLL_INTERVAL_MIN_MS, POLL_INTERVAL_MAX_MS, SETTINGS_DEFAULTS.pollIntervalMs),
298
+ // 0 = keep descriptions in full (the UI default); > 0 truncates.
299
+ toolDescriptionMaxLength: clampInt(raw && raw.toolDescriptionMaxLength, 0, 2000, SETTINGS_DEFAULTS.toolDescriptionMaxLength),
300
+ // 模型写规则需确认(D2):程序化强制,只读设置供 tools/pre-execute 判定。
301
+ requireConfirmForModelRuleWrite: (raw && typeof raw.requireConfirmForModelRuleWrite === 'boolean')
302
+ ? raw.requireConfirmForModelRuleWrite
303
+ : SETTINGS_DEFAULTS.requireConfirmForModelRuleWrite,
304
+ // 子代理运行花真 token:默认确认,设置可关(设计 §3.2)。
305
+ requireConfirmForModelSubagentRun: (raw && typeof raw.requireConfirmForModelSubagentRun === 'boolean')
306
+ ? raw.requireConfirmForModelSubagentRun
307
+ : SETTINGS_DEFAULTS.requireConfirmForModelSubagentRun,
308
+ },
309
+ };
310
+ return pluginSettingsCache.value;
311
+ }
312
+ async function mcpmSettings(args) {
313
+ const current = await readPluginSettings();
314
+ if (!args || args.set !== true)
315
+ return { ok: true, settings: current };
316
+ const next = {
317
+ pollIntervalMs: clampInt(args.pollIntervalMs, POLL_INTERVAL_MIN_MS, POLL_INTERVAL_MAX_MS, current.pollIntervalMs),
318
+ toolDescriptionMaxLength: clampInt(args.toolDescriptionMaxLength, 0, 2000, current.toolDescriptionMaxLength),
319
+ requireConfirmForModelRuleWrite: (args && typeof args.requireConfirmForModelRuleWrite === 'boolean')
320
+ ? args.requireConfirmForModelRuleWrite
321
+ : current.requireConfirmForModelRuleWrite,
322
+ requireConfirmForModelSubagentRun: (args && typeof args.requireConfirmForModelSubagentRun === 'boolean')
323
+ ? args.requireConfirmForModelSubagentRun
324
+ : current.requireConfirmForModelSubagentRun,
325
+ };
326
+ const p = await ensurePaths();
327
+ return withWriteLock(async () => {
328
+ try {
329
+ await writeJsonFile(mcpSidecar(MCP_SETTINGS_FILE), next);
330
+ }
331
+ catch (e) {
332
+ return { ok: false, error: '设置保存失败: ' + message(e) };
333
+ }
334
+ pluginSettingsCache = { at: Date.now(), value: next };
335
+ return { ok: true, settings: next };
336
+ });
337
+ }
338
+ // ---------- per-tool enable/disable (execution + visibility boundary) ----------
339
+ // dsh-mcp-client has no per-tool config, but the DSH tool runtime exposes
340
+ // two official seams: `tools.restrict({ deny })` removes a global tool from
341
+ // every scope's model-visible schema list, and `tools.guard` denies the call
342
+ // before the body runs. Disabled tools therefore become invisible AND
343
+ // uncallable — no patch rewrite, no DSH restart.
344
+ let disabledToolsCache = null;
345
+ async function readDisabledTools(force = false) {
346
+ if (disabledToolsCache && !force && Date.now() - disabledToolsCache.at < SIDECAR_TTL_MS)
347
+ return disabledToolsCache.value;
348
+ const p = await ensurePaths();
349
+ const raw = await readJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE));
350
+ const out = {};
351
+ if (raw && typeof raw === 'object') {
352
+ for (const serverName of Object.keys(raw)) {
353
+ const list = raw[serverName];
354
+ if (Array.isArray(list)) {
355
+ // `*` 必须原样保留:它是「整台服务器停用」的通配(场景档案未勾选的服务器写的就是它),
356
+ // 被这里过滤掉的话 guard / restrict / 快照三条链路一起失效——整台停用变成空转。
357
+ const names = list.map((name) => String(name)).filter((name) => name === '*' || /^[A-Za-z0-9_-]{1,128}$/.test(name));
358
+ if (names.length)
359
+ out[serverName] = names;
360
+ }
361
+ }
362
+ }
363
+ disabledToolsCache = { at: Date.now(), value: out };
364
+ return out;
365
+ }
366
+ /** Fully-qualified model-facing names (`mcp__<serverName>__<tool>`) of every disabled tool. */
367
+ function disabledToolNames(map) {
368
+ const out = [];
369
+ for (const serverName of Object.keys(map)) {
370
+ for (const tool of map[serverName])
371
+ out.push('mcp__' + serverName + '__' + tool);
372
+ }
373
+ return out;
374
+ }
375
+ // ---------- 「已知工具」侧车(v0.8.1)-----------------------------------
376
+ // MCP 工具名只在服务器**运行**时可见(tools.schemas() 里才有)。未运行的服务器在
377
+ // 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
378
+ // 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
379
+ // 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
380
+ let knownMcpToolsCache = null;
381
+ async function readKnownMcpTools(force = false) {
382
+ if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
383
+ return knownMcpToolsCache.value;
384
+ const p = await ensurePaths();
385
+ const raw = await readJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE));
386
+ const out = {};
387
+ if (raw && typeof raw === 'object') {
388
+ for (const serverName of Object.keys(raw)) {
389
+ const list = normalizeKnownTools(raw[serverName]);
390
+ if (list.length)
391
+ out[serverName] = list;
392
+ }
393
+ }
394
+ knownMcpToolsCache = { at: Date.now(), value: out };
395
+ return out;
396
+ }
397
+ async function writeKnownMcpTools(value) {
398
+ const p = await ensurePaths();
399
+ await writeJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE), value);
400
+ knownMcpToolsCache = { at: Date.now(), value: value };
401
+ }
402
+ /** 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。 */
403
+ function isToolDisabledIn(map, fullName) {
404
+ if (!fullName.startsWith('mcp__'))
405
+ return false;
406
+ const rest = fullName.slice(5);
407
+ const i = rest.indexOf('__');
408
+ if (i <= 0)
409
+ return false;
410
+ const list = map[rest.slice(0, i)];
411
+ if (!list)
412
+ return false;
413
+ if (list.indexOf('*') >= 0)
414
+ return true;
415
+ return list.indexOf(rest.slice(i + 2)) >= 0;
416
+ }
417
+ async function mcpmToolEnabled(args) {
418
+ const serverName = String((args && args.serverName) || '').trim();
419
+ const tool = String((args && args.tool) || '').trim();
420
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
421
+ return { ok: false, error: 'serverName 不合法' };
422
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(tool))
423
+ return { ok: false, error: 'tool 名称不合法' };
424
+ const enabled = (args && args.enabled) !== false;
425
+ const p = await ensurePaths();
426
+ return withWriteLock(async () => {
427
+ // Force re-read so an externally edited disabled-tools file is merged, not clobbered.
428
+ const map = Object.assign({}, await readDisabledTools(true));
429
+ const disabled = new Set(map[serverName] || []);
430
+ if (enabled)
431
+ disabled.delete(tool);
432
+ else
433
+ disabled.add(tool);
434
+ if (disabled.size)
435
+ map[serverName] = [...disabled].sort();
436
+ else
437
+ delete map[serverName];
438
+ try {
439
+ await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), map);
440
+ }
441
+ catch (e) {
442
+ return { ok: false, error: '保存失败: ' + message(e) };
443
+ }
444
+ disabledToolsCache = { at: Date.now(), value: map };
445
+ await applyToolRestrictions();
446
+ return { ok: true, serverName, disabled: map[serverName] || [] };
447
+ });
448
+ }
449
+ // Visibility seam: keep one active restriction per **agent scope**, refreshed whenever
450
+ // the tool set or the disabled set changes. Three facts decide this design(都读过官方源码
451
+ // dsh-tools 0.1.5-rc.2):
452
+ // · `restrict()` **要求 scoped context** —— 插件级 ctx 调用必抛 "requires a scoped
453
+ // context",这正是「停用工具从模型可见 schema 消失」这半边此前从未生效的原因(V1);
454
+ // · 每次 layer 变化(restriction 就是一次 layer effect)官方都会 emit `tools/change`
455
+ // (`layers = new ScopedLayers(…, () => this.ctx.emit("tools/change"))`),而重放正挂在
456
+ // `tools/change` 上 —— 名单没变还重放就是自激;
457
+ // · 表要取自**不受限**的插件级视图:在 agent scope 里取 `schemas()`,第一次限制生效后
458
+ // 表里就没有被停用的工具了,第二次重放会把它从 deny 名单里筛掉 —— 停用静默复活。
459
+ let restrictTimer = null;
460
+ /** 已应用名单的键;相同即返回(防自激,也让每次 tools/change 变成一次廉价判等)。 */
461
+ let appliedNamesKey = null;
462
+ /** 当前期望的 deny 名单(agent 晚到 / 重放时用它)。 */
463
+ let desiredNames = [];
464
+ /** agent → 撤掉这层限制的 disposer。 */
465
+ const agentRestrictions = new Map();
466
+ /** 见过的 agent(重放时逐个重新应用;disposed 时移除)。 */
467
+ const seenAgents = new Set();
468
+ function scopedToolsOf(agent) {
469
+ const scoped = agent && agent.ctx && agent.ctx.tools;
470
+ return scoped && typeof scoped === 'object' ? scoped : undefined;
471
+ }
472
+ /** 把当前名单装到某个 agent scope 上(空名单 = 没有要摘的工具,不碰它)。 */
473
+ function restrictAgent(agent) {
474
+ if (desiredNames.length === 0)
475
+ return;
476
+ const scoped = scopedToolsOf(agent);
477
+ if (!scoped || typeof scoped.restrict !== 'function') {
478
+ // agent scope 上没有 tools 面(或它没有 restrict):这半边做不成,如实说 ——
479
+ // 静默跳过会让人以为"停用工具从模型工具表里消失"已经生效。
480
+ noteRuntime({
481
+ id: 'mcp-tool-visibility',
482
+ label: '停用工具的可见性',
483
+ kind: 'write',
484
+ fallback: 'inform-only',
485
+ detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
486
+ });
487
+ return;
488
+ }
489
+ try {
490
+ const dispose = scoped.restrict({ deny: desiredNames });
491
+ agentRestrictions.set(agent, typeof dispose === 'function' ? dispose : () => { });
492
+ clearRuntimeNote('mcp-tool-visibility');
493
+ }
494
+ catch (e) {
495
+ // 这个名字在这个 scope 里认不出 / scope 已经收了:可见性半边没生效。执行侧的 guard
496
+ // 仍然拦住调用,所以功能不缺 —— 但必须如实上报,不能假装成功(此前正是静默吞掉)。
497
+ noteRuntime({
498
+ id: 'mcp-tool-visibility',
499
+ label: '停用工具的可见性',
500
+ kind: 'write',
501
+ fallback: 'inform-only',
502
+ detail: '停用的 MCP 工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
503
+ });
504
+ }
505
+ }
506
+ /** agent 上线(`agent/created` 或启动期的 agent 列表):登记并立刻应用当前名单。 */
507
+ function attachAgent(agent) {
508
+ if (!agent || (typeof agent !== 'object' && typeof agent !== 'function'))
509
+ return;
510
+ seenAgents.add(agent);
511
+ restrictAgent(agent);
512
+ }
513
+ /** agent 下线(`agent/disposed`):先撤限制再销登记(撤限制会把 disposer 摘掉)。 */
514
+ function detachAgent(agent) {
515
+ seenAgents.delete(agent);
516
+ const dispose = agentRestrictions.get(agent);
517
+ agentRestrictions.delete(agent);
518
+ if (dispose) {
519
+ try {
520
+ dispose();
521
+ }
522
+ catch (e) { /* ignore */ }
523
+ }
524
+ }
525
+ async function applyToolRestrictions() {
526
+ if (typeof tools.restrict !== 'function')
527
+ return;
528
+ // Force re-read: this runs on the tools/change path, which is rare, so a
529
+ // stale cache must not keep an externally edited deny list hidden.
530
+ const map = await readDisabledTools(true);
531
+ // 通配 `*`(整服务器停用)先展开成已注册的全名,再与精确名单合并。
532
+ const wanted = disabledToolNames(map);
533
+ let registered;
534
+ try {
535
+ registered = new Set((await tools.schemas()).map((schema) => String(schema.name)));
536
+ }
537
+ catch (e) {
538
+ return;
539
+ }
540
+ for (const [serverName, list] of Object.entries(map)) {
541
+ if (list.indexOf('*') < 0)
542
+ continue;
543
+ const prefix = 'mcp__' + serverName + '__';
544
+ for (const fullName of registered)
545
+ if (fullName.startsWith(prefix))
546
+ wanted.push(fullName);
547
+ }
548
+ const names = wanted.filter((name) => registered.has(name));
549
+ const key = names.join('\u0000');
550
+ if (key === appliedNamesKey)
551
+ return;
552
+ appliedNamesKey = key;
553
+ desiredNames = names;
554
+ // 每次重放先撤旧限制:名单变短(重新启用某个工具)时,只有撤掉这层限制它才会重新可见。
555
+ for (const [agent, dispose] of agentRestrictions) {
556
+ agentRestrictions.delete(agent);
557
+ try {
558
+ dispose();
559
+ }
560
+ catch (e) { /* ignore */ }
561
+ }
562
+ if (!names.length)
563
+ return;
564
+ for (const agent of seenAgents)
565
+ restrictAgent(agent);
566
+ }
567
+ function scheduleToolRestrictions() {
568
+ if (restrictTimer)
569
+ return;
570
+ restrictTimer = setTimeout(() => {
571
+ restrictTimer = null;
572
+ applyToolRestrictions().catch(() => { });
573
+ }, 300);
574
+ }
575
+ function truncateText(value, limit) {
576
+ if (!limit || value.length <= limit)
577
+ return value;
578
+ return value.slice(0, Math.max(1, limit - 1)).replace(/\s+$/, '') + '…';
579
+ }
580
+ // ---------- secret masking (UI view only) ----------
581
+ // Only sensitive-looking keys are masked; `$VAR` / `!!js` references are
582
+ // indirections rather than secrets, so they stay readable. URL query strings
583
+ // are redacted because MCP credentials often ride there.
584
+ const SENSITIVE_KEY_RE = /(token|secret|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private[_-]?key|cookie|session|signature|bearer)/i;
585
+ function maskSecretValue(value) {
586
+ const text = String(value == null ? '' : value);
587
+ if (text === '')
588
+ return '';
589
+ if (text.startsWith('$'))
590
+ return text;
591
+ if (/^!!js\s/.test(text))
592
+ return text;
593
+ return '••••••';
594
+ }
595
+ function maskValueMap(map, onlySensitiveKeys) {
596
+ if (!map || typeof map !== 'object')
597
+ return map;
598
+ const out = {};
599
+ for (const key of Object.keys(map)) {
600
+ out[key] = (!onlySensitiveKeys || SENSITIVE_KEY_RE.test(key)) ? maskSecretValue(map[key]) : String(map[key]);
601
+ }
602
+ return out;
603
+ }
604
+ function maskUrlQuery(url) {
605
+ if (!url)
606
+ return url;
607
+ try {
608
+ const parsed = new URL(url);
609
+ if (parsed.search)
610
+ parsed.search = '?<redacted>';
611
+ return parsed.toString();
612
+ }
613
+ catch (e) {
614
+ return url;
615
+ }
616
+ }
617
+ /** Shared UI rows: mcpmList plus notes, secrets masked unless `reveal`. */
618
+ async function mcpmRowsWithNotes(reveal) {
619
+ const result = await mcpmList();
620
+ if (!result || result.ok === false)
621
+ return result;
622
+ const notes = await readNotes();
623
+ const rows = (result.rows || []).map((row) => {
624
+ const view = Object.assign({}, row, { notes: notes[row.id] || '' });
625
+ if (!reveal) {
626
+ view.url = maskUrlQuery(row.url);
627
+ view.headers = maskValueMap(row.headers, true);
628
+ view.env = maskValueMap(row.env, true);
629
+ }
630
+ return view;
631
+ });
632
+ return Object.assign({}, result, { rows });
633
+ }
634
+ /** Default list view: secrets always masked. */
635
+ async function mcpmListView() {
636
+ return mcpmRowsWithNotes(false);
637
+ }
638
+ /**
639
+ * Plain-text view. Split from mcpm-list into its own op so it can live in
640
+ * WRITE_OPS: `mcpm-list` stays token-free for rendering, while revealing
641
+ * env/headers secrets requires `x-dsh-token` when a token is configured —
642
+ * otherwise a LAN-exposed port would leak credentials read-only.
643
+ */
644
+ async function mcpmReveal() {
645
+ return mcpmRowsWithNotes(true);
646
+ }
647
+ // Tool preview for one MCP server: project the model-facing schemas down to
648
+ // a name + description + parameter-summary list. The registry prefixes every
649
+ // MCP tool with `mcp__<serverName>__`; we filter on that and strip the prefix
650
+ // for display. Only the direct `properties` of the parameters object are
651
+ // summarized — nested object/array children are omitted (one level is enough
652
+ // for a preview, keeps the dialog readable).
653
+ async function mcpmTools(args) {
654
+ const serverName = String(args && args.serverName || '').trim();
655
+ if (!serverName)
656
+ return { ok: false, error: 'serverName 不能为空' };
657
+ const prefix = 'mcp__' + serverName + '__';
658
+ let schemas = [];
659
+ try {
660
+ schemas = await tools.schemas();
661
+ }
662
+ catch (e) {
663
+ return { ok: false, error: message(e) };
664
+ }
665
+ const descriptionLimit = (await readPluginSettings()).toolDescriptionMaxLength;
666
+ const disabledMapHere = await readDisabledTools();
667
+ const disabledHere = new Set(disabledMapHere[serverName] || []);
668
+ const wildcardHere = disabledHere.has('*');
669
+ const toolsList = [];
670
+ for (const s of schemas) {
671
+ const fullName = String(s && s.name || '');
672
+ if (!fullName.startsWith(prefix))
673
+ continue;
674
+ const rawName = fullName.slice(prefix.length);
675
+ const params = [];
676
+ const props = s.parameters && typeof s.parameters === 'object' ? s.parameters.properties : null;
677
+ const requiredSet = new Set();
678
+ if (s.parameters && Array.isArray(s.parameters.required)) {
679
+ for (const k of s.parameters.required)
680
+ if (typeof k === 'string')
681
+ requiredSet.add(k);
682
+ }
683
+ if (props && typeof props === 'object') {
684
+ for (const [key, spec] of Object.entries(props)) {
685
+ const ps = (spec && typeof spec === 'object') ? spec : {};
686
+ params.push({
687
+ key,
688
+ required: requiredSet.has(key),
689
+ type: typeof ps.type === 'string' ? ps.type : 'any',
690
+ ...(typeof ps.description === 'string' ? { description: ps.description } : {}),
691
+ });
692
+ }
693
+ }
694
+ toolsList.push({
695
+ name: rawName,
696
+ description: truncateText(typeof s.description === 'string' ? s.description : '', descriptionLimit),
697
+ enabled: wildcardHere ? false : !disabledHere.has(rawName),
698
+ parameters: params,
699
+ });
700
+ }
701
+ // Some dsh-tools builds strip restricted tools from schemas(); without
702
+ // this merge a disabled tool would vanish from the dialog with no way to
703
+ // re-enable it from the UI. Names come from the sidecar, so they always
704
+ // stay reachable; the description is unavailable once the schema is gone.
705
+ const listed = new Set(toolsList.map((t) => t.name));
706
+ for (const name of disabledHere) {
707
+ if (name === '*')
708
+ continue; // 整服务器通配:已在上方逐工具体现,不再展示占位行
709
+ if (!listed.has(name)) {
710
+ toolsList.push({ name, description: '(已停用;描述暂不可用)', enabled: false, parameters: [] });
711
+ }
712
+ }
713
+ // 「已知工具」缓存兜底:服务器没运行时 schemas() 里没有它任何工具 —— 场景档案里
714
+ // 想给未运行服务器挑工具就全靠这份最后见过的名单。描述也一并带上(v0.9.0 起缓存),
715
+ // 并标 `stale: true`:界面据此说明「上次运行时」,不假装是实时数据。
716
+ for (const known of (await readKnownMcpTools())[serverName] || []) {
717
+ if (listed.has(known.name))
718
+ continue;
719
+ toolsList.push({
720
+ name: known.name,
721
+ description: known.description || '(服务器未运行;这个名字来自上次运行记录)',
722
+ // 与上方 live 行同一口径:整台停用(`*`)时 stale 行同样报停用,
723
+ // 否则同页「live 行停用、stale 行启用」自相矛盾。
724
+ enabled: wildcardHere ? false : !disabledHere.has(known.name),
725
+ parameters: [],
726
+ stale: true,
727
+ });
728
+ listed.add(known.name);
729
+ }
730
+ return { ok: true, tools: toolsList };
731
+ }
732
+ /**
733
+ * 临时启动服务器以枚举工具(v0.8.1):从未运行过的服务器在 schemas() 与「已知工具」
734
+ * 缓存里都没有工具名,场景档案勾选器对它一无所知。这里**临时**把它启用(写补丁),
735
+ * 轮询等它的工具注册(npx 冷启动要下载,上限 TOOLS_REFRESH_TIMEOUT_MS),拿到名单后就
736
+ * **恢复原启停状态**—— 用户环境的服务器开关不受影响。已有已知工具时直接返回现有清单,
737
+ * 不做任何改动。
738
+ */
739
+ // npx 首次启动要下载包,慢的那次可能要好几秒;给 30 秒上限,超了就如实报"没等到"。
740
+ const TOOLS_REFRESH_TIMEOUT_MS = 30_000;
741
+ async function mcpmToolsRefresh(args) {
742
+ const serverName = String((args && args.serverName) || '').trim();
743
+ if (!serverName)
744
+ return { ok: false, error: 'serverName 不能为空' };
745
+ const existing = await mcpmTools({ serverName });
746
+ if (existing && existing.ok === false)
747
+ return existing;
748
+ if (existing && (existing.tools || []).length)
749
+ return existing;
750
+ const list = await mcpmListView();
751
+ const row = ((list && list.rows) || []).find((r) => String(r.serverName) === serverName);
752
+ if (!row)
753
+ return { ok: false, error: `未找到服务器: ${serverName}` };
754
+ if (row.level !== 'global' && row.level !== 'project') {
755
+ return { ok: false, error: `该服务器不驻留在补丁文件(level=${row.level}),无法临时启动;请先在 MCP 页启用它一次以记录工具` };
756
+ }
757
+ const wasDisabled = !!row.disabled;
758
+ // 恢复「停用」必须能被调用方看见:return 的值在 finally 之前就已求值,原来只在
759
+ // finally 里 console.warn,于是恢复失败时调用方仍拿到 ok: true —— 用户停用的服务器
760
+ // 停在启用态,下次会话真的会起进程。所以把结果先存下来、恢复动作在其后显式执行,
761
+ // 失败就改写结果(降级为 ok:false / 附 warning)。
762
+ const restoreDisabled = async () => {
763
+ if (!wasDisabled)
764
+ return null;
765
+ try {
766
+ const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: false });
767
+ if (!r || r.ok === false)
768
+ return String((r && r.error) || '未知原因');
769
+ return null;
770
+ }
771
+ catch (e) {
772
+ return message(e);
773
+ }
774
+ };
775
+ let result;
776
+ try {
777
+ if (wasDisabled) {
778
+ const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: true });
779
+ if (r && r.ok === false)
780
+ result = r;
781
+ }
782
+ if (!result) {
783
+ const deadline = Date.now() + TOOLS_REFRESH_TIMEOUT_MS;
784
+ for (;;) {
785
+ const t = await mcpmTools({ serverName });
786
+ if (t && t.ok && (t.tools || []).length) {
787
+ // 记进「已知工具」缓存:以后未运行时场景档案也能列出(名字 + 这次的描述)。
788
+ const known = t.tools
789
+ .filter((x) => String(x && x.name))
790
+ .map((x) => ({ name: String(x.name), ...(typeof x.description === 'string' && x.description ? { description: x.description } : {}) }));
791
+ if (known.length) {
792
+ try {
793
+ const cache = await readKnownMcpTools();
794
+ const byName = new Map((cache[serverName] || []).map((item) => [item.name, item]));
795
+ for (const item of known) {
796
+ const old = byName.get(item.name);
797
+ byName.set(item.name, old && old.description && !item.description ? old : { ...old, ...item });
798
+ }
799
+ cache[serverName] = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
800
+ await writeKnownMcpTools(cache);
801
+ }
802
+ catch { /* 缓存写失败不影响本次返回 */ }
803
+ }
804
+ result = t;
805
+ break;
806
+ }
807
+ if (Date.now() >= deadline) {
808
+ result = { ok: false, error: `等待超时:${serverName} 在 30 秒内没有注册任何工具(服务器可能启动失败或连接过慢)` };
809
+ break;
810
+ }
811
+ await new Promise((resolve) => setTimeout(resolve, 1000));
812
+ }
813
+ }
814
+ }
815
+ catch (e) {
816
+ // 抛错路径同样要恢复停用状态、同样要刷新目录缓存(原来靠 finally 兜,改显式后不能漏)。
817
+ const failed = await restoreDisabled();
818
+ void mcpStateCatalog.refresh().catch(() => { });
819
+ throw failed ? new Error(`${message(e)};另外,恢复「${serverName}」的停用状态也失败了(${failed})`) : e;
820
+ }
821
+ const restoreFailed = await restoreDisabled();
822
+ // 目录刷新是副作用,失败不该带崩本次返回(原来没挂 .catch,会变成未处理拒绝)。
823
+ void mcpStateCatalog.refresh().catch(() => { });
824
+ if (restoreFailed) {
825
+ const note = `「${serverName}」的停用状态没能恢复(${restoreFailed}):它现在处于启用态,下次会话会真的启动它,请到 MCP 页手动关掉`;
826
+ if (!result || typeof result !== 'object')
827
+ result = { ok: true, warning: note };
828
+ else if (result.ok === false)
829
+ result = { ...result, error: `${result.error};另外,${note}` };
830
+ else
831
+ result = { ...result, warning: note };
832
+ }
833
+ return result;
834
+ }
835
+ async function mcpmList() {
836
+ const p = await ensurePaths();
837
+ const rows = [];
838
+ const errors = [];
839
+ for (const level of ['project', 'global']) {
840
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
841
+ let content = '';
842
+ try {
843
+ content = await readPatch(abs);
844
+ }
845
+ catch (e) {
846
+ errors.push(level + ': ' + message(e));
847
+ continue;
848
+ }
849
+ const { rows: fileRows } = parseRows(content);
850
+ for (const r of fileRows)
851
+ rows.push(normalizeRow(r, level, abs));
852
+ }
853
+ const toolCounts = {};
854
+ const enabledToolCounts = {};
855
+ // 只来自**真实 schema** 的两个数,以及只来自**「已知工具」缓存**的一个数。
856
+ //
857
+ // 为什么必须与上面那两个分开(2026-09-17 用户实测发现):`toolCounts` 是三处并集
858
+ // (live ∪ 停用表 ∪ 缓存),把"这台 server 曾经有什么工具"与"它现在有什么工具"混成了
859
+ // 一个数。界面状态胶囊与注入段此前读的就是它,于是**一台已经连不上的 server 会被报成
860
+ // 「已运行」并注入上下文**,模型照着去调 `mcp__X__*`,而那个工具根本没注册 —— 白烧一轮。
861
+ //
862
+ // 分开之后三个数各有明确含义:
863
+ // liveToolCounts 当前真实注册的工具数(0 = 一个都没注册)
864
+ // liveEnabledToolCounts 上面这个数里未被停用表扣减的(真正能调到的)
865
+ // knownToolCounts 缓存里的工具数 —— 它是**曾经真的连上过**的证据(缓存只在真实
866
+ // schema 里见到工具时才写入),0 = 从未连上过
867
+ //
868
+ // 前两个给状态胶囊与注入段用;`toolCounts` / `enabledToolCounts` 语义不变,继续给场景
869
+ // 档案挑工具、详情页清单用(那里"上次见过哪些工具"正是需要的,且已标 `stale`)。
870
+ const liveToolCounts = {};
871
+ const liveEnabledToolCounts = {};
872
+ const knownToolCounts = {};
873
+ try {
874
+ const schemas = await tools.schemas();
875
+ const knownCache = await readKnownMcpTools();
876
+ const disabledMap = await readDisabledTools();
877
+ const liveNames = {};
878
+ const liveTools = {};
879
+ const seen = new Set();
880
+ for (const s of schemas) {
881
+ const fullName = String(s && s.name || '');
882
+ if (seen.has(fullName))
883
+ continue;
884
+ seen.add(fullName);
885
+ const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__(.+)$/);
886
+ if (!m)
887
+ continue;
888
+ const list = liveNames[m[1]] || (liveNames[m[1]] = []);
889
+ if (list.indexOf(m[2]) < 0)
890
+ list.push(m[2]);
891
+ // 顺手把描述记下来:未运行时详情页就有东西可显示,不必再重复「描述暂不可用」。
892
+ const bucket = liveTools[m[1]] || (liveTools[m[1]] = []);
893
+ const desc = typeof s.description === 'string' ? s.description : '';
894
+ if (!bucket.some((t) => t.name === m[2]))
895
+ bucket.push({ name: m[2], ...(desc ? { description: desc } : {}) });
896
+ }
897
+ // 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
898
+ // 也能显示最后一次见过的工具数,而不是永远 0。
899
+ const union = {};
900
+ const addTool = (server, tool) => {
901
+ if (!server || !tool)
902
+ return;
903
+ const set = union[server] || (union[server] = new Set());
904
+ set.add(tool);
905
+ };
906
+ for (const [server, list] of Object.entries(liveNames))
907
+ for (const t of list)
908
+ addTool(server, t);
909
+ for (const [server, list] of Object.entries(disabledMap))
910
+ for (const t of list)
911
+ if (t !== '*')
912
+ addTool(server, t);
913
+ for (const [server, list] of Object.entries(knownCache))
914
+ for (const t of list)
915
+ addTool(server, t.name);
916
+ for (const [server, set] of Object.entries(union)) {
917
+ toolCounts[server] = set.size;
918
+ // 可用数 = 已知工具里未被停用表扣减的(`*` = 整台停用 → 0)。场景档案收窄与
919
+ // 手动逐工具开关写的是同一张停用表,段与页面的「N 个工具」都必须按它扣减,
920
+ // 否则收窄后模型看到的数字比它能调的工具多,页面与详情页也会互相矛盾。
921
+ enabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
922
+ }
923
+ // 真实来源的两个数:只数 `liveNames`(schema 里真正注册了的),再按停用表扣减。
924
+ for (const [server, list] of Object.entries(liveNames)) {
925
+ const set = new Set(list);
926
+ liveToolCounts[server] = set.size;
927
+ liveEnabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
928
+ }
929
+ // 「曾经连上过」的证据:缓存里这台 server 见过几个工具。注意读的是**回写之前**的
930
+ // `knownCache` —— 回写会把这次 live 见到的并进去,而这次 live 见到的本来就已经算在
931
+ // liveToolCounts 里了,不需要它来充当"曾经"。
932
+ for (const [server, list] of Object.entries(knownCache))
933
+ knownToolCounts[server] = list.length;
934
+ // 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
935
+ // 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
936
+ let cacheChanged = false;
937
+ for (const [server, list] of Object.entries(liveTools)) {
938
+ const prev = knownCache[server] || [];
939
+ const byName = new Map(prev.map((t) => [t.name, t]));
940
+ for (const tool of list) {
941
+ const old = byName.get(tool.name);
942
+ if (!old) {
943
+ byName.set(tool.name, tool);
944
+ cacheChanged = true;
945
+ continue;
946
+ }
947
+ if (tool.description && old.description !== tool.description) {
948
+ byName.set(tool.name, { ...old, description: tool.description });
949
+ cacheChanged = true;
950
+ }
951
+ }
952
+ const next = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
953
+ if (next.length !== prev.length)
954
+ cacheChanged = true;
955
+ knownCache[server] = next;
956
+ }
957
+ if (cacheChanged)
958
+ void writeKnownMcpTools(knownCache).catch(() => { });
959
+ }
960
+ catch (e) { /* ignore */ }
961
+ let live = [];
962
+ if (pluginInventory) {
963
+ try {
964
+ const res = await pluginInventory.list();
965
+ live = res.entries.filter((e) => e.moduleName === MCP_CLIENT_MODULE);
966
+ }
967
+ catch (e) { /* ignore */ }
968
+ }
969
+ for (const e of live) {
970
+ const bid = bareEntryId(e.entryId);
971
+ const found = rows.find((r) => r.id === bid);
972
+ if (found) {
973
+ // **补丁说停用 → 它不可能在运行。** loader 的清单偶尔停在上一帧(用户实测:退出场景后
974
+ // 开关已关、进程已停、AI 也调不到,页面却仍显示「已运行」)——同一个响应里两处矛盾时
975
+ // 以**补丁**为准:开关是真正生效的控制面,显示跟着它走,四者(实际 / 显示 / 开关 / AI)
976
+ // 才一致。反方向的偏差(刚停用、进程还在收尾的几秒)同样由补丁给出的答案兜住。
977
+ found.live = found.disabled
978
+ ? { enabled: false, phase: e.fiberPhase }
979
+ : { enabled: e.enabled, phase: e.fiberPhase };
980
+ }
981
+ else
982
+ 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 } });
983
+ }
984
+ for (const row of rows) {
985
+ if (row.toolCount === undefined)
986
+ row.toolCount = toolCounts[row.serverName] || 0;
987
+ if (row.enabledToolCount === undefined)
988
+ row.enabledToolCount = enabledToolCounts[row.serverName] || 0;
989
+ // 这三个**总是**覆盖:它们描述的是"此刻的真实状态",而缓存合并出来的那两个数
990
+ // 描述的是"这台 server 有过哪些工具"。两者混淆过一次,代价是注入段谎报可用。
991
+ row.liveToolCount = liveToolCounts[row.serverName] || 0;
992
+ row.liveEnabledToolCount = liveEnabledToolCounts[row.serverName] || 0;
993
+ row.knownToolCount = knownToolCounts[row.serverName] || 0;
994
+ }
995
+ // 列表顺序(用户要求 2026-09-17):**全局在前、应用级在后**,各级按服务器名排,
996
+ // 当前在跑的 loader 行垫底。放在服务端而不是页面里:同一个顺序也是场景档案勾选器
997
+ // (`scene-inventory`)与模型侧 `mcp_manager_list` 的顺序 —— 三处说同一件事。
998
+ // 名字比较与技能页同一套(localeCompare),大小写混排时不会把大写全顶到前面。
999
+ const levelRank = { global: 0, project: 1, loader: 2 };
1000
+ const serverNameOf = (row) => String(row.serverName || row.id || '');
1001
+ rows.sort((a, b) => (levelRank[a.level] ?? 3) - (levelRank[b.level] ?? 3) ||
1002
+ serverNameOf(a).localeCompare(serverNameOf(b)) ||
1003
+ String(a.id).localeCompare(String(b.id)));
1004
+ // A loader id that appears twice (same id in both patch files, or twice in
1005
+ // one) makes the composition fail to boot. Surface it instead of hiding it.
1006
+ const idCounts = new Map();
1007
+ for (const row of rows)
1008
+ idCounts.set(String(row.id), (idCounts.get(String(row.id)) || 0) + 1);
1009
+ const duplicateIds = [...idCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
1010
+ for (const row of rows)
1011
+ row.duplicate = duplicateIds.indexOf(String(row.id)) >= 0;
1012
+ const warnings = [];
1013
+ if (duplicateIds.length)
1014
+ warnings.push('检测到重复的 loader id(会导致 DSH 无法启动,请手动清理补丁文件):' + duplicateIds.join('、'));
1015
+ // 补丁文件里**已经**是打码占位符的密钥:那说明真值曾经被写坏过(本机 2026-09-18 的
1016
+ // `TAVILY_API_KEY` 就是)。主动报出来 —— 不报的话用户只会在下次编辑保存时再次触发
1017
+ // 同一个坏结果,而原值已经找不回来了,必须让他知道要去重新填。
1018
+ const brokenSecrets = [...new Set(rows.flatMap((row) => [
1019
+ ...maskedKeysIn(row.env),
1020
+ ...maskedKeysIn(row.headers),
1021
+ ].map((key) => row.serverName + '.' + key)))];
1022
+ if (brokenSecrets.length)
1023
+ warnings.push('这些密钥在补丁文件里已是打码占位符,真实值已丢失,请在编辑里重新填写:' + brokenSecrets.join('、'));
1024
+ return {
1025
+ ok: true,
1026
+ rows,
1027
+ paths: { project: p.projectPatch, global: p.globalPatch, home: p.home, profile: p.profileName },
1028
+ errors,
1029
+ warnings,
1030
+ };
1031
+ }
1032
+ async function mcpmAdd(args) {
1033
+ const p = await ensurePaths();
1034
+ const serverName = String(args.serverName || '').trim();
1035
+ const transport = args.transport === 'stdio' ? 'stdio' : 'streamable-http';
1036
+ const level = args.level === 'global' ? 'global' : 'project';
1037
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
1038
+ return { ok: false, error: 'serverName 需为 1-32 位 [A-Za-z0-9_-]' };
1039
+ const baseId = 'mcp-' + serverName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
1040
+ const existing = await collectAll();
1041
+ if (existing.serverNames.has(serverName))
1042
+ return { ok: false, error: 'serverName "' + serverName + '" 已存在' };
1043
+ let id = baseId;
1044
+ let n = 2;
1045
+ while (existing.ids.has(id)) {
1046
+ id = baseId + '-' + n;
1047
+ n++;
1048
+ }
1049
+ const row = { id, serverName, transport };
1050
+ // 新增条目**没有旧值可顶替**,所以打码值一律丢弃(判据与理由见 ./mcp/secret-guard.js):
1051
+ // 谁把打码值粘进表单,都不该让它变成一个占位的"密钥"。
1052
+ const guardNotes = [];
1053
+ const sanitize = (parsed) => {
1054
+ const outcome = resolveMaskedKv(parsed, null);
1055
+ const note = describeMaskedOutcome(outcome);
1056
+ if (note)
1057
+ guardNotes.push(note);
1058
+ return outcome.value;
1059
+ };
1060
+ if (transport === 'streamable-http') {
1061
+ const url = String(args.url || '').trim();
1062
+ if (!/^https?:\/\//.test(url))
1063
+ return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
1064
+ row.url = url;
1065
+ row.headers = sanitize(parseKv(args.headers));
1066
+ }
1067
+ else {
1068
+ const command = String(args.command || '').trim();
1069
+ if (!command)
1070
+ return { ok: false, error: 'command 不能为空' };
1071
+ row.command = command;
1072
+ row.args = parseArgs(args.args);
1073
+ row.env = sanitize(parseKv(args.env));
1074
+ }
1075
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1076
+ return withWriteLock(async () => {
1077
+ let content = '';
1078
+ try {
1079
+ content = await readPatch(abs);
1080
+ }
1081
+ catch (e) {
1082
+ return { ok: false, error: '读取补丁失败: ' + message(e) };
1083
+ }
1084
+ const before = content;
1085
+ content = appendBlock(content, buildInsertBlock(row));
1086
+ // v0.8.5:新增服务器默认不启动(用户裁定,与技能 / 子智能体同口径);
1087
+ // 显式传 enabled: true 才创建即启动。
1088
+ const defaultDisabled = args.enabled !== true;
1089
+ if (defaultDisabled)
1090
+ content = appendBlock(content, buildDisableBlock(id, true));
1091
+ const guard = duplicateGuard(before, content);
1092
+ if (guard)
1093
+ return guard;
1094
+ try {
1095
+ await writePatch(abs, content);
1096
+ }
1097
+ catch (e) {
1098
+ return { ok: false, error: '写入补丁失败: ' + message(e) };
1099
+ }
1100
+ return { ok: true, row: { ...row, level, disabled: defaultDisabled }, ...(guardNotes.length ? { warning: guardNotes.join(';') } : {}) };
1101
+ });
1102
+ }
1103
+ async function mcpmEdit(args) {
1104
+ const p = await ensurePaths();
1105
+ const id = String(args.id || '');
1106
+ const level = args.level === 'global' ? 'global' : 'project';
1107
+ if (!id)
1108
+ return { ok: false, error: '缺少 id' };
1109
+ const all = await collectAll();
1110
+ const cur = all.rows.find((r) => r.id === id);
1111
+ if (!cur)
1112
+ return { ok: false, error: '未找到条目 ' + id };
1113
+ const serverName = String(args.serverName || '').trim();
1114
+ const transport = args.transport === 'stdio' ? 'stdio' : 'streamable-http';
1115
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
1116
+ return { ok: false, error: 'serverName 需为 1-32 位 [A-Za-z0-9_-]' };
1117
+ if (serverName !== cur.serverName && all.serverNames.has(serverName))
1118
+ return { ok: false, error: 'serverName "' + serverName + '" 已被其他服务占用' };
1119
+ const row = { id, serverName, transport };
1120
+ if (transport === 'streamable-http') {
1121
+ const url = String(args.url || '').trim();
1122
+ if (!/^https?:\/\//.test(url))
1123
+ return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
1124
+ row.url = url;
1125
+ row.headers = parseKv(args.headers);
1126
+ }
1127
+ else {
1128
+ const command = String(args.command || '').trim();
1129
+ if (!command)
1130
+ return { ok: false, error: 'command 不能为空' };
1131
+ row.command = command;
1132
+ row.args = parseArgs(args.args);
1133
+ row.env = parseKv(args.env);
1134
+ }
1135
+ const oldAbs = cur.level === 'global' ? p.globalPatch : p.projectPatch;
1136
+ const newAbs = level === 'global' ? p.globalPatch : p.projectPatch;
1137
+ // 编辑表单不建模 config 里的全部键:手写补丁可能带 toolCallTimeoutMs 这类字段,
1138
+ // 重建前从旧条目原样带回,否则一次「编辑」就把它静默删掉。只透传 buildInsertBlock
1139
+ // 已有发射路径的键 —— 其余未知键的保真需要值保持式序列化(裸布尔/数字经
1140
+ // unquote/yq 往返会漂成字符串),不在此处理,宁可如实丢弃也不写错类型。
1141
+ let curRaw = null;
1142
+ try {
1143
+ curRaw = parseRows(await readPatch(oldAbs)).rows.find((r) => r.id === id);
1144
+ if (curRaw && curRaw.config && curRaw.config.toolCallTimeoutMs != null) {
1145
+ row.toolCallTimeoutMs = curRaw.config.toolCallTimeoutMs;
1146
+ }
1147
+ }
1148
+ catch { /* 旧文件读不了时按建模字段重建,与原行为一致 */ }
1149
+ // 打码值**不能入库**:编辑弹窗的密钥框预填的就是打码值(界面拿到的行本身就是打码的),
1150
+ // 所以这里必须拿旧真值顶替,否则"改个别的字段再保存"就把真密钥覆盖成 `••••••`
1151
+ // —— 本机 2026-09-18 的 `TAVILY_API_KEY` 就是这样丢的。旧文件读不出来时 `curRaw`
1152
+ // 为空,走"丢弃"分支,方向仍是安全的(宁可少一个键,也不写一个假值)。
1153
+ const guardNotes = [];
1154
+ const curConfig = (curRaw && curRaw.config) || {};
1155
+ const applyGuard = (field) => {
1156
+ if (row[field] === undefined)
1157
+ return;
1158
+ const outcome = resolveMaskedKv(row[field], curConfig[field]);
1159
+ row[field] = outcome.value;
1160
+ const note = describeMaskedOutcome(outcome);
1161
+ if (note)
1162
+ guardNotes.push(note);
1163
+ };
1164
+ applyGuard('headers');
1165
+ applyGuard('env');
1166
+ // URL 的打码形态不是 `••••••` 而是把查询串换成 `<redacted>`(`maskUrlQuery`),
1167
+ // `isMaskedValue` 永远认不出来 —— 编辑框预填的同样就是这个形态,所以「改个字段再保存」
1168
+ // 会把 URL 里的凭据永久写成占位串(README 承诺「打码值永不入库」,此前 URL 是漏网的
1169
+ // 那一条)。没有旧真值可顶替时**拒绝整次保存**:URL 不是可丢的键值对,只删查询串会
1170
+ // 留下一个"能连上但鉴权失败"的地址,比报错更难发现。
1171
+ if (transport === 'streamable-http') {
1172
+ const outcome = resolveMaskedUrl(row.url, curConfig.url);
1173
+ if (outcome.unrecoverable) {
1174
+ return { ok: false, error: 'URL 里的查询串是打码占位符,而补丁文件里没有原值可恢复:请重新填写完整 URL(含查询串)后再保存。' };
1175
+ }
1176
+ row.url = outcome.value;
1177
+ if (outcome.restored)
1178
+ guardNotes.push('URL 未改动,已保留原值');
1179
+ }
1180
+ const block = buildInsertBlock(row);
1181
+ return withWriteLock(async () => {
1182
+ // Per-tool disable state is keyed by the serverName namespace: migrate
1183
+ // it when a rename moves the tools to a new prefix.
1184
+ if (serverName !== cur.serverName) {
1185
+ const toolMap = Object.assign({}, await readDisabledTools(true));
1186
+ if (toolMap[cur.serverName]) {
1187
+ toolMap[serverName] = toolMap[cur.serverName];
1188
+ delete toolMap[cur.serverName];
1189
+ try {
1190
+ await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
1191
+ }
1192
+ catch (e) { /* non-fatal */ }
1193
+ disabledToolsCache = { at: Date.now(), value: toolMap };
1194
+ }
1195
+ // F-025:运行时快照的 mcp 停用表同样按 serverName 键 —— 改名不跟着改,
1196
+ // 退出场景整体回写时旧键成死键、新名工具的停用状态丢失(回到全启用)。
1197
+ try {
1198
+ const slice = await memoriesService.readArchiveSlice();
1199
+ const mode = slice && slice.mode;
1200
+ const mcpMap = mode && mode.snapshot && mode.snapshot.mcp;
1201
+ if (mcpMap && Object.prototype.hasOwnProperty.call(mcpMap, cur.serverName)) {
1202
+ const next = {};
1203
+ for (const [k, v] of Object.entries(mcpMap))
1204
+ next[k === cur.serverName ? serverName : k] = v;
1205
+ await memoriesService.patchIndex({ mode: { ...mode, snapshot: { ...mode.snapshot, mcp: next } } });
1206
+ }
1207
+ }
1208
+ catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
1209
+ }
1210
+ if (oldAbs !== newAbs) {
1211
+ // Level migration: remove from the old file, insert into the new one.
1212
+ // Not atomic, so keep the old content and restore it if the second
1213
+ // write fails — losing the entry is worse than a transient dup.
1214
+ const origOld = await readPatch(oldAbs);
1215
+ let c = origOld;
1216
+ c = removeEntryAll(c, id);
1217
+ try {
1218
+ await writePatch(oldAbs, c);
1219
+ }
1220
+ catch (e) {
1221
+ return { ok: false, error: '写入失败: ' + message(e) };
1222
+ }
1223
+ let c2 = await readPatch(newAbs);
1224
+ const beforeNew = c2;
1225
+ c2 = appendBlock(c2, block);
1226
+ if (cur.disabled)
1227
+ c2 = appendBlock(c2, buildDisableBlock(id, true));
1228
+ const migrationGuard = duplicateGuard(beforeNew, c2);
1229
+ if (migrationGuard) {
1230
+ try {
1231
+ await writePatch(oldAbs, origOld);
1232
+ }
1233
+ catch (e2) { /* best effort */ }
1234
+ return migrationGuard;
1235
+ }
1236
+ try {
1237
+ await writePatch(newAbs, c2);
1238
+ }
1239
+ catch (e) {
1240
+ try {
1241
+ await writePatch(oldAbs, origOld);
1242
+ }
1243
+ catch (e2) { /* best effort */ }
1244
+ return { ok: false, error: '写入失败(已回滚): ' + message(e) };
1245
+ }
1246
+ }
1247
+ else {
1248
+ let c = await readPatch(newAbs);
1249
+ const before = c;
1250
+ c = removeEntryAll(c, id);
1251
+ c = appendBlock(c, block);
1252
+ if (cur.disabled)
1253
+ c = appendBlock(c, buildDisableBlock(id, true));
1254
+ const guard = duplicateGuard(before, c);
1255
+ if (guard)
1256
+ return guard;
1257
+ await writePatch(newAbs, c);
1258
+ }
1259
+ return { ok: true, ...(guardNotes.length ? { warning: guardNotes.join(';') } : {}) };
1260
+ });
1261
+ }
1262
+ async function mcpmSetEnabled(args) {
1263
+ const p = await ensurePaths();
1264
+ const { id, level } = args;
1265
+ const enabled = !!args.enabled;
1266
+ if (!id || (level !== 'global' && level !== 'project'))
1267
+ return { ok: false, error: '缺少 id 或 level' };
1268
+ if (!(await entryExists(id, level)))
1269
+ return { ok: false, error: '未找到条目 ' + id };
1270
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1271
+ return withWriteLock(async () => {
1272
+ let c = await readPatch(abs);
1273
+ if (enabled) {
1274
+ // Drop every `disabled: true` override for this id. If the insert row
1275
+ // itself still says disabled (e.g. user hand-edited it), append an
1276
+ // explicit `disabled: false` override so the effective state flips.
1277
+ // Enable overrides are intentionally left in place — they are the
1278
+ // mechanism that lets a disabled-by-default row be turned on.
1279
+ c = removeMarked(c, id, 'disable');
1280
+ const { rows } = parseRows(c);
1281
+ const row = rows.find((r) => r.id === id);
1282
+ if (row && row.disabled)
1283
+ c = appendBlock(c, buildDisableBlock(id, false));
1284
+ }
1285
+ else {
1286
+ c = removeMarked(c, id, 'enable');
1287
+ // 幂等:已经生效为「停用」时不再追加 disable 块。无条件 append 会让每次点
1288
+ // 「停用」都往 patch 里塞一条重复条目(历史上 18 条互相矛盾的条目就是这么来的,
1289
+ // 最终生效值只能靠 last-wins 合并顺序猜),且文件会随每次启停线性膨胀。
1290
+ const { rows } = parseRows(c);
1291
+ const row = rows.find((r) => r.id === id);
1292
+ if (!row || !row.disabled)
1293
+ c = appendBlock(c, buildDisableBlock(id, true));
1294
+ }
1295
+ await writePatch(abs, c);
1296
+ return { ok: true };
1297
+ });
1298
+ }
1299
+ // Bulk enable/disable for every patch-resident server row. Covers both
1300
+ // levels at once, or a single level via args.level. Loader-only rows
1301
+ // (never written to a patch file) are out of scope. Each file is rewritten
1302
+ // at most once; rows already in the target state are left untouched.
1303
+ async function mcpmSetAll(args) {
1304
+ const p = await ensurePaths();
1305
+ const enabled = !!args.enabled;
1306
+ const levelFilter = args.level === 'global' ? 'global' : args.level === 'project' ? 'project' : null;
1307
+ return withWriteLock(async () => {
1308
+ const changed = [];
1309
+ for (const level of ['project', 'global']) {
1310
+ if (levelFilter && levelFilter !== level)
1311
+ continue;
1312
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
1313
+ let c = '';
1314
+ try {
1315
+ c = await readPatch(abs);
1316
+ }
1317
+ catch (e) {
1318
+ continue;
1319
+ }
1320
+ const { rows } = parseRows(c);
1321
+ for (const row of rows) {
1322
+ if (!!row.disabled === !enabled)
1323
+ continue;
1324
+ changed.push(row.id);
1325
+ if (enabled) {
1326
+ c = removeMarked(c, row.id, 'disable');
1327
+ const { rows: after } = parseRows(c);
1328
+ const still = after.find((r) => r.id === row.id);
1329
+ if (still && still.disabled)
1330
+ c = appendBlock(c, buildDisableBlock(row.id, false));
1331
+ }
1332
+ else {
1333
+ c = removeMarked(c, row.id, 'enable');
1334
+ c = appendBlock(c, buildDisableBlock(row.id, true));
1335
+ }
1336
+ }
1337
+ await writePatch(abs, c);
1338
+ }
1339
+ return { ok: true, enabled, changed };
1340
+ });
1341
+ }
1342
+ /** Effective disabled state of a patch row (insert-row flag merged with override blocks). */
1343
+ async function isRowDisabled(id, level) {
1344
+ const p = await ensurePaths();
1345
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1346
+ try {
1347
+ const { rows } = parseRows(await readPatch(abs));
1348
+ const row = rows.find((r) => r.id === id);
1349
+ return row ? !!row.disabled : false;
1350
+ }
1351
+ catch (e) {
1352
+ return false;
1353
+ }
1354
+ }
1355
+ // 重启等 loader 反映新状态的上限与轮询间隔。改上限时告警文案会跟着变(文案用
1356
+ // `LOADER_STATE_WAIT_MS / 1000` 拼,不再另写一遍秒数)。
1357
+ const LOADER_STATE_WAIT_MS = 5000;
1358
+ const LOADER_STATE_POLL_MS = 300;
1359
+ async function mcpmRestart(args) {
1360
+ const p = await ensurePaths();
1361
+ const { id, level } = args;
1362
+ if (!id || (level !== 'global' && level !== 'project'))
1363
+ return { ok: false, error: '缺少 id 或 level' };
1364
+ if (!(await entryExists(id, level)))
1365
+ return { ok: false, error: '未找到条目 ' + id };
1366
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1367
+ // A restart reconnects the server; it must NOT flip the enabled state.
1368
+ // The recovery write below used to strip every disable override, which
1369
+ // silently re-enabled servers the user had disabled on purpose — so the
1370
+ // pre-restart state is captured and restored.
1371
+ const wasDisabled = await isRowDisabled(id, level);
1372
+ const warnings = [];
1373
+ // Phase 1 write (short lock): force-disable. Stale disable overrides are
1374
+ // cleared first so duplicate blocks never accumulate.
1375
+ await withWriteLock(async () => {
1376
+ let c = await readPatch(abs);
1377
+ c = removeMarked(c, id, 'enable');
1378
+ c = removeMarked(c, id, 'disable');
1379
+ c = appendBlock(c, buildDisableBlock(id, true));
1380
+ await writePatch(abs, c);
1381
+ });
1382
+ // 阶段 2 必须**无论中间发生什么都要跑**:阶段 1 已经把服务器写成停用并落盘,
1383
+ // 等待窗口里任何一次抛错都会把它永久留在停用态(重启本该是"状态与重启前一致")。
1384
+ // 所以把等待段包进 try/finally,恢复写在 finally 里。
1385
+ try {
1386
+ if (pluginInventory) {
1387
+ const off = await waitFor(async () => {
1388
+ const e = await liveEntry(id);
1389
+ return e ? e.enabled === false : false;
1390
+ }, LOADER_STATE_WAIT_MS, LOADER_STATE_POLL_MS);
1391
+ if (!off)
1392
+ warnings.push(`loader 未在 ${LOADER_STATE_WAIT_MS / 1000} 秒内停用该服务`);
1393
+ }
1394
+ await wait(1000);
1395
+ }
1396
+ finally {
1397
+ // Phase 2 write (short lock): restore the pre-restart state. The polling
1398
+ // waits deliberately run OUTSIDE the write lock — holding the global
1399
+ // write lock for up to ~11s stalled every other write op.
1400
+ await withWriteLock(async () => {
1401
+ // 并发护栏:阶段 1 的强制写自身必然把 effective 状态置为停用,所以走到这里时
1402
+ // 若读到「已启用」,只可能是等待窗口内别的写操作重新启用了它 —— 以现状为准并
1403
+ // 警告,绝不按重启前快照把用户的显式选择改回去。其余情形按 wasDisabled 原样恢复。
1404
+ // (已知残留:窗口内被「停用」与阶段 1 的写不可区分,仍按快照恢复 —— 重启语义
1405
+ // 本就是「状态与重启前一致」,方向性无害。)
1406
+ const currentDisabled = await isRowDisabled(id, level);
1407
+ if (!currentDisabled && wasDisabled) {
1408
+ warnings.push('重启等待窗口内该服务被重新启用:已保留启用状态,未按重启前状态恢复');
1409
+ return;
1410
+ }
1411
+ let c = await readPatch(abs);
1412
+ c = removeMarked(c, id, 'disable');
1413
+ if (wasDisabled)
1414
+ c = appendBlock(c, buildDisableBlock(id, true));
1415
+ await writePatch(abs, c);
1416
+ }).catch((e) => {
1417
+ warnings.push('恢复重启前状态失败(该服务可能停留在停用态):' + message(e));
1418
+ });
1419
+ }
1420
+ if (pluginInventory) {
1421
+ if (!wasDisabled) {
1422
+ const on = await waitFor(async () => {
1423
+ const e = await liveEntry(id);
1424
+ return e ? e.enabled === true : false;
1425
+ }, LOADER_STATE_WAIT_MS, LOADER_STATE_POLL_MS);
1426
+ if (!on)
1427
+ warnings.push(`loader 未在 ${LOADER_STATE_WAIT_MS / 1000} 秒内重新启用该服务`);
1428
+ }
1429
+ }
1430
+ else
1431
+ await wait(1500);
1432
+ return warnings.length ? { ok: true, warning: warnings.join(';') } : { ok: true };
1433
+ }
1434
+ async function mcpmRemove(args) {
1435
+ const p = await ensurePaths();
1436
+ const { id, level } = args;
1437
+ if (!id || (level !== 'global' && level !== 'project'))
1438
+ return { ok: false, error: '缺少 id 或 level' };
1439
+ const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1440
+ return withWriteLock(async () => {
1441
+ let c = await readPatch(abs);
1442
+ c = removeEntryAll(c, id);
1443
+ await writePatch(abs, c);
1444
+ return { ok: true };
1445
+ });
1446
+ }
1447
+ /**
1448
+ * 收敛补丁文件里的启停覆盖块(用户显式点「整理补丁」才走这里)。
1449
+ *
1450
+ * 只删"删掉也不改变生效状态"的块(判定见 planOverrideCompaction):insert 行、
1451
+ * 带 config 的覆盖块、以及决定当前生效值的那一条都原样保留。删除前 writePatch
1452
+ * 会自动备份上一版(保留最近 5 份),所以这一步是可回退的。
1453
+ *
1454
+ * 基准状态由 planOverrideCompaction 自己从 insert 行读(另一份文件的内容只用来补
1455
+ * "insert 行不在本文件里"的情况)。这里**不要**喂 parseRows() 的 rows —— 那是合并
1456
+ * 覆盖块之后的生效值,会让每个 decider 都被判成多余:第一版就是这么把 6 台 MCP
1457
+ * 全部启用的。同理,界面不显示任何"多余块数"(那个数字也来自同一条错误口径)。
1458
+ */
1459
+ async function mcpmCompact() {
1460
+ const p = await ensurePaths();
1461
+ return withWriteLock(async () => {
1462
+ const levels = ['project', 'global'];
1463
+ const absOf = (level) => (level === 'project' ? p.projectPatch : p.globalPatch);
1464
+ const contents = {};
1465
+ for (const level of levels) {
1466
+ try {
1467
+ contents[level] = await readPatch(absOf(level));
1468
+ }
1469
+ catch (e) {
1470
+ contents[level] = '';
1471
+ }
1472
+ }
1473
+ const removed = {};
1474
+ const files = [];
1475
+ let total = 0;
1476
+ for (const level of levels) {
1477
+ const content = contents[level];
1478
+ if (!content)
1479
+ continue;
1480
+ const others = levels.filter((other) => other !== level).map((other) => contents[other]).filter(Boolean);
1481
+ const plan = planOverrideCompaction(content, others);
1482
+ if (!plan.ranges.length)
1483
+ continue;
1484
+ await writePatch(absOf(level), spliceRanges(splitLines(content), plan.ranges));
1485
+ files.push(absOf(level));
1486
+ for (const [id, stat] of Object.entries(plan.ids)) {
1487
+ if (!stat.dropped)
1488
+ continue;
1489
+ removed[id] = (removed[id] || 0) + stat.dropped;
1490
+ total += stat.dropped;
1491
+ }
1492
+ }
1493
+ return { ok: true, removed: total, ids: removed, files };
1494
+ });
1495
+ }
1496
+ async function mcpmExport() {
1497
+ const p = await ensurePaths();
1498
+ const list = await mcpmList();
1499
+ const rows = (list.rows || []).filter((r) => r.level !== 'loader').map((r) => ({
1500
+ id: r.id,
1501
+ serverName: r.serverName,
1502
+ transport: r.transport,
1503
+ url: r.url || undefined,
1504
+ command: r.command || undefined,
1505
+ args: r.args || undefined,
1506
+ env: r.env || undefined,
1507
+ headers: r.headers || undefined,
1508
+ level: r.level,
1509
+ disabled: r.disabled,
1510
+ }));
1511
+ const json = JSON.stringify({ exportedAt: new Date().toISOString(), rows }, null, 2);
1512
+ let savedTo = null;
1513
+ try {
1514
+ const abs = mcpSidecar(MCP_EXPORT_FILE);
1515
+ await writePatch(abs, json);
1516
+ savedTo = abs;
1517
+ }
1518
+ catch (e) { /* non-fatal */ }
1519
+ return { ok: true, json, savedTo };
1520
+ }
1521
+ async function mcpmImport(args) {
1522
+ const p = await ensurePaths();
1523
+ const overwrite = !!(args && args.conflict === 'overwrite');
1524
+ let parsed = null;
1525
+ try {
1526
+ parsed = JSON.parse(String(args.json || ''));
1527
+ }
1528
+ catch (e) {
1529
+ return { ok: false, error: 'JSON 解析失败: ' + message(e) };
1530
+ }
1531
+ const entries = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.rows) ? parsed.rows : null);
1532
+ if (!entries)
1533
+ return { ok: false, error: '导入内容格式不正确:需要数组或 { rows: [...] }' };
1534
+ const added = [];
1535
+ const overwritten = [];
1536
+ const skipped = [];
1537
+ // 打码值命中的说明(见 ./mcp/secret-guard.js):导入的 JSON 可能来自"打码状态下
1538
+ // 复制出来的配置",覆盖模式下更要拿旧真值顶替,否则一次导入就把真密钥换成占位符。
1539
+ const importNotes = [];
1540
+ // 整批导入持**一次**写锁。原先每条目各持一次、并在锁内重跑 collectAll():
1541
+ // ① 重扫 = 重新读取并解析两个补丁文件,N 条就是 N 次全量解析(行数多时平方级);
1542
+ // ② 顺带把「整批导入」变成对其它写者的原子操作,不再允许别的写插到条目之间。
1543
+ // 存在性判定仍留在锁内(TOCTOU 的初衷不变),改成从一次性快照增量维护。
1544
+ // 注意 withWriteLock **不可重入**(它挂在同一条 writeChain 上),所以循环体里不能再包一层。
1545
+ await withWriteLock(async () => {
1546
+ const existing = await collectAll();
1547
+ // 按 id / serverName 两张表维护同一份快照:覆盖模式会摘掉旧行,两张表必须同步。
1548
+ const byId = new Map(existing.rows.map((r) => [r.id, r]));
1549
+ const byName = new Map(existing.rows.map((r) => [r.serverName, r]));
1550
+ for (const item of entries) {
1551
+ const norm = normalizeImportItem(item);
1552
+ if (!norm.ok) {
1553
+ skipped.push({ id: (item && (item.id || item.serverName)) || '?', reason: norm.error });
1554
+ continue;
1555
+ }
1556
+ const row = norm.row;
1557
+ const idTaken = byId.has(row.id);
1558
+ const nameTaken = byName.has(row.serverName);
1559
+ // 打码值不能入库(判据见 ./mcp/secret-guard.js):覆盖模式下拿被覆盖条目在补丁里的
1560
+ // 旧真值顶替;非覆盖模式没有旧值可比,走"丢弃"分支。
1561
+ const prevRow = idTaken ? byId.get(row.id) : null;
1562
+ for (const field of ['env', 'headers']) {
1563
+ if (row[field] === undefined)
1564
+ continue;
1565
+ const outcome = resolveMaskedKv(row[field], prevRow ? prevRow[field] : null);
1566
+ row[field] = outcome.value;
1567
+ const note = describeMaskedOutcome(outcome);
1568
+ if (note)
1569
+ importNotes.push(row.serverName + ':' + note);
1570
+ }
1571
+ // serverName is globally unique: a DIFFERENT id owning the name is
1572
+ // always skipped, even in overwrite mode.
1573
+ if (nameTaken && !idTaken) {
1574
+ skipped.push({ id: row.id, reason: 'serverName 已存在' });
1575
+ continue;
1576
+ }
1577
+ if (idTaken && !overwrite) {
1578
+ skipped.push({ id: row.id, reason: 'id 已存在' });
1579
+ continue;
1580
+ }
1581
+ // Overwrite: purge every trace of the id from BOTH patch files first,
1582
+ // then insert the imported row at its own level. The purge is not
1583
+ // atomic with the insert, so keep the pre-purge content of both files
1584
+ // and restore it when the insert fails — same trade-off as mcpmEdit's
1585
+ // level migration: losing the old entries is worse than a transient dup.
1586
+ const origs = [];
1587
+ let purgeFailed = false;
1588
+ if (idTaken && overwrite) {
1589
+ for (const lvl of ['project', 'global']) {
1590
+ const lAbs = lvl === 'global' ? p.globalPatch : p.projectPatch;
1591
+ let lContent = '';
1592
+ try {
1593
+ lContent = await readPatch(lAbs);
1594
+ }
1595
+ catch (e) {
1596
+ continue;
1597
+ }
1598
+ origs.push({ abs: lAbs, content: lContent });
1599
+ try {
1600
+ await writePatch(lAbs, removeEntryAll(lContent, row.id));
1601
+ }
1602
+ catch (e) {
1603
+ // 前面已清掉的文件也要恢复:purge 中途失败同样不能留下「旧条目没了」的状态。
1604
+ for (const o of origs) {
1605
+ try {
1606
+ await writePatch(o.abs, o.content);
1607
+ }
1608
+ catch { /* best effort */ }
1609
+ }
1610
+ skipped.push({ id: row.id, reason: '覆盖旧条目失败(已回滚): ' + message(e) });
1611
+ purgeFailed = true;
1612
+ break;
1613
+ }
1614
+ }
1615
+ }
1616
+ if (purgeFailed)
1617
+ continue;
1618
+ const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
1619
+ try {
1620
+ let c = await readPatch(abs);
1621
+ const before = c;
1622
+ c = appendBlock(c, buildInsertBlock(row));
1623
+ if (row.disabled)
1624
+ c = appendBlock(c, buildDisableBlock(row.id, true));
1625
+ const guard = duplicateGuard(before, c);
1626
+ if (guard) {
1627
+ for (const o of origs) {
1628
+ try {
1629
+ await writePatch(o.abs, o.content);
1630
+ }
1631
+ catch { /* best effort */ }
1632
+ }
1633
+ skipped.push({ id: row.id, reason: guard.error });
1634
+ continue;
1635
+ }
1636
+ await writePatch(abs, c);
1637
+ }
1638
+ catch (e) {
1639
+ for (const o of origs) {
1640
+ try {
1641
+ await writePatch(o.abs, o.content);
1642
+ }
1643
+ catch { /* best effort */ }
1644
+ }
1645
+ skipped.push({ id: row.id, reason: '写入失败(已回滚): ' + message(e) });
1646
+ continue;
1647
+ }
1648
+ // 增量维护快照:后面的条目必须看得见刚写进去的这一行(覆盖模式下旧行已从两个文件里清掉,
1649
+ // 它的 serverName 也要从表里摘掉,否则「换个名字覆盖」会被误判成 serverName 已存在)。
1650
+ const prev = byId.get(row.id);
1651
+ if (prev)
1652
+ byName.delete(prev.serverName);
1653
+ byId.set(row.id, row);
1654
+ byName.set(row.serverName, row);
1655
+ added.push(row.id);
1656
+ if (idTaken && overwrite)
1657
+ overwritten.push(row.id);
1658
+ }
1659
+ });
1660
+ const notes = importNotes.length ? { warning: importNotes.join(';') } : {};
1661
+ return overwrite ? { ok: true, added, overwritten, skipped, ...notes } : { ok: true, added, skipped, ...notes };
1662
+ }
1663
+ function parseKv(text) {
1664
+ const out = {};
1665
+ String(text || '').split(/\r?\n/).forEach((line) => {
1666
+ const t = line.trim();
1667
+ if (!t || t.startsWith('#'))
1668
+ return;
1669
+ const i = t.indexOf('=');
1670
+ if (i <= 0)
1671
+ return;
1672
+ out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
1673
+ });
1674
+ return out;
1675
+ }
1676
+ function parseArgs(text) {
1677
+ return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter((s) => s !== '');
1678
+ }
1679
+ /**
1680
+ * 写停用表。**顺序照搬**原 index.ts 里 archiveService.applyMcpEntries 的那四步:
1681
+ * 先确保目录、再在写锁下落盘、然后更新 TTL 缓存、最后重排工具可见性。
1682
+ * 顺序有意义:缓存必须在落盘成功之后才更新(失败时不能留下"已生效"的假象)。
1683
+ */
1684
+ async function writeDisabledTools(entries) {
1685
+ await deps.ensurePaths();
1686
+ await deps.withWriteLock(async () => {
1687
+ await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), entries);
1688
+ });
1689
+ disabledToolsCache = { at: Date.now(), value: entries };
1690
+ scheduleToolRestrictions();
1691
+ }
1692
+ /**
1693
+ * 读-改-写备注,整段在写锁内。调用方只给"怎么改"(场景档案按 serverName 存的映射
1694
+ * 由它自己决定),避免把引擎的映射规则搬进本文件。
1695
+ */
1696
+ async function updateNotes(mutate) {
1697
+ await deps.withWriteLock(async () => {
1698
+ const map = Object.assign({}, await readNotes(true));
1699
+ mutate(map);
1700
+ await deps.ensurePaths();
1701
+ await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
1702
+ notesCache = { at: Date.now(), value: map };
1703
+ });
1704
+ }
1705
+ /** 启动预热(原 index.ts 的 readDisabledTools().then(applyToolRestrictions),错误由调用方吞)。 */
1706
+ async function warmUp() {
1707
+ await readDisabledTools();
1708
+ await applyToolRestrictions();
1709
+ }
1710
+ /** 卸载清理(原 index.ts 的 cleanup effect 正文)。 */
1711
+ function dispose() {
1712
+ if (restrictTimer) {
1713
+ clearTimeout(restrictTimer);
1714
+ restrictTimer = null;
1715
+ }
1716
+ // 插件卸载时把挂在每个 agent scope 上的限制都撤掉(否则那些 scope 会继续背着一层名单)。
1717
+ for (const [, release] of agentRestrictions) {
1718
+ try {
1719
+ release();
1720
+ }
1721
+ catch (e) { /* ignore */ }
1722
+ }
1723
+ agentRestrictions.clear();
1724
+ seenAgents.clear();
1725
+ }
1726
+ return {
1727
+ stateCatalog: mcpStateCatalog,
1728
+ ops: {
1729
+ 'mcpm-list': mcpmListView,
1730
+ 'mcpm-reveal': mcpmReveal,
1731
+ 'mcpm-note': mcpmNote,
1732
+ 'mcpm-settings': mcpmSettings,
1733
+ 'mcpm-tool-enabled': mcpmToolEnabled,
1734
+ 'mcpm-tools': mcpmTools,
1735
+ 'mcpm-tools-refresh': mcpmToolsRefresh,
1736
+ 'mcpm-add': mcpmAdd,
1737
+ 'mcpm-edit': mcpmEdit,
1738
+ 'mcpm-set-enabled': mcpmSetEnabled,
1739
+ 'mcpm-set-all': mcpmSetAll,
1740
+ 'mcpm-restart': mcpmRestart,
1741
+ 'mcpm-remove': mcpmRemove,
1742
+ 'mcpm-compact': mcpmCompact,
1743
+ 'mcpm-export': mcpmExport,
1744
+ 'mcpm-import': mcpmImport,
1745
+ },
1746
+ readDisabledTools,
1747
+ readKnownMcpTools,
1748
+ readNotes,
1749
+ writeDisabledTools,
1750
+ updateNotes,
1751
+ mcpmListView,
1752
+ mcpmRowsWithNotes,
1753
+ readPluginSettings,
1754
+ isToolDisabled: (name) => isToolDisabledIn(disabledToolsCache ? disabledToolsCache.value : {}, name),
1755
+ /** 停用表里的工具条数(读 TTL 缓存;功能总览用)。 */
1756
+ disabledToolCount: () => {
1757
+ const map = disabledToolsCache ? disabledToolsCache.value : {};
1758
+ let n = 0;
1759
+ for (const list of Object.values(map))
1760
+ n += Array.isArray(list) ? list.length : 0;
1761
+ return n;
1762
+ },
1763
+ warmUp,
1764
+ scheduleToolRestrictions,
1765
+ attachAgent,
1766
+ detachAgent,
1767
+ dispose,
1768
+ };
1769
+ }