@zackbart/connecta 0.41.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 (148) hide show
  1. package/CHANGELOG.md +61 -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 +2 -1
  7. package/dist/activity.js +5 -5
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-routes.js +31 -5
  10. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  11. package/dist/auth/downstream-oauth.js +3 -0
  12. package/dist/auth/static-oauth.js +26 -18
  13. package/dist/auth-elicitation.js +118 -61
  14. package/dist/authorization-handoff.js +36 -3
  15. package/dist/catalog-cache.js +292 -24
  16. package/dist/catalog-freshness.js +12 -0
  17. package/dist/catalog-service.js +373 -70
  18. package/dist/catalog.js +147 -58
  19. package/dist/config-defaults.d.ts +2 -1
  20. package/dist/config-defaults.js +4 -1
  21. package/dist/config.d.ts +43 -17
  22. package/dist/config.js +26 -10
  23. package/dist/connectors/remote-mcp/catalog.js +52 -28
  24. package/dist/connectors/remote-mcp/connection.js +62 -5
  25. package/dist/connectors/remote-mcp.js +10 -1
  26. package/dist/describe-config.d.ts +3 -2
  27. package/dist/discovery-health.js +26 -11
  28. package/dist/errors.d.ts +52 -3
  29. package/dist/errors.js +86 -3
  30. package/dist/execute.js +476 -200
  31. package/dist/executor-admission.js +2 -0
  32. package/dist/executor-contract.js +6 -0
  33. package/dist/executor-result.js +2 -2
  34. package/dist/executors/quickjs-child.js +13 -4
  35. package/dist/executors/quickjs-runtime.d.ts +2 -0
  36. package/dist/executors/quickjs-runtime.js +36 -19
  37. package/dist/executors/quickjs.js +23 -6
  38. package/dist/guest-functions.js +5 -4
  39. package/dist/guest-runtime.js +37 -1
  40. package/dist/guest-types.d.ts +18 -0
  41. package/dist/http/client.js +7 -2
  42. package/dist/http/errors.js +9 -4
  43. package/dist/http/fields.js +17 -5
  44. package/dist/http/hatch.js +9 -3
  45. package/dist/http/paging.js +1 -1
  46. package/dist/http/provider.js +2 -6
  47. package/dist/http/vendor.js +6 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/invocation.js +98 -34
  51. package/dist/meta-output.js +24 -18
  52. package/dist/meta-tool-names.d.ts +1 -1
  53. package/dist/meta-tool-names.js +1 -1
  54. package/dist/meta-tools.js +259 -60
  55. package/dist/native-recovery.js +4 -1
  56. package/dist/native-tools.js +166 -51
  57. package/dist/node.js +3 -1
  58. package/dist/oauth-handoff.js +53 -43
  59. package/dist/operation-activity.js +2 -1
  60. package/dist/operation-vocabulary.js +6 -0
  61. package/dist/operator-record.d.ts +1 -1
  62. package/dist/operator-record.js +1 -0
  63. package/dist/operator-ui/generated.js +2 -2
  64. package/dist/program-result.js +2 -0
  65. package/dist/program-source.js +1 -1
  66. package/dist/program-writes.js +112 -19
  67. package/dist/provider.d.ts +0 -13
  68. package/dist/provider.js +0 -3
  69. package/dist/providers/_shared/google/kit.js +2 -2
  70. package/dist/providers/breeze/api.js +2 -2
  71. package/dist/providers/breeze/skill.generated.js +1 -1
  72. package/dist/providers/ccb/api.js +2 -2
  73. package/dist/providers/ccb/skill.generated.js +1 -1
  74. package/dist/providers/cloudflare/api.js +2 -2
  75. package/dist/providers/cloudflare/index.js +2 -0
  76. package/dist/providers/cloudflare/skill.generated.js +1 -1
  77. package/dist/providers/docs/skill.generated.js +1 -1
  78. package/dist/providers/drive/skill.generated.js +1 -1
  79. package/dist/providers/forms/index.js +1 -0
  80. package/dist/providers/forms/skill.generated.js +1 -1
  81. package/dist/providers/github/api.js +98 -6
  82. package/dist/providers/github/index.js +7 -2
  83. package/dist/providers/github/lib/auth.d.ts +3 -1
  84. package/dist/providers/github/lib/auth.js +43 -12
  85. package/dist/providers/github/lib/discovery.js +11 -1
  86. package/dist/providers/github/lib/operations.js +8 -0
  87. package/dist/providers/github/safety.js +39 -0
  88. package/dist/providers/github/skill.generated.js +1 -1
  89. package/dist/providers/github/tools.js +12 -5
  90. package/dist/providers/gmail/index.js +1 -0
  91. package/dist/providers/gmail/skill.generated.js +1 -1
  92. package/dist/providers/granola/skill.generated.js +1 -1
  93. package/dist/providers/overflow/index.js +2 -0
  94. package/dist/providers/overflow/skill.generated.js +1 -1
  95. package/dist/providers/planning-center/index.js +11 -5
  96. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  97. package/dist/providers/planning-center/skill.generated.js +1 -1
  98. package/dist/providers/quickbooks/index.js +2 -5
  99. package/dist/providers/quickbooks/skill.generated.js +1 -1
  100. package/dist/providers/sheets/index.js +1 -0
  101. package/dist/providers/sheets/skill.generated.js +1 -1
  102. package/dist/providers/slides/index.js +2 -2
  103. package/dist/providers/slides/skill.generated.js +1 -1
  104. package/dist/providers/stripe/skill.generated.js +1 -1
  105. package/dist/providers/tithely/index.js +1 -0
  106. package/dist/providers/tithely/skill.generated.js +1 -1
  107. package/dist/providers/vercel/api.js +2 -2
  108. package/dist/providers/vercel/skill.generated.js +1 -1
  109. package/dist/registry.d.ts +36 -4
  110. package/dist/registry.js +127 -27
  111. package/dist/result-preview.js +210 -0
  112. package/dist/result-seal.js +168 -0
  113. package/dist/route-names.js +13 -4
  114. package/dist/routes/connect.js +21 -13
  115. package/dist/routes/mcp.js +55 -26
  116. package/dist/routes/shared.d.ts +6 -0
  117. package/dist/runtime/call.js +15 -10
  118. package/dist/runtime/request-scope.js +36 -1
  119. package/dist/runtime/run.js +4 -1
  120. package/dist/sent-secrets.d.ts +20 -0
  121. package/dist/sent-secrets.js +49 -1
  122. package/dist/skills.js +100 -33
  123. package/dist/storage/keys.js +9 -6
  124. package/dist/storage/sql-native.js +1 -1
  125. package/dist/tool-safety.d.ts +33 -0
  126. package/dist/tool-safety.js +51 -15
  127. package/dist/types.d.ts +31 -0
  128. package/dist/usage-guide.js +14 -13
  129. package/dist/validate.js +29 -10
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker.js +28 -8
  133. package/documentation/architecture.md +104 -42
  134. package/documentation/auth.md +46 -19
  135. package/documentation/code-mode.md +378 -116
  136. package/documentation/deploying.md +10 -9
  137. package/documentation/hosted-mcp-migration.md +9 -3
  138. package/documentation/integrating.md +27 -5
  139. package/documentation/meta-tools.md +655 -153
  140. package/documentation/operating.md +5 -3
  141. package/documentation/providers.md +18 -6
  142. package/examples/worker/README.md +3 -3
  143. package/guides/how-it-works.md +2 -1
  144. package/guides/safety-and-trust.md +1 -3
  145. package/package.json +1 -1
  146. package/templates/node/AGENTS.md +3 -3
  147. package/templates/node/README.md +1 -1
  148. package/templates/node/package.json +1 -1
