@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.
- package/CHANGELOG.md +99 -0
- package/README.md +126 -134
- package/bin/connecta.mjs +13 -2
- package/bin/version.mjs +1 -1
- package/dist/argument-redaction.js +230 -0
- package/dist/auth-elicitation.js +14 -9
- package/dist/authorization-handoff.js +111 -0
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-cache.js +2 -2
- package/dist/catalog-service.js +103 -63
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -7
- package/dist/connectors/guarded-fetch.d.ts +9 -1
- package/dist/connectors/guarded-fetch.js +17 -5
- package/dist/connectors/negotiation-cache.js +1 -1
- package/dist/downstream-input-context.js +9 -0
- package/dist/downstream-input.js +13 -11
- package/dist/errors.d.ts +38 -12
- package/dist/errors.js +19 -9
- package/dist/execute.js +375 -43
- package/dist/executor-result.js +11 -9
- package/dist/executors/quickjs-child.js +12 -5
- package/dist/executors/quickjs-protocol.js +5 -6
- package/dist/executors/quickjs-runtime.js +6 -1
- package/dist/executors/quickjs.js +20 -4
- package/dist/guest-runtime.js +52 -2
- package/dist/guest-types.d.ts +47 -5
- package/dist/index.d.ts +1 -1
- package/dist/invocation.js +72 -14
- package/dist/meta-output.js +0 -15
- package/dist/meta-tools.js +166 -95
- package/dist/oauth-handoff.js +88 -11
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.d.ts +9 -0
- package/dist/operator-ui/view.js +11 -0
- package/dist/program-result.js +5 -0
- package/dist/program-writes.js +7 -48
- package/dist/provider.d.ts +8 -6
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/request-token.js +83 -0
- package/dist/providers/_shared/rest/dispatch.js +41 -0
- package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
- package/dist/providers/_shared/rest/operation-index.js +527 -0
- package/dist/providers/_shared/rest/tools.d.ts +141 -0
- package/dist/providers/_shared/rest/tools.js +569 -0
- package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
- package/dist/providers/_shared/rest/value-safety.js +580 -0
- package/dist/providers/cloudflare/graphql.js +385 -0
- package/dist/providers/cloudflare/index.d.ts +47 -51
- package/dist/providers/cloudflare/index.js +188 -1997
- package/dist/providers/cloudflare/openapi.generated.js +3347 -0
- package/dist/providers/cloudflare/rest.d.ts +37 -0
- package/dist/providers/cloudflare/rest.js +1056 -0
- package/dist/providers/cloudflare/skill.generated.js +7 -4
- package/dist/providers/cloudflare/value-safety.js +1247 -0
- package/dist/providers/infisical/index.d.ts +7 -0
- package/dist/providers/infisical/index.js +713 -0
- package/dist/providers/infisical/skill.generated.js +8 -0
- package/dist/providers/notion/index.d.ts +35 -31
- package/dist/providers/notion/index.js +60 -1499
- package/dist/providers/notion/openapi.generated.js +75 -0
- package/dist/providers/notion/rest.d.ts +24 -0
- package/dist/providers/notion/rest.js +1067 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- package/dist/providers/stripe/index.d.ts +26 -20
- package/dist/providers/stripe/index.js +103 -130
- package/dist/providers/stripe/openapi.generated.js +649 -0
- package/dist/providers/stripe/rest.d.ts +14 -0
- package/dist/providers/stripe/rest.js +395 -0
- package/dist/providers/stripe/skill.generated.js +7 -5
- package/dist/providers/stripe/value-safety.js +705 -0
- package/dist/providers/vercel/index.d.ts +31 -22
- package/dist/providers/vercel/index.js +96 -853
- package/dist/providers/vercel/openapi.generated.js +456 -0
- package/dist/providers/vercel/rest.d.ts +20 -0
- package/dist/providers/vercel/rest.js +1002 -0
- package/dist/providers/vercel/skill.generated.js +4 -5
- package/dist/providers/vercel/value-safety.js +328 -0
- package/dist/registry.d.ts +8 -6
- package/dist/registry.js +134 -61
- package/dist/resource-read.js +4 -1
- package/dist/routes/mcp.js +11 -2
- package/dist/sent-secrets.d.ts +54 -13
- package/dist/sent-secrets.js +770 -231
- package/dist/skills.js +26 -16
- package/dist/sqlite.js +21 -6
- package/dist/storage/keys.js +20 -5
- package/dist/storage/memory.js +9 -4
- package/dist/storage/sql.js +18 -9
- package/dist/types.d.ts +17 -3
- package/dist/usage-guide.js +49 -40
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +2 -1
- package/dist/write-outcome.js +47 -0
- package/documentation/README.md +64 -0
- package/documentation/architecture.md +241 -97
- package/documentation/auth.md +138 -5
- package/documentation/code-mode.md +224 -107
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +170 -0
- package/documentation/meta-tools.md +142 -30
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +4 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-auth-migration.md +307 -0
- package/documentation/provider-migration-0.29.md +22 -5
- package/examples/worker/README.md +9 -2
- package/package.json +9 -2
- package/templates/node/README.md +9 -2
- package/templates/node/package.json +1 -1
- 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
|
+
}
|
package/dist/auth-elicitation.js
CHANGED
|
@@ -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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
+
}
|
package/dist/catalog-cache.js
CHANGED
|
@@ -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).
|
|
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).
|
|
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 {
|
package/dist/catalog-service.js
CHANGED
|
@@ -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.
|
|
800
|
+
const catalogErrors = (await Promise.all(catalogs.map(async (catalog, index) => {
|
|
741
801
|
if (Result.isSuccess(catalog))
|
|
742
802
|
return [];
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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 {
|