Merge branch 'main' into playwright-test

This commit is contained in:
Marc Abramowitz 2025-06-19 00:02:11 -07:00
commit 2ca63cb161
13 changed files with 1798 additions and 344 deletions

View file

@ -2,8 +2,9 @@
import open from "open";
import { resolve, dirname } from "path";
import { spawnPromise } from "spawn-rx";
import { spawnPromise, spawn } from "spawn-rx";
import { fileURLToPath } from "url";
import { randomBytes } from "crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -11,6 +12,157 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function startDevServer(serverOptions) {
const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
serverOptions;
const serverCommand = "npx";
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
const isWindows = process.platform === "win32";
const spawnOptions = {
cwd: resolve(__dirname, "../..", "server"),
env: {
...process.env,
PORT: SERVER_PORT,
CLIENT_PORT: CLIENT_PORT,
MCP_PROXY_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
};
// For Windows, we need to use stdin: 'ignore' to simulate < NUL
if (isWindows) {
spawnOptions.stdin = "ignore";
}
const server = spawn(serverCommand, serverArgs, spawnOptions);
// Give server time to start
const serverOk = await Promise.race([
new Promise((resolve) => {
server.subscribe({
complete: () => resolve(false),
error: () => resolve(false),
next: () => {}, // We're using echoOutput
});
}),
delay(3000).then(() => true),
]);
return { server, serverOk };
}
async function startProdServer(serverOptions) {
const {
SERVER_PORT,
CLIENT_PORT,
sessionToken,
envVars,
abort,
command,
mcpServerArgs,
} = serverOptions;
const inspectorServerPath = resolve(
__dirname,
"../..",
"server",
"build",
"index.js",
);
const server = spawnPromise(
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
CLIENT_PORT: CLIENT_PORT,
MCP_PROXY_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
const serverOk = await Promise.race([server, delay(2 * 1000)]);
return { server, serverOk };
}
async function startDevClient(clientOptions) {
const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } =
clientOptions;
const clientCommand = "npx";
const clientArgs = ["vite", "--port", CLIENT_PORT];
const client = spawn(clientCommand, clientArgs, {
cwd: resolve(__dirname, ".."),
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
// Auto-open browser after vite starts
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
const url = authDisabled
? `http://127.0.0.1:${CLIENT_PORT}`
: `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
// Give vite time to start before opening browser
setTimeout(() => {
open(url);
console.log(`\n🔗 Opening browser at: ${url}\n`);
}, 3000);
}
await new Promise((resolve) => {
client.subscribe({
complete: resolve,
error: (err) => {
if (!cancelled || process.env.DEBUG) {
console.error("Client error:", err);
}
resolve(null);
},
next: () => {}, // We're using echoOutput
});
});
}
async function startProdClient(clientOptions) {
const { CLIENT_PORT, authDisabled, sessionToken, abort } = clientOptions;
const inspectorClientPath = resolve(
__dirname,
"../..",
"client",
"bin",
"client.js",
);
// Auto-open browser with token
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
const url = authDisabled
? `http://127.0.0.1:${CLIENT_PORT}`
: `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
open(url);
}
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
}
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
@ -18,6 +170,7 @@ async function main() {
const mcpServerArgs = [];
let command = null;
let parsingFlags = true;
let isDev = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@ -27,6 +180,11 @@ async function main() {
continue;
}
if (parsingFlags && arg === "--dev") {
isDev = true;
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
@ -38,34 +196,25 @@ async function main() {
} else {
envVars[envVar] = "";
}
} else if (!command) {
} else if (!command && !isDev) {
command = arg;
} else {
} else if (!isDev) {
mcpServerArgs.push(arg);
}
}
const inspectorServerPath = resolve(
__dirname,
"../..",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"../..",
"client",
"bin",
"client.js",
);
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
console.log(
isDev
? "Starting MCP inspector in development mode..."
: "Starting MCP inspector...",
);
// Generate session token for authentication
const sessionToken = randomBytes(32).toString("hex");
const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH;
const abort = new AbortController();
@ -74,40 +223,41 @@ async function main() {
cancelled = true;
abort.abort();
});
let server, serverOk;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
let server, serverOk;
try {
const serverOptions = {
SERVER_PORT,
CLIENT_PORT,
sessionToken,
envVars,
abort,
command,
mcpServerArgs,
};
const result = isDev
? await startDevServer(serverOptions)
: await startProdServer(serverOptions);
server = result.server;
serverOk = result.serverOk;
} catch (error) {}
if (serverOk) {
try {
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
open(`http://127.0.0.1:${CLIENT_PORT}`);
}
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
const clientOptions = {
CLIENT_PORT,
authDisabled,
sessionToken,
abort,
cancelled,
};
await (isDev
? startDevClient(clientOptions)
: startProdClient(clientOptions));
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.14.0",
"version": "0.14.3",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -63,11 +63,13 @@ import ToolsTab from "./components/ToolsTab";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
getMCPProxyAuthToken,
getInitialSseUrl,
getInitialTransportType,
getInitialCommand,
getInitialArgs,
initializeInspectorConfig,
saveInspectorConfig,
} from "./utils/configUtils";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -226,7 +228,7 @@ const App = () => {
}, [headerName]);
useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
// Auto-connect to previously saved serverURL after OAuth callback
@ -344,7 +346,14 @@ const App = () => {
}, [sseUrl]);
useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
const headers: HeadersInit = {};
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
if (proxyAuthToken) {
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
.then((response) => response.json())
.then((data) => {
setEnv(data.defaultEnvironment);
@ -358,8 +367,7 @@ const App = () => {
.catch((error) =>
console.error("Error fetching default environment:", error),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [config]);
useEffect(() => {
rootsRef.current = roots;

View file

@ -0,0 +1,240 @@
import { render, waitFor } from "@testing-library/react";
import App from "../App";
import { DEFAULT_INSPECTOR_CONFIG } from "../lib/constants";
import { InspectorConfig } from "../lib/configurationTypes";
import * as configUtils from "../utils/configUtils";
// Mock auth dependencies first
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
}));
jest.mock("../lib/oauth-state-machine", () => ({
OAuthStateMachine: jest.fn(),
}));
jest.mock("../lib/auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue(null),
clear: jest.fn(),
})),
DebugInspectorOAuthClientProvider: jest.fn(),
}));
// Mock the config utils
jest.mock("../utils/configUtils", () => ({
...jest.requireActual("../utils/configUtils"),
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
getMCPProxyAuthToken: jest.fn((config: InspectorConfig) => ({
token: config.MCP_PROXY_AUTH_TOKEN.value,
header: "X-MCP-Proxy-Auth",
})),
getInitialTransportType: jest.fn(() => "stdio"),
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
getInitialCommand: jest.fn(() => "mcp-server-everything"),
getInitialArgs: jest.fn(() => ""),
initializeInspectorConfig: jest.fn(() => DEFAULT_INSPECTOR_CONFIG),
saveInspectorConfig: jest.fn(),
}));
// Get references to the mocked functions
const mockGetMCPProxyAuthToken = configUtils.getMCPProxyAuthToken as jest.Mock;
const mockInitializeInspectorConfig =
configUtils.initializeInspectorConfig as jest.Mock;
// Mock other dependencies
jest.mock("../lib/hooks/useConnection", () => ({
useConnection: () => ({
connectionStatus: "disconnected",
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
}),
}));
jest.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: () => ({
height: 300,
handleDragStart: jest.fn(),
}),
useDraggableSidebar: () => ({
width: 320,
isDragging: false,
handleDragStart: jest.fn(),
}),
}));
jest.mock("../components/Sidebar", () => ({
__esModule: true,
default: () => <div>Sidebar</div>,
}));
// Mock fetch
global.fetch = jest.fn();
describe("App - Config Endpoint", () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockResolvedValue({
json: () =>
Promise.resolve({
defaultEnvironment: { TEST_ENV: "test" },
defaultCommand: "test-command",
defaultArgs: "test-args",
}),
});
});
afterEach(() => {
jest.clearAllMocks();
// Reset getMCPProxyAuthToken to default behavior
mockGetMCPProxyAuthToken.mockImplementation((config: InspectorConfig) => ({
token: config.MCP_PROXY_AUTH_TOKEN.value,
header: "X-MCP-Proxy-Auth",
}));
});
test("sends X-MCP-Proxy-Auth header when fetching config with proxy auth token", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
// Mock initializeInspectorConfig to return our test config
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
{
headers: {
"X-MCP-Proxy-Auth": "Bearer test-proxy-token",
},
},
);
});
});
test("does not send auth header when proxy auth token is empty", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "",
},
};
// Mock initializeInspectorConfig to return our test config
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
{
headers: {},
},
);
});
});
test("uses custom header name if getMCPProxyAuthToken returns different header", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
// Mock to return a custom header name
mockGetMCPProxyAuthToken.mockReturnValue({
token: "test-proxy-token",
header: "X-Custom-Auth",
});
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
{
headers: {
"X-Custom-Auth": "Bearer test-proxy-token",
},
},
);
});
});
test("config endpoint response updates app state", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
render(<App />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
});
// Verify the fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/config",
expect.objectContaining({
headers: expect.objectContaining({
"X-MCP-Proxy-Auth": "Bearer test-proxy-token",
}),
}),
);
});
test("handles config endpoint errors gracefully", async () => {
const mockConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
// Mock fetch to reject
(global.fetch as jest.Mock).mockRejectedValue(new Error("Network error"));
// Spy on console.error
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
render(<App />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error fetching default environment:",
expect.any(Error),
);
});
consoleErrorSpy.mockRestore();
});
});

View file

@ -666,8 +666,13 @@ const Sidebar = ({
switch (connectionStatus) {
case "connected":
return "Connected";
case "error":
return "Connection Error, is your MCP server running?";
case "error": {
const hasProxyToken = config.MCP_PROXY_AUTH_TOKEN?.value;
if (!hasProxyToken) {
return "Connection Error - Did you add the proxy session token in Configuration?";
}
return "Connection Error - Check if your MCP server is running and proxy token is correct";
}
case "error-connecting-to-proxy":
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
default:

View file

@ -84,12 +84,6 @@ Object.defineProperty(window, "sessionStorage", {
value: sessionStorageMock,
});
Object.defineProperty(window, "location", {
value: {
origin: "http://localhost:3000",
},
});
describe("AuthDebugger", () => {
const defaultAuthState = EMPTY_DEBUGGER_STATE;
@ -104,7 +98,7 @@ describe("AuthDebugger", () => {
jest.clearAllMocks();
sessionStorageMock.getItem.mockReturnValue(null);
// Supress
// Suppress console errors in tests to avoid JSDOM navigation noise
jest.spyOn(console, "error").mockImplementation(() => {});
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
@ -447,18 +441,6 @@ describe("AuthDebugger", () => {
it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => {
const updateAuthState = jest.fn();
// Mock window.location.href setter
const originalLocation = window.location;
const locationMock = {
...originalLocation,
href: "",
origin: "http://localhost:3000",
};
Object.defineProperty(window, "location", {
writable: true,
value: locationMock,
});
// Setup mocks for OAuth flow
mockStartAuthorization.mockResolvedValue({
authorizationUrl: new URL(

View file

@ -519,6 +519,7 @@ describe("Sidebar Environment Variables", () => {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 5000,
is_session_item: false,
},
}),
);
@ -544,6 +545,7 @@ describe("Sidebar Environment Variables", () => {
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "http://localhost:8080",
is_session_item: false,
},
}),
);
@ -569,6 +571,7 @@ describe("Sidebar Environment Variables", () => {
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 10000,
is_session_item: false,
},
}),
);
@ -591,6 +594,7 @@ describe("Sidebar Environment Variables", () => {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 0,
is_session_item: false,
},
}),
);
@ -637,6 +641,7 @@ describe("Sidebar Environment Variables", () => {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
is_session_item: false,
},
}),
);

View file

@ -2,6 +2,7 @@ export type ConfigItem = {
label: string;
description: string;
value: string | number | boolean;
is_session_item: boolean;
};
/**
@ -33,4 +34,9 @@ export type InspectorConfig = {
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
*/
MCP_PROXY_FULL_ADDRESS: ConfigItem;
/**
* Session token for authenticating with the MCP Proxy Server. This token is displayed in the proxy server console on startup.
*/
MCP_PROXY_AUTH_TOKEN: ConfigItem;
};

View file

@ -36,22 +36,33 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
is_session_item: false,
},
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
label: "Reset Timeout on Progress",
description: "Reset timeout on progress notifications",
value: true,
is_session_item: false,
},
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 60000,
is_session_item: false,
},
MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "",
is_session_item: false,
},
MCP_PROXY_AUTH_TOKEN: {
label: "Proxy Session Token",
description:
"Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)",
value: "",
is_session_item: true,
},
} as const;

View file

@ -3,6 +3,7 @@ import { useConnection } from "../useConnection";
import { z } from "zod";
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
@ -23,21 +24,46 @@ const mockClient = {
setRequestHandler: jest.fn(),
};
// Mock transport instances
const mockSSETransport: {
start: jest.Mock;
url: URL | undefined;
options: SSEClientTransportOptions | undefined;
} = {
start: jest.fn(),
url: undefined,
options: undefined,
};
const mockStreamableHTTPTransport: {
start: jest.Mock;
url: URL | undefined;
options: SSEClientTransportOptions | undefined;
} = {
start: jest.fn(),
url: undefined,
options: undefined,
};
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: jest.fn().mockImplementation(() => mockClient),
}));
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: jest.fn((url) => ({
toString: () => url,
})),
SSEClientTransport: jest.fn((url, options) => {
mockSSETransport.url = url;
mockSSETransport.options = options;
return mockSSETransport;
}),
SseError: jest.fn(),
}));
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: jest.fn((url) => ({
toString: () => url,
})),
StreamableHTTPClientTransport: jest.fn((url, options) => {
mockStreamableHTTPTransport.url = url;
mockStreamableHTTPTransport.options = options;
return mockStreamableHTTPTransport;
}),
}));
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
@ -259,4 +285,181 @@ describe("useConnection", () => {
);
});
});
describe("Proxy Authentication Headers", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock transport objects
mockSSETransport.url = undefined;
mockSSETransport.options = undefined;
mockStreamableHTTPTransport.url = undefined;
mockStreamableHTTPTransport.options = undefined;
});
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", async () => {
const propsWithProxyAuth = {
...defaultProps,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
await act(async () => {
await result.current.connect();
});
// Check that the transport was created with the correct headers
expect(mockSSETransport.options).toBeDefined();
expect(mockSSETransport.options?.requestInit).toBeDefined();
expect(mockSSETransport.options?.requestInit?.headers).toHaveProperty(
"X-MCP-Proxy-Auth",
"Bearer test-proxy-token",
);
expect(mockSSETransport?.options?.eventSourceInit?.fetch).toBeDefined();
// Verify the fetch function includes the proxy auth header
const mockFetch = mockSSETransport.options?.eventSourceInit?.fetch;
const testUrl = "http://test.com";
await mockFetch?.(testUrl, {
headers: {
Accept: "text/event-stream",
},
cache: "no-store",
mode: "cors",
signal: new AbortController().signal,
redirect: "follow",
credentials: "include",
});
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(
(global.fetch as jest.Mock).mock.calls[0][1].headers,
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe(testUrl);
expect(
(global.fetch as jest.Mock).mock.calls[1][1].headers,
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
});
test("does NOT send Authorization header for proxy auth", async () => {
const propsWithProxyAuth = {
...defaultProps,
config: {
...DEFAULT_INSPECTOR_CONFIG,
proxyAuthToken: "test-proxy-token",
},
};
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
await act(async () => {
await result.current.connect();
});
// Check that Authorization header is NOT used for proxy auth
expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty(
"Authorization",
"Bearer test-proxy-token",
);
});
test("preserves server Authorization header when proxy auth is configured", async () => {
const propsWithBothAuth = {
...defaultProps,
bearerToken: "server-auth-token",
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};
const { result } = renderHook(() => useConnection(propsWithBothAuth));
await act(async () => {
await result.current.connect();
});
// Check that both headers are present and distinct
const headers = mockSSETransport.options?.requestInit?.headers;
expect(headers).toHaveProperty(
"Authorization",
"Bearer server-auth-token",
);
expect(headers).toHaveProperty(
"X-MCP-Proxy-Auth",
"Bearer test-proxy-token",
);
});
test("sends X-MCP-Proxy-Auth in health check requests", async () => {
const fetchMock = global.fetch as jest.Mock;
fetchMock.mockClear();
const propsWithProxyAuth = {
...defaultProps,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
await act(async () => {
await result.current.connect();
});
// Find the health check call
const healthCheckCall = fetchMock.mock.calls.find(
(call) => call[0].pathname === "/health",
);
expect(healthCheckCall).toBeDefined();
expect(healthCheckCall[1].headers).toHaveProperty(
"X-MCP-Proxy-Auth",
"Bearer test-proxy-token",
);
});
test("works correctly with streamable-http transport", async () => {
const propsWithStreamableHttp = {
...defaultProps,
transportType: "streamable-http" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};
const { result } = renderHook(() =>
useConnection(propsWithStreamableHttp),
);
await act(async () => {
await result.current.connect();
});
// Check that the streamable HTTP transport was created with the correct headers
expect(mockStreamableHTTPTransport.options).toBeDefined();
expect(
mockStreamableHTTPTransport.options?.requestInit?.headers,
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
});
});
});

View file

@ -42,6 +42,7 @@ import {
getMCPProxyAddress,
getMCPServerRequestMaxTotalTimeout,
resetRequestTimeoutOnProgress,
getMCPProxyAuthToken,
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
@ -242,7 +243,13 @@ export function useConnection({
const checkProxyHealth = async () => {
try {
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
const proxyHealthResponse = await fetch(proxyHealthUrl);
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
const headers: HeadersInit = {};
if (proxyAuthToken) {
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
const proxyHealthResponse = await fetch(proxyHealthUrl, { headers });
const proxyHealth = await proxyHealthResponse.json();
if (proxyHealth?.status !== "ok") {
throw new Error("MCP Proxy Server is not healthy");
@ -261,6 +268,13 @@ export function useConnection({
);
};
const isProxyAuthError = (error: unknown): boolean => {
return (
error instanceof Error &&
error.message.includes("Authentication required. Use the session token")
);
};
const handleAuthError = async (error: unknown) => {
if (is401Error(error)) {
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
@ -318,6 +332,14 @@ export function useConnection({
}
}
// Add proxy authentication
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
const proxyHeaders: HeadersInit = {};
if (proxyAuthToken) {
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
// Create appropriate transport
let transportOptions:
| StreamableHTTPClientTransportOptions
@ -335,11 +357,15 @@ export function useConnection({
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers,
headers: { ...headers, ...proxyHeaders },
},
};
break;
@ -351,11 +377,15 @@ export function useConnection({
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers,
headers: { ...headers, ...proxyHeaders },
},
};
break;
@ -367,11 +397,15 @@ export function useConnection({
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers,
headers: { ...headers, ...proxyHeaders },
},
// TODO these should be configurable...
reconnectionOptions: {
@ -447,6 +481,18 @@ export function useConnection({
error,
);
// Check if it's a proxy auth error
if (isProxyAuthError(error)) {
toast({
title: "Proxy Authentication Required",
description:
"Please enter the session token from the proxy server console in the Configuration settings.",
variant: "destructive",
});
setConnectionStatus("error");
return;
}
const shouldRetry = await handleAuthError(error);
if (shouldRetry) {
return connect(undefined, retryCount + 1);

View file

@ -0,0 +1,72 @@
import { getMCPProxyAuthToken } from "../configUtils";
import { DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants";
import { InspectorConfig } from "../../lib/configurationTypes";
describe("configUtils", () => {
describe("getMCPProxyAuthToken", () => {
test("returns token and default header name", () => {
const config: InspectorConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-token-123",
},
};
const result = getMCPProxyAuthToken(config);
expect(result).toEqual({
token: "test-token-123",
header: "X-MCP-Proxy-Auth",
});
});
test("returns empty token when not configured", () => {
const config: InspectorConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "",
},
};
const result = getMCPProxyAuthToken(config);
expect(result).toEqual({
token: "",
header: "X-MCP-Proxy-Auth",
});
});
test("always returns X-MCP-Proxy-Auth as header name", () => {
const config: InspectorConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "any-token",
},
};
const result = getMCPProxyAuthToken(config);
expect(result.header).toBe("X-MCP-Proxy-Auth");
});
test("handles null/undefined value gracefully", () => {
const config: InspectorConfig = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: null as unknown as string,
},
};
const result = getMCPProxyAuthToken(config);
expect(result).toEqual({
token: null,
header: "X-MCP-Proxy-Auth",
});
});
});
});

View file

@ -28,6 +28,18 @@ export const getMCPServerRequestMaxTotalTimeout = (
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
};
export const getMCPProxyAuthToken = (
config: InspectorConfig,
): {
token: string;
header: string;
} => {
return {
token: config.MCP_PROXY_AUTH_TOKEN.value as string,
header: "X-MCP-Proxy-Auth",
};
};
const getSearchParam = (key: string): string | null => {
try {
const url = new URL(window.location.href);
@ -100,27 +112,67 @@ export const getConfigOverridesFromQueryParams = (
export const initializeInspectorConfig = (
localStorageKey: string,
): InspectorConfig => {
const savedConfig = localStorage.getItem(localStorageKey);
let baseConfig: InspectorConfig;
if (savedConfig) {
// merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
// Read persistent config from localStorage
const savedPersistentConfig = localStorage.getItem(localStorageKey);
// Read ephemeral config from sessionStorage
const savedEphemeralConfig = sessionStorage.getItem(
`${localStorageKey}_ephemeral`,
);
// update description of keys to match the new description (in case of any updates to the default config description)
for (const [key, value] of Object.entries(mergedConfig)) {
mergedConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
};
}
baseConfig = mergedConfig;
} else {
baseConfig = DEFAULT_INSPECTOR_CONFIG;
// Start with default config
let baseConfig = { ...DEFAULT_INSPECTOR_CONFIG };
// Apply saved persistent config
if (savedPersistentConfig) {
const parsedPersistentConfig = JSON.parse(savedPersistentConfig);
baseConfig = { ...baseConfig, ...parsedPersistentConfig };
}
// Apply saved ephemeral config
if (savedEphemeralConfig) {
const parsedEphemeralConfig = JSON.parse(savedEphemeralConfig);
baseConfig = { ...baseConfig, ...parsedEphemeralConfig };
}
// Ensure all config items have the latest labels/descriptions from defaults
for (const [key, value] of Object.entries(baseConfig)) {
baseConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
description:
DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].description,
is_session_item:
DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].is_session_item,
};
}
// Apply query param overrides
const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG);
return { ...baseConfig, ...overrides };
};
export const saveInspectorConfig = (
localStorageKey: string,
config: InspectorConfig,
): void => {
const persistentConfig: Partial<InspectorConfig> = {};
const ephemeralConfig: Partial<InspectorConfig> = {};
// Split config based on is_session_item flag
for (const [key, value] of Object.entries(config)) {
if (value.is_session_item) {
ephemeralConfig[key as keyof InspectorConfig] = value;
} else {
persistentConfig[key as keyof InspectorConfig] = value;
}
}
// Save persistent config to localStorage
localStorage.setItem(localStorageKey, JSON.stringify(persistentConfig));
// Save ephemeral config to sessionStorage
sessionStorage.setItem(
`${localStorageKey}_ephemeral`,
JSON.stringify(ephemeralConfig),
);
};