dsh-armory 2.4.3 → 2.4.5

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.
@@ -0,0 +1,439 @@
1
+ // 社区 MCP 数据层:官方 MCP Registry(registry.modelcontextprotocol.io)只读取数。
2
+ //
3
+ // 边界(与 lib/wb-awesome-market.js 同一纪律):
4
+ // - 纯数据层:取数、映射、过滤、内存缓存;**不写配置、不执行任何命令、不知道浏览器存在**。
5
+ // - 源 URL 只活在本文件的 MCP_REGISTRY_SOURCES 里,禁止第二处硬编码。
6
+ // - 测试通过 options.fetchImpl / options.now 注入,零真实网络。
7
+ //
8
+ // 为什么不做全量快照:实测 60 页 × 100 = 6000 条仍未翻完、逐页约 2.4s。社区列表按
9
+ // 「首屏 + 搜索 + 游标翻页」按需取,与 SkillHub 的远程分页同构;数据层只负责一次
10
+ // 游标请求的映射,累计条数上限由面板侧控制。
11
+ // 2026-10-30 复测:全库至少 13,000 条(130 页 × 100 仍未到底,本机串行 2.4–8s/页、
12
+ // 约 9MB),所以「一次爬完做快照」依然不成立;总数只能由 `wbMcpCommunityCount`
13
+ // 做**有界、可续爬**的分片统计(见该函数注释)。
14
+ //
15
+ // 安全底线:只 GET;不探测 npm 包、不跑 npx/uvx;env 的 default 原样展示、绝不落盘。
16
+
17
+ export const MCP_REGISTRY_SOURCES = [
18
+ 'https://registry.modelcontextprotocol.io/v0/servers',
19
+ ];
20
+
21
+ /** Registry 单页上限(实测 1000 → 422,100 是服务端接受的最大值)。 */
22
+ export const COMMUNITY_PAGE_LIMIT = 100;
23
+ /** 单次请求超时;失败只重试 1 次,且仅限网络错误与 5xx(4xx 直接放弃)。 */
24
+ const FETCH_TIMEOUT_MS = 15_000;
25
+ /** 内存缓存:key = search|cursor|limit,TTL 5 分钟,最多 50 条(先进先出)。 */
26
+ const CACHE_TTL_MS = 5 * 60_000;
27
+ const CACHE_MAX_ENTRIES = 50;
28
+ /** description 截断长度(卡片里只做一行摘要,完整说明留给上游仓库)。 */
29
+ const DESCRIPTION_MAX = 200;
30
+ /** registry 官方元信息键:status 非 active 的条目一律丢弃(下架 / 弃用)。 */
31
+ const REGISTRY_META_KEY = 'io.modelcontextprotocol.registry/official';
32
+ /** 视为「社区提交」的命名空间前缀;其余(ac.*、com.*、无斜杠)按厂商/官方处理。 */
33
+ const COMMUNITY_NAMESPACES = ['io.github.', 'io.gitlab.', 'io.bitbucket.'];
34
+ /** serverName 净化:尾段只保留小写字母、数字、连字符与下划线。 */
35
+ const SERVER_NAME_SAFE = /[^a-z0-9\-_]+/g;
36
+ const SERVER_NAME_MAX = 60;
37
+
38
+ const cache = new Map();
39
+
40
+ const asString = (value) => (typeof value === 'string' ? value : '');
41
+ const asArray = (value) => (Array.isArray(value) ? value : []);
42
+
43
+ function truncate(text, max = DESCRIPTION_MAX) {
44
+ const value = String(text || '');
45
+ return value.length > max ? value.slice(0, max - 1) + '…' : value;
46
+ }
47
+
48
+ /**
49
+ * 仓库地址只放行 https。Registry 条目由社区提交,`javascript:` / `file:` 进 href
50
+ * 会在点击时执行或打开本地文件;不合法时归空串,卡片上就不渲染「仓库」按钮。
51
+ */
52
+ export function safeRepoUrl(value) {
53
+ const text = asString(value).trim();
54
+ if (!text) return '';
55
+ try {
56
+ const parsed = new URL(text);
57
+ return parsed.protocol === 'https:' ? parsed.toString() : '';
58
+ } catch {
59
+ return '';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 从仓库地址解析 GitHub owner(头像 / 卡片副标题用)。
65
+ * 只认 github.com 的公开路径;解析不出返回空串,绝不猜。
66
+ */
67
+ export function parseGithubOwner(url) {
68
+ const match = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)/i.exec(String(url || ''));
69
+ return match && match[1] ? match[1] : '';
70
+ }
71
+
72
+ /**
73
+ * serverName 净化与冲突后缀:name 尾段 → [a-z0-9-_],与已装条目冲突时追加 -2、-3。
74
+ * 数据层只做净化与后缀,不读宿主状态(takenNames 由调用方传入)。
75
+ */
76
+ export function sanitizeServerName(raw, takenNames = []) {
77
+ // 尾部斜杠(真实条目里出现过)不能让尾段变成空串:先剥掉分隔符再取最后一段。
78
+ const segments = String(raw || '').split('/').filter(Boolean);
79
+ const tail = segments.length ? segments[segments.length - 1] : '';
80
+ let base = tail.toLowerCase().replace(SERVER_NAME_SAFE, '-').replace(/^-+|-+$/g, '').slice(0, SERVER_NAME_MAX);
81
+ if (!base) base = 'mcp-server';
82
+ const taken = new Set(asArray(takenNames).map((name) => String(name || '').toLowerCase()).filter(Boolean));
83
+ if (!taken.has(base)) return base;
84
+ let suffix = 2;
85
+ while (taken.has(`${base}-${suffix}`)) suffix += 1;
86
+ return `${base}-${suffix}`;
87
+ }
88
+
89
+ /** runtimeArguments / packageArguments 的取值顺序:value 数组按原顺序(`-y` 必须在包名前)。 */
90
+ function argumentValues(list) {
91
+ return asArray(list)
92
+ .filter((item) => item && typeof item === 'object' && typeof item.value === 'string' && item.value)
93
+ .map((item) => item.value);
94
+ }
95
+
96
+ /** 运行命令:优先 runtimeHint,其次按 registryType 推断;无法确定返回空串。 */
97
+ function commandForPackage(pkg) {
98
+ const hint = asString(pkg?.runtimeHint).toLowerCase();
99
+ // 只收能直接执行的 npx / uvx。docker 需要 `run -i --rm` 等一整套参数,注册表条目
100
+ // 的 runtimeArguments 并不保证带全;按 v1 口径(oci 也不支持)宁可丢弃,也不生成
101
+ // 「看着可装、点下去跑不起来」的卡片。
102
+ if (hint === 'npx' || hint === 'uvx') return hint;
103
+ const registryType = asString(pkg?.registryType).toLowerCase();
104
+ if (registryType === 'npm') return 'npx';
105
+ if (registryType === 'pypi') return 'uvx';
106
+ return '';
107
+ }
108
+
109
+ /** 环境变量逐项映射:isRequired / isSecret / default 原样带出,值永不落盘。 */
110
+ function envForPackage(pkg) {
111
+ return asArray(pkg?.environmentVariables)
112
+ .filter((row) => row && typeof row === 'object' && typeof row.name === 'string' && row.name)
113
+ .map((row) => ({
114
+ name: row.name,
115
+ required: row.isRequired === true,
116
+ secret: row.isSecret === true,
117
+ description: asString(row.description),
118
+ default: row.default === undefined || row.default === null ? '' : String(row.default),
119
+ hasDefault: row.default !== undefined && row.default !== null,
120
+ }));
121
+ }
122
+
123
+ /**
124
+ * 官方 Registry 条目 → 面板行。
125
+ *
126
+ * 入参兼容两种形态:`{ server, _meta }`(真实响应)或 server 本体。
127
+ * 返回 null = 该条不可用(缺 name / 非 active / oci 包 / 既无 stdio 包也无远程地址)。
128
+ *
129
+ * 参数顺序钉死为 runtimeArguments → identifier → packageArguments:
130
+ * 真实条目里 `-y` 是 runtimeArgument,必须排在包名之前,反了就变成 `pkg -y`。
131
+ */
132
+ export function mapRegistryServer(entry, options = {}) {
133
+ const server = entry && typeof entry === 'object' && entry.server && typeof entry.server === 'object'
134
+ ? entry.server
135
+ : entry;
136
+ if (!server || typeof server !== 'object') return null;
137
+ const name = asString(server.name).trim();
138
+ if (!name) return null;
139
+ const meta = entry?._meta?.[REGISTRY_META_KEY];
140
+ const status = meta && typeof meta.status === 'string' ? meta.status : 'active';
141
+ if (status !== 'active') return null;
142
+
143
+ const packages = asArray(server.packages).filter((pkg) => pkg && typeof pkg === 'object');
144
+ const remotes = asArray(server.remotes).filter((remote) => remote && typeof remote === 'object');
145
+ const pkg = packages[0];
146
+ // v1 不支持 docker 映射:oci 包生成出来的配置跑不起来,直接丢弃。
147
+ if (pkg && asString(pkg.registryType).toLowerCase() === 'oci') return null;
148
+
149
+ const command = pkg ? commandForPackage(pkg) : '';
150
+ const transportType = asString(pkg?.transport?.type).toLowerCase();
151
+ const runnable = Boolean(pkg && command && transportType === 'stdio');
152
+ // 只收 DSH 真正支持的远程传输(streamable-http;缺 type 按 streamable-http 处理,
153
+ // 与 lib/wb-market.js 的安装判定同口径)。sse / ws 会被宿主 add 直接拒绝,
154
+ // 不能为了「有链接」把它标成 http 塞进列表。
155
+ const remote = remotes.find((entry) => {
156
+ const type = asString(entry?.type).toLowerCase();
157
+ return (type === '' || type === 'http' || type === 'streamable-http' || type === 'streamablehttp')
158
+ && Boolean(asString(entry?.url));
159
+ });
160
+ const remoteUrl = remote ? asString(remote.url) : '';
161
+ // 既跑不起来也连不上:卡片上没有可执行的下一步,不进列表。
162
+ if (!runnable && !remoteUrl) return null;
163
+
164
+ const env = pkg ? envForPackage(pkg) : [];
165
+ const identifier = asString(pkg?.identifier);
166
+ const args = pkg
167
+ ? [...argumentValues(pkg.runtimeArguments), identifier, ...argumentValues(pkg.packageArguments)].filter(Boolean)
168
+ : [];
169
+ const repoUrl = safeRepoUrl(server.repository?.url);
170
+ const takenNames = asArray(options.takenNames);
171
+
172
+ return {
173
+ key: name,
174
+ name,
175
+ title: asString(server.title).trim() || name.split('/').pop() || name,
176
+ description: truncate(server.description),
177
+ version: asString(server.version),
178
+ owner: parseGithubOwner(repoUrl),
179
+ repoUrl,
180
+ runnable,
181
+ transport: runnable ? 'stdio' : 'http',
182
+ serverName: sanitizeServerName(name, takenNames),
183
+ // 冲突后缀之前的原名,专门给「已加入」判定用。serverName 带后缀(-2、-3)是「预填什么名字」,
184
+ // 而「这条是不是已经装了」比较的是原始名:装完 adeu 之后 taken 里就有 adeu,下一次列表里
185
+ // serverName 会变成 adeu-2,拿它去比已装名永远不相等——「已加入」因此永远点不亮。
186
+ baseServerName: sanitizeServerName(name, []),
187
+ command: runnable ? command : '',
188
+ args: runnable ? args : [],
189
+ env,
190
+ remoteUrl,
191
+ requiresToken: env.some((row) => row.required) || (!runnable && Boolean(remoteUrl)),
192
+ official: !COMMUNITY_NAMESPACES.some((prefix) => name.toLowerCase().startsWith(prefix)),
193
+ };
194
+ }
195
+
196
+ /** 本地过滤:只保留可直接运行(npx / uvx)的条目。纯函数,不发请求。 */
197
+ export function filterCommunityRows(rows, options = {}) {
198
+ const list = asArray(rows).filter(Boolean);
199
+ if (options.onlyRunnable === false) return list;
200
+ return list.filter((row) => row.runnable);
201
+ }
202
+
203
+ /**
204
+ * 响应防御式归一:`servers` 非数组归空、`nextCursor` 非字符串归 null。
205
+ * 未知字段一律忽略(Registry 仍是 v0,字段可能升级)。
206
+ */
207
+ export function normalizeRegistryResponse(raw) {
208
+ const servers = raw && Array.isArray(raw.servers) ? raw.servers : [];
209
+ const cursor = raw && raw.metadata && typeof raw.metadata.nextCursor === 'string' && raw.metadata.nextCursor
210
+ ? raw.metadata.nextCursor
211
+ : null;
212
+ return { servers, nextCursor: cursor };
213
+ }
214
+
215
+ /** limit 钳制:非法 / < 1 → 1,> 100 → 100。 */
216
+ function clampLimit(value) {
217
+ const number = Number(value);
218
+ if (!Number.isFinite(number)) return COMMUNITY_PAGE_LIMIT;
219
+ return Math.min(COMMUNITY_PAGE_LIMIT, Math.max(1, Math.floor(number)));
220
+ }
221
+
222
+ /** 拼请求地址:cursor 必须经 URLSearchParams 编码(游标里含 `:` 与 `/`)。 */
223
+ export function buildRegistryUrl(payload = {}) {
224
+ const params = new URLSearchParams();
225
+ params.set('limit', String(clampLimit(payload.limit)));
226
+ // version=latest:服务端只回最新版,省掉本地按版本去重。
227
+ params.set('version', 'latest');
228
+ const search = asString(payload.search).trim();
229
+ if (search) params.set('search', search);
230
+ const cursor = asString(payload.cursor).trim();
231
+ if (cursor) params.set('cursor', cursor);
232
+ return `${MCP_REGISTRY_SOURCES[0]}?${params.toString()}`;
233
+ }
234
+
235
+ /** 单次 GET JSON:超时 + 仅网络错误/5xx 重试 1 次;4xx 与解析失败直接放弃。 */
236
+ async function fetchJson(url, options = {}) {
237
+ const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch;
238
+ if (typeof fetchImpl !== 'function') throw new Error('当前宿主没有 fetch,无法读取社区目录');
239
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : FETCH_TIMEOUT_MS;
240
+ for (let attempt = 0; attempt < 2; attempt += 1) {
241
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
242
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
243
+ try {
244
+ const response = await fetchImpl(url, {
245
+ method: 'GET',
246
+ headers: { accept: 'application/json' },
247
+ signal: controller ? controller.signal : undefined,
248
+ });
249
+ const status = Number(response?.status) || 0;
250
+ // ok 是权威判定,不能只信 status:代理 / 矿化响应可能 ok=false 却没有状态码。
251
+ // status 0 视为网关层异常(可重试一次),4xx 明确不重试,5xx 重试一次。
252
+ if (!response || response.ok === false) {
253
+ const error = new Error(`HTTP ${status || '未知'}`);
254
+ error.retryable = status === 0 || status >= 500;
255
+ throw error;
256
+ }
257
+ const body = typeof response.json === 'function' ? await response.json() : response;
258
+ return body;
259
+ } catch (cause) {
260
+ // 非 HTTP 错误(网络 / 超时 / 解析)与 5xx 才重试;4xx 明确不重试。
261
+ if (attempt === 0 && cause?.retryable !== false) continue;
262
+ throw cause;
263
+ } finally {
264
+ if (timer) clearTimeout(timer);
265
+ }
266
+ }
267
+ throw new Error('读取社区目录失败');
268
+ }
269
+
270
+ function cacheKeyOf(payload) {
271
+ return [
272
+ asString(payload.search).trim(),
273
+ asString(payload.cursor).trim(),
274
+ String(clampLimit(payload.limit)),
275
+ ].join('|');
276
+ }
277
+
278
+ function readCache(key, now, ttlMs) {
279
+ const hit = cache.get(key);
280
+ if (!hit) return null;
281
+ if (now - hit.fetchedAt > ttlMs) {
282
+ cache.delete(key);
283
+ return null;
284
+ }
285
+ return hit;
286
+ }
287
+
288
+ function writeCache(key, value) {
289
+ if (cache.has(key)) cache.delete(key);
290
+ cache.set(key, value);
291
+ while (cache.size > CACHE_MAX_ENTRIES) {
292
+ const oldest = cache.keys().next().value;
293
+ cache.delete(oldest);
294
+ }
295
+ }
296
+
297
+ /** 测试隔离口:清空内存缓存(生产代码不调用)。 */
298
+ export function resetCommunityCache() {
299
+ cache.clear();
300
+ }
301
+
302
+ /**
303
+ * 社区 MCP 列表(一次游标请求)。
304
+ *
305
+ * @param {{search?: string, cursor?: string, limit?: number, takenNames?: string[]}} payload
306
+ * @param {{fetchImpl?: Function, now?: Function, timeoutMs?: number, cacheTtlMs?: number}} options
307
+ * @returns {Promise<{ok:true, rows:object[], nextCursor:string|null, fetchedAt:number, cached:boolean}
308
+ * | {ok:false, error:string}>}
309
+ *
310
+ * 永不抛错:所有失败都收敛成 `{ok:false, error}`,面板只负责把错误落在列表内。
311
+ * `takenNames` 来自调用方的 MCP 列表,用于 serverName 冲突后缀;本函数内已分配的
312
+ * 名字也会累加进去,保证同批两条同尾段条目不会撞名。
313
+ */
314
+ export async function wbMcpCommunityList(payload = {}, options = {}) {
315
+ const input = payload && typeof payload === 'object' ? payload : {};
316
+ const now = typeof options.now === 'function' ? options.now : Date.now;
317
+ const ttlMs = Number(options.cacheTtlMs) > 0 ? Number(options.cacheTtlMs) : CACHE_TTL_MS;
318
+ const key = cacheKeyOf(input);
319
+ const hit = readCache(key, now(), ttlMs);
320
+ if (hit) {
321
+ // serverName 取决于「当前已装了哪些 MCP」:缓存只存上游映射,命中时按本次
322
+ // takenNames 重新分配冲突后缀,否则用户装完一条再回列表仍看到旧名字。
323
+ const taken = [...asArray(input.takenNames)];
324
+ const seen = new Set();
325
+ const rows = hit.rows.map((row) => {
326
+ let serverName = sanitizeServerName(row.name, taken);
327
+ if (seen.has(serverName)) serverName = sanitizeServerName(row.name, taken);
328
+ seen.add(serverName);
329
+ taken.push(serverName);
330
+ return serverName === row.serverName ? row : { ...row, serverName };
331
+ });
332
+ return { ok: true, rows, nextCursor: hit.nextCursor, fetchedAt: hit.fetchedAt, cached: true };
333
+ }
334
+
335
+ let raw;
336
+ try {
337
+ raw = await fetchJson(buildRegistryUrl(input), options);
338
+ } catch (cause) {
339
+ return { ok: false, error: String(cause?.message || cause) };
340
+ }
341
+
342
+ const { servers, nextCursor } = normalizeRegistryResponse(raw);
343
+ const taken = [...asArray(input.takenNames)];
344
+ const rows = [];
345
+ const seen = new Set();
346
+ for (const entry of servers) {
347
+ const row = mapRegistryServer(entry, { takenNames: taken });
348
+ if (!row) continue;
349
+ // 同一页里可能同时出现同一 serverName(同名不同版本已被 version=latest 挡掉,
350
+ // 这里只防尾段撞名的不同条目):后出现的自动后缀。
351
+ if (seen.has(row.serverName)) row.serverName = sanitizeServerName(row.name, taken);
352
+ seen.add(row.serverName);
353
+ taken.push(row.serverName);
354
+ rows.push(row);
355
+ }
356
+
357
+ const value = { rows, nextCursor, fetchedAt: now() };
358
+ writeCache(key, value);
359
+ return { ok: true, rows, nextCursor, fetchedAt: value.fetchedAt, cached: false };
360
+ }
361
+
362
+ /** 单次统计请求最多翻几页(3 页 = 300 条)。面板侧还会再套一层会话预算。 */
363
+ export const COMMUNITY_COUNT_CHUNK_PAGES = 3;
364
+ /** 一次统计调用允许的页数上限(12 页 = 1200 条):防止 payload 被塞一个巨大值把上游打穿。 */
365
+ export const COMMUNITY_COUNT_MAX_PAGES = 12;
366
+
367
+ /** 页数钳制:非法 / < 1 → 默认 3 页,> 上限 → 上限。 */
368
+ function clampCountPages(value) {
369
+ const number = Number(value);
370
+ if (!Number.isFinite(number) || number < 1) return COMMUNITY_COUNT_CHUNK_PAGES;
371
+ return Math.min(COMMUNITY_COUNT_MAX_PAGES, Math.floor(number));
372
+ }
373
+
374
+ /**
375
+ * 社区目录「全库总数」的**有界**统计:从给定游标起顺着翻页,最多翻 `pages` 页,
376
+ * 返回这一段数到的条目数与下一步该从哪儿接着数。
377
+ *
378
+ * 为什么要有这个函数:官方 Registry 不返回总数(实测 `metadata.count` 就是本页条数、
379
+ * `/v0/servers/count` 与 `/v0/registry/stats` 均 404、`limit>100` 直接 422),而面板
380
+ * 徽标只显示「已读取 98 条」会被用户读成"社区一共只有 98 个"(2026-10-30 用户反馈)。
381
+ * 唯一的真数只能靠顺游标爬,所以做成**可续爬的分片统计**:每次只花几页的预算,
382
+ * 进度由面板侧持久化,跨会话继续,爬到尽头就给精确值、没到尽头就给下界(`N+`)。
383
+ *
384
+ * 口径与列表严格一致:走同一个 `buildRegistryUrl`(含 `version=latest`)与同一个
385
+ * `mapRegistryServer`(非 active / 既跑不起来又没合法远程地址 / oci 包都会被丢弃),
386
+ * 所以这个数就是"用户翻页能翻到的条目数",不是上游数据库行数。
387
+ *
388
+ * 刻意**不写内存缓存**:一次全库要 130+ 页,塞进 50 条上限的翻页缓存会把用户真正
389
+ * 在看的那几页挤掉,反而拖慢列表。
390
+ *
391
+ * 跨块不去重(游标链本身互斥,上游若真返回重叠页,最坏是把下界算高一点点);
392
+ * 单块内按 `key` 去重,与面板侧「已读取」累计口径同源。
393
+ *
394
+ * 与 `wbMcpCommunityList` 同纪律:永不抛错,失败收敛成 `{ok:false, error}`,
395
+ * 并把已经数到的部分如实带回去(中断不该把前面的工作清零)。
396
+ *
397
+ * @param {{cursor?: string, pages?: number}} payload
398
+ * @param {{fetchImpl?: Function, timeoutMs?: number}} options
399
+ * @returns {Promise<{ok:true, counted:number, nextCursor:string|null, done:boolean, pages:number}
400
+ * | {ok:false, error:string, counted:number, nextCursor:string|null, done:false, pages:number}>}
401
+ */
402
+ export async function wbMcpCommunityCount(payload = {}, options = {}) {
403
+ const input = payload && typeof payload === 'object' ? payload : {};
404
+ const budget = clampCountPages(input.pages);
405
+ let cursor = asString(input.cursor).trim();
406
+ let counted = 0;
407
+ let walked = 0;
408
+ const seen = new Set();
409
+ for (let round = 0; round < budget; round += 1) {
410
+ let raw;
411
+ try {
412
+ raw = await fetchJson(buildRegistryUrl({ cursor, limit: COMMUNITY_PAGE_LIMIT }), options);
413
+ } catch (cause) {
414
+ return {
415
+ ok: false,
416
+ error: String(cause?.message || cause),
417
+ counted,
418
+ nextCursor: cursor || null,
419
+ done: false,
420
+ pages: walked,
421
+ };
422
+ }
423
+ const { servers, nextCursor } = normalizeRegistryResponse(raw);
424
+ walked += 1;
425
+ for (const entry of servers) {
426
+ const row = mapRegistryServer(entry, {});
427
+ if (!row || !row.key || seen.has(row.key)) continue;
428
+ seen.add(row.key);
429
+ counted += 1;
430
+ }
431
+ // 两种「到底」都要认:没游标了,或上游给了游标但这一页是空的——只认前一种的话,
432
+ // 遇到「空页 + 残留游标」的实现会在续爬时原地打转。
433
+ if (!servers.length || !nextCursor) {
434
+ return { ok: true, counted, nextCursor: null, done: true, pages: walked };
435
+ }
436
+ cursor = nextCursor;
437
+ }
438
+ return { ok: true, counted, nextCursor: cursor || null, done: false, pages: walked };
439
+ }
package/lib/mcp-config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseDocument, Scalar, YAMLMap, YAMLSeq, isMap, isSeq } from 'yaml'
2
2
  import { isSafeExpression, makeEnvExpression, makeExpression } from './env-expression.js'
