@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -1
  3. package/dist/connectors/remote-mcp.d.ts +24 -1
  4. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  5. package/dist/connectors/remote-mcp.js +208 -88
  6. package/dist/connectors/remote-mcp.js.map +1 -1
  7. package/dist/credential-health.d.ts +8 -5
  8. package/dist/credential-health.d.ts.map +1 -1
  9. package/dist/credential-health.js +20 -13
  10. package/dist/credential-health.js.map +1 -1
  11. package/dist/execute.d.ts.map +1 -1
  12. package/dist/execute.js +10 -8
  13. package/dist/execute.js.map +1 -1
  14. package/dist/executors/quickjs.d.ts.map +1 -1
  15. package/dist/executors/quickjs.js +57 -5
  16. package/dist/executors/quickjs.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/meta-tools.d.ts +25 -0
  21. package/dist/meta-tools.d.ts.map +1 -1
  22. package/dist/meta-tools.js +162 -20
  23. package/dist/meta-tools.js.map +1 -1
  24. package/dist/registry.d.ts +18 -22
  25. package/dist/registry.d.ts.map +1 -1
  26. package/dist/registry.js +33 -21
  27. package/dist/registry.js.map +1 -1
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +18 -7
  30. package/dist/server.js.map +1 -1
  31. package/dist/timeout.d.ts +9 -4
  32. package/dist/timeout.d.ts.map +1 -1
  33. package/dist/timeout.js +34 -4
  34. package/dist/timeout.js.map +1 -1
  35. package/dist/toolkits.d.ts +8 -0
  36. package/dist/toolkits.d.ts.map +1 -1
  37. package/dist/toolkits.js +3 -0
  38. package/dist/toolkits.js.map +1 -1
  39. package/dist/types.d.ts +2 -2
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/ui.d.ts +12 -1
  42. package/dist/ui.d.ts.map +1 -1
  43. package/dist/ui.js +187 -6
  44. package/dist/ui.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +1 -1
  48. package/src/connectors/remote-mcp.ts +269 -93
  49. package/src/credential-health.ts +20 -18
  50. package/src/execute.ts +18 -7
  51. package/src/executors/quickjs.ts +65 -5
  52. package/src/index.ts +7 -2
  53. package/src/meta-tools.ts +226 -43
  54. package/src/registry.ts +48 -20
  55. package/src/server.ts +20 -9
  56. package/src/timeout.ts +41 -4
  57. package/src/toolkits.ts +11 -0
  58. package/src/types.ts +2 -2
  59. package/src/ui.ts +212 -11
  60. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,116 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.7.3 — 2026-07-27
