import { renderHook, act } from "@testing-library/react"; 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"; import { ElicitResult, ElicitRequest, } from "@modelcontextprotocol/sdk/types.js"; // Mock fetch global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ status: "ok" }), }); // Mock the SDK dependencies const mockRequest = jest.fn().mockResolvedValue({ test: "response" }); const mockClient = { request: mockRequest, notification: jest.fn(), connect: jest.fn().mockResolvedValue(undefined), close: jest.fn(), getServerCapabilities: jest.fn(), getServerVersion: jest.fn(), getInstructions: jest.fn(), setNotificationHandler: 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", () => ({ Client: jest.fn().mockImplementation(() => mockClient), })); jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ 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, options) => { mockStreamableHTTPTransport.url = url; mockStreamableHTTPTransport.options = options; return mockStreamableHTTPTransport; }), })); jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn().mockResolvedValue("AUTHORIZED"), })); // Mock the toast hook jest.mock("@/lib/hooks/useToast", () => ({ useToast: () => ({ toast: jest.fn(), }), })); // Mock the auth provider jest.mock("../../auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }), })), clearClientInformationFromSessionStorage: jest.fn(), saveClientInformationToSessionStorage: jest.fn(), })); describe("useConnection", () => { const defaultProps = { transportType: "sse" as const, command: "", args: "", sseUrl: "http://localhost:8080", env: {}, config: DEFAULT_INSPECTOR_CONFIG, }; describe("Request Configuration", () => { beforeEach(() => { jest.clearAllMocks(); }); test("uses the default config values in makeRequest", async () => { const { result } = renderHook(() => useConnection(defaultProps)); // Connect the client await act(async () => { await result.current.connect(); }); // Wait for state update await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); const mockRequest: ClientRequest = { method: "ping", params: {}, }; const mockSchema = z.object({ test: z.string(), }); await act(async () => { await result.current.makeRequest(mockRequest, mockSchema); }); expect(mockClient.request).toHaveBeenCalledWith( mockRequest, mockSchema, expect.objectContaining({ timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value, maxTotalTimeout: DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value, resetTimeoutOnProgress: DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS .value, }), ); }); test("overrides the default config values when passed in options in makeRequest", async () => { const { result } = renderHook(() => useConnection(defaultProps)); // Connect the client await act(async () => { await result.current.connect(); }); // Wait for state update await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); const mockRequest: ClientRequest = { method: "ping", params: {}, }; const mockSchema = z.object({ test: z.string(), }); await act(async () => { await result.current.makeRequest(mockRequest, mockSchema, { timeout: 1000, maxTotalTimeout: 2000, resetTimeoutOnProgress: false, }); }); expect(mockClient.request).toHaveBeenCalledWith( mockRequest, mockSchema, expect.objectContaining({ timeout: 1000, maxTotalTimeout: 2000, resetTimeoutOnProgress: false, }), ); }); }); test("throws error when mcpClient is not connected", async () => { const { result } = renderHook(() => useConnection(defaultProps)); const mockRequest: ClientRequest = { method: "ping", params: {}, }; const mockSchema = z.object({ test: z.string(), }); await expect( result.current.makeRequest(mockRequest, mockSchema), ).rejects.toThrow("MCP client not connected"); }); describe("Elicitation Support", () => { beforeEach(() => { jest.clearAllMocks(); }); test("declares elicitation capability during client initialization", async () => { const Client = jest.requireMock( "@modelcontextprotocol/sdk/client/index.js", ).Client; const { result } = renderHook(() => useConnection(defaultProps)); await act(async () => { await result.current.connect(); }); expect(Client).toHaveBeenCalledWith( expect.objectContaining({ name: "mcp-inspector", version: expect.any(String), }), expect.objectContaining({ capabilities: expect.objectContaining({ elicitation: {}, }), }), ); }); test("sets up elicitation request handler when onElicitationRequest is provided", async () => { const mockOnElicitationRequest = jest.fn(); const propsWithElicitation = { ...defaultProps, onElicitationRequest: mockOnElicitationRequest, }; const { result } = renderHook(() => useConnection(propsWithElicitation)); await act(async () => { await result.current.connect(); }); const elicitRequestHandlerCall = mockClient.setRequestHandler.mock.calls.find((call) => { try { const schema = call[0]; const testRequest = { method: "elicitation/create", params: { message: "test message", requestedSchema: { type: "object", properties: { name: { type: "string" }, }, }, }, }; const parseResult = schema.safeParse && schema.safeParse(testRequest); return parseResult?.success; } catch { return false; } }); expect(elicitRequestHandlerCall).toBeDefined(); expect(mockClient.setRequestHandler).toHaveBeenCalledWith( expect.any(Object), expect.any(Function), ); }); test("does not set up elicitation request handler when onElicitationRequest is not provided", async () => { const { result } = renderHook(() => useConnection(defaultProps)); await act(async () => { await result.current.connect(); }); const elicitRequestHandlerCall = mockClient.setRequestHandler.mock.calls.find((call) => { try { const schema = call[0]; const testRequest = { method: "elicitation/create", params: { message: "test message", requestedSchema: { type: "object", properties: { name: { type: "string" }, }, }, }, }; const parseResult = schema.safeParse && schema.safeParse(testRequest); return parseResult?.success; } catch { return false; } }); expect(elicitRequestHandlerCall).toBeUndefined(); }); test("elicitation request handler calls onElicitationRequest callback", async () => { const mockOnElicitationRequest = jest.fn(); const propsWithElicitation = { ...defaultProps, onElicitationRequest: mockOnElicitationRequest, }; const { result } = renderHook(() => useConnection(propsWithElicitation)); await act(async () => { await result.current.connect(); }); const elicitRequestHandlerCall = mockClient.setRequestHandler.mock.calls.find((call) => { try { const schema = call[0]; const testRequest = { method: "elicitation/create", params: { message: "test message", requestedSchema: { type: "object", properties: { name: { type: "string" }, }, }, }, }; const parseResult = schema.safeParse && schema.safeParse(testRequest); return parseResult?.success; } catch { return false; } }); expect(elicitRequestHandlerCall).toBeDefined(); const [, handler] = elicitRequestHandlerCall; const mockElicitationRequest: ElicitRequest = { method: "elicitation/create", params: { message: "Please provide your name", requestedSchema: { type: "object", properties: { name: { type: "string" }, }, required: ["name"], }, }, }; mockOnElicitationRequest.mockImplementation((_request, resolve) => { resolve({ action: "accept", content: { name: "test" } }); }); await act(async () => { await handler(mockElicitationRequest); }); expect(mockOnElicitationRequest).toHaveBeenCalledWith( mockElicitationRequest, expect.any(Function), ); }); test("elicitation request handler returns a promise that resolves with the callback result", async () => { const mockOnElicitationRequest = jest.fn(); const propsWithElicitation = { ...defaultProps, onElicitationRequest: mockOnElicitationRequest, }; const { result } = renderHook(() => useConnection(propsWithElicitation)); await act(async () => { await result.current.connect(); }); const elicitRequestHandlerCall = mockClient.setRequestHandler.mock.calls.find((call) => { try { const schema = call[0]; const testRequest = { method: "elicitation/create", params: { message: "test message", requestedSchema: { type: "object", properties: { name: { type: "string" }, }, }, }, }; const parseResult = schema.safeParse && schema.safeParse(testRequest); return parseResult?.success; } catch { return false; } }); const [, handler] = elicitRequestHandlerCall; const mockElicitationRequest: ElicitRequest = { method: "elicitation/create", params: { message: "Please provide your name", requestedSchema: { type: "object", properties: { name: { type: "string" }, }, required: ["name"], }, }, }; const mockResponse: ElicitResult = { action: "accept", content: { name: "John Doe" }, }; mockOnElicitationRequest.mockImplementation((_request, resolve) => { resolve(mockResponse); }); let handlerResult; await act(async () => { handlerResult = await handler(mockElicitationRequest); }); expect(handlerResult).toEqual(mockResponse); }); }); describe("URL Port Handling", () => { const SSEClientTransport = jest.requireMock( "@modelcontextprotocol/sdk/client/sse.js", ).SSEClientTransport; const StreamableHTTPClientTransport = jest.requireMock( "@modelcontextprotocol/sdk/client/streamableHttp.js", ).StreamableHTTPClientTransport; beforeEach(() => { jest.clearAllMocks(); }); test("preserves HTTPS port number when connecting", async () => { const props = { ...defaultProps, sseUrl: "https://example.com:8443/api", transportType: "sse" as const, }; const { result } = renderHook(() => useConnection(props)); await act(async () => { await result.current.connect(); }); const call = SSEClientTransport.mock.calls[0][0]; expect(call.toString()).toContain( "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", ); }); test("preserves HTTP port number when connecting", async () => { const props = { ...defaultProps, sseUrl: "http://localhost:3000/api", transportType: "sse" as const, }; const { result } = renderHook(() => useConnection(props)); await act(async () => { await result.current.connect(); }); const call = SSEClientTransport.mock.calls[0][0]; expect(call.toString()).toContain( "url=http%3A%2F%2Flocalhost%3A3000%2Fapi", ); }); test("uses default port for HTTPS when not specified", async () => { const props = { ...defaultProps, sseUrl: "https://example.com/api", transportType: "sse" as const, }; const { result } = renderHook(() => useConnection(props)); await act(async () => { await result.current.connect(); }); const call = SSEClientTransport.mock.calls[0][0]; expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi"); expect(call.toString()).not.toContain("%3A443"); }); test("preserves port number in streamable-http transport", async () => { const props = { ...defaultProps, sseUrl: "https://example.com:8443/api", transportType: "streamable-http" as const, }; const { result } = renderHook(() => useConnection(props)); await act(async () => { await result.current.connect(); }); const call = StreamableHTTPClientTransport.mock.calls[0][0]; expect(call.toString()).toContain( "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", ); }); }); 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"); }); }); });