@zackbart/connecta 0.7.1 → 0.7.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/CHANGELOG.md +122 -0
- package/README.md +5 -2
- package/dist/connector-scope.d.ts +7 -4
- package/dist/connector-scope.d.ts.map +1 -1
- package/dist/connector-scope.js +36 -16
- package/dist/connector-scope.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +164 -95
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +11 -7
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +26 -19
- package/dist/credential-health.js.map +1 -1
- package/dist/execute.d.ts +3 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +70 -29
- package/dist/execute.js.map +1 -1
- package/dist/executor-admission.d.ts +53 -0
- package/dist/executor-admission.d.ts.map +1 -0
- package/dist/executor-admission.js +151 -0
- package/dist/executor-admission.js.map +1 -0
- package/dist/executor-result.d.ts +13 -0
- package/dist/executor-result.d.ts.map +1 -0
- package/dist/executor-result.js +57 -0
- package/dist/executor-result.js.map +1 -0
- package/dist/executors/quickjs-child.d.ts +2 -0
- package/dist/executors/quickjs-child.d.ts.map +1 -0
- package/dist/executors/quickjs-child.js +131 -0
- package/dist/executors/quickjs-child.js.map +1 -0
- package/dist/executors/quickjs-protocol.d.ts +55 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -0
- package/dist/executors/quickjs-protocol.js +22 -0
- package/dist/executors/quickjs-protocol.js.map +1 -0
- package/dist/executors/quickjs-runtime.d.ts +26 -0
- package/dist/executors/quickjs-runtime.d.ts.map +1 -0
- package/dist/executors/quickjs-runtime.js +383 -0
- package/dist/executors/quickjs-runtime.js.map +1 -0
- package/dist/executors/quickjs.d.ts +20 -15
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +465 -265
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +4 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +49 -12
- package/dist/meta-tools.js.map +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +36 -3
- package/dist/node.js.map +1 -1
- package/dist/registry.d.ts +21 -24
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +37 -25
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +19 -9
- package/dist/server.js.map +1 -1
- package/dist/timeout.d.ts +9 -4
- package/dist/timeout.d.ts.map +1 -1
- package/dist/timeout.js +34 -4
- package/dist/timeout.js.map +1 -1
- package/dist/toolkits.d.ts +8 -0
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +3 -0
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +13 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +188 -7
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/connector-scope.ts +43 -18
- package/src/connectors/remote-mcp.ts +184 -102
- package/src/credential-health.ts +33 -17
- package/src/execute.ts +93 -35
- package/src/executor-admission.ts +229 -0
- package/src/executor-result.ts +63 -0
- package/src/executors/quickjs-child.ts +168 -0
- package/src/executors/quickjs-protocol.ts +77 -0
- package/src/executors/quickjs-runtime.ts +440 -0
- package/src/executors/quickjs.ts +669 -302
- package/src/index.ts +30 -5
- package/src/meta-tools.ts +84 -27
- package/src/node.ts +36 -2
- package/src/registry.ts +53 -22
- package/src/server.ts +21 -10
- package/src/timeout.ts +41 -4
- package/src/toolkits.ts +11 -0
- package/src/types.ts +19 -2
- package/src/ui.ts +218 -12
- package/src/version.ts +1 -1
|
@@ -5,7 +5,9 @@ import type {
|
|
|
5
5
|
FetchLike,
|
|
6
6
|
Transport,
|
|
7
7
|
} from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
8
|
+
import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
9
|
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
10
|
+
import { z } from "zod";
|
|
9
11
|
import { KvOAuthProvider } from "../auth/downstream-oauth.js";
|
|
10
12
|
import { ConnectorCallError } from "../errors.js";
|
|
11
13
|
import { CONNECTA_VERSION } from "../version.js";
|
|
@@ -73,15 +75,14 @@ export interface RemoteMcpOptions {
|
|
|
73
75
|
|
|
74
76
|
/**
|
|
75
77
|
* How long a downstream gets to answer the session-termination DELETE before
|
|
76
|
-
* teardown stops waiting.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* does not get to tell us so.
|
|
78
|
+
* teardown stops waiting. This is a network round-trip budget, deliberately
|
|
79
|
+
* independent of the core's 100 ms caller-facing scope-close window: an
|
|
80
|
+
* already-established cross-internet connection avoids setup, but 50 ms is
|
|
81
|
+
* still too short for an ordinary round trip plus modest provider scheduling.
|
|
82
|
+
* The bounded tail is deferred on runtimes that can keep it alive after the
|
|
83
|
+
* response, while callers continue to wait at most 100 ms.
|
|
83
84
|
*/
|
|
84
|
-
const TERMINATE_SESSION_BUDGET_MS =
|
|
85
|
+
const TERMINATE_SESSION_BUDGET_MS = 1_000;
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
88
|
* Ceiling on the tools one catalog refresh will accumulate while walking
|
|
@@ -115,27 +116,33 @@ const MAX_TOOLS = 100_000;
|
|
|
115
116
|
* is a definite loop, two consecutive pages that add no new tools are a server
|
|
116
117
|
* going nowhere, and MAX_TOOLS caps what any of it can accumulate. This exists
|
|
117
118
|
* only so the loop is finite even if a downstream somehow satisfies all three
|
|
118
|
-
* forever
|
|
119
|
-
*
|
|
119
|
+
* forever on a path with no discovery deadline. Set high enough that no honest
|
|
120
|
+
* server reaches it.
|
|
120
121
|
*/
|
|
121
122
|
const MAX_TOOL_PAGES = 10_000;
|
|
122
123
|
|
|
123
124
|
/** One entry of the SDK's `tools/list` result, before it becomes a ToolDef. */
|
|
124
125
|
type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Compatibility concession for hand-rolled servers that serialize
|
|
129
|
+
* end-of-pagination as `null`. Only the cursor is widened; every tool and every
|
|
130
|
+
* other result field still passes through the SDK's pinned schema.
|
|
131
|
+
*/
|
|
132
|
+
const CompatibleListToolsResultSchema = ListToolsResultSchema.extend({
|
|
133
|
+
nextCursor: z.string().nullable().optional(),
|
|
134
|
+
});
|
|
135
|
+
|
|
126
136
|
/**
|
|
127
137
|
* Re-prime an SDK client's tool-metadata cache from the *full* walked catalog.
|
|
128
138
|
*
|
|
129
|
-
* `Client.listTools()`
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* structured-content validation, and finds no task requirement so a
|
|
137
|
-
* required-task tool is dispatched as a plain `tools/call`. Enforcement would
|
|
138
|
-
* depend on which page a tool happened to land on, which is not enforcement.
|
|
139
|
+
* The SDK's `Client.listTools()` caches one page at a time and **clears** the
|
|
140
|
+
* output-schema validators and task-support sets before each replacement.
|
|
141
|
+
* This walk uses `Client.request()` so it can make the narrow null-cursor
|
|
142
|
+
* compatibility concession above, then primes the metadata exactly once from
|
|
143
|
+
* the complete chain. Otherwise `callTool` would find no validator or task
|
|
144
|
+
* requirement for earlier-page tools and enforcement would depend on where a
|
|
145
|
+
* tool happened to land, which is not enforcement.
|
|
139
146
|
*
|
|
140
147
|
* So hand the whole aggregated list back deliberately, once, at the end. The
|
|
141
148
|
* SDK types the method `private`, hence the cast; the SDK version is pinned
|
|
@@ -154,9 +161,9 @@ function primeToolMetadata(client: Client, tools: ListedTool[]): void {
|
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
/**
|
|
157
|
-
* True for a result-parse failure caused by the page's `nextCursor` itself
|
|
158
|
-
*
|
|
159
|
-
*
|
|
164
|
+
* True for a result-parse failure caused by the page's `nextCursor` itself.
|
|
165
|
+
* `null` is accepted deliberately; other non-string values remain a named
|
|
166
|
+
* downstream nonconformance instead of surfacing as a raw validation dump.
|
|
160
167
|
* Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
|
|
161
168
|
* zod instance, and cross-instance `instanceof` is a coin flip.
|
|
162
169
|
*/
|
|
@@ -191,24 +198,62 @@ function msg(err: unknown): string {
|
|
|
191
198
|
* (405 is a legal answer), errors, or never replies all fall through to the
|
|
192
199
|
* close with the session left to age out as it did before.
|
|
193
200
|
*/
|
|
194
|
-
async function terminateSession(
|
|
201
|
+
async function terminateSession(
|
|
202
|
+
transport: Transport,
|
|
203
|
+
logger: Logger,
|
|
204
|
+
connectorId: string,
|
|
205
|
+
): Promise<void> {
|
|
195
206
|
const terminate = (
|
|
196
207
|
transport as Transport & { terminateSession?: () => Promise<void> }
|
|
197
208
|
).terminateSession;
|
|
198
209
|
if (typeof terminate !== "function") return;
|
|
199
210
|
// The SDK issues no request at all when no `mcp-session-id` was captured, so
|
|
200
211
|
// a stateless downstream never sees a spurious DELETE.
|
|
201
|
-
const done = (
|
|
202
|
-
// The session is being abandoned either way; a refusal changes nothing.
|
|
203
|
-
// Caught here rather than at the await below so a late rejection — one
|
|
204
|
-
// arriving after the budget expired — is still consumed.
|
|
205
|
-
});
|
|
212
|
+
const done = Promise.resolve().then(() => terminate.call(transport));
|
|
206
213
|
await new Promise<void>((resolve) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
let finished = false;
|
|
215
|
+
const warn = (message: string, error?: unknown) => {
|
|
216
|
+
try {
|
|
217
|
+
if (error === undefined) logger.warn(message);
|
|
218
|
+
else logger.warn(message, error);
|
|
219
|
+
} catch {
|
|
220
|
+
// A diagnostic sink cannot make best-effort teardown observable to the
|
|
221
|
+
// caller in the one way this contract forbids: by replacing its result.
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const timer = setTimeout(() => {
|
|
225
|
+
if (finished) return;
|
|
226
|
+
finished = true;
|
|
227
|
+
warn(
|
|
228
|
+
`[connecta] connector "${connectorId}" session termination was not ` +
|
|
229
|
+
`acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
|
|
230
|
+
"downstream may still finish the headers-only DELETE, otherwise " +
|
|
231
|
+
"the session will remain until its provider timeout.",
|
|
232
|
+
);
|
|
210
233
|
resolve();
|
|
211
|
-
});
|
|
234
|
+
}, TERMINATE_SESSION_BUDGET_MS);
|
|
235
|
+
done.then(
|
|
236
|
+
() => {
|
|
237
|
+
if (finished) return;
|
|
238
|
+
finished = true;
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
resolve();
|
|
241
|
+
},
|
|
242
|
+
(error) => {
|
|
243
|
+
// The rejection handler stays attached after the timer wins, so an
|
|
244
|
+
// abort or other late failure is consumed without a duplicate warning.
|
|
245
|
+
if (finished) return;
|
|
246
|
+
finished = true;
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
warn(
|
|
249
|
+
`[connecta] connector "${connectorId}" session termination was ` +
|
|
250
|
+
"refused or failed; the downstream session may remain until its " +
|
|
251
|
+
"provider timeout.",
|
|
252
|
+
error,
|
|
253
|
+
);
|
|
254
|
+
resolve();
|
|
255
|
+
},
|
|
256
|
+
);
|
|
212
257
|
});
|
|
213
258
|
}
|
|
214
259
|
|
|
@@ -372,6 +417,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
372
417
|
// transport, response bodies, AbortSignals, or connection promise reachable
|
|
373
418
|
// from the isolate singleton. Those are request-bound in Cloudflare Workers.
|
|
374
419
|
const states = new WeakMap<object, ConnectionState>();
|
|
420
|
+
// Closing is terminal even after `states.delete`: a late or future lookup
|
|
421
|
+
// must not recreate an ownerless connection under the ended scope.
|
|
422
|
+
const closedScopes = new WeakSet<object>();
|
|
375
423
|
const isOauth = opts.auth?.type === "oauth";
|
|
376
424
|
const logger = opts.logger ?? console;
|
|
377
425
|
|
|
@@ -407,22 +455,33 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
407
455
|
new Error(`Connector "${id}" scope ended during connection.`);
|
|
408
456
|
|
|
409
457
|
/**
|
|
410
|
-
* One `tools/list` request. The
|
|
411
|
-
* `
|
|
412
|
-
*
|
|
413
|
-
* *absent* cursor. The SDK surfaces that as a raw validation dump about a
|
|
414
|
-
* field the operator never sees; say which server broke which rule instead.
|
|
415
|
-
* Accepting `null` as end-of-chain outright is issue #99.
|
|
458
|
+
* One `tools/list` request. The SDK schema is retained wholesale except for
|
|
459
|
+
* accepting `null` as the common, unambiguous end-of-chain spelling. Other
|
|
460
|
+
* cursor shapes still get a useful connector-level diagnosis.
|
|
416
461
|
*/
|
|
417
|
-
const listPage = async (
|
|
462
|
+
const listPage = async (
|
|
463
|
+
client: Client,
|
|
464
|
+
cursor: string | undefined,
|
|
465
|
+
ctx: ConnectorContext,
|
|
466
|
+
) => {
|
|
418
467
|
try {
|
|
419
|
-
return await client.
|
|
420
|
-
|
|
468
|
+
return await client.request(
|
|
469
|
+
{
|
|
470
|
+
method: "tools/list",
|
|
471
|
+
...(cursor === undefined ? {} : { params: { cursor } }),
|
|
472
|
+
},
|
|
473
|
+
CompatibleListToolsResultSchema,
|
|
474
|
+
ctx.timeoutMs || ctx.signal
|
|
475
|
+
? {
|
|
476
|
+
...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
|
|
477
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
478
|
+
}
|
|
479
|
+
: undefined,
|
|
421
480
|
);
|
|
422
481
|
} catch (err) {
|
|
423
482
|
if (!isCursorShapeError(err)) throw err;
|
|
424
483
|
throw new Error(
|
|
425
|
-
`Connector "${id}" returned a tools/list page whose nextCursor is neither a string nor absent
|
|
484
|
+
`Connector "${id}" returned a tools/list page whose nextCursor is neither a string, null, nor absent — this catalog cannot be walked.`,
|
|
426
485
|
{ cause: err },
|
|
427
486
|
);
|
|
428
487
|
}
|
|
@@ -430,6 +489,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
430
489
|
|
|
431
490
|
const stateFor = (ctx: ConnectorContext): ConnectionState => {
|
|
432
491
|
const scope = ctx.requestScope ?? ctx;
|
|
492
|
+
if (closedScopes.has(scope)) throw scopeEndedError();
|
|
433
493
|
let state = states.get(scope);
|
|
434
494
|
if (!state) {
|
|
435
495
|
state = {
|
|
@@ -495,6 +555,14 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
495
555
|
ctx: ConnectorContext,
|
|
496
556
|
state: ConnectionState,
|
|
497
557
|
): Promise<void> => {
|
|
558
|
+
// A 401 after connect is a verdict for the whole request scope, not merely
|
|
559
|
+
// for the one call that observed it. Do not let the still-cached client make
|
|
560
|
+
// a later status or call in the same scope report healthy.
|
|
561
|
+
if (state.authRequired) {
|
|
562
|
+
throw authRequiredError(
|
|
563
|
+
new UnauthorizedError("Downstream authorization is no longer valid."),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
498
566
|
// Cross-isolate force re-auth: another isolate bumped the KV generation and
|
|
499
567
|
// wiped credentials. This request's cached client still speaks the old
|
|
500
568
|
// token — drop it so the next connect runs against current state.
|
|
@@ -626,64 +694,76 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
626
694
|
/** Consecutive pages that advertised a successor but added nothing. */
|
|
627
695
|
let barren = 0;
|
|
628
696
|
let complete = false;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
)
|
|
697
|
+
try {
|
|
698
|
+
for (let page = 0; page < MAX_TOOL_PAGES; page++) {
|
|
699
|
+
// The scope can end between pages (probe timeout, teardown). Stop
|
|
700
|
+
// rather than keep paging into a transport that is being closed.
|
|
701
|
+
if (state.closed) throw scopeEndedError();
|
|
702
|
+
// A discovery deadline uses the same signal for the whole chain.
|
|
703
|
+
// Check it before issuing each page as well as passing it to the
|
|
704
|
+
// in-flight SDK request, so expiry never starts one more round trip.
|
|
705
|
+
if (ctx.signal?.aborted) {
|
|
706
|
+
throw ctx.signal.reason instanceof Error
|
|
707
|
+
? ctx.signal.reason
|
|
708
|
+
: new Error(`Connector "${id}" catalog deadline expired.`);
|
|
709
|
+
}
|
|
710
|
+
// Page one sends no params at all, so a non-paginated server sees
|
|
711
|
+
// exactly the request it saw before pagination existed.
|
|
712
|
+
const res = await listPage(client, cursor, ctx);
|
|
713
|
+
let added = 0;
|
|
714
|
+
for (const t of res.tools) {
|
|
715
|
+
// First page wins. An unstable cursor can serve the same tool on
|
|
716
|
+
// two pages — a duplicate would inflate `toolCount`, double the
|
|
717
|
+
// `search_tools` row, and churn catalog persistence.
|
|
718
|
+
if (names.has(t.name)) continue;
|
|
719
|
+
names.add(t.name);
|
|
720
|
+
listed.push(t);
|
|
721
|
+
added++;
|
|
722
|
+
}
|
|
723
|
+
// Pagination ends when `nextCursor` is absent or null — never merely
|
|
724
|
+
// falsy. Empty string is present and means "keep going".
|
|
725
|
+
const next = res.nextCursor;
|
|
726
|
+
if (next === undefined || next === null) {
|
|
727
|
+
complete = true;
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
// A page that adds nothing and still claims a successor made no
|
|
731
|
+
// progress. Allow exactly one: the widespread idiom is to advertise
|
|
732
|
+
// a cursor whenever a page came back full and then serve one empty
|
|
733
|
+
// page to terminate. Two in a row is a downstream going nowhere.
|
|
734
|
+
if (added === 0 && ++barren > 1) {
|
|
735
|
+
throw new Error(
|
|
736
|
+
`Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
if (added > 0) barren = 0;
|
|
740
|
+
// A cursor handed back a second time is a loop, not a slow server.
|
|
741
|
+
if (spent.has(next)) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
// Checked here rather than on arrival: this bounds what a *walk* may
|
|
747
|
+
// accumulate; a one-page server was always free to send its page.
|
|
748
|
+
if (listed.length > MAX_TOOLS) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
`Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_TOOLS}-tool ceiling one catalog refresh will collect.`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
// Opaque by contract: handed straight back, never parsed, rewritten,
|
|
754
|
+
// or persisted.
|
|
755
|
+
spent.add(next);
|
|
756
|
+
cursor = next;
|
|
674
757
|
}
|
|
675
|
-
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
);
|
|
758
|
+
} catch (err) {
|
|
759
|
+
// A grant can be revoked after connect and after any earlier page.
|
|
760
|
+
// Classify that exactly like connect-time and call-time authorization
|
|
761
|
+
// failures, and latch it for the rest of this request scope.
|
|
762
|
+
if (err instanceof UnauthorizedError) {
|
|
763
|
+
state.authRequired = true;
|
|
764
|
+
throw authRequiredError(err);
|
|
682
765
|
}
|
|
683
|
-
|
|
684
|
-
// or persisted.
|
|
685
|
-
spent.add(next);
|
|
686
|
-
cursor = next;
|
|
766
|
+
throw err;
|
|
687
767
|
}
|
|
688
768
|
// Fail the refresh outright. Returning what we have would publish a
|
|
689
769
|
// partial catalog that looks complete; throwing lets the registry keep
|
|
@@ -734,11 +814,13 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
734
814
|
|
|
735
815
|
async closeScope(ctx) {
|
|
736
816
|
const scope = ctx.requestScope ?? ctx;
|
|
817
|
+
// Tombstone before any lookup or await. This also makes close-before-use
|
|
818
|
+
// terminal rather than allowing the scope to spring into existence later.
|
|
819
|
+
closedScopes.add(scope);
|
|
737
820
|
const state = states.get(scope);
|
|
738
821
|
if (!state) return;
|
|
739
822
|
|
|
740
|
-
// Delete before awaiting: a duplicate teardown is a no-op
|
|
741
|
-
// lookup can reuse the state while its client is closing.
|
|
823
|
+
// Delete before awaiting: a duplicate teardown is a no-op.
|
|
742
824
|
states.delete(scope);
|
|
743
825
|
state.closed = true;
|
|
744
826
|
const client = state.client;
|
|
@@ -752,7 +834,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
752
834
|
// Ask the downstream to drop its session first — closing only aborts our
|
|
753
835
|
// side, and the DELETE that frees the server's rides on the very
|
|
754
836
|
// AbortSignal the close is about to trip.
|
|
755
|
-
if (transport) await terminateSession(transport);
|
|
837
|
+
if (transport) await terminateSession(transport, ctx.logger, id);
|
|
756
838
|
|
|
757
839
|
// Client.close() owns its connected transport. During an unfinished or
|
|
758
840
|
// failed connect there is no cached client yet, so close the transport
|
package/src/credential-health.ts
CHANGED
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
credentialTestRule,
|
|
20
|
+
STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR,
|
|
20
21
|
storedCredentialShape,
|
|
21
22
|
} from "./credentials.js";
|
|
22
23
|
import type { CredentialVault } from "./credentials.js";
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
closeConnectorScope,
|
|
26
|
+
type DeferredWork,
|
|
27
|
+
} from "./connector-scope.js";
|
|
24
28
|
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, withTimeout } from "./timeout.js";
|
|
25
29
|
import type {
|
|
26
30
|
Connector,
|
|
@@ -333,8 +337,8 @@ export interface CredentialCheckOptions {
|
|
|
333
337
|
/** Restrict the sweep to these connector ids. Default: every connector. */
|
|
334
338
|
ids?: string[];
|
|
335
339
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
340
|
+
* @deprecated Ignored. Credential checks always create and close their own
|
|
341
|
+
* probe scope; no core path supplies an existing request scope.
|
|
338
342
|
*/
|
|
339
343
|
requestScope?: object;
|
|
340
344
|
}
|
|
@@ -535,6 +539,7 @@ export class CredentialHealthChecker {
|
|
|
535
539
|
async check(
|
|
536
540
|
baseUrl: string,
|
|
537
541
|
opts: CredentialCheckOptions = {},
|
|
542
|
+
defer?: DeferredWork,
|
|
538
543
|
): Promise<CredentialCheckResult[]> {
|
|
539
544
|
// An id naming no connector is reported, not dropped: a typo in a scheduled
|
|
540
545
|
// check would otherwise return an empty list that looks exactly like a
|
|
@@ -545,7 +550,7 @@ export class CredentialHealthChecker {
|
|
|
545
550
|
return mapWithConcurrency(targets, this.concurrency, (target) =>
|
|
546
551
|
typeof target === "string"
|
|
547
552
|
? Promise.resolve({ connectorId: target, skipped: "not_found" as const })
|
|
548
|
-
: this.checkOne(target, baseUrl, opts),
|
|
553
|
+
: this.checkOne(target, baseUrl, opts, defer),
|
|
549
554
|
);
|
|
550
555
|
}
|
|
551
556
|
|
|
@@ -555,13 +560,16 @@ export class CredentialHealthChecker {
|
|
|
555
560
|
* gate is armed BEFORE the sweep starts, so a burst of concurrent requests
|
|
556
561
|
* produces one sweep.
|
|
557
562
|
*/
|
|
558
|
-
sweepIfDue(
|
|
563
|
+
sweepIfDue(
|
|
564
|
+
baseUrl: string,
|
|
565
|
+
defer?: DeferredWork,
|
|
566
|
+
): Promise<CredentialCheckResult[]> | undefined {
|
|
559
567
|
if (!this.onRequest || this.sweeping) return undefined;
|
|
560
568
|
const now = Date.now();
|
|
561
569
|
if (now < this.nextSweepAt) return undefined;
|
|
562
570
|
if (!this.hasCheckableConnectors()) return undefined;
|
|
563
571
|
this.nextSweepAt = now + this.intervalMs;
|
|
564
|
-
const sweep = this.check(baseUrl).finally(() => {
|
|
572
|
+
const sweep = this.check(baseUrl, {}, defer).finally(() => {
|
|
565
573
|
this.sweeping = undefined;
|
|
566
574
|
});
|
|
567
575
|
this.sweeping = sweep;
|
|
@@ -572,6 +580,7 @@ export class CredentialHealthChecker {
|
|
|
572
580
|
connector: Connector,
|
|
573
581
|
baseUrl: string,
|
|
574
582
|
opts: CredentialCheckOptions,
|
|
583
|
+
defer?: DeferredWork,
|
|
575
584
|
): Promise<CredentialCheckResult> {
|
|
576
585
|
const connectorId = connector.id;
|
|
577
586
|
if (!isCheckableConnector(connector)) {
|
|
@@ -590,7 +599,7 @@ export class CredentialHealthChecker {
|
|
|
590
599
|
connector,
|
|
591
600
|
baseUrl,
|
|
592
601
|
opts.force ?? false,
|
|
593
|
-
|
|
602
|
+
defer,
|
|
594
603
|
);
|
|
595
604
|
this.inFlight.set(connectorId, run);
|
|
596
605
|
try {
|
|
@@ -611,7 +620,7 @@ export class CredentialHealthChecker {
|
|
|
611
620
|
connector: Connector,
|
|
612
621
|
baseUrl: string,
|
|
613
622
|
force: boolean,
|
|
614
|
-
|
|
623
|
+
defer?: DeferredWork,
|
|
615
624
|
): Promise<CredentialCheckResult> {
|
|
616
625
|
const connectorId = connector.id;
|
|
617
626
|
const started = Date.now();
|
|
@@ -633,7 +642,7 @@ export class CredentialHealthChecker {
|
|
|
633
642
|
}
|
|
634
643
|
const shape = storedCredentialShape(connector.credential, values);
|
|
635
644
|
if (shape.state === "mismatch") {
|
|
636
|
-
// Drift is a persistent operator-
|
|
645
|
+
// Drift is a persistent operator-reconfiguration state, not an event:
|
|
637
646
|
// outside the freshness gate it would spend a write on every sweep in
|
|
638
647
|
// every isolate, forever, against exactly the deployments this feature
|
|
639
648
|
// is meant to help (and on Cloudflare KV those writes are metered).
|
|
@@ -646,7 +655,7 @@ export class CredentialHealthChecker {
|
|
|
646
655
|
const current = await this.store.get(connectorId);
|
|
647
656
|
if (
|
|
648
657
|
current &&
|
|
649
|
-
current.state === "
|
|
658
|
+
current.state === "auth_required" &&
|
|
650
659
|
current.message === shape.message &&
|
|
651
660
|
Date.now() - Date.parse(current.checkedAt) < this.intervalMs
|
|
652
661
|
) {
|
|
@@ -654,7 +663,9 @@ export class CredentialHealthChecker {
|
|
|
654
663
|
}
|
|
655
664
|
}
|
|
656
665
|
return this.settle(connectorId, started, generation, {
|
|
657
|
-
|
|
666
|
+
// Unlike a failed check, this is a completed static classification:
|
|
667
|
+
// the current declaration cannot consume what the vault holds.
|
|
668
|
+
state: "auth_required",
|
|
658
669
|
checkedAt: new Date().toISOString(),
|
|
659
670
|
message: shape.message,
|
|
660
671
|
});
|
|
@@ -671,8 +682,9 @@ export class CredentialHealthChecker {
|
|
|
671
682
|
return { connectorId, skipped: "fresh", record: current };
|
|
672
683
|
}
|
|
673
684
|
}
|
|
674
|
-
|
|
675
|
-
|
|
685
|
+
// Credential checks are always probe owners. No caller may lend them an
|
|
686
|
+
// ordinary request scope and thereby suppress the teardown below.
|
|
687
|
+
const scope = {};
|
|
676
688
|
const ctx = this.deps.contextFor(connectorId, baseUrl, scope);
|
|
677
689
|
try {
|
|
678
690
|
if (credentialReadError) {
|
|
@@ -713,7 +725,7 @@ export class CredentialHealthChecker {
|
|
|
713
725
|
});
|
|
714
726
|
}
|
|
715
727
|
} finally {
|
|
716
|
-
|
|
728
|
+
await closeConnectorScope(connector, ctx, defer);
|
|
717
729
|
}
|
|
718
730
|
}
|
|
719
731
|
|
|
@@ -797,9 +809,12 @@ export class CredentialHealthChecker {
|
|
|
797
809
|
* blip. Error verdicts stay visible in `credentialCheck` (an operator wants
|
|
798
810
|
* to know checks are failing) but the status keeps coming from observed real
|
|
799
811
|
* calls, which is evidence.
|
|
800
|
-
* 2. **A successful real call retires the verdict
|
|
801
|
-
* probe, so a `lastSuccessAt` at or after
|
|
802
|
-
*
|
|
812
|
+
* 2. **A successful real call retires the verdict, except static shape drift.**
|
|
813
|
+
* Traffic beats a background probe, so a `lastSuccessAt` at or after
|
|
814
|
+
* `checkedAt` normally means the credential demonstrably works. Stored-shape
|
|
815
|
+
* drift is different: a credential-independent tool can succeed without
|
|
816
|
+
* making a missing declared field appear, so only replacement/removal clears
|
|
817
|
+
* that verdict.
|
|
803
818
|
*
|
|
804
819
|
* `auth_required` deliberately outranks an observed real-call *failure*: both
|
|
805
820
|
* say something is wrong, and only one of them carries the URL that fixes it.
|
|
@@ -810,6 +825,7 @@ export function credentialVerdictApplies(
|
|
|
810
825
|
lastSuccessAt: string | undefined,
|
|
811
826
|
): boolean {
|
|
812
827
|
if (!record || record.state !== "auth_required") return false;
|
|
828
|
+
if (record.message === STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR) return true;
|
|
813
829
|
if (!lastSuccessAt) return true;
|
|
814
830
|
const success = Date.parse(lastSuccessAt);
|
|
815
831
|
return Number.isNaN(success) || success < Date.parse(record.checkedAt);
|