3
+ import { isCmdShellCommand, planStdioLaunch, win32LaunchContext } from './win32-stdio.js'
3
4
 
4
5
  export const MCP_PLUGIN_NAME = '@deepseek-ai/dsh-mcp-client'
5
6
  const JS_TAG = 'tag:yaml.org,2002:js'
@@ -183,6 +184,20 @@ function normalizeEntry(name, input, warnings) {
183
184
  if (typeof raw.cwd !== 'string') throw new Error(`${serverName}.cwd 必须是字符串`)
184
185
  if (raw.cwd) result.cwd = expressionValue(raw.cwd)
185
186
  }
187
+ // win32 上裸 npx / npm 是 .cmd 壳,宿主 `spawn(command, args, {shell:false})` 直接 ENOENT,
188
+ // 子进程起不来 → 面板只剩「SdkError: Connection closed」。写入前归一成绝对 node.exe + 入口 JS;
189
+ // 非 win32、绝对路径、${VAR} / !!js 表达式一律原样放行(判据与实测见 lib/win32-stdio.js)。
190
+ const launchContext = win32LaunchContext()
191
+ const launch = planStdioLaunch(result, launchContext)
192
+ if (launch.rewritten) {
193
+ warnings.push(`${serverName}:Windows 上裸 ${result.command} 无法 spawn,已改用本机 Node 启动器`)
194
+ result.command = launch.spec.command
195
+ result.args = launch.spec.args
196
+ if (launch.spec.env !== undefined) result.env = launch.spec.env
197
+ } else if (launchContext.probeError && isCmdShellCommand(result.command)) {
198
+ // 探测层异常时如实说明「本该归一但没归」,别让条目带着裸 npx 落盘后只剩一句连接失败。
199
+ warnings.push(`${serverName}:本机 Node 启动器探测失败(${launchContext.probeError}),命令已按原样写入,Windows 上可能无法启动`)
200
+ }
186
201
  } else {
187
202
  const normalizedUrl = hasUrl ? expressionValue(url.trim()) : ''
188
203
  if (!hasUrl) throw new Error(`${serverName} 的 HTTP 配置需要 http(s):// URL`)