@zackbart/connecta 0.7.0 → 0.7.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 +110 -0
- package/README.md +2 -1
- package/dist/connectors/remote-mcp.d.ts +24 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +208 -88
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +8 -5
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +20 -13
- package/dist/credential-health.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +10 -8
- package/dist/execute.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +57 -5
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +25 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +162 -20
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +18 -22
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +33 -21
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +18 -7
- 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 +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +12 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +187 -6
- 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/connectors/remote-mcp.ts +269 -93
- package/src/credential-health.ts +20 -18
- package/src/execute.ts +18 -7
- package/src/executors/quickjs.ts +65 -5
- package/src/index.ts +7 -2
- package/src/meta-tools.ts +226 -43
- package/src/registry.ts +48 -20
- package/src/server.ts +20 -9
- package/src/timeout.ts +41 -4
- package/src/toolkits.ts +11 -0
- package/src/types.ts +2 -2
- package/src/ui.ts +212 -11
- package/src/version.ts +1 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
2
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
FetchLike,
|
|
6
|
+
Transport,
|
|
7
|
+
} from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
8
|
+
import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
9
|
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
10
|
+
import { z } from "zod";
|
|
6
11
|
import { KvOAuthProvider } from "../auth/downstream-oauth.js";
|
|
7
12
|
import { ConnectorCallError } from "../errors.js";
|
|
8
13
|
import { CONNECTA_VERSION } from "../version.js";
|
|
@@ -18,6 +23,8 @@ export type RemoteMcpAuth =
|
|
|
18
23
|
| { type: "headers"; headers: Record<string, string> }
|
|
19
24
|
| { type: "oauth" };
|
|
20
25
|
|
|
26
|
+
export type RemoteMcpRedirectPolicy = "none" | "same-origin";
|
|
27
|
+
|
|
21
28
|
export interface RemoteMcpOptions {
|
|
22
29
|
url: string;
|
|
23
30
|
/** Human-readable display name; the connector id remains the address prefix. */
|
|
@@ -37,6 +44,14 @@ export interface RemoteMcpOptions {
|
|
|
37
44
|
*/
|
|
38
45
|
usageGuide?: string;
|
|
39
46
|
auth?: RemoteMcpAuth;
|
|
47
|
+
/**
|
|
48
|
+
* Downstream HTTP redirect policy. Defaults to `"none"`: every redirect is
|
|
49
|
+
* rejected. `"same-origin"` follows at most five redirects while preserving
|
|
50
|
+
* standard 301/302/303/307/308 method semantics. Cross-origin redirects and
|
|
51
|
+
* HTTPS downgrades are always refused, so credentials never cross the
|
|
52
|
+
* configured request's origin.
|
|
53
|
+
*/
|
|
54
|
+
redirects?: RemoteMcpRedirectPolicy;
|
|
40
55
|
/**
|
|
41
56
|
* Refuse to connect to a non-`https://` `url` at construction (default
|
|
42
57
|
* false). Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always
|
|
@@ -102,27 +117,33 @@ const MAX_TOOLS = 100_000;
|
|
|
102
117
|
* is a definite loop, two consecutive pages that add no new tools are a server
|
|
103
118
|
* going nowhere, and MAX_TOOLS caps what any of it can accumulate. This exists
|
|
104
119
|
* only so the loop is finite even if a downstream somehow satisfies all three
|
|
105
|
-
* forever
|
|
106
|
-
*
|
|
120
|
+
* forever on a path with no discovery deadline. Set high enough that no honest
|
|
121
|
+
* server reaches it.
|
|
107
122
|
*/
|
|
108
123
|
const MAX_TOOL_PAGES = 10_000;
|
|
109
124
|
|
|
110
125
|
/** One entry of the SDK's `tools/list` result, before it becomes a ToolDef. */
|
|
111
126
|
type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
|
|
112
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Compatibility concession for hand-rolled servers that serialize
|
|
130
|
+
* end-of-pagination as `null`. Only the cursor is widened; every tool and every
|
|
131
|
+
* other result field still passes through the SDK's pinned schema.
|
|
132
|
+
*/
|
|
133
|
+
const CompatibleListToolsResultSchema = ListToolsResultSchema.extend({
|
|
134
|
+
nextCursor: z.string().nullable().optional(),
|
|
135
|
+
});
|
|
136
|
+
|
|
113
137
|
/**
|
|
114
138
|
* Re-prime an SDK client's tool-metadata cache from the *full* walked catalog.
|
|
115
139
|
*
|
|
116
|
-
* `Client.listTools()`
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* structured-content validation, and finds no task requirement so a
|
|
124
|
-
* required-task tool is dispatched as a plain `tools/call`. Enforcement would
|
|
125
|
-
* depend on which page a tool happened to land on, which is not enforcement.
|
|
140
|
+
* The SDK's `Client.listTools()` caches one page at a time and **clears** the
|
|
141
|
+
* output-schema validators and task-support sets before each replacement.
|
|
142
|
+
* This walk uses `Client.request()` so it can make the narrow null-cursor
|
|
143
|
+
* compatibility concession above, then primes the metadata exactly once from
|
|
144
|
+
* the complete chain. Otherwise `callTool` would find no validator or task
|
|
145
|
+
* requirement for earlier-page tools and enforcement would depend on where a
|
|
146
|
+
* tool happened to land, which is not enforcement.
|
|
126
147
|
*
|
|
127
148
|
* So hand the whole aggregated list back deliberately, once, at the end. The
|
|
128
149
|
* SDK types the method `private`, hence the cast; the SDK version is pinned
|
|
@@ -141,9 +162,9 @@ function primeToolMetadata(client: Client, tools: ListedTool[]): void {
|
|
|
141
162
|
}
|
|
142
163
|
|
|
143
164
|
/**
|
|
144
|
-
* True for a result-parse failure caused by the page's `nextCursor` itself
|
|
145
|
-
*
|
|
146
|
-
*
|
|
165
|
+
* True for a result-parse failure caused by the page's `nextCursor` itself.
|
|
166
|
+
* `null` is accepted deliberately; other non-string values remain a named
|
|
167
|
+
* downstream nonconformance instead of surfacing as a raw validation dump.
|
|
147
168
|
* Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
|
|
148
169
|
* zod instance, and cross-instance `instanceof` is a coin flip.
|
|
149
170
|
*/
|
|
@@ -208,6 +229,127 @@ function isLoopbackHost(hostname: string): boolean {
|
|
|
208
229
|
);
|
|
209
230
|
}
|
|
210
231
|
|
|
232
|
+
export const MAX_REMOTE_REDIRECT_HOPS = 5;
|
|
233
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
234
|
+
const BODY_HEADERS = [
|
|
235
|
+
"content-encoding",
|
|
236
|
+
"content-language",
|
|
237
|
+
"content-length",
|
|
238
|
+
"content-location",
|
|
239
|
+
"content-type",
|
|
240
|
+
"transfer-encoding",
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
export class RemoteMcpRedirectError extends ConnectorCallError {
|
|
244
|
+
constructor(connectorId: string, reason: string) {
|
|
245
|
+
super(
|
|
246
|
+
"connector_call_failed",
|
|
247
|
+
`Connector "${connectorId}" redirect policy rejected the downstream response: ${reason}.`,
|
|
248
|
+
);
|
|
249
|
+
this.name = "RemoteMcpRedirectError";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function redirectedInit(init: RequestInit, status: number): RequestInit {
|
|
254
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
255
|
+
const becomesGet =
|
|
256
|
+
(status === 303 && method !== "GET" && method !== "HEAD") ||
|
|
257
|
+
((status === 301 || status === 302) && method === "POST");
|
|
258
|
+
if (!becomesGet) return init;
|
|
259
|
+
const headers = new Headers(init.headers);
|
|
260
|
+
for (const name of BODY_HEADERS) headers.delete(name);
|
|
261
|
+
return { ...init, method: "GET", body: undefined, headers };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Wrap fetch with explicit, bounded redirect handling.
|
|
266
|
+
*
|
|
267
|
+
* The starting URL of each fetch call is trusted by its caller (the configured
|
|
268
|
+
* MCP endpoint, or an OAuth URL discovered by the pinned SDK). Only Location
|
|
269
|
+
* values are policy-controlled here. No rejected target is ever fetched, so
|
|
270
|
+
* arbitrary static header names receive the same protection as Authorization.
|
|
271
|
+
*/
|
|
272
|
+
export function redirectSafeFetch(
|
|
273
|
+
connectorId: string,
|
|
274
|
+
policy: RemoteMcpRedirectPolicy = "none",
|
|
275
|
+
baseFetch: FetchLike = fetch,
|
|
276
|
+
): FetchLike {
|
|
277
|
+
return async (input, initialInit = {}) => {
|
|
278
|
+
let current = new URL(input);
|
|
279
|
+
let init = initialInit;
|
|
280
|
+
const seen = new Set<string>([current.href]);
|
|
281
|
+
let hops = 0;
|
|
282
|
+
|
|
283
|
+
while (true) {
|
|
284
|
+
const response = await baseFetch(current, {
|
|
285
|
+
...init,
|
|
286
|
+
redirect: "manual",
|
|
287
|
+
});
|
|
288
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
289
|
+
|
|
290
|
+
const location = response.headers.get("location");
|
|
291
|
+
await response.body?.cancel().catch(() => {});
|
|
292
|
+
if (!location) {
|
|
293
|
+
throw new RemoteMcpRedirectError(
|
|
294
|
+
connectorId,
|
|
295
|
+
`HTTP ${response.status} carried no Location header`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (policy === "none") {
|
|
299
|
+
throw new RemoteMcpRedirectError(
|
|
300
|
+
connectorId,
|
|
301
|
+
`HTTP ${response.status} redirects are disabled`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (hops >= MAX_REMOTE_REDIRECT_HOPS) {
|
|
305
|
+
throw new RemoteMcpRedirectError(
|
|
306
|
+
connectorId,
|
|
307
|
+
`the redirect chain exceeded ${MAX_REMOTE_REDIRECT_HOPS} hops`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let next: URL;
|
|
312
|
+
try {
|
|
313
|
+
next = new URL(location, current);
|
|
314
|
+
} catch {
|
|
315
|
+
throw new RemoteMcpRedirectError(
|
|
316
|
+
connectorId,
|
|
317
|
+
`HTTP ${response.status} carried an invalid Location header`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (current.protocol === "https:" && next.protocol !== "https:") {
|
|
321
|
+
throw new RemoteMcpRedirectError(
|
|
322
|
+
connectorId,
|
|
323
|
+
"an HTTPS-to-HTTP downgrade is not allowed",
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (next.origin !== current.origin) {
|
|
327
|
+
throw new RemoteMcpRedirectError(
|
|
328
|
+
connectorId,
|
|
329
|
+
"a cross-origin redirect is not allowed",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (next.username || next.password) {
|
|
333
|
+
throw new RemoteMcpRedirectError(
|
|
334
|
+
connectorId,
|
|
335
|
+
"a redirect target containing URL credentials is not allowed",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (seen.has(next.href)) {
|
|
339
|
+
throw new RemoteMcpRedirectError(
|
|
340
|
+
connectorId,
|
|
341
|
+
"the redirect chain loops",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
seen.add(next.href);
|
|
346
|
+
hops++;
|
|
347
|
+
init = redirectedInit(init, response.status);
|
|
348
|
+
current = next;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
211
353
|
interface ConnectionState {
|
|
212
354
|
client: Client | null;
|
|
213
355
|
transport: Transport | null;
|
|
@@ -238,6 +380,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
238
380
|
// transport, response bodies, AbortSignals, or connection promise reachable
|
|
239
381
|
// from the isolate singleton. Those are request-bound in Cloudflare Workers.
|
|
240
382
|
const states = new WeakMap<object, ConnectionState>();
|
|
383
|
+
// Closing is terminal even after `states.delete`: a late or future lookup
|
|
384
|
+
// must not recreate an ownerless connection under the ended scope.
|
|
385
|
+
const closedScopes = new WeakSet<object>();
|
|
241
386
|
const isOauth = opts.auth?.type === "oauth";
|
|
242
387
|
const logger = opts.logger ?? console;
|
|
243
388
|
|
|
@@ -273,22 +418,33 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
273
418
|
new Error(`Connector "${id}" scope ended during connection.`);
|
|
274
419
|
|
|
275
420
|
/**
|
|
276
|
-
* One `tools/list` request. The
|
|
277
|
-
* `
|
|
278
|
-
*
|
|
279
|
-
* *absent* cursor. The SDK surfaces that as a raw validation dump about a
|
|
280
|
-
* field the operator never sees; say which server broke which rule instead.
|
|
281
|
-
* Accepting `null` as end-of-chain outright is issue #99.
|
|
421
|
+
* One `tools/list` request. The SDK schema is retained wholesale except for
|
|
422
|
+
* accepting `null` as the common, unambiguous end-of-chain spelling. Other
|
|
423
|
+
* cursor shapes still get a useful connector-level diagnosis.
|
|
282
424
|
*/
|
|
283
|
-
const listPage = async (
|
|
425
|
+
const listPage = async (
|
|
426
|
+
client: Client,
|
|
427
|
+
cursor: string | undefined,
|
|
428
|
+
ctx: ConnectorContext,
|
|
429
|
+
) => {
|
|
284
430
|
try {
|
|
285
|
-
return await client.
|
|
286
|
-
|
|
431
|
+
return await client.request(
|
|
432
|
+
{
|
|
433
|
+
method: "tools/list",
|
|
434
|
+
...(cursor === undefined ? {} : { params: { cursor } }),
|
|
435
|
+
},
|
|
436
|
+
CompatibleListToolsResultSchema,
|
|
437
|
+
ctx.timeoutMs || ctx.signal
|
|
438
|
+
? {
|
|
439
|
+
...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
|
|
440
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
441
|
+
}
|
|
442
|
+
: undefined,
|
|
287
443
|
);
|
|
288
444
|
} catch (err) {
|
|
289
445
|
if (!isCursorShapeError(err)) throw err;
|
|
290
446
|
throw new Error(
|
|
291
|
-
`Connector "${id}" returned a tools/list page whose nextCursor is neither a string nor absent
|
|
447
|
+
`Connector "${id}" returned a tools/list page whose nextCursor is neither a string, null, nor absent — this catalog cannot be walked.`,
|
|
292
448
|
{ cause: err },
|
|
293
449
|
);
|
|
294
450
|
}
|
|
@@ -296,6 +452,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
296
452
|
|
|
297
453
|
const stateFor = (ctx: ConnectorContext): ConnectionState => {
|
|
298
454
|
const scope = ctx.requestScope ?? ctx;
|
|
455
|
+
if (closedScopes.has(scope)) throw scopeEndedError();
|
|
299
456
|
let state = states.get(scope);
|
|
300
457
|
if (!state) {
|
|
301
458
|
state = {
|
|
@@ -324,30 +481,27 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
324
481
|
return state.provider;
|
|
325
482
|
};
|
|
326
483
|
|
|
327
|
-
// NOTE: StreamableHTTPClientTransport speaks over fetch, which transparently
|
|
328
|
-
// follows 3xx redirects. A malicious or compromised downstream MCP could
|
|
329
|
-
// redirect to an internal address (e.g. http://169.254.169.254/…) and fetch
|
|
330
|
-
// would re-issue the request — potentially carrying static auth headers. The
|
|
331
|
-
// scheme check above only guards the first hop; a fully robust guard (manual
|
|
332
|
-
// redirect handling + per-hop re-validation + stripping auth headers cross-
|
|
333
|
-
// origin) lives in the SDK transport and is deferred to a future non-patch
|
|
334
|
-
// release rather than reimplemented here.
|
|
335
484
|
const buildTransport = (
|
|
336
485
|
ctx: ConnectorContext,
|
|
337
486
|
state: ConnectionState,
|
|
338
487
|
): Transport => {
|
|
339
488
|
if (opts._transportFactory) return opts._transportFactory(ctx);
|
|
340
489
|
const url = new URL(opts.url);
|
|
490
|
+
const guardedFetch = redirectSafeFetch(id, opts.redirects);
|
|
341
491
|
if (opts.auth?.type === "oauth") {
|
|
342
492
|
return new StreamableHTTPClientTransport(url, {
|
|
343
493
|
authProvider: getProvider(ctx, state),
|
|
494
|
+
fetch: guardedFetch,
|
|
344
495
|
});
|
|
345
496
|
}
|
|
346
497
|
const headers =
|
|
347
498
|
opts.auth?.type === "headers" ? opts.auth.headers : undefined;
|
|
348
499
|
return new StreamableHTTPClientTransport(
|
|
349
500
|
url,
|
|
350
|
-
|
|
501
|
+
{
|
|
502
|
+
...(headers ? { requestInit: { headers } } : {}),
|
|
503
|
+
fetch: guardedFetch,
|
|
504
|
+
},
|
|
351
505
|
);
|
|
352
506
|
};
|
|
353
507
|
|
|
@@ -364,6 +518,14 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
364
518
|
ctx: ConnectorContext,
|
|
365
519
|
state: ConnectionState,
|
|
366
520
|
): Promise<void> => {
|
|
521
|
+
// A 401 after connect is a verdict for the whole request scope, not merely
|
|
522
|
+
// for the one call that observed it. Do not let the still-cached client make
|
|
523
|
+
// a later status or call in the same scope report healthy.
|
|
524
|
+
if (state.authRequired) {
|
|
525
|
+
throw authRequiredError(
|
|
526
|
+
new UnauthorizedError("Downstream authorization is no longer valid."),
|
|
527
|
+
);
|
|
528
|
+
}
|
|
367
529
|
// Cross-isolate force re-auth: another isolate bumped the KV generation and
|
|
368
530
|
// wiped credentials. This request's cached client still speaks the old
|
|
369
531
|
// token — drop it so the next connect runs against current state.
|
|
@@ -495,64 +657,76 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
495
657
|
/** Consecutive pages that advertised a successor but added nothing. */
|
|
496
658
|
let barren = 0;
|
|
497
659
|
let complete = false;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
)
|
|
660
|
+
try {
|
|
661
|
+
for (let page = 0; page < MAX_TOOL_PAGES; page++) {
|
|
662
|
+
// The scope can end between pages (probe timeout, teardown). Stop
|
|
663
|
+
// rather than keep paging into a transport that is being closed.
|
|
664
|
+
if (state.closed) throw scopeEndedError();
|
|
665
|
+
// A discovery deadline uses the same signal for the whole chain.
|
|
666
|
+
// Check it before issuing each page as well as passing it to the
|
|
667
|
+
// in-flight SDK request, so expiry never starts one more round trip.
|
|
668
|
+
if (ctx.signal?.aborted) {
|
|
669
|
+
throw ctx.signal.reason instanceof Error
|
|
670
|
+
? ctx.signal.reason
|
|
671
|
+
: new Error(`Connector "${id}" catalog deadline expired.`);
|
|
672
|
+
}
|
|
673
|
+
// Page one sends no params at all, so a non-paginated server sees
|
|
674
|
+
// exactly the request it saw before pagination existed.
|
|
675
|
+
const res = await listPage(client, cursor, ctx);
|
|
676
|
+
let added = 0;
|
|
677
|
+
for (const t of res.tools) {
|
|
678
|
+
// First page wins. An unstable cursor can serve the same tool on
|
|
679
|
+
// two pages — a duplicate would inflate `toolCount`, double the
|
|
680
|
+
// `search_tools` row, and churn catalog persistence.
|
|
681
|
+
if (names.has(t.name)) continue;
|
|
682
|
+
names.add(t.name);
|
|
683
|
+
listed.push(t);
|
|
684
|
+
added++;
|
|
685
|
+
}
|
|
686
|
+
// Pagination ends when `nextCursor` is absent or null — never merely
|
|
687
|
+
// falsy. Empty string is present and means "keep going".
|
|
688
|
+
const next = res.nextCursor;
|
|
689
|
+
if (next === undefined || next === null) {
|
|
690
|
+
complete = true;
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
// A page that adds nothing and still claims a successor made no
|
|
694
|
+
// progress. Allow exactly one: the widespread idiom is to advertise
|
|
695
|
+
// a cursor whenever a page came back full and then serve one empty
|
|
696
|
+
// page to terminate. Two in a row is a downstream going nowhere.
|
|
697
|
+
if (added === 0 && ++barren > 1) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (added > 0) barren = 0;
|
|
703
|
+
// A cursor handed back a second time is a loop, not a slow server.
|
|
704
|
+
if (spent.has(next)) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
`Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
// Checked here rather than on arrival: this bounds what a *walk* may
|
|
710
|
+
// accumulate; a one-page server was always free to send its page.
|
|
711
|
+
if (listed.length > MAX_TOOLS) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_TOOLS}-tool ceiling one catalog refresh will collect.`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
// Opaque by contract: handed straight back, never parsed, rewritten,
|
|
717
|
+
// or persisted.
|
|
718
|
+
spent.add(next);
|
|
719
|
+
cursor = next;
|
|
543
720
|
}
|
|
544
|
-
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
// A grant can be revoked after connect and after any earlier page.
|
|
723
|
+
// Classify that exactly like connect-time and call-time authorization
|
|
724
|
+
// failures, and latch it for the rest of this request scope.
|
|
725
|
+
if (err instanceof UnauthorizedError) {
|
|
726
|
+
state.authRequired = true;
|
|
727
|
+
throw authRequiredError(err);
|
|
551
728
|
}
|
|
552
|
-
|
|
553
|
-
// or persisted.
|
|
554
|
-
spent.add(next);
|
|
555
|
-
cursor = next;
|
|
729
|
+
throw err;
|
|
556
730
|
}
|
|
557
731
|
// Fail the refresh outright. Returning what we have would publish a
|
|
558
732
|
// partial catalog that looks complete; throwing lets the registry keep
|
|
@@ -603,11 +777,13 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
603
777
|
|
|
604
778
|
async closeScope(ctx) {
|
|
605
779
|
const scope = ctx.requestScope ?? ctx;
|
|
780
|
+
// Tombstone before any lookup or await. This also makes close-before-use
|
|
781
|
+
// terminal rather than allowing the scope to spring into existence later.
|
|
782
|
+
closedScopes.add(scope);
|
|
606
783
|
const state = states.get(scope);
|
|
607
784
|
if (!state) return;
|
|
608
785
|
|
|
609
|
-
// Delete before awaiting: a duplicate teardown is a no-op
|
|
610
|
-
// lookup can reuse the state while its client is closing.
|
|
786
|
+
// Delete before awaiting: a duplicate teardown is a no-op.
|
|
611
787
|
states.delete(scope);
|
|
612
788
|
state.closed = true;
|
|
613
789
|
const client = state.client;
|
package/src/credential-health.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
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";
|
|
@@ -333,8 +334,8 @@ export interface CredentialCheckOptions {
|
|
|
333
334
|
/** Restrict the sweep to these connector ids. Default: every connector. */
|
|
334
335
|
ids?: string[];
|
|
335
336
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
337
|
+
* @deprecated Ignored. Credential checks always create and close their own
|
|
338
|
+
* probe scope; no core path supplies an existing request scope.
|
|
338
339
|
*/
|
|
339
340
|
requestScope?: object;
|
|
340
341
|
}
|
|
@@ -586,12 +587,7 @@ export class CredentialHealthChecker {
|
|
|
586
587
|
...(await this.recordOrNothing(connectorId)),
|
|
587
588
|
};
|
|
588
589
|
}
|
|
589
|
-
const run = this.runCheck(
|
|
590
|
-
connector,
|
|
591
|
-
baseUrl,
|
|
592
|
-
opts.force ?? false,
|
|
593
|
-
opts.requestScope,
|
|
594
|
-
);
|
|
590
|
+
const run = this.runCheck(connector, baseUrl, opts.force ?? false);
|
|
595
591
|
this.inFlight.set(connectorId, run);
|
|
596
592
|
try {
|
|
597
593
|
return await run;
|
|
@@ -611,7 +607,6 @@ export class CredentialHealthChecker {
|
|
|
611
607
|
connector: Connector,
|
|
612
608
|
baseUrl: string,
|
|
613
609
|
force: boolean,
|
|
614
|
-
requestScope?: object,
|
|
615
610
|
): Promise<CredentialCheckResult> {
|
|
616
611
|
const connectorId = connector.id;
|
|
617
612
|
const started = Date.now();
|
|
@@ -633,7 +628,7 @@ export class CredentialHealthChecker {
|
|
|
633
628
|
}
|
|
634
629
|
const shape = storedCredentialShape(connector.credential, values);
|
|
635
630
|
if (shape.state === "mismatch") {
|
|
636
|
-
// Drift is a persistent operator-
|
|
631
|
+
// Drift is a persistent operator-reconfiguration state, not an event:
|
|
637
632
|
// outside the freshness gate it would spend a write on every sweep in
|
|
638
633
|
// every isolate, forever, against exactly the deployments this feature
|
|
639
634
|
// is meant to help (and on Cloudflare KV those writes are metered).
|
|
@@ -646,7 +641,7 @@ export class CredentialHealthChecker {
|
|
|
646
641
|
const current = await this.store.get(connectorId);
|
|
647
642
|
if (
|
|
648
643
|
current &&
|
|
649
|
-
current.state === "
|
|
644
|
+
current.state === "auth_required" &&
|
|
650
645
|
current.message === shape.message &&
|
|
651
646
|
Date.now() - Date.parse(current.checkedAt) < this.intervalMs
|
|
652
647
|
) {
|
|
@@ -654,7 +649,9 @@ export class CredentialHealthChecker {
|
|
|
654
649
|
}
|
|
655
650
|
}
|
|
656
651
|
return this.settle(connectorId, started, generation, {
|
|
657
|
-
|
|
652
|
+
// Unlike a failed check, this is a completed static classification:
|
|
653
|
+
// the current declaration cannot consume what the vault holds.
|
|
654
|
+
state: "auth_required",
|
|
658
655
|
checkedAt: new Date().toISOString(),
|
|
659
656
|
message: shape.message,
|
|
660
657
|
});
|
|
@@ -671,8 +668,9 @@ export class CredentialHealthChecker {
|
|
|
671
668
|
return { connectorId, skipped: "fresh", record: current };
|
|
672
669
|
}
|
|
673
670
|
}
|
|
674
|
-
|
|
675
|
-
|
|
671
|
+
// Credential checks are always probe owners. No caller may lend them an
|
|
672
|
+
// ordinary request scope and thereby suppress the teardown below.
|
|
673
|
+
const scope = {};
|
|
676
674
|
const ctx = this.deps.contextFor(connectorId, baseUrl, scope);
|
|
677
675
|
try {
|
|
678
676
|
if (credentialReadError) {
|
|
@@ -713,7 +711,7 @@ export class CredentialHealthChecker {
|
|
|
713
711
|
});
|
|
714
712
|
}
|
|
715
713
|
} finally {
|
|
716
|
-
|
|
714
|
+
await closeConnectorScope(connector, ctx);
|
|
717
715
|
}
|
|
718
716
|
}
|
|
719
717
|
|
|
@@ -797,9 +795,12 @@ export class CredentialHealthChecker {
|
|
|
797
795
|
* blip. Error verdicts stay visible in `credentialCheck` (an operator wants
|
|
798
796
|
* to know checks are failing) but the status keeps coming from observed real
|
|
799
797
|
* calls, which is evidence.
|
|
800
|
-
* 2. **A successful real call retires the verdict
|
|
801
|
-
* probe, so a `lastSuccessAt` at or after
|
|
802
|
-
*
|
|
798
|
+
* 2. **A successful real call retires the verdict, except static shape drift.**
|
|
799
|
+
* Traffic beats a background probe, so a `lastSuccessAt` at or after
|
|
800
|
+
* `checkedAt` normally means the credential demonstrably works. Stored-shape
|
|
801
|
+
* drift is different: a credential-independent tool can succeed without
|
|
802
|
+
* making a missing declared field appear, so only replacement/removal clears
|
|
803
|
+
* that verdict.
|
|
803
804
|
*
|
|
804
805
|
* `auth_required` deliberately outranks an observed real-call *failure*: both
|
|
805
806
|
* say something is wrong, and only one of them carries the URL that fixes it.
|
|
@@ -810,6 +811,7 @@ export function credentialVerdictApplies(
|
|
|
810
811
|
lastSuccessAt: string | undefined,
|
|
811
812
|
): boolean {
|
|
812
813
|
if (!record || record.state !== "auth_required") return false;
|
|
814
|
+
if (record.message === STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR) return true;
|
|
813
815
|
if (!lastSuccessAt) return true;
|
|
814
816
|
const success = Date.parse(lastSuccessAt);
|
|
815
817
|
return Number.isNaN(success) || success < Date.parse(record.checkedAt);
|