@zackbart/connecta 0.24.4 → 0.26.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 (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. package/dist/timeout.d.ts +0 -21
@@ -1,10 +1,13 @@
1
1
  import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
2
+ import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
2
3
  import { registerExecuteTool } from "../execute.js";
3
4
  import { ExecutorAdmissionError, } from "../executor-admission.js";
4
5
  import { registerMetaTools } from "../meta-tools.js";
6
+ import { registerResumeTool } from "../resumable.js";
5
7
  import { intersectAccess } from "../connector-access.js";
6
8
  import { instructionsFor } from "../skills.js";
7
9
  import { msg } from "../errors.js";
10
+ import { detach } from "../runtime/run.js";
8
11
  import { authorize, loggableValue, mayManageConnector, validateAuthPermissions, } from "./shared.js";
9
12
  export const MCP_CORS_HEADERS = {
10
13
  "Access-Control-Allow-Origin": "*",
@@ -73,11 +76,16 @@ function requestAdmissionFailure(error) {
73
76
  }), { status: 503, headers });
74
77
  }
75
78
  /**
76
- * A request owns its permit through the response body, not merely until the
77
- * handler returns. This is what makes slow clients and response-stream failure
78
- * part of the same bounded lifecycle as success, error, and cancellation.
79
+ * Hand a response the Scope that holds what its request acquired.
80
+ *
81
+ * A request owns its permit and its McpServer through the response body, not
82
+ * merely until the handler returns. `close` runs once, on whichever comes
83
+ * first: the body is read to its end or fails, its reader cancels it, or the
84
+ * request's signal aborts. This is what makes slow clients and response-stream
85
+ * failure part of the same bounded lifecycle as success, error, and
86
+ * cancellation.
79
87
  */
