fix(proxy): route through undici's own fetch and keep undici external

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>
This commit is contained in:
cliffhall 2026-08-23 21:03:31 -04:00
commit d90a955164
11 changed files with 758 additions and 133 deletions

View file

@ -88,9 +88,15 @@ The file is the only durable way to give a run its roots: there is no roots flag
### HTTP proxy support
Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to the Node transport shared by the CLI and the web backend — no inspector-specific flag is needed. When a proxy variable is set, outbound requests are routed through undici's `EnvHttpProxyAgent`.
Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to every Node client — CLI, TUI, and the web backend — with no inspector-specific flag. It also covers **OAuth discovery and token requests**, which run through the same fetch.
Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (`^8.5.0`, which requires Node `>= 22.19.0` — the inspector's supported floor). It is imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost.
Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (declared in the **root** manifest only; `^8.x` requires Node `>= 22.19.0`, the Inspector's supported floor). It is imported lazily on the first request and only when a proxy variable is set, so runs without a proxy configured pay no cost.
**Both halves of the pair come from userland undici — its `fetch` _and_ its `EnvHttpProxyAgent` ([#2067](https://github.com/modelcontextprotocol/inspector/issues/2067)).** The obvious-looking alternative is to hand the agent to Node's built-in `fetch` as a `dispatcher`, which is what v2.0.0–2.3.0 did. That couples two _different copies_ of undici at the dispatcher handler interface, and that interface 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 outright — `fetch failed: invalid onRequestStart method` — and the request never leaves the process. Keeping both sides inside one copy is what makes proxying work unchanged from the Node 22.19 floor through Node 26. (Node's own `NODE_USE_ENV_PROXY` is not a substitute: it is unsupported at our 22.19 engine floor.)
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 MCP SDK, whose OAuth error formatter would degrade every message to `Raw body: [object Response]`.
`undici` is declared **only** in the root `package.json`, and every client's tsup config lists it as `external`. Both halves matter: tsup auto-externalizes what the _nearest_ manifest declares, so without the explicit entry the web and TUI bundles inlined it — and a CommonJS package inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` the first time it is used. `npm run verify:bundle-externals` is the durable guard.
## Options

View file

@ -14,7 +14,6 @@
"commander": "^13.1.0",
"open": "^10.2.0",
"pino": "^9.14.0",
"undici": "^8.9.0",
"zod": "^4.4.3"
},
"bin": {
@ -4440,15 +4439,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",

View file

@ -38,7 +38,6 @@
"commander": "^13.1.0",
"open": "^10.2.0",
"pino": "^9.14.0",
"undici": "^8.9.0",
"zod": "^4.4.3"
},
"devDependencies": {

View file

@ -17,6 +17,7 @@ import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { cleanRoots } from "@inspector/core/mcp/serverList.js";
import {
createProxyFetch,
createTransportNode,
loadServerEntries,
selectServerEntry,
@ -122,6 +123,11 @@ async function callMethod(
const environment: InspectorClientEnvironment = {
transport: createTransportNode,
// Proxy support sits at the bottom of the fetch stack so InspectorClient's
// wrappers compose over it — and so OAuth discovery/token requests, which
// also run through `environment.fetch`, are proxied too (#2067). Undefined
// when no proxy env var is set, which leaves the built-in fetch in place.
fetch: createProxyFetch(),
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
// Disarmed until the CLI-owned interactive OAuth flow runs — SDK `auth()`

View file

@ -20,6 +20,14 @@ export default defineConfig({
// Bundle core source; leave npm deps external.
noExternal: [/^@inspector\/core/],
external: [
// `undici` MUST stay external. It is CommonJS, so inlining it rewrites
// `import("undici")` to a relative chunk whose `require("assert")` hits
// esbuild's ESM `__require` shim and throws "Dynamic require of \"assert\"
// is not supported" — and because the specifier was rewritten at build time,
// no user-side install can ever satisfy it (#2067). It is declared in the
// ROOT manifest only, so tsup cannot infer this from a nearest-manifest
// lookup; the entry has to be explicit.
"undici",
"@napi-rs/keyring",
// Root-declared (see the repo's dependency-placement rule) and CJS, which
// is the combination that bites: tsup externalizes what the *client's*

View file

@ -232,6 +232,10 @@ vi.mock("@inspector/core/mcp/state/index.js", () => ({
}));
vi.mock("@inspector/core/mcp/node/index.js", () => ({
createTransportNode: vi.fn(),
// Returns undefined for "no proxy configured", which is what the real one
// does with no proxy env var set — so `environment.fetch` stays unset and the
// client falls back to the built-in fetch, exactly as before #2067.
createProxyFetch: vi.fn(() => undefined),
}));
vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
useInspectorClient: h.useInspectorClient,

View file

@ -31,7 +31,10 @@ import {
FetchRequestLogState,
StderrLogState,
} from "@inspector/core/mcp/state/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
import {
createProxyFetch,
createTransportNode,
} from "@inspector/core/mcp/node/index.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
@ -290,6 +293,10 @@ function App({
const environment: InspectorClientEnvironment = {
transport: createTransportNode,
logger: getTuiLogger(),
// Bottom of the fetch stack, so InspectorClient's wrappers compose
// over it and OAuth requests are proxied too (#2067). Undefined when
// no proxy env var is set.
fetch: createProxyFetch(),
};
const defaultMetadata = savedSettings?.metadata
? Object.fromEntries(

View file

@ -99,6 +99,14 @@ export default defineConfig({
// `__tests__/tsupConfig.test.ts` guards this list.
noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"],
external: [
// `undici` MUST stay external. It is CommonJS, so inlining it rewrites
// `import("undici")` to a relative chunk whose `require("assert")` hits
// esbuild's ESM `__require` shim and throws "Dynamic require of \"assert\"
// is not supported" — and because the specifier was rewritten at build time,
// no user-side install can ever satisfy it (#2067). It is declared in the
// ROOT manifest only, so tsup cannot infer this from a nearest-manifest
// lookup; the entry has to be explicit.
"undici",
// `react` is deliberately external — the single instance every inlined
// package above resolves to, from this build directory.
"react",

View file

@ -193,4 +193,6 @@ In every case, exposing the Inspector beyond loopback also means anyone who can
## HTTP proxy support
The web backend connects to remote MCP servers through the shared Node transport (`core/mcp/node/transport.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing is powered by [`undici`](https://www.npmjs.com/package/undici)'s `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail.
The web backend connects to remote MCP servers through the shared Node fetch (`core/mcp/node/proxyFetch.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing uses [`undici`](https://www.npmjs.com/package/undici)'s own `fetch` bound to its `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost.
The proxy sits at the **bottom** of the fetch stack rather than wrapping the transport's fetch, and both undici halves must come from the same copy — see [HTTP proxy support](../cli/README.md#http-proxy-support) in the CLI README for why ([#2067](https://github.com/modelcontextprotocol/inspector/issues/2067)).

View file

@ -1,10 +1,18 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { getServerType } from "@inspector/core/mcp/config.js";
import {
createTransportNode,
afterAll,
afterEach,
beforeAll,
describe,
it,
expect,
vi,
} from "vitest";
import { getServerType } from "@inspector/core/mcp/config.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
createProxyFetch,
readProxyEnv,
withProxyDispatcher,
} from "@inspector/core/mcp/node/transport.js";
} from "@inspector/core/mcp/node/proxyFetch.js";
import type {
InspectorServerSettings,
MCPServerConfig,
@ -16,6 +24,8 @@ import {
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
import { Client } from "@modelcontextprotocol/client";
import { createServer, request, type Server } from "node:http";
import type { AddressInfo } from "node:net";
describe("Transport", () => {
describe("getServerType", () => {
@ -396,50 +406,27 @@ describe("Transport", () => {
expect(readProxyEnv()).toBeUndefined();
});
it("withProxyDispatcher returns the original fetch unchanged when no proxy is configured", () => {
it("createProxyFetch returns undefined when no proxy is configured", () => {
clearProxyEnv();
const base = vi.fn();
expect(withProxyDispatcher(base as unknown as typeof fetch)).toBe(base);
expect(createProxyFetch()).toBeUndefined();
});
it("withProxyDispatcher injects an EnvHttpProxyAgent dispatcher into RequestInit", async () => {
it("createProxyFetch returns a fetch when a proxy is configured", () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:3128";
const calls: Array<{
input: Parameters<typeof fetch>[0];
init: RequestInit | undefined;
}> = [];
const base = vi.fn(
async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
calls.push({ input, init });
return new Response("ok");
},
);
const proxied = withProxyDispatcher(base as unknown as typeof fetch);
expect(proxied).not.toBe(base);
await proxied("https://example.com/mcp", { method: "POST" });
expect(calls).toHaveLength(1);
expect(calls[0].input).toBe("https://example.com/mcp");
expect(calls[0].init?.method).toBe("POST");
const dispatcher = (calls[0].init as { dispatcher?: object }).dispatcher;
expect(dispatcher).toBeDefined();
expect(dispatcher?.constructor.name).toBe("EnvHttpProxyAgent");
// Second call reuses the same dispatcher (lazy singleton).
await proxied("https://example.com/mcp");
const dispatcher2 = (calls[1].init as { dispatcher?: object }).dispatcher;
expect(dispatcher2).toBe(dispatcher);
expect(typeof createProxyFetch()).toBe("function");
});
it("createTransportNode wraps the supplied fetch with the proxy dispatcher for streamable-http", async () => {
it("createTransportNode leaves a supplied fetchFn untouched under a proxy", async () => {
// The proxy now lives at the *bottom* of the stack (environment.fetch), so
// a caller-supplied fetch must be used verbatim — the old behavior wrapped
// it and injected a `dispatcher`, which is what #2067 removed.
clearProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:3128";
let seenDispatcher: object | undefined;
let seenInit: RequestInit | undefined;
const fetchFn = vi.fn(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
seenDispatcher = (init as { dispatcher?: object } | undefined)
?.dispatcher;
// Reject after capturing so connect() fails fast without hitting a
// real proxy.
seenInit = init;
throw new Error("stop");
},
);
@ -450,24 +437,173 @@ describe("Transport", () => {
const client = new Client({ name: "t", version: "1" });
await expect(client.connect(result.transport)).rejects.toThrow();
expect(fetchFn).toHaveBeenCalled();
expect(seenDispatcher?.constructor.name).toBe("EnvHttpProxyAgent");
expect(
(seenInit as { dispatcher?: unknown } | undefined)?.dispatcher,
).toBeUndefined();
});
it("throws an actionable error when a proxy is configured but undici cannot be loaded", async () => {
describe("through a real proxy server", () => {
// A forwarding proxy, so these exercise the actual undici code path rather
// than a mock. That matters: #2067's second bug was a *runtime* interface
// mismatch between userland undici's dispatcher and Node's built-in fetch
// ("invalid onRequestStart method"), which every mock-based test missed
// because no mock ever dispatches. Only a request that really leaves the
// process can catch that class.
let proxy: Server;
let proxyUrl: string;
let origin: Server;
let originUrl: string;
const proxied: string[] = [];
const seenRequests: Array<{ method: string; probe: string }> = [];
beforeAll(async () => {
origin = createServer((req, res) => {
seenRequests.push({
method: req.method ?? "",
probe: String(req.headers["x-probe"] ?? ""),
});
if (req.url === "/no-content") {
res.writeHead(204);
res.end();
return;
}
res.writeHead(200, { "content-type": "text/plain", "x-origin": "1" });
res.end("hello from origin");
});
await new Promise<void>((r) => origin.listen(0, "127.0.0.1", r));
originUrl = `http://127.0.0.1:${(origin.address() as AddressInfo).port}`;
proxy = createServer((req, res) => {
// Absolute-form request URI is what makes this a proxy request.
proxied.push(req.url ?? "");
const target = new URL(req.url ?? "");
const upstream = request(
{
host: target.hostname,
port: target.port,
path: target.pathname + target.search,
method: req.method,
headers: req.headers,
},
(up) => {
res.writeHead(up.statusCode ?? 502, up.headers);
up.pipe(res);
},
);
upstream.on("error", () => {
res.writeHead(502);
res.end();
});
req.pipe(upstream);
});
await new Promise<void>((r) => proxy.listen(0, "127.0.0.1", r));
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((r) => proxy.close(() => r()));
await new Promise<void>((r) => origin.close(() => r()));
});
it("routes requests through the proxy and returns a global Response", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
expect(proxyFetch).toBeDefined();
const before = proxied.length;
const res = await proxyFetch!(`${originUrl}/hello`);
// The request really went through the proxy...
expect(proxied.length).toBe(before + 1);
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/hello`);
// ...and came back as a *global* Response, not undici's own class. The
// SDK branches on `input instanceof Response` when formatting OAuth
// errors, so a foreign Response silently degrades those messages.
expect(res).toBeInstanceOf(Response);
expect(res.headers).toBeInstanceOf(Headers);
expect(res.status).toBe(200);
expect(res.headers.get("x-origin")).toBe("1");
expect(await res.text()).toBe("hello from origin");
});
it("honors NO_PROXY", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
process.env.NO_PROXY = "127.0.0.1";
const proxyFetch = createProxyFetch();
const before = proxied.length;
const res = await proxyFetch!(`${originUrl}/direct`);
expect(res.status).toBe(200);
// Went straight to the origin — the proxy never saw it.
expect(proxied.length).toBe(before);
});
it("preserves a null-body status", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(`${originUrl}/no-content`);
expect(res.status).toBe(204);
expect(res.body).toBeNull();
});
it("accepts a URL instance and forwards the init", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(new URL(`${originUrl}/from-url`), {
method: "POST",
headers: { "x-probe": "yes" },
});
expect(res.status).toBe(200);
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/from-url`);
expect(seenRequests[seenRequests.length - 1]).toMatchObject({
method: "POST",
probe: "yes",
});
});
it("accepts a Request as input", async () => {
clearProxyEnv();
process.env.HTTP_PROXY = proxyUrl;
const proxyFetch = createProxyFetch();
const res = await proxyFetch!(
new Request(`${originUrl}/from-request`, { method: "GET" }),
);
expect(res.status).toBe(200);
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/from-request`);
});
});
it("reports a packaging fault when undici cannot be loaded", async () => {
// The message deliberately does NOT tell the user to install undici. It
// is a root dependency and always present; the only way this branch is
// reached in a published build is that the bundler mangled the specifier
// — which is exactly what #2067 was, and the old copy sent every affected
// user chasing an install that could not have helped.
clearProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:3128";
vi.doMock("undici", () => {
throw new Error("Cannot find module 'undici'");
});
// Re-import after the mock so the dynamic import("undici") inside
// withProxyDispatcher is intercepted.
const { withProxyDispatcher: wrap } =
await import("@inspector/core/mcp/node/transport.js");
const proxied = wrap(globalThis.fetch);
await expect(proxied("https://example.com")).rejects.toThrow(
/HTTPS_PROXY.*undici.*not.*available/i,
// Re-import after the mock so the dynamic import() inside the module is
// intercepted — and so the module-level agent memo starts out empty.
vi.resetModules();
const { createProxyFetch: create } =
await import("@inspector/core/mcp/node/proxyFetch.js");
const proxied = create();
await expect(proxied!("https://example.com")).rejects.toThrow(
/undici.*could not be loaded/i,
);
vi.doUnmock("undici");
vi.resetModules();
});
});
});

View file

@ -17,6 +17,14 @@ export default defineConfig({
platform: "node",
noExternal: [/^@inspector\/core/],
external: [
// `undici` MUST stay external. It is CommonJS, so inlining it rewrites
// `import("undici")` to a relative chunk whose `require("assert")` hits
// esbuild's ESM `__require` shim and throws "Dynamic require of \"assert\"
// is not supported" — and because the specifier was rewritten at build time,
// no user-side install can ever satisfy it (#2067). It is declared in the
// ROOT manifest only, so tsup cannot infer this from a nearest-manifest
// lookup; the entry has to be explicit.
"undici",
"commander",
"open",
"pino",