@@ -5,7 +5,7 @@ import { SdkHttpError, specTypeSchemas, UnauthorizedError } from "@modelcontextp
5
5
  import { redactCatalog } from "../../sent-secrets.js";
6
6
  import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "../../catalog-limits.js";
7
7
  import { catalogItems, observeCompletedCatalogRefresh, observeCatalogFetch, observeCatalogExpiry, } from "../../catalog-cache.js";
8
- import { ConnectorCallError, msg } from "../../errors.js";
8
+ import { ConnectorCallError, isAccessRefusal, msg } from "../../errors.js";
9
9
  import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
10
10
  import { assertDownstreamOutputSafe } from "../../downstream-input-context.js";
11
11
  import { hasSdkPayload, ownAbortReason, requiresAuthorization } from "./errors.js";
@@ -78,6 +78,10 @@ export function remoteMcpCatalog(rt) {
78
78
  listingContext(options),
79
79
  authMethod(() => authFlow(function* () {
80
80
  const before = toolPages;
81
+ // The one write that follows no cache read, which is what
82
+ // otherwise captures the fence this listing may lift.
83
+ if (options?.cacheMode === "refresh")
84
+ (yield authInvoke(cache, "captureFence", []));
81
85
  let result;
82
86
  try {
83
87
  result = (yield authCall(listTools, [params, options]));
@@ -86,6 +90,9 @@ export function remoteMcpCatalog(rt) {
86
90
  const bounded = rt.atMcpBoundary(cache.currentContext(), error, "tools/list", client.transport, [
87
91
  cache.currentContext().signal,
88
92
  ]);
93
+ // Fenced as this listing was bound, whatever is current by now.
94
+ if (requiresAuthorization(bounded) || isAccessRefusal(bounded))
95
+ (yield authInvoke(cache, "fenceListing", []));
89
96
  const transient = (error instanceof SdkHttpError && [502, 503, 504].includes(error.status)) ||
90
97
  (bounded instanceof ConnectorCallError && bounded.code === "unavailable");
91
98
  const fallback = transient && toolPages === before
@@ -247,32 +254,49 @@ export function remoteMcpCatalog(rt) {
247
254
  }
248
255
  }
249
256
  state.toolDefinitions = new Map(clean.map((tool) => [tool.name, tool]));
250
- return clean.map((t) => ({
251
- name: t.name,
252
- ...(t.title !== undefined ? { title: t.title } : {}),
253
- ...(t.icons !== undefined
254
- ? {
255
- icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
256
- }
257
- : {}),
258
- ...(t.execution !== undefined ? { execution: t.execution } : {}),
259
- ...(t.description !== undefined ? { description: t.description } : {}),
260
- ...(t.inputSchema !== undefined
261
- ? {
262
- inputSchema: t.inputSchema,
263
- }
264
- : {}),
265
- ...(t.outputSchema !== undefined
266
- ? {
267
- outputSchema: t.outputSchema,
268
- }
269
- : {}),
270
- ...(t.annotations !== undefined
271
- ? {
272
- annotations: t.annotations,
273
- }
274
- : {}),
275
- }));
257
+ return toolDefinitions(clean);
258
+ }));
259
+ // Served by discovery only, marked by the caller; the listing above stays
260
+ // the only source a call resolves through.
261
+ const peekCatalogOperation = authMethod((ctx) => authFlow(function* () {
262
+ if (liveListings.has(ctx))
263
+ return undefined;
264
+ const entry = (yield authCall(rt.peekCatalog, [ctx]));
265
+ if (!entry)
266
+ return undefined;
267
+ return {
268
+ tools: toolDefinitions(redactCatalog(ctx, entry.result.tools)),
269
+ fetchedAt: entry.fetchedAt,
270
+ expiresAt: entry.expiresAt,
271
+ };
272
+ }));
273
+ return { installCatalogIntake, listingContexts, liveListings, listToolsOperation, peekCatalogOperation };
274
+ }
275
+ function toolDefinitions(tools) {
276
+ return tools.map((t) => ({
277
+ name: t.name,
278
+ ...(t.title !== undefined ? { title: t.title } : {}),
279
+ ...(t.icons !== undefined
280
+ ? {
281
+ icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
282
+ }
283
+ : {}),
284
+ ...(t.execution !== undefined ? { execution: t.execution } : {}),
285
+ ...(t.description !== undefined ? { description: t.description } : {}),
286
+ ...(t.inputSchema !== undefined
287
+ ? {
288
+ inputSchema: t.inputSchema,
289
+ }
290
+ : {}),
291
+ ...(t.outputSchema !== undefined
292
+ ? {
293
+ outputSchema: t.outputSchema,
294
+ }
295
+ : {}),
296
+ ...(t.annotations !== undefined
297
+ ? {
298
+ annotations: t.annotations,
299
+ }
300
+ : {}),
276
301
  }));
277
- return { installCatalogIntake, listingContexts, liveListings, listToolsOperation };
278
302
  }
@@ -12,8 +12,8 @@ import { Deferred, Effect, Exit, Scope } from "effect";
12
12
  import { KvOAuthProvider, refreshCoordinatorsByPartition } from "../../auth/downstream-oauth.js";
13
13
  import { trackOAuthStartReset } from "../../auth/oauth-start-reset.js";
14
14
  import { sentSecretsFor } from "../../sent-secrets.js";
15
- import { catalogClientOptions, prefetchCatalog, observeCatalogChange } from "../../catalog-cache.js";
16
- import { ConnectorCallError, resendAdvice } from "../../errors.js";
15
+ import { catalogClientOptions, observeCatalogChange, observeCatalogExpiry, observeCatalogFetch, prefetchCatalog, retainedCatalogIdentity, } from "../../catalog-cache.js";
16
+ import { ConnectorCallError, isAccessRefusal, resendAdvice } from "../../errors.js";
17
17
  import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
18
18
  import { CONNECTA_VERSION } from "../../version.js";
19
19
  import { oauthSealerFor } from "../../oauth-sealing.js";
@@ -288,7 +288,7 @@ export function remoteMcpConnection(rt) {
288
288
  * same cache through the SDK, so it skips this read. What was already read
289
289
  * is handed to that connect, so a resolver runs once per listing.
290
290
  */
291
- const cachedCatalog = authMethod((ctx) => authFlow(function* () {
291
+ const readCatalog = authMethod((ctx, use) => authFlow(function* () {
292
292
  const principal = principalPolicy
293
293
  ? requestPrincipalFor(principalPolicy, ctx, (reason) => new rt.CredentialRequiredError(`Connector "${id}" ${reason}.`))
294
294
  : undefined;
@@ -340,7 +340,7 @@ export function remoteMcpConnection(rt) {
340
340
  ]);
341
341
  const prior = (yield authCall(readNegotiation, [ctx, digest]));
342
342
  preread.negotiation = { digest, prior };
343
- if (prior?.kind === "modern")
343
+ if (prior?.kind === "modern" && use === "list")
344
344
  return preread;
345
345
  }
346
346
  const cache = (yield authCall(catalogClientOptions, [
@@ -354,6 +354,10 @@ export function remoteMcpConnection(rt) {
354
354
  identity,
355
355
  true,
356
356
  ]));
357
+ if (use === "peek") {
358
+ const entry = (yield authInvoke(cache, "peekTools", [ctx]));
359
+ return entry ? { ...preread, peek: entry } : preread;
360
+ }
357
361
  const result = (yield authInvoke(cache, "cachedTools", [ctx]));
358
362
  if (result)
359
363
  return { tools: result.tools };
@@ -365,6 +369,34 @@ export function remoteMcpConnection(rt) {
365
369
  }
366
370
  return preread;
367
371
  }));
372
+ // A peek is the first half of the listing that may follow it: that listing
373
+ // takes what the peek read, once, so a discovery that peeks first still reads
374
+ // its stored entry and runs a credential resolver once. Nothing is taken if
375
+ // the catalog was invalidated or the credential changed in between, and the
376
+ // whole hand-off lives and dies with the request scope.
377
+ const peeks = new WeakMap();
378
+ const peekCatalog = authMethod((ctx) => authFlow(function* () {
379
+ // A connector known to need authorization is never listed from a cache.
380
+ if (stateFor(ctx).authRequired)
381
+ return undefined;
382
+ const read = (yield authCall(readCatalog, [ctx, "peek"]));
383
+ peeks.set(ctx.requestScope ?? ctx, { identity: retainedCatalogIdentity(ctx), read });
384
+ return read.peek;
385
+ }));
386
+ const cachedCatalog = authMethod((ctx) => authFlow(function* () {
387
+ const scope = ctx.requestScope ?? ctx;
388
+ const handed = peeks.get(scope);
389
+ peeks.delete(scope);
390
+ if (!handed || handed.identity !== retainedCatalogIdentity(ctx))
391
+ return (yield authCall(readCatalog, [ctx, "list"]));
392
+ const { peek, ...preread } = handed.read;
393
+ if (!peek || peek.expiresAt <= Date.now())
394
+ return preread;
395
+ // The listing's own view keeps the provenance a direct read would record.
396
+ observeCatalogFetch(ctx, peek.fetchedAt);
397
+ observeCatalogExpiry(ctx, "tools/list", typeof peek.result.ttlMs === "number" ? peek.result.ttlMs : peek.expiresAt - peek.fetchedAt, peek.expiresAt);
398
+ return { tools: peek.result.tools };
399
+ }));
368
400
  const ensureConnected = authMethod((ctx, state, preread) => authFlow(function* () {
369
401
  let joined = state.connecting;
370
402
  if (joined)
@@ -554,6 +586,25 @@ export function remoteMcpConnection(rt) {
554
586
  ])));
555
587
  if (!owned())
556
588
  return yield* Effect.fail(rt.scopeEndedError());
589
+ // The generation a refused handshake is about, as it is now. Read from
590
+ // this connect's memo, so a connect that is not refused costs no I/O.
591
+ const handshakeBinding = yield* promised(authMethod(() => authInvoke(cacheOptions, "bindListing", [ctx, { credentialIdentity: undefined }]))).pipe(Effect.catch(() => Effect.succeed(undefined)));
592
+ if (!owned())
593
+ return yield* Effect.fail(rt.scopeEndedError());
594
+ // What the attempt sent, in memory: never the grant as it is by now. A
595
+ // provider that read no tokens in this epoch sent none and has no catalog.
596
+ const fenceHandshake = authMethod(() => authFlow(function* () {
597
+ let credentialIdentity;
598
+ if (provider) {
599
+ if (!provider.observedTokens || provider.observed?.epoch !== genAtStart)
600
+ return;
601
+ credentialIdentity = yield authCall(secretDigest, [ctx, JSON.stringify(provider.observedTokens)]);
602
+ }
603
+ (yield authInvoke(cacheOptions, "fenceTools", [
604
+ ctx,
605
+ { ...handshakeBinding, credentialIdentity },
606
+ ]));
607
+ }));
557
608
  const makeClient = () => {
558
609
  const { intake: _intake, completeCatalogRefresh: _refresh, withListing: _listing, currentContext: _context, cachedTools: _cached, ...clientOptions } = cacheOptions;
559
610
  const client = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
@@ -621,7 +672,12 @@ export function remoteMcpConnection(rt) {
621
672
  }
622
673
  }))).pipe(Effect.mapError((err) => rt.atMcpBoundary(ctx,
623
674
  // First, while the runtime's text can still be read for it.
624
- rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])));
675
+ rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])),
676
+ // Before the lease closes the signal this write runs under. Waiters see
677
+ // the failure after it, and it never replaces the refusal.
678
+ Effect.tapError((error) => handshakeBinding && (requiresAuthorization(error) || isAccessRefusal(error))
679
+ ? promised(fenceHandshake).pipe(Effect.catch(() => Effect.void))
680
+ : Effect.void));
625
681
  held.client = c;
626
682
  // A probe deadline can end its scope while connect is still in flight.
627
683
  // The transport is closed immediately by closeScope; if connect wins
@@ -709,6 +765,7 @@ export function remoteMcpConnection(rt) {
709
765
  ensureConnected,
710
766
  requestOptions,
711
767
  cachedCatalog,
768
+ peekCatalog,
712
769
  cacheOwner,
713
770
  reset,
714
771
  states,
@@ -155,7 +155,7 @@ export function remoteMcp(id, opts) {
155
155
  Object.assign(rt, remoteMcpCatalog(rt));
156
156
  Object.assign(rt, remoteMcpResources(rt));
157
157
  Object.assign(rt, remoteMcpCall(rt));
158
- const { listSkills, readSkill, liveListings, listToolsOperation, callToolOperation, readResourceOperation, entryFor, cacheOwner, reset, stateFor, newProvider, resourceTemplateRefusals, ensureConnected, granted, connectedProviders, CredentialRequiredError, OperatorDisconnectedError, UnresolvedTokenError, oauthSurface, activeContexts, states, isClosed, scopeEndedError, operatorDisconnectedError, authRequiredError, renewBeforeCall, } = rt;
158
+ const { listSkills, readSkill, liveListings, listToolsOperation, peekCatalogOperation, callToolOperation, readResourceOperation, entryFor, cacheOwner, reset, stateFor, newProvider, resourceTemplateRefusals, ensureConnected, granted, connectedProviders, CredentialRequiredError, OperatorDisconnectedError, UnresolvedTokenError, oauthSurface, activeContexts, states, isClosed, scopeEndedError, operatorDisconnectedError, authRequiredError, renewBeforeCall, } = rt;
159
159
  // Built once from construction-time values: names, framing, and public
160
160
  // URLs only. A static header's value and a stored credential never reach
161
161
  // it, and a URL keeps origin and path: a CIMD document's query may carry a
@@ -261,6 +261,7 @@ export function remoteMcp(id, opts) {
261
261
  }
262
262
  : {}),
263
263
  listTools: listToolsOperation,
264
+ peekCatalog: peekCatalogOperation,
264
265
  callTool: callToolOperation,
265
266
  readResource: readResourceOperation,
266
267
  closeScope: authMethod((ctx) => authFlow(function* () {
@@ -386,6 +387,11 @@ export function remoteMcp(id, opts) {
386
387
  ctx,
387
388
  authMethod(() => authCall(listTools, [ctx])),
388
389
  ]));
390
+ const peekCatalog = connector.peekCatalog;
391
+ connector.peekCatalog = authMethod((ctx) => authCall(withActiveSecrets, [
392
+ ctx,
393
+ authMethod(() => authCall(peekCatalog, [ctx])),
394
+ ]));
389
395
  const readResource = connector.readResource;
390
396
  connector.readResource = authMethod((uri, ctx) => authCall(withActiveSecrets, [
391
397
  ctx,
@@ -426,6 +432,7 @@ export function remoteMcp(id, opts) {
426
432
  return (yield authView(ctx, authMethod(() => authCall(operation, [...args])), requestOwned));
427
433
  }));
428
434
  connector.listTools = settlingOperation(connector.listTools, 0);
435
+ connector.peekCatalog = settlingOperation(connector.peekCatalog, 0);
429
436
  connector.readResource = settlingOperation(connector.readResource, 1);
430
437
  if (connector.downstreamSkills) {
431
438
  connector.downstreamSkills.list = settlingOperation(connector.downstreamSkills.list, 0);
@@ -445,6 +452,7 @@ export function remoteMcp(id, opts) {
445
452
  // a refresh flight exists. These operations do not take call admission.
446
453
  const retain = retainOperation;
447
454
  connector.listTools = retain(connector.listTools, 0);
455
+ connector.peekCatalog = retain(connector.peekCatalog, 0);
448
456
  connector.callTool = retain(connector.callTool, 2);
449
457
  connector.readResource = retain(connector.readResource, 1);
450
458
  if (connector.downstreamSkills) {
@@ -460,6 +468,7 @@ export function remoteMcp(id, opts) {
460
468
  connector.finishAuth = retain(connector.finishAuth, 1);
461
469
  }
462
470
  connector.listTools = payloadFree(connector.listTools);
471
+ connector.peekCatalog = payloadFree(connector.peekCatalog);
463
472
  connector.callTool = payloadFree(connector.callTool);
464
473
  connector.readResource = payloadFree(connector.readResource);
465
474
  if (connector.downstreamSkills) {
@@ -1,3 +1,4 @@
1
+ import type { ClassificationOverride } from "./tool-safety.js";
1
2
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
2
3
  import type { ConnectaConfig, ResolvedConfig } from "./config.js";
3
4
  import { type ResolvedPool } from "./membership.js";
@@ -95,7 +96,7 @@ export interface ConnectaConfigDescription {
95
96
  maxResultBytes: DescribedLimit;
96
97
  };
97
98
  results: Record<keyof typeof CONFIG_DEFAULTS.results, DescribedLimit>;
98
- execute: Record<keyof typeof CONFIG_DEFAULTS.execute, DescribedLimit>;
99
+ execute: Record<keyof typeof CONFIG_DEFAULTS.execute, DescribedLimit<number | boolean>>;
99
100
  requests: Record<keyof typeof CONFIG_DEFAULTS.admission.requests, DescribedLimit>;
100
101
  };
101
102
  /** Whether the default /mcp endpoint refuses every write, and exact classification overrides from deployment code. */
@@ -115,7 +116,7 @@ export interface ConnectaConfigDescription {
115
116
  id: string;
116
117
  pools: string[];
117
118
  }>;
118
- classification: Record<string, Record<string, "read" | "write" | "hidden">>;
119
+ classification: Record<string, Record<string, ClassificationOverride>>;
119
120
  /** Inbound auth providers in the order they are tried. */
120
121
  auth: Array<{
121
122
  kind: string;
@@ -5,9 +5,10 @@
5
5
  // the connector-health family's `discovery` key, a typed code, when it was
6
6
  // seen, and when to try again (INV-6: never downstream text, tools or
7
7
  // credentials). Unscoped search skips a connector whose record is current and
8
- // reports it as unavailable in `catalogErrors` with `retryAfterMs`; it never
9
- // serves tools from the failed or interrupted walk (INV-8). Scoped search,
10
- // describe and calls still reach the connector.
8
+ // reports it as unavailable in `catalogErrors` with `retryAfterMs`, listing its
9
+ // last complete catalog, marked stale, when one exists; it never serves tools
10
+ // from the failed or interrupted walk (INV-8). Scoped search, describe and calls
11
+ // still reach the connector.
11
12
  //
12
13
  // Records sit beside the catalog cache, in its store and owner partition:
13
14
  // principal, subject, caller identity and pool, origin, the admitted endpoint,
@@ -21,7 +22,9 @@
21
22
  //
22
23
  // After its retry time a record lingers, so exactly one request can claim the
23
24
  // next probe by compare-and-set as it dispatches it; the others keep skipping
24
- // until that probe settles or its lease lapses. A success, or an auth failure that proves the
25
+ // until that probe settles or its lease lapses. Every write is conditional on
26
+ // the record its request read, so concurrent failures record once and a lease
27
+ // is never overwritten. A success, or an auth failure that proves the
25
28
  // connector answers, clears the record. A fast failure is not recorded:
26
29
  // asking again costs the next search nothing. Caller cancellation and a probe cut
27
30
  // short by the discovery budget record nothing.
@@ -32,7 +35,7 @@ import { classificationCode, classifyCallError } from "./errors.js";
32
35
  import { failureRecord, logFailure } from "./operator-record.js";
33
36
  import { causeError } from "./runtime/run.js";
34
37
  import { Storage } from "./runtime/services.js";
35
- import { storageCompareAndSet, storageGetMany, storageSet } from "./runtime/storage.js";
38
+ import { storageCompareAndSet, storageGetMany } from "./runtime/storage.js";
36
39
  import { connectorHealthKeys, CONNECTOR_HEALTH_LINGER_MS } from "./storage/keys.js";
37
40
  /** How long unscoped search skips a connector after a failed discovery. */
38
41
  const DISCOVERY_RETRY_MS = 60_000;
@@ -123,13 +126,22 @@ export function readDiscoveryHealth(registry, ids, baseUrl, requestScope, option
123
126
  * dispatched: one compare-and-set against the record this request read, with
124
127
  * a lease to the absolute time `leaseUntil`, past the claimant's whole
125
128
  * discovery budget. Others keep skipping until the lease lapses or the probe
126
- * settles. False when another request holds it, or the record changed.
129
+ * settles. An absent record is claimed the same way, by creating it. False when
130
+ * another request holds it, or the record changed.
127
131
  */
128
132
  export function claimDiscoveryProbe(slot, leaseUntil) {
129
133
  return Effect.suspend(() => {
130
- const fact = parseFact(slot.raw);
134
+ const fact = slot.raw === null
135
+ ? {
136
+ v: 1,
137
+ code: "timeout",
138
+ observedAt: Date.now(),
139
+ retryAt: leaseUntil,
140
+ generation: slot.generation,
141
+ }
142
+ : parseFact(slot.raw);
131
143
  if (!fact)
132
- return Effect.succeed(slot.raw === null);
144
+ return Effect.succeed(false);
133
145
  const claim = encodeFact({ ...fact, retryAt: leaseUntil });
134
146
  return storageCompareAndSet(slot.key, slot.raw, claim.raw, { expiresAtMs: claim.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map((claimed) => {
135
147
  if (claimed)
@@ -188,9 +200,12 @@ export function settleDiscoveryHealth(slot, outcome) {
188
200
  }
189
201
  const encoded = encodeFact(fact);
190
202
  const skip = { code: fact.code, retryAt: fact.retryAt };
191
- return storageSet(slot.key, encoded.raw, { expiresAtMs: encoded.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map(() => {
192
- slot.raw = encoded.raw;
193
- return { slot, skip };
203
+ // Against the record this request read: a concurrent failure or a held
204
+ // lease stays, and this request learns it does not own what follows.
205
+ return storageCompareAndSet(slot.key, slot.raw, encoded.raw, { expiresAtMs: encoded.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map((recorded) => {
206
+ if (recorded)
207
+ slot.raw = encoded.raw;
208
+ return { slot, skip, recorded };
194
209
  }), Effect.catchCause((cause) => Effect.sync(() => {
195
210
  logFailure(slot.logger, "discovery health write failed", failureRecord({}, causeError(cause)));
196
211
  return { slot, skip };
package/dist/errors.d.ts CHANGED
@@ -33,7 +33,7 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "downstream_o
33
33
  * Adding a member fails typecheck until {@link CLASSIFICATION_CODE_TABLE}
34
34
  * lists it.
35
35
  */
36
- export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "destructive_tool_requires_approval" | "read_only_endpoint" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
36
+ export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "result_not_retained" | "destructive_tool_requires_approval" | "read_only_endpoint" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
37
37
  /** `value` when it is a code connecta assigns, else undefined. */
38
38
  export declare function classificationCode(value: unknown): ClassificationCode | undefined;
39
39
  /**
@@ -101,12 +101,48 @@ export interface ArgumentRepairDetails {
101
101
  * finding stays a few hundred bytes inside the 4 KiB envelope below.
102
102
  */
103
103
  export declare const MAX_ARGUMENT_VALIDATION_ISSUES = 8;
104
+ /** Whitespace and the quotes and brackets that delimit a link in text, HTML and JSON. Each is
105
+ * ASCII, so a cut before one splits no codepoint in UTF-8 or UTF-16. */
106
+ export declare const TOKEN_END: Set<number>;
104
107
  /**
105
- * Clamp a string to a UTF-8 byte budget, appending `…` when it clipped.
106
- * Short strings — the common case, and the one that has to stay exact — are
108
+ * The first `units` UTF-16 units of `value`, less any unfinished token. Every cut of text
109
+ * that output redaction reads later goes through this or a sibling below: a remnant shorter
110
+ * than a fragment matches nothing, so a credential registered after the cut must not leave
111
+ * its opening behind. A whole token is the only tail a cut can prove finished.
112
+ */
113
+ export declare function tokenPrefix(value: string, units: number): string;
114
+ /**
115
+ * Clamp a string to a UTF-8 byte budget without cutting a token, appending `…` when it
116
+ * clipped. Short strings — the common case, and the one that has to stay exact — are
107
117
  * returned unchanged and untagged.
108
118
  */
109
119
  export declare function boundedEchoText(value: string, maxBytes?: number): string;
120
+ /**
121
+ * Clamp a name for an operator's record, which no agent reads and no output redaction
122
+ * follows, to a UTF-8 byte budget. Its prefix is what identifies it, so it may end inside a
123
+ * token; agent-facing text goes through {@link boundedEchoText}.
124
+ */
125
+ export declare function clampedRecordName(value: string, maxBytes: number): string;
126
+ /**
127
+ * How much of a tool's own `isError` text reaches the agent. A refusal can be the
128
+ * only copy of what to do next: a paragraph of instructions, a request id and a
129
+ * signed approval URL, which alone commonly run to a couple of kilobytes. Four
130
+ * kilobytes holds those, and in a direct result's two copies stays well inside the
131
+ * default result cap; a tighter cap shrinks it further.
132
+ */
133
+ export declare const MAX_TOOL_ERROR_BYTES = 4096;
134
+ /**
135
+ * Clamp downstream prose to a UTF-8 byte budget, marker included, without cutting
136
+ * a token. The cut backs off to a byte no URL or credential holds, so a link arrives
137
+ * whole or not at all, and no prefix of a credential registered later stays behind.
138
+ * Each such byte is ASCII, so no codepoint is split.
139
+ */
140
+ export declare function clipText(value: string, maxBytes?: number): string;
141
+ /**
142
+ * Clamp text so its JSON string fits `maxChars` serialized characters, quotes and marker
143
+ * included, without cutting a token. Escapes make a character cost up to six.
144
+ */
145
+ export declare function clipSerialized(value: string, maxChars: number): string;
110
146
  /**
111
147
  * Snapshot the caller's arguments after bounded schema-aware writeOnly omission,
112
148
  * then apply the shared byte budget without clipping. Unresolved sensitivity
@@ -179,6 +215,8 @@ export declare class WithheldTextError extends Error {
179
215
  }
180
216
  /** Agent-visible recovery class for downstream credential failures. */
181
217
  export type AuthRecoveryMode = "oauth" | "operator_config" | "unavailable";
218
+ /** The downstream said this caller has no access: affirmative evidence, unlike a failure to answer. */
219
+ export declare function isAccessRefusal(error: unknown): boolean;
182
220
  /** Runtime membership in the closed connector taxonomy, checked against its exhaustive table. */
183
221
  export declare function isConnectorCallErrorCode(value: unknown): value is ConnectorCallErrorCode;
184
222
  /**
@@ -283,6 +321,13 @@ export interface CallErrorDetails extends Partial<AuthorizationHandoff> {
283
321
  * {@link ConnectorCallError.writeOutcome}). Check its target before repeating it.
284
322
  */
285
323
  writeOutcome?: "unknown";
324
+ /**
325
+ * A write's downstream tool answered `isError` itself, so `message` holds its
326
+ * words. They do not prove the write unapplied: its handler ran before it answered.
327
+ */
328
+ toolAnswered?: true;
329
+ /** On `unknown_tool`: nearby canonical addresses on the same connector. */
330
+ suggestions?: string[];
286
331
  /** The single model-facing entry point for every credential class. */
287
332
  nextAction?: {
288
333
  tool: "skills";
@@ -297,6 +342,10 @@ export interface CallErrorDetails extends Partial<AuthorizationHandoff> {
297
342
  connector: string;
298
343
  };
299
344
  operatorHandoff: string;
345
+ } | {
346
+ /** An error that carries its handoff: pass this link on; no tool call is needed. */
347
+ url: string;
348
+ purpose: string;
300
349
  } | {
301
350
  tool: "search_tools";
302
351
  arguments: {
package/dist/errors.js CHANGED
@@ -29,6 +29,7 @@ const CLASSIFICATION_CODE_TABLE = {
29
29
  ambiguous_tool_alias: true,
30
30
  catalog_lookup_failed: true,
31
31
  result_too_large: true,
32
+ result_not_retained: true,
32
33
  destructive_tool_requires_approval: true,
33
34
  read_only_endpoint: true,
34
35
  auth_replay_refused: true,
@@ -58,7 +59,7 @@ function boundedIssueText(value, maxChars) {
58
59
  if (value.length <= maxChars)
59
60
  return { value, truncated: false };
60
61
  return {
61
- value: `${value.slice(0, Math.max(0, maxChars - 1))}…`,
62
+ value: `${tokenPrefix(value, maxChars - 1)}…`,
62
63
  truncated: true,
63
64
  };
64
65
  }
@@ -91,12 +92,43 @@ const MAX_ECHOED_BYTES = 512;
91
92
  */
92
93
  const echoEncoder = new TextEncoder();
93
94
  const echoDecoder = new TextDecoder();
95
+ /** Whitespace and the quotes and brackets that delimit a link in text, HTML and JSON. Each is
96
+ * ASCII, so a cut before one splits no codepoint in UTF-8 or UTF-16. */
97
+ export const TOKEN_END = new Set([0x09, 0x0a, 0x0d, 0x20, 0x22, 0x3c, 0x3e, 0x60]);
94
98
  /**
95
- * Clamp a string to a UTF-8 byte budget, appending `…` when it clipped.
96
- * Short strings — the common case, and the one that has to stay exact — are
99
+ * The first `units` UTF-16 units of `value`, less any unfinished token. Every cut of text
100
+ * that output redaction reads later goes through this or a sibling below: a remnant shorter
101
+ * than a fragment matches nothing, so a credential registered after the cut must not leave
102
+ * its opening behind. A whole token is the only tail a cut can prove finished.
103
+ */
104
+ export function tokenPrefix(value, units) {
105
+ if (units >= value.length)
106
+ return value;
107
+ let end = Math.max(0, units);
108
+ while (end > 0 && !TOKEN_END.has(value.charCodeAt(end)))
109
+ end--;
110
+ return value.slice(0, end).trimEnd();
111
+ }
112
+ /**
113
+ * Clamp a string to a UTF-8 byte budget without cutting a token, appending `…` when it
114
+ * clipped. Short strings — the common case, and the one that has to stay exact — are
97
115
  * returned unchanged and untagged.
98
116
  */
99
117
  export function boundedEchoText(value, maxBytes = MAX_ECHOED_BYTES) {
118
+ const bytes = echoEncoder.encode(value);
119
+ if (bytes.length <= maxBytes)
120
+ return value;
121
+ let end = Math.max(0, maxBytes);
122
+ while (end > 0 && !TOKEN_END.has(bytes[end]))
123
+ end--;
124
+ return `${echoDecoder.decode(bytes.slice(0, end)).trimEnd()}…`;
125
+ }
126
+ /**
127
+ * Clamp a name for an operator's record, which no agent reads and no output redaction
128
+ * follows, to a UTF-8 byte budget. Its prefix is what identifies it, so it may end inside a
129
+ * token; agent-facing text goes through {@link boundedEchoText}.
130
+ */
131
+ export function clampedRecordName(value, maxBytes) {
100
132
  const bytes = echoEncoder.encode(value);
101
133
  if (bytes.length <= maxBytes)
102
134
  return value;
@@ -107,6 +139,50 @@ export function boundedEchoText(value, maxBytes = MAX_ECHOED_BYTES) {
107
139
  end--;
108
140
  return `${echoDecoder.decode(bytes.slice(0, end))}…`;
109
141
  }
142
+ /**
143
+ * How much of a tool's own `isError` text reaches the agent. A refusal can be the
144
+ * only copy of what to do next: a paragraph of instructions, a request id and a
145
+ * signed approval URL, which alone commonly run to a couple of kilobytes. Four
146
+ * kilobytes holds those, and in a direct result's two copies stays well inside the
147
+ * default result cap; a tighter cap shrinks it further.
148
+ */
149
+ export const MAX_TOOL_ERROR_BYTES = 4_096;
150
+ const CLIPPED = "… [truncated]";
151
+ const CLIPPED_BYTES = echoEncoder.encode(CLIPPED).length;
152
+ /**
153
+ * Clamp downstream prose to a UTF-8 byte budget, marker included, without cutting
154
+ * a token. The cut backs off to a byte no URL or credential holds, so a link arrives
155
+ * whole or not at all, and no prefix of a credential registered later stays behind.
156
+ * Each such byte is ASCII, so no codepoint is split.
157
+ */
158
+ export function clipText(value, maxBytes = MAX_TOOL_ERROR_BYTES) {
159
+ const bytes = echoEncoder.encode(value);
160
+ if (bytes.length <= maxBytes)
161
+ return value;
162
+ let end = Math.max(0, maxBytes - CLIPPED_BYTES);
163
+ while (end > 0 && !TOKEN_END.has(bytes[end]))
164
+ end--;
165
+ return `${echoDecoder.decode(bytes.slice(0, end)).trimEnd()}${end > 0 ? " " : ""}${CLIPPED}`;
166
+ }
167
+ /**
168
+ * Clamp text so its JSON string fits `maxChars` serialized characters, quotes and marker
169
+ * included, without cutting a token. Escapes make a character cost up to six.
170
+ */
171
+ export function clipSerialized(value, maxChars) {
172
+ if (JSON.stringify(value).length <= maxChars)
173
+ return value;
174
+ const marked = (kept) => `${kept}${kept ? " " : ""}${CLIPPED}`;
175
+ let low = 0;
176
+ let high = Math.min(value.length, maxChars);
177
+ while (low < high) {
178
+ const mid = Math.ceil((low + high) / 2);
179
+ if (JSON.stringify(marked(value.slice(0, mid))).length <= maxChars)
180
+ low = mid;
181
+ else
182
+ high = mid - 1;
183
+ }
184
+ return marked(tokenPrefix(value, low));
185
+ }
110
186
  /**
111
187
  * Snapshot the caller's arguments after bounded schema-aware writeOnly omission,
112
188
  * then apply the shared byte budget without clipping. Unresolved sensitivity
@@ -344,6 +420,13 @@ const RETRYABLE_BY_CODE = {
344
420
  input_required_limit: false,
345
421
  connector_call_failed: false,
346
422
  };
423
+ /** The downstream said this caller has no access: affirmative evidence, unlike a failure to answer. */
424
+ export function isAccessRefusal(error) {
425
+ return (error instanceof ConnectorCallError &&
426
+ (error.code === "auth_required" ||
427
+ error.code === "downstream_oauth_required" ||
428
+ error.code === "provider_permission_denied"));
429
+ }
347
430
  /** Runtime membership in the closed connector taxonomy, checked against its exhaustive table. */
348
431
  export function isConnectorCallErrorCode(value) {
349
432
  return typeof value === "string" && Object.hasOwn(RETRYABLE_BY_CODE, value);