@zackbart/connecta 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (335) hide show
  1. package/AGENTS.md +40 -20
  2. package/CHANGELOG.md +653 -0
  3. package/README.md +25 -5
  4. package/bin/connecta.mjs +44 -3
  5. package/dist/access-tokens.d.ts +0 -1
  6. package/dist/access-tokens.js +0 -1
  7. package/dist/activity.d.ts +50 -2
  8. package/dist/activity.js +34 -1
  9. package/dist/apps-shell.d.ts +0 -1
  10. package/dist/apps-shell.js +0 -1
  11. package/dist/auth/bearer.d.ts +0 -1
  12. package/dist/auth/bearer.js +0 -1
  13. package/dist/auth/clerk.d.ts +0 -1
  14. package/dist/auth/clerk.js +45 -9
  15. package/dist/auth/downstream-oauth.d.ts +0 -1
  16. package/dist/auth/downstream-oauth.js +0 -1
  17. package/dist/call-admission.d.ts +0 -1
  18. package/dist/call-admission.js +0 -1
  19. package/dist/catalog-drift.d.ts +99 -0
  20. package/dist/catalog-drift.js +251 -0
  21. package/dist/catalog-fingerprint.d.ts +0 -1
  22. package/dist/catalog-fingerprint.js +0 -1
  23. package/dist/catalog-limits.d.ts +0 -1
  24. package/dist/catalog-limits.js +0 -1
  25. package/dist/catalog-service.d.ts +0 -1
  26. package/dist/catalog-service.js +41 -5
  27. package/dist/catalog.d.ts +11 -1
  28. package/dist/catalog.js +20 -1
  29. package/dist/concurrency.d.ts +0 -1
  30. package/dist/concurrency.js +0 -1
  31. package/dist/connector-scope.d.ts +0 -1
  32. package/dist/connector-scope.js +0 -1
  33. package/dist/connectors/api.d.ts +29 -21
  34. package/dist/connectors/api.js +45 -19
  35. package/dist/connectors/guarded-fetch.d.ts +106 -0
  36. package/dist/connectors/guarded-fetch.js +289 -0
  37. package/dist/connectors/remote-mcp.d.ts +0 -1
  38. package/dist/connectors/remote-mcp.js +0 -1
  39. package/dist/credentials.d.ts +0 -1
  40. package/dist/credentials.js +0 -1
  41. package/dist/errors.d.ts +30 -3
  42. package/dist/errors.js +1 -1
  43. package/dist/execute.d.ts +0 -1
  44. package/dist/execute.js +0 -1
  45. package/dist/executor-admission.d.ts +11 -1
  46. package/dist/executor-admission.js +28 -1
  47. package/dist/executor-result.d.ts +0 -1
  48. package/dist/executor-result.js +0 -1
  49. package/dist/executors/quickjs-child.d.ts +0 -1
  50. package/dist/executors/quickjs-child.js +0 -1
  51. package/dist/executors/quickjs-protocol.d.ts +0 -1
  52. package/dist/executors/quickjs-protocol.js +0 -1
  53. package/dist/executors/quickjs-runtime.d.ts +0 -1
  54. package/dist/executors/quickjs-runtime.js +0 -1
  55. package/dist/executors/quickjs.d.ts +0 -1
  56. package/dist/executors/quickjs.js +2 -1
  57. package/dist/favicon.d.ts +0 -1
  58. package/dist/favicon.js +0 -1
  59. package/dist/index.d.ts +2 -3
  60. package/dist/index.js +24 -7
  61. package/dist/invocation.d.ts +0 -1
  62. package/dist/invocation.js +0 -1
  63. package/dist/json-schema.d.ts +0 -1
  64. package/dist/json-schema.js +0 -1
  65. package/dist/mcp-result.d.ts +0 -1
  66. package/dist/mcp-result.js +0 -1
  67. package/dist/meta-tools.d.ts +0 -1
  68. package/dist/meta-tools.js +0 -1
  69. package/dist/node.d.ts +0 -1
  70. package/dist/node.js +0 -1
  71. package/dist/operator-ui/generated.d.ts +2 -3
  72. package/dist/operator-ui/generated.js +3 -4
  73. package/dist/operator-ui/model.d.ts +10 -1
  74. package/dist/operator-ui/model.js +0 -1
  75. package/dist/operator-ui/view.d.ts +167 -0
  76. package/dist/operator-ui/view.js +270 -0
  77. package/dist/providers/cloudflare.d.ts +0 -1
  78. package/dist/providers/cloudflare.js +261 -299
  79. package/dist/providers/linear.d.ts +26 -5
  80. package/dist/providers/linear.js +32 -61
  81. package/dist/providers/mixpanel.d.ts +35 -4
  82. package/dist/providers/mixpanel.js +53 -79
  83. package/dist/providers/notion.d.ts +0 -1
  84. package/dist/providers/notion.js +87 -42
  85. package/dist/providers/stripe.d.ts +13 -1
  86. package/dist/providers/stripe.js +31 -60
  87. package/dist/registry.d.ts +34 -2
  88. package/dist/registry.js +85 -5
  89. package/dist/retired-toolkits.d.ts +0 -1
  90. package/dist/retired-toolkits.js +0 -1
  91. package/dist/routes/access-tokens.d.ts +0 -1
  92. package/dist/routes/access-tokens.js +0 -1
  93. package/dist/routes/activity.d.ts +0 -1
  94. package/dist/routes/activity.js +0 -1
  95. package/dist/routes/credentials.d.ts +0 -1
  96. package/dist/routes/credentials.js +0 -1
  97. package/dist/routes/mcp.d.ts +0 -1
  98. package/dist/routes/mcp.js +0 -1
  99. package/dist/routes/oauth.d.ts +0 -1
  100. package/dist/routes/oauth.js +0 -1
  101. package/dist/routes/shared.d.ts +2 -1
  102. package/dist/routes/shared.js +0 -1
  103. package/dist/routes/ui.d.ts +0 -1
  104. package/dist/routes/ui.js +0 -1
  105. package/dist/server.d.ts +0 -1
  106. package/dist/server.js +11 -1
  107. package/dist/skills.d.ts +0 -1
  108. package/dist/skills.js +0 -1
  109. package/dist/storage/file.d.ts +0 -1
  110. package/dist/storage/file.js +0 -1
  111. package/dist/storage/memory.d.ts +0 -1
  112. package/dist/storage/memory.js +0 -1
  113. package/dist/timeout.d.ts +0 -1
  114. package/dist/timeout.js +0 -1
  115. package/dist/tool-safety.d.ts +0 -1
  116. package/dist/tool-safety.js +0 -1
  117. package/dist/types.d.ts +45 -1
  118. package/dist/types.js +0 -1
  119. package/dist/ui.d.ts +0 -1
  120. package/dist/ui.js +13 -137
  121. package/dist/validate.d.ts +13 -15
  122. package/dist/validate.js +24 -11
  123. package/dist/version.d.ts +1 -2
  124. package/dist/version.js +1 -2
  125. package/documentation/architecture.md +186 -5
  126. package/documentation/auth.md +11 -0
  127. package/documentation/call-admission.md +142 -6
  128. package/documentation/cloudflare.md +92 -10
  129. package/documentation/code-first-exploration.md +2 -2
  130. package/documentation/code-mode.md +14 -14
  131. package/documentation/connector-guides.md +172 -5
  132. package/documentation/connectors.md +89 -0
  133. package/documentation/linear.md +20 -2
  134. package/documentation/mcp-ui-design.md +2 -2
  135. package/documentation/meta-tools.md +7 -1
  136. package/documentation/mixpanel.md +42 -9
  137. package/documentation/notion.md +23 -2
  138. package/documentation/operations.md +325 -5
  139. package/documentation/operator-ui.md +120 -5
  140. package/documentation/provider-audit.md +160 -0
  141. package/documentation/provider-conventions.md +667 -0
  142. package/documentation/request-admission.md +167 -5
  143. package/documentation/rich-output-design.md +1 -2
  144. package/documentation/stripe.md +15 -0
  145. package/documentation/upgrading.md +454 -0
  146. package/ethos.md +14 -9
  147. package/examples/worker/README.md +102 -12
  148. package/examples/worker/src/index.ts +46 -1
  149. package/examples/worker/wrangler.jsonc +14 -1
  150. package/package.json +10 -6
  151. package/templates/node/.dockerignore +10 -0
  152. package/templates/node/.env.example +40 -3
  153. package/templates/node/AGENTS.md +16 -1
  154. package/templates/node/Dockerfile +49 -0
  155. package/templates/node/README.md +117 -2
  156. package/templates/node/docker-compose.yml +39 -0
  157. package/templates/node/package.json +1 -1
  158. package/templates/node/src/file-activity.ts +182 -0
  159. package/templates/node/src/index.ts +80 -4
  160. package/assets/connecta-clay-hero.png +0 -0
  161. package/dist/access-tokens.d.ts.map +0 -1
  162. package/dist/access-tokens.js.map +0 -1
  163. package/dist/activity.d.ts.map +0 -1
  164. package/dist/activity.js.map +0 -1
  165. package/dist/apps-shell.d.ts.map +0 -1
  166. package/dist/apps-shell.js.map +0 -1
  167. package/dist/auth/bearer.d.ts.map +0 -1
  168. package/dist/auth/bearer.js.map +0 -1
  169. package/dist/auth/clerk.d.ts.map +0 -1
  170. package/dist/auth/clerk.js.map +0 -1
  171. package/dist/auth/downstream-oauth.d.ts.map +0 -1
  172. package/dist/auth/downstream-oauth.js.map +0 -1
  173. package/dist/call-admission.d.ts.map +0 -1
  174. package/dist/call-admission.js.map +0 -1
  175. package/dist/catalog-fingerprint.d.ts.map +0 -1
  176. package/dist/catalog-fingerprint.js.map +0 -1
  177. package/dist/catalog-limits.d.ts.map +0 -1
  178. package/dist/catalog-limits.js.map +0 -1
  179. package/dist/catalog-service.d.ts.map +0 -1
  180. package/dist/catalog-service.js.map +0 -1
  181. package/dist/catalog.d.ts.map +0 -1
  182. package/dist/catalog.js.map +0 -1
  183. package/dist/concurrency.d.ts.map +0 -1
  184. package/dist/concurrency.js.map +0 -1
  185. package/dist/connector-scope.d.ts.map +0 -1
  186. package/dist/connector-scope.js.map +0 -1
  187. package/dist/connectors/api.d.ts.map +0 -1
  188. package/dist/connectors/api.js.map +0 -1
  189. package/dist/connectors/remote-mcp.d.ts.map +0 -1
  190. package/dist/connectors/remote-mcp.js.map +0 -1
  191. package/dist/credentials.d.ts.map +0 -1
  192. package/dist/credentials.js.map +0 -1
  193. package/dist/errors.d.ts.map +0 -1
  194. package/dist/errors.js.map +0 -1
  195. package/dist/execute.d.ts.map +0 -1
  196. package/dist/execute.js.map +0 -1
  197. package/dist/executor-admission.d.ts.map +0 -1
  198. package/dist/executor-admission.js.map +0 -1
  199. package/dist/executor-result.d.ts.map +0 -1
  200. package/dist/executor-result.js.map +0 -1
  201. package/dist/executors/quickjs-child.d.ts.map +0 -1
  202. package/dist/executors/quickjs-child.js.map +0 -1
  203. package/dist/executors/quickjs-protocol.d.ts.map +0 -1
  204. package/dist/executors/quickjs-protocol.js.map +0 -1
  205. package/dist/executors/quickjs-runtime.d.ts.map +0 -1
  206. package/dist/executors/quickjs-runtime.js.map +0 -1
  207. package/dist/executors/quickjs.d.ts.map +0 -1
  208. package/dist/executors/quickjs.js.map +0 -1
  209. package/dist/favicon.d.ts.map +0 -1
  210. package/dist/favicon.js.map +0 -1
  211. package/dist/index.d.ts.map +0 -1
  212. package/dist/index.js.map +0 -1
  213. package/dist/invocation.d.ts.map +0 -1
  214. package/dist/invocation.js.map +0 -1
  215. package/dist/json-schema.d.ts.map +0 -1
  216. package/dist/json-schema.js.map +0 -1
  217. package/dist/mcp-result.d.ts.map +0 -1
  218. package/dist/mcp-result.js.map +0 -1
  219. package/dist/meta-tools.d.ts.map +0 -1
  220. package/dist/meta-tools.js.map +0 -1
  221. package/dist/node.d.ts.map +0 -1
  222. package/dist/node.js.map +0 -1
  223. package/dist/operator-ui/generated.d.ts.map +0 -1
  224. package/dist/operator-ui/generated.js.map +0 -1
  225. package/dist/operator-ui/model.d.ts.map +0 -1
  226. package/dist/operator-ui/model.js.map +0 -1
  227. package/dist/providers/cloudflare.d.ts.map +0 -1
  228. package/dist/providers/cloudflare.js.map +0 -1
  229. package/dist/providers/linear.d.ts.map +0 -1
  230. package/dist/providers/linear.js.map +0 -1
  231. package/dist/providers/mixpanel.d.ts.map +0 -1
  232. package/dist/providers/mixpanel.js.map +0 -1
  233. package/dist/providers/notion.d.ts.map +0 -1
  234. package/dist/providers/notion.js.map +0 -1
  235. package/dist/providers/stripe.d.ts.map +0 -1
  236. package/dist/providers/stripe.js.map +0 -1
  237. package/dist/registry.d.ts.map +0 -1
  238. package/dist/registry.js.map +0 -1
  239. package/dist/retired-toolkits.d.ts.map +0 -1
  240. package/dist/retired-toolkits.js.map +0 -1
  241. package/dist/routes/access-tokens.d.ts.map +0 -1
  242. package/dist/routes/access-tokens.js.map +0 -1
  243. package/dist/routes/activity.d.ts.map +0 -1
  244. package/dist/routes/activity.js.map +0 -1
  245. package/dist/routes/credentials.d.ts.map +0 -1
  246. package/dist/routes/credentials.js.map +0 -1
  247. package/dist/routes/mcp.d.ts.map +0 -1
  248. package/dist/routes/mcp.js.map +0 -1
  249. package/dist/routes/oauth.d.ts.map +0 -1
  250. package/dist/routes/oauth.js.map +0 -1
  251. package/dist/routes/shared.d.ts.map +0 -1
  252. package/dist/routes/shared.js.map +0 -1
  253. package/dist/routes/ui.d.ts.map +0 -1
  254. package/dist/routes/ui.js.map +0 -1
  255. package/dist/server.d.ts.map +0 -1
  256. package/dist/server.js.map +0 -1
  257. package/dist/skills.d.ts.map +0 -1
  258. package/dist/skills.js.map +0 -1
  259. package/dist/storage/file.d.ts.map +0 -1
  260. package/dist/storage/file.js.map +0 -1
  261. package/dist/storage/memory.d.ts.map +0 -1
  262. package/dist/storage/memory.js.map +0 -1
  263. package/dist/timeout.d.ts.map +0 -1
  264. package/dist/timeout.js.map +0 -1
  265. package/dist/tool-safety.d.ts.map +0 -1
  266. package/dist/tool-safety.js.map +0 -1
  267. package/dist/types.d.ts.map +0 -1
  268. package/dist/types.js.map +0 -1
  269. package/dist/ui.d.ts.map +0 -1
  270. package/dist/ui.js.map +0 -1
  271. package/dist/validate.d.ts.map +0 -1
  272. package/dist/validate.js.map +0 -1
  273. package/dist/version.d.ts.map +0 -1
  274. package/dist/version.js.map +0 -1
  275. package/examples/node/README.md +0 -52
  276. package/examples/node/src/index.ts +0 -71
  277. package/src/access-tokens.ts +0 -289
  278. package/src/activity.ts +0 -234
  279. package/src/apps-shell.ts +0 -370
  280. package/src/auth/bearer.ts +0 -61
  281. package/src/auth/clerk.ts +0 -553
  282. package/src/auth/downstream-oauth.ts +0 -575
  283. package/src/call-admission.ts +0 -521
  284. package/src/catalog-fingerprint.ts +0 -52
  285. package/src/catalog-limits.ts +0 -11
  286. package/src/catalog-service.ts +0 -1153
  287. package/src/catalog.ts +0 -790
  288. package/src/concurrency.ts +0 -39
  289. package/src/connector-scope.ts +0 -66
  290. package/src/connectors/api.ts +0 -159
  291. package/src/connectors/remote-mcp.ts +0 -1090
  292. package/src/credentials.ts +0 -428
  293. package/src/errors.ts +0 -364
  294. package/src/execute.ts +0 -1406
  295. package/src/executor-admission.ts +0 -328
  296. package/src/executor-result.ts +0 -107
  297. package/src/executors/quickjs-child.ts +0 -172
  298. package/src/executors/quickjs-protocol.ts +0 -99
  299. package/src/executors/quickjs-runtime.ts +0 -470
  300. package/src/executors/quickjs.ts +0 -776
  301. package/src/favicon.ts +0 -53
  302. package/src/index.ts +0 -666
  303. package/src/invocation.ts +0 -655
  304. package/src/json-schema.ts +0 -11
  305. package/src/mcp-result.ts +0 -43
  306. package/src/meta-tools.ts +0 -1607
  307. package/src/node.ts +0 -206
  308. package/src/operator-ui/browser.css +0 -423
  309. package/src/operator-ui/browser.ts +0 -1281
  310. package/src/operator-ui/generated.ts +0 -4
  311. package/src/operator-ui/model.ts +0 -111
  312. package/src/providers/cloudflare.ts +0 -3999
  313. package/src/providers/linear.ts +0 -302
  314. package/src/providers/mixpanel.ts +0 -229
  315. package/src/providers/notion.ts +0 -1879
  316. package/src/providers/stripe.ts +0 -307
  317. package/src/registry.ts +0 -1085
  318. package/src/retired-toolkits.ts +0 -14
  319. package/src/routes/access-tokens.ts +0 -115
  320. package/src/routes/activity.ts +0 -215
  321. package/src/routes/credentials.ts +0 -254
  322. package/src/routes/mcp.ts +0 -429
  323. package/src/routes/oauth.ts +0 -322
  324. package/src/routes/shared.ts +0 -247
  325. package/src/routes/ui.ts +0 -152
  326. package/src/server.ts +0 -195
  327. package/src/skills.ts +0 -271
  328. package/src/storage/file.ts +0 -123
  329. package/src/storage/memory.ts +0 -44
  330. package/src/timeout.ts +0 -84
  331. package/src/tool-safety.ts +0 -15
  332. package/src/types.ts +0 -523
  333. package/src/ui.ts +0 -744
  334. package/src/validate.ts +0 -387
  335. package/src/version.ts +0 -7
package/src/registry.ts DELETED
@@ -1,1085 +0,0 @@
1
- import type {
2
- Connector,
3
- ConnectorContext,
4
- ConnectorStatus,
5
- KVStorage,
6
- Logger,
7
- ToolDef,
8
- } from "./types.js";
9
- import {
10
- storedCredentialShape,
11
- type CredentialVault,
12
- } from "./credentials.js";
13
- import { ConnectorCallError } from "./errors.js";
14
- import {
15
- ConnectorCallAdmissionController,
16
- type CallAdmissionPermit,
17
- type ConnectorCallAdmissionSnapshot,
18
- } from "./call-admission.js";
19
- import {
20
- fingerprintSerializedCatalog,
21
- snapshotCatalog,
22
- type CatalogSnapshot,
23
- } from "./catalog-fingerprint.js";
24
- import {
25
- MAX_CATALOG_CHUNK_BYTES,
26
- MAX_CATALOG_TOOLS,
27
- MAX_SERIALIZED_CATALOG_BYTES,
28
- } from "./catalog-limits.js";
29
- import { mapSettledWithConcurrency } from "./concurrency.js";
30
-
31
- const ID_RE = /^[a-z0-9_-]+$/;
32
- const DEFAULT_TTL_SECONDS = 300;
33
- const DEFAULT_STALE_SECONDS = 3600;
34
- const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
35
- const DEFAULT_MAX_RESULT_BYTES = 50_000;
36
- const encoder = new TextEncoder();
37
-
38
- /**
39
- * Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
40
- * no dots, so a downstream tool name may. Exported because an address that
41
- * resolves to nothing is still an address the invocation path has to record
42
- * activity for — a connector id an agent invented is the most common address
43
- * mistake, and the one an operator most needs to see.
44
- */
45
- export function splitAddress(
46
- address: string,
47
- ): { connectorId: string; toolName: string } | null {
48
- const dot = address.indexOf(".");
49
- if (dot <= 0 || dot === address.length - 1) return null;
50
- return {
51
- connectorId: address.slice(0, dot),
52
- toolName: address.slice(dot + 1),
53
- };
54
- }
55
-
56
- /**
57
- * Smallest accepted inline-result cap. One byte is pathological but harmless:
58
- * `alignEndToCharBoundary` widens a window narrower than the codepoint at the
59
- * offset, so even a 1-byte cap still truncates sanely and still pages. Caps
60
- * that small already ship in the test suite (4 and 5), so the floor is placed
61
- * where it excludes only values that are *broken* rather than merely tiny.
62
- */
63
- export const MIN_MAX_RESULT_BYTES = 1;
64
-
65
- /**
66
- * The one definition of a usable `maxResultBytes`: a finite whole number of at
67
- * least {@link MIN_MAX_RESULT_BYTES} bytes. Shared by all three intake points
68
- * — `calls.maxResultBytes`, the per-connector override, and `get_result`'s
69
- * `maxBytes` argument — so a value that is valid at one is valid at all.
70
- *
71
- * Everything else is rejected rather than coerced, because each rejected shape
72
- * silently does something *worse* than the default: `0`/`NaN` serve an empty
73
- * head (`slice(0, 0)`) and make paging fail to advance, negatives serve a
74
- * LARGER head than asked for (`slice(0, -1)` counts from the end) while still
75
- * claiming truncation, and `Infinity` disables the guard with no notice.
76
- */
77
- export function isValidMaxResultBytes(value: number): boolean {
78
- return Number.isInteger(value) && value >= MIN_MAX_RESULT_BYTES;
79
- }
80
-
81
- /**
82
- * Resolve a configured cap against the value it inherits, dropping anything
83
- * `isValidMaxResultBytes` rejects. Operator-facing surfaces pair this with a
84
- * startup warning (see `Registry.checkResultCaps`) so the fallback is never
85
- * silent; the resolution itself stays total so no call site has to cope with
86
- * a broken cap.
87
- */
88
- export function resolveMaxResultBytes(
89
- value: number | undefined,
90
- inherited: number,
91
- ): number {
92
- return value !== undefined && isValidMaxResultBytes(value)
93
- ? value
94
- : inherited;
95
- }
96
-
97
- interface CacheEntry {
98
- tools: ToolDef[];
99
- fingerprint: string;
100
- exp: number; // epoch ms
101
- staleUntil: number;
102
- }
103
-
104
- interface LegacyPersistedCatalog {
105
- tools: ToolDef[];
106
- /** Optional only for catalogs written before fingerprints were introduced. */
107
- fingerprint?: string;
108
- fetchedAt: number;
109
- expiresAt: number;
110
- staleUntil: number;
111
- }
112
-
113
- interface PersistedCatalogManifest {
114
- version: 2;
115
- revision: string;
116
- toolCount: number;
117
- byteCount: number;
118
- chunkCount: number;
119
- fetchedAt: number;
120
- expiresAt: number;
121
- staleUntil: number;
122
- }
123
-
124
- interface PersistedCatalog {
125
- tools: ToolDef[];
126
- fingerprint: string;
127
- fetchedAt: number;
128
- expiresAt: number;
129
- staleUntil: number;
130
- }
131
-
132
- const CATALOG_CHUNK_IO_CONCURRENCY = 4;
133
-
134
- export interface HealthObservation {
135
- lastSuccessAt?: string;
136
- lastFailureAt?: string;
137
- lastLatencyMs?: number;
138
- consecutiveFailures: number;
139
- lastError?: string;
140
- }
141
-
142
- /**
143
- * Recent real-call outcomes per connector.
144
- */
145
- class HealthLog {
146
- private readonly observations = new Map<string, HealthObservation>();
147
-
148
- recordSuccess(id: string, latencyMs: number): void {
149
- const previous = this.observations.get(id);
150
- const observation: HealthObservation = {
151
- ...previous,
152
- lastSuccessAt: new Date().toISOString(),
153
- lastLatencyMs: latencyMs,
154
- consecutiveFailures: 0,
155
- };
156
- delete observation.lastError;
157
- this.observations.set(id, observation);
158
- }
159
-
160
- recordFailure(id: string, latencyMs: number, error: unknown): void {
161
- const previous = this.observations.get(id);
162
- this.observations.set(id, {
163
- ...previous,
164
- lastFailureAt: new Date().toISOString(),
165
- lastLatencyMs: latencyMs,
166
- consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
167
- lastError: msg(error),
168
- });
169
- }
170
-
171
- get(id: string): HealthObservation | undefined {
172
- const observation = this.observations.get(id);
173
- return observation ? { ...observation } : undefined;
174
- }
175
- }
176
-
177
- export interface RegistryOptions {
178
- storage: KVStorage;
179
- logger: Logger;
180
- credentialVault?: CredentialVault;
181
- toolCacheTtlSeconds?: number;
182
- persistToolCatalog?: boolean;
183
- toolCatalogStaleSeconds?: number;
184
- /**
185
- * Cap on inline result size before truncation + get_result paging. Must be a
186
- * whole number of bytes >= 1; anything else warns at startup and falls back
187
- * to the default 50_000.
188
- */
189
- maxResultBytes?: number;
190
- }
191
-
192
- function namespaced(storage: KVStorage, prefix: string): KVStorage {
193
- return {
194
- get: (k) => storage.get(prefix + k),
195
- set: (k, v, o) => storage.set(prefix + k, v, o),
196
- delete: (k) => storage.delete(prefix + k),
197
- };
198
- }
199
-
200
- function msg(err: unknown): string {
201
- return err instanceof Error ? err.message : String(err);
202
- }
203
-
204
- export type ConnectorOperationOptions = Pick<
205
- ConnectorContext,
206
- "signal" | "timeoutMs"
207
- >;
208
-
209
- /**
210
- * The registry surface a per-connection MCP server consumes: every meta-tool
211
- * (`src/meta-tools.ts`) and the `execute_code` sandbox bridge (`src/execute.ts`)
212
- * is typed against THIS, never against the concrete `Registry`.
213
- *
214
- * The read-only seam remains useful even without scoped views: meta-tools can
215
- * consume registry behavior without depending on the concrete implementation
216
- * or its construction-only methods.
217
- */
218
- export interface RegistryView {
219
- /** Deployment-wide result-size cap threaded to the meta-tools. */
220
- readonly maxResultBytes: number;
221
- listConnectors(): Connector[];
222
- getConnector(id: string): Connector | undefined;
223
- resolveAddress(
224
- address: string,
225
- ): { connector: Connector; toolName: string } | null;
226
- getTools(
227
- id: string,
228
- baseUrl: string,
229
- requestScope?: object,
230
- callOptions?: ConnectorOperationOptions,
231
- ): Promise<ToolDef[]>;
232
- refreshTools(
233
- id: string,
234
- baseUrl: string,
235
- requestScope?: object,
236
- callOptions?: ConnectorOperationOptions,
237
- ): Promise<ToolDef[]>;
238
- peekTools(id: string): ToolDef[] | undefined;
239
- contextFor(
240
- id: string,
241
- baseUrl: string,
242
- requestScope?: object,
243
- callOptions?: ConnectorOperationOptions,
244
- ): ConnectorContext;
245
- /** Acquire the connector's shared downstream-call permit. */
246
- admitCall(
247
- id: string,
248
- input: { toolName: string; args: unknown; signal?: AbortSignal },
249
- ): Promise<CallAdmissionPermit>;
250
- resultsStorage(): KVStorage;
251
- recordSuccess(id: string, latencyMs: number): void;
252
- recordFailure(id: string, latencyMs: number, error: unknown): void;
253
- healthFor(id: string): HealthObservation | undefined;
254
- hasObservedSuccess(id: string): boolean;
255
- observedSuccessAt(id: string): string | undefined;
256
- /** Local declared-vs-stored credential mismatch, with no downstream I/O. */
257
- credentialDriftFor(id: string): Promise<string | undefined>;
258
- statusFor(
259
- id: string,
260
- baseUrl: string,
261
- requestScope?: object,
262
- callOptions?: ConnectorOperationOptions,
263
- ): Promise<ConnectorStatus>;
264
- invalidateStored(id: string): Promise<void>;
265
- }
266
-
267
- /**
268
- * Holds the connector set, resolves addresses, and caches per-connector tool
269
- * lists in memory with a TTL. Connector failures are isolated: a broken
270
- * connector surfaces status "error"; the rest keep working.
271
- */
272
- export class Registry implements RegistryView {
273
- private readonly connectors = new Map<string, Connector>();
274
- private readonly callAdmission = new Map<
275
- string,
276
- ConnectorCallAdmissionController
277
- >();
278
- private readonly cache = new Map<string, CacheEntry>();
279
- private readonly invalidated = new Set<string>();
280
- /** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
281
- private readonly catalogGenerations = new Map<string, number>();
282
- /** Serialize persisted catalog set/delete operations within this isolate. */
283
- private readonly catalogMutations = new Map<string, Promise<void>>();
284
- /** Same-request cold loads share one promise without retaining the request. */
285
- private readonly requestCatalogLoads = new WeakMap<
286
- object,
287
- Map<string, Promise<ToolDef[]>>
288
- >();
289
- /** Deployment-wide observations — every call, whatever view made it. */
290
- private readonly health = new HealthLog();
291
- private readonly ttlMs: number;
292
- private readonly staleMs: number;
293
- private readonly persistToolCatalog: boolean;
294
- /** Result-size guard cap threaded to the meta-tools. */
295
- readonly maxResultBytes: number;
296
-
297
- constructor(
298
- connectors: Connector[],
299
- private readonly opts: RegistryOptions,
300
- ) {
301
- this.ttlMs =
302
- (opts.toolCacheTtlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
303
- this.staleMs =
304
- (opts.toolCatalogStaleSeconds ?? DEFAULT_STALE_SECONDS) * 1000;
305
- this.persistToolCatalog = opts.persistToolCatalog ?? true;
306
- this.maxResultBytes = resolveMaxResultBytes(
307
- opts.maxResultBytes,
308
- DEFAULT_MAX_RESULT_BYTES,
309
- );
310
- for (const c of connectors) {
311
- if (!ID_RE.test(c.id)) {
312
- throw new Error(
313
- `Invalid connector id "${c.id}": must match ${ID_RE.source}`,
314
- );
315
- }
316
- if (this.connectors.has(c.id)) {
317
- throw new Error(`Duplicate connector id "${c.id}"`);
318
- }
319
- this.connectors.set(c.id, c);
320
- if (c.callAdmission) {
321
- this.callAdmission.set(
322
- c.id,
323
- new ConnectorCallAdmissionController(c.id, c.callAdmission),
324
- );
325
- }
326
- }
327
- this.checkConventions(opts.logger);
328
- this.checkResultCaps(opts.logger, opts.maxResultBytes);
329
- }
330
-
331
- /**
332
- * Warn once per unusable result cap, at construction time — the same
333
- * "runs fine but is surely unintended" channel as the insecure-config
334
- * warnings in `createConnecta`. A rejected cap can't be honoured, and
335
- * honouring it *approximately* is exactly the inversion issue #32 is about,
336
- * so the value is dropped in favour of what it inherits and the operator is
337
- * told which one is actually in force.
338
- */
339
- private checkResultCaps(
340
- logger: Logger,
341
- configured: number | undefined,
342
- ): void {
343
- if (configured !== undefined && !isValidMaxResultBytes(configured)) {
344
- logger.warn(
345
- `[connecta] calls.maxResultBytes ${configured} is not a whole number of ` +
346
- `bytes >= ${MIN_MAX_RESULT_BYTES}: it would serve an empty, ` +
347
- "oversized, or unguarded result instead of truncating. Using the " +
348
- `default ${DEFAULT_MAX_RESULT_BYTES} instead.`,
349
- );
350
- }
351
- for (const c of this.connectors.values()) {
352
- if (
353
- c.maxResultBytes !== undefined &&
354
- !isValidMaxResultBytes(c.maxResultBytes)
355
- ) {
356
- // The number quoted here is `this.maxResultBytes` because that is
357
- // literally what a call falls back to: meta-tools reads the inherited
358
- // cap off `RegistryView.maxResultBytes`, so the warned value and the
359
- // runtime value are the same field rather than two copies of it.
360
- logger.warn(
361
- `[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}).`,
366
- );
367
- }
368
- }
369
- }
370
-
371
- /**
372
- * Warn once per convention violation at construction time. Static only —
373
- * never calls listTools() (remote connectors are lazy/network); tool-level
374
- * checks apply to connectors that expose `staticTools` (i.e. api()).
375
- */
376
- private checkConventions(logger: Logger): void {
377
- for (const c of this.connectors.values()) {
378
- if (!c.description) {
379
- logger.warn(
380
- `[connecta] connector "${c.id}" has no description — add one (convention: "<Service> — <top capabilities>")`,
381
- );
382
- }
383
- for (const t of c.staticTools ?? []) {
384
- const address = `${c.id}.${t.name}`;
385
- if (!t.description) {
386
- logger.warn(
387
- `[connecta] tool "${address}" has no description — add one (convention: imperative one-liner, e.g. "Send an email via Resend")`,
388
- );
389
- }
390
- if (!t.inputSchema) {
391
- logger.warn(
392
- `[connecta] tool "${address}" has no inputSchema — add one (convention: { type: "object" } with a description on every property)`,
393
- );
394
- }
395
- }
396
- }
397
- }
398
-
399
- listConnectors(): Connector[] {
400
- return [...this.connectors.values()];
401
- }
402
-
403
- getConnector(id: string): Connector | undefined {
404
- return this.connectors.get(id);
405
- }
406
-
407
- contextFor(
408
- id: string,
409
- baseUrl: string,
410
- requestScope: object = {},
411
- callOptions: ConnectorOperationOptions = {},
412
- ): ConnectorContext {
413
- const credentialConfig = this.connectors.get(id)?.credential;
414
- let credentialAccess: ConnectorContext["credential"];
415
- if (this.opts.credentialVault && credentialConfig) {
416
- const vault = this.opts.credentialVault;
417
- const readValues = async () => {
418
- const values = await vault.getAll(id);
419
- const shape = storedCredentialShape(credentialConfig, values);
420
- if (shape.state === "mismatch") {
421
- throw new ConnectorCallError("auth_required", shape.message);
422
- }
423
- return values;
424
- };
425
- credentialAccess = {
426
- get: async (field = "value") =>
427
- (await readValues())?.[field] ?? null,
428
- getAll: readValues,
429
- };
430
- }
431
- return {
432
- storage: namespaced(this.opts.storage, `conn:${id}:`),
433
- logger: this.opts.logger,
434
- baseUrl,
435
- ...(credentialAccess ? { credential: credentialAccess } : {}),
436
- requestScope,
437
- ...callOptions,
438
- };
439
- }
440
-
441
- admitCall(
442
- id: string,
443
- input: { toolName: string; args: unknown; signal?: AbortSignal },
444
- ): Promise<CallAdmissionPermit> {
445
- const admission = this.callAdmission.get(id);
446
- if (admission) return admission.acquire(input);
447
- return Promise.resolve({ waitMs: 0, release() {} });
448
- }
449
-
450
- /** Payload-free aggregate state for the open health endpoint. */
451
- callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot> {
452
- return Object.fromEntries(
453
- [...this.callAdmission].map(([id, admission]) => [
454
- id,
455
- admission.snapshot(),
456
- ]),
457
- );
458
- }
459
-
460
- /** Reject queued/future downstream admission; active permits release safely. */
461
- closeCallAdmission(): void {
462
- for (const admission of this.callAdmission.values()) admission.close();
463
- }
464
-
465
- /**
466
- * Storage namespaced to the meta-tool result store (`results:` prefix), kept
467
- * separate from any connector's `conn:<id>:` namespace. Backs get_result.
468
- */
469
- resultsStorage(): KVStorage {
470
- return namespaced(this.opts.storage, "results:");
471
- }
472
-
473
- /** Resolve "<connectorId>.<toolName>" → connector + tool name. */
474
- resolveAddress(
475
- address: string,
476
- ): { connector: Connector; toolName: string } | null {
477
- const parts = splitAddress(address);
478
- if (!parts) return null;
479
- const connector = this.connectors.get(parts.connectorId);
480
- if (!connector) return null;
481
- return { connector, toolName: parts.toolName };
482
- }
483
-
484
- private catalogKey(id: string): string {
485
- return `catalog:${id}`;
486
- }
487
-
488
- private catalogChunkKey(id: string, revision: string, index: number): string {
489
- return `${this.catalogKey(id)}:chunk:${revision}:${index}`;
490
- }
491
-
492
- private validLegacyCatalog(value: unknown): LegacyPersistedCatalog | null {
493
- if (!value || typeof value !== "object") return null;
494
- const catalog = value as Partial<LegacyPersistedCatalog>;
495
- if (
496
- !Array.isArray(catalog.tools) ||
497
- typeof catalog.fetchedAt !== "number" ||
498
- typeof catalog.expiresAt !== "number" ||
499
- typeof catalog.staleUntil !== "number" ||
500
- (catalog.fingerprint !== undefined &&
501
- typeof catalog.fingerprint !== "string") ||
502
- !this.validCatalogTools(catalog.tools)
503
- ) {
504
- return null;
505
- }
506
- return catalog as LegacyPersistedCatalog;
507
- }
508
-
509
- private validCatalogTools(value: unknown[]): value is ToolDef[] {
510
- return value.every(
511
- (tool) =>
512
- tool !== null &&
513
- typeof tool === "object" &&
514
- typeof (tool as ToolDef).name === "string",
515
- );
516
- }
517
-
518
- private validCatalogManifest(
519
- value: unknown,
520
- ): PersistedCatalogManifest | null {
521
- if (!value || typeof value !== "object") return null;
522
- const manifest = value as Partial<PersistedCatalogManifest>;
523
- const maxChunks =
524
- Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES) + 1;
525
- if (
526
- manifest.version !== 2 ||
527
- typeof manifest.revision !== "string" ||
528
- !/^sha256:[0-9]{1,8}:[0-9a-f]{64}$/.test(manifest.revision) ||
529
- !Number.isInteger(manifest.toolCount) ||
530
- manifest.toolCount! < 0 ||
531
- manifest.toolCount! > MAX_CATALOG_TOOLS ||
532
- !Number.isInteger(manifest.byteCount) ||
533
- manifest.byteCount! < 2 ||
534
- manifest.byteCount! > MAX_SERIALIZED_CATALOG_BYTES ||
535
- !manifest.revision.startsWith(`sha256:${manifest.byteCount}:`) ||
536
- !Number.isInteger(manifest.chunkCount) ||
537
- manifest.chunkCount! < 1 ||
538
- manifest.chunkCount! > maxChunks ||
539
- typeof manifest.fetchedAt !== "number" ||
540
- typeof manifest.expiresAt !== "number" ||
541
- typeof manifest.staleUntil !== "number"
542
- ) {
543
- return null;
544
- }
545
- return manifest as PersistedCatalogManifest;
546
- }
547
-
548
- private parseCatalogManifest(
549
- raw: string | null,
550
- ): PersistedCatalogManifest | null {
551
- if (!raw) return null;
552
- try {
553
- return this.validCatalogManifest(JSON.parse(raw));
554
- } catch {
555
- return null;
556
- }
557
- }
558
-
559
- private splitCatalogChunks(snapshot: CatalogSnapshot): string[] {
560
- const chunks: string[] = [];
561
- const decoder = new TextDecoder("utf-8", {
562
- fatal: true,
563
- ignoreBOM: true,
564
- });
565
- let offset = 0;
566
- while (offset < snapshot.serializedBytes.byteLength) {
567
- let end = Math.min(
568
- offset + MAX_CATALOG_CHUNK_BYTES,
569
- snapshot.serializedBytes.byteLength,
570
- );
571
- // Move a boundary that landed inside a multibyte UTF-8 sequence back to
572
- // the next character start. Every stored string then remains valid UTF-8.
573
- while (
574
- end < snapshot.serializedBytes.byteLength &&
575
- ((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80
576
- ) {
577
- end--;
578
- }
579
- chunks.push(
580
- decoder.decode(snapshot.serializedBytes.subarray(offset, end)),
581
- );
582
- offset = end;
583
- }
584
- return chunks;
585
- }
586
-
587
- private async readCatalog(
588
- id: string,
589
- raw: string | null,
590
- now: number,
591
- ): Promise<PersistedCatalog | null> {
592
- if (!raw) return null;
593
- let parsed: unknown;
594
- try {
595
- parsed = JSON.parse(raw);
596
- } catch {
597
- return null;
598
- }
599
-
600
- if (
601
- !parsed ||
602
- typeof parsed !== "object" ||
603
- (parsed as { version?: unknown }).version !== 2
604
- ) {
605
- const legacy = this.validLegacyCatalog(parsed);
606
- if (!legacy || legacy.staleUntil <= now) return null;
607
- const snapshot = await snapshotCatalog(legacy.tools);
608
- if (
609
- legacy.tools.length > MAX_CATALOG_TOOLS ||
610
- snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
611
- (legacy.fingerprint !== undefined &&
612
- legacy.fingerprint !== snapshot.fingerprint)
613
- ) {
614
- this.opts.logger.warn(
615
- `[connecta] connector "${id}" legacy catalog is oversized or has a fingerprint mismatch; ignoring persisted catalog.`,
616
- );
617
- return null;
618
- }
619
- return {
620
- tools: legacy.tools,
621
- fingerprint: snapshot.fingerprint,
622
- fetchedAt: legacy.fetchedAt,
623
- expiresAt: legacy.expiresAt,
624
- staleUntil: legacy.staleUntil,
625
- };
626
- }
627
-
628
- const manifest = this.validCatalogManifest(parsed);
629
- if (!manifest) {
630
- this.opts.logger.warn(
631
- `[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`,
632
- );
633
- return null;
634
- }
635
- if (manifest.staleUntil <= now) return null;
636
-
637
- const chunkReads = await mapSettledWithConcurrency(
638
- Array.from({ length: manifest.chunkCount }, (_, index) => index),
639
- CATALOG_CHUNK_IO_CONCURRENCY,
640
- (index) =>
641
- this.opts.storage.get(
642
- this.catalogChunkKey(id, manifest.revision, index),
643
- ),
644
- );
645
- const chunks: string[] = [];
646
- let chunkBytes = 0;
647
- for (const [index, result] of chunkReads.entries()) {
648
- if (result.status === "rejected") throw result.reason;
649
- const chunk = result.value;
650
- if (chunk === null) {
651
- this.opts.logger.warn(
652
- `[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`,
653
- );
654
- return null;
655
- }
656
- const byteLength = encoder.encode(chunk).byteLength;
657
- chunkBytes += byteLength;
658
- if (
659
- byteLength > MAX_CATALOG_CHUNK_BYTES ||
660
- chunkBytes > manifest.byteCount
661
- ) {
662
- this.opts.logger.warn(
663
- `[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`,
664
- );
665
- return null;
666
- }
667
- chunks.push(chunk);
668
- }
669
-
670
- const serializedTools = chunks.join("");
671
- const stored = await fingerprintSerializedCatalog(serializedTools);
672
- if (
673
- stored.byteLength !== manifest.byteCount ||
674
- stored.fingerprint !== manifest.revision
675
- ) {
676
- this.opts.logger.warn(
677
- `[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`,
678
- );
679
- return null;
680
- }
681
-
682
- let tools: unknown;
683
- try {
684
- tools = JSON.parse(serializedTools);
685
- } catch {
686
- this.opts.logger.warn(
687
- `[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`,
688
- );
689
- return null;
690
- }
691
- if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
692
- this.opts.logger.warn(
693
- `[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`,
694
- );
695
- return null;
696
- }
697
- if (tools.length !== manifest.toolCount) {
698
- this.opts.logger.warn(
699
- `[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`,
700
- );
701
- return null;
702
- }
703
- return {
704
- tools,
705
- fingerprint: stored.fingerprint,
706
- fetchedAt: manifest.fetchedAt,
707
- expiresAt: manifest.expiresAt,
708
- staleUntil: manifest.staleUntil,
709
- };
710
- }
711
-
712
- private async storeCatalog(
713
- id: string,
714
- snapshot: CatalogSnapshot,
715
- ): Promise<void> {
716
- if (!this.persistToolCatalog) return;
717
- const fetchedAt = Date.now();
718
- const expiresAt = fetchedAt + this.ttlMs;
719
- const staleUntil = expiresAt + this.staleMs;
720
- const ttlSeconds = Math.max(
721
- 60,
722
- Math.ceil((this.ttlMs + this.staleMs) / 1000),
723
- );
724
- const chunks = this.splitCatalogChunks(snapshot);
725
- const chunkWrites = await mapSettledWithConcurrency(
726
- chunks,
727
- CATALOG_CHUNK_IO_CONCURRENCY,
728
- (chunk, index) =>
729
- this.opts.storage.set(
730
- this.catalogChunkKey(id, snapshot.fingerprint, index),
731
- chunk,
732
- { ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS },
733
- ),
734
- );
735
- for (const result of chunkWrites) {
736
- if (result.status === "rejected") throw result.reason;
737
- }
738
- // The manifest is the only publication point. A failed/partial chunk write
739
- // therefore leaves the previous manifest authoritative (or no catalog);
740
- // unreachable chunks carry a bounded TTL and require no prefix scan.
741
- const manifest: PersistedCatalogManifest = {
742
- version: 2,
743
- revision: snapshot.fingerprint,
744
- toolCount: snapshot.tools.length,
745
- byteCount: snapshot.serializedBytes.byteLength,
746
- chunkCount: chunks.length,
747
- fetchedAt,
748
- expiresAt,
749
- staleUntil,
750
- };
751
- await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
752
- ttlSeconds,
753
- });
754
- }
755
-
756
- private async deleteCatalog(id: string): Promise<void> {
757
- let raw: string | null = null;
758
- let readError: unknown;
759
- try {
760
- raw = await this.opts.storage.get(this.catalogKey(id));
761
- } catch (err) {
762
- readError = err;
763
- }
764
- const manifest = this.parseCatalogManifest(raw);
765
- // The root is authoritative, so attempt its deletion even when the
766
- // best-effort read needed for physical chunk cleanup failed.
767
- await this.opts.storage.delete(this.catalogKey(id));
768
- if (!manifest) {
769
- if (readError) throw readError;
770
- return;
771
- }
772
- let firstError: unknown;
773
- for (let index = 0; index < manifest.chunkCount; index++) {
774
- try {
775
- await this.opts.storage.delete(
776
- this.catalogChunkKey(id, manifest.revision, index),
777
- );
778
- } catch (err) {
779
- firstError ??= err;
780
- }
781
- }
782
- if (readError) throw readError;
783
- if (firstError) throw firstError;
784
- }
785
-
786
- private catalogGeneration(id: string): number {
787
- return this.catalogGenerations.get(id) ?? 0;
788
- }
789
-
790
- private advanceCatalogGeneration(id: string): void {
791
- this.catalogGenerations.set(id, this.catalogGeneration(id) + 1);
792
- }
793
-
794
- /**
795
- * Keep this isolate's writes and invalidations ordered. Without the queue, an
796
- * old refresh can finish its storage.set after a credential change deletes
797
- * the catalog and resurrect the pre-change listing.
798
- */
799
- private enqueueCatalogMutation(
800
- id: string,
801
- operation: () => Promise<void>,
802
- ): Promise<void> {
803
- const previous = this.catalogMutations.get(id) ?? Promise.resolve();
804
- const next = previous.catch(() => {}).then(operation);
805
- this.catalogMutations.set(id, next);
806
- void next
807
- .finally(() => {
808
- if (this.catalogMutations.get(id) === next) {
809
- this.catalogMutations.delete(id);
810
- }
811
- })
812
- .catch(() => {});
813
- return next;
814
- }
815
-
816
- /** Force a live listTools refresh and replace both catalog cache layers. */
817
- async refreshTools(
818
- id: string,
819
- baseUrl: string,
820
- requestScope?: object,
821
- callOptions: ConnectorOperationOptions = {},
822
- ): Promise<ToolDef[]> {
823
- const connector = this.connectors.get(id);
824
- if (!connector) throw new Error(`Unknown connector "${id}"`);
825
- if (connector.staticTools) return connector.staticTools;
826
- const generation = this.catalogGeneration(id);
827
- const tools = await connector.listTools(
828
- this.contextFor(id, baseUrl, requestScope, callOptions),
829
- );
830
- // The caller that began this refresh may still use its result, but a
831
- // credential/OAuth change that landed while listTools was in flight means
832
- // the listing must not enter either shared cache layer.
833
- if (generation !== this.catalogGeneration(id)) return tools;
834
- if (tools.length > MAX_CATALOG_TOOLS) {
835
- const message =
836
- `Connector "${id}" returned ${tools.length} tools, over the ` +
837
- `${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
838
- this.opts.logger.warn(`[connecta] ${message}`);
839
- throw new Error(message);
840
- }
841
- const previous = this.cache.get(id);
842
- const snapshot = await snapshotCatalog(tools);
843
- if (generation !== this.catalogGeneration(id)) return tools;
844
- if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
845
- const message =
846
- `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
847
- `serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
848
- "refusing the complete catalog.";
849
- this.opts.logger.warn(`[connecta] ${message}`);
850
- throw new Error(message);
851
- }
852
- const now = Date.now();
853
- const catalogChanged =
854
- !previous || previous.fingerprint !== snapshot.fingerprint;
855
- const shouldPersist =
856
- catalogChanged ||
857
- (previous !== undefined && previous.exp <= now) ||
858
- this.invalidated.has(id);
859
- this.cache.set(id, {
860
- tools,
861
- fingerprint: snapshot.fingerprint,
862
- exp: now + this.ttlMs,
863
- staleUntil: now + this.ttlMs + this.staleMs,
864
- });
865
- this.invalidated.delete(id);
866
- if (shouldPersist) {
867
- await this.enqueueCatalogMutation(id, async () => {
868
- if (generation !== this.catalogGeneration(id)) return;
869
- try {
870
- await this.storeCatalog(id, snapshot);
871
- } catch (err) {
872
- this.opts.logger.warn(
873
- `[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
874
- );
875
- }
876
- });
877
- }
878
- return tools;
879
- }
880
-
881
- /** Cached listTools with in-memory + persisted serializable catalog layers. */
882
- private async loadTools(
883
- id: string,
884
- baseUrl: string,
885
- requestScope?: object,
886
- callOptions: ConnectorOperationOptions = {},
887
- ): Promise<ToolDef[]> {
888
- const connector = this.connectors.get(id);
889
- if (!connector) throw new Error(`Unknown connector "${id}"`);
890
- if (connector.staticTools) return connector.staticTools;
891
-
892
- const now = Date.now();
893
- const requestGeneration = this.catalogGeneration(id);
894
- const hit = this.cache.get(id);
895
- if (hit && hit.exp > now) return hit.tools;
896
-
897
- let stale = hit && hit.staleUntil > now ? hit.tools : undefined;
898
- if (this.persistToolCatalog && !this.invalidated.has(id)) {
899
- const generation = this.catalogGeneration(id);
900
- let persisted: PersistedCatalog | null = null;
901
- try {
902
- persisted = await this.readCatalog(
903
- id,
904
- await this.opts.storage.get(this.catalogKey(id)),
905
- now,
906
- );
907
- } catch (err) {
908
- this.opts.logger.warn(
909
- `[connecta] connector "${id}" catalog read failed: ${msg(err)}`,
910
- );
911
- }
912
- if (generation !== this.catalogGeneration(id)) {
913
- persisted = null;
914
- stale = undefined;
915
- }
916
- if (persisted && persisted.staleUntil > now) {
917
- this.cache.set(id, {
918
- tools: persisted.tools,
919
- fingerprint: persisted.fingerprint,
920
- exp: persisted.expiresAt,
921
- staleUntil: persisted.staleUntil,
922
- });
923
- if (persisted.expiresAt > now) return persisted.tools;
924
- stale = persisted.tools;
925
- }
926
- }
927
-
928
- try {
929
- return await this.refreshTools(id, baseUrl, requestScope, callOptions);
930
- } catch (err) {
931
- if (
932
- stale &&
933
- requestGeneration === this.catalogGeneration(id) &&
934
- !this.invalidated.has(id)
935
- ) {
936
- this.opts.logger.warn(
937
- `[connecta] connector "${id}" catalog refresh failed; serving stale catalog: ${msg(err)}`,
938
- );
939
- return stale;
940
- }
941
- throw err;
942
- }
943
- }
944
-
945
- /**
946
- * Coalesce one connector's cold load inside one inbound request. The WeakMap
947
- * neither roots the request scope nor lets its connector context escape into
948
- * another request; settled entries are also removed eagerly.
949
- *
950
- * The first caller's `callOptions` govern the shared load: a later caller's
951
- * signal or timeout neither cancels nor extends it, and an abort by the
952
- * first caller rejects every coalesced caller. Within one request that is
953
- * the deal being made — one fetch, one deadline.
954
- */
955
- async getTools(
956
- id: string,
957
- baseUrl: string,
958
- requestScope?: object,
959
- callOptions: ConnectorOperationOptions = {},
960
- ): Promise<ToolDef[]> {
961
- const connector = this.connectors.get(id);
962
- if (!connector) throw new Error(`Unknown connector "${id}"`);
963
- if (connector.staticTools) return connector.staticTools;
964
- if (!requestScope) {
965
- return this.loadTools(id, baseUrl, requestScope, callOptions);
966
- }
967
-
968
- let loads = this.requestCatalogLoads.get(requestScope);
969
- if (!loads) {
970
- loads = new Map();
971
- this.requestCatalogLoads.set(requestScope, loads);
972
- }
973
- const existing = loads.get(id);
974
- if (existing) return existing;
975
- const loading = this.loadTools(id, baseUrl, requestScope, callOptions);
976
- loads.set(id, loading);
977
- try {
978
- return await loading;
979
- } finally {
980
- if (loads.get(id) === loading) loads.delete(id);
981
- if (loads.size === 0) this.requestCatalogLoads.delete(requestScope);
982
- }
983
- }
984
-
985
- /** Return a cached catalog without performing storage or network I/O. */
986
- peekTools(id: string): ToolDef[] | undefined {
987
- const connector = this.connectors.get(id);
988
- if (connector?.staticTools) return connector.staticTools;
989
- const hit = this.cache.get(id);
990
- return hit && hit.staleUntil > Date.now() ? hit.tools : undefined;
991
- }
992
-
993
- recordSuccess(id: string, latencyMs: number): void {
994
- this.health.recordSuccess(id, latencyMs);
995
- }
996
-
997
- recordFailure(id: string, latencyMs: number, error: unknown): void {
998
- this.health.recordFailure(id, latencyMs, error);
999
- }
1000
-
1001
- healthFor(id: string): HealthObservation | undefined {
1002
- return this.health.get(id);
1003
- }
1004
-
1005
- /** Whether this deployment has seen a successful call to `id`. */
1006
- hasObservedSuccess(id: string): boolean {
1007
- return this.observedSuccessAt(id) !== undefined;
1008
- }
1009
-
1010
- /** The timestamp behind `hasObservedSuccess`. */
1011
- observedSuccessAt(id: string): string | undefined {
1012
- return this.health.get(id)?.lastSuccessAt;
1013
- }
1014
-
1015
- async credentialDriftFor(id: string): Promise<string | undefined> {
1016
- const credential = this.connectors.get(id)?.credential;
1017
- const vault = this.opts.credentialVault;
1018
- if (!credential || !vault) return undefined;
1019
- try {
1020
- const values = await vault.getAll(id);
1021
- const shape = storedCredentialShape(credential, values);
1022
- return shape.state === "mismatch" ? shape.message : undefined;
1023
- } catch (error) {
1024
- this.opts.logger.warn(
1025
- `[connecta] connector "${id}" credential shape read failed: ${msg(error)}`,
1026
- );
1027
- return undefined;
1028
- }
1029
- }
1030
-
1031
- /** Best-effort connector status for the operator UI. */
1032
- async statusFor(
1033
- id: string,
1034
- baseUrl: string,
1035
- requestScope: object = {},
1036
- callOptions: ConnectorOperationOptions = {},
1037
- ): Promise<ConnectorStatus> {
1038
- const connector = this.connectors.get(id);
1039
- if (!connector) return { state: "error", message: "Unknown connector" };
1040
- const ctx = this.contextFor(id, baseUrl, requestScope, callOptions);
1041
- if (connector.status) {
1042
- try {
1043
- return await connector.status(ctx);
1044
- } catch (err) {
1045
- return { state: "error", message: msg(err) };
1046
- }
1047
- }
1048
- try {
1049
- await this.getTools(id, baseUrl, requestScope, callOptions);
1050
- return { state: "ok" };
1051
- } catch (err) {
1052
- return { state: "error", message: msg(err) };
1053
- }
1054
- }
1055
-
1056
- private markCatalogInvalid(id: string): void {
1057
- this.advanceCatalogGeneration(id);
1058
- this.cache.delete(id);
1059
- this.invalidated.add(id);
1060
- }
1061
-
1062
- private deleteStoredCatalog(id: string): Promise<void> {
1063
- return this.enqueueCatalogMutation(id, async () => {
1064
- try {
1065
- await this.deleteCatalog(id);
1066
- } catch (err) {
1067
- this.opts.logger.warn(
1068
- `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
1069
- );
1070
- }
1071
- });
1072
- }
1073
-
1074
- /** Drop a connector's cached tool list (e.g. after auth completes). */
1075
- invalidate(id: string): void {
1076
- this.markCatalogInvalid(id);
1077
- if (this.persistToolCatalog) void this.deleteStoredCatalog(id);
1078
- }
1079
-
1080
- /** Drop both in-memory and persisted tool catalogs. */
1081
- async invalidateStored(id: string): Promise<void> {
1082
- this.markCatalogInvalid(id);
1083
- if (this.persistToolCatalog) await this.deleteStoredCatalog(id);
1084
- }
1085
- }