dsh-armory 2.4.2 → 2.4.4

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,353 @@
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
+ //
12
+ // 安全底线:只 GET;不探测 npm 包、不跑 npx/uvx;env 的 default 原样展示、绝不落盘。
13
+
14
+ export const MCP_REGISTRY_SOURCES = [
15
+ 'https://registry.modelcontextprotocol.io/v0/servers',
16
+ ];
17
+
18
+ /** Registry 单页上限(实测 1000 → 422,100 是服务端接受的最大值)。 */
19
+ export const COMMUNITY_PAGE_LIMIT = 100;
20
+ /** 单次请求超时;失败只重试 1 次,且仅限网络错误与 5xx(4xx 直接放弃)。 */
21
+ const FETCH_TIMEOUT_MS = 15_000;
22
+ /** 内存缓存:key = search|cursor|limit,TTL 5 分钟,最多 50 条(先进先出)。 */
23
+ const CACHE_TTL_MS = 5 * 60_000;
24
+ const CACHE_MAX_ENTRIES = 50;
25
+ /** description 截断长度(卡片里只做一行摘要,完整说明留给上游仓库)。 */
26
+ const DESCRIPTION_MAX = 200;
27
+ /** registry 官方元信息键:status 非 active 的条目一律丢弃(下架 / 弃用)。 */
28
+ const REGISTRY_META_KEY = 'io.modelcontextprotocol.registry/official';
29
+ /** 视为「社区提交」的命名空间前缀;其余(ac.*、com.*、无斜杠)按厂商/官方处理。 */
30
+ const COMMUNITY_NAMESPACES = ['io.github.', 'io.gitlab.', 'io.bitbucket.'];
31
+ /** serverName 净化:尾段只保留小写字母、数字、连字符与下划线。 */
32
+ const SERVER_NAME_SAFE = /[^a-z0-9\-_]+/g;
33
+ const SERVER_NAME_MAX = 60;
34
+
35
+ const cache = new Map();
36
+
37
+ const asString = (value) => (typeof value === 'string' ? value : '');
38
+ const asArray = (value) => (Array.isArray(value) ? value : []);
39
+
40
+ function truncate(text, max = DESCRIPTION_MAX) {
41
+ const value = String(text || '');
42
+ return value.length > max ? value.slice(0, max - 1) + '…' : value;
43
+ }
44
+
45
+ /**
46
+ * 仓库地址只放行 https。Registry 条目由社区提交,`javascript:` / `file:` 进 href
47
+ * 会在点击时执行或打开本地文件;不合法时归空串,卡片上就不渲染「仓库」按钮。
48
+ */
49
+ export function safeRepoUrl(value) {
50
+ const text = asString(value).trim();
51
+ if (!text) return '';
52
+ try {
53
+ const parsed = new URL(text);
54
+ return parsed.protocol === 'https:' ? parsed.toString() : '';
55
+ } catch {
56
+ return '';
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 从仓库地址解析 GitHub owner(头像 / 卡片副标题用)。
62
+ * 只认 github.com 的公开路径;解析不出返回空串,绝不猜。
63
+ */
64
+ export function parseGithubOwner(url) {
65
+ const match = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)/i.exec(String(url || ''));
66
+ return match && match[1] ? match[1] : '';
67
+ }
68
+
69
+ /**
70
+ * serverName 净化与冲突后缀:name 尾段 → [a-z0-9-_],与已装条目冲突时追加 -2、-3。
71
+ * 数据层只做净化与后缀,不读宿主状态(takenNames 由调用方传入)。
72
+ */
73
+ export function sanitizeServerName(raw, takenNames = []) {
74
+ // 尾部斜杠(真实条目里出现过)不能让尾段变成空串:先剥掉分隔符再取最后一段。
75
+ const segments = String(raw || '').split('/').filter(Boolean);
76
+ const tail = segments.length ? segments[segments.length - 1] : '';
77
+ let base = tail.toLowerCase().replace(SERVER_NAME_SAFE, '-').replace(/^-+|-+$/g, '').slice(0, SERVER_NAME_MAX);
78
+ if (!base) base = 'mcp-server';
79
+ const taken = new Set(asArray(takenNames).map((name) => String(name || '').toLowerCase()).filter(Boolean));
80
+ if (!taken.has(base)) return base;
81
+ let suffix = 2;
82
+ while (taken.has(`${base}-${suffix}`)) suffix += 1;
83
+ return `${base}-${suffix}`;
84
+ }
85
+
86
+ /** runtimeArguments / packageArguments 的取值顺序:value 数组按原顺序(`-y` 必须在包名前)。 */
87
+ function argumentValues(list) {
88
+ return asArray(list)
89
+ .filter((item) => item && typeof item === 'object' && typeof item.value === 'string' && item.value)
90
+ .map((item) => item.value);
91
+ }
92
+
93
+ /** 运行命令:优先 runtimeHint,其次按 registryType 推断;无法确定返回空串。 */
94
+ function commandForPackage(pkg) {
95
+ const hint = asString(pkg?.runtimeHint).toLowerCase();
96
+ // 只收能直接执行的 npx / uvx。docker 需要 `run -i --rm` 等一整套参数,注册表条目
97
+ // 的 runtimeArguments 并不保证带全;按 v1 口径(oci 也不支持)宁可丢弃,也不生成
98
+ // 「看着可装、点下去跑不起来」的卡片。
99
+ if (hint === 'npx' || hint === 'uvx') return hint;
100
+ const registryType = asString(pkg?.registryType).toLowerCase();
101
+ if (registryType === 'npm') return 'npx';
102
+ if (registryType === 'pypi') return 'uvx';
103
+ return '';
104
+ }
105
+
106
+ /** 环境变量逐项映射:isRequired / isSecret / default 原样带出,值永不落盘。 */
107
+ function envForPackage(pkg) {
108
+ return asArray(pkg?.environmentVariables)
109
+ .filter((row) => row && typeof row === 'object' && typeof row.name === 'string' && row.name)
110
+ .map((row) => ({
111
+ name: row.name,
112
+ required: row.isRequired === true,
113
+ secret: row.isSecret === true,
114
+ description: asString(row.description),
115
+ default: row.default === undefined || row.default === null ? '' : String(row.default),
116
+ hasDefault: row.default !== undefined && row.default !== null,
117
+ }));
118
+ }
119
+
120
+ /**
121
+ * 官方 Registry 条目 → 面板行。
122
+ *
123
+ * 入参兼容两种形态:`{ server, _meta }`(真实响应)或 server 本体。
124
+ * 返回 null = 该条不可用(缺 name / 非 active / oci 包 / 既无 stdio 包也无远程地址)。
125
+ *
126
+ * 参数顺序钉死为 runtimeArguments → identifier → packageArguments:
127
+ * 真实条目里 `-y` 是 runtimeArgument,必须排在包名之前,反了就变成 `pkg -y`。
128
+ */
129
+ export function mapRegistryServer(entry, options = {}) {
130
+ const server = entry && typeof entry === 'object' && entry.server && typeof entry.server === 'object'
131
+ ? entry.server
132
+ : entry;
133
+ if (!server || typeof server !== 'object') return null;
134
+ const name = asString(server.name).trim();
135
+ if (!name) return null;
136
+ const meta = entry?._meta?.[REGISTRY_META_KEY];
137
+ const status = meta && typeof meta.status === 'string' ? meta.status : 'active';
138
+ if (status !== 'active') return null;
139
+
140
+ const packages = asArray(server.packages).filter((pkg) => pkg && typeof pkg === 'object');
141
+ const remotes = asArray(server.remotes).filter((remote) => remote && typeof remote === 'object');
142
+ const pkg = packages[0];
143
+ // v1 不支持 docker 映射:oci 包生成出来的配置跑不起来,直接丢弃。
144
+ if (pkg && asString(pkg.registryType).toLowerCase() === 'oci') return null;
145
+
146
+ const command = pkg ? commandForPackage(pkg) : '';
147
+ const transportType = asString(pkg?.transport?.type).toLowerCase();
148
+ const runnable = Boolean(pkg && command && transportType === 'stdio');
149
+ // 只收 DSH 真正支持的远程传输(streamable-http;缺 type 按 streamable-http 处理,
150
+ // 与 lib/wb-market.js 的安装判定同口径)。sse / ws 会被宿主 add 直接拒绝,
151
+ // 不能为了「有链接」把它标成 http 塞进列表。
152
+ const remote = remotes.find((entry) => {
153
+ const type = asString(entry?.type).toLowerCase();
154
+ return (type === '' || type === 'http' || type === 'streamable-http' || type === 'streamablehttp')
155
+ && Boolean(asString(entry?.url));
156
+ });
157
+ const remoteUrl = remote ? asString(remote.url) : '';
158
+ // 既跑不起来也连不上:卡片上没有可执行的下一步,不进列表。
159
+ if (!runnable && !remoteUrl) return null;
160
+
161
+ const env = pkg ? envForPackage(pkg) : [];
162
+ const identifier = asString(pkg?.identifier);
163
+ const args = pkg
164
+ ? [...argumentValues(pkg.runtimeArguments), identifier, ...argumentValues(pkg.packageArguments)].filter(Boolean)
165
+ : [];
166
+ const repoUrl = safeRepoUrl(server.repository?.url);
167
+ const takenNames = asArray(options.takenNames);
168
+
169
+ return {
170
+ key: name,
171
+ name,
172
+ title: asString(server.title).trim() || name.split('/').pop() || name,
173
+ description: truncate(server.description),
174
+ version: asString(server.version),
175
+ owner: parseGithubOwner(repoUrl),
176
+ repoUrl,
177
+ runnable,
178
+ transport: runnable ? 'stdio' : 'http',
179
+ serverName: sanitizeServerName(name, takenNames),
180
+ command: runnable ? command : '',
181
+ args: runnable ? args : [],
182
+ env,
183
+ remoteUrl,
184
+ requiresToken: env.some((row) => row.required) || (!runnable && Boolean(remoteUrl)),
185
+ official: !COMMUNITY_NAMESPACES.some((prefix) => name.toLowerCase().startsWith(prefix)),
186
+ };
187
+ }
188
+
189
+ /** 本地过滤:只保留可直接运行(npx / uvx)的条目。纯函数,不发请求。 */
190
+ export function filterCommunityRows(rows, options = {}) {
191
+ const list = asArray(rows).filter(Boolean);
192
+ if (options.onlyRunnable === false) return list;
193
+ return list.filter((row) => row.runnable);
194
+ }
195
+
196
+ /**
197
+ * 响应防御式归一:`servers` 非数组归空、`nextCursor` 非字符串归 null。
198
+ * 未知字段一律忽略(Registry 仍是 v0,字段可能升级)。
199
+ */
200
+ export function normalizeRegistryResponse(raw) {
201
+ const servers = raw && Array.isArray(raw.servers) ? raw.servers : [];
202
+ const cursor = raw && raw.metadata && typeof raw.metadata.nextCursor === 'string' && raw.metadata.nextCursor
203
+ ? raw.metadata.nextCursor
204
+ : null;
205
+ return { servers, nextCursor: cursor };
206
+ }
207
+
208
+ /** limit 钳制:非法 / < 1 → 1,> 100 → 100。 */
209
+ function clampLimit(value) {
210
+ const number = Number(value);
211
+ if (!Number.isFinite(number)) return COMMUNITY_PAGE_LIMIT;
212
+ return Math.min(COMMUNITY_PAGE_LIMIT, Math.max(1, Math.floor(number)));
213
+ }
214
+
215
+ /** 拼请求地址:cursor 必须经 URLSearchParams 编码(游标里含 `:` 与 `/`)。 */
216
+ export function buildRegistryUrl(payload = {}) {
217
+ const params = new URLSearchParams();
218
+ params.set('limit', String(clampLimit(payload.limit)));
219
+ // version=latest:服务端只回最新版,省掉本地按版本去重。
220
+ params.set('version', 'latest');
221
+ const search = asString(payload.search).trim();
222
+ if (search) params.set('search', search);
223
+ const cursor = asString(payload.cursor).trim();
224
+ if (cursor) params.set('cursor', cursor);
225
+ return `${MCP_REGISTRY_SOURCES[0]}?${params.toString()}`;
226
+ }
227
+
228
+ /** 单次 GET JSON:超时 + 仅网络错误/5xx 重试 1 次;4xx 与解析失败直接放弃。 */
229
+ async function fetchJson(url, options = {}) {
230
+ const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch;
231
+ if (typeof fetchImpl !== 'function') throw new Error('当前宿主没有 fetch,无法读取社区目录');
232
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : FETCH_TIMEOUT_MS;
233
+ for (let attempt = 0; attempt < 2; attempt += 1) {
234
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
235
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
236
+ try {
237
+ const response = await fetchImpl(url, {
238
+ method: 'GET',
239
+ headers: { accept: 'application/json' },
240
+ signal: controller ? controller.signal : undefined,
241
+ });
242
+ const status = Number(response?.status) || 0;
243
+ // ok 是权威判定,不能只信 status:代理 / 矿化响应可能 ok=false 却没有状态码。
244
+ // status 0 视为网关层异常(可重试一次),4xx 明确不重试,5xx 重试一次。
245
+ if (!response || response.ok === false) {
246
+ const error = new Error(`HTTP ${status || '未知'}`);
247
+ error.retryable = status === 0 || status >= 500;
248
+ throw error;
249
+ }
250
+ const body = typeof response.json === 'function' ? await response.json() : response;
251
+ return body;
252
+ } catch (cause) {
253
+ // 非 HTTP 错误(网络 / 超时 / 解析)与 5xx 才重试;4xx 明确不重试。
254
+ if (attempt === 0 && cause?.retryable !== false) continue;
255
+ throw cause;
256
+ } finally {
257
+ if (timer) clearTimeout(timer);
258
+ }
259
+ }
260
+ throw new Error('读取社区目录失败');
261
+ }
262
+
263
+ function cacheKeyOf(payload) {
264
+ return [
265
+ asString(payload.search).trim(),
266
+ asString(payload.cursor).trim(),
267
+ String(clampLimit(payload.limit)),
268
+ ].join('|');
269
+ }
270
+
271
+ function readCache(key, now, ttlMs) {
272
+ const hit = cache.get(key);
273
+ if (!hit) return null;
274
+ if (now - hit.fetchedAt > ttlMs) {
275
+ cache.delete(key);
276
+ return null;
277
+ }
278
+ return hit;
279
+ }
280
+
281
+ function writeCache(key, value) {
282
+ if (cache.has(key)) cache.delete(key);
283
+ cache.set(key, value);
284
+ while (cache.size > CACHE_MAX_ENTRIES) {
285
+ const oldest = cache.keys().next().value;
286
+ cache.delete(oldest);
287
+ }
288
+ }
289
+
290
+ /** 测试隔离口:清空内存缓存(生产代码不调用)。 */
291
+ export function resetCommunityCache() {
292
+ cache.clear();
293
+ }
294
+
295
+ /**
296
+ * 社区 MCP 列表(一次游标请求)。
297
+ *
298
+ * @param {{search?: string, cursor?: string, limit?: number, takenNames?: string[]}} payload
299
+ * @param {{fetchImpl?: Function, now?: Function, timeoutMs?: number, cacheTtlMs?: number}} options
300
+ * @returns {Promise<{ok:true, rows:object[], nextCursor:string|null, fetchedAt:number, cached:boolean}
301
+ * | {ok:false, error:string}>}
302
+ *
303
+ * 永不抛错:所有失败都收敛成 `{ok:false, error}`,面板只负责把错误落在列表内。
304
+ * `takenNames` 来自调用方的 MCP 列表,用于 serverName 冲突后缀;本函数内已分配的
305
+ * 名字也会累加进去,保证同批两条同尾段条目不会撞名。
306
+ */
307
+ export async function wbMcpCommunityList(payload = {}, options = {}) {
308
+ const input = payload && typeof payload === 'object' ? payload : {};
309
+ const now = typeof options.now === 'function' ? options.now : Date.now;
310
+ const ttlMs = Number(options.cacheTtlMs) > 0 ? Number(options.cacheTtlMs) : CACHE_TTL_MS;
311
+ const key = cacheKeyOf(input);
312
+ const hit = readCache(key, now(), ttlMs);
313
+ if (hit) {
314
+ // serverName 取决于「当前已装了哪些 MCP」:缓存只存上游映射,命中时按本次
315
+ // takenNames 重新分配冲突后缀,否则用户装完一条再回列表仍看到旧名字。
316
+ const taken = [...asArray(input.takenNames)];
317
+ const seen = new Set();
318
+ const rows = hit.rows.map((row) => {
319
+ let serverName = sanitizeServerName(row.name, taken);
320
+ if (seen.has(serverName)) serverName = sanitizeServerName(row.name, taken);
321
+ seen.add(serverName);
322
+ taken.push(serverName);
323
+ return serverName === row.serverName ? row : { ...row, serverName };
324
+ });
325
+ return { ok: true, rows, nextCursor: hit.nextCursor, fetchedAt: hit.fetchedAt, cached: true };
326
+ }
327
+
328
+ let raw;
329
+ try {
330
+ raw = await fetchJson(buildRegistryUrl(input), options);
331
+ } catch (cause) {
332
+ return { ok: false, error: String(cause?.message || cause) };
333
+ }
334
+
335
+ const { servers, nextCursor } = normalizeRegistryResponse(raw);
336
+ const taken = [...asArray(input.takenNames)];
337
+ const rows = [];
338
+ const seen = new Set();
339
+ for (const entry of servers) {
340
+ const row = mapRegistryServer(entry, { takenNames: taken });
341
+ if (!row) continue;
342
+ // 同一页里可能同时出现同一 serverName(同名不同版本已被 version=latest 挡掉,
343
+ // 这里只防尾段撞名的不同条目):后出现的自动后缀。
344
+ if (seen.has(row.serverName)) row.serverName = sanitizeServerName(row.name, taken);
345
+ seen.add(row.serverName);
346
+ taken.push(row.serverName);
347
+ rows.push(row);
348
+ }
349
+
350
+ const value = { rows, nextCursor, fetchedAt: now() };
351
+ writeCache(key, value);
352
+ return { ok: true, rows, nextCursor, fetchedAt: value.fetchedAt, cached: false };
353
+ }