@zackbart/connecta 0.40.0 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -1,18 +1,24 @@
1
+ import { utilityRecovery } from "./utility-recovery.js";
2
+ import { traceFor } from "./timing-trace.js";
3
+ import { utf8Length } from "./bytes.js";
4
+ import { catalogOwnerPartition } from "./catalog-policy.js";
1
5
  import { registerAuthMethod } from "./auth/native.js";
2
6
  import { META_ROUTES, routeToolInPlace } from "./agent-routes.js";
7
+ import { readProgramRefuses } from "./tool-safety.js";
3
8
  import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
4
- import { carryCatalogFreshness, catalogIsFresh } from "./catalog-freshness.js";
9
+ import { carryCatalogFreshness, catalogGenerationFor, catalogIsFresh, catalogStaleness, markCatalogStale, } from "./catalog-freshness.js";
5
10
  import { retainedCatalogIdentity } from "./catalog-cache.js";
6
11
  import { cloneTool } from "./tool-safety.js";
7
12
  import { hasControlCharacters } from "./tool-name.js";
8
- import { Effect, Result } from "effect";
9
- import { compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, typescriptSignature, } from "./catalog.js";
13
+ import { Effect, Result, Semaphore } from "effect";
14
+ import { prepareLexicalCatalog, carryLexicalCatalog, compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, discoverySummary, vocabularyScore, typescriptSignature, } from "./catalog.js";
10
15
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
11
16
  import { boundedEchoText, classifyCallError, ConnectorCallError, framingError } from "./errors.js";
12
17
  import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName } from "./skills.js";
13
- import { runEdge, withDeadlineEffect } from "./runtime/run.js";
18
+ import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
14
19
  import { registryProgram } from "./runtime/registry.js";
15
20
  import { SharedRead } from "./runtime/shared-read.js";
21
+ import { closeScope } from "./runtime/connector-scope.js";
16
22
  import { duringViewEffect } from "./runtime/call.js";
17
23
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, UNSCOPED_DISCOVERY_BUDGET_MS, UNSCOPED_PROBE_TIMEOUT_MS, } from "./timeout.js";
18
24
  import { claimDiscoveryProbe, readDiscoveryHealth, settleDiscoveryHealth, } from "./discovery-health.js";
@@ -20,6 +26,29 @@ import { keepErrorName } from "./redaction-identity.js";
20
26
  import { operationVocabularyMatches, searchSynonyms } from "./operation-vocabulary.js";
21
27
  /** The slice of an unscoped search's budget kept for recording what its probes saw. */
22
28
  const SETTLE_RESERVE_MS = 500;
29
+ /** Cached reads running at once in one discovery. Storage only, so they never wait for a probe slot. */
30
+ const CATALOG_PEEK_CONCURRENCY = 16;
31
+ /** The longest a stale catalog's read holds up a finished probe, and the least any peek gets. */
32
+ const STALE_READ_TIMEOUT_MS = 1_000;
33
+ /**
34
+ * One deferred refresh's whole tail, from being scheduled to the end of its
35
+ * cleanup: queueing, claim, listing, settlement and close. Inside the 30 seconds
36
+ * Workers keeps `waitUntil` alive.
37
+ */
38
+ const CATALOG_REFRESH_TIMEOUT_MS = 20_000;
39
+ /** The end of that tail no listing may use: the record's settlement, then connector cleanup. */
40
+ const REFRESH_SETTLE_MS = 1_000;
41
+ const REFRESH_CLOSE_MS = 2_000;
42
+ /**
43
+ * Failures that say the connector did not answer, so its last complete catalog
44
+ * can stand in for the listing. Auth and permission failures, refusals and
45
+ * malformed catalogs are answers, and stay failures.
46
+ */
47
+ const STALE_LISTING_CODES = new Set(["timeout", "unavailable", "rate_limited"]);
48
+ /** A retryable downstream failure (HTTP 502, 503 or 504); a refusal under this code is not retryable. */
49
+ const staleListingAllowed = (code, retryable) => STALE_LISTING_CODES.has(code) || (code === "connector_call_failed" && retryable);
50
+ const lexicalStores = new WeakMap();
51
+ let nextLexicalStore = 0;
23
52
  /** How long a probe claim outlives its request's discovery budget, for settlement and clock skew. */
24
53
  const CLAIM_MARGIN_MS = 2_000;
25
54
  /** A connector unscoped search skipped because its discovery failed recently. */
@@ -32,10 +61,12 @@ class DiscoverySkipped extends Error {
32
61
  this.retryAfterMs = retryAfterMs;
33
62
  }
34
63
  }
35
- export const DEFAULT_SEARCH_LIMIT = 8;
64
+ const DEFAULT_SEARCH_LIMIT = 8;
36
65
  export const MAX_SEARCH_LIMIT = 100;
37
66
  export const MAX_DESCRIBE_ADDRESSES = 100;
38
67
  export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
68
+ /** UTF-8 bytes of full tool purposes one describe call returns; fallback summaries have their own bound. */
69
+ export const MAX_DESCRIBE_DESCRIPTION_BYTES = 32 * 1024;
39
70
  const MAX_QUERY_TERMS = 8;
40
71
  const MAX_QUERY_TERM_LENGTH = 64;
41
72
  /**
@@ -262,6 +293,23 @@ function schemaKeyMetadata(input, output) {
262
293
  ...(outputKeys && outputKeys.properties.length > 0 ? { outputKeys: outputKeys.properties } : {}),
263
294
  };
264
295
  }
296
+ /** Fixed text and typed facts only: nothing here comes from the connector. */
297
+ function staleCatalogFailure(connector, stale) {
298
+ // A downstream that asked for time gets it: asking now would only be refused again.
299
+ const advice = stale.code === "rate_limited" || stale.downstreamWait
300
+ ? `Wait${stale.retryAfterMs === undefined ? "" : ` ${stale.retryAfterMs} ms`} before searching with connector "${connector}".`
301
+ : `Search with connector "${connector}" to try it now.`;
302
+ return {
303
+ connector,
304
+ code: stale.code,
305
+ message: `Connector "${connector}" did not answer discovery (${stale.code}), so its tools are listed from its last complete catalog. Calls still check the live catalog. ${advice}`,
306
+ retryable: true,
307
+ ...(stale.retryAfterMs === undefined ? {} : { retryAfterMs: stale.retryAfterMs }),
308
+ stale: true,
309
+ ageMs: stale.ageMs,
310
+ };
311
+ }
312
+ const staleListedText = (count) => `${count} connector catalog${count === 1 ? " is" : "s are"} listed from an older complete catalog; inspect catalogErrors.`;
265
313
  const stringListSchema = { type: "array", items: { type: "string" } };
