@zackbart/connecta 0.25.0 → 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 (93) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +271 -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 +76 -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 +449 -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 +194 -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 +652 -0
  28. package/dist/artifacts.d.ts +6 -0
  29. package/dist/artifacts.js +9 -0
  30. package/dist/catalog-service.js +39 -29
  31. package/dist/catalog.js +42 -8
  32. package/dist/connector-access.d.ts +18 -4
  33. package/dist/connector-access.js +65 -20
  34. package/dist/connector-caller.d.ts +8 -0
  35. package/dist/connector-caller.js +19 -0
  36. package/dist/connectors/remote-mcp.js +16 -2
  37. package/dist/errors.d.ts +16 -1
  38. package/dist/errors.js +28 -0
  39. package/dist/execute.js +427 -70
  40. package/dist/executor-admission.d.ts +11 -1
  41. package/dist/executor-admission.js +79 -14
  42. package/dist/executors/quickjs.js +4 -2
  43. package/dist/index.d.ts +62 -6
  44. package/dist/index.js +230 -6
  45. package/dist/invocation.js +56 -10
  46. package/dist/meta-tools.js +7 -2
  47. package/dist/module-contracts.d.ts +21 -1
  48. package/dist/operator-ui/generated.js +2 -2
  49. package/dist/operator-ui/model.d.ts +62 -5
  50. package/dist/operator-ui/view.js +94 -5
  51. package/dist/program-source.js +116 -0
  52. package/dist/registry.d.ts +42 -6
  53. package/dist/registry.js +148 -47
  54. package/dist/resumable.js +1291 -0
  55. package/dist/routes/activity.js +6 -69
  56. package/dist/routes/actor-labels.js +114 -0
  57. package/dist/routes/credentials.js +27 -5
  58. package/dist/routes/mcp.js +116 -59
  59. package/dist/routes/oauth-management.js +22 -9
  60. package/dist/routes/operator.js +1 -0
  61. package/dist/routes/shared.d.ts +23 -1
  62. package/dist/routes/shared.js +20 -2
  63. package/dist/routes/ui-data.js +2 -1
  64. package/dist/routes/ui.js +13 -4
  65. package/dist/run-journal.js +347 -0
  66. package/dist/runtime/shared-read.js +106 -0
  67. package/dist/server.js +26 -0
  68. package/dist/skills.js +27 -8
  69. package/dist/timeout.js +26 -0
  70. package/dist/tool-safety.d.ts +35 -0
  71. package/dist/tool-safety.js +31 -0
  72. package/dist/types.d.ts +18 -0
  73. package/dist/ui.d.ts +12 -5
  74. package/dist/ui.js +32 -6
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/documentation/architecture.md +196 -19
  78. package/documentation/auth.md +83 -2
  79. package/documentation/code-mode.md +221 -48
  80. package/documentation/meta-tools.md +44 -25
  81. package/ethos.md +12 -10
  82. package/examples/worker/AGENTS.md +6 -0
  83. package/examples/worker/README.md +174 -10
  84. package/examples/worker/src/d1-activity-row.ts +14 -2
  85. package/examples/worker/src/d1-activity.ts +4 -2
  86. package/examples/worker/src/index.ts +36 -1
  87. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  88. package/examples/worker/wrangler.jsonc +5 -1
  89. package/package.json +6 -2
  90. package/templates/node/AGENTS.md +9 -2
  91. package/templates/node/README.md +31 -3
  92. package/templates/node/package.json +1 -1
  93. 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,9 @@
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
3
  import { registerExecuteTool } from "../execute.js";
4
4
  import { ExecutorAdmissionError, } from "../executor-admission.js";
5
5
  import { registerMetaTools } from "../meta-tools.js";
6
+ import { registerResumeTool } from "../resumable.js";
6
7
  import { intersectAccess } from "../connector-access.js";
7
8
  import { instructionsFor } from "../skills.js";
8
9
  import { msg } from "../errors.js";
@@ -101,9 +102,9 @@ function closeWithBody(response, signal, close) {
101
102
  const reader = response.body.getReader();
102
103
  onAbort = () => {
103
104
  // `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);
105
+ // reject or never settle. Release now; consume either outcome separately.
106
+ release();
107
+ void reader.cancel(signal.reason).catch(() => { });
107
108
  };
108
109
  signal.addEventListener("abort", onAbort, { once: true });
109
110
  if (signal.aborted)
@@ -126,12 +127,8 @@ function closeWithBody(response, signal, close) {
126
127
  }
127
128
  },
128
129
  async cancel(reason) {
129
- try {
130
- await reader.cancel(reason);
131
- }
132
- finally {
133
- release();
134
- }
130
+ release();
131
+ await reader.cancel(reason);
135
132
  },
136
133
  });
137
134
  return new Response(body, {
@@ -220,14 +217,19 @@ function toolkitRetired(logger) {
220
217
  },
221
218
  });
222
219
  }
223
- 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;
224
226
  // Every McpServer the request builds is fresh and closes with its scope.
225
227
  // The modern handler tears its own down after the exchange; the legacy
226
228
  // transport never does, and neither may stay wired to an ended request.
227
229
  const servers = [];
228
230
  const createServer = () => {
229
231
  const server = new McpServer(opts.serverInfo, {
230
- instructions: instructionsFor(),
232
+ instructions: instructionsFor(resumable !== undefined),
231
233
  cacheHints: {
232
234
  "tools/list": {
233
235
  ttlMs: 3_600_000,
@@ -265,17 +267,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
265
267
  ...(opts.discoveryConcurrency !== undefined
266
268
  ? { discoveryConcurrency: opts.discoveryConcurrency }
267
269
  : {}),
268
- requestSignal: request.signal,
270
+ requestSignal,
269
271
  ...(runtimeContext?.waitUntil
270
272
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
271
273
  : {}),
274
+ approval: opts.approval,
272
275
  });
273
- registerExecuteTool(server, registry, {
276
+ const runner = registerExecuteTool(server, registry, {
274
277
  baseUrl,
275
278
  executor: opts.executor,
276
279
  logger: opts.logger,
277
280
  ...(activity ? { activity } : {}),
278
- requestSignal: request.signal,
281
+ requestSignal,
279
282
  ...(runtimeContext?.waitUntil
280
283
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
281
284
  : {}),
@@ -300,6 +303,18 @@ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtim
300
303
  ...(opts.watchdogMs !== undefined
301
304
  ? { watchdogMs: opts.watchdogMs }
302
305
  : {}),
306
+ resumable,
307
+ approval: opts.approval,
308
+ maxWrites: opts.maxWrites,
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,
303
318
  });
304
319
  servers.push(server);
305
320
  return server;
@@ -405,7 +420,23 @@ export function createMcpRoute(opts) {
405
420
  if (request.method === "OPTIONS") {
406
421
  return Effect.succeed(cors(new Response(null, { status: 204 })));
407
422
  }
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;
408
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
+ }));
409
440
  const admission = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
410
441
  if (Result.isFailure(admission)) {
411
442
  const error = admission.failure;
@@ -420,6 +451,17 @@ export function createMcpRoute(opts) {
420
451
  }
421
452
  return cors(requestAdmissionFailure(error));
422
453
  }
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 }));
458
+ }
459
+ if (remainingMs !== undefined) {
460
+ deadlineTimer = setTimeout(() => {
461
+ localAbort.abort(new Error("MCP request lifetime exceeded."));
462
+ }, remainingMs);
463
+ }
464
+ const localRequest = new Request(request, { signal: localAbort.signal });
423
465
  if (admission.success.waitMs > 0) {
424
466
  opts.logger.debug("[connecta] MCP request admitted after queue wait", {
425
467
  waitMs: admission.success.waitMs,
@@ -431,52 +473,67 @@ export function createMcpRoute(opts) {
431
473
  // Effect `authorize` would put Effect in the /activity bundle, which
432
474
  // shares it, to save only the lookups an abandoned authorization
433
475
  // 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";
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 }));
450
499
  }
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 }));
500
+ access = intersectAccess(authz, pool.access);
456
501
  }
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);
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);
480
537
  }
481
538
  return { handle: routeMcp, rejectOrigin };
482
539
  }
@@ -4,7 +4,7 @@ import { isSafeHttpUrl } from "../ui.js";
4
4
  import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
5
5
  import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
6
6
  function oauthManagementRequest(context, connectorId) {
7
- const { request, baseUrl, defer } = context;
7
+ const { request, baseUrl, defer, opts } = context;
8
8
  return Effect.gen(function* () {
9
9
  if (!isSameOrigin(request, baseUrl)) {
10
10
  return yield* refuse("same-origin request required", 403);
@@ -50,12 +50,25 @@ function oauthManagementRequest(context, connectorId) {
50
50
  try: () => registry.invalidateStored(connectorId),
51
51
  catch: (error) => error,
52
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";
53
64
  // A Result, not a caught value, decides whether it failed: a hook that
54
65
  // rejects with no reason at all still failed.
55
- if (Result.isFailure(operation))
56
- return yield* refuse(msg(operation.failure), 400);
57
- if (Result.isFailure(invalidated))
58
- return yield* refuse(msg(invalidated.failure), 400);
66
+ if (Result.isFailure(operation)) {
67
+ return yield* failed(msg(operation.failure), couldNot, 400);
68
+ }
69
+ if (Result.isFailure(invalidated)) {
70
+ return yield* failed(`stored-state invalidation: ${msg(invalidated.failure)}`, couldNot, 400);
71
+ }
59
72
  const result = operation.success;
60
73
  if (!result) {
61
74
  return new Response(null, {
@@ -70,15 +83,15 @@ function oauthManagementRequest(context, connectorId) {
70
83
  ? result.authorizationUrl
71
84
  : undefined;
72
85
  if (result.state === "error") {
73
- return yield* refuse(result.message || "OAuth authorization could not start", 502);
86
+ return yield* failed(result.message || "the connector reported an error state", couldNot, 502);
74
87
  }
75
88
  if (result.state === "auth_required" && !authorizationUrl) {
76
- return yield* refuse(result.message ||
77
- "OAuth authorization requires consent but no safe URL is available", 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);
78
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.
79
93
  return privateJson({
80
94
  state: result.state,
81
- ...(result.message ? { message: result.message } : {}),
82
95
  ...(authorizationUrl ? { authorizationUrl } : {}),
83
96
  });
84
97
  }).pipe(Effect.scoped);
@@ -51,6 +51,7 @@ export function scopeFor(authz, connectorIds = authz.connectorIds) {
51
51
  return {
52
52
  connectorIds,
53
53
  ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
54
+ ...(authz.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
54
55
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
55
56
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
56
57
  };