Skip to content
Draft
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
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,85 @@ You can paste the Server Entry into your existing `mcp.json` file under your cho

The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar.

### Apply custom headers to OAuth requests

Custom Headers are, by default, only attached to the MCP transport requests.
That is a problem when an MCP server sits behind a gateway that gates access
with a header — for example [Vercel Deployment Protection's Protection Bypass
for Automation](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation),
which accepts an `x-vercel-protection-bypass: <secret>` header on any request.
In that setup the OAuth flow fails, because the OAuth auxiliary requests the SDK
issues itself — protected-resource / authorization-server metadata discovery,
dynamic client registration (`/register`), and the token exchange (`/token`) —
go out **without** the custom header and hit the gateway's auth wall.

The **"Also apply to OAuth requests"** toggle beneath Custom Headers (in the
sidebar's _Authentication_ panel) fixes this. When enabled, the configured
custom headers are injected into **every** outbound request to the target
server:

- the MCP transport requests (as always), and
- the OAuth auxiliary calls (metadata discovery, DCR, and token exchange).

Injection happens in a single shared fetch wrapper that is threaded into the
SDK's auth methods, so it works in **both** connection modes — "Via Proxy" and
direct — and reaches the OAuth calls regardless of which layer issues them (see
[#995](https://github.com/modelcontextprotocol/inspector/issues/995)). When the
toggle is off (the default), behavior is unchanged. Header values are masked in
the UI and are never logged.

#### Configuring for headless / CLI use

The custom headers can be preconfigured without the UI, via CLI flags or env
vars. They are surfaced to the web UI through the proxy server's `/config`
endpoint, so a headless launch preloads the fields — and, because headless
headers are almost always a gateway bypass, the "apply to all requests" toggle
defaults on when any are provided (opt out with
`MCP_APPLY_HEADERS_TO_ALL_REQUESTS=false`):

```bash
# Repeatable CLI flag (UI mode). --header uses "Name: Value"
mcp-inspector \
--header "x-vercel-protection-bypass: $BYPASS_SECRET"

# Or via environment variable (JSON object) — handy for Docker
MCP_REQUEST_HEADERS='{"x-vercel-protection-bypass":"'"$BYPASS_SECRET"'"}' \
mcp-inspector
```

In pure [CLI Mode](#cli-mode) (headless `--cli`, no OAuth), the existing
`--header` flag already applies the header to every request:

```bash
mcp-inspector --cli https://your-server.example.com/mcp --method tools/list \
--header "x-vercel-protection-bypass: $BYPASS_SECRET"
```

#### Running against a protected deployment

For the real-world shape — the MCP server (transport + `.well-known` metadata)
behind Vercel Deployment Protection, while its OAuth authorization server
(`/authorize`, `/token`, `/register`) lives on a **different, unprotected**
host:

1. Grab a
[protection-bypass secret](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation)
(Vercel project → _Settings → Deployment Protection → Protection Bypass for
Automation_).
2. Start the inspector (proxy mode is fine) and open your protected server URL.
3. In the sidebar → _Authentication_, add a Custom Header
`x-vercel-protection-bypass` = `<secret>` and enable **"Also apply to OAuth
requests"**.
4. Run the OAuth flow. Protected-resource discovery now succeeds, the client
follows `authorization_servers` to the unprotected auth host, you log in, and
the tool list loads.

> **Note on Vercel deployment URLs:** connect to the exact host your server
> advertises as its OAuth `resource` (its canonical/production URL). Vercel
> exposes the same deployment under several hostnames (per-deployment vs. branch
> alias vs. production domain); connecting through a different alias than the one
> the server advertises makes the OAuth resource check fail.

### Security Considerations

The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server.
Expand Down
7 changes: 7 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ async function runWebClient(args: Args): Promise<void> {
startArgs.push("--server-url", args.serverUrl);
}

// Pass custom-header defaults so the web UI is preconfigured (headless use)
if (args.headers) {
for (const [key, value] of Object.entries(args.headers)) {
startArgs.push("--header", `${key}: ${value}`);
}
}

// Pass command and args (using -- to separate them)
if (args.command) {
startArgs.push("--", args.command, ...args.args);
Expand Down
35 changes: 35 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ import {
CustomHeaders,
migrateFromLegacyAuth,
} from "./lib/types/customHeaders";
import {
APPLY_HEADERS_TO_ALL_REQUESTS_STORAGE_KEY,
loadApplyHeadersToAllRequests,
} from "./lib/oauthHeaderStorage";
import MetadataTab from "./components/MetadataTab";

const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
Expand Down Expand Up @@ -247,6 +251,11 @@ const App = () => {
];
});

// When on, the custom headers are also applied to the OAuth auxiliary
// requests (metadata discovery, DCR, token exchange), not just the transport.
const [applyHeadersToAllRequests, setApplyHeadersToAllRequests] =
useState<boolean>(() => loadApplyHeadersToAllRequests());

const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
Expand Down Expand Up @@ -396,6 +405,7 @@ const App = () => {
sseUrl,
env,
customHeaders,
applyHeadersToAllRequests,
oauthClientId,
oauthClientSecret,
oauthScope,
Expand Down Expand Up @@ -570,6 +580,13 @@ const App = () => {
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
}, [customHeaders]);

useEffect(() => {
localStorage.setItem(
APPLY_HEADERS_TO_ALL_REQUESTS_STORAGE_KEY,
String(applyHeadersToAllRequests),
);
}, [applyHeadersToAllRequests]);

// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
useEffect(() => {
if (customHeaders.length === 0 && (bearerToken || headerName)) {
Expand Down Expand Up @@ -739,6 +756,20 @@ const App = () => {
if (data.defaultServerUrl) {
setSseUrl(data.defaultServerUrl);
}
// CLI/env defaults for custom headers + the "apply to all requests"
// toggle (headless use). Only override the persisted values when the
// server actually declares headless header defaults — otherwise a
// normal launch (which reports an empty list and `false`) would clobber
// the user's saved headers/toggle on every reload.
if (
Array.isArray(data.defaultCustomHeaders) &&
data.defaultCustomHeaders.length > 0
) {
setCustomHeaders(data.defaultCustomHeaders);
if (typeof data.defaultApplyHeadersToAllRequests === "boolean") {
setApplyHeadersToAllRequests(data.defaultApplyHeadersToAllRequests);
}
}
})
.catch((error) =>
console.error("Error fetching default environment:", error),
Expand Down Expand Up @@ -1340,6 +1371,8 @@ const App = () => {
updateAuthState={updateAuthState}
config={config}
connectionType={connectionType}
customHeaders={customHeaders}
applyHeadersToAllRequests={applyHeadersToAllRequests}
/>
</TabsContent>
);
Expand Down Expand Up @@ -1393,6 +1426,8 @@ const App = () => {
setConfig={setConfig}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
applyHeadersToAllRequests={applyHeadersToAllRequests}
setApplyHeadersToAllRequests={setApplyHeadersToAllRequests}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthClientSecret={oauthClientSecret}
Expand Down
25 changes: 20 additions & 5 deletions client/src/components/AuthDebugger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { createProxyFetch } from "../lib/proxyFetch";
import {
hasEnabledHeaders,
withCustomHeaderFetch,
} from "../lib/customHeaderFetch";
import { SESSION_KEYS } from "../lib/constants";
import { validateRedirectUrl } from "@/utils/urlValidation";
import type { InspectorConfig } from "../lib/configurationTypes";
import type { CustomHeaders } from "../lib/types/customHeaders";

export interface AuthDebuggerProps {
serverUrl: string;
Expand All @@ -17,6 +22,8 @@ export interface AuthDebuggerProps {
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
config?: InspectorConfig;
connectionType?: "direct" | "proxy";
customHeaders?: CustomHeaders;
applyHeadersToAllRequests?: boolean;
}

interface StatusMessageProps {
Expand Down Expand Up @@ -66,6 +73,8 @@ const AuthDebugger = ({
updateAuthState,
config,
connectionType,
customHeaders,
applyHeadersToAllRequests,
}: AuthDebuggerProps) => {
// Check for existing tokens on mount
useEffect(() => {
Expand Down Expand Up @@ -108,13 +117,19 @@ const AuthDebugger = ({
});
}, [serverUrl, updateAuthState]);

const fetchFn = useMemo(
() =>
const fetchFn = useMemo(() => {
const oauthHeaders = applyHeadersToAllRequests ? customHeaders : undefined;
const baseFetch =
connectionType === "proxy" && config
? createProxyFetch(config)
: undefined,
[connectionType, config],
);
: undefined;
// Inject the custom headers (when opted in) into every OAuth aux call
// (metadata discovery, DCR, token exchange). Not opted in → preserve the
// prior behavior exactly (undefined in direct mode → SDK default fetch).
return hasEnabledHeaders(oauthHeaders)
? withCustomHeaderFetch(baseFetch ?? fetch, oauthHeaders)
: baseFetch;
}, [connectionType, config, customHeaders, applyHeadersToAllRequests]);

const stateMachine = useMemo(
() => new OAuthStateMachine(serverUrl, updateAuthState, fetchFn),
Expand Down
34 changes: 34 additions & 0 deletions client/src/components/OAuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
import { createProxyFetch } from "../lib/proxyFetch";
import {
hasEnabledHeaders,
withCustomHeaderFetch,
} from "../lib/customHeaderFetch";
import { loadOAuthHeaders } from "../lib/oauthHeaderStorage";
import { initializeInspectorConfig } from "@/utils/configUtils";

const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";

// This route runs as its own lazily-loaded page after the authorization-server
// redirect, so it can't read the App's React state. It rebuilds the same
// fetch function the connection flow uses (proxy vs. direct, plus the custom
// headers when "apply to all requests" is on) from persisted storage, so the
// token exchange (and any re-discovery `auth()` triggers) go through the proxy
// when selected (see issue #995) and carry the configured headers.
const buildCallbackFetchFn = (): typeof fetch | undefined => {
const connectionType =
(localStorage.getItem("lastConnectionType") as "direct" | "proxy") ||
"proxy";
const config = initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY);
const oauthHeaders = loadOAuthHeaders();

const baseFetch =
connectionType === "proxy" ? createProxyFetch(config) : undefined;

if (hasEnabledHeaders(oauthHeaders)) {
return withCustomHeaderFetch(baseFetch ?? fetch, oauthHeaders);
}
return baseFetch;
};

interface OAuthCallbackProps {
onConnect: (serverUrl: string) => void;
Expand Down Expand Up @@ -46,9 +77,12 @@ const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);

const fetchFn = buildCallbackFetchFn();

result = await auth(serverAuthProvider, {
serverUrl,
authorizationCode: params.code,
...(fetchFn && { fetchFn }),
});
} catch (error) {
console.error("OAuth callback error:", error);
Expand Down
39 changes: 38 additions & 1 deletion client/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import CustomHeaders from "./CustomHeaders";
import { Switch } from "@/components/ui/switch";
import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders";
import { useToast } from "../lib/hooks/useToast";
import IconDisplay, { WithIcons } from "./IconDisplay";
Expand All @@ -60,6 +61,9 @@ interface SidebarProps {
// Custom headers support
customHeaders: CustomHeadersType;
setCustomHeaders: (headers: CustomHeadersType) => void;
// When on, custom headers are also applied to OAuth aux requests
applyHeadersToAllRequests: boolean;
setApplyHeadersToAllRequests: (value: boolean) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthClientSecret: string;
Expand Down Expand Up @@ -94,6 +98,8 @@ const Sidebar = ({
setEnv,
customHeaders,
setCustomHeaders,
applyHeadersToAllRequests,
setApplyHeadersToAllRequests,
oauthClientId,
setOauthClientId,
oauthClientSecret,
Expand Down Expand Up @@ -547,11 +553,42 @@ const Sidebar = ({
{showAuthConfig && (
<>
{/* Custom Headers Section */}
<div className="p-3 rounded border overflow-hidden">
<div className="p-3 rounded border overflow-hidden space-y-3">
<CustomHeaders
headers={customHeaders}
onChange={setCustomHeaders}
/>
{/* Opt-in: also send the custom headers on the OAuth
auxiliary requests (discovery, registration, token). */}
<div className="flex items-start gap-2 pt-2 border-t">
<Switch
checked={applyHeadersToAllRequests}
onCheckedChange={setApplyHeadersToAllRequests}
className="shrink-0 mt-0.5"
data-testid="apply-headers-to-all-requests-switch"
aria-label="Apply custom headers to OAuth requests"
/>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
Also apply to OAuth requests
</p>
<p className="text-xs text-muted-foreground">
Send these headers on the OAuth metadata discovery,
client registration, and token requests too — not just
the MCP transport. Needed for servers behind a gateway
that gates access with a header, e.g.{" "}
<a
href="https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation"
target="_blank"
rel="noreferrer"
className="underline"
>
Vercel Deployment Protection
</a>
.
</p>
</div>
</div>
</div>
{transportType !== "stdio" && (
// OAuth Configuration
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ describe("Sidebar", () => {
setEnv: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
applyHeadersToAllRequests: false,
setApplyHeadersToAllRequests: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
Expand Down
Loading
Loading