dsh-plugin-tool-management 0.17.0 → 0.18.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 (83) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -3
  3. package/README_EN.md +18 -3
  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 +16 -0
  21. package/lib/client.js +1573 -133
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/http-api.js +229 -0
  33. package/lib/hub.js +2 -0
  34. package/lib/index.js +134 -836
  35. package/lib/mcp/inspect-probe.js +199 -0
  36. package/lib/mcp/manager.js +30 -548
  37. package/lib/mcp/tool-visibility.js +388 -0
  38. package/lib/memories/archive-engine.js +142 -5
  39. package/lib/memories/archive.js +10 -1
  40. package/lib/memories/discovery.js +261 -0
  41. package/lib/memories/export-plan.js +42 -0
  42. package/lib/memories/memories-types.js +1 -0
  43. package/lib/memories/service.js +4 -38
  44. package/lib/memories/snapshot.js +10 -252
  45. package/lib/op-registry.js +21 -0
  46. package/lib/ops/compat.js +11 -0
  47. package/lib/ops/quick-prompts.js +26 -0
  48. package/lib/ops/scene-records.js +40 -0
  49. package/lib/ops/scene-sync.js +71 -1
  50. package/lib/ops/sessions.js +13 -9
  51. package/lib/ops/snapshot.js +18 -12
  52. package/lib/prompts/quick-service.js +211 -0
  53. package/lib/scene-linkage.js +184 -0
  54. package/lib/sessions/archive-schemas.js +121 -0
  55. package/lib/sessions/path-key.js +46 -0
  56. package/lib/sessions/workspace.js +5 -163
  57. package/lib/settings-sidecars.js +143 -0
  58. package/lib/skills/core-types.js +8 -0
  59. package/lib/skills/core-util.js +81 -0
  60. package/lib/skills/core.js +56 -2326
  61. package/lib/skills/dir-watch.js +71 -0
  62. package/lib/skills/frontmatter.js +96 -0
  63. package/lib/skills/homes.js +14 -0
  64. package/lib/skills/import-pipeline.js +231 -0
  65. package/lib/skills/manager-state.js +800 -0
  66. package/lib/skills/official.js +269 -0
  67. package/lib/skills/provider-registration.js +89 -0
  68. package/lib/skills/service.js +5 -240
  69. package/lib/skills/skill-roots.js +287 -0
  70. package/lib/skills/skill-trash.js +265 -0
  71. package/lib/skills/system-trash.js +97 -0
  72. package/lib/skills/upload-pipeline.js +374 -0
  73. package/lib/slash-settings.js +25 -0
  74. package/lib/subagents/persona-parse.js +312 -0
  75. package/lib/subagents/persona-types.js +5 -0
  76. package/lib/subagents/persona-write.js +93 -0
  77. package/lib/subagents/run-provider.js +159 -0
  78. package/lib/subagents/service.js +14 -638
  79. package/lib/subagents/tool-filter.js +98 -0
  80. package/lib/token-config.js +240 -0
  81. package/lib/tool-table-switch.js +163 -0
  82. package/lib/tools/scene.js +40 -9
  83. package/package.json +3 -2
@@ -18,13 +18,13 @@
18
18
  // 其余 14 项(ensurePaths / withWriteLock / readPatch / pluginInventory / memoriesService 等)
19
19
  // 由 deps 显式传入。
20
20
  import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
21
- // 配置体检查 PATH 用(见 mcpmInspect):execFile 不走 shell,参数是数组 —— 体检只查询
22
- // 命令名,**绝不执行**配置里的命令本身。
23
- import { execFile } from 'node:child_process';
24
21
  import { MCP_CLIENT_MODULE, MCP_TOOL_PREFIX, ambiguousServerNames, looksLikeMcpPrefixDrift, serverNameCandidates, splitMcpToolName } from '../host-names.js';
25
22
  import { hubPath } from '../hub.js';
26
23
  import { planOverrideCompaction } from './override-blocks.js';
24
+ // 配置体检的探测(PATH 查询 / 端点连通性 / 检查清单 + mcpm-inspect op),2026-10-01 剥出。
25
+ import { createInspectProbe } from './inspect-probe.js';
27
26
  import { createMcpStateCatalog } from './state-section.js';
27
+ import { createToolVisibility } from './tool-visibility.js';
28
28
  import { appendBlock, buildDisableBlock, buildInsertBlock, insertEntryIds, parseRows, removeEntryAll, removeMarked, spliceRanges, splitLines, } from './patch-yaml.js';
29
29
  import { describeMaskedOutcome, maskSecretValue, maskUrlQuery, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
30
30
  /**
@@ -383,344 +383,18 @@ export function createMcpManager(deps) {
383
383
  return { ok: true, settings: next };
384
384
  });
385
385
  }
386
- // ---------- per-tool enable/disable (execution + visibility boundary) ----------
387
- // dsh-mcp-client has no per-tool config, but the DSH tool runtime exposes
388
- // two official seams: `tools.restrict({ deny })` removes a global tool from
389
- // every scope's model-visible schema list, and `tools.guard` denies the call
390
- // before the body runs. Disabled tools therefore become invisible AND
391
- // uncallable — no patch rewrite, no DSH restart.
392
- let disabledToolsCache = null;
393
- async function readDisabledTools(force = false) {
394
- if (disabledToolsCache && !force && Date.now() - disabledToolsCache.at < SIDECAR_TTL_MS)
395
- return disabledToolsCache.value;
396
- const p = await ensurePaths();
397
- const raw = await readJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE));
398
- const out = {};
399
- if (raw && typeof raw === 'object') {
400
- for (const serverName of Object.keys(raw)) {
401
- const list = raw[serverName];
402
- if (Array.isArray(list)) {
403
- // `*` 必须原样保留:它是「整台服务器停用」的通配(场景档案未勾选的服务器写的就是它),
404
- // 被这里过滤掉的话 guard / restrict / 快照三条链路一起失效——整台停用变成空转。
405
- const names = list.map((name) => String(name)).filter((name) => name === '*' || /^[A-Za-z0-9_-]{1,128}$/.test(name));
406
- if (names.length)
407
- out[serverName] = names;
408
- }
409
- }
410
- }
411
- disabledToolsCache = { at: Date.now(), value: out };
412
- return out;
413
- }
414
- /** Fully-qualified model-facing names (`mcp__<serverName>__<tool>`) of every disabled tool. */
415
- function disabledToolNames(map) {
416
- const out = [];
417
- for (const serverName of Object.keys(map)) {
418
- for (const tool of map[serverName])
419
- out.push(MCP_TOOL_PREFIX + serverName + '__' + tool);
420
- }
421
- return out;
422
- }
423
- // ---------- 「已知工具」侧车(v0.8.1)-----------------------------------
424
- // MCP 工具名只在服务器**运行**时可见(tools.schemas() 里才有)。未运行的服务器在
425
- // 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
426
- // 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
427
- // 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
428
- let knownMcpToolsCache = null;
429
- async function readKnownMcpTools(force = false) {
430
- if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
431
- return knownMcpToolsCache.value;
432
- const p = await ensurePaths();
433
- const raw = await readJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE));
434
- const out = {};
435
- if (raw && typeof raw === 'object') {
436
- for (const serverName of Object.keys(raw)) {
437
- const list = normalizeKnownTools(raw[serverName]);
438
- if (list.length)
439
- out[serverName] = list;
440
- }
441
- }
442
- knownMcpToolsCache = { at: Date.now(), value: out };
443
- return out;
444
- }
445
- async function writeKnownMcpTools(value) {
446
- const p = await ensurePaths();
447
- await writeJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE), value);
448
- knownMcpToolsCache = { at: Date.now(), value: value };
449
- }
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;
501
- }
502
- async function mcpmToolEnabled(args) {
503
- const serverName = String((args && args.serverName) || '').trim();
504
- const tool = String((args && args.tool) || '').trim();
505
- if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
506
- return { ok: false, error: 'serverName 不合法' };
507
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(tool))
508
- return { ok: false, error: 'tool 名称不合法' };
509
- const enabled = (args && args.enabled) !== false;
510
- const p = await ensurePaths();
511
- return withWriteLock(async () => {
512
- // Force re-read so an externally edited disabled-tools file is merged, not clobbered.
513
- const map = Object.assign({}, await readDisabledTools(true));
514
- const disabled = new Set(map[serverName] || []);
515
- if (enabled)
516
- disabled.delete(tool);
517
- else
518
- disabled.add(tool);
519
- if (disabled.size)
520
- map[serverName] = [...disabled].sort();
521
- else
522
- delete map[serverName];
523
- try {
524
- await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), map);
525
- }
526
- catch (e) {
527
- return { ok: false, error: '保存失败: ' + message(e) };
528
- }
529
- disabledToolsCache = { at: Date.now(), value: map };
530
- await applyToolRestrictions();
531
- return { ok: true, serverName, disabled: map[serverName] || [] };
532
- });
533
- }
534
- // Visibility seam: keep one active restriction per **agent scope**, refreshed whenever
535
- // the tool set / the disabled set / this agent's carrier set changes. Three facts decide
536
- // this design(都读过官方源码 dsh-tools 0.1.5-rc.2):
537
- // · `restrict()` **要求 scoped context** —— 插件级 ctx 调用必抛 "requires a scoped
538
- // context",这正是「停用工具从模型可见 schema 消失」这半边此前从未生效的原因(V1);
539
- // · 每次 layer 变化(restriction 就是一次 layer effect)官方都会 emit `tools/change`
540
- // (`layers = new ScopedLayers(…, () => this.ctx.emit("tools/change"))`),而重放正挂在
541
- // `tools/change` 上 —— 名单没变还重放就是自激;
542
- // · 表要取自**不受限**的插件级视图:在 agent scope 里取 `schemas()`,第一次限制生效后
543
- // 表里就没有被停用的工具了,第二次重放会把它从 deny 名单里筛掉 —— 停用静默复活。
544
- // (2026-09-23:名单有三处来源,其中 `carrierHiddenTools` 逐 agent 不同,所以从"一份名单给
545
- // 所有 agent"改成逐 agent 各装一层 —— 见下面 restrictAgent / appliedByAgent 的注释。)
546
- let restrictTimer = null;
547
- /** 已应用名单的键;相同即返回(防自激,也让每次 tools/change 变成一次廉价判等)。 */
548
- let appliedNamesKey = null;
549
- /** 当前期望的**全局** deny 名单(agent 晚到 / 重放时用它,再叠加该 agent 自己那份)。 */
550
- let desiredNames = [];
551
- /** agent → 撤掉这层限制的 disposer。 */
552
- const agentRestrictions = new Map();
553
- /** agent → 已应用的**合并后**名单键(名单现在与 agent 有关,不能再只记一份全局的)。 */
554
- const appliedByAgent = new Map();
555
- /** 见过的 agent(重放时逐个重新应用;disposed 时移除)。 */
556
- const seenAgents = new Set();
557
- /**
558
- * 重放代数:`carrierHiddenTools` 是异步的,算名单期间可能又来了新的一轮重放(或 agent
559
- * 已经下线)。每次重放自增,回来时代数对不上就作废 —— 否则会用旧名单盖掉新的那一层。
560
- */
561
- let epoch = 0;
562
- function scopedToolsOf(agent) {
563
- const scoped = agent && agent.ctx && agent.ctx.tools;
564
- return scoped && typeof scoped === 'object' ? scoped : undefined;
565
- }
566
- /** 撤掉某个 agent 上的限制(名单变了要重装:`restrict` 是叠加层,装新的不撤旧的会越叠越多)。 */
567
- function liftRestriction(agent) {
568
- const release = agentRestrictions.get(agent);
569
- agentRestrictions.delete(agent);
570
- appliedByAgent.delete(agent);
571
- if (release) {
572
- try {
573
- release();
574
- }
575
- catch (e) { /* ignore */ }
576
- }
577
- }
578
- /** 把该 agent 该背的名单装到它 scope 上(全局那份 ∪ 它自己那份;空名单 = 不碰它)。 */
579
- async function restrictAgent(agent) {
580
- const mine = epoch;
581
- let extra = [];
582
- if (deps.carrierHiddenTools) {
583
- // 判据失败一律当"没有额外要藏的":藏错了模型就少一条路,留着只是多花 token。
584
- try {
585
- extra = await deps.carrierHiddenTools(agent);
586
- }
587
- catch (e) {
588
- extra = [];
589
- }
590
- }
591
- if (mine !== epoch)
592
- return;
593
- if (!seenAgents.has(agent))
594
- return;
595
- const names = [...new Set([...desiredNames, ...extra])];
596
- const scoped = scopedToolsOf(agent);
597
- if (!scoped || typeof scoped.restrict !== 'function') {
598
- if (names.length === 0)
599
- return;
600
- // agent scope 上没有 tools 面(或它没有 restrict):这半边做不成,如实说 ——
601
- // 静默跳过会让人以为"停用工具从模型工具表里消失"已经生效。
602
- noteRuntime({
603
- id: 'mcp-tool-visibility',
604
- label: '停用工具的可见性',
605
- kind: 'write',
606
- fallback: 'inform-only',
607
- detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
608
- detailKey: 'mcp-tool-visibility.no-restrict',
609
- });
610
- return;
611
- }
612
- const key = names.slice().sort().join('\u0000');
613
- if (appliedByAgent.get(agent) === key)
614
- return;
615
- liftRestriction(agent);
616
- if (names.length === 0) {
617
- clearRuntimeNote('mcp-tool-visibility');
618
- return;
619
- }
620
- try {
621
- const dispose = scoped.restrict({ deny: names });
622
- agentRestrictions.set(agent, typeof dispose === 'function' ? dispose : () => { });
623
- appliedByAgent.set(agent, key);
624
- clearRuntimeNote('mcp-tool-visibility');
625
- }
626
- catch (e) {
627
- // 这个名字在这个 scope 里认不出 / scope 已经收了:可见性半边没生效。执行侧的 guard
628
- // 仍然拦住调用,所以功能不缺 —— 但必须如实上报,不能假装成功(此前正是静默吞掉)。
629
- noteRuntime({
630
- id: 'mcp-tool-visibility',
631
- label: '停用工具的可见性',
632
- kind: 'write',
633
- fallback: 'inform-only',
634
- detail: '有工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
635
- detailKey: 'mcp-tool-visibility.partial',
636
- params: { reason: message(e) },
637
- });
638
- }
639
- }
640
- /** agent 上线(`agent/created` 或启动期的 agent 列表):登记并立刻应用当前名单。 */
641
- function attachAgent(agent) {
642
- if (!agent || (typeof agent !== 'object' && typeof agent !== 'function'))
643
- return;
644
- seenAgents.add(agent);
645
- void restrictAgent(agent).catch(() => { });
646
- }
647
- /** agent 下线(`agent/disposed`):先撤限制再销登记。 */
648
- function detachAgent(agent) {
649
- seenAgents.delete(agent);
650
- liftRestriction(agent);
651
- }
652
- async function applyToolRestrictions() {
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
- });
666
- return;
667
- }
668
- // Force re-read: this runs on the tools/change path, which is rare, so a
669
- // stale cache must not keep an externally edited deny list hidden.
670
- const map = await readDisabledTools(true);
671
- // 通配 `*`(整服务器停用)先展开成已注册的全名,再与精确名单合并。
672
- const wanted = disabledToolNames(map);
673
- let registered;
674
- try {
675
- registered = new Set((await tools.schemas()).map((schema) => String(schema.name)));
676
- }
677
- catch (e) {
678
- return;
679
- }
680
- for (const [serverName, list] of Object.entries(map)) {
681
- if (list.indexOf('*') < 0)
682
- continue;
683
- const prefix = MCP_TOOL_PREFIX + serverName + '__';
684
- for (const fullName of registered)
685
- if (fullName.startsWith(prefix))
686
- wanted.push(fullName);
687
- }
688
- // 兼容页「模型工具表」关掉的本插件工具并进同一份名单(理由见 deps.pluginHiddenTools)。
689
- // 交给上面那个 `registered` 过滤是**必要的**而不是保险:官方对认不出的名字直接抛错,
690
- // 而关掉的工具里可能有一个这次根本没注册成功(注册失败会被子智能体页的黄条报出来)。
691
- if (deps.pluginHiddenTools) {
692
- try {
693
- for (const name of deps.pluginHiddenTools())
694
- wanted.push(name);
695
- }
696
- catch (e) { /* 读设置失败 = 这次不摘它们 */ }
697
- }
698
- const names = [...new Set(wanted)].filter((name) => registered.has(name));
699
- const key = names.join('\u0000');
700
- // 有 `carrierHiddenTools` 时不拿全局键短路:那份名单与"官方工具此时挂没挂"有关,
701
- // 而它变了不会改全局键(官方挂载/卸载本身就发 tools/change,正是靠这条通路追上)。
702
- // 代价是每次 tools/change 都重算一遍 —— 逐 agent 的键判等会把绝大多数挡在装层之前,
703
- // 事实读取也有 TTL 缓存;装层本身会再发一次 tools/change,那一轮键相同即收敛,不自激。
704
- if (key === appliedNamesKey && deps.carrierHiddenTools === undefined)
705
- return;
706
- appliedNamesKey = key;
707
- desiredNames = names;
708
- // 每次重放先撤掉每个 agent 上的旧层:名单变短(重新启用某个工具)时,只有撤掉这层限制
709
- // 它才会重新可见;名单变了(含逐 agent 那份)也必须重装而不是叠加。
710
- epoch += 1;
711
- for (const agent of [...agentRestrictions.keys()])
712
- liftRestriction(agent);
713
- for (const agent of seenAgents)
714
- void restrictAgent(agent).catch(() => { });
715
- }
716
- function scheduleToolRestrictions() {
717
- if (restrictTimer)
718
- return;
719
- restrictTimer = setTimeout(() => {
720
- restrictTimer = null;
721
- applyToolRestrictions().catch(() => { });
722
- }, 300);
723
- }
386
+ // ---------- per-tool enable/disable + 可见性半边 → ./tool-visibility.ts ----------
387
+ // 那 294 行整段搬进同目录模块,靠 deps 拿这里的闭包名字(侧车读写、写锁、tools 服务、
388
+ // 三个侧车常量),所以母文件这一段只剩接线。
389
+ // 唯一被块外改写的状态是停用表缓存:改名迁移、删台、整表回写三处写它,出口两处同步读它
390
+ // (isToolDisabled / disabledToolSummary)—— 于是块交回两个句柄,语义与原样一致:
391
+ // setDisabledToolsCache(x) ≡ `disabledToolsCache = { at: Date.now(), value: x }`,
392
+ // disabledToolsSnapshot() ≡ `disabledToolsCache ? disabledToolsCache.value : {}`。
393
+ const { readDisabledTools, readKnownMcpTools, writeKnownMcpTools, mutateKnownMcpTools, mergeLiveToolsInto, mcpmToolEnabled, applyToolRestrictions, scheduleToolRestrictions, attachAgent, detachAgent, setDisabledToolsCache, disabledToolsSnapshot, disposeVisibility, } = createToolVisibility({
394
+ readJsonFile, writeJsonFile, ensurePaths, withWriteLock, message, tools,
395
+ pluginHiddenTools: deps.pluginHiddenTools, carrierHiddenTools: deps.carrierHiddenTools,
396
+ normalizeKnownTools, mcpSidecar, SIDECAR_TTL_MS, MCP_DISABLED_TOOLS_FILE, MCP_KNOWN_TOOLS_FILE,
397
+ });
724
398
  function truncateText(value, limit) {
725
399
  if (!limit || value.length <= limit)
726
400
  return value;
@@ -1427,7 +1101,7 @@ export function createMcpManager(deps) {
1427
1101
  await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
1428
1102
  }
1429
1103
  catch (e) { /* non-fatal */ }
1430
- disabledToolsCache = { at: Date.now(), value: toolMap };
1104
+ setDisabledToolsCache(toolMap);
1431
1105
  }
1432
1106
  // F-025:运行时快照的 mcp 停用表同样按 serverName 键 —— 改名不跟着改,
1433
1107
  // 退出场景整体回写时旧键成死键、新名工具的停用状态丢失(回到全启用)。
@@ -1764,7 +1438,7 @@ export function createMcpManager(deps) {
1764
1438
  if (Object.prototype.hasOwnProperty.call(toolMap, serverName)) {
1765
1439
  delete toolMap[serverName];
1766
1440
  await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
1767
- disabledToolsCache = { at: Date.now(), value: toolMap };
1441
+ setDisabledToolsCache(toolMap);
1768
1442
  await applyToolRestrictions();
1769
1443
  }
1770
1444
  const knownMap = Object.assign({}, await readKnownMcpTools(true));
@@ -1845,197 +1519,14 @@ export function createMcpManager(deps) {
1845
1519
  return { ok: true, removed: total, ids: removed, files };
1846
1520
  });
1847
1521
  }
1848
- // ---------- 配置体检(0.15.0 B1;0.16.5 起 streamable-http 含端点探测)--------------
1849
- // 对补丁文件**只读**:不写盘、不拉起子进程。对外调用两处 ——
1850
- // · `where` / `which` 查 PATH:只查「这个命令名能不能找到」,绝不执行配置里的命令本身;
1851
- // · streamable-http 行对配置地址发一次 GET 连通性探测(带配置的鉴权头、5 秒超时、
1852
- // 不读响应体 —— 与宿主连接该服务走的是同一地址同一头部,只是更轻)。
1853
- //
1854
- // 为什么单独读补丁而不复用 `mcpmList()`:那个函数顺带回写「已知工具」缓存、还要问一次
1855
- // schema 注册表。体检的承诺是「点了什么都不变」,所以这里只走 readPatch + parseRows。
1856
- const INSPECT_PATH_TIMEOUT_MS = 3000;
1857
- /** 命令是否在 PATH 上:`unknown`(查不了)与 `missing`(确认没有)必须分开 —— 前者不是问题。 */
1858
- async function probeCommandOnPath(command) {
1859
- if (!command)
1860
- return 'unknown';
1861
- const finder = process.platform === 'win32' ? 'where' : 'which';
1862
- return await new Promise((resolve) => {
1863
- execFile(finder, [command], { timeout: INSPECT_PATH_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
1864
- if (!error) {
1865
- resolve(String(stdout || '').trim() ? 'ok' : 'missing');
1866
- return;
1867
- }
1868
- // ENOENT = 连 where/which 自己都没跑到;killed = 超时。两种都是「无法确认」,
1869
- // 剩下的(退出码非 0)才是「PATH 上没有这条命令」。
1870
- const code = error.code;
1871
- if (code === 'ENOENT' || error.killed === true)
1872
- resolve('unknown');
1873
- else
1874
- resolve('missing');
1875
- });
1876
- });
1877
- }
1878
- /** streamable-http 连通性探测的超时:再长,「全部检查」就会被一台挂掉的服务拖住。 */
1879
- const INSPECT_PROBE_TIMEOUT_MS = 5000;
1880
- /** 探测失败的原因短语(进词典模板的 {reason}):认得出常见错误码就说人话,认不出给原文截断。 */
1881
- function describeProbeFailure(e) {
1882
- const err = e;
1883
- if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
1884
- return '探测超时(' + Math.round(INSPECT_PROBE_TIMEOUT_MS / 1000) + ' 秒无响应)';
1885
- }
1886
- const code = err && err.cause && err.cause.code;
1887
- if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
1888
- return '域名解析失败(' + code + ')';
1889
- if (code === 'ECONNREFUSED')
1890
- return '连接被拒绝(' + code + ')';
1891
- if (code === 'ETIMEDOUT')
1892
- return '连接超时(' + code + ')';
1893
- if (code === 'ECONNRESET')
1894
- return '连接被重置(' + code + ')';
1895
- if (typeof code === 'string' && /CERT|SSL|TLS/.test(code))
1896
- return 'TLS 证书校验失败(' + code + ')';
1897
- const text = String((err && err.cause && err.cause.message) || (err && err.message) || e || '');
1898
- return text.length > 80 ? text.slice(0, 77) + '…' : text;
1899
- }
1900
- /**
1901
- * streamable-http 的连通性探测:对配置地址 GET 一次,**任何** HTTP 响应(含 404/405)
1902
- * 都算「可达」—— 探测只回答"这个地址现在连不连得上",不校验它是不是 MCP 服务。
1903
- * 不读响应体:streamable-http 对 GET 常回一条 SSE 长连接,拿到状态行就取消。
1904
- */
1905
- async function probeHttpEndpoint(url, headers) {
1906
- const extra = {};
1907
- if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
1908
- for (const [k, v] of Object.entries(headers)) {
1909
- if (typeof v === 'string' && v)
1910
- extra[k] = v;
1911
- }
1912
- }
1913
- try {
1914
- const res = await fetch(url, { method: 'GET', headers: extra, redirect: 'follow', signal: AbortSignal.timeout(INSPECT_PROBE_TIMEOUT_MS) });
1915
- try {
1916
- await res.body?.cancel();
1917
- }
1918
- catch { /* 取消失败不影响「可达」结论 */ }
1919
- return { reachable: true };
1920
- }
1921
- catch (e) {
1922
- return { reachable: false, reason: describeProbeFailure(e) };
1923
- }
1924
- }
1925
- /** 一行配置 → 检查项。`level: 'warn'` 才点黄;`info` 是「查不了 / 不算问题」那类说明。 */
1926
- async function inspectChecks(row, duplicateIds) {
1927
- const checks = [];
1928
- const declared = String(row.transport || '');
1929
- const url = String(row.url || '');
1930
- const command = String(row.command || '');
1931
- // transport 缺失/不认识时按字段形状推:历史上手工改过补丁文件的行会绕过 normalize,
1932
- // 直接判「没有 transport」会把一条其实能用的配置说成坏的。
1933
- const transport = declared === 'stdio' || (!declared && command && !url) ? 'stdio'
1934
- : declared === 'streamable-http' || (!declared && url) ? 'streamable-http'
1935
- : declared;
1936
- if (!declared && transport)
1937
- checks.push({ id: 'transportMissing', level: 'info' });
1938
- /** 值必须是字符串映射:非对象整片算坏,对象里逐个挑出非字符串的键。 */
1939
- const badKv = (map) => {
1940
- if (map == null)
1941
- return [];
1942
- if (typeof map !== 'object' || Array.isArray(map))
1943
- return ['*'];
1944
- return Object.keys(map).filter((k) => typeof map[k] !== 'string');
1945
- };
1946
- if (transport === 'stdio') {
1947
- if (!command)
1948
- checks.push({ id: 'noCommand', level: 'warn' });
1949
- else {
1950
- // 唯一一次外部调用。npx / uvx 这类 shim 找不到时界面给的下半句是「shim 属正常可忽略」,
1951
- // 不让用户以为命令坏了。
1952
- //
1953
- // `params.command` 原样回给客户端:这与 `mcpm-list`(同样是**免令牌**的只读 op)一致
1954
- // —— 列表视图里的 `command` 一直是明文,命令名本身不是凭据(真正装凭据的 `args`
1955
- // 两边都不回)。若哪天要收紧,两处必须一起收,否则只是把同一份值换个 op 暴露。
1956
- const found = await probeCommandOnPath(command);
1957
- if (found === 'missing')
1958
- checks.push({ id: 'cmdMissing', level: 'warn', params: { command } });
1959
- else if (found === 'unknown')
1960
- checks.push({ id: 'cmdUnknown', level: 'info', params: { command } });
1961
- }
1962
- if (row.args != null && !Array.isArray(row.args))
1963
- checks.push({ id: 'argsNotArray', level: 'warn' });
1964
- const badEnv = badKv(row.env);
1965
- if (badEnv.length)
1966
- checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'env', keys: badEnv.join('、') } });
1967
- }
1968
- else if (transport === 'streamable-http') {
1969
- if (!url)
1970
- checks.push({ id: 'noUrl', level: 'warn' });
1971
- // URL 一律**打码后**回给客户端(`maskUrlQuery`,与列表视图同一口径)。本 op 在登记表里
1972
- // 是 `readonly: true` 的免令牌 op,原先这里回的是补丁文件里的 URL 原文 —— 于是一个
1973
- // 无需令牌的请求就能拿到 `?api_key=…` 的明文,绕过了 `mcpm-reveal` 那道
1974
- // 「令牌是明文凭据最后一道防线」的设计(审查 P0-5)。路径与主机保留,定位不受影响。
1975
- else if (!/^https?:\/\//.test(url))
1976
- checks.push({ id: 'badUrl', level: 'warn', params: { url: maskUrlQuery(url) } });
1977
- else {
1978
- // 格式对了再做一次真实探测(0.16.5):此前的体检止步于格式校验,一条编造的
1979
- // 地址也能拿绿点 —— 绿点承诺的「没发现问题」其实只覆盖了一半(2026-09-29 实测)。
1980
- const probe = await probeHttpEndpoint(url, row.headers);
1981
- if (!probe.reachable)
1982
- checks.push({ id: 'httpUnreachable', level: 'warn', params: { url: maskUrlQuery(url), reason: probe.reason } });
1983
- }
1984
- const badHeaders = badKv(row.headers);
1985
- if (badHeaders.length)
1986
- checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'headers', keys: badHeaders.join('、') } });
1987
- }
1988
- else if (!command && !url) {
1989
- checks.push({ id: 'noEndpoint', level: 'warn' });
1990
- }
1991
- else {
1992
- checks.push({ id: 'badTransport', level: 'warn', params: { transport } });
1993
- }
1994
- if (duplicateIds > 1)
1995
- checks.push({ id: 'nameDup', level: 'warn', params: { name: String(row.serverName || ''), count: duplicateIds } });
1996
- return checks;
1997
- }
1998
- async function mcpmInspect(args) {
1999
- const p = await ensurePaths();
2000
- const rows = [];
2001
- const errors = [];
2002
- for (const level of ['project', 'global']) {
2003
- const abs = level === 'project' ? p.projectPatch : p.globalPatch;
2004
- let content = '';
2005
- try {
2006
- content = await readPatch(abs);
2007
- }
2008
- catch (e) {
2009
- errors.push(level + ': ' + message(e));
2010
- continue;
2011
- }
2012
- const { rows: fileRows } = parseRows(content);
2013
- for (const r of fileRows)
2014
- rows.push(normalizeRow(r, level, abs));
2015
- }
2016
- // 重名判据:同一个 serverName 挂在**不同 id** 下。同 id 跨层是正常遮蔽(界面已有「重复 id」标签),
2017
- // 而按名字注册的 `mcp__<serverName>__*` 会撞车 —— 那才是配置问题。
2018
- const idsByName = {};
2019
- for (const r of rows) {
2020
- const n = String(r.serverName || '');
2021
- if (!idsByName[n])
2022
- idsByName[n] = new Set();
2023
- idsByName[n].add(String(r.id));
2024
- }
2025
- const onlyId = args && String(args.id || '') ? String(args.id) : '';
2026
- const onlyLevel = args && String(args.level || '') ? String(args.level) : '';
2027
- // 逐行检查并行跑:体检含 streamable-http 的端点探测(每次至多 5 秒超时),串行会让
2028
- // 「全部检查」在多台同时连不上时按行数翻倍地慢。Promise.all 保序,与旧实现一致。
2029
- const targets = rows.filter((r) => (!onlyId || String(r.id) === onlyId) && (!onlyLevel || String(r.level) === onlyLevel));
2030
- const results = await Promise.all(targets.map(async (r) => ({
2031
- id: r.id,
2032
- serverName: r.serverName,
2033
- level: r.level,
2034
- transport: r.transport || null,
2035
- checks: await inspectChecks(r, (idsByName[String(r.serverName || '')] || new Set()).size),
2036
- })));
2037
- return { ok: true, results, errors };
2038
- }
1522
+ // ---------- 配置体检的探测(0.15.0 B1;0.16.5 起含端点探测)----------------------
1523
+ // 正文 2026-10-01 整段搬到 ./inspect-probe.js(一字未改,只把闭包作用域的几个名字改走
1524
+ // deps)。那段「对补丁只读、`where`/`which` 只查命令名绝不执行配置里的命令、复用列表结果
1525
+ // 但不回写任何缓存」的口径连同理由一起搬过去了,见那边的文件头。
1526
+ const { mcpmInspect } = createInspectProbe({
1527
+ ensurePaths, readPatch, message, normalizeRow, mcpmList,
1528
+ });
1529
+ // ---------- 配置的导出 / 导入 ----------
2039
1530
  async function mcpmExport() {
2040
1531
  const p = await ensurePaths();
2041
1532
  const list = await mcpmList();
@@ -2257,7 +1748,7 @@ export function createMcpManager(deps) {
2257
1748
  await deps.withWriteLock(async () => {
2258
1749
  await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), entries);
2259
1750
  });
2260
- disabledToolsCache = { at: Date.now(), value: entries };
1751
+ setDisabledToolsCache(entries);
2261
1752
  scheduleToolRestrictions();
2262
1753
  }
2263
1754
  /**
@@ -2278,18 +1769,9 @@ export function createMcpManager(deps) {
2278
1769
  await readDisabledTools();
2279
1770
  await applyToolRestrictions();
2280
1771
  }
2281
- /** 卸载清理(原 index.ts 的 cleanup effect 正文)。 */
1772
+ /** 卸载清理(那六行随可见性段搬进 tool-visibility.ts,这里只留出口接线)。 */
2282
1773
  function dispose() {
2283
- if (restrictTimer) {
2284
- clearTimeout(restrictTimer);
2285
- restrictTimer = null;
2286
- }
2287
- // 插件卸载时把挂在每个 agent scope 上的限制都撤掉(否则那些 scope 会继续背着一层名单)。
2288
- epoch += 1;
2289
- for (const agent of [...agentRestrictions.keys()])
2290
- liftRestriction(agent);
2291
- appliedByAgent.clear();
2292
- seenAgents.clear();
1774
+ disposeVisibility();
2293
1775
  }
2294
1776
  return {
2295
1777
  stateCatalog: mcpStateCatalog,
@@ -2321,10 +1803,10 @@ export function createMcpManager(deps) {
2321
1803
  configuredServerNames,
2322
1804
  mcpmRowsWithNotes,
2323
1805
  readPluginSettings,
2324
- isToolDisabled: (name) => isToolDisabledIn(disabledToolsCache ? disabledToolsCache.value : {}, name),
1806
+ isToolDisabled: (name) => isToolDisabledIn(disabledToolsSnapshot(), name),
2325
1807
  /** 停用表的两个口径(读 TTL 缓存;功能总览用):`*` 通配的台数 + 单独停用的工具数。 */
2326
1808
  disabledToolSummary: () => {
2327
- const map = disabledToolsCache ? disabledToolsCache.value : {};
1809
+ const map = disabledToolsSnapshot();
2328
1810
  let servers = 0;
2329
1811
  let tools = 0;
2330
1812
  for (const list of Object.values(map)) {