@zackbart/connecta 0.28.1 → 0.29.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 (319) hide show
  1. package/CHANGELOG.md +788 -5
  2. package/README.md +62 -42
  3. package/bin/connecta.mjs +110 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  12. package/dist/auth/clerk-sdk/client.js +5 -0
  13. package/dist/auth/clerk-sdk/generated.js +1845 -0
  14. package/dist/auth/clerk-transport.js +19 -0
  15. package/dist/auth/clerk.d.ts +17 -7
  16. package/dist/auth/clerk.js +189 -107
  17. package/dist/auth/cloudflare-access.js +25 -26
  18. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  19. package/dist/auth/downstream-client-metadata.js +133 -0
  20. package/dist/auth/downstream-oauth.js +1584 -1580
  21. package/dist/auth/downstream-revocation.js +106 -0
  22. package/dist/auth/oauth-token-response.js +231 -0
  23. package/dist/auth/oauth-v2-migration.js +116 -0
  24. package/dist/auth/static-oauth.js +93 -71
  25. package/dist/auth-elicitation.js +283 -0
  26. package/dist/branding.d.ts +8 -16
  27. package/dist/branding.js +26 -59
  28. package/dist/byte-read-response.js +39 -0
  29. package/dist/call-admission.js +12 -16
  30. package/dist/catalog-cache.js +739 -0
  31. package/dist/catalog-drift.d.ts +50 -15
  32. package/dist/catalog-drift.js +365 -128
  33. package/dist/catalog-fingerprint.js +2 -4
  34. package/dist/catalog-freshness.js +13 -0
  35. package/dist/catalog-limits.js +2 -2
  36. package/dist/catalog-service.js +368 -224
  37. package/dist/catalog.js +43 -84
  38. package/dist/concurrency.js +3 -4
  39. package/dist/config-defaults.d.ts +39 -0
  40. package/dist/config-defaults.js +46 -0
  41. package/dist/config-schema.d.ts +181 -0
  42. package/dist/config-schema.js +435 -0
  43. package/dist/config-value-sources.js +102 -0
  44. package/dist/config.d.ts +631 -0
  45. package/dist/config.js +503 -0
  46. package/dist/connector-access.d.ts +1 -0
  47. package/dist/connector-access.js +13 -15
  48. package/dist/connector-caller.d.ts +8 -0
  49. package/dist/connector-caller.js +15 -6
  50. package/dist/connector-scope.js +1 -1
  51. package/dist/connectors/api-connector.d.ts +59 -7
  52. package/dist/connectors/api-connector.js +106 -19
  53. package/dist/connectors/api.js +4 -1
  54. package/dist/connectors/guarded-fetch.d.ts +144 -0
  55. package/dist/connectors/guarded-fetch.js +84 -35
  56. package/dist/connectors/negotiation-cache.js +46 -0
  57. package/dist/connectors/option-shapes.js +27 -0
  58. package/dist/connectors/remote-mcp.d.ts +30 -4
  59. package/dist/connectors/remote-mcp.js +1774 -320
  60. package/dist/connectors/resource-uri.js +335 -0
  61. package/dist/credential-contract.d.ts +10 -0
  62. package/dist/credential-rules.js +1 -3
  63. package/dist/credential-url.js +108 -0
  64. package/dist/credentials.d.ts +6 -1
  65. package/dist/credentials.js +27 -18
  66. package/dist/d1.d.ts +130 -0
  67. package/dist/d1.js +290 -0
  68. package/dist/describe-config.d.ts +165 -0
  69. package/dist/describe-config.js +315 -0
  70. package/dist/described.js +106 -0
  71. package/dist/downstream-input-context.js +119 -0
  72. package/dist/downstream-input.js +323 -0
  73. package/dist/errors.d.ts +97 -20
  74. package/dist/errors.js +158 -80
  75. package/dist/execute.js +453 -305
  76. package/dist/executor-admission.d.ts +1 -0
  77. package/dist/executor-admission.js +11 -15
  78. package/dist/executor-contract.d.ts +20 -0
  79. package/dist/executor-contract.js +48 -0
  80. package/dist/executor-result.js +24 -9
  81. package/dist/executors/quickjs-child.js +3 -4
  82. package/dist/executors/quickjs-runtime.js +147 -59
  83. package/dist/executors/quickjs.js +29 -35
  84. package/dist/favicon.js +1 -2
  85. package/dist/fix-prompt.js +1 -3
  86. package/dist/guest-runtime.js +188 -0
  87. package/dist/guest-types.d.ts +176 -0
  88. package/dist/guest-types.js +1 -0
  89. package/dist/html-security.js +24 -0
  90. package/dist/identity.js +1 -3
  91. package/dist/inbound-credential.js +52 -0
  92. package/dist/index.d.ts +24 -256
  93. package/dist/index.js +65 -478
  94. package/dist/invocation-auth.js +69 -0
  95. package/dist/invocation.js +230 -73
  96. package/dist/json-schema.d.ts +1 -1
  97. package/dist/mcp-client-context.js +35 -0
  98. package/dist/mcp-result.js +11 -7
  99. package/dist/meta-output.js +76 -0
  100. package/dist/meta-tool-names.d.ts +2 -0
  101. package/dist/meta-tool-names.js +3 -0
  102. package/dist/meta-tools.js +285 -209
  103. package/dist/module-contracts.d.ts +8 -23
  104. package/dist/node.d.ts +0 -2
  105. package/dist/node.js +2 -2
  106. package/dist/oauth-callback-outcome.js +1 -3
  107. package/dist/oauth-handoff.js +117 -0
  108. package/dist/oauth-sealing.js +20 -0
  109. package/dist/operator-record.d.ts +137 -0
  110. package/dist/operator-record.js +420 -0
  111. package/dist/operator-ui/assets.js +31 -0
  112. package/dist/operator-ui/contract.d.ts +72 -0
  113. package/dist/operator-ui/contract.js +1 -0
  114. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  115. package/dist/operator-ui/generated.js +5 -4
  116. package/dist/operator-ui/model.d.ts +12 -87
  117. package/dist/operator-ui/model.js +2 -9
  118. package/dist/operator-ui/setup-commands.js +1 -3
  119. package/dist/operator-ui/view.d.ts +323 -0
  120. package/dist/operator-ui/view.js +62 -189
  121. package/dist/page-styles.js +2 -3
  122. package/dist/program-source.js +14 -2
  123. package/dist/{exempt-writes.js → program-writes.js} +55 -18
  124. package/dist/provider.d.ts +142 -0
  125. package/dist/provider.js +187 -0
  126. package/dist/providers/_shared/google/result-size.js +57 -0
  127. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  128. package/dist/providers/_shared/google/service-account.js +500 -0
  129. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  130. package/dist/providers/_shared/google/workspace.js +518 -0
  131. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  132. package/dist/providers/basecamp/index.js +997 -0
  133. package/dist/providers/basecamp/skill.generated.js +9 -0
  134. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  135. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  136. package/dist/providers/breeze/skill.generated.js +9 -0
  137. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  138. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  139. package/dist/providers/ccb/skill.generated.js +9 -0
  140. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  141. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  142. package/dist/providers/cloudflare/skill.generated.js +10 -0
  143. package/dist/providers/docs/index.d.ts +54 -0
  144. package/dist/providers/docs/index.js +1237 -0
  145. package/dist/providers/docs/skill.generated.js +9 -0
  146. package/dist/providers/drive/index.d.ts +53 -0
  147. package/dist/providers/drive/index.js +1523 -0
  148. package/dist/providers/drive/skill.generated.js +9 -0
  149. package/dist/providers/forms/index.d.ts +54 -0
  150. package/dist/providers/forms/index.js +1217 -0
  151. package/dist/providers/forms/skill.generated.js +11 -0
  152. package/dist/providers/github/auth.d.ts +32 -0
  153. package/dist/providers/github/auth.js +361 -0
  154. package/dist/providers/github/hosted.js +218 -0
  155. package/dist/providers/github/index.d.ts +13 -0
  156. package/dist/providers/github/index.js +140 -0
  157. package/dist/providers/github/key.js +100 -0
  158. package/dist/providers/github/rest.js +321 -0
  159. package/dist/providers/github/scope.d.ts +29 -0
  160. package/dist/providers/github/scope.js +124 -0
  161. package/dist/providers/github/skill.generated.js +8 -0
  162. package/dist/providers/gmail/index.d.ts +51 -0
  163. package/dist/providers/gmail/index.js +1436 -0
  164. package/dist/providers/gmail/skill.generated.js +9 -0
  165. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  166. package/dist/providers/linear/index.js +352 -0
  167. package/dist/providers/linear/skill.generated.js +8 -0
  168. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  169. package/dist/providers/mixpanel/index.js +416 -0
  170. package/dist/providers/mixpanel/skill.generated.js +9 -0
  171. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  172. package/dist/providers/{notion.js → notion/index.js} +226 -239
  173. package/dist/providers/notion/skill.generated.js +10 -0
  174. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  175. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  176. package/dist/providers/overflow/skill.generated.js +8 -0
  177. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  178. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  179. package/dist/providers/planning-center/skill.generated.js +10 -0
  180. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  181. package/dist/providers/revenuecat/index.js +557 -0
  182. package/dist/providers/revenuecat/skill.generated.js +11 -0
  183. package/dist/providers/sheets/index.d.ts +54 -0
  184. package/dist/providers/sheets/index.js +1237 -0
  185. package/dist/providers/sheets/skill.generated.js +10 -0
  186. package/dist/providers/slides/index.d.ts +53 -0
  187. package/dist/providers/slides/index.js +2645 -0
  188. package/dist/providers/slides/skill.generated.js +9 -0
  189. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  190. package/dist/providers/stripe/index.js +266 -0
  191. package/dist/providers/stripe/skill.generated.js +11 -0
  192. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  193. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  194. package/dist/providers/tithely/skill.generated.js +8 -0
  195. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  196. package/dist/providers/vercel/index.js +1038 -0
  197. package/dist/providers/vercel/mcp-ownership.js +212 -0
  198. package/dist/providers/vercel/skill.generated.js +11 -0
  199. package/dist/registry.d.ts +108 -171
  200. package/dist/registry.js +513 -925
  201. package/dist/request-state.js +21 -0
  202. package/dist/resource-read.js +100 -0
  203. package/dist/result-shapes.js +17 -49
  204. package/dist/retired-toolkits.js +1 -1
  205. package/dist/routes/access-tokens.js +3 -6
  206. package/dist/routes/activity-context.js +21 -0
  207. package/dist/routes/activity-disclosure.js +40 -0
  208. package/dist/routes/activity.js +59 -21
  209. package/dist/routes/actor-labels.js +3 -50
  210. package/dist/routes/connect.js +221 -0
  211. package/dist/routes/credentials.js +15 -27
  212. package/dist/routes/mcp.js +356 -130
  213. package/dist/routes/oauth-client-metadata.js +15 -0
  214. package/dist/routes/oauth-management.js +45 -107
  215. package/dist/routes/oauth.js +110 -57
  216. package/dist/routes/operator.js +3 -3
  217. package/dist/routes/shared.d.ts +25 -64
  218. package/dist/routes/shared.js +160 -80
  219. package/dist/routes/skills.js +30 -0
  220. package/dist/routes/ui-config.js +225 -0
  221. package/dist/routes/ui-data.js +40 -35
  222. package/dist/routes/ui.js +66 -56
  223. package/dist/runtime/admission.js +4 -2
  224. package/dist/runtime/call-admission.js +3 -1
  225. package/dist/runtime/connector-scope.js +1 -4
  226. package/dist/runtime/run.js +10 -39
  227. package/dist/runtime/services.js +12 -117
  228. package/dist/runtime/shared-read.js +1 -3
  229. package/dist/runtime/storage.js +8 -1
  230. package/dist/sent-secrets.d.ts +52 -0
  231. package/dist/sent-secrets.js +474 -0
  232. package/dist/server.js +28 -71
  233. package/dist/skills.js +406 -174
  234. package/dist/sqlite.d.ts +48 -0
  235. package/dist/sqlite.js +166 -0
  236. package/dist/storage/keys.js +428 -0
  237. package/dist/storage/memory.js +7 -0
  238. package/dist/storage/sql.d.ts +66 -0
  239. package/dist/storage/sql.js +534 -0
  240. package/dist/timeout.js +4 -2
  241. package/dist/tool-name.js +17 -0
  242. package/dist/tool-safety.d.ts +9 -35
  243. package/dist/tool-safety.js +20 -40
  244. package/dist/types.d.ts +320 -54
  245. package/dist/types.js +0 -1
  246. package/dist/ui.d.ts +7 -19
  247. package/dist/ui.js +76 -90
  248. package/dist/url-safety.js +3 -6
  249. package/dist/usage-guide.js +137 -0
  250. package/dist/validate.d.ts +7 -0
  251. package/dist/validate.js +309 -47
  252. package/dist/version.d.ts +1 -7
  253. package/dist/version.js +3 -7
  254. package/dist/worker.d.ts +11 -0
  255. package/dist/worker.js +434 -0
  256. package/documentation/architecture.md +664 -231
  257. package/documentation/auth.md +936 -546
  258. package/documentation/code-mode.md +501 -312
  259. package/documentation/meta-tools.md +449 -200
  260. package/documentation/operator-tests.md +93 -0
  261. package/documentation/provider-migration-0.29.md +60 -0
  262. package/examples/worker/AGENTS.md +34 -12
  263. package/examples/worker/README.md +286 -255
  264. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  265. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  266. package/examples/worker/src/connecta.config.ts +151 -0
  267. package/examples/worker/src/index.ts +14 -197
  268. package/examples/worker/wrangler.jsonc +15 -35
  269. package/package.json +120 -54
  270. package/templates/node/.dockerignore +1 -2
  271. package/templates/node/.env.example +20 -21
  272. package/templates/node/AGENTS.md +21 -17
  273. package/templates/node/Dockerfile +3 -5
  274. package/templates/node/README.md +133 -58
  275. package/templates/node/docker-compose.yml +11 -12
  276. package/templates/node/package.json +4 -2
  277. package/templates/node/src/connecta.config.ts +119 -0
  278. package/templates/node/src/index.ts +9 -154
  279. package/templates/node/src/provision-token.ts +17 -0
  280. package/AGENTS.md +0 -170
  281. package/dist/artifacts/connector.d.ts +0 -58
  282. package/dist/artifacts/connector.js +0 -730
  283. package/dist/artifacts/document.js +0 -86
  284. package/dist/artifacts/guide.js +0 -140
  285. package/dist/artifacts/html-scan.js +0 -329
  286. package/dist/artifacts/json.js +0 -78
  287. package/dist/artifacts/kv-store.d.ts +0 -20
  288. package/dist/artifacts/kv-store.js +0 -140
  289. package/dist/artifacts/markdown.js +0 -478
  290. package/dist/artifacts/module.d.ts +0 -37
  291. package/dist/artifacts/module.js +0 -66
  292. package/dist/artifacts/operations.d.ts +0 -197
  293. package/dist/artifacts/operations.js +0 -1002
  294. package/dist/artifacts/refresh.d.ts +0 -55
  295. package/dist/artifacts/refresh.js +0 -236
  296. package/dist/artifacts/routes.js +0 -206
  297. package/dist/artifacts/types.d.ts +0 -214
  298. package/dist/artifacts/types.js +0 -22
  299. package/dist/artifacts/validate.d.ts +0 -62
  300. package/dist/artifacts/validate.js +0 -651
  301. package/dist/artifacts.d.ts +0 -6
  302. package/dist/artifacts.js +0 -9
  303. package/dist/auth/bearer.d.ts +0 -11
  304. package/dist/auth/bearer.js +0 -50
  305. package/dist/providers/basecamp.js +0 -445
  306. package/dist/providers/linear.js +0 -217
  307. package/dist/providers/mixpanel.js +0 -247
  308. package/dist/providers/revenuecat.js +0 -317
  309. package/dist/providers/stripe.js +0 -253
  310. package/dist/providers/vercel.js +0 -1253
  311. package/dist/storage/file.d.ts +0 -15
  312. package/dist/storage/file.js +0 -455
  313. package/ethos.md +0 -93
  314. package/examples/worker/src/cloudflare-kv.ts +0 -43
  315. package/examples/worker/src/d1-activity-row.ts +0 -155
  316. package/examples/worker/src/d1-activity.ts +0 -147
  317. package/examples/worker/src/d1-storage.ts +0 -138
  318. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  319. package/templates/node/src/file-activity.ts +0 -182
