@zackbart/connecta 0.24.1 → 0.24.3
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 +169 -0
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/clerk.d.ts +0 -5
- package/dist/auth/clerk.js +21 -8
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- 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/connector-access.d.ts +32 -0
- package/dist/connector-access.js +79 -0
- package/dist/connectors/api.js +11 -1
- package/dist/connectors/guarded-fetch.d.ts +1 -1
- package/dist/connectors/guarded-fetch.js +27 -20
- package/dist/connectors/remote-mcp.js +84 -53
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +58 -0
- 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 +37 -1
- package/dist/index.js +89 -3
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +118 -39
- package/dist/registry.d.ts +29 -1
- package/dist/registry.js +122 -15
- package/dist/routes/credentials.js +1 -0
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +112 -12
- package/dist/routes/oauth-management.js +1 -0
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +7 -1
- package/dist/routes/shared.js +12 -13
- package/dist/routes/ui.js +2 -1
- 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/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +30 -9
- package/documentation/auth.md +110 -6
- package/documentation/call-admission.md +24 -8
- package/documentation/code-mode.md +34 -22
- package/documentation/connectors.md +47 -5
- package/documentation/meta-tools.md +74 -6
- package/documentation/operations.md +20 -19
- package/documentation/provider-conventions.md +7 -0
- package/documentation/request-admission.md +38 -4
- package/documentation/storage-and-credentials.md +54 -1
- package/documentation/upgrading.md +21 -5
- package/ethos.md +1 -1
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const POOL_NAME_RE = /^[a-z0-9_-]+$/;
|
|
2
|
+
const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
|
|
3
|
+
// MCP does not restrict tool names, and remote servers ship spaced and
|
|
4
|
+
// non-ASCII ones. Only control characters are refused, so a grant for a
|
|
5
|
+
// legitimately named tool cannot 403 the whole identity at request time.
|
|
6
|
+
const TOOL_ADDRESS_RE = /^[a-z0-9_-]+\..{1,256}$/su;
|
|
7
|
+
const hasControlCharacter = (value) => [...value].some((ch) => {
|
|
8
|
+
const code = ch.codePointAt(0);
|
|
9
|
+
return code < 0x20 || code === 0x7f;
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* Normalize a grant list. A bare connector id grants every tool on that
|
|
13
|
+
* connector; a `connector.tool` address grants one tool. Grants are additive,
|
|
14
|
+
* so a bare id beside addresses for the same connector means the whole
|
|
15
|
+
* connector. Anything else — an unknown shape, an empty tool name, a
|
|
16
|
+
* non-string — throws, and the caller decides whether that is a construction
|
|
17
|
+
* failure or a 403: a grant that cannot be parsed must never fail open.
|
|
18
|
+
*/
|
|
19
|
+
export function parseConnectorAccess(value) {
|
|
20
|
+
if (value === "all")
|
|
21
|
+
return { connectorIds: "all" };
|
|
22
|
+
if (!Array.isArray(value))
|
|
23
|
+
throw new Error("invalid connector permission");
|
|
24
|
+
const whole = new Set();
|
|
25
|
+
const partial = new Map();
|
|
26
|
+
for (const entry of value) {
|
|
27
|
+
if (typeof entry !== "string")
|
|
28
|
+
throw new Error("invalid connector permission");
|
|
29
|
+
if (CONNECTOR_ID_RE.test(entry)) {
|
|
30
|
+
whole.add(entry);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!TOOL_ADDRESS_RE.test(entry) || hasControlCharacter(entry))
|
|
34
|
+
throw new Error("invalid connector permission");
|
|
35
|
+
const dot = entry.indexOf(".");
|
|
36
|
+
const connectorId = entry.slice(0, dot);
|
|
37
|
+
const tools = partial.get(connectorId) ?? new Set();
|
|
38
|
+
tools.add(entry.slice(dot + 1));
|
|
39
|
+
partial.set(connectorId, tools);
|
|
40
|
+
}
|
|
41
|
+
for (const id of whole)
|
|
42
|
+
partial.delete(id);
|
|
43
|
+
const connectorIds = [...new Set([...whole, ...partial.keys()])];
|
|
44
|
+
return partial.size > 0
|
|
45
|
+
? { connectorIds, toolAccess: partial }
|
|
46
|
+
: { connectorIds };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The view a pool endpoint serves: the pool's grants, never wider than the
|
|
50
|
+
* identity's own. A connector or tool outside either side is gone; a
|
|
51
|
+
* connector whose tool intersection is empty is gone too, so the pool can
|
|
52
|
+
* only narrow what the identity resolver already allowed.
|
|
53
|
+
*/
|
|
54
|
+
export function intersectAccess(ceiling, pool) {
|
|
55
|
+
if (pool.connectorIds === "all")
|
|
56
|
+
return ceiling;
|
|
57
|
+
const allowedIds = ceiling.connectorIds === "all"
|
|
58
|
+
? null
|
|
59
|
+
: new Set(ceiling.connectorIds);
|
|
60
|
+
const connectorIds = [];
|
|
61
|
+
const toolAccess = new Map();
|
|
62
|
+
for (const id of pool.connectorIds) {
|
|
63
|
+
if (allowedIds && !allowedIds.has(id))
|
|
64
|
+
continue;
|
|
65
|
+
const fromPool = pool.toolAccess?.get(id);
|
|
66
|
+
const fromCeiling = ceiling.toolAccess?.get(id);
|
|
67
|
+
if (fromPool && fromCeiling) {
|
|
68
|
+
const both = new Set([...fromPool].filter((name) => fromCeiling.has(name)));
|
|
69
|
+
if (both.size === 0)
|
|
70
|
+
continue;
|
|
71
|
+
toolAccess.set(id, both);
|
|
72
|
+
}
|
|
73
|
+
else if (fromPool ?? fromCeiling) {
|
|
74
|
+
toolAccess.set(id, (fromPool ?? fromCeiling));
|
|
75
|
+
}
|
|
76
|
+
connectorIds.push(id);
|
|
77
|
+
}
|
|
78
|
+
return toolAccess.size > 0 ? { connectorIds, toolAccess } : { connectorIds };
|
|
79
|
+
}
|
package/dist/connectors/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ConnectorCallError, networkErrorCode, unavailableCallError, } from "../errors.js";
|
|
1
2
|
import { compileValidator, validateToolInput } from "../validate.js";
|
|
2
3
|
export function defined(value) {
|
|
3
4
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
@@ -74,7 +75,16 @@ export function api(id, opts) {
|
|
|
74
75
|
// its first await never sits handler-less for the thenable-adoption
|
|
75
76
|
// microtask — workerd and vitest both report that gap as an unhandled
|
|
76
77
|
// rejection even though the caller catches the failure.
|
|
77
|
-
|
|
78
|
+
try {
|
|
79
|
+
return await tool.handler(input, ctx);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// A handler owns its destinations. ctx.baseUrl is Connecta's inbound
|
|
83
|
+
// URL, so it must never masquerade as the failed downstream host.
|
|
84
|
+
if (error instanceof ConnectorCallError || !networkErrorCode(error))
|
|
85
|
+
throw error;
|
|
86
|
+
throw unavailableCallError(error);
|
|
87
|
+
}
|
|
78
88
|
},
|
|
79
89
|
};
|
|
80
90
|
}
|
|
@@ -51,7 +51,7 @@ interface GuardedResponse {
|
|
|
51
51
|
parseError: unknown;
|
|
52
52
|
}>;
|
|
53
53
|
}
|
|
54
|
-
/** Parse
|
|
54
|
+
/** Parse delta-seconds or an HTTP-date into a non-negative wait window. */
|
|
55
55
|
export declare function retryAfterMs(headers: Headers): number | undefined;
|
|
56
56
|
/**
|
|
57
57
|
* Turn one response into the provider's own result, or throw the provider's
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/** See documentation/connectors.md#the-guarded-fetch-transport. Web APIs only. */
|
|
2
|
-
import { ConnectorCallError } from "../errors.js";
|
|
3
|
-
/** Parse
|
|
2
|
+
import { ConnectorCallError, unavailableCallError } from "../errors.js";
|
|
3
|
+
/** Parse delta-seconds or an HTTP-date into a non-negative wait window. */
|
|
4
4
|
export function retryAfterMs(headers) {
|
|
5
5
|
const raw = headers.get("retry-after");
|
|
6
6
|
if (!raw)
|
|
7
7
|
return undefined;
|
|
8
8
|
const seconds = Number(raw.trim());
|
|
9
|
-
if (
|
|
9
|
+
if (Number.isFinite(seconds)) {
|
|
10
|
+
return seconds < 0 ? undefined : Math.trunc(seconds * 1000);
|
|
11
|
+
}
|
|
12
|
+
// Require the HTTP-date shape, so Date.parse cannot reinterpret "-1" as
|
|
13
|
+
// a calendar date on runtimes that accept loose date strings.
|
|
14
|
+
if (!/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(raw.trim()))
|
|
10
15
|
return undefined;
|
|
11
|
-
|
|
16
|
+
const date = Date.parse(raw);
|
|
17
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
12
18
|
}
|
|
13
19
|
/** Statuses that instruct a client to re-send somewhere else. Never followed. */
|
|
14
20
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
@@ -144,13 +150,20 @@ function boundedResponse(provider, response, limit) {
|
|
|
144
150
|
});
|
|
145
151
|
return read;
|
|
146
152
|
};
|
|
153
|
+
let readText;
|
|
154
|
+
const text = () => {
|
|
155
|
+
readText ??= stream
|
|
156
|
+
? bytes().then((body) => decoder.decode(body))
|
|
157
|
+
: response.text().then((body) => {
|
|
158
|
+
const size = encoder.encode(body).length;
|
|
159
|
+
if (size > limit)
|
|
160
|
+
throw oversized(provider, limit, `${size} bytes`);
|
|
161
|
+
return body;
|
|
162
|
+
});
|
|
163
|
+
return readText;
|
|
164
|
+
};
|
|
147
165
|
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());
|
|
166
|
+
const body = await text();
|
|
154
167
|
return body.trim() === "" ? undefined : JSON.parse(body);
|
|
155
168
|
};
|
|
156
169
|
return {
|
|
@@ -158,15 +171,7 @@ function boundedResponse(provider, response, limit) {
|
|
|
158
171
|
ok: response.ok,
|
|
159
172
|
headers: response.headers,
|
|
160
173
|
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
|
-
},
|
|
174
|
+
text,
|
|
170
175
|
json,
|
|
171
176
|
jsonResult: () => jsonResult(json),
|
|
172
177
|
};
|
|
@@ -230,7 +235,9 @@ export function guardedFetch(options) {
|
|
|
230
235
|
});
|
|
231
236
|
}
|
|
232
237
|
catch (cause) {
|
|
233
|
-
|
|
238
|
+
if (cause instanceof ConnectorCallError)
|
|
239
|
+
throw cause;
|
|
240
|
+
throw unavailableCallError(cause, url.href, `Could not reach the ${provider} API.`);
|
|
234
241
|
}
|
|
235
242
|
if (REDIRECT_STATUSES.has(response.status)) {
|
|
236
243
|
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
|
|
@@ -157,6 +157,28 @@ async function terminateSession(transport, logger, connectorId) {
|
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
159
|
}
|
|
160
|
+
/** Classify protocol/status facts; provider prose never decides retryability. */
|
|
161
|
+
function downstreamCallError(error) {
|
|
162
|
+
if (error instanceof ProtocolError && error.code === -32602) {
|
|
163
|
+
return new ConnectorCallError("invalid_args", boundedEchoText(error.message));
|
|
164
|
+
}
|
|
165
|
+
if (error instanceof SdkHttpError && error.status >= 400 && error.status < 500) {
|
|
166
|
+
let message = error.message;
|
|
167
|
+
if (typeof error.data.text === "string") {
|
|
168
|
+
try {
|
|
169
|
+
const body = JSON.parse(error.data.text);
|
|
170
|
+
const detail = body?.message ?? body?.error?.message ?? body?.error_description;
|
|
171
|
+
if (typeof detail === "string")
|
|
172
|
+
message = detail;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Non-JSON refusals still retain a bounded diagnostic.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return new ConnectorCallError(error.status === 429 ? "rate_limited" : error.status === 408 ? "timeout" : "connector_call_failed", boundedEchoText(message));
|
|
179
|
+
}
|
|
180
|
+
return error;
|
|
181
|
+
}
|
|
160
182
|
const encoder = new TextEncoder();
|
|
161
183
|
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
162
184
|
function base64Utf8(value) {
|
|
@@ -266,10 +288,18 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
|
|
|
266
288
|
const seen = new Set([current.href]);
|
|
267
289
|
let hops = 0;
|
|
268
290
|
while (true) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
let response;
|
|
292
|
+
try {
|
|
293
|
+
response = await baseFetch(current, {
|
|
294
|
+
...init,
|
|
295
|
+
redirect: "manual",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (cause) {
|
|
299
|
+
if (cause instanceof ConnectorCallError)
|
|
300
|
+
throw cause;
|
|
301
|
+
throw unavailableCallError(cause, current.href);
|
|
302
|
+
}
|
|
273
303
|
if (!REDIRECT_STATUSES.has(response.status))
|
|
274
304
|
return response;
|
|
275
305
|
const location = response.headers.get("location");
|
|
@@ -549,19 +579,38 @@ export function remoteMcp(id, opts) {
|
|
|
549
579
|
state.credentialDigest = null;
|
|
550
580
|
// `closed` is deliberately not cleared — see ConnectionState.
|
|
551
581
|
};
|
|
552
|
-
|
|
582
|
+
// The context has no deferred-work hook. Detached exits start this bounded
|
|
583
|
+
// best-effort tail immediately; closeScope awaits its own tail so the core
|
|
584
|
+
// can pass it to the runtime's deferred channel.
|
|
585
|
+
const closingSessions = new WeakMap();
|
|
586
|
+
const closeConnection = (client, transport, logger) => {
|
|
587
|
+
const previous = transport && closingSessions.get(transport);
|
|
588
|
+
if (previous)
|
|
589
|
+
return previous;
|
|
590
|
+
const closing = (async () => {
|
|
591
|
+
try {
|
|
592
|
+
if (transport)
|
|
593
|
+
await terminateSession(transport, logger, id);
|
|
594
|
+
if (client)
|
|
595
|
+
await client.close();
|
|
596
|
+
else
|
|
597
|
+
await transport?.close();
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// Local close cannot replace the operation's result.
|
|
601
|
+
}
|
|
602
|
+
})();
|
|
603
|
+
// A connect can acquire a session after an early close. Deduplicate only
|
|
604
|
+
// once that session exists, so its late abandonment still sends DELETE.
|
|
605
|
+
if (transport?.sessionId)
|
|
606
|
+
closingSessions.set(transport, closing);
|
|
607
|
+
return closing;
|
|
608
|
+
};
|
|
609
|
+
const closeHalf = (state, ctx) => {
|
|
553
610
|
const client = state.client;
|
|
554
611
|
const transport = state.transport;
|
|
555
612
|
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
|
-
}
|
|
613
|
+
void closeConnection(client, transport, ctx.logger);
|
|
565
614
|
};
|
|
566
615
|
const ensureConnected = async (ctx, state) => {
|
|
567
616
|
// A 401 after connect is a verdict for the whole request scope, not merely
|
|
@@ -581,7 +630,7 @@ export function remoteMcp(id, opts) {
|
|
|
581
630
|
if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
|
|
582
631
|
const connecting = state.connecting;
|
|
583
632
|
void connecting?.catch(() => { });
|
|
584
|
-
|
|
633
|
+
closeHalf(state, ctx);
|
|
585
634
|
throw operatorDisconnectedError();
|
|
586
635
|
}
|
|
587
636
|
}
|
|
@@ -592,7 +641,7 @@ export function remoteMcp(id, opts) {
|
|
|
592
641
|
if (state.closed)
|
|
593
642
|
throw scopeEndedError();
|
|
594
643
|
if (oauthGeneration !== state.connectedGeneration) {
|
|
595
|
-
|
|
644
|
+
closeHalf(state, ctx);
|
|
596
645
|
}
|
|
597
646
|
}
|
|
598
647
|
// The static-credential counterpart of the epoch read above, and
|
|
@@ -618,7 +667,7 @@ export function remoteMcp(id, opts) {
|
|
|
618
667
|
throw scopeEndedError();
|
|
619
668
|
const connecting = state.connecting;
|
|
620
669
|
void connecting?.catch(() => { });
|
|
621
|
-
|
|
670
|
+
closeHalf(state, ctx);
|
|
622
671
|
}
|
|
623
672
|
}
|
|
624
673
|
if (state.closed)
|
|
@@ -629,13 +678,8 @@ export function remoteMcp(id, opts) {
|
|
|
629
678
|
let attempt;
|
|
630
679
|
attempt = (async () => {
|
|
631
680
|
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
|
-
}
|
|
681
|
+
const abandon = (client, transport) => {
|
|
682
|
+
void closeConnection(client, transport, ctx.logger);
|
|
639
683
|
throw scopeEndedError();
|
|
640
684
|
};
|
|
641
685
|
// Let the assignment immediately below this async IIFE publish
|
|
@@ -666,7 +710,7 @@ export function remoteMcp(id, opts) {
|
|
|
666
710
|
});
|
|
667
711
|
const t = buildTransport(ctx, provider, credentialFramed);
|
|
668
712
|
if (!ownsAttempt())
|
|
669
|
-
|
|
713
|
+
abandon(null, t);
|
|
670
714
|
state.transport = t;
|
|
671
715
|
try {
|
|
672
716
|
await c.connect(t);
|
|
@@ -675,7 +719,7 @@ export function remoteMcp(id, opts) {
|
|
|
675
719
|
// that race anyway, close the resulting client rather than
|
|
676
720
|
// resurrecting a session in the detached state object.
|
|
677
721
|
if (!ownsAttempt())
|
|
678
|
-
|
|
722
|
+
abandon(c, t);
|
|
679
723
|
// A force re-auth that landed WHILE we were connecting wiped the
|
|
680
724
|
// credentials this client just bound to. Discard it rather than
|
|
681
725
|
// cache a stale-isolate connection.
|
|
@@ -685,24 +729,22 @@ export function remoteMcp(id, opts) {
|
|
|
685
729
|
// connect succeeded but before this client is cached. Discard the
|
|
686
730
|
// client on that side of the await too.
|
|
687
731
|
if (!ownsAttempt())
|
|
688
|
-
|
|
732
|
+
abandon(c, t);
|
|
689
733
|
if (generation !== genAtStart) {
|
|
690
|
-
|
|
691
|
-
await c.close();
|
|
692
|
-
}
|
|
693
|
-
catch {
|
|
694
|
-
// discarding either way
|
|
695
|
-
}
|
|
734
|
+
void closeConnection(c, t, ctx.logger);
|
|
696
735
|
throw new UnauthorizedError("Connector was re-authorized during connect; reconnect required.");
|
|
697
736
|
}
|
|
698
737
|
}
|
|
699
738
|
if (!ownsAttempt())
|
|
700
|
-
|
|
739
|
+
abandon(c, t);
|
|
701
740
|
state.client = c;
|
|
702
741
|
state.connectedGeneration = genAtStart;
|
|
703
742
|
state.authRequired = false;
|
|
704
743
|
}
|
|
705
744
|
catch (err) {
|
|
745
|
+
void closeConnection(c, t, ctx.logger);
|
|
746
|
+
if (ownsAttempt())
|
|
747
|
+
state.transport = null;
|
|
706
748
|
// Only a real 401/UnauthorizedError means auth is the problem — a
|
|
707
749
|
// network error on an oauth connector must surface as "error", not
|
|
708
750
|
// "auth_required".
|
|
@@ -748,7 +790,7 @@ export function remoteMcp(id, opts) {
|
|
|
748
790
|
// client/transport exists. Reset is unconditional because KV may already
|
|
749
791
|
// be fenced behind a newer epoch after a cleanup error.
|
|
750
792
|
void connecting?.catch(() => { });
|
|
751
|
-
|
|
793
|
+
closeHalf(state, ctx);
|
|
752
794
|
}
|
|
753
795
|
};
|
|
754
796
|
const connector = {
|
|
@@ -976,7 +1018,7 @@ export function remoteMcp(id, opts) {
|
|
|
976
1018
|
state.authRequired = true;
|
|
977
1019
|
throw authRequiredError(err);
|
|
978
1020
|
}
|
|
979
|
-
throw err;
|
|
1021
|
+
throw downstreamCallError(err);
|
|
980
1022
|
}
|
|
981
1023
|
},
|
|
982
1024
|
async closeScope(ctx) {
|
|
@@ -993,20 +1035,7 @@ export function remoteMcp(id, opts) {
|
|
|
993
1035
|
const client = state.client;
|
|
994
1036
|
const transport = state.transport;
|
|
995
1037
|
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
|
-
}
|
|
1038
|
+
await closeConnection(client, transport, ctx.logger);
|
|
1010
1039
|
},
|
|
1011
1040
|
async status(ctx) {
|
|
1012
1041
|
const state = stateFor(ctx);
|
|
@@ -1054,7 +1083,9 @@ export function remoteMcp(id, opts) {
|
|
|
1054
1083
|
}
|
|
1055
1084
|
await provider.clearPending();
|
|
1056
1085
|
// Reset so the next use reconnects with the freshly stored tokens.
|
|
1057
|
-
|
|
1086
|
+
if (!state.transport)
|
|
1087
|
+
state.transport = t;
|
|
1088
|
+
closeHalf(state, ctx);
|
|
1058
1089
|
},
|
|
1059
1090
|
};
|
|
1060
1091
|
if (opts.auth?.type === "oauth") {
|
package/dist/errors.d.ts
CHANGED
|
@@ -39,6 +39,17 @@ export declare function boundedEchoText(value: string, maxBytes?: number): strin
|
|
|
39
39
|
export declare function echoedCallArgs(args: unknown): {
|
|
40
40
|
args?: unknown;
|
|
41
41
|
};
|
|
42
|
+
/** Optional transport diagnostics; never a URL path or raw runtime message. */
|
|
43
|
+
interface UnavailableDetails {
|
|
44
|
+
/** HTTP(S) origin only, at most 253 UTF-8 bytes. */
|
|
45
|
+
host?: string;
|
|
46
|
+
/** Validated network errno or `timeout`, at most 32 bytes. */
|
|
47
|
+
code?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Runtime fields only: provider prose cannot supply an errno or a deadline. */
|
|
50
|
+
export declare function networkErrorCode(error: unknown): string | undefined;
|
|
51
|
+
/** Use at a fetch boundary where the destination and transport failure are known. */
|
|
52
|
+
export declare function unavailableCallError(cause: unknown, host?: string, message?: string): ConnectorCallError;
|
|
42
53
|
/** Agent-visible recovery class attached only to `auth_required` failures. */
|
|
43
54
|
export type AuthRecoveryMode = "oauth" | "operator_config" | "unavailable";
|
|
44
55
|
/**
|
|
@@ -67,15 +78,20 @@ export declare class ConnectorCallError extends Error {
|
|
|
67
78
|
readonly retryAfterMs: number | undefined;
|
|
68
79
|
/** Bounded schema findings for `invalid_args`; never submitted values. */
|
|
69
80
|
readonly validation: ArgumentValidationDetails | undefined;
|
|
81
|
+
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
82
|
+
readonly details: UnavailableDetails | undefined;
|
|
70
83
|
constructor(code: ConnectorCallErrorCode, message: string, opts?: {
|
|
71
84
|
retryable?: boolean;
|
|
72
85
|
retryAfterMs?: number;
|
|
73
86
|
cause?: unknown;
|
|
74
87
|
validation?: ArgumentValidationDetails;
|
|
88
|
+
details?: UnavailableDetails;
|
|
75
89
|
});
|
|
76
90
|
}
|
|
77
91
|
/** The `error` object surfaced in value-mode call results and rejected promises. */
|
|
78
92
|
export interface CallErrorDetails {
|
|
93
|
+
/** Sanitized transport diagnostics, absent when the runtime supplies none. */
|
|
94
|
+
details?: UnavailableDetails;
|
|
79
95
|
code: string;
|
|
80
96
|
message: string;
|
|
81
97
|
retryable: boolean;
|
|
@@ -151,3 +167,4 @@ export declare function framingError(code: string, message: string): CallErrorDe
|
|
|
151
167
|
*/
|
|
152
168
|
export declare function classifyCallError(err: unknown, fallbackCode?: string): CallErrorDetails;
|
|
153
169
|
export declare function msg(err: unknown): string;
|
|
170
|
+
export {};
|
package/dist/errors.js
CHANGED
|
@@ -102,6 +102,58 @@ function boundedValidation(details) {
|
|
|
102
102
|
...(truncated ? { truncated: true } : {}),
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
106
|
+
"ECONNREFUSED", "ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN",
|
|
107
|
+
"EAI_FAIL", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EHOSTDOWN",
|
|
108
|
+
"ECONNABORTED", "EPIPE", "EACCES", "EPERM",
|
|
109
|
+
"UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
|
|
110
|
+
"UND_ERR_SOCKET", "timeout",
|
|
111
|
+
]);
|
|
112
|
+
function networkCode(value) {
|
|
113
|
+
return typeof value === "string" && value.length <= 32 && NETWORK_ERROR_CODES.has(value)
|
|
114
|
+
? value
|
|
115
|
+
: undefined;
|
|
116
|
+
}
|
|
117
|
+
function sanitizedUnavailableDetails(details) {
|
|
118
|
+
if (!details)
|
|
119
|
+
return undefined;
|
|
120
|
+
let host;
|
|
121
|
+
if (typeof details.host === "string") {
|
|
122
|
+
try {
|
|
123
|
+
const url = new URL(details.host);
|
|
124
|
+
if ((url.protocol === "https:" || url.protocol === "http:") &&
|
|
125
|
+
echoEncoder.encode(url.origin).length <= 253)
|
|
126
|
+
host = url.origin;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// An invalid or oversized origin is absent, never a clipped destination.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const code = networkCode(details.code);
|
|
133
|
+
return host || code ? { ...(host ? { host } : {}), ...(code ? { code } : {}) } : undefined;
|
|
134
|
+
}
|
|
135
|
+
/** Runtime fields only: provider prose cannot supply an errno or a deadline. */
|
|
136
|
+
export function networkErrorCode(error) {
|
|
137
|
+
if (!error || typeof error !== "object")
|
|
138
|
+
return undefined;
|
|
139
|
+
if (error instanceof Error &&
|
|
140
|
+
(error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
141
|
+
return "timeout";
|
|
142
|
+
}
|
|
143
|
+
const runtime = error;
|
|
144
|
+
const cause = runtime.cause;
|
|
145
|
+
return networkCode(runtime.code) ?? (cause && typeof cause === "object"
|
|
146
|
+
? networkCode(cause.code)
|
|
147
|
+
: undefined);
|
|
148
|
+
}
|
|
149
|
+
/** Use at a fetch boundary where the destination and transport failure are known. */
|
|
150
|
+
export function unavailableCallError(cause, host, message = "Could not reach the downstream service.") {
|
|
151
|
+
const code = networkErrorCode(cause);
|
|
152
|
+
return new ConnectorCallError("unavailable", message, {
|
|
153
|
+
cause,
|
|
154
|
+
details: { ...(host ? { host } : {}), ...(code ? { code } : {}) },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
105
157
|
const RETRYABLE_BY_CODE = {
|
|
106
158
|
timeout: true,
|
|
107
159
|
rate_limited: true,
|
|
@@ -146,12 +198,16 @@ export class ConnectorCallError extends Error {
|
|
|
146
198
|
retryAfterMs;
|
|
147
199
|
/** Bounded schema findings for `invalid_args`; never submitted values. */
|
|
148
200
|
validation;
|
|
201
|
+
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
202
|
+
details;
|
|
149
203
|
constructor(code, message, opts = {}) {
|
|
150
204
|
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
151
205
|
this.name = "ConnectorCallError";
|
|
152
206
|
this.code = code;
|
|
153
207
|
this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
|
|
154
208
|
this.retryAfterMs = normalizeRetryAfterMs(opts.retryAfterMs);
|
|
209
|
+
this.details =
|
|
210
|
+
code === "unavailable" ? sanitizedUnavailableDetails(opts.details) : undefined;
|
|
155
211
|
this.validation =
|
|
156
212
|
code === "invalid_args" ? boundedValidation(opts.validation) : undefined;
|
|
157
213
|
}
|
|
@@ -164,6 +220,7 @@ export class ConnectorCallError extends Error {
|
|
|
164
220
|
* trusts the flag would cheerfully retry a refusal forever.
|
|
165
221
|
*/
|
|
166
222
|
const NEVER_RETRYABLE_FRAMING = new Set([
|
|
223
|
+
"result_processing_failed",
|
|
167
224
|
"unknown_address",
|
|
168
225
|
"unknown_tool",
|
|
169
226
|
"ambiguous_tool_alias",
|
|
@@ -203,6 +260,7 @@ export function classifyCallError(err, fallbackCode = "connector_call_failed") {
|
|
|
203
260
|
? { retryAfterMs: err.retryAfterMs }
|
|
204
261
|
: {}),
|
|
205
262
|
...(err.validation ? { validation: err.validation } : {}),
|
|
263
|
+
...(err.details ? { details: err.details } : {}),
|
|
206
264
|
};
|
|
207
265
|
}
|
|
208
266
|
// An aborted fetch rejects with a DOMException named "AbortError" whose
|