@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
@@ -1,1090 +0,0 @@
1
- import {
2
- Client,
3
- isInputRequiredResult,
4
- specTypeSchemas,
5
- StreamableHTTPClientTransport,
6
- UnauthorizedError,
7
- } from "@modelcontextprotocol/client";
8
- import type {
9
- FetchLike,
10
- ListToolsResult,
11
- StandardSchemaV1,
12
- Tool,
13
- Transport,
14
- } from "@modelcontextprotocol/client";
15
- import { KvOAuthProvider } from "../auth/downstream-oauth.js";
16
- import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
17
- import { ConnectorCallError } from "../errors.js";
18
- import { CONNECTA_VERSION } from "../version.js";
19
- import type {
20
- Connector,
21
- ConnectorCallAdmissionPolicy,
22
- ConnectorContext,
23
- ConnectorStatus,
24
- ConnectorUsageGuide,
25
- Logger,
26
- ToolDef,
27
- } from "../types.js";
28
-
29
- export type RemoteMcpAuth =
30
- | { type: "headers"; headers: Record<string, string> }
31
- | { type: "oauth" };
32
-
33
- export type RemoteMcpRedirectPolicy = "none" | "same-origin";
34
-
35
- export interface RemoteMcpOptions {
36
- url: string;
37
- /** Human-readable display name; the connector id remains the address prefix. */
38
- title?: string;
39
- description?: string;
40
- /**
41
- * Max inline result size (bytes) for this connector's tools before
42
- * call_tool truncates and stashes the full text for get_result
43
- * paging. Overrides the deployment's `calls.maxResultBytes`; omit to inherit
44
- * it. Must be a whole number of bytes >= 1; anything else warns at startup
45
- * and is ignored.
46
- */
47
- maxResultBytes?: number;
48
- /** Optional per-runtime downstream call-admission policy. */
49
- callAdmission?: ConnectorCallAdmissionPolicy;
50
- /**
51
- * Optional agent-facing usage guide served by `skills` as
52
- * `connector:<id>`. A string is markdown; the structured form adds bounded
53
- * discovery metadata. See `Connector.usageGuide`.
54
- */
55
- usageGuide?: string | ConnectorUsageGuide;
56
- auth?: RemoteMcpAuth;
57
- /**
58
- * Downstream MCP version-negotiation mode. Defaults to `"auto"`, which
59
- * probes with `server/discover` and falls back to the legacy lifecycle when
60
- * the response identifies a legacy server. Set `"legacy"` only for a known
61
- * legacy downstream that cannot safely receive the discovery probe; that
62
- * path starts directly with the ordinary 2025 `initialize` handshake.
63
- */
64
- versionNegotiation?: "auto" | "legacy";
65
- /**
66
- * Downstream HTTP redirect policy. Defaults to `"none"`: every redirect is
67
- * rejected. `"same-origin"` follows at most five redirects while preserving
68
- * standard 301/302/303/307/308 method semantics. Cross-origin redirects and
69
- * HTTPS downgrades are always refused, so credentials never cross the
70
- * configured request's origin.
71
- */
72
- redirects?: RemoteMcpRedirectPolicy;
73
- /**
74
- * Refuse to connect to a non-`https://` `url` at construction (default
75
- * false). Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always
76
- * allowed for local development. Off by default, static `headers` credentials
77
- * over a cleartext connection are warned about but permitted; set this true
78
- * to make that misconfiguration a hard error instead.
79
- */
80
- requireHttps?: boolean;
81
- /**
82
- * Destination for the cleartext-credential warning emitted at construction.
83
- * Default console.
84
- */
85
- logger?: Logger;
86
- /**
87
- * @internal Testing seam. When set, this transport is used instead of the
88
- * HTTP transport, letting tests point the connector at an in-process MCP
89
- * server (e.g. via InMemoryTransport). Not part of the public API.
90
- */
91
- _transportFactory?: (ctx: ConnectorContext) => Transport;
92
- }
93
-
94
- /**
95
- * How long a downstream gets to answer the session-termination DELETE before
96
- * teardown stops waiting. This is a network round-trip budget, deliberately
97
- * independent of the core's 100 ms caller-facing scope-close window: an
98
- * already-established cross-internet connection avoids setup, but 50 ms is
99
- * still too short for an ordinary round trip plus modest provider scheduling.
100
- * The bounded tail is deferred on runtimes that can keep it alive after the
101
- * response, while callers continue to wait at most 100 ms.
102
- */
103
- const TERMINATE_SESSION_BUDGET_MS = 1_000;
104
-
105
- /**
106
- * Ceiling on the tools one catalog refresh will accumulate while walking
107
- * `tools/list`.
108
- *
109
- * This is the bound that matters, and pages are the wrong dimension to put it
110
- * in: the *server* picks the page size, so a page ceiling is a tool ceiling
111
- * multiplied by a number connecta can neither observe in advance nor control.
112
- * At ten tools a page, 100 pages is 1,000 tools; at a hundred, 10,000 — and
113
- * connecta's own large-catalog envelope is benchmarked to 100,000 (issue #82).
114
- * A page ceiling low enough to be a real defense therefore sits *inside* the
115
- * catalog sizes this product exists to serve. Worse, the common conformant
116
- * idiom is to advertise a `nextCursor` whenever a page came back full and then
117
- * serve one empty page to terminate, so a perfectly well-behaved 10,000-tool
118
- * server paging at 100 spends 101 requests: bound the pages and its entire
119
- * catalog fails, for doing nothing wrong.
120
- *
121
- * So the ceiling goes on accumulated tools — the thing actually held in memory
122
- * — and it sits at the top of the benchmarked envelope rather than below it.
123
- * Deliberately the same philosophy as issue #82's discovery-response bounds:
124
- * cap the bytes a caller can be made to hold, not the number of round trips it
125
- * took to get them.
126
- */
127
- /**
128
- * Absolute backstop on `tools/list` pages in one refresh — a runaway guard, not
129
- * the primary defense.
130
- *
131
- * The walk terminates on its own well before this: a cursor handed back twice
132
- * is a definite loop, two consecutive pages that add no new tools are a server
133
- * going nowhere, and MAX_CATALOG_TOOLS caps what any of it can accumulate.
134
- * This exists only so the loop is finite even if a downstream somehow
135
- * satisfies all three forever on a path with no discovery deadline. Set high
136
- * enough that no honest server reaches it.
137
- */
138
- const MAX_TOOL_PAGES = 10_000;
139
-
140
- /** One entry of the SDK's `tools/list` result, before it becomes a ToolDef. */
141
- type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
142
-
143
- /**
144
- * Compatibility concession for hand-rolled servers that serialize
145
- * end-of-pagination as `null`. Only the cursor is widened; every tool and every
146
- * other result field still passes through the SDK's pinned schema.
147
- */
148
- const CompatibleListToolsResultSchema: StandardSchemaV1<
149
- unknown,
150
- ListToolsResult
151
- > = {
152
- "~standard": {
153
- version: 1,
154
- vendor: "connecta",
155
- validate(value) {
156
- const normalized =
157
- typeof value === "object" &&
158
- value !== null &&
159
- "nextCursor" in value &&
160
- value.nextCursor === null
161
- ? (() => {
162
- const copy = { ...value };
163
- delete copy.nextCursor;
164
- return copy;
165
- })()
166
- : value;
167
- return specTypeSchemas.ListToolsResult["~standard"].validate(normalized);
168
- },
169
- },
170
- };
171
-
172
- /**
173
- * True for a result-parse failure caused by the page's `nextCursor` itself.
174
- * `null` is accepted deliberately; other non-string values remain a named
175
- * downstream nonconformance instead of surfacing as a raw validation dump.
176
- * Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
177
- * zod instance, and cross-instance `instanceof` is a coin flip.
178
- */
179
- function isCursorShapeError(err: unknown): boolean {
180
- const issues = (err as { issues?: unknown } | null)?.issues;
181
- if (
182
- Array.isArray(issues) &&
183
- issues.some((issue) => {
184
- const path = (issue as { path?: unknown }).path;
185
- return Array.isArray(path) && path[0] === "nextCursor";
186
- })
187
- ) {
188
- return true;
189
- }
190
- // SDK v2 wraps Standard Schema failures in a ProtocolError and preserves the
191
- // failing path in the message rather than exposing the validator's issues.
192
- return msg(err).startsWith("Invalid result for tools/list: nextCursor:");
193
- }
194
-
195
- function msg(err: unknown): string {
196
- return err instanceof Error ? err.message : String(err);
197
- }
198
-
199
- /**
200
- * End the downstream's session before the connection is torn down.
201
- *
202
- * `Client.close()` only unwinds our side — it aborts the transport's controller
203
- * and fires `onclose`. Spec session termination is a separate DELETE carrying
204
- * `Mcp-Session-Id`, and without it a stateful provider keeps the session alive
205
- * until its own (often hour-long) timeout, accumulating abandoned sessions.
206
- *
207
- * Ordering is load-bearing: the SDK sends that DELETE on the transport's
208
- * AbortSignal, so calling this *after* close would abort the request on issue
209
- * and silently do nothing. Everything else is best-effort — a transport with no
210
- * `terminateSession` (a custom one, or an older SDK), a downstream that refuses
211
- * (405 is a legal answer), errors, or never replies all fall through to the
212
- * close with the session left to age out as it did before.
213
- */
214
- async function terminateSession(
215
- transport: Transport,
216
- logger: Logger,
217
- connectorId: string,
218
- ): Promise<void> {
219
- const terminate = (
220
- transport as Transport & { terminateSession?: () => Promise<void> }
221
- ).terminateSession;
222
- if (typeof terminate !== "function") return;
223
- // The SDK issues no request at all when no `mcp-session-id` was captured, so
224
- // a stateless downstream never sees a spurious DELETE.
225
- const done = Promise.resolve().then(() => terminate.call(transport));
226
- await new Promise<void>((resolve) => {
227
- let finished = false;
228
- const warn = (message: string, error?: unknown) => {
229
- try {
230
- if (error === undefined) logger.warn(message);
231
- else logger.warn(message, error);
232
- } catch {
233
- // A diagnostic sink cannot make best-effort teardown observable to the
234
- // caller in the one way this contract forbids: by replacing its result.
235
- }
236
- };
237
- const timer = setTimeout(() => {
238
- if (finished) return;
239
- finished = true;
240
- warn(
241
- `[connecta] connector "${connectorId}" session termination was not ` +
242
- `acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
243
- "downstream may still finish the headers-only DELETE, otherwise " +
244
- "the session will remain until its provider timeout.",
245
- );
246
- resolve();
247
- }, TERMINATE_SESSION_BUDGET_MS);
248
- done.then(
249
- () => {
250
- if (finished) return;
251
- finished = true;
252
- clearTimeout(timer);
253
- resolve();
254
- },
255
- (error) => {
256
- // The rejection handler stays attached after the timer wins, so an
257
- // abort or other late failure is consumed without a duplicate warning.
258
- if (finished) return;
259
- finished = true;
260
- clearTimeout(timer);
261
- warn(
262
- `[connecta] connector "${connectorId}" session termination was ` +
263
- "refused or failed; the downstream session may remain until its " +
264
- "provider timeout.",
265
- error,
266
- );
267
- resolve();
268
- },
269
- );
270
- });
271
- }
272
-
273
- function isLoopbackHost(hostname: string): boolean {
274
- return (
275
- hostname === "localhost" ||
276
- hostname === "127.0.0.1" ||
277
- hostname === "[::1]" ||
278
- hostname === "::1"
279
- );
280
- }
281
-
282
- export const MAX_REMOTE_REDIRECT_HOPS = 5;
283
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
284
- const BODY_HEADERS = [
285
- "content-encoding",
286
- "content-language",
287
- "content-length",
288
- "content-location",
289
- "content-type",
290
- "transfer-encoding",
291
- ];
292
-
293
- export class RemoteMcpRedirectError extends ConnectorCallError {
294
- constructor(connectorId: string, reason: string) {
295
- super(
296
- "connector_call_failed",
297
- `Connector "${connectorId}" redirect policy rejected the downstream response: ${reason}.`,
298
- );
299
- this.name = "RemoteMcpRedirectError";
300
- }
301
- }
302
-
303
- function redirectedInit(init: RequestInit, status: number): RequestInit {
304
- const method = (init.method ?? "GET").toUpperCase();
305
- const becomesGet =
306
- (status === 303 && method !== "GET" && method !== "HEAD") ||
307
- ((status === 301 || status === 302) && method === "POST");
308
- if (!becomesGet) return init;
309
- const headers = new Headers(init.headers);
310
- for (const name of BODY_HEADERS) headers.delete(name);
311
- const redirected = { ...init, method: "GET", headers };
312
- delete redirected.body;
313
- return redirected;
314
- }
315
-
316
- /**
317
- * Wrap fetch with explicit, bounded redirect handling.
318
- *
319
- * The starting URL of each fetch call is trusted by its caller (the configured
320
- * MCP endpoint, or an OAuth URL discovered by the pinned SDK). Only Location
321
- * values are policy-controlled here. No rejected target is ever fetched, so
322
- * arbitrary static header names receive the same protection as Authorization.
323
- */
324
- export function redirectSafeFetch(
325
- connectorId: string,
326
- policy: RemoteMcpRedirectPolicy = "none",
327
- baseFetch: FetchLike = fetch,
328
- ): FetchLike {
329
- return async (input, initialInit = {}) => {
330
- let current = new URL(input);
331
- let init = initialInit;
332
- const seen = new Set<string>([current.href]);
333
- let hops = 0;
334
-
335
- while (true) {
336
- const response = await baseFetch(current, {
337
- ...init,
338
- redirect: "manual",
339
- });
340
- if (!REDIRECT_STATUSES.has(response.status)) return response;
341
-
342
- const location = response.headers.get("location");
343
- await response.body?.cancel().catch(() => {});
344
- if (!location) {
345
- throw new RemoteMcpRedirectError(
346
- connectorId,
347
- `HTTP ${response.status} carried no Location header`,
348
- );
349
- }
350
- if (policy === "none") {
351
- throw new RemoteMcpRedirectError(
352
- connectorId,
353
- `HTTP ${response.status} redirects are disabled`,
354
- );
355
- }
356
- if (hops >= MAX_REMOTE_REDIRECT_HOPS) {
357
- throw new RemoteMcpRedirectError(
358
- connectorId,
359
- `the redirect chain exceeded ${MAX_REMOTE_REDIRECT_HOPS} hops`,
360
- );
361
- }
362
-
363
- let next: URL;
364
- try {
365
- next = new URL(location, current);
366
- } catch {
367
- throw new RemoteMcpRedirectError(
368
- connectorId,
369
- `HTTP ${response.status} carried an invalid Location header`,
370
- );
371
- }
372
- if (current.protocol === "https:" && next.protocol !== "https:") {
373
- throw new RemoteMcpRedirectError(
374
- connectorId,
375
- "an HTTPS-to-HTTP downgrade is not allowed",
376
- );
377
- }
378
- if (next.origin !== current.origin) {
379
- throw new RemoteMcpRedirectError(
380
- connectorId,
381
- "a cross-origin redirect is not allowed",
382
- );
383
- }
384
- if (next.username || next.password) {
385
- throw new RemoteMcpRedirectError(
386
- connectorId,
387
- "a redirect target containing URL credentials is not allowed",
388
- );
389
- }
390
- if (seen.has(next.href)) {
391
- throw new RemoteMcpRedirectError(
392
- connectorId,
393
- "the redirect chain loops",
394
- );
395
- }
396
-
397
- seen.add(next.href);
398
- hops++;
399
- init = redirectedInit(init, response.status);
400
- current = next;
401
- }
402
- };
403
- }
404
-
405
- interface ConnectionState {
406
- client: Client | null;
407
- transport: Transport | null;
408
- /**
409
- * The last complete raw catalog, retained only for this request scope.
410
- *
411
- * SDK v2 exposes `toolDefinition` as the public call-time seam for output
412
- * validation and header mirroring, replacing the v1 private
413
- * `cacheToolMetadata` reach-through.
414
- */
415
- toolDefinitions: Map<string, Tool>;
416
- connecting: Promise<void> | null;
417
- authRequired: boolean;
418
- provider: KvOAuthProvider | null;
419
- connectedGeneration: string | null;
420
- /**
421
- * One-way latch: set by closeScope and never cleared, so neither a late
422
- * connect nor a `reset()` can cache a client into a scope that is already
423
- * gone — that client would have no owner left to close it.
424
- */
425
- closed: boolean;
426
- }
427
-
428
- /**
429
- * Proxy a downstream remote MCP server. SDK clients and transports are scoped
430
- * to one inbound request: reused by calls within a batch/execute_code run, but
431
- * never carried into a later Cloudflare Worker request. Static-header auth
432
- * passes headers via requestInit; "oauth" runs the full downstream OAuth flow
433
- * via KvOAuthProvider.
434
- *
435
- * Auth failures degrade the connector to "auth_required" (never crash the
436
- * server or hide other connectors).
437
- */
438
- export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
439
- // Weak keys ensure a completed request does not leave its SDK client,
440
- // transport, response bodies, AbortSignals, or connection promise reachable
441
- // from the isolate singleton. Those are request-bound in Cloudflare Workers.
442
- const states = new WeakMap<object, ConnectionState>();
443
- // Closing is terminal even after `states.delete`: a late or future lookup
444
- // must not recreate an ownerless connection under the ended scope.
445
- const closedScopes = new WeakSet<object>();
446
- const isOauth = opts.auth?.type === "oauth";
447
- const logger = opts.logger ?? console;
448
-
449
- // Check the destination scheme once at construction: buildTransport (and the
450
- // SDK's fetch) attach any static credentials to every request, so an http://
451
- // endpoint sends bearer tokens / API keys in cleartext. Loopback is exempt
452
- // for local development.
453
- const destination = new URL(opts.url);
454
- const insecureDestination =
455
- destination.protocol !== "https:" && !isLoopbackHost(destination.hostname);
456
- if (insecureDestination) {
457
- if (opts.requireHttps) {
458
- throw new Error(
459
- `[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`,
460
- );
461
- }
462
- if (opts.auth?.type === "headers") {
463
- logger.warn(
464
- `[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`,
465
- );
466
- }
467
- }
468
-
469
- /** Typed per-call auth signal; the SDK's UnauthorizedError stays as cause. */
470
- const authRequiredError = (cause: unknown) =>
471
- new ConnectorCallError(
472
- "auth_required",
473
- `Connector "${id}" requires authorization — call authorize_connector({ connector: "${id}" }) and open the returned URL.`,
474
- { cause },
475
- );
476
-
477
- class OperatorDisconnectedError extends ConnectorCallError {
478
- constructor() {
479
- super(
480
- "auth_required",
481
- `Connector "${id}" was disconnected by an operator — explicitly start authorization to reconnect it.`,
482
- );
483
- }
484
- }
485
- const operatorDisconnectedError = () => new OperatorDisconnectedError();
486
-
487
- const scopeEndedError = () =>
488
- new Error(`Connector "${id}" scope ended during connection.`);
489
-
490
- const requestOptions = (ctx: ConnectorContext) =>
491
- ctx.timeoutMs || ctx.signal
492
- ? {
493
- ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
494
- ...(ctx.signal ? { signal: ctx.signal } : {}),
495
- }
496
- : undefined;
497
-
498
- /**
499
- * One `tools/list` request. The SDK schema is retained wholesale except for
500
- * accepting `null` as the common, unambiguous end-of-chain spelling. Other
501
- * cursor shapes still get a useful connector-level diagnosis.
502
- */
503
- const listPage = async (
504
- client: Client,
505
- cursor: string | undefined,
506
- ctx: ConnectorContext,
507
- ) => {
508
- try {
509
- return await client.request(
510
- {
511
- method: "tools/list",
512
- ...(cursor === undefined ? {} : { params: { cursor } }),
513
- },
514
- CompatibleListToolsResultSchema,
515
- requestOptions(ctx),
516
- );
517
- } catch (err) {
518
- if (!isCursorShapeError(err)) throw err;
519
- throw new Error(
520
- `Connector "${id}" returned a tools/list page whose nextCursor is neither a string, null, nor absent — this catalog cannot be walked.`,
521
- { cause: err },
522
- );
523
- }
524
- };
525
-
526
- const stateFor = (ctx: ConnectorContext): ConnectionState => {
527
- const scope = ctx.requestScope ?? ctx;
528
- if (closedScopes.has(scope)) throw scopeEndedError();
529
- let state = states.get(scope);
530
- if (!state) {
531
- state = {
532
- client: null,
533
- transport: null,
534
- toolDefinitions: new Map(),
535
- connecting: null,
536
- authRequired: false,
537
- provider: null,
538
- connectedGeneration: null,
539
- closed: false,
540
- };
541
- states.set(scope, state);
542
- }
543
- return state;
544
- };
545
-
546
- const getProvider = (
547
- ctx: ConnectorContext,
548
- state: ConnectionState,
549
- ): KvOAuthProvider => {
550
- state.provider ??= new KvOAuthProvider(
551
- id,
552
- ctx.storage,
553
- `${ctx.baseUrl}/oauth/callback/${id}`,
554
- );
555
- return state.provider;
556
- };
557
-
558
- const newProvider = (ctx: ConnectorContext): KvOAuthProvider =>
559
- new KvOAuthProvider(
560
- id,
561
- ctx.storage,
562
- `${ctx.baseUrl}/oauth/callback/${id}`,
563
- );
564
-
565
- const buildTransport = (
566
- ctx: ConnectorContext,
567
- provider: KvOAuthProvider | null,
568
- ): Transport => {
569
- if (opts._transportFactory) return opts._transportFactory(ctx);
570
- const url = new URL(opts.url);
571
- const guardedFetch = redirectSafeFetch(id, opts.redirects);
572
- if (opts.auth?.type === "oauth") {
573
- return new StreamableHTTPClientTransport(url, {
574
- authProvider: provider ?? newProvider(ctx),
575
- fetch: guardedFetch,
576
- });
577
- }
578
- const headers =
579
- opts.auth?.type === "headers" ? opts.auth.headers : undefined;
580
- return new StreamableHTTPClientTransport(url, {
581
- ...(headers ? { requestInit: { headers } } : {}),
582
- fetch: guardedFetch,
583
- });
584
- };
585
-
586
- const reset = (state: ConnectionState) => {
587
- state.client = null;
588
- state.transport = null;
589
- state.toolDefinitions.clear();
590
- state.connecting = null;
591
- state.authRequired = false;
592
- state.provider = null;
593
- state.connectedGeneration = null;
594
- // `closed` is deliberately not cleared — see ConnectionState.
595
- };
596
-
597
- const ensureConnected = async (
598
- ctx: ConnectorContext,
599
- state: ConnectionState,
600
- ): Promise<void> => {
601
- // A 401 after connect is a verdict for the whole request scope, not merely
602
- // for the one call that observed it. Do not let the still-cached client make
603
- // a later status or call in the same scope report healthy.
604
- if (state.authRequired) {
605
- throw authRequiredError(
606
- new UnauthorizedError("Downstream authorization is no longer valid."),
607
- );
608
- }
609
- // Read the OAuth epoch before trusting either a cached client or starting a
610
- // transport. A disconnected epoch is a durable operator instruction, not
611
- // merely the absence of credentials: passive status/tool probes must not
612
- // turn it back into a pending consent flow.
613
- let oauthGeneration: string | undefined;
614
- if (isOauth && (state.client || state.connecting)) {
615
- const provider = getProvider(ctx, state);
616
- oauthGeneration = await provider.generation();
617
- if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
618
- const connecting = state.connecting;
619
- const client = state.client;
620
- const transport = state.transport;
621
- reset(state);
622
- void connecting?.catch(() => {});
623
- try {
624
- if (client) await client.close();
625
- else await transport?.close();
626
- } catch {
627
- // The disconnected epoch is authoritative even if local close fails.
628
- }
629
- throw operatorDisconnectedError();
630
- }
631
- }
632
- // Cross-isolate force re-auth: another isolate bumped the KV generation and
633
- // wiped credentials. This request's cached client still speaks the old
634
- // token — drop it so the next connect runs against current state.
635
- if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
636
- if (state.closed) throw scopeEndedError();
637
- if (oauthGeneration !== state.connectedGeneration) {
638
- reset(state);
639
- }
640
- }
641
- if (state.closed) throw scopeEndedError();
642
- if (state.client) return;
643
- if (!state.connecting) {
644
- let attempt!: Promise<void>;
645
- attempt = (async () => {
646
- const ownsAttempt = () =>
647
- state.connecting === attempt && !state.closed;
648
- const abandon = async (owner: Client | Transport) => {
649
- try {
650
- await owner.close();
651
- } catch {
652
- // The attempt is detached either way.
653
- }
654
- throw scopeEndedError();
655
- };
656
- // Let the assignment immediately below this async IIFE publish
657
- // `state.connecting = attempt` before ownership is checked. OAuth's
658
- // generation read naturally yields; unauthenticated transports do not.
659
- await Promise.resolve();
660
- // A provider belongs to exactly one connect attempt. A force reset can
661
- // abandon that attempt while its transport still holds the provider;
662
- // the replacement must never mutate the abandoned provider's epoch.
663
- const provider = isOauth ? newProvider(ctx) : null;
664
- const genAtStart = provider ? await provider.generation() : "";
665
- if (!ownsAttempt()) throw scopeEndedError();
666
- if (provider?.isOperatorDisconnectedGeneration(genAtStart)) {
667
- throw operatorDisconnectedError();
668
- }
669
- provider?.captureGeneration(genAtStart);
670
- // SDK v2 selects its validator by runtime export condition: AJV on
671
- // Node and @cfworker/json-schema under workerd. The Workers-safe path
672
- // no longer needs Connecta-specific wiring.
673
- const c = new Client(
674
- { name: "connecta", version: CONNECTA_VERSION },
675
- {
676
- versionNegotiation: {
677
- mode: opts.versionNegotiation ?? "auto",
678
- },
679
- // Connecta has no interactive relay. Surface the result manually
680
- // below as one structured, non-retryable connector failure.
681
- inputRequired: { autoFulfill: false },
682
- },
683
- );
684
- const t = buildTransport(ctx, provider);
685
- if (!ownsAttempt()) await abandon(t);
686
- state.transport = t;
687
- try {
688
- await c.connect(t);
689
- // A probe deadline can end its scope while connect is still in flight.
690
- // The transport is closed immediately by closeScope; if connect wins
691
- // that race anyway, close the resulting client rather than
692
- // resurrecting a session in the detached state object.
693
- if (!ownsAttempt()) await abandon(c);
694
- // A force re-auth that landed WHILE we were connecting wiped the
695
- // credentials this client just bound to. Discard it rather than
696
- // cache a stale-isolate connection.
697
- if (provider) {
698
- const generation = await provider.generation();
699
- // closeScope can land while the generation read is pending, after
700
- // connect succeeded but before this client is cached. Discard the
701
- // client on that side of the await too.
702
- if (!ownsAttempt()) await abandon(c);
703
- if (generation !== genAtStart) {
704
- try {
705
- await c.close();
706
- } catch {
707
- // discarding either way
708
- }
709
- throw new UnauthorizedError(
710
- "Connector was re-authorized during connect; reconnect required.",
711
- );
712
- }
713
- }
714
- if (!ownsAttempt()) await abandon(c);
715
- state.client = c;
716
- state.connectedGeneration = genAtStart;
717
- state.authRequired = false;
718
- } catch (err) {
719
- // Only a real 401/UnauthorizedError means auth is the problem — a
720
- // network error on an oauth connector must surface as "error", not
721
- // "auth_required".
722
- if (err instanceof UnauthorizedError && ownsAttempt()) {
723
- state.authRequired = true;
724
- }
725
- if (err instanceof UnauthorizedError) {
726
- throw authRequiredError(err);
727
- }
728
- throw err;
729
- } finally {
730
- // Force reset may have abandoned this attempt and installed a new one
731
- // in the same request scope. An old completion must not erase the new
732
- // promise and allow a third concurrent connect.
733
- if (state.connecting === attempt) state.connecting = null;
734
- }
735
- })();
736
- state.connecting = attempt;
737
- }
738
- return state.connecting;
739
- };
740
-
741
- const disconnectAuthorization = async (
742
- ctx: ConnectorContext,
743
- state: ConnectionState,
744
- operatorDisconnected = false,
745
- ): Promise<void> => {
746
- const provider = getProvider(ctx, state);
747
- // Publish the replacement epoch before waiting on or closing any
748
- // request-local transport. A hung connect therefore cannot delay the
749
- // fence, and every late OAuth write stays in the older namespace.
750
- const connecting = state.connecting;
751
- try {
752
- await provider.resetAuthorization(operatorDisconnected);
753
- } finally {
754
- // Consume the abandoned connect and close whichever half of the
755
- // client/transport exists. Reset is unconditional because KV may already
756
- // be fenced behind a newer epoch after a cleanup error.
757
- void connecting?.catch(() => {});
758
- try {
759
- if (state.client) {
760
- await state.client.close();
761
- } else {
762
- await state.transport?.close();
763
- }
764
- } catch {
765
- // best-effort; the state is discarded either way
766
- }
767
- reset(state);
768
- }
769
- };
770
-
771
- const connector: Connector = {
772
- id,
773
- ...(opts.title !== undefined ? { title: opts.title } : {}),
774
- kind: "mcp",
775
- ...(opts.description !== undefined
776
- ? { description: opts.description }
777
- : {}),
778
- ...(opts.maxResultBytes !== undefined
779
- ? { maxResultBytes: opts.maxResultBytes }
780
- : {}),
781
- ...(opts.callAdmission !== undefined
782
- ? { callAdmission: opts.callAdmission }
783
- : {}),
784
- ...(opts.usageGuide !== undefined ? { usageGuide: opts.usageGuide } : {}),
785
-
786
- // `tools/list` is cursor-paginated: the server chooses the page size and
787
- // signals "there is more" with a `nextCursor`, which the SDK's
788
- // Client.listTools() returns without following. Collect the whole chain
789
- // here, because a half-collected catalog is indistinguishable from a small
790
- // one — later-page tools would simply appear not to exist, unsearchable and
791
- // unaddressable, with nothing anywhere saying why.
792
- //
793
- // All pages ride the one request-scoped client already connected above, and
794
- // the accumulator is returned rather than stored: a cursor is opaque and
795
- // session-bound, so nothing here may outlive this call.
796
- async listTools(ctx) {
797
- const state = stateFor(ctx);
798
- await ensureConnected(ctx, state);
799
- // Bind the client once so the whole walk provably rides one session — a
800
- // cursor is only meaningful to the connection that issued it, and a
801
- // re-read could in principle pick up a different one. It is NOT guarding
802
- // against closeScope nulling state.client mid-loop: closeScope sets
803
- // `closed` and nulls `client` in one synchronous run, and the loop
804
- // re-checks `closed` before every page, so the nulled client is
805
- // unreachable from here.
806
- const client = state.client!;
807
- // Raw SDK tools, not ToolDefs: the metadata re-prime below needs fields
808
- // (task support) that a ToolDef deliberately does not carry.
809
- const listed: ListedTool[] = [];
810
- const names = new Set<string>();
811
- const spent = new Set<string>();
812
- let cursor: string | undefined;
813
- /** Consecutive pages that advertised a successor but added nothing. */
814
- let barren = 0;
815
- let complete = false;
816
- try {
817
- for (let page = 0; page < MAX_TOOL_PAGES; page++) {
818
- // The scope can end between pages (probe timeout, teardown). Stop
819
- // rather than keep paging into a transport that is being closed.
820
- if (state.closed) throw scopeEndedError();
821
- // A discovery deadline uses the same signal for the whole chain.
822
- // Check it before issuing each page as well as passing it to the
823
- // in-flight SDK request, so expiry never starts one more round trip.
824
- if (ctx.signal?.aborted) {
825
- throw ctx.signal.reason instanceof Error
826
- ? ctx.signal.reason
827
- : new Error(`Connector "${id}" catalog deadline expired.`);
828
- }
829
- // Page one sends no params at all, so a non-paginated server sees
830
- // exactly the request it saw before pagination existed.
831
- const res = await listPage(client, cursor, ctx);
832
- let added = 0;
833
- for (const t of res.tools) {
834
- // First page wins. An unstable cursor can serve the same tool on
835
- // two pages — a duplicate would inflate `toolCount`, double the
836
- // `search_tools` row, and churn catalog persistence.
837
- if (names.has(t.name)) continue;
838
- names.add(t.name);
839
- listed.push(t);
840
- added++;
841
- }
842
- // Pagination ends when `nextCursor` is absent or null — never merely
843
- // falsy. Empty string is present and means "keep going".
844
- const next = res.nextCursor;
845
- if (next === undefined || next === null) {
846
- complete = true;
847
- break;
848
- }
849
- // A page that adds nothing and still claims a successor made no
850
- // progress. Allow exactly one: the widespread idiom is to advertise
851
- // a cursor whenever a page came back full and then serve one empty
852
- // page to terminate. Two in a row is a downstream going nowhere.
853
- if (added === 0 && ++barren > 1) {
854
- throw new Error(
855
- `Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`,
856
- );
857
- }
858
- if (added > 0) barren = 0;
859
- // A cursor handed back a second time is a loop, not a slow server.
860
- if (spent.has(next)) {
861
- throw new Error(
862
- `Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`,
863
- );
864
- }
865
- // Checked here rather than on arrival: this bounds what a *walk* may
866
- // accumulate; a one-page server was always free to send its page.
867
- if (listed.length > MAX_CATALOG_TOOLS) {
868
- throw new Error(
869
- `Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_CATALOG_TOOLS}-tool ceiling one catalog refresh will collect.`,
870
- );
871
- }
872
- // Opaque by contract: handed straight back, never parsed, rewritten,
873
- // or persisted.
874
- spent.add(next);
875
- cursor = next;
876
- }
877
- } catch (err) {
878
- // A grant can be revoked after connect and after any earlier page.
879
- // Classify that exactly like connect-time and call-time authorization
880
- // failures, and latch it for the rest of this request scope.
881
- if (err instanceof UnauthorizedError) {
882
- if (state.client === client) state.authRequired = true;
883
- throw authRequiredError(err);
884
- }
885
- throw err;
886
- }
887
- // Fail the refresh outright. Returning what we have would publish a
888
- // partial catalog that looks complete; throwing lets the registry keep
889
- // serving the last complete one via its stale fallback.
890
- if (!complete) {
891
- throw new Error(
892
- `Connector "${id}" kept advertising more tools/list pages after ${MAX_TOOL_PAGES} — refusing to page further.`,
893
- );
894
- }
895
- // Publish definitions only after the full walk succeeds. A later-page
896
- // failure must not leave a partial validation/header view behind.
897
- state.toolDefinitions = new Map(listed.map((tool) => [tool.name, tool]));
898
- return listed.map((t) => ({
899
- name: t.name,
900
- ...(t.description !== undefined ? { description: t.description } : {}),
901
- ...(t.inputSchema !== undefined
902
- ? {
903
- inputSchema: t.inputSchema as NonNullable<
904
- ToolDef["inputSchema"]
905
- >,
906
- }
907
- : {}),
908
- ...(t.outputSchema !== undefined
909
- ? {
910
- outputSchema: t.outputSchema as NonNullable<
911
- ToolDef["outputSchema"]
912
- >,
913
- }
914
- : {}),
915
- ...(t.annotations !== undefined
916
- ? {
917
- annotations: t.annotations as NonNullable<
918
- ToolDef["annotations"]
919
- >,
920
- }
921
- : {}),
922
- }));
923
- },
924
-
925
- async callTool(name, args, ctx) {
926
- const state = stateFor(ctx);
927
- await ensureConnected(ctx, state);
928
- const client = state.client!;
929
- try {
930
- const toolDefinition = state.toolDefinitions.get(name);
931
- if (toolDefinition?.execution?.taskSupport === "required") {
932
- throw new Error(
933
- `Tool "${name}" requires task-based execution, which Connecta does not support.`,
934
- );
935
- }
936
- const result = await client.callTool(
937
- {
938
- name,
939
- arguments: (args ?? {}) as Record<string, unknown>,
940
- },
941
- {
942
- ...requestOptions(ctx),
943
- allowInputRequired: true,
944
- ...(toolDefinition ? { toolDefinition } : {}),
945
- },
946
- );
947
- if (isInputRequiredResult(result)) {
948
- throw new ConnectorCallError(
949
- "input_required_unsupported",
950
- `Connector "${id}" returned input_required for "${name}". ` +
951
- "Connecta cannot relay multi-round-trip input yet; this " +
952
- "capability is gated pending real host and downstream adoption.",
953
- );
954
- }
955
- return result;
956
- } catch (err) {
957
- // A grant revoked after connect surfaces here, not in ensureConnected.
958
- if (err instanceof UnauthorizedError) {
959
- if (state.client === client) state.authRequired = true;
960
- throw authRequiredError(err);
961
- }
962
- throw err;
963
- }
964
- },
965
-
966
- async closeScope(ctx) {
967
- const scope = ctx.requestScope ?? ctx;
968
- // Tombstone before any lookup or await. This also makes close-before-use
969
- // terminal rather than allowing the scope to spring into existence later.
970
- closedScopes.add(scope);
971
- const state = states.get(scope);
972
- if (!state) return;
973
-
974
- // Delete before awaiting: a duplicate teardown is a no-op.
975
- states.delete(scope);
976
- state.closed = true;
977
- const client = state.client;
978
- const transport = state.transport;
979
- state.client = null;
980
- state.transport = null;
981
- state.toolDefinitions.clear();
982
- state.connecting = null;
983
- state.authRequired = false;
984
- state.connectedGeneration = null;
985
-
986
- // Ask the downstream to drop its session first — closing only aborts our
987
- // side, and the DELETE that frees the server's rides on the very
988
- // AbortSignal the close is about to trip.
989
- if (transport) await terminateSession(transport, ctx.logger, id);
990
-
991
- // Client.close() owns its connected transport. During an unfinished or
992
- // failed connect there is no cached client yet, so close the transport
993
- // directly to abort/release that half-open session.
994
- if (client) {
995
- await client.close();
996
- } else {
997
- await transport?.close();
998
- }
999
- },
1000
-
1001
- async status(ctx): Promise<ConnectorStatus> {
1002
- const state = stateFor(ctx);
1003
- try {
1004
- await ensureConnected(ctx, state);
1005
- return { state: "ok" };
1006
- } catch (err) {
1007
- if (state.authRequired) {
1008
- const url = await getProvider(ctx, state).pendingAuthorizationUrl();
1009
- return {
1010
- state: "auth_required",
1011
- ...(url !== undefined ? { authorizationUrl: url } : {}),
1012
- message: "Authorization required — open the URL to connect.",
1013
- };
1014
- }
1015
- if (err instanceof OperatorDisconnectedError) {
1016
- return { state: "auth_required", message: err.message };
1017
- }
1018
- return { state: "error", message: msg(err) };
1019
- }
1020
- },
1021
-
1022
- async finishAuth(code, ctx, callbackParams) {
1023
- const state = stateFor(ctx);
1024
- const provider = getProvider(ctx, state);
1025
- // verifyState ran on this request-scoped provider first and captured the
1026
- // pending flow's generation. If force reset races the exchange, any late
1027
- // token write remains tagged with that older generation and is unreadable.
1028
- const t = (state.transport ??
1029
- buildTransport(ctx, provider)) as StreamableHTTPClientTransport;
1030
- if (callbackParams !== undefined) {
1031
- await t.finishAuth(callbackParams);
1032
- } else {
1033
- await t.finishAuth(code);
1034
- }
1035
- await provider.clearPending();
1036
- // Reset so the next use reconnects with the freshly stored tokens.
1037
- reset(state);
1038
- },
1039
- };
1040
-
1041
- if (opts.auth?.type === "oauth") {
1042
- connector.verifyState = async (oauthState, ctx) => {
1043
- const state = stateFor(ctx);
1044
- return getProvider(ctx, state).verifyState(oauthState);
1045
- };
1046
-
1047
- connector.disconnectAuth = async (ctx) => {
1048
- await disconnectAuthorization(ctx, stateFor(ctx), true);
1049
- };
1050
-
1051
- connector.startAuth = async (ctx, startOpts) => {
1052
- const state = stateFor(ctx);
1053
- const p = getProvider(ctx, state);
1054
- if (startOpts?.force || (await p.operatorDisconnected())) {
1055
- await disconnectAuthorization(ctx, state);
1056
- } else {
1057
- // A consent URL already outstanding? Re-issue it rather than re-running
1058
- // the SDK flow, which would overwrite the PKCE verifier and invalidate
1059
- // the URL the operator may be mid-consent on.
1060
- const pending = await p.pendingAuthorizationUrl();
1061
- if (pending) {
1062
- return {
1063
- state: "auth_required",
1064
- authorizationUrl: pending,
1065
- message: "Authorization required — open the URL to connect.",
1066
- };
1067
- }
1068
- }
1069
- try {
1070
- await ensureConnected(ctx, state);
1071
- return {
1072
- state: "ok",
1073
- message: "Already authorized — connection is healthy.",
1074
- };
1075
- } catch (err) {
1076
- if (state.authRequired) {
1077
- const authorizationUrl = await p.pendingAuthorizationUrl();
1078
- return {
1079
- state: "auth_required",
1080
- ...(authorizationUrl !== undefined ? { authorizationUrl } : {}),
1081
- message: "Authorization required — open the URL to connect.",
1082
- };
1083
- }
1084
- return { state: "error", message: msg(err) };
1085
- }
1086
- };
1087
- }
1088
-
1089
- return connector;
1090
- }