Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,23 @@
}));

// Mutable holder so individual tests can simulate "Authorize & Fetch" having
// produced a token before submit.
const oauthHook = vi.hoisted(() => ({ tokenResponse: null as Record<string, unknown> | null }));
// produced a token before submit, and inspect the reset wiring.
const oauthHook = vi.hoisted(() => ({
tokenResponse: null as Record<string, unknown> | null,
reset: vi.fn(),
onTokenReceived: null as ((token: Record<string, unknown> | null) => void) | null,
}));
vi.mock("@/hooks/useMcpOAuthFlow", () => ({
useMcpOAuthFlow: () => ({
startOAuthFlow: vi.fn(),
status: "idle",
error: null,
tokenResponse: oauthHook.tokenResponse,
}),
useMcpOAuthFlow: (opts: { onTokenReceived: (token: Record<string, unknown> | null) => void }) => {
oauthHook.onTokenReceived = opts.onTokenReceived;
return {
startOAuthFlow: vi.fn(),
status: "idle",
error: null,
tokenResponse: oauthHook.tokenResponse,
reset: oauthHook.reset,
};
},
}));

vi.mock("./mcp_server_cost_config", () => ({
Expand Down Expand Up @@ -59,7 +67,9 @@
}));

vi.mock("./mcp_connection_status", () => ({
default: () => <div data-testid="mcp-connection-status" />,
default: ({ tools }: { tools?: any[] }) => (

Check warning on line 70 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
<div data-testid="mcp-connection-status" data-tool-count={tools?.length ?? 0} />
),
}));

