@zackbart/connecta 0.7.4 → 0.7.6

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 (41) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +4 -2
  3. package/dist/execute.d.ts.map +1 -1
  4. package/dist/execute.js +11 -0
  5. package/dist/execute.js.map +1 -1
  6. package/dist/executor-admission.d.ts +18 -1
  7. package/dist/executor-admission.d.ts.map +1 -1
  8. package/dist/executor-admission.js +82 -3
  9. package/dist/executor-admission.js.map +1 -1
  10. package/dist/executors/quickjs-protocol.d.ts +1 -0
  11. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  12. package/dist/executors/quickjs-protocol.js +7 -4
  13. package/dist/executors/quickjs-protocol.js.map +1 -1
  14. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  15. package/dist/executors/quickjs-runtime.js +22 -9
  16. package/dist/executors/quickjs-runtime.js.map +1 -1
  17. package/dist/executors/quickjs.d.ts.map +1 -1
  18. package/dist/executors/quickjs.js +44 -10
  19. package/dist/executors/quickjs.js.map +1 -1
  20. package/dist/index.d.ts +30 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +38 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/server.d.ts +3 -0
  25. package/dist/server.d.ts.map +1 -1
  26. package/dist/server.js +175 -16
  27. package/dist/server.js.map +1 -1
  28. package/dist/types.d.ts +26 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/package.json +2 -1
  33. package/src/execute.ts +11 -0
  34. package/src/executor-admission.ts +90 -3
  35. package/src/executors/quickjs-protocol.ts +7 -4
  36. package/src/executors/quickjs-runtime.ts +31 -9
  37. package/src/executors/quickjs.ts +61 -12
  38. package/src/index.ts +87 -1
  39. package/src/server.ts +217 -29
  40. package/src/types.ts +27 -0
  41. package/src/version.ts +1 -1
package/src/server.ts CHANGED
@@ -33,6 +33,12 @@ import type {
33
33
  ToolkitBinding,
34
34
  } from "./types.js";
35
35
  import { CONNECTA_FAVICON_ICO } from "./favicon.js";
36
+ import {
37
+ ExecutorAdmissionError,
38
+ isAdmittingExecutor,
39
+ type AdmissionController,
40
+ type AdmissionLease,
41
+ } from "./executor-admission.js";
36
42
  import {
37
43
  buildUiData,
38
44
  CONNECTA_FAVICON_SVG,
@@ -96,6 +102,8 @@ export interface ServerOptions {
96
102
  probeTimeoutMs?: number;
97
103
  /** When set, the execute_code meta-tool is registered on top of the nine. */
98
104
  executor?: Executor;
105
+ /** Global FIFO boundary for all non-preflight `/mcp` requests. */
106
+ requestAdmission: AdmissionController;
99
107
  /** Encrypted connector-credential storage backing the Credentials page. */
100
108
  credentialVault?: CredentialVault;
101
109
  /** Optional browser UI and OAuth result-page labels. */
@@ -301,7 +309,7 @@ function withMcpCors(response: Response): Response {
301
309
  }
302
310
  headers.set(
303
311
  "Access-Control-Expose-Headers",
304
- "WWW-Authenticate, mcp-session-id, mcp-protocol-version",
312
+ "WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version",
305
313
  );
306
314
  return new Response(response.body, {
307
315
  status: response.status,
@@ -310,6 +318,102 @@ function withMcpCors(response: Response): Response {
310
318
  });
311
319
  }
312
320
 
321
+ function requestAdmissionFailure(error: ExecutorAdmissionError): Response {
322
+ const overloaded = error.code === "executor_overloaded";
323
+ const data = {
324
+ code: overloaded ? "server_overloaded" : "server_shutting_down",
325
+ retryable: overloaded,
326
+ ...(overloaded && error.retryAfterMs !== undefined
327
+ ? { retryAfterMs: error.retryAfterMs }
328
+ : {}),
329
+ };
330
+ const headers = new Headers({
331
+ "Content-Type": "application/json",
332
+ "Cache-Control": "no-store",
333
+ });
334
+ if (overloaded && error.retryAfterMs !== undefined) {
335
+ headers.set(
336
+ "Retry-After",
337
+ String(Math.max(1, Math.ceil(error.retryAfterMs / 1_000))),
338
+ );
339
+ }
340
+ return new Response(
341
+ JSON.stringify({
342
+ jsonrpc: "2.0",
343
+ id: null,
344
+ error: {
345
+ code: overloaded ? -32001 : -32002,
346
+ message: overloaded
347
+ ? "Server capacity is exhausted. Retry later."
348
+ : "Server is shutting down.",
349
+ data,
350
+ },
351
+ }),
352
+ { status: 503, headers },
353
+ );
354
+ }
355
+
356
+ /**
357
+ * A request owns its permit through the response body, not merely until the
358
+ * handler returns. This is what makes slow clients and response-stream failure
359
+ * part of the same bounded lifecycle as success, error, and cancellation.
360
+ */
361
+ function releaseAdmissionWithResponse(
362
+ response: Response,
363
+ lease: AdmissionLease,
364
+ signal: AbortSignal,
365
+ ): Response {
366
+ let released = false;
367
+ let onAbort = () => {};
368
+ const release = () => {
369
+ if (released) return;
370
+ released = true;
371
+ signal.removeEventListener("abort", onAbort);
372
+ lease.release();
373
+ };
374
+ if (!response.body) {
375
+ release();
376
+ return response;
377
+ }
378
+ const reader = response.body.getReader();
379
+ onAbort = () => {
380
+ // `cancel()` belongs to an operator/auth/SDK-provided stream and may
381
+ // reject. Consume both outcomes: `.finally(release)` would release the
382
+ // permit but preserve the rejection as an unhandled promise.
383
+ void reader.cancel(signal.reason).then(release, release);
384
+ };
385
+ signal.addEventListener("abort", onAbort, { once: true });
386
+ if (signal.aborted) onAbort();
387
+ const body = new ReadableStream<Uint8Array>({
388
+ async pull(controller) {
389
+ try {
390
+ const next = await reader.read();
391
+ if (next.done) {
392
+ release();
393
+ controller.close();
394
+ } else {
395
+ controller.enqueue(next.value);
396
+ }
397
+ } catch (error) {
398
+ release();
399
+ controller.error(error);
400
+ }
401
+ },
402
+ async cancel(reason) {
403
+ try {
404
+ await reader.cancel(reason);
405
+ } finally {
406
+ release();
407
+ }
408
+ },
409
+ });
410
+ return new Response(body, {
411
+ status: response.status,
412
+ statusText: response.statusText,
413
+ headers: response.headers,
414
+ });
415
+ }
416
+
313
417
  function withSecurityHeaders(
314
418
  response: Response,
315
419
  requestUrl: URL,
@@ -1042,6 +1146,23 @@ export function createFetchHandler(
1042
1146
  runtimeContext?: RuntimeExecutionContext,
1043
1147
  ) => Promise<Response> {
1044
1148
  const { registry, auth, publicUrl, serverInfo } = opts;
1149
+ let lastAdmissionWarningAt = 0;
1150
+ let suppressedAdmissionWarnings = 0;
1151
+ const warnAdmissionRejected = (error: ExecutorAdmissionError): void => {
1152
+ const now = Date.now();
1153
+ if (now - lastAdmissionWarningAt < 1_000) {
1154
+ suppressedAdmissionWarnings++;
1155
+ return;
1156
+ }
1157
+ opts.logger.warn("[connecta] MCP request admission rejected", {
1158
+ retryAfterMs: error.retryAfterMs,
1159
+ active: opts.requestAdmission.activeCount,
1160
+ queued: opts.requestAdmission.queuedCount,
1161
+ suppressedSinceLastWarning: suppressedAdmissionWarnings,
1162
+ });
1163
+ lastAdmissionWarningAt = now;
1164
+ suppressedAdmissionWarnings = 0;
1165
+ };
1045
1166
  return async function fetch(
1046
1167
  request: Request,
1047
1168
  runtimeContext?: RuntimeExecutionContext,
@@ -1154,10 +1275,29 @@ export function createFetchHandler(
1154
1275
  }
1155
1276
 
1156
1277
  if (path === "/health") {
1278
+ const codeAdmission =
1279
+ opts.executor && isAdmittingExecutor(opts.executor)
1280
+ ? opts.executor.admissionSnapshot?.()
1281
+ : undefined;
1157
1282
  return Response.json({
1158
1283
  status: "ok",
1159
1284
  connectors: registry.listConnectors().length,
1160
1285
  server: opts.serverInfo,
1286
+ admission: {
1287
+ policy: "global-fifo",
1288
+ requests: opts.requestAdmission.snapshot(),
1289
+ code: opts.executor
1290
+ ? (codeAdmission ?? { managedByExecutor: true })
1291
+ : null,
1292
+ reservedRoutes: [
1293
+ "/health",
1294
+ "/",
1295
+ "/credentials",
1296
+ "/activity",
1297
+ "/ui",
1298
+ "/ui/*",
1299
+ ],
1300
+ },
1161
1301
  ...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
1162
1302
  });
1163
1303
  }
@@ -1312,34 +1452,82 @@ export function createFetchHandler(
1312
1452
  }
1313
1453
 
1314
1454
  if (path === "/mcp") {
1315
- // Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
1316
- // must not be able to probe which toolkit names exist.
1317
- const authz = await authorize(request, baseUrl, auth, opts.logger);
1318
- if (!authz.ok) return withMcpCors(authz.response);
1319
- const selected = resolveToolkitScope(
1320
- url,
1321
- registry,
1322
- opts.toolkits,
1323
- opts.logger,
1324
- {
1325
- actor: authz.actor,
1326
- ...(authz.toolkitBinding
1327
- ? { binding: authz.toolkitBinding }
1328
- : {}),
1329
- },
1330
- );
1331
- if (!selected.ok) return withMcpCors(selected.response);
1332
- sweepCredentials();
1333
- return withMcpCors(
1334
- await serveMcp(
1335
- request,
1336
- opts,
1337
- baseUrl,
1338
- authz.actor,
1339
- selected.scope,
1340
- runtimeContext,
1341
- ),
1342
- );
1455
+ let admission: AdmissionLease;
1456
+ try {
1457
+ admission = await opts.requestAdmission.acquire({
1458
+ signal: request.signal,
1459
+ });
1460
+ if (admission.waitMs > 0) {
1461
+ opts.logger.debug("[connecta] MCP request admitted after queue wait", {
1462
+ waitMs: admission.waitMs,
1463
+ active: opts.requestAdmission.activeCount,
1464
+ queued: opts.requestAdmission.queuedCount,
1465
+ });
1466
+ }
1467
+ } catch (error) {
1468
+ if (
1469
+ error instanceof ExecutorAdmissionError &&
1470
+ error.code === "executor_cancelled"
1471
+ ) {
1472
+ throw request.signal.reason ?? error;
1473
+ }
1474
+ if (error instanceof ExecutorAdmissionError) {
1475
+ if (error.code === "executor_overloaded") {
1476
+ warnAdmissionRejected(error);
1477
+ }
1478
+ return withMcpCors(requestAdmissionFailure(error));
1479
+ }
1480
+ throw error;
1481
+ }
1482
+ try {
1483
+ // Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
1484
+ // must not be able to probe which toolkit names exist.
1485
+ const authz = await authorize(request, baseUrl, auth, opts.logger);
1486
+ if (!authz.ok) {
1487
+ return releaseAdmissionWithResponse(
1488
+ withMcpCors(authz.response),
1489
+ admission,
1490
+ request.signal,
1491
+ );
1492
+ }
1493
+ const selected = resolveToolkitScope(
1494
+ url,
1495
+ registry,
1496
+ opts.toolkits,
1497
+ opts.logger,
1498
+ {
1499
+ actor: authz.actor,
1500
+ ...(authz.toolkitBinding
1501
+ ? { binding: authz.toolkitBinding }
1502
+ : {}),
1503
+ },
1504
+ );
1505
+ if (!selected.ok) {
1506
+ return releaseAdmissionWithResponse(
1507
+ withMcpCors(selected.response),
1508
+ admission,
1509
+ request.signal,
1510
+ );
1511
+ }
1512
+ sweepCredentials();
1513
+ return releaseAdmissionWithResponse(
1514
+ withMcpCors(
1515
+ await serveMcp(
1516
+ request,
1517
+ opts,
1518
+ baseUrl,
1519
+ authz.actor,
1520
+ selected.scope,
1521
+ runtimeContext,
1522
+ ),
1523
+ ),
1524
+ admission,
1525
+ request.signal,
1526
+ );
1527
+ } catch (error) {
1528
+ admission.release();
1529
+ throw error;
1530
+ }
1343
1531
  }
1344
1532
 
1345
1533
  // Connector-owned public routes, dispatched last: a connector can add a
package/src/types.ts CHANGED
@@ -268,6 +268,29 @@ export interface Executor {
268
268
  close?(): void | Promise<void>;
269
269
  }
270
270
 
271
+ /** Payload-free, monotonically increasing admission observations. */
272
+ export interface AdmissionSnapshot {
273
+ concurrency: number;
274
+ maxQueueSize: number;
275
+ queueTimeoutMs: number;
276
+ retryAfterMs: number;
277
+ active: number;
278
+ queued: number;
279
+ closed: boolean;
280
+ totals: {
281
+ admitted: number;
282
+ queued: number;
283
+ rejected: number;
284
+ cancelled: number;
285
+ closed: number;
286
+ };
287
+ queueWaitMs: {
288
+ count: number;
289
+ total: number;
290
+ max: number;
291
+ };
292
+ }
293
+
271
294
  /**
272
295
  * Optional admission capability used by bounded executors. The acquired lease
273
296
  * carries execution so an already-admitted caller cannot accidentally acquire
@@ -275,9 +298,13 @@ export interface Executor {
275
298
  */
276
299
  export interface AdmittingExecutor extends Executor {
277
300
  acquire(options?: { signal?: AbortSignal }): Promise<ExecutorLease>;
301
+ /** Payload-free health/metrics view when the executor exposes one. */
302
+ admissionSnapshot?(): AdmissionSnapshot;
278
303
  }
279
304
 
280
305
  export interface ExecutorLease {
306
+ /** Time spent waiting before this lease was granted, when observed. */
307
+ readonly waitMs?: number;
281
308
  execute(code: string, providers: ExecutorProvider[]): Promise<ExecuteResult>;
282
309
  /** Idempotent. Call from finally even when provider construction fails. */
283
310
  release(): void;
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.7.4";
7
+ export const CONNECTA_VERSION = "0.7.6";