@zackbart/connecta 0.25.0 → 0.26.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 (100) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +387 -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/artifacts/connector.d.ts +55 -0
  9. package/dist/artifacts/connector.js +724 -0
  10. package/dist/artifacts/document.js +80 -0
  11. package/dist/artifacts/guide.js +140 -0
  12. package/dist/artifacts/html-scan.js +329 -0
  13. package/dist/artifacts/json.js +68 -0
  14. package/dist/artifacts/kv-store.d.ts +20 -0
  15. package/dist/artifacts/kv-store.js +140 -0
  16. package/dist/artifacts/markdown.js +478 -0
  17. package/dist/artifacts/module.d.ts +37 -0
  18. package/dist/artifacts/module.js +60 -0
  19. package/dist/artifacts/operations.d.ts +195 -0
  20. package/dist/artifacts/operations.js +944 -0
  21. package/dist/artifacts/refresh.d.ts +53 -0
  22. package/dist/artifacts/refresh.js +235 -0
  23. package/dist/artifacts/routes.js +217 -0
  24. package/dist/artifacts/types.d.ts +214 -0
  25. package/dist/artifacts/types.js +22 -0
  26. package/dist/artifacts/validate.d.ts +62 -0
  27. package/dist/artifacts/validate.js +653 -0
  28. package/dist/artifacts.d.ts +6 -0
  29. package/dist/artifacts.js +9 -0
  30. package/dist/auth/downstream-oauth.js +397 -48
  31. package/dist/auth/oauth-start-reset.js +20 -0
  32. package/dist/branding.d.ts +87 -0
  33. package/dist/branding.js +170 -1
  34. package/dist/catalog-service.js +39 -29
  35. package/dist/catalog.js +42 -8
  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/connectors/remote-mcp.js +48 -9
  41. package/dist/errors.d.ts +16 -1
  42. package/dist/errors.js +28 -0
  43. package/dist/execute.js +431 -75
  44. package/dist/executor-admission.d.ts +11 -1
  45. package/dist/executor-admission.js +79 -14
  46. package/dist/executors/quickjs.js +4 -2
  47. package/dist/index.d.ts +62 -6
  48. package/dist/index.js +230 -6
  49. package/dist/invocation.js +56 -10
  50. package/dist/meta-tools.js +7 -2
  51. package/dist/module-contracts.d.ts +21 -1
  52. package/dist/oauth-callback-outcome.js +43 -18
  53. package/dist/operator-ui/generated.js +2 -2
  54. package/dist/operator-ui/model.d.ts +62 -5
  55. package/dist/operator-ui/view.js +372 -28
  56. package/dist/page-styles.js +7 -0
  57. package/dist/program-source.js +116 -0
  58. package/dist/registry.d.ts +42 -6
  59. package/dist/registry.js +164 -60
  60. package/dist/resumable.js +1291 -0
  61. package/dist/routes/activity.js +6 -69
  62. package/dist/routes/actor-labels.js +114 -0
  63. package/dist/routes/credentials.js +27 -5
  64. package/dist/routes/mcp.js +120 -59
  65. package/dist/routes/oauth-management.js +87 -20
  66. package/dist/routes/oauth.js +54 -87
  67. package/dist/routes/operator.js +1 -0
  68. package/dist/routes/shared.d.ts +23 -1
  69. package/dist/routes/shared.js +20 -2
  70. package/dist/routes/ui-data.js +2 -1
  71. package/dist/routes/ui.js +19 -4
  72. package/dist/run-journal.js +347 -0
  73. package/dist/runtime/shared-read.js +106 -0
  74. package/dist/server.js +30 -2
  75. package/dist/skills.js +30 -11
  76. package/dist/timeout.js +26 -0
  77. package/dist/tool-safety.d.ts +35 -0
  78. package/dist/tool-safety.js +31 -0
  79. package/dist/types.d.ts +23 -0
  80. package/dist/ui.d.ts +17 -5
  81. package/dist/ui.js +51 -68
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/documentation/architecture.md +214 -27
  85. package/documentation/auth.md +186 -2
  86. package/documentation/code-mode.md +236 -63
  87. package/documentation/meta-tools.md +55 -29
  88. package/ethos.md +12 -10
  89. package/examples/worker/AGENTS.md +6 -0
  90. package/examples/worker/README.md +174 -10
  91. package/examples/worker/src/d1-activity-row.ts +14 -2
  92. package/examples/worker/src/d1-activity.ts +4 -2
  93. package/examples/worker/src/index.ts +36 -1
  94. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  95. package/examples/worker/wrangler.jsonc +5 -1
  96. package/package.json +6 -2
  97. package/templates/node/AGENTS.md +9 -2
  98. package/templates/node/README.md +31 -3
  99. package/templates/node/package.json +1 -1
  100. package/templates/node/src/index.ts +27 -0
