@zackbart/connecta 0.28.1 → 0.30.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 (332) hide show
  1. package/CHANGELOG.md +855 -5
  2. package/README.md +125 -116
  3. package/bin/connecta.mjs +121 -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/argument-redaction.js +230 -0
  12. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  13. package/dist/auth/clerk-sdk/client.js +5 -0
  14. package/dist/auth/clerk-sdk/generated.js +1845 -0
  15. package/dist/auth/clerk-transport.js +19 -0
  16. package/dist/auth/clerk.d.ts +17 -7
  17. package/dist/auth/clerk.js +189 -107
  18. package/dist/auth/cloudflare-access.js +25 -26
  19. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  20. package/dist/auth/downstream-client-metadata.js +133 -0
  21. package/dist/auth/downstream-oauth.js +1584 -1580
  22. package/dist/auth/downstream-revocation.js +106 -0
  23. package/dist/auth/oauth-token-response.js +231 -0
  24. package/dist/auth/oauth-v2-migration.js +116 -0
  25. package/dist/auth/static-oauth.js +93 -71
  26. package/dist/auth-elicitation.js +288 -0
  27. package/dist/authorization-handoff.js +111 -0
  28. package/dist/branding.d.ts +8 -16
  29. package/dist/branding.js +26 -59
  30. package/dist/byte-read-response.js +39 -0
  31. package/dist/call-admission.js +12 -16
  32. package/dist/catalog-cache.js +739 -0
  33. package/dist/catalog-drift.d.ts +50 -15
  34. package/dist/catalog-drift.js +365 -128
  35. package/dist/catalog-fingerprint.js +2 -4
  36. package/dist/catalog-freshness.js +13 -0
  37. package/dist/catalog-limits.js +2 -2
  38. package/dist/catalog-service.js +409 -225
  39. package/dist/catalog.js +43 -84
  40. package/dist/concurrency.js +3 -4
  41. package/dist/config-defaults.d.ts +39 -0
  42. package/dist/config-defaults.js +46 -0
  43. package/dist/config-schema.d.ts +181 -0
  44. package/dist/config-schema.js +435 -0
  45. package/dist/config-value-sources.js +102 -0
  46. package/dist/config.d.ts +633 -0
  47. package/dist/config.js +508 -0
  48. package/dist/connector-access.d.ts +1 -0
  49. package/dist/connector-access.js +13 -15
  50. package/dist/connector-caller.d.ts +8 -0
  51. package/dist/connector-caller.js +15 -6
  52. package/dist/connector-scope.js +1 -1
  53. package/dist/connectors/api-connector.d.ts +59 -7
  54. package/dist/connectors/api-connector.js +106 -19
  55. package/dist/connectors/api.js +4 -1
  56. package/dist/connectors/guarded-fetch.d.ts +144 -0
  57. package/dist/connectors/guarded-fetch.js +87 -38
  58. package/dist/connectors/negotiation-cache.js +46 -0
  59. package/dist/connectors/option-shapes.js +27 -0
  60. package/dist/connectors/remote-mcp.d.ts +30 -4
  61. package/dist/connectors/remote-mcp.js +1774 -320
  62. package/dist/connectors/resource-uri.js +335 -0
  63. package/dist/credential-contract.d.ts +10 -0
  64. package/dist/credential-rules.js +1 -3
  65. package/dist/credential-url.js +108 -0
  66. package/dist/credentials.d.ts +6 -1
  67. package/dist/credentials.js +27 -18
  68. package/dist/d1.d.ts +130 -0
  69. package/dist/d1.js +290 -0
  70. package/dist/describe-config.d.ts +165 -0
  71. package/dist/describe-config.js +315 -0
  72. package/dist/described.js +106 -0
  73. package/dist/downstream-input-context.js +128 -0
  74. package/dist/downstream-input.js +325 -0
  75. package/dist/errors.d.ts +132 -31
  76. package/dist/errors.js +175 -87
  77. package/dist/execute.js +798 -318
  78. package/dist/executor-admission.d.ts +1 -0
  79. package/dist/executor-admission.js +11 -15
  80. package/dist/executor-contract.d.ts +20 -0
  81. package/dist/executor-contract.js +48 -0
  82. package/dist/executor-result.js +30 -13
  83. package/dist/executors/quickjs-child.js +15 -9
  84. package/dist/executors/quickjs-protocol.js +5 -6
  85. package/dist/executors/quickjs-runtime.js +152 -59
  86. package/dist/executors/quickjs.js +49 -39
  87. package/dist/favicon.js +1 -2
  88. package/dist/fix-prompt.js +1 -3
  89. package/dist/guest-runtime.js +238 -0
  90. package/dist/guest-types.d.ts +218 -0
  91. package/dist/guest-types.js +1 -0
  92. package/dist/html-security.js +24 -0
  93. package/dist/identity.js +1 -3
  94. package/dist/inbound-credential.js +52 -0
  95. package/dist/index.d.ts +24 -256
  96. package/dist/index.js +65 -478
  97. package/dist/invocation-auth.js +69 -0
  98. package/dist/invocation.js +262 -79
  99. package/dist/json-schema.d.ts +1 -1
  100. package/dist/mcp-client-context.js +35 -0
  101. package/dist/mcp-result.js +11 -7
  102. package/dist/meta-output.js +61 -0
  103. package/dist/meta-tool-names.d.ts +2 -0
  104. package/dist/meta-tool-names.js +3 -0
  105. package/dist/meta-tools.js +419 -272
  106. package/dist/module-contracts.d.ts +8 -23
  107. package/dist/node.d.ts +0 -2
  108. package/dist/node.js +2 -2
  109. package/dist/oauth-callback-outcome.js +1 -3
  110. package/dist/oauth-handoff.js +194 -0
  111. package/dist/oauth-sealing.js +20 -0
  112. package/dist/operator-record.d.ts +137 -0
  113. package/dist/operator-record.js +420 -0
  114. package/dist/operator-ui/assets.js +31 -0
  115. package/dist/operator-ui/contract.d.ts +72 -0
  116. package/dist/operator-ui/contract.js +1 -0
  117. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  118. package/dist/operator-ui/generated.js +5 -4
  119. package/dist/operator-ui/model.d.ts +12 -87
  120. package/dist/operator-ui/model.js +2 -9
  121. package/dist/operator-ui/setup-commands.js +1 -3
  122. package/dist/operator-ui/view.d.ts +323 -0
  123. package/dist/operator-ui/view.js +62 -189
  124. package/dist/page-styles.js +2 -3
  125. package/dist/program-result.js +5 -0
  126. package/dist/program-source.js +14 -2
  127. package/dist/{exempt-writes.js → program-writes.js} +60 -19
  128. package/dist/provider.d.ts +142 -0
  129. package/dist/provider.js +187 -0
  130. package/dist/providers/_shared/google/result-size.js +57 -0
  131. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  132. package/dist/providers/_shared/google/service-account.js +500 -0
  133. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  134. package/dist/providers/_shared/google/workspace.js +518 -0
  135. package/dist/providers/_shared/request-token.js +83 -0
  136. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  137. package/dist/providers/basecamp/index.js +997 -0
  138. package/dist/providers/basecamp/skill.generated.js +9 -0
  139. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  140. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  141. package/dist/providers/breeze/skill.generated.js +9 -0
  142. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  143. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  144. package/dist/providers/ccb/skill.generated.js +9 -0
  145. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  146. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  147. package/dist/providers/cloudflare/skill.generated.js +10 -0
  148. package/dist/providers/docs/index.d.ts +54 -0
  149. package/dist/providers/docs/index.js +1237 -0
  150. package/dist/providers/docs/skill.generated.js +9 -0
  151. package/dist/providers/drive/index.d.ts +53 -0
  152. package/dist/providers/drive/index.js +1523 -0
  153. package/dist/providers/drive/skill.generated.js +9 -0
  154. package/dist/providers/forms/index.d.ts +54 -0
  155. package/dist/providers/forms/index.js +1217 -0
  156. package/dist/providers/forms/skill.generated.js +11 -0
  157. package/dist/providers/github/auth.d.ts +32 -0
  158. package/dist/providers/github/auth.js +361 -0
  159. package/dist/providers/github/hosted.js +218 -0
  160. package/dist/providers/github/index.d.ts +13 -0
  161. package/dist/providers/github/index.js +140 -0
  162. package/dist/providers/github/key.js +100 -0
  163. package/dist/providers/github/rest.js +321 -0
  164. package/dist/providers/github/scope.d.ts +29 -0
  165. package/dist/providers/github/scope.js +124 -0
  166. package/dist/providers/github/skill.generated.js +8 -0
  167. package/dist/providers/gmail/index.d.ts +51 -0
  168. package/dist/providers/gmail/index.js +1436 -0
  169. package/dist/providers/gmail/skill.generated.js +9 -0
  170. package/dist/providers/infisical/index.d.ts +7 -0
  171. package/dist/providers/infisical/index.js +713 -0
  172. package/dist/providers/infisical/skill.generated.js +8 -0
  173. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  174. package/dist/providers/linear/index.js +352 -0
  175. package/dist/providers/linear/skill.generated.js +8 -0
  176. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  177. package/dist/providers/mixpanel/index.js +416 -0
  178. package/dist/providers/mixpanel/skill.generated.js +9 -0
  179. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  180. package/dist/providers/{notion.js → notion/index.js} +226 -239
  181. package/dist/providers/notion/skill.generated.js +10 -0
  182. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  183. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  184. package/dist/providers/overflow/skill.generated.js +8 -0
  185. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  186. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  187. package/dist/providers/planning-center/skill.generated.js +10 -0
  188. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  189. package/dist/providers/revenuecat/index.js +557 -0
  190. package/dist/providers/revenuecat/skill.generated.js +11 -0
  191. package/dist/providers/sheets/index.d.ts +54 -0
  192. package/dist/providers/sheets/index.js +1237 -0
  193. package/dist/providers/sheets/skill.generated.js +10 -0
  194. package/dist/providers/slides/index.d.ts +53 -0
  195. package/dist/providers/slides/index.js +2645 -0
  196. package/dist/providers/slides/skill.generated.js +9 -0
  197. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  198. package/dist/providers/stripe/index.js +266 -0
  199. package/dist/providers/stripe/skill.generated.js +11 -0
  200. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  201. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  202. package/dist/providers/tithely/skill.generated.js +8 -0
  203. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  204. package/dist/providers/vercel/index.js +1038 -0
  205. package/dist/providers/vercel/mcp-ownership.js +212 -0
  206. package/dist/providers/vercel/skill.generated.js +11 -0
  207. package/dist/registry.d.ts +110 -171
  208. package/dist/registry.js +588 -927
  209. package/dist/request-state.js +21 -0
  210. package/dist/resource-read.js +103 -0
  211. package/dist/result-shapes.js +17 -49
  212. package/dist/retired-toolkits.js +1 -1
  213. package/dist/routes/access-tokens.js +3 -6
  214. package/dist/routes/activity-context.js +21 -0
  215. package/dist/routes/activity-disclosure.js +40 -0
  216. package/dist/routes/activity.js +59 -21
  217. package/dist/routes/actor-labels.js +3 -50
  218. package/dist/routes/connect.js +221 -0
  219. package/dist/routes/credentials.js +15 -27
  220. package/dist/routes/mcp.js +365 -130
  221. package/dist/routes/oauth-client-metadata.js +15 -0
  222. package/dist/routes/oauth-management.js +45 -107
  223. package/dist/routes/oauth.js +110 -57
  224. package/dist/routes/operator.js +3 -3
  225. package/dist/routes/shared.d.ts +25 -64
  226. package/dist/routes/shared.js +160 -80
  227. package/dist/routes/skills.js +30 -0
  228. package/dist/routes/ui-config.js +225 -0
  229. package/dist/routes/ui-data.js +40 -35
  230. package/dist/routes/ui.js +66 -56
  231. package/dist/runtime/admission.js +4 -2
  232. package/dist/runtime/call-admission.js +3 -1
  233. package/dist/runtime/connector-scope.js +1 -4
  234. package/dist/runtime/run.js +10 -39
  235. package/dist/runtime/services.js +12 -117
  236. package/dist/runtime/shared-read.js +1 -3
  237. package/dist/runtime/storage.js +8 -1
  238. package/dist/sent-secrets.d.ts +93 -0
  239. package/dist/sent-secrets.js +1013 -0
  240. package/dist/server.js +28 -71
  241. package/dist/skills.js +416 -174
  242. package/dist/sqlite.d.ts +48 -0
  243. package/dist/sqlite.js +181 -0
  244. package/dist/storage/keys.js +443 -0
  245. package/dist/storage/memory.js +16 -4
  246. package/dist/storage/sql.d.ts +66 -0
  247. package/dist/storage/sql.js +543 -0
  248. package/dist/timeout.js +4 -2
  249. package/dist/tool-name.js +17 -0
  250. package/dist/tool-safety.d.ts +9 -35
  251. package/dist/tool-safety.js +20 -40
  252. package/dist/types.d.ts +336 -56
  253. package/dist/types.js +0 -1
  254. package/dist/ui.d.ts +7 -19
  255. package/dist/ui.js +76 -90
  256. package/dist/url-safety.js +3 -6
  257. package/dist/usage-guide.js +146 -0
  258. package/dist/validate.d.ts +7 -0
  259. package/dist/validate.js +309 -47
  260. package/dist/version.d.ts +1 -7
  261. package/dist/version.js +3 -7
  262. package/dist/worker.d.ts +11 -0
  263. package/dist/worker.js +435 -0
  264. package/documentation/README.md +63 -0
  265. package/documentation/architecture.md +696 -231
  266. package/documentation/auth.md +1069 -546
  267. package/documentation/code-mode.md +662 -356
  268. package/documentation/deploying.md +146 -0
  269. package/documentation/integrating.md +159 -0
  270. package/documentation/meta-tools.md +570 -216
  271. package/documentation/operating.md +135 -0
  272. package/documentation/operator-tests.md +97 -0
  273. package/documentation/operator-ui.md +132 -0
  274. package/documentation/provider-migration-0.29.md +65 -0
  275. package/examples/worker/AGENTS.md +34 -12
  276. package/examples/worker/README.md +293 -255
  277. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  278. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  279. package/examples/worker/src/connecta.config.ts +151 -0
  280. package/examples/worker/src/index.ts +14 -197
  281. package/examples/worker/wrangler.jsonc +15 -35
  282. package/package.json +126 -54
  283. package/templates/node/.dockerignore +1 -2
  284. package/templates/node/.env.example +20 -21
  285. package/templates/node/AGENTS.md +21 -17
  286. package/templates/node/Dockerfile +3 -5
  287. package/templates/node/README.md +140 -58
  288. package/templates/node/docker-compose.yml +11 -12
  289. package/templates/node/package.json +4 -2
  290. package/templates/node/src/connecta.config.ts +119 -0
  291. package/templates/node/src/index.ts +9 -154
  292. package/templates/node/src/provision-token.ts +17 -0
  293. package/AGENTS.md +0 -170
  294. package/dist/artifacts/connector.d.ts +0 -58
  295. package/dist/artifacts/connector.js +0 -730
  296. package/dist/artifacts/document.js +0 -86
  297. package/dist/artifacts/guide.js +0 -140
  298. package/dist/artifacts/html-scan.js +0 -329
  299. package/dist/artifacts/json.js +0 -78
  300. package/dist/artifacts/kv-store.d.ts +0 -20
  301. package/dist/artifacts/kv-store.js +0 -140
  302. package/dist/artifacts/markdown.js +0 -478
  303. package/dist/artifacts/module.d.ts +0 -37
  304. package/dist/artifacts/module.js +0 -66
  305. package/dist/artifacts/operations.d.ts +0 -197
  306. package/dist/artifacts/operations.js +0 -1002
  307. package/dist/artifacts/refresh.d.ts +0 -55
  308. package/dist/artifacts/refresh.js +0 -236
  309. package/dist/artifacts/routes.js +0 -206
  310. package/dist/artifacts/types.d.ts +0 -214
  311. package/dist/artifacts/types.js +0 -22
  312. package/dist/artifacts/validate.d.ts +0 -62
  313. package/dist/artifacts/validate.js +0 -651
  314. package/dist/artifacts.d.ts +0 -6
  315. package/dist/artifacts.js +0 -9
  316. package/dist/auth/bearer.d.ts +0 -11
  317. package/dist/auth/bearer.js +0 -50
  318. package/dist/providers/basecamp.js +0 -445
  319. package/dist/providers/linear.js +0 -217
  320. package/dist/providers/mixpanel.js +0 -247
  321. package/dist/providers/revenuecat.js +0 -317
  322. package/dist/providers/stripe.js +0 -253
  323. package/dist/providers/vercel.js +0 -1253
  324. package/dist/storage/file.d.ts +0 -15
  325. package/dist/storage/file.js +0 -455
  326. package/ethos.md +0 -93
  327. package/examples/worker/src/cloudflare-kv.ts +0 -43
  328. package/examples/worker/src/d1-activity-row.ts +0 -155
  329. package/examples/worker/src/d1-activity.ts +0 -147
  330. package/examples/worker/src/d1-storage.ts +0 -138
  331. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  332. 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, stashExpiryKeys, } 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,179 @@ 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 {
90
+ capabilities: storage.capabilities,
107
91
  get: (k) => storage.get(prefix + k),
108
92
  set: (k, v, o) => storage.set(prefix + k, v, o),
109
93
  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
- : {}),
94
+ list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
95
+ compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
120
96
  };
121
97
  }
122
98
  const MAX_PERSONAL_REGISTRIES = 1_024;
123
99
  const MAX_ABSENT_GRANT_WARNINGS = 1_024;
124
- const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60;
100
+ /**
101
+ * Compare-and-set attempts one stash makes on the ledger. A lost swap means
102
+ * another stash changed the ledger first, never that capacity ran out, so
103
+ * the loser re-reads and plans again after a jittered, growing pause: of 64
104
+ * simultaneous claims, each one either books a charge or meets a full ledger.
105
+ * Exhausting the attempts takes seconds of sustained contention on the one
106
+ * ledger record, and then the caller keeps its preview.
107
+ */
108
+ const STASH_LEDGER_ATTEMPTS = 32;
109
+ /** First backoff window after a lost swap; it doubles up to the cap. */
110
+ const STASH_LEDGER_BACKOFF_MS = 4;
111
+ const STASH_LEDGER_BACKOFF_CAP_MS = 250;
112
+ /** Whole chunk-write phase, measured from booking, including the header. */
113
+ const STASH_WRITE_TIMEOUT_MS = 30_000;
114
+ /** Total cleanup/release or settlement budget, including ledger retries. */
115
+ const STASH_COMPLETION_TIMEOUT_MS = 15_000;
116
+ /** Stop waiting without starting background storage work after the deadline. */
117
+ function completeStash(effect) {
118
+ return effect.pipe(Effect.asVoid, Effect.timeoutOrElse({ duration: Duration.millis(STASH_COMPLETION_TIMEOUT_MS), orElse: () => Effect.void }),
119
+ // onError finalizers are otherwise uninterruptible, including their I/O.
120
+ Effect.interruptible);
121
+ }
122
+ /**
123
+ * Live charges in a stored ledger. Anything malformed reads as empty: the
124
+ * ledger bounds an advisory cache, and the next successful swap replaces it.
125
+ */
126
+ function stashCharges(raw) {
127
+ if (raw === null)
128
+ return [];
129
+ let parsed;
130
+ try {
131
+ parsed = jsonCodec.decode(raw);
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ const entries = parsed?.entries;
137
+ if (!Array.isArray(entries))
138
+ return [];
139
+ return entries.filter((entry) => Array.isArray(entry) &&
140
+ entry.length === 3 &&
141
+ typeof entry[0] === "string" &&
142
+ Number.isSafeInteger(entry[1]) &&
143
+ entry[1] >= 0 &&
144
+ Number.isSafeInteger(entry[2]));
145
+ }
146
+ /** Caller time can nominate a charge for pruning, but only storage proves expiry. */
147
+ function liveStashCharges(raw, now) {
148
+ return Effect.gen(function* () {
149
+ const live = [];
150
+ const expired = new Map();
151
+ for (const entry of stashCharges(raw)) {
152
+ const deadline = entry[2];
153
+ if (deadline > now) {
154
+ live.push(entry);
155
+ continue;
156
+ }
157
+ let proven = expired.get(deadline);
158
+ if (proven === undefined) {
159
+ // A successful absolute-expiry write followed by an absent read proves
160
+ // storage has passed this deadline, even when the caller runs ahead.
161
+ // These probes expire at the deadline they check; no cleanup is needed.
162
+ const key = stashExpiryKeys.deadline(deadline);
163
+ yield* storageSet(key, "live", { expiresAtMs: deadline });
164
+ proven = (yield* storageGet(key)) === null;
165
+ expired.set(deadline, proven);
166
+ }
167
+ if (!proven)
168
+ live.push(entry);
169
+ }
170
+ return live;
171
+ });
172
+ }
173
+ /** Rewrite the ledger with bounded retries; every stored charge already expires. */
174
+ function swapStashLedger(plan) {
175
+ return Effect.gen(function* () {
176
+ const ledger = stashLedgerKeys.ledger;
177
+ for (let attempt = 0; attempt < STASH_LEDGER_ATTEMPTS; attempt++) {
178
+ if (attempt > 0) {
179
+ const window = Math.min(STASH_LEDGER_BACKOFF_CAP_MS, STASH_LEDGER_BACKOFF_MS * 2 ** (attempt - 1));
180
+ yield* Effect.sleep(Duration.millis(Math.floor((yield* Random.next) * window) + 1));
181
+ }
182
+ const outcome = yield* Effect.gen(function* () {
183
+ const raw = yield* storageGet(ledger);
184
+ const now = yield* Clock.currentTimeMillis;
185
+ const planned = plan(yield* liveStashCharges(raw, now), now);
186
+ if (planned.entries !== undefined) {
187
+ const entries = planned.entries;
188
+ // The ledger row itself expires with its last charge. A delayed CAS
189
+ // cannot create an immortal row or restart any reservation's lifetime.
190
+ const next = entries.length ? jsonCodec.encode({ v: 1, entries }) : null;
191
+ const expiresAtMs = entries.reduce((latest, entry) => Math.max(latest, entry[2]), entries[0]?.[2] ?? now);
192
+ if (!(yield* storageCompareAndSet(ledger, raw, next, { expiresAtMs })))
193
+ return undefined;
194
+ }
195
+ return { status: "applied", result: planned.result };
196
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)));
197
+ if (outcome !== undefined)
198
+ return outcome;
199
+ }
200
+ // The final CAS can commit and then throw too. Confirm a read-only plan
201
+ // once more, especially an already-booked reservation, without another CAS.
202
+ return yield* Effect.gen(function* () {
203
+ const raw = yield* storageGet(ledger);
204
+ const now = yield* Clock.currentTimeMillis;
205
+ const planned = plan(yield* liveStashCharges(raw, now), now);
206
+ return planned.entries === undefined
207
+ ? { status: "applied", result: planned.result }
208
+ : { status: "exhausted" };
209
+ }).pipe(Effect.catch(() => Effect.succeed({ status: "exhausted" })));
210
+ });
211
+ }
125
212
  async function sha256Hex(value) {
126
213
  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("");
214
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
130
215
  }
131
216
  /**
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
217
+ * Holds the connector set, resolves addresses, and coalesces tool listings
218
+ * within one request. Remote MCP clients own persisted response caching. Connector failures are isolated: a broken
134
219
  * connector surfaces status "error"; the rest keep working.
135
220
  */
136
221
  export class Registry {
137
222
  opts;
138
223
  connectors = new Map();
139
224
  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. */
225
+ /** Coalesce listings only within one request and authorization partition. */
150
226
  requestCatalogLoads = new WeakMap();
151
- /** One live refresh per connector across agent and operator requests. */
152
- catalogRefreshes = new Map();
227
+ catalogObservedAt = new Map();
153
228
  /** Last payload-free agent catalog access in this runtime. */
154
229
  catalogAccess = new Map();
155
- /** Last drift counts reported to activity, per connector, in this runtime. */
156
- reportedDrift = new Map();
230
+ /** Count-only intake findings, including catalogs loaded from storage. */
231
+ droppedToolNames = new Map();
232
+ /**
233
+ * Schema digest verification per reviewed facts array. Only the registry's
234
+ * own deep-frozen arrays are keys, so an entry stays true while they live.
235
+ */
236
+ verifiedFacts = new WeakMap();
157
237
  observedOutputSchemas;
158
- ttlMs;
159
- staleMs;
160
- persistToolCatalog;
161
238
  /** Result-size guard cap threaded to the meta-tools. */
162
239
  maxResultBytes;
163
- /** Only keys, byte counts, and expiry survive requests; never write promises. */
164
- resultStash = new Map();
165
- resultStashBytes = 0;
240
+ classification;
166
241
  configuredConnectors;
167
242
  personalRegistries = new Map();
168
243
  oauthPartition = {};
@@ -173,13 +248,9 @@ export class Registry {
173
248
  this.opts = opts;
174
249
  this.configuredConnectors = [...connectors];
175
250
  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
251
  this.maxResultBytes = resolveMaxResultBytes(opts.maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
182
252
  for (const c of connectors) {
253
+ assertStaticToolNames(c.staticTools ?? [], `Connector(${JSON.stringify(c.id)}).staticTools`);
183
254
  if ("handleRequest" in c) {
184
255
  throw new Error(`Connector "${c.id}" declares removed handleRequest. ` +
185
256
  "Move custom HTTP routes into the deployment's fetch handler.");
@@ -190,29 +261,46 @@ export class Registry {
190
261
  if (this.connectors.has(c.id)) {
191
262
  throw new Error(`Duplicate connector id "${c.id}"`);
192
263
  }
193
- if (c.authScope !== undefined &&
194
- c.authScope !== "shared" &&
195
- c.authScope !== "personal") {
264
+ if (c.authScope !== undefined && c.authScope !== "shared" && c.authScope !== "personal") {
196
265
  throw new Error(`Invalid authScope on connector "${c.id}": expected "shared" or "personal"`);
197
266
  }
198
- const configuredGuideSummary = typeof c.usageGuide === "object"
199
- ? normalizeGuideSummary(c.usageGuide.summary ?? "")
200
- : undefined;
201
- if (configuredGuideSummary !== undefined &&
202
- configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
267
+ const configuredGuideSummary = typeof c.usageGuide === "object" ? normalizeGuideSummary(c.usageGuide.summary ?? "") : undefined;
268
+ if (configuredGuideSummary !== undefined && configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
203
269
  throw new Error(`Connector "${c.id}" usageGuide.summary is ` +
204
270
  `${configuredGuideSummary.length} characters after whitespace ` +
205
271
  `normalization; the discovery bound is ${GUIDE_SUMMARY_LENGTH}. ` +
206
272
  "Shorten it or omit it to derive one.");
207
273
  }
274
+ // Validated here, before any request (INV-11), and read once per
275
+ // connector object.
276
+ if (catalogReviewOf(c) && c.staticTools) {
277
+ throw new Error(`Connector "${c.id}" declares both staticTools and a classification; ` + "annotate static tools directly.");
278
+ }
208
279
  this.connectors.set(c.id, c);
209
280
  if (c.callAdmission) {
210
281
  this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
211
282
  }
212
283
  }
284
+ this.classification = Object.assign(Object.create(null), Object.fromEntries(Object.entries(opts.classification ?? {}).map(([id, tools]) => [
285
+ id,
286
+ Object.freeze(Object.assign(Object.create(null), tools)),
287
+ ])));
288
+ Object.freeze(this.classification);
289
+ for (const [id, overrides] of Object.entries(this.classification)) {
290
+ for (const [name, verdict] of Object.entries(overrides)) {
291
+ if (!name || (verdict !== "read" && verdict !== "write")) {
292
+ throw new Error(`ConnectaConfig.classification.${id}.${name}: expected "read" or "write"`);
293
+ }
294
+ }
295
+ const connector = this.connectors.get(id);
296
+ if (!connector)
297
+ throw new Error(`ConnectaConfig.classification: unknown connector "${id}"`);
298
+ if (connector.staticTools)
299
+ this.validateClassification(id, connector.staticTools, overrides);
300
+ }
301
+ this.assertResultCaps(opts.maxResultBytes);
213
302
  if (opts.constructionChecks !== false) {
214
303
  this.checkConventions(opts.logger);
215
- this.checkResultCaps(opts.logger, opts.maxResultBytes);
216
304
  }
217
305
  }
218
306
  personalRegistry(principalKey) {
@@ -224,15 +312,9 @@ export class Registry {
224
312
  }
225
313
  if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
226
314
  // 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
315
  // OAuth status/start work bypasses both gates, and accepted rotations
232
316
  // 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 &&
317
+ const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every((admission) => admission.isIdle()) &&
236
318
  oauthPartitionIdle(candidate.oauthPartition));
237
319
  if (!idle) {
238
320
  throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
@@ -242,10 +324,15 @@ export class Registry {
242
324
  }
243
325
  const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
244
326
  ...this.opts,
245
- storage: namespaced(this.opts.storage, `principal:${principalKey}:`),
327
+ classification: Object.fromEntries(Object.entries(this.classification).filter(([id]) => this.connectors.get(id)?.authScope === "personal")),
328
+ storage: namespaced(this.opts.storage, scopes.principal(principalKey)),
246
329
  credentialOwner: principalKey,
330
+ catalogStorage: this.opts.catalogStorage ?? this.opts.storage,
247
331
  constructionChecks: false,
248
332
  });
333
+ // Counts only, like the connector-wide reviewed drift observation. No
334
+ // principal, credentials, or catalog payload crosses this registry boundary.
335
+ registry.droppedToolNames = this.droppedToolNames;
249
336
  if (this.callAdmissionClosed)
250
337
  registry.closeCallAdmission();
251
338
  this.personalRegistries.set(principalKey, registry);
@@ -253,9 +340,7 @@ export class Registry {
253
340
  }
254
341
  /** Build the only connector view an authenticated request receives. */
255
342
  scoped(scope) {
256
- const requested = scope.connectorIds === "all"
257
- ? new Set(this.connectors.keys())
258
- : new Set(scope.connectorIds);
343
+ const requested = scope.connectorIds === "all" ? new Set(this.connectors.keys()) : new Set(scope.connectorIds);
259
344
  for (const id of requested) {
260
345
  if (!this.connectors.has(id)) {
261
346
  throw new Error(`Identity access resolver returned unknown connector "${id}"`);
@@ -264,7 +349,7 @@ export class Registry {
264
349
  return new ScopedRegistryView(this, requested, scope);
265
350
  }
266
351
  scopedStorage(subjectKey) {
267
- return namespaced(this.opts.storage, `subject:${subjectKey}:`);
352
+ return namespaced(this.opts.storage, scopes.subject(subjectKey));
268
353
  }
269
354
  /**
270
355
  * A granted `connector.tool` address the live catalog does not contain is
@@ -282,87 +367,70 @@ export class Registry {
282
367
  if (oldest !== undefined)
283
368
  this.warnedAbsentGrants.delete(oldest);
284
369
  }
370
+ if (hasControlCharacters(toolName)) {
371
+ logFailure(this.opts.logger, "connectorAccess grant is unreachable", failureRecord({ connector: connectorId }));
372
+ return;
373
+ }
285
374
  // Grant names are operator data but may carry any non-control character;
286
375
  // quote them so a line terminator a log reader honours cannot forge a line.
287
376
  const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
288
377
  this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
289
378
  }
290
- oauthHandoffKey(connectorId, stateHash) {
291
- return `oauth-handoff:v1:${connectorId}:${stateHash}`;
292
- }
293
379
  async storeOAuthHandoff(connectorId, state, principalKey) {
294
- const key = this.oauthHandoffKey(connectorId, await sha256Hex(state));
380
+ const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
295
381
  for (let attempt = 0; attempt < 32; attempt++) {
296
382
  const existing = await this.opts.storage.get(key);
297
383
  if (existing && existing !== principalKey) {
298
384
  throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
299
385
  }
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;
386
+ // Bind ownership atomically, including renewal of the same owner's
387
+ // handoff. Two owners reading a miss must not overwrite each other.
388
+ if (await this.opts.storage.compareAndSet(key, existing, principalKey, {
389
+ ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS,
390
+ }))
391
+ return;
311
392
  }
312
393
  throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
313
394
  }
314
395
  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
396
  if (!state)
321
397
  return null;
322
- const principalKey = await this.opts.storage.get(this.oauthHandoffKey(connectorId, await sha256Hex(state)));
323
- if (!principalKey)
398
+ const principalKey = await this.opts.storage.get(oauthHandoffKeys.handoff(connectorId, await sha256Hex(state)));
399
+ const connector = this.connectors.get(connectorId);
400
+ if (!connector || !principalKey)
324
401
  return null;
325
402
  return {
326
- registry: this.scoped({
327
- connectorIds: [connectorId],
328
- subjectKey: principalKey,
329
- principalKey,
330
- }),
403
+ registry: connector.authScope === "personal"
404
+ ? this.scoped({
405
+ connectorIds: [connectorId],
406
+ subjectKey: principalKey,
407
+ principalKey,
408
+ })
409
+ : this,
331
410
  principalKey,
332
411
  };
333
412
  }
334
- async clearOAuthHandoff(connectorId, state) {
413
+ async consumeOAuthHandoff(connectorId, state, principalKey) {
335
414
  if (!state)
336
- return;
337
- await this.opts.storage.delete(this.oauthHandoffKey(connectorId, await sha256Hex(state)));
415
+ return false;
416
+ const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
417
+ return this.opts.storage.compareAndSet(key, principalKey, null);
338
418
  }
339
419
  /**
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.
420
+ * Refuse an unusable result cap at construction, like every other
421
+ * structural mistake (INV-11). A rejected cap can't be honoured, and
422
+ * honouring it *approximately* is exactly the inversion issue #32 is about;
423
+ * falling back with a warning let a deployment boot on a value nobody
424
+ * chose.
346
425
  */
347
- checkResultCaps(logger, configured) {
426
+ assertResultCaps(configured) {
348
427
  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.`);
428
+ throw new Error(`ConnectaConfig.calls.maxResultBytes must be a whole number of bytes >= ${MIN_MAX_RESULT_BYTES}.`);
353
429
  }
354
430
  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}).`);
431
+ if (c.maxResultBytes !== undefined && !isValidMaxResultBytes(c.maxResultBytes)) {
432
+ throw new Error(`Connector "${c.id}" maxResultBytes must be a whole number of bytes >= ` +
433
+ `${MIN_MAX_RESULT_BYTES}; omit it to inherit the deployment-wide cap.`);
366
434
  }
367
435
  }
368
436
  }
@@ -393,7 +461,13 @@ export class Registry {
393
461
  getConnector(id) {
394
462
  return this.connectors.get(id);
395
463
  }
396
- contextFor(id, baseUrl, requestScope = {}, callOptions = {}) {
464
+ getResourceConnector(id) {
465
+ return this.getConnector(id);
466
+ }
467
+ canReadConnectorSkills(id) {
468
+ return this.connectors.has(id);
469
+ }
470
+ contextFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
397
471
  const credentialConfig = this.connectors.get(id)?.credential;
398
472
  let credentialAccess;
399
473
  if (this.opts.credentialVault && credentialConfig) {
@@ -415,13 +489,42 @@ export class Registry {
415
489
  };
416
490
  }
417
491
  const context = {
418
- storage: namespaced(this.opts.storage, `conn:${id}:`),
492
+ storage: namespaced(this.opts.storage, scopes.connector(id)),
419
493
  logger: this.opts.logger,
420
494
  baseUrl,
495
+ ...(this.opts.publicUrl !== undefined ? { publicUrl: this.opts.publicUrl } : {}),
496
+ ...(this.opts.oauthClientName !== undefined ? { oauthClientName: this.opts.oauthClientName } : {}),
421
497
  ...(credentialAccess ? { credential: credentialAccess } : {}),
422
498
  requestScope,
423
499
  ...callOptions,
424
500
  };
501
+ attachCaller(context, scope?.caller);
502
+ attachCatalogCache(context, {
503
+ storage: this.opts.catalogStorage ?? this.opts.storage,
504
+ partition: JSON.stringify([
505
+ scope?.principalKey ?? this.opts.credentialOwner ?? null,
506
+ scope?.subjectKey ?? null,
507
+ scope?.caller?.identity ?? null,
508
+ scope?.caller?.authenticated ?? false,
509
+ scope?.caller?.pool ?? null,
510
+ ]),
511
+ sharedPartition: JSON.stringify([baseUrl, this.opts.publicUrl ?? null, scope?.caller?.pool ?? null]),
512
+ defaultTtlMs: (this.opts.toolCacheTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds) * 1000,
513
+ minTtlMs: (this.opts.catalogMinTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMinTtlSeconds) * 1000,
514
+ maxTtlMs: (this.opts.catalogMaxTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMaxTtlSeconds) * 1000,
515
+ ...(this.opts.catalogDriftActivity?.recordChange
516
+ ? {
517
+ onCompletedCatalogRefresh: (refresh, ctx) => this.recordCatalogDrift(refresh.previous, refresh.next, {
518
+ id,
519
+ connector: this.connectors.get(id),
520
+ ctx,
521
+ privateCatalog: refresh.private,
522
+ }),
523
+ }
524
+ : {}),
525
+ });
526
+ sentSecretsFor(context);
527
+ trackCredentialReads(context);
425
528
  attachOAuthPartition(context, this.oauthPartition);
426
529
  // Downstream OAuth state is sealed under the vault key, bound to this
427
530
  // connector and owner. The sealer rides beside the context, not on it.
@@ -446,71 +549,28 @@ export class Registry {
446
549
  snapshots.get(id)?.push(admission.snapshot());
447
550
  }
448
551
  }
449
- return Object.fromEntries([...snapshots].map(([id, values]) => [
450
- id, aggregateCallAdmissionSnapshots(values),
451
- ]));
552
+ return Object.fromEntries([...snapshots].map(([id, values]) => [id, aggregateCallAdmissionSnapshots(values)]));
452
553
  }
453
554
  /**
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.
555
+ * The drift a connector last showed in this runtime: the registry's own
556
+ * observation against a connector's `classification`, otherwise whatever
557
+ * the connector's `catalogDrift()` seam reports, bounded either way.
464
558
  */
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;
473
- }
474
- /**
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.
483
- */
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,
559
+ catalogDriftOf(connector) {
560
+ const report = boundedCatalogDrift(catalogReviewOf(connector) ? observedCatalogDrift(connector) : connector.catalogDrift?.());
561
+ const dropped = this.droppedToolNames.get(connector.id);
562
+ if (!dropped)
563
+ return report;
564
+ return {
565
+ ...(report ?? {
566
+ unclassifiedTools: 0,
567
+ unservedTools: 0,
568
+ annotationConflicts: 0,
569
+ schemaChanges: 0,
570
+ }),
571
+ observedAt: dropped.observedAt,
572
+ ...(dropped.count > 0 ? { droppedTools: dropped.count } : {}),
496
573
  };
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
574
  }
515
575
  /** Reject queued/future downstream admission; active permits release safely. */
516
576
  closeCallAdmission() {
@@ -521,75 +581,128 @@ export class Registry {
521
581
  registry.closeCallAdmission();
522
582
  }
523
583
  /**
524
- * Reserve capacity and write one ASCII paging envelope in this runtime.
584
+ * Reserve capacity and write one ASCII paging envelope.
585
+ *
586
+ * `chunks[n]` lands on `resultKeys.chunk(id, n)` inside `partition` — the
587
+ * layout connecta.result reads back, so a page fetches only the chunks it covers
588
+ * instead of the whole stored result (issue #540). Chunking is a read-cost
589
+ * decision, not a capacity one: however many keys an envelope occupies, it
590
+ * is one stash entry charged its total ASCII length.
525
591
  *
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.
592
+ * The bounds are the deployment's, not this isolate's. Every charge is a
593
+ * row in one ledger record in storage, booked by compare-and-set before any
594
+ * chunk is written, so every isolate and process sharing the store sees the
595
+ * same entries and bytes. A pending write keeps its finite charge reserved.
596
+ * Writes stop after 30 seconds; absolute chunk expiries cannot pass the
597
+ * booked deadline even on late commit. Rejected responses leave writes
598
+ * unconfirmed and retain their charge. Cleanup/release and settlement each
599
+ * stop waiting after 15 seconds. Storage TTL reclaims the rows.
531
600
  */
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.
601
+ stashResult(id, chunks, ttlSeconds, partition = scopes.results) {
536
602
  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;
603
+ const maxBytes = this.opts.results?.maxStashBytes ?? CONFIG_DEFAULTS.results.maxStashBytes;
604
+ const maxEntries = this.opts.results?.maxStashEntries ?? CONFIG_DEFAULTS.results.maxStashEntries;
539
605
  // The paging envelope is ASCII, so its string length is its stored byte count.
540
606
  const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
541
607
  if (bytes > maxBytes || maxEntries === 0)
542
608
  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)
609
+ const keys = chunks.map((_, index) => partition + resultKeys.chunk(id, index));
610
+ // IDs belong to reservations, not reusable result IDs or subjects.
611
+ const charge = crypto.randomUUID();
612
+ const ttlMs = ttlSeconds * 1000;
613
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)
614
+ return false;
615
+ const booking = yield* swapStashLedger((live, now) => {
616
+ // A committed CAS with a lost response is already our booking, even
617
+ // at full capacity. Reuse its deadline instead of adding a second slot.
618
+ const own = live.find((entry) => entry[0] === charge);
619
+ if (own)
620
+ return { result: own[2] };
621
+ const used = live.reduce((sum, entry) => sum + entry[1], 0);
622
+ if (live.length >= maxEntries || used + bytes > maxBytes)
623
+ return { result: undefined };
624
+ const deadline = now + STASH_WRITE_TIMEOUT_MS + ttlMs;
625
+ if (!Number.isSafeInteger(deadline))
626
+ return { result: undefined };
627
+ return { entries: [...live, [charge, bytes, deadline]], result: deadline };
628
+ });
629
+ if (booking.status === "exhausted" || booking.result === undefined)
559
630
  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;
565
- // Trailing chunks first: the header chunk is what makes an id readable, so
566
- // a write that fails midway leaves no envelope pointing at absent chunks.
567
- yield* Effect.gen(function* () {
631
+ const deadline = booking.result;
632
+ const writeDeadline = deadline - ttlMs;
633
+ const chunkExpiry = deadline - STASH_WRITE_TIMEOUT_MS;
634
+ const owns = (entry) => entry[0] === charge;
635
+ const settle = swapStashLedger((live) => live.some((entry) => owns(entry) && entry[2] > chunkExpiry)
636
+ ? {
637
+ entries: live.map((entry) => (owns(entry) ? [charge, bytes, Math.min(entry[2], chunkExpiry)] : entry)),
638
+ result: undefined,
639
+ }
640
+ : { result: undefined });
641
+ let pendingWrite = false;
642
+ // A rejected or timed-out driver call may still commit after deletion.
643
+ // Only a confirmed write response clears pendingWrite.
644
+ const release = completeStash(Effect.gen(function* () {
645
+ const deleted = yield* Effect.gen(function* () {
646
+ for (const key of keys)
647
+ yield* storageDelete(key);
648
+ return true;
649
+ }).pipe(Effect.catch(() => Effect.succeed(false)));
650
+ if (pendingWrite)
651
+ return;
652
+ if (deleted) {
653
+ const released = yield* swapStashLedger((live) => live.some(owns)
654
+ ? { entries: live.filter((entry) => !owns(entry)), result: undefined }
655
+ : { result: undefined });
656
+ if (released.status === "applied")
657
+ return;
658
+ }
659
+ yield* settle;
660
+ }));
661
+ // Trailing chunks first, then the header. The caller receives no result
662
+ // ID after failure or timeout, including a late header commit.
663
+ const remaining = Math.max(0, writeDeadline - (yield* Clock.currentTimeMillis));
664
+ const written = yield* Effect.gen(function* () {
568
665
  for (let index = keys.length - 1; index >= 0; index--) {
569
- yield* storageSet(keys[index], chunks[index], { ttlSeconds });
666
+ const before = yield* Clock.currentTimeMillis;
667
+ if (before >= writeDeadline || before >= chunkExpiry)
668
+ return false;
669
+ pendingWrite = true;
670
+ yield* storageSet(keys[index], chunks[index], { expiresAtMs: chunkExpiry }).pipe(Effect.tap(() => Effect.sync(() => {
671
+ pendingWrite = false;
672
+ })));
673
+ if ((yield* Clock.currentTimeMillis) >= writeDeadline)
674
+ return false;
570
675
  }
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;
676
+ return true;
677
+ }).pipe(Effect.timeoutOrElse({ duration: Duration.millis(remaining), orElse: () => Effect.succeed(false) }), Effect.onError(() => release.pipe(Effect.ignore)));
678
+ if (!written)
679
+ yield* release;
680
+ else
681
+ yield* completeStash(settle);
682
+ return written;
581
683
  }), this.opts);
582
684
  }
583
685
  /**
584
- * Storage namespaced to the meta-tool result store (`results:` prefix), kept
585
- * separate from any connector's `conn:<id>:` namespace. Backs get_result.
686
+ * Storage namespaced to the root result partition, kept separate from any
687
+ * connector's namespace. Backs connecta.result.
586
688
  */
689
+ resultIdentity() {
690
+ return { subject: null, principal: null, endpoint: "/mcp", origin: null };
691
+ }
692
+ recheckResultAccess(_address, _classification, _signal, _access) {
693
+ return Promise.resolve(true);
694
+ }
587
695
  resultsStorage() {
588
- return namespaced(this.opts.storage, "results:");
696
+ return namespaced(this.opts.storage, scopes.results);
589
697
  }
590
- credentialUiAvailable() { return Boolean(this.opts.credentialUi); }
591
- async bindOAuthHandoff() {
592
- // Shared OAuth already resolves in deployment-wide connector storage.
698
+ credentialUiAvailable() {
699
+ return Boolean(this.opts.credentialUi);
700
+ }
701
+ async bindOAuthHandoff(id, authorizationUrl, principalKey) {
702
+ const state = new URL(authorizationUrl).searchParams.get("state");
703
+ if (!state || !principalKey)
704
+ throw new Error("OAuth requires state and an initiating user");
705
+ await this.storeOAuthHandoff(id, state, principalKey);
593
706
  }
594
707
  observedOutputSchema(connectorId, definition) {
595
708
  return this.observedOutputSchemas.get(connectorId, definition);
@@ -607,608 +720,143 @@ export class Registry {
607
720
  return null;
608
721
  return { connector, toolName: parts.toolName };
609
722
  }
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;
723
+ /** Compare only the fingerprints accepted in this exact cache partition. */
724
+ recordCatalogDrift(previous, next, attribution) {
725
+ if (!previous)
726
+ return;
727
+ const { id, connector, ctx, privateCatalog } = attribution;
728
+ const before = new Map(previous.map((tool) => [tool.name, tool.fact]));
729
+ const after = new Map(next.map((tool) => [tool.name, tool.fact]));
730
+ const addedTools = [...after.keys()].filter((name) => !before.has(name)).length;
731
+ const removedTools = [...before.keys()].filter((name) => !after.has(name)).length;
732
+ const changedTools = [...after].filter(([name, fact]) => before.has(name) && before.get(name) !== fact).length;
733
+ if (addedTools || removedTools || changedTools)
734
+ this.opts.catalogDriftActivity?.recordChange?.({ ...this.opts.catalogDriftActivity, logger: this.opts.logger, ...(ctx.defer ? { defer: ctx.defer } : {}) }, {
735
+ connectorId: id,
736
+ drift: { kind: "catalog_changed", addedTools, removedTools, changedTools },
737
+ privateCatalog,
738
+ ...(connector.authScope === "personal" ? { personal: true } : {}),
739
+ }, activityRequest(ctx.requestScope));
740
+ }
741
+ async loadDownstreamTools(id, baseUrl, requestScope, callOptions = {}, scope) {
742
+ const connector = this.connectors.get(id);
743
+ if (!connector)
744
+ throw new Error(`Unknown connector "${id}"`);
745
+ if (connector.staticTools)
746
+ return markCatalogFreshness(connector.staticTools, Infinity);
747
+ const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
748
+ let listed;
649
749
  try {
650
- return this.validCatalogManifest(JSON.parse(raw));
750
+ listed = await connector.listTools(ctx);
651
751
  }
652
- catch {
653
- return null;
752
+ catch (error) {
753
+ const fallback = error instanceof ConnectorCallError && error.code === "unavailable"
754
+ ? await customCatalogFallback(ctx, id).catch(() => undefined)
755
+ : undefined;
756
+ if (!fallback)
757
+ throw sentSecretsForRequest(ctx.requestScope ?? ctx).redact(error);
758
+ listed = fallback;
654
759
  }
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;
760
+ const tools = redactCatalog(ctx, listed).map(({ classification: _ignored, ...fact }) => fact);
761
+ if (tools.length > MAX_CATALOG_TOOLS ||
762
+ encoder.encode(JSON.stringify(tools)).byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
763
+ throw new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", { retryable: false });
673
764
  }
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;
765
+ const facts = frozenFacts(structuredClone(tools));
766
+ const accepted = this.acceptToolNames(facts);
767
+ this.observeDroppedToolNames(connector, facts.length - accepted.length);
768
+ const review = catalogReviewOf(connector);
769
+ if (review)
770
+ await observeReviewedDrift(connector, review, accepted, this.opts.logger);
771
+ if (catalogFetchedAt(ctx) === undefined) {
687
772
  try {
688
- parsed = JSON.parse(raw);
689
- }
690
- catch {
691
- return null;
773
+ await storeCustomCatalogFallback(ctx, id, facts);
692
774
  }
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;
698
- }
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;
775
+ catch (error) {
776
+ logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
730
777
  }
731
- let tools;
732
778
  try {
733
- tools = JSON.parse(serializedTools);
779
+ await observeUncachedCatalogRefresh(ctx, id, facts);
734
780
  }
735
- catch {
736
- logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
737
- return null;
781
+ catch (error) {
782
+ logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
738
783
  }
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;
742
- }
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;
746
- }
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
784
  }
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,
785
+ this.catalogObservedAt.set(id, catalogFetchedAt(ctx) ?? Date.now());
786
+ const provenance = catalogExpiry(ctx);
787
+ const freshUntil = provenance && !provenance.staleFallback ? provenance.expiresAt : 0;
788
+ this.catalogAccess.set(id, {
789
+ state: freshUntil > Date.now() ? "fresh" : "stale",
790
+ observedAt: new Date().toISOString(),
904
791
  });
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;
792
+ return markCatalogFreshness(facts, freshUntil);
915
793
  }
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)));
926
- }
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);
940
- }
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 };
794
+ /** The served catalog excludes control-character names; raw caches stay complete. */
795
+ acceptToolNames(tools) {
796
+ const accepted = tools.filter((tool) => !hasControlCharacters(tool.name));
797
+ return accepted.length === tools.length ? tools : accepted;
964
798
  }
965
- /**
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.
970
- */
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);
799
+ /** Observe intake once, never while serving an older cached listing. */
800
+ observeDroppedToolNames(connector, count) {
801
+ if (count > 0 || this.droppedToolNames.has(connector.id)) {
802
+ this.droppedToolNames.set(connector.id, { count, observedAt: new Date().toISOString() });
977
803
  }
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
804
  }
981
805
  /**
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.
806
+ * One connector's catalog as served: the cached downstream facts, classified
807
+ * on every read by the connector's `classification`, into fresh objects.
808
+ * Safety is derived here and nowhere else, so no cache layer can carry a
809
+ * stale verdict — from an older review, an older release, or a stale
810
+ * fallback — and nothing a caller or decorator does to a served tool reaches
811
+ * the next read.
988
812
  */
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 = {}) {
813
+ async loadTools(id, baseUrl, requestScope, callOptions = {}, scope) {
814
+ const tools = await this.loadDownstreamTools(id, baseUrl, requestScope, callOptions, scope);
1001
815
  const connector = this.connectors.get(id);
1002
816
  if (!connector)
1003
817
  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)}`);
818
+ const accepted = this.acceptToolNames(tools);
819
+ const overrides = this.classification[id];
820
+ this.validateClassification(id, accepted, overrides);
821
+ const review = catalogReviewOf(connector);
822
+ return carryCatalogFreshness(tools, review
823
+ ? await classifyCatalog(review, id, accepted, this.opts.logger, this.verifiedFacts, overrides)
824
+ : accepted.map((tool) => this.publishUnreviewedTool(id, tool)));
825
+ }
826
+ publishUnreviewedTool(id, tool) {
827
+ const override = this.classification[id]?.[tool.name];
828
+ return {
829
+ ...structuredClone(tool),
830
+ classification: classifyTool(tool, override),
831
+ ...(override !== undefined
832
+ ? {
833
+ annotations: {
834
+ ...structuredClone(tool.annotations),
835
+ readOnlyHint: override === "read",
836
+ destructiveHint: override === "write",
837
+ },
838
+ }
839
+ : {}),
1069
840
  };
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
841
  }
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;
842
+ /** Construction-only view for the secret-free static config description. */
843
+ describeStaticTools(id) {
844
+ const tools = this.connectors.get(id)?.staticTools;
845
+ return tools?.map((tool) => this.publishUnreviewedTool(id, tool));
846
+ }
847
+ validateClassification(id, tools, overrides) {
848
+ const known = new Set(tools.map((tool) => tool.name));
849
+ for (const name of Object.keys(overrides ?? {})) {
850
+ if (!known.has(name))
851
+ throw new Error(`ConnectaConfig.classification: connector "${id}" has no tool "${name}"`);
1187
852
  }
1188
853
  }
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) {
854
+ async getTools(id, baseUrl, requestScope, callOptions = {}, scope) {
1205
855
  const connector = this.connectors.get(id);
1206
856
  if (!connector)
1207
857
  throw new Error(`Unknown connector "${id}"`);
1208
- if (connector.staticTools)
1209
- return connector.staticTools;
1210
858
  if (!requestScope) {
1211
- return this.loadTools(id, baseUrl, requestScope, callOptions, readOptions);
859
+ return this.loadTools(id, baseUrl, requestScope, callOptions, scope);
1212
860
  }
1213
861
  let loads = this.requestCatalogLoads.get(requestScope);
1214
862
  if (!loads) {
@@ -1216,19 +864,20 @@ export class Registry {
1216
864
  this.requestCatalogLoads.set(requestScope, loads);
1217
865
  }
1218
866
  const requestLoads = loads;
1219
- let load = requestLoads.get(id);
867
+ const key = JSON.stringify([id, scope?.principalKey, scope?.subjectKey, scope?.caller]);
868
+ let load = requestLoads.get(key);
1220
869
  if (!load) {
1221
- const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, readOptions),
870
+ const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, scope),
1222
871
  // Settled or cancelled, the read leaves the map before any caller
1223
872
  // resumes, so the next one asks the caches afresh.
1224
873
  () => {
1225
- if (requestLoads.get(id) === started)
1226
- requestLoads.delete(id);
874
+ if (requestLoads.get(key) === started)
875
+ requestLoads.delete(key);
1227
876
  if (requestLoads.size === 0) {
1228
877
  this.requestCatalogLoads.delete(requestScope);
1229
878
  }
1230
879
  });
1231
- requestLoads.set(id, started);
880
+ requestLoads.set(key, started);
1232
881
  load = started;
1233
882
  }
1234
883
  return runEdge(load.join(callOptions.signal));
@@ -1244,16 +893,21 @@ export class Registry {
1244
893
  return shape.state === "mismatch" ? shape.message : undefined;
1245
894
  }
1246
895
  catch (error) {
1247
- this.opts.logger.warn(`[connecta] connector "${id}" credential shape read failed: ${msg(error)}`);
896
+ logFailure(this.opts.logger, "credential shape read failed", failureRecord({ connector: id }, error));
1248
897
  return undefined;
1249
898
  }
1250
899
  }
900
+ /** Age of a complete cache entry; reads never refresh it. */
901
+ catalogAgeMs(id) {
902
+ const observedAt = this.catalogObservedAt.get(id);
903
+ return observedAt === undefined ? null : Math.max(0, Date.now() - observedAt);
904
+ }
1251
905
  /** Best-effort connector status for the operator UI. */
1252
- async statusFor(id, baseUrl, requestScope = {}, callOptions = {}) {
906
+ async statusFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
1253
907
  const connector = this.connectors.get(id);
1254
908
  if (!connector)
1255
- return { state: "error", message: "Unknown connector" };
1256
- const ctx = this.contextFor(id, baseUrl, requestScope, callOptions);
909
+ return ownStatus({ state: "error", message: "Unknown connector" });
910
+ const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
1257
911
  // Whatever the state turns out to be, it carries the drift the last
1258
912
  // refresh saw. Reading it is a lookup, not a probe: a connector that has
1259
913
  // listed nothing yet reports nothing, and status never lists on its own to
@@ -1262,56 +916,60 @@ export class Registry {
1262
916
  // is third-party output, and status is read by the operator UI and copied
1263
917
  // into responses.
1264
918
  const withObservations = (status) => {
1265
- const report = boundedCatalogDrift(connector.catalogDrift?.());
919
+ const report = this.catalogDriftOf(connector);
1266
920
  const access = this.catalogAccess.get(id);
1267
921
  // Connector.status is an open plugin seam. Rebuild its public fields so
1268
922
  // 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,
923
+ // observation when this runtime has not made one, nor through its
924
+ // message, which survives only when connecta wrote it (INV-6).
925
+ return Object.assign(boundedStatus(status), {
1276
926
  ...(report ? { catalogDrift: report } : {}),
1277
927
  ...(access ? { catalogAccess: { ...access } } : {}),
1278
- };
928
+ });
1279
929
  };
930
+ // Credential declarations own their readiness even when a static catalog
931
+ // or a plugin status would otherwise report success. Never probe an empty slot.
932
+ if (connector.credential) {
933
+ try {
934
+ const values = (await this.opts.credentialVault?.getAll(id, this.opts.credentialOwner)) ?? null;
935
+ const shape = storedCredentialShape(connector.credential, values);
936
+ if (shape.state === "missing")
937
+ return withObservations(ownStatus({ state: "credential_required" }));
938
+ if (shape.state === "mismatch")
939
+ return withObservations(ownStatus({ state: "auth_required" }));
940
+ }
941
+ catch (err) {
942
+ return withObservations(failureStatus(id, err));
943
+ }
944
+ }
1280
945
  if (connector.status) {
1281
946
  try {
1282
947
  return withObservations(await connector.status(ctx));
1283
948
  }
1284
949
  catch (err) {
1285
- return withObservations({ state: "error", message: msg(err) });
950
+ return withObservations(failureStatus(id, err));
1286
951
  }
1287
952
  }
1288
953
  try {
1289
- await this.getTools(id, baseUrl, requestScope, callOptions);
954
+ await this.getTools(id, baseUrl, requestScope, callOptions, scope);
1290
955
  return withObservations({ state: "ok" });
1291
956
  }
1292
957
  catch (err) {
1293
- return withObservations({ state: "error", message: msg(err) });
958
+ return withObservations(failureStatus(id, err));
1294
959
  }
1295
960
  }
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). */
961
+ /** Rotate the storage generation; in-flight old readers cannot republish it. */
1305
962
  invalidate(id) {
1306
- this.markCatalogInvalid(id);
1307
- if (this.persistToolCatalog)
1308
- void this.deleteStoredCatalog(id);
963
+ void this.invalidateStored(id);
1309
964
  }
1310
- /** Drop both in-memory and persisted tool catalogs. */
1311
965
  async invalidateStored(id) {
1312
- this.markCatalogInvalid(id);
1313
- if (this.persistToolCatalog)
1314
- await this.deleteStoredCatalog(id);
966
+ this.catalogObservedAt.delete(id);
967
+ try {
968
+ await invalidateCatalogCache(this.opts.catalogStorage ?? this.opts.storage, id);
969
+ }
970
+ catch (error) {
971
+ logFailure(this.opts.logger, "catalog invalidation failed", failureRecord({ connector: id }, error));
972
+ }
1315
973
  }
1316
974
  }
1317
975
  class ScopedRegistryView {
@@ -1322,9 +980,7 @@ class ScopedRegistryView {
1322
980
  /** An evicted view's admission controller must remain closed. */
1323
981
  admissionPersonal;
1324
982
  get personal() {
1325
- return this.scope.principalKey
1326
- ? this.root.personalRegistry(this.scope.principalKey)
1327
- : undefined;
983
+ return this.scope.principalKey ? this.root.personalRegistry(this.scope.principalKey) : undefined;
1328
984
  }
1329
985
  constructor(root, allowed, scope) {
1330
986
  this.root = root;
@@ -1333,9 +989,7 @@ class ScopedRegistryView {
1333
989
  this.maxResultBytes = root.maxResultBytes;
1334
990
  // Check capacity at construction, then resolve the current registry on
1335
991
  // 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;
992
+ this.admissionPersonal = scope.principalKey ? root.personalRegistry(scope.principalKey) : undefined;
1339
993
  }
1340
994
  registryFor(id, admission = false) {
1341
995
  if (!this.allowed.has(id))
@@ -1350,9 +1004,17 @@ class ScopedRegistryView {
1350
1004
  listConnectors() {
1351
1005
  return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
1352
1006
  }
1007
+ canReadConnectorSkills(id) {
1008
+ return (this.registryFor(id) !== undefined && !this.scope.toolAccess?.has(id) && !this.scope.guardedToolAccess?.has(id));
1009
+ }
1353
1010
  getConnector(id) {
1354
1011
  return this.registryFor(id)?.getConnector(id);
1355
1012
  }
1013
+ getResourceConnector(id) {
1014
+ if (this.scope.toolAccess?.has(id) || this.scope.guardedToolAccess?.has(id))
1015
+ return undefined;
1016
+ return this.getConnector(id);
1017
+ }
1356
1018
  resolveAddress(address) {
1357
1019
  const parsed = splitAddress(address);
1358
1020
  if (!parsed)
@@ -1365,7 +1027,7 @@ class ScopedRegistryView {
1365
1027
  if (!registry) {
1366
1028
  throw new Error(`Unknown connector "${args[0]}"`);
1367
1029
  }
1368
- const tools = await registry.getTools(...args);
1030
+ const tools = await registry.getTools(args[0], args[1], args[2], args[3], this.scope);
1369
1031
  const guarded = this.scope.guardedToolAccess?.get(args[0]);
1370
1032
  const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
1371
1033
  if (!granted)
@@ -1373,8 +1035,7 @@ class ScopedRegistryView {
1373
1035
  // Every consumer — search, describe, call_tool, and a program's
1374
1036
  // connecta.call — resolves through this list, so an ungranted tool is
1375
1037
  // indistinguishable from one the connector never had.
1376
- const visible = tools.filter((tool) => granted.has(tool.name) &&
1377
- (!guarded?.has(tool.name) || isExplicitlyReadOnly(tool)));
1038
+ const visible = tools.filter((tool) => granted.has(tool.name) && (!guarded?.has(tool.name) || tool.classification === "read"));
1378
1039
  if (visible.length < granted.size) {
1379
1040
  const present = new Set(tools.map((tool) => tool.name));
1380
1041
  for (const name of granted) {
@@ -1382,13 +1043,13 @@ class ScopedRegistryView {
1382
1043
  this.root.noteAbsentGrant(args[0], name);
1383
1044
  }
1384
1045
  }
1385
- return visible;
1046
+ return carryCatalogFreshness(tools, visible);
1386
1047
  }
1387
1048
  contextFor(...args) {
1388
1049
  const registry = this.registryFor(args[0]);
1389
1050
  if (!registry)
1390
1051
  throw new Error(`Unknown connector "${args[0]}"`);
1391
- return attachCaller(registry.contextFor(...args), this.scope.caller);
1052
+ return registry.contextFor(args[0], args[1], args[2], args[3], this.scope);
1392
1053
  }
1393
1054
  admitCall(...args) {
1394
1055
  const registry = this.registryFor(args[0], true);
@@ -1397,19 +1058,26 @@ class ScopedRegistryView {
1397
1058
  }
1398
1059
  return registry.admitCall(...args);
1399
1060
  }
1400
- stashResult(key, chunks, ttlSeconds) {
1401
- return this.root.stashResult(key, chunks, ttlSeconds, this.scope.subjectKey ? `subject:${this.scope.subjectKey}:` : "results:");
1061
+ stashResult(id, chunks, ttlSeconds) {
1062
+ return this.root.stashResult(id, chunks, ttlSeconds, this.scope.subjectKey ? scopes.subject(this.scope.subjectKey) : scopes.results);
1063
+ }
1064
+ resultIdentity() {
1065
+ return {
1066
+ subject: this.scope.subjectKey ?? null,
1067
+ principal: this.scope.principalKey ?? null,
1068
+ endpoint: this.scope.endpoint ?? "/mcp",
1069
+ origin: this.scope.origin ?? null,
1070
+ };
1071
+ }
1072
+ recheckResultAccess(address, classification, signal, access) {
1073
+ return this.scope.currentResultAccess?.(address, classification, signal, access) ?? Promise.resolve(true);
1402
1074
  }
1403
1075
  resultsStorage() {
1404
- return this.scope.subjectKey
1405
- ? this.root.scopedStorage(this.scope.subjectKey)
1406
- : this.root.resultsStorage();
1076
+ return this.scope.subjectKey ? this.root.scopedStorage(this.scope.subjectKey) : this.root.resultsStorage();
1407
1077
  }
1408
1078
  credentialDriftFor(id) {
1409
1079
  const registry = this.registryFor(id);
1410
- return registry
1411
- ? registry.credentialDriftFor(id)
1412
- : Promise.resolve(undefined);
1080
+ return registry ? registry.credentialDriftFor(id) : Promise.resolve(undefined);
1413
1081
  }
1414
1082
  observedOutputSchema(connectorId, definition) {
1415
1083
  return this.registryFor(connectorId)?.observedOutputSchema(connectorId, definition);
@@ -1417,30 +1085,23 @@ class ScopedRegistryView {
1417
1085
  observeOutputShape(connectorId, definition, value) {
1418
1086
  this.registryFor(connectorId)?.observeOutputShape(connectorId, definition, value);
1419
1087
  }
1088
+ catalogAgeMs(id) {
1089
+ return this.registryFor(id)?.catalogAgeMs(id) ?? null;
1090
+ }
1420
1091
  statusFor(...args) {
1421
1092
  const registry = this.registryFor(args[0]);
1422
1093
  return registry
1423
- ? registry.statusFor(...args)
1094
+ ? registry.statusFor(args[0], args[1], args[2], args[3], this.scope)
1424
1095
  : Promise.resolve({ state: "error", message: "Unknown connector" });
1425
1096
  }
1426
1097
  invalidateStored(id) {
1427
1098
  const registry = this.registryFor(id);
1428
1099
  return registry ? registry.invalidateStored(id) : Promise.resolve();
1429
1100
  }
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
- }
1101
+ credentialUiAvailable() {
1102
+ return this.root.credentialUiAvailable();
1103
+ }
1104
+ async bindOAuthHandoff(id, authorizationUrl, principalKey) {
1105
+ await this.root.bindOAuthHandoff(id, authorizationUrl, principalKey ?? this.scope.principalKey);
1445
1106
  }
1446
1107
  }