From c89908d035bef7d31c759f22a299c7029720a0ea Mon Sep 17 00:00:00 2001 From: shrey kharbanda Date: Thu, 5 Nov 2026 12:50:23 -0800 Subject: [PATCH] feat(ui): add the secret headers auth type to the MCP server forms --- .../CreateMCPServer.integration.test.tsx | 37 ++++++++ .../_components/CreateMCPServer.tsx | 13 ++- .../_components/SecretHeadersFields.test.tsx | 94 +++++++++++++++++++ .../_components/SecretHeadersFields.tsx | 92 ++++++++++++++++++ .../mcp_server_edit.integration.test.tsx | 29 ++++++ .../_components/mcp_server_edit.tsx | 13 ++- .../_components/mountedServerFields.test.ts | 8 ++ .../_components/mountedServerFields.ts | 3 + .../src/components/mcp_tools/types.test.tsx | 13 +++ .../src/components/mcp_tools/types.tsx | 6 ++ ui/litellm-dashboard/src/i18n/locales/en.json | 10 ++ 11 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx index 3b3269b87cc..76efe8f9ebd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx @@ -436,6 +436,43 @@ describe("CreateMCPServer", () => { expect(payload.credentials).toEqual({ auth_value: "ApiKey raw-key-123" }); }); + it("drops the Secret Headers selection when the transport switches to OpenAPI, so the server can still be saved", async () => { + await selectHttpTransport(); + await selectOption("Authentication", "Secret Headers"); + await screen.findByPlaceholderText("Header name (e.g., DD-API-KEY)"); + + await selectOption("Transport Type", "OpenAPI Spec"); + const specInput = await screen.findByPlaceholderText("https://petstore3.swagger.io/api/v3/openapi.json"); + fireEvent.change(getServerNameInput(), { target: { value: "Switched_To_OpenAPI" } }); + fireEvent.change(specInput, { target: { value: "https://example.com/openapi.json" } }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" })); + }); + expect(networking.createMCPServer).not.toHaveBeenCalled(); + await selectOption("Authentication", "None"); + + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-3", + server_name: "Switched_To_OpenAPI", + alias: "Switched_To_OpenAPI", + url: "", + transport: "http", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" })); + }); + + await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.auth_type).toBe("none"); + expect(payload.credentials?.headers).toBeUndefined(); + }); + it("does not write the browser-authorized token into form.credentials for true_passthrough", async () => { await selectHttpTransport(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx index 14a6e3d941f..cde19ca5897 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx @@ -21,7 +21,7 @@ import { MCPServerCostInfo, TRANSPORT, TRANSPORT_ITEMS, - AUTH_TYPE_ITEMS, + authTypeItemsFor, getMcpOAuthMode, MCP_OAUTH2_FLOW_M2M, isClientForwardedTokenMode, @@ -40,6 +40,7 @@ import { import { DUPLICATE_IDENTIFIER_MESSAGE_KEY, findDuplicateMcpServer, mcpSubmitErrorReason } from "./duplicateServerCheck"; import { readCreateUiSnapshot, writeCreateUiSnapshot } from "./createOAuthUiState"; import AwsSigV4Fields from "./AwsSigV4Fields"; +import SecretHeadersFields from "./SecretHeadersFields"; import OpenApiByokFields from "./OpenApiByokFields"; import OAuthFormFields from "./OAuthFormFields"; import TruePassthroughWarning from "./TruePassthroughWarning"; @@ -177,6 +178,7 @@ const CreateMCPServer: React.FC = ({ const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE; const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG; const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; + const isSecretHeadersAuthType = authType === AUTH_TYPE.SECRET_HEADERS; const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M; const persistCreateUiState = () => { @@ -879,12 +881,12 @@ const CreateMCPServer: React.FC = ({ }} > {(control) => ( - (control)} items={authTypeItemsFor(transportType)}> - {AUTH_TYPE_ITEMS.map((item) => ( + {authTypeItemsFor(transportType).map((item) => ( {item.label} @@ -958,6 +960,11 @@ const CreateMCPServer: React.FC = ({ {transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && } + {transportType !== "stdio" && + transportType !== "" && + transportType !== TRANSPORT.OPENAPI && + isSecretHeadersAuthType && } + {/* Stdio Configuration - only show for stdio transport */} {transportType === "stdio" && !stdioEnabled && } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.test.tsx new file mode 100644 index 00000000000..7fa55cfd014 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.test.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import SecretHeadersFields from "./SecretHeadersFields"; +import { McpFormHarness as WithForm } from "./McpFormTestHarness"; + +const NAME_PLACEHOLDER = "Header name (e.g., DD-API-KEY)"; +const VALUE_PLACEHOLDER = "Header value"; + +describe("SecretHeadersFields", () => { + it("starts a new server with one empty row whose value is masked", () => { + render( + + + , + ); + expect(screen.getAllByPlaceholderText(NAME_PLACEHOLDER)).toHaveLength(1); + expect(screen.getByPlaceholderText(VALUE_PLACEHOLDER)).toHaveAttribute("type", "password"); + }); + + it("adds a single starter row even when React runs effects twice", () => { + render( + + + + + , + ); + expect(screen.getAllByPlaceholderText(NAME_PLACEHOLDER)).toHaveLength(1); + }); + + it("starts an existing server with no rows, since the stored headers are never returned", () => { + render( + + + , + ); + expect(screen.queryByPlaceholderText(NAME_PLACEHOLDER)).not.toBeInTheDocument(); + expect(screen.getByText(/leave this empty to keep it/)).toBeInTheDocument(); + }); + + it("submits every row under secret_headers and drops a removed row", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + const addHeader = screen.getByRole("button", { name: /Add Header/ }); + await act(async () => fireEvent.click(addHeader)); + await act(async () => fireEvent.click(addHeader)); + const names = screen.getAllByPlaceholderText(NAME_PLACEHOLDER); + const values = screen.getAllByPlaceholderText(VALUE_PLACEHOLDER); + await act(async () => { + fireEvent.change(names[0], { target: { value: "DD-API-KEY" } }); + fireEvent.change(values[0], { target: { value: "api-secret" } }); + fireEvent.change(names[1], { target: { value: "DD-APPLICATION-KEY" } }); + fireEvent.change(values[1], { target: { value: "app-secret" } }); + }); + await act(async () => fireEvent.click(screen.getAllByLabelText("Remove header")[1])); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" }))); + expect(onFinish).toHaveBeenCalledWith({ secret_headers: [{ header: "DD-API-KEY", value: "api-secret" }] }); + }); + + it("offers removal as a labeled button and keeps the last row of a new server", async () => { + render( + + + , + ); + const remove = screen.getByRole("button", { name: "Remove header" }); + expect(remove).toBeDisabled(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: /Add Header/ }))); + const buttons = screen.getAllByRole("button", { name: "Remove header" }); + expect(buttons).toHaveLength(2); + buttons.forEach((button) => expect(button).toBeEnabled()); + await act(async () => fireEvent.click(buttons[1])); + expect(screen.getAllByPlaceholderText(NAME_PLACEHOLDER)).toHaveLength(1); + expect(screen.getByRole("button", { name: "Remove header" })).toBeDisabled(); + }); + + it("lets a saved server drop every row, since no rows keeps the stored headers", async () => { + render( + + + , + ); + await act(async () => fireEvent.click(screen.getByRole("button", { name: /Add Header/ }))); + const remove = screen.getByRole("button", { name: "Remove header" }); + expect(remove).toBeEnabled(); + await act(async () => fireEvent.click(remove)); + expect(screen.queryByPlaceholderText(NAME_PLACEHOLDER)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.tsx new file mode 100644 index 00000000000..0382cbddf7f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.tsx @@ -0,0 +1,92 @@ +import { CircleMinus, Plus } from "lucide-react"; +import React, { useEffect, useRef } from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { + MountedFormField, + useMountedName, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { textControl } from "./mcpFieldRules"; +import { listControl } from "./mcpFormStore"; + +const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring"; + +const nonBlankRule = + (message: string) => + (value: unknown): true | string => + typeof value === "string" && value.trim() !== "" ? true : message; + +const SecretHeadersFields: React.FC<{ isEditing?: boolean }> = ({ isEditing = false }) => { + const { t } = useTranslation(); + const { control } = useFormContext(); + const { fields, append, remove } = useFieldArray({ control: listControl(control), name: "secret_headers" }); + useMountedName("secret_headers"); + + const addedStarterRow = useRef(false); + useEffect(() => { + if (isEditing || addedStarterRow.current || fields.length > 0) return; + addedStarterRow.current = true; + append({}); + }, [isEditing, fields.length, append]); + + return ( +
+

+ {t(isEditing ? "mcpServers.secretHeadersFields.editIntro" : "mcpServers.secretHeadersFields.intro")} +

+ {fields.map((item, index) => ( +
+ + {(control) => ( + + )} + + + {(control) => ( + + )} + + +
+ ))} + +
+ ); +}; + +export default SecretHeadersFields; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx index 15b09961a84..cf6858c6ab5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx @@ -485,4 +485,33 @@ describe("MCPServerEdit live tool preview", () => { expect(await screen.findByText("Saved credentials rejected")).toBeInTheDocument(); expect(networking.listMCPTools).toHaveBeenCalledTimes(2); }); + + it("drops the Secret Headers selection when the transport switches to OpenAPI", async () => { + const server = { ...BASE, auth_type: "secret_headers" } as MCPServer; + vi.mocked(networking.updateMCPServer).mockResolvedValue(server as never); + render( + , + ); + await selectOption("Transport Type", "OpenAPI Spec"); + const specInput = await screen.findByPlaceholderText("https://petstore3.swagger.io/api/v3/openapi.json"); + fireEvent.change(specInput, { target: { value: "https://example.com/openapi.json" } }); + await act(async () => { + screen.getAllByRole("button", { name: "Save Changes" })[0].click(); + }); + expect(networking.updateMCPServer).not.toHaveBeenCalled(); + await selectOption("Authentication", "None"); + await act(async () => { + screen.getAllByRole("button", { name: "Save Changes" })[0].click(); + }); + await waitFor(() => expect(networking.updateMCPServer).toHaveBeenCalled()); + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect((payload as { auth_type?: string }).auth_type).toBe("none"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index ee27d23bf8e..8c29c828b7a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -27,7 +27,7 @@ import { MCPServerCostInfo, TRANSPORT, TRANSPORT_ITEMS, - AUTH_TYPE_ITEMS, + authTypeItemsFor, getMcpOAuthMode, oauth2FlowToFormValue, } from "@/components/mcp_tools/types"; @@ -47,6 +47,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import { StdioDisabledBanner, TransportSelectItems } from "./StdioAvailability"; import TokenExchangeFormFields from "./TokenExchangeFormFields"; +import SecretHeadersFields from "./SecretHeadersFields"; import IdJagFormFields from "./IdJagFormFields"; import OAuthFormFields from "./OAuthFormFields"; import MCPLogoSelector from "./MCPLogoSelector"; @@ -195,6 +196,7 @@ const MCPServerEdit: React.FC = ({ const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE; const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG; const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; + const isSecretHeadersAuthType = authType === AUTH_TYPE.SECRET_HEADERS; // Same fallback as the delegate switch below: the value is undefined until the field mounts, so // reading it alone flashes the "no OAuth flow set" warning at a server that already has one. const oauthFlowTypeValue = @@ -688,6 +690,7 @@ const MCPServerEdit: React.FC = ({ args: undefined, env_json: undefined, stdio_config: undefined, + ...(isSecretHeadersAuthType ? { auth_type: undefined } : {}), }; setFieldsValue(form, clearedForOpenapi); } else { @@ -1014,12 +1017,12 @@ const MCPServerEdit: React.FC = ({ }} > {(control) => ( - (control)} items={authTypeItemsFor(transportType)}> - {AUTH_TYPE_ITEMS.map((item) => ( + {authTypeItemsFor(transportType).map((item) => ( {item.label} @@ -1155,6 +1158,10 @@ const MCPServerEdit: React.FC = ({ {!isStdioTransport && isIdJagAuthType && } + {!isStdioTransport && !isOpenAPITransport && isSecretHeadersAuthType && ( + + )} + {!isStdioTransport && isAwsSigV4AuthType && ( <>

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts index a235069ece1..339d0037ce9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts @@ -111,6 +111,14 @@ describe("edit root: auth_type gates", () => { expect(entra).toContain("token_exchange_profile"); }); + it("mounts the secret header rows only for secret_headers, on edit and on create", () => { + expect(editRoot({ transport: "http", auth_type: "secret_headers" })).toContain("secret_headers"); + expect(createRoot({ transport: "http", auth_type: "secret_headers" })).toContain("secret_headers"); + expect(editRoot({ transport: "http", auth_type: "api_key" })).not.toContain("secret_headers"); + expect(createRoot({ transport: "http", auth_type: "none" })).not.toContain("secret_headers"); + expect(editRoot({ transport: "stdio", auth_type: "secret_headers" })).not.toContain("secret_headers"); + }); + it("mounts the seven aws credentials only for aws_sigv4", () => { expect(sorted(editCreds({ transport: "http", auth_type: "aws_sigv4" }))).toStrictEqual( sorted([ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts index e980b69132c..40a9cbd0a7f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts @@ -97,6 +97,9 @@ const authSubtreeRoot = ({ authType, oauthFlowType, tokenExchangeProfile }: Auth if (authType === AUTH_TYPE.OAUTH2_ID_JAG) { return ["token_exchange_endpoint", "audience", "subject_token_type"]; } + if (authType === AUTH_TYPE.SECRET_HEADERS) { + return ["secret_headers"]; + } return []; }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx index f555fc1efa9..48b60de5917 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx @@ -16,6 +16,7 @@ import { withoutMintedTokenCredentials, credentialAuthClass, isUnsupportedOnGatewayConnect, + authTypeItemsFor, } from "./types"; describe("getOAuthAuthorizationIdentity", () => { @@ -355,3 +356,15 @@ describe("preservedAdminCredentials vs preservedDeclaredAppCredentials", () => { }); }); }); + +describe("authTypeItemsFor", () => { + it("offers secret headers for every transport except OpenAPI", () => { + const offered = (transport: string) => authTypeItemsFor(transport).some((item) => item.value === "secret_headers"); + expect([TRANSPORT.HTTP, TRANSPORT.SSE, undefined].map((transport) => offered(transport as string))).toEqual([ + true, + true, + true, + ]); + expect(offered(TRANSPORT.OPENAPI)).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index b423a310b13..0dc00c1276b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -59,10 +59,16 @@ export const AUTH_TYPE_ITEMS = [ { value: AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, label: "OAuth Token Exchange (OBO)" }, { value: AUTH_TYPE.OAUTH2_ID_JAG, label: "ID-JAG (Okta Cross App Access)" }, { value: AUTH_TYPE.AWS_SIGV4, label: "AWS SigV4 (Bedrock AgentCore MCPs)" }, + { value: AUTH_TYPE.SECRET_HEADERS, label: "Secret Headers" }, { value: AUTH_TYPE.TRUE_PASSTHROUGH, label: "True Passthrough (no LiteLLM auth)" }, { value: AUTH_TYPE.OAUTH_DELEGATE, label: "OAuth Delegate (client-supplied upstream token)" }, ]; +export const authTypeItemsFor = (transport: string | undefined) => + transport === TRANSPORT.OPENAPI + ? AUTH_TYPE_ITEMS.filter((item) => item.value !== AUTH_TYPE.SECRET_HEADERS) + : AUTH_TYPE_ITEMS; + // The two client-forwarded token modes: the caller supplies the upstream Authorization (forwarded // verbatim for true_passthrough, alongside LiteLLM admission for oauth_delegate). The dashboard holds // their token in sessionStorage instead of persisting it, and the browser-authorize temp payload keeps diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index f9a7882b2de..dbdb8b4d7a5 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -11776,6 +11776,16 @@ "placeholder": "litellm-prod (optional, auto-generated if blank)" } }, + "secretHeadersFields": { + "intro": "Sent with every request to this MCP server. Values are stored encrypted and are not shown again after you save.", + "editIntro": "Header names and values are not shown after saving. Add headers here to replace the stored set, or leave this empty to keep it.", + "headerNameRequired": "Header name is required", + "headerNamePlaceholder": "Header name (e.g., DD-API-KEY)", + "headerValueRequired": "Header value is required", + "headerValuePlaceholder": "Header value", + "remove": "Remove header", + "add": "Add Header" + }, "passthroughAuthorizeSection": { "description": "Callers bring their own upstream token for this auth type, so LiteLLM never stores tokens. To preview tools and configure the tool allowlist, authorize against the upstream here: the token stays in this browser session only and is never saved to LiteLLM. An OAuth app configured below IS saved with the server, so internal users who authorize from the Tools page go through it.", "appMayNotMatchUpstream": "You changed the upstream URL or endpoints; the OAuth app entered here was registered for the previous upstream and may not be valid. Update the client ID, or clear it to use dynamic client registration.",