80
- function releaseAdmissionWithResponse(response, lease, signal) {
88
+ function closeWithBody(response, signal, close) {
81
89
  let released = false;
82
90
  let onAbort = () => { };
83
91
  const release = () => {
@@ -85,7 +93,7 @@ function releaseAdmissionWithResponse(response, lease, signal) {
85
93
  return;
86
94
  released = true;
87
95
  signal.removeEventListener("abort", onAbort);
88
- lease.release();
96
+ close();
89
97
  };
90
98
  if (!response.body) {
91
99
  release();
@@ -94,9 +102,9 @@ function releaseAdmissionWithResponse(response, lease, signal) {
94
102
  const reader = response.body.getReader();
95
103
  onAbort = () => {
96
104
  // `cancel()` belongs to an operator/auth/SDK-provided stream and may
97
- // reject. Consume both outcomes: `.finally(release)` would release the
98
- // permit but preserve the rejection as an unhandled promise.
99
- void reader.cancel(signal.reason).then(release, release);
105
+ // reject or never settle. Release now; consume either outcome separately.
106
+ release();
107
+ void reader.cancel(signal.reason).catch(() => { });
100
108
  };
101
109
  signal.addEventListener("abort", onAbort, { once: true });
102
110
  if (signal.aborted)
@@ -119,12 +127,8 @@ function releaseAdmissionWithResponse(response, lease, signal) {
119
127
  }
120
128
  },
121
129
  async cancel(reason) {
122
- try {
123
- await reader.cancel(reason);
124
- }
125
- finally {
126
- release();
127
- }
130
+ release();
131
+ await reader.cancel(reason);
128
132
  },
129
133
  });
130
134
  return new Response(body, {
@@ -133,6 +137,52 @@ function releaseAdmissionWithResponse(response, lease, signal) {
133
137
  headers: response.headers,
134
138
  });
135
139
  }
140
+ /**
141
+ * Run a request's handler in a Scope that outlives it.
142
+ *
143
+ * The handler acquires into the scope and its response carries the scope
144
+ * out, for `closeWithBody` to close when the body ends. A handler that fails,
145
+ * or is interrupted because the request's signal aborted, closes it at once:
146
+ * nothing it acquired waits on a body that will never be read.
147
+ */
148
+ function scopedToBody(handler, signal) {
149
+ return Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
150
+ const scope = yield* Scope.make();
151
+ const exit = yield* Effect.exit(restore(Scope.provide(scope)(handler)));
152
+ if (Exit.isFailure(exit)) {
153
+ yield* Scope.close(scope, exit);
154
+ return yield* exit;
155
+ }
156
+ // Every finalizer in the scope is synchronous, so the close has run by
157
+ // the time detach returns: the permit is back, and handed to the next
158
+ // queued request, inside the read or cancel that ended the body.
159
+ return closeWithBody(exit.value, signal, () => {
160
+ void detach(Scope.close(scope, Exit.void));
161
+ });
162
+ }));
163
+ }
164
+ /**
165
+ * The request's admission permit, owned by its Scope.
166
+ *
167
+ * Admission goes through the controller's Promise rather than its Effect
168
+ * program (`acquireScoped`), on purpose. The pool is shared by every request
169
+ * in the isolate, and a queued request is handed its permit from inside the
170
+ * request whose body just ended. A fiber resumed there would authorize and
171
+ * serve this request in that one's I/O context, which workerd refuses;
172
+ * awaiting `acquire()`'s promise resumes it in its own.
173
+ *
174
+ * The wait stays interruptible, and a permit granted just as it was
175
+ * interrupted still arrives, later, to be released on arrival.
176
+ */
177
+ function admitted(controller, signal) {
178
+ return Effect.acquireRelease(Effect.suspend(() => {
179
+ const pending = controller.acquire({ signal });
180
+ return Effect.tryPromise({ try: () => pending, catch: (error) => error })
181
+ .pipe(Effect.onInterrupt(() => Effect.sync(() => {
182
+ pending.then((lease) => lease.release(), () => { });
183
+ })));
184
+ }), (lease) => Effect.sync(() => lease.release()), { interruptible: true });
185
+ }
136
186
  /**
137
187
  * 404 for any `?toolkit=` value, kept after the feature's retirement (#178).
138
188
  *
@@ -167,10 +217,19 @@ function toolkitRetired(logger) {
167
217
  },
168
218
  });
169
219
  }
170
- async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
220
+ function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth, poolName, runtimeContext) {
221
+ // A paused run belongs to the endpoint it started on: resuming it through
222
+ // another pool's endpoint finds nothing, as another subject would.
223
+ const resumable = opts.resumable
224
+ ? { ...opts.resumable, pool: poolName ?? null, approval: opts.approval }
225
+ : undefined;
226
+ // Every McpServer the request builds is fresh and closes with its scope.
227
+ // The modern handler tears its own down after the exchange; the legacy
228
+ // transport never does, and neither may stay wired to an ended request.
229
+ const servers = [];
171
230
  const createServer = () => {
172
231
  const server = new McpServer(opts.serverInfo, {
173
- instructions: instructionsFor(),
232
+ instructions: instructionsFor(resumable !== undefined),
174
233
  cacheHints: {
175
234
  "tools/list": {
176
235
  ttlMs: 3_600_000,
@@ -208,17 +267,18 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
208
267
  ...(opts.discoveryConcurrency !== undefined
209
268
  ? { discoveryConcurrency: opts.discoveryConcurrency }
210
269
  : {}),
211
- requestSignal: request.signal,
270
+ requestSignal,
212
271
  ...(runtimeContext?.waitUntil
213
272
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
214
273
  : {}),
274
+ approval: opts.approval,
215
275
  });
216
- registerExecuteTool(server, registry, {
276
+ const runner = registerExecuteTool(server, registry, {
217
277
  baseUrl,
218
278
  executor: opts.executor,
219
279
  logger: opts.logger,
220
280
  ...(activity ? { activity } : {}),
221
- requestSignal: request.signal,
281
+ requestSignal,
222
282
  ...(runtimeContext?.waitUntil
223
283
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
224
284
  : {}),
@@ -240,26 +300,48 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
240
300
  ...(opts.hostCallTimeoutMs !== undefined
241
301
  ? { hostCallTimeoutMs: opts.hostCallTimeoutMs }
242
302
  : {}),
303
+ ...(opts.watchdogMs !== undefined
304
+ ? { watchdogMs: opts.watchdogMs }
305
+ : {}),
306
+ resumable,
307
+ approval: opts.approval,
308
+ maxWrites: opts.maxWrites,
243
309
  });
310
+ // Always registered, so the surface is the same eight tools on every
311
+ // deployment; without resumable writes it answers that there is nothing
312
+ // to resume.
313
+ registerResumeTool(server, registry, {
314
+ runner,
315
+ settings: resumable,
316
+ ...(activity ? { activity } : {}),
317
+ requestSignal,
318
+ });
319
+ servers.push(server);
244
320
  return server;
245
321
  };
246
- // The v2 entry's built-in legacy fallback streams 2025 results as SSE.
247
- // Connecta's established wire contract is JSON, so retain the documented
248
- // user-land legacy branch with the same transport setting while the modern
249
- // branch uses the fetch-native handler.
250
- if (!(await isLegacyRequest(request))) {
251
- return createMcpHandler(createServer, {
252
- legacy: "reject",
253
- onerror: (error) => opts.logger.error("[connecta] MCP handler error", error),
254
- }).fetch(request);
255
- }
256
- // Fresh server + transport per legacy request, stateless and JSON-shaped.
257
- const server = createServer();
258
- const transport = new WebStandardStreamableHTTPServerTransport({
259
- enableJsonResponse: true,
260
- });
261
- await server.connect(transport);
262
- return transport.handleRequest(request);
322
+ const exchange = async () => {
323
+ // The v2 entry's built-in legacy fallback streams 2025 results as SSE.
324
+ // Connecta's established wire contract is JSON, so retain the documented
325
+ // user-land legacy branch with the same transport setting while the modern
326
+ // branch uses the fetch-native handler.
327
+ if (!(await isLegacyRequest(request))) {
328
+ return createMcpHandler(createServer, {
329
+ legacy: "reject",
330
+ onerror: (error) => opts.logger.error("[connecta] MCP handler error", error),
331
+ }).fetch(request);
332
+ }
333
+ // Fresh server + transport per legacy request, stateless and JSON-shaped.
334
+ const server = createServer();
335
+ const transport = new WebStandardStreamableHTTPServerTransport({
336
+ enableJsonResponse: true,
337
+ });
338
+ await server.connect(transport);
339
+ return transport.handleRequest(request);
340
+ };
341
+ return Effect.addFinalizer(() => Effect.sync(() => {
342
+ for (const server of servers)
343
+ void server.close().catch(() => { });
344
+ })).pipe(Effect.andThen(Effect.promise(exchange)));
263
345
  }
264
346
  export function createMcpRoute(opts) {
265
347
  const configuredOrigins = opts.allowedOrigins;
@@ -322,10 +404,10 @@ export function createMcpRoute(opts) {
322
404
  lastAdmissionWarningAt = now;
323
405
  suppressedAdmissionWarnings = 0;
324
406
  };
325
- async function routeMcp(context) {
407
+ function routeMcp(context) {
326
408
  const { path, request, baseUrl, runtimeContext, } = context;
327
409
  if (path !== "/mcp" && !path.startsWith("/mcp/"))
328
- return null;
410
+ return Effect.succeed(null);
329
411
  const poolName = path === "/mcp" ? undefined : path.slice("/mcp/".length);
330
412
  const origin = request.headers.get("Origin");
331
413
  const allowed = origin === null || allowsOrigin(origin);
@@ -334,90 +416,124 @@ export function createMcpRoute(opts) {
334
416
  // local header check also guards OPTIONS before any provider metadata.
335
417
  const refusal = rejectOrigin(request);
336
418
  if (refusal)
337
- return refusal;
338
- if (request.method === "OPTIONS")
339
- return cors(new Response(null, { status: 204 }));
340
- let admission;
341
- try {
342
- admission = await opts.requestAdmission.acquire({
343
- signal: request.signal,
344
- });
345
- if (admission.waitMs > 0) {
346
- opts.logger.debug("[connecta] MCP request admitted after queue wait", {
347
- waitMs: admission.waitMs,
348
- active: opts.requestAdmission.activeCount,
349
- queued: opts.requestAdmission.queuedCount,
350
- });
351
- }
419
+ return Effect.succeed(refusal);
420
+ if (request.method === "OPTIONS") {
421
+ return Effect.succeed(cors(new Response(null, { status: 204 })));
352
422
  }
353
- catch (error) {
354
- if (error instanceof ExecutorAdmissionError &&
355
- error.code === "executor_cancelled") {
356
- throw request.signal.reason ?? error;
357
- }
358
- if (error instanceof ExecutorAdmissionError) {
423
+ // This controller belongs to this request. Its signal reaches auth and
424
+ // every registered tool; a controller sweep never touches it.
425
+ const localAbort = new AbortController();
426
+ const onCallerAbort = () => localAbort.abort(request.signal.reason);
427
+ request.signal.addEventListener("abort", onCallerAbort, { once: true });
428
+ if (request.signal.aborted)
429
+ onCallerAbort();
430
+ let deadlineTimer;
431
+ return scopedToBody(Effect.gen(function* () {
432
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
433
+ if (!localAbort.signal.aborted) {
434
+ localAbort.abort(request.signal.reason ?? new Error("MCP request ended."));
435
+ }
436
+ request.signal.removeEventListener("abort", onCallerAbort);
437
+ if (deadlineTimer !== undefined)
438
+ clearTimeout(deadlineTimer);
439
+ }));
440
+ const admission = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
441
+ if (Result.isFailure(admission)) {
442
+ const error = admission.failure;
443
+ if (!(error instanceof ExecutorAdmissionError)) {
444
+ return yield* Effect.fail(error);
445
+ }
446
+ if (error.code === "executor_cancelled") {
447
+ return yield* Effect.fail(request.signal.reason ?? error);
448
+ }
359
449
  if (error.code === "executor_overloaded") {
360
450
  warnAdmissionRejected(error);
361
451
  }
362
452
  return cors(requestAdmissionFailure(error));
363
453
  }
364
- throw error;
365
- }
366
- try {
367
- const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity);
368
- if (!authz.ok) {
369
- return releaseAdmissionWithResponse(cors(authz.response), admission, request.signal);
454
+ const remainingMs = admission.success.remainingMs();
455
+ if (remainingMs !== undefined && remainingMs <= 0) {
456
+ admission.success.release();
457
+ return cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
370
458
  }
371
- // A pool endpoint narrows the identity's own view and nothing else. An
372
- // undeclared name, a grant that refuses, and a grant that throws are
373
- // one identical 404 so a credential never enumerates the other pools;
374
- // the operator log is where the reason lives.
375
- let access = authz;
376
- if (poolName !== undefined) {
377
- const pool = opts.pools?.get(poolName);
378
- let granted = false;
379
- let reason = "undeclared";
380
- if (pool) {
381
- try {
382
- granted = (await pool.grant(authz.identity)) === true;
383
- reason = granted ? "granted" : "refused";
384
- }
385
- catch {
386
- reason = "grant threw";
387
- }
388
- }
389
- if (!pool || !granted) {
390
- opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
391
- (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
392
- return releaseAdmissionWithResponse(cors(new Response("Not Found", { status: 404 })), admission, request.signal);
393
- }
394
- access = intersectAccess(authz, pool.access);
459
+ if (remainingMs !== undefined) {
460
+ deadlineTimer = setTimeout(() => {
461
+ localAbort.abort(new Error("MCP request lifetime exceeded."));
462
+ }, remainingMs);
395
463
  }
396
- let scopedRegistry;
397
- try {
398
- validateAuthPermissions(authz, opts.registry);
399
- scopedRegistry = opts.registry.scoped({
400
- connectorIds: access.connectorIds,
401
- ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
402
- ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
403
- ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
464
+ const localRequest = new Request(request, { signal: localAbort.signal });
465
+ if (admission.success.waitMs > 0) {
466
+ opts.logger.debug("[connecta] MCP request admitted after queue wait", {
467
+ waitMs: admission.success.waitMs,
468
+ active: opts.requestAdmission.activeCount,
469
+ queued: opts.requestAdmission.queuedCount,
404
470
  });
405
471
  }
406
- catch (error) {
407
- return releaseAdmissionWithResponse(cors(new Response(JSON.stringify({ error: msg(error) }), {
408
- status: 403,
409
- headers: { "Content-Type": "application/json" },
410
- })), admission, request.signal);
411
- }
412
- if (new URL(request.url).searchParams.has("toolkit")) {
413
- return releaseAdmissionWithResponse(cors(toolkitRetired(opts.logger)), admission, request.signal);
414
- }
415
- return releaseAdmissionWithResponse(cors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
416
- }
417
- catch (error) {
418
- admission.release();
419
- throw error;
420
- }
472
+ // Promise code the fiber stops waiting on when the caller leaves. An
473
+ // Effect `authorize` would put Effect in the /activity bundle, which
474
+ // shares it, to save only the lookups an abandoned authorization
475
+ // finishes on its own.
476
+ const handled = Effect.gen(function* () {
477
+ const authz = yield* Effect.promise(() => authorize(localRequest, baseUrl, opts.auth, runtimeContext, opts.identity));
478
+ if (!authz.ok)
479
+ return cors(authz.response);
480
+ // A pool endpoint narrows the identity's own view and nothing else. An
481
+ // undeclared name, a grant that refuses, and a grant that throws are
482
+ // one identical 404 so a credential never enumerates the other pools;
483
+ // the operator log is where the reason lives.
484
+ let access = authz;
485
+ if (poolName !== undefined) {
486
+ const pool = opts.pools?.get(poolName);
487
+ const reason = !pool ? "undeclared" : yield* Effect.promise(async () => {
488
+ try {
489
+ return (await pool.grant(authz.identity)) === true ? "granted" : "refused";
490
+ }
491
+ catch {
492
+ return "grant threw";
493
+ }
494
+ });
495
+ if (!pool || reason !== "granted") {
496
+ opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
497
+ (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
498
+ return cors(new Response("Not Found", { status: 404 }));
499
+ }
500
+ access = intersectAccess(authz, pool.access);
501
+ }
502
+ let scopedRegistry;
503
+ try {
504
+ validateAuthPermissions(authz, opts.registry);
505
+ scopedRegistry = opts.registry.scoped({
506
+ connectorIds: access.connectorIds,
507
+ ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
508
+ ...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
509
+ ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
510
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
511
+ caller: {
512
+ identity: authz.identity,
513
+ ...(poolName !== undefined ? { pool: poolName } : {}),
514
+ },
515
+ });
516
+ }
517
+ catch (error) {
518
+ return cors(new Response(JSON.stringify({ error: msg(error) }), {
519
+ status: 403,
520
+ headers: { "Content-Type": "application/json" },
521
+ }));
522
+ }
523
+ if (new URL(request.url).searchParams.has("toolkit")) {
524
+ return cors(toolkitRetired(opts.logger));
525
+ }
526
+ return cors(yield* serveMcp(localRequest, localAbort.signal, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, poolName, runtimeContext));
527
+ });
528
+ if (remainingMs === undefined)
529
+ return yield* handled;
530
+ const bounded = yield* handled.pipe(Effect.timeoutOption(Duration.millis(remainingMs)));
531
+ if (Option.isNone(bounded))
532
+ localAbort.abort(new Error("MCP request lifetime exceeded."));
533
+ return Option.isSome(bounded)
534
+ ? bounded.value
535
+ : cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
536
+ }), localAbort.signal);
421
537
  }
422
538
  return { handle: routeMcp, rejectOrigin };
423
539
  }
@@ -1,70 +1,76 @@
1
- import { closeConnectorScope } from "../connector-scope.js";
1
+ import { Effect, Result } from "effect";
2
+ import { closeScope } from "../runtime/connector-scope.js";
2
3
  import { isSafeHttpUrl } from "../ui.js";
3
- import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, isSameOrigin, msg, privateJson, } from "./shared.js";
4
- async function handleOAuthManagementRequest(context, connectorId) {
5
- const { request, baseUrl, opts, defer } = context;
6
- if (!isSameOrigin(request, baseUrl)) {
7
- return privateJson({ error: "same-origin request required" }, { status: 403 });
8
- }
9
- const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "OAuth management", context.runtimeContext, opts.identity);
10
- if (!authz.ok)
11
- return authz.response;
12
- let registry;
13
- try {
14
- validateAuthPermissions(authz, opts.registry);
15
- registry = opts.registry.scoped({
16
- connectorIds: authz.connectorIds,
17
- ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
18
- ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
19
- ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
20
- });
21
- }
22
- catch (error) {
23
- return privateJson({ error: msg(error) }, { status: 403 });
24
- }
25
- const connector = registry.getConnector(connectorId);
26
- if (!connector?.disconnectAuth || !connector.startAuth) {
27
- return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
28
- }
29
- if (!mayManageConnector(authz, connector))
30
- return privateJson({ error: "credential management is not permitted" }, { status: 403 });
31
- if (connector.authScope === "personal" && !authz.principalKey) {
32
- return privateJson({ error: "forbidden" }, { status: 403 });
33
- }
34
- if (request.method !== "DELETE" && request.method !== "POST") {
35
- return privateJson({ error: "method not allowed" }, { status: 405 });
36
- }
37
- const requestScope = {};
38
- const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
39
- try {
40
- let result;
41
- let operationError;
42
- try {
43
- if (request.method === "DELETE") {
44
- await connector.disconnectAuth(ctx);
45
- }
46
- else {
47
- result = await connector.startAuth(ctx, { force: true });
48
- if (result.authorizationUrl) {
49
- await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
50
- }
51
- }
4
+ import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
5
+ import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
6
+ function oauthManagementRequest(context, connectorId) {
7
+ const { request, baseUrl, defer, opts } = context;
8
+ return Effect.gen(function* () {
9
+ if (!isSameOrigin(request, baseUrl)) {
10
+ return yield* refuse("same-origin request required", 403);
52
11
  }
53
- catch (error) {
54
- operationError = error;
12
+ const authz = yield* authorizedPerson(context, "OAuth management");
13
+ const registry = yield* visibleRegistry(context, authz);
14
+ const connector = registry.getConnector(connectorId);
15
+ const disconnectAuth = connector?.disconnectAuth?.bind(connector);
16
+ const startAuth = connector?.startAuth?.bind(connector);
17
+ if (!connector || !disconnectAuth || !startAuth) {
18
+ return yield* refuse("unknown OAuth connector", 404);
55
19
  }
20
+ if (!mayManageConnector(authz, connector)) {
21
+ return yield* refuse("credential management is not permitted", 403);
22
+ }
23
+ if (connector.authScope === "personal" && !authz.principalKey) {
24
+ return yield* refuse("forbidden", 403);
25
+ }
26
+ const disconnecting = request.method === "DELETE";
27
+ if (!disconnecting && request.method !== "POST") {
28
+ return yield* refuse("method not allowed", 405);
29
+ }
30
+ const ctx = registry.contextFor(connectorId, baseUrl, {});
31
+ // The connector scope this request opened ends with it, however it ends.
32
+ yield* Effect.addFinalizer(() => closeScope(connector, ctx, defer));
33
+ const operation = yield* Effect.result(Effect.tryPromise({
34
+ try: async () => {
35
+ if (disconnecting) {
36
+ await disconnectAuth(ctx);
37
+ return undefined;
38
+ }
39
+ const started = await startAuth(ctx, { force: true });
40
+ if (started.authorizationUrl) {
41
+ await registry.bindOAuthHandoff(connectorId, started.authorizationUrl);
42
+ }
43
+ return started;
44
+ },
45
+ catch: (error) => error,
46
+ }));
56
47
  // The old grant and its cached catalog are invalid after either operation,
57
48
  // including a partially failed physical cleanup whose epoch fence succeeded.
58
- try {
59
- await registry.invalidateStored(connectorId);
60
- }
61
- catch (error) {
62
- operationError ??= error;
49
+ const invalidated = yield* Effect.result(Effect.tryPromise({
50
+ try: () => registry.invalidateStored(connectorId),
51
+ catch: (error) => error,
52
+ }));
53
+ // Every failure below answers in the route's own fixed words. A hook's
54
+ // rejection and a status message can quote a token endpoint's error body
55
+ // or a provider's refusal, either of which can quote the secret it was
56
+ // sent, so that text goes to the deployment's log and nowhere else.
57
+ const failed = (detail, fixed, status) => {
58
+ opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : "restart"} failed: ${detail}`);
59
+ return refuse(fixed, status);
60
+ };
61
+ const couldNot = disconnecting
62
+ ? "OAuth disconnect failed"
63
+ : "OAuth authorization could not start";
64
+ // A Result, not a caught value, decides whether it failed: a hook that
65
+ // rejects with no reason at all still failed.
66
+ if (Result.isFailure(operation)) {
67
+ return yield* failed(msg(operation.failure), couldNot, 400);
63
68
  }
64
- if (operationError) {
65
- return privateJson({ error: msg(operationError) }, { status: 400 });
69
+ if (Result.isFailure(invalidated)) {
70
+ return yield* failed(`stored-state invalidation: ${msg(invalidated.failure)}`, couldNot, 400);
66
71
  }
67
- if (request.method === "DELETE") {
72
+ const result = operation.success;
73
+ if (!result) {
68
74
  return new Response(null, {
69
75
  status: 204,
70
76
  headers: {
@@ -77,23 +83,18 @@ async function handleOAuthManagementRequest(context, connectorId) {
77
83
  ? result.authorizationUrl
78
84
  : undefined;
79
85
  if (result.state === "error") {
80
- return privateJson({ error: result.message || "OAuth authorization could not start" }, { status: 502 });
86
+ return yield* failed(result.message || "the connector reported an error state", couldNot, 502);
81
87
  }
82
88
  if (result.state === "auth_required" && !authorizationUrl) {
83
- return privateJson({
84
- error: result.message ||
85
- "OAuth authorization requires consent but no safe URL is available",
86
- }, { status: 502 });
89
+ return yield* failed(result.message || "consent is required but no safe authorization URL was returned", "OAuth authorization requires consent but no safe URL is available", 502);
87
90
  }
91
+ // No message: a status message is the same text the Connections page
92
+ // stopped shipping, and the state and the link are the whole answer.
88
93
  return privateJson({
89
94
  state: result.state,
90
- ...(result.message ? { message: result.message } : {}),
91
95
  ...(authorizationUrl ? { authorizationUrl } : {}),
92
96
  });
93
- }
94
- finally {
95
- await closeConnectorScope(connector, ctx, defer);
96
- }
97
+ }).pipe(Effect.scoped);
97
98
  }
98
99
  export async function routeOAuthManagement(context) {
99
100
  const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
@@ -105,5 +106,6 @@ export async function routeOAuthManagement(context) {
105
106
  if (context.request.method === "OPTIONS") {
106
107
  return privateJson({ error: "method not allowed" }, { status: 405 });
107
108
  }
108
- return handleOAuthManagementRequest(context, connectorId);
109
+ // No signal: a disconnect that has started runs through to its invalidation.
110
+ return serveOperator(oauthManagementRequest(context, connectorId));
109
111
  }