@zackbart/connecta 0.24.2 → 0.24.4
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/AGENTS.md +18 -20
- package/CHANGELOG.md +205 -1
- package/README.md +5 -6
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- package/dist/call-admission.d.ts +4 -0
- package/dist/call-admission.js +26 -0
- package/dist/catalog-drift.js +9 -4
- package/dist/catalog-service.d.ts +2 -0
- package/dist/catalog-service.js +25 -8
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -121
- package/dist/connectors/api.d.ts +1 -1
- package/dist/connectors/api.js +21 -3
- package/dist/connectors/guarded-fetch.d.ts +6 -2
- package/dist/connectors/guarded-fetch.js +61 -24
- package/dist/connectors/remote-mcp.js +92 -57
- package/dist/errors.d.ts +28 -3
- package/dist/errors.js +60 -1
- package/dist/execute.js +85 -23
- package/dist/executor-result.js +3 -1
- package/dist/executors/quickjs-child.js +5 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -0
- package/dist/executors/quickjs-runtime.d.ts +1 -1
- package/dist/executors/quickjs-runtime.js +38 -21
- package/dist/executors/quickjs.js +68 -27
- package/dist/index.d.ts +15 -1
- package/dist/index.js +36 -4
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +191 -36
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.d.ts +38 -1
- package/dist/operator-ui/view.js +71 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +83 -10
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +42 -4
- package/dist/providers/stripe.d.ts +7 -1
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +11 -1
- package/dist/registry.d.ts +22 -2
- package/dist/registry.js +101 -13
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +84 -13
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +1 -0
- package/dist/routes/shared.js +4 -4
- package/dist/server.js +15 -3
- package/dist/skills.js +6 -5
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.js +312 -34
- package/dist/storage/memory.js +12 -1
- package/dist/types.d.ts +37 -0
- package/dist/ui.js +18 -10
- package/dist/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +193 -165
- package/documentation/auth.md +199 -145
- package/documentation/code-mode.md +433 -316
- package/documentation/meta-tools.md +363 -355
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +12 -10
- package/examples/worker/src/index.ts +12 -15
- package/package.json +1 -2
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- package/documentation/call-admission.md +0 -142
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -389
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -722
- package/documentation/request-admission.md +0 -170
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -201
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -754
- package/documentation/vercel.md +0 -241
|
@@ -1,14 +1,33 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The safety machinery every hand-written HTTP surface needs, extracted once
|
|
3
|
+
* (#341): path confinement, encoded query and framed bodies, per-request
|
|
4
|
+
* credential headers applied last, a refused redirect, a bounded read, and
|
|
5
|
+
* network-failure normalization. It owns no *meaning* — it never reads a status
|
|
6
|
+
* code and never invents an auth scheme, because the same 403 means an ungranted
|
|
7
|
+
* capability at one provider and a token scope at another, and those want
|
|
8
|
+
* opposite next moves. The provider's `authenticate` supplies headers and its
|
|
9
|
+
* mapper turns one `GuardedResponse` into a result or a typed failure.
|
|
10
|
+
*
|
|
11
|
+
* Not exported this release: the migrations proved the shape preserves behavior
|
|
12
|
+
* for connectors that already had this machinery, not that it is the right
|
|
13
|
+
* public surface. Web APIs only.
|
|
14
|
+
*/
|
|
15
|
+
import { ConnectorCallError, unavailableCallError } from "../errors.js";
|
|
16
|
+
/** Parse delta-seconds or an HTTP-date into a non-negative wait window. */
|
|
4
17
|
export function retryAfterMs(headers) {
|
|
5
18
|
const raw = headers.get("retry-after");
|
|
6
19
|
if (!raw)
|
|
7
20
|
return undefined;
|
|
8
21
|
const seconds = Number(raw.trim());
|
|
9
|
-
if (
|
|
22
|
+
if (Number.isFinite(seconds)) {
|
|
23
|
+
return seconds < 0 ? undefined : Math.trunc(seconds * 1000);
|
|
24
|
+
}
|
|
25
|
+
// Require the HTTP-date shape, so Date.parse cannot reinterpret "-1" as
|
|
26
|
+
// a calendar date on runtimes that accept loose date strings.
|
|
27
|
+
if (!/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(raw.trim()))
|
|
10
28
|
return undefined;
|
|
11
|
-
|
|
29
|
+
const date = Date.parse(raw);
|
|
30
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
12
31
|
}
|
|
13
32
|
/** Statuses that instruct a client to re-send somewhere else. Never followed. */
|
|
14
33
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
@@ -117,7 +136,18 @@ async function drain(provider, stream, limit) {
|
|
|
117
136
|
}
|
|
118
137
|
return body;
|
|
119
138
|
}
|
|
120
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Every read is capped at `maxResponseBytes`: a streaming body is abandoned at
|
|
141
|
+
* the ceiling rather than buffered past it, and where a runtime gives no body
|
|
142
|
+
* stream the text is measured in UTF-8 bytes before it is accepted, with JSON
|
|
143
|
+
* parsed from that same bounded text. Such a runtime still buffers internally,
|
|
144
|
+
* but cannot return an oversized body as a successful result.
|
|
145
|
+
*
|
|
146
|
+
* A mapper must re-throw `ConnectorCallError` and swallow only what it
|
|
147
|
+
* recognizes: a bare `catch` around `response.json()` eats this refusal along
|
|
148
|
+
* with a parse error and turns a response nobody was allowed to read into an
|
|
149
|
+
* empty success.
|
|
150
|
+
*/
|
|
121
151
|
function boundedResponse(provider, response, limit) {
|
|
122
152
|
if (BODILESS_STATUSES.has(response.status)) {
|
|
123
153
|
const emptyJson = async () => undefined;
|
|
@@ -144,13 +174,20 @@ function boundedResponse(provider, response, limit) {
|
|
|
144
174
|
});
|
|
145
175
|
return read;
|
|
146
176
|
};
|
|
177
|
+
let readText;
|
|
178
|
+
const text = () => {
|
|
179
|
+
readText ??= stream
|
|
180
|
+
? bytes().then((body) => decoder.decode(body))
|
|
181
|
+
: response.text().then((body) => {
|
|
182
|
+
const size = encoder.encode(body).length;
|
|
183
|
+
if (size > limit)
|
|
184
|
+
throw oversized(provider, limit, `${size} bytes`);
|
|
185
|
+
return body;
|
|
186
|
+
});
|
|
187
|
+
return readText;
|
|
188
|
+
};
|
|
147
189
|
const json = async () => {
|
|
148
|
-
|
|
149
|
-
// directly is taken at its word, which is the one accessor on the one
|
|
150
|
-
// path where the ceiling cannot be applied.
|
|
151
|
-
if (!stream)
|
|
152
|
-
return await response.json();
|
|
153
|
-
const body = decoder.decode(await bytes());
|
|
190
|
+
const body = await text();
|
|
154
191
|
return body.trim() === "" ? undefined : JSON.parse(body);
|
|
155
192
|
};
|
|
156
193
|
return {
|
|
@@ -158,15 +195,7 @@ function boundedResponse(provider, response, limit) {
|
|
|
158
195
|
ok: response.ok,
|
|
159
196
|
headers: response.headers,
|
|
160
197
|
bytes,
|
|
161
|
-
|
|
162
|
-
if (stream)
|
|
163
|
-
return decoder.decode(await bytes());
|
|
164
|
-
const body = await response.text();
|
|
165
|
-
const size = encoder.encode(body).length;
|
|
166
|
-
if (size > limit)
|
|
167
|
-
throw oversized(provider, limit, `${size} bytes`);
|
|
168
|
-
return body;
|
|
169
|
-
},
|
|
198
|
+
text,
|
|
170
199
|
json,
|
|
171
200
|
jsonResult: () => jsonResult(json),
|
|
172
201
|
};
|
|
@@ -181,7 +210,11 @@ async function jsonResult(read) {
|
|
|
181
210
|
return { parseError: cause };
|
|
182
211
|
}
|
|
183
212
|
}
|
|
184
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Build the transport a connector sends every request through. `maxResponseBytes`
|
|
215
|
+
* is required rather than defaulted: what counts as an absurd response is a fact
|
|
216
|
+
* about the API, not about HTTP.
|
|
217
|
+
*/
|
|
185
218
|
export function guardedFetch(options) {
|
|
186
219
|
const { provider, maxResponseBytes: limit } = options;
|
|
187
220
|
if (!Number.isInteger(limit) || limit < 1) {
|
|
@@ -224,13 +257,17 @@ export function guardedFetch(options) {
|
|
|
224
257
|
: request.rawBody !== undefined
|
|
225
258
|
? { body: request.rawBody }
|
|
226
259
|
: {}),
|
|
227
|
-
//
|
|
260
|
+
// A redirect is an instruction to re-send the credential to whatever
|
|
261
|
+
// origin `Location` names, and a confinement a redirect can undo was
|
|
262
|
+
// never one. Refused below rather than followed.
|
|
228
263
|
redirect: "manual",
|
|
229
264
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
230
265
|
});
|
|
231
266
|
}
|
|
232
267
|
catch (cause) {
|
|
233
|
-
|
|
268
|
+
if (cause instanceof ConnectorCallError)
|
|
269
|
+
throw cause;
|
|
270
|
+
throw unavailableCallError(cause, url.href, `Could not reach the ${provider} API.`);
|
|
234
271
|
}
|
|
235
272
|
if (REDIRECT_STATUSES.has(response.status)) {
|
|
236
273
|
await response.body?.cancel().catch(() => { });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Client, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
1
|
+
import { Client, ProtocolError, SdkHttpError, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
2
2
|
import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
|
|
3
3
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
4
|
-
import { ConnectorCallError, msg } from "../errors.js";
|
|
4
|
+
import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
|
|
5
5
|
import { CONNECTA_VERSION } from "../version.js";
|
|
6
6
|
/**
|
|
7
7
|
* Apply a maintained provider's slot copy and header framing to credential
|
|
@@ -105,8 +105,11 @@ function isCursorShapeError(err) {
|
|
|
105
105
|
* close with the session left to age out as it did before.
|
|
106
106
|
*/
|
|
107
107
|
async function terminateSession(transport, logger, connectorId) {
|
|
108
|
-
//
|
|
109
|
-
//
|
|
108
|
+
// SDK v2's Client.close() does not send the legacy session DELETE on our
|
|
109
|
+
// behalf. Connecta's own endpoint creates no protocol session, but a stateful
|
|
110
|
+
// legacy downstream can still issue `Mcp-Session-Id`, and every path that
|
|
111
|
+
// abandons one — scope teardown, credential rotation, OAuth retirement, an
|
|
112
|
+
// abandoned connect — owes it this best-effort, one-second DELETE.
|
|
110
113
|
const terminate = transport.terminateSession;
|
|
111
114
|
if (typeof terminate !== "function")
|
|
112
115
|
return;
|
|
@@ -157,6 +160,28 @@ async function terminateSession(transport, logger, connectorId) {
|
|
|
157
160
|
});
|
|
158
161
|
});
|
|
159
162
|
}
|
|
163
|
+
/** Classify protocol/status facts; provider prose never decides retryability. */
|
|
164
|
+
function downstreamCallError(error) {
|
|
165
|
+
if (error instanceof ProtocolError && error.code === -32602) {
|
|
166
|
+
return new ConnectorCallError("invalid_args", boundedEchoText(error.message));
|
|
167
|
+
}
|
|
168
|
+
if (error instanceof SdkHttpError && error.status >= 400 && error.status < 500) {
|
|
169
|
+
let message = error.message;
|
|
170
|
+
if (typeof error.data.text === "string") {
|
|
171
|
+
try {
|
|
172
|
+
const body = JSON.parse(error.data.text);
|
|
173
|
+
const detail = body?.message ?? body?.error?.message ?? body?.error_description;
|
|
174
|
+
if (typeof detail === "string")
|
|
175
|
+
message = detail;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Non-JSON refusals still retain a bounded diagnostic.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return new ConnectorCallError(error.status === 429 ? "rate_limited" : error.status === 408 ? "timeout" : "connector_call_failed", boundedEchoText(message));
|
|
182
|
+
}
|
|
183
|
+
return error;
|
|
184
|
+
}
|
|
160
185
|
const encoder = new TextEncoder();
|
|
161
186
|
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
162
187
|
function base64Utf8(value) {
|
|
@@ -266,10 +291,18 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
|
|
|
266
291
|
const seen = new Set([current.href]);
|
|
267
292
|
let hops = 0;
|
|
268
293
|
while (true) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
294
|
+
let response;
|
|
295
|
+
try {
|
|
296
|
+
response = await baseFetch(current, {
|
|
297
|
+
...init,
|
|
298
|
+
redirect: "manual",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (cause) {
|
|
302
|
+
if (cause instanceof ConnectorCallError)
|
|
303
|
+
throw cause;
|
|
304
|
+
throw unavailableCallError(cause, current.href);
|
|
305
|
+
}
|
|
273
306
|
if (!REDIRECT_STATUSES.has(response.status))
|
|
274
307
|
return response;
|
|
275
308
|
const location = response.headers.get("location");
|
|
@@ -549,19 +582,38 @@ export function remoteMcp(id, opts) {
|
|
|
549
582
|
state.credentialDigest = null;
|
|
550
583
|
// `closed` is deliberately not cleared — see ConnectionState.
|
|
551
584
|
};
|
|
552
|
-
|
|
585
|
+
// The context has no deferred-work hook. Detached exits start this bounded
|
|
586
|
+
// best-effort tail immediately; closeScope awaits its own tail so the core
|
|
587
|
+
// can pass it to the runtime's deferred channel.
|
|
588
|
+
const closingSessions = new WeakMap();
|
|
589
|
+
const closeConnection = (client, transport, logger) => {
|
|
590
|
+
const previous = transport && closingSessions.get(transport);
|
|
591
|
+
if (previous)
|
|
592
|
+
return previous;
|
|
593
|
+
const closing = (async () => {
|
|
594
|
+
try {
|
|
595
|
+
if (transport)
|
|
596
|
+
await terminateSession(transport, logger, id);
|
|
597
|
+
if (client)
|
|
598
|
+
await client.close();
|
|
599
|
+
else
|
|
600
|
+
await transport?.close();
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// Local close cannot replace the operation's result.
|
|
604
|
+
}
|
|
605
|
+
})();
|
|
606
|
+
// A connect can acquire a session after an early close. Deduplicate only
|
|
607
|
+
// once that session exists, so its late abandonment still sends DELETE.
|
|
608
|
+
if (transport?.sessionId)
|
|
609
|
+
closingSessions.set(transport, closing);
|
|
610
|
+
return closing;
|
|
611
|
+
};
|
|
612
|
+
const closeHalf = (state, ctx) => {
|
|
553
613
|
const client = state.client;
|
|
554
614
|
const transport = state.transport;
|
|
555
615
|
reset(state);
|
|
556
|
-
|
|
557
|
-
if (client)
|
|
558
|
-
await client.close();
|
|
559
|
-
else
|
|
560
|
-
await transport?.close();
|
|
561
|
-
}
|
|
562
|
-
catch {
|
|
563
|
-
// The discarded state remains authoritative if local close fails.
|
|
564
|
-
}
|
|
616
|
+
void closeConnection(client, transport, ctx.logger);
|
|
565
617
|
};
|
|
566
618
|
const ensureConnected = async (ctx, state) => {
|
|
567
619
|
// A 401 after connect is a verdict for the whole request scope, not merely
|
|
@@ -581,7 +633,7 @@ export function remoteMcp(id, opts) {
|
|
|
581
633
|
if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
|
|
582
634
|
const connecting = state.connecting;
|
|
583
635
|
void connecting?.catch(() => { });
|
|
584
|
-
|
|
636
|
+
closeHalf(state, ctx);
|
|
585
637
|
throw operatorDisconnectedError();
|
|
586
638
|
}
|
|
587
639
|
}
|
|
@@ -592,7 +644,7 @@ export function remoteMcp(id, opts) {
|
|
|
592
644
|
if (state.closed)
|
|
593
645
|
throw scopeEndedError();
|
|
594
646
|
if (oauthGeneration !== state.connectedGeneration) {
|
|
595
|
-
|
|
647
|
+
closeHalf(state, ctx);
|
|
596
648
|
}
|
|
597
649
|
}
|
|
598
650
|
// The static-credential counterpart of the epoch read above, and
|
|
@@ -618,7 +670,7 @@ export function remoteMcp(id, opts) {
|
|
|
618
670
|
throw scopeEndedError();
|
|
619
671
|
const connecting = state.connecting;
|
|
620
672
|
void connecting?.catch(() => { });
|
|
621
|
-
|
|
673
|
+
closeHalf(state, ctx);
|
|
622
674
|
}
|
|
623
675
|
}
|
|
624
676
|
if (state.closed)
|
|
@@ -629,13 +681,8 @@ export function remoteMcp(id, opts) {
|
|
|
629
681
|
let attempt;
|
|
630
682
|
attempt = (async () => {
|
|
631
683
|
const ownsAttempt = () => state.connecting === attempt && !state.closed;
|
|
632
|
-
const abandon =
|
|
633
|
-
|
|
634
|
-
await owner.close();
|
|
635
|
-
}
|
|
636
|
-
catch {
|
|
637
|
-
// The attempt is detached either way.
|
|
638
|
-
}
|
|
684
|
+
const abandon = (client, transport) => {
|
|
685
|
+
void closeConnection(client, transport, ctx.logger);
|
|
639
686
|
throw scopeEndedError();
|
|
640
687
|
};
|
|
641
688
|
// Let the assignment immediately below this async IIFE publish
|
|
@@ -666,7 +713,7 @@ export function remoteMcp(id, opts) {
|
|
|
666
713
|
});
|
|
667
714
|
const t = buildTransport(ctx, provider, credentialFramed);
|
|
668
715
|
if (!ownsAttempt())
|
|
669
|
-
|
|
716
|
+
abandon(null, t);
|
|
670
717
|
state.transport = t;
|
|
671
718
|
try {
|
|
672
719
|
await c.connect(t);
|
|
@@ -675,7 +722,7 @@ export function remoteMcp(id, opts) {
|
|
|
675
722
|
// that race anyway, close the resulting client rather than
|
|
676
723
|
// resurrecting a session in the detached state object.
|
|
677
724
|
if (!ownsAttempt())
|
|
678
|
-
|
|
725
|
+
abandon(c, t);
|
|
679
726
|
// A force re-auth that landed WHILE we were connecting wiped the
|
|
680
727
|
// credentials this client just bound to. Discard it rather than
|
|
681
728
|
// cache a stale-isolate connection.
|
|
@@ -685,24 +732,22 @@ export function remoteMcp(id, opts) {
|
|
|
685
732
|
// connect succeeded but before this client is cached. Discard the
|
|
686
733
|
// client on that side of the await too.
|
|
687
734
|
if (!ownsAttempt())
|
|
688
|
-
|
|
735
|
+
abandon(c, t);
|
|
689
736
|
if (generation !== genAtStart) {
|
|
690
|
-
|
|
691
|
-
await c.close();
|
|
692
|
-
}
|
|
693
|
-
catch {
|
|
694
|
-
// discarding either way
|
|
695
|
-
}
|
|
737
|
+
void closeConnection(c, t, ctx.logger);
|
|
696
738
|
throw new UnauthorizedError("Connector was re-authorized during connect; reconnect required.");
|
|
697
739
|
}
|
|
698
740
|
}
|
|
699
741
|
if (!ownsAttempt())
|
|
700
|
-
|
|
742
|
+
abandon(c, t);
|
|
701
743
|
state.client = c;
|
|
702
744
|
state.connectedGeneration = genAtStart;
|
|
703
745
|
state.authRequired = false;
|
|
704
746
|
}
|
|
705
747
|
catch (err) {
|
|
748
|
+
void closeConnection(c, t, ctx.logger);
|
|
749
|
+
if (ownsAttempt())
|
|
750
|
+
state.transport = null;
|
|
706
751
|
// Only a real 401/UnauthorizedError means auth is the problem — a
|
|
707
752
|
// network error on an oauth connector must surface as "error", not
|
|
708
753
|
// "auth_required".
|
|
@@ -748,7 +793,7 @@ export function remoteMcp(id, opts) {
|
|
|
748
793
|
// client/transport exists. Reset is unconditional because KV may already
|
|
749
794
|
// be fenced behind a newer epoch after a cleanup error.
|
|
750
795
|
void connecting?.catch(() => { });
|
|
751
|
-
|
|
796
|
+
closeHalf(state, ctx);
|
|
752
797
|
}
|
|
753
798
|
};
|
|
754
799
|
const connector = {
|
|
@@ -825,8 +870,9 @@ export function remoteMcp(id, opts) {
|
|
|
825
870
|
// the accumulator is returned rather than stored: a cursor is opaque and
|
|
826
871
|
// session-bound, so nothing here may outlive this call.
|
|
827
872
|
async listTools(ctx) {
|
|
828
|
-
// The
|
|
829
|
-
//
|
|
873
|
+
// The catalog contract: a downstream catalog is complete or it is a
|
|
874
|
+
// failure. Follow every page to the end of the cursor chain, preserve
|
|
875
|
+
// schemas and annotations, and never cache or serve a partial walk.
|
|
830
876
|
const state = stateFor(ctx);
|
|
831
877
|
await ensureConnected(ctx, state);
|
|
832
878
|
// Bind the client once so the whole walk provably rides one session — a
|
|
@@ -976,7 +1022,7 @@ export function remoteMcp(id, opts) {
|
|
|
976
1022
|
state.authRequired = true;
|
|
977
1023
|
throw authRequiredError(err);
|
|
978
1024
|
}
|
|
979
|
-
throw err;
|
|
1025
|
+
throw downstreamCallError(err);
|
|
980
1026
|
}
|
|
981
1027
|
},
|
|
982
1028
|
async closeScope(ctx) {
|
|
@@ -993,20 +1039,7 @@ export function remoteMcp(id, opts) {
|
|
|
993
1039
|
const client = state.client;
|
|
994
1040
|
const transport = state.transport;
|
|
995
1041
|
reset(state);
|
|
996
|
-
|
|
997
|
-
// side, and the DELETE that frees the server's rides on the very
|
|
998
|
-
// AbortSignal the close is about to trip.
|
|
999
|
-
if (transport)
|
|
1000
|
-
await terminateSession(transport, ctx.logger, id);
|
|
1001
|
-
// Client.close() owns its connected transport. During an unfinished or
|
|
1002
|
-
// failed connect there is no cached client yet, so close the transport
|
|
1003
|
-
// directly to abort/release that half-open session.
|
|
1004
|
-
if (client) {
|
|
1005
|
-
await client.close();
|
|
1006
|
-
}
|
|
1007
|
-
else {
|
|
1008
|
-
await transport?.close();
|
|
1009
|
-
}
|
|
1042
|
+
await closeConnection(client, transport, ctx.logger);
|
|
1010
1043
|
},
|
|
1011
1044
|
async status(ctx) {
|
|
1012
1045
|
const state = stateFor(ctx);
|
|
@@ -1054,7 +1087,9 @@ export function remoteMcp(id, opts) {
|
|
|
1054
1087
|
}
|
|
1055
1088
|
await provider.clearPending();
|
|
1056
1089
|
// Reset so the next use reconnects with the freshly stored tokens.
|
|
1057
|
-
|
|
1090
|
+
if (!state.transport)
|
|
1091
|
+
state.transport = t;
|
|
1092
|
+
closeHalf(state, ctx);
|
|
1058
1093
|
},
|
|
1059
1094
|
};
|
|
1060
1095
|
if (opts.auth?.type === "oauth") {
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
* Machine-readable classification of a failed connector tool call.
|
|
3
3
|
*
|
|
4
4
|
* A code earns its place by changing what the caller does next, never by
|
|
5
|
-
* naming a cause — the rule provider conventions call H11
|
|
5
|
+
* naming a cause — the rule provider conventions call H11, defined in
|
|
6
|
+
* `test/provider-conventions.test.ts`.
|
|
6
7
|
*/
|
|
7
8
|
export type ConnectorCallErrorCode = "timeout" | "auth_required" | "rate_limited" | "unavailable" | "invalid_args"
|
|
8
|
-
/**
|
|
9
|
-
* the
|
|
9
|
+
/**
|
|
10
|
+
* Provider-owned absence: the caller re-addresses rather than waiting,
|
|
11
|
+
* re-authorizing, or repairing arguments, and a program looping over ids can
|
|
12
|
+
* continue past it where `connector_call_failed` would abort. Map a status
|
|
13
|
+
* here only where the provider distinguishes absence from a permission gap —
|
|
14
|
+
* where it does not (Notion's `object_not_found` covers both), the honest
|
|
15
|
+
* code stays `connector_call_failed` or `auth_required` with a message that
|
|
16
|
+
* states the ambiguity (H11).
|
|
17
|
+
*/
|
|
10
18
|
| "not_found" | "input_required_unsupported" | "connector_call_failed";
|
|
11
19
|
/** One bounded, payload-free explanation of an input-schema mismatch. */
|
|
12
20
|
export interface ArgumentValidationIssue {
|
|
@@ -39,6 +47,17 @@ export declare function boundedEchoText(value: string, maxBytes?: number): strin
|
|
|
39
47
|
export declare function echoedCallArgs(args: unknown): {
|
|
40
48
|
args?: unknown;
|
|
41
49
|
};
|
|
50
|
+
/** Optional transport diagnostics; never a URL path or raw runtime message. */
|
|
51
|
+
interface UnavailableDetails {
|
|
52
|
+
/** HTTP(S) origin only, at most 253 UTF-8 bytes. */
|
|
53
|
+
host?: string;
|
|
54
|
+
/** Validated network errno or `timeout`, at most 32 bytes. */
|
|
55
|
+
code?: string;
|
|
56
|
+
}
|
|
57
|
+
/** Runtime fields only: provider prose cannot supply an errno or a deadline. */
|
|
58
|
+
export declare function networkErrorCode(error: unknown): string | undefined;
|
|
59
|
+
/** Use at a fetch boundary where the destination and transport failure are known. */
|
|
60
|
+
export declare function unavailableCallError(cause: unknown, host?: string, message?: string): ConnectorCallError;
|
|
42
61
|
/** Agent-visible recovery class attached only to `auth_required` failures. */
|
|
43
62
|
export type AuthRecoveryMode = "oauth" | "operator_config" | "unavailable";
|
|
44
63
|
/**
|
|
@@ -67,15 +86,20 @@ export declare class ConnectorCallError extends Error {
|
|
|
67
86
|
readonly retryAfterMs: number | undefined;
|
|
68
87
|
/** Bounded schema findings for `invalid_args`; never submitted values. */
|
|
69
88
|
readonly validation: ArgumentValidationDetails | undefined;
|
|
89
|
+
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
90
|
+
readonly details: UnavailableDetails | undefined;
|
|
70
91
|
constructor(code: ConnectorCallErrorCode, message: string, opts?: {
|
|
71
92
|
retryable?: boolean;
|
|
72
93
|
retryAfterMs?: number;
|
|
73
94
|
cause?: unknown;
|
|
74
95
|
validation?: ArgumentValidationDetails;
|
|
96
|
+
details?: UnavailableDetails;
|
|
75
97
|
});
|
|
76
98
|
}
|
|
77
99
|
/** The `error` object surfaced in value-mode call results and rejected promises. */
|
|
78
100
|
export interface CallErrorDetails {
|
|
101
|
+
/** Sanitized transport diagnostics, absent when the runtime supplies none. */
|
|
102
|
+
details?: UnavailableDetails;
|
|
79
103
|
code: string;
|
|
80
104
|
message: string;
|
|
81
105
|
retryable: boolean;
|
|
@@ -151,3 +175,4 @@ export declare function framingError(code: string, message: string): CallErrorDe
|
|
|
151
175
|
*/
|
|
152
176
|
export declare function classifyCallError(err: unknown, fallbackCode?: string): CallErrorDetails;
|
|
153
177
|
export declare function msg(err: unknown): string;
|
|
178
|
+
export {};
|
package/dist/errors.js
CHANGED
|
@@ -20,7 +20,8 @@ function boundedIssueText(value, maxChars) {
|
|
|
20
20
|
* the payload lands in both the text content and `structuredContent`. The agent
|
|
21
21
|
* already holds what it sent; the echo is a convenience, never the record.
|
|
22
22
|
*/
|
|
23
|
-
//
|
|
23
|
+
// One budget shared by the argument and text echoes, so a caller cannot make a
|
|
24
|
+
// refusal larger by splitting what it sent across the two.
|
|
24
25
|
const MAX_ECHOED_BYTES = 512;
|
|
25
26
|
/**
|
|
26
27
|
* The same budget spent on caller-authored *text* — the address it mistyped,
|
|
@@ -102,6 +103,58 @@ function boundedValidation(details) {
|
|
|
102
103
|
...(truncated ? { truncated: true } : {}),
|
|
103
104
|
};
|
|
104
105
|
}
|
|
106
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
107
|
+
"ECONNREFUSED", "ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN",
|
|
108
|
+
"EAI_FAIL", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EHOSTDOWN",
|
|
109
|
+
"ECONNABORTED", "EPIPE", "EACCES", "EPERM",
|
|
110
|
+
"UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
|
|
111
|
+
"UND_ERR_SOCKET", "timeout",
|
|
112
|
+
]);
|
|
113
|
+
function networkCode(value) {
|
|
114
|
+
return typeof value === "string" && value.length <= 32 && NETWORK_ERROR_CODES.has(value)
|
|
115
|
+
? value
|
|
116
|
+
: undefined;
|
|
117
|
+
}
|
|
118
|
+
function sanitizedUnavailableDetails(details) {
|
|
119
|
+
if (!details)
|
|
120
|
+
return undefined;
|
|
121
|
+
let host;
|
|
122
|
+
if (typeof details.host === "string") {
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(details.host);
|
|
125
|
+
if ((url.protocol === "https:" || url.protocol === "http:") &&
|
|
126
|
+
echoEncoder.encode(url.origin).length <= 253)
|
|
127
|
+
host = url.origin;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// An invalid or oversized origin is absent, never a clipped destination.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const code = networkCode(details.code);
|
|
134
|
+
return host || code ? { ...(host ? { host } : {}), ...(code ? { code } : {}) } : undefined;
|
|
135
|
+
}
|
|
136
|
+
/** Runtime fields only: provider prose cannot supply an errno or a deadline. */
|
|
137
|
+
export function networkErrorCode(error) {
|
|
138
|
+
if (!error || typeof error !== "object")
|
|
139
|
+
return undefined;
|
|
140
|
+
if (error instanceof Error &&
|
|
141
|
+
(error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
142
|
+
return "timeout";
|
|
143
|
+
}
|
|
144
|
+
const runtime = error;
|
|
145
|
+
const cause = runtime.cause;
|
|
146
|
+
return networkCode(runtime.code) ?? (cause && typeof cause === "object"
|
|
147
|
+
? networkCode(cause.code)
|
|
148
|
+
: undefined);
|
|
149
|
+
}
|
|
150
|
+
/** Use at a fetch boundary where the destination and transport failure are known. */
|
|
151
|
+
export function unavailableCallError(cause, host, message = "Could not reach the downstream service.") {
|
|
152
|
+
const code = networkErrorCode(cause);
|
|
153
|
+
return new ConnectorCallError("unavailable", message, {
|
|
154
|
+
cause,
|
|
155
|
+
details: { ...(host ? { host } : {}), ...(code ? { code } : {}) },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
105
158
|
const RETRYABLE_BY_CODE = {
|
|
106
159
|
timeout: true,
|
|
107
160
|
rate_limited: true,
|
|
@@ -146,12 +199,16 @@ export class ConnectorCallError extends Error {
|
|
|
146
199
|
retryAfterMs;
|
|
147
200
|
/** Bounded schema findings for `invalid_args`; never submitted values. */
|
|
148
201
|
validation;
|
|
202
|
+
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
203
|
+
details;
|
|
149
204
|
constructor(code, message, opts = {}) {
|
|
150
205
|
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
151
206
|
this.name = "ConnectorCallError";
|
|
152
207
|
this.code = code;
|
|
153
208
|
this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
|
|
154
209
|
this.retryAfterMs = normalizeRetryAfterMs(opts.retryAfterMs);
|
|
210
|
+
this.details =
|
|
211
|
+
code === "unavailable" ? sanitizedUnavailableDetails(opts.details) : undefined;
|
|
155
212
|
this.validation =
|
|
156
213
|
code === "invalid_args" ? boundedValidation(opts.validation) : undefined;
|
|
157
214
|
}
|
|
@@ -164,6 +221,7 @@ export class ConnectorCallError extends Error {
|
|
|
164
221
|
* trusts the flag would cheerfully retry a refusal forever.
|
|
165
222
|
*/
|
|
166
223
|
const NEVER_RETRYABLE_FRAMING = new Set([
|
|
224
|
+
"result_processing_failed",
|
|
167
225
|
"unknown_address",
|
|
168
226
|
"unknown_tool",
|
|
169
227
|
"ambiguous_tool_alias",
|
|
@@ -203,6 +261,7 @@ export function classifyCallError(err, fallbackCode = "connector_call_failed") {
|
|
|
203
261
|
? { retryAfterMs: err.retryAfterMs }
|
|
204
262
|
: {}),
|
|
205
263
|
...(err.validation ? { validation: err.validation } : {}),
|
|
264
|
+
...(err.details ? { details: err.details } : {}),
|
|
206
265
|
};
|
|
207
266
|
}
|
|
208
267
|
// An aborted fetch rejects with a DOMException named "AbortError" whose
|