merge: v2/1563-node-proxy (#1590)

This commit is contained in:
cliffhall 2026-07-01 22:03:55 -04:00
commit 4e3cd591a4
10 changed files with 225 additions and 18 deletions

View file

@ -13,7 +13,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
node-version: '22.x'
cache: 'npm'
- name: Install dependencies (root + all clients)

View file

@ -80,6 +80,12 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --tr
When a server is loaded from a `--catalog`/`--config` file, its per-server settings (headers, connection/request timeouts, and OAuth) are applied to the connection — the same resolution the TUI uses. A `--header` flag overrides the file's headers for that run while leaving the file's timeouts and OAuth in place.
### 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`.
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.
## Options
### MCP server (which server to connect to)
@ -115,23 +121,23 @@ The CLI does **not** start a local callback server or retry connect on 401. If t
#### OAuth callback URL
| Surface | Default callback |
| ------- | ---------------- |
| **Web** | `http://localhost:6274/oauth/callback` |
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) |
| Surface | Default callback |
| ------- | ----------------------------------------------------------------------------------------- |
| **Web** | `http://localhost:6274/oauth/callback` |
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) |
| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) |
Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port.
#### Flags
| Option | Env | Description |
| ------ | --- | ----------- |
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |
| Option | Env | Description |
| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |
**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:

View file

@ -15,6 +15,7 @@
"atomically": "^2.1.1",
"commander": "^13.1.0",
"pino": "^9.14.0",
"undici": "^8.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.13"
},
@ -5378,6 +5379,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
"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,6 +38,7 @@
"atomically": "^2.1.1",
"commander": "^13.1.0",
"pino": "^9.14.0",
"undici": "^8.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.13"
},

View file

@ -71,3 +71,7 @@ export default defineConfig([
},
])
```
## 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.

View file

@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import { getServerType } from "@inspector/core/mcp/config.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
createTransportNode,
readProxyEnv,
withProxyDispatcher,
} from "@inspector/core/mcp/node/transport.js";
import type {
InspectorServerSettings,
MCPServerConfig,
@ -327,4 +331,111 @@ describe("Transport", () => {
expect(result.transport).toBeDefined();
});
});
describe("HTTPS_PROXY / HTTP_PROXY", () => {
const PROXY_VARS = [
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"NO_PROXY",
"no_proxy",
] as const;
function clearProxyEnv() {
for (const name of PROXY_VARS) delete process.env[name];
}
afterEach(() => {
clearProxyEnv();
vi.restoreAllMocks();
});
it("readProxyEnv returns undefined with no proxy vars and the first set value otherwise", () => {
clearProxyEnv();
expect(readProxyEnv()).toBeUndefined();
process.env.HTTP_PROXY = "http://proxy.example:3128";
expect(readProxyEnv()).toBe("http://proxy.example:3128");
process.env.HTTPS_PROXY = "http://secure-proxy.example:3128";
// HTTPS_PROXY is checked before HTTP_PROXY
expect(readProxyEnv()).toBe("http://secure-proxy.example:3128");
clearProxyEnv();
process.env.https_proxy = " ";
expect(readProxyEnv()).toBeUndefined();
});
it("withProxyDispatcher returns the original fetch unchanged when no proxy is configured", () => {
clearProxyEnv();
const base = vi.fn();
expect(withProxyDispatcher(base as unknown as typeof fetch)).toBe(base);
});
it("withProxyDispatcher injects an EnvHttpProxyAgent dispatcher into RequestInit", async () => {
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);
});
it("createTransportNode wraps the supplied fetch with the proxy dispatcher for streamable-http", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:3128";
let seenDispatcher: object | 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.
throw new Error("stop");
},
);
const result = createTransportNode(
{ type: "streamable-http", url: "https://example.com/mcp" },
{ fetchFn: fetchFn as unknown as typeof fetch },
);
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");
});
it("throws an actionable error when a proxy is configured but undici cannot be loaded", async () => {
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,
);
vi.doUnmock("undici");
});
});
});

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;
@ -78,6 +140,8 @@ export function createTransportNode(
const sseConfig = config as SseServerConfig;
const url = new URL(sseConfig.url);
// A caller-supplied eventSourceInit.fetch wins as-is (explicit fetch is not
// re-wrapped for proxying); the default path uses the proxy-aware baseFetch.
const sseFetch =
(sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch;
const trackedFetch = onFetchRequest

12
package-lock.json generated
View file

@ -26,6 +26,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"undici": "^8.5.0",
"vite": "^8.0.0",
"yaml": "^2.9.0",
"zod": "^4.3.6",
@ -35,7 +36,7 @@
"mcp-inspector": "clients/launcher/build/index.js"
},
"engines": {
"node": ">=22.7.5"
"node": ">=22.19.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@ -3012,6 +3013,15 @@
"node": ">= 0.6"
}
},
"node_modules/undici": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View file

@ -72,6 +72,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"undici": "^8.5.0",
"vite": "^8.0.0",
"yaml": "^2.9.0",
"zod": "^4.3.6",
@ -81,6 +82,6 @@
"ink-select-input": "^6.2.0"
},
"engines": {
"node": ">=22.7.5"
"node": ">=22.19.0"
}
}

View file

@ -65,7 +65,7 @@ Root scripts `inspector`, `web`, and `web:dev` are thin wrappers around the laun
**Production web caveat:** launcher `prebuild` chains `build:runner` for web, not a full `vite build`. Running `mcp-inspector --web` (without `--dev`) requires `clients/web/dist/` from a prior `cd clients/web && npm run build`. CI builds CLI, TUI, and launcher before the launcher smoke step but does not currently validate prod web startup end-to-end.
**Build targets:** client `tsup` bundles target `node22`, aligned with root `engines.node` (`>=22.7.5`).
**Build targets:** client `tsup` bundles target `node22`, aligned with root `engines.node` (`>=22.19.0`).
---