feat(core): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY in the Node transport (closes #1563)

Wrap the Node transport's fetch with an undici EnvHttpProxyAgent dispatcher
when a standard proxy env var is set, so the CLI and web backend can reach
remote MCP servers through corporate proxies with zero new flags. undici is
imported lazily (only when a proxy var is present) and added as a dependency
at the repo root and in the CLI client; a missing undici raises an actionable
error. Documents the behavior and dependency in the CLI README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
cliffhall 2026-07-01 18:25:16 -04:00
commit d5be4929e9

View file

@ -12,6 +12,68 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { createFetchTracker } from "../fetchTracking.js";
import type { Dispatcher } from "undici";
/** Node's `RequestInit` plus the undici-specific `dispatcher` option. */
type NodeRequestInit = RequestInit & { dispatcher?: Dispatcher };
/** 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;
}
/**
* Wraps a fetch function so outbound HTTP/HTTPS requests honor the standard
* `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` environment variables.
*
* Node's built-in `fetch` (the bundled undici) accepts a `dispatcher` option
* in `RequestInit`; undici's `EnvHttpProxyAgent` reads the proxy env vars and
* routes per-request, so a single dispatcher covers both schemes and respects
* `NO_PROXY`. The `undici` package is loaded lazily on first call so callers
* that never set a proxy env var (TUI, web backend) need no extra dependency.
* If the proxy is configured but `undici` is unavailable, the wrapper throws
* with an actionable message rather than silently ignoring the env var.
*/
export function withProxyDispatcher(baseFetch: typeof fetch): typeof fetch {
if (readProxyEnv() === undefined) return baseFetch;
let dispatcherPromise: Promise<Dispatcher> | undefined;
const getDispatcher = (): Promise<Dispatcher> => {
dispatcherPromise ??= import("undici").then(
({ EnvHttpProxyAgent }) => new EnvHttpProxyAgent(),
(cause: unknown) => {
throw new Error(
"HTTPS_PROXY / HTTP_PROXY is set but the `undici` package is not " +
"available. Install it (it ships with the CLI client) or unset the " +
"proxy env var.",
{ cause },
);
},
);
return dispatcherPromise;
};
const proxiedFetch: typeof fetch = async (input, init) => {
const dispatcher = await getDispatcher();
return baseFetch(input, { ...init, dispatcher } as NodeRequestInit);
};
return proxiedFetch;
}
/**
* Build the wire `headers` record from `settings.headers`, dropping rows with
@ -48,7 +110,7 @@ export function createTransportNode(
settings,
} = options;
const baseFetch = optionsFetchFn ?? globalThis.fetch;
const baseFetch = withProxyDispatcher(optionsFetchFn ?? globalThis.fetch);
if (serverType === "stdio") {
const stdioConfig = config as StdioServerConfig;