@@ -1,71 +1,8 @@
1
1
  import { Duration, Effect } from "effect";
2
2
  import { InvalidActivityCursorError, } from "../activity.js";
3
+ import { ACTOR_LABEL_BUDGET_MS, ACTOR_LABEL_CONCURRENCY, actorKey, cleanActorLabel, labelLookups, } from "./actor-labels.js";
3
4
  import { authorized, refuse, serveOperator } from "./operator.js";
4
- import { activityActorNamespace, privateJson, } from "./shared.js";
5
- const ACTIVITY_LABEL_CONCURRENCY = 8;
6
- const ACTIVITY_LABEL_PAGE_BUDGET_MS = 1_500;
7
- const ACTIVITY_LABEL_MAX_LENGTH = 160;
8
- function cleanActivityActorLabel(value) {
9
- if (typeof value !== "string")
10
- return undefined;
11
- const compact = value.replace(/\s+/gu, " ").trim();
12
- if (!compact)
13
- return undefined;
14
- return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
15
- }
16
- function actorKey(actor) {
17
- return JSON.stringify([actor.kind, actor.namespace, actor.id]);
18
- }
19
- /**
20
- * One lookup per distinct actor on the page, each against the single provider
21
- * that owns the actor's directory. An actor no provider can claim unambiguously
22
- * gets no lookup at all.
23
- */
24
- function labelLookups(page, auth) {
25
- const identities = new Map();
26
- for (const event of page.events) {
27
- if (!event.actor.id)
28
- continue;
29
- identities.set(actorKey(event.actor), {
30
- kind: event.actor.kind,
31
- id: event.actor.id,
32
- ...(event.actor.namespace ? { namespace: event.actor.namespace } : {}),
33
- });
34
- }
35
- const lookups = [];
36
- for (const [key, identity] of identities) {
37
- const sameKindProviders = auth
38
- .map((provider, index) => ({ provider, index }))
39
- .filter(({ provider }) => provider.kind === identity.kind);
40
- const candidates = sameKindProviders.filter(({ provider }) => Boolean(provider.activityActorLabel));
41
- const eligible = identity.namespace
42
- ? candidates.filter(({ provider }) => activityActorNamespace(provider) === identity.namespace)
43
- : (() => {
44
- const directoryKey = ({ provider, index, }) => {
45
- const namespace = activityActorNamespace(provider);
46
- return namespace === undefined
47
- ? `provider:${index}`
48
- : `namespace:${namespace}`;
49
- };
50
- // Every same-kind provider participates in the ambiguity check,
51
- // even if it cannot resolve labels. Otherwise a legacy ID owned
52
- // by a provider without a resolver could be disclosed to a
53
- // different provider that happens to have one.
54
- const directories = new Set(sameKindProviders.map(directoryKey));
55
- if (directories.size !== 1)
56
- return [];
57
- const [directory] = directories;
58
- return candidates.filter((candidate) => directoryKey(candidate) === directory);
59
- })();
60
- // One namespace is one directory. Use its first configured resolver so
61
- // duplicate gate adapters over the same Clerk instance do not multiply
62
- // the provider-level concurrency cap.
63
- const provider = eligible[0]?.provider;
64
- if (provider)
65
- lookups.push({ key, id: identity.id, provider });
66
- }
67
- return lookups;
68
- }
5
+ import { privateJson } from "./shared.js";
69
6
  /**
70
7
  * Add display-only actor labels to one authorized activity page. Resolution is
71
8
  * best-effort, bounded, and read-time only: stored events retain stable ids and
@@ -78,14 +15,14 @@ function labelLookups(page, auth) {
78
15
  function enrichActivityActorLabels(page, auth) {
79
16
  return Effect.suspend(() => {
80
17
  const labels = new Map();
81
- const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActivityActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
18
+ const resolve = ({ key, id, provider }) => Effect.tryPromise(() => Promise.resolve(provider.activityActorLabel(id))).pipe(Effect.map(cleanActorLabel), Effect.orElseSucceed(() => undefined), Effect.map((label) => {
82
19
  if (label)
83
20
  labels.set(key, label);
84
21
  }));
85
- return Effect.forEach(labelLookups(page, auth), resolve, {
86
- concurrency: ACTIVITY_LABEL_CONCURRENCY,
22
+ return Effect.forEach(labelLookups(page.events.map((event) => event.actor), auth), resolve, {
23
+ concurrency: ACTOR_LABEL_CONCURRENCY,
87
24
  discard: true,
88
- }).pipe(Effect.timeoutOption(Duration.millis(ACTIVITY_LABEL_PAGE_BUDGET_MS)), Effect.map(() => ({
25
+ }).pipe(Effect.timeoutOption(Duration.millis(ACTOR_LABEL_BUDGET_MS)), Effect.map(() => ({
89
26
  ...page,
90
27
  events: page.events.map((event) => {
91
28
  const resolved = event.actor.id
@@ -0,0 +1,114 @@
1
+ // Display labels for stored actors, shared by the activity page and the
2
+ // artifact library. An actor is stored as a stable id; a label is resolved at
3
+ // read time from the one auth provider that owns the actor's directory, and
4
+ // only on an authorized read path. Nothing here is persisted, and a label a
5
+ // store happens to carry is never trusted or echoed.
6
+ import { activityActorNamespace } from "./shared.js";
7
+ export const ACTOR_LABEL_CONCURRENCY = 8;
8
+ export const ACTOR_LABEL_BUDGET_MS = 1_500;
9
+ const ACTOR_LABEL_MAX_LENGTH = 160;
10
+ export function cleanActorLabel(value) {
11
+ if (typeof value !== "string")
12
+ return undefined;
13
+ const compact = value.replace(/\s+/gu, " ").trim();
14
+ if (!compact)
15
+ return undefined;
16
+ return Array.from(compact).slice(0, ACTOR_LABEL_MAX_LENGTH).join("");
17
+ }
18
+ export function actorKey(actor) {
19
+ return JSON.stringify([actor.kind, actor.namespace, actor.id]);
20
+ }
21
+ /**
22
+ * One lookup per distinct actor, each against the single provider that owns
23
+ * the actor's directory. An actor no provider can claim unambiguously gets no
24
+ * lookup at all.
25
+ */
26
+ export function labelLookups(actors, auth) {
27
+ const identities = new Map();
28
+ for (const actor of actors) {
29
+ if (!actor.id)
30
+ continue;
31
+ identities.set(actorKey(actor), {
32
+ kind: actor.kind,
33
+ id: actor.id,
34
+ ...(actor.namespace ? { namespace: actor.namespace } : {}),
35
+ });
36
+ }
37
+ const lookups = [];
38
+ for (const [key, identity] of identities) {
39
+ const sameKindProviders = auth
40
+ .map((provider, index) => ({ provider, index }))
41
+ .filter(({ provider }) => provider.kind === identity.kind);
42
+ const candidates = sameKindProviders.filter(({ provider }) => Boolean(provider.activityActorLabel));
43
+ const eligible = identity.namespace
44
+ ? candidates.filter(({ provider }) => activityActorNamespace(provider) === identity.namespace)
45
+ : (() => {
46
+ const directoryKey = ({ provider, index, }) => {
47
+ const namespace = activityActorNamespace(provider);
48
+ return namespace === undefined
49
+ ? `provider:${index}`
50
+ : `namespace:${namespace}`;
51
+ };
52
+ // Every same-kind provider participates in the ambiguity check,
53
+ // even if it cannot resolve labels. Otherwise a legacy ID owned
54
+ // by a provider without a resolver could be disclosed to a
55
+ // different provider that happens to have one.
56
+ const directories = new Set(sameKindProviders.map(directoryKey));
57
+ if (directories.size !== 1)
58
+ return [];
59
+ const [directory] = directories;
60
+ return candidates.filter((candidate) => directoryKey(candidate) === directory);
61
+ })();
62
+ // One namespace is one directory. Use its first configured resolver so
63
+ // duplicate gate adapters over the same Clerk instance do not multiply
64
+ // the provider-level concurrency cap.
65
+ const provider = eligible[0]?.provider;
66
+ if (provider)
67
+ lookups.push({ key, id: identity.id, provider });
68
+ }
69
+ return lookups;
70
+ }
71
+ /**
72
+ * Resolve labels for `actors`, best-effort: at most eight lookups at once,
73
+ * 1.5 seconds in all, and whatever has resolved by then is the answer. A
74
+ * provider outage yields no labels, never a failed read.
75
+ */
76
+ export async function resolveActorLabels(actors, auth) {
77
+ const labels = new Map();
78
+ const queue = labelLookups(actors, auth);
79
+ let cursor = 0;
80
+ let expired = false;
81
+ const worker = async () => {
82
+ while (!expired && cursor < queue.length) {
83
+ const lookup = queue[cursor++];
84
+ if (!lookup)
85
+ return;
86
+ try {
87
+ const label = cleanActorLabel(await Promise.resolve(lookup.provider.activityActorLabel?.(lookup.id)));
88
+ if (label && !expired)
89
+ labels.set(lookup.key, label);
90
+ }
91
+ catch {
92
+ // A failed lookup leaves the actor unlabeled.
93
+ }
94
+ }
95
+ };
96
+ let timer;
97
+ const budget = new Promise((resolve) => {
98
+ timer = setTimeout(() => {
99
+ expired = true;
100
+ resolve();
101
+ }, ACTOR_LABEL_BUDGET_MS);
102
+ });
103
+ try {
104
+ await Promise.race([
105
+ Promise.all(Array.from({ length: Math.min(ACTOR_LABEL_CONCURRENCY, queue.length) }, worker)),
106
+ budget,
107
+ ]);
108
+ }
109
+ finally {
110
+ if (timer !== undefined)
111
+ clearTimeout(timer);
112
+ }
113
+ return new Map(labels);
114
+ }
@@ -131,31 +131,53 @@ function credentialRequest(context, connectorId, action) {
131
131
  : "this connector does not support credential testing", 400);
