From 6642b3fad25a69431e270562890d476176abeb3c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:28:39 +0000 Subject: [PATCH 1/3] feat: apply configurable headers & query params to all requests (incl. OAuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an "Apply to All Requests" affordance that injects a configurable set of extra HTTP headers and/or query params into every outbound request to the target MCP server — not just the MCP transport POSTs, but also the OAuth auxiliary calls (protected-resource / authorization-server metadata discovery, dynamic client registration, and the token exchange). This unblocks connecting to MCP servers deployed behind a gateway that gates access with a header or query param (e.g. Vercel Deployment Protection's x-vercel-protection-bypass). Previously custom headers only reached the transport, so OAuth discovery/DCR/token requests hit the gateway's auth wall and the flow never learned the real authorization server. Implementation: - New shared fetch wrapper (client/src/lib/requestExtras.ts) that injects the extras in one place; threaded into the transport (direct mode) and into the SDK auth methods (fetchFn) so it works in both "Via Proxy" and direct modes. - Query params are embedded into the proxy `url=` target for proxy mode and appended to the URL for direct mode; header extras flow through the existing custom-header plumbing (forwarded by the proxy via x-custom-auth-headers). - OAuthCallback now builds the same proxy/extras-aware fetchFn for the token exchange, fixing the OAuth-bypasses-Via-Proxy gap (#995). - UI: new ApplyToAllRequests section in the sidebar (masked values). - Headless: server CLI flags (--header, --query-param) and env vars (MCP_REQUEST_HEADERS, MCP_REQUEST_QUERY_PARAMS) surfaced via /config; the `mcp-inspector` launcher forwards them; the `--cli` invoker gains --query-param (appended to the server URL on every request). - Default behavior is unchanged when no extras are configured; no secrets logged. Tests: unit tests for the shared wrapper (transport + OAuth discovery/DCR/token injection and the no-op default) and the query-param type helpers, plus CLI integration tests asserting query-param injection reaches the server. Refs: #879, #995, #752, #817 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012FAFCf8AzpqyNF1oKZNGzh --- README.md | 75 +++++ cli/__tests__/helpers/test-server-http.ts | 4 + cli/__tests__/queryParams.test.ts | 171 ++++++++++ cli/src/cli.ts | 55 ++++ cli/src/index.ts | 52 +++- client/src/App.tsx | 51 +++ client/src/components/ApplyToAllRequests.tsx | 292 ++++++++++++++++++ client/src/components/AuthDebugger.tsx | 30 +- client/src/components/OAuthCallback.tsx | 31 ++ client/src/components/Sidebar.tsx | 20 ++ .../src/components/__tests__/Sidebar.test.tsx | 4 + .../src/lib/__tests__/requestExtras.test.ts | 176 +++++++++++ client/src/lib/extrasStorage.ts | 41 +++ client/src/lib/hooks/useConnection.ts | 60 +++- client/src/lib/requestExtras.ts | 136 ++++++++ client/src/lib/types/customQueryParams.ts | 41 +++ .../utils/__tests__/customQueryParams.test.ts | 44 +++ server/src/index.ts | 88 ++++++ 18 files changed, 1357 insertions(+), 14 deletions(-) create mode 100644 cli/__tests__/queryParams.test.ts create mode 100644 client/src/components/ApplyToAllRequests.tsx create mode 100644 client/src/lib/__tests__/requestExtras.test.ts create mode 100644 client/src/lib/extrasStorage.ts create mode 100644 client/src/lib/requestExtras.ts create mode 100644 client/src/lib/types/customQueryParams.ts create mode 100644 client/src/utils/__tests__/customQueryParams.test.ts diff --git a/README.md b/README.md index 36e9f3dee5..770eef1469 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,81 @@ 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 to All Requests (headers & query params, incl. OAuth) + +The **Custom Headers** field is 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 or query param (for example [Vercel Deployment +Protection](https://vercel.com/docs/deployment-protection), which accepts either +`x-vercel-protection-bypass: ` as a header **or** +`?x-vercel-protection-bypass=` / `?_vercel_share=` as a query +param on any URL). 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 **Apply to All Requests** section (in the sidebar's _Authentication_ panel) +solves this. Any headers and/or query params configured there are injected into +**every** outbound request to the target server: + +- the MCP transport requests, and +- the OAuth auxiliary calls (metadata discovery, DCR, and token exchange). + +A query param is often the more robust bypass than a header, because the gateway +honors it on any URL and it survives the SDK's internal URL derivation once +injected at the fetch layer. Both headers and query params are supported. + +Injection happens in a single shared fetch wrapper that is threaded into the +transport and 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 no extras are configured, behavior is unchanged. Values are treated as +secrets in the UI (masked) and are never logged. + +#### Configuring for headless / CLI use + +The same defaults 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: + +```bash +# Repeatable CLI flags (UI mode). --header uses "Name: Value"; --query-param uses "key=value" +mcp-inspector \ + --header "x-vercel-protection-bypass: $BYPASS_SECRET" \ + --query-param "x-vercel-protection-bypass=$BYPASS_SECRET" + +# Or via environment variables (JSON objects) — handy for Docker +MCP_REQUEST_HEADERS='{"x-vercel-protection-bypass":"'"$BYPASS_SECRET"'"}' \ +MCP_REQUEST_QUERY_PARAMS='{"x-vercel-protection-bypass":"'"$BYPASS_SECRET"'"}' \ + mcp-inspector +``` + +In pure [CLI Mode](#cli-mode) (headless `--cli`, no OAuth), use `--query-param` +to append params to the server URL on every request (the existing `--header` +flag already covers headers): + +```bash +mcp-inspector --cli https://your-server.example.com/mcp --method tools/list \ + --query-param "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 (Vercel project → _Settings → Deployment + Protection → Protection Bypass for Automation_), or a `_vercel_share` token. +2. Start the inspector (proxy mode is fine) and open your protected server URL. +3. In the sidebar → _Authentication → Apply to All Requests_, add a query param + `x-vercel-protection-bypass = ` (and/or the matching header). +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. + ### 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. diff --git a/cli/__tests__/helpers/test-server-http.ts b/cli/__tests__/helpers/test-server-http.ts index d5eadc3ff6..31853bc863 100644 --- a/cli/__tests__/helpers/test-server-http.ts +++ b/cli/__tests__/helpers/test-server-http.ts @@ -14,6 +14,7 @@ export interface RecordedRequest { method: string; params?: any; headers?: Record; + query?: Record; metadata?: Record; response: any; timestamp: number; @@ -67,6 +68,7 @@ export class TestServerHttp { private transport?: StreamableHTTPServerTransport | SSEServerTransport; private url?: string; private currentRequestHeaders?: Record; + private currentRequestQuery?: Record; private currentLogLevel: string | null = null; constructor(config: ServerConfig) { @@ -225,6 +227,7 @@ export class TestServerHttp { app.post("/mcp", async (req: Request, res: Response) => { // Capture headers for this request this.currentRequestHeaders = extractHeaders(req); + this.currentRequestQuery = { ...req.query } as Record; try { await (this.transport as StreamableHTTPServerTransport).handleRequest( @@ -268,6 +271,7 @@ export class TestServerHttp { method, params, headers: { ...this.currentRequestHeaders }, + query: { ...this.currentRequestQuery }, metadata: metadata ? { ...metadata } : undefined, response: { processed: true }, timestamp, diff --git a/cli/__tests__/queryParams.test.ts b/cli/__tests__/queryParams.test.ts new file mode 100644 index 0000000000..45b1a69faf --- /dev/null +++ b/cli/__tests__/queryParams.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers/cli-runner.js"; +import { + expectCliFailure, + expectCliSuccess, + expectOutputContains, +} from "./helpers/assertions.js"; +import { createTestServerHttp } from "./helpers/test-server-http.js"; +import { + createEchoTool, + createTestServerInfo, +} from "./helpers/test-fixtures.js"; + +describe("Query Param Injection (--query-param)", () => { + it("appends a single query param to every request to the server", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start("http"); + const serverUrl = `${server.getUrl()}/mcp`; + + const result = await runCli([ + serverUrl, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--query-param", + "x-vercel-protection-bypass=secret123", + ]); + + expectCliSuccess(result); + + const recordedRequests = server.getRecordedRequests(); + expect(recordedRequests.length).toBeGreaterThan(0); + // Every recorded request should carry the injected query param. + for (const req of recordedRequests) { + expect(req.query?.["x-vercel-protection-bypass"]).toBe("secret123"); + } + } finally { + await server.stop(); + } + }); + + it("appends multiple query params", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start("http"); + const serverUrl = `${server.getUrl()}/mcp`; + + const result = await runCli([ + serverUrl, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--query-param", + "x-vercel-protection-bypass=secret123", + "--query-param", + "_vercel_share=token456", + ]); + + expectCliSuccess(result); + + const recordedRequests = server.getRecordedRequests(); + const last = recordedRequests[recordedRequests.length - 1]; + expect(last.query?.["x-vercel-protection-bypass"]).toBe("secret123"); + expect(last.query?.["_vercel_share"]).toBe("token456"); + } finally { + await server.stop(); + } + }); + + it("preserves existing query params already on the URL", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start("http"); + const serverUrl = `${server.getUrl()}/mcp?existing=keep`; + + const result = await runCli([ + serverUrl, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--query-param", + "injected=added", + ]); + + expectCliSuccess(result); + + const recordedRequests = server.getRecordedRequests(); + const last = recordedRequests[recordedRequests.length - 1]; + expect(last.query?.["existing"]).toBe("keep"); + expect(last.query?.["injected"]).toBe("added"); + } finally { + await server.stop(); + } + }); + + it("no query params are added by default (no-op)", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start("http"); + const serverUrl = `${server.getUrl()}/mcp`; + + const result = await runCli([ + serverUrl, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + ]); + + expectCliSuccess(result); + + const recordedRequests = server.getRecordedRequests(); + const last = recordedRequests[recordedRequests.length - 1]; + expect(last.query).toEqual({}); + } finally { + await server.stop(); + } + }); + + it("rejects a malformed query param (missing '=')", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start("http"); + const serverUrl = `${server.getUrl()}/mcp`; + + const result = await runCli([ + serverUrl, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--query-param", + "malformed", + ]); + + expectCliFailure(result); + expectOutputContains(result, "Invalid query param format"); + } finally { + await server.stop(); + } + }); +}); diff --git a/cli/src/cli.ts b/cli/src/cli.ts index f4187e02d7..3ec7f6b364 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -17,6 +17,7 @@ type Args = { transport?: "stdio" | "sse" | "streamable-http"; serverUrl?: string; headers?: Record; + queryParams?: Record; }; type CliOptions = { @@ -27,6 +28,7 @@ type CliOptions = { transport?: string; serverUrl?: string; header?: Record; + queryParam?: Record; }; type ServerConfig = @@ -97,6 +99,18 @@ async function runWebClient(args: Args): Promise { startArgs.push("--server-url", args.serverUrl); } + // Pass "apply to all requests" defaults so the web UI is preconfigured + if (args.headers) { + for (const [key, value] of Object.entries(args.headers)) { + startArgs.push("--header", `${key}: ${value}`); + } + } + if (args.queryParams) { + for (const [key, value] of Object.entries(args.queryParams)) { + startArgs.push("--query-param", `${key}=${value}`); + } + } + // Pass command and args (using -- to separate them) if (args.command) { startArgs.push("--", args.command, ...args.args); @@ -151,6 +165,13 @@ async function runCli(args: Args): Promise { } } + // Add query params if specified + if (args.queryParams) { + for (const [key, value] of Object.entries(args.queryParams)) { + cliArgs.push("--query-param", `${key}=${value}`); + } + } + await spawnPromise("node", cliArgs, { env: { ...process.env, ...args.envArgs }, signal: abort.signal, @@ -242,6 +263,30 @@ function parseHeaderPair( return { ...previous, [key]: val }; } +function parseQueryParamPair( + value: string, + previous: Record = {}, +): Record { + const eqIndex = value.indexOf("="); + + if (eqIndex === -1) { + throw new Error( + `Invalid query param format: ${value}. Use "key=value" format.`, + ); + } + + const key = value.slice(0, eqIndex).trim(); + const val = value.slice(eqIndex + 1).trim(); + + if (key === "") { + throw new Error( + `Invalid query param format: ${value}. Use "key=value" format.`, + ); + } + + return { ...previous, [key]: val }; +} + function parseArgs(): Args { const program = new Command(); @@ -274,6 +319,12 @@ function parseArgs(): Args { 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', parseHeaderPair, {}, + ) + .option( + "--query-param ", + 'Query params as "key=value" pairs applied to every request (for HTTP/SSE transports)', + parseQueryParamPair, + {}, ); // Parse only the arguments before -- @@ -328,6 +379,7 @@ function parseArgs(): Args { cli: options.cli || false, transport: "stdio", headers: options.header, + queryParams: options.queryParam, }; } else if (config.type === "sse" || config.type === "streamable-http") { return { @@ -338,6 +390,7 @@ function parseArgs(): Args { transport: config.type, serverUrl: config.url, headers: options.header, + queryParams: options.queryParam, }; } else { // Backwards compatibility: if no type field, assume stdio @@ -348,6 +401,7 @@ function parseArgs(): Args { cli: options.cli || false, transport: "stdio", headers: options.header, + queryParams: options.queryParam, }; } } @@ -370,6 +424,7 @@ function parseArgs(): Args { transport: transport as "stdio" | "sse" | "streamable-http" | undefined, serverUrl: options.serverUrl, headers: options.header, + queryParams: options.queryParam, }; } diff --git a/cli/src/index.ts b/cli/src/index.ts index 45a71a052c..7b12ba2bc6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -44,6 +44,7 @@ type Args = { toolMeta?: Record; transport?: "sse" | "stdio" | "http"; headers?: Record; + queryParams?: Record; metadata?: Record; }; @@ -51,6 +52,7 @@ function createTransportOptions( target: string[], transport?: "sse" | "stdio" | "http", headers?: Record, + queryParams?: Record, ): TransportOptions { if (target.length === 0) { throw new Error( @@ -70,6 +72,17 @@ function createTransportOptions( throw new Error("Arguments cannot be passed to a URL-based MCP server."); } + // Append "apply to all requests" query params to the URL so they reach the + // target server on every request (e.g. a gateway bypass param). + let effectiveCommand = command; + if (isUrl && queryParams && Object.keys(queryParams).length > 0) { + const parsed = new URL(command); + for (const [key, value] of Object.entries(queryParams)) { + parsed.searchParams.set(key, value); + } + effectiveCommand = parsed.toString(); + } + let transportType: "sse" | "stdio" | "http"; if (transport) { if (!isUrl && transport !== "stdio") { @@ -96,7 +109,7 @@ function createTransportOptions( transportType, command: isUrl ? undefined : command, args: isUrl ? undefined : commandArgs, - url: isUrl ? command : undefined, + url: isUrl ? effectiveCommand : undefined, headers, }; } @@ -115,6 +128,7 @@ async function callMethod(args: Args): Promise { args.target, args.transport, args.headers, + args.queryParams, ); const transport = createTransport(transportOptions); @@ -253,6 +267,30 @@ function parseHeaderPair( return { ...previous, [key]: val }; } +function parseQueryParamPair( + value: string, + previous: Record = {}, +): Record { + const eqIndex = value.indexOf("="); + + if (eqIndex === -1) { + throw new Error( + `Invalid query param format: ${value}. Use "key=value" format.`, + ); + } + + const key = value.slice(0, eqIndex).trim(); + const val = value.slice(eqIndex + 1).trim(); + + if (key === "") { + throw new Error( + `Invalid query param format: ${value}. Use "key=value" format.`, + ); + } + + return { ...previous, [key]: val }; +} + function parseArgs(): Args { const program = new Command(); @@ -343,6 +381,16 @@ function parseArgs(): Args { {}, ) // + // Query params applied to every request (for HTTP/SSE transports). + // Useful for gateways that gate access with a query param. + // + .option( + "--query-param ", + 'Query params as "key=value" pairs appended to the server URL (for HTTP/SSE transports)', + parseQueryParamPair, + {}, + ) + // // Metadata options // .option( @@ -363,6 +411,7 @@ function parseArgs(): Args { const options = program.opts() as Omit & { header?: Record; + queryParam?: Record; metadata?: Record; toolMetadata?: Record; }; @@ -382,6 +431,7 @@ function parseArgs(): Args { target: finalArgs, ...options, headers: options.header, // commander.js uses 'header' field, map to 'headers' + queryParams: options.queryParam, // commander.js uses 'queryParam', map to 'queryParams' metadata: options.metadata ? Object.fromEntries( Object.entries(options.metadata).map(([key, value]) => [ diff --git a/client/src/App.tsx b/client/src/App.tsx index 7cf6d751ab..2413c6d488 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -99,6 +99,13 @@ import { CustomHeaders, migrateFromLegacyAuth, } from "./lib/types/customHeaders"; +import { CustomQueryParams } from "./lib/types/customQueryParams"; +import { + EXTRA_HEADERS_STORAGE_KEY, + EXTRA_QUERY_PARAMS_STORAGE_KEY, + loadExtraHeaders, + loadExtraQueryParams, +} from "./lib/extrasStorage"; import MetadataTab from "./components/MetadataTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -247,6 +254,15 @@ const App = () => { ]; }); + // "Apply to all requests" extras — headers and query params applied to every + // request to the target server, including the OAuth auxiliary calls. + const [extraHeaders, setExtraHeaders] = useState(() => + loadExtraHeaders(), + ); + const [extraQueryParams, setExtraQueryParams] = useState( + () => loadExtraQueryParams(), + ); + const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -396,6 +412,8 @@ const App = () => { sseUrl, env, customHeaders, + extraHeaders, + extraQueryParams, oauthClientId, oauthClientSecret, oauthScope, @@ -570,6 +588,20 @@ const App = () => { localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders)); }, [customHeaders]); + useEffect(() => { + localStorage.setItem( + EXTRA_HEADERS_STORAGE_KEY, + JSON.stringify(extraHeaders), + ); + }, [extraHeaders]); + + useEffect(() => { + localStorage.setItem( + EXTRA_QUERY_PARAMS_STORAGE_KEY, + JSON.stringify(extraQueryParams), + ); + }, [extraQueryParams]); + // Auto-migrate from legacy auth when custom headers are empty but legacy auth exists useEffect(() => { if (customHeaders.length === 0 && (bearerToken || headerName)) { @@ -739,6 +771,19 @@ const App = () => { if (data.defaultServerUrl) { setSseUrl(data.defaultServerUrl); } + // CLI/env defaults for the "apply to all requests" extras (headless use) + if ( + Array.isArray(data.defaultExtraHeaders) && + data.defaultExtraHeaders.length > 0 + ) { + setExtraHeaders(data.defaultExtraHeaders); + } + if ( + Array.isArray(data.defaultExtraQueryParams) && + data.defaultExtraQueryParams.length > 0 + ) { + setExtraQueryParams(data.defaultExtraQueryParams); + } }) .catch((error) => console.error("Error fetching default environment:", error), @@ -1340,6 +1385,8 @@ const App = () => { updateAuthState={updateAuthState} config={config} connectionType={connectionType} + extraHeaders={extraHeaders} + extraQueryParams={extraQueryParams} /> ); @@ -1393,6 +1440,10 @@ const App = () => { setConfig={setConfig} customHeaders={customHeaders} setCustomHeaders={setCustomHeaders} + extraHeaders={extraHeaders} + setExtraHeaders={setExtraHeaders} + extraQueryParams={extraQueryParams} + setExtraQueryParams={setExtraQueryParams} oauthClientId={oauthClientId} setOauthClientId={setOauthClientId} oauthClientSecret={oauthClientSecret} diff --git a/client/src/components/ApplyToAllRequests.tsx b/client/src/components/ApplyToAllRequests.tsx new file mode 100644 index 0000000000..b1e0322d8d --- /dev/null +++ b/client/src/components/ApplyToAllRequests.tsx @@ -0,0 +1,292 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Plus, Trash2, Eye, EyeOff } from "lucide-react"; +import { + CustomHeaders, + CustomHeader, + createEmptyHeader, +} from "@/lib/types/customHeaders"; +import { + CustomQueryParams, + CustomQueryParam, + createEmptyQueryParam, +} from "@/lib/types/customQueryParams"; + +interface ApplyToAllRequestsProps { + headers: CustomHeaders; + onHeadersChange: (headers: CustomHeaders) => void; + queryParams: CustomQueryParams; + onQueryParamsChange: (params: CustomQueryParams) => void; + className?: string; +} + +/** + * Editor for the "apply to all requests" extras: extra HTTP headers and extra + * query params that are attached to every outbound request to the target MCP + * server, including the OAuth auxiliary calls (metadata discovery, dynamic + * client registration, and token exchange). This is the affordance for + * reaching servers behind a gateway that gates access with a header or query + * param (e.g. Vercel Deployment Protection's `x-vercel-protection-bypass`). + */ +const ApplyToAllRequests = ({ + headers: headersProp, + onHeadersChange, + queryParams: queryParamsProp, + onQueryParamsChange, + className, +}: ApplyToAllRequestsProps) => { + const headers = headersProp ?? []; + const queryParams = queryParamsProp ?? []; + const [visibleHeaderValues, setVisibleHeaderValues] = useState>( + new Set(), + ); + const [visibleParamValues, setVisibleParamValues] = useState>( + new Set(), + ); + + const toggleVisibility = ( + index: number, + visible: Set, + setVisible: (next: Set) => void, + ) => { + const next = new Set(visible); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + setVisible(next); + }; + + const updateHeader = ( + index: number, + field: keyof CustomHeader, + value: string | boolean, + ) => { + const next = [...headers]; + next[index] = { ...next[index], [field]: value }; + onHeadersChange(next); + }; + + const updateParam = ( + index: number, + field: keyof CustomQueryParam, + value: string | boolean, + ) => { + const next = [...queryParams]; + next[index] = { ...next[index], [field]: value }; + onQueryParamsChange(next); + }; + + return ( +
+
+

Apply to All Requests

+

+ Headers and query params here are attached to every request to the + target server, including OAuth metadata discovery, client + registration, and token exchange. Useful for gateways such as Vercel + Deployment Protection. +

+
+ + {/* Extra headers */} +
+
+
Headers
+ +
+ {headers.length > 0 && ( +
+ {headers.map((header, index) => ( +
+ + updateHeader(index, "enabled", enabled) + } + className="shrink-0 mt-2" + /> +
+ + updateHeader(index, "name", e.target.value) + } + className="font-mono text-xs" + data-testid={`extra-header-name-input-${index}`} + /> +
+ + updateHeader(index, "value", e.target.value) + } + type={ + visibleHeaderValues.has(index) ? "text" : "password" + } + className="font-mono text-xs pr-8" + data-testid={`extra-header-value-input-${index}`} + /> + +
+
+ +
+ ))} +
+ )} +
+ + {/* Extra query params */} +
+
+
Query Parameters
+ +
+ {queryParams.length > 0 && ( +
+ {queryParams.map((param, index) => ( +
+ + updateParam(index, "enabled", enabled) + } + className="shrink-0 mt-2" + /> +
+ updateParam(index, "key", e.target.value)} + className="font-mono text-xs" + data-testid={`extra-query-param-name-input-${index}`} + /> +
+ + updateParam(index, "value", e.target.value) + } + type={visibleParamValues.has(index) ? "text" : "password"} + className="font-mono text-xs pr-8" + data-testid={`extra-query-param-value-input-${index}`} + /> + +
+
+ +
+ ))} +
+ )} +
+
+ ); +}; + +export default ApplyToAllRequests; diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 376817d671..e89989361d 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -6,9 +6,16 @@ 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 { + RequestExtras, + hasRequestExtras, + withRequestExtras, +} from "../lib/requestExtras"; import { SESSION_KEYS } from "../lib/constants"; import { validateRedirectUrl } from "@/utils/urlValidation"; import type { InspectorConfig } from "../lib/configurationTypes"; +import type { CustomHeaders } from "../lib/types/customHeaders"; +import type { CustomQueryParams } from "../lib/types/customQueryParams"; export interface AuthDebuggerProps { serverUrl: string; @@ -17,6 +24,8 @@ export interface AuthDebuggerProps { updateAuthState: (updates: Partial) => void; config?: InspectorConfig; connectionType?: "direct" | "proxy"; + extraHeaders?: CustomHeaders; + extraQueryParams?: CustomQueryParams; } interface StatusMessageProps { @@ -66,6 +75,8 @@ const AuthDebugger = ({ updateAuthState, config, connectionType, + extraHeaders, + extraQueryParams, }: AuthDebuggerProps) => { // Check for existing tokens on mount useEffect(() => { @@ -108,13 +119,22 @@ const AuthDebugger = ({ }); }, [serverUrl, updateAuthState]); - const fetchFn = useMemo( - () => + const fetchFn = useMemo(() => { + const extras: RequestExtras = { + headers: extraHeaders, + queryParams: extraQueryParams, + }; + const baseFetch = connectionType === "proxy" && config ? createProxyFetch(config) - : undefined, - [connectionType, config], - ); + : undefined; + // Inject the "apply to all requests" extras into every OAuth aux call + // (metadata discovery, DCR, token exchange). No extras → preserve the + // prior behavior exactly (undefined in direct mode → SDK default fetch). + return hasRequestExtras(extras) + ? withRequestExtras(baseFetch ?? fetch, extras) + : baseFetch; + }, [connectionType, config, extraHeaders, extraQueryParams]); const stateMachine = useMemo( () => new OAuthStateMachine(serverUrl, updateAuthState, fetchFn), diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index ccfd6d928e..95595d3d7a 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -7,6 +7,34 @@ import { generateOAuthErrorDescription, parseOAuthCallbackParams, } from "@/utils/oauthUtils.ts"; +import { createProxyFetch } from "../lib/proxyFetch"; +import { hasRequestExtras, withRequestExtras } from "../lib/requestExtras"; +import { loadRequestExtras } from "../lib/extrasStorage"; +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 +// "apply to all requests" extras) 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 header/param. +const buildCallbackFetchFn = (): typeof fetch | undefined => { + const connectionType = + (localStorage.getItem("lastConnectionType") as "direct" | "proxy") || + "proxy"; + const config = initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY); + const extras = loadRequestExtras(); + + const baseFetch = + connectionType === "proxy" ? createProxyFetch(config) : undefined; + + if (hasRequestExtras(extras)) { + return withRequestExtras(baseFetch ?? fetch, extras); + } + return baseFetch; +}; interface OAuthCallbackProps { onConnect: (serverUrl: string) => void; @@ -46,9 +74,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); diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 762678f22c..f562ad1750 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -40,7 +40,9 @@ import { TooltipContent, } from "@/components/ui/tooltip"; import CustomHeaders from "./CustomHeaders"; +import ApplyToAllRequests from "./ApplyToAllRequests"; import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders"; +import { CustomQueryParams as CustomQueryParamsType } from "@/lib/types/customQueryParams"; import { useToast } from "../lib/hooks/useToast"; import IconDisplay, { WithIcons } from "./IconDisplay"; import { validateRedirectUrl } from "@/utils/urlValidation"; @@ -60,6 +62,11 @@ interface SidebarProps { // Custom headers support customHeaders: CustomHeadersType; setCustomHeaders: (headers: CustomHeadersType) => void; + // "Apply to all requests" extras (also applied to OAuth aux calls) + extraHeaders: CustomHeadersType; + setExtraHeaders: (headers: CustomHeadersType) => void; + extraQueryParams: CustomQueryParamsType; + setExtraQueryParams: (params: CustomQueryParamsType) => void; oauthClientId: string; setOauthClientId: (id: string) => void; oauthClientSecret: string; @@ -94,6 +101,10 @@ const Sidebar = ({ setEnv, customHeaders, setCustomHeaders, + extraHeaders, + setExtraHeaders, + extraQueryParams, + setExtraQueryParams, oauthClientId, setOauthClientId, oauthClientSecret, @@ -553,6 +564,15 @@ const Sidebar = ({ onChange={setCustomHeaders} /> + {/* Apply to All Requests (headers + query params, incl. OAuth) */} +
+ +
{transportType !== "stdio" && ( // OAuth Configuration
diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 460161e59a..736d902b08 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -52,6 +52,10 @@ describe("Sidebar", () => { setEnv: jest.fn(), customHeaders: [], setCustomHeaders: jest.fn(), + extraHeaders: [], + setExtraHeaders: jest.fn(), + extraQueryParams: [], + setExtraQueryParams: jest.fn(), onConnect: jest.fn(), onDisconnect: jest.fn(), stdErrNotifications: [], diff --git a/client/src/lib/__tests__/requestExtras.test.ts b/client/src/lib/__tests__/requestExtras.test.ts new file mode 100644 index 0000000000..ed17281818 --- /dev/null +++ b/client/src/lib/__tests__/requestExtras.test.ts @@ -0,0 +1,176 @@ +import { + appendQueryParams, + extraHeadersRecord, + hasRequestExtras, + withRequestExtras, + RequestExtras, +} from "../requestExtras"; +import { CustomHeaders } from "../types/customHeaders"; +import { CustomQueryParams } from "../types/customQueryParams"; + +const bypassHeader: CustomHeaders = [ + { name: "x-vercel-protection-bypass", value: "secret123", enabled: true }, +]; +const bypassParam: CustomQueryParams = [ + { key: "x-vercel-protection-bypass", value: "secret123", enabled: true }, +]; + +describe("hasRequestExtras", () => { + it("is false for undefined / empty / all-disabled", () => { + expect(hasRequestExtras(undefined)).toBe(false); + expect(hasRequestExtras({})).toBe(false); + expect(hasRequestExtras({ headers: [], queryParams: [] })).toBe(false); + expect( + hasRequestExtras({ + headers: [{ name: "a", value: "b", enabled: false }], + queryParams: [{ key: "c", value: "d", enabled: false }], + }), + ).toBe(false); + // Enabled but blank name/value do not count. + expect( + hasRequestExtras({ + headers: [{ name: " ", value: " ", enabled: true }], + }), + ).toBe(false); + }); + + it("is true when an enabled header or param has content", () => { + expect(hasRequestExtras({ headers: bypassHeader })).toBe(true); + expect(hasRequestExtras({ queryParams: bypassParam })).toBe(true); + }); +}); + +describe("extraHeadersRecord", () => { + it("includes only enabled, non-blank headers (trimmed)", () => { + const headers: CustomHeaders = [ + { name: " X-A ", value: " 1 ", enabled: true }, + { name: "X-B", value: "2", enabled: false }, + { name: "", value: "3", enabled: true }, + ]; + expect(extraHeadersRecord(headers)).toEqual({ "X-A": "1" }); + }); +}); + +describe("appendQueryParams", () => { + it("returns the url unchanged when there are no enabled params", () => { + expect(appendQueryParams("https://mcp.example.com/mcp", [])).toBe( + "https://mcp.example.com/mcp", + ); + expect( + appendQueryParams("https://mcp.example.com/mcp", [ + { key: "k", value: "v", enabled: false }, + ]), + ).toBe("https://mcp.example.com/mcp"); + }); + + it("appends enabled params and preserves existing query", () => { + const out = appendQueryParams( + "https://mcp.example.com/mcp?foo=bar", + bypassParam, + ); + const url = new URL(out); + expect(url.searchParams.get("foo")).toBe("bar"); + expect(url.searchParams.get("x-vercel-protection-bypass")).toBe( + "secret123", + ); + }); + + it("is idempotent (uses set, not append)", () => { + const once = appendQueryParams("https://mcp.example.com/mcp", bypassParam); + const twice = appendQueryParams(once, bypassParam); + expect(twice).toBe(once); + expect( + new URL(twice).searchParams.getAll("x-vercel-protection-bypass"), + ).toEqual(["secret123"]); + }); + + it("leaves an unparseable url untouched", () => { + expect(appendQueryParams("not a url", bypassParam)).toBe("not a url"); + }); +}); + +describe("withRequestExtras (no-op default)", () => { + it("returns the exact same fetch reference when no extras are configured", () => { + const base = jest.fn() as unknown as typeof fetch; + expect(withRequestExtras(base, undefined)).toBe(base); + expect(withRequestExtras(base, { headers: [], queryParams: [] })).toBe( + base, + ); + }); +}); + +describe("withRequestExtras injection", () => { + const captured: { url: string; headers: Record }[] = []; + let base: jest.Mock; + + beforeEach(() => { + captured.length = 0; + base = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + const headers: Record = {}; + new Headers(init?.headers).forEach((v, k) => (headers[k] = v)); + captured.push({ url, headers }); + return new Response("{}", { status: 200 }); + }); + }); + + const extras: RequestExtras = { + headers: bypassHeader, + queryParams: bypassParam, + }; + + // The transport (direct mode) and every OAuth SDK call receive this wrapped + // fetch, so exercising it here covers transport + discovery + DCR + token. + it("injects header and query param for a string url", async () => { + const f = withRequestExtras(base, extras); + await f("https://mcp.example.com/.well-known/oauth-protected-resource"); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe( + "https://mcp.example.com/.well-known/oauth-protected-resource?x-vercel-protection-bypass=secret123", + ); + expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); + }); + + it("injects for a URL object and a Request object", async () => { + const f = withRequestExtras(base, extras); + await f(new URL("https://auth.example.com/register"), { method: "POST" }); + await f(new Request("https://auth.example.com/token", { method: "POST" })); + + expect(captured[0].url).toContain("x-vercel-protection-bypass=secret123"); + expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); + expect(captured[1].url).toContain("x-vercel-protection-bypass=secret123"); + expect(captured[1].headers["x-vercel-protection-bypass"]).toBe("secret123"); + }); + + it("does not clobber an existing header of the same name", async () => { + const f = withRequestExtras(base, { + headers: [ + { name: "Authorization", value: "Bearer INJECTED", enabled: true }, + ], + }); + await f("https://mcp.example.com/mcp", { + headers: { Authorization: "Bearer REAL" }, + }); + expect(captured[0].headers["authorization"]).toBe("Bearer REAL"); + }); + + it("only injects headers when only headers are configured", async () => { + const f = withRequestExtras(base, { headers: bypassHeader }); + await f("https://mcp.example.com/mcp"); + expect(captured[0].url).toBe("https://mcp.example.com/mcp"); + expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); + }); + + it("only injects params when only params are configured", async () => { + const f = withRequestExtras(base, { queryParams: bypassParam }); + await f("https://mcp.example.com/mcp"); + expect(captured[0].url).toContain("x-vercel-protection-bypass=secret123"); + expect(captured[0].headers["x-vercel-protection-bypass"]).toBeUndefined(); + }); +}); diff --git a/client/src/lib/extrasStorage.ts b/client/src/lib/extrasStorage.ts new file mode 100644 index 0000000000..3b36d7b5bd --- /dev/null +++ b/client/src/lib/extrasStorage.ts @@ -0,0 +1,41 @@ +import { CustomHeaders } from "./types/customHeaders"; +import { CustomQueryParams } from "./types/customQueryParams"; +import { RequestExtras } from "./requestExtras"; + +// localStorage keys for the "apply to all requests" extras. Kept here so both +// the main App and the standalone OAuth callback route read/write the same +// values (the callback runs as its own lazily-loaded page and can't share +// React state with the App). +export const EXTRA_HEADERS_STORAGE_KEY = "lastExtraHeaders"; +export const EXTRA_QUERY_PARAMS_STORAGE_KEY = "lastExtraQueryParams"; + +export const loadExtraHeaders = (): CustomHeaders => { + const saved = localStorage.getItem(EXTRA_HEADERS_STORAGE_KEY); + if (!saved) { + return []; + } + try { + const parsed = JSON.parse(saved); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +export const loadExtraQueryParams = (): CustomQueryParams => { + const saved = localStorage.getItem(EXTRA_QUERY_PARAMS_STORAGE_KEY); + if (!saved) { + return []; + } + try { + const parsed = JSON.parse(saved); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +export const loadRequestExtras = (): RequestExtras => ({ + headers: loadExtraHeaders(), + queryParams: loadExtraQueryParams(), +}); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index f0ec16cffd..e77b2b41e7 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -76,6 +76,13 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CustomHeaders } from "../types/customHeaders"; +import { CustomQueryParams } from "../types/customQueryParams"; +import { + RequestExtras, + appendQueryParams, + hasRequestExtras, + withRequestExtras, +} from "../requestExtras"; import { resolveRefsInMessage } from "@/utils/schemaUtils"; interface UseConnectionOptions { @@ -86,6 +93,10 @@ interface UseConnectionOptions { env: Record; // Custom headers support customHeaders?: CustomHeaders; + // "Apply to all requests" extras: headers and/or query params injected into + // every request to the target server, including the OAuth auxiliary calls. + extraHeaders?: CustomHeaders; + extraQueryParams?: CustomQueryParams; oauthClientId?: string; oauthClientSecret?: string; oauthScope?: string; @@ -111,6 +122,8 @@ export function useConnection({ sseUrl, env, customHeaders, + extraHeaders, + extraQueryParams, oauthClientId, oauthClientSecret, oauthScope, @@ -387,11 +400,25 @@ export function useConnection({ ); }; + // Extras ("apply to all requests") shared across the transport and OAuth flows. + const requestExtras: RequestExtras = { + headers: extraHeaders, + queryParams: extraQueryParams, + }; + const handleAuthError = async (error: unknown) => { if (isConnectionAuthError(error)) { let scope = oauthScope?.trim(); - const fetchFn = + // In proxy mode OAuth aux requests route through the inspector proxy to + // avoid CORS; in direct mode they use the browser fetch. Either way, wrap + // with the configured extras so the header/param reaches discovery, DCR + // and token requests. When no extras are set this preserves the exact + // prior behavior (undefined in direct mode → SDK default fetch). + const baseFetch = connectionType === "proxy" ? createProxyFetch(config) : undefined; + const fetchFn = hasRequestExtras(requestExtras) + ? withRequestExtras(baseFetch ?? fetch, requestExtras) + : baseFetch; if (!scope) { // Only discover resource metadata when we need to discover scopes @@ -498,8 +525,25 @@ export function useConnection({ // Create an auth provider with the current server URL const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - // Use custom headers (migration is handled in App.tsx) - let finalHeaders: CustomHeaders = customHeaders || []; + // Use custom headers (migration is handled in App.tsx). The + // "apply to all requests" extra headers are appended so they ride along + // with the transport requests too (in proxy mode they are forwarded via + // the x-custom-auth-headers mechanism below). + let finalHeaders: CustomHeaders = [ + ...(customHeaders || []), + ...(extraHeaders || []), + ]; + + // Target server URL with the "apply to all requests" query params + // injected. In proxy mode this string is embedded as the `url` search + // param the proxy connects to; in direct mode it becomes the transport + // URL directly. + const targetUrlWithParams = appendQueryParams(sseUrl, extraQueryParams); + // Fetch wrapper used for direct-mode transport requests so query params + // are also injected onto any URL the SDK derives internally. + const directFetch = withRequestExtras(fetch, { + queryParams: extraQueryParams, + }); const isEmptyAuthHeader = (header: CustomHeaders[number]) => header.name.trim().toLowerCase() === "authorization" && @@ -571,7 +615,7 @@ export function useConnection({ // Determine connection URL based on the connection type if (connectionType === "direct" && transportType !== "stdio") { // Direct connection - use the provided URL directly (not available for STDIO) - serverUrl = new URL(sseUrl); + serverUrl = new URL(targetUrlWithParams); const requestHeaders = { ...headers }; if (mcpSessionId) { @@ -587,7 +631,7 @@ export function useConnection({ url: string | URL | globalThis.Request, init?: RequestInit, ) => { - const response = await fetch(url, { + const response = await directFetch(url, { ...init, headers: requestHeaders, }); @@ -612,7 +656,7 @@ export function useConnection({ requestHeaders["Accept"] = "text/event-stream, application/json"; requestHeaders["Content-Type"] = "application/json"; - const response = await fetch(url, { + const response = await directFetch(url, { headers: requestHeaders, ...init, }); @@ -682,7 +726,7 @@ export function useConnection({ case "sse": { mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); + mcpProxyServerUrl.searchParams.append("url", targetUrlWithParams); const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS .value as string; @@ -713,7 +757,7 @@ export function useConnection({ case "streamable-http": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); + mcpProxyServerUrl.searchParams.append("url", targetUrlWithParams); transportOptions = { authProvider: serverAuthProvider, eventSourceInit: { diff --git a/client/src/lib/requestExtras.ts b/client/src/lib/requestExtras.ts new file mode 100644 index 0000000000..2f4f73c519 --- /dev/null +++ b/client/src/lib/requestExtras.ts @@ -0,0 +1,136 @@ +import { CustomHeaders, getEnabledHeaders } from "./types/customHeaders"; +import { + CustomQueryParams, + getEnabledQueryParams, +} from "./types/customQueryParams"; + +/** + * Extra headers and/or query parameters that should be attached to *every* + * outbound request to the target MCP server — the MCP transport requests as + * well as the OAuth auxiliary requests (protected-resource / authorization- + * server metadata discovery, dynamic client registration, and token exchange). + * + * This is the mechanism that lets the Inspector reach an MCP server deployed + * behind a gateway that gates access with a header or query param (e.g. Vercel + * Deployment Protection, which honors `x-vercel-protection-bypass` as either a + * header or a query param on any URL). + */ +export interface RequestExtras { + headers?: CustomHeaders; + queryParams?: CustomQueryParams; +} + +/** + * True when at least one enabled header or query param is configured. When + * false, callers should preserve their existing behavior exactly (no-op), so + * default flows are unaffected. + */ +export function hasRequestExtras(extras?: RequestExtras): boolean { + if (!extras) { + return false; + } + return ( + getEnabledHeaders(extras.headers ?? []).length > 0 || + getEnabledQueryParams(extras.queryParams ?? []).length > 0 + ); +} + +/** + * Builds a plain record of the enabled extra headers (trimmed name/value). + */ +export function extraHeadersRecord( + headers?: CustomHeaders, +): Record { + const record: Record = {}; + getEnabledHeaders(headers ?? []).forEach((header) => { + record[header.name.trim()] = header.value.trim(); + }); + return record; +} + +/** + * Appends the enabled query params to the given URL and returns the resulting + * URL string. Uses `set` so the operation is idempotent — reapplying the same + * params (e.g. the SDK re-deriving a URL that already carries them) does not + * accumulate duplicates. If the URL cannot be parsed the original value is + * returned unchanged. + */ +export function appendQueryParams( + url: string, + queryParams?: CustomQueryParams, +): string { + const params = getEnabledQueryParams(queryParams ?? []); + if (params.length === 0) { + return url; + } + try { + const parsed = new URL(url); + params.forEach((param) => { + parsed.searchParams.set(param.key.trim(), param.value.trim()); + }); + return parsed.toString(); + } catch { + // Not an absolute URL we can parse — leave it untouched. + return url; + } +} + +function resolveUrlString(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + // Request-like + return input.url; +} + +/** + * Wraps a base `fetch` implementation so that every request it issues gets the + * configured extra headers and query params injected. This is the single choke + * point used for both the MCP transport (direct mode) and the SDK OAuth calls, + * so header/param injection happens in exactly one place regardless of which + * layer issues the request. + * + * - Existing request headers always win over extras, so we never clobber an + * `Authorization` header or other request-specific headers. + * - Query params are applied with `set`, overriding any same-named param. + * - When no extras are configured, the base fetch is returned unchanged. + */ +export function withRequestExtras( + baseFetch: typeof fetch, + extras?: RequestExtras, +): typeof fetch { + if (!hasRequestExtras(extras)) { + return baseFetch; + } + + const headerRecord = extraHeadersRecord(extras?.headers); + const queryParams = extras?.queryParams; + + return async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = appendQueryParams(resolveUrlString(input), queryParams); + + // Merge headers from the incoming request/init, letting existing headers + // take precedence over the configured extras. + const merged = new Headers( + input instanceof Request ? input.headers : undefined, + ); + if (init?.headers) { + new Headers(init.headers).forEach((value, key) => { + merged.set(key, value); + }); + } + Object.entries(headerRecord).forEach(([name, value]) => { + if (!merged.has(name)) { + merged.set(name, value); + } + }); + + return baseFetch(url, { ...init, headers: merged }); + }; +} diff --git a/client/src/lib/types/customQueryParams.ts b/client/src/lib/types/customQueryParams.ts new file mode 100644 index 0000000000..2f80404766 --- /dev/null +++ b/client/src/lib/types/customQueryParams.ts @@ -0,0 +1,41 @@ +export interface CustomQueryParam { + key: string; + value: string; + enabled: boolean; +} + +export type CustomQueryParams = CustomQueryParam[]; + +export const createEmptyQueryParam = (): CustomQueryParam => ({ + key: "", + value: "", + enabled: true, +}); + +export const getEnabledQueryParams = ( + params: CustomQueryParams, +): CustomQueryParams => { + return params.filter( + (param) => param.enabled && param.key.trim() && param.value.trim(), + ); +}; + +export const queryParamsToRecord = ( + params: CustomQueryParams, +): Record => { + const record: Record = {}; + getEnabledQueryParams(params).forEach((param) => { + record[param.key.trim()] = param.value.trim(); + }); + return record; +}; + +export const recordToQueryParams = ( + record: Record, +): CustomQueryParams => { + return Object.entries(record).map(([key, value]) => ({ + key, + value, + enabled: true, + })); +}; diff --git a/client/src/utils/__tests__/customQueryParams.test.ts b/client/src/utils/__tests__/customQueryParams.test.ts new file mode 100644 index 0000000000..751e1d147b --- /dev/null +++ b/client/src/utils/__tests__/customQueryParams.test.ts @@ -0,0 +1,44 @@ +import { + createEmptyQueryParam, + getEnabledQueryParams, + queryParamsToRecord, + recordToQueryParams, + CustomQueryParams, +} from "@/lib/types/customQueryParams"; + +describe("customQueryParams helpers", () => { + it("createEmptyQueryParam returns an enabled blank param", () => { + expect(createEmptyQueryParam()).toEqual({ + key: "", + value: "", + enabled: true, + }); + }); + + it("getEnabledQueryParams filters disabled and blank entries", () => { + const params: CustomQueryParams = [ + { key: "a", value: "1", enabled: true }, + { key: "b", value: "2", enabled: false }, + { key: "", value: "3", enabled: true }, + { key: "c", value: "", enabled: true }, + ]; + expect(getEnabledQueryParams(params)).toEqual([ + { key: "a", value: "1", enabled: true }, + ]); + }); + + it("queryParamsToRecord trims keys/values of enabled params", () => { + const params: CustomQueryParams = [ + { key: " token ", value: " abc ", enabled: true }, + { key: "off", value: "x", enabled: false }, + ]; + expect(queryParamsToRecord(params)).toEqual({ token: "abc" }); + }); + + it("recordToQueryParams round-trips a record", () => { + expect(recordToQueryParams({ a: "1", b: "2" })).toEqual([ + { key: "a", value: "1", enabled: true }, + { key: "b", value: "2", enabled: true }, + ]); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 4c86bd791f..7d15177610 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -55,9 +55,95 @@ const { values } = parseArgs({ command: { type: "string", default: "" }, transport: { type: "string", default: "" }, "server-url": { type: "string", default: "" }, + // "Apply to all requests" defaults for headless use. Repeatable: + // --header "X-Name: value" --query-param "key=value" + header: { type: "string", multiple: true }, + "query-param": { type: "string", multiple: true }, }, }); +interface NameValueEntry { + name: string; + value: string; + enabled: boolean; +} + +interface KeyValueEntry { + key: string; + value: string; + enabled: boolean; +} + +/** + * Parses the "apply to all requests" default headers from CLI `--header` + * flags ("Name: value") and the `MCP_REQUEST_HEADERS` env var (a JSON object). + * These are surfaced to the client via GET /config so headless invocations can + * preconfigure the header/param that reaches every request (transport + OAuth). + */ +const parseDefaultExtraHeaders = (): NameValueEntry[] => { + const entries: NameValueEntry[] = []; + + const flagHeaders = (values.header as string[] | undefined) ?? []; + for (const raw of flagHeaders) { + const idx = raw.indexOf(":"); + if (idx === -1) continue; + const name = raw.slice(0, idx).trim(); + const value = raw.slice(idx + 1).trim(); + if (name) entries.push({ name, value, enabled: true }); + } + + if (process.env.MCP_REQUEST_HEADERS) { + try { + const parsed = JSON.parse(process.env.MCP_REQUEST_HEADERS); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + for (const [name, value] of Object.entries(parsed)) { + if (name) entries.push({ name, value: String(value), enabled: true }); + } + } + } catch (error) { + console.warn("Failed to parse MCP_REQUEST_HEADERS:", error); + } + } + + return entries; +}; + +/** + * Parses the "apply to all requests" default query params from CLI + * `--query-param` flags ("key=value") and the `MCP_REQUEST_QUERY_PARAMS` env + * var (a JSON object). + */ +const parseDefaultExtraQueryParams = (): KeyValueEntry[] => { + const entries: KeyValueEntry[] = []; + + const flagParams = (values["query-param"] as string[] | undefined) ?? []; + for (const raw of flagParams) { + const idx = raw.indexOf("="); + if (idx === -1) continue; + const key = raw.slice(0, idx).trim(); + const value = raw.slice(idx + 1).trim(); + if (key) entries.push({ key, value, enabled: true }); + } + + if (process.env.MCP_REQUEST_QUERY_PARAMS) { + try { + const parsed = JSON.parse(process.env.MCP_REQUEST_QUERY_PARAMS); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + for (const [key, value] of Object.entries(parsed)) { + if (key) entries.push({ key, value: String(value), enabled: true }); + } + } + } catch (error) { + console.warn("Failed to parse MCP_REQUEST_QUERY_PARAMS:", error); + } + } + + return entries; +}; + +const defaultExtraHeaders = parseDefaultExtraHeaders(); +const defaultExtraQueryParams = parseDefaultExtraQueryParams(); + /** * Helper function to detect 401 Unauthorized errors from various transport types. * StreamableHTTPClientTransport throws a generic Error with "HTTP 401" in the message @@ -1053,6 +1139,8 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { defaultArgs: values.args, defaultTransport: values.transport, defaultServerUrl: values["server-url"], + defaultExtraHeaders, + defaultExtraQueryParams, }); } catch (error) { console.error("Error in /config route:", error); From 24008f0f521789cb8c4d6f1365856a3222861313 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:42:07 +0000 Subject: [PATCH 2/3] refactor: collapse to a single "apply headers to OAuth requests" toggle Simplify the feature per review feedback: - Remove query-param support entirely. Headers alone cover the gateway-bypass use case (e.g. Vercel Deployment Protection accepts its bypass secret as a header), so the separate query-param list, plumbing, CLI/env flags, and tests are dropped. - Drop the separate "Apply to All Requests" header list (it duplicated Custom Headers). Instead, add a single "Also apply to OAuth requests" toggle beneath the existing Custom Headers. When on, the custom headers are injected into the OAuth auxiliary requests (metadata discovery, DCR, token exchange) in addition to the transport; when off (default), behavior is unchanged. - Replace the request-extras module with a focused header-only fetch wrapper (customHeaderFetch) threaded into the SDK auth methods, useConnection, AuthDebugger, and the OAuth callback route. - Headless: `--header` / MCP_REQUEST_HEADERS now feed the Custom Headers list and default the toggle on (opt out via MCP_APPLY_HEADERS_TO_ALL_REQUESTS=false), surfaced via GET /config. - README: document the toggle instead of a query param, and link Vercel's Protection Bypass for Automation docs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012FAFCf8AzpqyNF1oKZNGzh --- README.md | 90 +++--- cli/__tests__/helpers/test-server-http.ts | 4 - cli/__tests__/queryParams.test.ts | 171 ---------- cli/src/cli.ts | 50 +-- cli/src/index.ts | 52 +--- client/src/App.tsx | 63 ++-- client/src/components/ApplyToAllRequests.tsx | 292 ------------------ client/src/components/AuthDebugger.tsx | 31 +- client/src/components/OAuthCallback.tsx | 21 +- client/src/components/Sidebar.tsx | 59 ++-- .../src/components/__tests__/Sidebar.test.tsx | 6 +- .../lib/__tests__/customHeaderFetch.test.ts | 92 ++++++ .../src/lib/__tests__/requestExtras.test.ts | 176 ----------- client/src/lib/customHeaderFetch.ts | 65 ++++ client/src/lib/extrasStorage.ts | 41 --- client/src/lib/hooks/useConnection.ts | 68 ++-- client/src/lib/oauthHeaderStorage.ts | 36 +++ client/src/lib/requestExtras.ts | 136 -------- client/src/lib/types/customQueryParams.ts | 41 --- .../utils/__tests__/customQueryParams.test.ts | 44 --- server/src/index.ts | 69 ++--- 21 files changed, 368 insertions(+), 1239 deletions(-) delete mode 100644 cli/__tests__/queryParams.test.ts delete mode 100644 client/src/components/ApplyToAllRequests.tsx create mode 100644 client/src/lib/__tests__/customHeaderFetch.test.ts delete mode 100644 client/src/lib/__tests__/requestExtras.test.ts create mode 100644 client/src/lib/customHeaderFetch.ts delete mode 100644 client/src/lib/extrasStorage.ts create mode 100644 client/src/lib/oauthHeaderStorage.ts delete mode 100644 client/src/lib/requestExtras.ts delete mode 100644 client/src/lib/types/customQueryParams.ts delete mode 100644 client/src/utils/__tests__/customQueryParams.test.ts diff --git a/README.md b/README.md index 770eef1469..f2ebfa377d 100644 --- a/README.md +++ b/README.md @@ -170,63 +170,58 @@ 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 to All Requests (headers & query params, incl. OAuth) - -The **Custom Headers** field is 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 or query param (for example [Vercel Deployment -Protection](https://vercel.com/docs/deployment-protection), which accepts either -`x-vercel-protection-bypass: ` as a header **or** -`?x-vercel-protection-bypass=` / `?_vercel_share=` as a query -param on any URL). 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 **Apply to All Requests** section (in the sidebar's _Authentication_ panel) -solves this. Any headers and/or query params configured there are injected into -**every** outbound request to the target server: - -- the MCP transport requests, and +### 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: ` 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). -A query param is often the more robust bypass than a header, because the gateway -honors it on any URL and it survives the SDK's internal URL derivation once -injected at the fetch layer. Both headers and query params are supported. - Injection happens in a single shared fetch wrapper that is threaded into the -transport and 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 no extras are configured, behavior is unchanged. Values are treated as -secrets in the UI (masked) and are never logged. +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 same defaults can be preconfigured without the UI, via CLI flags or env +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: +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 flags (UI mode). --header uses "Name: Value"; --query-param uses "key=value" +# Repeatable CLI flag (UI mode). --header uses "Name: Value" mcp-inspector \ - --header "x-vercel-protection-bypass: $BYPASS_SECRET" \ - --query-param "x-vercel-protection-bypass=$BYPASS_SECRET" + --header "x-vercel-protection-bypass: $BYPASS_SECRET" -# Or via environment variables (JSON objects) — handy for Docker +# Or via environment variable (JSON object) — handy for Docker MCP_REQUEST_HEADERS='{"x-vercel-protection-bypass":"'"$BYPASS_SECRET"'"}' \ -MCP_REQUEST_QUERY_PARAMS='{"x-vercel-protection-bypass":"'"$BYPASS_SECRET"'"}' \ mcp-inspector ``` -In pure [CLI Mode](#cli-mode) (headless `--cli`, no OAuth), use `--query-param` -to append params to the server URL on every request (the existing `--header` -flag already covers headers): +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 \ - --query-param "x-vercel-protection-bypass=$BYPASS_SECRET" + --header "x-vercel-protection-bypass: $BYPASS_SECRET" ``` #### Running against a protected deployment @@ -236,15 +231,24 @@ behind Vercel Deployment Protection, while its OAuth authorization server (`/authorize`, `/token`, `/register`) lives on a **different, unprotected** host: -1. Grab a protection-bypass secret (Vercel project → _Settings → Deployment - Protection → Protection Bypass for Automation_), or a `_vercel_share` token. +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 → Apply to All Requests_, add a query param - `x-vercel-protection-bypass = ` (and/or the matching header). +3. In the sidebar → _Authentication_, add a Custom Header + `x-vercel-protection-bypass` = `` 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. diff --git a/cli/__tests__/helpers/test-server-http.ts b/cli/__tests__/helpers/test-server-http.ts index 31853bc863..d5eadc3ff6 100644 --- a/cli/__tests__/helpers/test-server-http.ts +++ b/cli/__tests__/helpers/test-server-http.ts @@ -14,7 +14,6 @@ export interface RecordedRequest { method: string; params?: any; headers?: Record; - query?: Record; metadata?: Record; response: any; timestamp: number; @@ -68,7 +67,6 @@ export class TestServerHttp { private transport?: StreamableHTTPServerTransport | SSEServerTransport; private url?: string; private currentRequestHeaders?: Record; - private currentRequestQuery?: Record; private currentLogLevel: string | null = null; constructor(config: ServerConfig) { @@ -227,7 +225,6 @@ export class TestServerHttp { app.post("/mcp", async (req: Request, res: Response) => { // Capture headers for this request this.currentRequestHeaders = extractHeaders(req); - this.currentRequestQuery = { ...req.query } as Record; try { await (this.transport as StreamableHTTPServerTransport).handleRequest( @@ -271,7 +268,6 @@ export class TestServerHttp { method, params, headers: { ...this.currentRequestHeaders }, - query: { ...this.currentRequestQuery }, metadata: metadata ? { ...metadata } : undefined, response: { processed: true }, timestamp, diff --git a/cli/__tests__/queryParams.test.ts b/cli/__tests__/queryParams.test.ts deleted file mode 100644 index 45b1a69faf..0000000000 --- a/cli/__tests__/queryParams.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { runCli } from "./helpers/cli-runner.js"; -import { - expectCliFailure, - expectCliSuccess, - expectOutputContains, -} from "./helpers/assertions.js"; -import { createTestServerHttp } from "./helpers/test-server-http.js"; -import { - createEchoTool, - createTestServerInfo, -} from "./helpers/test-fixtures.js"; - -describe("Query Param Injection (--query-param)", () => { - it("appends a single query param to every request to the server", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--cli", - "--method", - "tools/list", - "--transport", - "http", - "--query-param", - "x-vercel-protection-bypass=secret123", - ]); - - expectCliSuccess(result); - - const recordedRequests = server.getRecordedRequests(); - expect(recordedRequests.length).toBeGreaterThan(0); - // Every recorded request should carry the injected query param. - for (const req of recordedRequests) { - expect(req.query?.["x-vercel-protection-bypass"]).toBe("secret123"); - } - } finally { - await server.stop(); - } - }); - - it("appends multiple query params", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--cli", - "--method", - "tools/list", - "--transport", - "http", - "--query-param", - "x-vercel-protection-bypass=secret123", - "--query-param", - "_vercel_share=token456", - ]); - - expectCliSuccess(result); - - const recordedRequests = server.getRecordedRequests(); - const last = recordedRequests[recordedRequests.length - 1]; - expect(last.query?.["x-vercel-protection-bypass"]).toBe("secret123"); - expect(last.query?.["_vercel_share"]).toBe("token456"); - } finally { - await server.stop(); - } - }); - - it("preserves existing query params already on the URL", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp?existing=keep`; - - const result = await runCli([ - serverUrl, - "--cli", - "--method", - "tools/list", - "--transport", - "http", - "--query-param", - "injected=added", - ]); - - expectCliSuccess(result); - - const recordedRequests = server.getRecordedRequests(); - const last = recordedRequests[recordedRequests.length - 1]; - expect(last.query?.["existing"]).toBe("keep"); - expect(last.query?.["injected"]).toBe("added"); - } finally { - await server.stop(); - } - }); - - it("no query params are added by default (no-op)", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--cli", - "--method", - "tools/list", - "--transport", - "http", - ]); - - expectCliSuccess(result); - - const recordedRequests = server.getRecordedRequests(); - const last = recordedRequests[recordedRequests.length - 1]; - expect(last.query).toEqual({}); - } finally { - await server.stop(); - } - }); - - it("rejects a malformed query param (missing '=')", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--cli", - "--method", - "tools/list", - "--transport", - "http", - "--query-param", - "malformed", - ]); - - expectCliFailure(result); - expectOutputContains(result, "Invalid query param format"); - } finally { - await server.stop(); - } - }); -}); diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 3ec7f6b364..16cd765268 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -17,7 +17,6 @@ type Args = { transport?: "stdio" | "sse" | "streamable-http"; serverUrl?: string; headers?: Record; - queryParams?: Record; }; type CliOptions = { @@ -28,7 +27,6 @@ type CliOptions = { transport?: string; serverUrl?: string; header?: Record; - queryParam?: Record; }; type ServerConfig = @@ -99,17 +97,12 @@ async function runWebClient(args: Args): Promise { startArgs.push("--server-url", args.serverUrl); } - // Pass "apply to all requests" defaults so the web UI is preconfigured + // 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}`); } } - if (args.queryParams) { - for (const [key, value] of Object.entries(args.queryParams)) { - startArgs.push("--query-param", `${key}=${value}`); - } - } // Pass command and args (using -- to separate them) if (args.command) { @@ -165,13 +158,6 @@ async function runCli(args: Args): Promise { } } - // Add query params if specified - if (args.queryParams) { - for (const [key, value] of Object.entries(args.queryParams)) { - cliArgs.push("--query-param", `${key}=${value}`); - } - } - await spawnPromise("node", cliArgs, { env: { ...process.env, ...args.envArgs }, signal: abort.signal, @@ -263,30 +249,6 @@ function parseHeaderPair( return { ...previous, [key]: val }; } -function parseQueryParamPair( - value: string, - previous: Record = {}, -): Record { - const eqIndex = value.indexOf("="); - - if (eqIndex === -1) { - throw new Error( - `Invalid query param format: ${value}. Use "key=value" format.`, - ); - } - - const key = value.slice(0, eqIndex).trim(); - const val = value.slice(eqIndex + 1).trim(); - - if (key === "") { - throw new Error( - `Invalid query param format: ${value}. Use "key=value" format.`, - ); - } - - return { ...previous, [key]: val }; -} - function parseArgs(): Args { const program = new Command(); @@ -319,12 +281,6 @@ function parseArgs(): Args { 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', parseHeaderPair, {}, - ) - .option( - "--query-param ", - 'Query params as "key=value" pairs applied to every request (for HTTP/SSE transports)', - parseQueryParamPair, - {}, ); // Parse only the arguments before -- @@ -379,7 +335,6 @@ function parseArgs(): Args { cli: options.cli || false, transport: "stdio", headers: options.header, - queryParams: options.queryParam, }; } else if (config.type === "sse" || config.type === "streamable-http") { return { @@ -390,7 +345,6 @@ function parseArgs(): Args { transport: config.type, serverUrl: config.url, headers: options.header, - queryParams: options.queryParam, }; } else { // Backwards compatibility: if no type field, assume stdio @@ -401,7 +355,6 @@ function parseArgs(): Args { cli: options.cli || false, transport: "stdio", headers: options.header, - queryParams: options.queryParam, }; } } @@ -424,7 +377,6 @@ function parseArgs(): Args { transport: transport as "stdio" | "sse" | "streamable-http" | undefined, serverUrl: options.serverUrl, headers: options.header, - queryParams: options.queryParam, }; } diff --git a/cli/src/index.ts b/cli/src/index.ts index 7b12ba2bc6..45a71a052c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -44,7 +44,6 @@ type Args = { toolMeta?: Record; transport?: "sse" | "stdio" | "http"; headers?: Record; - queryParams?: Record; metadata?: Record; }; @@ -52,7 +51,6 @@ function createTransportOptions( target: string[], transport?: "sse" | "stdio" | "http", headers?: Record, - queryParams?: Record, ): TransportOptions { if (target.length === 0) { throw new Error( @@ -72,17 +70,6 @@ function createTransportOptions( throw new Error("Arguments cannot be passed to a URL-based MCP server."); } - // Append "apply to all requests" query params to the URL so they reach the - // target server on every request (e.g. a gateway bypass param). - let effectiveCommand = command; - if (isUrl && queryParams && Object.keys(queryParams).length > 0) { - const parsed = new URL(command); - for (const [key, value] of Object.entries(queryParams)) { - parsed.searchParams.set(key, value); - } - effectiveCommand = parsed.toString(); - } - let transportType: "sse" | "stdio" | "http"; if (transport) { if (!isUrl && transport !== "stdio") { @@ -109,7 +96,7 @@ function createTransportOptions( transportType, command: isUrl ? undefined : command, args: isUrl ? undefined : commandArgs, - url: isUrl ? effectiveCommand : undefined, + url: isUrl ? command : undefined, headers, }; } @@ -128,7 +115,6 @@ async function callMethod(args: Args): Promise { args.target, args.transport, args.headers, - args.queryParams, ); const transport = createTransport(transportOptions); @@ -267,30 +253,6 @@ function parseHeaderPair( return { ...previous, [key]: val }; } -function parseQueryParamPair( - value: string, - previous: Record = {}, -): Record { - const eqIndex = value.indexOf("="); - - if (eqIndex === -1) { - throw new Error( - `Invalid query param format: ${value}. Use "key=value" format.`, - ); - } - - const key = value.slice(0, eqIndex).trim(); - const val = value.slice(eqIndex + 1).trim(); - - if (key === "") { - throw new Error( - `Invalid query param format: ${value}. Use "key=value" format.`, - ); - } - - return { ...previous, [key]: val }; -} - function parseArgs(): Args { const program = new Command(); @@ -381,16 +343,6 @@ function parseArgs(): Args { {}, ) // - // Query params applied to every request (for HTTP/SSE transports). - // Useful for gateways that gate access with a query param. - // - .option( - "--query-param ", - 'Query params as "key=value" pairs appended to the server URL (for HTTP/SSE transports)', - parseQueryParamPair, - {}, - ) - // // Metadata options // .option( @@ -411,7 +363,6 @@ function parseArgs(): Args { const options = program.opts() as Omit & { header?: Record; - queryParam?: Record; metadata?: Record; toolMetadata?: Record; }; @@ -431,7 +382,6 @@ function parseArgs(): Args { target: finalArgs, ...options, headers: options.header, // commander.js uses 'header' field, map to 'headers' - queryParams: options.queryParam, // commander.js uses 'queryParam', map to 'queryParams' metadata: options.metadata ? Object.fromEntries( Object.entries(options.metadata).map(([key, value]) => [ diff --git a/client/src/App.tsx b/client/src/App.tsx index 2413c6d488..8229c44683 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -99,13 +99,10 @@ import { CustomHeaders, migrateFromLegacyAuth, } from "./lib/types/customHeaders"; -import { CustomQueryParams } from "./lib/types/customQueryParams"; import { - EXTRA_HEADERS_STORAGE_KEY, - EXTRA_QUERY_PARAMS_STORAGE_KEY, - loadExtraHeaders, - loadExtraQueryParams, -} from "./lib/extrasStorage"; + APPLY_HEADERS_TO_ALL_REQUESTS_STORAGE_KEY, + loadApplyHeadersToAllRequests, +} from "./lib/oauthHeaderStorage"; import MetadataTab from "./components/MetadataTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -254,14 +251,10 @@ const App = () => { ]; }); - // "Apply to all requests" extras — headers and query params applied to every - // request to the target server, including the OAuth auxiliary calls. - const [extraHeaders, setExtraHeaders] = useState(() => - loadExtraHeaders(), - ); - const [extraQueryParams, setExtraQueryParams] = useState( - () => loadExtraQueryParams(), - ); + // 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(() => loadApplyHeadersToAllRequests()); const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< @@ -412,8 +405,7 @@ const App = () => { sseUrl, env, customHeaders, - extraHeaders, - extraQueryParams, + applyHeadersToAllRequests, oauthClientId, oauthClientSecret, oauthScope, @@ -590,17 +582,10 @@ const App = () => { useEffect(() => { localStorage.setItem( - EXTRA_HEADERS_STORAGE_KEY, - JSON.stringify(extraHeaders), - ); - }, [extraHeaders]); - - useEffect(() => { - localStorage.setItem( - EXTRA_QUERY_PARAMS_STORAGE_KEY, - JSON.stringify(extraQueryParams), + APPLY_HEADERS_TO_ALL_REQUESTS_STORAGE_KEY, + String(applyHeadersToAllRequests), ); - }, [extraQueryParams]); + }, [applyHeadersToAllRequests]); // Auto-migrate from legacy auth when custom headers are empty but legacy auth exists useEffect(() => { @@ -771,18 +756,16 @@ const App = () => { if (data.defaultServerUrl) { setSseUrl(data.defaultServerUrl); } - // CLI/env defaults for the "apply to all requests" extras (headless use) + // CLI/env defaults for custom headers + the "apply to all requests" + // toggle (headless use). if ( - Array.isArray(data.defaultExtraHeaders) && - data.defaultExtraHeaders.length > 0 + Array.isArray(data.defaultCustomHeaders) && + data.defaultCustomHeaders.length > 0 ) { - setExtraHeaders(data.defaultExtraHeaders); + setCustomHeaders(data.defaultCustomHeaders); } - if ( - Array.isArray(data.defaultExtraQueryParams) && - data.defaultExtraQueryParams.length > 0 - ) { - setExtraQueryParams(data.defaultExtraQueryParams); + if (typeof data.defaultApplyHeadersToAllRequests === "boolean") { + setApplyHeadersToAllRequests(data.defaultApplyHeadersToAllRequests); } }) .catch((error) => @@ -1385,8 +1368,8 @@ const App = () => { updateAuthState={updateAuthState} config={config} connectionType={connectionType} - extraHeaders={extraHeaders} - extraQueryParams={extraQueryParams} + customHeaders={customHeaders} + applyHeadersToAllRequests={applyHeadersToAllRequests} /> ); @@ -1440,10 +1423,8 @@ const App = () => { setConfig={setConfig} customHeaders={customHeaders} setCustomHeaders={setCustomHeaders} - extraHeaders={extraHeaders} - setExtraHeaders={setExtraHeaders} - extraQueryParams={extraQueryParams} - setExtraQueryParams={setExtraQueryParams} + applyHeadersToAllRequests={applyHeadersToAllRequests} + setApplyHeadersToAllRequests={setApplyHeadersToAllRequests} oauthClientId={oauthClientId} setOauthClientId={setOauthClientId} oauthClientSecret={oauthClientSecret} diff --git a/client/src/components/ApplyToAllRequests.tsx b/client/src/components/ApplyToAllRequests.tsx deleted file mode 100644 index b1e0322d8d..0000000000 --- a/client/src/components/ApplyToAllRequests.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Plus, Trash2, Eye, EyeOff } from "lucide-react"; -import { - CustomHeaders, - CustomHeader, - createEmptyHeader, -} from "@/lib/types/customHeaders"; -import { - CustomQueryParams, - CustomQueryParam, - createEmptyQueryParam, -} from "@/lib/types/customQueryParams"; - -interface ApplyToAllRequestsProps { - headers: CustomHeaders; - onHeadersChange: (headers: CustomHeaders) => void; - queryParams: CustomQueryParams; - onQueryParamsChange: (params: CustomQueryParams) => void; - className?: string; -} - -/** - * Editor for the "apply to all requests" extras: extra HTTP headers and extra - * query params that are attached to every outbound request to the target MCP - * server, including the OAuth auxiliary calls (metadata discovery, dynamic - * client registration, and token exchange). This is the affordance for - * reaching servers behind a gateway that gates access with a header or query - * param (e.g. Vercel Deployment Protection's `x-vercel-protection-bypass`). - */ -const ApplyToAllRequests = ({ - headers: headersProp, - onHeadersChange, - queryParams: queryParamsProp, - onQueryParamsChange, - className, -}: ApplyToAllRequestsProps) => { - const headers = headersProp ?? []; - const queryParams = queryParamsProp ?? []; - const [visibleHeaderValues, setVisibleHeaderValues] = useState>( - new Set(), - ); - const [visibleParamValues, setVisibleParamValues] = useState>( - new Set(), - ); - - const toggleVisibility = ( - index: number, - visible: Set, - setVisible: (next: Set) => void, - ) => { - const next = new Set(visible); - if (next.has(index)) { - next.delete(index); - } else { - next.add(index); - } - setVisible(next); - }; - - const updateHeader = ( - index: number, - field: keyof CustomHeader, - value: string | boolean, - ) => { - const next = [...headers]; - next[index] = { ...next[index], [field]: value }; - onHeadersChange(next); - }; - - const updateParam = ( - index: number, - field: keyof CustomQueryParam, - value: string | boolean, - ) => { - const next = [...queryParams]; - next[index] = { ...next[index], [field]: value }; - onQueryParamsChange(next); - }; - - return ( -
-
-

Apply to All Requests

-

- Headers and query params here are attached to every request to the - target server, including OAuth metadata discovery, client - registration, and token exchange. Useful for gateways such as Vercel - Deployment Protection. -

-
- - {/* Extra headers */} -
-
-
Headers
- -
- {headers.length > 0 && ( -
- {headers.map((header, index) => ( -
- - updateHeader(index, "enabled", enabled) - } - className="shrink-0 mt-2" - /> -
- - updateHeader(index, "name", e.target.value) - } - className="font-mono text-xs" - data-testid={`extra-header-name-input-${index}`} - /> -
- - updateHeader(index, "value", e.target.value) - } - type={ - visibleHeaderValues.has(index) ? "text" : "password" - } - className="font-mono text-xs pr-8" - data-testid={`extra-header-value-input-${index}`} - /> - -
-
- -
- ))} -
- )} -
- - {/* Extra query params */} -
-
-
Query Parameters
- -
- {queryParams.length > 0 && ( -
- {queryParams.map((param, index) => ( -
- - updateParam(index, "enabled", enabled) - } - className="shrink-0 mt-2" - /> -
- updateParam(index, "key", e.target.value)} - className="font-mono text-xs" - data-testid={`extra-query-param-name-input-${index}`} - /> -
- - updateParam(index, "value", e.target.value) - } - type={visibleParamValues.has(index) ? "text" : "password"} - className="font-mono text-xs pr-8" - data-testid={`extra-query-param-value-input-${index}`} - /> - -
-
- -
- ))} -
- )} -
-
- ); -}; - -export default ApplyToAllRequests; diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index e89989361d..2e6e83a03a 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -7,15 +7,13 @@ import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthStateMachine } from "../lib/oauth-state-machine"; import { createProxyFetch } from "../lib/proxyFetch"; import { - RequestExtras, - hasRequestExtras, - withRequestExtras, -} from "../lib/requestExtras"; + 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"; -import type { CustomQueryParams } from "../lib/types/customQueryParams"; export interface AuthDebuggerProps { serverUrl: string; @@ -24,8 +22,8 @@ export interface AuthDebuggerProps { updateAuthState: (updates: Partial) => void; config?: InspectorConfig; connectionType?: "direct" | "proxy"; - extraHeaders?: CustomHeaders; - extraQueryParams?: CustomQueryParams; + customHeaders?: CustomHeaders; + applyHeadersToAllRequests?: boolean; } interface StatusMessageProps { @@ -75,8 +73,8 @@ const AuthDebugger = ({ updateAuthState, config, connectionType, - extraHeaders, - extraQueryParams, + customHeaders, + applyHeadersToAllRequests, }: AuthDebuggerProps) => { // Check for existing tokens on mount useEffect(() => { @@ -120,21 +118,18 @@ const AuthDebugger = ({ }, [serverUrl, updateAuthState]); const fetchFn = useMemo(() => { - const extras: RequestExtras = { - headers: extraHeaders, - queryParams: extraQueryParams, - }; + const oauthHeaders = applyHeadersToAllRequests ? customHeaders : undefined; const baseFetch = connectionType === "proxy" && config ? createProxyFetch(config) : undefined; - // Inject the "apply to all requests" extras into every OAuth aux call - // (metadata discovery, DCR, token exchange). No extras → preserve the + // 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 hasRequestExtras(extras) - ? withRequestExtras(baseFetch ?? fetch, extras) + return hasEnabledHeaders(oauthHeaders) + ? withCustomHeaderFetch(baseFetch ?? fetch, oauthHeaders) : baseFetch; - }, [connectionType, config, extraHeaders, extraQueryParams]); + }, [connectionType, config, customHeaders, applyHeadersToAllRequests]); const stateMachine = useMemo( () => new OAuthStateMachine(serverUrl, updateAuthState, fetchFn), diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index 95595d3d7a..256589df8d 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -8,30 +8,33 @@ import { parseOAuthCallbackParams, } from "@/utils/oauthUtils.ts"; import { createProxyFetch } from "../lib/proxyFetch"; -import { hasRequestExtras, withRequestExtras } from "../lib/requestExtras"; -import { loadRequestExtras } from "../lib/extrasStorage"; +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 -// "apply to all requests" extras) 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 header/param. +// 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 extras = loadRequestExtras(); + const oauthHeaders = loadOAuthHeaders(); const baseFetch = connectionType === "proxy" ? createProxyFetch(config) : undefined; - if (hasRequestExtras(extras)) { - return withRequestExtras(baseFetch ?? fetch, extras); + if (hasEnabledHeaders(oauthHeaders)) { + return withCustomHeaderFetch(baseFetch ?? fetch, oauthHeaders); } return baseFetch; }; diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index f562ad1750..01ccd6a273 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -40,9 +40,8 @@ import { TooltipContent, } from "@/components/ui/tooltip"; import CustomHeaders from "./CustomHeaders"; -import ApplyToAllRequests from "./ApplyToAllRequests"; +import { Switch } from "@/components/ui/switch"; import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders"; -import { CustomQueryParams as CustomQueryParamsType } from "@/lib/types/customQueryParams"; import { useToast } from "../lib/hooks/useToast"; import IconDisplay, { WithIcons } from "./IconDisplay"; import { validateRedirectUrl } from "@/utils/urlValidation"; @@ -62,11 +61,9 @@ interface SidebarProps { // Custom headers support customHeaders: CustomHeadersType; setCustomHeaders: (headers: CustomHeadersType) => void; - // "Apply to all requests" extras (also applied to OAuth aux calls) - extraHeaders: CustomHeadersType; - setExtraHeaders: (headers: CustomHeadersType) => void; - extraQueryParams: CustomQueryParamsType; - setExtraQueryParams: (params: CustomQueryParamsType) => 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; @@ -101,10 +98,8 @@ const Sidebar = ({ setEnv, customHeaders, setCustomHeaders, - extraHeaders, - setExtraHeaders, - extraQueryParams, - setExtraQueryParams, + applyHeadersToAllRequests, + setApplyHeadersToAllRequests, oauthClientId, setOauthClientId, oauthClientSecret, @@ -558,20 +553,42 @@ const Sidebar = ({ {showAuthConfig && ( <> {/* Custom Headers Section */} -
+
-
- {/* Apply to All Requests (headers + query params, incl. OAuth) */} -
- + {/* Opt-in: also send the custom headers on the OAuth + auxiliary requests (discovery, registration, token). */} +
+ +
+

+ Also apply to OAuth requests +

+

+ 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.{" "} + + Vercel Deployment Protection + + . +

+
+
{transportType !== "stdio" && ( // OAuth Configuration diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 736d902b08..2572733e90 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -52,10 +52,8 @@ describe("Sidebar", () => { setEnv: jest.fn(), customHeaders: [], setCustomHeaders: jest.fn(), - extraHeaders: [], - setExtraHeaders: jest.fn(), - extraQueryParams: [], - setExtraQueryParams: jest.fn(), + applyHeadersToAllRequests: false, + setApplyHeadersToAllRequests: jest.fn(), onConnect: jest.fn(), onDisconnect: jest.fn(), stdErrNotifications: [], diff --git a/client/src/lib/__tests__/customHeaderFetch.test.ts b/client/src/lib/__tests__/customHeaderFetch.test.ts new file mode 100644 index 0000000000..15e0e0d390 --- /dev/null +++ b/client/src/lib/__tests__/customHeaderFetch.test.ts @@ -0,0 +1,92 @@ +import { hasEnabledHeaders, withCustomHeaderFetch } from "../customHeaderFetch"; +import { CustomHeaders } from "../types/customHeaders"; + +const bypassHeader: CustomHeaders = [ + { name: "x-vercel-protection-bypass", value: "secret123", enabled: true }, +]; + +describe("hasEnabledHeaders", () => { + it("is false for undefined / empty / all-disabled / blank", () => { + expect(hasEnabledHeaders(undefined)).toBe(false); + expect(hasEnabledHeaders([])).toBe(false); + expect(hasEnabledHeaders([{ name: "a", value: "b", enabled: false }])).toBe( + false, + ); + expect( + hasEnabledHeaders([{ name: " ", value: " ", enabled: true }]), + ).toBe(false); + }); + + it("is true when an enabled header has content", () => { + expect(hasEnabledHeaders(bypassHeader)).toBe(true); + }); +}); + +describe("withCustomHeaderFetch (no-op default)", () => { + it("returns the exact same fetch reference when no enabled headers", () => { + const base = jest.fn() as unknown as typeof fetch; + expect(withCustomHeaderFetch(base, undefined)).toBe(base); + expect(withCustomHeaderFetch(base, [])).toBe(base); + expect( + withCustomHeaderFetch(base, [{ name: "", value: "", enabled: true }]), + ).toBe(base); + }); +}); + +describe("withCustomHeaderFetch injection", () => { + const captured: { url: string; headers: Record }[] = []; + let base: jest.Mock; + + beforeEach(() => { + captured.length = 0; + base = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + const headers: Record = {}; + new Headers(init?.headers).forEach((v, k) => (headers[k] = v)); + captured.push({ url, headers }); + return new Response("{}", { status: 200 }); + }); + }); + + // The OAuth SDK calls receive this wrapped fetch, so exercising it here + // covers discovery + DCR + token injection. + it("injects the header for a string url", async () => { + const f = withCustomHeaderFetch(base, bypassHeader); + await f("https://mcp.example.com/.well-known/oauth-protected-resource"); + expect(captured).toHaveLength(1); + expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); + }); + + it("injects for a URL object and a Request object", async () => { + const f = withCustomHeaderFetch(base, bypassHeader); + await f(new URL("https://auth.example.com/register"), { method: "POST" }); + await f(new Request("https://auth.example.com/token", { method: "POST" })); + expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); + expect(captured[1].headers["x-vercel-protection-bypass"]).toBe("secret123"); + }); + + it("does not clobber an existing header of the same name", async () => { + const f = withCustomHeaderFetch(base, [ + { name: "Authorization", value: "Bearer INJECTED", enabled: true }, + ]); + await f("https://mcp.example.com/mcp", { + headers: { Authorization: "Bearer REAL" }, + }); + expect(captured[0].headers["authorization"]).toBe("Bearer REAL"); + }); + + it("only sends enabled, non-blank headers (trimmed)", async () => { + const f = withCustomHeaderFetch(base, [ + { name: " X-A ", value: " 1 ", enabled: true }, + { name: "X-B", value: "2", enabled: false }, + ]); + await f("https://mcp.example.com/mcp"); + expect(captured[0].headers["x-a"]).toBe("1"); + expect(captured[0].headers["x-b"]).toBeUndefined(); + }); +}); diff --git a/client/src/lib/__tests__/requestExtras.test.ts b/client/src/lib/__tests__/requestExtras.test.ts deleted file mode 100644 index ed17281818..0000000000 --- a/client/src/lib/__tests__/requestExtras.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - appendQueryParams, - extraHeadersRecord, - hasRequestExtras, - withRequestExtras, - RequestExtras, -} from "../requestExtras"; -import { CustomHeaders } from "../types/customHeaders"; -import { CustomQueryParams } from "../types/customQueryParams"; - -const bypassHeader: CustomHeaders = [ - { name: "x-vercel-protection-bypass", value: "secret123", enabled: true }, -]; -const bypassParam: CustomQueryParams = [ - { key: "x-vercel-protection-bypass", value: "secret123", enabled: true }, -]; - -describe("hasRequestExtras", () => { - it("is false for undefined / empty / all-disabled", () => { - expect(hasRequestExtras(undefined)).toBe(false); - expect(hasRequestExtras({})).toBe(false); - expect(hasRequestExtras({ headers: [], queryParams: [] })).toBe(false); - expect( - hasRequestExtras({ - headers: [{ name: "a", value: "b", enabled: false }], - queryParams: [{ key: "c", value: "d", enabled: false }], - }), - ).toBe(false); - // Enabled but blank name/value do not count. - expect( - hasRequestExtras({ - headers: [{ name: " ", value: " ", enabled: true }], - }), - ).toBe(false); - }); - - it("is true when an enabled header or param has content", () => { - expect(hasRequestExtras({ headers: bypassHeader })).toBe(true); - expect(hasRequestExtras({ queryParams: bypassParam })).toBe(true); - }); -}); - -describe("extraHeadersRecord", () => { - it("includes only enabled, non-blank headers (trimmed)", () => { - const headers: CustomHeaders = [ - { name: " X-A ", value: " 1 ", enabled: true }, - { name: "X-B", value: "2", enabled: false }, - { name: "", value: "3", enabled: true }, - ]; - expect(extraHeadersRecord(headers)).toEqual({ "X-A": "1" }); - }); -}); - -describe("appendQueryParams", () => { - it("returns the url unchanged when there are no enabled params", () => { - expect(appendQueryParams("https://mcp.example.com/mcp", [])).toBe( - "https://mcp.example.com/mcp", - ); - expect( - appendQueryParams("https://mcp.example.com/mcp", [ - { key: "k", value: "v", enabled: false }, - ]), - ).toBe("https://mcp.example.com/mcp"); - }); - - it("appends enabled params and preserves existing query", () => { - const out = appendQueryParams( - "https://mcp.example.com/mcp?foo=bar", - bypassParam, - ); - const url = new URL(out); - expect(url.searchParams.get("foo")).toBe("bar"); - expect(url.searchParams.get("x-vercel-protection-bypass")).toBe( - "secret123", - ); - }); - - it("is idempotent (uses set, not append)", () => { - const once = appendQueryParams("https://mcp.example.com/mcp", bypassParam); - const twice = appendQueryParams(once, bypassParam); - expect(twice).toBe(once); - expect( - new URL(twice).searchParams.getAll("x-vercel-protection-bypass"), - ).toEqual(["secret123"]); - }); - - it("leaves an unparseable url untouched", () => { - expect(appendQueryParams("not a url", bypassParam)).toBe("not a url"); - }); -}); - -describe("withRequestExtras (no-op default)", () => { - it("returns the exact same fetch reference when no extras are configured", () => { - const base = jest.fn() as unknown as typeof fetch; - expect(withRequestExtras(base, undefined)).toBe(base); - expect(withRequestExtras(base, { headers: [], queryParams: [] })).toBe( - base, - ); - }); -}); - -describe("withRequestExtras injection", () => { - const captured: { url: string; headers: Record }[] = []; - let base: jest.Mock; - - beforeEach(() => { - captured.length = 0; - base = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - const headers: Record = {}; - new Headers(init?.headers).forEach((v, k) => (headers[k] = v)); - captured.push({ url, headers }); - return new Response("{}", { status: 200 }); - }); - }); - - const extras: RequestExtras = { - headers: bypassHeader, - queryParams: bypassParam, - }; - - // The transport (direct mode) and every OAuth SDK call receive this wrapped - // fetch, so exercising it here covers transport + discovery + DCR + token. - it("injects header and query param for a string url", async () => { - const f = withRequestExtras(base, extras); - await f("https://mcp.example.com/.well-known/oauth-protected-resource"); - - expect(captured).toHaveLength(1); - expect(captured[0].url).toBe( - "https://mcp.example.com/.well-known/oauth-protected-resource?x-vercel-protection-bypass=secret123", - ); - expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); - }); - - it("injects for a URL object and a Request object", async () => { - const f = withRequestExtras(base, extras); - await f(new URL("https://auth.example.com/register"), { method: "POST" }); - await f(new Request("https://auth.example.com/token", { method: "POST" })); - - expect(captured[0].url).toContain("x-vercel-protection-bypass=secret123"); - expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); - expect(captured[1].url).toContain("x-vercel-protection-bypass=secret123"); - expect(captured[1].headers["x-vercel-protection-bypass"]).toBe("secret123"); - }); - - it("does not clobber an existing header of the same name", async () => { - const f = withRequestExtras(base, { - headers: [ - { name: "Authorization", value: "Bearer INJECTED", enabled: true }, - ], - }); - await f("https://mcp.example.com/mcp", { - headers: { Authorization: "Bearer REAL" }, - }); - expect(captured[0].headers["authorization"]).toBe("Bearer REAL"); - }); - - it("only injects headers when only headers are configured", async () => { - const f = withRequestExtras(base, { headers: bypassHeader }); - await f("https://mcp.example.com/mcp"); - expect(captured[0].url).toBe("https://mcp.example.com/mcp"); - expect(captured[0].headers["x-vercel-protection-bypass"]).toBe("secret123"); - }); - - it("only injects params when only params are configured", async () => { - const f = withRequestExtras(base, { queryParams: bypassParam }); - await f("https://mcp.example.com/mcp"); - expect(captured[0].url).toContain("x-vercel-protection-bypass=secret123"); - expect(captured[0].headers["x-vercel-protection-bypass"]).toBeUndefined(); - }); -}); diff --git a/client/src/lib/customHeaderFetch.ts b/client/src/lib/customHeaderFetch.ts new file mode 100644 index 0000000000..6aa79a9dac --- /dev/null +++ b/client/src/lib/customHeaderFetch.ts @@ -0,0 +1,65 @@ +import { + CustomHeaders, + getEnabledHeaders, + headersToRecord, +} from "./types/customHeaders"; + +/** + * True when at least one enabled header with a non-blank name and value is + * configured. When false, callers should preserve their existing behavior + * exactly (no-op), so default flows are unaffected. + */ +export function hasEnabledHeaders(headers?: CustomHeaders): boolean { + return getEnabledHeaders(headers ?? []).length > 0; +} + +/** + * Wraps a base `fetch` so every request it issues also carries the given custom + * headers. This is threaded into the SDK's OAuth auth methods so the configured + * headers reach the OAuth auxiliary requests (protected-resource / + * authorization-server metadata discovery, dynamic client registration, and the + * token exchange) — not just the MCP transport requests. + * + * This is what lets the Inspector reach an MCP server deployed behind a gateway + * that gates access with a header (e.g. Vercel Deployment Protection's + * `x-vercel-protection-bypass`), whose OAuth calls would otherwise be issued by + * the SDK without the header and hit the gateway's auth wall. + * + * - Existing request headers always win over the injected ones, so we never + * clobber an `Authorization` header or other request-specific headers. + * - When no enabled headers are configured, the base fetch is returned + * unchanged. + */ +export function withCustomHeaderFetch( + baseFetch: typeof fetch, + headers?: CustomHeaders, +): typeof fetch { + if (!hasEnabledHeaders(headers)) { + return baseFetch; + } + + const headerRecord = headersToRecord(headers ?? []); + + return async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + // Merge headers from the incoming request/init, letting existing headers + // take precedence over the configured ones. + const merged = new Headers( + input instanceof Request ? input.headers : undefined, + ); + if (init?.headers) { + new Headers(init.headers).forEach((value, key) => { + merged.set(key, value); + }); + } + Object.entries(headerRecord).forEach(([name, value]) => { + if (!merged.has(name)) { + merged.set(name, value); + } + }); + + return baseFetch(input, { ...init, headers: merged }); + }; +} diff --git a/client/src/lib/extrasStorage.ts b/client/src/lib/extrasStorage.ts deleted file mode 100644 index 3b36d7b5bd..0000000000 --- a/client/src/lib/extrasStorage.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { CustomHeaders } from "./types/customHeaders"; -import { CustomQueryParams } from "./types/customQueryParams"; -import { RequestExtras } from "./requestExtras"; - -// localStorage keys for the "apply to all requests" extras. Kept here so both -// the main App and the standalone OAuth callback route read/write the same -// values (the callback runs as its own lazily-loaded page and can't share -// React state with the App). -export const EXTRA_HEADERS_STORAGE_KEY = "lastExtraHeaders"; -export const EXTRA_QUERY_PARAMS_STORAGE_KEY = "lastExtraQueryParams"; - -export const loadExtraHeaders = (): CustomHeaders => { - const saved = localStorage.getItem(EXTRA_HEADERS_STORAGE_KEY); - if (!saved) { - return []; - } - try { - const parsed = JSON.parse(saved); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -}; - -export const loadExtraQueryParams = (): CustomQueryParams => { - const saved = localStorage.getItem(EXTRA_QUERY_PARAMS_STORAGE_KEY); - if (!saved) { - return []; - } - try { - const parsed = JSON.parse(saved); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -}; - -export const loadRequestExtras = (): RequestExtras => ({ - headers: loadExtraHeaders(), - queryParams: loadExtraQueryParams(), -}); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index e77b2b41e7..3df4932ede 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -76,13 +76,7 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CustomHeaders } from "../types/customHeaders"; -import { CustomQueryParams } from "../types/customQueryParams"; -import { - RequestExtras, - appendQueryParams, - hasRequestExtras, - withRequestExtras, -} from "../requestExtras"; +import { hasEnabledHeaders, withCustomHeaderFetch } from "../customHeaderFetch"; import { resolveRefsInMessage } from "@/utils/schemaUtils"; interface UseConnectionOptions { @@ -93,10 +87,10 @@ interface UseConnectionOptions { env: Record; // Custom headers support customHeaders?: CustomHeaders; - // "Apply to all requests" extras: headers and/or query params injected into - // every request to the target server, including the OAuth auxiliary calls. - extraHeaders?: CustomHeaders; - extraQueryParams?: CustomQueryParams; + // When true, the custom headers are also injected into the OAuth auxiliary + // requests (metadata discovery, dynamic client registration, token exchange), + // not just the MCP transport requests. + applyHeadersToAllRequests?: boolean; oauthClientId?: string; oauthClientSecret?: string; oauthScope?: string; @@ -122,8 +116,7 @@ export function useConnection({ sseUrl, env, customHeaders, - extraHeaders, - extraQueryParams, + applyHeadersToAllRequests, oauthClientId, oauthClientSecret, oauthScope, @@ -400,24 +393,22 @@ export function useConnection({ ); }; - // Extras ("apply to all requests") shared across the transport and OAuth flows. - const requestExtras: RequestExtras = { - headers: extraHeaders, - queryParams: extraQueryParams, - }; + // Custom headers to inject into the OAuth aux requests, when the user opted + // in via "apply to all requests". Empty otherwise (headers stay transport-only). + const oauthHeaders = applyHeadersToAllRequests ? customHeaders : undefined; const handleAuthError = async (error: unknown) => { if (isConnectionAuthError(error)) { let scope = oauthScope?.trim(); // In proxy mode OAuth aux requests route through the inspector proxy to // avoid CORS; in direct mode they use the browser fetch. Either way, wrap - // with the configured extras so the header/param reaches discovery, DCR - // and token requests. When no extras are set this preserves the exact - // prior behavior (undefined in direct mode → SDK default fetch). + // with the custom headers (when opted in) so they reach discovery, DCR + // and token requests. When not opted in this preserves the exact prior + // behavior (undefined in direct mode → SDK default fetch). const baseFetch = connectionType === "proxy" ? createProxyFetch(config) : undefined; - const fetchFn = hasRequestExtras(requestExtras) - ? withRequestExtras(baseFetch ?? fetch, requestExtras) + const fetchFn = hasEnabledHeaders(oauthHeaders) + ? withCustomHeaderFetch(baseFetch ?? fetch, oauthHeaders) : baseFetch; if (!scope) { @@ -525,25 +516,8 @@ export function useConnection({ // Create an auth provider with the current server URL const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - // Use custom headers (migration is handled in App.tsx). The - // "apply to all requests" extra headers are appended so they ride along - // with the transport requests too (in proxy mode they are forwarded via - // the x-custom-auth-headers mechanism below). - let finalHeaders: CustomHeaders = [ - ...(customHeaders || []), - ...(extraHeaders || []), - ]; - - // Target server URL with the "apply to all requests" query params - // injected. In proxy mode this string is embedded as the `url` search - // param the proxy connects to; in direct mode it becomes the transport - // URL directly. - const targetUrlWithParams = appendQueryParams(sseUrl, extraQueryParams); - // Fetch wrapper used for direct-mode transport requests so query params - // are also injected onto any URL the SDK derives internally. - const directFetch = withRequestExtras(fetch, { - queryParams: extraQueryParams, - }); + // Use custom headers (migration is handled in App.tsx) + let finalHeaders: CustomHeaders = customHeaders || []; const isEmptyAuthHeader = (header: CustomHeaders[number]) => header.name.trim().toLowerCase() === "authorization" && @@ -615,7 +589,7 @@ export function useConnection({ // Determine connection URL based on the connection type if (connectionType === "direct" && transportType !== "stdio") { // Direct connection - use the provided URL directly (not available for STDIO) - serverUrl = new URL(targetUrlWithParams); + serverUrl = new URL(sseUrl); const requestHeaders = { ...headers }; if (mcpSessionId) { @@ -631,7 +605,7 @@ export function useConnection({ url: string | URL | globalThis.Request, init?: RequestInit, ) => { - const response = await directFetch(url, { + const response = await fetch(url, { ...init, headers: requestHeaders, }); @@ -656,7 +630,7 @@ export function useConnection({ requestHeaders["Accept"] = "text/event-stream, application/json"; requestHeaders["Content-Type"] = "application/json"; - const response = await directFetch(url, { + const response = await fetch(url, { headers: requestHeaders, ...init, }); @@ -726,7 +700,7 @@ export function useConnection({ case "sse": { mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("url", targetUrlWithParams); + mcpProxyServerUrl.searchParams.append("url", sseUrl); const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS .value as string; @@ -757,7 +731,7 @@ export function useConnection({ case "streamable-http": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); - mcpProxyServerUrl.searchParams.append("url", targetUrlWithParams); + mcpProxyServerUrl.searchParams.append("url", sseUrl); transportOptions = { authProvider: serverAuthProvider, eventSourceInit: { diff --git a/client/src/lib/oauthHeaderStorage.ts b/client/src/lib/oauthHeaderStorage.ts new file mode 100644 index 0000000000..4f146d8f5e --- /dev/null +++ b/client/src/lib/oauthHeaderStorage.ts @@ -0,0 +1,36 @@ +import { CustomHeaders } from "./types/customHeaders"; + +// localStorage keys shared between the main App and the standalone OAuth +// callback route (the callback runs as its own lazily-loaded page and can't +// read the App's React state). +export const CUSTOM_HEADERS_STORAGE_KEY = "lastCustomHeaders"; +export const APPLY_HEADERS_TO_ALL_REQUESTS_STORAGE_KEY = + "lastApplyHeadersToAllRequests"; + +const loadCustomHeaders = (): CustomHeaders => { + const saved = localStorage.getItem(CUSTOM_HEADERS_STORAGE_KEY); + if (!saved) { + return []; + } + try { + const parsed = JSON.parse(saved); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +export const loadApplyHeadersToAllRequests = (): boolean => { + return ( + localStorage.getItem(APPLY_HEADERS_TO_ALL_REQUESTS_STORAGE_KEY) === "true" + ); +}; + +/** + * The custom headers that should be applied to OAuth requests: the configured + * custom headers when the "apply to all requests" toggle is on, otherwise an + * empty list (headers stay transport-only, the default behavior). + */ +export const loadOAuthHeaders = (): CustomHeaders => { + return loadApplyHeadersToAllRequests() ? loadCustomHeaders() : []; +}; diff --git a/client/src/lib/requestExtras.ts b/client/src/lib/requestExtras.ts deleted file mode 100644 index 2f4f73c519..0000000000 --- a/client/src/lib/requestExtras.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { CustomHeaders, getEnabledHeaders } from "./types/customHeaders"; -import { - CustomQueryParams, - getEnabledQueryParams, -} from "./types/customQueryParams"; - -/** - * Extra headers and/or query parameters that should be attached to *every* - * outbound request to the target MCP server — the MCP transport requests as - * well as the OAuth auxiliary requests (protected-resource / authorization- - * server metadata discovery, dynamic client registration, and token exchange). - * - * This is the mechanism that lets the Inspector reach an MCP server deployed - * behind a gateway that gates access with a header or query param (e.g. Vercel - * Deployment Protection, which honors `x-vercel-protection-bypass` as either a - * header or a query param on any URL). - */ -export interface RequestExtras { - headers?: CustomHeaders; - queryParams?: CustomQueryParams; -} - -/** - * True when at least one enabled header or query param is configured. When - * false, callers should preserve their existing behavior exactly (no-op), so - * default flows are unaffected. - */ -export function hasRequestExtras(extras?: RequestExtras): boolean { - if (!extras) { - return false; - } - return ( - getEnabledHeaders(extras.headers ?? []).length > 0 || - getEnabledQueryParams(extras.queryParams ?? []).length > 0 - ); -} - -/** - * Builds a plain record of the enabled extra headers (trimmed name/value). - */ -export function extraHeadersRecord( - headers?: CustomHeaders, -): Record { - const record: Record = {}; - getEnabledHeaders(headers ?? []).forEach((header) => { - record[header.name.trim()] = header.value.trim(); - }); - return record; -} - -/** - * Appends the enabled query params to the given URL and returns the resulting - * URL string. Uses `set` so the operation is idempotent — reapplying the same - * params (e.g. the SDK re-deriving a URL that already carries them) does not - * accumulate duplicates. If the URL cannot be parsed the original value is - * returned unchanged. - */ -export function appendQueryParams( - url: string, - queryParams?: CustomQueryParams, -): string { - const params = getEnabledQueryParams(queryParams ?? []); - if (params.length === 0) { - return url; - } - try { - const parsed = new URL(url); - params.forEach((param) => { - parsed.searchParams.set(param.key.trim(), param.value.trim()); - }); - return parsed.toString(); - } catch { - // Not an absolute URL we can parse — leave it untouched. - return url; - } -} - -function resolveUrlString(input: RequestInfo | URL): string { - if (typeof input === "string") { - return input; - } - if (input instanceof URL) { - return input.toString(); - } - // Request-like - return input.url; -} - -/** - * Wraps a base `fetch` implementation so that every request it issues gets the - * configured extra headers and query params injected. This is the single choke - * point used for both the MCP transport (direct mode) and the SDK OAuth calls, - * so header/param injection happens in exactly one place regardless of which - * layer issues the request. - * - * - Existing request headers always win over extras, so we never clobber an - * `Authorization` header or other request-specific headers. - * - Query params are applied with `set`, overriding any same-named param. - * - When no extras are configured, the base fetch is returned unchanged. - */ -export function withRequestExtras( - baseFetch: typeof fetch, - extras?: RequestExtras, -): typeof fetch { - if (!hasRequestExtras(extras)) { - return baseFetch; - } - - const headerRecord = extraHeadersRecord(extras?.headers); - const queryParams = extras?.queryParams; - - return async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - const url = appendQueryParams(resolveUrlString(input), queryParams); - - // Merge headers from the incoming request/init, letting existing headers - // take precedence over the configured extras. - const merged = new Headers( - input instanceof Request ? input.headers : undefined, - ); - if (init?.headers) { - new Headers(init.headers).forEach((value, key) => { - merged.set(key, value); - }); - } - Object.entries(headerRecord).forEach(([name, value]) => { - if (!merged.has(name)) { - merged.set(name, value); - } - }); - - return baseFetch(url, { ...init, headers: merged }); - }; -} diff --git a/client/src/lib/types/customQueryParams.ts b/client/src/lib/types/customQueryParams.ts deleted file mode 100644 index 2f80404766..0000000000 --- a/client/src/lib/types/customQueryParams.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface CustomQueryParam { - key: string; - value: string; - enabled: boolean; -} - -export type CustomQueryParams = CustomQueryParam[]; - -export const createEmptyQueryParam = (): CustomQueryParam => ({ - key: "", - value: "", - enabled: true, -}); - -export const getEnabledQueryParams = ( - params: CustomQueryParams, -): CustomQueryParams => { - return params.filter( - (param) => param.enabled && param.key.trim() && param.value.trim(), - ); -}; - -export const queryParamsToRecord = ( - params: CustomQueryParams, -): Record => { - const record: Record = {}; - getEnabledQueryParams(params).forEach((param) => { - record[param.key.trim()] = param.value.trim(); - }); - return record; -}; - -export const recordToQueryParams = ( - record: Record, -): CustomQueryParams => { - return Object.entries(record).map(([key, value]) => ({ - key, - value, - enabled: true, - })); -}; diff --git a/client/src/utils/__tests__/customQueryParams.test.ts b/client/src/utils/__tests__/customQueryParams.test.ts deleted file mode 100644 index 751e1d147b..0000000000 --- a/client/src/utils/__tests__/customQueryParams.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - createEmptyQueryParam, - getEnabledQueryParams, - queryParamsToRecord, - recordToQueryParams, - CustomQueryParams, -} from "@/lib/types/customQueryParams"; - -describe("customQueryParams helpers", () => { - it("createEmptyQueryParam returns an enabled blank param", () => { - expect(createEmptyQueryParam()).toEqual({ - key: "", - value: "", - enabled: true, - }); - }); - - it("getEnabledQueryParams filters disabled and blank entries", () => { - const params: CustomQueryParams = [ - { key: "a", value: "1", enabled: true }, - { key: "b", value: "2", enabled: false }, - { key: "", value: "3", enabled: true }, - { key: "c", value: "", enabled: true }, - ]; - expect(getEnabledQueryParams(params)).toEqual([ - { key: "a", value: "1", enabled: true }, - ]); - }); - - it("queryParamsToRecord trims keys/values of enabled params", () => { - const params: CustomQueryParams = [ - { key: " token ", value: " abc ", enabled: true }, - { key: "off", value: "x", enabled: false }, - ]; - expect(queryParamsToRecord(params)).toEqual({ token: "abc" }); - }); - - it("recordToQueryParams round-trips a record", () => { - expect(recordToQueryParams({ a: "1", b: "2" })).toEqual([ - { key: "a", value: "1", enabled: true }, - { key: "b", value: "2", enabled: true }, - ]); - }); -}); diff --git a/server/src/index.ts b/server/src/index.ts index 7d15177610..4a50bc3e97 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -55,10 +55,9 @@ const { values } = parseArgs({ command: { type: "string", default: "" }, transport: { type: "string", default: "" }, "server-url": { type: "string", default: "" }, - // "Apply to all requests" defaults for headless use. Repeatable: - // --header "X-Name: value" --query-param "key=value" + // Default custom headers for headless use. Repeatable: + // --header "X-Name: value" header: { type: "string", multiple: true }, - "query-param": { type: "string", multiple: true }, }, }); @@ -68,19 +67,14 @@ interface NameValueEntry { enabled: boolean; } -interface KeyValueEntry { - key: string; - value: string; - enabled: boolean; -} - /** - * Parses the "apply to all requests" default headers from CLI `--header` - * flags ("Name: value") and the `MCP_REQUEST_HEADERS` env var (a JSON object). - * These are surfaced to the client via GET /config so headless invocations can - * preconfigure the header/param that reaches every request (transport + OAuth). + * Parses the default custom headers from CLI `--header` flags ("Name: value") + * and the `MCP_REQUEST_HEADERS` env var (a JSON object). These are surfaced to + * the client via GET /config so headless invocations can preconfigure the + * headers (and, since they're typically a gateway bypass, the "apply to all + * requests" toggle) without the UI. */ -const parseDefaultExtraHeaders = (): NameValueEntry[] => { +const parseDefaultCustomHeaders = (): NameValueEntry[] => { const entries: NameValueEntry[] = []; const flagHeaders = (values.header as string[] | undefined) ?? []; @@ -108,41 +102,14 @@ const parseDefaultExtraHeaders = (): NameValueEntry[] => { return entries; }; -/** - * Parses the "apply to all requests" default query params from CLI - * `--query-param` flags ("key=value") and the `MCP_REQUEST_QUERY_PARAMS` env - * var (a JSON object). - */ -const parseDefaultExtraQueryParams = (): KeyValueEntry[] => { - const entries: KeyValueEntry[] = []; - - const flagParams = (values["query-param"] as string[] | undefined) ?? []; - for (const raw of flagParams) { - const idx = raw.indexOf("="); - if (idx === -1) continue; - const key = raw.slice(0, idx).trim(); - const value = raw.slice(idx + 1).trim(); - if (key) entries.push({ key, value, enabled: true }); - } - - if (process.env.MCP_REQUEST_QUERY_PARAMS) { - try { - const parsed = JSON.parse(process.env.MCP_REQUEST_QUERY_PARAMS); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - for (const [key, value] of Object.entries(parsed)) { - if (key) entries.push({ key, value: String(value), enabled: true }); - } - } - } catch (error) { - console.warn("Failed to parse MCP_REQUEST_QUERY_PARAMS:", error); - } - } - - return entries; -}; - -const defaultExtraHeaders = parseDefaultExtraHeaders(); -const defaultExtraQueryParams = parseDefaultExtraQueryParams(); +const defaultCustomHeaders = parseDefaultCustomHeaders(); +// When headers are provided headlessly they're almost always a gateway bypass +// that must reach the OAuth calls too, so default the toggle on. Opt out with +// MCP_APPLY_HEADERS_TO_ALL_REQUESTS=false. +const defaultApplyHeadersToAllRequests = + process.env.MCP_APPLY_HEADERS_TO_ALL_REQUESTS === "false" + ? false + : defaultCustomHeaders.length > 0; /** * Helper function to detect 401 Unauthorized errors from various transport types. @@ -1139,8 +1106,8 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { defaultArgs: values.args, defaultTransport: values.transport, defaultServerUrl: values["server-url"], - defaultExtraHeaders, - defaultExtraQueryParams, + defaultCustomHeaders, + defaultApplyHeadersToAllRequests, }); } catch (error) { console.error("Error in /config route:", error); From 27e8bf00a7cc063204c0770fcdfbe2277e30f80a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:57:45 +0000 Subject: [PATCH 3/3] fix: don't let /config clobber the persisted "apply to OAuth" toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a normal launch the server reports `defaultCustomHeaders: []` and `defaultApplyHeadersToAllRequests: false`. The client applied the boolean unconditionally, so after enabling the toggle and reloading it would render on (from localStorage) and then flip off once the /config fetch resolved — and the persistence effect wrote that `false` back, destroying the saved value. Only apply the headless header/toggle defaults when the server actually declares header defaults (i.e. --header / MCP_REQUEST_HEADERS were provided); otherwise leave the user's persisted headers and toggle untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012FAFCf8AzpqyNF1oKZNGzh --- client/src/App.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8229c44683..25828bb266 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -757,15 +757,18 @@ const App = () => { setSseUrl(data.defaultServerUrl); } // CLI/env defaults for custom headers + the "apply to all requests" - // toggle (headless use). + // 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); + if (typeof data.defaultApplyHeadersToAllRequests === "boolean") { + setApplyHeadersToAllRequests(data.defaultApplyHeadersToAllRequests); + } } }) .catch((error) =>