Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/project-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ an MCP server, the request/response lifecycle, and a set of state stores.
| Directory | Owns |
| --- | --- |
| `core/mcp/` | `InspectorClient`, transports, state stores, config import, URI templates, subscription/App-elicitation protocol helpers |
| `core/mcp/node/` | Node stdio transport factory; `proxyFetch.ts` (the shared HTTPS_PROXY/NO_PROXY fetch) |
| `core/mcp/node/` | Node stdio transport factory; `proxyFetch.ts` (the shared HTTPS_PROXY/NO_PROXY fetch); `fetchGuard.ts` (the OAuth destination guard every Node client and `/api/fetch` use) |
| `core/mcp/remote/` | Browser HTTP/SSE transport + remote logger/fetch, and (under `node/`) the Hono backend it talks to |
| `core/mcp/state/` | The stores `core/react/` hooks read |
| `core/auth/` | OAuth end to end — providers, discovery, storage, endpoint overrides, scopes, revocation, mid-session recovery — split into isomorphic logic plus `browser/`, `node/` and `remote/` backends |
Expand Down
6 changes: 4 additions & 2 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ 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 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.
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**.

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.
OAuth requests go through the destination guard rather than the plain proxied fetch: it reads the same variables, refuses link-local and cloud-metadata targets, and pins each direct connection to the addresses it checked ([#2749](https://github.com/modelcontextprotocol/inspector/issues/2749)). What it can and cannot promise behind a proxy is in [Outbound fetch destination guard](../web/README.md#outbound-fetch-destination-guard).

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, for MCP traffic, only when a proxy variable is set, so MCP connections without a proxy configured pay no cost. OAuth requests load it either way, for the destination guard.

**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.)

Expand Down
11 changes: 11 additions & 0 deletions clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import {
import { MIN_REVOCATION_REQUEST_BUDGET_MS } from "@inspector/core/auth/revocation.js";
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";

// The OAuth destination guard (#2749) calls undici directly, past the
// `globalThis.fetch` spy these tests fake the authorization server with, so
// route it back through the spy. The guard itself is tested in clients/web.
vi.mock("@inspector/core/mcp/node/fetchGuard.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("@inspector/core/mcp/node/fetchGuard.js")
>()),
createGuardedFetch: (): typeof fetch => (input, init) =>
globalThis.fetch(input, init),
}));

const AS_ISSUER = "https://as.example.com";

describe("clearStoredAuthForRelogin", () => {
Expand Down
11 changes: 11 additions & 0 deletions clients/cli/__tests__/relogin-revocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ import * as path from "node:path";
import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js";
import { runCli } from "./helpers/cli-runner.js";

// The OAuth destination guard (#2749) calls undici directly, past the
// `globalThis.fetch` spy these tests fake the authorization server with, so
// route it back through the spy. The guard itself is tested in clients/web.
vi.mock("@inspector/core/mcp/node/fetchGuard.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("@inspector/core/mcp/node/fetchGuard.js")
>()),
createGuardedFetch: (): typeof fetch => (input, init) =>
globalThis.fetch(input, init),
}));

const SERVER_URL = "https://example.com/mcp";
const AS_ISSUER = "https://as.example.com";
const REVOKE_URL = `${AS_ISSUER}/revoke`;
Expand Down
6 changes: 4 additions & 2 deletions clients/cli/src/clear-stored-auth-for-relogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type OAuthRevocationPlan,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
import { createGuardedFetch } from "@inspector/core/mcp/node/fetchGuard.js";

/** Same canonicalisation as CLI `normalizeServerUrl` (avoid cycles). */
function normalizeServerUrl(serverUrl: string): string {
Expand Down Expand Up @@ -113,7 +113,9 @@ async function sendPlans(
plans: OAuthRevocationPlan[],
budgetMs: number,
): Promise<TokenRevocationOutcome | undefined> {
const fetchFn = createProxyFetch() ?? fetch;
// Revocation endpoints come from authorization-server metadata, so they get
// the same destination guard as the rest of OAuth (#2749); proxy-aware too.
const fetchFn = createGuardedFetch();
// Monotonic and epsilon-floored for the same reasons as the shared deadline
// inside `executeOAuthRevocation` — see MIN_REVOCATION_REQUEST_BUDGET_MS.
const deadlineAt = performance.now() + budgetMs;
Expand Down
18 changes: 12 additions & 6 deletions clients/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { InspectorClient } from "@inspector/core/mcp/index.js";
import { cleanRoots } from "@inspector/core/mcp/serverList.js";
import { UI_EXTENSION_KEY } from "@inspector/core/mcp/extensions.js";
import {
createGuardedFetch,
createProxyFetch,
createTransportNode,
loadServerEntries,
Expand Down Expand Up @@ -206,6 +207,10 @@ async function callMethod(
// 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(),
// OAuth on its own base, which refuses link-local and cloud-metadata
// destinations a hostile server could steer discovery at (#2749). It is
// proxy-aware itself, so the proxy still covers OAuth.
oauthFetch: createGuardedFetch(),
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
// Disarmed until the CLI-owned interactive OAuth flow runs — SDK `auth()`
Expand Down Expand Up @@ -527,18 +532,19 @@ export async function refreshStoredAuthToken(
// `serverMetadata` still cannot refresh against an authorization server that
// publishes RFC 8414 metadata at the OIDC well-known path (Copilot).
//
// Built over `createProxyFetch()` for the same reason `environment.fetch` is
// (#2067): this whole function runs outside `InspectorClient`, so nothing
// else puts a proxy under it, and a server reachable only through
// `HTTPS_PROXY` would otherwise be probed directly. The same fetch is handed
// to the token request below, so neither leg bypasses the proxy (Copilot).
// Built over `createGuardedFetch()` for the same reason `environment.oauthFetch`
// is: this whole function runs outside `InspectorClient`, so nothing else
// puts the destination guard (#2749) or a proxy (#2067) under it — the guard
// is proxy-aware, so a server reachable only through `HTTPS_PROXY` is still
// reached through it. The same fetch is handed to the token request below,
// so neither leg bypasses either (Copilot).
// #2319: this path runs outside `InspectorClient`, so nothing else bounds it
// — the discovery and the token request below would otherwise hang forever
// against an authorization server that accepts the connection and never
// answers. Innermost, so the compat wrapper's own probe requests inherit the
// deadline too.
const storedAuthFetch = withRfc8414OidcCompat(
withOAuthRequestTimeout(createProxyFetch() ?? fetch),
withOAuthRequestTimeout(createGuardedFetch()),
);
const discover: typeof discoverAuthorizationServerMetadata =
deps.discover ??
Expand Down
24 changes: 23 additions & 1 deletion clients/mcpdo/__tests__/authorize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const disconnectSpy = vi.fn().mockResolvedValue(undefined);
const navigationSpy = vi.fn();
const clientCtorSpy = vi.fn();
const createProxyFetchSpy = vi.fn<() => typeof fetch | undefined>();
const createGuardedFetchSpy = vi.fn<() => typeof fetch>();

vi.mock("@inspector/core/cli/cliOAuth.js", () => ({
connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args),
Expand All @@ -32,7 +33,11 @@ vi.mock("@inspector/core/mcp/index.js", () => ({
vi.mock("@inspector/core/mcp/node/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/mcp/node/index.js")>();
return { ...actual, createProxyFetch: () => createProxyFetchSpy() };
return {
...actual,
createProxyFetch: () => createProxyFetchSpy(),
createGuardedFetch: () => createGuardedFetchSpy(),
};
});

vi.mock("@inspector/core/client/runner.js", async (importOriginal) => {
Expand All @@ -52,6 +57,7 @@ describe("authorizeInFrontend", () => {
navigationSpy.mockClear();
clientCtorSpy.mockClear();
createProxyFetchSpy.mockReset();
createGuardedFetchSpy.mockReset();
});

it("installs the proxy-aware fetch on the client environment (#2642)", async () => {
Expand All @@ -73,6 +79,22 @@ describe("authorizeInFrontend", () => {
expect(options.environment.fetch).toBe(proxyFetch);
});

it("installs the guarded fetch as the OAuth base (#2749)", async () => {
connectSpy.mockResolvedValue(undefined);
const guardedFetch: typeof fetch = vi.fn<typeof fetch>(); // a stand-in, never called
createGuardedFetchSpy.mockReturnValue(guardedFetch);
const { authorizeInFrontend } =
await import("../src/connection/authorize.js");
await authorizeInFrontend(
{ type: "streamable-http", url: "https://example.com/mcp" },
undefined,
);
const options = clientCtorSpy.mock.calls[0]?.[1] as {
environment: { oauthFetch?: typeof fetch };
};
expect(options.environment.oauthFetch).toBe(guardedFetch);
});

it("names the CLI's timeout knobs, not the web's Server Settings (#2615)", async () => {
connectSpy.mockResolvedValue(undefined);
const { authorizeInFrontend } =
Expand Down
20 changes: 11 additions & 9 deletions clients/mcpdo/__tests__/ema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {
const runRunnerInteractiveOAuth = vi.fn();
const startIdpOidcAuthorization = vi.fn();
const completeIdpOidcAuthorization = vi.fn();
const createProxyFetch = vi.fn<() => typeof fetch | undefined>();
const createGuardedFetch = vi.fn<() => typeof fetch>();

vi.mock("@inspector/core/mcp/node/proxyFetch.js", () => ({
createProxyFetch: () => createProxyFetch(),
vi.mock("@inspector/core/mcp/node/fetchGuard.js", () => ({
createGuardedFetch: () => createGuardedFetch(),
}));

vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => {
Expand Down Expand Up @@ -62,7 +62,7 @@ describe("mcpdo ema helpers", () => {
runRunnerInteractiveOAuth.mockReset();
startIdpOidcAuthorization.mockReset();
completeIdpOidcAuthorization.mockReset();
createProxyFetch.mockReset();
createGuardedFetch.mockReset();
});

afterEach(() => {
Expand Down Expand Up @@ -264,10 +264,12 @@ describe("mcpdo ema helpers", () => {
);
});

it("emaLogin routes IdP discovery and the code exchange through the proxy-aware fetch (#2642)", async () => {
// The guarded fetch is proxy-aware itself (#2642), and refuses link-local and
// cloud-metadata destinations (#2749).
it("emaLogin routes IdP discovery and the code exchange through the guarded fetch", async () => {
writeClientConfig(emaClientConfig());
const proxyFetch: typeof fetch = vi.fn<typeof fetch>(); // a stand-in, never called
createProxyFetch.mockReturnValue(proxyFetch);
const guardedFetch: typeof fetch = vi.fn<typeof fetch>(); // a stand-in, never called
createGuardedFetch.mockReturnValue(guardedFetch);
startIdpOidcAuthorization.mockResolvedValue({
authorizationUrl: new URL("https://idp.example.com/authorize"),
});
Expand Down Expand Up @@ -296,10 +298,10 @@ describe("mcpdo ema helpers", () => {
process.stderr.write = originalWrite;
}
expect(startIdpOidcAuthorization).toHaveBeenCalledWith(
expect.objectContaining({ fetchFn: proxyFetch }),
expect.objectContaining({ fetchFn: guardedFetch }),
);
expect(completeIdpOidcAuthorization).toHaveBeenCalledWith(
expect.objectContaining({ fetchFn: proxyFetch }),
expect.objectContaining({ fetchFn: guardedFetch }),
);
});

Expand Down
4 changes: 4 additions & 0 deletions clients/mcpdo/src/connection/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@inspector/core/client/runner.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import {
createGuardedFetch,
createProxyFetch,
createTransportNode,
} from "@inspector/core/mcp/node/index.js";
Expand Down Expand Up @@ -65,6 +66,9 @@ export async function authorizeInFrontend(
// at the bottom of the stack, for OAuth discovery and token requests to
// reach it. Undefined when no proxy variable is set.
fetch: createProxyFetch(),
// OAuth on the destination-guarded base (#2749), which refuses link-local
// and cloud-metadata targets; proxy-aware itself.
oauthFetch: createGuardedFetch(),
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
const callbackUrlConfig = parseRunnerOAuthCallbackUrl(
Expand Down
8 changes: 4 additions & 4 deletions clients/mcpdo/src/connection/ema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
parseRunnerOAuthCallbackUrl,
} from "@inspector/core/auth/node/runner-oauth-callback.js";
import { getClientConfigFilePath } from "@inspector/core/client/index.js";
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
import { createGuardedFetch } from "@inspector/core/mcp/node/fetchGuard.js";
import { loadRunnerClientConfig } from "@inspector/core/client/runner.js";
import type { EnterpriseManagedAuthIdpConfig } from "@inspector/core/client/types.js";
import { createCliOAuthNavigation } from "@inspector/core/cli/cli-oauth-navigation.js";
Expand Down Expand Up @@ -233,9 +233,9 @@ export async function runEmaIdpInteractiveFlow(
redirectUrlProvider.redirectUrl =
formatRunnerOAuthRedirectUrl(callbackUrlConfig);
// IdP discovery and the code exchange run outside `InspectorClient`, so
// nothing else puts HTTPS_PROXY / HTTP_PROXY under them (#2642). Undefined
// when no proxy variable is set, which leaves the built-in fetch in place.
const fetchFn = createProxyFetch();
// nothing else puts HTTPS_PROXY / HTTP_PROXY (#2642) or the OAuth
// destination guard (#2749) under them. The guarded fetch is both.
const fetchFn = createGuardedFetch();

// Adapter over the server-bound runner-interactive-OAuth surface: EMA leg 1
// is server-less, so authenticate/completeOAuthFlow map straight onto the
Expand Down
4 changes: 4 additions & 0 deletions clients/mcpdo/src/daemon/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type MCPServerConfig,
} from "@inspector/core/mcp/types.js";
import {
createGuardedFetch,
createProxyFetch,
createTransportNode,
} from "@inspector/core/mcp/node/index.js";
Expand Down Expand Up @@ -826,6 +827,9 @@ async function createConnectionClient(
// `createProxyFetch()` fallback is never reached — leave this out and the
// daemon connects direct. Undefined when no proxy variable is set.
fetch: createProxyFetch(),
// OAuth on the destination-guarded base (#2749), which refuses link-local
// and cloud-metadata targets; proxy-aware itself.
oauthFetch: createGuardedFetch(),
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
if (isOAuthCapableServerConfig(serverConfig)) {
Expand Down
15 changes: 14 additions & 1 deletion clients/tui/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,17 @@ const h = vi.hoisted(() => {
// (#2615).
opts?: {
defaultMetadata?: unknown;
environment?: { fetch?: unknown };
environment?: { fetch?: unknown; oauthFetch?: unknown };
connectionTimeoutRemedy?: string;
};
}> = [];
// What the mocked `createProxyFetch` returns. `undefined` is the real
// behavior with no proxy env var set; a test points this at a sentinel to
// exercise the proxy-configured branch (#2067).
const proxyFetchState: { current: unknown } = { current: undefined };
// What the mocked `createGuardedFetch` returns: a fixed sentinel, so a test
// can assert it landed in `environment.oauthFetch` (#2749).
const guardedFetchSentinel: typeof fetch = async () => new Response("");
const fireClientEvent = (event: string, detail?: unknown) => {
clientEvents.get(event)?.forEach((e) => e.fn({ detail }));
};
Expand Down Expand Up @@ -259,6 +262,7 @@ const h = vi.hoisted(() => {
return {
ctrl,
proxyFetchState,
guardedFetchSentinel,
connect,
disconnect,
openUrl,
Expand Down Expand Up @@ -337,6 +341,7 @@ vi.mock("@inspector/core/mcp/node/index.js", () => ({
// test can point `h.proxyFetchState.current` at a sentinel to exercise the
// other branch.
createProxyFetch: vi.fn(() => h.proxyFetchState.current),
createGuardedFetch: vi.fn(() => h.guardedFetchSentinel),
}));
vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
useInspectorClient: h.useInspectorClient,
Expand Down Expand Up @@ -2191,6 +2196,14 @@ describe("App (OAuth result branches)", () => {
expect(h.clientInstances[0]!.opts?.environment?.fetch).toBeUndefined();
});

it("installs the guarded fetch as environment.oauthFetch (#2749)", async () => {
await mount(oneHttp());
expect(h.clientInstances.length).toBeGreaterThan(0);
expect(h.clientInstances[0]!.opts?.environment?.oauthFetch).toBe(
h.guardedFetchSentinel,
);
});

it("ignores auth lifecycle events from a non-selected server", async () => {
const r = await mount(twoHttp()); // web is selected; api is not
await press(r, ["a"]);
Expand Down
4 changes: 4 additions & 0 deletions clients/tui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
ResourceSubscriptionsState,
} from "@inspector/core/mcp/state/index.js";
import {
createGuardedFetch,
createProxyFetch,
createTransportNode,
} from "@inspector/core/mcp/node/index.js";
Expand Down Expand Up @@ -401,6 +402,9 @@ function App({
// over it and OAuth requests are proxied too (#2067). Undefined when
// no proxy env var is set.
fetch: createProxyFetch(),
// OAuth on the destination-guarded base (#2749), which refuses
// link-local and cloud-metadata targets; proxy-aware itself.
oauthFetch: createGuardedFetch(),
};
// Per-server default `_meta` is already a JSON object (#1910) — no
// pair-array flattening left to do; `{}` means "no defaults".
Expand Down
Loading
Loading