@zackbart/connecta 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/SECURITY.md +25 -0
  5. package/assets/connecta-clay-hero.png +0 -0
  6. package/dist/activity.d.ts +82 -0
  7. package/dist/activity.d.ts.map +1 -0
  8. package/dist/activity.js +51 -0
  9. package/dist/activity.js.map +1 -0
  10. package/dist/auth/bearer.d.ts +10 -0
  11. package/dist/auth/bearer.d.ts.map +1 -0
  12. package/dist/auth/bearer.js +47 -0
  13. package/dist/auth/bearer.js.map +1 -0
  14. package/dist/auth/clerk.d.ts +20 -0
  15. package/dist/auth/clerk.d.ts.map +1 -0
  16. package/dist/auth/clerk.js +167 -0
  17. package/dist/auth/clerk.js.map +1 -0
  18. package/dist/auth/downstream-oauth.d.ts +78 -0
  19. package/dist/auth/downstream-oauth.d.ts.map +1 -0
  20. package/dist/auth/downstream-oauth.js +180 -0
  21. package/dist/auth/downstream-oauth.js.map +1 -0
  22. package/dist/catalog.d.ts +11 -0
  23. package/dist/catalog.d.ts.map +1 -0
  24. package/dist/catalog.js +144 -0
  25. package/dist/catalog.js.map +1 -0
  26. package/dist/connectors/api.d.ts +34 -0
  27. package/dist/connectors/api.d.ts.map +1 -0
  28. package/dist/connectors/api.js +36 -0
  29. package/dist/connectors/api.js.map +1 -0
  30. package/dist/connectors/remote-mcp.d.ts +33 -0
  31. package/dist/connectors/remote-mcp.d.ts.map +1 -0
  32. package/dist/connectors/remote-mcp.js +255 -0
  33. package/dist/connectors/remote-mcp.js.map +1 -0
  34. package/dist/credentials.d.ts +33 -0
  35. package/dist/credentials.d.ts.map +1 -0
  36. package/dist/credentials.js +177 -0
  37. package/dist/credentials.js.map +1 -0
  38. package/dist/execute.d.ts +40 -0
  39. package/dist/execute.d.ts.map +1 -0
  40. package/dist/execute.js +424 -0
  41. package/dist/execute.js.map +1 -0
  42. package/dist/executors/quickjs.d.ts +26 -0
  43. package/dist/executors/quickjs.d.ts.map +1 -0
  44. package/dist/executors/quickjs.js +267 -0
  45. package/dist/executors/quickjs.js.map +1 -0
  46. package/dist/favicon.d.ts +2 -0
  47. package/dist/favicon.d.ts.map +1 -0
  48. package/dist/favicon.js +49 -0
  49. package/dist/favicon.js.map +1 -0
  50. package/dist/index.d.ts +85 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +73 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/mcp-result.d.ts +9 -0
  55. package/dist/mcp-result.d.ts.map +1 -0
  56. package/dist/mcp-result.js +35 -0
  57. package/dist/mcp-result.js.map +1 -0
  58. package/dist/meta-tools.d.ts +100 -0
  59. package/dist/meta-tools.d.ts.map +1 -0
  60. package/dist/meta-tools.js +797 -0
  61. package/dist/meta-tools.js.map +1 -0
  62. package/dist/node.d.ts +9 -0
  63. package/dist/node.d.ts.map +1 -0
  64. package/dist/node.js +57 -0
  65. package/dist/node.js.map +1 -0
  66. package/dist/registry.d.ts +78 -0
  67. package/dist/registry.d.ts.map +1 -0
  68. package/dist/registry.js +299 -0
  69. package/dist/registry.js.map +1 -0
  70. package/dist/server.d.ts +31 -0
  71. package/dist/server.d.ts.map +1 -0
  72. package/dist/server.js +543 -0
  73. package/dist/server.js.map +1 -0
  74. package/dist/skills.d.ts +8 -0
  75. package/dist/skills.d.ts.map +1 -0
  76. package/dist/skills.js +60 -0
  77. package/dist/skills.js.map +1 -0
  78. package/dist/storage/file.d.ts +8 -0
  79. package/dist/storage/file.d.ts.map +1 -0
  80. package/dist/storage/file.js +53 -0
  81. package/dist/storage/file.js.map +1 -0
  82. package/dist/storage/memory.d.ts +4 -0
  83. package/dist/storage/memory.d.ts.map +1 -0
  84. package/dist/storage/memory.js +29 -0
  85. package/dist/storage/memory.js.map +1 -0
  86. package/dist/types.d.ts +219 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +3 -0
  89. package/dist/types.js.map +1 -0
  90. package/dist/ui.d.ts +91 -0
  91. package/dist/ui.d.ts.map +1 -0
  92. package/dist/ui.js +1031 -0
  93. package/dist/ui.js.map +1 -0
  94. package/package.json +96 -0
  95. package/src/activity.ts +144 -0
  96. package/src/auth/bearer.ts +54 -0
  97. package/src/auth/clerk.ts +226 -0
  98. package/src/auth/downstream-oauth.ts +202 -0
  99. package/src/catalog.ts +166 -0
  100. package/src/connectors/api.ts +80 -0
  101. package/src/connectors/remote-mcp.ts +324 -0
  102. package/src/credentials.ts +259 -0
  103. package/src/execute.ts +550 -0
  104. package/src/executors/quickjs.ts +321 -0
  105. package/src/favicon.ts +53 -0
  106. package/src/index.ts +197 -0
  107. package/src/mcp-result.ts +43 -0
  108. package/src/meta-tools.ts +1120 -0
  109. package/src/node.ts +62 -0
  110. package/src/registry.ts +400 -0
  111. package/src/server.ts +758 -0
  112. package/src/skills.ts +63 -0
  113. package/src/storage/file.ts +62 -0
  114. package/src/storage/memory.ts +34 -0
  115. package/src/types.ts +254 -0
  116. package/src/ui.ts +1121 -0