132
132
  }
133
133
  // Whatever goes wrong past this point is the test's result, not the
134
- // route's: the page shows it beside the Test button.
134
+ // route's: the page shows it beside the Test button. Only `ok` leaves
135
+ // the host. A hook's message, and anything it throws, can quote the
136
+ // downstream's reply, and that reply can quote the credential it just
137
+ // rejected — so the text goes to the deployment's log and the page
138
+ // says a fixed sentence for the outcome instead.
139
+ const logged = (ok, detail) => {
140
+ if (!detail)
141
+ return;
142
+ const line = `[connecta] connector "${connectorId}" credential test ${ok ? "passed" : "failed"}: ${detail}`;
143
+ if (ok)
144
+ opts.logger.info(line);
145
+ else
146
+ opts.logger.warn(line);
147
+ };
135
148
  return yield* Effect.tryPromise({
136
149
  try: async () => {
137
150
  const values = await vault.getAll(connectorId, owner);
138
151
  const shape = storedCredentialShape(credential, values);
152
+ // Both refusals are the route's own words, and each carries the
153
+ // problem kind the page keys its copy and fix prompt off.
139
154
  if (shape.state === "missing") {
140
155
  return privateJson({
141
156
  error: mode === "multiple"
142
157
  ? "configure the credentials before testing them"
143
158
  : "configure the credential before testing it",
159
+ problem: "credential_required",
144
160
  }, { status: 409 });
145
161
  }
146
162
  if (shape.state === "mismatch") {
147
- return privateJson({ error: shape.message }, { status: 409 });
163
+ return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
148
164
  }
149
165
  const storedValues = values;
150
166
  const ctx = registry.contextFor(connectorId, baseUrl);
151
- return privateJson(mode === "multiple"
167
+ const result = mode === "multiple"
152
168
  ? await connector.testCredentials(storedValues, ctx)
153
169
  : await connector.testCredential(
154
170
  // The single-value shape check above guarantees this key.
155
- storedValues.value, ctx));
171
+ storedValues.value, ctx);
172
+ const ok = result?.ok === true;
173
+ logged(ok, result?.message);
174
+ return privateJson({ ok });
156
175
  },
157
176
  catch: (error) => error,
158
- }).pipe(Effect.catch((error) => Effect.succeed(privateJson({ ok: false, message: msg(error) }))));
177
+ }).pipe(Effect.catch((error) => {
178
+ logged(false, msg(error));
179
+ return Effect.succeed(privateJson({ ok: false }));
180
+ }));
159
181
  }
160
182
  if (action)
161
183
  return yield* refuse("not found", 404);
@@ -1,8 +1,10 @@
1
1
  import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
2
- import { Effect, Exit, Result, Scope } from "effect";
2
+ import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
3
+ import { notFoundResponse } from "../branding.js";
3
4
  import { registerExecuteTool } from "../execute.js";
4
5
  import { ExecutorAdmissionError, } from "../executor-admission.js";
5
6
  import { registerMetaTools } from "../meta-tools.js";
7
+ import { registerResumeTool } from "../resumable.js";
6
8
  import { intersectAccess } from "../connector-access.js";
7
9
  import { instructionsFor } from "../skills.js";
8
10
  import { msg } from "../errors.js";
@@ -101,9 +103,9 @@ function closeWithBody(response, signal, close) {
101
103
  const reader = response.body.getReader();
102
104
  onAbort = () => {
103
105
  // `cancel()` belongs to an operator/auth/SDK-provided stream and may
104
- // reject. Consume both outcomes: `.finally(release)` would release the
105
- // permit but preserve the rejection as an unhandled promise.
106
- void reader.cancel(signal.reason).then(release, release);
106
+ // reject or never settle. Release now; consume either outcome separately.
107
+ release();
108
+ void reader.cancel(signal.reason).catch(() => { });
107
109
  };
108
110
  signal.addEventListener("abort", onAbort, { once: true });
109
111
  if (signal.aborted)
@@ -126,12 +128,8 @@ function closeWithBody(response, signal, close) {
126
128
  }
127
129
  },
