From 37cff101b197760cdc923fd964b7f2ff64587ac8 Mon Sep 17 00:00:00 2001 From: Jerome Date: Sat, 14 Jun 2025 17:20:30 +0100 Subject: [PATCH] test: add tests for X-MCP-Proxy-Auth header implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added comprehensive test coverage for proxy authentication using X-MCP-Proxy-Auth header - Tests verify proper header separation between proxy and server authentication - Fixed transport mocks to properly capture options and headers - Updated config structure in tests to match production code - All proxy auth tests now passing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../hooks/__tests__/useConnection.test.tsx | 206 +++++++++++++++++- 1 file changed, 200 insertions(+), 6 deletions(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 6e5748d7..91833f60 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -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,172 @@ 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); + + 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"); + }); + }); });