266
314
  const STRING = { type: "string" };
267
315
  // The reasons enum is stated once under `$defs` and referenced from each field.
@@ -275,6 +323,8 @@ const catalogFailureProperties = {
275
323
  message: { type: "string" },
276
324
  retryable: { type: "boolean" },
277
325
  retryAfterMs: { type: "integer", minimum: 0 },
326
+ stale: { const: true },
327
+ ageMs: { type: "integer" },
278
328
  connector: { type: "string" },
279
329
  recovery: { enum: ["oauth", "operator_config", "unavailable"] },
280
330
  status: { const: "auth_required" },
@@ -299,29 +349,27 @@ const catalogFailureProperties = {
299
349
  },
300
350
  },
301
351
  retry: { type: "string" },
352
+ // Handoff fields above carry the detailed authorization contract. Keep
353
+ // additive action advice compact in the always-loaded search schema.
302
354
  nextAction: {
303
355
  type: "object",
304
- required: ["tool", "arguments", "operatorHandoff"],
305
- additionalProperties: false,
356
+ anyOf: [{ required: ["url"] }, { required: ["tool"] }, { required: ["action"] }],
306
357
  properties: {
358
+ url: STRING,
307
359
  tool: { const: "authorize_connector" },
308
- arguments: {
309
- type: "object",
310
- required: ["connector"],
311
- additionalProperties: false,
312
- properties: { connector: { type: "string" } },
313
- },
314
- operatorHandoff: { type: "string" },
360
+ action: { enum: ["contact_operator", "inspect_capabilities"] },
361
+ purpose: STRING,
362
+ arguments: { type: "object", properties: { connector: STRING } },
315
363
  },
316
364
  },
317
365
  };
318
366
  /** The one typed failure (and auth handoff) both failure fields reference. */
319
367
  const FAILURE_REF = { $ref: "#/$defs/failure" };
320
368
  /**
321
- * The outputSchema contract for either search adapter. Guest API registration
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.
369
+ * The full page contract for either search adapter, which the server validates
370
+ * every page against. `tools/list` advertises the open summary in `meta-output.ts`
371
+ * instead. Every failure field references one local `$defs` entry, so the typed
372
+ * auth handoff is stated once.
325
373
  */