package/dist/registry.js CHANGED
@@ -1,27 +1,28 @@
1
- import { Cause, Clock, Deferred, Duration, Effect, Exit, Option, Result, } from "effect";
2
- import { storedCredentialShape, } from "./credential-rules.js";
3
- import { ConnectorCallError, msg } from "./errors.js";
1
+ import { markCatalogFreshness, carryCatalogFreshness } from "./catalog-freshness.js";
2
+ import { activityRequest } from "./activity-request.js";
3
+ import { attachCatalogCache, catalogExpiry, customCatalogFallback, storeCustomCatalogFallback, catalogFetchedAt, invalidateCatalogCache, observeUncachedCatalogRefresh, } from "./catalog-cache.js";
4
+ import { CONFIG_DEFAULTS } from "./config-defaults.js";
5
+ import { assertStaticToolNames, hasControlCharacters } from "./tool-name.js";
6
+ import { Clock, Duration, Effect, Random } from "effect";
7
+ import { storedCredentialShape } from "./credential-rules.js";
8
+ import { ConnectorCallError } from "./errors.js";
9
+ import { boundedStatus, failureRecord, failureStatus, logFailure, ownStatus } from "./operator-record.js";
4
10
  import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
5
- import { boundedCatalogDrift } from "./catalog-drift.js";
6
- import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
7
- import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
11
+ import { boundedCatalogDrift, catalogReviewOf, classifyCatalog, observeReviewedDrift, observedCatalogDrift, } from "./catalog-drift.js";
12
+ import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "./catalog-limits.js";
8
13
  import { ObservedOutputSchemas } from "./result-shapes.js";
9
- import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
14
+ import { redactCatalog, sentSecretsFor, sentSecretsForRequest, trackCredentialReads } from "./sent-secrets.js";
15
+ import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary } from "./skills.js";
10
16
  import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
11
17
  import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
12
18
  import { attachCaller } from "./connector-caller.js";
13
- import { closeScopeOnExit } from "./runtime/connector-scope.js";
14
- import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
19
+ import { runEdge } from "./runtime/run.js";
15
20
  import { SharedRead } from "./runtime/shared-read.js";
16
- import { Logger as LoggerService } from "./runtime/services.js";
17
- import { runOnPartition, storageDelete, storageGet, storageSet, } from "./runtime/storage.js";
18
- import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
19
- import { isExplicitlyReadOnly } from "./tool-safety.js";
21
+ import { runOnPartition, storageCompareAndSet, storageDelete, storageGet, storageSet } from "./runtime/storage.js";
22
+ import { jsonCodec, OAUTH_HANDOFF_TTL_SECONDS, oauthHandoffKeys, resultKeys, scopes, stashLedgerKeys, } from "./storage/keys.js";
23
+ import { classifyTool } from "./tool-safety.js";
20
24
  const ID_RE = /^[a-z0-9_-]+$/;
21
- const DEFAULT_TTL_SECONDS = 300;
22
- const DEFAULT_STALE_SECONDS = 3600;
23
- const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
24
- const DEFAULT_MAX_RESULT_BYTES = 24_000;
25
+ const DEFAULT_MAX_RESULT_BYTES = CONFIG_DEFAULTS.calls.maxResultBytes;
25
26
  const encoder = new TextEncoder();
26
27
  /**
27
28
  * Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
@@ -50,7 +51,7 @@ export const MIN_MAX_RESULT_BYTES = 1;
50
51
  /**
51
52
  * The one definition of a usable `maxResultBytes`: a finite whole number of at
52
53
  * least {@link MIN_MAX_RESULT_BYTES} bytes. Shared by all three intake points
53
- * — `calls.maxResultBytes`, the per-connector override, and `get_result`'s
54
+ * — `calls.maxResultBytes`, the per-connector override, and `connecta.result`'s
54
55
  * `maxBytes` argument — so a value that is valid at one is valid at all.
55
56
  *
56
57
  * Everything else is rejected rather than coerced, because each rejected shape
@@ -64,105 +65,138 @@ export function isValidMaxResultBytes(value) {
64
65
  }
65
66
  /**
66
67
  * Resolve a configured cap against the value it inherits, dropping anything
67
- * `isValidMaxResultBytes` rejects. Operator-facing surfaces pair this with a
68
- * startup warning (see `Registry.checkResultCaps`) so the fallback is never
69
- * silent; the resolution itself stays total so no call site has to cope with
70
- * a broken cap.
68
+ * `isValidMaxResultBytes` rejects. Construction already refuses an unusable
69
+ * cap (`Registry.assertResultCaps`); the resolution stays total so no call
70
+ * site has to cope with a broken one.
71
71
  */
72
72
  export function resolveMaxResultBytes(value, inherited) {
73
- return value !== undefined && isValidMaxResultBytes(value)
74
- ? value
75
- : inherited;
73
+ return value !== undefined && isValidMaxResultBytes(value) ? value : inherited;
76
74
  }
