@zackbart/connecta 0.38.0 → 0.40.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 +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// Error mapping at the MCP boundary: what of an SDK, runtime or downstream
|
|
2
|
+
// error survives, how it is classified, and the connector's own error classes.
|
|
3
|
+
import { routePhrases } from "../../agent-routes.js";
|
|
4
|
+
import { authFlow, authCall, authMethod } from "../../auth/native.js";
|
|
5
|
+
import { AuthorizationServerMismatchError, InsecureTokenEndpointError, InsufficientScopeError, IssuerMismatchError, MissingRequiredClientCapabilityError, OAuthClientFlowError, OAuthError, ProtocolError, RegistrationRejectedError, ResourceNotFoundError, SdkError, SdkErrorCode, SdkHttpError, SseError, UnsupportedProtocolVersionError, UrlElicitationRequiredError, isJSONRPCErrorResponse, isJSONRPCNotification, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
6
|
+
import { redactSentSecrets, requestSentSecrets } from "../../sent-secrets.js";
|
|
7
|
+
import { boundedEchoText, ConnectorCallError, msg, networkErrorCode, resendAdviceOf, unavailableCallError, WithheldTextError, withResendAdvice, } from "../../errors.js";
|
|
8
|
+
import { attachFailureFacts, carryFailureFacts, errorLabel, failureRecord, labelErrorClass, OAUTH_ERROR_CODES, } from "../../operator-record.js";
|
|
9
|
+
/**
|
|
10
|
+
* An `authorize_connector` start message for a failure: its text, which has
|
|
11
|
+
* passed the MCP and OAuth boundaries, and the origin and errno connecta
|
|
12
|
+
* derived for it, which a message alone would drop.
|
|
13
|
+
*/
|
|
14
|
+
/** An authorization start's failure for authorize_connector: no write's, so its advice stays. */
|
|
15
|
+
export function startMessage(err) {
|
|
16
|
+
const details = err instanceof ConnectorCallError ? err.details : undefined;
|
|
17
|
+
const facts = [details?.host, details?.code].filter(Boolean).join(", ");
|
|
18
|
+
return withResendAdvice(facts ? `${msg(err)} (${facts})` : msg(err), resendAdviceOf(err));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One Promise step of an Effect program. It fails with exactly what the
|
|
22
|
+
* promise rejected with, never a wrapper, because callers check the SDK's and
|
|
23
|
+
* connecta's own error classes. `evaluate` takes no signal, so the step
|
|
24
|
+
* allocates no AbortController.
|
|
25
|
+
*/
|
|
26
|
+
export function promised(evaluate) {
|
|
27
|
+
return authCall(evaluate, []);
|
|
28
|
+
}
|
|
29
|
+
/** A retained cause chain must not smuggle an SDK payload past the boundary. */
|
|
30
|
+
export function hasSdkPayload(error) {
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
for (let current = error; current instanceof Error && !seen.has(current); current = current.cause) {
|
|
33
|
+
if (current instanceof ProtocolError || current instanceof SdkError)
|
|
34
|
+
return true;
|
|
35
|
+
seen.add(current);
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/** Cancellation can bypass an inner SDK catch and return the signal's reason. */
|
|
40
|
+
export function payloadFree(run) {
|
|
41
|
+
return authMethod((...args) => authFlow(function* () {
|
|
42
|
+
try {
|
|
43
|
+
return (yield authCall(run, [...args]));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw hasSdkPayload(error) ? carryFailureFacts(error, downstreamCallError(error)) : error;
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
/** Both the SDK's auth signal and its classified form invalidate a live client. */
|
|
51
|
+
export function requiresAuthorization(error) {
|
|
52
|
+
return (error instanceof UnauthorizedError ||
|
|
53
|
+
(error instanceof ConnectorCallError &&
|
|
54
|
+
(error.code === "auth_required" || error.code === "downstream_oauth_required")));
|
|
55
|
+
}
|
|
56
|
+
/** Classify SDK/runtime facts, dropping every SDK payload and cause chain. */
|
|
57
|
+
export function downstreamCallError(error, httpStatus, wait, oauthStep, secrets) {
|
|
58
|
+
if (error instanceof ConnectorCallError) {
|
|
59
|
+
return hasSdkPayload(error.cause) ? carryFailureFacts(error, withheldAs(error.message, error)) : error;
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof WithheldTextError || error instanceof UnauthorizedError)
|
|
62
|
+
return error;
|
|
63
|
+
const network = !(error instanceof SdkError) && !(error instanceof OAuthClientFlowError) ? networkErrorCode(error) : undefined;
|
|
64
|
+
if (network) {
|
|
65
|
+
return network === "timeout" || network === "ETIMEDOUT" || network.endsWith("_TIMEOUT")
|
|
66
|
+
? new ConnectorCallError("timeout", "The downstream request timed out.")
|
|
67
|
+
: unavailableCallError(error);
|
|
68
|
+
}
|
|
69
|
+
const status = error instanceof SdkHttpError || error instanceof RegistrationRejectedError ? error.status : httpStatus;
|
|
70
|
+
if (error instanceof InsufficientScopeError ||
|
|
71
|
+
(status === 403 && !(error instanceof RegistrationRejectedError)) ||
|
|
72
|
+
(error instanceof OAuthError && ["insufficient_scope", "invalid_scope", "access_denied"].includes(error.code))) {
|
|
73
|
+
return new ConnectorCallError("provider_permission_denied", "The provider denied this operation. Check the account's permissions and the tool's required access with the provider or an administrator.");
|
|
74
|
+
}
|
|
75
|
+
// Token endpoints return typed OAuth recovery codes on HTTP 400. Other
|
|
76
|
+
// OAuth steps, especially registration, keep their HTTP status verdict.
|
|
77
|
+
if (error instanceof OAuthError &&
|
|
78
|
+
OAUTH_ERROR_CODES.has(error.code) &&
|
|
79
|
+
(status === undefined || oauthStep === "token request")) {
|
|
80
|
+
return new ConnectorCallError(["invalid_client", "invalid_grant", "invalid_token", "unauthorized_client"].includes(error.code)
|
|
81
|
+
? "downstream_oauth_required"
|
|
82
|
+
: error.code === "too_many_requests"
|
|
83
|
+
? "rate_limited"
|
|
84
|
+
: ["server_error", "temporarily_unavailable"].includes(error.code)
|
|
85
|
+
? "unavailable"
|
|
86
|
+
: "connector_call_failed", `OAuth failed with error ${error.code}.`);
|
|
87
|
+
}
|
|
88
|
+
if (status !== undefined && status >= 400) {
|
|
89
|
+
let message = `The downstream service answered HTTP ${status}.`;
|
|
90
|
+
if (error instanceof SdkHttpError && status < 500 && typeof error.data.text === "string") {
|
|
91
|
+
message = error.message;
|
|
92
|
+
try {
|
|
93
|
+
const body = JSON.parse(error.data.text);
|
|
94
|
+
const detail = body?.message ?? body?.error?.message ?? body?.error_description;
|
|
95
|
+
if (typeof detail === "string")
|
|
96
|
+
message = detail;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Non-JSON MCP refusals retain only a bounded diagnostic.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return new ConnectorCallError(status === 401 && !(error instanceof RegistrationRejectedError)
|
|
103
|
+
? "auth_required"
|
|
104
|
+
: status === 429
|
|
105
|
+
? "rate_limited"
|
|
106
|
+
: status === 408 && !(error instanceof RegistrationRejectedError)
|
|
107
|
+
? "timeout"
|
|
108
|
+
: "connector_call_failed", boundedEchoText(secrets?.text(message) ?? message), {
|
|
109
|
+
retryable: [429, 502, 503, 504].includes(status) || (status === 408 && !(error instanceof RegistrationRejectedError)),
|
|
110
|
+
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) {
|
|
114
|
+
return new ConnectorCallError("timeout", "The downstream request timed out.");
|
|
115
|
+
}
|
|
116
|
+
// ProtocolError.data is server-chosen even when its message is allowed.
|
|
117
|
+
if (error instanceof ProtocolError) {
|
|
118
|
+
return new ConnectorCallError(error.code === -32602 ? "invalid_args" : "connector_call_failed", boundedEchoText(secrets?.text(error.message) ?? error.message));
|
|
119
|
+
}
|
|
120
|
+
return new ConnectorCallError("connector_call_failed", "The downstream operation failed.");
|
|
121
|
+
}
|
|
122
|
+
/** A connection whose request scope ended while it was being made. */
|
|
123
|
+
export class ScopeEndedError extends Error {
|
|
124
|
+
name = "ScopeEndedError";
|
|
125
|
+
}
|
|
126
|
+
labelErrorClass(ScopeEndedError, "ScopeEndedError");
|
|
127
|
+
/** The MCP client's transport lacks the OAuth flow seams this release is pinned against. */
|
|
128
|
+
export class UnboundOAuthFlowsError extends Error {
|
|
129
|
+
name = "UnboundOAuthFlowsError";
|
|
130
|
+
}
|
|
131
|
+
labelErrorClass(UnboundOAuthFlowsError, "UnboundOAuthFlowsError");
|
|
132
|
+
// The pinned SDK's exported errors, labelled by identity for records and
|
|
133
|
+
// connecta's fixed text.
|
|
134
|
+
for (const [ctor, label] of [
|
|
135
|
+
[SdkError, "SdkError"],
|
|
136
|
+
[SdkHttpError, "SdkHttpError"],
|
|
137
|
+
[ProtocolError, "ProtocolError"],
|
|
138
|
+
[MissingRequiredClientCapabilityError, "MissingRequiredClientCapabilityError"],
|
|
139
|
+
[ResourceNotFoundError, "ResourceNotFoundError"],
|
|
140
|
+
[UnsupportedProtocolVersionError, "UnsupportedProtocolVersionError"],
|
|
141
|
+
[UrlElicitationRequiredError, "UrlElicitationRequiredError"],
|
|
142
|
+
[SseError, "SseError"],
|
|
143
|
+
[OAuthError, "OAuthError"],
|
|
144
|
+
[UnauthorizedError, "UnauthorizedError"],
|
|
145
|
+
[OAuthClientFlowError, "OAuthClientFlowError"],
|
|
146
|
+
[AuthorizationServerMismatchError, "AuthorizationServerMismatchError"],
|
|
147
|
+
[InsecureTokenEndpointError, "InsecureTokenEndpointError"],
|
|
148
|
+
[InsufficientScopeError, "InsufficientScopeError"],
|
|
149
|
+
[IssuerMismatchError, "IssuerMismatchError"],
|
|
150
|
+
[RegistrationRejectedError, "RegistrationRejectedError"],
|
|
151
|
+
]) {
|
|
152
|
+
labelErrorClass(ctor, label);
|
|
153
|
+
}
|
|
154
|
+
/** The JSON-RPC error answers each transport delivered, as `code\0message`. */
|
|
155
|
+
const wireErrors = new WeakMap();
|
|
156
|
+
/** Answers remembered per transport; one request scope rarely sees more. */
|
|
157
|
+
const MAX_WIRE_ERRORS = 64;
|
|
158
|
+
function wireErrorKey(code, message) {
|
|
159
|
+
return `${String(code)}\u0000${String(message)}`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Remember every JSON-RPC error response `transport` delivers, so that a
|
|
163
|
+
* `ProtocolError` can be told apart as the downstream's own answer. The SDK
|
|
164
|
+
* builds its local refusals (an output schema it cannot compile, a result
|
|
165
|
+
* that fails it) from the same class, with its own text about what the
|
|
166
|
+
* downstream sent; only one whose code and message arrived on the wire is
|
|
167
|
+
* the answer. Installed before `Client.connect`, whose protocol calls the
|
|
168
|
+
* `onmessage` it finds there ahead of its own for every message, and whose
|
|
169
|
+
* version probe restores it when the probe is done.
|
|
170
|
+
*/
|
|
171
|
+
/** The JSON-RPC error a response body is, when it is one. */
|
|
172
|
+
export function jsonRpcErrorBody(text) {
|
|
173
|
+
if (typeof text !== "string")
|
|
174
|
+
return undefined;
|
|
175
|
+
try {
|
|
176
|
+
const message = JSON.parse(text);
|
|
177
|
+
if (message?.jsonrpc === "2.0" && Number.isInteger(message.error?.code))
|
|
178
|
+
return message.error;
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether a 400's recognized modern error leaves a modern verdict usable.
|
|
185
|
+
* `HeaderMismatch` (-32020) and `MissingRequiredClientCapability` (-32021)
|
|
186
|
+
* concern the request alone. `UnsupportedProtocolVersion` (-32022) keeps it
|
|
187
|
+
* only while its `supported` list, the versions the server now accepts, still
|
|
188
|
+
* names the version the request selected; a missing, malformed or other list
|
|
189
|
+
* ends the verdict. Anything else is a legacy refusal.
|
|
190
|
+
*/
|
|
191
|
+
export function keepsModernVerdict(error, selected) {
|
|
192
|
+
if (error?.code === -32020 || error?.code === -32021)
|
|
193
|
+
return true;
|
|
194
|
+
if (error?.code !== -32022 || selected === null)
|
|
195
|
+
return false;
|
|
196
|
+
const supported = error.data?.supported;
|
|
197
|
+
return (Array.isArray(supported) &&
|
|
198
|
+
supported.length <= 64 &&
|
|
199
|
+
supported.every((version) => typeof version === "string") &&
|
|
200
|
+
supported.includes(selected));
|
|
201
|
+
}
|
|
202
|
+
/** HTTP refusals with their own accounting: authorization, scope and throttling. */
|
|
203
|
+
export const AUTH_OR_THROTTLE = [401, 403, 429];
|
|
204
|
+
/**
|
|
205
|
+
* A bounded clone of an error answer's body, read before the SDK reads the
|
|
206
|
+
* original: `""` when longer than the bound, `undefined` for an event stream,
|
|
207
|
+
* an unreadable body, or a read one of `signals` ended. An abort cancels the
|
|
208
|
+
* clone's reader at once, so no read outlives the request or its scope (INV-7).
|
|
209
|
+
*/
|
|
210
|
+
export async function errorBodyText(response, signals) {
|
|
211
|
+
if (response.headers.get("content-type")?.toLowerCase().includes("text/event-stream"))
|
|
212
|
+
return undefined;
|
|
213
|
+
const reader = response.clone().body?.getReader();
|
|
214
|
+
const stop = () => void reader?.cancel().catch(() => { });
|
|
215
|
+
const ended = () => signals.some((signal) => signal?.aborted);
|
|
216
|
+
for (const signal of signals)
|
|
217
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
218
|
+
try {
|
|
219
|
+
const decoder = new TextDecoder();
|
|
220
|
+
let text = "";
|
|
221
|
+
for (let bytes = 0; reader && !ended();) {
|
|
222
|
+
const { done, value } = await reader.read();
|
|
223
|
+
if (done)
|
|
224
|
+
break;
|
|
225
|
+
bytes += value.byteLength;
|
|
226
|
+
if (bytes > MAX_ERROR_BODY_BYTES) {
|
|
227
|
+
stop();
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
text += decoder.decode(value, { stream: true });
|
|
231
|
+
}
|
|
232
|
+
if (ended()) {
|
|
233
|
+
stop();
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
return text + decoder.decode();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
stop();
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
for (const signal of signals)
|
|
244
|
+
signal?.removeEventListener("abort", stop);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const MAX_ERROR_BODY_BYTES = 16_384;
|
|
248
|
+
export function recordWireErrors(transport, catalogChanged) {
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
wireErrors.set(transport, seen);
|
|
251
|
+
transport.onmessage = (message) => {
|
|
252
|
+
// The public transport observer runs before SDK 2.3.1's delete-based
|
|
253
|
+
// eviction. Ordinary opposite-scope cleanup never rotates this fence.
|
|
254
|
+
if (isJSONRPCNotification(message) &&
|
|
255
|
+
(message.method === "notifications/tools/list_changed" ||
|
|
256
|
+
message.method === "notifications/resources/list_changed"))
|
|
257
|
+
catalogChanged();
|
|
258
|
+
if (isJSONRPCErrorResponse(message) && seen.size < MAX_WIRE_ERRORS) {
|
|
259
|
+
seen.add(wireErrorKey(message.error.code, message.error.message));
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Whether an error leaving the SDK may keep its own text. The boundary is an
|
|
265
|
+
* allow-list: a JSON-RPC error the downstream answered with, an HTTP 4xx
|
|
266
|
+
* refusal from the MCP endpoint, and errors already in connecta's words (its
|
|
267
|
+
* own classes, an OAuth error the token responses were rebuilt into, and
|
|
268
|
+
* `UnauthorizedError`, whose class is the whole verdict and whose text is
|
|
269
|
+
* never shown). The request's own abort reason is checked by identity before
|
|
270
|
+
* this. Everything else is withheld.
|
|
271
|
+
*/
|
|
272
|
+
function keepsItsText(err, transport) {
|
|
273
|
+
if (err instanceof ConnectorCallError ||
|
|
274
|
+
err instanceof WithheldTextError ||
|
|
275
|
+
err instanceof UnauthorizedError ||
|
|
276
|
+
err instanceof OAuthError) {
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
if (err instanceof SdkHttpError)
|
|
280
|
+
return err.status >= 400 && err.status < 500;
|
|
281
|
+
if (err instanceof ProtocolError) {
|
|
282
|
+
return transport !== undefined && wireErrors.get(transport)?.has(wireErrorKey(err.code, err.message)) === true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The SDK's own checks of a tool's output schema, recognized by how the
|
|
288
|
+
* pinned SDK (2.3.1) begins their messages, and told in connecta's words.
|
|
289
|
+
* Recognition reads the SDK's text only to choose connecta's: none of it, nor
|
|
290
|
+
* the validator's account that follows, is repeated.
|
|
291
|
+
*/
|
|
292
|
+
const SDK_OUTPUT_SCHEMA_CHECKS = [
|
|
293
|
+
[
|
|
294
|
+
/^Structured content does not match the tool's output schema/,
|
|
295
|
+
"the result did not match the tool's declared output schema",
|
|
296
|
+
],
|
|
297
|
+
[
|
|
298
|
+
/^Tool .* has an output schema but did not return structured content/s,
|
|
299
|
+
"the result did not return structured content although the tool declares an output schema",
|
|
300
|
+
],
|
|
301
|
+
[/^Tool .* has an invalid outputSchema/s, "the tool's declared output schema could not be compiled"],
|
|
302
|
+
];
|
|
303
|
+
function sdkCheckFailed(err) {
|
|
304
|
+
if (err instanceof SdkError && err.code === SdkErrorCode.RequestTimeout)
|
|
305
|
+
return ": the request timed out";
|
|
306
|
+
if (!(err instanceof ProtocolError))
|
|
307
|
+
return "";
|
|
308
|
+
const check = SDK_OUTPUT_SCHEMA_CHECKS.find(([pattern]) => pattern.test(err.message));
|
|
309
|
+
return check ? `: ${check[1]}` : "";
|
|
310
|
+
}
|
|
311
|
+
/** The first error in `err`'s cause chain already told in connecta's words. */
|
|
312
|
+
function connectaErrorWithin(err) {
|
|
313
|
+
const seen = new Set();
|
|
314
|
+
for (let current = err instanceof Error ? err.cause : undefined; current instanceof Error && !seen.has(current); current = current.cause) {
|
|
315
|
+
seen.add(current);
|
|
316
|
+
if (current instanceof ConnectorCallError || current instanceof WithheldTextError)
|
|
317
|
+
return current;
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
/** The request's own abort reason, by identity, never by name or class. */
|
|
322
|
+
export function ownAbortReason(err, signals) {
|
|
323
|
+
return signals.some((signal) => signal?.aborted === true && err === signal.reason);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The request's own abort reason as the SDK hands it back: a request it
|
|
327
|
+
* cancels rejects with `new SdkError(RequestTimeout, String(reason))` unless
|
|
328
|
+
* the reason already is one (pinned against client 2.3.1). Its text is the
|
|
329
|
+
* caller's, so it passes as the SDK wrote it.
|
|
330
|
+
*/
|
|
331
|
+
function ownAbortReasonAsSdkReports(err, signals) {
|
|
332
|
+
return (err instanceof SdkError &&
|
|
333
|
+
err.code === SdkErrorCode.RequestTimeout &&
|
|
334
|
+
signals.some((signal) => signal?.aborted === true && err.message === String(signal.reason)));
|
|
335
|
+
}
|
|
336
|
+
/** An error's class name, when it is a plain identifier worth naming. */
|
|
337
|
+
export function errorKind(err) {
|
|
338
|
+
const label = errorLabel(err);
|
|
339
|
+
return label && label !== "Error" ? ` (${label})` : "";
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Replace text while keeping a verdict already derived from structured facts.
|
|
343
|
+
* Never retain the original SDK error as a cause.
|
|
344
|
+
*/
|
|
345
|
+
export function withheldAs(message, verdict) {
|
|
346
|
+
if (verdict instanceof ConnectorCallError) {
|
|
347
|
+
return new ConnectorCallError(verdict.code, message, {
|
|
348
|
+
retryable: verdict.retryable,
|
|
349
|
+
...(verdict.retryAfterMs !== undefined ? { retryAfterMs: verdict.retryAfterMs } : {}),
|
|
350
|
+
...(verdict.details ? { details: verdict.details } : {}),
|
|
351
|
+
...(verdict.validation ? { validation: verdict.validation } : {}),
|
|
352
|
+
...(verdict.current ? { current: verdict.current } : {}),
|
|
353
|
+
...(verdict.writeOutcome ? { writeOutcome: verdict.writeOutcome } : {}),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return new WithheldTextError(message, verdict);
|
|
357
|
+
}
|
|
358
|
+
/** One connector's error classes and boundary mapping. */
|
|
359
|
+
export function remoteMcpErrors(rt) {
|
|
360
|
+
const { isOauth, id, endpointOrigin, requestAuth } = rt;
|
|
361
|
+
/**
|
|
362
|
+
* Typed per-call auth signal. The `UnauthorizedError` behind it is not kept
|
|
363
|
+
* as cause: the class is the whole verdict, and a logger rendering the
|
|
364
|
+
* chain would render whatever text the error carried.
|
|
365
|
+
*/
|
|
366
|
+
const authRequiredError = (ctx) => new ConnectorCallError(isOauth ? "downstream_oauth_required" : "auth_required", `Connector "${id}" requires authorization — ${routePhrases(ctx).authorizeAndOpen(id)}.`);
|
|
367
|
+
/**
|
|
368
|
+
* The MCP boundary, where an error leaves the SDK at the handshake, a
|
|
369
|
+
* `tools/list` page, or a `tools/call`. The request's own abort reason
|
|
370
|
+
* passes first, by identity, then what `keepsItsText` allows. Anything else
|
|
371
|
+
* (a parser's or validator's account, a non-4xx body, a runtime's message)
|
|
372
|
+
* is told as the step, the endpoint's origin, the HTTP status if there was
|
|
373
|
+
* one, and the error's class, classified as `verdict` would have been.
|
|
374
|
+
*/
|
|
375
|
+
const atMcpBoundary = (ctx, err, step, transport, signals) => {
|
|
376
|
+
// An OAuth flow already attached the step and origin it failed at. The
|
|
377
|
+
// outer MCP operation must not replace them with the endpoint's facts.
|
|
378
|
+
if (failureRecord({}, err).step?.startsWith("OAuth "))
|
|
379
|
+
return downstreamCallError(err);
|
|
380
|
+
const httpStatus = err instanceof SdkHttpError ? err.status : undefined;
|
|
381
|
+
// Skills operations retain the existing closed operator-record vocabulary.
|
|
382
|
+
const facts = {
|
|
383
|
+
...(step === "skills/list" || step === "resources/read" ? {} : { step }),
|
|
384
|
+
origin: endpointOrigin,
|
|
385
|
+
...(httpStatus ? { httpStatus } : {}),
|
|
386
|
+
};
|
|
387
|
+
const classified = downstreamCallError(redactSentSecrets(ctx, err), undefined, undefined, undefined, requestSentSecrets(ctx));
|
|
388
|
+
const verdict = isOauth && classified instanceof ConnectorCallError && classified.code === "auth_required"
|
|
389
|
+
? carryFailureFacts(err, authRequiredError(ctx))
|
|
390
|
+
: classified;
|
|
391
|
+
if (ownAbortReasonAsSdkReports(err, signals)) {
|
|
392
|
+
return attachFailureFacts(carryFailureFacts(err, withheldAs(msg(err), verdict)), facts);
|
|
393
|
+
}
|
|
394
|
+
if (err instanceof InsufficientScopeError) {
|
|
395
|
+
return attachFailureFacts(carryFailureFacts(err, verdict), facts);
|
|
396
|
+
}
|
|
397
|
+
if (requestAuth && httpStatus === 401) {
|
|
398
|
+
return attachFailureFacts(new ConnectorCallError("auth_required", "The downstream rejected this request's Bearer token."), facts);
|
|
399
|
+
}
|
|
400
|
+
if (ownAbortReason(err, signals) || keepsItsText(err, transport)) {
|
|
401
|
+
return attachFailureFacts(carryFailureFacts(err, redactSentSecrets(ctx, err instanceof UnauthorizedError || (ownAbortReason(err, signals) && !hasSdkPayload(err)) ? err : verdict)), facts);
|
|
402
|
+
}
|
|
403
|
+
// The SDK wraps a failure of connecta's own fetch (a refused redirect, an
|
|
404
|
+
// unreachable host) in an error of its own, such as the version probe's;
|
|
405
|
+
// that failure's words are connecta's, and they say what happened.
|
|
406
|
+
const ours = connectaErrorWithin(err);
|
|
407
|
+
if (ours) {
|
|
408
|
+
return attachFailureFacts(carryFailureFacts(ours, withheldAs(ours.message, ours)), facts);
|
|
409
|
+
}
|
|
410
|
+
const status = httpStatus ? ` with HTTP ${httpStatus}` : "";
|
|
411
|
+
return attachFailureFacts(carryFailureFacts(err, withheldAs(`Connector "${id}" ${step} with ${endpointOrigin} failed${status}` +
|
|
412
|
+
`${errorKind(err)}${sdkCheckFailed(err)}. The error is withheld because ` +
|
|
413
|
+
"its text can quote what the server sent.", verdict)), facts);
|
|
414
|
+
};
|
|
415
|
+
class OperatorDisconnectedError extends ConnectorCallError {
|
|
416
|
+
constructor() {
|
|
417
|
+
super("downstream_oauth_required", `Connector "${id}" was disconnected by an operator — explicitly start authorization to reconnect it.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const operatorDisconnectedError = () => new OperatorDisconnectedError();
|
|
421
|
+
/**
|
|
422
|
+
* The credential slot is declared but the vault has nothing in it (or has no
|
|
423
|
+
* key to read it with). Deliberately the same `auth_required` code an absent
|
|
424
|
+
* OAuth grant produces: the agent's next move is `authorize_connector`
|
|
425
|
+
* either way, and that tool reads `connector.credential` to hand the
|
|
426
|
+
* operator the connection in the UI when available instead of a consent URL.
|
|
427
|
+
*/
|
|
428
|
+
class CredentialRequiredError extends ConnectorCallError {
|
|
429
|
+
constructor(message) {
|
|
430
|
+
super("auth_required", message);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* A request resolver threw something untyped. Calls still fail with
|
|
435
|
+
* `auth_required`, but status reports an error: the resolver's own outage
|
|
436
|
+
* is not an authorization the operator can grant.
|
|
437
|
+
*/
|
|
438
|
+
class UnresolvedTokenError extends ConnectorCallError {
|
|
439
|
+
constructor() {
|
|
440
|
+
super("auth_required", "The request token could not be resolved.");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Replace, never edit.
|
|
445
|
+
*
|
|
446
|
+
* Any error whose message quotes the credential — the raw value or the
|
|
447
|
+
* framed header it becomes — is discarded whole and replaced with a fixed
|
|
448
|
+
* sentence. Nothing is substringed, masked, or truncated out of the original:
|
|
449
|
+
* a redaction that keeps part of a secret is still a leak, and the original
|
|
450
|
+
* error is not worth one. This is defense in depth behind the validation
|
|
451
|
+
* above, which is what keeps an unsendable value from reaching a transport
|
|
452
|
+
* at all.
|
|
453
|
+
*/
|
|
454
|
+
const withoutCredential = (ctx, err, ...secrets) => {
|
|
455
|
+
const quoted = secrets.filter((secret) => typeof secret === "string" && secret !== "");
|
|
456
|
+
if (quoted.length === 0)
|
|
457
|
+
return err;
|
|
458
|
+
const seen = new Set();
|
|
459
|
+
let current = err;
|
|
460
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
461
|
+
seen.add(current);
|
|
462
|
+
// Decided already, beneath the transport, and wrapped by the SDK on
|
|
463
|
+
// the way up (the version probe's error, say).
|
|
464
|
+
if (current instanceof CredentialRequiredError)
|
|
465
|
+
return current;
|
|
466
|
+
if (quoted.some((secret) => current instanceof Error && current.message.includes(secret))) {
|
|
467
|
+
return new CredentialRequiredError(`Connector "${id}" could not send its stored credential as a ` +
|
|
468
|
+
`header. ${routePhrases(ctx).authorizeFor("recovery options")}. When available, ` +
|
|
469
|
+
"re-enter it in this connection in the operator UI. " +
|
|
470
|
+
"The value is not shown or logged.");
|
|
471
|
+
}
|
|
472
|
+
current = current.cause;
|
|
473
|
+
}
|
|
474
|
+
return err;
|
|
475
|
+
};
|
|
476
|
+
// Its own class, so that a status record, which carries no message, still says so.
|
|
477
|
+
const scopeEndedError = () => new ScopeEndedError(`Connector "${id}" scope ended during connection.`);
|
|
478
|
+
return {
|
|
479
|
+
UnresolvedTokenError,
|
|
480
|
+
CredentialRequiredError,
|
|
481
|
+
atMcpBoundary,
|
|
482
|
+
scopeEndedError,
|
|
483
|
+
authRequiredError,
|
|
484
|
+
withoutCredential,
|
|
485
|
+
operatorDisconnectedError,
|
|
486
|
+
OperatorDisconnectedError,
|
|
487
|
+
};
|
|
488
|
+
}
|