diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx
index d49c49446bb..b4251267137 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx
@@ -150,151 +150,139 @@ describe("CreateMCPServer", () => {
});
});
- it(
- "should not require auth value when creating a server with API Key auth type",
- { timeout: 15000 },
- async () => {
- await selectHttpTransport();
+ it("should not require auth value when creating a server with API Key auth type", async () => {
+ await selectHttpTransport();
- const user = userEvent.setup({ delay: null });
+ const user = userEvent.setup({ delay: null });
- // Fill in server name (use id to avoid duplicate placeholder)
- const nameInput = getServerNameInput();
- await user.type(nameInput, "Test_Server");
+ // Fill in server name (use id to avoid duplicate placeholder)
+ const nameInput = getServerNameInput();
+ await user.type(nameInput, "Test_Server");
- // Fill in URL
- const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
- await user.type(urlInput, "https://example.com/mcp");
+ // Fill in URL
+ const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
+ await user.type(urlInput, "https://example.com/mcp");
- // Select API Key auth type
- await selectAntOption("Authentication", "API Key");
+ // Select API Key auth type
+ await selectAntOption("Authentication", "API Key");
- await waitFor(() => {
- expect(screen.getByText("Authentication Value")).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByText("Authentication Value")).toBeInTheDocument();
+ });
- // Leave auth value empty and submit
- vi.mocked(networking.createMCPServer).mockResolvedValue({
- server_id: "new-server-1",
- server_name: "Test_Server",
- alias: "Test_Server",
- url: "https://example.com/mcp",
- transport: "http",
- auth_type: "api_key",
- created_at: "2024-01-01T00:00:00Z",
- created_by: "user-1",
- updated_at: "2024-01-01T00:00:00Z",
- updated_by: "user-1",
- });
+ // Leave auth value empty and submit
+ vi.mocked(networking.createMCPServer).mockResolvedValue({
+ server_id: "new-server-1",
+ server_name: "Test_Server",
+ alias: "Test_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: "api_key",
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ });
- const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
- await act(async () => {
- fireEvent.click(submitButton);
- });
+ const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
- // The form should submit without validation error on auth_value
- await waitFor(() => {
- expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
- });
- },
- );
+ // The form should submit without validation error on auth_value
+ await waitFor(() => {
+ expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
+ });
+ });
- it(
- "should not require auth value when creating a server with Bearer Token auth type",
- { timeout: 15000 },
- async () => {
- await selectHttpTransport();
+ it("should not require auth value when creating a server with Bearer Token auth type", async () => {
+ await selectHttpTransport();
- const user = userEvent.setup({ delay: null });
+ const user = userEvent.setup({ delay: null });
- const nameInput = getServerNameInput();
- await user.type(nameInput, "Test_Server");
+ const nameInput = getServerNameInput();
+ await user.type(nameInput, "Test_Server");
- const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
- await user.type(urlInput, "https://example.com/mcp");
+ const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
+ await user.type(urlInput, "https://example.com/mcp");
- await selectAntOption("Authentication", "Bearer Token");
+ await selectAntOption("Authentication", "Bearer Token");
- await waitFor(() => {
- expect(screen.getByText("Authentication Value")).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByText("Authentication Value")).toBeInTheDocument();
+ });
- // Leave auth value empty and submit
- vi.mocked(networking.createMCPServer).mockResolvedValue({
- server_id: "new-server-1",
- server_name: "Test_Server",
- alias: "Test_Server",
- url: "https://example.com/mcp",
- transport: "http",
- auth_type: "bearer_token",
- created_at: "2024-01-01T00:00:00Z",
- created_by: "user-1",
- updated_at: "2024-01-01T00:00:00Z",
- updated_by: "user-1",
- });
+ // Leave auth value empty and submit
+ vi.mocked(networking.createMCPServer).mockResolvedValue({
+ server_id: "new-server-1",
+ server_name: "Test_Server",
+ alias: "Test_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: "bearer_token",
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ });
- const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
- await act(async () => {
- fireEvent.click(submitButton);
- });
+ const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
- await waitFor(() => {
- expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
- });
- },
- );
+ await waitFor(() => {
+ expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
+ });
+ });
- it(
- "should successfully create a server when auth value is provided",
- { timeout: 15000 },
- async () => {
- await selectHttpTransport();
+ it("should successfully create a server when auth value is provided", async () => {
+ await selectHttpTransport();
- const user = userEvent.setup({ delay: null });
+ const user = userEvent.setup({ delay: null });
- const nameInput = getServerNameInput();
- await user.type(nameInput, "My_Server");
+ const nameInput = getServerNameInput();
+ await user.type(nameInput, "My_Server");
- const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
- await user.type(urlInput, "https://example.com/mcp");
+ const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
+ await user.type(urlInput, "https://example.com/mcp");
- await selectAntOption("Authentication", "API Key");
+ await selectAntOption("Authentication", "API Key");
- await waitFor(() => {
- expect(screen.getByText("Authentication Value")).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByText("Authentication Value")).toBeInTheDocument();
+ });
- // Fill in auth value
- const authInput = screen.getByPlaceholderText("Enter token or secret");
- await user.type(authInput, "my-secret-key");
+ // Fill in auth value
+ const authInput = screen.getByPlaceholderText("Enter token or secret");
+ await user.type(authInput, "my-secret-key");
- vi.mocked(networking.createMCPServer).mockResolvedValue({
- server_id: "new-server-1",
- server_name: "My_Server",
- alias: "My_Server",
- url: "https://example.com/mcp",
- transport: "http",
- auth_type: "api_key",
- created_at: "2024-01-01T00:00:00Z",
- created_by: "user-1",
- updated_at: "2024-01-01T00:00:00Z",
- updated_by: "user-1",
- });
+ vi.mocked(networking.createMCPServer).mockResolvedValue({
+ server_id: "new-server-1",
+ server_name: "My_Server",
+ alias: "My_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: "api_key",
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ });
- const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
- await act(async () => {
- fireEvent.click(submitButton);
- });
+ const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
- await waitFor(() => {
- expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
- });
+ await waitFor(() => {
+ expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
+ });
- const [token, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
- expect(token).toBe("test-token");
- expect(payload.credentials).toEqual({ auth_value: "my-secret-key" });
- },
- );
+ const [token, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
+ expect(token).toBe("test-token");
+ expect(payload.credentials).toEqual({ auth_value: "my-secret-key" });
+ });
it("should not show auth value field when None auth type is selected", async () => {
await selectHttpTransport();
@@ -307,50 +295,187 @@ describe("CreateMCPServer", () => {
});
});
- it(
- "should successfully create a server with no auth",
- { timeout: 15000 },
- async () => {
- await selectHttpTransport();
+ it("should successfully create a server with no auth", async () => {
+ await selectHttpTransport();
- const user = userEvent.setup({ delay: null });
+ const user = userEvent.setup({ delay: null });
- const nameInput = getServerNameInput();
- await user.type(nameInput, "No_Auth_Server");
+ const nameInput = getServerNameInput();
+ await user.type(nameInput, "No_Auth_Server");
- const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
- await user.type(urlInput, "https://example.com/mcp");
+ const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
+ await user.type(urlInput, "https://example.com/mcp");
- await selectAntOption("Authentication", "None");
+ await selectAntOption("Authentication", "None");
- vi.mocked(networking.createMCPServer).mockResolvedValue({
- server_id: "new-server-1",
- server_name: "No_Auth_Server",
- alias: "No_Auth_Server",
- url: "https://example.com/mcp",
- transport: "http",
- auth_type: "none",
- created_at: "2024-01-01T00:00:00Z",
- created_by: "user-1",
- updated_at: "2024-01-01T00:00:00Z",
- updated_by: "user-1",
- });
+ vi.mocked(networking.createMCPServer).mockResolvedValue({
+ server_id: "new-server-1",
+ server_name: "No_Auth_Server",
+ alias: "No_Auth_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: "none",
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ });
- const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
- await act(async () => {
- fireEvent.click(submitButton);
- });
+ const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
- await waitFor(() => {
- expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
- });
+ await waitFor(() => {
+ expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
+ });
- const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
- expect(payload.auth_type).toBe("none");
- // No credentials should be sent for "none" auth
- expect(payload.credentials).toBeUndefined();
- },
- );
+ const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
+ expect(payload.auth_type).toBe("none");
+ // No credentials should be sent for "none" auth
+ expect(payload.credentials).toBeUndefined();
+ });
+ });
+
+ describe("when OAuth interactive auth is selected", () => {
+ /** Select HTTP transport + OAuth auth, then wait for the OAuth form to appear. */
+ async function setupOAuthInteractive() {
+ render();
+ await selectAntOption("Transport Type", "Streamable HTTP");
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument();
+ });
+
+ await selectAntOption("Authentication", "OAuth");
+
+ // Wait for OAuthFormFields to render (OAuth Flow Type selector is the sentinel)
+ await waitFor(() => {
+ expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument();
+ });
+
+ // OAuthFormFields defaults to INTERACTIVE, so the new fields should appear
+ await waitFor(() => {
+ expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument();
+ expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument();
+ });
+ }
+
+ it("shows Token Validation Rules and Token Storage TTL fields", async () => {
+ await setupOAuthInteractive();
+ // Asserted in setupOAuthInteractive
+ });
+
+ it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => {
+ vi.mocked(networking.createMCPServer).mockResolvedValue({
+ server_id: "new-server-oauth",
+ server_name: "OAuth_Server",
+ alias: "OAuth_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: "oauth2",
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ });
+
+ await setupOAuthInteractive();
+
+ // Fill required form fields
+ const nameInput = document.getElementById("server_name") as HTMLInputElement;
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: "OAuth_Server" } });
+ });
+ const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
+ await act(async () => {
+ fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } });
+ });
+
+ // Fill in the token_validation_json textarea
+ const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement;
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: '{"organization": "my-org", "team.id": "42"}' } });
+ });
+
+ const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
+ expect(payload.token_validation).toEqual({ organization: "my-org", "team.id": "42" });
+ });
+
+ it("omits token_validation from payload when token_validation_json is empty", async () => {
+ vi.mocked(networking.createMCPServer).mockResolvedValue({
+ server_id: "new-server-oauth",
+ server_name: "OAuth_Server",
+ alias: "OAuth_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: "oauth2",
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ });
+
+ await setupOAuthInteractive();
+
+ const nameInput = document.getElementById("server_name") as HTMLInputElement;
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: "OAuth_Server" } });
+ });
+ const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
+ await act(async () => {
+ fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } });
+ });
+
+ // Leave token_validation_json empty
+ const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
+ expect(payload.token_validation).toBeUndefined();
+ });
+
+ it("does not submit and shows validation error for invalid JSON in token_validation_json", async () => {
+ await setupOAuthInteractive();
+
+ const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement;
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "not-valid-json{" } });
+ });
+
+ const nameInput = document.getElementById("server_name") as HTMLInputElement;
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: "OAuth_Server" } });
+ });
+
+ const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Either the inline form validation message or the notification fires —
+ // both indicate the submit was blocked.
+ await waitFor(() => {
+ const inlineError = screen.queryByText("Must be valid JSON");
+ const notCalled = !vi.mocked(networking.createMCPServer).mock.calls.length;
+ expect(inlineError !== null || notCalled).toBe(true);
+ });
+ });
});
describe("when modal is cancelled", () => {
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx
index 4c824fcee0b..17bcd59c43e 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx
@@ -17,6 +17,7 @@ import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
+import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
const asset_logos_folder = "../ui/assets/logos/";
export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`;
@@ -94,8 +95,7 @@ const CreateMCPServer: React.FC = ({
}
try {
const values = form.getFieldsValue(true);
- // codeql[js/clear-text-storage-of-sensitive-data]
- window.sessionStorage.setItem(
+ setSecureItem(
CREATE_OAUTH_UI_STATE_KEY,
JSON.stringify({
modalVisible: isModalVisible,
@@ -178,7 +178,7 @@ const CreateMCPServer: React.FC = ({
if (typeof window === "undefined") {
return;
}
- const storedState = window.sessionStorage.getItem(CREATE_OAUTH_UI_STATE_KEY);
+ const storedState = getSecureItem(CREATE_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}
@@ -284,6 +284,7 @@ const CreateMCPServer: React.FC = ({
credentials: credentialValues,
allow_all_keys: allowAllKeysRaw,
available_on_public_internet: availableOnPublicInternetRaw,
+ token_validation_json: rawTokenValidationJson,
...restValues
} = values;
@@ -356,6 +357,18 @@ const CreateMCPServer: React.FC = ({
restValues.transport = "http";
}
+ // Parse token_validation JSON if provided
+ let tokenValidation: Record | null = null;
+ if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") {
+ try {
+ tokenValidation = JSON.parse(rawTokenValidationJson);
+ } catch {
+ NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules");
+ setIsLoading(false);
+ return;
+ }
+ }
+
// Prepare the payload with cost configuration and allowed tools
const payload: Record = {
...restValues,
@@ -376,6 +389,7 @@ const CreateMCPServer: React.FC = ({
allow_all_keys: Boolean(allowAllKeysRaw),
available_on_public_internet: Boolean(availableOnPublicInternetRaw),
static_headers: staticHeaders,
+ ...(tokenValidation !== null && { token_validation: tokenValidation }),
};
payload.static_headers = staticHeaders;
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx
index e33e2fff491..aba2a3d9222 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import MCPServerEdit from "./mcp_server_edit";
import * as networking from "../networking";
+import NotificationsManager from "../molecules/notifications_manager";
vi.mock("../networking", () => ({
updateMCPServer: vi.fn(),
@@ -37,6 +38,29 @@ vi.mock("./mcp_tool_configuration", () => ({
default: () => ,
}));
+// ── fixtures ──────────────────────────────────────────────────────────────────
+
+const interactiveOAuthServer = {
+ server_id: "oauth_server_1",
+ server_name: "OAuthServer",
+ alias: "oauth_server", // underscores: hyphens fail validateMCPServerName
+ description: "Interactive OAuth MCP server",
+ transport: "http",
+ url: "https://example.com/mcp",
+ auth_type: "oauth2",
+ // No token_url → edit form defaults to INTERACTIVE flow
+ token_url: null,
+ authorization_url: null,
+ registration_url: null,
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ mcp_access_groups: [],
+};
+
+// ── test suites ───────────────────────────────────────────────────────────────
+
describe("MCPServerEdit (stdio)", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -152,3 +176,228 @@ describe("MCPServerEdit (stdio)", () => {
expect(payload.env).toEqual({ CIRCLECI_TOKEN: "new-token", CIRCLECI_BASE_URL: "https://circleci.com" });
});
});
+
+describe("MCPServerEdit (interactive OAuth)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders Token Validation Rules and Token Storage TTL fields for interactive OAuth server", async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument();
+ expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument();
+ });
+ });
+
+ // Note: The M2M flow hiding logic is tested via OAuthFormFields.test.tsx (isM2M prop directly),
+ // since Form.useWatch doesn't synchronously reflect initialValues in jsdom.
+
+ it("pre-populates token_validation_json from existing server token_validation", async () => {
+ const tokenValidation = { organization: "my-org", "team.id": "123" };
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement;
+ expect(textarea).not.toBeNull();
+ const parsed = JSON.parse(textarea.value);
+ expect(parsed).toEqual(tokenValidation);
+ });
+ });
+
+ it("includes token_validation in update payload when token_validation_json is filled", async () => {
+ const onSuccess = vi.fn();
+ vi.mocked(networking.updateMCPServer).mockResolvedValue({
+ ...interactiveOAuthServer,
+ token_validation: { organization: "my-org" },
+ });
+
+ render(
+ ,
+ );
+
+ // Wait for the form to mount and the token_validation_json field to appear
+ await waitFor(() => {
+ expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument();
+ });
+
+ const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement;
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: '{"organization": "my-org"}' } });
+ });
+
+ const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
+ await act(async () => {
+ fireEvent.click(saveButtons[0]);
+ });
+
+ await waitFor(() => {
+ expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
+ expect(payload.token_validation).toEqual({ organization: "my-org" });
+ });
+
+ it("does not include token_validation in payload when field is empty and server had none", async () => {
+ vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument();
+ });
+
+ // Leave token_validation_json empty
+ const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
+ await act(async () => {
+ fireEvent.click(saveButtons[0]);
+ });
+
+ await waitFor(() => {
+ expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
+ expect(payload.token_validation).toBeUndefined();
+ });
+
+ it("sends token_validation: null to clear an existing value when textarea is cleared", async () => {
+ vi.mocked(networking.updateMCPServer).mockResolvedValue({
+ ...interactiveOAuthServer,
+ token_validation: null,
+ });
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement;
+ expect(textarea?.value).toContain("old-org");
+ });
+
+ // Clear the textarea
+ const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement;
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "" } });
+ });
+
+ const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
+ await act(async () => {
+ fireEvent.click(saveButtons[0]);
+ });
+
+ await waitFor(() => {
+ expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
+ // null signals the backend to clear the existing validation rules
+ expect(payload.token_validation).toBeNull();
+ });
+
+ it("shows inline validation error and does not submit on invalid JSON in token_validation_json", async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument();
+ });
+
+ const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement;
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "{ bad json" } });
+ });
+
+ const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
+ await act(async () => {
+ fireEvent.click(saveButtons[0]);
+ });
+
+ // The Form.Item inline validator intercepts invalid JSON before handleSave runs,
+ // so the inline error message appears and updateMCPServer is never called.
+ await waitFor(() => {
+ expect(screen.getByText("Must be valid JSON")).toBeInTheDocument();
+ });
+ expect(networking.updateMCPServer).not.toHaveBeenCalled();
+ });
+
+ it("includes token_storage_ttl_seconds in payload when set", async () => {
+ vi.mocked(networking.updateMCPServer).mockResolvedValue({
+ ...interactiveOAuthServer,
+ token_storage_ttl_seconds: 7200,
+ });
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument();
+ });
+
+ const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
+ await act(async () => {
+ fireEvent.click(saveButtons[0]);
+ });
+
+ await waitFor(() => {
+ expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
+ expect(payload.token_storage_ttl_seconds).toBe(7200);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
index e81d2f3960e..574e7871759 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
-import { Form, Select, Button as AntdButton, Tooltip, Input } from "antd";
+import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
@@ -12,6 +12,7 @@ import MCPLogoSelector from "./MCPLogoSelector";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
+import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
interface MCPServerEditProps {
mcpServer: MCPServer;
@@ -73,8 +74,7 @@ const MCPServerEdit: React.FC = ({
}
try {
const values = form.getFieldsValue(true);
- // codeql[js/clear-text-storage-of-sensitive-data]
- window.sessionStorage.setItem(
+ setSecureItem(
EDIT_OAUTH_UI_STATE_KEY,
JSON.stringify({
serverId: mcpServer.server_id,
@@ -190,6 +190,9 @@ const MCPServerEdit: React.FC = ({
transport: effectiveTransport,
static_headers: initialStaticHeaders,
oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE,
+ token_validation_json: mcpServer.token_validation
+ ? JSON.stringify(mcpServer.token_validation, null, 2)
+ : undefined,
}),
[mcpServer, effectiveTransport, initialStaticHeaders, initialEnvJson],
);
@@ -214,7 +217,7 @@ const MCPServerEdit: React.FC = ({
if (typeof window === "undefined") {
return;
}
- const storedState = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY);
+ const storedState = getSecureItem(EDIT_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}
@@ -400,6 +403,7 @@ const MCPServerEdit: React.FC = ({
args: rawArgs,
allow_all_keys: allowAllKeysRaw,
available_on_public_internet: availableOnPublicInternetRaw,
+ token_validation_json: rawTokenValidationJson,
...restValues
} = values;
@@ -522,6 +526,17 @@ const MCPServerEdit: React.FC = ({
restValues.transport = "http";
}
+ // Parse token_validation JSON if provided
+ let tokenValidation: Record | null = null;
+ if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") {
+ try {
+ tokenValidation = JSON.parse(rawTokenValidationJson);
+ } catch {
+ NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules");
+ return;
+ }
+ }
+
// Prepare the payload with cost configuration and permission fields
const mcpInfoServerName =
restValues.server_name ||
@@ -556,6 +571,10 @@ const MCPServerEdit: React.FC = ({
static_headers: staticHeaders,
allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys),
available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet),
+ // Include token_validation when it is set (non-null) or when clearing an existing value
+ ...(tokenValidation !== null || mcpServer.token_validation
+ ? { token_validation: tokenValidation }
+ : {}),
};
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
@@ -863,6 +882,58 @@ const MCPServerEdit: React.FC = ({
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
+ {!isM2MFlow && (
+ <>
+
+ Token Validation Rules (optional)
+
+
+
+
+ }
+ name="token_validation_json"
+ rules={[
+ {
+ validator: (_: any, value: string) => {
+ if (!value || value.trim() === "") return Promise.resolve();
+ try {
+ JSON.parse(value);
+ return Promise.resolve();
+ } catch {
+ return Promise.reject(new Error("Must be valid JSON"));
+ }
+ },
+ },
+ ]}
+ >
+
+
+
+ Token Storage TTL (seconds, optional)
+
+
+
+
+ }
+ name="token_storage_ttl_seconds"
+ >
+
+
+ >
+ )}
Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value.