@zackbart/connecta 0.38.0 → 0.40.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.
Files changed (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. package/templates/node/src/connecta.config.ts +1 -0
@@ -1,4 +1,5 @@
1
1
  import { registerAuthMethod } from "./auth/native.js";
2
+ import { META_ROUTES, routeToolInPlace } from "./agent-routes.js";
2
3
  import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
3
4
  import { carryCatalogFreshness, catalogIsFresh } from "./catalog-freshness.js";
4
5
  import { retainedCatalogIdentity } from "./catalog-cache.js";
@@ -13,9 +14,24 @@ import { runEdge, withDeadlineEffect } from "./runtime/run.js";
13
14
  import { registryProgram } from "./runtime/registry.js";
14
15
  import { SharedRead } from "./runtime/shared-read.js";
15
16
  import { duringViewEffect } from "./runtime/call.js";
16
- import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
17
+ import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, UNSCOPED_DISCOVERY_BUDGET_MS, UNSCOPED_PROBE_TIMEOUT_MS, } from "./timeout.js";
18
+ import { claimDiscoveryProbe, readDiscoveryHealth, settleDiscoveryHealth, } from "./discovery-health.js";
17
19
  import { keepErrorName } from "./redaction-identity.js";
18
- import { operationVocabularyMatches } from "./operation-vocabulary.js";
20
+ import { operationVocabularyMatches, searchSynonyms } from "./operation-vocabulary.js";
21
+ /** The slice of an unscoped search's budget kept for recording what its probes saw. */
22
+ const SETTLE_RESERVE_MS = 500;
23
+ /** How long a probe claim outlives its request's discovery budget, for settlement and clock skew. */
24
+ const CLAIM_MARGIN_MS = 2_000;
25
+ /** A connector unscoped search skipped because its discovery failed recently. */
26
+ class DiscoverySkipped extends Error {
27
+ code;
28
+ retryAfterMs;
29
+ constructor(code, retryAfterMs) {
30
+ super("Connector discovery skipped after a recent failure");
31
+ this.code = code;
32
+ this.retryAfterMs = retryAfterMs;
33
+ }
34
+ }
19
35
  export const DEFAULT_SEARCH_LIMIT = 8;
20
36
  export const MAX_SEARCH_LIMIT = 100;
21
37
  export const MAX_DESCRIBE_ADDRESSES = 100;
@@ -31,6 +47,8 @@ const MAX_IDENTITY_CONNECTORS = 3;
31
47
  const MAX_DESCRIBE_SUGGESTIONS = 3;
32
48
  const MAX_API_SEARCH_POINTERS = 3;
33
49
  const MAX_API_SEARCH_ADDRESS_BYTES = 256;
50
+ /** Fully determined operation reads one search page offers: one per connector, three connectors. */
51
+ const MAX_READY_CALLS = 3;
34
52
  /** Recognize the visible standard family without importing provider implementations or indexes. */
35
53
  function apiSearchTools(tools) {
36
54
  const names = new Set(tools.map((tool) => tool.name));
@@ -70,6 +88,13 @@ const SERVICE_NAMES = [
70
88
  "Google Slides",
71
89
  "Gmail",
72
90
  ];
91
+ /**
92
+ * Leading verbs that ask to read. A query led by one ranks read rows above
93
+ * write rows within each relevance tier; writes stay on the page.
94
+ */
95
+ const READ_INTENT = /^(?:list|get|find|search|show|read|fetch|count)$/;
96
+ /** Name words that state an action, never a resource. */
97
+ const ACTION_WORD = /^(?:list|get|find|search|show|read|fetch|count|create|update|delete)$/;
73
98
  function normalizedPhrase(text) {
74
99
  return lexicalQueryTerms(text.toLowerCase()).join(" ");
75
100
  }
@@ -238,15 +263,13 @@ function schemaKeyMetadata(input, output) {
238
263
  };
239
264
  }
240
265
  const stringListSchema = { type: "array", items: { type: "string" } };
241
- const guideProperties = {
242
- guide: { type: "string" },
243
- guideSummary: { type: "string" },
266
+ const STRING = { type: "string" };
267
+ // The reasons enum is stated once under `$defs` and referenced from each field.
268
+ const guideRequiredProperties = {
244
269
  guideRequired: { const: true },
245
- guideRequiredReasons: {
246
- type: "array",
247
- items: { enum: ["connector_required", "approval_required", "schema_truncated"] },
248
- },
270
+ guideRequiredReasons: { $ref: "#/$defs/reasons" },
249
271
  };
272
+ const guideProperties = { guide: STRING, guideSummary: STRING, ...guideRequiredProperties };
250
273
  const catalogFailureProperties = {
251
274
  code: { type: "string" },
252
275
  message: { type: "string" },
@@ -292,26 +315,31 @@ const catalogFailureProperties = {
292
315
  },
293
316
  },
294
317
  };
