@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
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,
|
|
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,
|
|
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
|
|
51
|
-
*
|
|
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()`
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
|
175
|
-
* `
|
|
176
|
-
*
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
barren
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
|
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;
|