@zackbart/connecta 0.6.1 → 0.7.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +24 -20
  3. package/dist/auth/bearer.d.ts +4 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +10 -8
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +8 -7
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +27 -8
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connector-scope.d.ts +13 -0
  12. package/dist/connector-scope.d.ts.map +1 -0
  13. package/dist/connector-scope.js +35 -0
  14. package/dist/connector-scope.js.map +1 -0
  15. package/dist/connectors/api.d.ts +5 -5
  16. package/dist/connectors/api.d.ts.map +1 -1
  17. package/dist/connectors/remote-mcp.d.ts +27 -4
  18. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  19. package/dist/connectors/remote-mcp.js +400 -19
  20. package/dist/connectors/remote-mcp.js.map +1 -1
  21. package/dist/credential-health.d.ts +8 -5
  22. package/dist/credential-health.d.ts.map +1 -1
  23. package/dist/credential-health.js +99 -51
  24. package/dist/credential-health.js.map +1 -1
  25. package/dist/credentials.d.ts +51 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js +68 -3
  28. package/dist/credentials.js.map +1 -1
  29. package/dist/execute.d.ts.map +1 -1
  30. package/dist/execute.js +10 -8
  31. package/dist/execute.js.map +1 -1
  32. package/dist/index.d.ts +84 -83
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +74 -24
  35. package/dist/index.js.map +1 -1
  36. package/dist/meta-tools.d.ts +28 -3
  37. package/dist/meta-tools.d.ts.map +1 -1
  38. package/dist/meta-tools.js +131 -16
  39. package/dist/meta-tools.js.map +1 -1
  40. package/dist/registry.d.ts +3 -2
  41. package/dist/registry.d.ts.map +1 -1
  42. package/dist/registry.js +4 -3
  43. package/dist/registry.js.map +1 -1
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +152 -52
  47. package/dist/server.js.map +1 -1
  48. package/dist/toolkits.js +1 -1
  49. package/dist/types.d.ts +41 -27
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/ui.d.ts +24 -10
  52. package/dist/ui.d.ts.map +1 -1
  53. package/dist/ui.js +594 -172
  54. package/dist/ui.js.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -2
  58. package/src/auth/bearer.ts +10 -8
  59. package/src/auth/clerk.ts +28 -9
  60. package/src/connector-scope.ts +41 -0
  61. package/src/connectors/api.ts +5 -5
  62. package/src/connectors/remote-mcp.ts +489 -35
  63. package/src/credential-health.ts +120 -57
  64. package/src/credentials.ts +96 -3
  65. package/src/execute.ts +18 -7
  66. package/src/index.ts +174 -107
  67. package/src/meta-tools.ts +173 -24
  68. package/src/registry.ts +4 -3
  69. package/src/server.ts +191 -70
  70. package/src/toolkits.ts +1 -1
  71. package/src/types.ts +41 -27
  72. package/src/ui.ts +631 -170
  73. package/src/version.ts +1 -1
