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:
parent
fd1fa06cf5
commit
d90a955164
5 changed files with 758 additions and 133 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
clients/cli/package-lock.json
generated
10
clients/cli/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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()`
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
Loading…
Reference in a new issue