@juspay/neurolink 13.0.2 → 13.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.
@@ -14,8 +14,7 @@
14
14
  *
15
15
  * @module @neurolink/react
16
16
  */
17
- import type { ReactNode } from "react";
18
- import type { NeuroLinkProviderProps, UseChatOptions, UseChatReturn, UseAgentOptions, UseAgentReturn, UseWorkflowOptions, UseWorkflowReturn, UseVoiceOptions, UseVoiceReturn, UseStreamOptions, UseStreamReturn, UseToolsOptions, UseToolsReturn, SpeechRecognitionInternal } from "../types/index.js";
17
+ import type { ClientReactElement, NeuroLinkProviderProps, UseChatOptions, UseChatReturn, UseAgentOptions, UseAgentReturn, UseWorkflowOptions, UseWorkflowReturn, UseVoiceOptions, UseVoiceReturn, UseStreamOptions, UseStreamReturn, UseToolsOptions, UseToolsReturn, SpeechRecognitionInternal } from "../types/index.js";
19
18
  import { NeuroLinkClient } from "./httpClient.js";
20
19
  /**
21
20
  * Provider component for NeuroLink client
@@ -40,9 +39,7 @@ import { NeuroLinkClient } from "./httpClient.js";
40
39
  * }
41
40
  * ```
42
41
  */
43
- export declare function NeuroLinkProvider({ config, children, }: NeuroLinkProviderProps & {
44
- children: ReactNode;
45
- }): ReactNode;
42
+ export declare function NeuroLinkProvider({ config, children, }: NeuroLinkProviderProps): ClientReactElement;
46
43
  /**
47
44
  * Hook to access the NeuroLink client
48
45
  *
@@ -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"
@@ -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) {
@@ -48,12 +48,15 @@ const DEFAULT_EMBEDDING_MIN_TOOLS = 20;
48
48
  const DEFAULT_EMBEDDING_TOP_K = 20;
49
49
  /**
50
50
  * Upper bound on the user-query text interpolated into the router prompt.
51
- * The query is untrusted and can attempt prompt injection — the blast radius
52
- * is already bounded (the worst a successful injection achieves is making the
53
- * router keep MORE already-registered tools; out-of-catalog ids are filtered),
54
- * but without a cap an arbitrarily large query is sent to the router LLM every
55
- * turn. 10K chars is far more than enough to classify routing intent while
56
- * leaving room for a window of recent conversation turns.
51
+ * The query is untrusted and can attempt prompt injection. The reach is
52
+ * bounded, not zero: out-of-catalog ids are filtered, so an injected query
53
+ * cannot name a tool that was not already registered, and an empty pick fails
54
+ * open. But the router returns the servers to KEEP and every unselected
55
+ * routable server is excluded for the turn with no confidence bar, so a
56
+ * steered pick can withhold tools. Without a cap an arbitrarily large query is
57
+ * sent to the router LLM every turn. 10K chars is far more than enough to
58
+ * classify routing intent while leaving room for a window of recent
59
+ * conversation turns.
57
60
  */
58
61
  const MAX_ROUTER_QUERY_CHARS = 10000;
59
62
  /**
@@ -33,9 +33,11 @@ const SERVER_NAMESPACE = "server";
33
33
  const DEFAULT_MIN_DROP_CONFIDENCE = 0.6;
34
34
  /**
35
35
  * Upper bound on the query text sent as state. The query is untrusted and may
36
- * attempt injection; the blast radius is bounded (the worst outcome is keeping
37
- * MORE already-registered tools, since ids are never read back off the wire —
38
- * answers are matched by position), but an unbounded state would still be
36
+ * attempt injection. Ids are never read back off the wire — answers are matched
37
+ * by position — so an injected query cannot name a tool that was not already
38
+ * registered, and the result is only a denylist appended to `excludeTools`. It
39
+ * can still steer a verdict: a confident `false` drops that server's tools for
40
+ * the turn. The reach is bounded, not zero. An unbounded state would also be
39
41
  * billed and could push the request past the model's input ceiling.
40
42
  */
41
43
  const MAX_STATE_CHARS = 10000;
@@ -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, with a re-emitted prompt replacing its earlier record.
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
- turns.set(turn.promptId ?? `line:${lineNo}`, readTurn(turn.usage));
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 from centralized registries.
5
- * Supports multiple registry sources including:
6
- * - Official MCP Registry
7
- * - NPM packages
8
- * - GitHub repositories
9
- * - Custom registries
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 from centralized registries.
5
- * Supports multiple registry sources including:
6
- * - Official MCP Registry
7
- * - NPM packages
8
- * - GitHub repositories
9
- * - Custom registries
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: config.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
- let entries = await this.getAllEntries();
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
- // TODO(#1956): Fetch from remote registry
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
- const cacheKey = "all-entries";
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
- if (this.config.enableCache) {
285
- const cached = this.cache.get(cacheKey);
286
- if (cached &&
287
- Date.now() - cached.timestamp < this.config.defaultCacheTTL) {
288
- return cached.data;
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 allEntries = [
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 (this.config.enableCache) {
300
- this.cache.set(cacheKey, { data: allEntries, timestamp: Date.now() });
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;