@@ -1,7 +1,10 @@
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 { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
+ import type {
5
+ FetchLike,
6
+ Transport,
7
+ } from "@modelcontextprotocol/sdk/shared/transport.js";
5
8
  import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
6
9
  import { KvOAuthProvider } from "../auth/downstream-oauth.js";
7
10
  import { ConnectorCallError } from "../errors.js";
@@ -18,6 +21,8 @@ export type RemoteMcpAuth =
18
21
  | { type: "headers"; headers: Record<string, string> }
19
22
  | { type: "oauth" };
20
23
 
24
+ export type RemoteMcpRedirectPolicy = "none" | "same-origin";
25
+
21
26
  export interface RemoteMcpOptions {
22
27
  url: string;
23
28
  /** Human-readable display name; the connector id remains the address prefix. */
@@ -26,9 +31,9 @@ export interface RemoteMcpOptions {
26
31
  /**
27
32
  * Max inline result size (bytes) for this connector's tools before
28
33
  * call_tool/batch_call truncate and stash the full text for get_result
29
- * paging. Overrides the deployment's `maxResultBytes`; omit to inherit it.
30
- * Must be a whole number of bytes >= 1; anything else warns at startup and
31
- * is ignored.
34
+ * paging. Overrides the deployment's `calls.maxResultBytes`; omit to inherit
35
+ * it. Must be a whole number of bytes >= 1; anything else warns at startup
36
+ * and is ignored.
32
37
  */
33
38
  maxResultBytes?: number;
34
39
  /**
@@ -37,6 +42,14 @@ export interface RemoteMcpOptions {
37
42
  */
38
43
  usageGuide?: string;
39
44
  auth?: RemoteMcpAuth;
45
+ /**
46
+ * Downstream HTTP redirect policy. Defaults to `"none"`: every redirect is
47
+ * rejected. `"same-origin"` follows at most five redirects while preserving
48
+ * standard 301/302/303/307/308 method semantics. Cross-origin redirects and
49
+ * HTTPS downgrades are always refused, so credentials never cross the
50
+ * configured request's origin.
51
+ */
52
+ redirects?: RemoteMcpRedirectPolicy;
40
53
  /**
41
54
  * Refuse to connect to a non-`https://` `url` at construction (default
42
55
  * false). Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always
@@ -58,10 +71,147 @@ export interface RemoteMcpOptions {
58
71
  _transportFactory?: (ctx: ConnectorContext) => Transport;
59
72
  }
60
73
 
74
+ /**
75
+ * How long a downstream gets to answer the session-termination DELETE before
76
+ * teardown stops waiting. Deliberately a fraction of the core's scope-close
77
+ * budget (`closeConnectorScope`), so the whole teardown still lands inside the
78
+ * window that budget makes safe on an edge runtime: whatever is still in flight
79
+ * when this expires is aborted by the close that immediately follows. What that
80
+ * costs is only the acknowledgement — the DELETE is headers-only and has long
81
+ * since gone out — so a slow provider still usually ends the session; it just
82
+ * does not get to tell us so.
83
+ */
84
+ const TERMINATE_SESSION_BUDGET_MS = 50;
85
+
86
+ /**
87
+ * Ceiling on the tools one catalog refresh will accumulate while walking
88
+ * `tools/list`.
89
+ *
90
+ * This is the bound that matters, and pages are the wrong dimension to put it
91
+ * in: the *server* picks the page size, so a page ceiling is a tool ceiling
92
+ * multiplied by a number connecta can neither observe in advance nor control.
93
+ * At ten tools a page, 100 pages is 1,000 tools; at a hundred, 10,000 — and
94
+ * connecta's own large-catalog envelope is benchmarked to 100,000 (issue #82).
95
+ * A page ceiling low enough to be a real defense therefore sits *inside* the
96
+ * catalog sizes this product exists to serve. Worse, the common conformant
97
+ * idiom is to advertise a `nextCursor` whenever a page came back full and then
98
+ * serve one empty page to terminate, so a perfectly well-behaved 10,000-tool
99
+ * server paging at 100 spends 101 requests: bound the pages and its entire
100
+ * catalog fails, for doing nothing wrong.
101
+ *
102
+ * So the ceiling goes on accumulated tools — the thing actually held in memory
103
+ * — and it sits at the top of the benchmarked envelope rather than below it.
104
+ * Deliberately the same philosophy as issue #82's discovery-response bounds:
105
+ * cap the bytes a caller can be made to hold, not the number of round trips it
106
+ * took to get them.
107
+ */
108
+ const MAX_TOOLS = 100_000;
109
+
110
+ /**
111
+ * Absolute backstop on `tools/list` pages in one refresh — a runaway guard, not
112
+ * the primary defense.
113
+ *
114
+ * The walk terminates on its own well before this: a cursor handed back twice
115
+ * is a definite loop, two consecutive pages that add no new tools are a server
116
+ * going nowhere, and MAX_TOOLS caps what any of it can accumulate. This exists
117
+ * only so the loop is finite even if a downstream somehow satisfies all three
118
+ * forever, because the caller's probe deadline abandons the *caller*, not the
119
+ * loop. Set high enough that no honest server reaches it.
120
+ */
121
+ const MAX_TOOL_PAGES = 10_000;
122
+
123
+ /** One entry of the SDK's `tools/list` result, before it becomes a ToolDef. */
124
+ type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
125
+
126
+ /**
127
+ * Re-prime an SDK client's tool-metadata cache from the *full* walked catalog.
128
+ *
129
+ * `Client.listTools()` ends by calling its private `cacheToolMetadata`, which
130
+ * **clears** the output-schema validators and the task-support sets before
131
+ * repopulating them from the page it just received. Call it once per page —
132
+ * which walking the chain necessarily does — and the request-scoped client is
133
+ * left holding metadata for the *last* page alone. `callTool` then finds no
134
+ * validator for every earlier-page tool and silently skips both the "declared
135
+ * an outputSchema but returned no structuredContent" check and the
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
+ *
140
+ * So hand the whole aggregated list back deliberately, once, at the end. The
141
+ * SDK types the method `private`, hence the cast; the SDK version is pinned
142
+ * exactly and `test/remote-mcp-pagination.test.ts` asserts the method still
143
+ * exists, so a bump that renames it fails CI rather than quietly restoring the
144
+ * bug.
145
+ */
146
+ function primeToolMetadata(client: Client, tools: ListedTool[]): void {
147
+ const prime = (
148
+ client as unknown as {
149
+ cacheToolMetadata?: (tools: ListedTool[]) => void;
150
+ }
151
+ ).cacheToolMetadata;
152
+ if (typeof prime !== "function") return;
153
+ prime.call(client, tools);
154
+ }
155
+
156
+ /**
157
+ * True for a result-parse failure caused by the page's `nextCursor` itself —
158
+ * in practice `nextCursor: null`, a very common JSON idiom for "no more pages"
159
+ * that the MCP schema does not accept (the chain ends on an *absent* cursor).
160
+ * Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
161
+ * zod instance, and cross-instance `instanceof` is a coin flip.
162
+ */
163
+ function isCursorShapeError(err: unknown): boolean {
164
+ const issues = (err as { issues?: unknown } | null)?.issues;
165
+ return (
166
+ Array.isArray(issues) &&
167
+ issues.some((issue) => {
168
+ const path = (issue as { path?: unknown }).path;
169
+ return Array.isArray(path) && path[0] === "nextCursor";
170
+ })
171
+ );
172
+ }
173
+
61
174
  function msg(err: unknown): string {
62
175
  return err instanceof Error ? err.message : String(err);
63
176
  }
64
177
 
178
+ /**
179
+ * End the downstream's session before the connection is torn down.
180
+ *
181
+ * `Client.close()` only unwinds our side — it aborts the transport's controller
182
+ * and fires `onclose`. Spec session termination is a separate DELETE carrying
183
+ * `Mcp-Session-Id`, and without it a stateful provider keeps the session alive
184
+ * until its own (often hour-long) timeout, which a periodic probe would then
185
+ * accumulate several of per connector.
186
+ *
187
+ * Ordering is load-bearing: the SDK sends that DELETE on the transport's
188
+ * AbortSignal, so calling this *after* close would abort the request on issue
189
+ * and silently do nothing. Everything else is best-effort — a transport with no
190
+ * `terminateSession` (a custom one, or an older SDK), a downstream that refuses
191
+ * (405 is a legal answer), errors, or never replies all fall through to the
192
+ * close with the session left to age out as it did before.
193
+ */
194
+ async function terminateSession(transport: Transport): Promise<void> {
195
+ const terminate = (
196
+ transport as Transport & { terminateSession?: () => Promise<void> }
197
+ ).terminateSession;
198
+ if (typeof terminate !== "function") return;
199
+ // The SDK issues no request at all when no `mcp-session-id` was captured, so
200
+ // a stateless downstream never sees a spurious DELETE.
201
+ const done = (async () => terminate.call(transport))().catch(() => {
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
+ });
206
+ await new Promise<void>((resolve) => {
207
+ const timer = setTimeout(resolve, TERMINATE_SESSION_BUDGET_MS);
208
+ done.then(() => {
209
+ clearTimeout(timer);
210
+ resolve();
211
+ });
212
+ });
213
+ }
214
+
65
215
  function isLoopbackHost(hostname: string): boolean {
66
216
  return (
67
217
  hostname === "localhost" ||
@@ -71,6 +221,127 @@ function isLoopbackHost(hostname: string): boolean {
71
221
  );
72
222
  }
73
223
 
224
+ export const MAX_REMOTE_REDIRECT_HOPS = 5;
225
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
226
+ const BODY_HEADERS = [
227
+ "content-encoding",
228
+ "content-language",
229
+ "content-length",
230
+ "content-location",
231
+ "content-type",
232
+ "transfer-encoding",
233
+ ];
234
+
235
+ export class RemoteMcpRedirectError extends ConnectorCallError {
236
+ constructor(connectorId: string, reason: string) {
237
+ super(
238
+ "connector_call_failed",
239
+ `Connector "${connectorId}" redirect policy rejected the downstream response: ${reason}.`,
240
+ );
241
+ this.name = "RemoteMcpRedirectError";
242
+ }
243
+ }
244
+
245
+ function redirectedInit(init: RequestInit, status: number): RequestInit {
246
+ const method = (init.method ?? "GET").toUpperCase();
247
+ const becomesGet =
248
+ (status === 303 && method !== "GET" && method !== "HEAD") ||
249
+ ((status === 301 || status === 302) && method === "POST");
250
+ if (!becomesGet) return init;
251
+ const headers = new Headers(init.headers);
252
+ for (const name of BODY_HEADERS) headers.delete(name);
253
+ return { ...init, method: "GET", body: undefined, headers };
254
+ }
255
+
256
+ /**
257
+ * Wrap fetch with explicit, bounded redirect handling.
258
+ *
259
+ * The starting URL of each fetch call is trusted by its caller (the configured
260
+ * MCP endpoint, or an OAuth URL discovered by the pinned SDK). Only Location
261
+ * values are policy-controlled here. No rejected target is ever fetched, so
262
+ * arbitrary static header names receive the same protection as Authorization.
263
+ */
264
+ export function redirectSafeFetch(
265
+ connectorId: string,
266
+ policy: RemoteMcpRedirectPolicy = "none",
267
+ baseFetch: FetchLike = fetch,
268
+ ): FetchLike {
269
+ return async (input, initialInit = {}) => {
270
+ let current = new URL(input);
271
+ let init = initialInit;
272
+ const seen = new Set<string>([current.href]);
273
+ let hops = 0;
274
+
275
+ while (true) {
276
+ const response = await baseFetch(current, {
277
+ ...init,
278
+ redirect: "manual",
279
+ });
280
+ if (!REDIRECT_STATUSES.has(response.status)) return response;
281
+
282
+ const location = response.headers.get("location");
283
+ await response.body?.cancel().catch(() => {});
284
+ if (!location) {
285
+ throw new RemoteMcpRedirectError(
286
+ connectorId,
287
+ `HTTP ${response.status} carried no Location header`,
288
+ );
289
+ }
290
+ if (policy === "none") {
291
+ throw new RemoteMcpRedirectError(
292
+ connectorId,
293
+ `HTTP ${response.status} redirects are disabled`,
294
+ );
295
+ }
296
+ if (hops >= MAX_REMOTE_REDIRECT_HOPS) {
297
+ throw new RemoteMcpRedirectError(
298
+ connectorId,
299
+ `the redirect chain exceeded ${MAX_REMOTE_REDIRECT_HOPS} hops`,
300
+ );
301
+ }
302
+
303
+ let next: URL;
304
+ try {
305
+ next = new URL(location, current);
306
+ } catch {
307
+ throw new RemoteMcpRedirectError(
308
+ connectorId,
309
+ `HTTP ${response.status} carried an invalid Location header`,
310
+ );
311
+ }
312
+ if (current.protocol === "https:" && next.protocol !== "https:") {
313
+ throw new RemoteMcpRedirectError(
314
+ connectorId,
315
+ "an HTTPS-to-HTTP downgrade is not allowed",
316
+ );
317
+ }
318
+ if (next.origin !== current.origin) {
319
+ throw new RemoteMcpRedirectError(
320
+ connectorId,
321
+ "a cross-origin redirect is not allowed",
322
+ );
323
+ }
324
+ if (next.username || next.password) {
325
+ throw new RemoteMcpRedirectError(
326
+ connectorId,
327
+ "a redirect target containing URL credentials is not allowed",
328
+ );
329
+ }
330
+ if (seen.has(next.href)) {
331
+ throw new RemoteMcpRedirectError(
332
+ connectorId,
333
+ "the redirect chain loops",
334
+ );
335
+ }
336
+
337
+ seen.add(next.href);
338
+ hops++;
339
+ init = redirectedInit(init, response.status);
340
+ current = next;
341
+ }
342
+ };
343
+ }
344
+
74
345
  interface ConnectionState {
75
346
  client: Client | null;
76
347
  transport: Transport | null;
@@ -78,6 +349,12 @@ interface ConnectionState {
78
349
  authRequired: boolean;
79
350
  provider: KvOAuthProvider | null;
80
351
  connectedGeneration: number | null;
352
+ /**
353
+ * One-way latch: set by closeScope and never cleared, so neither a late
354
+ * connect nor a `reset()` can cache a client into a scope that is already
355
+ * gone — that client would have no owner left to close it.
356
+ */
357
+ closed: boolean;
81
358
  }
82
359
 
83
360
  /**
@@ -126,6 +403,31 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
126
403
  { cause },
127
404
  );
128
405
 
406
+ const scopeEndedError = () =>
407
+ new Error(`Connector "${id}" scope ended during connection.`);
408
+
409
+ /**
410
+ * One `tools/list` request. The only thing wrapped here is the diagnosis of a
411
+ * `nextCursor` the MCP result schema refuses — overwhelmingly `null`, which
412
+ * plenty of servers use to mean "no more pages" but the spec spells as an
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.
416
+ */
417
+ const listPage = async (client: Client, cursor: string | undefined) => {
418
+ try {
419
+ return await client.listTools(
420
+ cursor === undefined ? undefined : { cursor },
421
+ );
422
+ } catch (err) {
423
+ if (!isCursorShapeError(err)) throw err;
424
+ throw new Error(
425
+ `Connector "${id}" returned a tools/list page whose nextCursor is neither a string nor absent (a null cursor is the usual culprit) — MCP ends pagination on an absent nextCursor, so this catalog cannot be walked.`,
426
+ { cause: err },
427
+ );
428
+ }
429
+ };
430
+
129
431
  const stateFor = (ctx: ConnectorContext): ConnectionState => {
130
432
  const scope = ctx.requestScope ?? ctx;
131
433
  let state = states.get(scope);
@@ -137,6 +439,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
137
439
  authRequired: false,
138
440
  provider: null,
139
441
  connectedGeneration: null,
442
+ closed: false,
140
443
  };
141
444
  states.set(scope, state);
142
445
  }
@@ -155,30 +458,27 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
155
458
  return state.provider;
156
459
  };
157
460
 
158
- // NOTE: StreamableHTTPClientTransport speaks over fetch, which transparently
159
- // follows 3xx redirects. A malicious or compromised downstream MCP could
160
- // redirect to an internal address (e.g. http://169.254.169.254/…) and fetch
161
- // would re-issue the request — potentially carrying static auth headers. The
162
- // scheme check above only guards the first hop; a fully robust guard (manual
163
- // redirect handling + per-hop re-validation + stripping auth headers cross-
164
- // origin) lives in the SDK transport and is deferred to a future non-patch
165
- // release rather than reimplemented here.
166
461
  const buildTransport = (
167
462
  ctx: ConnectorContext,
168
463
  state: ConnectionState,
169
464
  ): Transport => {
170
465
  if (opts._transportFactory) return opts._transportFactory(ctx);
171
466
  const url = new URL(opts.url);
467
+ const guardedFetch = redirectSafeFetch(id, opts.redirects);
172
468
  if (opts.auth?.type === "oauth") {
173
469
  return new StreamableHTTPClientTransport(url, {
174
470
  authProvider: getProvider(ctx, state),
471
+ fetch: guardedFetch,
175
472
  });
176
473
  }
177
474
  const headers =
178
475
  opts.auth?.type === "headers" ? opts.auth.headers : undefined;
179
476
  return new StreamableHTTPClientTransport(
180
477
  url,
181
- headers ? { requestInit: { headers } } : undefined,
478
+ {
479
+ ...(headers ? { requestInit: { headers } } : {}),
480
+ fetch: guardedFetch,
481
+ },
182
482
  );
183
483
  };
184
484
 
@@ -188,6 +488,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
188
488
  state.connecting = null;
189
489
  state.authRequired = false;
190
490
  state.connectedGeneration = null;
491
+ // `closed` is deliberately not cleared — see ConnectionState.
191
492
  };
192
493
 
193
494
  const ensureConnected = async (
@@ -198,17 +499,18 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
198
499
  // wiped credentials. This request's cached client still speaks the old
199
500
  // token — drop it so the next connect runs against current state.
200
501
  if (state.client && isOauth && state.connectedGeneration !== null) {
201
- if (
202
- (await getProvider(ctx, state).generation()) !==
203
- state.connectedGeneration
204
- ) {
502
+ const generation = await getProvider(ctx, state).generation();
503
+ if (state.closed) throw scopeEndedError();
504
+ if (generation !== state.connectedGeneration) {
205
505
  reset(state);
206
506
  }
207
507
  }
508
+ if (state.closed) throw scopeEndedError();
208
509
  if (state.client) return;
209
510
  state.connecting ??= (async () => {
210
511
  const provider = isOauth ? getProvider(ctx, state) : null;
211
512
  const genAtStart = provider ? await provider.generation() : 0;
513
+ if (state.closed) throw scopeEndedError();
212
514
  // Stamp the provider so any saveTokens/saveClientInformation the SDK fires
213
515
  // during this connect (code exchange, DCR) — or during a later refresh on
214
516
  // the resulting client — is dropped if a concurrent force bumps the
@@ -226,20 +528,46 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
226
528
  state.transport = t;
227
529
  try {
228
530
  await c.connect(t);
531
+ // A probe deadline can end its scope while connect is still in flight.
532
+ // The transport is closed immediately by closeScope; if connect wins
533
+ // that race anyway, close the resulting client rather than resurrecting
534
+ // a session in the detached state object.
535
+ if (state.closed) {
536
+ try {
537
+ await c.close();
538
+ } catch {
539
+ // The scope has already been discarded either way.
540
+ }
541
+ throw scopeEndedError();
542
+ }
229
543
  // A force re-auth that landed WHILE we were connecting wiped the creds
230
544
  // this client just bound to. Discard it rather than cache a connection
231
545
  // that resurrects the wiped-and-reauthorized connector from a stale
232
546
  // isolate. Surfaces as auth_required — the connector genuinely needs
233
547
  // re-consent now.
234
- if (provider && (await provider.generation()) !== genAtStart) {
235
- try {
236
- await c.close();
237
- } catch {
238
- // discarding either way
548
+ if (provider) {
549
+ const generation = await provider.generation();
550
+ // closeScope can land while the generation read is pending, after
551
+ // connect succeeded but before this client is cached. Discard the
552
+ // client on that side of the await too.
553
+ if (state.closed) {
554
+ try {
555
+ await c.close();
556
+ } catch {
557
+ // The scope has already been discarded either way.
558
+ }
559
+ throw scopeEndedError();
560
+ }
561
+ if (generation !== genAtStart) {
562
+ try {
563
+ await c.close();
564
+ } catch {
565
+ // discarding either way
566
+ }
567
+ throw new UnauthorizedError(
568
+ "Connector was re-authorized during connect; reconnect required.",
569
+ );
239
570
  }
240
- throw new UnauthorizedError(
241
- "Connector was re-authorized during connect; reconnect required.",
242
- );
243
571
  }
244
572
  state.client = c;
245
573
  state.connectedGeneration = genAtStart;
@@ -268,19 +596,113 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
268
596
  maxResultBytes: opts.maxResultBytes,
269
597
  usageGuide: opts.usageGuide,
270
598
 
599
+ // `tools/list` is cursor-paginated: the server chooses the page size and
600
+ // signals "there is more" with a `nextCursor`, which the SDK's
601
+ // Client.listTools() returns without following. Collect the whole chain
602
+ // here, because a half-collected catalog is indistinguishable from a small
603
+ // one — later-page tools would simply appear not to exist, unsearchable and
604
+ // unaddressable, with nothing anywhere saying why.
605
+ //
606
+ // All pages ride the one request-scoped client already connected above, and
607
+ // the accumulator is returned rather than stored: a cursor is opaque and
608
+ // session-bound, so nothing here may outlive this call.
271
609
  async listTools(ctx) {
272
610
  const state = stateFor(ctx);
273
611
  await ensureConnected(ctx, state);
274
- const res = await state.client!.listTools();
275
- return res.tools.map(
276
- (t): ToolDef => ({
277
- name: t.name,
278
- description: t.description,
279
- inputSchema: t.inputSchema as ToolDef["inputSchema"],
280
- outputSchema: t.outputSchema as ToolDef["outputSchema"],
281
- annotations: t.annotations as ToolDef["annotations"],
282
- }),
283
- );
612
+ // Bind the client once so the whole walk provably rides one session — a
613
+ // cursor is only meaningful to the connection that issued it, and a
614
+ // re-read could in principle pick up a different one. It is NOT guarding
615
+ // against closeScope nulling state.client mid-loop: closeScope sets
616
+ // `closed` and nulls `client` in one synchronous run, and the loop
617
+ // re-checks `closed` before every page, so the nulled client is
618
+ // unreachable from here.
619
+ const client = state.client!;
620
+ // Raw SDK tools, not ToolDefs: the metadata re-prime below needs fields
621
+ // (task support) that a ToolDef deliberately does not carry.
622
+ const listed: ListedTool[] = [];
623
+ const names = new Set<string>();
624
+ const spent = new Set<string>();
625
+ let cursor: string | undefined;
626
+ /** Consecutive pages that advertised a successor but added nothing. */
627
+ let barren = 0;
628
+ let complete = false;
629
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
630
+ // The scope can end between pages (probe timeout, teardown). Stop
631
+ // rather than keep paging into a transport that is being closed.
632
+ if (state.closed) throw scopeEndedError();
633
+ // Page one sends no params at all, so a non-paginated server sees
634
+ // exactly the request it saw before pagination existed.
635
+ const res = await listPage(client, cursor);
636
+ let added = 0;
637
+ for (const t of res.tools) {
638
+ // First page wins. An unstable cursor can serve the same tool on two
639
+ // pages — a failure mode that did not exist while only page one was
640
+ // read — and a duplicate would inflate `toolCount`, double the tool's
641
+ // `search_tools` row, and churn the registry's catalog-changed
642
+ // comparison into a persistence write on every refresh.
643
+ if (names.has(t.name)) continue;
644
+ names.add(t.name);
645
+ listed.push(t);
646
+ added++;
647
+ }
648
+ // Pagination ends when `nextCursor` is ABSENT — not when it is falsy.
649
+ // An empty string is a legal cursor and means "keep going"; `if
650
+ // (!next)` here would silently truncate that server's catalog.
651
+ const next = res.nextCursor;
652
+ if (typeof next !== "string") {
653
+ complete = true;
654
+ break;
655
+ }
656
+ // A page that adds nothing and still claims a successor made no
657
+ // progress. Allow exactly one: the widespread idiom is to advertise a
658
+ // cursor whenever a page came back full and then serve one empty page
659
+ // to terminate, and that server is conformant. Two in a row is a
660
+ // downstream going nowhere, and this kills a "fresh cursor forever, no
661
+ // tools" adversary in a couple of round trips instead of thousands.
662
+ if (added === 0 && ++barren > 1) {
663
+ throw new Error(
664
+ `Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`,
665
+ );
666
+ }
667
+ if (added > 0) barren = 0;
668
+ // A cursor handed back a second time is not a slow server, it is a
669
+ // loop. Fail now rather than walking it until a ceiling notices.
670
+ if (spent.has(next)) {
671
+ throw new Error(
672
+ `Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`,
673
+ );
674
+ }
675
+ // Checked here rather than on arrival: this bounds what a *walk* may
676
+ // accumulate, and a server that answers in one page was always free to
677
+ // send whatever it sends.
678
+ if (listed.length > MAX_TOOLS) {
679
+ throw new Error(
680
+ `Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_TOOLS}-tool ceiling one catalog refresh will collect.`,
681
+ );
682
+ }
683
+ // Opaque by contract: handed straight back, never parsed, rewritten,
684
+ // or persisted.
685
+ spent.add(next);
686
+ cursor = next;
687
+ }
688
+ // Fail the refresh outright. Returning what we have would publish a
689
+ // partial catalog that looks complete; throwing lets the registry keep
690
+ // serving the last complete one via its stale fallback.
691
+ if (!complete) {
692
+ throw new Error(
693
+ `Connector "${id}" kept advertising more tools/list pages after ${MAX_TOOL_PAGES} — refusing to page further.`,
694
+ );
695
+ }
696
+ // Repair what the per-page listTools calls left behind before any of
697
+ // these tools can be called. See primeToolMetadata.
698
+ primeToolMetadata(client, listed);
699
+ return listed.map((t) => ({
700
+ name: t.name,
701
+ description: t.description,
702
+ inputSchema: t.inputSchema as ToolDef["inputSchema"],
703
+ outputSchema: t.outputSchema as ToolDef["outputSchema"],
704
+ annotations: t.annotations as ToolDef["annotations"],
705
+ }));
284
706
  },
285
707
 
286
708
  async callTool(name, args, ctx) {
@@ -310,6 +732,38 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
310
732
  }
311
733
  },
312
734
 
735
+ async closeScope(ctx) {
736
+ const scope = ctx.requestScope ?? ctx;
737
+ const state = states.get(scope);
738
+ if (!state) return;
739
+
740
+ // Delete before awaiting: a duplicate teardown is a no-op, and no later
741
+ // lookup can reuse the state while its client is closing.
742
+ states.delete(scope);
743
+ state.closed = true;
744
+ const client = state.client;
745
+ const transport = state.transport;
746
+ state.client = null;
747
+ state.transport = null;
748
+ state.connecting = null;
749
+ state.authRequired = false;
750
+ state.connectedGeneration = null;
751
+
752
+ // Ask the downstream to drop its session first — closing only aborts our
753
+ // side, and the DELETE that frees the server's rides on the very
754
+ // AbortSignal the close is about to trip.
755
+ if (transport) await terminateSession(transport);
756
+
757
+ // Client.close() owns its connected transport. During an unfinished or
758
+ // failed connect there is no cached client yet, so close the transport
759
+ // directly to abort/release that half-open session.
760
+ if (client) {
761
+ await client.close();
762
+ } else {
763
+ await transport?.close();
764
+ }
765
+ },
766
+
313
767
  async status(ctx): Promise<ConnectorStatus> {
314
768
  const state = stateFor(ctx);
315
769
  try {