Corporate-proxy users could not connect to any remote MCP server from v2. Two independent defects, both reported in #2067. 1. The web and TUI bundles inlined undici. It was declared in the root and `clients/cli` manifests but named in no client's tsup `external` list, and tsup auto-externalizes only what the *nearest* manifest declares — so esbuild inlined 1.05MB of it into `clients/{web,tui}/build/`. undici is CommonJS, so the emitted chunk's `require("assert")` hit esbuild's ESM `__require` shim and threw `Dynamic require of "assert" is not supported`. The rejected `import()` surfaced as "the `undici` package is not available", telling users to install a package that was already present — and could not have helped, because the specifier had been rewritten to a relative chunk at build time. 2. `withProxyDispatcher` injected a *userland* undici `EnvHttpProxyAgent` into Node's *built-in* fetch. That couples two different copies of undici at the dispatcher handler interface, which is not stable across majors: Node 22 embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8 agent handed a built-in undici 7 handler is rejected with `fetch failed: invalid onRequestStart method`, and the request never leaves the process. So fixing the bundling alone would only have converted the web/TUI failure into the CLI's. Both halves of the pair now come from userland undici — its `fetch` and its `EnvHttpProxyAgent` — which keeps the handler inside one copy and works unchanged from the Node 22.19 engine floor through Node 26. Deleting the wrapper in favor of Node's native `NODE_USE_ENV_PROXY` was not an option: measured, it is unsupported on 22.19 (the floor) and only lands mid-22.x. Because undici's `Response` is a different class from `globalThis.Response`, the proxied fetch re-wraps each response as a genuine global `Response`, streaming preserved. Without that, `res instanceof Response` is false for callers that test it — including the SDK's OAuth error formatter, which would degrade every message to `Raw body: [object Response]`. The proxy now sits at the bottom of the fetch stack (`environment.fetch` for the Node clients, and the `createTransportNode` default for the web backend, its one caller that supplies no `fetchFn`) rather than wrapping the transport's fetch. Proxying means *substituting* the fetch, not decorating one, so wrapping a supplied `fetchFn` would discard it; installing it underneath lets InspectorClient's own wrappers compose over it. A side effect worth having: OAuth discovery and token requests go through `environment.fetch` too, and were never proxied before. Guarding it: - `undici` is added to all three tsup `external` lists and removed from `clients/cli/package.json`, so it is root-declared only, per the placement rule in AGENTS.md. The two installs had already drifted (root 8.5.0 vs cli 8.9.0). - New `npm run verify:bundle-externals` fails if any package a client declares external was inlined into that client's bundle anyway. It reads the *built output*, not the config — those two disagreed for four releases — and derives what to check from each client's own `external` array, so a newly externalized package is covered without editing the guard. Verified against the real pre-fix artifact, which it flags. - The proxy tests now drive a real forwarding proxy rather than a mock. That matters: defect 2 was a runtime interface mismatch, which no mock can catch because no mock ever dispatches. AGENTS.md's must-not-bundle rule (added by #2082) is extended with why undici slipped past it, and notes the `node -e` trap — probing the bundled chunk with `node -e` falsely succeeds, because it exposes a global `require` that satisfies esbuild's guard. Closes #2067 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
192 lines
7.7 KiB
TypeScript
192 lines
7.7 KiB
TypeScript
import type {
|
|
Dispatcher,
|
|
RequestInit as UndiciRequestInit,
|
|
Response as UndiciResponse,
|
|
} from "undici";
|
|
|
|
/**
|
|
* The undici and DOM declarations of `RequestInit` / `BodyInit` describe the
|
|
* same wire contract; they differ only in *which realm's* `ReadableStream`,
|
|
* `Blob`, and `Headers` types they name, and TypeScript cannot relate two `lib`
|
|
* sets' structurally-identical stream types (their `pipeThrough` signatures
|
|
* differ in variance alone). At runtime there is one implementation — undici
|
|
* consumes exactly the values Node's own `fetch` does — so these two casts
|
|
* bridge a declaration gap rather than a real type difference, and they are kept
|
|
* to this one module boundary instead of leaking into callers.
|
|
*/
|
|
function asUndiciInit(init: RequestInit | undefined): UndiciRequestInit {
|
|
return init as UndiciRequestInit;
|
|
}
|
|
|
|
/** See {@link asUndiciInit} — same declaration gap, opposite direction. */
|
|
function asBodyInit(body: UndiciResponse["body"]): BodyInit | null {
|
|
return body as BodyInit | null;
|
|
}
|
|
|
|
/** Standard proxy env vars, in the precedence undici's EnvHttpProxyAgent uses. */
|
|
const PROXY_ENV_VARS = [
|
|
"HTTPS_PROXY",
|
|
"https_proxy",
|
|
"HTTP_PROXY",
|
|
"http_proxy",
|
|
] as const;
|
|
|
|
/**
|
|
* First proxy URL found in the environment, or `undefined` if none is set.
|
|
* Exported for tests and so callers can decide whether proxying is in effect.
|
|
*/
|
|
export function readProxyEnv(): string | undefined {
|
|
for (const name of PROXY_ENV_VARS) {
|
|
const value = process.env[name];
|
|
if (value && value.trim() !== "") return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Statuses for which the `Response` constructor rejects a non-null body.
|
|
*
|
|
* undici already returns a null body for these, so the guard is belt-and-braces
|
|
* — but a `Response` constructor throwing inside the adapter would surface as an
|
|
* opaque failure of the request itself, which is a bad trade for one `Set`.
|
|
*/
|
|
const NULL_BODY_STATUSES: ReadonlySet<number> = new Set([
|
|
101, 103, 204, 205, 304,
|
|
]);
|
|
|
|
/**
|
|
* Re-wraps an undici `Response` as a *global* one, preserving streaming.
|
|
*
|
|
* This is not ceremony. Userland undici's `Response` is a different class from
|
|
* `globalThis.Response`, so `res instanceof Response` is `false` for it — and the
|
|
* SDK branches on exactly that: `parseErrorResponse` (in
|
|
* `@modelcontextprotocol/client`) reads `input instanceof Response ? await
|
|
* input.text() : input`, so an undici response falls into the string branch and
|
|
* every OAuth error degrades to `Raw body: [object Response]`. Handing back a
|
|
* genuine global `Response` keeps every consumer on the class it tests for.
|
|
*
|
|
* The body passes through as a `ReadableStream` rather than being buffered, so
|
|
* SSE responses (the streamable-HTTP transport's whole event channel) still
|
|
* stream. `url` and `redirected` are not carried over — the `Response`
|
|
* constructor cannot set them — which is safe here only because nothing in this
|
|
* repo or the SDK reads either.
|
|
*/
|
|
function toGlobalResponse(res: UndiciResponse): Response {
|
|
const headers = new Headers();
|
|
for (const [key, value] of res.headers) headers.append(key, value);
|
|
return new Response(
|
|
NULL_BODY_STATUSES.has(res.status) ? null : asBodyInit(res.body),
|
|
{
|
|
status: res.status,
|
|
statusText: res.statusText,
|
|
headers,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Loads userland undici and binds its `fetch` to an `EnvHttpProxyAgent`.
|
|
*
|
|
* Both halves of that pair must come from the *same* undici. Injecting a
|
|
* userland dispatcher into Node's built-in `fetch` — what this module used to do
|
|
* — couples the two undici copies at the dispatcher handler interface, which is
|
|
* not stable across majors: Node 22 ships undici 6, Node 24 ships 7, Node 26
|
|
* ships 8, so a userland undici 8 agent handed a built-in undici 7 handler is
|
|
* rejected with `invalid onRequestStart method` and the request never leaves the
|
|
* process (#2067). Using undici's own `fetch` keeps the handler on both sides of
|
|
* the call inside one copy, which is why it works unchanged from the Node 22.19
|
|
* engine floor through Node 26.
|
|
*/
|
|
/**
|
|
* Process-wide, so every client shares one `EnvHttpProxyAgent` and therefore one
|
|
* connection pool. The memo is deliberately not per-`createProxyFetch()` call:
|
|
* the TUI builds an environment per configured server, and a pool each would
|
|
* multiply sockets against the proxy for no benefit.
|
|
*/
|
|
let loadedProxiedFetch: Promise<typeof fetch> | undefined;
|
|
|
|
async function loadProxiedFetch(): Promise<typeof fetch> {
|
|
let undici: typeof import("undici");
|
|
try {
|
|
undici = await import("undici");
|
|
} catch (cause) {
|
|
throw new Error(
|
|
"HTTPS_PROXY / HTTP_PROXY is set but the `undici` package could not be " +
|
|
"loaded, so requests cannot be routed through the proxy. This is a " +
|
|
"packaging fault rather than something to fix by installing undici; " +
|
|
"please report it, or unset the proxy env var to fall back to a direct " +
|
|
"connection.",
|
|
{ cause },
|
|
);
|
|
}
|
|
|
|
const dispatcher: Dispatcher = new undici.EnvHttpProxyAgent();
|
|
return async (input, init) => {
|
|
const [target, requestInit] = toUndiciRequest(input, init);
|
|
return toGlobalResponse(
|
|
await undici.fetch(target, { ...asUndiciInit(requestInit), dispatcher }),
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalizes fetch's `input`/`init` pair into something undici's `fetch` accepts.
|
|
*
|
|
* undici builds its own `Request` from `input`, and its constructor cannot
|
|
* consume a *global* `Request` — it would stringify it to `"[object Request]"`
|
|
* and request that as a URL. Every call site in this repo and in the SDK passes
|
|
* a string or `URL` today, so the unpacking branch is defensive; without it a
|
|
* future caller passing a `Request` would fail in a way that looks like a
|
|
* network error rather than a type mismatch.
|
|
*/
|
|
function toUndiciRequest(
|
|
input: RequestInfo | URL,
|
|
init: RequestInit | undefined,
|
|
): [string, RequestInit] {
|
|
if (input instanceof Request) {
|
|
return [
|
|
input.url,
|
|
{
|
|
method: input.method,
|
|
headers: input.headers,
|
|
// A stream body needs `duplex: "half"`; the field is undici's, not in
|
|
// the DOM `RequestInit`, so it rides along as an extra property.
|
|
...(input.body ? { body: input.body, duplex: "half" } : {}),
|
|
signal: input.signal,
|
|
...init,
|
|
},
|
|
];
|
|
}
|
|
return [input instanceof URL ? input.href : input, init ?? {}];
|
|
}
|
|
|
|
/**
|
|
* A fetch that routes through `HTTPS_PROXY` / `HTTP_PROXY`, honoring `NO_PROXY`,
|
|
* or `undefined` when no proxy env var is set.
|
|
*
|
|
* Returning `undefined` rather than an identity wrapper is what keeps non-proxy
|
|
* users paying nothing: callers assign the result straight into an optional
|
|
* `fetch` slot, so undici is never imported and the built-in `fetch` is used as
|
|
* before.
|
|
*
|
|
* The env is read once, when this is called; undici's `EnvHttpProxyAgent` then
|
|
* owns per-request `NO_PROXY` matching. The agent itself is created lazily on
|
|
* the first request so that merely constructing a client costs nothing.
|
|
*
|
|
* This belongs at the *bottom* of a client's fetch stack — the Node clients'
|
|
* `environment.fetch` — because proxying now means substituting the fetch rather
|
|
* than decorating one. Wrapping it around a supplied `fetchFn` (as the old
|
|
* `withProxyDispatcher` did inside `createTransportNode`) would discard that
|
|
* function's behavior; installing it underneath instead lets `InspectorClient`'s
|
|
* own wrappers — request tracking, OAuth endpoint overrides — compose over it.
|
|
* A side benefit: OAuth discovery and token requests go through
|
|
* `environment.fetch` too, and were never proxied before.
|
|
*/
|
|
export function createProxyFetch(): typeof fetch | undefined {
|
|
if (readProxyEnv() === undefined) return undefined;
|
|
|
|
return async (input, init) => {
|
|
loadedProxiedFetch ??= loadProxiedFetch();
|
|
return (await loadedProxiedFetch)(input, init);
|
|
};
|
|
}
|