Merge pull request #509 from modelcontextprotocol/jerome/fix/proxy-token-mixup

fix: use X-MCP-Proxy-Auth header for proxy authentication
This commit is contained in:
Jerome 2025-06-14 19:31:10 +01:00 • committed by GitHub
commit 752a41522f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 553 additions and 21 deletions

View file

@ -347,9 +347,10 @@ const App = () => {
useEffect(() => {
const headers: HeadersInit = {};
const proxyAuthToken = getMCPProxyAuthToken(config);
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
if (proxyAuthToken) {
headers["Authorization"] = `Bearer ${proxyAuthToken}`;
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
fetch(`${getMCPProxyAddress(config)}/config`, { headers })

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

@ -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

@ -243,10 +243,11 @@ export function useConnection({
const checkProxyHealth = async () => {
try {
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
const proxyAuthToken = getMCPProxyAuthToken(config);
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
const headers: HeadersInit = {};
if (proxyAuthToken) {
headers["Authorization"] = `Bearer ${proxyAuthToken}`;
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
const proxyHealthResponse = await fetch(proxyHealthUrl, { headers });
const proxyHealth = await proxyHealthResponse.json();
@ -332,10 +333,11 @@ export function useConnection({
}
// Add proxy authentication
const proxyAuthToken = getMCPProxyAuthToken(config);
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
const proxyHeaders: HeadersInit = {};
if (proxyAuthToken) {
proxyHeaders["Authorization"] = `Bearer ${proxyAuthToken}`;
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
// Create appropriate transport
@ -355,7 +357,7 @@ export function useConnection({
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
init?: RequestInit,
) =>
fetch(url, {
...init,
@ -375,7 +377,7 @@ export function useConnection({
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
init?: RequestInit,
) =>
fetch(url, {
...init,
@ -395,7 +397,7 @@ export function useConnection({
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
init?: RequestInit,
) =>
fetch(url, {
...init,

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,8 +28,16 @@ export const getMCPServerRequestMaxTotalTimeout = (
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
};
export const getMCPProxyAuthToken = (config: InspectorConfig): string => {
return config.MCP_PROXY_AUTH_TOKEN.value as string;
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 => {

View file

@ -138,13 +138,17 @@ const authMiddleware = (
});
};
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
const authHeader = req.headers["x-mcp-proxy-auth"];
const authHeaderValue = Array.isArray(authHeader)
? authHeader[0]
: authHeader;
if (!authHeaderValue || !authHeaderValue.startsWith("Bearer ")) {
sendUnauthorized();
return;
}
const providedToken = authHeader.substring(7); // Remove 'Bearer ' prefix
const providedToken = authHeaderValue.substring(7); // Remove 'Bearer ' prefix
const expectedToken = sessionToken;
// Convert to buffers for timing-safe comparison
@ -196,7 +200,9 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
const headers = getHttpHeaders(req, transportType);
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
console.log(
`SSE transport: url=${url}, headers=${JSON.stringify(headers)}`,
);
const transport = new SSEClientTransport(new URL(url), {
eventSourceInit: {