318
+ /** The one typed failure (and auth handoff) both failure fields reference. */
319
+ const FAILURE_REF = { $ref: "#/$defs/failure" };
295
320
  /**
296
321
  * The outputSchema contract for either search adapter. Guest API registration
297
322
  * can reuse this value instead of maintaining a second page definition.
323
+ * Every failure field references one local `$defs` entry, so the typed auth
324
+ * handoff is advertised once instead of once per field.
298
325
  */
299
326
  export const CATALOG_SEARCH_RESULT_SCHEMA = {
300
327
  type: "object",
301
328
  required: ["catalogErrors", "tools", "total", "offset", "limit", "hasMore"],
302
329
  additionalProperties: false,
303
- properties: {
304
- catalogErrors: {
305
- type: "array",
306
- items: {
307
- type: "object",
308
- required: ["connector", "code", "message", "retryable"],
309
- additionalProperties: false,
310
- properties: {
311
- ...catalogFailureProperties,
312
- },
313
- },
330
+ $defs: {
331
+ reasons: { type: "array", items: { enum: ["connector_required", "approval_required", "schema_truncated"] } },
332
+ failure: {
333
+ type: "object",
334
+ required: ["code", "message", "retryable"],
335
+ additionalProperties: false,
336
+ properties: catalogFailureProperties,
314
337
  },
338
+ },
339
+ properties: {
340
+ // `allOf` rather than a `$ref` sibling keeps `required` enforced by
341
+ // validators that predate 2020-12 sibling semantics.
342
+ catalogErrors: { type: "array", items: { allOf: [FAILURE_REF], required: ["connector"] } },
315
343
  absence: {
316
344
  type: "object",
317
345
  required: ["service", "message", "configuredConnectors"],
@@ -331,7 +359,6 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
331
359
  properties: {
332
360
  name: { type: "string" },
333
361
  address: { type: "string" },
334
- connectorTitle: { type: "string" },
335
362
  description: { type: "string" },
336
363
  inputSchema: {},
337
364
  outputSchema: {},
@@ -345,10 +372,33 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
345
372
  outputKeys: stringListSchema,
346
373
  annotations: { type: "object" },
347
374
  classification: { enum: ["read", "write"] },
348
- ...guideProperties,
375
+ ...guideRequiredProperties,
349
376
  },
350
377
  },
351
378
  },
379
+ readyCalls: {
380
+ type: "array",
381
+ maxItems: MAX_READY_CALLS,
382
+ items: {
383
+ type: "object",
384
+ required: ["address", "args", "classification", "summary"],
385
+ properties: {
386
+ address: STRING,
387
+ args: { type: "object", required: ["path"], additionalProperties: false, properties: { path: STRING } },
388
+ classification: { const: "read" },
389
+ summary: STRING,
390
+ ...guideRequiredProperties,
391
+ },
392
+ },
393
+ },
394
+ connectors: {
395
+ type: "object",
396
+ additionalProperties: {
397
+ type: "object",
398
+ additionalProperties: false,
399
+ properties: { title: STRING, guide: STRING, guideSummary: STRING },
400
+ },
401
+ },
352
402
  total: { type: "integer", minimum: 0 },
353
403
  offset: { type: "integer", minimum: 0 },
354
404
  limit: { type: "integer", minimum: 1, maximum: MAX_SEARCH_LIMIT },
@@ -368,12 +418,7 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
368
418
  unknownConnector: { const: true },
369
419
  configuredConnectors: stringListSchema,
370
420
  unavailableConnectorCount: { type: "integer", minimum: 0 },
371
- catalogError: {
372
- type: "object",
373
- required: ["code", "message", "retryable"],
374
- additionalProperties: false,
375
- properties: catalogFailureProperties,
376
- },
421
+ catalogError: FAILURE_REF,
377
422
  ...guideProperties,
378
423
  guidance: { type: "string" },
379
424
  },
@@ -397,6 +442,7 @@ export class CatalogService {
397
442
  requestScope;
398
443
  defer;
399
444
  probeTimeoutMs;
445
+ discoveryBudgetMs;
400
446
  concurrency;
401
447
  searchRoute;
402
448
  requestSignal;
@@ -421,12 +467,18 @@ export class CatalogService {
421
467
  // gets new views and indexes. Each service belongs to one request and one
422
468
  // registry visibility view (caller/pool, hidden tools and classification).
423
469
  searchViews = new WeakMap();
470
+ // This request's discovery-health verdicts, read once per connector and kept
471
+ // current by its own probes, so repeated searches read no storage.
472
+ health = new Map();
473
+ routes;
424
474
  constructor(registry, baseUrl, options = {}) {
425
475
  this.registry = registry;
426
476
  this.baseUrl = baseUrl;
427
477
  void CatalogService.nativePrograms;
478
+ this.routes = options.routes ?? META_ROUTES;
428
479
  this.requestScope = options.requestScope ?? {};
429
480
  this.probeTimeoutMs = normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
481
+ this.discoveryBudgetMs = normalizeTimeoutMs(options.discoveryBudgetMs) ?? UNSCOPED_DISCOVERY_BUDGET_MS;
430
482
  this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
431
483
  this.searchRoute = options.searchRoute ?? "search_tools";
432
484
  this.requestSignal = options.requestSignal;
@@ -480,7 +532,16 @@ export class CatalogService {
480
532
  signal,
481
533
  timeoutMs: this.probeTimeoutMs,
482
534
  ...(this.defer ? { defer: this.defer } : {}),
483
- }).pipe(Effect.map((tools) => carryCatalogFreshness(tools, tools.map(cloneTool)))), (succeeded) => {
535
+ }).pipe(Effect.map((tools) => {
536
+ // An authored catalog lists its declared descriptions for this request's surface.
537
+ const connector = this.registry.getConnector(id);
538
+ return carryCatalogFreshness(tools, tools.map((tool) => {
539
+ // The request's own copy, which keeps the source classification.
540
+ const copy = cloneTool(tool);
541
+ routeToolInPlace(connector, copy, this.routes);
542
+ return copy;
543
+ }));
544
+ })), (succeeded) => {
484
545
  // Evicted before the read resumes anyone, so an asker that
485
546
  // retries on hearing of the failure starts a fresh read.
486
547
  if (!succeeded && this.catalogs.get(id)?.read === started) {
@@ -515,11 +576,117 @@ export class CatalogService {
515
576
  // that asked for it. On a read-only endpoint a write is absent from search
516
577
  // and describe, as if the connector never had it. Guides are not filtered.
517
578
  discoveryCatalogs(ids, route, includeWrites = false) {
518
- return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => Effect.map(this.catalog(id, { signal }), (tools) => this.searchView(id, tools, includeWrites, "all")), {
519
- ...(this.requestSignal ? { signal: this.requestSignal } : {}),
520
- timeoutMs: this.probeTimeoutMs,
521
- timeoutError: new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`),
522
- })), { concurrency: this.concurrency });
579
+ return Effect.forEach(ids, (id) => Effect.result(this.probe(id, includeWrites, this.probeTimeoutMs, new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`))), { concurrency: this.concurrency });
580
+ }
581
+ // Search's catalogs. A scoped search probes its connector under the full
582
+ // probe deadline, reads its health record beside the probe, and settles it
583
+ // after. An unscoped search reads health first, skips a connector whose
584
+ // discovery failed recently, probes the rest under a shorter deadline, and
585
+ // settles each record as its probe ends. One discovery budget bounds all of
586
+ // it, health storage included: a probe that starts late gets only what
587
+ // remains less a slice kept for settling, and health I/O starts only within
588
+ // it, then runs to its end. A lapsed record is claimed as its probe is
589
+ // dispatched, leased to the end of this budget plus a margin.
590
+ // Caller cancellation and a probe the budget cut short leave health as it was.
591
+ searchCatalogs(ids, scoped) {
592
+ return Effect.gen({ self: this }, function* () {
593
+ const deadline = scoped ? this.probeTimeoutMs : Math.min(this.probeTimeoutMs, UNSCOPED_PROBE_TIMEOUT_MS);
594
+ const budgetEnd = scoped ? Infinity : Date.now() + this.discoveryBudgetMs;
595
+ const settleReserve = scoped ? 0 : Math.min(SETTLE_RESERVE_MS, Math.floor(this.discoveryBudgetMs / 10));
596
+ // Health storage starts only while budget remains and the caller is
597
+ // here; running out is not a failure, health is then unknown. Once
598
+ // started, an operation is awaited to its end, never raced or detached:
599
+ // a storage write cannot be withdrawn once sent (INV-7). The budget
600
+ // bounds when health work may start, not when it finishes.
601
+ const withinBudget = (io, unknown) => Effect.suspend(() => Date.now() >= budgetEnd || this.requestSignal?.aborted ? Effect.succeed(unknown) : Effect.uninterruptible(io));
602
+ // A skip that has lapsed is read again, so this request can claim the probe.
603
+ const unread = ids.filter((id) => {
604
+ const known = this.health.get(id);
605
+ return !known || (!scoped && known.skip !== undefined && known.skip.retryAt <= Date.now());
606
+ });
607
+ const readHealth = Effect.map(withinBudget(readDiscoveryHealth(this.registry, unread, this.baseUrl, this.requestScope, { skip: !scoped }), unread.map(() => ({}))), (read) => unread.forEach((id, index) => this.health.set(id, read[index])));
608
+ // Record what one probe saw, unless the caller left.
609
+ const settleOne = (outcome) => {
610
+ const slot = this.health.get(outcome.id)?.slot;
611
+ const { result, settle } = outcome;
612
+ if (!settle || !slot || this.requestSignal?.aborted)
613
+ return Effect.void;
614
+ return Effect.map(withinBudget(settleDiscoveryHealth(slot, Result.isSuccess(result)
615
+ ? { ok: true }
616
+ : {
617
+ ok: false,
618
+ failure: result.failure,
619
+ timedOut: result.failure === settle.timeoutError,
620
+ elapsedMs: settle.elapsedMs,
621
+ }), undefined), (settled) => {
622
+ if (settled)
623
+ this.health.set(outcome.id, settled);
624
+ });
625
+ };
626
+ const probeOne = (id) => Effect.gen({ self: this }, function* () {
627
+ const verdict = scoped ? undefined : this.health.get(id);
628
+ const skip = verdict?.skip;
629
+ if (skip && skip.retryAt > Date.now())
630
+ return {
631
+ id,
632
+ result: Result.fail(new DiscoverySkipped(skip.code, Math.ceil(skip.retryAt - Date.now()))),
633
+ };
634
+ const timeoutFor = (remaining) => {
635
+ const budgetCut = remaining < deadline;
636
+ return {
637
+ budgetCut,
638
+ timeoutMs: budgetCut ? remaining : deadline,
639
+ timeoutError: budgetCut
640
+ ? new ConnectorCallError("timeout", `${this.searchRoute} stopped probing "${id}" at the ${this.discoveryBudgetMs}ms discovery budget`)
641
+ : new ConnectorCallError("timeout", `${this.searchRoute} probe of "${id}" timed out after ${deadline}ms`),
642
+ };
643
+ };
644
+ let plan = timeoutFor(Math.ceil(budgetEnd - settleReserve - Date.now()));
645
+ if (plan.timeoutMs <= 0)
646
+ return { id, result: Result.fail(plan.timeoutError) };
647
+ // A lapsed record: claim this probe now, as it is dispatched. The
648
+ // lease runs to an absolute time, the end of this request's
649
+ // discovery budget plus a margin, so however long the claim write
650
+ // takes, the probe the budget still allows ends inside it.
651
+ if (skip && verdict?.slot) {
652
+ const leaseUntil = budgetEnd + CLAIM_MARGIN_MS;
653
+ const claimed = yield* withinBudget(claimDiscoveryProbe(verdict.slot, leaseUntil), false);
654
+ if (!claimed) {
655
+ this.health.set(id, { slot: verdict.slot, skip: { code: skip.code, retryAt: leaseUntil } });
656
+ return {
657
+ id,
658
+ result: Result.fail(new DiscoverySkipped(skip.code, Math.max(1, Math.ceil(leaseUntil - Date.now())))),
659
+ };
660
+ }
661
+ this.health.set(id, { slot: verdict.slot });
662
+ plan = timeoutFor(Math.ceil(budgetEnd - settleReserve - Date.now()));
663
+ if (plan.timeoutMs <= 0)
664
+ return { id, result: Result.fail(plan.timeoutError) };
665
+ }
666
+ const started = Date.now();
667
+ const result = yield* Effect.result(this.probe(id, false, plan.timeoutMs, plan.timeoutError));
668
+ // Elapsed time is this probe's own, measured as it settles.
669
+ const elapsedMs = Date.now() - started;
670
+ const outcome = plan.budgetCut && Result.isFailure(result) && result.failure === plan.timeoutError
671
+ ? { id, result }
672
+ : { id, result, settle: { timeoutError: plan.timeoutError, elapsedMs } };
673
+ if (scoped)
674
+ return outcome;
675
+ yield* settleOne(outcome);
676
+ return { id, result };
677
+ });
678
+ if (!scoped) {
679
+ yield* readHealth;
680
+ const probed = yield* Effect.forEach(ids, probeOne, { concurrency: this.concurrency });
681
+ return probed.map((outcome) => outcome.result);
682
+ }
683
+ const [, probed] = yield* Effect.all([readHealth, Effect.forEach(ids, probeOne, { concurrency: this.concurrency })], { concurrency: 2 });
684
+ yield* Effect.forEach(probed, settleOne, { concurrency: "unbounded" });
685
+ return probed.map((outcome) => outcome.result);
686
+ });
687
+ }
688
+ probe(id, includeWrites, timeoutMs, timeoutError) {
689
+ return withDeadlineEffect((signal) => Effect.map(this.catalog(id, { signal }), (tools) => this.searchView(id, tools, includeWrites, "all")), { ...(this.requestSignal ? { signal: this.requestSignal } : {}), timeoutMs, timeoutError });
523
690
  }
524
691
  /**
525
692
  * Every visible catalog as discovery shows it, for a native `tools/list`:
@@ -619,6 +786,15 @@ export class CatalogService {
619
786
  }
620
787
  catalogFailure(connector, failure) {
621
788
  return Effect.gen({ self: this }, function* () {
789
+ if (failure instanceof DiscoverySkipped) {
790
+ return {
791
+ connector: connector.id,
792
+ code: failure.code,
793
+ message: `Connector "${connector.id}" was skipped because its catalog lookup failed recently (${failure.code}). Search with connector "${connector.id}" to try it now.`,
794
+ retryable: true,
795
+ retryAfterMs: failure.retryAfterMs,
796
+ };
797
+ }
622
798
  const classified = classifyCallError(failure, "catalog_lookup_failed");
623
799
  const error = {
624
800
  connector: connector.id,
@@ -700,11 +876,16 @@ export class CatalogService {
700
876
  : undefined;
701
877
  const connectors = absence ? [] : args.connector ? (scopedConnector ? [scopedConnector] : []) : visibleConnectors;
702
878
  // Timeout messages name the route the caller used.
703
- const catalogs = yield* this.discoveryCatalogs(connectors.map((connector) => connector.id), this.searchRoute);
879
+ const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined);
704
880
  const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => this.searchView(connectors[index].id, tools, true, safety)));
705
881
  const matches = [];
706
882
  let matchMode = "all";
707
- const statistics = lexicalCorpusStatistics(searchableCatalogs.flatMap((catalog) => (Result.isSuccess(catalog) ? [catalog.success] : [])), retrievalQuery);
883
+ // Provider-declared synonyms widen term matching within their own catalog only.
884
+ const synonymsFor = (tools) => {
885
+ const index = searchableCatalogs.findIndex((catalog) => Result.isSuccess(catalog) && catalog.success === tools);
886
+ return index < 0 ? undefined : searchSynonyms(connectors[index]);
887
+ };
888
+ const statistics = lexicalCorpusStatistics(searchableCatalogs.flatMap((catalog) => (Result.isSuccess(catalog) ? [catalog.success] : [])), retrievalQuery, synonymsFor);
708
889
  const trimmedQuery = query.trim();
709
890
  const isBrowse = trimmedQuery.length === 0;
710
891
  const queryTerms = lexicalQueryTerms(retrievalQuery);
@@ -716,8 +897,10 @@ export class CatalogService {
716
897
  const displayTerm = (term) => boundedQueryTerm(term).text;
717
898
  const queryMetadataTruncated = analysisTerms.length > analyzedTerms.length || analyzedTerms.some((term) => boundedQueryTerm(term).truncated);
718
899
  const vocabularyTools = new Set();
900
+ const readyFound = [];
719
901
  const collectMatches = (mode) => {
720
902
  const collected = [];
903
+ readyFound.length = 0;
721
904
  let orderBase = 0;
722
905
  searchableCatalogs.forEach((catalog, connectorIndex) => {
723
906
  const connector = connectors[connectorIndex];
@@ -734,7 +917,7 @@ export class CatalogService {
734
917
  : []),
735
918
  ]);
736
919
  const localStatistics = exactConnector
737
- ? lexicalCorpusStatistics([catalog.success], retrievalQuery)
920
+ ? lexicalCorpusStatistics([catalog.success], retrievalQuery, synonymsFor)
738
921
  : undefined;
739
922
  // An exact identity is a browse even when one tool repeats that
740
923
  // identity in its name or description. Action queries still keep
@@ -754,7 +937,12 @@ export class CatalogService {
754
937
  const rankedTools = rankTools(catalog.success, toolQuery, mode, statistics, query);
755
938
  const original = catalogs[connectorIndex];
756
939
  if (!isBrowse && query.length <= 200 && queryTerms.length <= 8 && Result.isSuccess(original)) {
757
- for (const tool of operationVocabularyMatches(connector, original.success, toolTerms.filter((term) => !connectorTerms.has(term)).join(" "), safety, this.readOnly)) {
940
+ const vocabulary = operationVocabularyMatches(connector, original.success, toolTerms.filter((term) => !connectorTerms.has(term)).join(" "), safety, this.readOnly);
941
+ // At most one ready call per connector: its first hatch's top operation.
942
+ const ready = vocabulary.ready[0];
943
+ if (ready && !readyFound.some((found) => found.connector === connector))
944
+ readyFound.push({ connector, ...ready });
945
+ for (const tool of vocabulary.tools) {
758
946
  vocabularyTools.add(tool);
759
947
  if (!rankedTools.some((match) => match.tool === tool))
760
948
  rankedTools.push({
@@ -798,9 +986,14 @@ export class CatalogService {
798
986
  if (!isBrowse && !unsearchableQuery && completeMatchCount === 0) {
799
987
  matchMode = "partial";
800
988
  }
989
+ // A read-intent verb leads the query once connector names are set aside.
990
+ const identityTerms = new Set(connectors.flatMap(connectorNames).flatMap(lexicalQueryTerms));
991
+ const readIntent = READ_INTENT.test(lexicalQueryTerms(query).find((term) => !identityTerms.has(term)) ?? "");
992
+ const readFirst = (match) => Number(readIntent && match.tool.classification === "read");
801
993
  // Exact tool names precede connector identity and lexical scoring. A
802
994
  // longer name such as get_issue_status cannot beat an exact get_issue
803
- // merely because its description repeats rare query terms.
995
+ // merely because its description repeats rare query terms. Within a
996
+ // tier, a read-intent query puts reads before writes.
804
997
  matches.sort((a, b) => {
805
998
  const aFirstTier = a.complete || a.exactName;
806
999
  const bFirstTier = b.complete || b.exactName;
@@ -808,6 +1001,7 @@ export class CatalogService {
808
1001
  Number(b.exactName) - Number(a.exactName) ||
809
1002
  b.connectorRank - a.connectorRank ||
810
1003
  Number(bFirstTier) - Number(aFirstTier) ||
1004
+ readFirst(b) - readFirst(a) ||
811
1005
  b.score - a.score ||
812
1006
  a.order - b.order);
813
1007
  });
@@ -883,6 +1077,40 @@ export class CatalogService {
883
1077
  },
884
1078
  };
885
1079
  });
1080
+ // An exact name already answers the query, and so does a complete match
1081
+ // on an authored tool (not the hatch's own) whose name adds nothing to
1082
+ // the query: each name word is an action, or a query word up to
1083
+ // plural/singular and declared synonyms. `list_teams` answers "list
1084
+ // teams"; `list_project_env_vars` does not answer "list projects".
1085
+ const top = matches[0];
1086
+ const synonymsOfTop = top && searchSynonyms(top.connector);
1087
+ const asked = queryTerms.flatMap((term) => [term, ...(synonymsOfTop?.(term) ?? [])]);
1088
+ const answered = top !== undefined &&
1089
+ (top.exactLookup ||
1090
+ top.exactName ||
1091
+ (top.complete &&
1092
+ !vocabularyTools.has(top.tool) &&
1093
+ !readyFound.some((ready) => ready.tool === top.tool) &&
1094
+ lexicalQueryTerms(top.tool.name).every((word) => ACTION_WORD.test(word) || asked.some((term) => matchesLexicalTerm(word, term)))));
1095
+ const readyCalls = offset === 0 && !answered
1096
+ ? readyFound.slice(0, MAX_READY_CALLS).map(({ connector, tool, path, summary }) => {
1097
+ const reasons = guideRequiredReasons(connector, tool, false);
1098
+ const guideSummary = connectorGuideSummary(connector);
1099
+ return {
1100
+ connector,
1101
+ call: {
1102
+ address: `${connector.id}.${tool.name}`,
1103
+ args: { path },
1104
+ classification: "read",
1105
+ summary,
1106
+ ...(reasons ? { guideRequired: true, guideRequiredReasons: reasons } : {}),
1107
+ },
1108
+ ...(connectorGuide(connector)
1109
+ ? { guide: connectorSkillName(connector.id), ...(guideSummary ? { guideSummary } : {}) }
1110
+ : {}),
1111
+ };
1112
+ })
1113
+ : [];
886
1114
  const nextOffset = offset + entries.length < matches.length ? offset + entries.length : undefined;
887
1115
  const pageTools = new Set(pageMatches.map((match) => match.tool));
888
1116
  const matchingTools = (term) => new Set([
@@ -1058,6 +1286,8 @@ export class CatalogService {
1058
1286
  onCatalogReference?.(connector.id);
1059
1287
  for (const entry of entries)
1060
1288
  onCatalogReference?.(entry.connector.id, entry.tool.name);
1289
+ for (const { connector, call } of readyCalls)
1290
+ onCatalogReference?.(connector.id, call.address.slice(connector.id.length + 1));
1061
1291
  for (const pointer of apiSearchPointers)
1062
1292
  onCatalogReference?.(pointer.connector, pointer.tool);
1063
1293
  for (const connector of absence?.configuredConnectors ?? [])
@@ -1070,6 +1300,7 @@ export class CatalogService {
1070
1300
  catalogErrors,
1071
1301
  ...(absence ? { absence } : {}),
1072
1302
  entries,
1303
+ ...(readyCalls.length ? { readyCalls } : {}),
1073
1304
  total: matches.length,
1074
1305
  offset,
1075
1306
  limit,
@@ -1264,17 +1495,23 @@ function pageTail(page) {
1264
1495
  };
1265
1496
  }
1266
1497
  export function flatSearchResult(page) {
1498
+ // fromEntries keeps an id such as "__proto__" an own key.
1499
+ const connectors = new Map();
1500
+ for (const { connector, guide, guideSummary } of [...page.entries, ...(page.readyCalls ?? [])]) {
1501
+ const title = connector.title && boundedEchoText(connector.title.replace(/\s+/g, " ").trim(), 117);
1502
+ if (title || guide)
1503
+ connectors.set(connector.id, {
1504
+ ...(title ? { title } : {}),
1505
+ ...(guide ? { guide } : {}),
1506
+ ...(guideSummary ? { guideSummary } : {}),
1507
+ });
1508
+ }
1267
1509
  return {
1268
1510
  catalogErrors: page.catalogErrors,
1269
1511
  ...(page.absence ? { absence: page.absence } : {}),
1270
- tools: page.entries.map((entry) => ({
1271
- ...entry.tool,
1272
- ...(entry.connector.title
1273
- ? { connectorTitle: boundedEchoText(entry.connector.title.replace(/\s+/g, " ").trim(), 117) }
1274
- : {}),
1275
- ...(entry.guide ? { guide: entry.guide } : {}),
1276
- ...(entry.guideSummary ? { guideSummary: entry.guideSummary } : {}),
1277
- })),
1512
+ tools: page.entries.map((entry) => entry.tool),
1513
+ ...(page.readyCalls ? { readyCalls: page.readyCalls.map((ready) => ready.call) } : {}),
1514
+ ...(connectors.size ? { connectors: Object.fromEntries(connectors) } : {}),
1278
1515
  ...pageTail(page),
1279
1516
  };
1280
1517
  }
package/dist/catalog.js CHANGED
@@ -255,7 +255,23 @@ export function matchesLexicalTerm(text, term) {
255
255
  * connector rank, so ubiquitous words contribute less than discriminative
256
256
  * ones without making any action word a stopword.
257
257
  */
258
- export function lexicalCorpusStatistics(toolSets, query) {
258
+ /** Whole-token candidates for one term, widened by a catalog's lexical synonyms. */
259
+ function synonymCandidates(term, synonyms) {
260
+ const own = matchingTokenCandidates(term);
261
+ if (!synonyms)
262
+ return own;
263
+ const candidates = new Set(own);
264
+ for (const candidate of own)
265
+ for (const synonym of synonyms(candidate))
266
+ for (const variant of matchingTokenCandidates(synonym))
267
+ candidates.add(variant);
268
+ return candidates;
269
+ }
270
+ /**
271
+ * `synonymsFor` gives a catalog's lexical synonyms, word to equivalent words:
272
+ * a term then matches a tool through any of them, as plain whole tokens.
273
+ */
274
+ export function lexicalCorpusStatistics(toolSets, query, synonymsFor) {
259
275
  const terms = lexicalQueryTerms(query);
260
276
  if (terms.length === 0) {
261
277
  return {
@@ -272,7 +288,7 @@ export function lexicalCorpusStatistics(toolSets, query) {
272
288
  const termDescriptionMatches = new Set();
273
289
  for (const tools of toolSets) {
274
290
  const index = indexFor(tools);
275
- for (const candidate of matchingTokenCandidates(term)) {
291
+ for (const candidate of synonymCandidates(term, synonymsFor?.(tools))) {
276
292
  for (const tool of index.nameTokenDocuments.get(candidate) ?? []) {
277
293
  termNameMatches.add(tool);
278
294
  }
package/dist/config.d.ts CHANGED
@@ -18,8 +18,10 @@ declare const discovery: {
18
18
  /**
19
19
  * Deadline (ms) for each downstream probe/catalog call fanned out by
20
20
  * `search_tools` and by `connecta.search`/`connecta.describe` inside
21
- * `execute_code`. Defaults to 30_000. A timed-out connector degrades
22
- * independently; this does not apply to tool calls. Catalog walks receive the
21
+ * `execute_code`. Defaults to 30_000. An unscoped search caps each probe at
22
+ * 5_000 and its whole fan-out at 9_000, and skips a connector whose
23
+ * discovery recently timed out for up to 60 seconds. A timed-out connector
24
+ * degrades independently; this does not apply to tool calls. Catalog walks receive the
23
25
  * same cancellation signal, which aborts an in-flight page where supported and
24
26
  * prevents another from starting.
25
27
  */
@@ -191,8 +193,9 @@ declare const pool: {
191
193
  /**
192
194
  * Who may open `/mcp/<name>`: principal ids in the namespace of the one
193
195
  * interactive auth provider, `{ namespace, id }`, or `"agent:<name>"` for a
194
- * declared agent. Required and non-empty; the pool serves exactly its
195
- * `tools` to these members and the same 404 to everyone else.
196
+ * declared agent. All members must be people or all must be agents. Required
197
+ * and non-empty; the pool serves exactly its `tools` to these members and
198
+ * the same 404 to everyone else.
196
199
  */
197
200
  members: import("./config-schema.js").Field<readonly MemberRef[], readonly MemberRef[], false>;
198
201
  /**
@@ -200,8 +203,8 @@ declare const pool: {
200
203
  * connector whose `subject` is a mapping function. Humans always act as
201
204
  * themselves through that mapping, and a fixed `subject` address applies to
202
205
  * everyone. Without an owner, a mapping-subject Workspace connector is
203
- * absent for this pool's agents, as a personal connector is; people in the
204
- * pool still see it.
206
+ * absent for this pool's agents, as a personal connector is. People use a
207
+ * separate pool and always act as themselves.
205
208
  */
206
209
  owner: import("./config-schema.js").Field<string, string | undefined, true>;
207
210
  /**
@@ -218,7 +221,7 @@ declare const pool: {
218
221
  * `<connector>__<tool>` and annotated from its classification, for clients
219
222
  * that run their own tool search; calls take the same invocation path.
220
223
  */
221
- surface: import("./config-schema.js").Field<"native" | "meta", "native" | "meta" | undefined, true>;
224
+ surface: import("./config-schema.js").Field<"meta" | "native", "meta" | "native" | undefined, true>;
222
225
  /**
223
226
  * `false` drops `execute_code` from a native pool and lists `read_result`
224
227
  * for paging instead. Only valid with `surface: "native"`: the meta surface
@@ -254,7 +257,7 @@ declare const connectaConfig: {
254
257
  readOnly?: boolean | undefined;
255
258
  owner?: string | undefined;
256
259
  description?: string | undefined;
257
- surface?: "native" | "meta" | undefined;
260
+ surface?: "meta" | "native" | undefined;
258
261
  executeCode?: boolean | undefined;
259
262
  tools: readonly string[];
260
263
  members: readonly MemberRef[];
@@ -264,7 +267,7 @@ declare const connectaConfig: {
264
267
  readonly members: readonly MemberRef[];
265
268
  readonly owner: string | undefined;
266
269
  readonly description: string | undefined;
267
- readonly surface: "native" | "meta" | undefined;
270
+ readonly surface: "meta" | "native" | undefined;
268
271
  readonly executeCode: boolean | undefined;
269
272
  }>> | undefined, true>;
270
273
  /** Exact connector id -> tool name -> verdict. Hidden tools cannot be discovered or called.
@@ -326,8 +329,10 @@ declare const connectaConfig: {
326
329
  /**
327
330
  * Deadline (ms) for each downstream probe/catalog call fanned out by
328
331
  * `search_tools` and by `connecta.search`/`connecta.describe` inside
329
- * `execute_code`. Defaults to 30_000. A timed-out connector degrades
330
- * independently; this does not apply to tool calls. Catalog walks receive the
332
+ * `execute_code`. Defaults to 30_000. An unscoped search caps each probe at
333
+ * 5_000 and its whole fan-out at 9_000, and skips a connector whose
334
+ * discovery recently timed out for up to 60 seconds. A timed-out connector
335
+ * degrades independently; this does not apply to tool calls. Catalog walks receive the
331
336
  * same cancellation signal, which aborts an in-flight page where supported and
332
337
  * prevents another from starting.
333
338
  */