@zackbart/connecta 0.29.0 → 0.31.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 (116) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +126 -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/call-recovery.js +25 -0
  9. package/dist/catalog-cache.js +2 -2
  10. package/dist/catalog-service.js +103 -63
  11. package/dist/config-schema.d.ts +14 -7
  12. package/dist/config-schema.js +29 -10
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +12 -7
  15. package/dist/connectors/guarded-fetch.d.ts +9 -1
  16. package/dist/connectors/guarded-fetch.js +17 -5
  17. package/dist/connectors/negotiation-cache.js +1 -1
  18. package/dist/downstream-input-context.js +9 -0
  19. package/dist/downstream-input.js +13 -11
  20. package/dist/errors.d.ts +38 -12
  21. package/dist/errors.js +19 -9
  22. package/dist/execute.js +375 -43
  23. package/dist/executor-result.js +11 -9
  24. package/dist/executors/quickjs-child.js +12 -5
  25. package/dist/executors/quickjs-protocol.js +5 -6
  26. package/dist/executors/quickjs-runtime.js +6 -1
  27. package/dist/executors/quickjs.js +20 -4
  28. package/dist/guest-runtime.js +52 -2
  29. package/dist/guest-types.d.ts +47 -5
  30. package/dist/index.d.ts +1 -1
  31. package/dist/invocation.js +72 -14
  32. package/dist/meta-output.js +0 -15
  33. package/dist/meta-tools.js +166 -95
  34. package/dist/oauth-handoff.js +88 -11
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +9 -0
  37. package/dist/operator-ui/view.js +11 -0
  38. package/dist/program-result.js +5 -0
  39. package/dist/program-writes.js +7 -48
  40. package/dist/provider.d.ts +8 -6
  41. package/dist/provider.js +3 -4
  42. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  43. package/dist/providers/_shared/request-token.js +83 -0
  44. package/dist/providers/_shared/rest/dispatch.js +41 -0
  45. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  46. package/dist/providers/_shared/rest/operation-index.js +527 -0
  47. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  48. package/dist/providers/_shared/rest/tools.js +569 -0
  49. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  50. package/dist/providers/_shared/rest/value-safety.js +580 -0
  51. package/dist/providers/cloudflare/graphql.js +385 -0
  52. package/dist/providers/cloudflare/index.d.ts +47 -51
  53. package/dist/providers/cloudflare/index.js +188 -1997
  54. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  55. package/dist/providers/cloudflare/rest.d.ts +37 -0
  56. package/dist/providers/cloudflare/rest.js +1056 -0
  57. package/dist/providers/cloudflare/skill.generated.js +7 -4
  58. package/dist/providers/cloudflare/value-safety.js +1247 -0
  59. package/dist/providers/infisical/index.d.ts +7 -0
  60. package/dist/providers/infisical/index.js +713 -0
  61. package/dist/providers/infisical/skill.generated.js +8 -0
  62. package/dist/providers/notion/index.d.ts +35 -31
  63. package/dist/providers/notion/index.js +60 -1499
  64. package/dist/providers/notion/openapi.generated.js +75 -0
  65. package/dist/providers/notion/rest.d.ts +24 -0
  66. package/dist/providers/notion/rest.js +1067 -0
  67. package/dist/providers/notion/skill.generated.js +5 -4
  68. package/dist/providers/notion/value-safety.js +31 -0
  69. package/dist/providers/stripe/index.d.ts +26 -20
  70. package/dist/providers/stripe/index.js +103 -130
  71. package/dist/providers/stripe/openapi.generated.js +649 -0
  72. package/dist/providers/stripe/rest.d.ts +14 -0
  73. package/dist/providers/stripe/rest.js +395 -0
  74. package/dist/providers/stripe/skill.generated.js +7 -5
  75. package/dist/providers/stripe/value-safety.js +705 -0
  76. package/dist/providers/vercel/index.d.ts +31 -22
  77. package/dist/providers/vercel/index.js +96 -853
  78. package/dist/providers/vercel/openapi.generated.js +456 -0
  79. package/dist/providers/vercel/rest.d.ts +20 -0
  80. package/dist/providers/vercel/rest.js +1002 -0
  81. package/dist/providers/vercel/skill.generated.js +4 -5
  82. package/dist/providers/vercel/value-safety.js +328 -0
  83. package/dist/registry.d.ts +8 -6
  84. package/dist/registry.js +134 -61
  85. package/dist/resource-read.js +4 -1
  86. package/dist/routes/mcp.js +11 -2
  87. package/dist/sent-secrets.d.ts +54 -13
  88. package/dist/sent-secrets.js +770 -231
  89. package/dist/skills.js +26 -16
  90. package/dist/sqlite.js +21 -6
  91. package/dist/storage/keys.js +20 -5
  92. package/dist/storage/memory.js +9 -4
  93. package/dist/storage/sql.js +18 -9
  94. package/dist/types.d.ts +17 -3
  95. package/dist/usage-guide.js +49 -40
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/dist/worker.js +2 -1
  99. package/dist/write-outcome.js +47 -0
  100. package/documentation/README.md +64 -0
  101. package/documentation/architecture.md +241 -97
  102. package/documentation/auth.md +138 -5
  103. package/documentation/code-mode.md +224 -107
  104. package/documentation/deploying.md +146 -0
  105. package/documentation/integrating.md +170 -0
  106. package/documentation/meta-tools.md +142 -30
  107. package/documentation/operating.md +135 -0
  108. package/documentation/operator-tests.md +4 -0
  109. package/documentation/operator-ui.md +132 -0
  110. package/documentation/provider-auth-migration.md +307 -0
  111. package/documentation/provider-migration-0.29.md +22 -5
  112. package/examples/worker/README.md +9 -2
  113. package/package.json +9 -2
  114. package/templates/node/README.md +9 -2
  115. package/templates/node/package.json +1 -1
  116. package/dist/providers/vercel/mcp-ownership.js +0 -212
@@ -0,0 +1,230 @@
1
+ const PUBLIC = {};
2
+ const OMIT = { omit: true };
3
+ const OMITTED = Symbol("omitted argument");
4
+ const MAX_DEPTH = 32;
5
+ const MAX_SCHEMAS = 2048;
6
+ function object(value) {
7
+ return value !== null && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ function sensitive(plan) {
10
+ return Object.keys(plan).length > 0;
11
+ }
12
+ function equal(left, right) {
13
+ return JSON.stringify(left) === JSON.stringify(right);
14
+ }
15
+ function item(plan, index) {
16
+ return plan.prefix?.[index] ?? plan.items ?? PUBLIC;
17
+ }
18
+ /** Combine restrictions from references, siblings, and allOf without losing array indices. */
19
+ function merge(left, right) {
20
+ if (left.omit || right.omit)
21
+ return OMIT;
22
+ const names = new Set([...Object.keys(left.properties ?? {}), ...Object.keys(right.properties ?? {})]);
23
+ const properties = Object.fromEntries([...names]
24
+ .sort()
25
+ .map((name) => [
26
+ name,
27
+ merge(Object.hasOwn(left.properties ?? {}, name) ? left.properties[name] : PUBLIC, Object.hasOwn(right.properties ?? {}, name) ? right.properties[name] : PUBLIC),
28
+ ]));
29
+ const items = mergeItems(left.items ?? PUBLIC, right.items ?? PUBLIC);
30
+ const prefix = Array.from({ length: Math.max(left.prefix?.length ?? 0, right.prefix?.length ?? 0) }, (_, index) => mergeItems(item(left, index), item(right, index)));
31
+ // Equivalent plans compare equally even when one schema spells out public prefixes.
32
+ while (prefix.length && equal(prefix.at(-1), items))
33
+ prefix.pop();
34
+ return {
35
+ ...(names.size ? { properties } : {}),
36
+ ...(prefix.length ? { prefix } : {}),
37
+ ...(sensitive(items) ? { items } : {}),
38
+ };
39
+ }
40
+ function mergeItems(left, right) {
41
+ if (!sensitive(left))
42
+ return right;
43
+ if (!sensitive(right))
44
+ return left;
45
+ return merge(left, right);
46
+ }
47
+ /** Compile a bounded plan. Unknown sensitivity refuses the entire echo, never just a branch. */
48
+ function compile(root, unionAlternatives = false) {
49
+ let visited = 0;
50
+ const active = new Set();
51
+ const unresolved = () => {
52
+ throw new Error("Unresolved argument sensitivity");
53
+ };
54
+ function reference(ref) {
55
+ if (typeof ref !== "string" || !ref.startsWith("#"))
56
+ return unresolved();
57
+ const pointer = decodeURIComponent(ref.slice(1));
58
+ if (pointer === "")
59
+ return root;
60
+ if (!pointer.startsWith("/"))
61
+ return unresolved();
62
+ let target = root;
63
+ for (const encoded of pointer.slice(1).split("/")) {
64
+ if (/~[^01]|~$/.test(encoded))
65
+ return unresolved();
66
+ const key = encoded.replace(/~1/g, "/").replace(/~0/g, "~");
67
+ if (target === null || typeof target !== "object" || !Object.hasOwn(target, key))
68
+ return unresolved();
69
+ target = target[key];
70
+ }
71
+ return target;
72
+ }
73
+ function walk(schema, depth) {
74
+ if (++visited > MAX_SCHEMAS || depth > MAX_DEPTH)
75
+ return unresolved();
76
+ if (typeof schema === "boolean")
77
+ return PUBLIC;
78
+ if (!object(schema) || active.has(schema))
79
+ return unresolved();
80
+ if (schema["writeOnly"] !== undefined && typeof schema["writeOnly"] !== "boolean")
81
+ return unresolved();
82
+ if (schema["writeOnly"] === true)
83
+ return OMIT;
84
+ if ((schema !== root && schema["$id"] !== undefined) ||
85
+ schema["$dynamicRef"] !== undefined ||
86
+ schema["$recursiveRef"] !== undefined)
87
+ return unresolved();
88
+ active.add(schema);
89
+ try {
90
+ let plan = PUBLIC;
91
+ const child = (value) => walk(value, depth + 1);
92
+ if (schema["$ref"] !== undefined)
93
+ plan = merge(plan, child(reference(schema["$ref"])));
94
+ if (schema["properties"] !== undefined) {
95
+ if (!object(schema["properties"]))
96
+ return unresolved();
97
+ const properties = Object.entries(schema["properties"])
98
+ .map(([name, value]) => [name, child(value)])
99
+ .filter(([, value]) => sensitive(value))
100
+ .sort(([a], [b]) => a.localeCompare(b));
101
+ plan = merge(plan, properties.length ? { properties: Object.fromEntries(properties) } : PUBLIC);
102
+ }
103
+ const prefixes = schema["prefixItems"] ?? (Array.isArray(schema["items"]) ? schema["items"] : undefined);
104
+ if (prefixes !== undefined && !Array.isArray(prefixes))
105
+ return unresolved();
106
+ if (schema["prefixItems"] !== undefined && Array.isArray(schema["items"]))
107
+ return unresolved();
108
+ const tail = Array.isArray(schema["items"]) ? schema["additionalItems"] : schema["items"];
109
+ const arrayPlan = {
110
+ ...(prefixes ? { prefix: prefixes.map(child) } : {}),
111
+ ...(tail !== undefined ? { items: child(tail) } : {}),
112
+ };
113
+ plan = merge(plan, arrayPlan);
114
+ for (const keyword of ["allOf", "oneOf", "anyOf"]) {
115
+ const branches = schema[keyword];
116
+ if (branches === undefined)
117
+ continue;
118
+ if (!Array.isArray(branches) || !branches.length)
119
+ return unresolved();
120
+ const plans = branches.map(child);
121
+ if (keyword === "allOf" || unionAlternatives)
122
+ for (const branch of plans)
123
+ plan = merge(plan, branch);
124
+ else {
125
+ if (plans.some((branch) => !equal(branch, plans[0])))
126
+ return unresolved();
127
+ plan = merge(plan, plans[0]);
128
+ }
129
+ }
130
+ // These applicators need matching/evaluation rules we do not implement.
131
+ // Public-only schemas are harmless; sensitivity or an unresolved reference fails closed.
132
+ for (const keyword of [
133
+ "additionalProperties",
134
+ "unevaluatedProperties",
135
+ "unevaluatedItems",
136
+ "contains",
137
+ "not",
138
+ "if",
139
+ "then",
140
+ "else",
141
+ ])
142
+ if (schema[keyword] !== undefined && sensitive(child(schema[keyword])))
143
+ return unresolved();
144
+ for (const keyword of ["patternProperties", "dependentSchemas", "dependencies"]) {
145
+ if (schema[keyword] === undefined)
146
+ continue;
147
+ if (!object(schema[keyword]))
148
+ return unresolved();
149
+ for (const value of Object.values(schema[keyword])) {
150
+ if (keyword === "dependencies" && Array.isArray(value))
151
+ continue;
152
+ if (sensitive(child(value)))
153
+ return unresolved();
154
+ }
155
+ }
156
+ return plan;
157
+ }
158
+ finally {
159
+ active.delete(schema);
160
+ }
161
+ }
162
+ return walk(root, 0);
163
+ }
164
+ /** Omit whole array values when private elements cannot be removed without shifting indices. */
165
+ function apply(value, plan, mark, depth = 0) {
166
+ if (!sensitive(plan))
167
+ return value;
168
+ if (depth > MAX_DEPTH)
169
+ throw new Error("Argument redaction depth exceeded");
170
+ if (plan.omit) {
171
+ mark(value);
172
+ return OMITTED;
173
+ }
174
+ if (Array.isArray(value)) {
175
+ const values = value.map((entry, index) => apply(entry, item(plan, index), mark, depth + 1));
176
+ return values.includes(OMITTED) ? OMITTED : values;
177
+ }
178
+ if (!object(value))
179
+ return value;
180
+ return Object.fromEntries(Object.entries(value).flatMap(([name, entry]) => {
181
+ const field = Object.hasOwn(plan.properties ?? {}, name) ? plan.properties[name] : PUBLIC;
182
+ const filtered = apply(entry, field, mark, depth + 1);
183
+ return filtered === OMITTED ? [] : [[name, filtered]];
184
+ }));
185
+ }
186
+ /** Output redaction protects the union of private fields across alternatives.
187
+ * Other sensitivity rules and unresolved-schema handling follow echo redaction. */
188
+ export function visitPrivateCallArguments(args, schema, visit) {
189
+ // Public-only schemas need no sensitivity compilation, regardless of size.
190
+ // Iterative traversal visits each object once, including definitions and cycles.
191
+ const pending = [schema];
192
+ const seen = new Set();
193
+ let privateSchema = false;
194
+ while (pending.length) {
195
+ const value = pending.pop();
196
+ if (value === null || typeof value !== "object" || seen.has(value))
197
+ continue;
198
+ seen.add(value);
199
+ if (Object.hasOwn(value, "writeOnly") && value["writeOnly"] === true) {
200
+ privateSchema = true;
201
+ break;
202
+ }
203
+ for (const entry of Object.values(value))
204
+ pending.push(entry);
205
+ }
206
+ if (!privateSchema)
207
+ return;
208
+ try {
209
+ apply(args, compile(schema, true), visit);
210
+ }
211
+ catch {
212
+ // When a private annotation exists but its location cannot be resolved,
213
+ // treat every submitted value as private, just as the echo is withheld.
214
+ visit(args);
215
+ }
216
+ }
217
+ /** Internal schema filter; the single echo helper owns snapshotting and byte budgeting. */
218
+ export function redactCallArguments(args, schema) {
219
+ try {
220
+ const plan = compile(schema);
221
+ let redacted = false;
222
+ const value = apply(args, plan, () => {
223
+ redacted = true;
224
+ });
225
+ return { ...(value !== OMITTED ? { value } : {}), redacted };
226
+ }
227
+ catch {
228
+ return { redacted: true };
229
+ }
230
+ }
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ const MAX_FACTS = 4;
2
+ const MAX_TEXT = 128;
3
+ const NAME = /^[A-Za-z][A-Za-z0-9]*$/;
4
+ const facts = new WeakMap();
5
+ /**
6
+ * Record facts that let the caller reconcile or safely retry this call's
7
+ * write. Bounded: at most four string facts with short camelCase names; a fact
8
+ * outside the bound is dropped rather than truncated.
9
+ */
10
+ export function recordRecovery(ctx, recovery) {
11
+ const kept = { ...facts.get(ctx) };
12
+ for (const [name, value] of Object.entries(recovery)) {
13
+ if (Object.keys(kept).length >= MAX_FACTS && !Object.hasOwn(kept, name))
14
+ break;
15
+ if (!NAME.test(name) || name.length > 32 || typeof value !== "string" || value.length > MAX_TEXT)
16
+ continue;
17
+ kept[name] = value;
18
+ }
19
+ facts.set(ctx, Object.freeze(kept));
20
+ }
21
+ /** The facts a connector recorded on this call's context, if any. */
22
+ export function recoveryFor(ctx) {
23
+ const recorded = ctx ? facts.get(ctx) : undefined;
24
+ return recorded && Object.keys(recorded).length > 0 ? recorded : undefined;
25
+ }
@@ -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 {