Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ import {
initializeInspectorConfig,
saveInspectorConfig,
getMCPTaskTtl,
getAutoConnect,
stripAutoConnectParam,
} from "./utils/configUtils";
import ElicitationTab, {
PendingElicitationRequest,
Expand Down Expand Up @@ -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);
Expand Down
127 changes: 127 additions & 0 deletions client/src/__tests__/App.autoConnect.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>Sidebar</div>,
}));

// 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(<App />);

await waitFor(() => {
expect(mockConnect).toHaveBeenCalledTimes(1);
});
});

test("strips autoConnect param from URL after consuming it", async () => {
mockGetAutoConnect.mockReturnValue(true);

render(<App />);

await waitFor(() => {
expect(mockStripAutoConnectParam).toHaveBeenCalledTimes(1);
});
});

test("does not call connectMcpServer when autoConnect is not set", async () => {
mockGetAutoConnect.mockReturnValue(false);

render(<App />);

// Wait for initial render effects to settle
await waitFor(() => {
expect(mockGetAutoConnect).toHaveBeenCalled();
});

expect(mockConnect).not.toHaveBeenCalled();
expect(mockStripAutoConnectParam).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions client/src/utils/configUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down