@proofofwork-agency/toolpin 0.2.3
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/CONTRIBUTING.md +117 -0
- package/LICENSE +183 -0
- package/README.md +323 -0
- package/SECURITY.md +61 -0
- package/action.yml +134 -0
- package/dist/canonicalJson.js +38 -0
- package/dist/capabilities.js +139 -0
- package/dist/ci.js +26 -0
- package/dist/cli.js +1843 -0
- package/dist/clientSupport.js +76 -0
- package/dist/codexToml.js +213 -0
- package/dist/config.js +337 -0
- package/dist/constants.js +3 -0
- package/dist/continueYaml.js +76 -0
- package/dist/doctor.js +163 -0
- package/dist/install.js +191 -0
- package/dist/installed.js +405 -0
- package/dist/integrity.js +14 -0
- package/dist/inventory.js +169 -0
- package/dist/packageIntegrity.js +153 -0
- package/dist/plan.js +595 -0
- package/dist/policy.js +310 -0
- package/dist/registry.js +1610 -0
- package/dist/runtimeAdvisory.js +80 -0
- package/dist/safeFetch.js +157 -0
- package/dist/sarif.js +162 -0
- package/dist/scan.js +113 -0
- package/dist/search.js +44 -0
- package/dist/secrets.js +165 -0
- package/dist/signing.js +146 -0
- package/dist/tester.js +240 -0
- package/dist/trust.js +528 -0
- package/dist/tui/app.js +1731 -0
- package/dist/tui/command.js +50 -0
- package/dist/tui/configSnippet.js +11 -0
- package/dist/tui/constants.js +37 -0
- package/dist/tui/format.js +31 -0
- package/dist/tui/installedState.js +23 -0
- package/dist/tui/layout.js +65 -0
- package/dist/tui/selectors.js +282 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/ui/trust.js +77 -0
- package/dist/tui/views/installed.js +82 -0
- package/dist/tui/views/panels.js +637 -0
- package/dist/tui.js +12 -0
- package/dist/types.js +1 -0
- package/dist/verificationTrust.js +103 -0
- package/dist/verify.js +537 -0
- package/dist/version.js +1 -0
- package/dist/versions.js +127 -0
- package/docs/assets/readme/terminal-demo.svg +174 -0
- package/docs/assets/readme/tui-browse-overview.jpg +0 -0
- package/docs/assets/readme/tui-config-preview.jpg +0 -0
- package/docs/assets/readme/tui-help.jpg +0 -0
- package/docs/assets/readme/tui-installed-inventory.jpg +0 -0
- package/docs/how-to/catch-drift-in-ci.md +189 -0
- package/docs/how-to/custom-registries.md +156 -0
- package/docs/how-to/toolpin-curated-registry.md +153 -0
- package/package.json +76 -0
- package/registry/README.md +92 -0
- package/registry/v0/servers +115 -0
package/dist/registry.js
ADDED
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import { safeFetchJson } from "./safeFetch.js";
|
|
7
|
+
import { compareVersionish } from "./versions.js";
|
|
8
|
+
const DEFAULT_REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0";
|
|
9
|
+
const TOOLPIN_REGISTRY_FILE = new URL("../registry/v0/servers", import.meta.url);
|
|
10
|
+
const TOOLPIN_REGISTRY_URL = "https://raw.githubusercontent.com/proofofwork-agency/toolpin/main/registry/v0/servers";
|
|
11
|
+
const TOOLPIN_FALLBACK_ERROR = "ToolPin hosted registry fetch failed; using bundled fallback snapshot";
|
|
12
|
+
const DEFAULT_CACHE_PATH = path.join(process.cwd(), ".toolpin", "registry-cache.json");
|
|
13
|
+
const DEFAULT_REGISTRY_CONFIG_PATH = path.join(process.cwd(), ".toolpin", "registries.json");
|
|
14
|
+
const DOCKER_TREE_URL = "https://api.github.com/repos/docker/mcp-registry/git/trees/main?recursive=1";
|
|
15
|
+
const DOCKER_RAW_BASE = "https://raw.githubusercontent.com/docker/mcp-registry/main";
|
|
16
|
+
const GLAMA_SERVERS_URL = "https://glama.ai/api/mcp/v1/servers";
|
|
17
|
+
const SMITHERY_SERVERS_URL = "https://api.smithery.ai/servers";
|
|
18
|
+
const PULSEMCP_SERVERS_URL = "https://api.pulsemcp.com/v0.1/servers";
|
|
19
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
20
|
+
const DEFAULT_RETRY_BACKOFF_MS = 100;
|
|
21
|
+
const DEFAULT_DOCKER_CONCURRENCY = 12;
|
|
22
|
+
const MAX_DOCKER_CONCURRENCY = 50;
|
|
23
|
+
const MAX_RETRY_AFTER_MS = 5_000;
|
|
24
|
+
const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
const DEFAULT_OFFICIAL_MAX_PAGES = 25;
|
|
26
|
+
export const BUILTIN_REGISTRY_SOURCES = [
|
|
27
|
+
{
|
|
28
|
+
id: "toolpin",
|
|
29
|
+
label: "ToolPin Curated Registry",
|
|
30
|
+
type: "toolpin",
|
|
31
|
+
adapter: "http-json",
|
|
32
|
+
mode: "installable",
|
|
33
|
+
trust: "curated",
|
|
34
|
+
enabled: true,
|
|
35
|
+
pinned: true,
|
|
36
|
+
authRequired: false,
|
|
37
|
+
url: TOOLPIN_REGISTRY_URL,
|
|
38
|
+
description: "GitHub-backed ToolPin curated registry with bundled fallback. This source is pinned, PR-reviewed, and cannot be disabled.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "official",
|
|
42
|
+
label: "Official MCP Registry",
|
|
43
|
+
type: "official",
|
|
44
|
+
adapter: "official-compatible",
|
|
45
|
+
mode: "installable",
|
|
46
|
+
trust: "canonical",
|
|
47
|
+
enabled: true,
|
|
48
|
+
authRequired: false,
|
|
49
|
+
description: "Canonical public MCP server metadata registry.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "docker",
|
|
53
|
+
label: "Docker MCP Catalog",
|
|
54
|
+
type: "docker",
|
|
55
|
+
adapter: "http-json",
|
|
56
|
+
mode: "installable",
|
|
57
|
+
trust: "curated",
|
|
58
|
+
enabled: true,
|
|
59
|
+
authRequired: false,
|
|
60
|
+
description: "Curated Docker MCP catalog with reviewed container/remote entries.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "pulsemcp",
|
|
64
|
+
label: "PulseMCP",
|
|
65
|
+
type: "pulsemcp",
|
|
66
|
+
adapter: "pulsemcp",
|
|
67
|
+
mode: "discovery",
|
|
68
|
+
trust: "directory",
|
|
69
|
+
enabled: false,
|
|
70
|
+
authRequired: true,
|
|
71
|
+
url: PULSEMCP_SERVERS_URL,
|
|
72
|
+
status: "auth-missing",
|
|
73
|
+
setupHint: "Set PULSEMCP_API_KEY and PULSEMCP_TENANT_ID to enable PulseMCP discovery.",
|
|
74
|
+
description: "PulseMCP directory discovery source. Entries stay discovery-only unless verified package or remote metadata is present.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "smithery",
|
|
78
|
+
label: "Smithery",
|
|
79
|
+
type: "smithery",
|
|
80
|
+
adapter: "smithery",
|
|
81
|
+
mode: "discovery",
|
|
82
|
+
trust: "directory",
|
|
83
|
+
enabled: false,
|
|
84
|
+
authRequired: false,
|
|
85
|
+
url: SMITHERY_SERVERS_URL,
|
|
86
|
+
status: "discovery-only",
|
|
87
|
+
setupHint: "Optionally set SMITHERY_API_KEY for higher Smithery rate limits.",
|
|
88
|
+
description: "Smithery directory discovery source. Hosted deployment targets are installable only with explicit opt-in (--allow-hosted-directory-targets) and are subject to Smithery terms; otherwise entries stay discovery-only.",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "glama",
|
|
92
|
+
label: "Glama",
|
|
93
|
+
type: "glama",
|
|
94
|
+
adapter: "glama",
|
|
95
|
+
mode: "discovery",
|
|
96
|
+
trust: "directory",
|
|
97
|
+
enabled: false,
|
|
98
|
+
authRequired: false,
|
|
99
|
+
url: GLAMA_SERVERS_URL,
|
|
100
|
+
status: "discovery-only",
|
|
101
|
+
description: "Glama public MCP directory discovery source. Glama exposes repository metadata only; servers stay discovery-only until they surface a verifiable install target (install via the official registry instead).",
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
export const REGISTRY_SOURCES = BUILTIN_REGISTRY_SOURCES;
|
|
105
|
+
export async function fetchRegistry(options = {}) {
|
|
106
|
+
const result = await fetchRegistryResult(options);
|
|
107
|
+
return dedupeRegistryEntries(result.entries);
|
|
108
|
+
}
|
|
109
|
+
export async function fetchRegistryResult(options = {}) {
|
|
110
|
+
const source = options.source ?? "official";
|
|
111
|
+
const adapters = await registryAdapters(options.registryConfigPath);
|
|
112
|
+
if (source === "all") {
|
|
113
|
+
const fetchable = adapters.filter((adapter) => adapter.info.enabled);
|
|
114
|
+
const results = await Promise.all(fetchable.map(async (adapter) => {
|
|
115
|
+
try {
|
|
116
|
+
return await adapter.fetch({ ...options, source: adapter.info.id });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return fetchErrorResult(adapter.info, error);
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
const entries = dedupeRegistryEntries(results.flatMap((result) => result.entries));
|
|
123
|
+
const failed = results.reduce((count, result) => count + result.failed + (result.status === "fetch-error" ? 1 : 0), 0);
|
|
124
|
+
return {
|
|
125
|
+
source: allSourcesInfo(),
|
|
126
|
+
status: entries.length ? (failed ? "stale" : "ready") : results.some((result) => result.status === "auth-missing") ? "auth-missing" : "fetch-error",
|
|
127
|
+
entries,
|
|
128
|
+
accepted: results.reduce((count, result) => count + result.accepted, 0),
|
|
129
|
+
skipped: results.reduce((count, result) => count + result.skipped, 0),
|
|
130
|
+
malformed: results.reduce((count, result) => count + result.malformed, 0),
|
|
131
|
+
failed,
|
|
132
|
+
lastError: results.filter((result) => result.lastError).map((result) => `${result.source.id}: ${result.lastError}`).join("; ") || undefined,
|
|
133
|
+
fetchedAt: new Date().toISOString(),
|
|
134
|
+
results,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const adapter = adapters.find((candidate) => candidate.info.id === source);
|
|
138
|
+
if (!adapter) {
|
|
139
|
+
throw new Error(`Unknown registry source: ${source}. Add it to .toolpin/registries.json or run \`toolpin registry list\`.`);
|
|
140
|
+
}
|
|
141
|
+
if (!adapter.info.enabled) {
|
|
142
|
+
throw new Error(`Registry source ${source} is disabled. Run \`toolpin registry enable ${source}\` to enable it.`);
|
|
143
|
+
}
|
|
144
|
+
return adapter.fetch(options);
|
|
145
|
+
}
|
|
146
|
+
export async function listRegistrySources(options = {}) {
|
|
147
|
+
return (await registryAdapters(options.registryConfigPath)).map((adapter) => adapter.info);
|
|
148
|
+
}
|
|
149
|
+
export async function listRegistrySourceStatuses(options = {}) {
|
|
150
|
+
const sources = await listRegistrySources({ registryConfigPath: options.registryConfigPath });
|
|
151
|
+
const cache = await readCacheMetadata(options.cachePath).catch(() => undefined);
|
|
152
|
+
return sources.map((source) => {
|
|
153
|
+
const partition = cache?.sources[source.id];
|
|
154
|
+
return {
|
|
155
|
+
...source,
|
|
156
|
+
status: source.enabled ? partition?.status ?? source.status ?? sourceStatus(source) : "disabled",
|
|
157
|
+
setupHint: source.setupHint,
|
|
158
|
+
cacheEntries: partition?.entries.length,
|
|
159
|
+
cachePageInfo: partition?.pageInfo,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
export async function readRegistryConfig(configPath = DEFAULT_REGISTRY_CONFIG_PATH) {
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = await readFile(configPath, "utf8");
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error.code === "ENOENT")
|
|
170
|
+
return { registries: [], sources: {} };
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(raw);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
throw new Error(`Invalid registry config JSON in ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
179
|
+
}
|
|
180
|
+
if (!isRegistryConfig(parsed)) {
|
|
181
|
+
throw new Error(`Invalid registry config schema in ${configPath}: expected { "registries": [...] }.`);
|
|
182
|
+
}
|
|
183
|
+
return parsed;
|
|
184
|
+
}
|
|
185
|
+
export async function updateRegistrySourceEnabled(sourceId, enabled, configPath = DEFAULT_REGISTRY_CONFIG_PATH) {
|
|
186
|
+
const config = await readRegistryConfig(configPath);
|
|
187
|
+
const known = await listRegistrySources({ registryConfigPath: configPath });
|
|
188
|
+
const source = known.find((entry) => entry.id === sourceId);
|
|
189
|
+
if (!source) {
|
|
190
|
+
throw new Error(`Unknown registry source: ${sourceId}. Run \`toolpin registry list\` to see available sources.`);
|
|
191
|
+
}
|
|
192
|
+
if (source.pinned && !enabled) {
|
|
193
|
+
throw new Error(`Registry source ${sourceId} is pinned and cannot be disabled.`);
|
|
194
|
+
}
|
|
195
|
+
const next = {
|
|
196
|
+
registries: config.registries,
|
|
197
|
+
sources: {
|
|
198
|
+
...(config.sources ?? {}),
|
|
199
|
+
[sourceId]: {
|
|
200
|
+
...(config.sources?.[sourceId] ?? {}),
|
|
201
|
+
enabled,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
206
|
+
await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
207
|
+
return next;
|
|
208
|
+
}
|
|
209
|
+
async function registryAdapters(configPath) {
|
|
210
|
+
const config = await readRegistryConfig(configPath);
|
|
211
|
+
const builtins = BUILTIN_REGISTRY_SOURCES.map((baseInfo) => {
|
|
212
|
+
const info = applySourcePreference(baseInfo, config.sources?.[baseInfo.id]);
|
|
213
|
+
return {
|
|
214
|
+
info,
|
|
215
|
+
fetch: info.id === "toolpin"
|
|
216
|
+
? (options) => fetchToolPinRegistry({ ...options, sourceInfo: info })
|
|
217
|
+
: info.id === "official"
|
|
218
|
+
? (options) => fetchOfficialRegistry({ ...options, sourceInfo: info })
|
|
219
|
+
: info.id === "docker"
|
|
220
|
+
? (options) => fetchDockerRegistry({ ...options, sourceInfo: info })
|
|
221
|
+
: info.id === "glama"
|
|
222
|
+
? (options) => fetchGlamaRegistry(info, options)
|
|
223
|
+
: info.id === "smithery"
|
|
224
|
+
? (options) => fetchSmitheryRegistry(info, options)
|
|
225
|
+
: info.id === "pulsemcp"
|
|
226
|
+
? (options) => fetchPulseMcpRegistry(info, options)
|
|
227
|
+
: async () => emptyResult(info, sourceStatus(info)),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
const custom = config.registries.map(configuredRegistryAdapter);
|
|
231
|
+
return mergeAdapters([...builtins, ...custom]);
|
|
232
|
+
}
|
|
233
|
+
function applySourcePreference(source, preference) {
|
|
234
|
+
const enabled = source.pinned ? true : preference?.enabled ?? source.enabled;
|
|
235
|
+
return {
|
|
236
|
+
...source,
|
|
237
|
+
enabled,
|
|
238
|
+
status: enabled ? source.status : "disabled",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function configuredRegistryAdapter(config) {
|
|
242
|
+
const adapter = adapterKind(config.adapter ?? config.type);
|
|
243
|
+
const type = config.type ?? (adapter === "official-compatible" || adapter === "http-json" ? "custom" : adapter);
|
|
244
|
+
const url = config.url ?? defaultUrlForAdapter(adapter);
|
|
245
|
+
const authEnv = config.authEnv ?? firstAuthEnv(config.auth?.env);
|
|
246
|
+
const mode = config.mode ?? (adapter === "official-compatible" ? "installable" : "discovery");
|
|
247
|
+
const info = {
|
|
248
|
+
id: config.id,
|
|
249
|
+
label: config.label ?? config.id,
|
|
250
|
+
type,
|
|
251
|
+
adapter,
|
|
252
|
+
mode,
|
|
253
|
+
trust: config.trust ?? "private",
|
|
254
|
+
enabled: config.enabled !== false,
|
|
255
|
+
authRequired: Boolean(authEnv),
|
|
256
|
+
description: config.description ?? `${type} registry configured in .toolpin/registries.json.`,
|
|
257
|
+
url,
|
|
258
|
+
status: config.enabled === false ? "disabled" : mode === "discovery" ? "discovery-only" : "ready",
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
info,
|
|
262
|
+
fetch: adapter === "http-json"
|
|
263
|
+
? (options) => fetchHttpJsonRegistry(requiredUrl(url, info), info, options)
|
|
264
|
+
: adapter === "glama"
|
|
265
|
+
? (options) => fetchGlamaRegistry(info, options)
|
|
266
|
+
: adapter === "smithery"
|
|
267
|
+
? (options) => fetchSmitheryRegistry(info, options)
|
|
268
|
+
: adapter === "pulsemcp"
|
|
269
|
+
? (options) => fetchPulseMcpRegistry(info, options)
|
|
270
|
+
: (options) => fetchOfficialRegistry({ ...options, registryUrl: requiredUrl(url, info), sourceInfo: info }),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
async function fetchToolPinRegistry(options = {}) {
|
|
274
|
+
const sourceInfo = options.sourceInfo ?? BUILTIN_REGISTRY_SOURCES.find((source) => source.id === "toolpin");
|
|
275
|
+
try {
|
|
276
|
+
const response = await fetchWithRetry(TOOLPIN_REGISTRY_URL, { headers: githubHeaders() }, options, "ToolPin hosted registry request");
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
throw new Error(`ToolPin hosted registry request failed: ${response.status} ${response.statusText}`);
|
|
279
|
+
}
|
|
280
|
+
const body = await responseJson(response, "ToolPin hosted registry response");
|
|
281
|
+
return parseToolPinRegistryResult(body, sourceInfo, options);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
const fallback = await readBundledToolPinRegistry(sourceInfo, options);
|
|
285
|
+
return {
|
|
286
|
+
...fallback,
|
|
287
|
+
source: { ...fallback.source, status: "stale" },
|
|
288
|
+
status: "stale",
|
|
289
|
+
failed: Math.max(1, fallback.failed),
|
|
290
|
+
lastError: error instanceof Error ? `${TOOLPIN_FALLBACK_ERROR}: ${error.message}` : TOOLPIN_FALLBACK_ERROR,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function readBundledToolPinRegistry(sourceInfo, options) {
|
|
295
|
+
const raw = await readFile(TOOLPIN_REGISTRY_FILE, "utf8");
|
|
296
|
+
let body;
|
|
297
|
+
try {
|
|
298
|
+
body = JSON.parse(raw);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
throw new Error(`Invalid ToolPin curated registry JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
302
|
+
}
|
|
303
|
+
return parseToolPinRegistryResult(body, sourceInfo, options);
|
|
304
|
+
}
|
|
305
|
+
function parseToolPinRegistryResult(body, sourceInfo, options) {
|
|
306
|
+
const parsed = extractHttpJsonEntries(body);
|
|
307
|
+
if (!parsed) {
|
|
308
|
+
throw new Error("Registry schema drift: expected ToolPin curated registry to include a servers array.");
|
|
309
|
+
}
|
|
310
|
+
const search = options.search?.trim().toLowerCase();
|
|
311
|
+
const limit = Math.max(1, options.limit ?? 500);
|
|
312
|
+
const matched = parsed.entries.filter((entry) => !search || searchableText(entry.server).includes(search));
|
|
313
|
+
const entries = matched.slice(0, limit).map((entry) => tagRegistryEntry(entry, sourceInfo));
|
|
314
|
+
return successResult(sourceInfo, entries, {
|
|
315
|
+
accepted: entries.length,
|
|
316
|
+
skipped: parsed.report.skipped,
|
|
317
|
+
malformed: parsed.report.malformed,
|
|
318
|
+
failed: parsed.report.failed,
|
|
319
|
+
pageInfo: { fetchedPages: 1, maxPages: 1, hasMore: matched.length > entries.length, total: matched.length },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async function fetchOfficialRegistry(options = {}) {
|
|
323
|
+
const sourceInfo = options.sourceInfo ?? BUILTIN_REGISTRY_SOURCES.find((source) => source.id === "official");
|
|
324
|
+
const registryUrl = options.registryUrl ?? sourceInfo.url ?? DEFAULT_REGISTRY_URL;
|
|
325
|
+
const limit = Math.min(options.limit ?? 100, 100);
|
|
326
|
+
const maxPages = options.maxPages ?? DEFAULT_OFFICIAL_MAX_PAGES;
|
|
327
|
+
const entries = [];
|
|
328
|
+
let cursor;
|
|
329
|
+
let hasMore = false;
|
|
330
|
+
let total;
|
|
331
|
+
let fetchedPages = 0;
|
|
332
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
333
|
+
const url = new URL(`${registryUrl.replace(/\/$/, "")}/servers`);
|
|
334
|
+
url.searchParams.set("limit", String(limit));
|
|
335
|
+
if (cursor)
|
|
336
|
+
url.searchParams.set("cursor", cursor);
|
|
337
|
+
if (options.search)
|
|
338
|
+
url.searchParams.set("search", options.search);
|
|
339
|
+
const response = await fetchWithRetry(url, {}, options, "Registry request");
|
|
340
|
+
if (!response.ok) {
|
|
341
|
+
throw new Error(`Registry request failed: ${response.status} ${response.statusText}`);
|
|
342
|
+
}
|
|
343
|
+
const body = await responseJson(response, "official registry response");
|
|
344
|
+
if (!isRegistryListResponse(body)) {
|
|
345
|
+
throw new Error("Registry schema drift: expected official registry response to include a servers array.");
|
|
346
|
+
}
|
|
347
|
+
entries.push(...body.servers.map((entry) => tagRegistryEntry(entry, sourceInfo)));
|
|
348
|
+
fetchedPages += 1;
|
|
349
|
+
total = typeof body.metadata?.total === "number" ? body.metadata.total : total;
|
|
350
|
+
cursor = body.metadata?.nextCursor;
|
|
351
|
+
hasMore = Boolean(cursor);
|
|
352
|
+
if (!cursor)
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
return successResult(sourceInfo, entries, {
|
|
356
|
+
accepted: entries.length,
|
|
357
|
+
pageInfo: { fetchedPages, maxPages, hasMore, nextCursor: cursor, total },
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
async function fetchDockerRegistry(options = {}) {
|
|
361
|
+
const sourceInfo = options.sourceInfo ?? BUILTIN_REGISTRY_SOURCES.find((source) => source.id === "docker");
|
|
362
|
+
const limit = Math.min(options.limit ?? 100, 500);
|
|
363
|
+
const response = await fetchWithRetry(DOCKER_TREE_URL, { headers: githubHeaders() }, options, "Docker registry request");
|
|
364
|
+
if (!response.ok) {
|
|
365
|
+
throw new Error(`Docker registry request failed: ${response.status} ${response.statusText}`);
|
|
366
|
+
}
|
|
367
|
+
const body = await responseJson(response, "Docker registry tree response");
|
|
368
|
+
if (!isDockerTreeResponse(body)) {
|
|
369
|
+
throw new Error("Registry schema drift: expected Docker registry tree response to include a tree array.");
|
|
370
|
+
}
|
|
371
|
+
const fetchCount = options.search ? 500 : limit;
|
|
372
|
+
const serverPaths = body.tree
|
|
373
|
+
.map((entry) => entry.path)
|
|
374
|
+
.filter((entryPath) => /^servers\/[^/]+\/server\.yaml$/.test(entryPath))
|
|
375
|
+
.slice(0, fetchCount);
|
|
376
|
+
const concurrency = clampInteger(options.dockerConcurrency ?? DEFAULT_DOCKER_CONCURRENCY, 1, MAX_DOCKER_CONCURRENCY);
|
|
377
|
+
const report = { accepted: 0, skipped: 0, malformed: 0, failed: 0, reasons: [] };
|
|
378
|
+
const entries = await mapConcurrent(serverPaths, concurrency, async (entryPath) => {
|
|
379
|
+
try {
|
|
380
|
+
const raw = await fetchText(`${DOCKER_RAW_BASE}/${entryPath}`, options);
|
|
381
|
+
const parsed = dockerYamlToEntry(raw, entryPath);
|
|
382
|
+
if (!parsed.entry) {
|
|
383
|
+
report.skipped += 1;
|
|
384
|
+
report.reasons.push(`${entryPath}: ${parsed.reason}`);
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
report.accepted += 1;
|
|
388
|
+
return tagRegistryEntry(parsed.entry, sourceInfo);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
report.failed += 1;
|
|
392
|
+
report.reasons.push(`${entryPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
if (report.failed || report.skipped || report.malformed)
|
|
397
|
+
warnRegistryReport("Docker registry", report, options);
|
|
398
|
+
const acceptedEntries = entries.filter((entry) => Boolean(entry));
|
|
399
|
+
return successResult(sourceInfo, acceptedEntries, {
|
|
400
|
+
accepted: report.accepted,
|
|
401
|
+
skipped: report.skipped,
|
|
402
|
+
malformed: report.malformed,
|
|
403
|
+
failed: report.failed,
|
|
404
|
+
pageInfo: { fetchedPages: 1, maxPages: 1, hasMore: serverPaths.length < body.tree.length },
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
async function fetchHttpJsonRegistry(url, sourceInfo, options = {}) {
|
|
408
|
+
const response = await fetchWithRetry(url, {}, options, `${sourceInfo.label} registry request`);
|
|
409
|
+
if (!response.ok) {
|
|
410
|
+
throw new Error(`${sourceInfo.label} registry request failed: ${response.status} ${response.statusText}`);
|
|
411
|
+
}
|
|
412
|
+
const body = await responseJson(response, `${sourceInfo.label} registry response`);
|
|
413
|
+
const parsed = extractHttpJsonEntries(body);
|
|
414
|
+
if (!parsed) {
|
|
415
|
+
throw new Error(`Registry schema drift: expected ${sourceInfo.id} response to include a servers or entries array.`);
|
|
416
|
+
}
|
|
417
|
+
if (parsed.report.skipped || parsed.report.malformed || parsed.report.failed) {
|
|
418
|
+
warnRegistryReport(sourceInfo.label, parsed.report, options);
|
|
419
|
+
}
|
|
420
|
+
const entries = parsed.entries.map((entry) => tagRegistryEntry(entry, sourceInfo));
|
|
421
|
+
return successResult(sourceInfo, entries, {
|
|
422
|
+
accepted: parsed.report.accepted,
|
|
423
|
+
skipped: parsed.report.skipped,
|
|
424
|
+
malformed: parsed.report.malformed,
|
|
425
|
+
failed: parsed.report.failed,
|
|
426
|
+
pageInfo: { fetchedPages: 1, maxPages: 1, hasMore: false },
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async function fetchGlamaRegistry(sourceInfo, options = {}) {
|
|
430
|
+
const urlBase = sourceInfo.url ?? GLAMA_SERVERS_URL;
|
|
431
|
+
const first = Math.min(options.limit ?? 100, 100);
|
|
432
|
+
const maxPages = options.maxPages ?? 5;
|
|
433
|
+
return fetchCursorDirectory(sourceInfo, options, {
|
|
434
|
+
urlBase,
|
|
435
|
+
maxPages,
|
|
436
|
+
buildUrl: (cursor) => {
|
|
437
|
+
const url = new URL(urlBase);
|
|
438
|
+
url.searchParams.set("first", String(first));
|
|
439
|
+
if (cursor)
|
|
440
|
+
url.searchParams.set("after", cursor);
|
|
441
|
+
if (options.search)
|
|
442
|
+
url.searchParams.set("query", options.search);
|
|
443
|
+
return url;
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
async function fetchSmitheryRegistry(sourceInfo, options = {}) {
|
|
448
|
+
const urlBase = sourceInfo.url ?? SMITHERY_SERVERS_URL;
|
|
449
|
+
const headers = {};
|
|
450
|
+
if (process.env.SMITHERY_API_KEY)
|
|
451
|
+
headers.Authorization = `Bearer ${process.env.SMITHERY_API_KEY}`;
|
|
452
|
+
return fetchCursorDirectory(sourceInfo, options, {
|
|
453
|
+
urlBase,
|
|
454
|
+
headers,
|
|
455
|
+
maxPages: options.maxPages ?? 3,
|
|
456
|
+
buildUrl: (cursor) => {
|
|
457
|
+
const url = new URL(urlBase);
|
|
458
|
+
url.searchParams.set("pageSize", String(Math.min(options.limit ?? 100, 100)));
|
|
459
|
+
if (cursor)
|
|
460
|
+
url.searchParams.set("cursor", cursor);
|
|
461
|
+
if (options.search)
|
|
462
|
+
url.searchParams.set("q", options.search);
|
|
463
|
+
return url;
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async function fetchPulseMcpRegistry(sourceInfo, options = {}) {
|
|
468
|
+
const apiKey = process.env.PULSEMCP_API_KEY;
|
|
469
|
+
const tenantId = process.env.PULSEMCP_TENANT_ID;
|
|
470
|
+
if (!apiKey || !tenantId) {
|
|
471
|
+
return emptyResult({
|
|
472
|
+
...sourceInfo,
|
|
473
|
+
status: "auth-missing",
|
|
474
|
+
setupHint: sourceInfo.setupHint ?? "Set PULSEMCP_API_KEY and PULSEMCP_TENANT_ID to enable PulseMCP discovery.",
|
|
475
|
+
}, "auth-missing", "Missing PULSEMCP_API_KEY or PULSEMCP_TENANT_ID.");
|
|
476
|
+
}
|
|
477
|
+
const urlBase = sourceInfo.url ?? PULSEMCP_SERVERS_URL;
|
|
478
|
+
return fetchCursorDirectory(sourceInfo, options, {
|
|
479
|
+
urlBase,
|
|
480
|
+
headers: {
|
|
481
|
+
Authorization: `Bearer ${apiKey}`,
|
|
482
|
+
"x-tenant-id": tenantId,
|
|
483
|
+
},
|
|
484
|
+
maxPages: options.maxPages ?? 3,
|
|
485
|
+
buildUrl: (cursor) => {
|
|
486
|
+
const url = new URL(urlBase);
|
|
487
|
+
url.searchParams.set("limit", String(Math.min(options.limit ?? 100, 100)));
|
|
488
|
+
if (cursor)
|
|
489
|
+
url.searchParams.set("cursor", cursor);
|
|
490
|
+
if (options.search)
|
|
491
|
+
url.searchParams.set("query", options.search);
|
|
492
|
+
return url;
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async function fetchCursorDirectory(sourceInfo, options, config) {
|
|
497
|
+
const entries = [];
|
|
498
|
+
const report = { accepted: 0, skipped: 0, malformed: 0, failed: 0, reasons: [] };
|
|
499
|
+
let cursor;
|
|
500
|
+
let hasMore = false;
|
|
501
|
+
let total;
|
|
502
|
+
let fetchedPages = 0;
|
|
503
|
+
for (let page = 0; page < config.maxPages; page += 1) {
|
|
504
|
+
const response = await fetchWithRetry(config.buildUrl(cursor), { headers: config.headers }, options, `${sourceInfo.label} registry request`);
|
|
505
|
+
if (!response.ok) {
|
|
506
|
+
throw new Error(`${sourceInfo.label} registry request failed: ${response.status} ${response.statusText}`);
|
|
507
|
+
}
|
|
508
|
+
const body = await responseJson(response, `${sourceInfo.label} registry response`);
|
|
509
|
+
const parsed = extractDirectoryEntries(body, sourceInfo);
|
|
510
|
+
if (!parsed) {
|
|
511
|
+
throw new Error(`Registry schema drift: expected ${sourceInfo.id} response to include a servers, data, items, or results array.`);
|
|
512
|
+
}
|
|
513
|
+
entries.push(...parsed.entries);
|
|
514
|
+
report.accepted += parsed.report.accepted;
|
|
515
|
+
report.skipped += parsed.report.skipped;
|
|
516
|
+
report.malformed += parsed.report.malformed;
|
|
517
|
+
report.failed += parsed.report.failed;
|
|
518
|
+
report.reasons.push(...parsed.report.reasons);
|
|
519
|
+
fetchedPages += 1;
|
|
520
|
+
cursor = parsed.pageInfo.nextCursor;
|
|
521
|
+
total = parsed.pageInfo.total ?? total;
|
|
522
|
+
hasMore = parsed.pageInfo.hasMore;
|
|
523
|
+
if (!cursor || !hasMore)
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
if (report.skipped || report.malformed || report.failed)
|
|
527
|
+
warnRegistryReport(sourceInfo.label, report, options);
|
|
528
|
+
return successResult(sourceInfo, entries, {
|
|
529
|
+
status: sourceInfo.mode === "discovery" ? "discovery-only" : "ready",
|
|
530
|
+
accepted: report.accepted,
|
|
531
|
+
skipped: report.skipped,
|
|
532
|
+
malformed: report.malformed,
|
|
533
|
+
failed: report.failed,
|
|
534
|
+
pageInfo: { fetchedPages, maxPages: config.maxPages, hasMore, nextCursor: cursor, total },
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
async function fetchText(url, options) {
|
|
538
|
+
const response = await fetchWithRetry(url, { headers: githubHeaders() }, options, "Request");
|
|
539
|
+
if (!response.ok) {
|
|
540
|
+
throw new Error(`Request failed: ${response.status} ${response.statusText} ${url}`);
|
|
541
|
+
}
|
|
542
|
+
return response.text();
|
|
543
|
+
}
|
|
544
|
+
async function fetchWithRetry(url, init, options, errorPrefix) {
|
|
545
|
+
const fetchLike = options.fetch ?? defaultFetch;
|
|
546
|
+
const timeoutMs = Math.max(1, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
547
|
+
const retryBackoffMs = Math.max(0, options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS);
|
|
548
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
549
|
+
const response = await fetchOnce(fetchLike, url, init, timeoutMs, errorPrefix);
|
|
550
|
+
if (attempt === 0 && isRetryableStatus(response.status)) {
|
|
551
|
+
await delay(retryDelayMs(response, retryBackoffMs));
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
return response;
|
|
555
|
+
}
|
|
556
|
+
throw new Error(`${errorPrefix} failed after retry.`);
|
|
557
|
+
}
|
|
558
|
+
async function fetchOnce(fetchLike, url, init, timeoutMs, errorPrefix) {
|
|
559
|
+
const controller = new AbortController();
|
|
560
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
561
|
+
try {
|
|
562
|
+
return await fetchLike(url, { ...init, signal: controller.signal });
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (controller.signal.aborted) {
|
|
566
|
+
throw new Error(`${errorPrefix} timed out after ${timeoutMs}ms`);
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
clearTimeout(timeout);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async function defaultFetch(url, init) {
|
|
575
|
+
const response = await fetch(url, init);
|
|
576
|
+
return {
|
|
577
|
+
ok: response.ok,
|
|
578
|
+
status: response.status,
|
|
579
|
+
statusText: response.statusText,
|
|
580
|
+
headers: response.headers,
|
|
581
|
+
json: () => response.json(),
|
|
582
|
+
text: () => response.text(),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function isRetryableStatus(status) {
|
|
586
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
587
|
+
}
|
|
588
|
+
function retryDelayMs(response, fallbackMs) {
|
|
589
|
+
if (response.status !== 429)
|
|
590
|
+
return fallbackMs;
|
|
591
|
+
return retryAfterDelayMs(response.headers?.get("retry-after") ?? response.headers?.get("Retry-After"), Date.now()) ?? fallbackMs;
|
|
592
|
+
}
|
|
593
|
+
export function retryAfterDelayMs(value, nowMs = Date.now()) {
|
|
594
|
+
if (!value)
|
|
595
|
+
return undefined;
|
|
596
|
+
const trimmed = value.trim();
|
|
597
|
+
if (!trimmed)
|
|
598
|
+
return undefined;
|
|
599
|
+
if (/^\d+$/.test(trimmed)) {
|
|
600
|
+
return Math.min(Number.parseInt(trimmed, 10) * 1000, MAX_RETRY_AFTER_MS);
|
|
601
|
+
}
|
|
602
|
+
const dateMs = Date.parse(trimmed);
|
|
603
|
+
if (!Number.isFinite(dateMs))
|
|
604
|
+
return undefined;
|
|
605
|
+
return Math.min(Math.max(0, dateMs - nowMs), MAX_RETRY_AFTER_MS);
|
|
606
|
+
}
|
|
607
|
+
function delay(ms) {
|
|
608
|
+
return ms === 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms));
|
|
609
|
+
}
|
|
610
|
+
async function responseJson(response, description) {
|
|
611
|
+
try {
|
|
612
|
+
return await response.json();
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
616
|
+
throw new Error(`Invalid JSON from ${description}: ${message}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function isRegistryListResponse(value) {
|
|
620
|
+
return isRecord(value) && Array.isArray(value.servers);
|
|
621
|
+
}
|
|
622
|
+
function isDockerTreeResponse(value) {
|
|
623
|
+
return (isRecord(value) &&
|
|
624
|
+
Array.isArray(value.tree) &&
|
|
625
|
+
value.tree.every((entry) => isRecord(entry) && typeof entry.path === "string" && typeof entry.type === "string"));
|
|
626
|
+
}
|
|
627
|
+
function dockerYamlToEntry(raw, entryPath) {
|
|
628
|
+
const parsed = parseYaml(raw);
|
|
629
|
+
if (!isRecord(parsed))
|
|
630
|
+
return { reason: "YAML root is not an object" };
|
|
631
|
+
if (typeof parsed.name !== "string" || !parsed.name)
|
|
632
|
+
return { reason: "missing name" };
|
|
633
|
+
const name = String(parsed.name);
|
|
634
|
+
const about = asRecord(parsed.about);
|
|
635
|
+
const source = asRecord(parsed.source);
|
|
636
|
+
const config = asRecord(parsed.config);
|
|
637
|
+
const title = String(about.title ?? name);
|
|
638
|
+
const description = String(about.description ?? "");
|
|
639
|
+
const version = String(source.commit ?? source.branch ?? "docker-catalog");
|
|
640
|
+
const repositoryUrl = typeof source.project === "string" ? source.project : "https://github.com/docker/mcp-registry";
|
|
641
|
+
const secrets = Array.isArray(config.secrets) ? config.secrets : [];
|
|
642
|
+
const envVars = secrets
|
|
643
|
+
.filter(isRecord)
|
|
644
|
+
.map((secret) => ({
|
|
645
|
+
name: String(secret.env ?? secret.name ?? "SECRET"),
|
|
646
|
+
description: typeof secret.description === "string" ? secret.description : undefined,
|
|
647
|
+
isRequired: true,
|
|
648
|
+
isSecret: true,
|
|
649
|
+
}));
|
|
650
|
+
const server = {
|
|
651
|
+
name: `io.docker.mcp/${name}`,
|
|
652
|
+
title,
|
|
653
|
+
description,
|
|
654
|
+
version,
|
|
655
|
+
repository: {
|
|
656
|
+
url: repositoryUrl,
|
|
657
|
+
source: "github",
|
|
658
|
+
},
|
|
659
|
+
_meta: {
|
|
660
|
+
"dev.toolpin/source": {
|
|
661
|
+
source: "docker",
|
|
662
|
+
path: entryPath,
|
|
663
|
+
category: asRecord(parsed.meta).category,
|
|
664
|
+
tags: asRecord(parsed.meta).tags,
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
const remote = asRecord(parsed.remote);
|
|
669
|
+
if (parsed.type === "remote" && typeof remote.url === "string") {
|
|
670
|
+
const headers = asRecord(remote.headers);
|
|
671
|
+
server.remotes = [{
|
|
672
|
+
type: typeof remote.transport_type === "string" ? remote.transport_type : "streamable-http",
|
|
673
|
+
url: String(remote.url),
|
|
674
|
+
headers: Object.keys(headers).map((header) => {
|
|
675
|
+
const value = String(headers[header]);
|
|
676
|
+
return {
|
|
677
|
+
name: header,
|
|
678
|
+
value,
|
|
679
|
+
env: extractEnvName(value),
|
|
680
|
+
isRequired: true,
|
|
681
|
+
isSecret: value.includes("${"),
|
|
682
|
+
};
|
|
683
|
+
}),
|
|
684
|
+
}];
|
|
685
|
+
}
|
|
686
|
+
else if (typeof parsed.image === "string" && parsed.image) {
|
|
687
|
+
server.packages = [{
|
|
688
|
+
registryType: "oci",
|
|
689
|
+
identifier: parsed.image,
|
|
690
|
+
transport: { type: "stdio" },
|
|
691
|
+
runtimeHint: "docker",
|
|
692
|
+
environmentVariables: envVars,
|
|
693
|
+
}];
|
|
694
|
+
}
|
|
695
|
+
return { entry: {
|
|
696
|
+
server,
|
|
697
|
+
_meta: {
|
|
698
|
+
"dev.toolpin/source": {
|
|
699
|
+
source: "docker",
|
|
700
|
+
path: entryPath,
|
|
701
|
+
curated: true,
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
} };
|
|
705
|
+
}
|
|
706
|
+
function tagRegistryEntry(entry, sourceInfo) {
|
|
707
|
+
const maybeEntry = entry;
|
|
708
|
+
const existingMeta = isRecord(maybeEntry._meta) ? maybeEntry._meta : {};
|
|
709
|
+
const existingServerMeta = isRecord(entry.server._meta) ? entry.server._meta : {};
|
|
710
|
+
const existingSourceMeta = isRecord(existingMeta["dev.toolpin/source"]) ? existingMeta["dev.toolpin/source"] : {};
|
|
711
|
+
const sourceMeta = {
|
|
712
|
+
...existingSourceMeta,
|
|
713
|
+
source: sourceInfo.id,
|
|
714
|
+
type: sourceInfo.type,
|
|
715
|
+
mode: sourceInfo.mode,
|
|
716
|
+
trust: sourceInfo.trust,
|
|
717
|
+
url: sourceInfo.url,
|
|
718
|
+
};
|
|
719
|
+
return {
|
|
720
|
+
...entry,
|
|
721
|
+
source: sourceInfo.id,
|
|
722
|
+
server: {
|
|
723
|
+
...entry.server,
|
|
724
|
+
_meta: {
|
|
725
|
+
...existingServerMeta,
|
|
726
|
+
"dev.toolpin/source": {
|
|
727
|
+
...(isRecord(existingServerMeta["dev.toolpin/source"]) ? existingServerMeta["dev.toolpin/source"] : {}),
|
|
728
|
+
...sourceMeta,
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
_meta: {
|
|
733
|
+
...existingMeta,
|
|
734
|
+
"dev.toolpin/source": sourceMeta,
|
|
735
|
+
},
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
export async function writeCache(entries, cachePath = DEFAULT_CACHE_PATH) {
|
|
739
|
+
const bySource = new Map();
|
|
740
|
+
for (const entry of entries) {
|
|
741
|
+
const source = entry.source ?? detectSource(entry);
|
|
742
|
+
bySource.set(source, [...(bySource.get(source) ?? []), entry]);
|
|
743
|
+
}
|
|
744
|
+
const adapters = await registryAdapters().catch(() => []);
|
|
745
|
+
const now = new Date().toISOString();
|
|
746
|
+
const results = [...bySource.entries()].map(([sourceId, sourceEntries]) => {
|
|
747
|
+
const source = adapters.find((adapter) => adapter.info.id === sourceId)?.info ?? {
|
|
748
|
+
id: sourceId,
|
|
749
|
+
label: sourceId,
|
|
750
|
+
type: "custom",
|
|
751
|
+
mode: "installable",
|
|
752
|
+
trust: "private",
|
|
753
|
+
enabled: true,
|
|
754
|
+
authRequired: false,
|
|
755
|
+
description: "Registry source inferred from cached entries.",
|
|
756
|
+
};
|
|
757
|
+
return successResult(source, sourceEntries, { accepted: sourceEntries.length, fetchedAt: now });
|
|
758
|
+
});
|
|
759
|
+
await writeCacheResults(results, cachePath);
|
|
760
|
+
}
|
|
761
|
+
export async function writeCacheResults(results, cachePath = DEFAULT_CACHE_PATH) {
|
|
762
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
763
|
+
const generatedAt = new Date().toISOString();
|
|
764
|
+
const sources = {};
|
|
765
|
+
for (const result of results) {
|
|
766
|
+
if (result.source.id === "all")
|
|
767
|
+
continue;
|
|
768
|
+
sources[result.source.id] = resultToPartition(result);
|
|
769
|
+
}
|
|
770
|
+
await writeFile(cachePath, JSON.stringify({ schema: "dev.toolpin.registry-cache.v2", generatedAt, ttlMs: DEFAULT_CACHE_TTL_MS, sources }, null, 2), "utf8");
|
|
771
|
+
}
|
|
772
|
+
export async function refreshCache(options = {}) {
|
|
773
|
+
const cachePath = options.cachePath ?? DEFAULT_CACHE_PATH;
|
|
774
|
+
const result = await fetchRegistryResult(options);
|
|
775
|
+
const existing = await readCacheMetadata(cachePath).catch(() => emptyCacheFile());
|
|
776
|
+
const next = {
|
|
777
|
+
schema: "dev.toolpin.registry-cache.v2",
|
|
778
|
+
generatedAt: new Date().toISOString(),
|
|
779
|
+
ttlMs: DEFAULT_CACHE_TTL_MS,
|
|
780
|
+
sources: { ...existing.sources },
|
|
781
|
+
};
|
|
782
|
+
const results = result.results ?? [result];
|
|
783
|
+
for (const sourceResult of results) {
|
|
784
|
+
if (sourceResult.source.id === "all")
|
|
785
|
+
continue;
|
|
786
|
+
if (sourceResult.status === "fetch-error" || sourceResult.status === "auth-missing" || sourceResult.status === "disabled") {
|
|
787
|
+
const stale = next.sources[sourceResult.source.id];
|
|
788
|
+
next.sources[sourceResult.source.id] = stale
|
|
789
|
+
? { ...stale, source: sourceResult.source, status: "stale", lastError: sourceResult.lastError, failed: stale.failed + Math.max(1, sourceResult.failed) }
|
|
790
|
+
: resultToPartition(sourceResult);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
next.sources[sourceResult.source.id] = resultToPartition(sourceResult);
|
|
794
|
+
}
|
|
795
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
796
|
+
await writeFile(cachePath, JSON.stringify(next, null, 2), "utf8");
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
export async function readCache(cachePath = DEFAULT_CACHE_PATH, options = {}) {
|
|
800
|
+
return flattenCache(await readCacheMetadata(cachePath, options));
|
|
801
|
+
}
|
|
802
|
+
export async function readCacheMetadata(cachePath = DEFAULT_CACHE_PATH, options = {}) {
|
|
803
|
+
let parsed;
|
|
804
|
+
try {
|
|
805
|
+
parsed = JSON.parse(await readFile(cachePath, "utf8"));
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
if (error instanceof SyntaxError) {
|
|
809
|
+
throw new CacheSchemaError(`Invalid registry cache JSON in ${cachePath}: ${error.message}`);
|
|
810
|
+
}
|
|
811
|
+
throw error;
|
|
812
|
+
}
|
|
813
|
+
if (!isCacheFile(parsed) && !isCacheFileV2(parsed)) {
|
|
814
|
+
throw new CacheSchemaError(`Invalid registry cache schema in ${cachePath}: expected an object with an entries array.`);
|
|
815
|
+
}
|
|
816
|
+
const cache = isCacheFileV2(parsed) ? parsed : v1CacheToV2(parsed);
|
|
817
|
+
const generatedAt = Date.parse(cache.generatedAt);
|
|
818
|
+
const ttlMs = options.cacheTtlMs ?? cache.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
819
|
+
const stale = Number.isFinite(generatedAt) && Date.now() - generatedAt > ttlMs;
|
|
820
|
+
if (stale) {
|
|
821
|
+
const message = `Registry cache ${cachePath} is stale; generatedAt=${cache.generatedAt}, ttlMs=${ttlMs}.`;
|
|
822
|
+
if (options.ci && !options.allowStaleCache)
|
|
823
|
+
throw new CacheSchemaError(message);
|
|
824
|
+
process.stderr.write(`Warning: ${message}\n`);
|
|
825
|
+
}
|
|
826
|
+
return reconcileBundledToolPinCache(cache);
|
|
827
|
+
}
|
|
828
|
+
export class CacheSchemaError extends Error {
|
|
829
|
+
constructor(message) {
|
|
830
|
+
super(message);
|
|
831
|
+
this.name = "CacheSchemaError";
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function emptyCacheFile() {
|
|
835
|
+
return {
|
|
836
|
+
schema: "dev.toolpin.registry-cache.v2",
|
|
837
|
+
generatedAt: new Date().toISOString(),
|
|
838
|
+
ttlMs: DEFAULT_CACHE_TTL_MS,
|
|
839
|
+
sources: {},
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function resultToPartition(result) {
|
|
843
|
+
return {
|
|
844
|
+
source: result.source,
|
|
845
|
+
status: result.status,
|
|
846
|
+
generatedAt: result.fetchedAt,
|
|
847
|
+
ttlMs: DEFAULT_CACHE_TTL_MS,
|
|
848
|
+
...(result.source.id === "toolpin" ? { bundledRegistryFingerprint: bundledRegistryFingerprint() } : {}),
|
|
849
|
+
entries: result.entries,
|
|
850
|
+
pageInfo: result.pageInfo,
|
|
851
|
+
accepted: result.accepted,
|
|
852
|
+
skipped: result.skipped,
|
|
853
|
+
malformed: result.malformed,
|
|
854
|
+
failed: result.failed,
|
|
855
|
+
lastError: result.lastError,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function v1CacheToV2(cache) {
|
|
859
|
+
const sources = {};
|
|
860
|
+
for (const entry of cache.entries) {
|
|
861
|
+
const sourceId = entry.source ?? detectSource(entry);
|
|
862
|
+
const source = sources[sourceId]?.source ?? {
|
|
863
|
+
id: sourceId,
|
|
864
|
+
label: sourceId,
|
|
865
|
+
type: sourceId === "toolpin" ? "toolpin" : sourceId === "official" ? "official" : sourceId === "docker" ? "docker" : "custom",
|
|
866
|
+
mode: "installable",
|
|
867
|
+
trust: sourceId === "official" ? "canonical" : sourceId === "toolpin" || sourceId === "docker" ? "curated" : "private",
|
|
868
|
+
enabled: true,
|
|
869
|
+
authRequired: false,
|
|
870
|
+
description: "Registry source migrated from v1 cache.",
|
|
871
|
+
};
|
|
872
|
+
const existing = sources[sourceId];
|
|
873
|
+
sources[sourceId] = {
|
|
874
|
+
source,
|
|
875
|
+
status: "ready",
|
|
876
|
+
generatedAt: cache.generatedAt,
|
|
877
|
+
ttlMs: cache.ttlMs ?? DEFAULT_CACHE_TTL_MS,
|
|
878
|
+
entries: [...(existing?.entries ?? []), entry],
|
|
879
|
+
accepted: (existing?.accepted ?? 0) + 1,
|
|
880
|
+
skipped: existing?.skipped ?? 0,
|
|
881
|
+
malformed: existing?.malformed ?? 0,
|
|
882
|
+
failed: existing?.failed ?? 0,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
schema: "dev.toolpin.registry-cache.v2",
|
|
887
|
+
generatedAt: cache.generatedAt,
|
|
888
|
+
ttlMs: cache.ttlMs ?? DEFAULT_CACHE_TTL_MS,
|
|
889
|
+
sources,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function flattenCache(cache) {
|
|
893
|
+
return Object.values(cache.sources).flatMap((partition) => partition.entries);
|
|
894
|
+
}
|
|
895
|
+
function reconcileBundledToolPinCache(cache) {
|
|
896
|
+
const cached = cache.sources.toolpin;
|
|
897
|
+
if (cached?.bundledRegistryFingerprint === bundledRegistryFingerprint())
|
|
898
|
+
return cache;
|
|
899
|
+
const bundled = bundledToolPinPartition();
|
|
900
|
+
if (!bundled)
|
|
901
|
+
return cache;
|
|
902
|
+
return {
|
|
903
|
+
...cache,
|
|
904
|
+
sources: {
|
|
905
|
+
...cache.sources,
|
|
906
|
+
toolpin: bundled,
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
function bundledToolPinPartition() {
|
|
911
|
+
try {
|
|
912
|
+
const snapshot = bundledRegistrySnapshot();
|
|
913
|
+
if (!snapshot.text)
|
|
914
|
+
return undefined;
|
|
915
|
+
const body = JSON.parse(snapshot.text);
|
|
916
|
+
const source = BUILTIN_REGISTRY_SOURCES.find((entry) => entry.id === "toolpin");
|
|
917
|
+
if (!source)
|
|
918
|
+
return undefined;
|
|
919
|
+
return resultToPartition(parseToolPinRegistryResult(body, source, { limit: 500 }));
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
let bundledRegistrySnapshotCache;
|
|
926
|
+
function bundledRegistrySnapshot() {
|
|
927
|
+
if (bundledRegistrySnapshotCache)
|
|
928
|
+
return bundledRegistrySnapshotCache;
|
|
929
|
+
try {
|
|
930
|
+
const body = readFileSync(TOOLPIN_REGISTRY_FILE);
|
|
931
|
+
bundledRegistrySnapshotCache = {
|
|
932
|
+
text: body.toString("utf8"),
|
|
933
|
+
fingerprint: createHash("sha256").update(body).digest("hex"),
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
bundledRegistrySnapshotCache = { fingerprint: "unavailable" };
|
|
938
|
+
}
|
|
939
|
+
return bundledRegistrySnapshotCache;
|
|
940
|
+
}
|
|
941
|
+
function bundledRegistryFingerprint() {
|
|
942
|
+
return bundledRegistrySnapshot().fingerprint;
|
|
943
|
+
}
|
|
944
|
+
export function normalizeEntry(entry) {
|
|
945
|
+
const server = entry.server;
|
|
946
|
+
const officialMeta = getOfficialMeta(entry._meta);
|
|
947
|
+
const packages = server.packages ?? [];
|
|
948
|
+
const remotes = server.remotes ?? [];
|
|
949
|
+
const packageTypes = unique(packages.map((pkg) => pkg.registryType).filter(Boolean));
|
|
950
|
+
const remoteTypes = unique(remotes.map((remote) => remote.type).filter(Boolean));
|
|
951
|
+
const packageTransports = packages.map((pkg) => pkg.transport?.type).filter(Boolean);
|
|
952
|
+
const transports = unique([...packageTransports, ...remoteTypes]);
|
|
953
|
+
const sourceMeta = getToolpinSourceMeta(entry);
|
|
954
|
+
const registrySource = entry.source ?? detectSource(entry);
|
|
955
|
+
const registryMode = sourceMeta.mode === "discovery" ? "discovery" : "installable";
|
|
956
|
+
const hasInstallTarget = packages.length > 0 || remotes.length > 0;
|
|
957
|
+
const hasVerifiableTarget = hasVerifiableInstallTarget(server);
|
|
958
|
+
const glamaNeedsOfficialResolution = registrySource === "glama" && registryMode === "discovery";
|
|
959
|
+
const installable = hasInstallTarget && (registryMode === "installable" || (hasVerifiableTarget && !glamaNeedsOfficialResolution));
|
|
960
|
+
return {
|
|
961
|
+
registrySource,
|
|
962
|
+
registryMode,
|
|
963
|
+
name: server.name,
|
|
964
|
+
title: server.title ?? server.name,
|
|
965
|
+
description: server.description ?? "",
|
|
966
|
+
version: server.version,
|
|
967
|
+
isLatest: officialMeta?.isLatest === true,
|
|
968
|
+
installable,
|
|
969
|
+
installableReason: installable
|
|
970
|
+
? undefined
|
|
971
|
+
: registryMode === "discovery"
|
|
972
|
+
? glamaNeedsOfficialResolution
|
|
973
|
+
? "Glama entries require official registry re-resolution before install"
|
|
974
|
+
: "registry entry has no verifiable package or HTTPS remote target"
|
|
975
|
+
: "registry entry has no package or remote install target",
|
|
976
|
+
repositoryUrl: server.repository?.url,
|
|
977
|
+
packageTypes,
|
|
978
|
+
remoteTypes,
|
|
979
|
+
transports,
|
|
980
|
+
requiresSecrets: hasSecrets(server),
|
|
981
|
+
raw: server,
|
|
982
|
+
registryMeta: entry._meta,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
function detectSource(entry) {
|
|
986
|
+
const sourceMeta = getToolpinSourceMeta(entry);
|
|
987
|
+
return typeof sourceMeta.source === "string" ? sourceMeta.source : "official";
|
|
988
|
+
}
|
|
989
|
+
function getToolpinSourceMeta(entry) {
|
|
990
|
+
const meta = entry._meta?.["dev.toolpin/source"] ?? entry.server._meta?.["dev.toolpin/source"];
|
|
991
|
+
return isRecord(meta) ? meta : {};
|
|
992
|
+
}
|
|
993
|
+
function extractEnvName(value) {
|
|
994
|
+
return value.match(/\$\{([^}]+)\}/)?.[1];
|
|
995
|
+
}
|
|
996
|
+
export function normalizeEntries(entries) {
|
|
997
|
+
return entries.map(normalizeEntry);
|
|
998
|
+
}
|
|
999
|
+
export function latestOnly(servers) {
|
|
1000
|
+
const byName = new Map();
|
|
1001
|
+
for (const server of servers) {
|
|
1002
|
+
const existing = byName.get(server.name);
|
|
1003
|
+
if (!existing || server.isLatest || compareVersionish(server.version, existing.version) > 0) {
|
|
1004
|
+
byName.set(server.name, server);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return [...byName.values()];
|
|
1008
|
+
}
|
|
1009
|
+
function getOfficialMeta(meta) {
|
|
1010
|
+
const value = meta?.["io.modelcontextprotocol.registry/official"];
|
|
1011
|
+
return value && typeof value === "object" ? value : undefined;
|
|
1012
|
+
}
|
|
1013
|
+
function hasSecrets(server) {
|
|
1014
|
+
const packages = (server.packages ?? []);
|
|
1015
|
+
const remotes = (server.remotes ?? []);
|
|
1016
|
+
return (packages.some((pkg) => pkg.environmentVariables?.some((env) => env.isSecret)) ||
|
|
1017
|
+
remotes.some((remote) => remote.headers?.some((header) => header.isSecret)));
|
|
1018
|
+
}
|
|
1019
|
+
function hasVerifiableInstallTarget(server) {
|
|
1020
|
+
return (server.packages ?? []).some(isVerifiablePackageTarget)
|
|
1021
|
+
|| (server.remotes ?? []).some((remote) => isHttpsUrl(remote.url));
|
|
1022
|
+
}
|
|
1023
|
+
function isVerifiablePackageTarget(pkg) {
|
|
1024
|
+
if (pkg.registryType === "oci")
|
|
1025
|
+
return /@sha256:[a-fA-F0-9]{64}$/.test(pkg.identifier);
|
|
1026
|
+
if (pkg.registryType === "mcpb")
|
|
1027
|
+
return isValidSha256Hex(pkg.fileSha256) && isHttpsUrl(pkg.identifier);
|
|
1028
|
+
if (pkg.registryType === "npm")
|
|
1029
|
+
return Boolean(pkg.version && !isFloatingVersion(pkg.version));
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
function isHttpsUrl(value) {
|
|
1033
|
+
if (!value)
|
|
1034
|
+
return false;
|
|
1035
|
+
try {
|
|
1036
|
+
return new URL(value).protocol === "https:";
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function isValidSha256Hex(value) {
|
|
1043
|
+
return typeof value === "string" && /^[a-fA-F0-9]{64}$/.test(value);
|
|
1044
|
+
}
|
|
1045
|
+
function unique(values) {
|
|
1046
|
+
return [...new Set(values)];
|
|
1047
|
+
}
|
|
1048
|
+
function isFloatingVersion(version) {
|
|
1049
|
+
return ["latest", "*"].includes(version.trim().toLowerCase()) || /[~^x*]/i.test(version);
|
|
1050
|
+
}
|
|
1051
|
+
function isCacheFile(value) {
|
|
1052
|
+
return (isRecord(value) &&
|
|
1053
|
+
typeof value.generatedAt === "string" &&
|
|
1054
|
+
(value.ttlMs === undefined || typeof value.ttlMs === "number") &&
|
|
1055
|
+
Array.isArray(value.entries) &&
|
|
1056
|
+
value.entries.every((entry) => (isRecord(entry) &&
|
|
1057
|
+
isRecord(entry.server) &&
|
|
1058
|
+
typeof entry.server.name === "string" &&
|
|
1059
|
+
typeof entry.server.version === "string")));
|
|
1060
|
+
}
|
|
1061
|
+
function isCacheFileV2(value) {
|
|
1062
|
+
return (isRecord(value) &&
|
|
1063
|
+
value.schema === "dev.toolpin.registry-cache.v2" &&
|
|
1064
|
+
typeof value.generatedAt === "string" &&
|
|
1065
|
+
typeof value.ttlMs === "number" &&
|
|
1066
|
+
isRecord(value.sources) &&
|
|
1067
|
+
Object.values(value.sources).every((partition) => (isRecord(partition) &&
|
|
1068
|
+
isRecord(partition.source) &&
|
|
1069
|
+
typeof partition.source.id === "string" &&
|
|
1070
|
+
typeof partition.source.label === "string" &&
|
|
1071
|
+
typeof partition.generatedAt === "string" &&
|
|
1072
|
+
Array.isArray(partition.entries) &&
|
|
1073
|
+
partition.entries.every((entry) => (isRecord(entry) &&
|
|
1074
|
+
isRecord(entry.server) &&
|
|
1075
|
+
typeof entry.server.name === "string" &&
|
|
1076
|
+
typeof entry.server.version === "string")))));
|
|
1077
|
+
}
|
|
1078
|
+
function isRegistryConfig(value) {
|
|
1079
|
+
return (isRecord(value) &&
|
|
1080
|
+
Array.isArray(value.registries) &&
|
|
1081
|
+
(value.sources === undefined || (isRecord(value.sources) &&
|
|
1082
|
+
Object.values(value.sources).every((entry) => (isRecord(entry) &&
|
|
1083
|
+
(entry.enabled === undefined || typeof entry.enabled === "boolean"))))) &&
|
|
1084
|
+
value.registries.every((entry) => (isRecord(entry) &&
|
|
1085
|
+
typeof entry.id === "string" &&
|
|
1086
|
+
entry.id.length > 0 &&
|
|
1087
|
+
(entry.url === undefined || (typeof entry.url === "string" && entry.url.length > 0)) &&
|
|
1088
|
+
(entry.type === undefined || ["official-compatible", "http-json", "toolpin", "official", "docker", "glama", "smithery", "pulsemcp", "custom"].includes(String(entry.type))) &&
|
|
1089
|
+
(entry.adapter === undefined || ["official-compatible", "http-json", "glama", "smithery", "pulsemcp"].includes(String(entry.adapter))) &&
|
|
1090
|
+
(entry.mode === undefined || entry.mode === "installable" || entry.mode === "discovery") &&
|
|
1091
|
+
(entry.enabled === undefined || typeof entry.enabled === "boolean"))));
|
|
1092
|
+
}
|
|
1093
|
+
function extractHttpJsonEntries(body) {
|
|
1094
|
+
const value = isRecord(body) ? body.servers ?? body.entries : undefined;
|
|
1095
|
+
if (!Array.isArray(value))
|
|
1096
|
+
return undefined;
|
|
1097
|
+
const report = { accepted: 0, skipped: 0, malformed: 0, failed: 0, reasons: [] };
|
|
1098
|
+
const entries = [];
|
|
1099
|
+
for (const [index, item] of value.entries()) {
|
|
1100
|
+
const parsed = parseRegistryEntry(item);
|
|
1101
|
+
if (parsed.entry) {
|
|
1102
|
+
entries.push(parsed.entry);
|
|
1103
|
+
report.accepted += 1;
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
report.malformed += 1;
|
|
1107
|
+
report.reasons.push(`entry[${index}]: ${parsed.reason}`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return { entries, report };
|
|
1111
|
+
}
|
|
1112
|
+
function parseRegistryEntry(item) {
|
|
1113
|
+
if (!isRecord(item))
|
|
1114
|
+
return { reason: "entry is not an object" };
|
|
1115
|
+
const maybeServer = isRecord(item.server) ? item.server : item;
|
|
1116
|
+
const server = parseRegistryServer(maybeServer);
|
|
1117
|
+
if (!server.server)
|
|
1118
|
+
return { reason: server.reason };
|
|
1119
|
+
return {
|
|
1120
|
+
entry: {
|
|
1121
|
+
server: server.server,
|
|
1122
|
+
_meta: isRecord(item._meta) ? item._meta : undefined,
|
|
1123
|
+
source: typeof item.source === "string" ? item.source : undefined,
|
|
1124
|
+
},
|
|
1125
|
+
reason: "ok",
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function extractDirectoryEntries(body, sourceInfo) {
|
|
1129
|
+
const array = directoryArray(body);
|
|
1130
|
+
if (!array)
|
|
1131
|
+
return undefined;
|
|
1132
|
+
const report = { accepted: 0, skipped: 0, malformed: 0, failed: 0, reasons: [] };
|
|
1133
|
+
const entries = [];
|
|
1134
|
+
for (const [index, item] of array.entries()) {
|
|
1135
|
+
const entry = directoryItemToEntry(item, sourceInfo);
|
|
1136
|
+
if (entry.entry) {
|
|
1137
|
+
entries.push(tagRegistryEntry(entry.entry, sourceInfo));
|
|
1138
|
+
report.accepted += 1;
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
report.malformed += 1;
|
|
1142
|
+
report.reasons.push(`entry[${index}]: ${entry.reason}`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return { entries, report, pageInfo: directoryPageInfo(body) };
|
|
1146
|
+
}
|
|
1147
|
+
function directoryArray(body) {
|
|
1148
|
+
if (!isRecord(body))
|
|
1149
|
+
return undefined;
|
|
1150
|
+
const candidates = [
|
|
1151
|
+
body.servers,
|
|
1152
|
+
body.data,
|
|
1153
|
+
body.items,
|
|
1154
|
+
body.results,
|
|
1155
|
+
isRecord(body.page) ? body.page.items : undefined,
|
|
1156
|
+
];
|
|
1157
|
+
return candidates.find(Array.isArray);
|
|
1158
|
+
}
|
|
1159
|
+
function directoryPageInfo(body) {
|
|
1160
|
+
const root = asRecord(body);
|
|
1161
|
+
const pageInfo = asRecord(root.pageInfo ?? root.pagination ?? root.metadata);
|
|
1162
|
+
const nextCursor = stringValue(pageInfo.endCursor ?? pageInfo.nextCursor ?? pageInfo.cursor ?? root.nextCursor);
|
|
1163
|
+
const hasMoreValue = pageInfo.hasNextPage ?? pageInfo.hasMore ?? root.hasMore;
|
|
1164
|
+
return {
|
|
1165
|
+
fetchedPages: 1,
|
|
1166
|
+
maxPages: 1,
|
|
1167
|
+
hasMore: typeof hasMoreValue === "boolean" ? hasMoreValue : Boolean(nextCursor),
|
|
1168
|
+
nextCursor,
|
|
1169
|
+
total: numberValue(pageInfo.total ?? pageInfo.count ?? root.total ?? root.count),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
function directoryItemToEntry(item, sourceInfo) {
|
|
1173
|
+
if (!isRecord(item))
|
|
1174
|
+
return { reason: "entry is not an object" };
|
|
1175
|
+
const maybeOfficial = parseRegistryEntry(item);
|
|
1176
|
+
if (maybeOfficial.entry)
|
|
1177
|
+
return maybeOfficial;
|
|
1178
|
+
const name = firstNonEmptyString(item.name, item.qualifiedName, item.packageName, item.slug, item.id);
|
|
1179
|
+
if (!name)
|
|
1180
|
+
return { reason: "directory entry has no name, slug, id, packageName, or qualifiedName" };
|
|
1181
|
+
const repositoryUrl = repositoryUrlFromDirectoryItem(item);
|
|
1182
|
+
const server = {
|
|
1183
|
+
name,
|
|
1184
|
+
title: stringValue(item.title ?? item.displayName ?? item.name) ?? name,
|
|
1185
|
+
description: stringValue(item.description ?? item.summary ?? item.readme) ?? "",
|
|
1186
|
+
version: stringValue(item.version ?? item.latestVersion ?? item.packageVersion) ?? "directory",
|
|
1187
|
+
repository: repositoryUrl ? { url: repositoryUrl, source: repositorySource(repositoryUrl) } : undefined,
|
|
1188
|
+
_meta: {
|
|
1189
|
+
"dev.toolpin/source": {
|
|
1190
|
+
source: sourceInfo.id,
|
|
1191
|
+
mode: "discovery",
|
|
1192
|
+
directoryId: stringValue(item.id ?? item.slug),
|
|
1193
|
+
rawUrl: stringValue(item.url ?? item.homepageUrl ?? item.websiteUrl),
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
};
|
|
1197
|
+
const target = verifiedInstallTarget(item);
|
|
1198
|
+
if (target.packages.length || target.remotes.length) {
|
|
1199
|
+
server.packages = target.packages.length ? target.packages : undefined;
|
|
1200
|
+
server.remotes = target.remotes.length ? target.remotes : undefined;
|
|
1201
|
+
}
|
|
1202
|
+
return { entry: { server }, reason: "ok" };
|
|
1203
|
+
}
|
|
1204
|
+
function repositoryUrlFromDirectoryItem(item) {
|
|
1205
|
+
const repository = asRecord(item.repository ?? item.repo);
|
|
1206
|
+
return stringValue(repository.url ?? repository.homepage)
|
|
1207
|
+
?? stringValue(item.repositoryUrl ?? item.repoUrl ?? item.sourceUrl ?? item.githubUrl);
|
|
1208
|
+
}
|
|
1209
|
+
function repositorySource(url) {
|
|
1210
|
+
try {
|
|
1211
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
1212
|
+
}
|
|
1213
|
+
catch {
|
|
1214
|
+
return undefined;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
function verifiedInstallTarget(item) {
|
|
1218
|
+
const packages = Array.isArray(item.packages) ? item.packages.filter(isRegistryPackage) : [];
|
|
1219
|
+
const remotes = Array.isArray(item.remotes) ? item.remotes.filter(isRegistryRemote) : [];
|
|
1220
|
+
const packageTarget = asRecord(item.package);
|
|
1221
|
+
if (packages.length === 0 && typeof packageTarget.registryType === "string" && typeof packageTarget.identifier === "string") {
|
|
1222
|
+
packages.push(packageTarget);
|
|
1223
|
+
}
|
|
1224
|
+
const remoteTarget = asRecord(item.remote);
|
|
1225
|
+
if (remotes.length === 0 && typeof remoteTarget.type === "string" && typeof remoteTarget.url === "string") {
|
|
1226
|
+
remotes.push(remoteTarget);
|
|
1227
|
+
}
|
|
1228
|
+
return { packages, remotes };
|
|
1229
|
+
}
|
|
1230
|
+
function parseRegistryServer(value) {
|
|
1231
|
+
if (typeof value.name !== "string" || !value.name)
|
|
1232
|
+
return { reason: "server.name is required" };
|
|
1233
|
+
if (typeof value.version !== "string" || !value.version)
|
|
1234
|
+
return { reason: "server.version is required" };
|
|
1235
|
+
const packages = Array.isArray(value.packages) ? value.packages.filter(isRegistryPackage) : undefined;
|
|
1236
|
+
const remotes = Array.isArray(value.remotes) ? value.remotes.filter(isRegistryRemote) : undefined;
|
|
1237
|
+
return {
|
|
1238
|
+
server: {
|
|
1239
|
+
$schema: typeof value.$schema === "string" ? value.$schema : undefined,
|
|
1240
|
+
name: value.name,
|
|
1241
|
+
title: typeof value.title === "string" ? value.title : undefined,
|
|
1242
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
1243
|
+
version: value.version,
|
|
1244
|
+
packages,
|
|
1245
|
+
remotes,
|
|
1246
|
+
repository: isRegistryRepository(value.repository) ? value.repository : undefined,
|
|
1247
|
+
_meta: isRecord(value._meta) ? value._meta : undefined,
|
|
1248
|
+
},
|
|
1249
|
+
reason: "ok",
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
function isRegistryPackage(value) {
|
|
1253
|
+
return isRecord(value) && typeof value.registryType === "string" && typeof value.identifier === "string";
|
|
1254
|
+
}
|
|
1255
|
+
function isRegistryRemote(value) {
|
|
1256
|
+
return isRecord(value) && typeof value.type === "string" && typeof value.url === "string";
|
|
1257
|
+
}
|
|
1258
|
+
function isRegistryRepository(value) {
|
|
1259
|
+
return isRecord(value) && typeof value.url === "string";
|
|
1260
|
+
}
|
|
1261
|
+
function warnRegistryReport(label, report, options) {
|
|
1262
|
+
const message = `${label}: accepted ${report.accepted}, skipped ${report.skipped}, malformed ${report.malformed}, failed ${report.failed}.`;
|
|
1263
|
+
if (options.ci && (report.failed || report.malformed)) {
|
|
1264
|
+
throw new Error(`${message} ${report.reasons.slice(0, 5).join("; ")}`);
|
|
1265
|
+
}
|
|
1266
|
+
if (report.skipped || report.malformed || report.failed) {
|
|
1267
|
+
process.stderr.write(`Warning: ${message}${report.reasons.length ? ` ${report.reasons.slice(0, 5).join("; ")}` : ""}\n`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
function successResult(source, entries, options = {}) {
|
|
1271
|
+
return {
|
|
1272
|
+
source: { ...source, status: options.status ?? sourceStatus(source) },
|
|
1273
|
+
status: options.status ?? sourceStatus(source),
|
|
1274
|
+
entries,
|
|
1275
|
+
accepted: options.accepted ?? entries.length,
|
|
1276
|
+
skipped: options.skipped ?? 0,
|
|
1277
|
+
malformed: options.malformed ?? 0,
|
|
1278
|
+
failed: options.failed ?? 0,
|
|
1279
|
+
lastError: options.lastError,
|
|
1280
|
+
pageInfo: options.pageInfo,
|
|
1281
|
+
fetchedAt: options.fetchedAt ?? new Date().toISOString(),
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
function emptyResult(source, status, lastError) {
|
|
1285
|
+
return {
|
|
1286
|
+
source: { ...source, status },
|
|
1287
|
+
status,
|
|
1288
|
+
entries: [],
|
|
1289
|
+
accepted: 0,
|
|
1290
|
+
skipped: 0,
|
|
1291
|
+
malformed: 0,
|
|
1292
|
+
failed: lastError ? 1 : 0,
|
|
1293
|
+
lastError,
|
|
1294
|
+
fetchedAt: new Date().toISOString(),
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
function fetchErrorResult(source, error) {
|
|
1298
|
+
return emptyResult(source, "fetch-error", error instanceof Error ? error.message : String(error));
|
|
1299
|
+
}
|
|
1300
|
+
function allSourcesInfo() {
|
|
1301
|
+
return {
|
|
1302
|
+
id: "all",
|
|
1303
|
+
label: "All enabled registry sources",
|
|
1304
|
+
type: "custom",
|
|
1305
|
+
mode: "discovery",
|
|
1306
|
+
trust: "directory",
|
|
1307
|
+
enabled: true,
|
|
1308
|
+
authRequired: false,
|
|
1309
|
+
description: "Aggregated result from every enabled source.",
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
function sourceStatus(source) {
|
|
1313
|
+
if (!source.enabled)
|
|
1314
|
+
return "disabled";
|
|
1315
|
+
if (source.status === "auth-missing" || source.status === "fetch-error" || source.status === "stale")
|
|
1316
|
+
return source.status;
|
|
1317
|
+
if (source.mode === "discovery")
|
|
1318
|
+
return "discovery-only";
|
|
1319
|
+
return "ready";
|
|
1320
|
+
}
|
|
1321
|
+
function defaultUrlForAdapter(adapter) {
|
|
1322
|
+
if (adapter === "glama")
|
|
1323
|
+
return GLAMA_SERVERS_URL;
|
|
1324
|
+
if (adapter === "smithery")
|
|
1325
|
+
return SMITHERY_SERVERS_URL;
|
|
1326
|
+
if (adapter === "pulsemcp")
|
|
1327
|
+
return PULSEMCP_SERVERS_URL;
|
|
1328
|
+
return undefined;
|
|
1329
|
+
}
|
|
1330
|
+
function adapterKind(value) {
|
|
1331
|
+
if (value === "http-json" || value === "glama" || value === "smithery" || value === "pulsemcp")
|
|
1332
|
+
return value;
|
|
1333
|
+
return "official-compatible";
|
|
1334
|
+
}
|
|
1335
|
+
function firstAuthEnv(value) {
|
|
1336
|
+
if (Array.isArray(value))
|
|
1337
|
+
return value.find(Boolean);
|
|
1338
|
+
return value;
|
|
1339
|
+
}
|
|
1340
|
+
function requiredUrl(url, source) {
|
|
1341
|
+
if (url)
|
|
1342
|
+
return url;
|
|
1343
|
+
throw new Error(`${source.label} registry URL is required.`);
|
|
1344
|
+
}
|
|
1345
|
+
function githubHeaders() {
|
|
1346
|
+
const headers = { "Accept": "application/vnd.github+json" };
|
|
1347
|
+
if (process.env.GITHUB_TOKEN)
|
|
1348
|
+
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
1349
|
+
return headers;
|
|
1350
|
+
}
|
|
1351
|
+
function asRecord(value) {
|
|
1352
|
+
return isRecord(value) ? value : {};
|
|
1353
|
+
}
|
|
1354
|
+
function mergeAdapters(adapters) {
|
|
1355
|
+
const byId = new Map();
|
|
1356
|
+
for (const adapter of adapters) {
|
|
1357
|
+
if (byId.has(adapter.info.id))
|
|
1358
|
+
continue;
|
|
1359
|
+
byId.set(adapter.info.id, adapter);
|
|
1360
|
+
}
|
|
1361
|
+
return [...byId.values()];
|
|
1362
|
+
}
|
|
1363
|
+
export function dedupeRegistryEntries(entries) {
|
|
1364
|
+
const byKey = new Map();
|
|
1365
|
+
for (const entry of entries) {
|
|
1366
|
+
const key = registryEntryKey(entry);
|
|
1367
|
+
const existing = byKey.get(key);
|
|
1368
|
+
if (!existing || registrySourceIdRank(entry.source) < registrySourceIdRank(existing.source)) {
|
|
1369
|
+
byKey.set(key, entry);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return [...byKey.values()];
|
|
1373
|
+
}
|
|
1374
|
+
function registryEntryKey(entry) {
|
|
1375
|
+
const repositoryUrl = normalizeUrl(entry.server.repository?.url);
|
|
1376
|
+
if (repositoryUrl)
|
|
1377
|
+
return `repo:${repositoryUrl}:${entry.server.name}:${entry.server.version}`;
|
|
1378
|
+
return `name:${entry.server.name}:${entry.server.version}`;
|
|
1379
|
+
}
|
|
1380
|
+
export function compareRegistrySources(left, right) {
|
|
1381
|
+
return Number(Boolean(right.pinned)) - Number(Boolean(left.pinned))
|
|
1382
|
+
|| Number(right.enabled) - Number(left.enabled)
|
|
1383
|
+
|| registrySourceTrustRank(left.trust) - registrySourceTrustRank(right.trust)
|
|
1384
|
+
|| registrySourceModeRank(left.mode) - registrySourceModeRank(right.mode)
|
|
1385
|
+
|| registrySourceIdRank(left.id) - registrySourceIdRank(right.id)
|
|
1386
|
+
|| left.label.localeCompare(right.label);
|
|
1387
|
+
}
|
|
1388
|
+
export function registrySourceTrustRank(trust) {
|
|
1389
|
+
if (trust === "canonical")
|
|
1390
|
+
return 0;
|
|
1391
|
+
if (trust === "curated")
|
|
1392
|
+
return 1;
|
|
1393
|
+
if (trust === "directory")
|
|
1394
|
+
return 2;
|
|
1395
|
+
return 3;
|
|
1396
|
+
}
|
|
1397
|
+
export function registrySourceModeRank(mode) {
|
|
1398
|
+
return mode === "installable" ? 0 : 1;
|
|
1399
|
+
}
|
|
1400
|
+
export function registrySourceIdRank(source) {
|
|
1401
|
+
if (source === "toolpin")
|
|
1402
|
+
return 0;
|
|
1403
|
+
if (source === "official")
|
|
1404
|
+
return 1;
|
|
1405
|
+
if (source === "docker")
|
|
1406
|
+
return 2;
|
|
1407
|
+
return 3;
|
|
1408
|
+
}
|
|
1409
|
+
function normalizeUrl(value) {
|
|
1410
|
+
if (!value)
|
|
1411
|
+
return undefined;
|
|
1412
|
+
return value.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase();
|
|
1413
|
+
}
|
|
1414
|
+
function searchableText(server) {
|
|
1415
|
+
return [
|
|
1416
|
+
server.name,
|
|
1417
|
+
server.title,
|
|
1418
|
+
server.description,
|
|
1419
|
+
server.repository?.url,
|
|
1420
|
+
...((server.packages ?? []).map((pkg) => pkg.identifier)),
|
|
1421
|
+
...((server.remotes ?? []).map((remote) => remote.url)),
|
|
1422
|
+
].filter(Boolean).join("\n").toLowerCase();
|
|
1423
|
+
}
|
|
1424
|
+
async function mapConcurrent(values, concurrency, mapper) {
|
|
1425
|
+
const results = new Array(values.length);
|
|
1426
|
+
let nextIndex = 0;
|
|
1427
|
+
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
1428
|
+
while (nextIndex < values.length) {
|
|
1429
|
+
const index = nextIndex;
|
|
1430
|
+
nextIndex += 1;
|
|
1431
|
+
results[index] = await mapper(values[index], index);
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
await Promise.all(workers);
|
|
1435
|
+
return results;
|
|
1436
|
+
}
|
|
1437
|
+
function clampInteger(value, min, max) {
|
|
1438
|
+
if (!Number.isFinite(value))
|
|
1439
|
+
return min;
|
|
1440
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
1441
|
+
}
|
|
1442
|
+
function stringValue(value) {
|
|
1443
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
1444
|
+
}
|
|
1445
|
+
export async function enrichSmitheryTarget(server, options = {}) {
|
|
1446
|
+
if (server.registrySource !== "smithery")
|
|
1447
|
+
return server;
|
|
1448
|
+
if (!options.allowHostedDirectoryTargets) {
|
|
1449
|
+
return {
|
|
1450
|
+
...server,
|
|
1451
|
+
installable: false,
|
|
1452
|
+
installableReason: "Smithery hosted targets require explicit opt-in (--allow-hosted-directory-targets); subject to Smithery terms",
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
if ((server.raw.remotes ?? []).length > 0 || (server.raw.packages ?? []).length > 0) {
|
|
1456
|
+
return {
|
|
1457
|
+
...server,
|
|
1458
|
+
resolutionNote: "hosted by Smithery; subject to Smithery terms",
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
const qualifiedName = server.name;
|
|
1462
|
+
if (!qualifiedName)
|
|
1463
|
+
return server;
|
|
1464
|
+
const headers = {};
|
|
1465
|
+
if (process.env.SMITHERY_API_KEY)
|
|
1466
|
+
headers.Authorization = `Bearer ${process.env.SMITHERY_API_KEY}`;
|
|
1467
|
+
let detail;
|
|
1468
|
+
try {
|
|
1469
|
+
detail = await safeFetchJson(`${SMITHERY_SERVERS_URL}/${encodeURIComponent(qualifiedName)}`, { timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, headers });
|
|
1470
|
+
}
|
|
1471
|
+
catch {
|
|
1472
|
+
return server;
|
|
1473
|
+
}
|
|
1474
|
+
const deploymentUrl = firstNonEmptyString(detail.deploymentUrl, ...(Array.isArray(detail.connections) ? detail.connections : []).map((connection) => isRecord(connection) ? connection.deploymentUrl : undefined));
|
|
1475
|
+
if (!deploymentUrl || !isHttpsUrl(deploymentUrl))
|
|
1476
|
+
return server;
|
|
1477
|
+
const remote = { type: "streamable-http", url: deploymentUrl };
|
|
1478
|
+
const remotes = [...(server.raw.remotes ?? []), remote];
|
|
1479
|
+
const packages = server.raw.packages ?? [];
|
|
1480
|
+
const remoteTypes = Array.from(new Set([...server.remoteTypes, "streamable-http"]));
|
|
1481
|
+
const transports = Array.from(new Set([...server.transports, "streamable-http"]));
|
|
1482
|
+
return {
|
|
1483
|
+
...server,
|
|
1484
|
+
raw: { ...server.raw, remotes },
|
|
1485
|
+
packageTypes: server.packageTypes,
|
|
1486
|
+
remoteTypes,
|
|
1487
|
+
transports,
|
|
1488
|
+
installable: true,
|
|
1489
|
+
installableReason: undefined,
|
|
1490
|
+
resolutionNote: "hosted by Smithery; subject to Smithery terms",
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
function firstNonEmptyString(...values) {
|
|
1494
|
+
for (const value of values) {
|
|
1495
|
+
const candidate = stringValue(value);
|
|
1496
|
+
if (candidate)
|
|
1497
|
+
return candidate;
|
|
1498
|
+
}
|
|
1499
|
+
return undefined;
|
|
1500
|
+
}
|
|
1501
|
+
export function canonicalRepoUrl(input) {
|
|
1502
|
+
const trimmed = typeof input === "string" ? input.trim() : "";
|
|
1503
|
+
if (!trimmed)
|
|
1504
|
+
return undefined;
|
|
1505
|
+
let s = trimmed
|
|
1506
|
+
.replace(/^(git\+|svn\+|hg\+|bzr\+)/i, "")
|
|
1507
|
+
.replace(/^github:/i, "https://github.com/")
|
|
1508
|
+
.replace(/^git@([^:/#]+):/i, "https://$1/")
|
|
1509
|
+
.replace(/^ssh:\/\/git@/i, "https://")
|
|
1510
|
+
.replace(/^(ssh|git):\/\//i, "https://");
|
|
1511
|
+
let host;
|
|
1512
|
+
let pathName;
|
|
1513
|
+
try {
|
|
1514
|
+
const url = new URL(s);
|
|
1515
|
+
host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
1516
|
+
pathName = url.pathname;
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
const fallback = s.replace(/[?#].*$/, "").replace(/\.git$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
1520
|
+
return fallback.includes("/") ? fallback : undefined;
|
|
1521
|
+
}
|
|
1522
|
+
pathName = pathName.replace(/\.git$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
1523
|
+
const canonical = host + pathName;
|
|
1524
|
+
return canonical === host || !pathName || pathName === "/" ? undefined : canonical;
|
|
1525
|
+
}
|
|
1526
|
+
export async function enrichGlamaTarget(server, options = {}) {
|
|
1527
|
+
if (server.registrySource !== "glama")
|
|
1528
|
+
return server;
|
|
1529
|
+
if ((server.raw.packages ?? []).length > 0 || (server.raw.remotes ?? []).length > 0)
|
|
1530
|
+
return server;
|
|
1531
|
+
const glamaRepo = server.repositoryUrl;
|
|
1532
|
+
if (!glamaRepo)
|
|
1533
|
+
return server;
|
|
1534
|
+
const match = await findOfficialMatch({ repoUrl: glamaRepo, name: server.name, cachePath: options.cachePath });
|
|
1535
|
+
if (!match) {
|
|
1536
|
+
return {
|
|
1537
|
+
...server,
|
|
1538
|
+
installableReason: "no matching official-registry entry; install via the publisher's repo",
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
const packages = match.server.packages ?? [];
|
|
1542
|
+
const remotes = match.server.remotes ?? [];
|
|
1543
|
+
if (packages.length === 0 && remotes.length === 0)
|
|
1544
|
+
return server;
|
|
1545
|
+
const packageTypes = unique(packages.map((pkg) => pkg.registryType).filter(Boolean));
|
|
1546
|
+
const remoteTypes = unique(remotes.map((remote) => remote.type).filter(Boolean));
|
|
1547
|
+
const transports = unique([...server.transports, ...packageTypes, ...remoteTypes]);
|
|
1548
|
+
return {
|
|
1549
|
+
...server,
|
|
1550
|
+
raw: {
|
|
1551
|
+
...server.raw,
|
|
1552
|
+
packages: packages.length ? packages : undefined,
|
|
1553
|
+
remotes: remotes.length ? remotes : undefined,
|
|
1554
|
+
},
|
|
1555
|
+
packageTypes,
|
|
1556
|
+
remoteTypes,
|
|
1557
|
+
transports,
|
|
1558
|
+
installable: true,
|
|
1559
|
+
installableReason: undefined,
|
|
1560
|
+
resolvedFromRegistry: "official",
|
|
1561
|
+
resolutionNote: match.matchedByName
|
|
1562
|
+
? "installed via official registry (matched from Glama by repo + name)"
|
|
1563
|
+
: "installed via official registry (matched from Glama by repo)",
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
async function findOfficialMatch(options) {
|
|
1567
|
+
const canonical = canonicalRepoUrl(options.repoUrl);
|
|
1568
|
+
if (!canonical)
|
|
1569
|
+
return undefined;
|
|
1570
|
+
const candidates = await loadOfficialCandidates({ cachePath: options.cachePath });
|
|
1571
|
+
const sameRepo = candidates.filter((entry) => canonicalRepoUrl(entry.server.repository?.url) === canonical);
|
|
1572
|
+
if (sameRepo.length === 0)
|
|
1573
|
+
return undefined;
|
|
1574
|
+
if (sameRepo.length === 1)
|
|
1575
|
+
return { server: sameRepo[0].server, matchedByName: false };
|
|
1576
|
+
const byName = sameRepo.filter((entry) => namesMatch(entry.server.name, options.name));
|
|
1577
|
+
if (byName.length === 1)
|
|
1578
|
+
return { server: byName[0].server, matchedByName: true };
|
|
1579
|
+
return undefined;
|
|
1580
|
+
}
|
|
1581
|
+
async function loadOfficialCandidates(options = {}) {
|
|
1582
|
+
try {
|
|
1583
|
+
const cache = await readCacheMetadata(options.cachePath ?? DEFAULT_CACHE_PATH, { allowStaleCache: true });
|
|
1584
|
+
const partition = cache.sources?.["official"];
|
|
1585
|
+
if (partition?.entries?.length)
|
|
1586
|
+
return partition.entries;
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
// fall through to a live fetch
|
|
1590
|
+
}
|
|
1591
|
+
try {
|
|
1592
|
+
const entries = await fetchRegistry({ maxPages: DEFAULT_OFFICIAL_MAX_PAGES });
|
|
1593
|
+
return entries.filter((entry) => (entry.source ?? "official") === "official");
|
|
1594
|
+
}
|
|
1595
|
+
catch {
|
|
1596
|
+
return [];
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
function namesMatch(officialName, glamaName) {
|
|
1600
|
+
const normalizeName = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1601
|
+
const officialLeaf = normalizeName(officialName.split("/").pop() ?? officialName);
|
|
1602
|
+
const slug = normalizeName(glamaName);
|
|
1603
|
+
return officialLeaf.length > 1 && slug.length > 1 && (officialLeaf === slug || officialLeaf.includes(slug) || slug.includes(officialLeaf));
|
|
1604
|
+
}
|
|
1605
|
+
function numberValue(value) {
|
|
1606
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1607
|
+
}
|
|
1608
|
+
function isRecord(value) {
|
|
1609
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1610
|
+
}
|