128
130
  async cancel(reason) {
129
- try {
130
- await reader.cancel(reason);
131
- }
132
- finally {
133
- release();
134
- }
131
+ release();
132
+ await reader.cancel(reason);
135
133
  },
136
134
  });
137
135
  return new Response(body, {
@@ -220,14 +218,19 @@ function toolkitRetired(logger) {
220
218
  },
221
219
  });
222
220
  }
223
- function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
221
+ function serveMcp(request, requestSignal, opts, baseUrl, actor, registry, canManageAuth, poolName, runtimeContext) {
222
+ // A paused run belongs to the endpoint it started on: resuming it through
223
+ // another pool's endpoint finds nothing, as another subject would.
224
+ const resumable = opts.resumable
225
+ ? { ...opts.resumable, pool: poolName ?? null, approval: opts.approval }
226
+ : undefined;
224
227
  // Every McpServer the request builds is fresh and closes with its scope.
225
228
  // The modern handler tears its own down after the exchange; the legacy
226
229
  // transport never does, and neither may stay wired to an ended request.
227
230
  const servers = [];
228
231
  const createServer = () => {
229
232
  const server = new McpServer(opts.serverInfo, {
230
- instructions: instructionsFor(),
233
+ instructions: instructionsFor(resumable !== undefined),
231
234
  cacheHints: {
232
235
  "tools/list": {
233
236
  ttlMs: 3_600_000,
@@ -265,17 +268,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
265
268
  ...(opts.discoveryConcurrency !== undefined
266
269
  ? { discoveryConcurrency: opts.discoveryConcurrency }
267
270
  : {}),
268
- requestSignal: request.signal,
271
+ requestSignal,
269
272
  ...(runtimeContext?.waitUntil
270
273
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
271
274
  : {}),
275
+ approval: opts.approval,
272
276
  });
273
- registerExecuteTool(server, registry, {
277
+ const runner = registerExecuteTool(server, registry, {
274
278
  baseUrl,
275
279
  executor: opts.executor,
276
280
  logger: opts.logger,
277
281
  ...(activity ? { activity } : {}),
278
- requestSignal: request.signal,
282
+ requestSignal,
279
283
  ...(runtimeContext?.waitUntil
280
284
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
281
285
  : {}),
@@ -300,6 +304,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
300
304
  ...(opts.watchdogMs !== undefined
301
305
  ? { watchdogMs: opts.watchdogMs }
302
306
  : {}),
307
+ resumable,
308
+ approval: opts.approval,
309
+ maxWrites: opts.maxWrites,
310
+ });
311
+ // Always registered, so the surface is the same eight tools on every
312
+ // deployment; without resumable writes it answers that there is nothing
313
+ // to resume.
314
+ registerResumeTool(server, registry, {
315
+ runner,
316
+ settings: resumable,
317
+ ...(activity ? { activity } : {}),
318
+ requestSignal,
303
319
  });
304
320
  servers.push(server);
305
321
  return server;
@@ -405,7 +421,23 @@ export function createMcpRoute(opts) {
405
421
  if (request.method === "OPTIONS") {
406
422
  return Effect.succeed(cors(new Response(null, { status: 204 })));
407
423
  }
424
+ // This controller belongs to this request. Its signal reaches auth and
425
+ // every registered tool; a controller sweep never touches it.
426
+ const localAbort = new AbortController();
427
+ const onCallerAbort = () => localAbort.abort(request.signal.reason);
428
+ request.signal.addEventListener("abort", onCallerAbort, { once: true });
429
+ if (request.signal.aborted)
430
+ onCallerAbort();
431
+ let deadlineTimer;
408
432
  return scopedToBody(Effect.gen(function* () {
433
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
434
+ if (!localAbort.signal.aborted) {
435
+ localAbort.abort(request.signal.reason ?? new Error("MCP request ended."));
436
+ }
437
+ request.signal.removeEventListener("abort", onCallerAbort);
438
+ if (deadlineTimer !== undefined)
439
+ clearTimeout(deadlineTimer);
440
+ }));
409
441
  const admission = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
410
442
  if (Result.isFailure(admission)) {
411
443
  const error = admission.failure;
@@ -420,6 +452,17 @@ export function createMcpRoute(opts) {
420
452
  }
421
453
  return cors(requestAdmissionFailure(error));
422
454
  }
455
+ const remainingMs = admission.success.remainingMs();
456
+ if (remainingMs !== undefined && remainingMs <= 0) {
457
+ admission.success.release();
458
+ return cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
459
+ }
460
+ if (remainingMs !== undefined) {
461
+ deadlineTimer = setTimeout(() => {
462
+ localAbort.abort(new Error("MCP request lifetime exceeded."));
463
+ }, remainingMs);
464
+ }
465
+ const localRequest = new Request(request, { signal: localAbort.signal });
423
466
  if (admission.success.waitMs > 0) {
424
467
  opts.logger.debug("[connecta] MCP request admitted after queue wait", {
425
468
  waitMs: admission.success.waitMs,
@@ -431,52 +474,70 @@ export function createMcpRoute(opts) {
431
474
  // Effect `authorize` would put Effect in the /activity bundle, which
432
475
  // shares it, to save only the lookups an abandoned authorization
433
476
  // finishes on its own.
434
- const authz = yield* Effect.promise(() => authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity));
435
- if (!authz.ok)
436
- return cors(authz.response);
437
- // A pool endpoint narrows the identity's own view and nothing else. An
438
- // undeclared name, a grant that refuses, and a grant that throws are
439
- // one identical 404 so a credential never enumerates the other pools;
440
- // the operator log is where the reason lives.
441
- let access = authz;
442
- if (poolName !== undefined) {
443
- const pool = opts.pools?.get(poolName);
444
- const reason = !pool ? "undeclared" : yield* Effect.promise(async () => {
445
- try {
446
- return (await pool.grant(authz.identity)) === true ? "granted" : "refused";
447
- }
448
- catch {
449
- return "grant threw";
477
+ const handled = Effect.gen(function* () {
478
+ const authz = yield* Effect.promise(() => authorize(localRequest, baseUrl, opts.auth, runtimeContext, opts.identity));
479
+ if (!authz.ok)
480
+ return cors(authz.response);
481
+ // A pool endpoint narrows the identity's own view and nothing else. An
482
+ // undeclared name, a grant that refuses, and a grant that throws are
483
+ // one identical 404 so a credential never enumerates the other pools;
484
+ // the operator log is where the reason lives.
485
+ let access = authz;
486
+ if (poolName !== undefined) {
487
+ const pool = opts.pools?.get(poolName);
488
+ const reason = !pool ? "undeclared" : yield* Effect.promise(async () => {
489
+ try {
490
+ return (await pool.grant(authz.identity)) === true ? "granted" : "refused";
491
+ }
492
+ catch {
493
+ return "grant threw";
494
+ }
495
+ });
496
+ if (!pool || reason !== "granted") {
497
+ opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
498
+ (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
499
+ // The server's own 404, so a browser sees the page every unserved
500
+ // path shows and an MCP client (which never asks for text/html)
501
+ // still reads a plain "Not Found".
502
+ return cors(notFoundResponse(request, opts));
450
503
  }
451
- });
452
- if (!pool || reason !== "granted") {
453
- opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
454
- (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
455
- return cors(new Response("Not Found", { status: 404 }));
504
+ access = intersectAccess(authz, pool.access);
456
505
  }
457
- access = intersectAccess(authz, pool.access);
458
- }
459
- let scopedRegistry;
460
- try {
461
- validateAuthPermissions(authz, opts.registry);
462
- scopedRegistry = opts.registry.scoped({
463
- connectorIds: access.connectorIds,
464
- ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
465
- ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
466
- ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
467
- });
468
- }
469
- catch (error) {
470
- return cors(new Response(JSON.stringify({ error: msg(error) }), {
471
- status: 403,
472
- headers: { "Content-Type": "application/json" },
473
- }));
474
- }
475
- if (new URL(request.url).searchParams.has("toolkit")) {
476
- return cors(toolkitRetired(opts.logger));
477
- }
478
- return cors(yield* serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext));
479
- }), request.signal);
506
+ let scopedRegistry;
507
+ try {
508
+ validateAuthPermissions(authz, opts.registry);
509
+ scopedRegistry = opts.registry.scoped({
510
+ connectorIds: access.connectorIds,
511
+ ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
512
+ ...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
513
+ ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
514
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
515
+ caller: {
516
+ identity: authz.identity,
517
+ ...(poolName !== undefined ? { pool: poolName } : {}),
518
+ },
519
+ });
520
+ }
521
+ catch (error) {
522
+ return cors(new Response(JSON.stringify({ error: msg(error) }), {
523
+ status: 403,
524
+ headers: { "Content-Type": "application/json" },
525
+ }));
526
+ }
527
+ if (new URL(request.url).searchParams.has("toolkit")) {
528
+ return cors(toolkitRetired(opts.logger));
529
+ }
530
+ 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));
531
+ });
532
+ if (remainingMs === undefined)
533
+ return yield* handled;
534
+ const bounded = yield* handled.pipe(Effect.timeoutOption(Duration.millis(remainingMs)));
535
+ if (Option.isNone(bounded))
536
+ localAbort.abort(new Error("MCP request lifetime exceeded."));
537
+ return Option.isSome(bounded)
538
+ ? bounded.value
539
+ : cors(new Response("MCP request lifetime exceeded.", { status: 504 }));
540
+ }), localAbort.signal);
480
541
  }
481
542
  return { handle: routeMcp, rejectOrigin };
482
543
  }