@zackbart/connecta 0.40.0 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -1,4 +1,5 @@
1
- import { routeNames } from "../agent-routes.js";
1
+ import { startTimingTrace } from "../timing-trace.js";
2
+ import { META_ROUTES, routeNames } from "../agent-routes.js";
2
3
  import { upwardStep } from "../upward.js";
3
4
  import { bindDiscoveryEndpoint } from "../discovery-health.js";
4
5
  import { authCall, authInvoke, authFlow, authMethod } from "../auth/native.js";
@@ -8,15 +9,15 @@ import { observeAgentAdmission } from "../agent-observation.js";
8
9
  import { bindActivityRequest } from "../activity-request.js";
9
10
  import { closeScope } from "../runtime/connector-scope.js";
10
11
  import { executeLimits } from "../config.js";
11
- import { oauthConnectUrl, oauthConnectLink, oauthConnectLinkProgress, closeOAuthConnectLinks, claimAuthRetry, oauthConnectUnavailable, } from "../oauth-handoff.js";
12
+ import { oauthConnectUrl, oauthConnectLink, oauthConnectLinkStarted, claimAuthRetry, oauthConnectUnavailable, } from "../oauth-handoff.js";
12
13
  import { AuthElicitation } from "../auth-elicitation.js";
13
14
  import { classifyInboundRequest, createMcpHandler, isJSONRPCErrorResponse, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
14
15
  import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
15
16
  import { registerExecuteTool } from "../execute.js";
16
17
  import { ExecutorAdmissionError } from "../executor-admission.js";
17
18
  import { registerMetaTools } from "../meta-tools.js";
18
- import { endpointInstructions } from "../skills.js";
19
- import { NATIVE_TOOLS_LIST_TTL_MS, NativeSurface, nativeRequestHint } from "../native-tools.js";
19
+ import { ENDPOINT_EXTENSION, endpointExtension, endpointInstructions } from "../skills.js";
20
+ import { NATIVE_DEGRADED_TOOLS_LIST_TTL_MS, NATIVE_TOOLS_LIST_TTL_MS, NativeSurface, nativeRequestHint, } from "../native-tools.js";
20
21
  import { registerSkills } from "./skills.js";
21
22
  import { failureRecord, logFailure } from "../operator-record.js";
22
23
  import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
@@ -128,7 +129,7 @@ function withMcpCors(response, request, allowedOrigin) {
128
129
  }
129
130
  headers.append("Vary", "Access-Control-Request-Headers");
130
131
  }
131
- headers.set("Access-Control-Expose-Headers", "WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version, Connecta-Error-Code, Connecta-Recovery");
132
+ headers.set("Access-Control-Expose-Headers", "WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version, Connecta-Error-Code, Connecta-Recovery, Connecta-Next-Action");
132
133
  const wrapped = new Response(response.body, {
133
134
  status: response.status,
134
135
  statusText: response.statusText,
@@ -321,7 +322,7 @@ function toolkitRetired(logger, sentSecrets) {
321
322
  },
322
323
  });
323
324
  }
324
- function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor) {
325
+ function serveMcp(request, requestSignal, requestStartedAt, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor, agentEndpoint = false) {
325
326
  const sentSecrets = sentSecretsForRequest(requestScope);
326
327
  // Every McpServer the request builds is fresh and closes with its scope.
327
328
  // The modern handler tears its own down after the exchange; the legacy
@@ -329,7 +330,14 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
329
330
  const servers = [];
330
331
  // A native pool lists its downstream tools directly (pools.<name>.surface).
331
332
  const native = pool !== undefined ? opts.pools.get(pool)?.native : undefined;
333
+ // Agent endpoints keep six tools until Eve is tested against the seventh. The
334
+ // eval arms that render other route names measure their own fixed surfaces.
335
+ const readRoute = opts.config.execute.readRoute && !native && !readOnly && !agentEndpoint && opts.routes === META_ROUTES;
332
336
  const createServer = authMethod((hint) => authFlow(function* () {
337
+ // A native pool's text uses the native contract's wording.
338
+ const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
339
+ // Direct-call and handoff text names the program route read follow-ups should take.
340
+ const directRoutes = readRoute ? routeNames("read-route") : routes;
333
341
  const authElicitation = new AuthElicitation({
334
342
  vault: opts.config.vault,
335
343
  publicUrl: opts.config.publicUrl,
@@ -337,40 +345,49 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
337
345
  principal: principalKey,
338
346
  registry,
339
347
  canManage: canManageAuth,
340
- connectLink: authMethod((id, force, after) => authCall(oauthConnectLink, [
348
+ connectLink: authMethod((id, force, flow) => authCall(oauthConnectLink, [
341
349
  opts,
342
350
  opts.config.publicUrl ?? baseUrl,
343
351
  id,
344
352
  principalKey,
345
353
  force,
346
354
  Boolean(opts.config.vault?.seal && opts.config.vault.open),
347
- after,
355
+ flow,
348
356
  ])),
349
- linkProgress: authMethod((id, nonce) => authCall(oauthConnectLinkProgress, [opts, baseUrl, id, nonce])),
350
- closeLinks: authMethod((id, nonces) => authCall(closeOAuthConnectLinks, [opts, baseUrl, id, nonces])),
357
+ linkStarted: authMethod((id, nonce) => authCall(oauthConnectLinkStarted, [opts, baseUrl, id, nonce])),
351
358
  claimRetry: authMethod((id, nonce) => authCall(claimAuthRetry, [opts, baseUrl, id, nonce])),
352
359
  unavailable: oauthConnectUnavailable(opts),
353
360
  credentialUi: Boolean(opts.config.ui && opts.config.vault),
361
+ credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
362
+ routes: directRoutes,
354
363
  requestSignal,
355
364
  requestScope,
356
365
  defer: runtimeContext?.waitUntil?.bind(runtimeContext),
357
366
  });
367
+ // Read as this request's response is built, so a degraded native listing can shorten it.
368
+ const toolsListHint = { ttlMs: native ? NATIVE_TOOLS_LIST_TTL_MS : 3_600_000, cacheScope: "private" };
369
+ const connectors = registry.listConnectors();
370
+ const effectiveReadOnly = readOnly || (connectors.length > 0 && connectors.every((connector) => connector.readOnly === true));
358
371
  const server = new McpServer(opts.config.serverInfo, {
359
372
  // A request-local server cannot publish catalog changes. Set this before
360
373
  // tool registration, whose SDK default otherwise advertises listChanged.
361
- capabilities: { tools: { listChanged: false }, extensions: { "io.modelcontextprotocol/skills": {} } },
362
- // Instructions differ per endpoint only by the admitted pool's
363
- // description. Every cache hint below is private, so a client keeps
364
- // what it learned per endpoint URL and never shares it across them.
365
- instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined, native),
374
+ capabilities: {
375
+ tools: { listChanged: false },
376
+ extensions: {
377
+ "io.modelcontextprotocol/skills": {},
378
+ [ENDPOINT_EXTENSION]: endpointExtension(connectors, native !== undefined, native?.executeCode ?? true, effectiveReadOnly),
379
+ },
380
+ },
381
+ // Instructions reflect the admitted pool, its connector roster and the effective write
382
+ // policy. `registry` is this caller's admitted view, the one execute_code's inventory
383
+ // reads, so a person at plain /mcp never sees an id outside their pool. Every cache hint
384
+ // below is private, so a client keeps what it learned for this caller and endpoint URL.
385
+ instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined, native, effectiveReadOnly, connectors.map((connector) => connector.id), readRoute),
366
386
  inputRequired: { legacyShim: false },
367
387
  requestState: { verify: (state, context) => authElicitation.verify(state, context) },
368
388
  cacheHints: {
369
389
  "server/discover": { ttlMs: 3_600_000, cacheScope: "private" },
370
- "tools/list": {
371
- ttlMs: native ? NATIVE_TOOLS_LIST_TTL_MS : 3_600_000,
372
- cacheScope: "private",
373
- },
390
+ "tools/list": toolsListHint,
374
391
  },
375
392
  });
376
393
  servers.push(server);
@@ -378,6 +395,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
378
395
  ? {
379
396
  sink: opts.config.activity?.store,
380
397
  recordTool: opts.config.activity?.recordTool,
398
+ recordOperation: opts.config.activity?.recordOperation,
381
399
  actor,
382
400
  ...(principalActor ? { principalActor } : {}),
383
401
  requestId: crypto.randomUUID(),
@@ -397,12 +415,15 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
397
415
  probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
398
416
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
399
417
  });
400
- const diagnostics = native ? { ...native, catalogErrors: [] } : undefined;
401
- // A native pool's text uses the native contract's wording.
402
- const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
418
+ const diagnostics = native
419
+ ? { ...native, catalogErrors: [], staleCatalogs: [] }
420
+ : undefined;
421
+ // The program tool this request lists and its reads may offer: the read route, where listed.
422
+ const programTool = !native || native.executeCode ? directRoutes.program : undefined;
403
423
  const nativeRoute = registerMetaTools(server, registry, {
404
424
  native: diagnostics,
405
- routes,
425
+ programTool,
426
+ routes: directRoutes,
406
427
  authElicitation,
407
428
  client,
408
429
  baseUrl,
@@ -414,19 +435,23 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
414
435
  credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
415
436
  ...(activity ? { activity } : {}),
416
437
  defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
438
+ responseCeilingMs: opts.config.execute.watchdogMs,
439
+ requestStartedAt,
417
440
  maxProgramResultChars: opts.config.execute.maxResultChars,
441
+ maxProgramHostCalls: opts.config.execute.maxHostCalls,
418
442
  probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
419
443
  discoveryConcurrency: opts.config.discovery.concurrency,
420
444
  requestSignal,
421
445
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
422
446
  });
423
- if (!native || native.executeCode) {
447
+ if (programTool) {
424
448
  registerExecuteTool(server, registry, {
425
449
  authElicitation,
426
450
  client,
427
451
  baseUrl,
428
452
  requestScope,
429
453
  executor: opts.executor,
454
+ requestStartedAt,
430
455
  defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
431
456
  logger: opts.config.logger,
432
457
  ...(activity ? { activity } : {}),
@@ -436,6 +461,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
436
461
  readOnly,
437
462
  native: native !== undefined,
438
463
  routes,
464
+ readRoute,
439
465
  });
440
466
  }
441
467
  if (native && nativeRoute && hint) {
@@ -445,6 +471,10 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
445
471
  route: nativeRoute,
446
472
  bind: (context) => authElicitation.bind(context),
447
473
  catalogErrors: diagnostics.catalogErrors,
474
+ staleCatalogs: diagnostics.staleCatalogs,
475
+ onDegradedListing: () => {
476
+ toolsListHint.ttlMs = NATIVE_DEGRADED_TOOLS_LIST_TTL_MS;
477
+ },
448
478
  logger: opts.config.logger,
449
479
  requestScope,
450
480
  requestSignal,
@@ -635,16 +665,22 @@ export function createMcpRoute(opts) {
635
665
  request.signal.addEventListener("abort", onCallerAbort, { once: true });
636
666
  if (request.signal.aborted)
637
667
  onCallerAbort();
668
+ const finishTiming = startTimingTrace(requestScope, opts.config.timingTraceSampleRate, opts.config.logger);
638
669
  let deadlineTimer;
639
670
  return scopedToBody(Effect.gen(function* () {
640
671
  yield* Effect.addFinalizer(() => Effect.sync(() => {
641
- requestOwner(requestScope).close();
642
- if (!localAbort.signal.aborted) {
643
- localAbort.abort(request.signal.reason ?? new Error("MCP request ended."));
672
+ try {
673
+ requestOwner(requestScope).close();
674
+ }
675
+ finally {
676
+ finishTiming?.();
677
+ if (!localAbort.signal.aborted) {
678
+ localAbort.abort(request.signal.reason ?? new Error("MCP request ended."));
679
+ }
680
+ request.signal.removeEventListener("abort", onCallerAbort);
681
+ if (deadlineTimer !== undefined)
682
+ clearTimeout(deadlineTimer);
644
683
  }
645
- request.signal.removeEventListener("abort", onCallerAbort);
646
- if (deadlineTimer !== undefined)
647
- clearTimeout(deadlineTimer);
648
684
  }));
649
685
  // A request naming `subscriptions/listen` defers its permit: nothing of
650
686
  // its body is read until auth and endpoint admission pass, and then at
@@ -652,8 +688,12 @@ export function createMcpRoute(opts) {
652
688
  // modern listen goes without a permit; trusting the header alone would
653
689
  // let a legacy tools/call bypass the pool, so anything else takes one
654
690
  // after classification. Every other request takes its permit here,
655
- // before auth, and no body is read before auth on any path.
691
+ // before auth, and this route reads no body before auth on any path.
692
+ // The Node adapter has already buffered up to its own pre-auth cap
693
+ // (`listen`'s `maxBodyBytes`) before a request reaches core.
656
694
  const startedAt = Date.now();
695
+ // The response ceiling counts from here; a future stamp could only extend it.
696
+ const arrivedAt = Math.min(context.runtimeContext?.requestArrivedAt ?? startedAt, startedAt);
657
697
  const maxDurationMs = opts.requestAdmission.maxDurationMs;
658
698
  const listenHeader = request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen";
659
699
  const refuseAdmission = (error) => {
@@ -766,8 +806,12 @@ export function createMcpRoute(opts) {
766
806
  const headers = new Headers(response.headers);
767
807
  headers.set("Connecta-Error-Code", "host_auth_required");
768
808
  headers.set("Connecta-Recovery", "host_connection");
769
- // Custom adapters may own a streaming or non-JSON response. Preserve
770
- // that body and its request lifetime, and carry recovery in headers.
809
+ headers.set("Connecta-Next-Action", JSON.stringify({
810
+ action: "reconnect_host",
811
+ purpose: "Reconnect the host with valid inbound credentials and endpoint grants; downstream OAuth cannot repair this connection.",
812
+ }));
813
+ // Custom adapters own their response stream and request lifetime.
814
+ // Their typed recovery is carried in headers instead of replacing it.
771
815
  if (!response.headers.get("Content-Type")?.includes("application/json")) {
772
816
  return cors(new Response(response.body, { status: response.status, headers }));
773
817
  }
@@ -780,6 +824,10 @@ export function createMcpRoute(opts) {
780
824
  message: "The host is not authorized to connect to this endpoint. Sign in or update the host's connecta access token and endpoint grants.",
781
825
  retryable: false,
782
826
  recovery: "host_connection",
827
+ nextAction: {
828
+ action: "reconnect_host",
829
+ purpose: "Reconnect this host to Connecta with valid inbound credentials and endpoint grants. Downstream connector authorization cannot repair the host connection.",
830
+ },
783
831
  },
784
832
  })), { status: response.status, headers }));
785
833
  }
@@ -923,7 +971,7 @@ export function createMcpRoute(opts) {
923
971
  if (new URL(request.url).searchParams.has("toolkit")) {
924
972
  return cors(toolkitRetired(opts.config.logger, sentSecrets));
925
973
  }
926
- return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
974
+ return cors(yield* serveMcp(localRequest, localAbort.signal, arrivedAt, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
927
975
  const connector = scopedRegistry.getConnector(id);
928
976
  return Boolean(connector && mayManageConnector(opts, authz, connector));
929
977
  }, authz.principalKey, runtimeContext, readOnly, scope.pool, authz.identity.principal
@@ -932,7 +980,7 @@ export function createMcpRoute(opts) {
932
980
  id: authz.identity.principal.id,
933
981
  namespace: authz.identity.principal.namespace,
934
982
  }
935
- : undefined));
983
+ : undefined, agentEndpoint));
936
984
  });
937
985
  if (remainingMs === undefined)
938
986
  return yield* handled;
@@ -34,6 +34,12 @@ export interface ServerOptions {
34
34
  }
35
35
  export interface RuntimeExecutionContext extends InboundAuthRuntimeContext {
36
36
  waitUntil(promise: Promise<unknown>): void;
37
+ /**
38
+ * When the Node adapter received this request's headers, before it buffered
39
+ * the body. It comes from the host, never from anything the request carries.
40
+ * Workers read the body lazily, so the route's own clock already counts upload.
41
+ */
42
+ readonly requestArrivedAt?: number;
37
43
  }
38
44
  export interface RouteContext {
39
45
  request: Request;
@@ -171,7 +171,7 @@ export function scanOperatorActivity(context, authz, registry, rows, agents) {
171
171
  continue;
172
172
  // A last call is a call; any row recorded for an agent, a catalog
173
173
  // refresh included, is a sign it was seen.
174
- const row = event.kind !== "catalog_drift" && OUTCOMES.has(event.outcome) ? byId.get(event.connectorId) : undefined;
174
+ const row = event.kind === undefined && OUTCOMES.has(event.outcome) ? byId.get(event.connectorId) : undefined;
175
175
  const agent = (event.actor.kind === "access_token" || event.actor.kind === "vercel_oidc") &&
176
176
  event.actor.namespace === AGENT_NAMESPACE &&
177
177
  event.actor.id
@@ -249,6 +249,7 @@ export function uiData(registry, baseUrl, options) {
249
249
  toolCount,
250
250
  observedAt: Date.now(),
251
251
  }));
252
+ const observedAgeMs = observation ? Math.max(0, Date.now() - observation.observedAt) : undefined;
252
253
  return {
253
254
  id: c.id,
254
255
  authScope: c.authScope ?? "shared",
@@ -271,9 +272,9 @@ export function uiData(registry, baseUrl, options) {
271
272
  ...(status.registrationPath ? { auth: { registrationPath: status.registrationPath } } : {}),
272
273
  catalogAgeMs: registry.catalogAgeMs(c.id),
273
274
  lastCall: null,
274
- ...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
275
+ ...(observedAgeMs !== undefined ? { observedAgeMs } : {}),
275
276
  },
276
- ...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
277
+ ...(observedAgeMs !== undefined ? { observedAgeMs } : {}),
277
278
  // Counts only, and only when a refresh in this runtime produced them.
278
279
  // `Registry.withCatalogObservations` rebuilt the report through
279
280
  ...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
@@ -359,17 +359,22 @@ export function wrapViewCredentials(view) {
359
359
  return;
360
360
  entry.credentialWrapped = true;
361
361
  const access = view.credential;
362
+ // A read outlives a caller that stopped waiting and registers when it lands, so the
363
+ // request counts it until then: a sealing run must not publish ahead of that.
364
+ const read = async (value, values) => {
365
+ const done = requestOf(entry.request).credentialReads.begin();
366
+ try {
367
+ const result = await value();
368
+ registerCredentialValues(viewSecrets(view), values(result));
369
+ return result;
370
+ }
371
+ finally {
372
+ done();
373
+ }
374
+ };
362
375
  view.credential = {
363
- get: (field) => settleOnClose(view, async () => {
364
- const value = await access.get(field);
365
- registerCredentialValues(viewSecrets(view), value === null ? null : { [field ?? "value"]: value });
366
- return value;
367
- }, true),
368
- getAll: () => settleOnClose(view, async () => {
369
- const values = await access.getAll();
370
- registerCredentialValues(viewSecrets(view), values);
371
- return values;
372
- }, true),
376
+ get: (field) => settleOnClose(view, () => read(() => access.get(field), (value) => (value === null ? null : { [field ?? "value"]: value })), true),
377
+ getAll: () => settleOnClose(view, () => read(() => access.getAll(), (values) => values), true),
373
378
  };
374
379
  }
375
380
  /** Credentials for request-owned transports use the operation, never a closed call. */
@@ -25,6 +25,11 @@ export function registerNativeStorage(edge, native) {
25
25
  storageMethods.set(edge, own);
26
26
  const getMany = native.getMany;
27
27
  storage.set(edge, {
28
+ catalogSnapshot: native.catalogSnapshot
29
+ ? (keys, generationKey, prefix, suffixes) => typed(() => getNativeStorage(edge)
30
+ ? native.catalogSnapshot(keys, generationKey, prefix, suffixes)
31
+ : Effect.succeed(new Map()))
32
+ : undefined,
28
33
  accessToken: native.accessToken
29
34
  ? (hash) => typed(() => (getNativeStorage(edge) ? native.accessToken(hash) : Effect.succeed(undefined)))
30
35
  : undefined,
@@ -37,6 +42,13 @@ export function registerNativeStorage(edge, native) {
37
42
  : Effect.forEach(keys, (key) => current(edge, "get", own.get, [key], () => native.get(key)))),
38
43
  }
39
44
  : {}),
45
+ ...(native.setMany
46
+ ? {
47
+ setMany: (entries, options) => typed(() => getNativeStorage(edge)
48
+ ? native.setMany(entries, options)
49
+ : Effect.asVoid(Effect.forEach(entries, ([key, value]) => current(edge, "set", own.set, [key, value, options], () => native.set(key, value, options))))),
50
+ }
51
+ : {}),
40
52
  set: (key, value, options) => current(edge, "set", own.set, [key, value, options], () => native.set(key, value, options)),
41
53
  delete: (key) => current(edge, "delete", own.delete, [key], () => native.delete(key)),
42
54
  list: (prefix) => current(edge, "list", own.list, [prefix], () => native.list(prefix)),
@@ -1,3 +1,4 @@
1
+ import { utf8Length } from "../bytes.js";
1
2
  // Request-scoped memo of storage reads (INV-7).
2
3
  //
3
4
  // One request often reads the same key many times: a catalog listing reads its
@@ -36,6 +37,7 @@
36
37
  // `FinalizationRegistry` (workerd before compatibility date 2025-05-05 without
37
38
  // `enable_weak_ref`) memoizes nothing: every read goes to storage.
38
39
  import { Effect } from "effect";
40
+ import { getNativeStorage } from "./native-storage.js";
39
41
  const roots = new WeakMap();
40
42
  const observed = new WeakMap();
41
43
  /**
@@ -88,7 +90,7 @@ export function observeStorageWrites(storage, layer) {
88
90
  return storage;
89
91
  }
90
92
  /** The underlying store and key a read of `key` reaches, when every layer is observed. */
91
- function physical(storage, key) {
93
+ export function physicalStorageAddress(storage, key) {
92
94
  const entry = observed.get(storage);
93
95
  if (!entry)
94
96
  return undefined;
@@ -99,10 +101,17 @@ function physical(storage, key) {
99
101
  own.list !== storage.list ||
100
102
  own.compareAndSet !== storage.compareAndSet)
101
103
  return undefined;
102
- return "root" in layer ? { store: layer.root, key } : physical(layer.storage, layer.prefix + key);
104
+ return "root" in layer ? { store: layer.root, key } : physicalStorageAddress(layer.storage, layer.prefix + key);
103
105
  }
104
106
  const weakReferences = () => typeof WeakRef === "function" && typeof FinalizationRegistry === "function";
105
107
  export class RequestReads {
108
+ trace;
109
+ constructor(trace) {
110
+ this.trace = trace;
111
+ }
112
+ note(fresh, hit, value, started) {
113
+ this.trace?.record(fresh ? "memo.fresh" : hit ? "memo.hit" : "memo.miss", performance.now() - started, value === null ? 0 : utf8Length(value), 0, value === null ? 1 : 0);
114
+ }
106
115
  closed = false;
107
116
  stores = new Map();
108
117
  ref = weakReferences() ? new WeakRef(this) : undefined;
@@ -129,7 +138,7 @@ export class RequestReads {
129
138
  }
130
139
  /** A memoized answer, or how to record the value a storage read returns. */
131
140
  lookup(storage, key, fresh) {
132
- const address = this.closed || !this.ref ? undefined : physical(storage, key);
141
+ const address = this.closed || !this.ref ? undefined : physicalStorageAddress(storage, key);
133
142
  if (!address)
134
143
  return undefined;
135
144
  const entries = this.entriesFor(address.store);
@@ -155,31 +164,70 @@ export class RequestReads {
155
164
  read(storage, key, load, fresh = false) {
156
165
  return Effect.suspend(() => {
157
166
  const found = this.lookup(storage, key, fresh);
158
- if (!found)
167
+ if (!found && !this.trace)
159
168
  return load;
160
- if (found.hit)
169
+ const started = this.trace ? performance.now() : 0;
170
+ if (found?.hit) {
171
+ if (this.trace)
172
+ this.note(fresh, true, found.value, started);
161
173
  return Effect.succeed(found.value);
162
- return Effect.tap(load, (value) => Effect.sync(() => found.record(value)));
174
+ }
175
+ return Effect.tap(load, (value) => Effect.sync(() => {
176
+ found?.record(value);
177
+ if (this.trace)
178
+ this.note(fresh, false, value, started);
179
+ }));
163
180
  });
164
181
  }
165
182
  /** Prefetch only keys whose callers already permit request-local staleness. */
166
- prefetch(requests, load) {
183
+ prefetch(requests, load, linked) {
167
184
  return Effect.suspend(() => {
168
185
  const pending = requests.map(({ storage, key }) => ({
169
- address: physical(storage, key),
186
+ address: physicalStorageAddress(storage, key),
170
187
  found: this.lookup(storage, key, false),
171
188
  }));
189
+ if (this.trace)
190
+ for (const { found } of pending)
191
+ if (found?.hit)
192
+ this.note(false, true, found.value, performance.now());
193
+ const started = this.trace ? performance.now() : 0;
172
194
  const root = pending[0]?.address?.store;
173
195
  const storage = root && roots.get(root);
174
196
  if (!storage || pending.some(({ address, found }) => !found || address?.store !== root))
175
197
  return Effect.void;
198
+ if (pending.every(({ found }) => found?.hit))
199
+ return Effect.void;
200
+ const generation = linked && physicalStorageAddress(linked.storage, linked.generationKey);
201
+ const prefix = linked && physicalStorageAddress(linked.storage, linked.prefix);
202
+ const snapshot = getNativeStorage(storage)?.catalogSnapshot;
203
+ if (linked && snapshot && generation?.store === root && prefix?.store === root) {
204
+ // Candidates only locate entries. An intervening external write can make
205
+ // these stale, so consumers retain the separate post-chunk primary fence.
206
+ return Effect.map(snapshot(pending.map(({ address }) => address.key), generation.key, prefix.key, linked.suffixes), (values) => {
207
+ pending.forEach(({ address, found }) => {
208
+ if (found && !found.hit)
209
+ found.record(values.get(address.key) ?? null);
210
+ });
211
+ for (const [key, value] of values) {
212
+ if (pending.some(({ address }) => address.key === key))
213
+ continue;
214
+ const found = this.lookup(storage, key, false);
215
+ if (found && !found.hit)
216
+ found.record(value);
217
+ }
218
+ });
219
+ }
176
220
  const misses = pending.filter(({ found }) => found && !found.hit);
177
221
  if (!misses.length)
178
222
  return Effect.void;
179
223
  return Effect.map(load(storage, misses.map(({ address }) => address.key)), (values) => {
180
224
  misses.forEach(({ found }, i) => {
181
- if (found && !found.hit)
182
- found.record(values[i] ?? null);
225
+ if (found && !found.hit) {
226
+ const value = values[i] ?? null;
227
+ found.record(value);
228
+ if (this.trace)
229
+ this.note(false, false, value, started);
230
+ }
183
231
  });
184
232
  });
185
233
  });
@@ -187,12 +235,16 @@ export class RequestReads {
187
235
  /** `read` for a Promise-shaped caller, without another Effect edge. */
188
236
  async readPromise(storage, key, load, fresh = false) {
189
237
  const found = this.lookup(storage, key, fresh);
190
- if (!found)
191
- return load();
192
- if (found.hit)
238
+ const started = this.trace ? performance.now() : 0;
239
+ if (found?.hit) {
240
+ if (this.trace)
241
+ this.note(fresh, true, found.value, started);
193
242
  return found.value;
243
+ }
194
244
  const value = await load();
195
- found.record(value);
245
+ found?.record(value);
246
+ if (this.trace)
247
+ this.note(fresh, false, value, started);
196
248
  return value;
197
249
  }
198
250
  close() {
@@ -1,8 +1,42 @@
1
+ import { traceFor } from "../timing-trace.js";
1
2
  // One owner per existing upstream request identity. Facts survive revocation;
2
3
  // retaining them never reconstitutes a call capability or a live operation.
3
- import { Context, Exit, Scope } from "effect";
4
+ import { Context, Effect, Exit, Scope } from "effect";
4
5
  import { RequestReads } from "./request-reads.js";
5
6
  import { detach } from "./run.js";
7
+ /** Work that can still add to a secret set. A sealing run waits until none is left. */
8
+ export class PendingWork {
9
+ active = 0;
10
+ waiting = new Set();
11
+ get pending() {
12
+ return this.active > 0;
13
+ }
14
+ /** Returns the function that ends this piece of work; calling it twice counts once. */
15
+ begin() {
16
+ this.active++;
17
+ let done = false;
18
+ return () => {
19
+ if (done)
20
+ return;
21
+ done = true;
22
+ if (--this.active > 0)
23
+ return;
24
+ for (const wake of this.waiting)
25
+ wake();
26
+ this.waiting.clear();
27
+ };
28
+ }
29
+ /** Succeeds when nothing is pending. Interrupting it drops the wait. */
30
+ idle = Effect.callback((resume) => {
31
+ if (this.active === 0)
32
+ return resume(Effect.void);
33
+ const wake = () => resume(Effect.void);
34
+ this.waiting.add(wake);
35
+ return Effect.sync(() => {
36
+ this.waiting.delete(wake);
37
+ });
38
+ });
39
+ }
6
40
  export class RequestOwner {
7
41
  identity;
8
42
  open = true;
@@ -13,6 +47,8 @@ export class RequestOwner {
13
47
  calls = new Set();
14
48
  signals = new WeakMap();
15
49
  scope = Scope.makeUnsafe();
50
+ /** Credential reads whose values have yet to join this request's secret set. */
51
+ credentialReads = new PendingWork();
16
52
  // Initialize secrets only when data is sent. Metadata-only UI imports must
17
53
  // not retain the payload matcher merely to revoke a request.
18
54
  retained = {
@@ -71,7 +107,7 @@ export function requestReads(identity) {
71
107
  export function ownerReads(owner) {
72
108
  const retained = owner.retained;
73
109
  if (!retained.reads) {
74
- retained.reads = new RequestReads();
110
+ retained.reads = new RequestReads(traceFor(owner.identity));
75
111
  if (!owner.open)
76
112
  retained.reads.close();
77
113
  }
@@ -128,7 +128,10 @@ export function withDeadlineEffect(operation, options) {
128
128
  fromSignal(controller.signal),
129
129
  ];
130
130
  if (options.timeoutMs !== undefined) {
131
- contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
131
+ contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.suspend(() => {
132
+ const more = options.extendMs?.() ?? 0;
133
+ return more > 0 ? Effect.sleep(Duration.millis(more)) : Effect.void;
134
+ })), Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
132
135
  }
133
136
  return Effect.raceAllFirst(contenders).pipe(Effect.ensuring(Effect.sync(() => {
134
137
  controller.abort();