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:
commit
752a41522f
7 changed files with 553 additions and 21 deletions
|
|
@ -347,9 +347,10 @@ const App = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const headers: HeadersInit = {};
|
const headers: HeadersInit = {};
|
||||||
const proxyAuthToken = getMCPProxyAuthToken(config);
|
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||||
|
getMCPProxyAuthToken(config);
|
||||||
if (proxyAuthToken) {
|
if (proxyAuthToken) {
|
||||||
headers["Authorization"] = `Bearer ${proxyAuthToken}`;
|
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
|
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
|
||||||
|
|
|
||||||
240
client/src/__tests__/App.config.test.tsx
Normal file
240
client/src/__tests__/App.config.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import { useConnection } from "../useConnection";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
||||||
|
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
|
@ -23,21 +24,46 @@ const mockClient = {
|
||||||
setRequestHandler: jest.fn(),
|
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", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||||
Client: jest.fn().mockImplementation(() => mockClient),
|
Client: jest.fn().mockImplementation(() => mockClient),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||||
SSEClientTransport: jest.fn((url) => ({
|
SSEClientTransport: jest.fn((url, options) => {
|
||||||
toString: () => url,
|
mockSSETransport.url = url;
|
||||||
})),
|
mockSSETransport.options = options;
|
||||||
|
return mockSSETransport;
|
||||||
|
}),
|
||||||
SseError: jest.fn(),
|
SseError: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
StreamableHTTPClientTransport: jest.fn((url) => ({
|
StreamableHTTPClientTransport: jest.fn((url, options) => {
|
||||||
toString: () => url,
|
mockStreamableHTTPTransport.url = url;
|
||||||
})),
|
mockStreamableHTTPTransport.options = options;
|
||||||
|
return mockStreamableHTTPTransport;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -243,10 +243,11 @@ export function useConnection({
|
||||||
const checkProxyHealth = async () => {
|
const checkProxyHealth = async () => {
|
||||||
try {
|
try {
|
||||||
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
|
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
|
||||||
const proxyAuthToken = getMCPProxyAuthToken(config);
|
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||||
|
getMCPProxyAuthToken(config);
|
||||||
const headers: HeadersInit = {};
|
const headers: HeadersInit = {};
|
||||||
if (proxyAuthToken) {
|
if (proxyAuthToken) {
|
||||||
headers["Authorization"] = `Bearer ${proxyAuthToken}`;
|
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||||
}
|
}
|
||||||
const proxyHealthResponse = await fetch(proxyHealthUrl, { headers });
|
const proxyHealthResponse = await fetch(proxyHealthUrl, { headers });
|
||||||
const proxyHealth = await proxyHealthResponse.json();
|
const proxyHealth = await proxyHealthResponse.json();
|
||||||
|
|
@ -332,10 +333,11 @@ export function useConnection({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add proxy authentication
|
// Add proxy authentication
|
||||||
const proxyAuthToken = getMCPProxyAuthToken(config);
|
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||||
|
getMCPProxyAuthToken(config);
|
||||||
const proxyHeaders: HeadersInit = {};
|
const proxyHeaders: HeadersInit = {};
|
||||||
if (proxyAuthToken) {
|
if (proxyAuthToken) {
|
||||||
proxyHeaders["Authorization"] = `Bearer ${proxyAuthToken}`;
|
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create appropriate transport
|
// Create appropriate transport
|
||||||
|
|
@ -355,7 +357,7 @@ export function useConnection({
|
||||||
eventSourceInit: {
|
eventSourceInit: {
|
||||||
fetch: (
|
fetch: (
|
||||||
url: string | URL | globalThis.Request,
|
url: string | URL | globalThis.Request,
|
||||||
init: RequestInit | undefined,
|
init?: RequestInit,
|
||||||
) =>
|
) =>
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
|
@ -375,7 +377,7 @@ export function useConnection({
|
||||||
eventSourceInit: {
|
eventSourceInit: {
|
||||||
fetch: (
|
fetch: (
|
||||||
url: string | URL | globalThis.Request,
|
url: string | URL | globalThis.Request,
|
||||||
init: RequestInit | undefined,
|
init?: RequestInit,
|
||||||
) =>
|
) =>
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
|
@ -395,7 +397,7 @@ export function useConnection({
|
||||||
eventSourceInit: {
|
eventSourceInit: {
|
||||||
fetch: (
|
fetch: (
|
||||||
url: string | URL | globalThis.Request,
|
url: string | URL | globalThis.Request,
|
||||||
init: RequestInit | undefined,
|
init?: RequestInit,
|
||||||
) =>
|
) =>
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
|
|
||||||
72
client/src/utils/__tests__/configUtils.test.ts
Normal file
72
client/src/utils/__tests__/configUtils.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,8 +28,16 @@ export const getMCPServerRequestMaxTotalTimeout = (
|
||||||
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
|
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMCPProxyAuthToken = (config: InspectorConfig): string => {
|
export const getMCPProxyAuthToken = (
|
||||||
return config.MCP_PROXY_AUTH_TOKEN.value as string;
|
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 => {
|
const getSearchParam = (key: string): string | null => {
|
||||||
|
|
|
||||||
|
|
@ -138,13 +138,17 @@ const authMiddleware = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers["x-mcp-proxy-auth"];
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
const authHeaderValue = Array.isArray(authHeader)
|
||||||
|
? authHeader[0]
|
||||||
|
: authHeader;
|
||||||
|
|
||||||
|
if (!authHeaderValue || !authHeaderValue.startsWith("Bearer ")) {
|
||||||
sendUnauthorized();
|
sendUnauthorized();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providedToken = authHeader.substring(7); // Remove 'Bearer ' prefix
|
const providedToken = authHeaderValue.substring(7); // Remove 'Bearer ' prefix
|
||||||
const expectedToken = sessionToken;
|
const expectedToken = sessionToken;
|
||||||
|
|
||||||
// Convert to buffers for timing-safe comparison
|
// Convert to buffers for timing-safe comparison
|
||||||
|
|
@ -196,7 +200,9 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
|
|
||||||
const headers = getHttpHeaders(req, transportType);
|
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), {
|
const transport = new SSEClientTransport(new URL(url), {
|
||||||
eventSourceInit: {
|
eventSourceInit: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue