@juspay/neurolink 13.0.3 → 13.1.1
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/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +419 -419
- package/dist/cli/factories/commandFactory.js +13 -4
- package/dist/constants/enums.d.ts +2 -1
- package/dist/constants/enums.js +1 -0
- package/dist/localUsage/grokReader.js +6 -2
- package/dist/mcp/mcpRegistryClient.d.ts +70 -8
- package/dist/mcp/mcpRegistryClient.js +313 -23
- package/dist/mcp/mcpRegistryRemote.d.ts +50 -0
- package/dist/mcp/mcpRegistryRemote.js +289 -0
- package/dist/middleware/utils/guardrailsUtils.js +21 -9
- package/dist/middleware/utils/regexSafety.d.ts +46 -0
- package/dist/middleware/utils/regexSafety.js +483 -0
- package/dist/neurolink.d.ts +4 -0
- package/dist/neurolink.js +17 -0
- package/dist/providers/catalog/provider-catalog.schema.json +1 -1
- package/dist/providers/catalog/reka.json +15 -5
- package/dist/providers/cloudflareClef.js +5 -0
- package/dist/providers/decide/baseUrl.d.ts +8 -0
- package/dist/providers/decide/baseUrl.js +31 -0
- package/dist/providers/perplexityDecider.js +5 -0
- package/dist/providers/systemOneDecision.js +27 -3
- package/dist/types/guardrails.d.ts +54 -0
- package/dist/types/mcp.d.ts +105 -9
- package/dist/types/providerCatalog.d.ts +8 -7
- package/docs-site/static/search-index.json +52 -45
- package/package.json +8 -4
|
@@ -2303,10 +2303,14 @@ export class CLICommandFactory {
|
|
|
2303
2303
|
}
|
|
2304
2304
|
// Use SDK's provider diagnostic method instead of manual testing
|
|
2305
2305
|
const results = await sdk.getProviderStatus({ quiet: !!argv.quiet });
|
|
2306
|
+
// A configured provider whose live check the SDK skips comes back as
|
|
2307
|
+
// "not-configured" with configured: true. It is neither working nor
|
|
2308
|
+
// unconfigured, so it is shown as skipped and left out of the count.
|
|
2309
|
+
const isSkipped = (r) => r.configured && r.status === "not-configured";
|
|
2306
2310
|
if (spinner) {
|
|
2307
2311
|
const working = results.filter((r) => r.status === "working").length;
|
|
2308
|
-
const
|
|
2309
|
-
spinner.succeed(`Provider check complete: ${working}/${
|
|
2312
|
+
const checked = results.filter((r) => r.configured && !isSkipped(r)).length;
|
|
2313
|
+
spinner.succeed(`Provider check complete: ${working}/${checked} providers working`);
|
|
2310
2314
|
}
|
|
2311
2315
|
// Display results
|
|
2312
2316
|
for (const result of results) {
|
|
@@ -2314,11 +2318,16 @@ export class CLICommandFactory {
|
|
|
2314
2318
|
? chalk.green("✅ Working")
|
|
2315
2319
|
: result.status === "failed"
|
|
2316
2320
|
? chalk.red("❌ Failed")
|
|
2317
|
-
:
|
|
2321
|
+
: isSkipped(result)
|
|
2322
|
+
? chalk.gray("⏭ Skipped")
|
|
2323
|
+
: chalk.gray("⚪ Not configured");
|
|
2318
2324
|
const time = result.responseTime ? ` (${result.responseTime}ms)` : "";
|
|
2319
2325
|
const model = result.model ? ` [${result.model}]` : "";
|
|
2320
2326
|
logger.always(`${result.provider}: ${status}${time}${model}`);
|
|
2321
|
-
if (
|
|
2327
|
+
if (isSkipped(result) && result.error) {
|
|
2328
|
+
logger.always(` ${chalk.gray(result.error)}`);
|
|
2329
|
+
}
|
|
2330
|
+
else if (argv.verbose && result.error) {
|
|
2322
2331
|
logger.always(` Error: ${chalk.red(result.error)}`);
|
|
2323
2332
|
}
|
|
2324
2333
|
}
|
|
@@ -1540,7 +1540,8 @@ export declare enum PrimeIntellectModels {
|
|
|
1540
1540
|
export declare enum RekaModels {
|
|
1541
1541
|
REKA_FLASH = "reka-flash",
|
|
1542
1542
|
REKA_EDGE = "reka-edge",
|
|
1543
|
-
REKA_EDGE_2603 = "reka-edge-2603"
|
|
1543
|
+
REKA_EDGE_2603 = "reka-edge-2603",
|
|
1544
|
+
REKA_FLASH_3 = "reka-flash-3"
|
|
1544
1545
|
}
|
|
1545
1546
|
export declare enum RunInfraModels {
|
|
1546
1547
|
NEMOTRON_3_5_LIGHTNING_30B = "nemotron-3-5-lightning-30b"
|
package/dist/constants/enums.js
CHANGED
|
@@ -1842,6 +1842,7 @@ export var RekaModels;
|
|
|
1842
1842
|
RekaModels["REKA_FLASH"] = "reka-flash";
|
|
1843
1843
|
RekaModels["REKA_EDGE"] = "reka-edge";
|
|
1844
1844
|
RekaModels["REKA_EDGE_2603"] = "reka-edge-2603";
|
|
1845
|
+
RekaModels["REKA_FLASH_3"] = "reka-flash-3";
|
|
1845
1846
|
})(RekaModels || (RekaModels = {}));
|
|
1846
1847
|
export var RunInfraModels;
|
|
1847
1848
|
(function (RunInfraModels) {
|
|
@@ -239,7 +239,9 @@ function completedTurn(parsed) {
|
|
|
239
239
|
}
|
|
240
240
|
async function foldStream(filePath, totals, unpriced, errors) {
|
|
241
241
|
// Insertion-ordered, so the delta rule below sees turns in the order Grok
|
|
242
|
-
// wrote them
|
|
242
|
+
// wrote them. A re-emitted prompt replaces its earlier record and moves to
|
|
243
|
+
// where the re-emit was written: Map.set on an existing key keeps the old
|
|
244
|
+
// position, so the key is deleted first.
|
|
243
245
|
const turns = new Map();
|
|
244
246
|
let unrecognised = 0;
|
|
245
247
|
const rl = createInterface({
|
|
@@ -274,7 +276,9 @@ async function foldStream(filePath, totals, unpriced, errors) {
|
|
|
274
276
|
}
|
|
275
277
|
continue;
|
|
276
278
|
}
|
|
277
|
-
|
|
279
|
+
const key = turn.promptId ?? `line:${lineNo}`;
|
|
280
|
+
turns.delete(key);
|
|
281
|
+
turns.set(key, readTurn(turn.usage));
|
|
278
282
|
}
|
|
279
283
|
}
|
|
280
284
|
finally {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP Registry Client
|
|
3
3
|
*
|
|
4
|
-
* Client for discovering MCP servers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
4
|
+
* Client for discovering MCP servers. It always knows a built-in catalog of
|
|
5
|
+
* well-known servers plus any custom entries. When remote lookup is enabled
|
|
6
|
+
* (`remoteLookup: true`, off by default) it also consults:
|
|
7
|
+
* - the official MCP Registry (https://registry.modelcontextprotocol.io)
|
|
8
|
+
* - custom registries that serve the same `/v0.1` API
|
|
9
|
+
*
|
|
10
|
+
* `npm` and `github` registry sources are accepted in configuration but not
|
|
11
|
+
* queried.
|
|
10
12
|
*
|
|
11
13
|
* @module mcp/mcpRegistryClient
|
|
12
14
|
* @since 8.39.0
|
|
@@ -30,6 +32,10 @@ import type { MCPServerInfo, MCPRegistryClientConfig, RegistryConfig, RegistrySe
|
|
|
30
32
|
*
|
|
31
33
|
* // Convert to MCPServerInfo
|
|
32
34
|
* const serverInfo = client.toServerInfo(entry);
|
|
35
|
+
*
|
|
36
|
+
* // Opt in to the official MCP Registry (remote entries are never `verified`)
|
|
37
|
+
* const remote = new MCPRegistryClient({ remoteLookup: true });
|
|
38
|
+
* const found = await remote.getEntry("io.github.example/my-server");
|
|
33
39
|
* ```
|
|
34
40
|
*/
|
|
35
41
|
export declare class MCPRegistryClient extends EventEmitter {
|
|
@@ -42,13 +48,63 @@ export declare class MCPRegistryClient extends EventEmitter {
|
|
|
42
48
|
*/
|
|
43
49
|
search(options?: RegistrySearchOptions): Promise<RegistrySearchResult>;
|
|
44
50
|
/**
|
|
45
|
-
* Get a specific entry by ID
|
|
51
|
+
* Get a specific entry by ID.
|
|
52
|
+
*
|
|
53
|
+
* Checks custom entries, then the built-in catalog. Only when remote
|
|
54
|
+
* lookup is enabled does a miss fall through to the configured registries
|
|
55
|
+
* (first hit wins); a registry that fails or answers with something
|
|
56
|
+
* unreadable is skipped, so the result is `undefined` rather than an error.
|
|
46
57
|
*/
|
|
47
58
|
getEntry(id: string): Promise<McpRegistryEntry | undefined>;
|
|
48
59
|
/**
|
|
49
|
-
* Get all available entries
|
|
60
|
+
* Get all available entries.
|
|
61
|
+
*
|
|
62
|
+
* Lists the built-in catalog, then the custom entries (a custom entry
|
|
63
|
+
* replaces a built-in one with the same id), then, when remote lookup is
|
|
64
|
+
* enabled, the servers of each configured registry. A remote entry never
|
|
65
|
+
* replaces a local one. A registry that cannot be read contributes nothing.
|
|
50
66
|
*/
|
|
51
67
|
getAllEntries(): Promise<McpRegistryEntry[]>;
|
|
68
|
+
/**
|
|
69
|
+
* Collect local entries and, when remote lookup is on, each registry's
|
|
70
|
+
* servers. `query` narrows the remote lists with the registry's own
|
|
71
|
+
* `search` parameter (substring match on server name); `search()` still
|
|
72
|
+
* applies its full filter afterwards.
|
|
73
|
+
*
|
|
74
|
+
* The merged list is cached only when every registry answered, so a
|
|
75
|
+
* transient failure is not pinned for the whole TTL.
|
|
76
|
+
*/
|
|
77
|
+
private collectEntries;
|
|
78
|
+
/**
|
|
79
|
+
* Registries the client may query: `official` and `custom` sources with a
|
|
80
|
+
* usable URL. Other sources are skipped.
|
|
81
|
+
*/
|
|
82
|
+
private getRemoteTargets;
|
|
83
|
+
private fetchRemoteEntry;
|
|
84
|
+
private fetchRemoteList;
|
|
85
|
+
/**
|
|
86
|
+
* GET a JSON document from a registry, bounded by its timeout. A 404
|
|
87
|
+
* resolves with an undefined body (a normal "not found"); any other
|
|
88
|
+
* non-2xx status, a network error, a timeout or a body that is not JSON
|
|
89
|
+
* rejects.
|
|
90
|
+
*/
|
|
91
|
+
private requestJson;
|
|
92
|
+
/**
|
|
93
|
+
* Parse a JSON body, refusing to buffer more than
|
|
94
|
+
* `REMOTE_MAX_RESPONSE_BYTES`. The request timeout bounds duration, not
|
|
95
|
+
* memory, so the size is checked while reading.
|
|
96
|
+
*/
|
|
97
|
+
private readBoundedJson;
|
|
98
|
+
private reportRemoteError;
|
|
99
|
+
private readCache;
|
|
100
|
+
private readRemoteCache;
|
|
101
|
+
private writeRemoteCache;
|
|
102
|
+
/**
|
|
103
|
+
* The client-wide `enableCache: false` is a ceiling: a registry can switch
|
|
104
|
+
* its own caching off but cannot turn it back on.
|
|
105
|
+
*/
|
|
106
|
+
private isRemoteCacheEnabled;
|
|
107
|
+
private writeCache;
|
|
52
108
|
/**
|
|
53
109
|
* Get entries by category
|
|
54
110
|
*/
|
|
@@ -81,6 +137,12 @@ export declare class MCPRegistryClient extends EventEmitter {
|
|
|
81
137
|
* Add a registry configuration
|
|
82
138
|
*/
|
|
83
139
|
addRegistry(config: RegistryConfig): void;
|
|
140
|
+
/**
|
|
141
|
+
* Turn remote lookup on or off at runtime, e.g. on the client returned by
|
|
142
|
+
* `NeuroLink.getMCPRegistryClient()`. Off is the default. Clears the cache
|
|
143
|
+
* so the next lookup reflects the new setting.
|
|
144
|
+
*/
|
|
145
|
+
setRemoteLookup(enabled: boolean): void;
|
|
84
146
|
/**
|
|
85
147
|
* Clear the cache
|
|
86
148
|
*/
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP Registry Client
|
|
3
3
|
*
|
|
4
|
-
* Client for discovering MCP servers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
4
|
+
* Client for discovering MCP servers. It always knows a built-in catalog of
|
|
5
|
+
* well-known servers plus any custom entries. When remote lookup is enabled
|
|
6
|
+
* (`remoteLookup: true`, off by default) it also consults:
|
|
7
|
+
* - the official MCP Registry (https://registry.modelcontextprotocol.io)
|
|
8
|
+
* - custom registries that serve the same `/v0.1` API
|
|
9
|
+
*
|
|
10
|
+
* `npm` and `github` registry sources are accepted in configuration but not
|
|
11
|
+
* queried.
|
|
10
12
|
*
|
|
11
13
|
* @module mcp/mcpRegistryClient
|
|
12
14
|
* @since 8.39.0
|
|
13
15
|
*/
|
|
14
16
|
import { EventEmitter } from "events";
|
|
17
|
+
import { mcpLogger } from "../utils/logger.js";
|
|
18
|
+
import { mapRemoteRecord, parseRemotePage, parseRemoteRecord, resolveRemoteTarget, } from "./mcpRegistryRemote.js";
|
|
19
|
+
const REMOTE_PAGE_SIZE = 100;
|
|
20
|
+
const REMOTE_DEFAULT_MAX_PAGES = 5;
|
|
21
|
+
// A list page of 100 servers is well under 2 MiB; the cap only stops a hostile
|
|
22
|
+
// or broken registry from filling memory within the timeout.
|
|
23
|
+
const REMOTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
24
|
+
const CACHE_MAX_ENTRIES = 500;
|
|
15
25
|
/**
|
|
16
26
|
* Well-known MCP servers catalog
|
|
17
27
|
*/
|
|
@@ -183,6 +193,10 @@ const WELL_KNOWN_SERVERS = [
|
|
|
183
193
|
*
|
|
184
194
|
* // Convert to MCPServerInfo
|
|
185
195
|
* const serverInfo = client.toServerInfo(entry);
|
|
196
|
+
*
|
|
197
|
+
* // Opt in to the official MCP Registry (remote entries are never `verified`)
|
|
198
|
+
* const remote = new MCPRegistryClient({ remoteLookup: true });
|
|
199
|
+
* const found = await remote.getEntry("io.github.example/my-server");
|
|
186
200
|
* ```
|
|
187
201
|
*/
|
|
188
202
|
export class MCPRegistryClient extends EventEmitter {
|
|
@@ -192,9 +206,10 @@ export class MCPRegistryClient extends EventEmitter {
|
|
|
192
206
|
constructor(config = {}) {
|
|
193
207
|
super();
|
|
194
208
|
this.config = {
|
|
195
|
-
registries:
|
|
196
|
-
{ type: "official", enableCache: true },
|
|
209
|
+
registries: [
|
|
210
|
+
...(config.registries ?? [{ type: "official", enableCache: true }]),
|
|
197
211
|
],
|
|
212
|
+
remoteLookup: config.remoteLookup ?? false,
|
|
198
213
|
enableCache: config.enableCache ?? true,
|
|
199
214
|
defaultCacheTTL: config.defaultCacheTTL ?? 3600000, // 1 hour
|
|
200
215
|
timeout: config.timeout ?? 10000,
|
|
@@ -208,8 +223,12 @@ export class MCPRegistryClient extends EventEmitter {
|
|
|
208
223
|
const { query, categories, tags, transport, verifiedOnly = false, sortBy = "downloads", sortDirection = "desc", limit: rawLimit = 25, offset: rawOffset = 0, } = options;
|
|
209
224
|
const limit = Math.max(1, rawLimit);
|
|
210
225
|
const offset = Math.max(0, rawOffset);
|
|
211
|
-
// Get all entries (from cache or fetch)
|
|
212
|
-
|
|
226
|
+
// Get all entries (from cache or fetch). Copy: the sort below must not
|
|
227
|
+
// reorder the cached list.
|
|
228
|
+
const listed = query
|
|
229
|
+
? await this.collectEntries(query)
|
|
230
|
+
: await this.getAllEntries();
|
|
231
|
+
let entries = [...listed];
|
|
213
232
|
// Apply filters
|
|
214
233
|
if (query) {
|
|
215
234
|
const searchLower = query.toLowerCase();
|
|
@@ -260,7 +279,12 @@ export class MCPRegistryClient extends EventEmitter {
|
|
|
260
279
|
};
|
|
261
280
|
}
|
|
262
281
|
/**
|
|
263
|
-
* Get a specific entry by ID
|
|
282
|
+
* Get a specific entry by ID.
|
|
283
|
+
*
|
|
284
|
+
* Checks custom entries, then the built-in catalog. Only when remote
|
|
285
|
+
* lookup is enabled does a miss fall through to the configured registries
|
|
286
|
+
* (first hit wins); a registry that fails or answers with something
|
|
287
|
+
* unreadable is skipped, so the result is `undefined` rather than an error.
|
|
264
288
|
*/
|
|
265
289
|
async getEntry(id) {
|
|
266
290
|
// Check custom entries first
|
|
@@ -272,35 +296,291 @@ export class MCPRegistryClient extends EventEmitter {
|
|
|
272
296
|
if (wellKnown) {
|
|
273
297
|
return wellKnown;
|
|
274
298
|
}
|
|
275
|
-
|
|
299
|
+
if (!this.config.remoteLookup) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
// An empty id, "." and ".." are path segments: URL normalisation would
|
|
303
|
+
// turn them into a different registry endpoint, so they are a miss.
|
|
304
|
+
if (id === "" || id === "." || id === "..") {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
for (const target of this.getRemoteTargets()) {
|
|
308
|
+
const entry = await this.fetchRemoteEntry(target, id);
|
|
309
|
+
if (entry) {
|
|
310
|
+
return entry;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
276
313
|
return undefined;
|
|
277
314
|
}
|
|
278
315
|
/**
|
|
279
|
-
* Get all available entries
|
|
316
|
+
* Get all available entries.
|
|
317
|
+
*
|
|
318
|
+
* Lists the built-in catalog, then the custom entries (a custom entry
|
|
319
|
+
* replaces a built-in one with the same id), then, when remote lookup is
|
|
320
|
+
* enabled, the servers of each configured registry. A remote entry never
|
|
321
|
+
* replaces a local one. A registry that cannot be read contributes nothing.
|
|
280
322
|
*/
|
|
281
323
|
async getAllEntries() {
|
|
282
|
-
|
|
324
|
+
return this.collectEntries();
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Collect local entries and, when remote lookup is on, each registry's
|
|
328
|
+
* servers. `query` narrows the remote lists with the registry's own
|
|
329
|
+
* `search` parameter (substring match on server name); `search()` still
|
|
330
|
+
* applies its full filter afterwards.
|
|
331
|
+
*
|
|
332
|
+
* The merged list is cached only when every registry answered, so a
|
|
333
|
+
* transient failure is not pinned for the whole TTL.
|
|
334
|
+
*/
|
|
335
|
+
async collectEntries(query) {
|
|
336
|
+
// Without remote lookup the query changes nothing, so share one entry.
|
|
337
|
+
const cacheKey = query === undefined || !this.config.remoteLookup
|
|
338
|
+
? "all-entries"
|
|
339
|
+
: `all-entries:${query.toLowerCase()}`;
|
|
340
|
+
// The merged list must not outlive the shortest-lived registry cache, and
|
|
341
|
+
// is not cached at all if any registry has caching switched off.
|
|
342
|
+
const targets = this.config.remoteLookup ? this.getRemoteTargets() : [];
|
|
343
|
+
const cacheable = this.config.enableCache &&
|
|
344
|
+
targets.every((t) => this.isRemoteCacheEnabled(t));
|
|
345
|
+
const ttl = Math.min(this.config.defaultCacheTTL, ...targets.map((t) => t.config.cacheTTL ?? this.config.defaultCacheTTL));
|
|
283
346
|
// Check cache
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
347
|
+
const cached = cacheable
|
|
348
|
+
? this.readCache(cacheKey, ttl)
|
|
349
|
+
: undefined;
|
|
350
|
+
if (cached) {
|
|
351
|
+
return cached;
|
|
290
352
|
}
|
|
291
353
|
// Combine well-known servers and custom entries, deduplicating by ID.
|
|
292
354
|
// Custom entries take precedence over well-known servers.
|
|
293
355
|
const customIds = new Set(this.customEntries.keys());
|
|
294
|
-
const
|
|
356
|
+
const localEntries = [
|
|
295
357
|
...WELL_KNOWN_SERVERS.filter((entry) => !customIds.has(entry.id)),
|
|
296
358
|
...Array.from(this.customEntries.values()),
|
|
297
359
|
];
|
|
360
|
+
let allEntries = localEntries;
|
|
361
|
+
let complete = true;
|
|
362
|
+
if (this.config.remoteLookup) {
|
|
363
|
+
const lists = await Promise.all(targets.map((target) => this.fetchRemoteList(target, query)));
|
|
364
|
+
const seen = new Set(localEntries.map((entry) => entry.id));
|
|
365
|
+
const remoteEntries = [];
|
|
366
|
+
for (const list of lists) {
|
|
367
|
+
complete = complete && list.ok;
|
|
368
|
+
for (const entry of list.entries) {
|
|
369
|
+
if (!seen.has(entry.id)) {
|
|
370
|
+
seen.add(entry.id);
|
|
371
|
+
remoteEntries.push(entry);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
allEntries = [...localEntries, ...remoteEntries];
|
|
376
|
+
}
|
|
298
377
|
// Cache the result
|
|
299
|
-
if (
|
|
300
|
-
this.
|
|
378
|
+
if (cacheable && complete) {
|
|
379
|
+
this.writeCache(cacheKey, allEntries);
|
|
301
380
|
}
|
|
302
381
|
return allEntries;
|
|
303
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Registries the client may query: `official` and `custom` sources with a
|
|
385
|
+
* usable URL. Other sources are skipped.
|
|
386
|
+
*/
|
|
387
|
+
getRemoteTargets() {
|
|
388
|
+
return this.config.registries.flatMap((registry) => {
|
|
389
|
+
const target = resolveRemoteTarget(registry);
|
|
390
|
+
if (target === undefined) {
|
|
391
|
+
mcpLogger.debug(`[MCPRegistryClient] Skipping registry of type "${registry.type}": not queryable (unsupported type, missing or unusable URL, or a bearer token on plain http to a non-loopback host)`);
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
return [target];
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async fetchRemoteEntry(target, id) {
|
|
398
|
+
const cacheKey = `remote-entry:${target.apiBase}:${id}`;
|
|
399
|
+
const cached = this.readRemoteCache(cacheKey, target);
|
|
400
|
+
if (cached) {
|
|
401
|
+
return cached;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const response = await this.requestJson(target, `/servers/${encodeURIComponent(id)}/versions/latest`);
|
|
405
|
+
if (response.body === undefined) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
const record = parseRemoteRecord(response.body);
|
|
409
|
+
if (record === undefined ||
|
|
410
|
+
record.status === "deleted" ||
|
|
411
|
+
record.server.name.toLowerCase() !== id.toLowerCase()) {
|
|
412
|
+
throw new Error("registry returned no record for the requested server");
|
|
413
|
+
}
|
|
414
|
+
const entry = mapRemoteRecord(record, target.apiBase);
|
|
415
|
+
this.writeRemoteCache(cacheKey, entry, target);
|
|
416
|
+
return entry;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
this.reportRemoteError(target, "getEntry", error);
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async fetchRemoteList(target, query) {
|
|
424
|
+
const cacheKey = `remote-list:${target.apiBase}:${(query ?? "").toLowerCase()}`;
|
|
425
|
+
const cached = this.readRemoteCache(cacheKey, target);
|
|
426
|
+
if (cached) {
|
|
427
|
+
return { entries: cached, ok: true };
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const configuredPages = target.config.maxPages;
|
|
431
|
+
const maxPages = configuredPages !== undefined && Number.isFinite(configuredPages)
|
|
432
|
+
? Math.max(1, Math.floor(configuredPages))
|
|
433
|
+
: REMOTE_DEFAULT_MAX_PAGES;
|
|
434
|
+
const entries = [];
|
|
435
|
+
const seenCursors = new Set();
|
|
436
|
+
let cursor;
|
|
437
|
+
for (let page = 0; page < maxPages; page++) {
|
|
438
|
+
const params = new URLSearchParams({
|
|
439
|
+
version: "latest",
|
|
440
|
+
limit: String(REMOTE_PAGE_SIZE),
|
|
441
|
+
});
|
|
442
|
+
if (query) {
|
|
443
|
+
params.set("search", query);
|
|
444
|
+
}
|
|
445
|
+
if (cursor) {
|
|
446
|
+
params.set("cursor", cursor);
|
|
447
|
+
}
|
|
448
|
+
const response = await this.requestJson(target, "/servers", params);
|
|
449
|
+
const { records, nextCursor } = parseRemotePage(response.body);
|
|
450
|
+
entries.push(...records
|
|
451
|
+
.filter((record) => record.status !== "deleted")
|
|
452
|
+
.map((record) => mapRemoteRecord(record, target.apiBase)));
|
|
453
|
+
if (nextCursor === undefined || seenCursors.has(nextCursor)) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (page === maxPages - 1) {
|
|
457
|
+
mcpLogger.debug(`[MCPRegistryClient] ${target.apiBase}: stopped after ${maxPages} page(s); more servers are available`);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
seenCursors.add(nextCursor);
|
|
461
|
+
cursor = nextCursor;
|
|
462
|
+
}
|
|
463
|
+
this.writeRemoteCache(cacheKey, entries, target);
|
|
464
|
+
return { entries, ok: true };
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
this.reportRemoteError(target, "list", error);
|
|
468
|
+
return { entries: [], ok: false };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* GET a JSON document from a registry, bounded by its timeout. A 404
|
|
473
|
+
* resolves with an undefined body (a normal "not found"); any other
|
|
474
|
+
* non-2xx status, a network error, a timeout or a body that is not JSON
|
|
475
|
+
* rejects.
|
|
476
|
+
*/
|
|
477
|
+
async requestJson(target, path, params) {
|
|
478
|
+
const url = new URL(`${target.apiBase}${path}`);
|
|
479
|
+
params?.forEach((value, key) => url.searchParams.set(key, value));
|
|
480
|
+
const headers = {
|
|
481
|
+
Accept: "application/json",
|
|
482
|
+
"User-Agent": this.config.userAgent,
|
|
483
|
+
...(target.config.authToken
|
|
484
|
+
? { Authorization: `Bearer ${target.config.authToken}` }
|
|
485
|
+
: {}),
|
|
486
|
+
};
|
|
487
|
+
const response = await globalThis.fetch(url, {
|
|
488
|
+
method: "GET",
|
|
489
|
+
headers,
|
|
490
|
+
signal: AbortSignal.timeout(target.config.timeout ?? this.config.timeout),
|
|
491
|
+
});
|
|
492
|
+
if (response.status === 404) {
|
|
493
|
+
await response.body?.cancel();
|
|
494
|
+
return { body: undefined };
|
|
495
|
+
}
|
|
496
|
+
if (!response.ok) {
|
|
497
|
+
await response.body?.cancel();
|
|
498
|
+
throw new Error(`registry responded with HTTP ${response.status}`);
|
|
499
|
+
}
|
|
500
|
+
return { body: await this.readBoundedJson(response) };
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Parse a JSON body, refusing to buffer more than
|
|
504
|
+
* `REMOTE_MAX_RESPONSE_BYTES`. The request timeout bounds duration, not
|
|
505
|
+
* memory, so the size is checked while reading.
|
|
506
|
+
*/
|
|
507
|
+
async readBoundedJson(response) {
|
|
508
|
+
const chunks = [];
|
|
509
|
+
let size = 0;
|
|
510
|
+
if (response.body) {
|
|
511
|
+
const reader = response.body.getReader();
|
|
512
|
+
for (;;) {
|
|
513
|
+
const { done, value } = await reader.read();
|
|
514
|
+
if (done) {
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
size += value.byteLength;
|
|
518
|
+
if (size > REMOTE_MAX_RESPONSE_BYTES) {
|
|
519
|
+
await reader.cancel();
|
|
520
|
+
throw new Error(`registry response exceeds ${REMOTE_MAX_RESPONSE_BYTES} bytes`);
|
|
521
|
+
}
|
|
522
|
+
chunks.push(value);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
526
|
+
}
|
|
527
|
+
reportRemoteError(target, operation, error) {
|
|
528
|
+
const baseMessage = error instanceof Error ? error.message : String(error);
|
|
529
|
+
// fetch() reports every connection failure as "fetch failed"; the code
|
|
530
|
+
// (ECONNREFUSED, ENOTFOUND, ...) sits on the cause.
|
|
531
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
532
|
+
const causeCode = typeof cause === "object" && cause !== null && "code" in cause
|
|
533
|
+
? String(cause.code)
|
|
534
|
+
: undefined;
|
|
535
|
+
const message = causeCode === undefined ? baseMessage : `${baseMessage} (${causeCode})`;
|
|
536
|
+
mcpLogger.debug(`[MCPRegistryClient] ${operation} failed for ${target.apiBase}: ${message}`);
|
|
537
|
+
try {
|
|
538
|
+
this.emit("registryError", {
|
|
539
|
+
registry: target.apiBase,
|
|
540
|
+
operation,
|
|
541
|
+
message,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch (listenerError) {
|
|
545
|
+
// A throwing listener must not turn a degraded lookup into an error.
|
|
546
|
+
mcpLogger.debug(`[MCPRegistryClient] registryError listener threw: ${String(listenerError)}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
readCache(key, ttl) {
|
|
550
|
+
const cached = this.cache.get(key);
|
|
551
|
+
// Each key is written with one shape only, so the cast matches the writer.
|
|
552
|
+
return cached && Date.now() - cached.timestamp < ttl
|
|
553
|
+
? cached.data
|
|
554
|
+
: undefined;
|
|
555
|
+
}
|
|
556
|
+
readRemoteCache(key, target) {
|
|
557
|
+
return this.isRemoteCacheEnabled(target)
|
|
558
|
+
? this.readCache(key, target.config.cacheTTL ?? this.config.defaultCacheTTL)
|
|
559
|
+
: undefined;
|
|
560
|
+
}
|
|
561
|
+
writeRemoteCache(key, data, target) {
|
|
562
|
+
if (this.isRemoteCacheEnabled(target)) {
|
|
563
|
+
this.writeCache(key, data);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* The client-wide `enableCache: false` is a ceiling: a registry can switch
|
|
568
|
+
* its own caching off but cannot turn it back on.
|
|
569
|
+
*/
|
|
570
|
+
isRemoteCacheEnabled(target) {
|
|
571
|
+
return this.config.enableCache && (target.config.enableCache ?? true);
|
|
572
|
+
}
|
|
573
|
+
writeCache(key, data) {
|
|
574
|
+
// Bound the cache: a long-lived client that searches many different
|
|
575
|
+
// queries must not grow without limit. Map iterates oldest first.
|
|
576
|
+
if (!this.cache.has(key) && this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
577
|
+
const oldest = this.cache.keys().next();
|
|
578
|
+
if (!oldest.done) {
|
|
579
|
+
this.cache.delete(oldest.value);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
this.cache.set(key, { data, timestamp: Date.now() });
|
|
583
|
+
}
|
|
304
584
|
/**
|
|
305
585
|
* Get entries by category
|
|
306
586
|
*/
|
|
@@ -400,6 +680,16 @@ export class MCPRegistryClient extends EventEmitter {
|
|
|
400
680
|
const { authToken: _, ...safeConfig } = config;
|
|
401
681
|
this.emit("registryAdded", { config: safeConfig });
|
|
402
682
|
}
|
|
683
|
+
/**
|
|
684
|
+
* Turn remote lookup on or off at runtime, e.g. on the client returned by
|
|
685
|
+
* `NeuroLink.getMCPRegistryClient()`. Off is the default. Clears the cache
|
|
686
|
+
* so the next lookup reflects the new setting.
|
|
687
|
+
*/
|
|
688
|
+
setRemoteLookup(enabled) {
|
|
689
|
+
this.config.remoteLookup = enabled;
|
|
690
|
+
this.clearCache();
|
|
691
|
+
this.emit("remoteLookupChanged", { enabled });
|
|
692
|
+
}
|
|
403
693
|
/**
|
|
404
694
|
* Clear the cache
|
|
405
695
|
*/
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote registry helpers for MCPRegistryClient
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that resolve a registry configuration to an API base URL,
|
|
5
|
+
* validate responses from the official MCP Registry API (`/v0.1`) and map its
|
|
6
|
+
* server records to `McpRegistryEntry`. Nothing here performs I/O.
|
|
7
|
+
*
|
|
8
|
+
* API reference (spec `2025-12-01`):
|
|
9
|
+
* https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml
|
|
10
|
+
*
|
|
11
|
+
* @module mcp/mcpRegistryRemote
|
|
12
|
+
*/
|
|
13
|
+
import type { McpRegistryEntry, RegistryConfig, RemoteRegistryPage, RemoteRegistryRecord, RemoteRegistryTarget } from "../types/index.js";
|
|
14
|
+
/**
|
|
15
|
+
* Base URL of the official MCP Registry
|
|
16
|
+
*/
|
|
17
|
+
export declare const OFFICIAL_REGISTRY_URL = "https://registry.modelcontextprotocol.io";
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a registry configuration to the API base URL the client queries.
|
|
20
|
+
*
|
|
21
|
+
* Returns `undefined` for registries the client does not query: `npm` and
|
|
22
|
+
* `github` sources, a `custom` registry without a URL, any URL that is not
|
|
23
|
+
* plain `http(s)` or that embeds credentials (fetch rejects those, and they
|
|
24
|
+
* would end up in logs), and a registry with an `authToken` on plain `http:`
|
|
25
|
+
* to a non-loopback host (the bearer token would cross the network in clear).
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveRemoteTarget(config: RegistryConfig): RemoteRegistryTarget | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Validate one `ServerResponse` (`{ server, _meta }`) from the registry.
|
|
30
|
+
* Returns `undefined` when it carries no usable server, so a bad record can
|
|
31
|
+
* be skipped without discarding its neighbours.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseRemoteRecord(value: unknown): RemoteRegistryRecord | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Validate a `ServerList` response (`{ servers, metadata: { nextCursor } }`).
|
|
36
|
+
* Throws when `servers` is not an array, which the caller reports as a
|
|
37
|
+
* malformed registry; individual bad records are skipped.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseRemotePage(value: unknown): RemoteRegistryPage;
|
|
40
|
+
/**
|
|
41
|
+
* Map a registry record to a catalog entry.
|
|
42
|
+
*
|
|
43
|
+
* Remote entries are never `verified`: that flag means "reviewed by NeuroLink"
|
|
44
|
+
* for the built-in catalog, and the registry's contents are the publishers'
|
|
45
|
+
* own claims. Only an `npm` package whose identifier and version are valid
|
|
46
|
+
* npm names gets a runnable `command`; anything else (other package types such
|
|
47
|
+
* as pypi, oci or nuget, or an npm package with a malformed identifier or
|
|
48
|
+
* version) is listed in `metadata.packages` only, for the caller to act on.
|
|
49
|
+
*/
|
|
50
|
+
export declare function mapRemoteRecord(record: RemoteRegistryRecord, apiBase: string): McpRegistryEntry;
|