dsh-plugin-tool-management 0.16.6 → 0.17.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 (52) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +2 -1
  3. package/README_EN.md +2 -1
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +43 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +44 -17
  23. package/lib/compat/probe.js +75 -24
  24. package/lib/context-inject.js +73 -7
  25. package/lib/host-names.js +150 -0
  26. package/lib/hub.js +82 -11
  27. package/lib/imports/parsers.js +40 -15
  28. package/lib/index.js +168 -74
  29. package/lib/mcp/loader-token.js +26 -0
  30. package/lib/mcp/manager.js +314 -87
  31. package/lib/mcp/patch-yaml.js +207 -40
  32. package/lib/mcp/secret-guard.js +122 -16
  33. package/lib/memories/archive-engine.js +21 -11
  34. package/lib/memories/index-io.js +125 -12
  35. package/lib/memories/service.js +163 -40
  36. package/lib/memories/snapshot.js +32 -9
  37. package/lib/op-registry.js +51 -8
  38. package/lib/ops/compat.js +6 -2
  39. package/lib/ops/prompts.js +18 -5
  40. package/lib/ops/scene-records.js +117 -10
  41. package/lib/ops/sessions.js +50 -4
  42. package/lib/ops/snapshot.js +105 -12
  43. package/lib/ops/trash.js +45 -9
  44. package/lib/prompts/service.js +7 -2
  45. package/lib/request-gate.js +115 -56
  46. package/lib/scene-prompt-sync.js +46 -7
  47. package/lib/scenes/candidates.js +21 -3
  48. package/lib/sessions/workspace.js +40 -5
  49. package/lib/skills/core.js +74 -4
  50. package/lib/skills/service.js +9 -4
  51. package/lib/subagents/service.js +6 -1
  52. package/package.json +1 -1
@@ -21,12 +21,12 @@ import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
21
21
  // 配置体检查 PATH 用(见 mcpmInspect):execFile 不走 shell,参数是数组 —— 体检只查询
22
22
  // 命令名,**绝不执行**配置里的命令本身。
23
23
  import { execFile } from 'node:child_process';
24
- import { MCP_CLIENT_MODULE } from '../host-names.js';
24
+ import { MCP_CLIENT_MODULE, MCP_TOOL_PREFIX, ambiguousServerNames, looksLikeMcpPrefixDrift, serverNameCandidates, splitMcpToolName } from '../host-names.js';
25
25
  import { hubPath } from '../hub.js';
26
26
  import { planOverrideCompaction } from './override-blocks.js';
27
27
  import { createMcpStateCatalog } from './state-section.js';
28
- import { appendBlock, buildDisableBlock, buildInsertBlock, parseRows, removeEntryAll, removeMarked, spliceRanges, splitLines, } from './patch-yaml.js';
29
- import { describeMaskedOutcome, maskUrlQuery, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
28
+ import { appendBlock, buildDisableBlock, buildInsertBlock, insertEntryIds, parseRows, removeEntryAll, removeMarked, spliceRanges, splitLines, } from './patch-yaml.js';
29
+ import { describeMaskedOutcome, maskSecretValue, maskUrlQuery, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
30
30
  /**
31
31
  * 归一化「已知工具」侧车的一行值。
32
32
  *
@@ -71,6 +71,41 @@ export function countEnabledTools(known, disabled) {
71
71
  n++;
72
72
  return n;
73
73
  }
74
+ /**
75
+ * 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。
76
+ *
77
+ * 切分走 `splitMcpToolName`(`host-names.ts`)—— **全插件唯一的一套切分规则**(2026-09-30 审查 N1)。
78
+ *
79
+ * 改之前的两代口径(免得下次又退回老路):
80
+ * * 0.16.6 及以前用 `indexOf('__')`(取**第一个**):serverName 含 `__` 时被截成 `a` → 查表
81
+ * 未命中 → **返回 false(不拦)** → 被停用的工具仍可调用。这是**执行侧**守卫失效,不只是
82
+ * 显示问题:调用链是 `tools.guard`(`index.ts`)→ `mcp.isToolDisabled`(本文件的出口,
83
+ * 喂的就是本函数),而文件头承诺 disabled = invisible AND uncallable。
84
+ * * 0.16.7 改成「最长已知 serverName 前缀匹配」(候选 = 停用表的键),守卫这一侧修对了;但
85
+ * 显示侧(`index.ts` 的 `toolKeyParts`)当时改成了「取最后一个 `__`」——**两套规则并存**,
86
+ * 于是「工具名含 `__`」时守卫与界面键仍会给出不同的 `(server, tool)`。
87
+ * * 现在两侧共用 `splitMcpToolName`(最长候选命中,一个都不命中才退回最后一个 `__`)。
88
+ *
89
+ * 候选集合取**停用表的键**,不是「全部已配置服务器」—— 理由见 `splitMcpToolName` 的文档:
90
+ * 设 `a` 的 `b__c` 被停用、而 `a__b` 只是配置了、没停用任何工具,全名 `mcp__a__b__c` 在放宽后
91
+ * 最长匹配取 `a__b`(tool 只剩 `c`)→ 查 `a__b` 未命中 → **放行一个真的被停用的工具**
92
+ * (fail-open)。候选只有 `{a}` 时切出 `a`/`b__c` → 命中 → 拦住。
93
+ * 歧义时偏向「停用表里有的那个服务器」才是守卫该有的方向。
94
+ *
95
+ * 本函数是**纯函数**(不读闭包状态),因此提到模块作用域并导出:既是"两侧同源"的可测接缝,
96
+ * 也让 `createMcpManager` 不再需要为它保留一个闭包内定义。
97
+ */
98
+ export function isToolDisabledIn(map, fullName) {
99
+ const parts = splitMcpToolName(fullName, Object.keys(map));
100
+ if (parts === null)
101
+ return false;
102
+ const list = map[parts.server];
103
+ if (!list)
104
+ return false;
105
+ if (list.indexOf('*') >= 0)
106
+ return true;
107
+ return list.indexOf(parts.tool) >= 0;
108
+ }
74
109
  export function createMcpManager(deps) {
75
110
  // 外部能力一次解构成局部名:块内代码是逐字搬来的,保持原样最不容易出错。
76
111
  const { readJsonFile, writeJsonFile, ensurePaths, withWriteLock, message, tools, pluginInventory, settings, readPatch, writePatch, memoriesService, wait, } = deps;
@@ -83,11 +118,13 @@ export function createMcpManager(deps) {
83
118
  // are reported on read and new ones are refused before any write.
84
119
  function duplicateIdsOf(content) {
85
120
  const counts = new Map();
86
- for (const row of parseRows(content).rows) {
87
- if (!row.id)
88
- continue;
89
- counts.set(row.id, (counts.get(row.id) || 0) + 1);
90
- }
121
+ // 扫**全部** insert 子条目的 id,不只 MCP 行(2026-09-30 审查 P1-5)。原实现走
122
+ // `parseRows`,而它只收 `name === MCP_CLIENT_MODULE` 的行 —— 于是导入一条非 MCP 的
123
+ // loader(例如 `{"id":"dsh-plugin-tool-management", ...}`)能撞出第二条同 id 条目,
124
+ // 守卫看不见、直接 `appendBlock` 落盘。而按本仓一贯记载(`:229-231`),同 id 的两条
125
+ // insert 会让插件组装失败、**DSH 下次起不来**。这个约束对任何 loader 都成立。
126
+ for (const id of insertEntryIds(content))
127
+ counts.set(id, (counts.get(id) || 0) + 1);
91
128
  return [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
92
129
  }
93
130
  function duplicateGuard(before, after) {
@@ -117,7 +154,11 @@ export function createMcpManager(deps) {
117
154
  ids.add(r.id);
118
155
  const sn = r.config && r.config.serverName ? String(r.config.serverName) : r.id;
119
156
  serverNames.add(sn);
120
- rows.push({ id: r.id, serverName: sn, level, disabled: !!r.disabled });
157
+ // `config` 一并带出(2026-09-30 审查 F4):`mcpm-import` 的覆盖模式要靠它拿**旧真值**
158
+ // 顶替表单里的打码值(URL 的 userinfo/查询串/片段、env/headers 的整串 `•`)。
159
+ // 此前这里只带 id/serverName/level/disabled,于是 `prevRow.url` 恒为 `undefined` ——
160
+ // 覆盖模式也走"没有原值"分支,把打码值丢弃并报一句"没有原值可保留"(诊断也是错的)。
161
+ rows.push({ id: r.id, serverName: sn, level, disabled: !!r.disabled, config: (r.config || {}) });
121
162
  }
122
163
  }
123
164
  return { ids, serverNames, rows };
@@ -375,7 +416,7 @@ export function createMcpManager(deps) {
375
416
  const out = [];
376
417
  for (const serverName of Object.keys(map)) {
377
418
  for (const tool of map[serverName])
378
- out.push('mcp__' + serverName + '__' + tool);
419
+ out.push(MCP_TOOL_PREFIX + serverName + '__' + tool);
379
420
  }
380
421
  return out;
381
422
  }
@@ -406,20 +447,57 @@ export function createMcpManager(deps) {
406
447
  await writeJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE), value);
407
448
  knownMcpToolsCache = { at: Date.now(), value: value };
408
449
  }
409
- /** 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。 */
410
- function isToolDisabledIn(map, fullName) {
411
- if (!fullName.startsWith('mcp__'))
412
- return false;
413
- const rest = fullName.slice(5);
414
- const i = rest.indexOf('__');
415
- if (i <= 0)
416
- return false;
417
- const list = map[rest.slice(0, i)];
418
- if (!list)
419
- return false;
420
- if (list.indexOf('*') >= 0)
421
- return true;
422
- return list.indexOf(rest.slice(i + 2)) >= 0;
450
+ /**
451
+ * 「已知工具」侧车的**读-改-写**,整段在写锁内。
452
+ *
453
+ * 为什么不能「先 readKnownMcpTools() 再 writeKnownMcpTools()」(2026-09-30 审查 P2-15):
454
+ * 这个侧车有两个并发写者 —— 列表轮询(`mcpmListView`,每次发现新工具就回写)与
455
+ * 临时启动(`mcpmTools` 等到工具注册后回写)。两步之间是队外窗口,两个写者各读到同一份
456
+ * 旧表、各并进自己那一台服务器的工具,后写的把先写的整份覆盖掉 —— 先那台的"曾经见过"
457
+ * 记录消失,未运行时场景档案里又变成 0 工具。
458
+ *
459
+ * `mutate` 收到的是**锁内刚读出来**的表(`force` 绕 TTL,否则拿到的还是锁外那份缓存);
460
+ * 返回 `true` 表示有变化、需要落盘。
461
+ * 注意:`withWriteLock` **不可重入** —— 调用方不得已经持有写锁。
462
+ */
463
+ async function mutateKnownMcpTools(mutate) {
464
+ return withWriteLock(async () => {
465
+ const known = await readKnownMcpTools(true);
466
+ if (!mutate(known))
467
+ return;
468
+ await writeKnownMcpTools(known);
469
+ });
470
+ }
471
+ /**
472
+ * 把「这次 live 见到的工具」并进一份已知工具表;返回是否有变化。
473
+ *
474
+ * 抽出来是为了让"要不要写盘"的判断与"锁内真正合并"用**同一个**函数:调用方先在锁外
475
+ * 对本地缓存跑一遍拿到 `changed`(决定要不要进锁),进锁后再对新鲜表跑一遍(那次的结果
476
+ * 才决定落盘)—— 两边口径不一致的话,要么白写要么漏写。
477
+ */
478
+ function mergeLiveToolsInto(known, live) {
479
+ let changed = false;
480
+ for (const [server, list] of Object.entries(live)) {
481
+ const prev = known[server] || [];
482
+ const byName = new Map(prev.map((t) => [t.name, t]));
483
+ for (const tool of list) {
484
+ const old = byName.get(tool.name);
485
+ if (!old) {
486
+ byName.set(tool.name, tool);
487
+ changed = true;
488
+ continue;
489
+ }
490
+ if (tool.description && old.description !== tool.description) {
491
+ byName.set(tool.name, { ...old, description: tool.description });
492
+ changed = true;
493
+ }
494
+ }
495
+ const next = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
496
+ if (next.length !== prev.length)
497
+ changed = true;
498
+ known[server] = next;
499
+ }
500
+ return changed;
423
501
  }
424
502
  async function mcpmToolEnabled(args) {
425
503
  const serverName = String((args && args.serverName) || '').trim();
@@ -572,8 +650,21 @@ export function createMcpManager(deps) {
572
650
  liftRestriction(agent);
573
651
  }
574
652
  async function applyToolRestrictions() {
575
- if (typeof tools.restrict !== 'function')
653
+ // 全局 tools 面上没有 restrict → 可见性半边整条做不成,如实上报(2026-09-30 审查 P2-8)。
654
+ // 此前这里是**裸 return**:停用工具仍留在模型可见的工具表里,而兼容页因为没有上报仍报
655
+ // 一行"ok",用户看到的是"停用了但工具还在"。与 `restrictAgent` 里那条 noteRuntime 同文案、
656
+ // 同 id(noteRuntime 按 id 覆盖,两处不会互相刷屏)。
657
+ if (typeof tools.restrict !== 'function') {
658
+ noteRuntime({
659
+ id: 'mcp-tool-visibility',
660
+ label: '停用工具的可见性',
661
+ kind: 'write',
662
+ fallback: 'inform-only',
663
+ detail: '全局 tools 服务上没有可用的 tools.restrict(官方接口变了):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
664
+ detailKey: 'mcp-tool-visibility.no-restrict',
665
+ });
576
666
  return;
667
+ }
577
668
  // Force re-read: this runs on the tools/change path, which is rare, so a
578
669
  // stale cache must not keep an externally edited deny list hidden.
579
670
  const map = await readDisabledTools(true);
@@ -589,7 +680,7 @@ export function createMcpManager(deps) {
589
680
  for (const [serverName, list] of Object.entries(map)) {
590
681
  if (list.indexOf('*') < 0)
591
682
  continue;
592
- const prefix = 'mcp__' + serverName + '__';
683
+ const prefix = MCP_TOOL_PREFIX + serverName + '__';
593
684
  for (const fullName of registered)
594
685
  if (fullName.startsWith(prefix))
595
686
  wanted.push(fullName);
@@ -640,16 +731,9 @@ export function createMcpManager(deps) {
640
731
  // indirections rather than secrets, so they stay readable. URL query strings
641
732
  // are redacted because MCP credentials often ride there.
642
733
  const SENSITIVE_KEY_RE = /(token|secret|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private[_-]?key|cookie|session|signature|bearer)/i;
643
- function maskSecretValue(value) {
644
- const text = String(value == null ? '' : value);
645
- if (text === '')
646
- return '';
647
- if (text.startsWith('$'))
648
- return text;
649
- if (/^!!js\s/.test(text))
650
- return text;
651
- return '••••••';
652
- }
734
+ // 单值打码(`maskSecretValue`)与 URL 打码(`maskUrlQuery`)都挪到 ./secret-guard.js ——
735
+ // 那里是这两条形态的唯一口径,使用方现在有三个(本文件的列表视图 + index.ts 的确认卡
736
+ // + ops/snapshot.ts 的整机迁移导出)。此处不再自建一份。
653
737
  function maskValueMap(map, onlySensitiveKeys) {
654
738
  if (!map || typeof map !== 'object')
655
739
  return map;
@@ -660,7 +744,7 @@ export function createMcpManager(deps) {
660
744
  return out;
661
745
  }
662
746
  // `maskUrlQuery` 已挪到 ./secret-guard.js:那里是两条打码形态的唯一口径,而使用方
663
- // 现在有两个(本文件的列表视图 + index.ts 的确认卡)。
747
+ // 现在有三个(本文件的列表视图 + index.ts 的确认卡 + ops/snapshot.ts 的整机迁移导出)。
664
748
  /** Shared UI rows: mcpmList plus notes, secrets masked unless `reveal`. */
665
749
  async function mcpmRowsWithNotes(reveal) {
666
750
  const result = await mcpmList();
@@ -701,7 +785,7 @@ export function createMcpManager(deps) {
701
785
  const serverName = String(args && args.serverName || '').trim();
702
786
  if (!serverName)
703
787
  return { ok: false, error: 'serverName 不能为空' };
704
- const prefix = 'mcp__' + serverName + '__';
788
+ const prefix = MCP_TOOL_PREFIX + serverName + '__';
705
789
  let schemas = [];
706
790
  try {
707
791
  schemas = await tools.schemas();
@@ -837,14 +921,17 @@ export function createMcpManager(deps) {
837
921
  .map((x) => ({ name: String(x.name), ...(typeof x.description === 'string' && x.description ? { description: x.description } : {}) }));
838
922
  if (known.length) {
839
923
  try {
840
- const cache = await readKnownMcpTools();
841
- const byName = new Map((cache[serverName] || []).map((item) => [item.name, item]));
842
- for (const item of known) {
843
- const old = byName.get(item.name);
844
- byName.set(item.name, old && old.description && !item.description ? old : { ...old, ...item });
845
- }
846
- cache[serverName] = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
847
- await writeKnownMcpTools(cache);
924
+ // 读-改-写整段在写锁内(审查 P2-15):轮询路径也在回写同一份侧车,
925
+ // 锁外两步会互相覆盖。合并规则(保留旧的描述、新的描述优先)与轮询路径同源。
926
+ await mutateKnownMcpTools((cache) => {
927
+ const byName = new Map((cache[serverName] || []).map((item) => [item.name, item]));
928
+ for (const item of known) {
929
+ const old = byName.get(item.name);
930
+ byName.set(item.name, old && old.description && !item.description ? old : { ...old, ...item });
931
+ }
932
+ cache[serverName] = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
933
+ return true;
934
+ });
848
935
  }
849
936
  catch { /* 缓存写失败不影响本次返回 */ }
850
937
  }
@@ -879,6 +966,34 @@ export function createMcpManager(deps) {
879
966
  }
880
967
  return result;
881
968
  }
969
+ /**
970
+ * 已配置的 serverName 名单 —— 只读补丁 + `parseRows`,**不枚举工具 schema、不回写任何缓存**。
971
+ *
972
+ * 与 `configuredServers`(index.ts 的档案引擎依赖)取的是同一份东西,但那个走
973
+ * `mcpmListView()` → `mcpmList()`,会顺带枚举 schema 并回写「已知工具」缓存;切分的候选集合
974
+ * 只需要名字,不该付那份代价与副作用。顺序与 `mcpmList` 一致(project → global)。
975
+ */
976
+ async function configuredServerNames() {
977
+ const p = await ensurePaths();
978
+ const out = [];
979
+ for (const level of ['project', 'global']) {
980
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
981
+ let content = '';
982
+ // 读不到某个层级就当它没有条目(与 `mcpmList` 的处理一致:记进 errors 但不影响另一层)。
983
+ try {
984
+ content = await readPatch(abs);
985
+ }
986
+ catch (e) {
987
+ continue;
988
+ }
989
+ for (const r of parseRows(content).rows) {
990
+ const n = String(normalizeRow(r, level, abs).serverName || '');
991
+ if (n && out.indexOf(n) < 0)
992
+ out.push(n);
993
+ }
994
+ }
995
+ return out;
996
+ }
882
997
  async function mcpmList() {
883
998
  const p = await ensurePaths();
884
999
  const rows = [];
@@ -924,22 +1039,99 @@ export function createMcpManager(deps) {
924
1039
  const liveNames = {};
925
1040
  const liveTools = {};
926
1041
  const seen = new Set();
1042
+ // 切分走 `splitMcpToolName` —— 与执行侧守卫、界面勾选键**同一套规则**(2026-09-30 审查 N1)。
1043
+ //
1044
+ // 这里以前是贪婪正则 `^mcp__([A-Za-z0-9_-]+)__(.+)$`(等价于"取最后一个 `__`")。它不只是
1045
+ // 第三套规则:它算出的键就是 `mcp-known-tools.json` 的键,而那个缓存又会被显示侧当候选集合
1046
+ // 读回去 —— 规则不一致会**自证**(切错的键进候选,越切越错),所以必须一起换掉。
1047
+ //
1048
+ // 候选集合取补丁里的真实 serverName ∪ 停用表键 ∪ 已知缓存键:`rows` 来自补丁,是唯一与切分
1049
+ // 结果无关的来源;另两份是插件自己写出来的(见 `configuredServerNames` 的文档)。
1050
+ const splitCandidates = serverNameCandidates(rows.map((r) => String((r && r.serverName) || '')).filter(Boolean), disabledMap, knownCache);
1051
+ // 前缀漂移的线索(审查 §5 F6):判据与理由见 `host-names.ts` 的
1052
+ // `looksLikeMcpPrefixDrift` —— 关键是**排除 `mcp_` 开头的那一族**(本插件自己的
1053
+ // `mcp_manager_*` 就在里面,不排除的话这条上报每台机器都会误报)。
1054
+ const mcpStemOther = [];
927
1055
  for (const s of schemas) {
928
1056
  const fullName = String(s && s.name || '');
929
1057
  if (seen.has(fullName))
930
1058
  continue;
931
1059
  seen.add(fullName);
932
- const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__(.+)$/);
933
- if (!m)
1060
+ if (looksLikeMcpPrefixDrift(fullName) && mcpStemOther.length < 3)
1061
+ mcpStemOther.push(fullName);
1062
+ const parts = splitMcpToolName(fullName, splitCandidates);
1063
+ // serverName 的合法形态与 `mcpm-set-tool-enabled` / `mcpm-edit` 的校验同口径:不合法的一律
1064
+ // 不进表(旧正则的 `[A-Za-z0-9_-]+` 就是这个意思,这里保留,免得凭空多出服务器行)。
1065
+ if (!parts || !/^[A-Za-z0-9_-]{1,32}$/.test(parts.server))
934
1066
  continue;
935
- const list = liveNames[m[1]] || (liveNames[m[1]] = []);
936
- if (list.indexOf(m[2]) < 0)
937
- list.push(m[2]);
1067
+ const list = liveNames[parts.server] || (liveNames[parts.server] = []);
1068
+ if (list.indexOf(parts.tool) < 0)
1069
+ list.push(parts.tool);
938
1070
  // 顺手把描述记下来:未运行时详情页就有东西可显示,不必再重复「描述暂不可用」。
939
- const bucket = liveTools[m[1]] || (liveTools[m[1]] = []);
1071
+ const bucket = liveTools[parts.server] || (liveTools[parts.server] = []);
940
1072
  const desc = typeof s.description === 'string' ? s.description : '';
941
- if (!bucket.some((t) => t.name === m[2]))
942
- bucket.push({ name: m[2], ...(desc ? { description: desc } : {}) });
1073
+ if (!bucket.some((t) => t.name === parts.tool))
1074
+ bucket.push({ name: parts.tool, ...(desc ? { description: desc } : {}) });
1075
+ }
1076
+ // 前缀契约的 inform-only 上报(审查 §5 F6):宿主面上出现了词根后换了分隔符的 `mcp*` 工具名,
1077
+ // 同时按本插件认的前缀一条都筛不出来 —— 大概率官方改了 MCP 工具的命名前缀。这时停用表 /
1078
+ // 执行侧门禁 / 工具计数会**一起静默失明**(守卫认为"这工具不在我的表里",于是放行),
1079
+ // 而这是纯字符串契约、没有能力探测能提前发现。
1080
+ // 证据门槛刻意收紧(见上):`mcp_` 开头的一族不算证据,且要**至少两个**同名根的工具
1081
+ // (改前缀会重命名全部 MCP 工具,孤零零一个像模像样的名字不足为凭)。条件不成立就清掉,
1082
+ // 避免陈旧告警。
1083
+ if (mcpStemOther.length >= 2 && Object.keys(liveNames).length === 0) {
1084
+ noteRuntime({
1085
+ id: 'mcp-tool-prefix',
1086
+ label: 'MCP 工具命名前缀',
1087
+ kind: 'read',
1088
+ fallback: 'inform-only',
1089
+ detail: `宿主工具表里有多个以 mcp 开头、但用的不是 \`${MCP_TOOL_PREFIX}\` 的工具名(如 ${mcpStemOther.join('、')}),而按本插件认的前缀一条都筛不出来:官方可能改了 MCP 工具的命名前缀。此时停用表、执行侧门禁与工具计数都会失明(守卫会放行它以为"不在表里"的工具)。`,
1090
+ detailKey: 'mcp-tool-prefix.mismatch',
1091
+ params: { prefix: MCP_TOOL_PREFIX, samples: mcpStemOther.join('、') },
1092
+ });
1093
+ }
1094
+ else {
1095
+ clearRuntimeNote('mcp-tool-prefix');
1096
+ }
1097
+ // 命名歧义的 inform-only 上报(2026-09-30 审查 N2):`mcp__<server>__<tool>` 这个契约
1098
+ // 对 `__` 本身有歧义,而 `serverName` 的校验(`/^[A-Za-z0-9_-]{1,32}$/`)**允许含 `__`**。
1099
+ // 两种形状会让切分变成"猜":① 某个 serverName 自己含 `__`;② 两个 serverName 互为 `__`
1100
+ // 前缀(`a` 与 `a__b`)—— 全名 `mcp__a__b__c` 两种归属都讲得通。
1101
+ //
1102
+ // **只上报、不改行为**:改校验会把存量服务器从"能管"变成"管不了"(计划 §4 未决 1 的
1103
+ // 裁定),而这里要的只是"切不准时出声"。判据本身保证没有这两种形状时一条都不报
1104
+ // (`ambiguousServerNames`),条件不成立就清掉,避免陈旧告警 —— 与上面那条同一条纪律。
1105
+ // 两条互斥、取**更具体**的那条,永远只出一行:
1106
+ // 「互为前缀」必然蕴含「含 `__`」(长的那半就是 `短名 + '__' + 余下`),两条一起报
1107
+ // 就是同一件事说两遍。而「互为前缀」更具体 —— 它直接点名了是哪两个名字撞在一起。
1108
+ // 反过来「含 `__`」能覆盖"一个前缀对都凑不成"的单名字情形。两边都不成立就都清掉。
1109
+ const ambiguous = ambiguousServerNames(splitCandidates);
1110
+ const prefixPairs = ambiguous.prefixPairs.map(([a, b]) => `${a} / ${b}`).join('、');
1111
+ if (ambiguous.prefixPairs.length) {
1112
+ noteRuntime({
1113
+ id: 'mcp-server-name-ambiguous',
1114
+ label: 'MCP 服务器名互为 `__` 前缀',
1115
+ kind: 'read',
1116
+ fallback: 'inform-only',
1117
+ detail: `这些 MCP 服务器名互为 \`__\` 前缀关系(${prefixPairs}):例如 \`mcp__a__b__c\` 既可以读成 \`a\` 的 \`b__c\`,也可以读成 \`a__b\` 的 \`c\`,界面上的勾选可能落不到停用表里那条键上(工具本身照常可用)。改名可消除;不改也能用。`,
1118
+ detailKey: 'mcp-server-name-ambiguous.prefix',
1119
+ params: { pairs: prefixPairs },
1120
+ });
1121
+ }
1122
+ else if (ambiguous.withSeparator.length) {
1123
+ noteRuntime({
1124
+ id: 'mcp-server-name-ambiguous',
1125
+ label: 'MCP 服务器名含 `__`',
1126
+ kind: 'read',
1127
+ fallback: 'inform-only',
1128
+ detail: `这些 MCP 服务器名里含 \`__\`(${ambiguous.withSeparator.join('、')}):工具全名 \`mcp__<server>__<tool>\` 因此切不唯一,界面上的勾选可能落不到停用表里那条键上(工具本身照常可用)。改名可消除;不改也能用。`,
1129
+ detailKey: 'mcp-server-name-ambiguous.separator',
1130
+ params: { names: ambiguous.withSeparator.join('、') },
1131
+ });
1132
+ }
1133
+ else {
1134
+ clearRuntimeNote('mcp-server-name-ambiguous');
943
1135
  }
944
1136
  // 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
945
1137
  // 也能显示最后一次见过的工具数,而不是永远 0。
@@ -980,29 +1172,11 @@ export function createMcpManager(deps) {
980
1172
  knownToolCounts[server] = list.length;
981
1173
  // 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
982
1174
  // 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
983
- let cacheChanged = false;
984
- for (const [server, list] of Object.entries(liveTools)) {
985
- const prev = knownCache[server] || [];
986
- const byName = new Map(prev.map((t) => [t.name, t]));
987
- for (const tool of list) {
988
- const old = byName.get(tool.name);
989
- if (!old) {
990
- byName.set(tool.name, tool);
991
- cacheChanged = true;
992
- continue;
993
- }
994
- if (tool.description && old.description !== tool.description) {
995
- byName.set(tool.name, { ...old, description: tool.description });
996
- cacheChanged = true;
997
- }
998
- }
999
- const next = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1000
- if (next.length !== prev.length)
1001
- cacheChanged = true;
1002
- knownCache[server] = next;
1003
- }
1175
+ // 先在本地这份上跑一遍只为**判断要不要写盘**;真正落盘的那次合并发生在锁内、对象是
1176
+ // 刚读出来的新鲜表(审查 P2-15:临时启动路径也在写同一份侧车,锁外两步会互相覆盖)。
1177
+ const cacheChanged = mergeLiveToolsInto(knownCache, liveTools);
1004
1178
  if (cacheChanged)
1005
- void writeKnownMcpTools(knownCache).catch(() => { });
1179
+ void mutateKnownMcpTools((fresh) => mergeLiveToolsInto(fresh, liveTools)).catch(() => { });
1006
1180
  }
1007
1181
  catch (e) { /* ignore */ }
1008
1182
  let live = [];
@@ -1108,7 +1282,14 @@ export function createMcpManager(deps) {
1108
1282
  const url = String(args.url || '').trim();
1109
1283
  if (!/^https?:\/\//.test(url))
1110
1284
  return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
1111
- row.url = url;
1285
+ // 新增条目**没有旧值可顶替** —— 打码 URL 一律拒绝,让用户重填完整地址(判据见
1286
+ // ./mcp/secret-guard.js)。原来这里直接 `row.url = url`,于是「把列表里那条打码 URL
1287
+ // 复制到新增表单」就会把凭据永久写成 `%3Credacted%3E`,界面还显示成功(审查 F4)。
1288
+ const urlGuard = resolveMaskedUrl(url, null);
1289
+ if (urlGuard.unrecoverable) {
1290
+ return { ok: false, error: 'URL 里的 userinfo / 查询串 / 片段是打码占位符(新增条目没有原值可恢复):请重新填写完整 URL 后再保存。' };
1291
+ }
1292
+ row.url = urlGuard.value;
1112
1293
  row.headers = sanitize(parseKv(args.headers));
1113
1294
  }
1114
1295
  else {
@@ -1395,14 +1576,17 @@ export function createMcpManager(deps) {
1395
1576
  return { ok: true, enabled, changed };
1396
1577
  });
1397
1578
  }
1579
+ /** 一份补丁内容里某个 id 的**生效**启停值(insert 行自带的 flag 与顶层覆盖块合并后)。 */
1580
+ function effectiveDisabledIn(content, id) {
1581
+ const row = parseRows(content).rows.find((r) => r.id === id);
1582
+ return row ? !!row.disabled : false;
1583
+ }
1398
1584
  /** Effective disabled state of a patch row (insert-row flag merged with override blocks). */
1399
1585
  async function isRowDisabled(id, level) {
1400
1586
  const p = await ensurePaths();
1401
1587
  const abs = level === 'global' ? p.globalPatch : p.projectPatch;
1402
1588
  try {
1403
- const { rows } = parseRows(await readPatch(abs));
1404
- const row = rows.find((r) => r.id === id);
1405
- return row ? !!row.disabled : false;
1589
+ return effectiveDisabledIn(await readPatch(abs), id);
1406
1590
  }
1407
1591
  catch (e) {
1408
1592
  return false;
@@ -1466,8 +1650,14 @@ export function createMcpManager(deps) {
1466
1650
  }
1467
1651
  let c = await readPatch(abs);
1468
1652
  c = removeMarked(c, id, 'disable');
1469
- if (wasDisabled)
1470
- c = appendBlock(c, buildDisableBlock(id, true));
1653
+ c = removeMarked(c, id, 'enable');
1654
+ // 覆盖块全删掉之后,生效值**回落**到 insert 行自带的 `disabled:`,而它与「重启前的
1655
+ // 生效状态」不一定相同 —— insert 行写着 `disabled: true`、靠一条 enable 覆盖块启用,
1656
+ // 就是这种形状。此前只在 `wasDisabled` 为真时补一条 disable,于是这种形状下重启会把
1657
+ // 服务器**留在停用态**(2026-09-30 审查 P2-11)。所以这里比一次:不等就按 `wasDisabled`
1658
+ // 显式补一条覆盖;相等就不补 —— 补了会让每次重启都往补丁里多留一个块,没必要。
1659
+ if (effectiveDisabledIn(c, id) !== wasDisabled)
1660
+ c = appendBlock(c, buildDisableBlock(id, wasDisabled));
1471
1661
  await writePatch(abs, c);
1472
1662
  }).catch((e) => {
1473
1663
  warnings.push('恢复重启前状态失败(该服务可能停留在停用态):' + message(e));
@@ -1759,6 +1949,10 @@ export function createMcpManager(deps) {
1759
1949
  else {
1760
1950
  // 唯一一次外部调用。npx / uvx 这类 shim 找不到时界面给的下半句是「shim 属正常可忽略」,
1761
1951
  // 不让用户以为命令坏了。
1952
+ //
1953
+ // `params.command` 原样回给客户端:这与 `mcpm-list`(同样是**免令牌**的只读 op)一致
1954
+ // —— 列表视图里的 `command` 一直是明文,命令名本身不是凭据(真正装凭据的 `args`
1955
+ // 两边都不回)。若哪天要收紧,两处必须一起收,否则只是把同一份值换个 op 暴露。
1762
1956
  const found = await probeCommandOnPath(command);
1763
1957
  if (found === 'missing')
1764
1958
  checks.push({ id: 'cmdMissing', level: 'warn', params: { command } });
@@ -1774,14 +1968,18 @@ export function createMcpManager(deps) {
1774
1968
  else if (transport === 'streamable-http') {
1775
1969
  if (!url)
1776
1970
  checks.push({ id: 'noUrl', level: 'warn' });
1971
+ // URL 一律**打码后**回给客户端(`maskUrlQuery`,与列表视图同一口径)。本 op 在登记表里
1972
+ // 是 `readonly: true` 的免令牌 op,原先这里回的是补丁文件里的 URL 原文 —— 于是一个
1973
+ // 无需令牌的请求就能拿到 `?api_key=…` 的明文,绕过了 `mcpm-reveal` 那道
1974
+ // 「令牌是明文凭据最后一道防线」的设计(审查 P0-5)。路径与主机保留,定位不受影响。
1777
1975
  else if (!/^https?:\/\//.test(url))
1778
- checks.push({ id: 'badUrl', level: 'warn', params: { url } });
1976
+ checks.push({ id: 'badUrl', level: 'warn', params: { url: maskUrlQuery(url) } });
1779
1977
  else {
1780
1978
  // 格式对了再做一次真实探测(0.16.5):此前的体检止步于格式校验,一条编造的
1781
1979
  // 地址也能拿绿点 —— 绿点承诺的「没发现问题」其实只覆盖了一半(2026-09-29 实测)。
1782
1980
  const probe = await probeHttpEndpoint(url, row.headers);
1783
1981
  if (!probe.reachable)
1784
- checks.push({ id: 'httpUnreachable', level: 'warn', params: { url, reason: probe.reason } });
1982
+ checks.push({ id: 'httpUnreachable', level: 'warn', params: { url: maskUrlQuery(url), reason: probe.reason } });
1785
1983
  }
1786
1984
  const badHeaders = badKv(row.headers);
1787
1985
  if (badHeaders.length)
@@ -1853,11 +2051,19 @@ export function createMcpManager(deps) {
1853
2051
  level: r.level,
1854
2052
  disabled: r.disabled,
1855
2053
  }));
1856
- const json = JSON.stringify({ exportedAt: new Date().toISOString(), rows }, null, 2);
2054
+ const payload = { exportedAt: new Date().toISOString(), rows };
2055
+ const json = JSON.stringify(payload, null, 2);
1857
2056
  let savedTo = null;
1858
2057
  try {
1859
2058
  const abs = mcpSidecar(MCP_EXPORT_FILE);
1860
- await writePatch(abs, json);
2059
+ // 用 `writeJsonFile`(直写 hub 侧车)而**不是** `writePatch`(2026-09-30 审查 P1-3):
2060
+ // `writePatch` 是宿主**补丁文件**的写口,写前会跑 `checkPatchWrite` 的官方方言校验
2061
+ // (顶层必须是 YAML 数组),而这里写的是 hub 里一份 JSON **对象** —— 校验必然失败、
2062
+ // 异常被下面的 catch 吞掉,于是 `savedTo` 恒为 null,界面显示"没能落盘(hub 目录写入
2063
+ // 失败)",**诊断是错的**(真实原因是方言校验器拒收 JSON 对象)。而且 `writePatch`
2064
+ // 自己的调用面纪律(`index.ts`:「不写宿主配置的动作也不该调用它」)本就排除了它 ——
2065
+ // 本处是唯一一个传 hub 路径的调用点。顺带省掉一次 `danger-full-access` 策略升级。
2066
+ await writeJsonFile(abs, payload);
1861
2067
  savedTo = abs;
1862
2068
  }
1863
2069
  catch (e) { /* non-fatal */ }
@@ -1903,11 +2109,16 @@ export function createMcpManager(deps) {
1903
2109
  const nameTaken = byName.has(row.serverName);
1904
2110
  // 打码值不能入库(判据见 ./mcp/secret-guard.js):覆盖模式下拿被覆盖条目在补丁里的
1905
2111
  // 旧真值顶替;非覆盖模式没有旧值可比,走"丢弃"分支。
2112
+ //
2113
+ // `prevConfig` 同时认两种形状:`collectAll()` 出来的行把配置挂在 `config` 下,
2114
+ // 而同一个批次里**先前刚导入**的行(`byId.set(row.id, row)`)是 `mcpmAdd` 那种扁平形状。
2115
+ // 少认一种,同批里重复 id 的那条就拿不到旧值、被静默丢弃。
1906
2116
  const prevRow = idTaken ? byId.get(row.id) : null;
2117
+ const prevConfig = prevRow ? (prevRow.config || prevRow) : {};
1907
2118
  for (const field of ['env', 'headers']) {
1908
2119
  if (row[field] === undefined)
1909
2120
  continue;
1910
- const outcome = resolveMaskedKv(row[field], prevRow ? prevRow[field] : null);
2121
+ const outcome = resolveMaskedKv(row[field], prevConfig[field]);
1911
2122
  row[field] = outcome.value;
1912
2123
  const note = describeMaskedOutcome(outcome);
1913
2124
  if (note)
@@ -1923,6 +2134,21 @@ export function createMcpManager(deps) {
1923
2134
  skipped.push({ id: row.id, reason: 'id 已存在' });
1924
2135
  continue;
1925
2136
  }
2137
+ // URL 与 env / headers 同一条纪律(判据见 ./mcp/secret-guard.js):打码值绝不入库。
2138
+ // 覆盖模式下拿被覆盖条目的旧真值顶替;没有旧真值可顶替时**跳过这一条**并给出理由 ——
2139
+ // URL 不是可丢的键值对,写一个"能连上但鉴权失败"的地址比不导入更糟。
2140
+ // 这条原先不存在,于是 `snapshot-export`(默认 `includeSecrets=false` 会把 URL 打码)
2141
+ // 导到目标机后,补丁里落进 `%3Credacted%3E` 而**没有任何 warning**(审查 F4)。
2142
+ if (row.transport === 'streamable-http') {
2143
+ const urlGuard = resolveMaskedUrl(row.url, prevConfig.url);
2144
+ if (urlGuard.unrecoverable) {
2145
+ skipped.push({ id: row.id, reason: 'URL 里的 userinfo / 查询串 / 片段是打码占位符,且没有原值可恢复:请重填完整 URL 后再导入' });
2146
+ continue;
2147
+ }
2148
+ row.url = urlGuard.value;
2149
+ if (urlGuard.restored)
2150
+ importNotes.push(row.serverName + ':URL 未改动,已保留原值');
2151
+ }
1926
2152
  // Overwrite: purge every trace of the id from BOTH patch files first,
1927
2153
  // then insert the imported row at its own level. The purge is not
1928
2154
  // atomic with the insert, so keep the pre-purge content of both files
@@ -2092,6 +2318,7 @@ export function createMcpManager(deps) {
2092
2318
  writeDisabledTools,
2093
2319
  updateNotes,
2094
2320
  mcpmListView,
2321
+ configuredServerNames,
2095
2322
  mcpmRowsWithNotes,
2096
2323
  readPluginSettings,
2097
2324
  isToolDisabled: (name) => isToolDisabledIn(disabledToolsCache ? disabledToolsCache.value : {}, name),