@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.
- package/CHANGELOG.md +67 -0
- package/README.md +123 -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/catalog-cache.js +2 -2
- package/dist/catalog-service.js +103 -63
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -7
- package/dist/connectors/guarded-fetch.js +3 -3
- 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 +36 -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 +40 -14
- package/dist/meta-output.js +0 -15
- package/dist/meta-tools.js +166 -95
- package/dist/oauth-handoff.js +88 -11
- package/dist/program-result.js +5 -0
- package/dist/program-writes.js +5 -1
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/request-token.js +83 -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/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/documentation/README.md +63 -0
- package/documentation/architecture.md +121 -89
- package/documentation/auth.md +138 -5
- package/documentation/code-mode.md +224 -107
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +159 -0
- package/documentation/meta-tools.md +135 -30
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +4 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-migration-0.29.md +5 -0
- package/examples/worker/README.md +7 -0
- package/package.json +7 -1
- package/templates/node/README.md +7 -0
- package/templates/node/package.json +1 -1
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
|
+
}
|
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 {
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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)
|
|
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
|
|
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).
|
|
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
|
}
|
package/dist/downstream-input.js
CHANGED
|
@@ -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
|
|
3
|
-
import {
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
(
|
|
253
|
-
? "
|
|
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
|
-
//
|
|
318
|
-
return
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
-
/**
|
|
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;
|