dsh-plugin-tool-management 0.19.0 → 0.20.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 (47) hide show
  1. package/CHANGELOG.md +868 -798
  2. package/README.md +71 -59
  3. package/README_EN.md +91 -80
  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/images/logo.png +0 -0
  21. package/docs/images/promo-cover-EN.png +0 -0
  22. package/docs/images/promo-cover.png +0 -0
  23. package/docs/update.md +31 -520
  24. package/lib/client.js +8308 -6307
  25. package/lib/hub.js +96 -6
  26. package/lib/index.js +54 -2
  27. package/lib/mcp/manager.js +24 -13
  28. package/lib/mcp/secret-guard.js +2 -2
  29. package/lib/memories/constants.js +9 -2
  30. package/lib/memories/service.js +6 -1
  31. package/lib/op-registry.js +22 -7
  32. package/lib/ops/prompts.js +64 -7
  33. package/lib/ops/sessions.js +62 -7
  34. package/lib/ops/trash-retention.js +55 -0
  35. package/lib/ops/trash.js +62 -3
  36. package/lib/prompts/service.js +191 -25
  37. package/lib/scene-prompt-sync.js +1 -1
  38. package/lib/sessions/history.js +32 -22
  39. package/lib/skills/core.js +68 -2
  40. package/lib/skills/manager-state.js +32 -1
  41. package/lib/skills/service.js +7 -1
  42. package/lib/tools/mcp.js +6 -0
  43. package/lib/tools/prompt.js +14 -2
  44. package/lib/tools/scene.js +146 -25
  45. package/lib/trash-retention.js +256 -0
  46. package/package.json +1 -1
  47. package/lib/mcp/inspect-probe.js +0 -199
