diff --git a/client/src/App.tsx b/client/src/App.tsx index 59d15ba06..4440ab5a4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -90,6 +90,8 @@ import { initializeInspectorConfig, saveInspectorConfig, getMCPTaskTtl, + getAutoConnect, + stripAutoConnectParam, } from "./utils/configUtils"; import ElicitationTab, { PendingElicitationRequest, @@ -584,6 +586,19 @@ const App = () => { saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); + // Auto-connect when ?autoConnect=true is present in the URL. + // One-shot: the param is stripped after consumption so refreshes + // and disconnect/reconnect cycles don't re-trigger it. + useEffect(() => { + if (getAutoConnect()) { + stripAutoConnectParam(); + void connectMcpServer(); + } + // Only run once on mount — intentionally omitting connectMcpServer + // from deps so this doesn't re-fire on reconnects. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const onOAuthConnect = useCallback( (serverUrl: string) => { setSseUrl(serverUrl); diff --git a/client/src/__tests__/App.autoConnect.test.tsx b/client/src/__tests__/App.autoConnect.test.tsx new file mode 100644 index 000000000..7adeb877e --- /dev/null +++ b/client/src/__tests__/App.autoConnect.test.tsx @@ -0,0 +1,127 @@ +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 — keep the real implementations but allow overriding +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(), + getAutoConnect: jest.fn(() => false), + stripAutoConnectParam: jest.fn(), +})); + +const mockGetAutoConnect = configUtils.getAutoConnect as jest.Mock; +const mockStripAutoConnectParam = + configUtils.stripAutoConnectParam as jest.Mock; + +// Mock useConnection to capture the connect function +const mockConnect = jest.fn(); +jest.mock("../lib/hooks/useConnection", () => ({ + useConnection: () => ({ + connectionStatus: "disconnected", + serverCapabilities: null, + mcpClient: null, + requestHistory: [], + clearRequestHistory: jest.fn(), + makeRequest: jest.fn(), + sendNotification: jest.fn(), + handleCompletion: jest.fn(), + completionsSupported: false, + connect: mockConnect, + 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: () =>
Sidebar
, +})); + +// Mock fetch +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve({}), +}); + +describe("App - autoConnect query param", () => { + beforeEach(() => { + jest.clearAllMocks(); + (global.fetch as jest.Mock).mockResolvedValue({ + json: () => Promise.resolve({}), + }); + }); + + test("calls connectMcpServer on mount when autoConnect=true", async () => { + mockGetAutoConnect.mockReturnValue(true); + + render(); + + await waitFor(() => { + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + }); + + test("strips autoConnect param from URL after consuming it", async () => { + mockGetAutoConnect.mockReturnValue(true); + + render(); + + await waitFor(() => { + expect(mockStripAutoConnectParam).toHaveBeenCalledTimes(1); + }); + }); + + test("does not call connectMcpServer when autoConnect is not set", async () => { + mockGetAutoConnect.mockReturnValue(false); + + render(); + + // Wait for initial render effects to settle + await waitFor(() => { + expect(mockGetAutoConnect).toHaveBeenCalled(); + }); + + expect(mockConnect).not.toHaveBeenCalled(); + expect(mockStripAutoConnectParam).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index bc081b8f8..b0f7b8323 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -92,6 +92,22 @@ export const getInitialArgs = (): string => { return localStorage.getItem("lastArgs") || ""; }; +export const getAutoConnect = (): boolean => { + return getSearchParam("autoConnect") === "true"; +}; + +export const stripAutoConnectParam = (): void => { + try { + const url = new URL(window.location.href); + if (url.searchParams.has("autoConnect")) { + url.searchParams.delete("autoConnect"); + window.history.replaceState({}, "", url.toString()); + } + } catch { + // Ignore URL parsing errors + } +}; + // Returns a map of config key -> value from query params if present export const getConfigOverridesFromQueryParams = ( defaultConfig: InspectorConfig,