vi.mock("./StdioConfiguration", () => ({
Expand Down Expand Up @@ -121,6 +131,7 @@
beforeEach(() => {
vi.clearAllMocks();
oauthHook.tokenResponse = null;
oauthHook.onTokenReceived = null;
});

it("should render the modal with title when visible", () => {
Expand Down Expand Up @@ -614,6 +625,100 @@

expect(defaultProps.setModalVisible).toHaveBeenCalledWith(false);
});

it("does not leak a previous server's OAuth token into the next add-server session", async () => {
const usedToken = (token: string) =>
vi.mocked(networking.testMCPToolsListRequest).mock.calls.some((call) => call[2] === token);

const { rerender } = render(<CreateMCPServer {...defaultProps} />);

await selectAntOption("Transport Type", "Streamable HTTP");
await waitFor(() => {
expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument();
});
await selectAntOption("Authentication", "OAuth");
await waitFor(() => {
expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument();
});

const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await act(async () => {
fireEvent.change(urlInput, { target: { value: "https://server-a.example.com/mcp" } });
});

// Simulate "Authorize & Fetch Token" completing for server A.
await act(async () => {
oauthHook.onTokenReceived?.({ access_token: "stale-token-A", expires_in: 3600 });
});

// Precondition: the freshly fetched token drives the tool preview for server A.
await waitFor(() => {
expect(usedToken("stale-token-A")).toBe(true);
});

// Parent hides the modal (Cancel / successful create both flip this prop).
rerender(<CreateMCPServer {...defaultProps} isModalVisible={false} />);

// The OAuth flow state (source of the "Token fetched" badge) is reset on close.
expect(oauthHook.reset).toHaveBeenCalled();

vi.mocked(networking.testMCPToolsListRequest).mockClear();

// Reopen for a brand-new server and enter a different URL without re-authorizing.
rerender(<CreateMCPServer {...defaultProps} isModalVisible={true} />);
const reopenedUrlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await act(async () => {
fireEvent.change(reopenedUrlInput, { target: { value: "https://server-b.example.com/mcp" } });
});

// The previous server's token must never be replayed for the new session.
expect(usedToken("stale-token-A")).toBe(false);
});

it("clears the tool list and form fields when a parent dismisses the modal", async () => {
vi.mocked(networking.testMCPToolsListRequest).mockResolvedValue({
tools: [{ name: "tool_a" }],
error: null,
});
const toolCount = () => screen.getByTestId("mcp-connection-status").getAttribute("data-tool-count");

const { rerender } = render(<CreateMCPServer {...defaultProps} />);

await selectAntOption("Transport Type", "Streamable HTTP");
await waitFor(() => {
expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument();
});
await selectAntOption("Authentication", "OAuth");
await waitFor(() => {
expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument();
});

const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await act(async () => {
fireEvent.change(urlInput, { target: { value: "https://server-a.example.com/mcp" } });
});
await act(async () => {
oauthHook.onTokenReceived?.({ access_token: "stale-token-A", expires_in: 3600 });
});

// Precondition: a tool list is shown for server A.
await waitFor(() => {
expect(toolCount()).toBe("1");
});

// Parent dismisses the modal without routing through Cancel or create.
rerender(<CreateMCPServer {...defaultProps} isModalVisible={false} />);

// Stale tools are cleared even though neither handler ran.
await waitFor(() => {
expect(toolCount()).toBe("0");
});

// Reopening starts clean: the URL the prior server left in the Ant form store is gone.
rerender(<CreateMCPServer {...defaultProps} isModalVisible={true} />);
const reopenedUrlInput = screen.getByPlaceholderText("https://your-mcp-server.com") as HTMLInputElement;
expect(reopenedUrlInput.value).toBe("");
});
});

describe("when stdio transport is selected", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
const [formValues, setFormValues] = useState<Record<string, any>>({});

Check warning on line 75 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const [pendingRestoredValues, setPendingRestoredValues] = useState<{
values: Record<string, any>;

Check warning on line 77 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
transport?: string;
} | null>(null);
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
Expand Down Expand Up @@ -134,6 +134,7 @@
status: oauthStatus,
error: oauthError,
tokenResponse: oauthTokenResponse,
reset: resetOAuthFlow,
} = useMcpOAuthFlow({
accessToken,
getCredentials: () => form.getFieldValue("credentials"),
Expand Down Expand Up @@ -265,7 +266,7 @@
const transport = prefillData.transport || "";
setTransportType(transport);

const prefillValues: Record<string, any> = {

Check warning on line 269 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
server_name: sanitizedName,
alias: sanitizedName,
description: prefillData.description || "",
Expand All @@ -273,7 +274,7 @@
};

if (transport === "stdio") {
const stdioObj: Record<string, any> = {};

Check warning on line 277 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (prefillData.command) stdioObj.command = prefillData.command;
if (prefillData.args && prefillData.args.length > 0) stdioObj.args = prefillData.args;
if (prefillData.env_vars && prefillData.env_vars.length > 0) {
Expand All @@ -295,7 +296,7 @@
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);

const handleCreate = async (values: Record<string, any>) => {

Check warning on line 299 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Async arrow function has a complexity of 37. Maximum allowed is 20

Check warning on line 299 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
setIsLoading(true);
try {
const {
Expand All @@ -319,7 +320,7 @@

const credentialsPayload =
credentialValues && typeof credentialValues === "object"
? Object.entries(credentialValues).reduce((acc: Record<string, any>, [key, value]) => {

Check warning on line 323 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (value === undefined || value === null || value === "") {
return acc;
}
Expand Down Expand Up @@ -352,12 +353,12 @@
// If it's the full mcpServers structure, extract the first server config
if (stdioConfig.mcpServers && typeof stdioConfig.mcpServers === "object") {
const serverNames = Object.keys(stdioConfig.mcpServers);
if (serverNames.length > 0) {

Check warning on line 356 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Blocks are nested too deeply (5). Maximum allowed is 4
const firstServerName = serverNames[0];
actualConfig = stdioConfig.mcpServers[firstServerName];

// If no alias is provided, use the server name from the JSON
if (!restValues.server_name) {

Check warning on line 361 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Blocks are nested too deeply (6). Maximum allowed is 4
restValues.server_name = firstServerName.replace(/-/g, "_"); // Replace hyphens with underscores
}
}
Expand Down Expand Up @@ -554,12 +555,19 @@
}
}, [formValues.server_name]);

// Clear formValues when modal closes to reset child components
// Clear form, tools, and OAuth state when the modal closes so a previous server's
// authorization, credentials, or tool list never bleed into the next "Add New MCP
// Server" session, including when a parent dismisses the modal without routing
// through handleCancel or handleCreate.
React.useEffect(() => {
if (!isModalVisible) {
form.resetFields();
setFormValues({});
Comment thread
veria-ai[bot] marked this conversation as resolved.
setOauthAccessToken(null);
clearTools();
resetOAuthFlow();
}
}, [isModalVisible]);
}, [isModalVisible, form, clearTools, resetOAuthFlow]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale transport state after modal reset

Medium Severity

The modal-close effect calls form.resetFields() and setFormValues({}) but leaves transportType unchanged. The transport Select is controlled by transportType, so reopening can show a prior transport while formValues.transport stays empty. useTestMCPConnection gates previews on formValues.transport, so a new OAuth session may not load tools until transport is chosen again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0e523f5. Configure here.


const isAdmin = isAdminRole(userRole);

Expand Down
117 changes: 117 additions & 0 deletions ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "@/components/networking";
import { setSecureItem } from "@/utils/secureStorage";
import { useMcpOAuthFlow } from "./useMcpOAuthFlow";

vi.mock("@/components/networking", () => ({
exchangeMcpOAuthToken: vi.fn(),
cacheTemporaryMcpServer: vi.fn(),
registerMcpOAuthClient: vi.fn(),
buildMcpOAuthAuthorizeUrl: vi.fn(),
getProxyBaseUrl: vi.fn(() => ""),
serverRootPath: "",
}));

vi.mock("@/components/molecules/notifications_manager", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));

const FLOW_STATE_KEY = "litellm-mcp-oauth-flow-state";
const RESULT_KEY = "litellm-mcp-oauth-result";

/** Seed the redirect result (the code returned by the IdP callback). */
function seedResult(code: string) {
setSecureItem(RESULT_KEY, JSON.stringify({ state: "state-1", code }));
}

/** Seed the flow state stored before the redirect. */
function seedFlowState() {
setSecureItem(
FLOW_STATE_KEY,
JSON.stringify({
state: "state-1",
codeVerifier: "verifier-1",
serverId: "server-1",
clientId: "client-1",
redirectUri: "https://app.example.com/ui/mcp/oauth/callback",
flowSource: "create",
}),
);
}

/** Seed storage so the hook's on-mount resume flow exchanges a code for a token. */
function seedCompletedRedirect() {
seedResult("code-1");
seedFlowState();
}

function renderFlow(onTokenReceived = vi.fn()) {
return renderHook(
({ onTokenReceived: cb }: { onTokenReceived: (t: any) => void }) =>
useMcpOAuthFlow({
accessToken: "admin-token",
getCredentials: () => ({}),
getTemporaryPayload: () => ({ url: "https://server-1.example.com/mcp", transport: "http" }),
onTokenReceived: cb,
flowSource: "create",
}),
{ initialProps: { onTokenReceived } },
);
}

describe("useMcpOAuthFlow reset", () => {
beforeEach(() => {
vi.clearAllMocks();
window.sessionStorage.clear();
window.localStorage.clear();
});

it("clears a successfully fetched token so it cannot leak into the next session", async () => {
const token = { access_token: "tok-123", expires_in: 3600 };
vi.mocked(networking.exchangeMcpOAuthToken).mockResolvedValue(token);
seedCompletedRedirect();

const onTokenReceived = vi.fn();
const { result } = renderFlow(onTokenReceived);

await waitFor(() => expect(result.current.status).toBe("success"));
expect(result.current.tokenResponse).toEqual(token);
expect(onTokenReceived).toHaveBeenCalledWith(token);

act(() => {
result.current.reset();
});

expect(result.current.status).toBe("idle");
expect(result.current.tokenResponse).toBeNull();
expect(result.current.error).toBeNull();
});

it("clears the in-flight guard so a callback after a mid-exchange close is not swallowed", async () => {
// First exchange hangs, mimicking the modal being closed while the token
// endpoint is still in flight. processingRef is left true at that point.
vi.mocked(networking.exchangeMcpOAuthToken).mockReturnValueOnce(new Promise<any>(() => {}));
seedFlowState();
seedResult("code-1");

const onTokenReceived1 = vi.fn();
const { result, rerender } = renderFlow(onTokenReceived1);

await waitFor(() => expect(result.current.status).toBe("exchanging"));

act(() => {
result.current.reset();
});

// The reopened modal receives a fresh callback; it must be processed, not
// dropped by a stale in-flight guard.
const token = { access_token: "tok-2" };
vi.mocked(networking.exchangeMcpOAuthToken).mockResolvedValueOnce(token);
seedResult("code-2");
const onTokenReceived2 = vi.fn();
rerender({ onTokenReceived: onTokenReceived2 });

await waitFor(() => expect(onTokenReceived2).toHaveBeenCalledWith(token));
});
});
9 changes: 9 additions & 0 deletions ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface UseMcpOAuthFlowResult {
status: McpOAuthStatus;
error: string | null;
tokenResponse: Record<string, any> | null;
reset: () => void;
}

export const useMcpOAuthFlow = ({
Expand Down Expand Up @@ -336,10 +337,18 @@ export const useMcpOAuthFlow = ({
resumeOAuthFlow();
}, [resumeOAuthFlow]);

const reset = useCallback(() => {
setStatus("idle");
setError(null);
setTokenResponse(null);
processingRef.current = false;
}, []);
Comment thread
tin-berri marked this conversation as resolved.

return {
startOAuthFlow,
status,
error,
tokenResponse,
reset,
};
};
6 changes: 3 additions & 3 deletions ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { testMCPToolsListRequest } from "../components/networking";
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT } from "@/components/mcp_tools/types";

Expand Down Expand Up @@ -177,12 +177,12 @@ export const useTestMCPConnection = ({
}
};

const clearTools = () => {
const clearTools = useCallback(() => {
setTools([]);
setToolsError(null);
setToolsErrorStackTrace(null);
setHasShownSuccessMessage(false);
};
}, []);

// Auto-fetch tools when form values change and required fields are available
useEffect(() => {
Expand Down
Loading