6
+
7
+ 0.7.3 is the first published package after 0.7.1. It consolidates the
8
+ unpublished 0.7.2 candidate with the hardening and operator work that followed:
9
+ catalog deadlines and authorization recovery, session-safe operator pages,
10
+ terminal probe and QuickJS cleanup, decisive credential-shape drift, stronger
11
+ documentation checks, and a read-only toolkit map. There are no dependency,
12
+ configuration, storage-schema, or breaking TypeScript changes. The authenticated
13
+ `/ui/data` response gains one additive `toolkits` array; existing fields and
14
+ package entrypoints are unchanged.
15
+
16
+ ### Added
17
+
18
+ - **Connections shows the configured toolkit views** (PR #112). Unrestricted
19
+ operators can inspect connector membership, tool inclusions/exclusions,
20
+ currently loaded effective tool counts, and copyable scoped MCP URLs without
21
+ gaining any mutation or persistence path. Toolkit-restricted identities still
22
+ cannot enumerate deployment-wide data, the open HTML shell remains data-free,
23
+ and config-only toolkit descriptions never leave the server.
24
+
25
+ ### Changed
26
+
27
+ - **Discovery deadlines now govern the catalog work, not only the caller's
28
+ wait** (issue #98). `list_connectors`, `search_tools`, and `describe_tools`
29
+ pass `discovery.probeTimeoutMs` into a full downstream `tools/list` walk.
30
+ Expiry cancels the in-flight page where the transport supports it and prevents
31
+ another page from starting; existing tool/page bounds remain as backstops,
32
+ partial catalogs are never published, and the last complete stale catalog
33
+ remains eligible.
34
+ - **`nextCursor: null` is accepted as end-of-pagination** (issue #99). This
35
+ narrow compatibility concession treats the common JSON null spelling like an
36
+ absent cursor while preserving empty string as a real cursor and retaining
37
+ the SDK's validation for every tool and other result field.
38
+
39
+ ### Fixed
40
+
41
+ - **Authorization expiry is classified consistently across a catalog walk**
42
+ (issue #97). A downstream 401 on any `tools/list` page now reports
43
+ `auth_required` with the pending authorization URL, latches that verdict for
44
+ the request scope, and never publishes the prefix collected before failure.
45
+ Network, protocol, and malformed-page failures remain ordinary errors.
46
+ - **Operator pages clear stale identity data when Clerk changes in another tab
47
+ or outside the page** (issue #92). Connector inventory, masked credential
48
+ metadata and notices, activity rows, toolkit rows, and capability navigation
49
+ are discarded together, and the next navigation fetches under the new session
50
+ rather than repainting cached data.
51
+ - **The three operator routes handle HEAD and small navigation-state edges
52
+ correctly** (issue #93). `/`, `/credentials`, and `/activity` return their GET
53
+ headers with no HEAD body; Back/Forward focus stays visible while gated;
54
+ credential notices clear on page changes; and credential controls use the
55
+ same Clerk capability predicate as the mutation API.
56
+ - **Stored credential-shape drift is decisive on both status and calls** (issue
57
+ #90). The fast connector inventory reports the existing `auth_required`
58
+ replacement state instead of a stale `ok`, while `ctx.credential.get()` and
59
+ `getAll()` reject the obsolete shape before returning any value. Generic
60
+ failed health checks remain non-decisive, valid stored supersets remain
61
+ readable, and replacement/removal remains the recovery path.
62
+ - **Probe teardown cannot be skipped or resurrect a closed request scope**
63
+ (issue #91). Sibling work settles before shared teardown, cleanup no longer
64
+ short-circuits on the first rejection, and a closed scope remains terminal.
65
+ The formerly exported scope-borrowing option remains accepted as deprecated
66
+ and ignored so the fix does not create a TypeScript compatibility break.
67
+ - **QuickJS host-result cleanup is deterministic under timeout and load**
68
+ (issue #84). Settled bridge results are bounded, consumed exactly once, and
69
+ released when execution completes or its deadline wins, preventing late work
70
+ from retaining unobservable values.
71
+ - **The documentation guard catches the legacy syntax it was meant to prevent**
72
+ (issue #94). Bare `§N` citations now fail on live source, docs, examples, and
73
+ README surfaces—including extensionless files—while historical changelog
74
+ entries remain explicitly exempt. Negative fixtures cover every structural
75
+ invariant.
76
+
77
+ ## 0.7.1 — 2026-07-27
78
+
79
+ 0.7.1 is a security-and-bounds patch. It closes both redirect paths that could
80
+ let untrusted URL text choose an origin, and it puts hard request and response
81
+ ceilings around discovery. There are no dependency changes and no breaking
82
+ TypeScript changes. There is one intentional runtime hardening to notice:
83
+ `remoteMcp` now rejects downstream redirects by default. A deployment whose MCP
84
+ endpoint legitimately redirects within the same origin can opt into the new
85
+ `redirects: "same-origin"` policy; cross-origin redirects and HTTPS downgrades
86
+ remain impossible.
87
+
88
+ ### Added
89
+
90
+ - **`remoteMcp` has an explicit downstream redirect policy** (issue #81).
91
+ `redirects` defaults to `"none"`; `"same-origin"` follows at most five
92
+ manually validated hops with deliberate 301/302/303/307/308 method and body
93
+ semantics. Static headers and OAuth credentials never reach a cross-origin
94
+ target, and policy failures are typed, non-retryable, and sanitized.
95
+
96
+ ### Changed
97
+
98
+ - **Discovery requests and generated results are bounded** (issue #82).
99
+ `search_tools` accepts at most 100 results, `describe_tools` accepts at most
100
+ 100 raw addresses, and both reject generated results above 256,000 UTF-8
101
+ bytes. Compact, JSON-schema, value, and code-mode paths share the same policy,
102
+ so alternate entry points cannot bypass it.
103
+
104
+ ### Fixed
105
+
106
+ - **Inbound HTTP-to-HTTPS upgrades preserve the configured public origin**
107
+ (issue #89). Protocol-relative, backslash, and control-character path forms
108
+ can no longer turn the upgrade response into an open redirect; ordinary
109
+ operator and private API paths still retain their path and query.
110
+ - **Downstream MCP redirects are validated before every target request**
111
+ (issue #81). Redirect loops, excessive chains, scheme downgrades, origin
112
+ changes, and credential-bearing URL targets now fail closed in both Node and
113
+ Workers.
114
+
5
115
  ## 0.7.0 — 2026-07-27
6
116
 
7
117
  0.7.0 is the surface settlement: one release that finishes moving connecta's
package/README.md CHANGED
@@ -95,7 +95,8 @@ rather than a redaction pass: the event type has nowhere to put a payload, which
95
95
  is what keeps an operations log from becoming something worth stealing.
96
96
 
97
97
  **Operator pages that cannot administer the deployment.** Connections at `GET /`
98
- shows connector health, tool counts, and downstream authorization links;
98
+ shows connector health, tool counts, downstream authorization links, and a
99
+ read-only map of the toolkit views declared in deployment config;
99
100
  `/credentials` rotates stored secrets; and `/activity` shows the optional
100
101
  payload-free ledger. They share one data-free shell with no build step and use
101
102
  authenticated private APIs for deployment data. They cannot add a connector,
@@ -1,4 +1,5 @@
1
- import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
1
+ import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
2
+ import { ConnectorCallError } from "../errors.js";
2
3
  import type { Connector, ConnectorContext, Logger } from "../types.js";
3
4
  export type RemoteMcpAuth = {
4
5
  type: "headers";
@@ -6,6 +7,7 @@ export type RemoteMcpAuth = {
6
7
  } | {
7
8
  type: "oauth";
8
9
  };
10
+ export type RemoteMcpRedirectPolicy = "none" | "same-origin";
9
11
  export interface RemoteMcpOptions {
10
12
  url: string;
11
13
  /** Human-readable display name; the connector id remains the address prefix. */
@@ -25,6 +27,14 @@ export interface RemoteMcpOptions {
25
27
  */
26
28
  usageGuide?: string;
27
29
  auth?: RemoteMcpAuth;
30
+ /**
31
+ * Downstream HTTP redirect policy. Defaults to `"none"`: every redirect is
32
+ * rejected. `"same-origin"` follows at most five redirects while preserving
33
+ * standard 301/302/303/307/308 method semantics. Cross-origin redirects and
34
+ * HTTPS downgrades are always refused, so credentials never cross the
35
+ * configured request's origin.
36
+ */
37
+ redirects?: RemoteMcpRedirectPolicy;
28
38
  /**
29
39
  * Refuse to connect to a non-`https://` `url` at construction (default
30
40
  * false). Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always
@@ -45,6 +55,19 @@ export interface RemoteMcpOptions {
45
55
  */
46
56
  _transportFactory?: (ctx: ConnectorContext) => Transport;
47
57
  }
58
+ export declare const MAX_REMOTE_REDIRECT_HOPS = 5;
59
+ export declare class RemoteMcpRedirectError extends ConnectorCallError {
60
+ constructor(connectorId: string, reason: string);
61
+ }
62
+ /**
63
+ * Wrap fetch with explicit, bounded redirect handling.
64
+ *
65
+ * The starting URL of each fetch call is trusted by its caller (the configured
66
+ * MCP endpoint, or an OAuth URL discovered by the pinned SDK). Only Location
67
+ * values are policy-controlled here. No rejected target is ever fetched, so
68
+ * arbitrary static header names receive the same protection as Authorization.
69
+ */
70
+ export declare function redirectSafeFetch(connectorId: string, policy?: RemoteMcpRedirectPolicy, baseFetch?: FetchLike): FetchLike;
48
71
  /**
49
72
  * Proxy a downstream remote MCP server. SDK clients and transports are scoped
50
73
  * to one inbound request: reused by calls within a batch/execute_code run, but
@@ -1 +1 @@
1
- {"version":3,"file":"remote-mcp.d.ts","sourceRoot":"","sources":["../../src/connectors/remote-mcp.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAK/E,OAAO,KAAK,EACV,SAAS,EACT,gBAAgB,EAEhB,MAAM,EAEP,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtB,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,SAAS,CAAC;CAC1D;AAuKD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,SAAS,CA2fvE"}
1
+ {"version":3,"file":"remote-mcp.d.ts","sourceRoot":"","sources":["../../src/connectors/remote-mcp.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,SAAS,EACT,SAAS,EACV,MAAM,+CAA+C,CAAC;AAKvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EACV,SAAS,EACT,gBAAgB,EAEhB,MAAM,EAEP,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtB,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,aAAa,CAAC;AAE7D,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,uBAAuB,CAAC;IACpC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,SAAS,CAAC;CAC1D;AA8JD,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAW1C,qBAAa,sBAAuB,SAAQ,kBAAkB;gBAChD,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAOhD;AAaD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,MAAM,EACnB,MAAM,GAAE,uBAAgC,EACxC,SAAS,GAAE,SAAiB,GAC3B,SAAS,CA2EX;AAiBD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,SAAS,CA6hBvE"}
@@ -1,7 +1,9 @@
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 { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
4
5
  import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
6
+ import { z } from "zod";
5
7
  import { KvOAuthProvider } from "../auth/downstream-oauth.js";
6
8
  import { ConnectorCallError } from "../errors.js";
7
9
  import { CONNECTA_VERSION } from "../version.js";
@@ -47,23 +49,28 @@ const MAX_TOOLS = 100_000;
47
49
  * is a definite loop, two consecutive pages that add no new tools are a server
48
50
  * going nowhere, and MAX_TOOLS caps what any of it can accumulate. This exists
49
51
  * only so the loop is finite even if a downstream somehow satisfies all three
50
- * forever, because the caller's probe deadline abandons the *caller*, not the
51
- * loop. Set high enough that no honest server reaches it.
52
+ * forever on a path with no discovery deadline. Set high enough that no honest
53
+ * server reaches it.
52
54
  */
53
55
  const MAX_TOOL_PAGES = 10_000;
56
+ /**
57
+ * Compatibility concession for hand-rolled servers that serialize
58
+ * end-of-pagination as `null`. Only the cursor is widened; every tool and every
59
+ * other result field still passes through the SDK's pinned schema.
60
+ */
61
+ const CompatibleListToolsResultSchema = ListToolsResultSchema.extend({
62
+ nextCursor: z.string().nullable().optional(),
63
+ });
54
64
  /**
55
65
  * Re-prime an SDK client's tool-metadata cache from the *full* walked catalog.
56
66
  *
57
- * `Client.listTools()` ends by calling its private `cacheToolMetadata`, which
58
- * **clears** the output-schema validators and the task-support sets before
59
- * repopulating them from the page it just received. Call it once per page —
60
- * which walking the chain necessarily does and the request-scoped client is
61
- * left holding metadata for the *last* page alone. `callTool` then finds no
62
- * validator for every earlier-page tool and silently skips both the "declared
63
- * an outputSchema but returned no structuredContent" check and the
64
- * structured-content validation, and finds no task requirement so a
65
- * required-task tool is dispatched as a plain `tools/call`. Enforcement would
66
- * depend on which page a tool happened to land on, which is not enforcement.
67
+ * The SDK's `Client.listTools()` caches one page at a time and **clears** the
68
+ * output-schema validators and task-support sets before each replacement.
69
+ * This walk uses `Client.request()` so it can make the narrow null-cursor
70
+ * compatibility concession above, then primes the metadata exactly once from
71
+ * the complete chain. Otherwise `callTool` would find no validator or task
72
+ * requirement for earlier-page tools and enforcement would depend on where a
73
+ * tool happened to land, which is not enforcement.
67
74
  *
68
75
  * So hand the whole aggregated list back deliberately, once, at the end. The
69
76
  * SDK types the method `private`, hence the cast; the SDK version is pinned
@@ -78,9 +85,9 @@ function primeToolMetadata(client, tools) {
78
85
  prime.call(client, tools);
79
86
  }
80
87
  /**
81
- * True for a result-parse failure caused by the page's `nextCursor` itself
82
- * in practice `nextCursor: null`, a very common JSON idiom for "no more pages"
83
- * that the MCP schema does not accept (the chain ends on an *absent* cursor).
88
+ * True for a result-parse failure caused by the page's `nextCursor` itself.
89
+ * `null` is accepted deliberately; other non-string values remain a named
90
+ * downstream nonconformance instead of surfacing as a raw validation dump.
84
91
  * Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
85
92
  * zod instance, and cross-instance `instanceof` is a coin flip.
86
93
  */
@@ -136,6 +143,91 @@ function isLoopbackHost(hostname) {
136
143
  hostname === "[::1]" ||
137
144
  hostname === "::1");
138
145
  }
146
+ export const MAX_REMOTE_REDIRECT_HOPS = 5;
147
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
148
+ const BODY_HEADERS = [
149
+ "content-encoding",
150
+ "content-language",
151
+ "content-length",
152
+ "content-location",
153
+ "content-type",
154
+ "transfer-encoding",
155
+ ];
156
+ export class RemoteMcpRedirectError extends ConnectorCallError {
157
+ constructor(connectorId, reason) {
158
+ super("connector_call_failed", `Connector "${connectorId}" redirect policy rejected the downstream response: ${reason}.`);
159
+ this.name = "RemoteMcpRedirectError";
160
+ }
161
+ }
162
+ function redirectedInit(init, status) {
163
+ const method = (init.method ?? "GET").toUpperCase();
164
+ const becomesGet = (status === 303 && method !== "GET" && method !== "HEAD") ||
165
+ ((status === 301 || status === 302) && method === "POST");
166
+ if (!becomesGet)
167
+ return init;
168
+ const headers = new Headers(init.headers);
169
+ for (const name of BODY_HEADERS)
170
+ headers.delete(name);
171
+ return { ...init, method: "GET", body: undefined, headers };
172
+ }
173
+ /**
174
+ * Wrap fetch with explicit, bounded redirect handling.
175
+ *
176
+ * The starting URL of each fetch call is trusted by its caller (the configured
177
+ * MCP endpoint, or an OAuth URL discovered by the pinned SDK). Only Location
178
+ * values are policy-controlled here. No rejected target is ever fetched, so
179
+ * arbitrary static header names receive the same protection as Authorization.
180
+ */
181
+ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetch) {
182
+ return async (input, initialInit = {}) => {
183
+ let current = new URL(input);
184
+ let init = initialInit;
185
+ const seen = new Set([current.href]);
186
+ let hops = 0;
187
+ while (true) {
188
+ const response = await baseFetch(current, {
189
+ ...init,
190
+ redirect: "manual",
191
+ });
192
+ if (!REDIRECT_STATUSES.has(response.status))
193
+ return response;
194
+ const location = response.headers.get("location");
195
+ await response.body?.cancel().catch(() => { });
196
+ if (!location) {
197
+ throw new RemoteMcpRedirectError(connectorId, `HTTP ${response.status} carried no Location header`);
198
+ }
199
+ if (policy === "none") {
200
+ throw new RemoteMcpRedirectError(connectorId, `HTTP ${response.status} redirects are disabled`);
201
+ }
202
+ if (hops >= MAX_REMOTE_REDIRECT_HOPS) {
203
+ throw new RemoteMcpRedirectError(connectorId, `the redirect chain exceeded ${MAX_REMOTE_REDIRECT_HOPS} hops`);
204
+ }
205
+ let next;
206
+ try {
207
+ next = new URL(location, current);
208
+ }
209
+ catch {
210
+ throw new RemoteMcpRedirectError(connectorId, `HTTP ${response.status} carried an invalid Location header`);
211
+ }
212
+ if (current.protocol === "https:" && next.protocol !== "https:") {
213
+ throw new RemoteMcpRedirectError(connectorId, "an HTTPS-to-HTTP downgrade is not allowed");
214
+ }
215
+ if (next.origin !== current.origin) {
216
+ throw new RemoteMcpRedirectError(connectorId, "a cross-origin redirect is not allowed");
217
+ }
218
+ if (next.username || next.password) {
219
+ throw new RemoteMcpRedirectError(connectorId, "a redirect target containing URL credentials is not allowed");
220
+ }
221
+ if (seen.has(next.href)) {
222
+ throw new RemoteMcpRedirectError(connectorId, "the redirect chain loops");
223
+ }
224
+ seen.add(next.href);
225
+ hops++;
226
+ init = redirectedInit(init, response.status);
227
+ current = next;
228
+ }
229
+ };
230
+ }
139
231
  /**
140
232
  * Proxy a downstream remote MCP server. SDK clients and transports are scoped
141
233
  * to one inbound request: reused by calls within a batch/execute_code run, but
@@ -151,6 +243,9 @@ export function remoteMcp(id, opts) {
151
243
  // transport, response bodies, AbortSignals, or connection promise reachable
152
244
  // from the isolate singleton. Those are request-bound in Cloudflare Workers.
153
245
  const states = new WeakMap();
246
+ // Closing is terminal even after `states.delete`: a late or future lookup
247
+ // must not recreate an ownerless connection under the ended scope.
248
+ const closedScopes = new WeakSet();
154
249
  const isOauth = opts.auth?.type === "oauth";
155
250
  const logger = opts.logger ?? console;
156
251
  // Check the destination scheme once at construction: buildTransport (and the
@@ -171,25 +266,32 @@ export function remoteMcp(id, opts) {
171
266
  const authRequiredError = (cause) => new ConnectorCallError("auth_required", `Connector "${id}" requires authorization — call authorize_connector({ connector: "${id}" }) and open the returned URL.`, { cause });
172
267
  const scopeEndedError = () => new Error(`Connector "${id}" scope ended during connection.`);
173
268
  /**
174
- * One `tools/list` request. The only thing wrapped here is the diagnosis of a
175
- * `nextCursor` the MCP result schema refuses — overwhelmingly `null`, which
176
- * plenty of servers use to mean "no more pages" but the spec spells as an
177
- * *absent* cursor. The SDK surfaces that as a raw validation dump about a
178
- * field the operator never sees; say which server broke which rule instead.
179
- * Accepting `null` as end-of-chain outright is issue #99.
269
+ * One `tools/list` request. The SDK schema is retained wholesale except for
270
+ * accepting `null` as the common, unambiguous end-of-chain spelling. Other
271
+ * cursor shapes still get a useful connector-level diagnosis.
180
272
  */
181
- const listPage = async (client, cursor) => {
273
+ const listPage = async (client, cursor, ctx) => {
182
274
  try {
183
- return await client.listTools(cursor === undefined ? undefined : { cursor });
275
+ return await client.request({
276
+ method: "tools/list",
277
+ ...(cursor === undefined ? {} : { params: { cursor } }),
278
+ }, CompatibleListToolsResultSchema, ctx.timeoutMs || ctx.signal
279
+ ? {
280
+ ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
281
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
282
+ }
283
+ : undefined);
184
284
  }
185
285
  catch (err) {
186
286
  if (!isCursorShapeError(err))
187
287
  throw err;
188
- throw new Error(`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.`, { cause: err });
288
+ throw new Error(`Connector "${id}" returned a tools/list page whose nextCursor is neither a string, null, nor absent — this catalog cannot be walked.`, { cause: err });
189
289
  }
190
290
  };
191
291
  const stateFor = (ctx) => {
192
292
  const scope = ctx.requestScope ?? ctx;
293
+ if (closedScopes.has(scope))
294
+ throw scopeEndedError();
193
295
  let state = states.get(scope);
194
296
  if (!state) {
195
297
  state = {
@@ -209,25 +311,22 @@ export function remoteMcp(id, opts) {
209
311
  state.provider ??= new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`);
210
312
  return state.provider;
211
313
  };
212
- // NOTE: StreamableHTTPClientTransport speaks over fetch, which transparently
213
- // follows 3xx redirects. A malicious or compromised downstream MCP could
214
- // redirect to an internal address (e.g. http://169.254.169.254/…) and fetch
215
- // would re-issue the request — potentially carrying static auth headers. The
216
- // scheme check above only guards the first hop; a fully robust guard (manual
217
- // redirect handling + per-hop re-validation + stripping auth headers cross-
218
- // origin) lives in the SDK transport and is deferred to a future non-patch
219
- // release rather than reimplemented here.
220
314
  const buildTransport = (ctx, state) => {
221
315
  if (opts._transportFactory)
222
316
  return opts._transportFactory(ctx);
223
317
  const url = new URL(opts.url);
318
+ const guardedFetch = redirectSafeFetch(id, opts.redirects);
224
319
  if (opts.auth?.type === "oauth") {
225
320
  return new StreamableHTTPClientTransport(url, {
226
321
  authProvider: getProvider(ctx, state),
322
+ fetch: guardedFetch,
227
323
  });
228
324
  }
229
325
  const headers = opts.auth?.type === "headers" ? opts.auth.headers : undefined;
230
- return new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined);
326
+ return new StreamableHTTPClientTransport(url, {
327
+ ...(headers ? { requestInit: { headers } } : {}),
328
+ fetch: guardedFetch,
329
+ });
231
330
  };
232
331
  const reset = (state) => {
233
332
  state.client = null;
@@ -238,6 +337,12 @@ export function remoteMcp(id, opts) {
238
337
  // `closed` is deliberately not cleared — see ConnectionState.
239
338
  };
240
339
  const ensureConnected = async (ctx, state) => {
340
+ // A 401 after connect is a verdict for the whole request scope, not merely
341
+ // for the one call that observed it. Do not let the still-cached client make
342
+ // a later status or call in the same scope report healthy.
343
+ if (state.authRequired) {
344
+ throw authRequiredError(new UnauthorizedError("Downstream authorization is no longer valid."));
345
+ }
241
346
  // Cross-isolate force re-auth: another isolate bumped the KV generation and
242
347
  // wiped credentials. This request's cached client still speaks the old
243
348
  // token — drop it so the next connect runs against current state.
@@ -371,61 +476,74 @@ export function remoteMcp(id, opts) {
371
476
  /** Consecutive pages that advertised a successor but added nothing. */
372
477
  let barren = 0;
373
478
  let complete = false;
374
- for (let page = 0; page < MAX_TOOL_PAGES; page++) {
375
- // The scope can end between pages (probe timeout, teardown). Stop
376
- // rather than keep paging into a transport that is being closed.
377
- if (state.closed)
378
- throw scopeEndedError();
379
- // Page one sends no params at all, so a non-paginated server sees
380
- // exactly the request it saw before pagination existed.
381
- const res = await listPage(client, cursor);
382
- let added = 0;
383
- for (const t of res.tools) {
384
- // First page wins. An unstable cursor can serve the same tool on two
385
- // pages — a failure mode that did not exist while only page one was
386
- // read and a duplicate would inflate `toolCount`, double the tool's
387
- // `search_tools` row, and churn the registry's catalog-changed
388
- // comparison into a persistence write on every refresh.
389
- if (names.has(t.name))
390
- continue;
391
- names.add(t.name);
392
- listed.push(t);
393
- added++;
394
- }
395
- // Pagination ends when `nextCursor` is ABSENT not when it is falsy.
396
- // An empty string is a legal cursor and means "keep going"; `if
397
- // (!next)` here would silently truncate that server's catalog.
398
- const next = res.nextCursor;
399
- if (typeof next !== "string") {
400
- complete = true;
401
- break;
402
- }
403
- // A page that adds nothing and still claims a successor made no
404
- // progress. Allow exactly one: the widespread idiom is to advertise a
405
- // cursor whenever a page came back full and then serve one empty page
406
- // to terminate, and that server is conformant. Two in a row is a
407
- // downstream going nowhere, and this kills a "fresh cursor forever, no
408
- // tools" adversary in a couple of round trips instead of thousands.
409
- if (added === 0 && ++barren > 1) {
410
- throw new Error(`Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`);
411
- }
412
- if (added > 0)
413
- barren = 0;
414
- // A cursor handed back a second time is not a slow server, it is a
415
- // loop. Fail now rather than walking it until a ceiling notices.
416
- if (spent.has(next)) {
417
- throw new Error(`Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`);
479
+ try {
480
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
481
+ // The scope can end between pages (probe timeout, teardown). Stop
482
+ // rather than keep paging into a transport that is being closed.
483
+ if (state.closed)
484
+ throw scopeEndedError();
485
+ // A discovery deadline uses the same signal for the whole chain.
486
+ // Check it before issuing each page as well as passing it to the
487
+ // in-flight SDK request, so expiry never starts one more round trip.
488
+ if (ctx.signal?.aborted) {
489
+ throw ctx.signal.reason instanceof Error
490
+ ? ctx.signal.reason
491
+ : new Error(`Connector "${id}" catalog deadline expired.`);
492
+ }
493
+ // Page one sends no params at all, so a non-paginated server sees
494
+ // exactly the request it saw before pagination existed.
495
+ const res = await listPage(client, cursor, ctx);
496
+ let added = 0;
497
+ for (const t of res.tools) {
498
+ // First page wins. An unstable cursor can serve the same tool on
499
+ // two pages — a duplicate would inflate `toolCount`, double the
500
+ // `search_tools` row, and churn catalog persistence.
501
+ if (names.has(t.name))
502
+ continue;
503
+ names.add(t.name);
504
+ listed.push(t);
505
+ added++;
506
+ }
507
+ // Pagination ends when `nextCursor` is absent or null — never merely
508
+ // falsy. Empty string is present and means "keep going".
509
+ const next = res.nextCursor;
510
+ if (next === undefined || next === null) {
511
+ complete = true;
512
+ break;
513
+ }
514
+ // A page that adds nothing and still claims a successor made no
515
+ // progress. Allow exactly one: the widespread idiom is to advertise
516
+ // a cursor whenever a page came back full and then serve one empty
517
+ // page to terminate. Two in a row is a downstream going nowhere.
518
+ if (added === 0 && ++barren > 1) {
519
+ throw new Error(`Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`);
520
+ }
521
+ if (added > 0)
522
+ barren = 0;
523
+ // A cursor handed back a second time is a loop, not a slow server.
524
+ if (spent.has(next)) {
525
+ throw new Error(`Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`);
526
+ }
527
+ // Checked here rather than on arrival: this bounds what a *walk* may
528
+ // accumulate; a one-page server was always free to send its page.
529
+ if (listed.length > MAX_TOOLS) {
530
+ throw new Error(`Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_TOOLS}-tool ceiling one catalog refresh will collect.`);
531
+ }
532
+ // Opaque by contract: handed straight back, never parsed, rewritten,
533
+ // or persisted.
534
+ spent.add(next);
535
+ cursor = next;
418
536
  }
419
- // Checked here rather than on arrival: this bounds what a *walk* may
420
- // accumulate, and a server that answers in one page was always free to
421
- // send whatever it sends.
422
- if (listed.length > MAX_TOOLS) {
423
- throw new Error(`Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_TOOLS}-tool ceiling one catalog refresh will collect.`);
537
+ }
538
+ catch (err) {
539
+ // A grant can be revoked after connect and after any earlier page.
540
+ // Classify that exactly like connect-time and call-time authorization
541
+ // failures, and latch it for the rest of this request scope.
542
+ if (err instanceof UnauthorizedError) {
543
+ state.authRequired = true;
544
+ throw authRequiredError(err);
424
545
  }
425
- // Opaque by contract: handed straight back, never parsed, rewritten,
426
- // or persisted.
427
- spent.add(next);
428
- cursor = next;
546
+ throw err;
429
547
  }
430
548
  // Fail the refresh outright. Returning what we have would publish a
431
549
  // partial catalog that looks complete; throwing lets the registry keep
@@ -469,11 +587,13 @@ export function remoteMcp(id, opts) {
469
587
  },
470
588
  async closeScope(ctx) {
471
589
  const scope = ctx.requestScope ?? ctx;
590
+ // Tombstone before any lookup or await. This also makes close-before-use
591
+ // terminal rather than allowing the scope to spring into existence later.
592
+ closedScopes.add(scope);
472
593
  const state = states.get(scope);
473
594
  if (!state)
474
595
  return;
475
- // Delete before awaiting: a duplicate teardown is a no-op, and no later
476
- // lookup can reuse the state while its client is closing.
596
+ // Delete before awaiting: a duplicate teardown is a no-op.
477
597
  states.delete(scope);
478
598
  state.closed = true;
479
599
  const client = state.client;