diff --git a/client/src/App.tsx b/client/src/App.tsx index 4c35b0ec1..6c9ae3331 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -107,6 +107,14 @@ const App = () => { const [transportType, setTransportType] = useState< "stdio" | "sse" | "streamable-http" >(getInitialTransportType); + const [connectionType, setConnectionType] = useState<"direct" | "proxy">( + () => { + return ( + (localStorage.getItem("lastConnectionType") as "direct" | "proxy") || + "proxy" + ); + }, + ); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [roots, setRoots] = useState([]); @@ -254,6 +262,7 @@ const App = () => { oauthClientId, oauthScope, config, + connectionType, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, @@ -338,6 +347,10 @@ const App = () => { localStorage.setItem("lastTransportType", transportType); }, [transportType]); + useEffect(() => { + localStorage.setItem("lastConnectionType", connectionType); + }, [connectionType]); + useEffect(() => { if (bearerToken) { localStorage.setItem("lastBearerToken", bearerToken); @@ -882,6 +895,8 @@ const App = () => { logLevel={logLevel} sendLogLevelRequest={sendLogLevelRequest} loggingSupported={!!serverCapabilities?.logging || false} + connectionType={connectionType} + setConnectionType={setConnectionType} />
void; + connectionType: "direct" | "proxy"; + setConnectionType: (type: "direct" | "proxy") => void; } const Sidebar = ({ @@ -94,6 +96,8 @@ const Sidebar = ({ loggingSupported, config, setConfig, + connectionType, + setConnectionType, }: SidebarProps) => { const [theme, setTheme] = useTheme(); const [showEnvVars, setShowEnvVars] = useState(false); @@ -104,6 +108,8 @@ const Sidebar = ({ const [copiedServerFile, setCopiedServerFile] = useState(false); const { toast } = useToast(); + const connectionTypeTip = + "Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy"; // Reusable error reporter for copy actions const reportError = useCallback( (error: unknown) => { @@ -313,6 +319,35 @@ const Sidebar = ({ /> )}
+ + {/* Connection Type switch - only visible for non-STDIO transport types */} + + +
+ + +
+
+ {connectionTypeTip} +
)} diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 3a23daafb..4684eb63b 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -59,6 +59,8 @@ describe("Sidebar", () => { loggingSupported: true, config: DEFAULT_INSPECTOR_CONFIG, setConfig: jest.fn(), + connectionType: "proxy" as const, + setConnectionType: jest.fn(), }; const renderSidebar = (props = {}) => { diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index a94f2386a..b16eaa3d0 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -18,6 +18,9 @@ import { CustomHeaders } from "../../types/customHeaders"; // Mock fetch global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ status: "ok" }), + headers: { + get: jest.fn().mockReturnValue(null), + }, }); // Mock the SDK dependencies @@ -574,9 +577,10 @@ describe("useConnection", () => { mockStreamableHTTPTransport.options = undefined; }); - test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", async () => { + test("sends X-MCP-Proxy-Auth header when proxy auth token is configured for proxy connectionType", async () => { const propsWithProxyAuth = { ...defaultProps, + connectionType: "proxy" as const, config: { ...DEFAULT_INSPECTOR_CONFIG, MCP_PROXY_AUTH_TOKEN: { @@ -626,6 +630,56 @@ describe("useConnection", () => { ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); }); + test("does NOT send X-MCP-Proxy-Auth header when proxy auth token is configured for direct connectionType", async () => { + const propsWithProxyAuth = { + ...defaultProps, + connectionType: "direct" 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(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(); + + // Verify that X-MCP-Proxy-Auth header is NOT present for direct connections + expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty( + "X-MCP-Proxy-Auth", + ); + expect(mockSSETransport?.options?.fetch).toBeDefined(); + + // Verify the fetch function does NOT include the proxy auth header + const mockFetch = mockSSETransport.options?.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(1); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(testUrl); + expect( + (global.fetch as jest.Mock).mock.calls[0][1].headers, + ).not.toHaveProperty("X-MCP-Proxy-Auth"); + }); + test("does NOT send Authorization header for proxy auth", async () => { const propsWithProxyAuth = { ...defaultProps, @@ -882,6 +936,57 @@ describe("useConnection", () => { }); }); + describe("Connection URL Verification", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the mock transport objects + mockSSETransport.url = undefined; + mockSSETransport.options = undefined; + mockStreamableHTTPTransport.url = undefined; + mockStreamableHTTPTransport.options = undefined; + }); + + test("uses server URL directly when connectionType is 'direct'", async () => { + const directProps = { + ...defaultProps, + connectionType: "direct" as const, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + // Verify the transport was created with the direct server URL + expect(mockSSETransport.url).toBeDefined(); + expect(mockSSETransport.url?.toString()).toBe("http://localhost:8080/"); + }); + + test("uses proxy server URL when connectionType is 'proxy'", async () => { + const proxyProps = { + ...defaultProps, + connectionType: "proxy" as const, + }; + + const { result } = renderHook(() => useConnection(proxyProps)); + + await act(async () => { + await result.current.connect(); + }); + + // Verify the transport was created with a proxy server URL + expect(mockSSETransport.url).toBeDefined(); + expect(mockSSETransport.url?.pathname).toBe("/sse"); + expect(mockSSETransport.url?.searchParams.get("url")).toBe( + "http://localhost:8080", + ); + expect(mockSSETransport.url?.searchParams.get("transportType")).toBe( + "sse", + ); + }); + }); + describe("OAuth Error Handling with Scope Discovery", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index f47f0d15d..1341b0c2b 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -70,6 +70,7 @@ interface UseConnectionOptions { oauthClientId?: string; oauthScope?: string; config: InspectorConfig; + connectionType?: "direct" | "proxy"; onNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -91,6 +92,7 @@ export function useConnection({ oauthClientId, oauthScope, config, + connectionType = "proxy", onNotification, onPendingRequest, onElicitationRequest, @@ -110,6 +112,10 @@ export function useConnection({ { request: string; response?: string }[] >([]); const [completionsSupported, setCompletionsSupported] = useState(false); + const [mcpSessionId, setMcpSessionId] = useState(null); + const [mcpProtocolVersion, setMcpProtocolVersion] = useState( + null, + ); useEffect(() => { if (!oauthClientId) { @@ -346,6 +352,17 @@ export function useConnection({ return false; }; + const captureResponseHeaders = (response: Response): void => { + const sessionId = response.headers.get("mcp-session-id"); + const protocolVersion = response.headers.get("mcp-protocol-version"); + if (sessionId && sessionId !== mcpSessionId) { + setMcpSessionId(sessionId); + } + if (protocolVersion && protocolVersion !== mcpProtocolVersion) { + setMcpProtocolVersion(protocolVersion); + } + }; + const connect = async (_e?: unknown, retryCount: number = 0) => { const client = new Client( { @@ -363,11 +380,14 @@ export function useConnection({ }, ); - try { - await checkProxyHealth(); - } catch { - setConnectionStatus("error-connecting-to-proxy"); - return; + // Only check proxy health for proxy connections + if (connectionType === "proxy") { + try { + await checkProxyHealth(); + } catch { + setConnectionStatus("error-connecting-to-proxy"); + return; + } } let lastRequest = ""; @@ -417,115 +437,183 @@ export function useConnection({ headers["x-custom-auth-headers"] = JSON.stringify(customHeaderNames); } - // 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 | SSEClientTransportOptions; - let mcpProxyServerUrl; - switch (transportType) { - case "stdio": { - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); - mcpProxyServerUrl.searchParams.append("command", command); - mcpProxyServerUrl.searchParams.append("args", args); - mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); - - const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS - .value as string; - if (proxyFullAddress) { - mcpProxyServerUrl.searchParams.append( - "proxyFullAddress", - proxyFullAddress, - ); - } - transportOptions = { - authProvider: serverAuthProvider, - eventSourceInit: { - fetch: ( + let serverUrl: URL; + + // Determine connection URL based on the connection type + if (connectionType === "direct" && transportType !== "stdio") { + // Direct connection - use the provided URL directly (not available for STDIO) + serverUrl = new URL(sseUrl); + + const requestHeaders = { ...headers }; + if (mcpSessionId) { + requestHeaders["mcp-session-id"] = mcpSessionId; + } + switch (transportType) { + case "sse": + requestHeaders["Accept"] = "text/event-stream"; + requestHeaders["content-type"] = "application/json"; + transportOptions = { + fetch: async ( url: string | URL | globalThis.Request, init?: RequestInit, - ) => - fetch(url, { + ) => { + const response = await fetch(url, { ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - }; - break; - } - - case "sse": { - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); - - const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS - .value as string; - if (proxyFullAddressSSE) { - mcpProxyServerUrl.searchParams.append( - "proxyFullAddress", - proxyFullAddressSSE, - ); - } - transportOptions = { - eventSourceInit: { - fetch: ( + headers: requestHeaders, + }); + + // Capture protocol-related headers from response + captureResponseHeaders(response); + return response; + }, + requestInit: { + headers: requestHeaders, + }, + }; + break; + + case "streamable-http": + transportOptions = { + fetch: async ( url: string | URL | globalThis.Request, init?: RequestInit, - ) => - fetch(url, { + ) => { + requestHeaders["Accept"] = + "text/event-stream, application/json"; + requestHeaders["Content-Type"] = "application/json"; + const response = await fetch(url, { + headers: requestHeaders, ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - }; - break; + }); + + // Capture protocol-related headers from response + captureResponseHeaders(response); + + return response; + }, + requestInit: { + headers: requestHeaders, + }, + // TODO these should be configurable... + reconnectionOptions: { + maxReconnectionDelay: 30000, + initialReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }, + }; + break; + } + } else { + // Proxy connection (default behavior) + // Add proxy authentication headers for proxy connections only + const { token: proxyAuthToken, header: proxyAuthTokenHeader } = + getMCPProxyAuthToken(config); + const proxyHeaders: HeadersInit = {}; + if (proxyAuthToken) { + proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; } - case "streamable-http": - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); - transportOptions = { - eventSourceInit: { - fetch: ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => - fetch(url, { - ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - // TODO these should be configurable... - reconnectionOptions: { - maxReconnectionDelay: 30000, - initialReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2, - }, - }; - break; + let mcpProxyServerUrl; + switch (transportType) { + case "stdio": { + mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); + mcpProxyServerUrl.searchParams.append("command", command); + mcpProxyServerUrl.searchParams.append("args", args); + mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); + + const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS + .value as string; + if (proxyFullAddress) { + mcpProxyServerUrl.searchParams.append( + "proxyFullAddress", + proxyFullAddress, + ); + } + transportOptions = { + authProvider: serverAuthProvider, + eventSourceInit: { + fetch: ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), + }, + requestInit: { + headers: { ...headers, ...proxyHeaders }, + }, + }; + break; + } + + case "sse": { + mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); + mcpProxyServerUrl.searchParams.append("url", sseUrl); + + const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS + .value as string; + if (proxyFullAddressSSE) { + mcpProxyServerUrl.searchParams.append( + "proxyFullAddress", + proxyFullAddressSSE, + ); + } + transportOptions = { + eventSourceInit: { + fetch: ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), + }, + requestInit: { + headers: { ...headers, ...proxyHeaders }, + }, + }; + break; + } + + case "streamable-http": + mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); + mcpProxyServerUrl.searchParams.append("url", sseUrl); + transportOptions = { + eventSourceInit: { + fetch: ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), + }, + requestInit: { + headers: { ...headers, ...proxyHeaders }, + }, + // TODO these should be configurable... + reconnectionOptions: { + maxReconnectionDelay: 30000, + initialReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }, + }; + break; + } + serverUrl = mcpProxyServerUrl as URL; + serverUrl.searchParams.append("transportType", transportType); } - (mcpProxyServerUrl as URL).searchParams.append( - "transportType", - transportType, - ); if (onNotification) { [ @@ -551,14 +639,11 @@ export function useConnection({ try { const transport = transportType === "streamable-http" - ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { + ? new StreamableHTTPClientTransport(serverUrl, { sessionId: undefined, ...transportOptions, }) - : new SSEClientTransport( - mcpProxyServerUrl as URL, - transportOptions, - ); + : new SSEClientTransport(serverUrl, transportOptions); await client.connect(transport as Transport); @@ -575,7 +660,9 @@ export function useConnection({ }); } catch (error) { console.error( - `Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`, + connectionType === "direct" + ? `Failed to connect directly to MCP Server at: ${serverUrl}:` + : `Failed to connect to MCP Server via the MCP Inspector Proxy: ${serverUrl}:`, error, ); @@ -674,6 +761,8 @@ export function useConnection({ setConnectionStatus("disconnected"); setCompletionsSupported(false); setServerCapabilities(null); + setMcpSessionId(null); + setMcpProtocolVersion(null); }; const clearRequestHistory = () => {