77
- /**
78
- * What a joiner hears from a flight that will not answer for it: one that
79
- * passed its bound, or failed for a reason that was its owner's alone. It
80
- * never reaches a caller; a joiner that hears it makes a fresh attempt.
81
- */
82
- class AbandonedCatalogRefresh extends Error {
83
- constructor(id) {
84
- super(`The catalog refresh of "${id}" was abandoned.`);
85
- this.name = "AbandonedCatalogRefresh";
86
- }
87
- }
88
- const CATALOG_CHUNK_IO_CONCURRENCY = 4;
89
- /**
90
- * Run `operation` over every chunk index or chunk with the fixed chunk I/O
91
- * bound, settling each: one failure neither interrupts its siblings nor
92
- * leaves their storage calls running behind the caller. Results keep input
93
- * order, so the first failure by index is the one a caller reports.
94
- */
95
- function forEachChunk(items, operation) {
96
- return Effect.forEach(items, (item, index) => Effect.result(operation(item, index)), { concurrency: CATALOG_CHUNK_IO_CONCURRENCY });
97
- }
98
- /**
99
- * A persisted-catalog mutation that logs its storage failure rather than
100
- * failing, so its turn in the mutation queue always ends.
101
- */
102
- function warnOnFailure(mutation, failed) {
103
- return Effect.catch(mutation, (err) => LoggerService.use((logger) => Effect.sync(() => logger.warn(`[connecta] ${failed}: ${msg(err)}`))));
75
+ /** Freeze registry-owned facts so review digest memoization remains valid. */
76
+ function frozenFacts(tools) {
77
+ const pending = [tools];
78
+ while (pending.length > 0) {
79
+ const value = pending.pop();
80
+ if (typeof value !== "object" || value === null || Object.isFrozen(value))
81
+ continue;
82
+ Object.freeze(value);
83
+ for (const item of Object.values(value))
84
+ pending.push(item);
85
+ }
86
+ return tools;
104
87
  }
105
88
  function namespaced(storage, prefix) {
106
89
  return {
107
90
  get: (k) => storage.get(prefix + k),
108
91
  set: (k, v, o) => storage.set(prefix + k, v, o),
109
92
  delete: (k) => storage.delete(prefix + k),
110
- ...(storage.list
111
- ? {
112
- list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
113
- }
114
- : {}),
115
- ...(storage.compareAndSet
116
- ? {
117
- compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
118
- }
119
- : {}),
93
+ list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
94
+ compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
120
95
  };
121
96
  }
122
97
  const MAX_PERSONAL_REGISTRIES = 1_024;
123
98
  const MAX_ABSENT_GRANT_WARNINGS = 1_024;
124
- const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60;
99
+ /**
100
+ * Compare-and-set attempts one stash makes on the ledger. A lost swap means
101
+ * another stash changed the ledger first, never that capacity ran out, so
102
+ * the loser re-reads and plans again after a jittered, growing pause: of 64
103
+ * simultaneous claims, each one either books a charge or meets a full ledger.
104
+ * Exhausting the attempts takes seconds of sustained contention on the one
105
+ * ledger record, and then the caller keeps its preview.
106
+ */
107
+ const STASH_LEDGER_ATTEMPTS = 32;
108
+ /** First backoff window after a lost swap; it doubles up to the cap. */
109
+ const STASH_LEDGER_BACKOFF_MS = 4;
110
+ const STASH_LEDGER_BACKOFF_CAP_MS = 250;
111
+ /**
112
+ * Slack past a stash's deadline before its charge leaves the ledger, and the
113
+ * longest one chunk write may take. A chunk's TTL is what remains of the
114
+ * deadline on the clock read before its write, and the store starts that TTL
115
+ * no later than the write returns, so the chunk expires within the write's
116
+ * duration of the deadline. A write slower than this fails the stash.
117
+ */
118
+ const STASH_LEDGER_GRACE_MS = 30_000;
119
+ /**
120
+ * Live charges in a stored ledger. Anything malformed reads as empty: the
121
+ * ledger bounds an advisory cache, and the next successful swap replaces it.
122
+ */
123
+ function liveStashCharges(raw, now) {
124
+ if (raw === null)
125
+ return [];
126
+ let parsed;
127
+ try {
128
+ parsed = jsonCodec.decode(raw);
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ const entries = parsed?.entries;
134
+ if (!Array.isArray(entries))
135
+ return [];
136
+ return entries.filter((entry) => Array.isArray(entry) &&
137
+ entry.length === 3 &&
138
+ typeof entry[0] === "string" &&
139
+ Number.isSafeInteger(entry[1]) &&
140
+ entry[1] >= 0 &&
141
+ typeof entry[2] === "number" &&
142
+ entry[2] > now);
143
+ }
144
+ /**
145
+ * Rewrite the stash ledger by compare-and-set. `plan` sees the live charges
146
+ * and the time it read them at, and answers its result with the entries to
147
+ * store, or without them when there is nothing to write. Only `plan` refuses:
148
+ * a lost swap backs off and plans again from a fresh read. Answers undefined
149
+ * once the attempts run out.
150
+ */
151
+ function swapStashLedger(plan) {
152
+ return Effect.gen(function* () {
153
+ const ledger = stashLedgerKeys.ledger;
154
+ for (let attempt = 0; attempt < STASH_LEDGER_ATTEMPTS; attempt++) {
155
+ if (attempt > 0) {
156
+ const window = Math.min(STASH_LEDGER_BACKOFF_CAP_MS, STASH_LEDGER_BACKOFF_MS * 2 ** (attempt - 1));
157
+ yield* Effect.sleep(Duration.millis(Math.floor((yield* Random.next) * window) + 1));
158
+ }
159
+ const raw = yield* storageGet(ledger);
160
+ const now = yield* Clock.currentTimeMillis;
161
+ const planned = plan(liveStashCharges(raw, now), now);
162
+ if (planned.entries === undefined)
163
+ return planned.result;
164
+ if (yield* storageCompareAndSet(ledger, raw, jsonCodec.encode({ v: 1, entries: planned.entries }))) {
165
+ return planned.result;
166
+ }
167
+ }
168
+ return undefined;
169
+ });
170
+ }
125
171
  async function sha256Hex(value) {
126
172
  const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
127
- return [...bytes]
128
- .map((byte) => byte.toString(16).padStart(2, "0"))
129
- .join("");
173
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
130
174
  }
131
175
  /**
132
- * Holds the connector set, resolves addresses, and caches per-connector tool
133
- * lists in memory with a TTL. Connector failures are isolated: a broken
176
+ * Holds the connector set, resolves addresses, and coalesces tool listings
177
+ * within one request. Remote MCP clients own persisted response caching. Connector failures are isolated: a broken
134
178
  * connector surfaces status "error"; the rest keep working.
135
179
  */
136
180
  export class Registry {
137
181
  opts;
138
182
  connectors = new Map();
139
183
  callAdmission = new Map();
140
- cache = new Map();
141
- invalidated = new Set();
142
- /** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
143
- catalogGenerations = new Map();
144
- // The last queued mutation per connector, as the turn its own request
145
- // completes once its storage work is done; see enqueueCatalogMutation.
146
- /** Serialize persisted catalog set/delete operations within this isolate. */
147
- catalogMutations = new Map();
148
- /** Same-request cold loads share one read without retaining the request.
149
- * See documentation/architecture.md#the-two-lifetimes. */
184
+ /** Coalesce listings only within one request and authorization partition. */
150
185
  requestCatalogLoads = new WeakMap();
151
- /** One live refresh per connector across agent and operator requests. */
152
- catalogRefreshes = new Map();
186
+ catalogObservedAt = new Map();
153
187
  /** Last payload-free agent catalog access in this runtime. */
154
188
  catalogAccess = new Map();
155
- /** Last drift counts reported to activity, per connector, in this runtime. */
156
- reportedDrift = new Map();
189
+ /** Count-only intake findings, including catalogs loaded from storage. */
190
+ droppedToolNames = new Map();
191
+ /**
192
+ * Schema digest verification per reviewed facts array. Only the registry's
193
+ * own deep-frozen arrays are keys, so an entry stays true while they live.
194
+ */
195
+ verifiedFacts = new WeakMap();
157
196
  observedOutputSchemas;
158
- ttlMs;
159
- staleMs;
160
- persistToolCatalog;
161
197
  /** Result-size guard cap threaded to the meta-tools. */
162
198
  maxResultBytes;
163
- /** Only keys, byte counts, and expiry survive requests; never write promises. */
164
- resultStash = new Map();
165
- resultStashBytes = 0;
199
+ classification;
166
200
  configuredConnectors;
167
201
  personalRegistries = new Map();
168
202
  oauthPartition = {};
@@ -173,13 +207,9 @@ export class Registry {
173
207
  this.opts = opts;
174
208
  this.configuredConnectors = [...connectors];
175
209
  this.observedOutputSchemas = new ObservedOutputSchemas();
176
- this.ttlMs =
177
- (opts.toolCacheTtlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
178
- this.staleMs =
179
- (opts.toolCatalogStaleSeconds ?? DEFAULT_STALE_SECONDS) * 1000;
180
- this.persistToolCatalog = opts.persistToolCatalog ?? true;
181
210
  this.maxResultBytes = resolveMaxResultBytes(opts.maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
182
211
  for (const c of connectors) {
212
+ assertStaticToolNames(c.staticTools ?? [], `Connector(${JSON.stringify(c.id)}).staticTools`);
183
213
  if ("handleRequest" in c) {
184
214
  throw new Error(`Connector "${c.id}" declares removed handleRequest. ` +
185
215
  "Move custom HTTP routes into the deployment's fetch handler.");
@@ -190,29 +220,46 @@ export class Registry {
190
220
  if (this.connectors.has(c.id)) {
191
221
  throw new Error(`Duplicate connector id "${c.id}"`);
192
222
  }
193
- if (c.authScope !== undefined &&
194
- c.authScope !== "shared" &&
195
- c.authScope !== "personal") {
223
+ if (c.authScope !== undefined && c.authScope !== "shared" && c.authScope !== "personal") {
196
224
  throw new Error(`Invalid authScope on connector "${c.id}": expected "shared" or "personal"`);
197
225
  }
198
- const configuredGuideSummary = typeof c.usageGuide === "object"
199
- ? normalizeGuideSummary(c.usageGuide.summary ?? "")
200
- : undefined;
201
- if (configuredGuideSummary !== undefined &&
202
- configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
226
+ const configuredGuideSummary = typeof c.usageGuide === "object" ? normalizeGuideSummary(c.usageGuide.summary ?? "") : undefined;
227
+ if (configuredGuideSummary !== undefined && configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
203
228
  throw new Error(`Connector "${c.id}" usageGuide.summary is ` +
204
229
  `${configuredGuideSummary.length} characters after whitespace ` +
205
230
  `normalization; the discovery bound is ${GUIDE_SUMMARY_LENGTH}. ` +
206
231
  "Shorten it or omit it to derive one.");
207
232
  }
233
+ // Validated here, before any request (INV-11), and read once per
234
+ // connector object.
235
+ if (catalogReviewOf(c) && c.staticTools) {
236
+ throw new Error(`Connector "${c.id}" declares both staticTools and a classification; ` + "annotate static tools directly.");
237
+ }
208
238
  this.connectors.set(c.id, c);
209
239
  if (c.callAdmission) {
210
240
  this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
211
241
  }
212
242
  }
243
+ this.classification = Object.assign(Object.create(null), Object.fromEntries(Object.entries(opts.classification ?? {}).map(([id, tools]) => [
244
+ id,
245
+ Object.freeze(Object.assign(Object.create(null), tools)),
246
+ ])));
247
+ Object.freeze(this.classification);
248
+ for (const [id, overrides] of Object.entries(this.classification)) {
249
+ for (const [name, verdict] of Object.entries(overrides)) {
250
+ if (!name || (verdict !== "read" && verdict !== "write")) {
251
+ throw new Error(`ConnectaConfig.classification.${id}.${name}: expected "read" or "write"`);
252
+ }
253
+ }
254
+ const connector = this.connectors.get(id);
255
+ if (!connector)
256
+ throw new Error(`ConnectaConfig.classification: unknown connector "${id}"`);
257
+ if (connector.staticTools)
258
+ this.validateClassification(id, connector.staticTools, overrides);
259
+ }
260
+ this.assertResultCaps(opts.maxResultBytes);
213
261
  if (opts.constructionChecks !== false) {
214
262
  this.checkConventions(opts.logger);
215
- this.checkResultCaps(opts.logger, opts.maxResultBytes);
216
263
  }
217
264
  }
218
265
  personalRegistry(principalKey) {
@@ -224,15 +271,9 @@ export class Registry {
224
271
  }
225
272
  if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
226
273
  // Eviction must not reset a live rolling budget or orphan queued calls.
227
- // Nor may it drop catalog work in flight: a replacement for the same
228
- // principal starts with no generations and no mutation queue, so a
229
- // refresh the evicted registry finishes later would persist a listing
230
- // that a credential change on the replacement had just deleted.
231
274
  // OAuth status/start work bypasses both gates, and accepted rotations
232
275
  // may still be saving after their caller leaves. Preserve its partition.
233
- const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
234
- candidate.catalogRefreshes.size === 0 &&
235
- candidate.catalogMutations.size === 0 &&
276
+ const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every((admission) => admission.isIdle()) &&
236
277
  oauthPartitionIdle(candidate.oauthPartition));
237
278
  if (!idle) {
238
279
  throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
@@ -242,10 +283,15 @@ export class Registry {
242
283
  }
243
284
  const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
244
285
  ...this.opts,
245
- storage: namespaced(this.opts.storage, `principal:${principalKey}:`),
286
+ classification: Object.fromEntries(Object.entries(this.classification).filter(([id]) => this.connectors.get(id)?.authScope === "personal")),
287
+ storage: namespaced(this.opts.storage, scopes.principal(principalKey)),
246
288
  credentialOwner: principalKey,
289
+ catalogStorage: this.opts.catalogStorage ?? this.opts.storage,
247
290
  constructionChecks: false,
248
291
  });
292
+ // Counts only, like the connector-wide reviewed drift observation. No
293
+ // principal, credentials, or catalog payload crosses this registry boundary.
294
+ registry.droppedToolNames = this.droppedToolNames;
249
295
  if (this.callAdmissionClosed)
250
296
  registry.closeCallAdmission();
251
297
  this.personalRegistries.set(principalKey, registry);
@@ -253,9 +299,7 @@ export class Registry {
253
299
  }
254
300
  /** Build the only connector view an authenticated request receives. */
255
301
  scoped(scope) {
256
- const requested = scope.connectorIds === "all"
257
- ? new Set(this.connectors.keys())
258
- : new Set(scope.connectorIds);
302
+ const requested = scope.connectorIds === "all" ? new Set(this.connectors.keys()) : new Set(scope.connectorIds);
259
303
  for (const id of requested) {
260
304
  if (!this.connectors.has(id)) {
261
305
  throw new Error(`Identity access resolver returned unknown connector "${id}"`);
@@ -264,7 +308,7 @@ export class Registry {
264
308
  return new ScopedRegistryView(this, requested, scope);
265
309
  }
266
310
  scopedStorage(subjectKey) {
267
- return namespaced(this.opts.storage, `subject:${subjectKey}:`);
311
+ return namespaced(this.opts.storage, scopes.subject(subjectKey));
268
312
  }
269
313
  /**
270
314
  * A granted `connector.tool` address the live catalog does not contain is
@@ -282,87 +326,70 @@ export class Registry {
282
326
  if (oldest !== undefined)
283
327
  this.warnedAbsentGrants.delete(oldest);
284
328
  }
329
+ if (hasControlCharacters(toolName)) {
330
+ logFailure(this.opts.logger, "connectorAccess grant is unreachable", failureRecord({ connector: connectorId }));
331
+ return;
332
+ }
285
333
  // Grant names are operator data but may carry any non-control character;
286
334
  // quote them so a line terminator a log reader honours cannot forge a line.
287
335
  const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
288
336
  this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
289
337
  }
290
- oauthHandoffKey(connectorId, stateHash) {
291
- return `oauth-handoff:v1:${connectorId}:${stateHash}`;
292
- }
293
338
  async storeOAuthHandoff(connectorId, state, principalKey) {
294
- const key = this.oauthHandoffKey(connectorId, await sha256Hex(state));
339
+ const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
295
340
  for (let attempt = 0; attempt < 32; attempt++) {
296
341
  const existing = await this.opts.storage.get(key);
297
342
  if (existing && existing !== principalKey) {
298
343
  throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
299
344
  }
300
- const expiry = { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS };
301
- if (this.opts.storage.compareAndSet) {
302
- // Bind ownership atomically, including renewal of the same owner's
303
- // handoff. Two owners reading a miss must not overwrite each other.
304
- if (!await this.opts.storage.compareAndSet(key, existing, principalKey, expiry))
305
- continue;
306
- }
307
- else {
308
- await this.opts.storage.set(key, principalKey, expiry);
309
- }
310
- return;
345
+ // Bind ownership atomically, including renewal of the same owner's
346
+ // handoff. Two owners reading a miss must not overwrite each other.
347
+ if (await this.opts.storage.compareAndSet(key, existing, principalKey, {
348
+ ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS,
349
+ }))
350
+ return;
311
351
  }
312
352
  throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
313
353
  }
314
354
  async oauthCallbackView(connectorId, state) {
315
- const connector = this.connectors.get(connectorId);
316
- if (!connector)
317
- return null;
318
- if (connector.authScope !== "personal")
319
- return { registry: this };
320
355
  if (!state)
321
356
  return null;
322
- const principalKey = await this.opts.storage.get(this.oauthHandoffKey(connectorId, await sha256Hex(state)));
323
- if (!principalKey)
357
+ const principalKey = await this.opts.storage.get(oauthHandoffKeys.handoff(connectorId, await sha256Hex(state)));
358
+ const connector = this.connectors.get(connectorId);
359
+ if (!connector || !principalKey)
324
360
  return null;
325
361
  return {
326
- registry: this.scoped({
327
- connectorIds: [connectorId],
328
- subjectKey: principalKey,
329
- principalKey,
330
- }),
362
+ registry: connector.authScope === "personal"
363
+ ? this.scoped({
364
+ connectorIds: [connectorId],
365
+ subjectKey: principalKey,
366
+ principalKey,
367
+ })
368
+ : this,
331
369
  principalKey,
332
370
  };
333
371
  }
334
- async clearOAuthHandoff(connectorId, state) {
372
+ async consumeOAuthHandoff(connectorId, state, principalKey) {
335
373
  if (!state)
336
- return;
337
- await this.opts.storage.delete(this.oauthHandoffKey(connectorId, await sha256Hex(state)));
374
+ return false;
375
+ const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
376
+ return this.opts.storage.compareAndSet(key, principalKey, null);
338
377
  }
339
378
  /**
340
- * Warn once per unusable result cap, at construction time — the same
341
- * "runs fine but is surely unintended" channel as the insecure-config
342
- * warnings in `createConnecta`. A rejected cap can't be honoured, and
343
- * honouring it *approximately* is exactly the inversion issue #32 is about,
344
- * so the value is dropped in favour of what it inherits and the operator is
345
- * told which one is actually in force.
379
+ * Refuse an unusable result cap at construction, like every other
380
+ * structural mistake (INV-11). A rejected cap can't be honoured, and
381
+ * honouring it *approximately* is exactly the inversion issue #32 is about;
382
+ * falling back with a warning let a deployment boot on a value nobody
383
+ * chose.
346
384
  */
347
- checkResultCaps(logger, configured) {
385
+ assertResultCaps(configured) {
348
386
  if (configured !== undefined && !isValidMaxResultBytes(configured)) {
349
- logger.warn(`[connecta] calls.maxResultBytes ${configured} is not a whole number of ` +
350
- `bytes >= ${MIN_MAX_RESULT_BYTES}: it would serve an empty, ` +
351
- "oversized, or unguarded result instead of truncating. Using the " +
352
- `default ${DEFAULT_MAX_RESULT_BYTES} instead.`);
387
+ throw new Error(`ConnectaConfig.calls.maxResultBytes must be a whole number of bytes >= ${MIN_MAX_RESULT_BYTES}.`);
353
388
  }
354
389
  for (const c of this.connectors.values()) {
355
- if (c.maxResultBytes !== undefined &&
356
- !isValidMaxResultBytes(c.maxResultBytes)) {
357
- // The number quoted here is `this.maxResultBytes` because that is
358
- // literally what a call falls back to: meta-tools reads the inherited
359
- // cap off `RegistryView.maxResultBytes`, so the warned value and the
360
- // runtime value are the same field rather than two copies of it.
361
- logger.warn(`[connecta] connector "${c.id}" sets maxResultBytes ` +
362
- `${c.maxResultBytes}, which is not a whole number of bytes >= ` +
363
- `${MIN_MAX_RESULT_BYTES}. Ignoring the override — the connector ` +
364
- `inherits the deployment-wide cap calls fall back to ` +
365
- `(${this.maxResultBytes}).`);
390
+ if (c.maxResultBytes !== undefined && !isValidMaxResultBytes(c.maxResultBytes)) {
391
+ throw new Error(`Connector "${c.id}" maxResultBytes must be a whole number of bytes >= ` +
392
+ `${MIN_MAX_RESULT_BYTES}; omit it to inherit the deployment-wide cap.`);
366
393
  }
367
394
  }
368
395
  }
@@ -393,7 +420,13 @@ export class Registry {
393
420
  getConnector(id) {
394
421
  return this.connectors.get(id);
395
422
  }
396
- contextFor(id, baseUrl, requestScope = {}, callOptions = {}) {
423
+ getResourceConnector(id) {
424
+ return this.getConnector(id);
425
+ }
426
+ canReadConnectorSkills(id) {
427
+ return this.connectors.has(id);
428
+ }
429
+ contextFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
397
430
  const credentialConfig = this.connectors.get(id)?.credential;
398
431
  let credentialAccess;
399
432
  if (this.opts.credentialVault && credentialConfig) {
@@ -415,13 +448,42 @@ export class Registry {
415
448
  };
416
449
  }
417
450
  const context = {
418
- storage: namespaced(this.opts.storage, `conn:${id}:`),
451
+ storage: namespaced(this.opts.storage, scopes.connector(id)),
419
452
  logger: this.opts.logger,
420
453
  baseUrl,
454
+ ...(this.opts.publicUrl !== undefined ? { publicUrl: this.opts.publicUrl } : {}),
455
+ ...(this.opts.oauthClientName !== undefined ? { oauthClientName: this.opts.oauthClientName } : {}),
421
456
  ...(credentialAccess ? { credential: credentialAccess } : {}),
422
457
  requestScope,
423
458
  ...callOptions,
424
459
  };
460
+ attachCaller(context, scope?.caller);
461
+ attachCatalogCache(context, {
462
+ storage: this.opts.catalogStorage ?? this.opts.storage,
463
+ partition: JSON.stringify([
464
+ scope?.principalKey ?? this.opts.credentialOwner ?? null,
465
+ scope?.subjectKey ?? null,
466
+ scope?.caller?.identity ?? null,
467
+ scope?.caller?.authenticated ?? false,
468
+ scope?.caller?.pool ?? null,
469
+ ]),
470
+ sharedPartition: JSON.stringify([baseUrl, this.opts.publicUrl ?? null, scope?.caller?.pool ?? null]),
471
+ defaultTtlMs: (this.opts.toolCacheTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds) * 1000,
472
+ minTtlMs: (this.opts.catalogMinTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMinTtlSeconds) * 1000,
473
+ maxTtlMs: (this.opts.catalogMaxTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMaxTtlSeconds) * 1000,
474
+ ...(this.opts.catalogDriftActivity?.recordChange
475
+ ? {
476
+ onCompletedCatalogRefresh: (refresh, ctx) => this.recordCatalogDrift(refresh.previous, refresh.next, {
477
+ id,
478
+ connector: this.connectors.get(id),
479
+ ctx,
480
+ privateCatalog: refresh.private,
481
+ }),
482
+ }
483
+ : {}),
484
+ });
485
+ sentSecretsFor(context);
486
+ trackCredentialReads(context);
425
487
  attachOAuthPartition(context, this.oauthPartition);
426
488
  // Downstream OAuth state is sealed under the vault key, bound to this
427
489
  // connector and owner. The sealer rides beside the context, not on it.
@@ -446,71 +508,28 @@ export class Registry {
446
508
  snapshots.get(id)?.push(admission.snapshot());
447
509
  }
448
510
  }
449
- return Object.fromEntries([...snapshots].map(([id, values]) => [
450
- id, aggregateCallAdmissionSnapshots(values),
451
- ]));
452
- }
453
- /**
454
- * Payload-free drift counts for the open health endpoint, so `connecta
455
- * doctor` can report a stale allowlist without asking any downstream
456
- * anything. Only connectors that ship a vetted manifest *and* have already
457
- * served a refresh *in this runtime* appear — a process or isolate that has
458
- * answered no catalog request yet honestly reports nothing, and drift is not
459
- * persisted the way the catalog itself is.
460
- *
461
- * Every report is rebuilt by {@link boundedCatalogDrift} on the way out:
462
- * `Connector.catalogDrift()` is the open plugin seam, and this snapshot is
463
- * serialized into an unauthenticated response.
464
- */
465
- catalogDriftSnapshot() {
466
- const snapshot = {};
467
- for (const connector of this.connectors.values()) {
468
- const report = boundedCatalogDrift(connector.catalogDrift?.());
469
- if (report)
470
- snapshot[connector.id] = report;
471
- }
472
- return snapshot;
511
+ return Object.fromEntries([...snapshots].map(([id, values]) => [id, aggregateCallAdmissionSnapshots(values)]));
473
512
  }
474
513
  /**
475
- * Turn the observation a refresh just took into at most one activity event.
476
- *
477
- * Emitted on change rather than on every refresh: an identical report every
478
- * TTL is a heartbeat, not news, and the current counts are already on
479
- * connector status. A first observation that is clean is not an event
480
- * either — nothing moved — but a later return to clean is, because "the
481
- * drift is gone" is exactly what an operator watching the timeline is
482
- * waiting for.
514
+ * The drift a connector last showed in this runtime: the registry's own
515
+ * observation against a connector's `classification`, otherwise whatever
516
+ * the connector's `catalogDrift()` seam reports, bounded either way.
483
517
  */
484
- observeCatalogDrift(connector) {
485
- const report = boundedCatalogDrift(connector.catalogDrift?.());
486
- if (!report)
487
- return;
488
- const previous = this.reportedDrift.get(connector.id);
489
- // Bounded counts, so a seam returning NaN cannot make every refresh look
490
- // like a change and emit an event per refresh forever.
491
- const counts = {
492
- unclassifiedTools: report.unclassifiedTools,
493
- unservedTools: report.unservedTools,
494
- annotationConflicts: report.annotationConflicts,
495
- schemaChanges: report.schemaChanges,
518
+ catalogDriftOf(connector) {
519
+ const report = boundedCatalogDrift(catalogReviewOf(connector) ? observedCatalogDrift(connector) : connector.catalogDrift?.());
520
+ const dropped = this.droppedToolNames.get(connector.id);
521
+ if (!dropped)
522
+ return report;
523
+ return {
524
+ ...(report ?? {
525
+ unclassifiedTools: 0,
526
+ unservedTools: 0,
527
+ annotationConflicts: 0,
528
+ schemaChanges: 0,
529
+ }),
530
+ observedAt: dropped.observedAt,
531
+ ...(dropped.count > 0 ? { droppedTools: dropped.count } : {}),
496
532
  };
497
- const unchanged = previous !== undefined &&
498
- previous.unclassifiedTools === counts.unclassifiedTools &&
499
- previous.unservedTools === counts.unservedTools &&
500
- previous.annotationConflicts === counts.annotationConflicts &&
501
- previous.schemaChanges === counts.schemaChanges;
502
- if (unchanged)
503
- return;
504
- const clean = counts.unclassifiedTools === 0 &&
505
- counts.unservedTools === 0 &&
506
- counts.annotationConflicts === 0 &&
507
- counts.schemaChanges === 0;
508
- this.reportedDrift.set(connector.id, counts);
509
- if (previous === undefined && clean)
510
- return;
511
- this.opts.catalogDriftActivity?.recordDrift?.(this.opts.catalogDriftActivity
512
- ? { ...this.opts.catalogDriftActivity, logger: this.opts.logger }
513
- : undefined, { connectorId: connector.id, ...counts });
514
533
  }
515
534
  /** Reject queued/future downstream admission; active permits release safely. */
516
535
  closeCallAdmission() {
@@ -521,75 +540,96 @@ export class Registry {
521
540
  registry.closeCallAdmission();
522
541
  }
523
542
  /**
524
- * Reserve capacity and write one ASCII paging envelope in this runtime.
543
+ * Reserve capacity and write one ASCII paging envelope.
525
544
  *
526
- * `chunks[0]` lands on `<prefix><key>` and `chunks[n]` on `<prefix><key>#<n>`
527
- * — the layout get_result reads back, so a page fetches only the chunks it
528
- * covers instead of the whole stored result (issue #540). Chunking is a
529
- * read-cost decision, not a capacity one: however many keys an envelope
530
- * occupies, it is one stash entry charged its total ASCII length.
545
+ * `chunks[n]` lands on `resultKeys.chunk(id, n)` inside `partition` — the
546
+ * layout connecta.result reads back, so a page fetches only the chunks it covers
547
+ * instead of the whole stored result (issue #540). Chunking is a read-cost
548
+ * decision, not a capacity one: however many keys an envelope occupies, it
549
+ * is one stash entry charged its total ASCII length.
550
+ *
551
+ * The bounds are the deployment's, not this isolate's. Every charge is a
552
+ * row in one ledger record in storage, booked by compare-and-set before any
553
+ * chunk is written, so every isolate and process sharing the store sees the
554
+ * same entries and bytes. A charge leaves the ledger just after its
555
+ * result's deadline, which every chunk's TTL ends by; the storage TTL
556
+ * reclaims the rows themselves.
531
557
  */
532
- stashResult(key, chunks, ttlSeconds, prefix = "results:") {
533
- // The fiber runs synchronously up to its first storage call. With nothing
534
- // expired to reclaim, that is the first chunk write, after the
535
- // reservation: a concurrent stash already sees this one's charge.
558
+ stashResult(id, chunks, ttlSeconds, partition = scopes.results) {
536
559
  return runOnPartition(Effect.gen({ self: this }, function* () {
537
- const maxBytes = this.opts.results?.maxStashBytes ?? 8 * 1024 * 1024;
538
- const maxEntries = this.opts.results?.maxStashEntries ?? 64;
560
+ const maxBytes = this.opts.results?.maxStashBytes ?? CONFIG_DEFAULTS.results.maxStashBytes;
561
+ const maxEntries = this.opts.results?.maxStashEntries ?? CONFIG_DEFAULTS.results.maxStashEntries;
539
562
  // The paging envelope is ASCII, so its string length is its stored byte count.
540
563
  const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
541
564
  if (bytes > maxBytes || maxEntries === 0)
542
565
  return false;
543
- const now = yield* Clock.currentTimeMillis;
544
- for (const [oldKey, entry] of this.resultStash) {
545
- if (entry.busy || entry.expiresAt > now)
546
- continue;
547
- entry.busy = true;
548
- // TTL alone cannot reclaim a lazy backend. Keep the charge until
549
- // deletion succeeds, including writes which persisted before throwing;
550
- // a failed delete fails this stash, and the entry waits for the next.
551
- yield* Effect.gen({ self: this }, function* () {
552
- for (const staleKey of entry.keys)
553
- yield* storageDelete(staleKey);
554
- this.resultStash.delete(oldKey);
555
- this.resultStashBytes -= entry.bytes;
556
- }).pipe(Effect.ensuring(Effect.sync(() => { entry.busy = false; })));
557
- }
558
- if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
566
+ const keys = chunks.map((_, index) => partition + resultKeys.chunk(id, index));
567
+ const charge = keys[0];
568
+ // One deadline for the charge and every chunk under it. Writes take
569
+ // time, so a chunk's TTL is whatever remains of the deadline when it is
570
+ // written, never a fresh `ttlSeconds`, and no single write may take
571
+ // longer than the grace: no chunk outlives the charge that bounds it.
572
+ const deadline = yield* swapStashLedger((live, now) => {
573
+ const used = live.reduce((sum, entry) => sum + entry[1], 0);
574
+ if (live.length >= maxEntries || used + bytes > maxBytes)
575
+ return { result: undefined };
576
+ const end = now + ttlSeconds * 1000;
577
+ return { entries: [...live, [charge, bytes, end + STASH_LEDGER_GRACE_MS]], result: end };
578
+ });
579
+ if (deadline === undefined)
559
580
  return false;
560
- const fullKey = prefix + key;
561
- const keys = chunks.map((_, index) => index === 0 ? fullKey : `${fullKey}#${index}`);
562
- const entry = { keys, bytes, expiresAt: Infinity, busy: true };
563
- this.resultStash.set(fullKey, entry);
564
- this.resultStashBytes += bytes;
581
+ // A failed write may still have persisted. Delete every key it could
582
+ // have written and release the charge only when all of them are gone;
583
+ // otherwise the charge stays booked until it expires, by which time the
584
+ // storage TTL has removed whatever did land.
585
+ const release = Effect.gen(function* () {
586
+ for (const key of keys)
587
+ yield* storageDelete(key);
588
+ yield* swapStashLedger((live) => live.some((entry) => entry[0] === charge)
589
+ ? { entries: live.filter((entry) => entry[0] !== charge), result: undefined }
590
+ : { result: undefined });
591
+ }).pipe(Effect.ignore);
565
592
  // Trailing chunks first: the header chunk is what makes an id readable, so
566
593
  // a write that fails midway leaves no envelope pointing at absent chunks.
567
- yield* Effect.gen(function* () {
594
+ const written = yield* Effect.gen(function* () {
568
595
  for (let index = keys.length - 1; index >= 0; index--) {
569
- yield* storageSet(keys[index], chunks[index], { ttlSeconds });
596
+ const before = yield* Clock.currentTimeMillis;
597
+ const remaining = Math.floor((deadline - before) / 1000);
598
+ // Zero would mean no expiry: a stash that outlasts its deadline fails.
599
+ if (remaining < 1)
600
+ return false;
601
+ yield* storageSet(keys[index], chunks[index], { ttlSeconds: remaining });
602
+ if ((yield* Clock.currentTimeMillis) - before > STASH_LEDGER_GRACE_MS)
603
+ return false;
570
604
  }
571
- }).pipe(
572
- // A failed write expires at once, still charged, so the next stash
573
- // reclaims every key it may have persisted before failing.
574
- Effect.onExit((exit) => Clock.currentTimeMillis.pipe(Effect.map((writtenAt) => {
575
- entry.expiresAt = Exit.isSuccess(exit)
576
- ? writtenAt + ttlSeconds * 1000
577
- : 0;
578
- entry.busy = false;
579
- }))));
580
- return true;
605
+ return true;
606
+ }).pipe(Effect.onError(() => release));
607
+ if (!written)
608
+ yield* release;
609
+ return written;
581
610
  }), this.opts);
582
611
  }
583
612
  /**
584
- * Storage namespaced to the meta-tool result store (`results:` prefix), kept
585
- * separate from any connector's `conn:<id>:` namespace. Backs get_result.
613
+ * Storage namespaced to the root result partition, kept separate from any
614
+ * connector's namespace. Backs connecta.result.
586
615
  */
616
+ resultIdentity() {
617
+ return { subject: null, principal: null, endpoint: "/mcp", origin: null };
618
+ }
619
+ recheckResultAccess(_address, _classification, _signal) {
620
+ return Promise.resolve(true);
621
+ }
587
622
  resultsStorage() {
588
- return namespaced(this.opts.storage, "results:");
623
+ return namespaced(this.opts.storage, scopes.results);
624
+ }
625
+ credentialUiAvailable() {
626
+ return Boolean(this.opts.credentialUi);
589
627
  }
590
- credentialUiAvailable() { return Boolean(this.opts.credentialUi); }
591
- async bindOAuthHandoff() {
592
- // Shared OAuth already resolves in deployment-wide connector storage.
628
+ async bindOAuthHandoff(id, authorizationUrl, principalKey) {
629
+ const state = new URL(authorizationUrl).searchParams.get("state");
630
+ if (!state || !principalKey)
631
+ throw new Error("OAuth requires state and an initiating user");
632
+ await this.storeOAuthHandoff(id, state, principalKey);
593
633
  }
594
634
  observedOutputSchema(connectorId, definition) {
595
635
  return this.observedOutputSchemas.get(connectorId, definition);
@@ -607,608 +647,143 @@ export class Registry {
607
647
  return null;
608
648
  return { connector, toolName: parts.toolName };
609
649
  }
610
- catalogKey(id) {
611
- return `catalog:${id}`;
612
- }
613
- catalogChunkKey(id, revision, index) {
614
- return `${this.catalogKey(id)}:chunk:${revision}:${index}`;
615
- }
616
- validCatalogTools(value) {
617
- return value.every((tool) => tool !== null &&
618
- typeof tool === "object" &&
619
- typeof tool.name === "string");
620
- }
621
- validCatalogManifest(value) {
622
- if (!value || typeof value !== "object")
623
- return null;
624
- const manifest = value;
625
- const maxChunks = Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES) + 1;
626
- if (manifest.version !== 2 ||
627
- typeof manifest.revision !== "string" ||
628
- !/^sha256:[0-9]{1,8}:[0-9a-f]{64}$/.test(manifest.revision) ||
629
- !Number.isInteger(manifest.toolCount) ||
630
- manifest.toolCount < 0 ||
631
- manifest.toolCount > MAX_CATALOG_TOOLS ||
632
- !Number.isInteger(manifest.byteCount) ||
633
- manifest.byteCount < 2 ||
634
- manifest.byteCount > MAX_SERIALIZED_CATALOG_BYTES ||
635
- !manifest.revision.startsWith(`sha256:${manifest.byteCount}:`) ||
636
- !Number.isInteger(manifest.chunkCount) ||
637
- manifest.chunkCount < 1 ||
638
- manifest.chunkCount > maxChunks ||
639
- typeof manifest.fetchedAt !== "number" ||
640
- typeof manifest.expiresAt !== "number" ||
641
- typeof manifest.staleUntil !== "number") {
642
- return null;
643
- }
644
- return manifest;
645
- }
646
- parseCatalogManifest(raw) {
647
- if (!raw)
648
- return null;
650
+ /** Compare only the fingerprints accepted in this exact cache partition. */
651
+ recordCatalogDrift(previous, next, attribution) {
652
+ if (!previous)
653
+ return;
654
+ const { id, connector, ctx, privateCatalog } = attribution;
655
+ const before = new Map(previous.map((tool) => [tool.name, tool.fact]));
656
+ const after = new Map(next.map((tool) => [tool.name, tool.fact]));
657
+ const addedTools = [...after.keys()].filter((name) => !before.has(name)).length;
658
+ const removedTools = [...before.keys()].filter((name) => !after.has(name)).length;
659
+ const changedTools = [...after].filter(([name, fact]) => before.has(name) && before.get(name) !== fact).length;
660
+ if (addedTools || removedTools || changedTools)
661
+ this.opts.catalogDriftActivity?.recordChange?.({ ...this.opts.catalogDriftActivity, logger: this.opts.logger, ...(ctx.defer ? { defer: ctx.defer } : {}) }, {
662
+ connectorId: id,
663
+ drift: { kind: "catalog_changed", addedTools, removedTools, changedTools },
664
+ privateCatalog,
665
+ ...(connector.authScope === "personal" ? { personal: true } : {}),
666
+ }, activityRequest(ctx.requestScope));
667
+ }
668
+ async loadDownstreamTools(id, baseUrl, requestScope, callOptions = {}, scope) {
669
+ const connector = this.connectors.get(id);
670
+ if (!connector)
671
+ throw new Error(`Unknown connector "${id}"`);
672
+ if (connector.staticTools)
673
+ return markCatalogFreshness(connector.staticTools, Infinity);
674
+ const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
675
+ let listed;
649
676
  try {
650
- return this.validCatalogManifest(JSON.parse(raw));
677
+ listed = await connector.listTools(ctx);
651
678
  }
652
- catch {
653
- return null;
679
+ catch (error) {
680
+ const fallback = error instanceof ConnectorCallError && error.code === "unavailable"
681
+ ? await customCatalogFallback(ctx, id).catch(() => undefined)
682
+ : undefined;
683
+ if (!fallback)
684
+ throw sentSecretsForRequest(ctx.requestScope ?? ctx).redact(error);
685
+ listed = fallback;
654
686
  }
655
- }
656
- splitCatalogChunks(snapshot) {
657
- const chunks = [];
658
- const decoder = new TextDecoder("utf-8", {
659
- fatal: true,
660
- ignoreBOM: true,
661
- });
662
- let offset = 0;
663
- while (offset < snapshot.serializedBytes.byteLength) {
664
- let end = Math.min(offset + MAX_CATALOG_CHUNK_BYTES, snapshot.serializedBytes.byteLength);
665
- // Move a boundary that landed inside a multibyte UTF-8 sequence back to
666
- // the next character start. Every stored string then remains valid UTF-8.
667
- while (end < snapshot.serializedBytes.byteLength &&
668
- ((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80) {
669
- end--;
670
- }
671
- chunks.push(decoder.decode(snapshot.serializedBytes.subarray(offset, end)));
672
- offset = end;
687
+ const tools = redactCatalog(ctx, listed).map(({ classification: _ignored, ...fact }) => fact);
688
+ if (tools.length > MAX_CATALOG_TOOLS ||
689
+ encoder.encode(JSON.stringify(tools)).byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
690
+ throw new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", { retryable: false });
673
691
  }
674
- return chunks;
675
- }
676
- // The persisted catalog's one reader: the manifest at `catalog:<id>`, then
677
- // the chunks it names. A storage failure fails the effect, and the caller
678
- // logs it; a manifest or chunk that is invalid, missing, torn, or does not
679
- // match its fingerprint is logged here and read as no catalog, because a
680
- // catalog is complete or it is nothing.
681
- readCatalog(id, now) {
682
- return Effect.gen({ self: this }, function* () {
683
- const raw = yield* storageGet(this.catalogKey(id));
684
- if (!raw)
685
- return null;
686
- let parsed;
692
+ const facts = frozenFacts(structuredClone(tools));
693
+ const accepted = this.acceptToolNames(facts);
694
+ this.observeDroppedToolNames(connector, facts.length - accepted.length);
695
+ const review = catalogReviewOf(connector);
696
+ if (review)
697
+ await observeReviewedDrift(connector, review, accepted, this.opts.logger);
698
+ if (catalogFetchedAt(ctx) === undefined) {
687
699
  try {
688
- parsed = JSON.parse(raw);
689
- }
690
- catch {
691
- return null;
700
+ await storeCustomCatalogFallback(ctx, id, facts);
692
701
  }
693
- const logger = yield* LoggerService;
694
- const manifest = this.validCatalogManifest(parsed);
695
- if (!manifest) {
696
- logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
697
- return null;
702
+ catch (error) {
703
+ logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
698
704
  }
699
- if (manifest.staleUntil <= now)
700
- return null;
701
- const chunkReads = yield* forEachChunk(Array.from({ length: manifest.chunkCount }, (_, index) => index), (index) => storageGet(this.catalogChunkKey(id, manifest.revision, index)));
702
- const chunks = [];
703
- let chunkBytes = 0;
704
- for (const [index, read] of chunkReads.entries()) {
705
- if (Result.isFailure(read))
706
- return yield* Effect.fail(read.failure);
707
- const chunk = read.success;
708
- if (chunk === null) {
709
- logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
710
- return null;
711
- }
712
- const byteLength = encoder.encode(chunk).byteLength;
713
- chunkBytes += byteLength;
714
- if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
715
- chunkBytes > manifest.byteCount) {
716
- logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
717
- return null;
718
- }
719
- chunks.push(chunk);
720
- }
721
- const serializedTools = chunks.join("");
722
- const stored = yield* Effect.tryPromise({
723
- try: () => fingerprintSerializedCatalog(serializedTools),
724
- catch: (error) => error,
725
- });
726
- if (stored.byteLength !== manifest.byteCount ||
727
- stored.fingerprint !== manifest.revision) {
728
- logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
729
- return null;
730
- }
731
- let tools;
732
705
  try {
733
- tools = JSON.parse(serializedTools);
734
- }
735
- catch {
736
- logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
737
- return null;
738
- }
739
- if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
740
- logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
741
- return null;
706
+ await observeUncachedCatalogRefresh(ctx, id, facts);
742
707
  }
743
- if (tools.length !== manifest.toolCount) {
744
- logger.warn(`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`);
745
- return null;
708
+ catch (error) {
709
+ logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
746
710
  }
747
- return {
748
- tools,
749
- fingerprint: stored.fingerprint,
750
- fetchedAt: manifest.fetchedAt,
751
- expiresAt: manifest.expiresAt,
752
- staleUntil: manifest.staleUntil,
753
- };
754
- });
755
- }
756
- // Persist one complete catalog: every chunk, then the manifest. A snapshot
757
- // only reaches here after the tool and byte ceilings accepted it whole.
758
- storeCatalog(id, snapshot) {
759
- return Effect.gen({ self: this }, function* () {
760
- if (!this.persistToolCatalog)
761
- return;
762
- const fetchedAt = yield* Clock.currentTimeMillis;
763
- const expiresAt = fetchedAt + this.ttlMs;
764
- const staleUntil = expiresAt + this.staleMs;
765
- const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
766
- const chunks = this.splitCatalogChunks(snapshot);
767
- const chunkWrites = yield* forEachChunk(chunks, (chunk, index) => storageSet(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, {
768
- ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS,
769
- }));
770
- for (const write of chunkWrites) {
771
- if (Result.isFailure(write))
772
- return yield* Effect.fail(write.failure);
773
- }
774
- // The manifest is the only publication point. A failed/partial chunk write
775
- // therefore leaves the previous manifest authoritative (or no catalog);
776
- // unreachable chunks carry a bounded TTL and require no prefix scan.
777
- const manifest = {
778
- version: 2,
779
- revision: snapshot.fingerprint,
780
- toolCount: snapshot.tools.length,
781
- byteCount: snapshot.serializedBytes.byteLength,
782
- chunkCount: chunks.length,
783
- fetchedAt,
784
- expiresAt,
785
- staleUntil,
786
- };
787
- yield* storageSet(this.catalogKey(id), JSON.stringify(manifest), {
788
- ttlSeconds,
789
- });
790
- });
791
- }
792
- // Remove the persisted catalog: the manifest and every chunk it names, all
793
- // at once under the chunk I/O bound. Every delete is attempted; the effect
794
- // fails with the manifest read's error first, then the manifest delete's,
795
- // then the first chunk delete's by index.
796
- deleteCatalog(id) {
797
- return Effect.gen({ self: this }, function* () {
798
- const read = yield* Effect.result(storageGet(this.catalogKey(id)));
799
- const manifest = Result.isSuccess(read)
800
- ? this.parseCatalogManifest(read.success)
801
- : null;
802
- // The root is authoritative, so attempt its deletion even when the
803
- // best-effort read needed for physical chunk cleanup failed. It need
804
- // not land first: a reader that finds the root but not a chunk has an
805
- // incomplete catalog, which is never served, and an eventually
806
- // consistent store could show it that way whatever the order.
807
- const keys = [
808
- this.catalogKey(id),
809
- ...Array.from({ length: manifest?.chunkCount ?? 0 }, (_, index) => this.catalogChunkKey(id, manifest.revision, index)),
810
- ];
811
- const deleted = yield* forEachChunk(keys, (key) => storageDelete(key));
812
- if (Result.isFailure(read))
813
- return yield* Effect.fail(read.failure);
814
- const failed = deleted.find(Result.isFailure);
815
- if (failed)
816
- return yield* Effect.fail(failed.failure);
817
- });
818
- }
819
- catalogGeneration(id) {
820
- return this.catalogGenerations.get(id) ?? 0;
821
- }
822
- advanceCatalogGeneration(id) {
823
- this.catalogGenerations.set(id, this.catalogGeneration(id) + 1);
824
- }
825
- /**
826
- * Keep this isolate's writes and invalidations ordered. Without the queue, an
827
- * old refresh can finish its storage.set after a credential change deletes
828
- * the catalog and resurrect the pre-change listing.
829
- */
830
- async enqueueCatalogMutation(id, operation) {
831
- // Mutations run in arrival order, each taking its turn from the one
832
- // queued before it. That one may belong to another request, and
833
- // completing a Deferred resumes its waiters inside the completing call,
834
- // so the wait is an edge of its own and this request's storage work
835
- // starts only after it. (Effect's Semaphore would do neither: it resumes
836
- // a waiter from the releasing fiber, and a newcomer can take the permit
837
- // before a waiter that queued earlier.) An operation logs its own storage
838
- // failure, so a turn always ends.
839
- const previous = this.catalogMutations.get(id);
840
- const turn = Deferred.makeUnsafe();
841
- this.catalogMutations.set(id, turn);
842
- try {
843
- if (previous) {
844
- // A cross-request Deferred alone looks hung to workerd. Keep a timer
845
- // owned by this request until the preceding mutation hands over its
846
- // turn; the race interrupts the timer as soon as the wait settles.
847
- await runEdge(Effect.raceFirst(Deferred.await(previous), Effect.forever(Effect.sleep(Duration.seconds(1)))));
848
- }
849
- await runOnPartition(operation, this.opts);
850
- }
851
- finally {
852
- if (this.catalogMutations.get(id) === turn) {
853
- this.catalogMutations.delete(id);
854
- }
855
- Deferred.doneUnsafe(turn, Exit.void);
856
- }
857
- }
858
- async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false, flight) {
859
- const generation = this.catalogGeneration(id);
860
- const tools = await connector.listTools(ctx);
861
- // The listing a maintained proxy just served is also the only catalog
862
- // comparison connecta ever makes. It rides this refresh whether or not the
863
- // result reaches a cache, because what drifted drifted.
864
- this.observeCatalogDrift(connector);
865
- // A deferred deadline may close the owned scope while a connector that
866
- // ignores abort is still listing. The completed list remains a valid drift
867
- // observation, but must not overwrite a newer same-generation refresh.
868
- // Blocking callers do not set this flag and retain their prior publication
869
- // behavior when an inbound abort races a completed listing.
870
- // Every returned listing must pass the ceilings, even when invalidation
871
- // or abandonment means it can no longer enter either shared cache layer.
872
- if (tools.length > MAX_CATALOG_TOOLS) {
873
- const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
874
- `${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
875
- this.opts.logger.warn(`[connecta] ${message}`);
876
- throw new Error(message);
877
- }
878
- const previous = this.cache.get(id);
879
- const snapshot = await snapshotCatalog(tools);
880
- if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
881
- const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
882
- `serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
883
- "refusing the complete catalog.";
884
- this.opts.logger.warn(`[connecta] ${message}`);
885
- throw new Error(message);
886
- }
887
- // The caller that began this refresh may still use its bounded result,
888
- // but credential changes, abandoned flights, and deferred cancellation
889
- // prevent publication. Recheck after snapshotting, which is asynchronous.
890
- if (!this.mayPublish(id, generation, flight) ||
891
- (skipPublicationWhenAborted && ctx.signal?.aborted)) {
892
- return tools;
893
711
  }
894
- const now = Date.now();
895
- const catalogChanged = !previous || previous.fingerprint !== snapshot.fingerprint;
896
- const shouldPersist = catalogChanged ||
897
- (previous !== undefined && previous.exp <= now) ||
898
- this.invalidated.has(id);
899
- this.cache.set(id, {
900
- tools,
901
- fingerprint: snapshot.fingerprint,
902
- exp: now + this.ttlMs,
903
- staleUntil: now + this.ttlMs + this.staleMs,
712
+ this.catalogObservedAt.set(id, catalogFetchedAt(ctx) ?? Date.now());
713
+ const provenance = catalogExpiry(ctx);
714
+ const freshUntil = provenance && !provenance.staleFallback ? provenance.expiresAt : 0;
715
+ this.catalogAccess.set(id, {
716
+ state: freshUntil > Date.now() ? "fresh" : "stale",
717
+ observedAt: new Date().toISOString(),
904
718
  });
905
- this.invalidated.delete(id);
906
- if (shouldPersist) {
907
- await this.enqueueCatalogMutation(id,
908
- // The generation is read when the turn comes, not when it is queued:
909
- // an invalidation queued meanwhile has already deleted the catalog.
910
- warnOnFailure(Effect.suspend(() => generation === this.catalogGeneration(id)
911
- ? this.storeCatalog(id, snapshot)
912
- : Effect.void), `connector "${id}" catalog persistence failed`));
913
- }
914
- return tools;
719
+ return markCatalogFreshness(facts, freshUntil);
915
720
  }
916
- /**
917
- * Whether a refresh may still enter the shared cache layers: its generation
918
- * is current, and the flight it runs in has been neither abandoned nor
919
- * outlived its bound. Asked at each publication point, since listing and
920
- * snapshotting both yield, and a late result must never overwrite the one a
921
- * fresh attempt published in the meantime.
922
- */
923
- mayPublish(id, generation, flight) {
924
- return (generation === this.catalogGeneration(id) &&
925
- (!flight || (!flight.abandoned && Date.now() < flight.abandonAt)));
721
+ /** The served catalog excludes control-character names; raw caches stay complete. */
722
+ acceptToolNames(tools) {
723
+ const accepted = tools.filter((tool) => !hasControlCharacters(tool.name));
724
+ return accepted.length === tools.length ? tools : accepted;
926
725
  }
927
- /**
928
- * Publish one shared refresh before starting its connector work. The first
929
- * caller owns the scope and signal, and the flight lives `boundMs` from now
930
- * at most; every later caller joins the result without gaining access to
931
- * that context. A flight found past its bound is abandoned here, and this
932
- * caller starts the fresh attempt.
933
- */
934
- startCatalogRefresh(id, generation, boundMs, work, ownerLeft = () => false) {
935
- const existing = this.catalogRefreshes.get(id);
936
- if (existing?.generation === generation) {
937
- if (Date.now() < existing.abandonAt)
938
- return { flight: existing };
939
- this.abandonCatalogRefresh(id, existing);
726
+ /** Observe intake once, never while serving an older cached listing. */
727
+ observeDroppedToolNames(connector, count) {
728
+ if (count > 0 || this.droppedToolNames.has(connector.id)) {
729
+ this.droppedToolNames.set(connector.id, { count, observedAt: new Date().toISOString() });
940
730
  }
941
- const flight = {
942
- generation,
943
- abandonAt: Date.now() + boundMs,
944
- abandoned: false,
945
- outcome: Deferred.makeUnsafe(),
946
- };
947
- this.catalogRefreshes.set(id, flight);
948
- const owner = Effect.onExit(work(flight), (exit) => Effect.sync(() => {
949
- if (this.catalogRefreshes.get(id) === flight) {
950
- this.catalogRefreshes.delete(id);
951
- }
952
- // A failure that was the owner's alone is no answer for anyone
953
- // else: its own cancellation (its signal, read here in its own
954
- // request), or anything that arrives once the flight is past its
955
- // bound. Joiners make a fresh attempt under their own deadlines.
956
- const ownersAlone = Exit.isFailure(exit) &&
957
- (flight.abandoned ||
958
- Date.now() >= flight.abandonAt ||
959
- ownerLeft());
960
- // Last, because joined fibers resume inside this call.
961
- Deferred.doneUnsafe(flight.outcome, ownersAlone ? Exit.fail(new AbandonedCatalogRefresh(id)) : exit);
962
- }));
963
- return { flight, owner };
964
731
  }
965
732
  /**
966
- * Give up on a flight that outlived its bound. Later readers start a fresh
967
- * attempt, its joiners are told to, and nothing its owner lists from now on
968
- * reaches a cache. The owner's work is left alone: it belongs to another
969
- * request, which nothing here may reach into, and it can no longer publish.
733
+ * One connector's catalog as served: the cached downstream facts, classified
734
+ * on every read by the connector's `classification`, into fresh objects.
735
+ * Safety is derived here and nowhere else, so no cache layer can carry a
736
+ * stale verdict — from an older review, an older release, or a stale
737
+ * fallback — and nothing a caller or decorator does to a served tool reaches
738
+ * the next read.
970
739
  */
971
- abandonCatalogRefresh(id, flight) {
972
- if (flight.abandoned)
973
- return;
974
- flight.abandoned = true;
975
- if (this.catalogRefreshes.get(id) === flight) {
976
- this.catalogRefreshes.delete(id);
977
- }
978
- this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh outlived its bound; abandoning it for a fresh attempt.`);
979
- Deferred.doneUnsafe(flight.outcome, Exit.fail(new AbandonedCatalogRefresh(id)));
980
- }
981
- /**
982
- * Wait on another caller's flight, no longer than its bound. Succeeds with
983
- * its tools, or with `undefined` when it will not answer for this caller —
984
- * past its bound, when this waiter abandons it, or failed for its owner
985
- * alone — so the caller makes a fresh attempt. The timer is this waiter's
986
- * own: on workerd it is what keeps a request that waits on another alive,
987
- * and bounded.
988
- */
989
- joinCatalogRefresh(id, flight) {
990
- return Effect.suspend(() => Deferred.await(flight.outcome).pipe(Effect.timeoutOption(Duration.millis(Math.max(0, flight.abandonAt - Date.now()))), Effect.flatMap((joined) => Option.isSome(joined)
991
- ? Effect.succeed(joined.value)
992
- : Effect.sync(() => {
993
- this.abandonCatalogRefresh(id, flight);
994
- return undefined;
995
- })), Effect.catch((error) => error instanceof AbandonedCatalogRefresh
996
- ? Effect.succeed(undefined)
997
- : Effect.fail(error))));
998
- }
999
- /** Force a live listTools refresh and replace both catalog cache layers. */
1000
- async refreshTools(id, baseUrl, requestScope, callOptions = {}) {
740
+ async loadTools(id, baseUrl, requestScope, callOptions = {}, scope) {
741
+ const tools = await this.loadDownstreamTools(id, baseUrl, requestScope, callOptions, scope);
1001
742
  const connector = this.connectors.get(id);
1002
743
  if (!connector)
1003
744
  throw new Error(`Unknown connector "${id}"`);
1004
- if (connector.staticTools)
1005
- return connector.staticTools;
1006
- // A flight lives as long as its owner will wait for it. Past that, the
1007
- // owner may have answered and gone, and on workerd its I/O went with it.
1008
- const boundMs = normalizeTimeoutMs(callOptions.timeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
1009
- for (;;) {
1010
- const { flight, owner } = this.startCatalogRefresh(id, this.catalogGeneration(id), boundMs, (flight) => Effect.tryPromise({
1011
- try: () => this.refreshToolsWithContext(id, connector, this.contextFor(id, baseUrl, requestScope, callOptions), false, flight),
1012
- catch: (error) => error,
1013
- }), () => callOptions.signal?.aborted === true);
1014
- // The owner lists in its own request. A joiner waits through an edge of
1015
- // its own, under its own signal, and whatever it does with the tools
1016
- // happens after that, back in its request (see "Effect inside" in
1017
- // documentation/architecture.md). A flight that will not answer for it
1018
- // sends it round again, to join a fresh attempt or to own one.
1019
- if (owner)
1020
- return runEdge(owner);
1021
- const joined = await runEdge(this.joinCatalogRefresh(id, flight), {
1022
- signal: callOptions.signal,
1023
- });
1024
- if (joined)
1025
- return joined;
1026
- }
1027
- }
1028
- observeCatalogAccess(id, state) {
1029
- this.catalogAccess.set(id, {
1030
- state,
1031
- observedAt: new Date().toISOString(),
1032
- });
1033
- }
1034
- /**
1035
- * Start or join one shared refresh. A newly deferred task owns its scope,
1036
- * deadline, and teardown; no inbound signal or request scope crosses into it.
1037
- */
1038
- deferCatalogRefresh(id, baseUrl, expectedGeneration, options) {
1039
- const connector = this.connectors.get(id);
1040
- const defer = options.defer;
1041
- if (!connector || connector.staticTools || !defer)
1042
- return;
1043
- // The refresh this request starts, if none of this generation is live.
1044
- // Its scope is closed however it ends, deadline included, before the
1045
- // flight completes.
1046
- const refresh = (flight) => withDeadlineEffect((signal) => Effect.gen({ self: this }, function* () {
1047
- const current = this.cache.get(id);
1048
- if (current && current.exp > Date.now())
1049
- return current.tools;
1050
- if (expectedGeneration !== this.catalogGeneration(id) ||
1051
- this.invalidated.has(id)) {
1052
- return yield* Effect.fail(new Error(`Deferred catalog refresh of "${id}" was invalidated before it started.`));
1053
- }
1054
- const ctx = this.contextFor(id, baseUrl, {}, {
1055
- signal,
1056
- timeoutMs: options.refreshTimeoutMs,
1057
- });
1058
- yield* closeScopeOnExit(connector, ctx, defer);
1059
- return yield* Effect.tryPromise({
1060
- try: () => this.refreshToolsWithContext(id, connector, ctx, true, flight),
1061
- catch: (error) => error,
1062
- });
1063
- }), {
1064
- timeoutMs: options.refreshTimeoutMs,
1065
- timeoutError: new Error(`deferred catalog refresh of "${id}" timed out after ${options.refreshTimeoutMs}ms`),
1066
- });
1067
- const logFailure = (err) => {
1068
- this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
745
+ const accepted = this.acceptToolNames(tools);
746
+ const overrides = this.classification[id];
747
+ this.validateClassification(id, accepted, overrides);
748
+ const review = catalogReviewOf(connector);
749
+ return carryCatalogFreshness(tools, review
750
+ ? await classifyCatalog(review, id, accepted, this.opts.logger, this.verifiedFacts, overrides)
751
+ : accepted.map((tool) => this.publishUnreviewedTool(id, tool)));
752
+ }
753
+ publishUnreviewedTool(id, tool) {
754
+ const override = this.classification[id]?.[tool.name];
755
+ return {
756
+ ...structuredClone(tool),
757
+ classification: classifyTool(tool, override),
758
+ ...(override !== undefined
759
+ ? {
760
+ annotations: {
761
+ ...structuredClone(tool.annotations),
762
+ readOnlyHint: override === "read",
763
+ destructiveHint: override === "write",
764
+ },
765
+ }
766
+ : {}),
1069
767
  };
1070
- const { flight, owner } = this.startCatalogRefresh(id, expectedGeneration, options.refreshTimeoutMs, (flight) => Effect.scoped(refresh(flight)));
1071
- if (owner) {
1072
- // Runs past this response, under the runtime's waitUntil below.
1073
- flight.deferredTail = detach(Effect.catchCause(owner, (cause) => Effect.sync(() => logFailure(Cause.squash(cause)))));
1074
- }
1075
- // A stale reader that joins a blocking refresh logs its failure once for
1076
- // every reader after it, and waits through an edge of its own, no longer
1077
- // than the flight's bound.
1078
- flight.deferredTail ??= runEdge(this.joinCatalogRefresh(id, flight)).then(() => { }, logFailure);
1079
- try {
1080
- defer(flight.deferredTail);
1081
- }
1082
- catch (err) {
1083
- this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh could not attach to the runtime: ${msg(err)}`);
1084
- }
1085
768
  }
1086
- /** Cached listTools with in-memory + persisted serializable catalog layers. */
1087
- async loadTools(id, baseUrl, requestScope, callOptions = {}, readOptions) {
1088
- const connector = this.connectors.get(id);
1089
- if (!connector)
1090
- throw new Error(`Unknown connector "${id}"`);
1091
- if (connector.staticTools)
1092
- return connector.staticTools;
1093
- const now = Date.now();
1094
- const requestGeneration = this.catalogGeneration(id);
1095
- const hit = this.cache.get(id);
1096
- if (hit && hit.exp > now) {
1097
- if (readOptions?.defer)
1098
- this.observeCatalogAccess(id, "fresh");
1099
- return hit.tools;
1100
- }
1101
- let stale = hit && hit.staleUntil > now
1102
- ? { tools: hit.tools, staleUntil: hit.staleUntil }
1103
- : undefined;
1104
- if (this.persistToolCatalog && !this.invalidated.has(id)) {
1105
- const generation = this.catalogGeneration(id);
1106
- let persisted = null;
1107
- try {
1108
- persisted = await runOnPartition(this.readCatalog(id, now), this.opts);
1109
- }
1110
- catch (err) {
1111
- this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
1112
- }
1113
- if (generation !== this.catalogGeneration(id)) {
1114
- persisted = null;
1115
- stale = undefined;
1116
- }
1117
- // Storage can yield while another request finishes a live refresh. Read
1118
- // the shared cache again before an older manifest gets any authority.
1119
- // The candidate with the later fresh deadline wins; both candidates have
1120
- // already passed their own fingerprint and completeness checks.
1121
- const reconciledAt = Date.now();
1122
- const current = this.cache.get(id);
1123
- const usableCurrent = current && current.staleUntil > reconciledAt ? current : undefined;
1124
- if (usableCurrent) {
1125
- stale = {
1126
- tools: usableCurrent.tools,
1127
- staleUntil: usableCurrent.staleUntil,
1128
- };
1129
- if (usableCurrent.exp > reconciledAt) {
1130
- if (readOptions?.defer)
1131
- this.observeCatalogAccess(id, "fresh");
1132
- return usableCurrent.tools;
1133
- }
1134
- }
1135
- if (persisted &&
1136
- persisted.staleUntil > reconciledAt &&
1137
- (!usableCurrent || persisted.expiresAt > usableCurrent.exp)) {
1138
- this.cache.set(id, {
1139
- tools: persisted.tools,
1140
- fingerprint: persisted.fingerprint,
1141
- exp: persisted.expiresAt,
1142
- staleUntil: persisted.staleUntil,
1143
- });
1144
- if (persisted.expiresAt > reconciledAt) {
1145
- if (readOptions?.defer)
1146
- this.observeCatalogAccess(id, "fresh");
1147
- return persisted.tools;
1148
- }
1149
- stale = {
1150
- tools: persisted.tools,
1151
- staleUntil: persisted.staleUntil,
1152
- };
1153
- }
1154
- }
1155
- if (stale &&
1156
- stale.staleUntil > Date.now() &&
1157
- readOptions?.defer &&
1158
- requestGeneration === this.catalogGeneration(id) &&
1159
- !this.invalidated.has(id)) {
1160
- this.deferCatalogRefresh(id, baseUrl, requestGeneration, readOptions);
1161
- // Invalidation can land synchronously while the refresh is attached.
1162
- // Repeat the authority check at the exact stale publication point.
1163
- if (stale.staleUntil > Date.now() &&
1164
- requestGeneration === this.catalogGeneration(id) &&
1165
- !this.invalidated.has(id)) {
1166
- this.observeCatalogAccess(id, "stale");
1167
- return stale.tools;
1168
- }
1169
- }
1170
- try {
1171
- const tools = await this.refreshTools(id, baseUrl, requestScope, callOptions);
1172
- if (readOptions?.defer)
1173
- this.observeCatalogAccess(id, "fresh");
1174
- return tools;
1175
- }
1176
- catch (err) {
1177
- if (stale &&
1178
- stale.staleUntil > Date.now() &&
1179
- requestGeneration === this.catalogGeneration(id) &&
1180
- !this.invalidated.has(id)) {
1181
- if (readOptions?.defer)
1182
- this.observeCatalogAccess(id, "stale");
1183
- this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh failed; serving stale catalog: ${msg(err)}`);
1184
- return stale.tools;
1185
- }
1186
- throw err;
769
+ /** Construction-only view for the secret-free static config description. */
770
+ describeStaticTools(id) {
771
+ const tools = this.connectors.get(id)?.staticTools;
772
+ return tools?.map((tool) => this.publishUnreviewedTool(id, tool));
773
+ }
774
+ validateClassification(id, tools, overrides) {
775
+ const known = new Set(tools.map((tool) => tool.name));
776
+ for (const name of Object.keys(overrides ?? {})) {
777
+ if (!known.has(name))
778
+ throw new Error(`ConnectaConfig.classification: connector "${id}" has no tool "${name}"`);
1187
779
  }
1188
780
  }
1189
- /**
1190
- * Coalesce one connector's catalog traversal inside one inbound request. The
1191
- * WeakMap neither roots the request scope nor lets its connector context
1192
- * escape into another request; settled entries are also removed eagerly.
1193
- *
1194
- * The read owns its signal, and each caller waits under its own: one that
1195
- * is cancelled while others wait leaves with its own failure, and the read
1196
- * is cancelled only when every caller has gone (src/runtime/shared-read.ts).
1197
- *
1198
- * The deployment-wide flight below this layer coalesces the actual live
1199
- * refresh across requests. Its owner's context governs the listing, and its
1200
- * owner's deadline bounds it; later callers join only its result, never its
1201
- * request scope, and a failure that was the owner's alone sends them to a
1202
- * fresh attempt rather than to them.
1203
- */
1204
- async getTools(id, baseUrl, requestScope, callOptions = {}, readOptions) {
781
+ async getTools(id, baseUrl, requestScope, callOptions = {}, scope) {
1205
782
  const connector = this.connectors.get(id);
1206
783
  if (!connector)
1207
784
  throw new Error(`Unknown connector "${id}"`);
1208
- if (connector.staticTools)
1209
- return connector.staticTools;
1210
785
  if (!requestScope) {
1211
- return this.loadTools(id, baseUrl, requestScope, callOptions, readOptions);
786
+ return this.loadTools(id, baseUrl, requestScope, callOptions, scope);
1212
787
  }
1213
788
  let loads = this.requestCatalogLoads.get(requestScope);
1214
789
  if (!loads) {
@@ -1216,19 +791,20 @@ export class Registry {
1216
791
  this.requestCatalogLoads.set(requestScope, loads);
1217
792
  }
1218
793
  const requestLoads = loads;
1219
- let load = requestLoads.get(id);
794
+ const key = JSON.stringify([id, scope?.principalKey, scope?.subjectKey, scope?.caller]);
795
+ let load = requestLoads.get(key);
1220
796
  if (!load) {
1221
- const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, readOptions),
797
+ const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, scope),
1222
798
  // Settled or cancelled, the read leaves the map before any caller
1223
799
  // resumes, so the next one asks the caches afresh.
1224
800
  () => {
1225
- if (requestLoads.get(id) === started)
1226
- requestLoads.delete(id);
801
+ if (requestLoads.get(key) === started)
802
+ requestLoads.delete(key);
1227
803
  if (requestLoads.size === 0) {
1228
804
  this.requestCatalogLoads.delete(requestScope);
1229
805
  }
1230
806
  });
1231
- requestLoads.set(id, started);
807
+ requestLoads.set(key, started);
1232
808
  load = started;
1233
809
  }
1234
810
  return runEdge(load.join(callOptions.signal));
@@ -1244,16 +820,21 @@ export class Registry {
1244
820
  return shape.state === "mismatch" ? shape.message : undefined;
1245
821
  }
1246
822
  catch (error) {
1247
- this.opts.logger.warn(`[connecta] connector "${id}" credential shape read failed: ${msg(error)}`);
823
+ logFailure(this.opts.logger, "credential shape read failed", failureRecord({ connector: id }, error));
1248
824
  return undefined;
1249
825
  }
1250
826
  }
827
+ /** Age of a complete cache entry; reads never refresh it. */
828
+ catalogAgeMs(id) {
829
+ const observedAt = this.catalogObservedAt.get(id);
830
+ return observedAt === undefined ? null : Math.max(0, Date.now() - observedAt);
831
+ }
1251
832
  /** Best-effort connector status for the operator UI. */
1252
- async statusFor(id, baseUrl, requestScope = {}, callOptions = {}) {
833
+ async statusFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
1253
834
  const connector = this.connectors.get(id);
1254
835
  if (!connector)
1255
- return { state: "error", message: "Unknown connector" };
1256
- const ctx = this.contextFor(id, baseUrl, requestScope, callOptions);
836
+ return ownStatus({ state: "error", message: "Unknown connector" });
837
+ const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
1257
838
  // Whatever the state turns out to be, it carries the drift the last
1258
839
  // refresh saw. Reading it is a lookup, not a probe: a connector that has
1259
840
  // listed nothing yet reports nothing, and status never lists on its own to
@@ -1262,56 +843,60 @@ export class Registry {
1262
843
  // is third-party output, and status is read by the operator UI and copied
1263
844
  // into responses.
1264
845
  const withObservations = (status) => {
1265
- const report = boundedCatalogDrift(connector.catalogDrift?.());
846
+ const report = this.catalogDriftOf(connector);
1266
847
  const access = this.catalogAccess.get(id);
1267
848
  // Connector.status is an open plugin seam. Rebuild its public fields so
1268
849
  // a connector cannot smuggle payload through either registry-owned
1269
- // observation when this runtime has not made one.
1270
- const boundedStatus = {
1271
- state: status.state,
1272
- ...(status.message !== undefined ? { message: status.message } : {}),
1273
- };
1274
- return {
1275
- ...boundedStatus,
850
+ // observation when this runtime has not made one, nor through its
851
+ // message, which survives only when connecta wrote it (INV-6).
852
+ return Object.assign(boundedStatus(status), {
1276
853
  ...(report ? { catalogDrift: report } : {}),
1277
854
  ...(access ? { catalogAccess: { ...access } } : {}),
1278
- };
855
+ });
1279
856
  };
857
+ // Credential declarations own their readiness even when a static catalog
858
+ // or a plugin status would otherwise report success. Never probe an empty slot.
859
+ if (connector.credential) {
860
+ try {
861
+ const values = (await this.opts.credentialVault?.getAll(id, this.opts.credentialOwner)) ?? null;
862
+ const shape = storedCredentialShape(connector.credential, values);
863
+ if (shape.state === "missing")
864
+ return withObservations(ownStatus({ state: "credential_required" }));
865
+ if (shape.state === "mismatch")
866
+ return withObservations(ownStatus({ state: "auth_required" }));
867
+ }
868
+ catch (err) {
869
+ return withObservations(failureStatus(id, err));
870
+ }
871
+ }
1280
872
  if (connector.status) {
1281
873
  try {
1282
874
  return withObservations(await connector.status(ctx));
1283
875
  }
1284
876
  catch (err) {
1285
- return withObservations({ state: "error", message: msg(err) });
877
+ return withObservations(failureStatus(id, err));
1286
878
  }
1287
879
  }
1288
880
  try {
1289
- await this.getTools(id, baseUrl, requestScope, callOptions);
881
+ await this.getTools(id, baseUrl, requestScope, callOptions, scope);
1290
882
  return withObservations({ state: "ok" });
1291
883
  }
1292
884
  catch (err) {
1293
- return withObservations({ state: "error", message: msg(err) });
885
+ return withObservations(failureStatus(id, err));
1294
886
  }
1295
887
  }
1296
- markCatalogInvalid(id) {
1297
- this.advanceCatalogGeneration(id);
1298
- this.cache.delete(id);
1299
- this.invalidated.add(id);
1300
- }
1301
- deleteStoredCatalog(id) {
1302
- return this.enqueueCatalogMutation(id, warnOnFailure(this.deleteCatalog(id), `connector "${id}" catalog invalidation failed`));
1303
- }
1304
- /** Drop a connector's cached tool list (e.g. after auth completes). */
888
+ /** Rotate the storage generation; in-flight old readers cannot republish it. */
1305
889
  invalidate(id) {
1306
- this.markCatalogInvalid(id);
1307
- if (this.persistToolCatalog)
1308
- void this.deleteStoredCatalog(id);
890
+ void this.invalidateStored(id);
1309
891
  }
1310
- /** Drop both in-memory and persisted tool catalogs. */
1311
892
  async invalidateStored(id) {
1312
- this.markCatalogInvalid(id);
1313
- if (this.persistToolCatalog)
1314
- await this.deleteStoredCatalog(id);
893
+ this.catalogObservedAt.delete(id);
894
+ try {
895
+ await invalidateCatalogCache(this.opts.catalogStorage ?? this.opts.storage, id);
896
+ }
897
+ catch (error) {
898
+ logFailure(this.opts.logger, "catalog invalidation failed", failureRecord({ connector: id }, error));
899
+ }
1315
900
  }
1316
901
  }
1317
902
  class ScopedRegistryView {
@@ -1322,9 +907,7 @@ class ScopedRegistryView {
1322
907
  /** An evicted view's admission controller must remain closed. */
1323
908
  admissionPersonal;
1324
909
  get personal() {
1325
- return this.scope.principalKey
1326
- ? this.root.personalRegistry(this.scope.principalKey)
1327
- : undefined;
910
+ return this.scope.principalKey ? this.root.personalRegistry(this.scope.principalKey) : undefined;
1328
911
  }
1329
912
  constructor(root, allowed, scope) {
1330
913
  this.root = root;
@@ -1333,9 +916,7 @@ class ScopedRegistryView {
1333
916
  this.maxResultBytes = root.maxResultBytes;
1334
917
  // Check capacity at construction, then resolve the current registry on
1335
918
  // use. A retained view must not revive one evicted while it was idle.
1336
- this.admissionPersonal = scope.principalKey
1337
- ? root.personalRegistry(scope.principalKey)
1338
- : undefined;
919
+ this.admissionPersonal = scope.principalKey ? root.personalRegistry(scope.principalKey) : undefined;
1339
920
  }
1340
921
  registryFor(id, admission = false) {
1341
922
  if (!this.allowed.has(id))
@@ -1350,9 +931,17 @@ class ScopedRegistryView {
1350
931
  listConnectors() {
1351
932
  return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
1352
933
  }
934
+ canReadConnectorSkills(id) {
935
+ return (this.registryFor(id) !== undefined && !this.scope.toolAccess?.has(id) && !this.scope.guardedToolAccess?.has(id));
936
+ }
1353
937
  getConnector(id) {
1354
938
  return this.registryFor(id)?.getConnector(id);
1355
939
  }
940
+ getResourceConnector(id) {
941
+ if (this.scope.toolAccess?.has(id) || this.scope.guardedToolAccess?.has(id))
942
+ return undefined;
943
+ return this.getConnector(id);
944
+ }
1356
945
  resolveAddress(address) {
1357
946
  const parsed = splitAddress(address);
1358
947
  if (!parsed)
@@ -1365,7 +954,7 @@ class ScopedRegistryView {
1365
954
  if (!registry) {
1366
955
  throw new Error(`Unknown connector "${args[0]}"`);
1367
956
  }
1368
- const tools = await registry.getTools(...args);
957
+ const tools = await registry.getTools(args[0], args[1], args[2], args[3], this.scope);
1369
958
  const guarded = this.scope.guardedToolAccess?.get(args[0]);
1370
959
  const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
1371
960
  if (!granted)
@@ -1373,8 +962,7 @@ class ScopedRegistryView {
1373
962
  // Every consumer — search, describe, call_tool, and a program's
1374
963
  // connecta.call — resolves through this list, so an ungranted tool is
1375
964
  // indistinguishable from one the connector never had.
1376
- const visible = tools.filter((tool) => granted.has(tool.name) &&
1377
- (!guarded?.has(tool.name) || isExplicitlyReadOnly(tool)));
965
+ const visible = tools.filter((tool) => granted.has(tool.name) && (!guarded?.has(tool.name) || tool.classification === "read"));
1378
966
  if (visible.length < granted.size) {
1379
967
  const present = new Set(tools.map((tool) => tool.name));
1380
968
  for (const name of granted) {
@@ -1382,13 +970,13 @@ class ScopedRegistryView {
1382
970
  this.root.noteAbsentGrant(args[0], name);
1383
971
  }
1384
972
  }
1385
- return visible;
973
+ return carryCatalogFreshness(tools, visible);
1386
974
  }
1387
975
  contextFor(...args) {
1388
976
  const registry = this.registryFor(args[0]);
1389
977
  if (!registry)
1390
978
  throw new Error(`Unknown connector "${args[0]}"`);
1391
- return attachCaller(registry.contextFor(...args), this.scope.caller);
979
+ return registry.contextFor(args[0], args[1], args[2], args[3], this.scope);
1392
980
  }
1393
981
  admitCall(...args) {
1394
982
  const registry = this.registryFor(args[0], true);
@@ -1397,19 +985,26 @@ class ScopedRegistryView {
1397
985
  }
1398
986
  return registry.admitCall(...args);
1399
987
  }
1400
- stashResult(key, chunks, ttlSeconds) {
1401
- return this.root.stashResult(key, chunks, ttlSeconds, this.scope.subjectKey ? `subject:${this.scope.subjectKey}:` : "results:");
988
+ stashResult(id, chunks, ttlSeconds) {
989
+ return this.root.stashResult(id, chunks, ttlSeconds, this.scope.subjectKey ? scopes.subject(this.scope.subjectKey) : scopes.results);
990
+ }
991
+ resultIdentity() {
992
+ return {
993
+ subject: this.scope.subjectKey ?? null,
994
+ principal: this.scope.principalKey ?? null,
995
+ endpoint: this.scope.endpoint ?? "/mcp",
996
+ origin: this.scope.origin ?? null,
997
+ };
998
+ }
999
+ recheckResultAccess(address, classification, signal) {
1000
+ return this.scope.currentResultAccess?.(address, classification, signal) ?? Promise.resolve(true);
1402
1001
  }
1403
1002
  resultsStorage() {
1404
- return this.scope.subjectKey
1405
- ? this.root.scopedStorage(this.scope.subjectKey)
1406
- : this.root.resultsStorage();
1003
+ return this.scope.subjectKey ? this.root.scopedStorage(this.scope.subjectKey) : this.root.resultsStorage();
1407
1004
  }
1408
1005
  credentialDriftFor(id) {
1409
1006
  const registry = this.registryFor(id);
1410
- return registry
1411
- ? registry.credentialDriftFor(id)
1412
- : Promise.resolve(undefined);
1007
+ return registry ? registry.credentialDriftFor(id) : Promise.resolve(undefined);
1413
1008
  }
1414
1009
  observedOutputSchema(connectorId, definition) {
1415
1010
  return this.registryFor(connectorId)?.observedOutputSchema(connectorId, definition);
@@ -1417,30 +1012,23 @@ class ScopedRegistryView {
1417
1012
  observeOutputShape(connectorId, definition, value) {
1418
1013
  this.registryFor(connectorId)?.observeOutputShape(connectorId, definition, value);
1419
1014
  }
1015
+ catalogAgeMs(id) {
1016
+ return this.registryFor(id)?.catalogAgeMs(id) ?? null;
1017
+ }
1420
1018
  statusFor(...args) {
1421
1019
  const registry = this.registryFor(args[0]);
1422
1020
  return registry
1423
- ? registry.statusFor(...args)
1021
+ ? registry.statusFor(args[0], args[1], args[2], args[3], this.scope)
1424
1022
  : Promise.resolve({ state: "error", message: "Unknown connector" });
1425
1023
  }
1426
1024
  invalidateStored(id) {
1427
1025
  const registry = this.registryFor(id);
1428
1026
  return registry ? registry.invalidateStored(id) : Promise.resolve();
1429
1027
  }
1430
- credentialUiAvailable() { return this.root.credentialUiAvailable(); }
1431
- async bindOAuthHandoff(id, authorizationUrl) {
1432
- const connector = this.getConnector(id);
1433
- if (connector?.authScope !== "personal" || !this.scope.principalKey)
1434
- return;
1435
- let state = null;
1436
- try {
1437
- state = new URL(authorizationUrl).searchParams.get("state");
1438
- }
1439
- catch {
1440
- return;
1441
- }
1442
- if (state) {
1443
- await this.root.storeOAuthHandoff(id, state, this.scope.principalKey);
1444
- }
1028
+ credentialUiAvailable() {
1029
+ return this.root.credentialUiAvailable();
1030
+ }
1031
+ async bindOAuthHandoff(id, authorizationUrl, principalKey) {
1032
+ await this.root.bindOAuthHandoff(id, authorizationUrl, principalKey ?? this.scope.principalKey);
1445
1033
  }
1446
1034
  }