dsh-plugin-tool-management 0.16.6 → 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.
- package/CHANGELOG.md +115 -0
- package/README.md +20 -4
- package/README_EN.md +20 -4
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +59 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +1615 -148
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +54 -714
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +57 -459
- package/lib/host-names.js +150 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +84 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +235 -843
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +267 -558
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +163 -16
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +167 -78
- package/lib/memories/snapshot.js +41 -260
- package/lib/op-registry.js +72 -8
- package/lib/ops/compat.js +17 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +157 -10
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +63 -13
- package/lib/ops/snapshot.js +123 -24
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/quick-service.js +211 -0
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-linkage.js +184 -0
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +45 -168
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +97 -2297
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +4 -234
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +20 -639
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/mcp/manager.js
CHANGED
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
// 其余 14 项(ensurePaths / withWriteLock / readPatch / pluginInventory / memoriesService 等)
|
|
19
19
|
// 由 deps 显式传入。
|
|
20
20
|
import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
|
|
21
|
-
|
|
22
|
-
// 命令名,**绝不执行**配置里的命令本身。
|
|
23
|
-
import { execFile } from 'node:child_process';
|
|
24
|
-
import { MCP_CLIENT_MODULE } from '../host-names.js';
|
|
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';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
27
|
+
import { createToolVisibility } from './tool-visibility.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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -342,294 +383,18 @@ export function createMcpManager(deps) {
|
|
|
342
383
|
return { ok: true, settings: next };
|
|
343
384
|
});
|
|
344
385
|
}
|
|
345
|
-
// ---------- per-tool enable/disable
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const out = {};
|
|
358
|
-
if (raw && typeof raw === 'object') {
|
|
359
|
-
for (const serverName of Object.keys(raw)) {
|
|
360
|
-
const list = raw[serverName];
|
|
361
|
-
if (Array.isArray(list)) {
|
|
362
|
-
// `*` 必须原样保留:它是「整台服务器停用」的通配(场景档案未勾选的服务器写的就是它),
|
|
363
|
-
// 被这里过滤掉的话 guard / restrict / 快照三条链路一起失效——整台停用变成空转。
|
|
364
|
-
const names = list.map((name) => String(name)).filter((name) => name === '*' || /^[A-Za-z0-9_-]{1,128}$/.test(name));
|
|
365
|
-
if (names.length)
|
|
366
|
-
out[serverName] = names;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
disabledToolsCache = { at: Date.now(), value: out };
|
|
371
|
-
return out;
|
|
372
|
-
}
|
|
373
|
-
/** Fully-qualified model-facing names (`mcp__<serverName>__<tool>`) of every disabled tool. */
|
|
374
|
-
function disabledToolNames(map) {
|
|
375
|
-
const out = [];
|
|
376
|
-
for (const serverName of Object.keys(map)) {
|
|
377
|
-
for (const tool of map[serverName])
|
|
378
|
-
out.push('mcp__' + serverName + '__' + tool);
|
|
379
|
-
}
|
|
380
|
-
return out;
|
|
381
|
-
}
|
|
382
|
-
// ---------- 「已知工具」侧车(v0.8.1)-----------------------------------
|
|
383
|
-
// MCP 工具名只在服务器**运行**时可见(tools.schemas() 里才有)。未运行的服务器在
|
|
384
|
-
// 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
|
|
385
|
-
// 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
|
|
386
|
-
// 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
|
|
387
|
-
let knownMcpToolsCache = null;
|
|
388
|
-
async function readKnownMcpTools(force = false) {
|
|
389
|
-
if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
|
|
390
|
-
return knownMcpToolsCache.value;
|
|
391
|
-
const p = await ensurePaths();
|
|
392
|
-
const raw = await readJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE));
|
|
393
|
-
const out = {};
|
|
394
|
-
if (raw && typeof raw === 'object') {
|
|
395
|
-
for (const serverName of Object.keys(raw)) {
|
|
396
|
-
const list = normalizeKnownTools(raw[serverName]);
|
|
397
|
-
if (list.length)
|
|
398
|
-
out[serverName] = list;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
knownMcpToolsCache = { at: Date.now(), value: out };
|
|
402
|
-
return out;
|
|
403
|
-
}
|
|
404
|
-
async function writeKnownMcpTools(value) {
|
|
405
|
-
const p = await ensurePaths();
|
|
406
|
-
await writeJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE), value);
|
|
407
|
-
knownMcpToolsCache = { at: Date.now(), value: value };
|
|
408
|
-
}
|
|
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;
|
|
423
|
-
}
|
|
424
|
-
async function mcpmToolEnabled(args) {
|
|
425
|
-
const serverName = String((args && args.serverName) || '').trim();
|
|
426
|
-
const tool = String((args && args.tool) || '').trim();
|
|
427
|
-
if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
|
|
428
|
-
return { ok: false, error: 'serverName 不合法' };
|
|
429
|
-
if (!/^[A-Za-z0-9_-]{1,128}$/.test(tool))
|
|
430
|
-
return { ok: false, error: 'tool 名称不合法' };
|
|
431
|
-
const enabled = (args && args.enabled) !== false;
|
|
432
|
-
const p = await ensurePaths();
|
|
433
|
-
return withWriteLock(async () => {
|
|
434
|
-
// Force re-read so an externally edited disabled-tools file is merged, not clobbered.
|
|
435
|
-
const map = Object.assign({}, await readDisabledTools(true));
|
|
436
|
-
const disabled = new Set(map[serverName] || []);
|
|
437
|
-
if (enabled)
|
|
438
|
-
disabled.delete(tool);
|
|
439
|
-
else
|
|
440
|
-
disabled.add(tool);
|
|
441
|
-
if (disabled.size)
|
|
442
|
-
map[serverName] = [...disabled].sort();
|
|
443
|
-
else
|
|
444
|
-
delete map[serverName];
|
|
445
|
-
try {
|
|
446
|
-
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), map);
|
|
447
|
-
}
|
|
448
|
-
catch (e) {
|
|
449
|
-
return { ok: false, error: '保存失败: ' + message(e) };
|
|
450
|
-
}
|
|
451
|
-
disabledToolsCache = { at: Date.now(), value: map };
|
|
452
|
-
await applyToolRestrictions();
|
|
453
|
-
return { ok: true, serverName, disabled: map[serverName] || [] };
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
// Visibility seam: keep one active restriction per **agent scope**, refreshed whenever
|
|
457
|
-
// the tool set / the disabled set / this agent's carrier set changes. Three facts decide
|
|
458
|
-
// this design(都读过官方源码 dsh-tools 0.1.5-rc.2):
|
|
459
|
-
// · `restrict()` **要求 scoped context** —— 插件级 ctx 调用必抛 "requires a scoped
|
|
460
|
-
// context",这正是「停用工具从模型可见 schema 消失」这半边此前从未生效的原因(V1);
|
|
461
|
-
// · 每次 layer 变化(restriction 就是一次 layer effect)官方都会 emit `tools/change`
|
|
462
|
-
// (`layers = new ScopedLayers(…, () => this.ctx.emit("tools/change"))`),而重放正挂在
|
|
463
|
-
// `tools/change` 上 —— 名单没变还重放就是自激;
|
|
464
|
-
// · 表要取自**不受限**的插件级视图:在 agent scope 里取 `schemas()`,第一次限制生效后
|
|
465
|
-
// 表里就没有被停用的工具了,第二次重放会把它从 deny 名单里筛掉 —— 停用静默复活。
|
|
466
|
-
// (2026-09-23:名单有三处来源,其中 `carrierHiddenTools` 逐 agent 不同,所以从"一份名单给
|
|
467
|
-
// 所有 agent"改成逐 agent 各装一层 —— 见下面 restrictAgent / appliedByAgent 的注释。)
|
|
468
|
-
let restrictTimer = null;
|
|
469
|
-
/** 已应用名单的键;相同即返回(防自激,也让每次 tools/change 变成一次廉价判等)。 */
|
|
470
|
-
let appliedNamesKey = null;
|
|
471
|
-
/** 当前期望的**全局** deny 名单(agent 晚到 / 重放时用它,再叠加该 agent 自己那份)。 */
|
|
472
|
-
let desiredNames = [];
|
|
473
|
-
/** agent → 撤掉这层限制的 disposer。 */
|
|
474
|
-
const agentRestrictions = new Map();
|
|
475
|
-
/** agent → 已应用的**合并后**名单键(名单现在与 agent 有关,不能再只记一份全局的)。 */
|
|
476
|
-
const appliedByAgent = new Map();
|
|
477
|
-
/** 见过的 agent(重放时逐个重新应用;disposed 时移除)。 */
|
|
478
|
-
const seenAgents = new Set();
|
|
479
|
-
/**
|
|
480
|
-
* 重放代数:`carrierHiddenTools` 是异步的,算名单期间可能又来了新的一轮重放(或 agent
|
|
481
|
-
* 已经下线)。每次重放自增,回来时代数对不上就作废 —— 否则会用旧名单盖掉新的那一层。
|
|
482
|
-
*/
|
|
483
|
-
let epoch = 0;
|
|
484
|
-
function scopedToolsOf(agent) {
|
|
485
|
-
const scoped = agent && agent.ctx && agent.ctx.tools;
|
|
486
|
-
return scoped && typeof scoped === 'object' ? scoped : undefined;
|
|
487
|
-
}
|
|
488
|
-
/** 撤掉某个 agent 上的限制(名单变了要重装:`restrict` 是叠加层,装新的不撤旧的会越叠越多)。 */
|
|
489
|
-
function liftRestriction(agent) {
|
|
490
|
-
const release = agentRestrictions.get(agent);
|
|
491
|
-
agentRestrictions.delete(agent);
|
|
492
|
-
appliedByAgent.delete(agent);
|
|
493
|
-
if (release) {
|
|
494
|
-
try {
|
|
495
|
-
release();
|
|
496
|
-
}
|
|
497
|
-
catch (e) { /* ignore */ }
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
/** 把该 agent 该背的名单装到它 scope 上(全局那份 ∪ 它自己那份;空名单 = 不碰它)。 */
|
|
501
|
-
async function restrictAgent(agent) {
|
|
502
|
-
const mine = epoch;
|
|
503
|
-
let extra = [];
|
|
504
|
-
if (deps.carrierHiddenTools) {
|
|
505
|
-
// 判据失败一律当"没有额外要藏的":藏错了模型就少一条路,留着只是多花 token。
|
|
506
|
-
try {
|
|
507
|
-
extra = await deps.carrierHiddenTools(agent);
|
|
508
|
-
}
|
|
509
|
-
catch (e) {
|
|
510
|
-
extra = [];
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
if (mine !== epoch)
|
|
514
|
-
return;
|
|
515
|
-
if (!seenAgents.has(agent))
|
|
516
|
-
return;
|
|
517
|
-
const names = [...new Set([...desiredNames, ...extra])];
|
|
518
|
-
const scoped = scopedToolsOf(agent);
|
|
519
|
-
if (!scoped || typeof scoped.restrict !== 'function') {
|
|
520
|
-
if (names.length === 0)
|
|
521
|
-
return;
|
|
522
|
-
// agent scope 上没有 tools 面(或它没有 restrict):这半边做不成,如实说 ——
|
|
523
|
-
// 静默跳过会让人以为"停用工具从模型工具表里消失"已经生效。
|
|
524
|
-
noteRuntime({
|
|
525
|
-
id: 'mcp-tool-visibility',
|
|
526
|
-
label: '停用工具的可见性',
|
|
527
|
-
kind: 'write',
|
|
528
|
-
fallback: 'inform-only',
|
|
529
|
-
detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
|
|
530
|
-
detailKey: 'mcp-tool-visibility.no-restrict',
|
|
531
|
-
});
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
const key = names.slice().sort().join('\u0000');
|
|
535
|
-
if (appliedByAgent.get(agent) === key)
|
|
536
|
-
return;
|
|
537
|
-
liftRestriction(agent);
|
|
538
|
-
if (names.length === 0) {
|
|
539
|
-
clearRuntimeNote('mcp-tool-visibility');
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
try {
|
|
543
|
-
const dispose = scoped.restrict({ deny: names });
|
|
544
|
-
agentRestrictions.set(agent, typeof dispose === 'function' ? dispose : () => { });
|
|
545
|
-
appliedByAgent.set(agent, key);
|
|
546
|
-
clearRuntimeNote('mcp-tool-visibility');
|
|
547
|
-
}
|
|
548
|
-
catch (e) {
|
|
549
|
-
// 这个名字在这个 scope 里认不出 / scope 已经收了:可见性半边没生效。执行侧的 guard
|
|
550
|
-
// 仍然拦住调用,所以功能不缺 —— 但必须如实上报,不能假装成功(此前正是静默吞掉)。
|
|
551
|
-
noteRuntime({
|
|
552
|
-
id: 'mcp-tool-visibility',
|
|
553
|
-
label: '停用工具的可见性',
|
|
554
|
-
kind: 'write',
|
|
555
|
-
fallback: 'inform-only',
|
|
556
|
-
detail: '有工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
|
|
557
|
-
detailKey: 'mcp-tool-visibility.partial',
|
|
558
|
-
params: { reason: message(e) },
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
/** agent 上线(`agent/created` 或启动期的 agent 列表):登记并立刻应用当前名单。 */
|
|
563
|
-
function attachAgent(agent) {
|
|
564
|
-
if (!agent || (typeof agent !== 'object' && typeof agent !== 'function'))
|
|
565
|
-
return;
|
|
566
|
-
seenAgents.add(agent);
|
|
567
|
-
void restrictAgent(agent).catch(() => { });
|
|
568
|
-
}
|
|
569
|
-
/** agent 下线(`agent/disposed`):先撤限制再销登记。 */
|
|
570
|
-
function detachAgent(agent) {
|
|
571
|
-
seenAgents.delete(agent);
|
|
572
|
-
liftRestriction(agent);
|
|
573
|
-
}
|
|
574
|
-
async function applyToolRestrictions() {
|
|
575
|
-
if (typeof tools.restrict !== 'function')
|
|
576
|
-
return;
|
|
577
|
-
// Force re-read: this runs on the tools/change path, which is rare, so a
|
|
578
|
-
// stale cache must not keep an externally edited deny list hidden.
|
|
579
|
-
const map = await readDisabledTools(true);
|
|
580
|
-
// 通配 `*`(整服务器停用)先展开成已注册的全名,再与精确名单合并。
|
|
581
|
-
const wanted = disabledToolNames(map);
|
|
582
|
-
let registered;
|
|
583
|
-
try {
|
|
584
|
-
registered = new Set((await tools.schemas()).map((schema) => String(schema.name)));
|
|
585
|
-
}
|
|
586
|
-
catch (e) {
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
for (const [serverName, list] of Object.entries(map)) {
|
|
590
|
-
if (list.indexOf('*') < 0)
|
|
591
|
-
continue;
|
|
592
|
-
const prefix = 'mcp__' + serverName + '__';
|
|
593
|
-
for (const fullName of registered)
|
|
594
|
-
if (fullName.startsWith(prefix))
|
|
595
|
-
wanted.push(fullName);
|
|
596
|
-
}
|
|
597
|
-
// 兼容页「模型工具表」关掉的本插件工具并进同一份名单(理由见 deps.pluginHiddenTools)。
|
|
598
|
-
// 交给上面那个 `registered` 过滤是**必要的**而不是保险:官方对认不出的名字直接抛错,
|
|
599
|
-
// 而关掉的工具里可能有一个这次根本没注册成功(注册失败会被子智能体页的黄条报出来)。
|
|
600
|
-
if (deps.pluginHiddenTools) {
|
|
601
|
-
try {
|
|
602
|
-
for (const name of deps.pluginHiddenTools())
|
|
603
|
-
wanted.push(name);
|
|
604
|
-
}
|
|
605
|
-
catch (e) { /* 读设置失败 = 这次不摘它们 */ }
|
|
606
|
-
}
|
|
607
|
-
const names = [...new Set(wanted)].filter((name) => registered.has(name));
|
|
608
|
-
const key = names.join('\u0000');
|
|
609
|
-
// 有 `carrierHiddenTools` 时不拿全局键短路:那份名单与"官方工具此时挂没挂"有关,
|
|
610
|
-
// 而它变了不会改全局键(官方挂载/卸载本身就发 tools/change,正是靠这条通路追上)。
|
|
611
|
-
// 代价是每次 tools/change 都重算一遍 —— 逐 agent 的键判等会把绝大多数挡在装层之前,
|
|
612
|
-
// 事实读取也有 TTL 缓存;装层本身会再发一次 tools/change,那一轮键相同即收敛,不自激。
|
|
613
|
-
if (key === appliedNamesKey && deps.carrierHiddenTools === undefined)
|
|
614
|
-
return;
|
|
615
|
-
appliedNamesKey = key;
|
|
616
|
-
desiredNames = names;
|
|
617
|
-
// 每次重放先撤掉每个 agent 上的旧层:名单变短(重新启用某个工具)时,只有撤掉这层限制
|
|
618
|
-
// 它才会重新可见;名单变了(含逐 agent 那份)也必须重装而不是叠加。
|
|
619
|
-
epoch += 1;
|
|
620
|
-
for (const agent of [...agentRestrictions.keys()])
|
|
621
|
-
liftRestriction(agent);
|
|
622
|
-
for (const agent of seenAgents)
|
|
623
|
-
void restrictAgent(agent).catch(() => { });
|
|
624
|
-
}
|
|
625
|
-
function scheduleToolRestrictions() {
|
|
626
|
-
if (restrictTimer)
|
|
627
|
-
return;
|
|
628
|
-
restrictTimer = setTimeout(() => {
|
|
629
|
-
restrictTimer = null;
|
|
630
|
-
applyToolRestrictions().catch(() => { });
|
|
631
|
-
}, 300);
|
|
632
|
-
}
|
|
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
|
+
});
|
|
633
398
|
function truncateText(value, limit) {
|
|
634
399
|
if (!limit || value.length <= limit)
|
|
635
400
|
return value;
|
|
@@ -640,16 +405,9 @@ export function createMcpManager(deps) {
|
|
|
640
405
|
// indirections rather than secrets, so they stay readable. URL query strings
|
|
641
406
|
// are redacted because MCP credentials often ride there.
|
|
642
407
|
const SENSITIVE_KEY_RE = /(token|secret|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private[_-]?key|cookie|session|signature|bearer)/i;
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
return '';
|
|
647
|
-
if (text.startsWith('$'))
|
|
648
|
-
return text;
|
|
649
|
-
if (/^!!js\s/.test(text))
|
|
650
|
-
return text;
|
|
651
|
-
return '••••••';
|
|
652
|
-
}
|
|
408
|
+
// 单值打码(`maskSecretValue`)与 URL 打码(`maskUrlQuery`)都挪到 ./secret-guard.js ——
|
|
409
|
+
// 那里是这两条形态的唯一口径,使用方现在有三个(本文件的列表视图 + index.ts 的确认卡
|
|
410
|
+
// + ops/snapshot.ts 的整机迁移导出)。此处不再自建一份。
|
|
653
411
|
function maskValueMap(map, onlySensitiveKeys) {
|
|
654
412
|
if (!map || typeof map !== 'object')
|
|
655
413
|
return map;
|
|
@@ -660,7 +418,7 @@ export function createMcpManager(deps) {
|
|
|
660
418
|
return out;
|
|
661
419
|
}
|
|
662
420
|
// `maskUrlQuery` 已挪到 ./secret-guard.js:那里是两条打码形态的唯一口径,而使用方
|
|
663
|
-
//
|
|
421
|
+
// 现在有三个(本文件的列表视图 + index.ts 的确认卡 + ops/snapshot.ts 的整机迁移导出)。
|
|
664
422
|
/** Shared UI rows: mcpmList plus notes, secrets masked unless `reveal`. */
|
|
665
423
|
async function mcpmRowsWithNotes(reveal) {
|
|
666
424
|
const result = await mcpmList();
|
|
@@ -701,7 +459,7 @@ export function createMcpManager(deps) {
|
|
|
701
459
|
const serverName = String(args && args.serverName || '').trim();
|
|
702
460
|
if (!serverName)
|
|
703
461
|
return { ok: false, error: 'serverName 不能为空' };
|
|
704
|
-
const prefix =
|
|
462
|
+
const prefix = MCP_TOOL_PREFIX + serverName + '__';
|
|
705
463
|
let schemas = [];
|
|
706
464
|
try {
|
|
707
465
|
schemas = await tools.schemas();
|
|
@@ -837,14 +595,17 @@ export function createMcpManager(deps) {
|
|
|
837
595
|
.map((x) => ({ name: String(x.name), ...(typeof x.description === 'string' && x.description ? { description: x.description } : {}) }));
|
|
838
596
|
if (known.length) {
|
|
839
597
|
try {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
598
|
+
// 读-改-写整段在写锁内(审查 P2-15):轮询路径也在回写同一份侧车,
|
|
599
|
+
// 锁外两步会互相覆盖。合并规则(保留旧的描述、新的描述优先)与轮询路径同源。
|
|
600
|
+
await mutateKnownMcpTools((cache) => {
|
|
601
|
+
const byName = new Map((cache[serverName] || []).map((item) => [item.name, item]));
|
|
602
|
+
for (const item of known) {
|
|
603
|
+
const old = byName.get(item.name);
|
|
604
|
+
byName.set(item.name, old && old.description && !item.description ? old : { ...old, ...item });
|
|
605
|
+
}
|
|
606
|
+
cache[serverName] = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
607
|
+
return true;
|
|
608
|
+
});
|
|
848
609
|
}
|
|
849
610
|
catch { /* 缓存写失败不影响本次返回 */ }
|
|
850
611
|
}
|
|
@@ -879,6 +640,34 @@ export function createMcpManager(deps) {
|
|
|
879
640
|
}
|
|
880
641
|
return result;
|
|
881
642
|
}
|
|
643
|
+
/**
|
|
644
|
+
* 已配置的 serverName 名单 —— 只读补丁 + `parseRows`,**不枚举工具 schema、不回写任何缓存**。
|
|
645
|
+
*
|
|
646
|
+
* 与 `configuredServers`(index.ts 的档案引擎依赖)取的是同一份东西,但那个走
|
|
647
|
+
* `mcpmListView()` → `mcpmList()`,会顺带枚举 schema 并回写「已知工具」缓存;切分的候选集合
|
|
648
|
+
* 只需要名字,不该付那份代价与副作用。顺序与 `mcpmList` 一致(project → global)。
|
|
649
|
+
*/
|
|
650
|
+
async function configuredServerNames() {
|
|
651
|
+
const p = await ensurePaths();
|
|
652
|
+
const out = [];
|
|
653
|
+
for (const level of ['project', 'global']) {
|
|
654
|
+
const abs = level === 'project' ? p.projectPatch : p.globalPatch;
|
|
655
|
+
let content = '';
|
|
656
|
+
// 读不到某个层级就当它没有条目(与 `mcpmList` 的处理一致:记进 errors 但不影响另一层)。
|
|
657
|
+
try {
|
|
658
|
+
content = await readPatch(abs);
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
for (const r of parseRows(content).rows) {
|
|
664
|
+
const n = String(normalizeRow(r, level, abs).serverName || '');
|
|
665
|
+
if (n && out.indexOf(n) < 0)
|
|
666
|
+
out.push(n);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return out;
|
|
670
|
+
}
|
|
882
671
|
async function mcpmList() {
|
|
883
672
|
const p = await ensurePaths();
|
|
884
673
|
const rows = [];
|
|
@@ -924,22 +713,99 @@ export function createMcpManager(deps) {
|
|
|
924
713
|
const liveNames = {};
|
|
925
714
|
const liveTools = {};
|
|
926
715
|
const seen = new Set();
|
|
716
|
+
// 切分走 `splitMcpToolName` —— 与执行侧守卫、界面勾选键**同一套规则**(2026-09-30 审查 N1)。
|
|
717
|
+
//
|
|
718
|
+
// 这里以前是贪婪正则 `^mcp__([A-Za-z0-9_-]+)__(.+)$`(等价于"取最后一个 `__`")。它不只是
|
|
719
|
+
// 第三套规则:它算出的键就是 `mcp-known-tools.json` 的键,而那个缓存又会被显示侧当候选集合
|
|
720
|
+
// 读回去 —— 规则不一致会**自证**(切错的键进候选,越切越错),所以必须一起换掉。
|
|
721
|
+
//
|
|
722
|
+
// 候选集合取补丁里的真实 serverName ∪ 停用表键 ∪ 已知缓存键:`rows` 来自补丁,是唯一与切分
|
|
723
|
+
// 结果无关的来源;另两份是插件自己写出来的(见 `configuredServerNames` 的文档)。
|
|
724
|
+
const splitCandidates = serverNameCandidates(rows.map((r) => String((r && r.serverName) || '')).filter(Boolean), disabledMap, knownCache);
|
|
725
|
+
// 前缀漂移的线索(审查 §5 F6):判据与理由见 `host-names.ts` 的
|
|
726
|
+
// `looksLikeMcpPrefixDrift` —— 关键是**排除 `mcp_` 开头的那一族**(本插件自己的
|
|
727
|
+
// `mcp_manager_*` 就在里面,不排除的话这条上报每台机器都会误报)。
|
|
728
|
+
const mcpStemOther = [];
|
|
927
729
|
for (const s of schemas) {
|
|
928
730
|
const fullName = String(s && s.name || '');
|
|
929
731
|
if (seen.has(fullName))
|
|
930
732
|
continue;
|
|
931
733
|
seen.add(fullName);
|
|
932
|
-
|
|
933
|
-
|
|
734
|
+
if (looksLikeMcpPrefixDrift(fullName) && mcpStemOther.length < 3)
|
|
735
|
+
mcpStemOther.push(fullName);
|
|
736
|
+
const parts = splitMcpToolName(fullName, splitCandidates);
|
|
737
|
+
// serverName 的合法形态与 `mcpm-set-tool-enabled` / `mcpm-edit` 的校验同口径:不合法的一律
|
|
738
|
+
// 不进表(旧正则的 `[A-Za-z0-9_-]+` 就是这个意思,这里保留,免得凭空多出服务器行)。
|
|
739
|
+
if (!parts || !/^[A-Za-z0-9_-]{1,32}$/.test(parts.server))
|
|
934
740
|
continue;
|
|
935
|
-
const list = liveNames[
|
|
936
|
-
if (list.indexOf(
|
|
937
|
-
list.push(
|
|
741
|
+
const list = liveNames[parts.server] || (liveNames[parts.server] = []);
|
|
742
|
+
if (list.indexOf(parts.tool) < 0)
|
|
743
|
+
list.push(parts.tool);
|
|
938
744
|
// 顺手把描述记下来:未运行时详情页就有东西可显示,不必再重复「描述暂不可用」。
|
|
939
|
-
const bucket = liveTools[
|
|
745
|
+
const bucket = liveTools[parts.server] || (liveTools[parts.server] = []);
|
|
940
746
|
const desc = typeof s.description === 'string' ? s.description : '';
|
|
941
|
-
if (!bucket.some((t) => t.name ===
|
|
942
|
-
bucket.push({ name:
|
|
747
|
+
if (!bucket.some((t) => t.name === parts.tool))
|
|
748
|
+
bucket.push({ name: parts.tool, ...(desc ? { description: desc } : {}) });
|
|
749
|
+
}
|
|
750
|
+
// 前缀契约的 inform-only 上报(审查 §5 F6):宿主面上出现了词根后换了分隔符的 `mcp*` 工具名,
|
|
751
|
+
// 同时按本插件认的前缀一条都筛不出来 —— 大概率官方改了 MCP 工具的命名前缀。这时停用表 /
|
|
752
|
+
// 执行侧门禁 / 工具计数会**一起静默失明**(守卫认为"这工具不在我的表里",于是放行),
|
|
753
|
+
// 而这是纯字符串契约、没有能力探测能提前发现。
|
|
754
|
+
// 证据门槛刻意收紧(见上):`mcp_` 开头的一族不算证据,且要**至少两个**同名根的工具
|
|
755
|
+
// (改前缀会重命名全部 MCP 工具,孤零零一个像模像样的名字不足为凭)。条件不成立就清掉,
|
|
756
|
+
// 避免陈旧告警。
|
|
757
|
+
if (mcpStemOther.length >= 2 && Object.keys(liveNames).length === 0) {
|
|
758
|
+
noteRuntime({
|
|
759
|
+
id: 'mcp-tool-prefix',
|
|
760
|
+
label: 'MCP 工具命名前缀',
|
|
761
|
+
kind: 'read',
|
|
762
|
+
fallback: 'inform-only',
|
|
763
|
+
detail: `宿主工具表里有多个以 mcp 开头、但用的不是 \`${MCP_TOOL_PREFIX}\` 的工具名(如 ${mcpStemOther.join('、')}),而按本插件认的前缀一条都筛不出来:官方可能改了 MCP 工具的命名前缀。此时停用表、执行侧门禁与工具计数都会失明(守卫会放行它以为"不在表里"的工具)。`,
|
|
764
|
+
detailKey: 'mcp-tool-prefix.mismatch',
|
|
765
|
+
params: { prefix: MCP_TOOL_PREFIX, samples: mcpStemOther.join('、') },
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
clearRuntimeNote('mcp-tool-prefix');
|
|
770
|
+
}
|
|
771
|
+
// 命名歧义的 inform-only 上报(2026-09-30 审查 N2):`mcp__<server>__<tool>` 这个契约
|
|
772
|
+
// 对 `__` 本身有歧义,而 `serverName` 的校验(`/^[A-Za-z0-9_-]{1,32}$/`)**允许含 `__`**。
|
|
773
|
+
// 两种形状会让切分变成"猜":① 某个 serverName 自己含 `__`;② 两个 serverName 互为 `__`
|
|
774
|
+
// 前缀(`a` 与 `a__b`)—— 全名 `mcp__a__b__c` 两种归属都讲得通。
|
|
775
|
+
//
|
|
776
|
+
// **只上报、不改行为**:改校验会把存量服务器从"能管"变成"管不了"(计划 §4 未决 1 的
|
|
777
|
+
// 裁定),而这里要的只是"切不准时出声"。判据本身保证没有这两种形状时一条都不报
|
|
778
|
+
// (`ambiguousServerNames`),条件不成立就清掉,避免陈旧告警 —— 与上面那条同一条纪律。
|
|
779
|
+
// 两条互斥、取**更具体**的那条,永远只出一行:
|
|
780
|
+
// 「互为前缀」必然蕴含「含 `__`」(长的那半就是 `短名 + '__' + 余下`),两条一起报
|
|
781
|
+
// 就是同一件事说两遍。而「互为前缀」更具体 —— 它直接点名了是哪两个名字撞在一起。
|
|
782
|
+
// 反过来「含 `__`」能覆盖"一个前缀对都凑不成"的单名字情形。两边都不成立就都清掉。
|
|
783
|
+
const ambiguous = ambiguousServerNames(splitCandidates);
|
|
784
|
+
const prefixPairs = ambiguous.prefixPairs.map(([a, b]) => `${a} / ${b}`).join('、');
|
|
785
|
+
if (ambiguous.prefixPairs.length) {
|
|
786
|
+
noteRuntime({
|
|
787
|
+
id: 'mcp-server-name-ambiguous',
|
|
788
|
+
label: 'MCP 服务器名互为 `__` 前缀',
|
|
789
|
+
kind: 'read',
|
|
790
|
+
fallback: 'inform-only',
|
|
791
|
+
detail: `这些 MCP 服务器名互为 \`__\` 前缀关系(${prefixPairs}):例如 \`mcp__a__b__c\` 既可以读成 \`a\` 的 \`b__c\`,也可以读成 \`a__b\` 的 \`c\`,界面上的勾选可能落不到停用表里那条键上(工具本身照常可用)。改名可消除;不改也能用。`,
|
|
792
|
+
detailKey: 'mcp-server-name-ambiguous.prefix',
|
|
793
|
+
params: { pairs: prefixPairs },
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
else if (ambiguous.withSeparator.length) {
|
|
797
|
+
noteRuntime({
|
|
798
|
+
id: 'mcp-server-name-ambiguous',
|
|
799
|
+
label: 'MCP 服务器名含 `__`',
|
|
800
|
+
kind: 'read',
|
|
801
|
+
fallback: 'inform-only',
|
|
802
|
+
detail: `这些 MCP 服务器名里含 \`__\`(${ambiguous.withSeparator.join('、')}):工具全名 \`mcp__<server>__<tool>\` 因此切不唯一,界面上的勾选可能落不到停用表里那条键上(工具本身照常可用)。改名可消除;不改也能用。`,
|
|
803
|
+
detailKey: 'mcp-server-name-ambiguous.separator',
|
|
804
|
+
params: { names: ambiguous.withSeparator.join('、') },
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
clearRuntimeNote('mcp-server-name-ambiguous');
|
|
943
809
|
}
|
|
944
810
|
// 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
|
|
945
811
|
// 也能显示最后一次见过的工具数,而不是永远 0。
|
|
@@ -980,29 +846,11 @@ export function createMcpManager(deps) {
|
|
|
980
846
|
knownToolCounts[server] = list.length;
|
|
981
847
|
// 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
|
|
982
848
|
// 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
}
|
|
849
|
+
// 先在本地这份上跑一遍只为**判断要不要写盘**;真正落盘的那次合并发生在锁内、对象是
|
|
850
|
+
// 刚读出来的新鲜表(审查 P2-15:临时启动路径也在写同一份侧车,锁外两步会互相覆盖)。
|
|
851
|
+
const cacheChanged = mergeLiveToolsInto(knownCache, liveTools);
|
|
1004
852
|
if (cacheChanged)
|
|
1005
|
-
void
|
|
853
|
+
void mutateKnownMcpTools((fresh) => mergeLiveToolsInto(fresh, liveTools)).catch(() => { });
|
|
1006
854
|
}
|
|
1007
855
|
catch (e) { /* ignore */ }
|
|
1008
856
|
let live = [];
|
|
@@ -1108,7 +956,14 @@ export function createMcpManager(deps) {
|
|
|
1108
956
|
const url = String(args.url || '').trim();
|
|
1109
957
|
if (!/^https?:\/\//.test(url))
|
|
1110
958
|
return { ok: false, error: 'url 需为 http(s):// 开头的地址' };
|
|
1111
|
-
|
|
959
|
+
// 新增条目**没有旧值可顶替** —— 打码 URL 一律拒绝,让用户重填完整地址(判据见
|
|
960
|
+
// ./mcp/secret-guard.js)。原来这里直接 `row.url = url`,于是「把列表里那条打码 URL
|
|
961
|
+
// 复制到新增表单」就会把凭据永久写成 `%3Credacted%3E`,界面还显示成功(审查 F4)。
|
|
962
|
+
const urlGuard = resolveMaskedUrl(url, null);
|
|
963
|
+
if (urlGuard.unrecoverable) {
|
|
964
|
+
return { ok: false, error: 'URL 里的 userinfo / 查询串 / 片段是打码占位符(新增条目没有原值可恢复):请重新填写完整 URL 后再保存。' };
|
|
965
|
+
}
|
|
966
|
+
row.url = urlGuard.value;
|
|
1112
967
|
row.headers = sanitize(parseKv(args.headers));
|
|
1113
968
|
}
|
|
1114
969
|
else {
|
|
@@ -1246,7 +1101,7 @@ export function createMcpManager(deps) {
|
|
|
1246
1101
|
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
|
|
1247
1102
|
}
|
|
1248
1103
|
catch (e) { /* non-fatal */ }
|
|
1249
|
-
|
|
1104
|
+
setDisabledToolsCache(toolMap);
|
|
1250
1105
|
}
|
|
1251
1106
|
// F-025:运行时快照的 mcp 停用表同样按 serverName 键 —— 改名不跟着改,
|
|
1252
1107
|
// 退出场景整体回写时旧键成死键、新名工具的停用状态丢失(回到全启用)。
|
|
@@ -1395,14 +1250,17 @@ export function createMcpManager(deps) {
|
|
|
1395
1250
|
return { ok: true, enabled, changed };
|
|
1396
1251
|
});
|
|
1397
1252
|
}
|
|
1253
|
+
/** 一份补丁内容里某个 id 的**生效**启停值(insert 行自带的 flag 与顶层覆盖块合并后)。 */
|
|
1254
|
+
function effectiveDisabledIn(content, id) {
|
|
1255
|
+
const row = parseRows(content).rows.find((r) => r.id === id);
|
|
1256
|
+
return row ? !!row.disabled : false;
|
|
1257
|
+
}
|
|
1398
1258
|
/** Effective disabled state of a patch row (insert-row flag merged with override blocks). */
|
|
1399
1259
|
async function isRowDisabled(id, level) {
|
|
1400
1260
|
const p = await ensurePaths();
|
|
1401
1261
|
const abs = level === 'global' ? p.globalPatch : p.projectPatch;
|
|
1402
1262
|
try {
|
|
1403
|
-
|
|
1404
|
-
const row = rows.find((r) => r.id === id);
|
|
1405
|
-
return row ? !!row.disabled : false;
|
|
1263
|
+
return effectiveDisabledIn(await readPatch(abs), id);
|
|
1406
1264
|
}
|
|
1407
1265
|
catch (e) {
|
|
1408
1266
|
return false;
|
|
@@ -1466,8 +1324,14 @@ export function createMcpManager(deps) {
|
|
|
1466
1324
|
}
|
|
1467
1325
|
let c = await readPatch(abs);
|
|
1468
1326
|
c = removeMarked(c, id, 'disable');
|
|
1469
|
-
|
|
1470
|
-
|
|
1327
|
+
c = removeMarked(c, id, 'enable');
|
|
1328
|
+
// 覆盖块全删掉之后,生效值**回落**到 insert 行自带的 `disabled:`,而它与「重启前的
|
|
1329
|
+
// 生效状态」不一定相同 —— insert 行写着 `disabled: true`、靠一条 enable 覆盖块启用,
|
|
1330
|
+
// 就是这种形状。此前只在 `wasDisabled` 为真时补一条 disable,于是这种形状下重启会把
|
|
1331
|
+
// 服务器**留在停用态**(2026-09-30 审查 P2-11)。所以这里比一次:不等就按 `wasDisabled`
|
|
1332
|
+
// 显式补一条覆盖;相等就不补 —— 补了会让每次重启都往补丁里多留一个块,没必要。
|
|
1333
|
+
if (effectiveDisabledIn(c, id) !== wasDisabled)
|
|
1334
|
+
c = appendBlock(c, buildDisableBlock(id, wasDisabled));
|
|
1471
1335
|
await writePatch(abs, c);
|
|
1472
1336
|
}).catch((e) => {
|
|
1473
1337
|
warnings.push('恢复重启前状态失败(该服务可能停留在停用态):' + message(e));
|
|
@@ -1574,7 +1438,7 @@ export function createMcpManager(deps) {
|
|
|
1574
1438
|
if (Object.prototype.hasOwnProperty.call(toolMap, serverName)) {
|
|
1575
1439
|
delete toolMap[serverName];
|
|
1576
1440
|
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
|
|
1577
|
-
|
|
1441
|
+
setDisabledToolsCache(toolMap);
|
|
1578
1442
|
await applyToolRestrictions();
|
|
1579
1443
|
}
|
|
1580
1444
|
const knownMap = Object.assign({}, await readKnownMcpTools(true));
|
|
@@ -1655,189 +1519,14 @@ export function createMcpManager(deps) {
|
|
|
1655
1519
|
return { ok: true, removed: total, ids: removed, files };
|
|
1656
1520
|
});
|
|
1657
1521
|
}
|
|
1658
|
-
// ----------
|
|
1659
|
-
//
|
|
1660
|
-
//
|
|
1661
|
-
//
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
//
|
|
1666
|
-
const INSPECT_PATH_TIMEOUT_MS = 3000;
|
|
1667
|
-
/** 命令是否在 PATH 上:`unknown`(查不了)与 `missing`(确认没有)必须分开 —— 前者不是问题。 */
|
|
1668
|
-
async function probeCommandOnPath(command) {
|
|
1669
|
-
if (!command)
|
|
1670
|
-
return 'unknown';
|
|
1671
|
-
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
1672
|
-
return await new Promise((resolve) => {
|
|
1673
|
-
execFile(finder, [command], { timeout: INSPECT_PATH_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
|
|
1674
|
-
if (!error) {
|
|
1675
|
-
resolve(String(stdout || '').trim() ? 'ok' : 'missing');
|
|
1676
|
-
return;
|
|
1677
|
-
}
|
|
1678
|
-
// ENOENT = 连 where/which 自己都没跑到;killed = 超时。两种都是「无法确认」,
|
|
1679
|
-
// 剩下的(退出码非 0)才是「PATH 上没有这条命令」。
|
|
1680
|
-
const code = error.code;
|
|
1681
|
-
if (code === 'ENOENT' || error.killed === true)
|
|
1682
|
-
resolve('unknown');
|
|
1683
|
-
else
|
|
1684
|
-
resolve('missing');
|
|
1685
|
-
});
|
|
1686
|
-
});
|
|
1687
|
-
}
|
|
1688
|
-
/** streamable-http 连通性探测的超时:再长,「全部检查」就会被一台挂掉的服务拖住。 */
|
|
1689
|
-
const INSPECT_PROBE_TIMEOUT_MS = 5000;
|
|
1690
|
-
/** 探测失败的原因短语(进词典模板的 {reason}):认得出常见错误码就说人话,认不出给原文截断。 */
|
|
1691
|
-
function describeProbeFailure(e) {
|
|
1692
|
-
const err = e;
|
|
1693
|
-
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
1694
|
-
return '探测超时(' + Math.round(INSPECT_PROBE_TIMEOUT_MS / 1000) + ' 秒无响应)';
|
|
1695
|
-
}
|
|
1696
|
-
const code = err && err.cause && err.cause.code;
|
|
1697
|
-
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
|
|
1698
|
-
return '域名解析失败(' + code + ')';
|
|
1699
|
-
if (code === 'ECONNREFUSED')
|
|
1700
|
-
return '连接被拒绝(' + code + ')';
|
|
1701
|
-
if (code === 'ETIMEDOUT')
|
|
1702
|
-
return '连接超时(' + code + ')';
|
|
1703
|
-
if (code === 'ECONNRESET')
|
|
1704
|
-
return '连接被重置(' + code + ')';
|
|
1705
|
-
if (typeof code === 'string' && /CERT|SSL|TLS/.test(code))
|
|
1706
|
-
return 'TLS 证书校验失败(' + code + ')';
|
|
1707
|
-
const text = String((err && err.cause && err.cause.message) || (err && err.message) || e || '');
|
|
1708
|
-
return text.length > 80 ? text.slice(0, 77) + '…' : text;
|
|
1709
|
-
}
|
|
1710
|
-
/**
|
|
1711
|
-
* streamable-http 的连通性探测:对配置地址 GET 一次,**任何** HTTP 响应(含 404/405)
|
|
1712
|
-
* 都算「可达」—— 探测只回答"这个地址现在连不连得上",不校验它是不是 MCP 服务。
|
|
1713
|
-
* 不读响应体:streamable-http 对 GET 常回一条 SSE 长连接,拿到状态行就取消。
|
|
1714
|
-
*/
|
|
1715
|
-
async function probeHttpEndpoint(url, headers) {
|
|
1716
|
-
const extra = {};
|
|
1717
|
-
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
|
1718
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
1719
|
-
if (typeof v === 'string' && v)
|
|
1720
|
-
extra[k] = v;
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
try {
|
|
1724
|
-
const res = await fetch(url, { method: 'GET', headers: extra, redirect: 'follow', signal: AbortSignal.timeout(INSPECT_PROBE_TIMEOUT_MS) });
|
|
1725
|
-
try {
|
|
1726
|
-
await res.body?.cancel();
|
|
1727
|
-
}
|
|
1728
|
-
catch { /* 取消失败不影响「可达」结论 */ }
|
|
1729
|
-
return { reachable: true };
|
|
1730
|
-
}
|
|
1731
|
-
catch (e) {
|
|
1732
|
-
return { reachable: false, reason: describeProbeFailure(e) };
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
/** 一行配置 → 检查项。`level: 'warn'` 才点黄;`info` 是「查不了 / 不算问题」那类说明。 */
|
|
1736
|
-
async function inspectChecks(row, duplicateIds) {
|
|
1737
|
-
const checks = [];
|
|
1738
|
-
const declared = String(row.transport || '');
|
|
1739
|
-
const url = String(row.url || '');
|
|
1740
|
-
const command = String(row.command || '');
|
|
1741
|
-
// transport 缺失/不认识时按字段形状推:历史上手工改过补丁文件的行会绕过 normalize,
|
|
1742
|
-
// 直接判「没有 transport」会把一条其实能用的配置说成坏的。
|
|
1743
|
-
const transport = declared === 'stdio' || (!declared && command && !url) ? 'stdio'
|
|
1744
|
-
: declared === 'streamable-http' || (!declared && url) ? 'streamable-http'
|
|
1745
|
-
: declared;
|
|
1746
|
-
if (!declared && transport)
|
|
1747
|
-
checks.push({ id: 'transportMissing', level: 'info' });
|
|
1748
|
-
/** 值必须是字符串映射:非对象整片算坏,对象里逐个挑出非字符串的键。 */
|
|
1749
|
-
const badKv = (map) => {
|
|
1750
|
-
if (map == null)
|
|
1751
|
-
return [];
|
|
1752
|
-
if (typeof map !== 'object' || Array.isArray(map))
|
|
1753
|
-
return ['*'];
|
|
1754
|
-
return Object.keys(map).filter((k) => typeof map[k] !== 'string');
|
|
1755
|
-
};
|
|
1756
|
-
if (transport === 'stdio') {
|
|
1757
|
-
if (!command)
|
|
1758
|
-
checks.push({ id: 'noCommand', level: 'warn' });
|
|
1759
|
-
else {
|
|
1760
|
-
// 唯一一次外部调用。npx / uvx 这类 shim 找不到时界面给的下半句是「shim 属正常可忽略」,
|
|
1761
|
-
// 不让用户以为命令坏了。
|
|
1762
|
-
const found = await probeCommandOnPath(command);
|
|
1763
|
-
if (found === 'missing')
|
|
1764
|
-
checks.push({ id: 'cmdMissing', level: 'warn', params: { command } });
|
|
1765
|
-
else if (found === 'unknown')
|
|
1766
|
-
checks.push({ id: 'cmdUnknown', level: 'info', params: { command } });
|
|
1767
|
-
}
|
|
1768
|
-
if (row.args != null && !Array.isArray(row.args))
|
|
1769
|
-
checks.push({ id: 'argsNotArray', level: 'warn' });
|
|
1770
|
-
const badEnv = badKv(row.env);
|
|
1771
|
-
if (badEnv.length)
|
|
1772
|
-
checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'env', keys: badEnv.join('、') } });
|
|
1773
|
-
}
|
|
1774
|
-
else if (transport === 'streamable-http') {
|
|
1775
|
-
if (!url)
|
|
1776
|
-
checks.push({ id: 'noUrl', level: 'warn' });
|
|
1777
|
-
else if (!/^https?:\/\//.test(url))
|
|
1778
|
-
checks.push({ id: 'badUrl', level: 'warn', params: { url } });
|
|
1779
|
-
else {
|
|
1780
|
-
// 格式对了再做一次真实探测(0.16.5):此前的体检止步于格式校验,一条编造的
|
|
1781
|
-
// 地址也能拿绿点 —— 绿点承诺的「没发现问题」其实只覆盖了一半(2026-09-29 实测)。
|
|
1782
|
-
const probe = await probeHttpEndpoint(url, row.headers);
|
|
1783
|
-
if (!probe.reachable)
|
|
1784
|
-
checks.push({ id: 'httpUnreachable', level: 'warn', params: { url, reason: probe.reason } });
|
|
1785
|
-
}
|
|
1786
|
-
const badHeaders = badKv(row.headers);
|
|
1787
|
-
if (badHeaders.length)
|
|
1788
|
-
checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'headers', keys: badHeaders.join('、') } });
|
|
1789
|
-
}
|
|
1790
|
-
else if (!command && !url) {
|
|
1791
|
-
checks.push({ id: 'noEndpoint', level: 'warn' });
|
|
1792
|
-
}
|
|
1793
|
-
else {
|
|
1794
|
-
checks.push({ id: 'badTransport', level: 'warn', params: { transport } });
|
|
1795
|
-
}
|
|
1796
|
-
if (duplicateIds > 1)
|
|
1797
|
-
checks.push({ id: 'nameDup', level: 'warn', params: { name: String(row.serverName || ''), count: duplicateIds } });
|
|
1798
|
-
return checks;
|
|
1799
|
-
}
|
|
1800
|
-
async function mcpmInspect(args) {
|
|
1801
|
-
const p = await ensurePaths();
|
|
1802
|
-
const rows = [];
|
|
1803
|
-
const errors = [];
|
|
1804
|
-
for (const level of ['project', 'global']) {
|
|
1805
|
-
const abs = level === 'project' ? p.projectPatch : p.globalPatch;
|
|
1806
|
-
let content = '';
|
|
1807
|
-
try {
|
|
1808
|
-
content = await readPatch(abs);
|
|
1809
|
-
}
|
|
1810
|
-
catch (e) {
|
|
1811
|
-
errors.push(level + ': ' + message(e));
|
|
1812
|
-
continue;
|
|
1813
|
-
}
|
|
1814
|
-
const { rows: fileRows } = parseRows(content);
|
|
1815
|
-
for (const r of fileRows)
|
|
1816
|
-
rows.push(normalizeRow(r, level, abs));
|
|
1817
|
-
}
|
|
1818
|
-
// 重名判据:同一个 serverName 挂在**不同 id** 下。同 id 跨层是正常遮蔽(界面已有「重复 id」标签),
|
|
1819
|
-
// 而按名字注册的 `mcp__<serverName>__*` 会撞车 —— 那才是配置问题。
|
|
1820
|
-
const idsByName = {};
|
|
1821
|
-
for (const r of rows) {
|
|
1822
|
-
const n = String(r.serverName || '');
|
|
1823
|
-
if (!idsByName[n])
|
|
1824
|
-
idsByName[n] = new Set();
|
|
1825
|
-
idsByName[n].add(String(r.id));
|
|
1826
|
-
}
|
|
1827
|
-
const onlyId = args && String(args.id || '') ? String(args.id) : '';
|
|
1828
|
-
const onlyLevel = args && String(args.level || '') ? String(args.level) : '';
|
|
1829
|
-
// 逐行检查并行跑:体检含 streamable-http 的端点探测(每次至多 5 秒超时),串行会让
|
|
1830
|
-
// 「全部检查」在多台同时连不上时按行数翻倍地慢。Promise.all 保序,与旧实现一致。
|
|
1831
|
-
const targets = rows.filter((r) => (!onlyId || String(r.id) === onlyId) && (!onlyLevel || String(r.level) === onlyLevel));
|
|
1832
|
-
const results = await Promise.all(targets.map(async (r) => ({
|
|
1833
|
-
id: r.id,
|
|
1834
|
-
serverName: r.serverName,
|
|
1835
|
-
level: r.level,
|
|
1836
|
-
transport: r.transport || null,
|
|
1837
|
-
checks: await inspectChecks(r, (idsByName[String(r.serverName || '')] || new Set()).size),
|
|
1838
|
-
})));
|
|
1839
|
-
return { ok: true, results, errors };
|
|
1840
|
-
}
|
|
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
|
+
// ---------- 配置的导出 / 导入 ----------
|
|
1841
1530
|
async function mcpmExport() {
|
|
1842
1531
|
const p = await ensurePaths();
|
|
1843
1532
|
const list = await mcpmList();
|
|
@@ -1853,11 +1542,19 @@ export function createMcpManager(deps) {
|
|
|
1853
1542
|
level: r.level,
|
|
1854
1543
|
disabled: r.disabled,
|
|
1855
1544
|
}));
|
|
1856
|
-
const
|
|
1545
|
+
const payload = { exportedAt: new Date().toISOString(), rows };
|
|
1546
|
+
const json = JSON.stringify(payload, null, 2);
|
|
1857
1547
|
let savedTo = null;
|
|
1858
1548
|
try {
|
|
1859
1549
|
const abs = mcpSidecar(MCP_EXPORT_FILE);
|
|
1860
|
-
|
|
1550
|
+
// 用 `writeJsonFile`(直写 hub 侧车)而**不是** `writePatch`(2026-09-30 审查 P1-3):
|
|
1551
|
+
// `writePatch` 是宿主**补丁文件**的写口,写前会跑 `checkPatchWrite` 的官方方言校验
|
|
1552
|
+
// (顶层必须是 YAML 数组),而这里写的是 hub 里一份 JSON **对象** —— 校验必然失败、
|
|
1553
|
+
// 异常被下面的 catch 吞掉,于是 `savedTo` 恒为 null,界面显示"没能落盘(hub 目录写入
|
|
1554
|
+
// 失败)",**诊断是错的**(真实原因是方言校验器拒收 JSON 对象)。而且 `writePatch`
|
|
1555
|
+
// 自己的调用面纪律(`index.ts`:「不写宿主配置的动作也不该调用它」)本就排除了它 ——
|
|
1556
|
+
// 本处是唯一一个传 hub 路径的调用点。顺带省掉一次 `danger-full-access` 策略升级。
|
|
1557
|
+
await writeJsonFile(abs, payload);
|
|
1861
1558
|
savedTo = abs;
|
|
1862
1559
|
}
|
|
1863
1560
|
catch (e) { /* non-fatal */ }
|
|
@@ -1903,11 +1600,16 @@ export function createMcpManager(deps) {
|
|
|
1903
1600
|
const nameTaken = byName.has(row.serverName);
|
|
1904
1601
|
// 打码值不能入库(判据见 ./mcp/secret-guard.js):覆盖模式下拿被覆盖条目在补丁里的
|
|
1905
1602
|
// 旧真值顶替;非覆盖模式没有旧值可比,走"丢弃"分支。
|
|
1603
|
+
//
|
|
1604
|
+
// `prevConfig` 同时认两种形状:`collectAll()` 出来的行把配置挂在 `config` 下,
|
|
1605
|
+
// 而同一个批次里**先前刚导入**的行(`byId.set(row.id, row)`)是 `mcpmAdd` 那种扁平形状。
|
|
1606
|
+
// 少认一种,同批里重复 id 的那条就拿不到旧值、被静默丢弃。
|
|
1906
1607
|
const prevRow = idTaken ? byId.get(row.id) : null;
|
|
1608
|
+
const prevConfig = prevRow ? (prevRow.config || prevRow) : {};
|
|
1907
1609
|
for (const field of ['env', 'headers']) {
|
|
1908
1610
|
if (row[field] === undefined)
|
|
1909
1611
|
continue;
|
|
1910
|
-
const outcome = resolveMaskedKv(row[field],
|
|
1612
|
+
const outcome = resolveMaskedKv(row[field], prevConfig[field]);
|
|
1911
1613
|
row[field] = outcome.value;
|
|
1912
1614
|
const note = describeMaskedOutcome(outcome);
|
|
1913
1615
|
if (note)
|
|
@@ -1923,6 +1625,21 @@ export function createMcpManager(deps) {
|
|
|
1923
1625
|
skipped.push({ id: row.id, reason: 'id 已存在' });
|
|
1924
1626
|
continue;
|
|
1925
1627
|
}
|
|
1628
|
+
// URL 与 env / headers 同一条纪律(判据见 ./mcp/secret-guard.js):打码值绝不入库。
|
|
1629
|
+
// 覆盖模式下拿被覆盖条目的旧真值顶替;没有旧真值可顶替时**跳过这一条**并给出理由 ——
|
|
1630
|
+
// URL 不是可丢的键值对,写一个"能连上但鉴权失败"的地址比不导入更糟。
|
|
1631
|
+
// 这条原先不存在,于是 `snapshot-export`(默认 `includeSecrets=false` 会把 URL 打码)
|
|
1632
|
+
// 导到目标机后,补丁里落进 `%3Credacted%3E` 而**没有任何 warning**(审查 F4)。
|
|
1633
|
+
if (row.transport === 'streamable-http') {
|
|
1634
|
+
const urlGuard = resolveMaskedUrl(row.url, prevConfig.url);
|
|
1635
|
+
if (urlGuard.unrecoverable) {
|
|
1636
|
+
skipped.push({ id: row.id, reason: 'URL 里的 userinfo / 查询串 / 片段是打码占位符,且没有原值可恢复:请重填完整 URL 后再导入' });
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
row.url = urlGuard.value;
|
|
1640
|
+
if (urlGuard.restored)
|
|
1641
|
+
importNotes.push(row.serverName + ':URL 未改动,已保留原值');
|
|
1642
|
+
}
|
|
1926
1643
|
// Overwrite: purge every trace of the id from BOTH patch files first,
|
|
1927
1644
|
// then insert the imported row at its own level. The purge is not
|
|
1928
1645
|
// atomic with the insert, so keep the pre-purge content of both files
|
|
@@ -2031,7 +1748,7 @@ export function createMcpManager(deps) {
|
|
|
2031
1748
|
await deps.withWriteLock(async () => {
|
|
2032
1749
|
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), entries);
|
|
2033
1750
|
});
|
|
2034
|
-
|
|
1751
|
+
setDisabledToolsCache(entries);
|
|
2035
1752
|
scheduleToolRestrictions();
|
|
2036
1753
|
}
|
|
2037
1754
|
/**
|
|
@@ -2052,18 +1769,9 @@ export function createMcpManager(deps) {
|
|
|
2052
1769
|
await readDisabledTools();
|
|
2053
1770
|
await applyToolRestrictions();
|
|
2054
1771
|
}
|
|
2055
|
-
/**
|
|
1772
|
+
/** 卸载清理(那六行随可见性段搬进 tool-visibility.ts,这里只留出口接线)。 */
|
|
2056
1773
|
function dispose() {
|
|
2057
|
-
|
|
2058
|
-
clearTimeout(restrictTimer);
|
|
2059
|
-
restrictTimer = null;
|
|
2060
|
-
}
|
|
2061
|
-
// 插件卸载时把挂在每个 agent scope 上的限制都撤掉(否则那些 scope 会继续背着一层名单)。
|
|
2062
|
-
epoch += 1;
|
|
2063
|
-
for (const agent of [...agentRestrictions.keys()])
|
|
2064
|
-
liftRestriction(agent);
|
|
2065
|
-
appliedByAgent.clear();
|
|
2066
|
-
seenAgents.clear();
|
|
1774
|
+
disposeVisibility();
|
|
2067
1775
|
}
|
|
2068
1776
|
return {
|
|
2069
1777
|
stateCatalog: mcpStateCatalog,
|
|
@@ -2092,12 +1800,13 @@ export function createMcpManager(deps) {
|
|
|
2092
1800
|
writeDisabledTools,
|
|
2093
1801
|
updateNotes,
|
|
2094
1802
|
mcpmListView,
|
|
1803
|
+
configuredServerNames,
|
|
2095
1804
|
mcpmRowsWithNotes,
|
|
2096
1805
|
readPluginSettings,
|
|
2097
|
-
isToolDisabled: (name) => isToolDisabledIn(
|
|
1806
|
+
isToolDisabled: (name) => isToolDisabledIn(disabledToolsSnapshot(), name),
|
|
2098
1807
|
/** 停用表的两个口径(读 TTL 缓存;功能总览用):`*` 通配的台数 + 单独停用的工具数。 */
|
|
2099
1808
|
disabledToolSummary: () => {
|
|
2100
|
-
const map =
|
|
1809
|
+
const map = disabledToolsSnapshot();
|
|
2101
1810
|
let servers = 0;
|
|
2102
1811
|
let tools = 0;
|
|
2103
1812
|
for (const list of Object.values(map)) {
|