@zackbart/connecta 0.29.0 → 0.30.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 (69) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +123 -134
  3. package/bin/connecta.mjs +13 -2
  4. package/bin/version.mjs +1 -1
  5. package/dist/argument-redaction.js +230 -0
  6. package/dist/auth-elicitation.js +14 -9
  7. package/dist/authorization-handoff.js +111 -0
  8. package/dist/catalog-cache.js +2 -2
  9. package/dist/catalog-service.js +103 -63
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +12 -7
  12. package/dist/connectors/guarded-fetch.js +3 -3
  13. package/dist/connectors/negotiation-cache.js +1 -1
  14. package/dist/downstream-input-context.js +9 -0
  15. package/dist/downstream-input.js +13 -11
  16. package/dist/errors.d.ts +36 -12
  17. package/dist/errors.js +19 -9
  18. package/dist/execute.js +375 -43
  19. package/dist/executor-result.js +11 -9
  20. package/dist/executors/quickjs-child.js +12 -5
  21. package/dist/executors/quickjs-protocol.js +5 -6
  22. package/dist/executors/quickjs-runtime.js +6 -1
  23. package/dist/executors/quickjs.js +20 -4
  24. package/dist/guest-runtime.js +52 -2
  25. package/dist/guest-types.d.ts +47 -5
  26. package/dist/index.d.ts +1 -1
  27. package/dist/invocation.js +40 -14
  28. package/dist/meta-output.js +0 -15
  29. package/dist/meta-tools.js +166 -95
  30. package/dist/oauth-handoff.js +88 -11
  31. package/dist/program-result.js +5 -0
  32. package/dist/program-writes.js +5 -1
  33. package/dist/provider.d.ts +1 -1
  34. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  35. package/dist/providers/_shared/request-token.js +83 -0
  36. package/dist/providers/infisical/index.d.ts +7 -0
  37. package/dist/providers/infisical/index.js +713 -0
  38. package/dist/providers/infisical/skill.generated.js +8 -0
  39. package/dist/registry.d.ts +8 -6
  40. package/dist/registry.js +134 -61
  41. package/dist/resource-read.js +4 -1
  42. package/dist/routes/mcp.js +11 -2
  43. package/dist/sent-secrets.d.ts +54 -13
  44. package/dist/sent-secrets.js +770 -231
  45. package/dist/skills.js +26 -16
  46. package/dist/sqlite.js +21 -6
  47. package/dist/storage/keys.js +20 -5
  48. package/dist/storage/memory.js +9 -4
  49. package/dist/storage/sql.js +18 -9
  50. package/dist/types.d.ts +17 -3
  51. package/dist/usage-guide.js +49 -40
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/dist/worker.js +2 -1
  55. package/documentation/README.md +63 -0
  56. package/documentation/architecture.md +121 -89
  57. package/documentation/auth.md +138 -5
  58. package/documentation/code-mode.md +224 -107
  59. package/documentation/deploying.md +146 -0
  60. package/documentation/integrating.md +159 -0
  61. package/documentation/meta-tools.md +135 -30
  62. package/documentation/operating.md +135 -0
  63. package/documentation/operator-tests.md +4 -0
  64. package/documentation/operator-ui.md +132 -0
  65. package/documentation/provider-migration-0.29.md +5 -0
  66. package/examples/worker/README.md +7 -0
  67. package/package.json +7 -1
  68. package/templates/node/README.md +7 -0
  69. package/templates/node/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { enableAuthorizationHandoffs } from "./authorization-handoff.js";
1
2
  import { createRequestStateCodec, inputRequired, ProtocolError, INVALID_PARAMS, PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY, } from "@modelcontextprotocol/server";
2
3
  import { Effect } from "effect";
3
4
  import { runEdge, withDeadlineEffect } from "./runtime/run.js";
@@ -183,6 +184,14 @@ export class AuthElicitation {
183
184
  return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
184
185
  }
185
186
  }
187
+ const envelope = context.mcpReq.envelope;
188
+ const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
189
+ const elicitation = object(capabilities) ? capabilities.elicitation : undefined;
190
+ const capable = envelope?.[PROTOCOL_VERSION_META_KEY] === "2026-07-28" &&
191
+ object(elicitation) &&
192
+ Object.hasOwn(elicitation, "url") &&
193
+ object(elicitation.url);
194
+ enableAuthorizationHandoffs(this.options.requestScope, !capable);
186
195
  let result = await operation();
187
196
  const relayed = await this.downstream.finish(tool, args, context, result, previous);
188
197
  if (relayed !== result)
@@ -221,21 +230,17 @@ export class AuthElicitation {
221
230
  unavailable ||
222
231
  !canManage(id))
223
232
  return result;
224
- const envelope = context.mcpReq.envelope;
225
- const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
226
- const elicitation = object(capabilities) ? capabilities.elicitation : undefined;
227
- const capable = envelope?.[PROTOCOL_VERSION_META_KEY] === "2026-07-28" &&
228
- object(elicitation) &&
229
- Object.hasOwn(elicitation, "url") &&
230
- object(elicitation.url);
231
- if (blocked || !capable || !vault?.requestStateKey || !vault.seal || !vault.open) {
233
+ // Incapable hosts already received the shared explicit-authorization handoff.
234
+ if (!capable)
235
+ return result;
236
+ if (blocked || !vault?.requestStateKey || !vault.seal || !vault.open) {
232
237
  if (!authFailure)
233
238
  return result;
234
239
  const structuredContent = {
235
240
  ...result.structuredContent,
236
241
  error: {
237
242
  ...result.structuredContent?.error,
238
- authorizationUrl: (await this.options.connectLink(id)).url,
243
+ authorizationUrl: error.authorizationUrl ?? (await this.options.connectLink(id)).url,
239
244
  },
240
245
  };
241
246
  return { ...result, structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
@@ -0,0 +1,111 @@
1
+ import { boundedEchoText } from "./errors.js";
2
+ function oauthFollowUp(connectorId) {
3
+ return `Then retry the original call; connecta.search({ connector: ${JSON.stringify(connectorId)} }) inside execute_code confirms the catalog now loads.`;
4
+ }
5
+ /** Shared with explicit authorization; errors never request a forced restart. */
6
+ export async function authorizationHandoff(registry, baseUrl, requestScope, opts, args) {
7
+ const connector = registry.getConnector(args.connector);
8
+ if (!connector) {
9
+ throw new Error(`Unknown connector "${boundedEchoText(args.connector)}"`);
10
+ }
11
+ if (!connector.startAuth) {
12
+ if (!connector.credential) {
13
+ return {
14
+ connector: connector.id,
15
+ recovery: "unavailable",
16
+ message: `Connector "${connector.id}" declares neither downstream OAuth ` +
17
+ "nor an operator-managed credential slot. Update the connector " +
18
+ "or deployment configuration before retrying.",
19
+ };
20
+ }
21
+ const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
22
+ if (!ctx.credential || !opts.credentialHandoffUrl) {
23
+ return {
24
+ connector: connector.id,
25
+ recovery: "unavailable",
26
+ message: "Credential recovery needs both a vault and the optional UI. Configure " +
27
+ "vault and ui in deployment code, then call " +
28
+ "authorize_connector again.",
29
+ };
30
+ }
31
+ const fields = connector.credential.fields?.map((field) => ({
32
+ name: field.name,
33
+ guidance: field.description ?? field.label,
34
+ })) ?? [
35
+ {
36
+ name: "value",
37
+ guidance: connector.credential.description ?? connector.credential.label,
38
+ },
39
+ ];
40
+ return {
41
+ connector: connector.id,
42
+ recovery: "operator_config",
43
+ credential: {
44
+ label: connector.credential.label,
45
+ fields,
46
+ },
47
+ operatorUrl: opts.credentialHandoffUrl,
48
+ instructions: "Have the operator open operatorUrl, set and test the credential, " +
49
+ "then retry the original call. No redeploy is needed. " +
50
+ (connector.authScope === "personal"
51
+ ? "Credential mutation requires the signed-in principal who owns this connection."
52
+ : "Shared credential mutation requires a signed-in human with access to this connector."),
53
+ };
54
+ }
55
+ if (opts.oauthConnectUnavailable || !opts.oauthConnectUrl)
56
+ return {
57
+ connector: connector.id,
58
+ recovery: "unavailable",
59
+ message: opts.oauthConnectUnavailable ??
60
+ "OAuth connection requires an interactive provider and a credential vault with a handoff signing key.",
61
+ };
62
+ if (!opts.canManageAuth?.(connector.id))
63
+ return {
64
+ connector: connector.id,
65
+ recovery: "unavailable",
66
+ message: "Your identity is not permitted to manage authentication for this connection.",
67
+ };
68
+ return {
69
+ connector: connector.id,
70
+ recovery: "oauth",
71
+ status: "auth_required",
72
+ authorizationUrl: await opts.oauthConnectUrl(connector.id, args.force),
73
+ instructions: "Copy authorizationUrl exactly as returned; do not decode or re-encode it. Open it in a browser and sign in as the user who requested this connection. Connecta verifies your identity and permission before starting consent. " +
74
+ oauthFollowUp(connector.id),
75
+ };
76
+ }
77
+ const requests = new WeakMap();
78
+ /** Retain the admitted route's factory when program utilities share its scope. */
79
+ export function bindAuthorizationHandoffs(scope, create) {
80
+ if (!requests.has(scope))
81
+ requests.set(scope, { create, enabled: true, handoffs: new Map() });
82
+ }
83
+ export function enableAuthorizationHandoffs(scope, enabled) {
84
+ const request = requests.get(scope);
85
+ if (request)
86
+ request.enabled = enabled;
87
+ }
88
+ /** One promise per connector also bounds concurrent errors in a program. */
89
+ export async function withAuthorizationHandoff(scope, error) {
90
+ const request = requests.get(scope);
91
+ if (!request?.enabled ||
92
+ !error.nextAction ||
93
+ !("tool" in error.nextAction) ||
94
+ error.nextAction.tool !== "authorize_connector")
95
+ return error;
96
+ const connector = error.nextAction.arguments.connector;
97
+ let handoff = request.handoffs.get(connector);
98
+ if (!handoff) {
99
+ handoff = request.create(connector);
100
+ request.handoffs.set(connector, handoff);
101
+ }
102
+ try {
103
+ const value = await handoff;
104
+ return { ...error, ...value, message: error.message, ...(value.message ? { instructions: value.message } : {}) };
105
+ }
106
+ catch {
107
+ // A signing/configuration failure cannot replace the original call failure
108
+ // or leak an arbitrary vault exception into the recovery envelope.
109
+ return error;
110
+ }
111
+ }
@@ -203,7 +203,7 @@ export function catalogIntake(ctx, result) {
203
203
  }));
204
204
  if (tools.length > MAX_CATALOG_TOOLS)
205
205
  throw catalogCeiling();
206
- const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redact({ ...result, tools });
206
+ const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redactCredentials({ ...result, tools });
207
207
  if (new TextEncoder().encode(JSON.stringify(clean)).byteLength > MAX_SERIALIZED_CATALOG_BYTES)
208
208
  throw catalogCeiling();
209
209
  for (const tool of clean.tools)
@@ -447,7 +447,7 @@ export async function catalogClientOptions(ctx, connectorId, config, authPartiti
447
447
  const items = catalogItems(method, body).map(({ _meta: _ignored, ...item }) => item);
448
448
  if (items.length > MAX_CATALOG_TOOLS)
449
449
  throw catalogCeiling();
450
- const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redact({ ...body, [field]: items });
450
+ const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redactCredentials({ ...body, [field]: items });
451
451
  if (new TextEncoder().encode(JSON.stringify(clean)).byteLength > MAX_SERIALIZED_CATALOG_BYTES)
452
452
  throw catalogCeiling();
453
453
  return {
@@ -1,3 +1,4 @@
1
+ import { withAuthorizationHandoff } from "./authorization-handoff.js";
1
2
  import { carryCatalogFreshness, catalogIsFresh } from "./catalog-freshness.js";
2
3
  import { hasControlCharacters } from "./tool-name.js";
3
4
  import { Effect, Result } from "effect";
@@ -228,6 +229,45 @@ const catalogFailureProperties = {
228
229
  message: { type: "string" },
229
230
  retryable: { type: "boolean" },
230
231
  retryAfterMs: { type: "integer", minimum: 0 },
232
+ connector: { type: "string" },
233
+ recovery: { enum: ["oauth", "operator_config", "unavailable"] },
234
+ status: { const: "auth_required" },
235
+ authorizationUrl: { type: "string" },
236
+ operatorUrl: { type: "string" },
237
+ instructions: { type: "string" },
238
+ credential: {
239
+ type: "object",
240
+ required: ["label", "fields"],
241
+ additionalProperties: false,
242
+ properties: {
243
+ label: { type: "string" },
244
+ fields: {
245
+ type: "array",
246
+ items: {
247
+ type: "object",
248
+ required: ["name"],
249
+ additionalProperties: false,
250
+ properties: { name: { type: "string" }, guidance: { type: "string" } },
251
+ },
252
+ },
253
+ },
254
+ },
255
+ retry: { type: "string" },
256
+ nextAction: {
257
+ type: "object",
258
+ required: ["tool", "arguments", "operatorHandoff"],
259
+ additionalProperties: false,
260
+ properties: {
261
+ tool: { const: "authorize_connector" },
262
+ arguments: {
263
+ type: "object",
264
+ required: ["connector"],
265
+ additionalProperties: false,
266
+ properties: { connector: { type: "string" } },
267
+ },
268
+ operatorHandoff: { type: "string" },
269
+ },
270
+ },
231
271
  };
232
272
  /**
233
273
  * The outputSchema contract for either search adapter. Guest API registration
@@ -245,25 +285,7 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
245
285
  required: ["connector", "code", "message", "retryable"],
246
286
  additionalProperties: false,
247
287
  properties: {
248
- connector: { type: "string" },
249
288
  ...catalogFailureProperties,
250
- recovery: { enum: ["oauth", "operator_config", "unavailable"] },
251
- retry: { type: "string" },
252
- nextAction: {
253
- type: "object",
254
- required: ["tool", "arguments", "operatorHandoff"],
255
- additionalProperties: false,
256
- properties: {
257
- tool: { const: "authorize_connector" },
258
- arguments: {
259
- type: "object",
260
- required: ["connector"],
261
- additionalProperties: false,
262
- properties: { connector: { type: "string" } },
263
- },
264
- operatorHandoff: { type: "string" },
265
- },
266
- },
267
289
  },
268
290
  },
269
291
  },
@@ -517,6 +539,44 @@ export class CatalogService {
517
539
  });
518
540
  });
519
541
  }
542
+ async catalogFailure(connector, failure) {
543
+ const classified = classifyCallError(failure, "catalog_lookup_failed");
544
+ const error = {
545
+ connector: connector.id,
546
+ code: classified.code,
547
+ // Catalog discovery reports recovery facts, never connector-authored
548
+ // error text. Clipping a downstream message does not make it safe.
549
+ message: classified.code === "downstream_oauth_required"
550
+ ? `Connector "${connector.id}" requires downstream OAuth authorization. When an auth error includes a handoff, give it to the user or operator.`
551
+ : classified.code === "auth_required"
552
+ ? `Connector "${connector.id}" requires operator-managed credentials or configuration. When an auth error includes a handoff, give it to the user or operator.`
553
+ : classified.code === "provider_permission_denied"
554
+ ? `Connector "${connector.id}" requires permission from the provider's resource owner or administrator. Reconnecting alone will not grant access.`
555
+ : `Connector "${connector.id}" catalog lookup failed (${classified.code}).`,
556
+ retryable: classified.retryable,
557
+ ...(classified.retryAfterMs === undefined ? {} : { retryAfterMs: classified.retryAfterMs }),
558
+ };
559
+ if (error.code === "auth_required" || error.code === "downstream_oauth_required") {
560
+ error.recovery = connector.startAuth
561
+ ? "oauth"
562
+ : this.registry.credentialUiAvailable() &&
563
+ connector.credential &&
564
+ this.registry.contextFor(connector.id, this.baseUrl, this.requestScope).credential
565
+ ? "operator_config"
566
+ : "unavailable";
567
+ error.nextAction = {
568
+ tool: "authorize_connector",
569
+ arguments: { connector: connector.id },
570
+ operatorHandoff: "Give the URL and instructions it returns to the operator.",
571
+ };
572
+ error.retry = `Retry discovery after the operator completes recovery for "${connector.id}".`;
573
+ }
574
+ else if (error.code === "provider_permission_denied") {
575
+ error.retry =
576
+ "Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Reconnecting alone will not grant access.";
577
+ }
578
+ return withAuthorizationHandoff(this.requestScope, error);
579
+ }
520
580
  async search(args) {
521
581
  if (args.query !== undefined && typeof args.query !== "string") {
522
582
  throw new DiscoveryPolicyError("invalid_args", "query must be a string. Omit it or use an empty string to browse the catalog.");
@@ -737,59 +797,25 @@ export class CatalogService {
737
797
  }
738
798
  }
739
799
  const unavailableCatalogs = catalogs.filter(Result.isFailure).length;
740
- const catalogErrors = catalogs.flatMap((catalog, index) => {
800
+ const catalogErrors = (await Promise.all(catalogs.map(async (catalog, index) => {
741
801
  if (Result.isSuccess(catalog))
742
802
  return [];
743
- const connector = connectors[index];
744
- const classified = classifyCallError(catalog.failure, "catalog_lookup_failed");
745
- const error = {
746
- connector: connector.id,
747
- code: classified.code,
748
- // Catalog discovery reports recovery facts, never connector-authored
749
- // error text. Clipping a downstream message does not make it safe.
750
- message: classified.code === "downstream_oauth_required"
751
- ? `Connector "${connector.id}" requires downstream OAuth authorization. Call authorize_connector and give its handoff to the operator.`
752
- : classified.code === "auth_required"
753
- ? `Connector "${connector.id}" requires operator-managed credentials or configuration. Call authorize_connector and give its handoff to the operator.`
754
- : classified.code === "provider_permission_denied"
755
- ? `Connector "${connector.id}" requires permission from the provider's resource owner or administrator. Reconnecting alone will not grant access.`
756
- : `Connector "${connector.id}" catalog lookup failed (${classified.code}).`,
757
- retryable: classified.retryable,
758
- ...(classified.retryAfterMs === undefined ? {} : { retryAfterMs: classified.retryAfterMs }),
759
- };
760
- if (error.code === "auth_required" || error.code === "downstream_oauth_required") {
761
- error.recovery = connector.startAuth
762
- ? "oauth"
763
- : this.registry.credentialUiAvailable() &&
764
- connector.credential &&
765
- this.registry.contextFor(connector.id, this.baseUrl, this.requestScope).credential
766
- ? "operator_config"
767
- : "unavailable";
768
- error.nextAction = {
769
- tool: "authorize_connector",
770
- arguments: { connector: connector.id },
771
- operatorHandoff: "Give the URL and instructions it returns to the operator.",
772
- };
773
- error.retry = `Retry discovery after the operator completes recovery for "${connector.id}".`;
774
- }
775
- else if (error.code === "provider_permission_denied") {
776
- error.retry =
777
- "Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Reconnecting alone will not grant access.";
778
- }
779
- return [error];
780
- });
803
+ return [await this.catalogFailure(connectors[index], catalog.failure)];
804
+ }))).flat();
781
805
  const needsAuth = (error) => error.code === "auth_required" ||
782
806
  error.code === "downstream_oauth_required" ||
783
807
  error.code === "provider_permission_denied";
784
808
  catalogErrors.sort((a, b) => Number(needsAuth(b)) - Number(needsAuth(a)));
785
809
  const scopedFailure = args.connector ? catalogErrors[0] : undefined;
786
810
  const scopedCatalogError = scopedFailure
787
- ? {
788
- code: scopedFailure.code,
789
- message: scopedFailure.message,
790
- retryable: scopedFailure.retryable,
791
- ...(scopedFailure.retryAfterMs === undefined ? {} : { retryAfterMs: scopedFailure.retryAfterMs }),
792
- }
811
+ ? scopedFailure.code === "auth_required" || scopedFailure.code === "downstream_oauth_required"
812
+ ? scopedFailure
813
+ : {
814
+ code: scopedFailure.code,
815
+ message: scopedFailure.message,
816
+ retryable: scopedFailure.retryable,
817
+ ...(scopedFailure.retryAfterMs === undefined ? {} : { retryAfterMs: scopedFailure.retryAfterMs }),
818
+ }
793
819
  : undefined;
794
820
  const safetyLabel = safety === "readOnly" ? "read-only " : safety === "approvalRequired" ? "approval-required " : "";
795
821
  const filterRecovery = safety === "all" ? "" : " Change safety to inspect the other tools.";
@@ -940,6 +966,17 @@ export class CatalogService {
940
966
  ];
941
967
  const loaded = await runEdge(this.discoveryCatalogs(connectorIds, "connecta.describe"));
942
968
  const catalogs = new Map(connectorIds.map((id, index) => [id, loaded[index]]));
969
+ const authFailures = new Map(await Promise.all(connectorIds.map(async (id) => {
970
+ const catalog = catalogs.get(id);
971
+ if (!catalog || !Result.isFailure(catalog))
972
+ return [id, undefined];
973
+ const classified = classifyCallError(catalog.failure, "catalog_lookup_failed");
974
+ const connector = this.registry.getConnector(id);
975
+ const error = classified.code === "auth_required" || classified.code === "downstream_oauth_required"
976
+ ? await this.catalogFailure(connector, catalog.failure)
977
+ : undefined;
978
+ return [id, error];
979
+ })));
943
980
  return resolved.map(({ address, resolved: addressResolution }) => {
944
981
  if (!addressResolution) {
945
982
  const message = `Unknown address "${boundedEchoText(address)}"`;
@@ -955,6 +992,9 @@ export class CatalogService {
955
992
  }
956
993
  const catalog = catalogs.get(addressResolution.connector.id);
957
994
  if (catalog && Result.isFailure(catalog)) {
995
+ const authFailure = authFailures.get(addressResolution.connector.id);
996
+ if (authFailure)
997
+ return { address: boundedEchoText(address), error: authFailure.message, errorDetails: authFailure };
958
998
  const classified = classifyCallError(catalog.failure, "catalog_lookup_failed");
959
999
  const message = boundedEchoText(classified.message);
960
1000
  return {
package/dist/config.d.ts CHANGED
@@ -252,6 +252,8 @@ declare const connectaConfig: {
252
252
  * `@zackbart/connecta/d1` on Workers, `sqliteStorage(path)` from
253
253
  * `@zackbart/connecta/sqlite` on Node. Defaults to memoryStorage(), which
254
254
  * forgets everything on restart.
255
+ * Custom adapters must declare `capabilities.absoluteExpiry: true` and honor
256
+ * `expiresAtMs` on set and compareAndSet; older adapters fail construction.
255
257
  */
256
258
  storage: import("./config-schema.js").Field<KVStorage, KVStorage, true>;
257
259
  /**
package/dist/config.js CHANGED
@@ -291,6 +291,8 @@ const connectaConfig = {
291
291
  * `@zackbart/connecta/d1` on Workers, `sqliteStorage(path)` from
292
292
  * `@zackbart/connecta/sqlite` on Node. Defaults to memoryStorage(), which
293
293
  * forgets everything on restart.
294
+ * Custom adapters must declare `capabilities.absoluteExpiry: true` and honor
295
+ * `expiresAtMs` on set and compareAndSet; older adapters fail construction.
294
296
  */
295
297
  storage: opaque({
296
298
  // Every subsystem relies on the atomic claim and enumeration, so storage
@@ -299,13 +301,16 @@ const connectaConfig = {
299
301
  // adapter, which could never offer either guarantee.
300
302
  check: (value, path) => {
301
303
  const missing = STORAGE_METHODS.filter((method) => !hasMethods(value, [method]));
302
- if (missing.length === 0)
303
- return;
304
- throw new ConfigError(`${path} must implement ${missing.join(" and ")}. ` +
305
- "Use d1Storage(env.CONNECTA_DB) from @zackbart/connecta/d1 on Workers, " +
306
- "sqliteStorage(path) from @zackbart/connecta/sqlite on Node, or " +
307
- "memoryStorage() in tests. Workers KV is not supported: it is eventually " +
308
- "consistent and cannot compare-and-set.");
304
+ if (missing.length > 0)
305
+ throw new ConfigError(`${path} must implement ${missing.join(" and ")}. ` +
306
+ "Use d1Storage(env.CONNECTA_DB) from @zackbart/connecta/d1 on Workers, " +
307
+ "sqliteStorage(path) from @zackbart/connecta/sqlite on Node, or " +
308
+ "memoryStorage() in tests. Workers KV is not supported: it is eventually " +
309
+ "consistent and cannot compare-and-set.");
310
+ if (!isObject(value) || !isObject(value.capabilities) || value.capabilities.absoluteExpiry !== true) {
311
+ throw new ConfigError(`${path} must declare capabilities.absoluteExpiry: true and honor expiresAtMs on set and compareAndSet. ` +
312
+ "Upgrade the storage adapter before declaring this capability.");
313
+ }
309
314
  },
310
315
  resolve: (value) => value ?? memoryStorage(),
311
316
  }),
@@ -70,17 +70,17 @@ function confinementBase(provider, baseUrl) {
70
70
  base = new URL(baseUrl);
71
71
  }
72
72
  catch {
73
- throw new Error(`${provider} baseUrl must be an absolute URL: ${baseUrl}`);
73
+ throw new Error(`${provider} baseUrl must be an absolute URL.`);
74
74
  }
75
75
  const loopback = base.hostname === "localhost" || base.hostname === "127.0.0.1" || base.hostname === "[::1]";
76
76
  if (base.protocol !== "https:" && !(base.protocol === "http:" && loopback)) {
77
- throw new Error(`${provider} baseUrl must be https (http is allowed only for a loopback proxy or test double): ${baseUrl}`);
77
+ throw new Error(`${provider} baseUrl must be https (http is allowed only for a loopback proxy or test double).`);
78
78
  }
79
79
  if (base.username || base.password) {
80
80
  throw new Error(`${provider} baseUrl must not embed URL credentials; authentication belongs in headers.`);
81
81
  }
82
82
  if (base.search || base.hash) {
83
- throw new Error(`${provider} baseUrl must not carry a query or fragment: ${baseUrl}`);
83
+ throw new Error(`${provider} baseUrl must not carry a query or fragment.`);
84
84
  }
85
85
  return base;
86
86
  }
@@ -31,7 +31,7 @@ export async function storeNegotiation(ctx, digest, prior) {
31
31
  return;
32
32
  assertDownstreamOutputSafe(ctx.requestScope ?? ctx, prior);
33
33
  // DiscoverResult instructions, metadata and capability keys can echo auth.
34
- const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redact(prior);
34
+ const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redactCredentials(prior);
35
35
  if (clean.kind === "modern" && !isSpecType.DiscoverResult(clean.discover))
36
36
  return;
37
37
  try {
@@ -18,6 +18,15 @@ export function bindDownstreamContinuation(scope, target) {
18
18
  export function clearDownstreamContinuation(scope) {
19
19
  continuations.delete(scope);
20
20
  }
21
+ /** The invocation's schema-filtered snapshot, never raw continuation arguments. */
22
+ export function recordDownstreamArgumentEcho(scope, echo) {
23
+ const continuation = continuations.get(scope);
24
+ if (continuation)
25
+ continuation.argumentEcho = echo;
26
+ }
27
+ export function downstreamArgumentEcho(scope) {
28
+ return continuations.get(scope)?.argumentEcho ?? {};
29
+ }
21
30
  export function downstreamWriteContinuation(scope) {
22
31
  return continuations.get(scope)?.write === true;
23
32
  }
@@ -1,9 +1,9 @@
1
1
  import { inputRequired, specTypeSchemas, ProtocolError, INVALID_PARAMS, CLIENT_CAPABILITIES_META_KEY, PROTOCOL_VERSION_META_KEY, } from "@modelcontextprotocol/server";
2
- import { ConnectorCallError, echoedCallArgs } from "./errors.js";
3
- import { redactAgentOutput, sentSecretsForRequest } from "./sent-secrets.js";
2
+ import { ConnectorCallError } from "./errors.js";
3
+ import { sentSecretsForRequest } from "./sent-secrets.js";
4
4
  import { inputRetryKeys } from "./storage/keys.js";
5
5
  import { REQUEST_STATE_TTL_MS, MAX_INPUT_ROUNDS, invalidRequestState, requestDigest, stateObject, } from "./request-state.js";
6
- import { bindDownstreamCapabilities, downstreamInputCapabilities, bindDownstreamContinuation, clearDownstreamContinuation, assertNoPrivateStateEchoes, assertDownstreamOutputSafe, } from "./downstream-input-context.js";
6
+ import { bindDownstreamCapabilities, downstreamInputCapabilities, bindDownstreamContinuation, clearDownstreamContinuation, downstreamArgumentEcho, assertNoPrivateStateEchoes, assertDownstreamOutputSafe, } from "./downstream-input-context.js";
7
7
  const MAX_PAYLOAD_BYTES = 64 * 1024;
8
8
  export const MAX_RELAY_STATE_CHARS = 128 * 1024;
9
9
  const MAX_REQUESTS = 16;
@@ -67,7 +67,7 @@ export async function captureDownstreamInput(scope, connector, address, raw, sec
67
67
  url.password ||
68
68
  [...params.url].some((char) => char.charCodeAt(0) <= 0x20 || char.charCodeAt(0) === 0x7f) ||
69
69
  secrets.containsUrl(params.url) ||
70
- redactAgentOutput(secrets, params.url) !== params.url)
70
+ secrets.redact(params.url) !== params.url)
71
71
  return invalidInput();
72
72
  }
73
73
  catch {
@@ -84,7 +84,7 @@ export async function captureDownstreamInput(scope, connector, address, raw, sec
84
84
  return invalidInput();
85
85
  // A schema controls the accepted answer. Refuse redaction changes rather
86
86
  // than asking the host to submit different field names or enum values.
87
- if (redactAgentOutput(secrets, parsed.value.requestedSchema) !== parsed.value.requestedSchema)
87
+ if (secrets.redact(parsed.value.requestedSchema) !== parsed.value.requestedSchema)
88
88
  return invalidInput();
89
89
  inputRequests[namespace(connector, index)] = inputRequired.elicit({
90
90
  message: `Downstream ${connector}: ${parsed.value.message}`,
@@ -237,7 +237,7 @@ export class DownstreamElicitation {
237
237
  return result;
238
238
  // The nonce is spent even when the downstream failure would normally
239
239
  // allow a retry. Preserve write reconciliation and auth prerequisites.
240
- const echoed = echoedCallArgs(args.args);
240
+ const echoed = downstreamArgumentEcho(this.options.requestScope);
241
241
  const structuredContent = {
242
242
  ...result.structuredContent,
243
243
  error: {
@@ -249,9 +249,11 @@ export class DownstreamElicitation {
249
249
  tool: state.tool,
250
250
  arguments: { address: state.address, ...echoed },
251
251
  purpose: "Re-issue the original direct call without requestState or inputResponses to start a fresh input round. Do not resend this continuation." +
252
- (args.args !== undefined && !("args" in echoed)
253
- ? " Use the exact arguments you sent; they exceed the echo budget."
254
- : ""),
252
+ (echoed.argsRedacted
253
+ ? " Re-send the original arguments; sensitive fields are omitted."
254
+ : args.args !== undefined && !("args" in echoed)
255
+ ? " Use the exact arguments you sent; they could not be echoed safely."
256
+ : ""),
255
257
  },
256
258
  }
257
259
  : {}),
@@ -314,8 +316,8 @@ export class DownstreamElicitation {
314
316
  if (requestState.length > MAX_RELAY_STATE_CHARS)
315
317
  return failure("input_required_limit", "The sealed downstream state exceeds 128 KiB.");
316
318
  // Opaque echoes and mutable schemas/URLs were refused during capture.
317
- // Prompt messages take the same #736 agent boundary as ordinary outputs.
318
- return redactAgentOutput(sentSecretsForRequest(scope), inputRequired({
319
+ // Downstream prompt messages are prose under the payload rules.
320
+ return sentSecretsForRequest(scope).redact(inputRequired({
319
321
  ...(Object.keys(pending.inputRequests).length ? { inputRequests: pending.inputRequests } : {}),
320
322
  requestState,
321
323
  }));
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExecutorAdmissionErrorCode } from "./executor-admission.js";
2
+ import type { JsonSchema } from "./types.js";
2
3
  /**
3
4
  * Machine-readable classification of a failed connector tool call.
4
5
  *
@@ -77,14 +78,14 @@ export declare const MAX_ARGUMENT_VALIDATION_ISSUES = 3;
77
78
  */
78
79
  export declare function boundedEchoText(value: string, maxBytes?: number): string;
79
80
  /**
80
- * `{ args }` when the caller's arguments fit the shared echo budget,
81
- * `{}` when they do not. All or nothing: a clipped echo would be a *different*
82
- * call than the one that was refused, and the routes this feeds end at a human
83
- * approving one. Unserializable arguments are treated the same way as oversized
84
- * ones — there is nothing honest to put in the field.
81
+ * Snapshot the caller's arguments after bounded schema-aware writeOnly omission,
82
+ * then apply the shared byte budget without clipping. Unresolved sensitivity
83
+ * omits the entire echo. argsRedacted marks partial or withheld arguments as
84
+ * reconciliation context; any retry must use the caller's original arguments.
85
85
  */
86
- export declare function echoedCallArgs(args: unknown): {
86
+ export declare function echoedCallArgs(args: unknown, schema?: JsonSchema): {
87
87
  args?: unknown;
88
+ argsRedacted?: true;
88
89
  };
89
90
  /** Optional transport diagnostics; never a URL path or raw runtime message. */
90
91
  interface UnavailableDetails {
@@ -182,8 +183,25 @@ export declare class ConnectorCallError extends Error {
182
183
  current?: Readonly<Record<string, number>>;
183
184
  });
184
185
  }
186
+ /** Secret-free payload returned by authorize_connector and auth failures. */
187
+ export interface AuthorizationHandoff {
188
+ connector: string;
189
+ recovery: AuthRecoveryMode;
190
+ message?: string;
191
+ status?: "auth_required";
192
+ authorizationUrl?: string;
193
+ operatorUrl?: string;
194
+ instructions?: string;
195
+ credential?: {
196
+ label: string;
197
+ fields: {
198
+ name: string;
199
+ guidance?: string;
200
+ }[];
201
+ };
202
+ }
185
203
  /** The `error` object surfaced in value-mode call results and rejected promises. */
186
- export interface CallErrorDetails {
204
+ export interface CallErrorDetails extends Partial<AuthorizationHandoff> {
187
205
  /** Sanitized transport diagnostics, absent when the runtime supplies none. */
188
206
  details?: UnavailableDetails;
189
207
  code: string;
@@ -224,12 +242,13 @@ export interface CallErrorDetails {
224
242
  arguments: {
225
243
  address: string;
226
244
  /**
227
- * The caller's own arguments, echoed only when they fit
228
- * the shared echo budget — and then whole, never clipped. Absent
229
- * means "re-send exactly what you sent": a half-copied argument object
230
- * routed into a human approval prompt would describe a call nobody made.
245
+ * A schema-filtered snapshot within the shared byte budget, never
246
+ * clipped. With argsRedacted, this is partial reconciliation context.
247
+ * If absent or redacted, retry only with the original arguments.
231
248
  */
232
249
  args?: unknown;
250
+ /** Sensitive fields were omitted, or sensitivity could not be resolved. */
251
+ argsRedacted?: true;
233
252
  };
234
253
  purpose: string;
235
254
  } | {
@@ -253,11 +272,16 @@ export interface CallErrorDetails {
253
272
  };
254
273
  /** Configured connectors in this request's registry view only. */
255
274
  configuredConnectors?: string[];
256
- /** Agent-only uncertain write, never copied to an operator record. */
275
+ /**
276
+ * Agent-only uncertain write, never copied to an operator record. argsRedacted
277
+ * marks partial or withheld schema-filtered arguments; argsOmitted means args
278
+ * is absent. Reconciliation and any explicit retry require the original input.
279
+ */
257
280
  uncertainCall?: {
258
281
  address: string;
259
282
  args?: unknown;
260
283
  argsOmitted?: true;
284
+ argsRedacted?: true;
261
285
  };
262
286
  /** Explicit retry guidance; recovery never retries or mutates by itself. */
263
287
  retry?: string;