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
1 changed files with 758 additions and 133 deletions
|
|
@ -1,10 +1,18 @@
|
|||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||
import {
|
||||
createTransportNode,
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import {
|
||||
createProxyFetch,
|
||||
readProxyEnv,
|
||||
withProxyDispatcher,
|
||||
} from "@inspector/core/mcp/node/transport.js";
|
||||
} from "@inspector/core/mcp/node/proxyFetch.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
|
|
@ -16,6 +24,8 @@ import {
|
|||
createTestServerInfo,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { createServer, request, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
describe("Transport", () => {
|
||||
describe("getServerType", () => {
|
||||
|
|
@ -396,50 +406,27 @@ describe("Transport", () => {
|
|||
expect(readProxyEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withProxyDispatcher returns the original fetch unchanged when no proxy is configured", () => {
|
||||
it("createProxyFetch returns undefined when no proxy is configured", () => {
|
||||
clearProxyEnv();
|
||||
const base = vi.fn();
|
||||
expect(withProxyDispatcher(base as unknown as typeof fetch)).toBe(base);
|
||||
expect(createProxyFetch()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withProxyDispatcher injects an EnvHttpProxyAgent dispatcher into RequestInit", async () => {
|
||||
it("createProxyFetch returns a fetch when a proxy is configured", () => {
|
||||
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);
|
||||
expect(typeof createProxyFetch()).toBe("function");
|
||||
});
|
||||
|
||||
it("createTransportNode wraps the supplied fetch with the proxy dispatcher for streamable-http", async () => {
|
||||
it("createTransportNode leaves a supplied fetchFn untouched under a proxy", async () => {
|
||||
// The proxy now lives at the *bottom* of the stack (environment.fetch), so
|
||||
// a caller-supplied fetch must be used verbatim — the old behavior wrapped
|
||||
// it and injected a `dispatcher`, which is what #2067 removed.
|
||||
clearProxyEnv();
|
||||
process.env.HTTPS_PROXY = "http://proxy.example:3128";
|
||||
let seenDispatcher: object | undefined;
|
||||
let seenInit: RequestInit | 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.
|
||||
seenInit = init;
|
||||
throw new Error("stop");
|
||||
},
|
||||
);
|
||||
|
|
@ -450,24 +437,173 @@ describe("Transport", () => {
|
|||
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");
|
||||
expect(
|
||||
(seenInit as { dispatcher?: unknown } | undefined)?.dispatcher,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws an actionable error when a proxy is configured but undici cannot be loaded", async () => {
|
||||
describe("through a real proxy server", () => {
|
||||
// A forwarding proxy, so these exercise the actual undici code path rather
|
||||
// than a mock. That matters: #2067's second bug was a *runtime* interface
|
||||
// mismatch between userland undici's dispatcher and Node's built-in fetch
|
||||
// ("invalid onRequestStart method"), which every mock-based test missed
|
||||
// because no mock ever dispatches. Only a request that really leaves the
|
||||
// process can catch that class.
|
||||
let proxy: Server;
|
||||
let proxyUrl: string;
|
||||
let origin: Server;
|
||||
let originUrl: string;
|
||||
const proxied: string[] = [];
|
||||
const seenRequests: Array<{ method: string; probe: string }> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
origin = createServer((req, res) => {
|
||||
seenRequests.push({
|
||||
method: req.method ?? "",
|
||||
probe: String(req.headers["x-probe"] ?? ""),
|
||||
});
|
||||
if (req.url === "/no-content") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "text/plain", "x-origin": "1" });
|
||||
res.end("hello from origin");
|
||||
});
|
||||
await new Promise<void>((r) => origin.listen(0, "127.0.0.1", r));
|
||||
originUrl = `http://127.0.0.1:${(origin.address() as AddressInfo).port}`;
|
||||
|
||||
proxy = createServer((req, res) => {
|
||||
// Absolute-form request URI is what makes this a proxy request.
|
||||
proxied.push(req.url ?? "");
|
||||
const target = new URL(req.url ?? "");
|
||||
const upstream = request(
|
||||
{
|
||||
host: target.hostname,
|
||||
port: target.port,
|
||||
path: target.pathname + target.search,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
},
|
||||
(up) => {
|
||||
res.writeHead(up.statusCode ?? 502, up.headers);
|
||||
up.pipe(res);
|
||||
},
|
||||
);
|
||||
upstream.on("error", () => {
|
||||
res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
req.pipe(upstream);
|
||||
});
|
||||
await new Promise<void>((r) => proxy.listen(0, "127.0.0.1", r));
|
||||
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((r) => proxy.close(() => r()));
|
||||
await new Promise<void>((r) => origin.close(() => r()));
|
||||
});
|
||||
|
||||
it("routes requests through the proxy and returns a global Response", async () => {
|
||||
clearProxyEnv();
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
const proxyFetch = createProxyFetch();
|
||||
expect(proxyFetch).toBeDefined();
|
||||
|
||||
const before = proxied.length;
|
||||
const res = await proxyFetch!(`${originUrl}/hello`);
|
||||
|
||||
// The request really went through the proxy...
|
||||
expect(proxied.length).toBe(before + 1);
|
||||
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/hello`);
|
||||
|
||||
// ...and came back as a *global* Response, not undici's own class. The
|
||||
// SDK branches on `input instanceof Response` when formatting OAuth
|
||||
// errors, so a foreign Response silently degrades those messages.
|
||||
expect(res).toBeInstanceOf(Response);
|
||||
expect(res.headers).toBeInstanceOf(Headers);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("x-origin")).toBe("1");
|
||||
expect(await res.text()).toBe("hello from origin");
|
||||
});
|
||||
|
||||
it("honors NO_PROXY", async () => {
|
||||
clearProxyEnv();
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
process.env.NO_PROXY = "127.0.0.1";
|
||||
const proxyFetch = createProxyFetch();
|
||||
|
||||
const before = proxied.length;
|
||||
const res = await proxyFetch!(`${originUrl}/direct`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Went straight to the origin — the proxy never saw it.
|
||||
expect(proxied.length).toBe(before);
|
||||
});
|
||||
|
||||
it("preserves a null-body status", async () => {
|
||||
clearProxyEnv();
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
const proxyFetch = createProxyFetch();
|
||||
|
||||
const res = await proxyFetch!(`${originUrl}/no-content`);
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.body).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a URL instance and forwards the init", async () => {
|
||||
clearProxyEnv();
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
const proxyFetch = createProxyFetch();
|
||||
|
||||
const res = await proxyFetch!(new URL(`${originUrl}/from-url`), {
|
||||
method: "POST",
|
||||
headers: { "x-probe": "yes" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/from-url`);
|
||||
expect(seenRequests[seenRequests.length - 1]).toMatchObject({
|
||||
method: "POST",
|
||||
probe: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a Request as input", async () => {
|
||||
clearProxyEnv();
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
const proxyFetch = createProxyFetch();
|
||||
|
||||
const res = await proxyFetch!(
|
||||
new Request(`${originUrl}/from-request`, { method: "GET" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(proxied[proxied.length - 1]).toBe(`${originUrl}/from-request`);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a packaging fault when undici cannot be loaded", async () => {
|
||||
// The message deliberately does NOT tell the user to install undici. It
|
||||
// is a root dependency and always present; the only way this branch is
|
||||
// reached in a published build is that the bundler mangled the specifier
|
||||
// — which is exactly what #2067 was, and the old copy sent every affected
|
||||
// user chasing an install that could not have helped.
|
||||
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,
|
||||
// Re-import after the mock so the dynamic import() inside the module is
|
||||
// intercepted — and so the module-level agent memo starts out empty.
|
||||
vi.resetModules();
|
||||
const { createProxyFetch: create } =
|
||||
await import("@inspector/core/mcp/node/proxyFetch.js");
|
||||
const proxied = create();
|
||||
await expect(proxied!("https://example.com")).rejects.toThrow(
|
||||
/undici.*could not be loaded/i,
|
||||
);
|
||||
vi.doUnmock("undici");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue