@zackbart/connecta 0.6.1 → 0.7.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +24 -20
  3. package/dist/auth/bearer.d.ts +4 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +10 -8
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +8 -7
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +27 -8
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connector-scope.d.ts +13 -0
  12. package/dist/connector-scope.d.ts.map +1 -0
  13. package/dist/connector-scope.js +35 -0
  14. package/dist/connector-scope.js.map +1 -0
  15. package/dist/connectors/api.d.ts +5 -5
  16. package/dist/connectors/api.d.ts.map +1 -1
  17. package/dist/connectors/remote-mcp.d.ts +27 -4
  18. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  19. package/dist/connectors/remote-mcp.js +400 -19
  20. package/dist/connectors/remote-mcp.js.map +1 -1
  21. package/dist/credential-health.d.ts +8 -5
  22. package/dist/credential-health.d.ts.map +1 -1
  23. package/dist/credential-health.js +99 -51
  24. package/dist/credential-health.js.map +1 -1
  25. package/dist/credentials.d.ts +51 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js +68 -3
  28. package/dist/credentials.js.map +1 -1
  29. package/dist/execute.d.ts.map +1 -1
  30. package/dist/execute.js +10 -8
  31. package/dist/execute.js.map +1 -1
  32. package/dist/index.d.ts +84 -83
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +74 -24
  35. package/dist/index.js.map +1 -1
  36. package/dist/meta-tools.d.ts +28 -3
  37. package/dist/meta-tools.d.ts.map +1 -1
  38. package/dist/meta-tools.js +131 -16
  39. package/dist/meta-tools.js.map +1 -1
  40. package/dist/registry.d.ts +3 -2
  41. package/dist/registry.d.ts.map +1 -1
  42. package/dist/registry.js +4 -3
  43. package/dist/registry.js.map +1 -1
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +152 -52
  47. package/dist/server.js.map +1 -1
  48. package/dist/toolkits.js +1 -1
  49. package/dist/types.d.ts +41 -27
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/ui.d.ts +24 -10
  52. package/dist/ui.d.ts.map +1 -1
  53. package/dist/ui.js +594 -172
  54. package/dist/ui.js.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -2
  58. package/src/auth/bearer.ts +10 -8
  59. package/src/auth/clerk.ts +28 -9
  60. package/src/connector-scope.ts +41 -0
  61. package/src/connectors/api.ts +5 -5
  62. package/src/connectors/remote-mcp.ts +489 -35
  63. package/src/credential-health.ts +120 -57
  64. package/src/credentials.ts +96 -3
  65. package/src/execute.ts +18 -7
  66. package/src/index.ts +174 -107
  67. package/src/meta-tools.ts +173 -24
  68. package/src/registry.ts +4 -3
  69. package/src/server.ts +191 -70
  70. package/src/toolkits.ts +1 -1
  71. package/src/types.ts +41 -27
  72. package/src/ui.ts +631 -170
  73. package/src/version.ts +1 -1
@@ -5,7 +5,7 @@
5
5
  // something *observed* a failure, so an expired or revoked token surfaced
6
6
  // mid-task as a failed agent call. A liveness check asks the connector whether
7
7
  // the credential it holds still works, records the verdict, and lets the cached
8
- // status surfaces (`list_connectors({ probe: false })`, `/ui`) report
8
+ // status surfaces (`list_connectors({ probe: false })`, operator pages) report
9
9
  // `auth_required` BEFORE a real call discovers it.
10
10
  //
11
11
  // Runtime-agnostic on purpose: nothing here schedules itself. The core exposes a
@@ -15,8 +15,12 @@
15
15
  // There is no background daemon and no long-lived timer, so Workers and Node run
16
16
  // the same code.
17
17
 
18
- import { credentialTestRule } from "./credentials.js";
18
+ import {
19
+ credentialTestRule,
20
+ storedCredentialShape,
21
+ } from "./credentials.js";
19
22
  import type { CredentialVault } from "./credentials.js";
23
+ import { closeConnectorScope } from "./connector-scope.js";
20
24
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, withTimeout } from "./timeout.js";
21
25
  import type {
22
26
  Connector,
@@ -328,7 +332,10 @@ export interface CredentialCheckOptions {
328
332
  force?: boolean;
329
333
  /** Restrict the sweep to these connector ids. Default: every connector. */
330
334
  ids?: string[];
331
- /** Request-scope identity to reuse a connector's per-request resources. */
335
+ /**
336
+ * Internal scope identity supplied by an existing owner. When omitted, the
337
+ * check creates and ends its own probe scope.
338
+ */
332
339
  requestScope?: object;
333
340
  }
334
341
 
@@ -339,14 +346,14 @@ export interface CredentialCheckOptions {
339
346
  * Deliberately narrow. `listTools`/`callTool` are NOT liveness probes here: a
340
347
  * tool call may mutate downstream state, and the catalog path is already covered
341
348
  * by the existing probe. So a connector is checkable only through the two hooks
342
- * that exist to answer exactly this question — `testCredential(s)` (what /ui's
349
+ * that exist to answer exactly this question — `testCredential(s)` (what the
343
350
  * Test button runs) and `status()` — and only when it has a credential of ours
344
351
  * to be asked about: an operator-managed `credential`, or a stored downstream
345
352
  * grant it reports via `hasStoredCredential`. A static-token connector stores
346
353
  * nothing here and is never probed on a timer.
347
354
  *
348
355
  * Whether a test hook counts is `credentialTestRule`'s call, not this function's
349
- * the same rule /ui's Test button and the credential API read (issue #55), so
356
+ * Credentials Test button and the credential API read (issue #55), so
350
357
  * a credential the operator cannot test by hand is not one a sweep tests behind
351
358
  * their back. A connector whose only hook cannot test its declared shape is
352
359
  * checkable only if it also implements `status()`.
@@ -367,7 +374,7 @@ export function isCheckableConnector(connector: Connector): boolean {
367
374
  *
368
375
  * `isCheckableConnector` answers the static question ("could this connector be
369
376
  * asked at all"); this answers it against the vault. The hook itself is picked
370
- * by `credentialTestRule` (src/credentials.ts, issue #55), the one rule /ui's
377
+ * by `credentialTestRule` (src/credentials.ts, issue #55), the one rule the UI's
371
378
  * `testable` flag and `POST /ui/credentials/<id>/test` also read: named
372
379
  * `credential.fields` are tested as a set by `testCredentials`, a single-value
373
380
  * `credential` by `testCredential` on the vault's reserved `value` field, and
@@ -384,15 +391,21 @@ function testHookFor(
384
391
  connector: Connector,
385
392
  values: ConnectorCredentialValues | null,
386
393
  ): ((ctx: ConnectorContext) => Promise<CredentialTestResult>) | undefined {
387
- if (!values) return undefined;
394
+ if (
395
+ !connector.credential ||
396
+ storedCredentialShape(connector.credential, values).state !== "valid"
397
+ ) {
398
+ return undefined;
399
+ }
400
+ const storedValues = values!;
388
401
  const { mode } = credentialTestRule(connector);
389
402
  if (mode === "multiple") {
390
- return (ctx) => connector.testCredentials!(values, ctx);
403
+ return (ctx) => connector.testCredentials!(storedValues, ctx);
391
404
  }
392
405
  // A single-value shape with nothing under the reserved field is still nothing
393
406
  // to test, so the stored value gets the last word even when the rule fits.
394
- if (mode === "single" && typeof values.value === "string") {
395
- return (ctx) => connector.testCredential!(values.value, ctx);
407
+ if (mode === "single" && typeof storedValues.value === "string") {
408
+ return (ctx) => connector.testCredential!(storedValues.value, ctx);
396
409
  }
397
410
  return undefined;
398
411
  }
@@ -433,8 +446,8 @@ async function mapWithConcurrency<T, R>(
433
446
  * triggered sweep per interval per isolate, and never two at once, so a burst
434
447
  * of requests costs one sweep, not one per request.
435
448
  * 4. **Deadline + fan-out bound** — each check is bounded by `timeoutMs` and at
436
- * most `concurrency` run together (the same shape as the `probeTimeoutMs`
437
- * bound on the discovery fan-out, issue #19).
449
+ * most `concurrency` run together (the same shape as the
450
+ * `discovery.probeTimeoutMs` bound on the discovery fan-out, issue #19).
438
451
  */
439
452
  export class CredentialHealthChecker {
440
453
  private readonly store: CredentialHealthStore;
@@ -573,16 +586,12 @@ export class CredentialHealthChecker {
573
586
  ...(await this.recordOrNothing(connectorId)),
574
587
  };
575
588
  }
576
- if (!opts.force) {
577
- const current = await this.store.get(connectorId);
578
- if (
579
- current &&
580
- Date.now() - Date.parse(current.checkedAt) < this.intervalMs
581
- ) {
582
- return { connectorId, skipped: "fresh", record: current };
583
- }
584
- }
585
- const run = this.runCheck(connector, baseUrl, opts.requestScope);
589
+ const run = this.runCheck(
590
+ connector,
591
+ baseUrl,
592
+ opts.force ?? false,
593
+ opts.requestScope,
594
+ );
586
595
  this.inFlight.set(connectorId, run);
587
596
  try {
588
597
  return await run;
@@ -601,56 +610,110 @@ export class CredentialHealthChecker {
601
610
  private async runCheck(
602
611
  connector: Connector,
603
612
  baseUrl: string,
613
+ force: boolean,
604
614
  requestScope?: object,
605
615
  ): Promise<CredentialCheckResult> {
606
616
  const connectorId = connector.id;
607
617
  const started = Date.now();
608
- // Captured BEFORE anything downstream happens: everything after this point
609
- // is a window in which the operator may replace the very credential being
610
- // judged, and `settle` fences the write against exactly that.
618
+ // Captured BEFORE anything downstream happens including the vault read
619
+ // below: everything after this point is a window in which the operator may
620
+ // replace the very credential being judged, and `settle` fences the write
621
+ // against exactly that. It cannot move later to save a read on the fresh
622
+ // path; a generation sampled AFTER the values would miss a `clear` that
623
+ // landed between the two and let a verdict about the replaced credential
624
+ // through the fence.
611
625
  const generation = await this.store.generation(connectorId);
612
- const ctx = this.deps.contextFor(connectorId, baseUrl, requestScope);
613
626
  let values: ConnectorCredentialValues | null = null;
627
+ let credentialReadError: unknown;
614
628
  if (connector.credential && this.deps.credentialVault) {
615
629
  try {
616
630
  values = await this.deps.credentialVault.getAll(connectorId);
617
631
  } catch (err) {
618
- // A stored credential that cannot be decrypted (rotated key, corrupt
619
- // envelope) is exactly the kind of dead credential this feature exists
620
- // to surface early, so it is a verdict rather than a skip.
632
+ credentialReadError = err;
633
+ }
634
+ const shape = storedCredentialShape(connector.credential, values);
635
+ if (shape.state === "mismatch") {
636
+ // Drift is a persistent operator-error state, not an event: left
637
+ // outside the freshness gate it would spend a write on every sweep in
638
+ // every isolate, forever, against exactly the deployments this feature
639
+ // is meant to help (and on Cloudflare KV those writes are metered).
640
+ // Once the SAME drift verdict is already stored and still fresh, this
641
+ // is the rate limit doing its job, same as a fresh `ok`. A stored
642
+ // verdict that says anything else — a pre-redeploy `ok`, a different
643
+ // message — is still replaced immediately, which is the whole point of
644
+ // checking the shape before the gate.
645
+ if (!force) {
646
+ const current = await this.store.get(connectorId);
647
+ if (
648
+ current &&
649
+ current.state === "error" &&
650
+ current.message === shape.message &&
651
+ Date.now() - Date.parse(current.checkedAt) < this.intervalMs
652
+ ) {
653
+ return { connectorId, skipped: "fresh", record: current };
654
+ }
655
+ }
621
656
  return this.settle(connectorId, started, generation, {
622
- state: "auth_required",
657
+ state: "error",
623
658
  checkedAt: new Date().toISOString(),
624
- message: msg(err),
659
+ message: shape.message,
625
660
  });
626
661
  }
627
662
  }
628
- const stored = connector.hasStoredCredential
629
- ? await connector
630
- .hasStoredCredential(ctx)
631
- .catch(() => values !== null)
632
- : values !== null;
633
- if (!stored) return { connectorId, skipped: "no_credential" };
634
- if (!this.canAsk(connector, values)) {
635
- return { connectorId, skipped: "not_checkable" };
663
+ // Shape drift is checked before this shortcut so an old, still-fresh `ok`
664
+ // cannot survive a redeploy that changed the credential declaration.
665
+ if (!force) {
666
+ const current = await this.store.get(connectorId);
667
+ if (
668
+ current &&
669
+ Date.now() - Date.parse(current.checkedAt) < this.intervalMs
670
+ ) {
671
+ return { connectorId, skipped: "fresh", record: current };
672
+ }
636
673
  }
637
-
674
+ const ownsScope = requestScope === undefined;
675
+ const scope = requestScope ?? {};
676
+ const ctx = this.deps.contextFor(connectorId, baseUrl, scope);
638
677
  try {
639
- const verdict = await withTimeout(
640
- this.probe(connector, ctx, values),
641
- this.timeoutMs,
642
- `credential check of "${connectorId}"`,
643
- );
644
- return await this.settle(connectorId, started, generation, {
645
- ...verdict,
646
- checkedAt: new Date().toISOString(),
647
- });
648
- } catch (err) {
649
- return await this.settle(connectorId, started, generation, {
650
- state: "error",
651
- checkedAt: new Date().toISOString(),
652
- message: msg(err),
653
- });
678
+ if (credentialReadError) {
679
+ // A stored credential that cannot be decrypted (rotated key, corrupt
680
+ // envelope) is exactly the kind of dead credential this feature
681
+ // exists to surface early, so it is a verdict rather than a skip.
682
+ return this.settle(connectorId, started, generation, {
683
+ state: "auth_required",
684
+ checkedAt: new Date().toISOString(),
685
+ message: msg(credentialReadError),
686
+ });
687
+ }
688
+ const stored = connector.hasStoredCredential
689
+ ? await connector
690
+ .hasStoredCredential(ctx)
691
+ .catch(() => values !== null)
692
+ : values !== null;
693
+ if (!stored) return { connectorId, skipped: "no_credential" };
694
+ if (!this.canAsk(connector, values)) {
695
+ return { connectorId, skipped: "not_checkable" };
696
+ }
697
+
698
+ try {
699
+ const verdict = await withTimeout(
700
+ this.probe(connector, ctx, values),
701
+ this.timeoutMs,
702
+ `credential check of "${connectorId}"`,
703
+ );
704
+ return await this.settle(connectorId, started, generation, {
705
+ ...verdict,
706
+ checkedAt: new Date().toISOString(),
707
+ });
708
+ } catch (err) {
709
+ return await this.settle(connectorId, started, generation, {
710
+ state: "error",
711
+ checkedAt: new Date().toISOString(),
712
+ message: msg(err),
713
+ });
714
+ }
715
+ } finally {
716
+ if (ownsScope) await closeConnectorScope(connector, ctx);
654
717
  }
655
718
  }
656
719
 
@@ -687,12 +750,12 @@ export class CredentialHealthChecker {
687
750
  }
688
751
  // A rejected stored credential needs an operator, not a retry — the same
689
752
  // actionable state a revoked OAuth grant reports. There is no consent URL
690
- // for a vault credential; /ui's credential form is where it is replaced.
753
+ // for a vault credential; /credentials is where it is replaced.
691
754
  return {
692
755
  state: "auth_required",
693
756
  message:
694
757
  result.message ??
695
- "Stored credential was rejected by the connector — replace it in /ui.",
758
+ "Stored credential was rejected by the connector — replace it in /credentials.",
696
759
  };
697
760
  }
698
761
  const status = await connector.status!(ctx);
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  Connector,
3
+ ConnectorCredentialConfig,
3
4
  ConnectorCredentialValues,
4
5
  KVStorage,
5
6
  } from "./types.js";
@@ -42,6 +43,98 @@ export interface CredentialMetadata {
42
43
  /** Which hook a testable credential is checked with. */
43
44
  export type CredentialTestMode = "single" | "multiple";
44
45
 
46
+ /** Operator-safe explanation shared by every surface that detects shape drift. */
47
+ export const STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR =
48
+ "Stored credential fields do not match this connector's current declaration. Replace the credential before using or testing this connector.";
49
+
50
+ export type StoredCredentialShape =
51
+ | { state: "missing" }
52
+ | {
53
+ state: "valid";
54
+ mode: CredentialTestMode;
55
+ /**
56
+ * Stored keys the connector no longer declares, sorted. Harmless — the
57
+ * credential works — but worth telling an operator about, since nothing
58
+ * else in `/ui` can show a field the declaration has stopped naming.
59
+ */
60
+ undeclared: string[];
61
+ }
62
+ | {
63
+ state: "mismatch";
64
+ mode: CredentialTestMode;
65
+ message: typeof STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR;
66
+ };
67
+
68
+ /**
69
+ * Compare a connector's current declaration with the keys in its stored
70
+ * credential. Values are deliberately ignored: callers may pass decrypted
71
+ * values or `/credentials`' masked field metadata and get the same answer.
72
+ *
73
+ * The test is CONTAINMENT, not equality: the stored key set is compatible when
74
+ * it holds every field currently declared — the reserved `value` key for a
75
+ * single-value declaration, every declared name for a named one. Anything the
76
+ * declaration asks for and the vault does not have is `mismatch`, which is
77
+ * precisely what a renamed field, a newly added field, or a swap between the
78
+ * two shapes produces. The swap needs no special case: `value` is never one of
79
+ * the declared names, so single→named and named→single each leave the declared
80
+ * side unsatisfied. An empty stored map (reachable through a hand-written
81
+ * plaintext) satisfies nothing and is a mismatch too.
82
+ *
83
+ * Extra keys are NOT drift. They are what *dropping* a field leaves behind, and
84
+ * every accessor a connector actually uses — `ctx.credential.get("apiKey")`,
85
+ * `getAll().apiKey` — keeps returning the right secret across that redeploy.
86
+ * Calling it drift would order an operator to re-enter a working secret that
87
+ * many providers will not reissue in readable form. The leftovers come back as
88
+ * `undeclared` instead, for a surface to mention without blocking anything.
89
+ */
90
+ export function storedCredentialShape(
91
+ config: ConnectorCredentialConfig,
92
+ stored: Readonly<Record<string, unknown>> | null,
93
+ ): StoredCredentialShape {
94
+ if (!stored) return { state: "missing" };
95
+ const mode: CredentialTestMode = config.fields?.length
96
+ ? "multiple"
97
+ : "single";
98
+ const declared = new Set(
99
+ mode === "multiple" ? config.fields!.map((field) => field.name) : ["value"],
100
+ );
101
+ const actual = Object.keys(stored);
102
+ const present = new Set(actual);
103
+ for (const field of declared) {
104
+ if (!present.has(field)) {
105
+ return {
106
+ state: "mismatch",
107
+ mode,
108
+ message: STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR,
109
+ };
110
+ }
111
+ }
112
+ return {
113
+ state: "valid",
114
+ mode,
115
+ undeclared: actual.filter((field) => !declared.has(field)).sort(),
116
+ };
117
+ }
118
+
119
+ /** How many leftover field names an advisory names before it summarizes. */
120
+ const UNDECLARED_SAMPLE = 5;
121
+
122
+ /**
123
+ * The one sentence describing leftover stored fields, so every surface words it
124
+ * the same way. Names only — the values stay in the vault, and a field name from
125
+ * a previous declaration is not a secret. Deliberately reassuring: nothing is
126
+ * broken, and the only thing an operator gains by acting is that a connector
127
+ * iterating `getAll()` stops seeing a field its code no longer knows about.
128
+ */
129
+ export function describeUndeclaredCredentialFields(fields: string[]): string {
130
+ const shown = fields.slice(0, UNDECLARED_SAMPLE);
131
+ const rest = fields.length - shown.length;
132
+ const named = shown.join(", ") + (rest > 0 ? `, and ${rest} more` : "");
133
+ return fields.length === 1
134
+ ? `Stored credential also holds a field this connector no longer declares (${named}). It keeps working; replace the credential to drop it.`
135
+ : `Stored credential also holds fields this connector no longer declares (${named}). It keeps working; replace the credential to drop them.`;
136
+ }
137
+
45
138
  /** A declared credential shape whose only test hook cannot test it. */
46
139
  export interface CredentialTestMismatch {
47
140
  /** The shape the connector declared. */
@@ -59,7 +152,7 @@ export interface CredentialTestRule {
59
152
 
60
153
  /**
61
154
  * The one rule deciding whether a connector's credential can be tested — read
62
- * by /ui's `testable` flag, by the `POST /ui/credentials/<id>/test` route when
155
+ * by /credentials' `testable` flag, by `POST /ui/credentials/<id>/test` when
63
156
  * it picks a hook, and by the construction-time mismatch warning, so those
64
157
  * three cannot drift apart.
65
158
  *
@@ -121,7 +214,7 @@ function base64ToBytes(value: string): Uint8Array {
121
214
  binary = atob(value);
122
215
  } catch {
123
216
  throw new Error(
124
- "credentialEncryptionKey must be a base64-encoded 32-byte key",
217
+ "credentials.encryptionKey must be a base64-encoded 32-byte key",
125
218
  );
126
219
  }
127
220
  return Uint8Array.from(binary, (char) => char.charCodeAt(0));
@@ -216,7 +309,7 @@ export class CredentialVault {
216
309
  const raw = base64ToBytes(encryptionKey.trim());
217
310
  if (raw.byteLength !== KEY_BYTES) {
218
311
  throw new Error(
219
- "credentialEncryptionKey must be a base64-encoded 32-byte key",
312
+ "credentials.encryptionKey must be a base64-encoded 32-byte key",
220
313
  );
221
314
  }
222
315
  this.key = crypto.subtle.importKey(
package/src/execute.ts CHANGED
@@ -3,6 +3,9 @@ import { z } from "zod";
3
3
  import { compactSchema, rankTools, summarizeDescription } from "./catalog.js";
4
4
  import { recordToolActivity, type ActivityRequestContext } from "./activity.js";
5
5
  import {
6
+ assertDiscoveryResultSize,
7
+ discoveryAddresses,
8
+ discoverySearchLimit,
6
9
  errorResult,
7
10
  jsonResult,
8
11
  serializeResultText,
@@ -369,7 +372,7 @@ export async function buildSandboxProviders(
369
372
  }
370
373
  matches.sort((a, b) => b.score - a.score || a.order - b.order);
371
374
  const offset = Math.max(0, Math.trunc(args.offset ?? 0));
372
- const limit = Math.max(1, Math.trunc(args.limit ?? 25));
375
+ const limit = discoverySearchLimit(args.limit);
373
376
  const page = matches.slice(offset, offset + limit).map((match) => {
374
377
  const input = match.tool.inputSchema ?? { type: "object" };
375
378
  return {
@@ -404,7 +407,7 @@ export async function buildSandboxProviders(
404
407
  offset + page.length < matches.length
405
408
  ? offset + page.length
406
409
  : undefined;
407
- return {
410
+ const result = {
408
411
  tools: page,
409
412
  total: matches.length,
410
413
  offset,
@@ -412,6 +415,11 @@ export async function buildSandboxProviders(
412
415
  hasMore: nextOffset !== undefined,
413
416
  ...(nextOffset !== undefined ? { nextOffset } : {}),
414
417
  };
418
+ assertDiscoveryResultSize(
419
+ result,
420
+ "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
421
+ );
422
+ return result;
415
423
  },
416
424
  describe: async (raw: unknown) => {
417
425
  const args = (raw ?? {}) as {
@@ -419,12 +427,10 @@ export async function buildSandboxProviders(
419
427
  format?: "compact" | "json";
420
428
  fullDescriptions?: boolean;
421
429
  };
422
- if (!Array.isArray(args.addresses)) {
423
- throw new Error("addresses must be an array");
424
- }
430
+ const addresses = discoveryAddresses(args.addresses);
425
431
  const format = args.format ?? "compact";
426
- return {
427
- tools: args.addresses.map((rawAddress) => {
432
+ const result = {
433
+ tools: addresses.map((rawAddress) => {
428
434
  const address = String(rawAddress);
429
435
  const resolved = registry.resolveAddress(address);
430
436
  if (!resolved) {
@@ -457,6 +463,11 @@ export async function buildSandboxProviders(
457
463
  };
458
464
  }),
459
465
  };
466
+ assertDiscoveryResultSize(
467
+ result,
468
+ 'Split the address list or use format: "compact".',
469
+ );
470
+ return result;
460
471
  },
461
472
  },
462
473
  });