From e73ba08d4b1f61df11e231ea16a67f8102b3be43 Mon Sep 17 00:00:00 2001 From: Ke Wang Date: Sun, 12 Apr 2026 16:07:20 -0500 Subject: [PATCH] feat: support ?autoConnect=true query param to connect on page load When present, the Inspector calls connectMcpServer() once after the initial connection state has been hydrated from the URL. The param is consumed on first use and stripped from the URL via history.replaceState, so refreshes and disconnect/reconnect cycles don't re-trigger it. This lets external tools (docker-compose sidecars, editor extensions, CI smoke tests) build deep-links that drop the user directly into a connected Inspector session without requiring a manual "Connect" click. Composable with existing pre-fill params (?transport, ?serverUrl, ?serverCommand, ?MCP_PROXY_AUTH_TOKEN). Closes #1183 --- client/src/App.tsx | 15 +++ client/src/__tests__/App.autoConnect.test.tsx | 127 ++++++++++++++++++ client/src/utils/configUtils.ts | 16 +++ 3 files changed, 158 insertions(+) create mode 100644 client/src/__tests__/App.autoConnect.test.tsx 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,