merge: v2/1563-node-proxy (#1590)
This commit is contained in:
commit
4e3cd591a4
10 changed files with 225 additions and 18 deletions
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
10
clients/cli/package-lock.json
generated
10
clients/cli/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
12
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue