dsh-plugin-tool-management 0.19.1 → 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.
@@ -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
- }