Skip to content

core: honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY in the Node transport #1563

Description

@cliffhall

Summary

Make the Node transport factory honor the standard HTTPS_PROXY / HTTP_PROXY / NO_PROXY environment variables (CLI and web backend) by wrapping fetch with undici's EnvHttpProxyAgent.

Why

Users behind corporate proxies currently can't reach remote MCP servers from the CLI or the web backend without inspector-specific configuration. Honoring the conventional env vars fixes that with zero new flags.

Reference implementation (PR #1510)

Re-implement informed by these changes at 33fac3f:

Depends on

Nothing — Wave 1 foundation, parallel-safe.

Notes

  • undici is lazily imported only when a proxy env var is set, so non-proxy users pay nothing.
  • Check undici@^8.x's Node engine floor against our supported Node range and document it in the README (dependency + proxy behavior) per AGENTS.md's docs rule.
  • Integration tests live under clients/web/src/test/integration/mcp/node/ (auto-picked-up by the integration vitest project).

Part of the PR #1510 decomposition (see tracking issue).

Activity

  1. self-assigned this
    on Jul 1, 2026
  2. cliffhall commented on Jul 2, 2026

    @cliffhall
    MemberAuthor

    Smoke test & AGENTS.md audit — PR #1590

    Reviewed v2/1563-node-proxy (core: honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY in the Node transport via undici). Node used: v25.9.0 (well above the new >=22.19.0 floor, so the real proxy path was exercised, not just the tests).

    1. Smoke test (real output, no Playwright)

    Wrote a standalone tsx script that imports the real exported readProxyEnv() / withProxyDispatcher() from core/mcp/node/transport.ts and drives them directly (script in the collapsible below). The undici-missing branch is reproduced faithfully with a module-resolution hook that makes import("undici") reject only for transport.ts, hitting the actual catch in withProxyDispatcher (no source edits).

    === 1. readProxyEnv() reads the standard env vars ===
    no proxy vars set        -> readProxyEnv() = undefined
    HTTP_PROXY set           -> readProxyEnv() = http://proxy.example:3128
    + HTTPS_PROXY (precedence)-> readProxyEnv() = http://secure.example:3128
    whitespace-only value    -> readProxyEnv() = undefined
    
    === 2. Passthrough: no dispatcher injected when no proxy is set ===
    withProxyDispatcher === original fetch (unchanged)? true
    
    === 3. Injection: dispatcher added to RequestInit when a proxy IS set ===
    withProxyDispatcher returned a NEW wrapped fetch?   true
    request input preserved?                            true
    request init.method preserved?                      true
    dispatcher injected into RequestInit?               true
    dispatcher constructor name                         EnvHttpProxyAgent
    2nd call reuses same lazy singleton dispatcher?     true
    
    === 4. Actionable error when a proxy is set but undici is unavailable ===
    threw actionable error:
      message: 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:   Error: Cannot find module 'undici' (blocked by smoke hook)
    

    Confirmed behaviors: env precedence (HTTPS before HTTP, lowercase forms, whitespace-only ignored), passthrough returns the identical fetch reference when no proxy is set (zero cost), an EnvHttpProxyAgent dispatcher is injected into RequestInit with input/init preserved, the dispatcher is a lazy singleton reused across calls, and the actionable error fires (with cause chained) when a proxy is configured but undici can't load.

    2. AGENTS.md compliance audit

    • Mantine: N/A — this is a core/ transport change with no UI/component surface.

    • TypeScript: clean. No any, no eslint-disable / @ts-* / suppressions in the added lines (grepped the full diff). Uses a precise NodeRequestInit = RequestInit & { dispatcher?: Dispatcher } type, import type { Dispatcher } from "undici" (erased at runtime), and cause: unknown in the import-failure handler.

    • Coverage — core/mcp/node/transport.ts (all four dims, ≥90 gate):

      Metric %
      Statements 100
      Branch 93.61
      Functions 100
      Lines 100

      All four clear the 90 gate. (Uncovered: only branch partials on lines 92/119/129.)

    • Integration suite: npm run test:integration → 804 passed (38 files), including the 5 new proxy tests (readProxyEnv precedence, passthrough, dispatcher injection + lazy singleton, createTransportNode streamable-http wrapping, and the undici-missing actionable error via vi.doMock).

    • Node-floor consistency (5 touch-points, all aligned):

      1. .github/workflows/main.yml — CI node-version 20.x → 22.x
      2. package.json — engines.node >=22.7.5 → >=22.19.0
      3. package-lock.json (root) — engines.node → >=22.19.0
      4. clients/cli/package.json + lock — adds undici@^8.5.0 (whose own engines.node is >=22.19.0, the reason for the bump)
      5. specification/v2_cli_tui_launcher.md — doc floor >=22.7.5 → >=22.19.0

      No stray 22.7.5 or 20.x references remain (grepped repo-wide). CLI + web READMEs document the proxy support and the Node floor.

    Notes

    • A full cd clients/web && npm run test:coverage (unit + integration) showed 2 pre-existing, unrelated happy-dom timeouts under coverage instrumentation (ResourcesScreen, ServerConfigModal) — no connection to the transport/proxy change; the transport.ts numbers above are from a clean integration-project coverage run.

    Verdict

    APPROVE. The change is correct, well-typed, well-tested, and cheap when no proxy is set (identical-reference passthrough, lazy undici import). Real end-to-end smoke confirms dispatcher injection, passthrough, and the actionable undici-missing error. Coverage clears the 90 gate on all four dims; the Node >=22.19.0 floor is consistent across all five touch-points.

    Smoke script (tsx) + undici-block module hook

    proxy-smoke.mts (sections 1–3):

    const TRANSPORT = "<repo>/core/mcp/node/transport.ts";
    const PROXY_VARS = ["HTTPS_PROXY","https_proxy","HTTP_PROXY","http_proxy","NO_PROXY","no_proxy"];
    const clearProxy = () => { for (const v of PROXY_VARS) delete process.env[v]; };
    const line = (s = "") => process.stdout.write(s + "\n");
    
    async function main() {
      const { readProxyEnv, withProxyDispatcher } = await import(TRANSPORT);
    
      line("=== 1. readProxyEnv() reads the standard env vars ===");
      clearProxy();
      line(`no proxy vars set        -> readProxyEnv() = ${String(readProxyEnv())}`);
      process.env.HTTP_PROXY = "http://proxy.example:3128";
      line(`HTTP_PROXY set           -> readProxyEnv() = ${readProxyEnv()}`);
      process.env.HTTPS_PROXY = "http://secure.example:3128";
      line(`+ HTTPS_PROXY (precedence)-> readProxyEnv() = ${readProxyEnv()}`);
      clearProxy(); process.env.https_proxy = "   ";
      line(`whitespace-only value    -> readProxyEnv() = ${String(readProxyEnv())}`);
    
      line("\n=== 2. Passthrough: no dispatcher injected when no proxy is set ===");
      clearProxy();
      const passBase = (async () => new Response("ok")) as unknown as typeof fetch;
      line(`withProxyDispatcher === original fetch (unchanged)? ${withProxyDispatcher(passBase) === passBase}`);
    
      line("\n=== 3. Injection: dispatcher added to RequestInit when a proxy IS set ===");
      clearProxy(); process.env.HTTPS_PROXY = "http://proxy.example:3128";
      const calls: Array<{ input: unknown; init: Record<string, unknown> | undefined }> = [];
      const base = (async (input: unknown, init?: Record<string, unknown>) => {
        calls.push({ input, init }); return new Response("ok");
      }) as unknown as typeof fetch;
      const proxied = withProxyDispatcher(base);
      line(`withProxyDispatcher returned a NEW wrapped fetch?   ${proxied !== base}`);
      await proxied("https://example.com/mcp", { method: "POST" } as RequestInit);
      const d1 = (calls[0].init as { dispatcher?: { constructor: { name: string } } }).dispatcher;
      line(`request input preserved?                            ${calls[0].input === "https://example.com/mcp"}`);
      line(`request init.method preserved?                      ${(calls[0].init as { method?: string }).method === "POST"}`);
      line(`dispatcher injected into RequestInit?               ${d1 !== undefined}`);
      line(`dispatcher constructor name                         ${d1?.constructor.name}`);
      await proxied("https://example.com/mcp");
      line(`2nd call reuses same lazy singleton dispatcher?     ${(calls[1].init as { dispatcher?: unknown }).dispatcher === d1}`);
    }
    main();

    proxy-undici-missing.mts (section 4 — self-registers the hook, then imports the transport under tsx):

    import { register } from "node:module";
    import { pathToFileURL } from "node:url";
    const TRANSPORT = "<repo>/core/mcp/node/transport.ts";
    register(pathToFileURL("<scratch>/undici-block-hook.mjs"));
    
    const { withProxyDispatcher } = await import(TRANSPORT);
    process.env.HTTPS_PROXY = "http://proxy.example:3128";
    const proxied = withProxyDispatcher((async () => new Response("ok")) as unknown as typeof fetch);
    try { await proxied("https://example.com"); }
    catch (err) {
      process.stdout.write("  message: " + (err as Error).message + "\n");
      process.stdout.write("  cause:   " + String((err as Error).cause) + "\n");
    }

    undici-block-hook.mjs (resolve hook — fails import("undici") only for transport.ts):

    export async function resolve(specifier, context, next) {
      if (specifier === "undici" && String(context.parentURL).includes("core/mcp/node/transport")) {
        throw new Error("Cannot find module 'undici' (blocked by smoke hook)");
      }
      return next(specifier, context);
    }

    Run: clients/tui/node_modules/.bin/tsx proxy-smoke.mts then … tsx proxy-undici-missing.mts. Not committed.

  3. cliffhall commented on Jul 2, 2026

    @cliffhall
    MemberAuthor

    🔗 Implemented by PR #1590. Its code is already integrated into the Wave 1 rollup branch 1579-wave-1 (see rollup PR #1601 / verification #1600). #1590 currently targets v2/main and will auto-show as Merged once the rollup lands in v2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.

  4. added a commit that references this issue on Jul 28, 2026
    d5be492
  5. added this to the v2.0.0 milestone on Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

v2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions