diff --git a/clients/cli/README.md b/clients/cli/README.md index ce03c465..c1f81d73 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -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.7.5` — 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 ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | -| `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | -| `--client-secret ` | — | OAuth client secret; overrides `client.json`. | -| `--client-metadata-url ` | — | CIMD metadata URL; overrides `client.json`. | -| `--callback-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 ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | +| `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | +| `--client-secret ` | — | OAuth client secret; overrides `client.json`. | +| `--client-metadata-url ` | — | CIMD metadata URL; overrides `client.json`. | +| `--callback-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: diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 940cfb55..6dc3d8b4 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -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", diff --git a/clients/cli/package.json b/clients/cli/package.json index d76b3b45..5f185550 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -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" }, diff --git a/clients/web/src/test/integration/mcp/node/transport.test.ts b/clients/web/src/test/integration/mcp/node/transport.test.ts index f887149d..eb9872d4 100644 --- a/clients/web/src/test/integration/mcp/node/transport.test.ts +++ b/clients/web/src/test/integration/mcp/node/transport.test.ts @@ -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[0]; + init: RequestInit | undefined; + }> = []; + const base = vi.fn( + async (input: Parameters[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[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"); + }); + }); }); diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index 81ebe5dd..01ab036d 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -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 | undefined; + const getDispatcher = (): Promise => { + 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; diff --git a/package-lock.json b/package-lock.json index fc02e30d..a182673f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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", diff --git a/package.json b/package.json index f49ae384..28fe4151 100644 --- a/package.json +++ b/package.json @@ -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",