package/src/server.ts ADDED
@@ -0,0 +1,758 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
+ import { registerExecuteTool } from "./execute.js";
4
+ import { registerMetaTools } from "./meta-tools.js";
5
+ import { CONNECTA_INSTRUCTIONS } from "./skills.js";
6
+ import type {
7
+ ActivityActor,
8
+ ActivityReadGate,
9
+ ActivityRequestContext,
10
+ ActivityStore,
11
+ } from "./activity.js";
12
+ import { InvalidActivityCursorError } from "./activity.js";
13
+ import type { CredentialVault } from "./credentials.js";
14
+ import type { Registry } from "./registry.js";
15
+ import type {
16
+ ConnectorCredentialConfig,
17
+ ConnectorCredentialValues,
18
+ ConnectaBranding,
19
+ Executor,
20
+ InboundAuth,
21
+ Logger,
22
+ } from "./types.js";
23
+ import { CONNECTA_FAVICON_ICO } from "./favicon.js";
24
+ import {
25
+ buildUiData,
26
+ CONNECTA_FAVICON_SVG,
27
+ resolveBranding,
28
+ renderUiHtml,
29
+ } from "./ui.js";
30
+
31
+ const CORS_HEADERS = {
32
+ "Access-Control-Allow-Origin": "*",
33
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
34
+ "Access-Control-Allow-Headers":
35
+ "Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
36
+ };
37
+
38
+ export interface ServerOptions {
39
+ registry: Registry;
40
+ auth: InboundAuth[];
41
+ publicUrl?: string;
42
+ serverInfo: { name: string; version: string };
43
+ logger: Logger;
44
+ activity?: ActivityStore;
45
+ activityReadGate?: ActivityReadGate;
46
+ activityDeploymentId?: string;
47
+ deploymentInfo?: Record<string, unknown>;
48
+ /** When set, the execute_code meta-tool is registered on top of the nine. */
49
+ executor?: Executor;
50
+ /** Encrypted connector-credential storage backing the authenticated /ui controls. */
51
+ credentialVault?: CredentialVault;
52
+ /** Optional browser UI and OAuth result-page labels. */
53
+ branding?: ConnectaBranding;
54
+ }
55
+
56
+ function msg(err: unknown): string {
57
+ return err instanceof Error ? err.message : String(err);
58
+ }
59
+
60
+ function escapeHtml(s: string): string {
61
+ return s
62
+ .replaceAll("&", "&amp;")
63
+ .replaceAll("<", "&lt;")
64
+ .replaceAll(">", "&gt;")
65
+ .replaceAll('"', "&quot;");
66
+ }
67
+
68
+ /** `body` is escaped — callback params and error messages are attacker-influenced. */
69
+ function html(
70
+ body: string,
71
+ status = 200,
72
+ branding?: ConnectaBranding,
73
+ ): Response {
74
+ const brand = resolveBranding(branding);
75
+ const title = brand.ownerName
76
+ ? `${brand.productName} — ${brand.ownerName}`
77
+ : brand.productName;
78
+ const owner = brand.ownerName
79
+ ? brand.ownerUrl
80
+ ? `<a class="brand" href="${escapeHtml(brand.ownerUrl)}">${escapeHtml(brand.ownerName)}</a>`
81
+ : `<span class="brand">${escapeHtml(brand.ownerName)}</span>`
82
+ : `<span class="brand">${escapeHtml(brand.productName)}</span>`;
83
+ const product = brand.ownerName
84
+ ? `<span class="product">${escapeHtml(brand.productName)}</span>`
85
+ : "";
86
+ return new Response(
87
+ `<!doctype html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="utf-8">
91
+ <meta name="viewport" content="width=device-width,initial-scale=1">
92
+ <meta name="theme-color" content="#ffffff">
93
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
94
+ <link rel="shortcut icon" href="/favicon.ico">
95
+ <title>${escapeHtml(title)}</title>
96
+ <style>
97
+ * { border-radius: 0; box-sizing: border-box; }
98
+ html { color: #000; background: #fff; font: 16px/1.5 "Helvetica Neue",
99
+ Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
100
+ body { margin: 0; min-height: 100vh; }
101
+ ::selection { color: #fff; background: #000; }
102
+ .shell { margin: 0 auto; max-width: 70rem; padding: 1rem; }
103
+ .grid { display: grid; gap: 1rem 1.5rem;
104
+ grid-template-columns: repeat(3, minmax(0, 1fr)); }
105
+ .brand { font-weight: 500; grid-column: 1; text-decoration: none; }
106
+ .product { grid-column: 2 / -1; }
107
+ main { margin-top: 5rem; }
108
+ h1, p { font: inherit; margin: 0; }
109
+ h1 { grid-column: 1; }
110
+ .copy { grid-column: 2 / -1; max-width: 34em; }
111
+ .copy > * + * { margin-top: 1.5rem; }
112
+ a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
113
+ text-underline-offset: .22em; }
114
+ a:hover { text-decoration-color: transparent; }
115
+ a:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
116
+ @media (max-width: 36.99rem) {
117
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
118
+ .product { grid-column: 2; }
119
+ main { margin-top: 3rem; }
120
+ h1, .copy { grid-column: 1 / -1; }
121
+ }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <header class="shell grid">
126
+ ${owner}
127
+ ${product}
128
+ </header>
129
+ <main class="shell grid">
130
+ <h1>Connection status</h1>
131
+ <div class="copy">
132
+ <p>${escapeHtml(body)}</p>
133
+ <p><a href="/ui">Return to ${escapeHtml(brand.productName)}</a></p>
134
+ </div>
135
+ </main>
136
+ </body>
137
+ </html>`,
138
+ { status, headers: { "Content-Type": "text/html; charset=utf-8" } },
139
+ );
140
+ }
141
+
142
+ async function authorize(
143
+ request: Request,
144
+ baseUrl: string,
145
+ auth: InboundAuth[],
146
+ ): Promise<
147
+ | {
148
+ ok: true;
149
+ actor: ActivityActor;
150
+ providerKind?: string;
151
+ userId?: string;
152
+ }
153
+ | { ok: false; response: Response }
154
+ > {
155
+ if (auth.length === 0) {
156
+ return { ok: true, actor: { kind: "anonymous" } };
157
+ }
158
+ let lastResponse: Response | null = null;
159
+ for (const provider of auth) {
160
+ const result = await provider.authorize(request, baseUrl);
161
+ if (result.ok) {
162
+ const subjectId = result.subjectId ?? result.userId;
163
+ return {
164
+ ok: true,
165
+ actor: {
166
+ kind: provider.kind,
167
+ ...(subjectId ? { id: subjectId } : {}),
168
+ },
169
+ providerKind: provider.kind,
170
+ ...(result.userId ? { userId: result.userId } : {}),
171
+ };
172
+ }
173
+ lastResponse = result.response;
174
+ }
175
+ return {
176
+ ok: false,
177
+ response:
178
+ lastResponse ??
179
+ new Response(JSON.stringify({ error: "unauthorized" }), {
180
+ status: 401,
181
+ headers: {
182
+ "Content-Type": "application/json",
183
+ "WWW-Authenticate": "Bearer",
184
+ },
185
+ }),
186
+ };
187
+ }
188
+
189
+ function privateJson(body: unknown, init: ResponseInit = {}): Response {
190
+ const headers = new Headers(init.headers);
191
+ headers.set("Content-Type", "application/json");
192
+ headers.set("Cache-Control", "no-store");
193
+ headers.set("Referrer-Policy", "no-referrer");
194
+ return new Response(JSON.stringify(body), { ...init, headers });
195
+ }
196
+
197
+ // Browser-based MCP clients call /mcp cross-origin. Without CORS on every
198
+ // response — errors included — the browser hides the 401, the client cannot
199
+ // read WWW-Authenticate, and OAuth discovery silently never starts.
200
+ function withMcpCors(response: Response): Response {
201
+ const headers = new Headers(response.headers);
202
+ for (const [name, value] of Object.entries(CORS_HEADERS)) {
203
+ headers.set(name, value);
204
+ }
205
+ headers.set(
206
+ "Access-Control-Expose-Headers",
207
+ "WWW-Authenticate, mcp-session-id, mcp-protocol-version",
208
+ );
209
+ return new Response(response.body, {
210
+ status: response.status,
211
+ statusText: response.statusText,
212
+ headers,
213
+ });
214
+ }
215
+
216
+ function withSecurityHeaders(
217
+ response: Response,
218
+ requestUrl: URL,
219
+ path: string,
220
+ ): Response {
221
+ const headers = new Headers(response.headers);
222
+ headers.set("X-Content-Type-Options", "nosniff");
223
+ headers.set("Referrer-Policy", "no-referrer");
224
+ if (requestUrl.protocol === "https:") {
225
+ headers.set("Strict-Transport-Security", "max-age=31536000");
226
+ }
227
+ if (path === "/ui") {
228
+ // A directive-only CSP does not interfere with the UI's existing scripts,
229
+ // while preventing the authenticated operator surface from being framed.
230
+ headers.set("Content-Security-Policy", "frame-ancestors 'none'");
231
+ headers.set("X-Frame-Options", "DENY");
232
+ }
233
+ return new Response(response.body, {
234
+ status: response.status,
235
+ statusText: response.statusText,
236
+ headers,
237
+ });
238
+ }
239
+
240
+ async function authorizeUiAdmin(
241
+ request: Request,
242
+ baseUrl: string,
243
+ auth: InboundAuth[],
244
+ ): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
245
+ // Credential mutation is intentionally narrower than /mcp and /ui/data:
246
+ // only the interactive Clerk provider may admit it. A static bearer token is
247
+ // useful for headless tool calls but must not become a vault-admin key.
248
+ const provider = auth.find((candidate) => candidate.uiAuth?.kind === "clerk");
249
+ if (!provider) {
250
+ return {
251
+ ok: false,
252
+ response: privateJson(
253
+ { error: "credential management requires Clerk authentication" },
254
+ { status: 403 },
255
+ ),
256
+ };
257
+ }
258
+ const result = await provider.authorize(request, baseUrl);
259
+ if (!result.ok) return result;
260
+ if (!result.userId) {
261
+ return {
262
+ ok: false,
263
+ response: privateJson(
264
+ { error: "authenticated user required" },
265
+ { status: 403 },
266
+ ),
267
+ };
268
+ }
269
+ return { ok: true, userId: result.userId };
270
+ }
271
+
272
+ function isSameOrigin(request: Request, baseUrl: string): boolean {
273
+ const origin = request.headers.get("origin");
274
+ if (!origin) return false;
275
+ try {
276
+ return new URL(origin).origin === new URL(baseUrl).origin;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ type CredentialInput =
283
+ | { kind: "single"; value: string }
284
+ | { kind: "multiple"; values: ConnectorCredentialValues };
285
+
286
+ async function readCredentialInput(
287
+ request: Request,
288
+ config: ConnectorCredentialConfig,
289
+ ): Promise<
290
+ { ok: true; input: CredentialInput } | { ok: false; response: Response }
291
+ > {
292
+ if (
293
+ !request.headers
294
+ .get("content-type")
295
+ ?.toLowerCase()
296
+ .startsWith("application/json")
297
+ ) {
298
+ return {
299
+ ok: false,
300
+ response: privateJson(
301
+ { error: "Content-Type must be application/json" },
302
+ { status: 415 },
303
+ ),
304
+ };
305
+ }
306
+ const raw = await request.text();
307
+ if (raw.length > 20_000) {
308
+ return {
309
+ ok: false,
310
+ response: privateJson(
311
+ { error: "request body is too large" },
312
+ { status: 413 },
313
+ ),
314
+ };
315
+ }
316
+ try {
317
+ const body = JSON.parse(raw) as {
318
+ value?: unknown;
319
+ values?: unknown;
320
+ };
321
+ if (!config.fields?.length) {
322
+ if (typeof body.value !== "string" || !body.value.trim()) {
323
+ return {
324
+ ok: false,
325
+ response: privateJson(
326
+ { error: "value must be a non-empty string" },
327
+ { status: 400 },
328
+ ),
329
+ };
330
+ }
331
+ return { ok: true, input: { kind: "single", value: body.value } };
332
+ }
333
+ if (
334
+ !body.values ||
335
+ typeof body.values !== "object" ||
336
+ Array.isArray(body.values)
337
+ ) {
338
+ return {
339
+ ok: false,
340
+ response: privateJson(
341
+ { error: "values must be an object" },
342
+ { status: 400 },
343
+ ),
344
+ };
345
+ }
346
+ const rawValues = body.values as Record<string, unknown>;
347
+ const expected = new Set(config.fields.map((field) => field.name));
348
+ const unexpected = Object.keys(rawValues).find(
349
+ (field) => !expected.has(field),
350
+ );
351
+ if (unexpected) {
352
+ return {
353
+ ok: false,
354
+ response: privateJson(
355
+ { error: `unexpected credential field "${unexpected}"` },
356
+ { status: 400 },
357
+ ),
358
+ };
359
+ }
360
+ const values: ConnectorCredentialValues = {};
361
+ for (const field of config.fields) {
362
+ const value = rawValues[field.name];
363
+ if (typeof value !== "string" || !value.trim()) {
364
+ return {
365
+ ok: false,
366
+ response: privateJson(
367
+ { error: `${field.name} must be a non-empty string` },
368
+ { status: 400 },
369
+ ),
370
+ };
371
+ }
372
+ values[field.name] = value;
373
+ }
374
+ return { ok: true, input: { kind: "multiple", values } };
375
+ } catch {
376
+ return {
377
+ ok: false,
378
+ response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
379
+ };
380
+ }
381
+ }
382
+
383
+ async function handleCredentialRequest(
384
+ request: Request,
385
+ connectorId: string,
386
+ action: string | undefined,
387
+ opts: ServerOptions,
388
+ baseUrl: string,
389
+ ): Promise<Response> {
390
+ if (!opts.credentialVault) {
391
+ return privateJson(
392
+ { error: "credential storage is not configured" },
393
+ { status: 503 },
394
+ );
395
+ }
396
+ if (!isSameOrigin(request, baseUrl)) {
397
+ return privateJson(
398
+ { error: "same-origin request required" },
399
+ { status: 403 },
400
+ );
401
+ }
402
+ const admin = await authorizeUiAdmin(request, baseUrl, opts.auth);
403
+ if (!admin.ok) return admin.response;
404
+
405
+ const connector = opts.registry.getConnector(connectorId);
406
+ if (!connector?.credential) {
407
+ return privateJson({ error: "unknown credential slot" }, { status: 404 });
408
+ }
409
+
410
+ if (action === "test") {
411
+ if (request.method !== "POST") {
412
+ return privateJson({ error: "method not allowed" }, { status: 405 });
413
+ }
414
+ if (!connector.testCredential && !connector.testCredentials) {
415
+ return privateJson(
416
+ { error: "this connector does not support credential testing" },
417
+ { status: 400 },
418
+ );
419
+ }
420
+ try {
421
+ const ctx = opts.registry.contextFor(connectorId, baseUrl);
422
+ let result;
423
+ if (connector.testCredentials) {
424
+ const values = await opts.credentialVault.getAll(connectorId);
425
+ if (!values) {
426
+ return privateJson(
427
+ { error: "configure the credentials before testing them" },
428
+ { status: 409 },
429
+ );
430
+ }
431
+ result = await connector.testCredentials(values, ctx);
432
+ } else {
433
+ const value = await opts.credentialVault.get(connectorId);
434
+ if (!value) {
435
+ return privateJson(
436
+ { error: "configure the credential before testing it" },
437
+ { status: 409 },
438
+ );
439
+ }
440
+ result = await connector.testCredential!(value, ctx);
441
+ }
442
+ return privateJson(result);
443
+ } catch (err) {
444
+ return privateJson({ ok: false, message: msg(err) });
445
+ }
446
+ }
447
+
448
+ if (action) {
449
+ return privateJson({ error: "not found" }, { status: 404 });
450
+ }
451
+
452
+ if (request.method === "PUT") {
453
+ const input = await readCredentialInput(request, connector.credential);
454
+ if (!input.ok) return input.response;
455
+ try {
456
+ const metadata =
457
+ input.input.kind === "single"
458
+ ? await opts.credentialVault.set(
459
+ connectorId,
460
+ input.input.value,
461
+ admin.userId,
462
+ )
463
+ : await opts.credentialVault.setAll(
464
+ connectorId,
465
+ input.input.values,
466
+ admin.userId,
467
+ );
468
+ opts.registry.invalidate(connectorId);
469
+ return privateJson({ credential: metadata });
470
+ } catch (err) {
471
+ return privateJson({ error: msg(err) }, { status: 400 });
472
+ }
473
+ }
474
+
475
+ if (request.method === "DELETE") {
476
+ await opts.credentialVault.delete(connectorId);
477
+ opts.registry.invalidate(connectorId);
478
+ return new Response(null, {
479
+ status: 204,
480
+ headers: {
481
+ "Cache-Control": "no-store",
482
+ "Referrer-Policy": "no-referrer",
483
+ },
484
+ });
485
+ }
486
+
487
+ return privateJson({ error: "method not allowed" }, { status: 405 });
488
+ }
489
+
490
+ async function serveMcp(
491
+ request: Request,
492
+ opts: ServerOptions,
493
+ baseUrl: string,
494
+ actor: ActivityActor,
495
+ runtimeContext?: RuntimeExecutionContext,
496
+ ): Promise<Response> {
497
+ // Fresh McpServer + transport per request (SDK ≥1.26 requirement), stateless.
498
+ const server = new McpServer(opts.serverInfo, {
499
+ instructions: CONNECTA_INSTRUCTIONS,
500
+ });
501
+ const activity: ActivityRequestContext | undefined = opts.activity
502
+ ? {
503
+ sink: opts.activity,
504
+ actor,
505
+ requestId: crypto.randomUUID(),
506
+ serverInfo: opts.serverInfo,
507
+ ...(opts.activityDeploymentId
508
+ ? { deploymentId: opts.activityDeploymentId }
509
+ : {}),
510
+ ...(runtimeContext?.waitUntil
511
+ ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
512
+ : {}),
513
+ logger: opts.logger,
514
+ }
515
+ : undefined;
516
+ registerMetaTools(server, opts.registry, { baseUrl, activity });
517
+ if (opts.executor) {
518
+ registerExecuteTool(server, opts.registry, {
519
+ baseUrl,
520
+ executor: opts.executor,
521
+ logger: opts.logger,
522
+ activity,
523
+ });
524
+ }
525
+ const transport = new WebStandardStreamableHTTPServerTransport({
526
+ enableJsonResponse: true,
527
+ });
528
+ await server.connect(transport);
529
+ return transport.handleRequest(request);
530
+ }
531
+
532
+ async function handleOAuthCallback(
533
+ url: URL,
534
+ registry: Registry,
535
+ baseUrl: string,
536
+ branding?: ConnectaBranding,
537
+ ): Promise<Response> {
538
+ const id = url.pathname.slice("/oauth/callback/".length);
539
+ const connector = registry.getConnector(id);
540
+ if (!connector || !connector.finishAuth) {
541
+ return html(`Unknown connector "${id}".`, 404, branding);
542
+ }
543
+ const error = url.searchParams.get("error");
544
+ if (error) return html(`Authorization denied: ${error}`, 400, branding);
545
+ const code = url.searchParams.get("code");
546
+ if (!code) return html("Missing authorization code.", 400, branding);
547
+ const context = registry.contextFor(id, baseUrl);
548
+ // CSRF / login-fixation guard: this route is intentionally public, so verify
549
+ // the `state` matches the flow connecta started BEFORE exchanging the code.
550
+ if (connector.verifyState) {
551
+ const state = url.searchParams.get("state");
552
+ const ok = await connector.verifyState(state, context);
553
+ if (!ok) {
554
+ return html(
555
+ "Authorization state mismatch — this callback did not originate from " +
556
+ "a flow started by connecta. Re-run authorization and try again.",
557
+ 400,
558
+ branding,
559
+ );
560
+ }
561
+ }
562
+ try {
563
+ await connector.finishAuth(code, context);
564
+ await registry.invalidateStored(id);
565
+ return html(
566
+ `Connected "${id}". You can close this window.`,
567
+ 200,
568
+ branding,
569
+ );
570
+ } catch (err) {
571
+ return html(`Authorization failed: ${msg(err)}`, 500, branding);
572
+ }
573
+ }
574
+
575
+ /** Build the Web-standard fetch handler that serves connecta. */
576
+ export function createFetchHandler(
577
+ opts: ServerOptions,
578
+ ): (
579
+ request: Request,
580
+ runtimeContext?: RuntimeExecutionContext,
581
+ ) => Promise<Response> {
582
+ const { registry, auth, publicUrl, serverInfo } = opts;
583
+ return async function fetch(
584
+ request: Request,
585
+ runtimeContext?: RuntimeExecutionContext,
586
+ ): Promise<Response> {
587
+ const url = new URL(request.url);
588
+ const baseUrl = publicUrl ?? url.origin;
589
+ const path = url.pathname;
590
+ if (
591
+ publicUrl &&
592
+ new URL(publicUrl).protocol === "https:" &&
593
+ url.protocol === "http:"
594
+ ) {
595
+ const target = new URL(`${url.pathname}${url.search}`, publicUrl);
596
+ return withSecurityHeaders(
597
+ new Response(null, {
598
+ status: 308,
599
+ headers: { Location: target.toString() },
600
+ }),
601
+ url,
602
+ path,
603
+ );
604
+ }
605
+
606
+ const route = async (): Promise<Response> => {
607
+ const credentialMatch =
608
+ /^\/ui\/credentials\/([a-z0-9_-]+)(?:\/([a-z]+))?$/.exec(path);
609
+ if (credentialMatch) {
610
+ // Never opt these mutation routes into the server's wildcard CORS
611
+ // preflight behavior.
612
+ if (request.method === "OPTIONS") {
613
+ return privateJson({ error: "method not allowed" }, { status: 405 });
614
+ }
615
+ return handleCredentialRequest(
616
+ request,
617
+ credentialMatch[1],
618
+ credentialMatch[2],
619
+ opts,
620
+ baseUrl,
621
+ );
622
+ }
623
+
624
+ if (request.method === "OPTIONS") {
625
+ for (const a of auth) {
626
+ if (a.handleMetadata) {
627
+ const r = await a.handleMetadata(request, baseUrl);
628
+ if (r) return r;
629
+ }
630
+ }
631
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
632
+ }
633
+
634
+ if (path.startsWith("/.well-known/")) {
635
+ for (const a of auth) {
636
+ if (a.handleMetadata) {
637
+ const r = await a.handleMetadata(request, baseUrl);
638
+ if (r) return r;
639
+ }
640
+ }
641
+ return new Response("Not Found", { status: 404 });
642
+ }
643
+
644
+ if (path === "/health") {
645
+ return Response.json({
646
+ status: "ok",
647
+ connectors: registry.listConnectors().length,
648
+ server: opts.serverInfo,
649
+ ...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
650
+ });
651
+ }
652
+
653
+ if (path.startsWith("/oauth/callback/")) {
654
+ return handleOAuthCallback(url, registry, baseUrl, opts.branding);
655
+ }
656
+
657
+ if (request.method === "GET" && path === "/favicon.svg") {
658
+ return new Response(CONNECTA_FAVICON_SVG, {
659
+ headers: {
660
+ "Content-Type": "image/svg+xml",
661
+ "Cache-Control": "public, max-age=86400",
662
+ },
663
+ });
664
+ }
665
+
666
+ if (request.method === "GET" && path === "/favicon.ico") {
667
+ return new Response(CONNECTA_FAVICON_ICO, {
668
+ headers: {
669
+ "Content-Type": "image/x-icon",
670
+ "Cache-Control": "public, max-age=86400",
671
+ },
672
+ });
673
+ }
674
+
675
+ if (path === "/ui") {
676
+ // Open shell — carries no data; data comes only from the gated /ui/data.
677
+ const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
678
+ const mcpUrl = new URL("/mcp", baseUrl).toString();
679
+ return new Response(renderUiHtml(uiAuth, mcpUrl, opts.branding), {
680
+ status: 200,
681
+ headers: { "Content-Type": "text/html; charset=utf-8" },
682
+ });
683
+ }
684
+
685
+ if (path === "/ui/data") {
686
+ const authz = await authorize(request, baseUrl, auth);
687
+ if (!authz.ok) return authz.response;
688
+ const data = await buildUiData(
689
+ registry,
690
+ baseUrl,
691
+ serverInfo,
692
+ // The static headless bearer may read connector health, but only a
693
+ // Clerk-authenticated operator receives credential metadata.
694
+ authz.providerKind === "clerk" && authz.userId
695
+ ? opts.credentialVault
696
+ : undefined,
697
+ Boolean(opts.activity?.list),
698
+ );
699
+ return privateJson(data);
700
+ }
701
+
702
+ if (path === "/ui/activity") {
703
+ if (request.method !== "GET") {
704
+ return privateJson({ error: "method not allowed" }, { status: 405 });
705
+ }
706
+ const authz = await authorize(request, baseUrl, auth);
707
+ if (!authz.ok) return authz.response;
708
+ if (
709
+ opts.activityReadGate &&
710
+ !(await opts.activityReadGate(authz.actor))
711
+ ) {
712
+ return privateJson({ error: "forbidden" }, { status: 403 });
713
+ }
714
+ if (!opts.activity?.list) {
715
+ return privateJson(
716
+ { error: "activity history is not configured" },
717
+ { status: 404 },
718
+ );
719
+ }
720
+ const cursor = url.searchParams.get("cursor") ?? undefined;
721
+ if (cursor && cursor.length > 500) {
722
+ return privateJson({ error: "invalid cursor" }, { status: 400 });
723
+ }
724
+ const requestedLimit = Number(url.searchParams.get("limit") ?? "50");
725
+ const limit = Number.isFinite(requestedLimit)
726
+ ? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
727
+ : 50;
728
+ try {
729
+ return privateJson(await opts.activity.list({ cursor, limit }));
730
+ } catch (error) {
731
+ if (error instanceof InvalidActivityCursorError) {
732
+ return privateJson({ error: error.message }, { status: 400 });
733
+ }
734
+ opts.logger.error("[connecta] activity read failed", error);
735
+ return privateJson(
736
+ { error: "activity history is temporarily unavailable" },
737
+ { status: 503 },
738
+ );
739
+ }
740
+ }
741
+
742
+ if (path === "/mcp") {
743
+ const authz = await authorize(request, baseUrl, auth);
744
+ const response = authz.ok
745
+ ? await serveMcp(request, opts, baseUrl, authz.actor, runtimeContext)
746
+ : authz.response;
747
+ return withMcpCors(response);
748
+ }
749
+
750
+ return new Response("Not Found", { status: 404 });
751
+ };
752
+ return withSecurityHeaders(await route(), url, path);
753
+ };
754
+ }
755
+
756
+ interface RuntimeExecutionContext {
757
+ waitUntil(promise: Promise<unknown>): void;
758
+ }