inspector/clients/web/tsup.runner.config.ts
cliffhall d90a955164 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>
2026-08-23 21:12:16 -04:00

53 lines
1.9 KiB
TypeScript

import { defineConfig } from "tsup";
import path from "node:path";
import { fileURLToPath } from "node:url";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(dirname, "../..");
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
outDir: "build",
clean: true,
// No source maps in the published bundle — they roughly double the on-disk
// size and aren't needed at runtime (debug via `npm run dev` on the source).
sourcemap: false,
target: "node22",
platform: "node",
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",
"commander",
"open",
"pino",
"hono",
"@hono/node-server",
"vite",
"@vitejs/plugin-react",
"atomically",
"chokidar",
"@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*
// package.json declares, so a root-only dependency is bundled unless named
// here — and inlining a CJS module into an ESM bundle leaves esbuild's
// `Dynamic require of "path" is not supported` shim, which throws at
// import time and takes the whole binary down before it parses a flag.
"proper-lockfile",
"@modelcontextprotocol/client",
"@modelcontextprotocol/core",
],
esbuildOptions(options) {
options.alias = {
"@inspector/core": path.join(repoRoot, "core"),
};
},
});