@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
package/src/index.ts CHANGED
@@ -28,6 +28,84 @@ import type {
28
28
  Logger,
29
29
  } from "./types.js";
30
30
 
31
+ /** Payload-free activity storage and operator-read policy. */
32
+ export interface ConnectaActivityConfig {
33
+ /**
34
+ * Privacy-minimal downstream tool activity storage. Writes are best-effort
35
+ * and never change tool results. Implement `list` to enable the Activity UI.
36
+ */
37
+ store: ActivityStore;
38
+ /**
39
+ * Optional authorization gate for the Activity read API. MCP authentication
40
+ * is still required first. Omit to admit every authenticated actor.
41
+ */
42
+ readGate?: ActivityReadGate;
43
+ /** Stable deployment label included in activity events, e.g. "production". */
44
+ deploymentId?: string;
45
+ }
46
+
47
+ /** Operator-vault encryption and proactive credential-health tuning. */
48
+ export interface ConnectaCredentialsConfig {
49
+ /**
50
+ * Base64-encoded 32-byte AES key for credentials managed on /credentials.
51
+ * Keep this in the runtime's secret store, never in KV or source control.
52
+ */
53
+ encryptionKey?: string;
54
+ /**
55
+ * Tuning for proactive credential liveness checks that let a connector's
56
+ * status flip to `auth_required` before an agent's call fails. Defaults: one
57
+ * check per connector per 15 minutes, four in flight, 30 seconds each,
58
+ * triggered opportunistically by inbound authenticated traffic.
59
+ *
60
+ * Optional even without an encryption key because downstream OAuth connectors
61
+ * manage their own grants. `Connecta.checkCredentials()` runs the same checks
62
+ * on demand for a Worker cron trigger or Node interval.
63
+ */
64
+ health?: CredentialHealthConfig;
65
+ }
66
+
67
+ /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
68
+ export interface ConnectaDiscoveryConfig {
69
+ /** Tool-list cache TTL (seconds). Default 300. */
70
+ catalogTtlSeconds?: number;
71
+ /**
72
+ * Persist serializable remote tool catalogs in storage so cold isolates can
73
+ * discover tools without a downstream handshake. Default true.
74
+ */
75
+ persistCatalog?: boolean;
76
+ /**
77
+ * How long an expired persisted catalog remains available as a fallback
78
+ * when a live refresh fails. Default 3600 seconds.
79
+ */
80
+ staleCatalogSeconds?: number;
81
+ /**
82
+ * Deadline (ms) for each downstream probe/catalog call fanned out by
83
+ * `list_connectors`, `search_tools`, and `describe_tools`. Defaults to
84
+ * 30_000. A timed-out connector degrades independently; this does not apply
85
+ * to tool calls or currently abort the underlying fetch.
86
+ */
87
+ probeTimeoutMs?: number;
88
+ }
89
+
90
+ /** Deployment-wide call deadlines and inline-result paging threshold. */
91
+ export interface ConnectaCallsConfig {
92
+ /**
93
+ * Deadline (ms) for `call_tool`/`batch_call` calls that pass no `timeoutMs`.
94
+ * An explicit per-call value wins. Opt-in: unset by default, so existing
95
+ * long-running calls gain no surprise deadline.
96
+ *
97
+ * This bounds one attempt, not all retries. `execute_code` host calls are
98
+ * unaffected because they already carry their own bound.
99
+ */
100
+ defaultTimeoutMs?: number;
101
+ /**
102
+ * Max inline result size (bytes) before truncation and `get_result` paging.
103
+ * Must be a finite whole number >= 1; invalid values warn and fall back to
104
+ * 50_000. Connectors may override it individually.
105
+ */
106
+ maxResultBytes?: number;
107
+ }
108
+
31
109
  export interface ConnectaConfig {
32
110
  connectors: Connector[];
33
111
  /**
@@ -62,18 +140,6 @@ export interface ConnectaConfig {
62
140
  * address, or an address naming no tool on an in-code connector all throw.
63
141
  */
64
142
  toolkits?: ToolkitConfig;
65
- /**
66
- * Privacy-minimal downstream tool activity storage. Writes are best-effort
67
- * and never change tool results. Implement `list` to enable the Activity UI.
68
- */
69
- activity?: ActivityStore;
70
- /**
71
- * Optional authorization gate for the Activity read API. MCP authentication
72
- * is still required first. Omit to admit every authenticated actor.
73
- */
74
- activityReadGate?: ActivityReadGate;
75
- /** Stable deployment label included in activity events, e.g. "production". */
76
- activityDeploymentId?: string;
77
143
  /** Inbound auth adapters. Includes bearerToken(...); omit for open (dev). */
78
144
  auth?: InboundAuth | InboundAuth[];
79
145
  /** KVStorage impl. Defaults to memoryStorage(). */
@@ -83,78 +149,17 @@ export interface ConnectaConfig {
83
149
  * HTTPS URL also redirects matching inbound HTTP requests to HTTPS.
84
150
  */
85
151
  publicUrl?: string;
86
- /**
87
- * Base64-encoded 32-byte AES key for connector credentials managed in /ui.
88
- * Keep this in the runtime's secret store, never in KV or source control.
89
- */
90
- credentialEncryptionKey?: string;
152
+ /** Payload-free tool activity storage and operator-read policy. */
153
+ activity?: ConnectaActivityConfig;
154
+ /** Operator credential vault and proactive liveness-check settings. */
155
+ credentials?: ConnectaCredentialsConfig;
156
+ /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
157
+ discovery?: ConnectaDiscoveryConfig;
158
+ /** Deployment-wide call deadlines and result paging threshold. */
159
+ calls?: ConnectaCallsConfig;
91
160
  /** Optional browser UI and OAuth result-page labels. */
92
161
  branding?: ConnectaBranding;
93
162
  logger?: Logger;
94
- /** Tool-list cache TTL (seconds). Default 300. */
95
- toolCacheTtlSeconds?: number;
96
- /**
97
- * Persist serializable remote tool catalogs in storage so cold isolates can
98
- * discover tools without a downstream handshake. Default true.
99
- */
100
- persistToolCatalog?: boolean;
101
- /**
102
- * How long an expired persisted catalog remains available as a fallback
103
- * when a live refresh fails. Default 3600 seconds.
104
- */
105
- toolCatalogStaleSeconds?: number;
106
- /**
107
- * Max inline result size (bytes) before call_tool/batch_call truncate and
108
- * stash the full text for get_result paging. Must be a whole number of bytes
109
- * >= 1; anything else (0, negative, fractional, NaN, Infinity) warns at
110
- * startup and falls back to the default 50_000.
111
- */
112
- maxResultBytes?: number;
113
- /**
114
- * Deadline (ms) applied to call_tool/batch_call calls that pass no
115
- * `timeoutMs`, giving the connector both a budget (`ctx.timeoutMs`) and a
116
- * cancellation signal (`ctx.signal`). An explicit per-call `timeoutMs` always
117
- * wins. **Opt-in — undefined by default**, because switching it on globally
118
- * would put a deadline on every call in an existing deployment and the
119
- * failure mode is a working long-running call starting to time out.
120
- * `execute_code` host calls are unaffected; they already carry a 15 s bound.
121
- *
122
- * Bounds a single attempt, not the whole call — the same as an explicit
123
- * `timeoutMs` has always done. A call that also passes `maxRetries` can
124
- * therefore run to roughly `(maxRetries + 1)` times this value plus backoff.
125
- * `maxRetries` defaults to 0, so this is the total for every call that does
126
- * not explicitly ask to retry.
127
- */
128
- defaultToolTimeoutMs?: number;
129
- /**
130
- * Deadline (ms) applied to each individual downstream probe/catalog call that
131
- * the discovery meta-tools fan out — `list_connectors` (with `probe`),
132
- * `search_tools`, and `describe_tools` — so a single hung connector can no
133
- * longer stall the whole meta-tool call. **Defaults to a generous 30_000**,
134
- * chosen to trip only on a pathological hang, not on a realistically slow
135
- * probe, so having it on by default will not break existing deployments.
136
- * Bounds one downstream call, not the whole fan-out: a connector that outruns
137
- * it degrades to an unavailable/errored entry while the rest are unaffected.
138
- *
139
- * Does NOT apply to `call_tool`/`batch_call` — those carry their own budget
140
- * via `defaultToolTimeoutMs` or a per-call `timeoutMs`. Note this bounds the
141
- * caller-facing wait only; the underlying fetch is not currently aborted, so
142
- * real cancellation of the downstream request is a deferred follow-up.
143
- */
144
- probeTimeoutMs?: number;
145
- /**
146
- * Tuning for the proactive credential liveness checks (issue #24) that let a
147
- * connector's status flip to `auth_required` *before* an agent's call fails.
148
- * Defaults are safe to leave alone: at most one check per connector per 15
149
- * minutes, four in flight, 30 s each, triggered opportunistically by inbound
150
- * authenticated traffic. Only connectors holding a credential connecta stores
151
- * — an operator-managed `credential`, or a downstream-OAuth grant — are ever
152
- * checked, and a check never calls a downstream tool.
153
- *
154
- * `Connecta.checkCredentials()` is the same check on demand, for a Worker cron
155
- * trigger or a Node interval.
156
- */
157
- credentialHealth?: CredentialHealthConfig;
158
163
  serverInfo?: {
159
164
  name?: string;
160
165
  version?: string;
@@ -193,7 +198,7 @@ export interface Connecta {
193
198
  *
194
199
  * Returns one outcome per connector considered, including why a connector was
195
200
  * skipped (`fresh` is the rate limit: a connector checked less than
196
- * `credentialHealth.intervalSeconds` ago is not re-checked unless `force`).
201
+ * `credentials.health.intervalSeconds` ago is not re-checked unless `force`).
197
202
  * Never rejects on a connector failure — a broken connector becomes an `error`
198
203
  * verdict. Needs a base URL for connector contexts: `publicUrl` supplies it,
199
204
  * or pass one.
@@ -223,6 +228,59 @@ function normalizeAuth(auth: ConnectaConfig["auth"]): InboundAuth[] {
223
228
  });
224
229
  }
225
230
 
231
+ const LEGACY_CONFIG_MIGRATIONS = [
232
+ ["activityReadGate", "activity.readGate"],
233
+ ["activityDeploymentId", "activity.deploymentId"],
234
+ ["credentialEncryptionKey", "credentials.encryptionKey"],
235
+ ["credentialHealth", "credentials.health"],
236
+ ["toolCacheTtlSeconds", "discovery.catalogTtlSeconds"],
237
+ ["persistToolCatalog", "discovery.persistCatalog"],
238
+ ["toolCatalogStaleSeconds", "discovery.staleCatalogSeconds"],
239
+ ["probeTimeoutMs", "discovery.probeTimeoutMs"],
240
+ ["defaultToolTimeoutMs", "calls.defaultTimeoutMs"],
241
+ ["maxResultBytes", "calls.maxResultBytes"],
242
+ ] as const;
243
+
244
+ const hasOwn = (value: object, key: PropertyKey): boolean =>
245
+ Object.prototype.hasOwnProperty.call(value, key);
246
+
247
+ /**
248
+ * Fail closed at the public boundary: a JavaScript caller on the v0.6 shape
249
+ * must receive one complete migration error, never silently lose an option to
250
+ * a default. This runs before createConnecta reads any other config field.
251
+ */
252
+ function assertNoLegacyConfig(config: ConnectaConfig): void {
253
+ const candidate = config as unknown as Record<PropertyKey, unknown>;
254
+ const found: Array<readonly [string, string]> = [];
255
+ if (hasOwn(candidate, "activity")) {
256
+ const activity = candidate.activity;
257
+ const isObject =
258
+ typeof activity === "object" && activity !== null;
259
+ // A valid v0.6 ActivityStore can itself own a backend field named `store`.
260
+ // Its required `record` method (including a prototype method) therefore
261
+ // takes precedence over the otherwise-new wrapper shape.
262
+ const hasLegacyRecord =
263
+ isObject &&
264
+ typeof (activity as { record?: unknown }).record === "function";
265
+ if (
266
+ activity !== undefined &&
267
+ (!isObject ||
268
+ hasLegacyRecord ||
269
+ !hasOwn(activity, "store"))
270
+ ) {
271
+ found.push(["activity", "activity.store"]);
272
+ }
273
+ }
274
+ for (const migration of LEGACY_CONFIG_MIGRATIONS) {
275
+ if (hasOwn(candidate, migration[0])) found.push(migration);
276
+ }
277
+ if (found.length === 0) return;
278
+ throw new Error(
279
+ "Unsupported v0.6.x ConnectaConfig options. Migrate each path for v0.7.0:\n" +
280
+ found.map(([oldPath, newPath]) => `- ${oldPath} -> ${newPath}`).join("\n"),
281
+ );
282
+ }
283
+
226
284
  /**
227
285
  * One-time construction warnings for deployment shapes that run fine but are
228
286
  * usually unintended. Warning-only — never throws and never changes behavior;
@@ -333,12 +391,12 @@ function warnInsecureConfig(
333
391
  );
334
392
  }
335
393
 
336
- // /ui renders exactly one provider's browser sign-in config — the first that
337
- // offers one, which is the same `find` the /ui route performs — and that
394
+ // Operator shells render exactly one provider's browser sign-in config — the
395
+ // first that offers one, matching the server route's `find` — and that
338
396
  // provider's URLs reach the browser: frontendApiUrl as the loader's
339
397
  // `<script src>`, signInUrl/signUpUrl as the addresses ClerkJS navigates to.
340
398
  // Gate-or-drop like a branding href: rendering drops a rejected value and the
341
- // dashboard then either reports that Clerk could not load or quietly signs in
399
+ // operator page then reports that Clerk could not load or quietly signs in
342
400
  // through Clerk's defaults — both confusing symptoms without this line naming
343
401
  // the cause. Checking only the rendered provider keeps the claim true — a
344
402
  // later provider's uiAuth never reaches the page, so there is nothing there
@@ -351,7 +409,7 @@ function warnInsecureConfig(
351
409
  `${droppedUiAuth.join(", ")} dropped: every uiAuth URL reaches the ` +
352
410
  "browser — as the sign-in loader's source, or as a place Clerk sends " +
353
411
  "the operator — so each must be an absolute https URL. A dropped " +
354
- "value reaches no part of the page: without frontendApiUrl /ui renders " +
412
+ "value reaches no part of the page: without frontendApiUrl the operator shell renders " +
355
413
  "no loader and cannot start a sign-in, and without signInUrl/signUpUrl " +
356
414
  "it signs in through Clerk's defaults.",
357
415
  );
@@ -359,7 +417,7 @@ function warnInsecureConfig(
359
417
 
360
418
  // A credential test hook that cannot test the declared credential shape.
361
419
  // The shape picks the hook (see `credentialTestRule`) and the other one is
362
- // never substituted, so the connector is simply not testable: /ui offers no
420
+ // never substituted, so the connector is simply not testable: /credentials offers no
363
421
  // Test action and the route answers 400. Without this line the only way to
364
422
  // discover the mistake is to click a button that isn't there.
365
423
  for (const connector of config.connectors) {
@@ -367,20 +425,23 @@ function warnInsecureConfig(
367
425
  if (!mismatch) continue;
368
426
  logger.warn(
369
427
  `[connecta] connector "${connector.id}" cannot test its credential: ` +
370
- `${describeCredentialTestMismatch(mismatch)}. /ui offers no Test ` +
428
+ `${describeCredentialTestMismatch(mismatch)}. /credentials offers no Test ` +
371
429
  `action and POST /ui/credentials/${connector.id}/test answers 400 ` +
372
430
  "until the matching hook is implemented.",
373
431
  );
374
432
  }
375
433
 
376
- // OAuth connectors whose callback performs no state/CSRF check: the public
377
- // /oauth/callback/<id> route would exchange any delivered code.
434
+ // OAuth connectors whose callback cannot perform a state/CSRF check. The
435
+ // public route refuses every callback for these connectors rather than hand
436
+ // an unverified code to finishAuth, so this warning explains why auth cannot
437
+ // complete instead of describing a vulnerability the server permits.
378
438
  for (const connector of oauthConnectors) {
379
439
  if (!connector.verifyState) {
380
440
  logger.warn(
381
441
  `[connecta] connector "${connector.id}" has an OAuth callback with no ` +
382
- `state/CSRF check: /oauth/callback/${connector.id} will exchange any ` +
383
- "delivered code. Implement `verifyState` (the shipped remoteMcp " +
442
+ `state/CSRF check: /oauth/callback/${connector.id} refuses every ` +
443
+ "callback rather than exchange an unverified code. Implement " +
444
+ "`verifyState` to complete authorization (the shipped remoteMcp " +
384
445
  "connector already does).",
385
446
  );
386
447
  }
@@ -388,26 +449,28 @@ function warnInsecureConfig(
388
449
  }
389
450
 
390
451
  export function createConnecta(config: ConnectaConfig): Connecta {
452
+ assertNoLegacyConfig(config);
391
453
  const storage = config.storage ?? memoryStorage();
392
454
  const logger = config.logger ?? defaultLogger();
393
455
  const credentialConnectors = config.connectors.filter((c) => c.credential);
394
- if (credentialConnectors.length > 0 && !config.credentialEncryptionKey) {
456
+ const encryptionKey = config.credentials?.encryptionKey;
457
+ if (credentialConnectors.length > 0 && !encryptionKey) {
395
458
  throw new Error(
396
- `credentialEncryptionKey is required by connector credentials: ${credentialConnectors.map((c) => c.id).join(", ")}`,
459
+ `credentials.encryptionKey is required by connector credentials: ${credentialConnectors.map((c) => c.id).join(", ")}`,
397
460
  );
398
461
  }
399
- const credentialVault = config.credentialEncryptionKey
400
- ? new CredentialVault(storage, config.credentialEncryptionKey)
462
+ const credentialVault = encryptionKey
463
+ ? new CredentialVault(storage, encryptionKey)
401
464
  : undefined;
402
465
  const registry = new Registry(config.connectors, {
403
466
  storage,
404
467
  logger,
405
468
  credentialVault,
406
- toolCacheTtlSeconds: config.toolCacheTtlSeconds,
407
- persistToolCatalog: config.persistToolCatalog,
408
- toolCatalogStaleSeconds: config.toolCatalogStaleSeconds,
409
- maxResultBytes: config.maxResultBytes,
410
- credentialHealth: config.credentialHealth,
469
+ toolCacheTtlSeconds: config.discovery?.catalogTtlSeconds,
470
+ persistToolCatalog: config.discovery?.persistCatalog,
471
+ toolCatalogStaleSeconds: config.discovery?.staleCatalogSeconds,
472
+ maxResultBytes: config.calls?.maxResultBytes,
473
+ credentialHealth: config.credentials?.health,
411
474
  });
412
475
  // Throws on every structural mistake it can see (see resolveToolkits): a
413
476
  // typo must not become a scope the operator never wrote. Note this is about
@@ -430,12 +493,12 @@ export function createConnecta(config: ConnectaConfig): Connecta {
430
493
  version: config.serverInfo?.version ?? CONNECTA_VERSION,
431
494
  },
432
495
  logger,
433
- activity: config.activity,
434
- activityReadGate: config.activityReadGate,
435
- activityDeploymentId: config.activityDeploymentId,
496
+ activity: config.activity?.store,
497
+ activityReadGate: config.activity?.readGate,
498
+ activityDeploymentId: config.activity?.deploymentId,
436
499
  executor: config.executor,
437
- defaultToolTimeoutMs: config.defaultToolTimeoutMs,
438
- probeTimeoutMs: config.probeTimeoutMs,
500
+ defaultToolTimeoutMs: config.calls?.defaultTimeoutMs,
501
+ probeTimeoutMs: config.discovery?.probeTimeoutMs,
439
502
  credentialVault,
440
503
  deploymentInfo: config.deploymentInfo,
441
504
  branding: config.branding,
@@ -511,7 +574,11 @@ export type {
511
574
  CredentialHealthRecord,
512
575
  } from "./credential-health.js";
513
576
 
514
- export type { RemoteMcpOptions, RemoteMcpAuth } from "./connectors/remote-mcp.js";
577
+ export type {
578
+ RemoteMcpOptions,
579
+ RemoteMcpAuth,
580
+ RemoteMcpRedirectPolicy,
581
+ } from "./connectors/remote-mcp.js";
515
582
  export type { ApiOptions, ApiTool } from "./connectors/api.js";
516
583
  export type {
517
584
  Connector,
package/src/meta-tools.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type ActivityCallSource,
7
7
  type ActivityRequestContext,
8
8
  } from "./activity.js";
9
+ import { closeConnectorScope } from "./connector-scope.js";
9
10
  import { unwrapMcpResult } from "./mcp-result.js";
10
11
  import {
11
12
  classifyCallError,
@@ -64,12 +65,126 @@ function msg(err: unknown): string {
64
65
  return err instanceof Error ? err.message : String(err);
65
66
  }
66
67
 
67
- const DEFAULT_SEARCH_LIMIT = 25;
68
+ export const DEFAULT_SEARCH_LIMIT = 25;
69
+ /**
70
+ * A discovery page is for choosing the next tool, not exporting the catalog.
71
+ * One hundred leaves room for broad browsing while keeping each deliberate
72
+ * page far below the catalog sizes Connecta supports.
73
+ */
74
+ export const MAX_SEARCH_LIMIT = 100;
75
+ /** Same one-request work bound for address-based discovery. */
76
+ export const MAX_DESCRIBE_ADDRESSES = 100;
77
+ /**
78
+ * Final UTF-8 ceiling for a generated search/describe response. The count
79
+ * limits are the ordinary guard; this catches unusually large full schemas or
80
+ * descriptions that make even a bounded page expensive.
81
+ */
82
+ export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
68
83
  const enc = new TextEncoder();
69
84
  const dec = new TextDecoder();
70
85
 
71
86
  type ErrorDetails = CallErrorDetails;
72
87
 
88
+ export class DiscoveryPolicyError extends Error {
89
+ constructor(
90
+ readonly code: "invalid_args" | "result_too_large",
91
+ message: string,
92
+ ) {
93
+ super(message);
94
+ this.name = "DiscoveryPolicyError";
95
+ }
96
+ }
97
+
98
+ /** Validate before ranking so a huge page request does no proportional work. */
99
+ export function discoverySearchLimit(value: unknown): number {
100
+ if (value === undefined) return DEFAULT_SEARCH_LIMIT;
101
+ if (
102
+ typeof value !== "number" ||
103
+ !Number.isInteger(value) ||
104
+ value < 1 ||
105
+ value > MAX_SEARCH_LIMIT
106
+ ) {
107
+ throw new DiscoveryPolicyError(
108
+ "invalid_args",
109
+ `limit must be a whole number from 1 through ${MAX_SEARCH_LIMIT}. Page through larger catalogs with offset.`,
110
+ );
111
+ }
112
+ return value;
113
+ }
114
+
115
+ /** Validate the raw list so duplicate addresses consume the same bound. */
116
+ export function discoveryAddresses(value: unknown): unknown[] {
117
+ if (!Array.isArray(value)) {
118
+ throw new DiscoveryPolicyError(
119
+ "invalid_args",
120
+ "addresses must be an array.",
121
+ );
122
+ }
123
+ if (value.length > MAX_DESCRIBE_ADDRESSES) {
124
+ throw new DiscoveryPolicyError(
125
+ "invalid_args",
126
+ `addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across describe_tools calls.`,
127
+ );
128
+ }
129
+ return value;
130
+ }
131
+
132
+ /** Serialize once and count the exact bytes jsonResult would emit. */
133
+ function boundedDiscoveryText(
134
+ value: unknown,
135
+ hint: string,
136
+ ): string {
137
+ const text = JSON.stringify(value, null, 2);
138
+ if (text === undefined) {
139
+ throw new TypeError("Discovery result is not JSON-serializable.");
140
+ }
141
+ const bytes = enc.encode(text).length;
142
+ if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
143
+ throw new DiscoveryPolicyError(
144
+ "result_too_large",
145
+ `Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`,
146
+ );
147
+ }
148
+ return text;
149
+ }
150
+
151
+ /** Apply the same final result guard to code-mode discovery helpers. */
152
+ export function assertDiscoveryResultSize(
153
+ value: unknown,
154
+ hint: string,
155
+ ): void {
156
+ boundedDiscoveryText(value, hint);
157
+ }
158
+
159
+ function discoveryErrorResult(error: DiscoveryPolicyError): ToolResult {
160
+ const result = jsonResult({
161
+ error: {
162
+ code: error.code,
163
+ message: error.message,
164
+ retryable: false,
165
+ },
166
+ });
167
+ result.isError = true;
168
+ return result;
169
+ }
170
+
171
+ function discoveryResult(value: unknown, hint: string): ToolResult {
172
+ try {
173
+ const text = boundedDiscoveryText(value, hint);
174
+ return {
175
+ content: [{ type: "text", text }],
176
+ ...(value !== null && typeof value === "object" && !Array.isArray(value)
177
+ ? { structuredContent: value as Record<string, unknown> }
178
+ : {}),
179
+ };
180
+ } catch (err) {
181
+ if (err instanceof DiscoveryPolicyError) {
182
+ return discoveryErrorResult(err);
183
+ }
184
+ throw err;
185
+ }
186
+ }
187
+
73
188
  /**
74
189
  * The longest the engine will park a synchronous inbound request in *waiting
75
190
  * alone*. The engine already treats ~15 s as the outer bound of one reasonable
@@ -458,9 +573,9 @@ export interface SkillArgs {
458
573
  * optional tenth tool, is registered separately by registerExecuteTool.)
459
574
  *
460
575
  * The deployment-wide result-size cap is read off the registry view rather than
461
- * passed in: `ConnectaConfig.maxResultBytes` and the per-connector override are
462
- * the only places a cap is set, so there is one answer to where a deployment
463
- * sets it (issue #44).
576
+ * passed in: `ConnectaConfig.calls.maxResultBytes` and the per-connector
577
+ * override are the only places a cap is set, so there is one answer to where a
578
+ * deployment sets it (issue #44).
464
579
  */
465
580
  export function createMetaTools(
466
581
  registry: RegistryView,
@@ -788,8 +903,12 @@ export function createMetaTools(
788
903
 
789
904
  async listConnectors(args: ListArgs = {}): Promise<ToolResult> {
790
905
  const probe = args.probe ?? true;
906
+ // Live inventory owns a short-lived scope separate from the request's
907
+ // call scope. Closing it cannot defeat call_tool/batch/execute_code reuse.
908
+ const connectors = registry.listConnectors();
909
+ const scope = probe ? {} : requestScope;
791
910
  const out = await Promise.all(
792
- registry.listConnectors().map(async (c) => {
911
+ connectors.map(async (c) => {
793
912
  const statusStarted = Date.now();
794
913
  const observed = registry.healthFor(c.id);
795
914
  const verdict = await registry.credentialHealthFor(c.id);
@@ -799,7 +918,7 @@ export function createMetaTools(
799
918
  if (probe) {
800
919
  try {
801
920
  status = await withTimeout(
802
- registry.statusFor(c.id, baseUrl, requestScope),
921
+ registry.statusFor(c.id, baseUrl, scope),
803
922
  probeTimeoutMs,
804
923
  `list_connectors probe of "${c.id}"`,
805
924
  );
@@ -886,7 +1005,7 @@ export function createMetaTools(
886
1005
  if (probe && status.state === "ok") {
887
1006
  try {
888
1007
  tools = await withTimeout(
889
- registry.refreshTools(c.id, baseUrl, requestScope),
1008
+ registry.refreshTools(c.id, baseUrl, scope),
890
1009
  probeTimeoutMs,
891
1010
  `list_connectors catalog refresh of "${c.id}"`,
892
1011
  );
@@ -918,13 +1037,31 @@ export function createMetaTools(
918
1037
  ...(status.message ? { message: status.message } : {}),
919
1038
  };
920
1039
  }),
921
- );
1040
+ ).finally(async () => {
1041
+ if (!probe) return;
1042
+ await Promise.all(
1043
+ connectors.map((connector) =>
1044
+ closeConnectorScope(
1045
+ connector,
1046
+ registry.contextFor(connector.id, baseUrl, scope),
1047
+ ),
1048
+ ),
1049
+ );
1050
+ });
922
1051
  return jsonResult({ connectors: out });
923
1052
  },
924
1053
 
925
1054
  async searchTools(args: SearchArgs): Promise<ToolResult> {
926
1055
  const q = args.query ?? "";
927
- const limit = Math.max(1, Math.trunc(args.limit ?? DEFAULT_SEARCH_LIMIT));
1056
+ let limit: number;
1057
+ try {
1058
+ limit = discoverySearchLimit(args.limit);
1059
+ } catch (err) {
1060
+ if (err instanceof DiscoveryPolicyError) {
1061
+ return discoveryErrorResult(err);
1062
+ }
1063
+ throw err;
1064
+ }
928
1065
  const offset = Math.max(0, Math.trunc(args.offset ?? 0));
929
1066
  const conns = args.connector
930
1067
  ? [registry.getConnector(args.connector)].filter(
@@ -1035,17 +1172,28 @@ export function createMetaTools(
1035
1172
  offset + page.length < matches.length
1036
1173
  ? offset + page.length
1037
1174
  : undefined;
1038
- return jsonResult({
1039
- connectors: groups,
1040
- total: matches.length,
1041
- offset,
1042
- limit,
1043
- hasMore: nextOffset !== undefined,
1044
- ...(nextOffset !== undefined ? { nextOffset } : {}),
1045
- });
1175
+ return discoveryResult(
1176
+ {
1177
+ connectors: groups,
1178
+ total: matches.length,
1179
+ offset,
1180
+ limit,
1181
+ hasMore: nextOffset !== undefined,
1182
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
1183
+ },
1184
+ "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
1185
+ );
1046
1186
  },
1047
1187
 
1048
1188
  async describeTools(args: DescribeArgs): Promise<ToolResult> {
1189
+ try {
1190
+ discoveryAddresses(args.addresses);
1191
+ } catch (err) {
1192
+ if (err instanceof DiscoveryPolicyError) {
1193
+ return discoveryErrorResult(err);
1194
+ }
1195
+ throw err;
1196
+ }
1049
1197
  const format = args.format ?? "compact";
1050
1198
  const resolved = args.addresses.map((address) => ({
1051
1199
  address,
@@ -1116,7 +1264,10 @@ export function createMetaTools(
1116
1264
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
1117
1265
  };
1118
1266
  });
1119
- return jsonResult({ tools: out });
1267
+ return discoveryResult(
1268
+ { tools: out },
1269
+ 'Split the address list or use format: "compact".',
1270
+ );
1120
1271
  },
1121
1272
 
1122
1273
  async callTool(args: CallArgs): Promise<ToolResult> {
@@ -1312,10 +1463,8 @@ export function createMetaTools(
1312
1463
 
1313
1464
  const LIST_DESC =
1314
1465
  "List connectors with status, cached tool count, and recent real-call health. Use probe=false for a fast inventory; use probe=true (default) only to diagnose live health or authorization.";
1315
- const SEARCH_DESC =
1316
- 'Start here when a tool address is unknown. Exact/name matches rank above description matches; an empty query browses all. includeSchemas="compact" usually removes the describe_tools round trip.';
1317
- const DESCRIBE_DESC =
1318
- 'Inspect known tool addresses when search_tools did not include a sufficient schema. Returns descriptions, input/output schemas, and behavior annotations; format "compact" is the default.';
1466
+ const SEARCH_DESC = `Start here when a tool address is unknown. Exact/name matches rank above description matches; an empty query browses all. Pages contain at most ${MAX_SEARCH_LIMIT} tools. includeSchemas="compact" usually removes the describe_tools round trip.`;
1467
+ const DESCRIBE_DESC = `Inspect up to ${MAX_DESCRIBE_ADDRESSES} known tool addresses when search_tools did not include a sufficient schema. Returns descriptions, input/output schemas, and behavior annotations; format "compact" is the default.`;
1319
1468
  const CALL_DESC =
1320
1469
  'Use for one tool explicitly annotated readOnlyHint: true. For 2–10 independent read-only calls use batch_call; for dependent steps or data reduction use execute_code when available. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths, resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
1321
1470
  const CALL_DESTRUCTIVE_DESC =
@@ -1429,7 +1578,7 @@ export function registerMetaTools(
1429
1578
  inputSchema: {
1430
1579
  query: z.string().optional(),
1431
1580
  connector: z.string().optional(),
1432
- limit: z.number().int().positive().optional(),
1581
+ limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
1433
1582
  offset: z.number().int().nonnegative().optional(),
1434
1583
  fullDescriptions: z.boolean().optional(),
1435
1584
  includeSchemas: z.enum(["compact", "json"]).optional(),
@@ -1444,7 +1593,7 @@ export function registerMetaTools(
1444
1593
  {
1445
1594
  description: describedFor(registry, DESCRIBE_DESC, "describe"),
1446
1595
  inputSchema: {
1447
- addresses: z.array(z.string()),
1596
+ addresses: z.array(z.string()).max(MAX_DESCRIBE_ADDRESSES),
1448
1597
  format: z.enum(["compact", "json"]).optional(),
1449
1598
  fullDescriptions: z.boolean().optional(),
1450
1599
  },