loadout-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ConnectorRequestError, fetchBoundedJson, rateLimitFromHeaders, readDiscoveryConnectorCache, writeDiscoveryConnectorCache, } from "./discovery-connector.js";
|
|
3
|
+
import { loadoutHome } from "./paths.js";
|
|
4
|
+
const SOURCE = "official-mcp-registry";
|
|
5
|
+
const API = "https://registry.modelcontextprotocol.io/v0.1/servers";
|
|
6
|
+
const ATTRIBUTION = "https://registry.modelcontextprotocol.io/docs";
|
|
7
|
+
const SERVER_NAME = /^[A-Za-z0-9.-]+\/[A-Za-z0-9._-]+$/;
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
10
|
+
}
|
|
11
|
+
function boundedString(value, maximum, options = {}) {
|
|
12
|
+
return (typeof value === "string" &&
|
|
13
|
+
value.length <= maximum &&
|
|
14
|
+
(options.empty || value.length > 0) &&
|
|
15
|
+
!value.includes("\0") &&
|
|
16
|
+
(options.multiline || !/[\r\n]/.test(value)));
|
|
17
|
+
}
|
|
18
|
+
function validDate(value) {
|
|
19
|
+
return (boundedString(value, 64) && Number.isFinite(Date.parse(value)));
|
|
20
|
+
}
|
|
21
|
+
function safeRelativePath(value) {
|
|
22
|
+
return (boundedString(value, 1_024) &&
|
|
23
|
+
!value.startsWith("/") &&
|
|
24
|
+
!value.split(/[\\/]/).includes(".."));
|
|
25
|
+
}
|
|
26
|
+
function safeHttpsUrl(value) {
|
|
27
|
+
if (!boundedString(value, 2_048))
|
|
28
|
+
return false;
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(value);
|
|
31
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function githubRepository(value) {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(value);
|
|
40
|
+
if (url.origin !== "https://github.com")
|
|
41
|
+
return undefined;
|
|
42
|
+
const path = url.pathname
|
|
43
|
+
.replace(/^\//, "")
|
|
44
|
+
.replace(/\.git$/i, "")
|
|
45
|
+
.replace(/\/$/, "");
|
|
46
|
+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path) ? path : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function transportType(value) {
|
|
53
|
+
if (!isRecord(value))
|
|
54
|
+
return undefined;
|
|
55
|
+
return value.type === "stdio" ||
|
|
56
|
+
value.type === "sse" ||
|
|
57
|
+
value.type === "streamable-http"
|
|
58
|
+
? value.type
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
61
|
+
function normalizePackages(value) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return [];
|
|
64
|
+
if (!Array.isArray(value) || value.length > 50)
|
|
65
|
+
return undefined;
|
|
66
|
+
const distributions = [];
|
|
67
|
+
for (const item of value) {
|
|
68
|
+
if (!isRecord(item))
|
|
69
|
+
return undefined;
|
|
70
|
+
const transport = transportType(item.transport);
|
|
71
|
+
if (!boundedString(item.registryType, 64) ||
|
|
72
|
+
!boundedString(item.identifier, 2_048) ||
|
|
73
|
+
!transport ||
|
|
74
|
+
(item.version !== undefined && !boundedString(item.version, 255)) ||
|
|
75
|
+
(item.fileSha256 !== undefined &&
|
|
76
|
+
(typeof item.fileSha256 !== "string" ||
|
|
77
|
+
!/^[a-f0-9]{64}$/.test(item.fileSha256))))
|
|
78
|
+
return undefined;
|
|
79
|
+
distributions.push({
|
|
80
|
+
kind: "package",
|
|
81
|
+
type: item.registryType,
|
|
82
|
+
identifier: item.identifier,
|
|
83
|
+
...(typeof item.version === "string" ? { version: item.version } : {}),
|
|
84
|
+
transport,
|
|
85
|
+
...(typeof item.fileSha256 === "string"
|
|
86
|
+
? { integritySha256: item.fileSha256 }
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return distributions;
|
|
91
|
+
}
|
|
92
|
+
function normalizeRemotes(value) {
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
return [];
|
|
95
|
+
if (!Array.isArray(value) || value.length > 50)
|
|
96
|
+
return undefined;
|
|
97
|
+
const distributions = [];
|
|
98
|
+
for (const item of value) {
|
|
99
|
+
if (!isRecord(item))
|
|
100
|
+
return undefined;
|
|
101
|
+
const transport = transportType(item);
|
|
102
|
+
if ((transport !== "sse" && transport !== "streamable-http") ||
|
|
103
|
+
!safeHttpsUrl(item.url))
|
|
104
|
+
return undefined;
|
|
105
|
+
distributions.push({
|
|
106
|
+
kind: "remote",
|
|
107
|
+
type: transport,
|
|
108
|
+
identifier: item.url,
|
|
109
|
+
transport,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return distributions;
|
|
113
|
+
}
|
|
114
|
+
function normalizeRepository(value) {
|
|
115
|
+
if (value === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (!isRecord(value) ||
|
|
118
|
+
!safeHttpsUrl(value.url) ||
|
|
119
|
+
!boundedString(value.source, 100) ||
|
|
120
|
+
(value.id !== undefined && !boundedString(value.id, 255)) ||
|
|
121
|
+
(value.subfolder !== undefined && !safeRelativePath(value.subfolder)))
|
|
122
|
+
return false;
|
|
123
|
+
const repository = value.source.toLowerCase() === "github"
|
|
124
|
+
? githubRepository(value.url)
|
|
125
|
+
: undefined;
|
|
126
|
+
if (value.source.toLowerCase() === "github" && !repository)
|
|
127
|
+
return false;
|
|
128
|
+
return {
|
|
129
|
+
url: value.url,
|
|
130
|
+
source: value.source,
|
|
131
|
+
...(typeof value.id === "string" ? { id: value.id } : {}),
|
|
132
|
+
...(typeof value.subfolder === "string"
|
|
133
|
+
? { subfolder: value.subfolder }
|
|
134
|
+
: {}),
|
|
135
|
+
...(repository ? { repository } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function officialMetadata(value) {
|
|
139
|
+
if (!isRecord(value))
|
|
140
|
+
return undefined;
|
|
141
|
+
const official = value["io.modelcontextprotocol.registry/official"];
|
|
142
|
+
return isRecord(official) ? official : undefined;
|
|
143
|
+
}
|
|
144
|
+
function normalizeServer(value, observedAt) {
|
|
145
|
+
if (!isRecord(value) || !isRecord(value.server))
|
|
146
|
+
return undefined;
|
|
147
|
+
const server = value.server;
|
|
148
|
+
if (!boundedString(server.name, 200) ||
|
|
149
|
+
!SERVER_NAME.test(server.name) ||
|
|
150
|
+
!boundedString(server.description, 100, { multiline: true }) ||
|
|
151
|
+
!boundedString(server.version, 255) ||
|
|
152
|
+
server.version === "latest" ||
|
|
153
|
+
(server.title !== undefined && !boundedString(server.title, 100)))
|
|
154
|
+
return undefined;
|
|
155
|
+
const repository = normalizeRepository(server.repository);
|
|
156
|
+
const packages = normalizePackages(server.packages);
|
|
157
|
+
const remotes = normalizeRemotes(server.remotes);
|
|
158
|
+
if (repository === false || !packages || !remotes)
|
|
159
|
+
return undefined;
|
|
160
|
+
const meta = officialMetadata(value._meta);
|
|
161
|
+
const status = meta?.status;
|
|
162
|
+
const lifecycleStatus = status === "active" || status === "deprecated" || status === "deleted"
|
|
163
|
+
? status
|
|
164
|
+
: "unknown";
|
|
165
|
+
if ((meta?.status !== undefined && lifecycleStatus === "unknown") ||
|
|
166
|
+
(meta?.statusMessage !== undefined &&
|
|
167
|
+
!boundedString(meta.statusMessage, 500, { multiline: true })) ||
|
|
168
|
+
(meta?.publishedAt !== undefined && !validDate(meta.publishedAt)) ||
|
|
169
|
+
(meta?.updatedAt !== undefined && !validDate(meta.updatedAt)) ||
|
|
170
|
+
(meta?.statusChangedAt !== undefined && !validDate(meta.statusChangedAt)) ||
|
|
171
|
+
(meta?.isLatest !== undefined && typeof meta.isLatest !== "boolean"))
|
|
172
|
+
return undefined;
|
|
173
|
+
const namespace = server.name.split("/", 1)[0];
|
|
174
|
+
const repositoryKey = repository?.repository
|
|
175
|
+
? `github:${repository.repository.toLowerCase()}`
|
|
176
|
+
: undefined;
|
|
177
|
+
const sourceUrl = `${API}/${encodeURIComponent(server.name)}/versions/${encodeURIComponent(server.version)}`;
|
|
178
|
+
return {
|
|
179
|
+
source: SOURCE,
|
|
180
|
+
kind: "mcp-server",
|
|
181
|
+
identityKey: `mcp-registry:${server.name.toLowerCase()}@${server.version}`,
|
|
182
|
+
...(repositoryKey ? { repositoryKey } : {}),
|
|
183
|
+
externalId: `${server.name}@${server.version}`,
|
|
184
|
+
namespace,
|
|
185
|
+
name: server.name,
|
|
186
|
+
...(typeof server.title === "string" ? { title: server.title } : {}),
|
|
187
|
+
description: server.description,
|
|
188
|
+
version: server.version,
|
|
189
|
+
sourceUrl,
|
|
190
|
+
...(repository ? { repository } : {}),
|
|
191
|
+
distributions: [...packages, ...remotes],
|
|
192
|
+
verification: {
|
|
193
|
+
registry: "Official MCP Registry",
|
|
194
|
+
lifecycleStatus,
|
|
195
|
+
...(typeof meta?.statusMessage === "string"
|
|
196
|
+
? { statusMessage: meta.statusMessage }
|
|
197
|
+
: {}),
|
|
198
|
+
...(typeof meta?.publishedAt === "string"
|
|
199
|
+
? { publishedAt: meta.publishedAt }
|
|
200
|
+
: {}),
|
|
201
|
+
...(typeof meta?.updatedAt === "string"
|
|
202
|
+
? { updatedAt: meta.updatedAt }
|
|
203
|
+
: {}),
|
|
204
|
+
...(typeof meta?.statusChangedAt === "string"
|
|
205
|
+
? { statusChangedAt: meta.statusChangedAt }
|
|
206
|
+
: {}),
|
|
207
|
+
...(typeof meta?.isLatest === "boolean"
|
|
208
|
+
? { isLatest: meta.isLatest }
|
|
209
|
+
: {}),
|
|
210
|
+
namespaceEvidence: "The official registry records publisher namespace verification for publication; Loadout did not independently re-prove namespace ownership.",
|
|
211
|
+
meaning: "Registry membership establishes identity and distribution metadata only, not safety, popularity, availability, permissions, or performance.",
|
|
212
|
+
},
|
|
213
|
+
attribution: {
|
|
214
|
+
source: SOURCE,
|
|
215
|
+
sourceUrl: ATTRIBUTION,
|
|
216
|
+
observedAt,
|
|
217
|
+
meaning: "Metadata reported by the read-only Official MCP Registry v0.1 API; no server or package was installed or executed.",
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function parsePage(value, pageSize) {
|
|
222
|
+
if (!isRecord(value) || !Array.isArray(value.servers))
|
|
223
|
+
throw new Error("Official MCP Registry response envelope is invalid");
|
|
224
|
+
if (value.servers.length > pageSize)
|
|
225
|
+
throw new Error("Official MCP Registry exceeded the requested page size");
|
|
226
|
+
if (value.metadata !== undefined && !isRecord(value.metadata))
|
|
227
|
+
throw new Error("Official MCP Registry pagination metadata is invalid");
|
|
228
|
+
const count = isRecord(value.metadata) ? value.metadata.count : undefined;
|
|
229
|
+
const nextCursor = isRecord(value.metadata)
|
|
230
|
+
? value.metadata.nextCursor
|
|
231
|
+
: undefined;
|
|
232
|
+
if ((count !== undefined &&
|
|
233
|
+
(!Number.isSafeInteger(count) || count !== value.servers.length)) ||
|
|
234
|
+
(nextCursor !== undefined && !boundedString(nextCursor, 2_048)))
|
|
235
|
+
throw new Error("Official MCP Registry pagination metadata is invalid");
|
|
236
|
+
return {
|
|
237
|
+
servers: value.servers,
|
|
238
|
+
...(typeof nextCursor === "string" && nextCursor ? { nextCursor } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
export function isMcpRegistryDiscoveryRecord(value) {
|
|
242
|
+
if (!isRecord(value))
|
|
243
|
+
return false;
|
|
244
|
+
const record = value;
|
|
245
|
+
return (record.source === SOURCE &&
|
|
246
|
+
record.kind === "mcp-server" &&
|
|
247
|
+
boundedString(record.identityKey, 1_024) &&
|
|
248
|
+
boundedString(record.externalId, 512) &&
|
|
249
|
+
boundedString(record.namespace, 200) &&
|
|
250
|
+
boundedString(record.name, 200) &&
|
|
251
|
+
SERVER_NAME.test(record.name) &&
|
|
252
|
+
boundedString(record.description, 100, { multiline: true }) &&
|
|
253
|
+
boundedString(record.version, 255) &&
|
|
254
|
+
boundedString(record.sourceUrl, 2_048) &&
|
|
255
|
+
Array.isArray(record.distributions) &&
|
|
256
|
+
record.distributions.length <= 100 &&
|
|
257
|
+
isRecord(record.verification) &&
|
|
258
|
+
isRecord(record.attribution) &&
|
|
259
|
+
record.attribution.source === SOURCE &&
|
|
260
|
+
validDate(record.attribution.observedAt));
|
|
261
|
+
}
|
|
262
|
+
function issueFromError(error, page) {
|
|
263
|
+
if (error instanceof ConnectorRequestError)
|
|
264
|
+
return {
|
|
265
|
+
code: error.status === 429 ? "rate-limited" : "request-failed",
|
|
266
|
+
message: `Official MCP Registry page ${page} failed: ${error.message}`,
|
|
267
|
+
page,
|
|
268
|
+
...(error.retryAfterSeconds !== undefined
|
|
269
|
+
? { retryAfterSeconds: error.retryAfterSeconds }
|
|
270
|
+
: {}),
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
code: "invalid-response",
|
|
274
|
+
message: `Official MCP Registry page ${page} was invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
275
|
+
page,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
async function cachedOrUnavailable(cachePath, fetchedAt, issues) {
|
|
279
|
+
const cachedPath = cachePath === false ? undefined : cachePath;
|
|
280
|
+
const cache = cachedPath === undefined
|
|
281
|
+
? undefined
|
|
282
|
+
: await readDiscoveryConnectorCache(cachedPath, SOURCE, isMcpRegistryDiscoveryRecord);
|
|
283
|
+
return cache
|
|
284
|
+
? {
|
|
285
|
+
source: SOURCE,
|
|
286
|
+
status: "cached",
|
|
287
|
+
fetchedAt,
|
|
288
|
+
records: cache.records,
|
|
289
|
+
pagesFetched: 0,
|
|
290
|
+
issues,
|
|
291
|
+
cache: { path: cachedPath, cachedAt: cache.cachedAt },
|
|
292
|
+
}
|
|
293
|
+
: {
|
|
294
|
+
source: SOURCE,
|
|
295
|
+
status: "unavailable",
|
|
296
|
+
fetchedAt,
|
|
297
|
+
records: [],
|
|
298
|
+
pagesFetched: 0,
|
|
299
|
+
issues,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** Read public identity/distribution metadata from the official v0.1 registry. */
|
|
303
|
+
export async function discoverOfficialMcpRegistry(options = {}) {
|
|
304
|
+
const now = options.now ?? new Date();
|
|
305
|
+
const fetchedAt = now.toISOString();
|
|
306
|
+
const pageSize = Math.max(1, Math.min(options.pageSize ?? 100, 100));
|
|
307
|
+
const maxPages = Math.max(1, Math.min(options.maxPages ?? 5, 10));
|
|
308
|
+
const maxRecords = Math.max(1, Math.min(options.maxRecords ?? 500, 1_000));
|
|
309
|
+
const search = options.search?.trim();
|
|
310
|
+
if (search && (!boundedString(search, 200) || search.length < 2))
|
|
311
|
+
throw new Error("Official MCP Registry search must be 2-200 safe characters");
|
|
312
|
+
if (options.updatedSince !== undefined &&
|
|
313
|
+
(!validDate(options.updatedSince) || options.updatedSince.length > 64))
|
|
314
|
+
throw new Error("Official MCP Registry updatedSince must be an RFC3339 date");
|
|
315
|
+
if (options.cursor !== undefined && !boundedString(options.cursor, 2_048))
|
|
316
|
+
throw new Error("Official MCP Registry cursor is invalid");
|
|
317
|
+
const cachePath = options.cachePath === false
|
|
318
|
+
? false
|
|
319
|
+
: (options.cachePath ??
|
|
320
|
+
join(loadoutHome(), "discovery-cache", "official-mcp-registry.json"));
|
|
321
|
+
const records = [];
|
|
322
|
+
const positions = new Map();
|
|
323
|
+
const issues = [];
|
|
324
|
+
const seenCursors = new Set();
|
|
325
|
+
let cursor = options.cursor;
|
|
326
|
+
let pagesFetched = 0;
|
|
327
|
+
let stoppedByFailure = false;
|
|
328
|
+
let stoppedByRecordLimit = false;
|
|
329
|
+
let truncatedWithinPage = false;
|
|
330
|
+
let rateLimit;
|
|
331
|
+
for (let page = 0; page < maxPages && records.length < maxRecords; page++) {
|
|
332
|
+
if (cursor) {
|
|
333
|
+
if (seenCursors.has(cursor)) {
|
|
334
|
+
issues.push({
|
|
335
|
+
code: "pagination-replay",
|
|
336
|
+
message: "Official MCP Registry repeated a pagination cursor; traversal stopped",
|
|
337
|
+
page,
|
|
338
|
+
});
|
|
339
|
+
stoppedByFailure = true;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
seenCursors.add(cursor);
|
|
343
|
+
}
|
|
344
|
+
const url = new URL(API);
|
|
345
|
+
url.searchParams.set("limit", String(pageSize));
|
|
346
|
+
if (cursor)
|
|
347
|
+
url.searchParams.set("cursor", cursor);
|
|
348
|
+
if (search)
|
|
349
|
+
url.searchParams.set("search", search);
|
|
350
|
+
if (options.updatedSince)
|
|
351
|
+
url.searchParams.set("updated_since", options.updatedSince);
|
|
352
|
+
if (options.latestOnly !== false)
|
|
353
|
+
url.searchParams.set("version", "latest");
|
|
354
|
+
if (options.includeDeleted)
|
|
355
|
+
url.searchParams.set("include_deleted", "true");
|
|
356
|
+
try {
|
|
357
|
+
const response = await fetchBoundedJson(url.toString(), {
|
|
358
|
+
fetcher: options.fetcher,
|
|
359
|
+
// The public registry can take slightly over ten seconds from a cold
|
|
360
|
+
// edge. Keep the request bounded while avoiding false offline results.
|
|
361
|
+
timeoutMs: options.timeoutMs ?? 20_000,
|
|
362
|
+
maximumBytes: 8 * 1024 * 1024,
|
|
363
|
+
headers: {
|
|
364
|
+
accept: "application/json",
|
|
365
|
+
"user-agent": "loadout-ai-discovery",
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
pagesFetched++;
|
|
369
|
+
rateLimit = rateLimitFromHeaders(response.headers) ?? rateLimit;
|
|
370
|
+
const parsed = parsePage(response.value, pageSize);
|
|
371
|
+
for (const [index, value] of parsed.servers.entries()) {
|
|
372
|
+
const normalized = normalizeServer(value, fetchedAt);
|
|
373
|
+
if (!normalized) {
|
|
374
|
+
issues.push({
|
|
375
|
+
code: "invalid-record",
|
|
376
|
+
message: `Official MCP Registry page ${page} record ${index + 1} was skipped because it failed the bounded v0.1 metadata schema`,
|
|
377
|
+
page,
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const existingIndex = positions.get(normalized.identityKey);
|
|
382
|
+
if (existingIndex !== undefined) {
|
|
383
|
+
const existing = records[existingIndex];
|
|
384
|
+
const existingUpdated = Date.parse(existing.verification.updatedAt ??
|
|
385
|
+
existing.verification.publishedAt ??
|
|
386
|
+
new Date(0).toISOString());
|
|
387
|
+
const candidateUpdated = Date.parse(normalized.verification.updatedAt ??
|
|
388
|
+
normalized.verification.publishedAt ??
|
|
389
|
+
new Date(0).toISOString());
|
|
390
|
+
if (candidateUpdated > existingUpdated)
|
|
391
|
+
records[existingIndex] = normalized;
|
|
392
|
+
issues.push({
|
|
393
|
+
code: "duplicate-record",
|
|
394
|
+
message: `Official MCP Registry repeated ${normalized.externalId}; the most recently updated copy was retained`,
|
|
395
|
+
page,
|
|
396
|
+
});
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
positions.set(normalized.identityKey, records.length);
|
|
400
|
+
records.push(normalized);
|
|
401
|
+
if (records.length >= maxRecords) {
|
|
402
|
+
truncatedWithinPage = index < parsed.servers.length - 1;
|
|
403
|
+
stoppedByRecordLimit =
|
|
404
|
+
truncatedWithinPage || Boolean(parsed.nextCursor);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
cursor = truncatedWithinPage ? undefined : parsed.nextCursor;
|
|
409
|
+
if (!cursor)
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
issues.push(issueFromError(error, page));
|
|
414
|
+
stoppedByFailure = true;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!stoppedByFailure && stoppedByRecordLimit)
|
|
419
|
+
issues.push({
|
|
420
|
+
code: "pagination-limit",
|
|
421
|
+
message: `Official MCP Registry pagination stopped at the configured ${maxRecords}-record limit${truncatedWithinPage ? "; no continuation is exposed because the limit was reached within a page" : ""}`,
|
|
422
|
+
page: pagesFetched,
|
|
423
|
+
});
|
|
424
|
+
else if (!stoppedByFailure && cursor && pagesFetched >= maxPages)
|
|
425
|
+
issues.push({
|
|
426
|
+
code: "pagination-limit",
|
|
427
|
+
message: `Official MCP Registry pagination stopped at the configured ${maxPages}-page limit`,
|
|
428
|
+
page: pagesFetched,
|
|
429
|
+
});
|
|
430
|
+
if (!records.length && issues.length)
|
|
431
|
+
return cachedOrUnavailable(cachePath, fetchedAt, issues);
|
|
432
|
+
let status = issues.length ? "partial" : "complete";
|
|
433
|
+
if (status === "complete" && cachePath !== false) {
|
|
434
|
+
try {
|
|
435
|
+
await writeDiscoveryConnectorCache(cachePath, SOURCE, records, now);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
status = "partial";
|
|
439
|
+
issues.push({
|
|
440
|
+
code: "cache-write-failed",
|
|
441
|
+
message: `Official MCP Registry metadata was fetched but its offline cache could not be written: ${error instanceof Error ? error.message : String(error)}`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
source: SOURCE,
|
|
447
|
+
status,
|
|
448
|
+
fetchedAt,
|
|
449
|
+
records,
|
|
450
|
+
pagesFetched,
|
|
451
|
+
issues,
|
|
452
|
+
...(cursor ? { next: cursor } : {}),
|
|
453
|
+
...(rateLimit ? { rateLimit } : {}),
|
|
454
|
+
};
|
|
455
|
+
}
|