326
374
  export const CATALOG_SEARCH_RESULT_SCHEMA = {
327
375
  type: "object",
@@ -360,6 +408,7 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
360
408
  name: { type: "string" },
361
409
  address: { type: "string" },
362
410
  description: { type: "string" },
411
+ descriptionTruncated: { const: true },
363
412
  inputSchema: {},
364
413
  outputSchema: {},
365
414
  signature: { type: "string" },
@@ -447,6 +496,7 @@ export class CatalogService {
447
496
  searchRoute;
448
497
  requestSignal;
449
498
  readOnly;
499
+ markUncallableWrites;
450
500
  // The request-scoped catalog cache: one shared read per connector asked
451
501
  // about, started by the first asker and joined by every later one. A
452
502
  // success stays while its in-process invalidation fence matches, so a search
@@ -471,6 +521,13 @@ export class CatalogService {
471
521
  // current by its own probes, so repeated searches read no storage.
472
522
  health = new Map();
473
523
  routes;
524
+ // This request's peeks: a stale listing is kept here, never in `catalogs`,
525
+ // because resolve reads `catalogs` and a call must never resolve through it.
526
+ peeks = new Map();
527
+ // At most one deferred refresh per connector per request.
528
+ refreshed = new Set();
529
+ refreshSlots;
530
+ unscopedProbeTimeoutMs;
474
531
  constructor(registry, baseUrl, options = {}) {
475
532
  this.registry = registry;
476
533
  this.baseUrl = baseUrl;
@@ -479,11 +536,13 @@ export class CatalogService {
479
536
  this.requestScope = options.requestScope ?? {};
480
537
  this.probeTimeoutMs = normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
481
538
  this.discoveryBudgetMs = normalizeTimeoutMs(options.discoveryBudgetMs) ?? UNSCOPED_DISCOVERY_BUDGET_MS;
539
+ this.unscopedProbeTimeoutMs = normalizeTimeoutMs(options.unscopedProbeTimeoutMs) ?? UNSCOPED_PROBE_TIMEOUT_MS;
482
540
  this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
483
541
  this.searchRoute = options.searchRoute ?? "search_tools";
484
542
  this.requestSignal = options.requestSignal;
485
543
  this.defer = options.defer;
486
544
  this.readOnly = options.readOnly === true;
545
+ this.markUncallableWrites = options.markUncallableWrites === true;
487
546
  }
488
547
  /**
489
548
  * Send a caller back to discovery through the surface it can actually reach.
@@ -533,6 +592,7 @@ export class CatalogService {
533
592
  timeoutMs: this.probeTimeoutMs,
534
593
  ...(this.defer ? { defer: this.defer } : {}),
535
594
  }).pipe(Effect.map((tools) => {
595
+ traceFor(this.requestScope)?.record("catalog.accepted", 0, utf8Length(JSON.stringify(tools)), 0, 0, tools.length);
536
596
  // An authored catalog lists its declared descriptions for this request's surface.
537
597
  const connector = this.registry.getConnector(id);
538
598
  return carryCatalogFreshness(tools, tools.map((tool) => {
@@ -565,10 +625,113 @@ export class CatalogService {
565
625
  let view = views.get(key);
566
626
  if (!view) {
567
627
  view = toolsForSafety(readOnly ? tools.filter((tool) => tool.classification === "read") : tools, safety ?? "all");
628
+ carryLexicalCatalog(tools, view);
629
+ const stale = catalogStaleness(tools);
630
+ if (stale)
631
+ markCatalogStale(view, stale);
568
632
  views.set(key, view);
569
633
  }
570
634
  return view;
571
635
  }
636
+ // One connector's cached catalog, read at most once per request and identity.
637
+ // Storage only (INV-10), under its own deadline and the request's signal. A
638
+ // read that fails or runs out of time is a miss: discovery never fails on it.
639
+ peek(id, timeoutMs) {
640
+ return withDeadlineEffect((signal) => this.joinPeek(id, signal), {
641
+ ...(this.requestSignal ? { signal: this.requestSignal } : {}),
642
+ timeoutMs,
643
+ timeoutError: new ConnectorCallError("timeout", `catalog peek of "${id}" timed out`),
644
+ }).pipe(Effect.catchCause(() => Effect.succeed(undefined)));
645
+ }
646
+ joinPeek(id, signal) {
647
+ return Effect.suspend(() => {
648
+ const identity = retainedCatalogIdentity(this.registry.contextFor(id, this.baseUrl, this.requestScope));
649
+ let retained = this.peeks.get(id);
650
+ if (!retained || retained.identity !== identity) {
651
+ const started = new SharedRead((signal) => registryProgram(this.registry, "peekCatalog", id, this.baseUrl, this.requestScope, { signal }).pipe(Effect.map((peek) => {
652
+ if (!peek)
653
+ return undefined;
654
+ const connector = this.registry.getConnector(id);
655
+ for (const tool of peek.tools)
656
+ routeToolInPlace(connector, tool, this.routes);
657
+ return peek;
658
+ })), (succeeded) => {
659
+ if (!succeeded && this.peeks.get(id)?.read === started)
660
+ this.peeks.delete(id);
661
+ }, this.requestScope);
662
+ retained = { identity, read: started };
663
+ this.peeks.set(id, retained);
664
+ }
665
+ return retained.read.join(signal);
666
+ });
667
+ }
668
+ /**
669
+ * A peek as this discovery's answer for a connector that did not. Whatever a
670
+ * listing would hide stays hidden: the view is filtered like a live one, and
671
+ * the mark is what makes search and native listing report it.
672
+ */
673
+ staleListing(id, peek, includeWrites, failure) {
674
+ const source = markCatalogStale(peek.tools.map((tool) => cloneTool(tool)), { ...failure, ageMs: Math.max(0, Date.now() - peek.fetchedAt) });
675
+ return this.searchView(id, source, includeWrites, "all");
676
+ }
677
+ /**
678
+ * Refresh a catalog whose probe ran out of time, in a request scope of its
679
+ * own under `defer`, the runtime's tail. Nothing here shares this request's
680
+ * signal, transports or reads, so nothing request-bound outlives the request
681
+ * (INV-7); the refresh owns its transport and closes it within its own
682
+ * deadline, which Workers `waitUntil` covers. It lists like any discovery:
683
+ * a partial walk publishes nothing (INV-8) and no authorization starts
684
+ * (INV-10). The health record's lease keeps other requests from starting
685
+ * another while this one runs, and its result settles that record.
686
+ *
687
+ * Every step answers to one clock, started as it is scheduled, so queueing,
688
+ * a slow claim and a slow settlement spend the same tail. Waiting on storage
689
+ * stops at its limit; a write already sent is left to land or to lapse.
690
+ */
691
+ refreshLater(id, slot, spentMs) {
692
+ const tailMs = Math.min(this.probeTimeoutMs, CATALOG_REFRESH_TIMEOUT_MS);
693
+ const closeMs = Math.min(REFRESH_CLOSE_MS, Math.floor(tailMs / 4));
694
+ const settleMs = Math.min(REFRESH_SETTLE_MS, Math.floor(tailMs / 4));
695
+ if (!this.defer || this.refreshed.has(id) || tailMs - closeMs - settleMs <= spentMs)
696
+ return;
697
+ this.refreshed.add(id);
698
+ const scope = {};
699
+ const connector = this.registry.getConnector(id);
700
+ const endsAt = Date.now() + tailMs;
701
+ const listEnd = endsAt - closeMs - settleMs;
702
+ const timeoutError = new ConnectorCallError("timeout", `catalog refresh of "${id}" timed out`);
703
+ const until = (limit, storage, otherwise) => Effect.suspend(() => limit <= Date.now()
704
+ ? Effect.succeed(otherwise)
705
+ : withDeadlineEffect(() => storage, { timeoutMs: limit - Date.now(), timeoutError }).pipe(Effect.catchCause(() => Effect.succeed(otherwise))));
706
+ const refresh = Effect.gen({ self: this }, function* () {
707
+ const claimed = yield* until(listEnd, claimDiscoveryProbe(slot, endsAt + CLAIM_MARGIN_MS), false);
708
+ // Only what the claim left: a claim that spent it all dispatches nothing,
709
+ // and its lease lapses by itself.
710
+ const timeoutMs = listEnd - Date.now();
711
+ if (!claimed || !connector || timeoutMs <= 0)
712
+ return;
713
+ const started = Date.now();
714
+ const result = yield* Effect.result(withDeadlineEffect((signal) => registryProgram(this.registry, "getTools", id, this.baseUrl, scope, {
715
+ signal,
716
+ timeoutMs,
717
+ ...(this.defer ? { defer: this.defer } : {}),
718
+ }), { timeoutMs, timeoutError }));
719
+ yield* until(endsAt - closeMs, settleDiscoveryHealth(slot, Result.isSuccess(result)
720
+ ? { ok: true }
721
+ : {
722
+ ok: false,
723
+ failure: result.failure,
724
+ timedOut: result.failure === timeoutError,
725
+ elapsedMs: Date.now() - started,
726
+ }), undefined);
727
+ });
728
+ this.refreshSlots ??= Semaphore.makeUnsafe(this.concurrency);
729
+ detach(this.refreshSlots
730
+ .withPermits(1)(refresh)
731
+ .pipe(Effect.ensuring(Effect.suspend(() => connector
732
+ ? closeScope(connector, this.registry.contextFor(id, this.baseUrl, scope), this.defer)
733
+ : Effect.void))), { waitUntil: this.defer });
734
+ }
572
735
  // Every named catalog under its own probe deadline, `concurrency` at a time,
573
736
  // each settling in its input slot: an unavailable catalog is a Failure
574
737
  // there, not a failed discovery. `route` names the surface in a timeout.
@@ -588,11 +751,22 @@ export class CatalogService {
588
751
  // it, then runs to its end. A lapsed record is claimed as its probe is
589
752
  // dispatched, leased to the end of this budget plus a margin.
590
753
  // Caller cancellation and a probe the budget cut short leave health as it was.
591
- searchCatalogs(ids, scoped) {
754
+ //
755
+ // Unscoped search and a native listing also serve a connector's last complete
756
+ // catalog, marked stale, when it did not answer in time. A cached or authored
757
+ // catalog is read without a probe slot, so only a connector that must actually
758
+ // be asked waits for one. A native listing has no budget and gives a connector
759
+ // with nothing to fall back on its full deadline, since it would otherwise vanish.
760
+ searchCatalogs(ids, mode, includeWrites = false) {
592
761
  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));
762
+ const scoped = mode === "scoped";
763
+ const shortDeadline = Math.min(this.probeTimeoutMs, this.unscopedProbeTimeoutMs);
764
+ const budgeted = mode === "unscoped";
765
+ const route = mode === "native" ? "tools/list" : this.searchRoute;
766
+ const budgetEnd = budgeted ? Date.now() + this.discoveryBudgetMs : Infinity;
767
+ const settleReserve = budgeted ? Math.min(SETTLE_RESERVE_MS, Math.floor(this.discoveryBudgetMs / 10)) : 0;
768
+ const slots = yield* Semaphore.make(this.concurrency);
769
+ const peekSlots = yield* Semaphore.make(CATALOG_PEEK_CONCURRENCY);
596
770
  // Health storage starts only while budget remains and the caller is
597
771
  // here; running out is not a failure, health is then unknown. Once
598
772
  // started, an operation is awaited to its end, never raced or detached:
@@ -624,60 +798,142 @@ export class CatalogService {
624
798
  });
625
799
  };
626
800
  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())
801
+ const connector = this.registry.getConnector(id);
802
+ // An authored catalog answers from configuration: no slot, no budget, no health.
803
+ if (!scoped && connector?.staticTools)
630
804
  return {
631
805
  id,
632
- result: Result.fail(new DiscoverySkipped(skip.code, Math.ceil(skip.retryAt - Date.now()))),
806
+ result: yield* Effect.result(this.probe(id, includeWrites, this.probeTimeoutMs, new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`))),
633
807
  };
808
+ // Storage only, so a connector with a current catalog never queues
809
+ // behind one that must be asked. A custom connector is always listed
810
+ // anew and starts no cache I/O here; it peeks only if that fails.
811
+ let peek = !scoped && connector?.peekCatalog && Date.now() < budgetEnd
812
+ ? yield* peekSlots.withPermits(1)(this.peek(id, Math.max(shortDeadline, STALE_READ_TIMEOUT_MS)))
813
+ : undefined;
814
+ const verdict = scoped ? undefined : this.health.get(id);
815
+ const skip = verdict?.skip;
816
+ // A peek is only as good as the moment it was read. After any wait for
817
+ // a slot or a probe it is read again, through every acceptance fence,
818
+ // so an invalidation, a rotated credential or a refusal in the
819
+ // meantime retires it, in this isolate or another.
820
+ const staleAnswer = (failure, waited = true) => Effect.gen({ self: this }, function* () {
821
+ // Caller cancellation is not a connector that failed to answer.
822
+ if (scoped || this.requestSignal?.aborted)
823
+ return undefined;
824
+ if (waited)
825
+ this.peeks.delete(id);
826
+ if (waited || !peek)
827
+ peek = yield* this.peek(id, STALE_READ_TIMEOUT_MS);
828
+ return peek ? this.staleListing(id, peek, includeWrites, failure) : undefined;
829
+ });
830
+ if (skip && skip.retryAt > Date.now() && !peek?.fresh) {
831
+ const retryAfterMs = Math.ceil(skip.retryAt - Date.now());
832
+ // Discovery health records only timeouts and retryable failures.
833
+ if (staleListingAllowed(skip.code, true)) {
834
+ const stale = yield* staleAnswer({ code: skip.code, retryAfterMs }, false);
835
+ if (stale)
836
+ return { id, result: Result.succeed(stale) };
837
+ }
838
+ // A native listing has no search to come back through: it still asks.
839
+ if (mode !== "native")
840
+ return { id, result: Result.fail(new DiscoverySkipped(skip.code, retryAfterMs)) };
841
+ }
842
+ // With something to fall back on a short deadline is enough. Without
843
+ // it, a native listing waits its full deadline, as the connector
844
+ // would otherwise be absent; unscoped search keeps its short one.
845
+ const deadline = scoped || (mode === "native" && !peek) ? this.probeTimeoutMs : shortDeadline;
634
846
  const timeoutFor = (remaining) => {
635
847
  const budgetCut = remaining < deadline;
636
848
  return {
637
849
  budgetCut,
638
850
  timeoutMs: budgetCut ? remaining : deadline,
639
851
  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`),
852
+ ? new ConnectorCallError("timeout", `${route} stopped probing "${id}" at the ${this.discoveryBudgetMs}ms discovery budget`)
853
+ : new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${deadline}ms`),
642
854
  };
643
855
  };
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()));
856
+ const asked = Effect.gen({ self: this }, function* () {
857
+ // A current catalog is a storage read in hand, not a probe the budget has to bound.
858
+ const remaining = () => (peek?.fresh ? Infinity : Math.ceil(budgetEnd - settleReserve - Date.now()));
859
+ let plan = timeoutFor(remaining());
663
860
  if (plan.timeoutMs <= 0)
664
- return { id, result: Result.fail(plan.timeoutError) };
861
+ return { result: Result.fail(plan.timeoutError), plan };
862
+ // A lapsed record: claim this probe now, as it is dispatched. The
863
+ // lease runs to an absolute time, the end of this request's
864
+ // discovery budget plus a margin, so however long the claim write
865
+ // takes, the probe the budget still allows ends inside it.
866
+ if (budgeted && skip && verdict?.slot && !peek?.fresh) {
867
+ const leaseUntil = budgetEnd + CLAIM_MARGIN_MS;
868
+ const claimed = yield* withinBudget(claimDiscoveryProbe(verdict.slot, leaseUntil), false);
869
+ if (!claimed) {
870
+ this.health.set(id, { slot: verdict.slot, skip: { code: skip.code, retryAt: leaseUntil } });
871
+ return {
872
+ result: Result.fail(new DiscoverySkipped(skip.code, Math.max(1, Math.ceil(leaseUntil - Date.now())))),
873
+ plan,
874
+ };
875
+ }
876
+ this.health.set(id, { slot: verdict.slot });
877
+ plan = timeoutFor(remaining());
878
+ if (plan.timeoutMs <= 0)
879
+ return { result: Result.fail(plan.timeoutError), plan };
880
+ }
881
+ const started = Date.now();
882
+ const result = yield* Effect.result(this.probe(id, includeWrites, plan.timeoutMs, plan.timeoutError));
883
+ // Elapsed time is this probe's own, measured as it settles.
884
+ return { result, plan, elapsedMs: Date.now() - started };
885
+ });
886
+ // A current catalog's read takes no slot: it hands the peek to the listing.
887
+ const { result, plan, elapsedMs } = (peek?.fresh ? yield* asked : yield* slots.withPermits(1)(asked));
888
+ if (elapsedMs === undefined) {
889
+ if (Result.isFailure(result)) {
890
+ const failure = result.failure;
891
+ const stale = yield* staleAnswer(failure instanceof DiscoverySkipped
892
+ ? { code: failure.code, retryAfterMs: failure.retryAfterMs }
893
+ : { code: "timeout" });
894
+ if (stale)
895
+ return { id, result: Result.succeed(stale) };
896
+ }
897
+ return { id, result };
665
898
  }
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
899
  const outcome = plan.budgetCut && Result.isFailure(result) && result.failure === plan.timeoutError
671
900
  ? { id, result }
672
901
  : { id, result, settle: { timeoutError: plan.timeoutError, elapsedMs } };
673
902
  if (scoped)
674
903
  return outcome;
675
- yield* settleOne(outcome);
904
+ // A catalog served from cache says nothing about a recorded failure.
905
+ if (!(skip && peek?.fresh))
906
+ yield* settleOne(outcome);
907
+ if (Result.isSuccess(result))
908
+ return { id, result };
909
+ const failure = result.failure;
910
+ // A probe that ran out of time: its abort left nothing cached, so ask
911
+ // again past this request, under the longer deadline the runtime's tail allows.
912
+ if (failure === plan.timeoutError && !plan.budgetCut) {
913
+ // Only the request whose settlement recorded this: anyone else lost
914
+ // to a record that already has an owner, or wrote nothing at all.
915
+ const recorded = this.health.get(id);
916
+ if (recorded?.slot && recorded.recorded)
917
+ this.refreshLater(id, recorded.slot, plan.timeoutMs);
918
+ }
919
+ const classified = failure === plan.timeoutError
920
+ ? { code: "timeout", retryable: true }
921
+ : classifyCallError(failure, "catalog_lookup_failed");
922
+ if (staleListingAllowed(classified.code, classified.retryable)) {
923
+ const stale = yield* staleAnswer({
924
+ code: classified.code,
925
+ ...(classified.retryAfterMs === undefined
926
+ ? {}
927
+ : { retryAfterMs: classified.retryAfterMs, downstreamWait: true }),
928
+ });
929
+ if (stale)
930
+ return { id, result: Result.succeed(stale) };
931
+ }
676
932
  return { id, result };
677
933
  });
678
934
  if (!scoped) {
679
935
  yield* readHealth;
680
- const probed = yield* Effect.forEach(ids, probeOne, { concurrency: this.concurrency });
936
+ const probed = yield* Effect.forEach(ids, probeOne, { concurrency: "unbounded" });
681
937
  return probed.map((outcome) => outcome.result);
682
938
  }
683
939
  const [, probed] = yield* Effect.all([readHealth, Effect.forEach(ids, probeOne, { concurrency: this.concurrency })], { concurrency: 2 });
@@ -694,13 +950,20 @@ export class CatalogService {
694
950
  * settles on its own, so one unavailable connector is an `error` entry
695
951
  * beside the others rather than a failed listing.
696
952
  */
697
- listCatalogs(includeWrites = false, connectors = this.registry.listConnectors()) {
698
- return runEdge(this.listCatalogsEffect(includeWrites, connectors));
953
+ listCatalogs(includeWrites = false, connectors = this.registry.listConnectors(), allowStale = false) {
954
+ return runEdge(this.listCatalogsEffect(includeWrites, connectors, allowStale));
699
955
  }
700
- listCatalogsEffect(includeWrites = false, connectors) {
956
+ /**
957
+ * `allowStale` is for a listing only. Resolving a native call must not pass
958
+ * it: uniqueness and authority come from a catalog that answered.
959
+ */
960
+ listCatalogsEffect(includeWrites = false, connectors, allowStale = false) {
701
961
  return duringViewEffect({ requestScope: this.requestScope, signal: this.requestSignal }, () => Effect.suspend(() => {
702
962
  const visible = connectors ?? this.registry.listConnectors();
703
- return Effect.map(this.discoveryCatalogs(visible.map((connector) => connector.id), "tools/list", includeWrites), (results) => results.map((result, index) => {
963
+ const ids = visible.map((connector) => connector.id);
964
+ return Effect.map(allowStale
965
+ ? this.searchCatalogs(ids, "native", includeWrites)
966
+ : this.discoveryCatalogs(ids, "tools/list", includeWrites), (results) => results.map((result, index) => {
704
967
  const connector = visible[index];
705
968
  return Result.isSuccess(result)
706
969
  ? { connector, tools: carryCatalogFreshness(result.success, result.success.map(cloneTool)) }
@@ -735,7 +998,10 @@ export class CatalogService {
735
998
  cause,
736
999
  };
737
1000
  }
738
- unknownToolFailure(toolName, connector, started) {
1001
+ unknownToolFailure(toolName, connector, started, tools) {
1002
+ // The same view discovery serves: a read-only scope never names a write here.
1003
+ const visible = this.readOnly || connector.readOnly === true ? tools.filter((tool) => tool.classification === "read") : tools;
1004
+ const suggestions = hasControlCharacters(toolName) ? [] : describeSuggestions(connector.id, toolName, visible);
739
1005
  return {
740
1006
  ok: false,
741
1007
  error: {
@@ -743,6 +1009,7 @@ export class CatalogService {
743
1009
  ? `Unknown tool on connector "${connector.id}"`
744
1010
  : `Unknown tool "${boundedEchoText(toolName)}" on connector "${connector.id}"`),
745
1011
  nextAction: this.searchRecovery({ query: hasControlCharacters(toolName) ? "" : recoveryQuery(toolName), connector: connector.id }, "Find the connector's current canonical tool address."),
1012
+ ...(suggestions.length > 0 ? { suggestions } : {}),
746
1013
  },
747
1014
  catalogMs: Date.now() - started,
748
1015
  connector,
@@ -768,7 +1035,7 @@ export class CatalogService {
768
1035
  onSuccess: (tools) => {
769
1036
  const definition = tools.find((tool) => tool.name === toolName);
770
1037
  if (!definition) {
771
- return this.unknownToolFailure(toolName, connector, started);
1038
+ return this.unknownToolFailure(toolName, connector, started, tools);
772
1039
  }
773
1040
  return {
774
1041
  ok: true,
@@ -793,6 +1060,9 @@ export class CatalogService {
793
1060
  message: `Connector "${connector.id}" was skipped because its catalog lookup failed recently (${failure.code}). Search with connector "${connector.id}" to try it now.`,
794
1061
  retryable: true,
795
1062
  retryAfterMs: failure.retryAfterMs,
1063
+ ...(["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(failure.code)
1064
+ ? { nextAction: utilityRecovery(failure.code, "Catalog lookup is unavailable.").nextAction }
1065
+ : {}),
796
1066
  };
797
1067
  }
798
1068
  const classified = classifyCallError(failure, "catalog_lookup_failed");
@@ -830,6 +1100,9 @@ export class CatalogService {
830
1100
  error.retry =
831
1101
  "Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Reconnecting alone will not grant access.";
832
1102
  }
1103
+ if (["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(error.code)) {
1104
+ error.nextAction = utilityRecovery(error.code, error.message).nextAction;
1105
+ }
833
1106
  return yield* withAuthorizationHandoffEffect(this.requestScope, error);
834
1107
  });
835
1108
  }
@@ -876,8 +1149,23 @@ export class CatalogService {
876
1149
  : undefined;
877
1150
  const connectors = absence ? [] : args.connector ? (scopedConnector ? [scopedConnector] : []) : visibleConnectors;
878
1151
  // Timeout messages name the route the caller used.
879
- const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined);
880
- const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => this.searchView(connectors[index].id, tools, true, safety)));
1152
+ const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined ? "scoped" : "unscoped");
1153
+ const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => {
1154
+ const id = connectors[index].id;
1155
+ const ctx = this.registry.contextFor(id, this.baseUrl, this.requestScope);
1156
+ const { storage, partition } = catalogOwnerPartition(ctx);
1157
+ let store = lexicalStores.get(storage);
1158
+ if (store === undefined)
1159
+ lexicalStores.set(storage, (store = ++nextLexicalStore));
1160
+ prepareLexicalCatalog(tools, JSON.stringify([
1161
+ store,
1162
+ id,
1163
+ partition,
1164
+ retainedCatalogIdentity(ctx),
1165
+ catalogGenerationFor(this.requestScope, id),
1166
+ ]));
1167
+ return this.searchView(id, tools, true, safety);
1168
+ }));
881
1169
  const matches = [];
882
1170
  let matchMode = "all";
883
1171
  // Provider-declared synonyms widen term matching within their own catalog only.
@@ -951,21 +1239,36 @@ export class CatalogService {
951
1239
  order: original.success.indexOf(tool),
952
1240
  exactName: false,
953
1241
  matchedTermCount: toolTermCount,
1242
+ headTerms: [],
1243
+ nameTerms: [],
1244
+ namesDistinctiveTerm: false,
954
1245
  });
955
1246
  }
956
1247
  }
1248
+ // A tool on the named connector need not repeat that connector's name.
1249
+ const statedTerms = args.connector || exactConnector ? toolTerms.filter((term) => !connectorTerms.has(term)) : toolTerms;
1250
+ const requiredTerms = statedTerms.length > 0 ? statedTerms : toolTerms;
957
1251
  for (const ranked of rankedTools) {
1252
+ const complete = isBrowse || requiredTerms.every((term) => ranked.headTerms.includes(term));
1253
+ const vocabulary = vocabularyTools.has(ranked.tool);
1254
+ // Discovery pointers follow every tool that answers lexically.
1255
+ const vocabularyRank = complete ? 0 : vocabularyScore(requiredTerms, statistics);
958
1256
  collected.push({
959
1257
  connector,
960
1258
  tool: ranked.tool,
961
- score: vocabularyTools.has(ranked.tool) && !ranked.exactName ? 0 : ranked.score,
1259
+ score: vocabulary && !ranked.exactName ? vocabularyRank : ranked.score,
962
1260
  order: orderBase + ranked.order,
963
- exactName: ranked.exactName,
1261
+ // A tool named just `search` is not what a longer query containing that verb names.
1262
+ exactName: ranked.exactName && !ACTION_WORD.test(ranked.tool.name.toLowerCase()),
964
1263
  exactLookup: rawPhrase === lexicalQueryTerms(ranked.tool.name).join(" ") ||
965
1264
  rawPhrase === lexicalQueryTerms(`${connector.id}.${ranked.tool.name}`).join(" "),
966
1265
  connectorRank,
967
1266
  matchedTermCount: vocabularyTools.has(ranked.tool) ? toolTermCount : ranked.matchedTermCount,
968
- complete: isBrowse || vocabularyTools.has(ranked.tool) || ranked.matchedTermCount === toolTermCount,
1267
+ complete,
1268
+ covered: isBrowse || vocabulary || ranked.matchedTermCount === toolTermCount,
1269
+ vocabulary,
1270
+ namesSubject: ranked.nameTerms.some((term) => !ACTION_WORD.test(term) && !connectorTerms.has(term)),
1271
+ namesDistinctiveTerm: ranked.namesDistinctiveTerm,
969
1272
  });
970
1273
  }
971
1274
  }
@@ -978,12 +1281,13 @@ export class CatalogService {
978
1281
  // unrelated zero-score match, with no coverage to explain the result.
979
1282
  const rankedMatches = unsearchableQuery ? [] : collectMatches(isBrowse ? "all" : "partial");
980
1283
  const completeMatchCount = rankedMatches.filter((match) => match.complete).length;
981
- matches.push(...rankedMatches.filter((match) => match.complete ||
1284
+ matches.push(...rankedMatches.filter((match) => match.covered ||
1285
+ match.namesDistinctiveTerm ||
982
1286
  completeMatchCount === 0 ||
983
1287
  match.exactName ||
984
1288
  match.connectorRank > 0 ||
985
1289
  match.matchedTermCount >= 2));
986
- if (!isBrowse && !unsearchableQuery && completeMatchCount === 0) {
1290
+ if (!isBrowse && !unsearchableQuery && !rankedMatches.some((match) => match.covered)) {
987
1291
  matchMode = "partial";
988
1292
  }
989
1293
  // A read-intent verb leads the query once connector names are set aside.
@@ -994,13 +1298,15 @@ export class CatalogService {
994
1298
  // longer name such as get_issue_status cannot beat an exact get_issue
995
1299
  // merely because its description repeats rare query terms. Within a
996
1300
  // tier, a read-intent query puts reads before writes.
1301
+ // A hatch with a matching operation stays above partial matches, as does a
1302
+ // tool that names the query's subject and states the rest in its tail. A
1303
+ // tail alone earns no tier: long prose can mention anything.
1304
+ const tier = (match) => match.complete || match.exactName ? 2 : match.vocabulary || (match.covered && match.namesSubject) ? 1 : 0;
997
1305
  matches.sort((a, b) => {
998
- const aFirstTier = a.complete || a.exactName;
999
- const bFirstTier = b.complete || b.exactName;
1000
1306
  return (Number(b.exactLookup) - Number(a.exactLookup) ||
1001
1307
  Number(b.exactName) - Number(a.exactName) ||
1002
1308
  b.connectorRank - a.connectorRank ||
1003
- Number(bFirstTier) - Number(aFirstTier) ||
1309
+ tier(b) - tier(a) ||
1004
1310
  readFirst(b) - readFirst(a) ||
1005
1311
  b.score - a.score ||
1006
1312
  a.order - b.order);
@@ -1029,7 +1335,10 @@ export class CatalogService {
1029
1335
  ? renderSearchSchema(output.schema, schemaFormat)
1030
1336
  : undefined;
1031
1337
  const schemaKeys = withSchemas && args.includeSchemaKeys ? schemaKeyMetadata(input, output.schema) : undefined;
1032
- const description = summarizeDiscoveryDescription(match.tool.description, args.fullDescriptions === true);
1338
+ const summary = match.tool.description && args.fullDescriptions !== true
1339
+ ? discoverySummary(match.tool.description)
1340
+ : undefined;
1341
+ const description = summary ? summary.text : match.tool.description || undefined;
1033
1342
  const requiredReasons = guideRequiredReasons(match.connector, match.tool, renderedInput?.truncated === true || renderedOutput?.truncated === true);
1034
1343
  const guideSummary = connectorGuideSummary(match.connector);
1035
1344
  return {
@@ -1044,6 +1353,7 @@ export class CatalogService {
1044
1353
  name: match.tool.name,
1045
1354
  address: `${match.connector.id}.${match.tool.name}`,
1046
1355
  ...(description !== undefined ? { description } : {}),
1356
+ ...(summary?.truncated ? { descriptionTruncated: true } : {}),
1047
1357
  ...(schemaFormat
1048
1358
  ? {
1049
1359
  inputSchema: renderedInput?.schema,
@@ -1068,6 +1378,7 @@ export class CatalogService {
1068
1378
  ...(schemaKeys?.outputKeys && !renderedOutput?.truncated ? { outputKeys: schemaKeys.outputKeys } : {}),
1069
1379
  ...(match.tool.annotations ? { annotations: match.tool.annotations } : {}),
1070
1380
  classification: match.tool.classification,
1381
+ ...(this.markUncallableWrites && readProgramRefuses(match.tool) ? { callable: false } : {}),
1071
1382
  ...(requiredReasons
1072
1383
  ? {
1073
1384
  guideRequired: true,
@@ -1134,14 +1445,19 @@ export class CatalogService {
1134
1445
  }
1135
1446
  }
1136
1447
  const unavailableCatalogs = catalogs.filter(Result.isFailure).length;
1137
- const catalogErrors = (yield* Effect.forEach(catalogs, (catalog, index) => Result.isSuccess(catalog)
1138
- ? Effect.succeed([])
1139
- : this.catalogFailure(connectors[index], catalog.failure).pipe(Effect.map((error) => [error])), { concurrency: "unbounded" })).flat();
1448
+ const staleCatalogs = catalogs.filter((catalog) => Result.isSuccess(catalog) && catalogStaleness(catalog.success)).length;
1449
+ const catalogErrors = (yield* Effect.forEach(catalogs, (catalog, index) => {
1450
+ if (Result.isFailure(catalog))
1451
+ return this.catalogFailure(connectors[index], catalog.failure).pipe(Effect.map((error) => [error]));
1452
+ const stale = catalogStaleness(catalog.success);
1453
+ return Effect.succeed(stale ? [staleCatalogFailure(connectors[index].id, stale)] : []);
1454
+ }, { concurrency: "unbounded" })).flat();
1140
1455
  const needsAuth = (error) => error.code === "auth_required" ||
1141
1456
  error.code === "downstream_oauth_required" ||
1142
1457
  error.code === "provider_permission_denied";
1143
1458
  catalogErrors.sort((a, b) => Number(needsAuth(b)) - Number(needsAuth(a)));
1144
- const scopedFailure = args.connector ? catalogErrors[0] : undefined;
1459
+ // A stale row beside listed tools is not this scoped search's failure.
1460
+ const scopedFailure = args.connector ? catalogErrors.find((error) => !error.stale) : undefined;
1145
1461
  const scopedCatalogError = scopedFailure
1146
1462
  ? scopedFailure.code === "auth_required" || scopedFailure.code === "downstream_oauth_required"
1147
1463
  ? scopedFailure
@@ -1211,6 +1527,7 @@ export class CatalogService {
1211
1527
  (unavailableCatalogs
1212
1528
  ? ` ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable; inspect catalogErrors.`
1213
1529
  : "") +
1530
+ (staleCatalogs ? ` ${staleListedText(staleCatalogs)}` : "") +
1214
1531
  filterRecovery;
1215
1532
  // A scope that resolved to nothing is the same silence one step earlier in
1216
1533
  // the lookup: no connector resolved, so no catalog was even attempted, so
@@ -1245,7 +1562,7 @@ export class CatalogService {
1245
1562
  }
1246
1563
  if (queryTerms.length === 0) {
1247
1564
  if (unavailableCatalogs === 0)
1248
- return undefined;
1565
+ return staleCatalogs ? staleListedText(staleCatalogs) : undefined;
1249
1566
  return scopedConnector
1250
1567
  ? `Connector "${scopedConnector.id}" could not be browsed because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
1251
1568
  : `${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable, so this browse is incomplete. Inspect catalogErrors for the typed reasons and recovery actions.`;
@@ -1264,8 +1581,10 @@ export class CatalogService {
1264
1581
  if (identityGuidance || apiSearchGuidance)
1265
1582
  return ([identityGuidance, apiSearchGuidance].filter(Boolean).join(" ") +
1266
1583
  (apiSearchGuidance ? "" : filterRecovery));
1584
+ if (unavailableCatalogs === 0 && staleCatalogs)
1585
+ return `No matching ${safetyLabel}capability was found. ${staleListedText(staleCatalogs)} Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`;
1267
1586
  return unavailableCatalogs === 0
1268
- ? `No matching ${safetyLabel}capability is configured in this deployment. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`
1587
+ ? `No ${safetyLabel}tool matched these terms. Try other terms, scope by connector, or browse with an empty query.${filterRecovery}`
1269
1588
  : `No matching ${safetyLabel}capability was found in the catalogs that answered; ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`;
1270
1589
  }
1271
1590
  if (matchMode !== "partial")
@@ -1372,6 +1691,10 @@ export class CatalogService {
1372
1691
  : undefined;
1373
1692
  return [id, error];
1374
1693
  }), { concurrency: "unbounded" }));
1694
+ // Full prose shares one budget in address order, so a large batch keeps its
1695
+ // schemas and a repeated describe renders the same text. A fallback summary
1696
+ // spends none of it: every entry keeps its purpose, within 480 bytes each.
1697
+ let descriptionBytes = MAX_DESCRIBE_DESCRIPTION_BYTES;
1375
1698
  return resolved.map(({ address, resolved: addressResolution }) => {
1376
1699
  if (!addressResolution) {
1377
1700
  const message = `Unknown address "${boundedEchoText(address)}"`;
@@ -1424,7 +1747,12 @@ export class CatalogService {
1424
1747
  const tool = cloneTool(selected);
1425
1748
  const input = tool.inputSchema ?? { type: "object" };
1426
1749
  const output = this.outputSchema(addressResolution.connector.id, tool);
1427
- const description = summarizeDescription(tool.description, args.fullDescriptions === true);
1750
+ const bytes = tool.description ? encoder.encode(tool.description).length : 0;
1751
+ const withinBudget = args.fullDescriptions === true || bytes <= descriptionBytes;
1752
+ if (withinBudget)
1753
+ descriptionBytes -= bytes;
1754
+ const summary = withinBudget || !tool.description ? undefined : discoverySummary(tool.description);
1755
+ const description = summary ? summary.text : tool.description || undefined;
1428
1756
  const signature = format === "typescript"
1429
1757
  ? typescriptSignature(input, output.schema, {
1430
1758
  observed: output.source === "observed",
@@ -1442,6 +1770,7 @@ export class CatalogService {
1442
1770
  schemaFormat: format === "json" ? "json" : "text",
1443
1771
  name: tool.name,
1444
1772
  ...(description !== undefined ? { description } : {}),
1773
+ ...(summary?.truncated ? { descriptionTruncated: true } : {}),
1445
1774
  ...(connectorGuide(addressResolution.connector)
1446
1775
  ? {
1447
1776
  guide: connectorSkillName(addressResolution.connector.id),
@@ -1465,6 +1794,7 @@ export class CatalogService {
1465
1794
  ...(output.source ? { outputSchemaSource: output.source } : {}),
1466
1795
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
1467
1796
  classification: tool.classification,
1797
+ ...(this.markUncallableWrites && readProgramRefuses(tool) ? { callable: false } : {}),
1468
1798
  };
1469
1799
  });
1470
1800
  });