@@ -0,0 +1,256 @@
1
+ // 回收站保留期(0.19.1)—— 超过保留期的回收站条目由清扫**自动永久删除**。
2
+ //
3
+ // 为什么要有它:回收站此前只有「进来」没有「出去」—— 除了用户一条条点「永久删除」,
4
+ // 没有任何自动清空的开关。删掉一个场景、一批技能、一堆记忆之后,条目会一直躺在磁盘上。
5
+ // History(归档会话)页早就有这套(`hub/history-retention.json` + `sweepHistory`),
6
+ // 这里**刻意同形**:同一个 `{retentionDays, updatedAt}` 侧车形状、同一条到期判据、同一个
7
+ // 「改设置即重置倒计时」语义。两处口径一旦分家,用户在两个页面看到的就是两套话术。
8
+ //
9
+ // 覆盖六类:hub 五类(`trash/<域>-trash/<id>/manifest.json`,含记忆 —— 它的落点自
10
+ // 2026-10-10 起也在这里,读写仍走 memories 域自己的实现)+ 技能(`trash/skills-trash/`,
11
+ // 元数据文件叫 `metadata.json`,是 core 层自己写的那套)。
12
+ //
13
+ // 三条口径(改这个文件前先读):
14
+ // ① `retentionDays <= 0` = **永久保留**,清扫是纯 no-op。这是默认值,也是本文件存在的
15
+ // 前提:`skills/manager-state.ts` 里有一条更早的产品裁定 ——「回收站没有自动清理:
16
+ // 超龄条目通过 warnings 提示用户到 Skills 管理页处理,**避免静默删除用户可能还想
17
+ // 恢复的内容**」。那条裁定针对的是「没有开关的自动删除」,所以这里默认关闭、
18
+ // 只有用户显式打开才删。默认值改了就等于推翻那条裁定,别顺手改。
19
+ // ② 到期基线 = `max(deletedAt, updatedAt)`,`updatedAt` 是**最近一次改保留期的时刻**。
20
+ // 于是「把保留期从 30 天改成 7 天」不会当场删掉一批 8 天前的条目 —— 倒计时从改设置
21
+ // 那一刻按新天数重算。与 History 页同一条(那边原话:「改保留期即重置倒计时」)。
22
+ // ③ `deletedAt` 缺失 / 解析失败**不清扫**。判据宁可漏删也不能误删:条目元数据被手改过、
23
+ // 或来自更早的版本时,我们并不知道它躺了多久 —— 按「刚删的」处理,让它等下一轮。
24
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
25
+ import { dirname } from 'node:path';
26
+ import { recordAudit } from './audit-log.js';
27
+ import { hubPath, listTrashEntries, purgeTrashEntry, } from './hub.js';
28
+ import { listTrash as listSkillTrash, permanentlyDeleteTrash } from './skills/skill-trash.js';
29
+ /** 一天的毫秒数(保留期按「天」配置,比较按毫秒)。与 sessions/history.ts 同一个常量。 */
30
+ export const MS_PER_DAY = 24 * 60 * 60 * 1000;
31
+ /** 保留期侧车文件名(放 hub 根,与 `history-retention.json` 并排)。 */
32
+ export const TRASH_RETENTION_FILE = 'trash-retention.json';
33
+ /** 走 hub 通用回收站的四类(技能与记忆各自另有实现,不在这里)。 */
34
+ export const HUB_TRASH_KINDS = ['subagents', 'scenes', 'prompts', 'quick-prompts'];
35
+ /** 永久删除时记流水用的 op 名 —— **复用各域已有的 `*-trash-delete`**,不另造一套。
36
+ * 这样兼容页「最近改动」里自动清理与手工删除长得一样,`compat.audit.op.trashDelete`
37
+ * 那条既有翻译也直接适用(否则会在流水里露出一串没人认识的 op 名)。 */
38
+ const AUDIT_OP_BY_KIND = {
39
+ prompts: 'agentsmd-trash-delete',
40
+ 'quick-prompts': 'quickprompt-trash-delete',
41
+ subagents: 'subagent-trash-delete',
42
+ scenes: 'scene-trash-delete',
43
+ };
44
+ const AUDIT_OP_SKILLS = 'skill-trash-delete';
45
+ const AUDIT_OP_MEMORIES = 'rules-trash-remove';
46
+ /** 侧车绝对路径。 */
47
+ export function trashRetentionPath() {
48
+ return hubPath(TRASH_RETENTION_FILE);
49
+ }
50
+ /**
51
+ * 解析侧车内容(纯函数,坏输入不抛)。
52
+ *
53
+ * 与 `readHistoryRetention` 同判据:`retentionDays` 只认**有限且非负**的数,其余一律 0
54
+ * (= 永久保留)。宁可退化成「什么都不删」,也不能因为一份被手改坏的 JSON 变成
55
+ * 「拿一个负数当保留期」—— 那会让 `now - 负数 * 一天` 落到未来,把全部条目判成到期。
56
+ */
57
+ export function parseTrashRetention(raw) {
58
+ const obj = raw && typeof raw === 'object' ? raw : null;
59
+ const days = Number((obj && obj.retentionDays) ?? 0);
60
+ const updatedAt = Number((obj && obj.updatedAt) ?? 0);
61
+ return {
62
+ retentionDays: Number.isFinite(days) && days >= 0 ? days : 0,
63
+ updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
64
+ };
65
+ }
66
+ /** 读侧车。文件不存在 / 读坏 / JSON 坏 → `{0, 0}`(= 永久保留)。 */
67
+ export async function readTrashRetention() {
68
+ try {
69
+ return parseTrashRetention(JSON.parse(await readFile(trashRetentionPath(), 'utf8')));
70
+ }
71
+ catch {
72
+ return { retentionDays: 0, updatedAt: 0 };
73
+ }
74
+ }
75
+ /** 写侧车。`updatedAt` = 本次修改时刻(到期基线随之重置,见文件头第 ② 条)。 */
76
+ export async function writeTrashRetention(retentionDays) {
77
+ const abs = trashRetentionPath();
78
+ await mkdir(dirname(abs), { recursive: true });
79
+ await writeFile(abs, JSON.stringify({ retentionDays, updatedAt: Date.now() }), 'utf8');
80
+ }
81
+ /**
82
+ * 算出到期应永久删除的条目 id(纯函数,便于测试)。
83
+ *
84
+ * 参数顺序、返回形状与 `sessions/history.ts` 的 `expiredArchivedIds` 逐字对应 ——
85
+ * 两处保留期必须是同一套判据,改一边就要改另一边。
86
+ *
87
+ * @param entries 至少要有 `id` 与 `deletedAt`(ISO 字符串)
88
+ * @param retentionDays `<= 0` = 永久保留,返回空集
89
+ * @param now 当前时刻(毫秒)
90
+ * @param updatedAt 最近一次改保留期的时刻(毫秒);`0` = 从没改过,退回到 deletedAt 语义
91
+ */
92
+ export function expiredTrashIds(entries, retentionDays, now, updatedAt = 0) {
93
+ if (!(retentionDays > 0))
94
+ return [];
95
+ const cutoff = now - retentionDays * MS_PER_DAY;
96
+ const out = [];
97
+ for (const entry of entries) {
98
+ const deleted = Date.parse(String(entry.deletedAt ?? ''));
99
+ // 缺失 / 解析失败 → 不知道躺了多久 → 不清扫(文件头第 ③ 条)。
100
+ if (!Number.isFinite(deleted))
101
+ continue;
102
+ const baseline = updatedAt > 0 ? Math.max(deleted, updatedAt) : deleted;
103
+ if (baseline <= cutoff)
104
+ out.push(entry.id);
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * 收集六类回收站条目(best-effort:某一类读不出来只跳过那一类,不拖垮整轮清扫)。
110
+ *
111
+ * 为什么每类各自 try/catch:清扫是启动期的后台动作,一个坏掉的 manifest 不该让另外五类
112
+ * 永远清不掉 —— 那正是「保留期看起来没生效」这类难查故障的温床。
113
+ */
114
+ export async function collectTrashCandidates(memories) {
115
+ const out = [];
116
+ for (const kind of HUB_TRASH_KINDS) {
117
+ let entries = [];
118
+ try {
119
+ entries = await listTrashEntries(kind);
120
+ }
121
+ catch {
122
+ entries = [];
123
+ }
124
+ for (const entry of entries) {
125
+ out.push({
126
+ key: `${kind}/${entry.id}`,
127
+ domain: kind,
128
+ name: entry.name,
129
+ deletedAt: String(entry.deletedAt ?? ''),
130
+ purge: () => purgeTrashEntry(kind, entry.id),
131
+ });
132
+ }
133
+ }
134
+ try {
135
+ for (const item of await listSkillTrash()) {
136
+ out.push({
137
+ key: `skills/${item.id}`,
138
+ domain: 'skills',
139
+ name: item.name,
140
+ // `deletedAt` 在技能元数据里是可选的:更早的版本可能没写 → 空串 → 不清扫(第 ③ 条)。
141
+ deletedAt: String(item.deletedAt ?? ''),
142
+ purge: () => permanentlyDeleteTrash(item.id, undefined),
143
+ });
144
+ }
145
+ }
146
+ catch { /* 技能回收站读不出来 → 本轮跳过 */ }
147
+ try {
148
+ for (const item of await memories.list()) {
149
+ out.push({
150
+ key: `memories/${item.id}`,
151
+ domain: 'memories',
152
+ name: item.name,
153
+ deletedAt: String(item.deletedAt ?? ''),
154
+ purge: () => memories.purge(item.id),
155
+ });
156
+ }
157
+ }
158
+ catch { /* 记忆回收站读不出来 → 本轮跳过 */ }
159
+ return out;
160
+ }
161
+ /** 删除成功与否:`{ok:false}` 与抛错都算失败(与 `recordOpAudit` 同口径)。 */
162
+ async function purgeSucceeded(run) {
163
+ try {
164
+ const res = await run();
165
+ if (res && typeof res === 'object' && res.ok === false)
166
+ return false;
167
+ return true;
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
173
+ /**
174
+ * 扫一轮:按当前保留期把到期条目永久删除。
175
+ *
176
+ * **刻意不经 op 表**(与 `sweepHistory` 同一条):这是引擎自己的后台动作,不是「谁发起的
177
+ * 一次改动」。走 op 表会同时套上令牌门禁与场景冻结 —— 于是「锁着一个场景」就等于
178
+ * 「保留期静默失效」,而用户根本看不出原因。手工删除仍走各域的 `*-trash-delete`(那几条
179
+ * 是冻结的),两条路的差别只在**发起方**:用户点的是写操作,清扫不是。
180
+ *
181
+ * 流水照记(`source: 'engine'`):自动删掉的东西必须留痕,否则用户只会看到条目"自己消失了"。
182
+ */
183
+ export async function sweepTrash(memories, now = Date.now()) {
184
+ const { retentionDays, updatedAt } = await readTrashRetention();
185
+ if (!(retentionDays > 0))
186
+ return { swept: [], failed: 0, retentionDays };
187
+ const candidates = await collectTrashCandidates(memories);
188
+ const expired = new Set(expiredTrashIds(candidates.map((c) => ({ id: c.key, deletedAt: c.deletedAt })), retentionDays, now, updatedAt));
189
+ const swept = [];
190
+ let failed = 0;
191
+ for (const candidate of candidates) {
192
+ if (!expired.has(candidate.key))
193
+ continue;
194
+ if (await purgeSucceeded(candidate.purge)) {
195
+ swept.push(`${candidate.domain}/${candidate.name || candidate.key}`);
196
+ recordAudit({
197
+ ts: now,
198
+ op: candidate.domain === 'skills' ? AUDIT_OP_SKILLS
199
+ : candidate.domain === 'memories' ? AUDIT_OP_MEMORIES
200
+ : (AUDIT_OP_BY_KIND[candidate.domain] || 'trash-sweep'),
201
+ target: candidate.name || candidate.key,
202
+ source: 'engine',
203
+ });
204
+ }
205
+ else {
206
+ failed += 1;
207
+ }
208
+ }
209
+ return { swept, failed, retentionDays };
210
+ }
211
+ /**
212
+ * 回收站保留期域:读 / 写侧车 + 清扫,并在**启动时扫一次、之后按 `sweepIntervalMs` 周期复跑**。
213
+ *
214
+ * 与 `createHistoryDomain` 同形(那边是归档会话的保留期):默认 6 小时一次,`sweepIntervalMs`
215
+ * 由插件配置覆盖。间隔读同一个配置键,是为了让"清扫频率"只有一个说法 —— 两个域各读各的键,
216
+ * 用户调了其中一个却只影响一半,是查不出来的那种不一致。
217
+ *
218
+ * 清扫**不阻断启动**:`void sweep()` 失败只 warn。回收站清理是附属能力,任何时候都不该
219
+ * 让插件起不来(与 audit-log.ts 第 ② 条同一条纪律)。
220
+ */
221
+ export function createTrashRetentionDomain(deps) {
222
+ const { ctx, config, memories } = deps;
223
+ const configured = Number(config?.sweepIntervalMs || 0);
224
+ const sweepIntervalMs = configured > 0 ? configured : 6 * 60 * 60 * 1000;
225
+ async function sweep() {
226
+ try {
227
+ return await sweepTrash(memories);
228
+ }
229
+ catch (e) {
230
+ ctx.logger?.warn?.(`trash retention sweep failed: ${String(e?.message ?? e)}`);
231
+ return { swept: [], failed: 0, retentionDays: 0 };
232
+ }
233
+ }
234
+ // 启动时扫一次:上次退出到现在可能已经过了好几个保留期。
235
+ try {
236
+ void sweep();
237
+ }
238
+ catch { /* 非致命 */ }
239
+ try {
240
+ ctx.effect(() => {
241
+ // interval 缺失时退化为不挂钟(fake-ctx 测试里正是如此,不阻塞测试)。
242
+ const timer = ctx;
243
+ const fn = typeof timer.interval === 'function' ? timer.interval
244
+ : (typeof timer.setInterval === 'function' ? timer.setInterval : undefined);
245
+ if (!fn)
246
+ return () => { };
247
+ return fn(() => { void sweep(); }, sweepIntervalMs);
248
+ }, 'dsh-plugin-tool-management: trash retention sweep');
249
+ }
250
+ catch { /* timer 缺失时静默 */ }
251
+ return {
252
+ read: readTrashRetention,
253
+ write: writeTrashRetention,
254
+ sweep,
255
+ };
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "DSH plugin: one settings panel for MCP servers, skills, scenes, memories, subagent personas and AGENTS.md presets — a scene switches the whole environment, and the model can drive it from chat too.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,199 +0,0 @@
1
- // MCP 配置体检的探测部分:命令在不在 PATH 上、http 端点通不通、把一行配置汇成检查清单,
2
- // 以及 `mcpm-inspect` 这个 op 本身。
3
- //
4
- // 正文从 mcp/manager.ts 的 createMcpManager 闭包里整段搬来,一字未改(只去不掉缩进 —— 它本来
5
- // 就在两层上;改的是两个闭包作用域的名字走 deps,moved-verify 按这两类容差机检)。
6
- //
7
- // 为什么先剥这一段:它是这 2211 行里少见的**纯消费方** —— 只读 normalizeRow 与 mcpmList 的
8
- // 结果,不碰工厂实例上那几个缓存(disabledToolsCache / restrictTimer / epoch /
9
- // agentRestrictions 那一簇被 ops 段与 dispose 共同改写,剥它要动的是调用点而不是这段)。
10
- // 体检自己的承诺是「点了什么都不变」,所以它复用列表结果而不回写任何缓存。
11
- import { maskUrlQuery } from './secret-guard.js';
12
- import { parseRows } from './patch-yaml.js';
13
- import { execFile } from 'node:child_process';
14
- export function createInspectProbe(deps) {
15
- const INSPECT_PATH_TIMEOUT_MS = 3000;
16
- /** 命令是否在 PATH 上:`unknown`(查不了)与 `missing`(确认没有)必须分开 —— 前者不是问题。 */
17
- async function probeCommandOnPath(command) {
18
- if (!command)
19
- return 'unknown';
20
- const finder = process.platform === 'win32' ? 'where' : 'which';
21
- return await new Promise((resolve) => {
22
- execFile(finder, [command], { timeout: INSPECT_PATH_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
23
- if (!error) {
24
- resolve(String(stdout || '').trim() ? 'ok' : 'missing');
25
- return;
26
- }
27
- // ENOENT = 连 where/which 自己都没跑到;killed = 超时。两种都是「无法确认」,
28
- // 剩下的(退出码非 0)才是「PATH 上没有这条命令」。
29
- const code = error.code;
30
- if (code === 'ENOENT' || error.killed === true)
31
- resolve('unknown');
32
- else
33
- resolve('missing');
34
- });
35
- });
36
- }
37
- /** streamable-http 连通性探测的超时:再长,「全部检查」就会被一台挂掉的服务拖住。 */
38
- const INSPECT_PROBE_TIMEOUT_MS = 5000;
39
- /** 探测失败的原因短语(进词典模板的 {reason}):认得出常见错误码就说人话,认不出给原文截断。 */
40
- function describeProbeFailure(e) {
41
- const err = e;
42
- if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
43
- return '探测超时(' + Math.round(INSPECT_PROBE_TIMEOUT_MS / 1000) + ' 秒无响应)';
44
- }
45
- const code = err && err.cause && err.cause.code;
46
- if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
47
- return '域名解析失败(' + code + ')';
48
- if (code === 'ECONNREFUSED')
49
- return '连接被拒绝(' + code + ')';
50
- if (code === 'ETIMEDOUT')
51
- return '连接超时(' + code + ')';
52
- if (code === 'ECONNRESET')
53
- return '连接被重置(' + code + ')';
54
- if (typeof code === 'string' && /CERT|SSL|TLS/.test(code))
55
- return 'TLS 证书校验失败(' + code + ')';
56
- const text = String((err && err.cause && err.cause.message) || (err && err.message) || e || '');
57
- return text.length > 80 ? text.slice(0, 77) + '…' : text;
58
- }
59
- /**
60
- * streamable-http 的连通性探测:对配置地址 GET 一次,**任何** HTTP 响应(含 404/405)
61
- * 都算「可达」—— 探测只回答"这个地址现在连不连得上",不校验它是不是 MCP 服务。
62
- * 不读响应体:streamable-http 对 GET 常回一条 SSE 长连接,拿到状态行就取消。
63
- */
64
- async function probeHttpEndpoint(url, headers) {
65
- const extra = {};
66
- if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
67
- for (const [k, v] of Object.entries(headers)) {
68
- if (typeof v === 'string' && v)
69
- extra[k] = v;
70
- }
71
- }
72
- try {
73
- const res = await fetch(url, { method: 'GET', headers: extra, redirect: 'follow', signal: AbortSignal.timeout(INSPECT_PROBE_TIMEOUT_MS) });
74
- try {
75
- await res.body?.cancel();
76
- }
77
- catch { /* 取消失败不影响「可达」结论 */ }
78
- return { reachable: true };
79
- }
80
- catch (e) {
81
- return { reachable: false, reason: describeProbeFailure(e) };
82
- }
83
- }
84
- /** 一行配置 → 检查项。`level: 'warn'` 才点黄;`info` 是「查不了 / 不算问题」那类说明。 */
85
- async function inspectChecks(row, duplicateIds) {
86
- const checks = [];
87
- const declared = String(row.transport || '');
88
- const url = String(row.url || '');
89
- const command = String(row.command || '');
90
- // transport 缺失/不认识时按字段形状推:历史上手工改过补丁文件的行会绕过 normalize,
91
- // 直接判「没有 transport」会把一条其实能用的配置说成坏的。
92
- const transport = declared === 'stdio' || (!declared && command && !url) ? 'stdio'
93
- : declared === 'streamable-http' || (!declared && url) ? 'streamable-http'
94
- : declared;
95
- if (!declared && transport)
96
- checks.push({ id: 'transportMissing', level: 'info' });
97
- /** 值必须是字符串映射:非对象整片算坏,对象里逐个挑出非字符串的键。 */
98
- const badKv = (map) => {
99
- if (map == null)
100
- return [];
101
- if (typeof map !== 'object' || Array.isArray(map))
102
- return ['*'];
103
- return Object.keys(map).filter((k) => typeof map[k] !== 'string');
104
- };
105
- if (transport === 'stdio') {
106
- if (!command)
107
- checks.push({ id: 'noCommand', level: 'warn' });
108
- else {
109
- // 唯一一次外部调用。npx / uvx 这类 shim 找不到时界面给的下半句是「shim 属正常可忽略」,
110
- // 不让用户以为命令坏了。
111
- //
112
- // `params.command` 原样回给客户端:这与 `mcpm-list`(同样是**免令牌**的只读 op)一致
113
- // —— 列表视图里的 `command` 一直是明文,命令名本身不是凭据(真正装凭据的 `args`
114
- // 两边都不回)。若哪天要收紧,两处必须一起收,否则只是把同一份值换个 op 暴露。
115
- const found = await probeCommandOnPath(command);
116
- if (found === 'missing')
117
- checks.push({ id: 'cmdMissing', level: 'warn', params: { command } });
118
- else if (found === 'unknown')
119
- checks.push({ id: 'cmdUnknown', level: 'info', params: { command } });
120
- }
121
- if (row.args != null && !Array.isArray(row.args))
122
- checks.push({ id: 'argsNotArray', level: 'warn' });
123
- const badEnv = badKv(row.env);
124
- if (badEnv.length)
125
- checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'env', keys: badEnv.join('、') } });
126
- }
127
- else if (transport === 'streamable-http') {
128
- if (!url)
129
- checks.push({ id: 'noUrl', level: 'warn' });
130
- // URL 一律**打码后**回给客户端(`maskUrlQuery`,与列表视图同一口径)。本 op 在登记表里
131
- // 是 `readonly: true` 的免令牌 op,原先这里回的是补丁文件里的 URL 原文 —— 于是一个
132
- // 无需令牌的请求就能拿到 `?api_key=…` 的明文,绕过了 `mcpm-reveal` 那道
133
- // 「令牌是明文凭据最后一道防线」的设计(审查 P0-5)。路径与主机保留,定位不受影响。
134
- else if (!/^https?:\/\//.test(url))
135
- checks.push({ id: 'badUrl', level: 'warn', params: { url: maskUrlQuery(url) } });
136
- else {
137
- // 格式对了再做一次真实探测(0.16.5):此前的体检止步于格式校验,一条编造的
138
- // 地址也能拿绿点 —— 绿点承诺的「没发现问题」其实只覆盖了一半(2026-09-29 实测)。
139
- const probe = await probeHttpEndpoint(url, row.headers);
140
- if (!probe.reachable)
141
- checks.push({ id: 'httpUnreachable', level: 'warn', params: { url: maskUrlQuery(url), reason: probe.reason } });
142
- }
143
- const badHeaders = badKv(row.headers);
144
- if (badHeaders.length)
145
- checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'headers', keys: badHeaders.join('、') } });
146
- }
147
- else if (!command && !url) {
148
- checks.push({ id: 'noEndpoint', level: 'warn' });
149
- }
150
- else {
151
- checks.push({ id: 'badTransport', level: 'warn', params: { transport } });
152
- }
153
- if (duplicateIds > 1)
154
- checks.push({ id: 'nameDup', level: 'warn', params: { name: String(row.serverName || ''), count: duplicateIds } });
155
- return checks;
156
- }
157
- async function mcpmInspect(args) {
158
- const p = await deps.ensurePaths();
159
- const rows = [];
160
- const errors = [];
161
- for (const level of ['project', 'global']) {
162
- const abs = level === 'project' ? p.projectPatch : p.globalPatch;
163
- let content = '';
164
- try {
165
- content = await deps.readPatch(abs);
166
- }
167
- catch (e) {
168
- errors.push(level + ': ' + deps.message(e));
169
- continue;
170
- }
171
- const { rows: fileRows } = parseRows(content);
172
- for (const r of fileRows)
173
- rows.push(deps.normalizeRow(r, level, abs));
174
- }
175
- // 重名判据:同一个 serverName 挂在**不同 id** 下。同 id 跨层是正常遮蔽(界面已有「重复 id」标签),
176
- // 而按名字注册的 `mcp__<serverName>__*` 会撞车 —— 那才是配置问题。
177
- const idsByName = {};
178
- for (const r of rows) {
179
- const n = String(r.serverName || '');
180
- if (!idsByName[n])
181
- idsByName[n] = new Set();
182
- idsByName[n].add(String(r.id));
183
- }
184
- const onlyId = args && String(args.id || '') ? String(args.id) : '';
185
- const onlyLevel = args && String(args.level || '') ? String(args.level) : '';
186
- // 逐行检查并行跑:体检含 streamable-http 的端点探测(每次至多 5 秒超时),串行会让
187
- // 「全部检查」在多台同时连不上时按行数翻倍地慢。Promise.all 保序,与旧实现一致。
188
- const targets = rows.filter((r) => (!onlyId || String(r.id) === onlyId) && (!onlyLevel || String(r.level) === onlyLevel));
189
- const results = await Promise.all(targets.map(async (r) => ({
190
- id: r.id,
191
- serverName: r.serverName,
192
- level: r.level,
193
- transport: r.transport || null,
194
- checks: await inspectChecks(r, (idsByName[String(r.serverName || '')] || new Set()).size),
195
- })));
196
- return { ok: true, results, errors };
197
- }
198
- return { probeCommandOnPath, describeProbeFailure, probeHttpEndpoint, inspectChecks, mcpmInspect };
199
- }