From 105a17292a21e8233160ba1972c4f5c851e43882 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 --- .../_components/CreateMCPServer.tsx | 13 ++- .../_components/SecretHeadersFields.test.tsx | 64 ++++++++++++++ .../_components/SecretHeadersFields.tsx | 86 +++++++++++++++++++ .../_components/createServerPayload.test.ts | 26 ++++++ .../_components/createServerPayload.ts | 13 ++- .../editServerPayload.differential.cases.ts | 1 + .../editServerPayload.differential.test.ts | 26 ++++++ .../_components/editServerPayload.ts | 9 +- .../_components/editToolPreview.test.ts | 42 +++++++++ .../_components/editToolPreview.ts | 31 +++++-- .../mcp-servers/_components/mcpFormStore.ts | 1 + .../_components/mcp_server_edit.tsx | 12 ++- .../_components/mountedServerFields.test.ts | 8 ++ .../_components/mountedServerFields.ts | 3 + .../src/components/mcp_tools/types.test.tsx | 13 +++ .../src/components/mcp_tools/types.tsx | 7 ++ .../src/hooks/useTestMCPConnection.test.tsx | 48 +++++++++++ .../src/hooks/useTestMCPConnection.tsx | 14 ++- ui/litellm-dashboard/src/i18n/locales/en.json | 10 +++ 19 files changed, 411 insertions(+), 16 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 create mode 100644 ui/litellm-dashboard/src/hooks/useTestMCPConnection.test.tsx 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..90194035998 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.test.tsx @@ -0,0 +1,64 @@ +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" }] }); + }); +}); 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..f60a5b1943c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/SecretHeadersFields.tsx @@ -0,0 +1,86 @@ +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) => ( + + )} + + remove(index)} + className="size-4 text-muted-foreground hover:text-destructive cursor-pointer" + /> +
+ ))} + +
+ ); +}; + +export default SecretHeadersFields; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts index 3ae846b0308..2732385798c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts @@ -239,6 +239,32 @@ describe("buildCreateServerPayload credentials", () => { }); }); +describe("buildCreateServerPayload secret headers", () => { + const rows = [ + { header: " DD-API-KEY ", value: " api-secret " }, + { header: "DD-APPLICATION-KEY", value: "app-secret" }, + ]; + + it("sends the rows as credentials.headers and never as a top-level field", () => { + const payload = payloadOf(build({ auth_type: "secret_headers", secret_headers: rows })); + expect(payload.credentials).toEqual({ + headers: { "DD-API-KEY": "api-secret", "DD-APPLICATION-KEY": "app-secret" }, + }); + expect(payload).not.toHaveProperty("secret_headers"); + }); + + it("leaves out rows that a different auth type left behind", () => { + const payload = payloadOf(build({ auth_type: "api_key", credentials: { auth_value: "k" }, secret_headers: rows })); + expect(payload.credentials).toEqual({ auth_value: "k" }); + expect(payload).not.toHaveProperty("secret_headers"); + }); + + it("omits credentials when no row names a header", () => { + const payload = payloadOf(build({ auth_type: "secret_headers", secret_headers: [{ header: " ", value: "v" }] })); + expect(payload).not.toHaveProperty("credentials"); + }); +}); + describe("buildCreateServerPayload flags", () => { it.each([["true_passthrough"], ["oauth_delegate"]])("defaults dcr_bridge on for %s", (authType) => { expect(payloadOf(build({ auth_type: authType })).dcr_bridge).toBe(true); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.ts index 5536b4b1bc4..5de20ca8ffa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.ts @@ -24,6 +24,7 @@ export const AUTH_TYPES_REQUIRING_CREDENTIALS = [ AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.OAUTH2_ID_JAG, AUTH_TYPE.AWS_SIGV4, + AUTH_TYPE.SECRET_HEADERS, AUTH_TYPE.TRUE_PASSTHROUGH, AUTH_TYPE.OAUTH_DELEGATE, ]; @@ -75,6 +76,15 @@ export const reduceStaticHeaders = (list: unknown): Record => { }, {}); }; +export const withSecretHeaders = ( + credentials: Readonly> | undefined, + authType: string | undefined, + secretHeadersList: unknown, +): Readonly> | undefined => { + const headers = authType === AUTH_TYPE.SECRET_HEADERS ? reduceStaticHeaders(secretHeadersList) : {}; + return Object.keys(headers).length > 0 ? { ...credentials, headers } : credentials; +}; + // Accepts both the full `{"mcpServers": {"name": {...}}}` shape a user copies out of a client config // and a bare `{"command": ..., "args": ..., "env": ...}`. A non-object JSON body (null, a number) // falls through to the invalid branch, which is what the caller surfaces to the admin. @@ -137,6 +147,7 @@ export const buildCreateServerPayload = ( const { static_headers: staticHeadersList, + secret_headers: secretHeadersList, env_vars: envVarsList, stdio_config: rawStdioConfig, credentials: credentialValues, @@ -172,7 +183,7 @@ export const buildCreateServerPayload = ( const transport = restValues.transport === TRANSPORT.OPENAPI ? "http" : restValues.transport; const authType = restValues.auth_type as string | undefined; - const credentialsPayload = filterCredentials(credentialValues); + const credentialsPayload = withSecretHeaders(filterCredentials(credentialValues), authType, secretHeadersList); const includeCredentials = authType !== undefined && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(authType); // Client-forwarded rows persist ONLY the declared app; strip any token material that lingered in // the form (e.g. from a prior oauth2 authorize on the same session) so it can never reach the row. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts index e9f32b5c4db..5b494633ba6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts @@ -87,6 +87,7 @@ const AUTH_TYPES = [ "oauth2_token_exchange", "oauth2_id_jag", "aws_sigv4", + "secret_headers", "true_passthrough", "oauth_delegate", ] as const; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts index ee5cce2d0d2..e81d2f82944 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts @@ -358,6 +358,32 @@ describe("buildEditServerPayload wire contract", () => { ); }); +describe("buildEditServerPayload secret headers", () => { + const rows = [{ header: "DD-API-KEY", value: "rotated-secret" }]; + const payloadOf = (values: EditServerFormValues) => { + const result = buildEditServerPayload({ ...EDIT_FORM_VALUES, auth_type: "secret_headers", ...values }, baseUi); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") throw new Error("Expected valid MCP server payload"); + return result.payload; + }; + + it("replaces the stored headers when rows are entered", () => { + const payload = payloadOf({ secret_headers: rows }); + expect(payload.credentials).toEqual({ headers: { "DD-API-KEY": "rotated-secret" } }); + expect(payload).not.toHaveProperty("secret_headers"); + }); + + it("sends no credentials, so the stored headers are kept, when no rows are entered", () => { + expect(payloadOf({ secret_headers: [] })).not.toHaveProperty("credentials"); + }); + + it("ignores rows once the server is switched to another auth type", () => { + const payload = payloadOf({ auth_type: "none", secret_headers: rows }); + expect(payload).not.toHaveProperty("credentials"); + expect(payload).not.toHaveProperty("secret_headers"); + }); +}); + void ADMIN_CONFIG_CREDENTIAL_KEYS; void AUTH_TYPE; void AUTH_TYPES_REQUIRING_CREDENTIALS; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts index 068cf85a6e1..e8af603cfcb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts @@ -14,7 +14,7 @@ import { isClientForwardedTokenMode, preservedAdminCredentials, } from "@/components/mcp_tools/types"; -import { AUTH_TYPES_REQUIRING_CREDENTIALS, reduceStaticHeaders } from "./createServerPayload"; +import { AUTH_TYPES_REQUIRING_CREDENTIALS, reduceStaticHeaders, withSecretHeaders } from "./createServerPayload"; import { TOOL_DISPLAY_NAME_PATTERN, normalizeEnvVars } from "./utils"; export type MCPAccessGroupValue = string | { readonly name?: string }; @@ -288,6 +288,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer const { static_headers: staticHeadersList, + secret_headers: secretHeadersList, env_vars: envVarsList, credentials: credentialValues, stdio_config: rawStdioConfig, @@ -308,7 +309,11 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer ); const staticHeaders = reduceStaticHeaders(staticHeadersList); const envVars = normalizeEnvVars(envVarsList); - const credentialsPayload = buildCredentials(credentialValues); + const credentialsPayload = withSecretHeaders( + buildCredentials(credentialValues), + rawRestValues.auth_type, + secretHeadersList, + ); const stdio = rawRestValues.transport === "stdio" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts index c7147466d43..de28b303e8a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts @@ -102,4 +102,46 @@ describe("getEditToolPreview", () => { expect(getEditToolPreview({ ...saved, transport }, saved)).toEqual({ kind: "saved" }); } }); + + describe("secret headers", () => { + const savedCustom = { ...saved, auth_type: "secret_headers", static_headers: [] }; + const rows = [{ header: "DD-API-KEY", value: "rotated-secret" }]; + + it("keeps saved discovery while no header rows are entered", () => { + expect(getEditToolPreview({ ...savedCustom, secret_headers: [] }, savedCustom)).toEqual({ kind: "saved" }); + }); + + it("previews entered rows as credentials.headers", () => { + expect(getEditToolPreview({ ...savedCustom, secret_headers: rows }, savedCustom)).toEqual({ + kind: "preview", + config: { + url: "https://example.com/mcp", + transport: "http", + auth_type: "secret_headers", + static_headers: {}, + credentials: { headers: { "DD-API-KEY": "rotated-secret" } }, + }, + }); + }); + + it("needs rows before previewing a switch to secret headers", () => { + expect(getEditToolPreview({ ...saved, auth_type: "secret_headers" }, saved).kind).toBe("incomplete"); + expect(getEditToolPreview({ ...saved, auth_type: "secret_headers", secret_headers: rows }, saved).kind).toBe( + "preview", + ); + }); + + it("does not reuse the stored headers on a new origin", () => { + expect(getEditToolPreview({ ...savedCustom, url: "https://other.example/mcp" }, savedCustom).kind).toBe( + "incomplete", + ); + }); + + it("waits for a value on every row", () => { + expect( + getEditToolPreview({ ...savedCustom, secret_headers: [{ header: "DD-API-KEY", value: " " }] }, savedCustom) + .kind, + ).toBe("incomplete"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts index 4ffc7dc600b..be42f92989d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts @@ -3,12 +3,28 @@ import i18n from "@/i18n/i18n"; import { AUTH_TYPE, TRANSPORT } from "@/components/mcp_tools/types"; import { AUTH_TYPES_REQUIRING_AUTH_VALUE, reduceStaticHeaders } from "./createServerPayload"; +const previewCredentials = ( + authValue: unknown, + needsAuthValue: boolean, + secretHeaders: Readonly>, +) => { + if (needsAuthValue && typeof authValue === "string" && authValue.trim()) return { auth_value: authValue }; + if (Object.keys(secretHeaders).length > 0) return { headers: secretHeaders }; + return undefined; +}; + const connectionConfig = (values: Readonly>) => { const credentials = values.credentials; const authValue = credentials && typeof credentials === "object" && "auth_value" in credentials ? credentials.auth_value : undefined; const needsAuthValue = typeof values.auth_type === "string" && AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(values.auth_type); + const secretHeaders = + values.auth_type === AUTH_TYPE.SECRET_HEADERS + ? Object.fromEntries( + Object.entries(reduceStaticHeaders(values.secret_headers)).sort(([a], [b]) => a.localeCompare(b)), + ) + : {}; return { url: typeof values.url === "string" ? values.url : "", transport: typeof values.transport === "string" ? values.transport : "", @@ -16,8 +32,7 @@ const connectionConfig = (values: Readonly>) => { static_headers: Object.fromEntries( Object.entries(reduceStaticHeaders(values.static_headers)).sort(([a], [b]) => a.localeCompare(b)), ), - credentials: - needsAuthValue && typeof authValue === "string" && authValue.trim() ? { auth_value: authValue } : undefined, + credentials: previewCredentials(authValue, needsAuthValue, secretHeaders), }; }; @@ -33,6 +48,7 @@ export const getEditToolPreview = ( ): EditToolPreview => { const staticAuth = values.auth_type === AUTH_TYPE.NONE || + values.auth_type === AUTH_TYPE.SECRET_HEADERS || (typeof values.auth_type === "string" && AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(values.auth_type)); if (!staticAuth || ![TRANSPORT.HTTP, TRANSPORT.SSE].includes(String(values.transport))) { return { kind: "saved" }; @@ -45,10 +61,13 @@ export const getEditToolPreview = ( const missingNewCredential = config.auth_type !== initialValues.auth_type && - AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(config.auth_type) && + (AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(config.auth_type) || config.auth_type === AUTH_TYPE.SECRET_HEADERS) && config.credentials === undefined; const validUrl = URL.canParse(config.url) && ["http:", "https:"].includes(new URL(config.url).protocol); - const incompleteHeaders = Object.values(config.static_headers).some((value) => !value.trim()); + const incompleteHeaders = [ + ...Object.values(config.static_headers), + ...Object.values(config.credentials?.headers ?? {}), + ].some((value) => !value.trim()); if (!validUrl || missingNewCredential || incompleteHeaders) { return { kind: "incomplete" }; } @@ -58,7 +77,9 @@ export const getEditToolPreview = ( const reusesHeader = Object.entries(config.static_headers).some( ([key, value]) => savedConfig.static_headers[key] === value, ); - const needsSavedCredential = AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(config.auth_type) && !config.credentials; + const needsSavedCredential = + (AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(config.auth_type) || config.auth_type === AUTH_TYPE.SECRET_HEADERS) && + !config.credentials; if (changedOrigin && (needsSavedCredential || reusesHeader)) { return { kind: "incomplete", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts index 20df3e32273..91d4b25a509 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts @@ -69,6 +69,7 @@ export interface McpEnvVarRow { export interface McpListValues { static_headers: McpStaticHeaderRow[]; + secret_headers: McpStaticHeaderRow[]; env_vars: McpEnvVarRow[]; } 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..c57c45abdf4 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 = @@ -1014,12 +1016,12 @@ const MCPServerEdit: React.FC = ({ }} > {(control) => ( - (control)} items={authTypeItemsFor(transportType)}> - {AUTH_TYPE_ITEMS.map((item) => ( + {authTypeItemsFor(transportType).map((item) => ( {item.label} @@ -1155,6 +1157,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 61c81c030c5..0dc00c1276b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -43,6 +43,7 @@ export const AUTH_TYPE = { OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange", OAUTH2_ID_JAG: "oauth2_id_jag", AWS_SIGV4: "aws_sigv4", + SECRET_HEADERS: "secret_headers", TRUE_PASSTHROUGH: "true_passthrough", OAUTH_DELEGATE: "oauth_delegate", }; @@ -58,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/hooks/useTestMCPConnection.test.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.test.tsx new file mode 100644 index 00000000000..eb9e27aa96f --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.test.tsx @@ -0,0 +1,48 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "@/i18n/i18n"; +import { testMCPToolsListRequest } from "../components/networking"; +import { useTestMCPConnection } from "./useTestMCPConnection"; + +vi.mock("../components/networking", () => ({ testMCPToolsListRequest: vi.fn() })); + +const formValues = { + url: "https://upstream.example/mcp", + transport: "http", + auth_type: "secret_headers", + static_headers: [{ header: "X-Datadog-Site", value: "datadoghq.com" }], +}; +const rows = [ + { header: "DD-API-KEY", value: "api-secret" }, + { header: "DD-APPLICATION-KEY", value: "app-secret" }, +]; + +describe("useTestMCPConnection with secret headers", () => { + beforeEach(() => { + vi.mocked(testMCPToolsListRequest).mockReset().mockResolvedValue({ tools: [] }); + }); + + it("sends the header rows as credentials.headers once every row is complete", async () => { + const { result } = renderHook(() => + useTestMCPConnection({ accessToken: "sk-admin", formValues: { ...formValues, secret_headers: rows } }), + ); + await waitFor(() => expect(testMCPToolsListRequest).toHaveBeenCalledTimes(1)); + expect(vi.mocked(testMCPToolsListRequest).mock.calls[0][1]).toMatchObject({ + auth_type: "secret_headers", + static_headers: { "X-Datadog-Site": "datadoghq.com" }, + credentials: { headers: { "DD-API-KEY": "api-secret", "DD-APPLICATION-KEY": "app-secret" } }, + }); + expect(result.current.canFetchTools).toBe(true); + }); + + it.each([[[]], [[{ header: "DD-API-KEY", value: "" }]]])( + "does not call upstream for incomplete rows %j", + (custom) => { + const { result } = renderHook(() => + useTestMCPConnection({ accessToken: "sk-admin", formValues: { ...formValues, secret_headers: custom } }), + ); + expect(result.current.canFetchTools).toBe(false); + expect(testMCPToolsListRequest).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx index d01174d2de6..7268e965ebc 100644 --- a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx +++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { testMCPToolsListRequest } from "../components/networking"; import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT, isClientForwardedTokenMode } from "@/components/mcp_tools/types"; +import { reduceStaticHeaders, withSecretHeaders } from "@/app/(dashboard)/mcp-servers/_components/createServerPayload"; interface MCPServerConfig { server_id?: string; @@ -20,6 +21,7 @@ interface MCPServerConfig { client_id?: string; client_secret?: string; scopes?: string[]; + headers?: Record; }; } @@ -62,6 +64,10 @@ export const useTestMCPConnection = ({ const requiresOAuthToken = (formValues.auth_type === AUTH_TYPE.OAUTH2 && !isM2MOAuth) || isBrowserHeldTokenMode; const isOpenAPITransport = formValues.transport === TRANSPORT.OPENAPI; const hasEndpoint = isOpenAPITransport ? !!formValues.spec_path : !!formValues.url; + const secretHeaders = reduceStaticHeaders(formValues.secret_headers); + const lacksSecretHeaders = + formValues.auth_type === AUTH_TYPE.SECRET_HEADERS && + (Object.keys(secretHeaders).length === 0 || Object.values(secretHeaders).some((value) => !value)); // For OpenAPI: tools are derived from the spec itself — no auth needed to load them. // For other transports: auth_type is required, and OAuth interactive flows need a token. @@ -72,11 +78,13 @@ export const useTestMCPConnection = ({ formValues.transport && formValues.auth_type && accessToken && + !lacksSecretHeaders && (!requiresOAuthToken || oauthAccessToken) ); const staticHeadersKey = JSON.stringify(formValues.static_headers ?? {}); const credentialsKey = JSON.stringify(formValues.credentials ?? {}); + const secretHeadersKey = JSON.stringify(formValues.secret_headers ?? []); const fetchTools = async () => { if (!accessToken || (!formValues.url && !formValues.spec_path)) { @@ -152,8 +160,9 @@ export const useTestMCPConnection = ({ static_headers: staticHeaders, }; - if (credentials && Object.keys(credentials).length > 0) { - mcpServerConfig.credentials = credentials; + const credentialsWithHeaders = withSecretHeaders(credentials, formValues.auth_type, formValues.secret_headers); + if (credentialsWithHeaders && Object.keys(credentialsWithHeaders).length > 0) { + mcpServerConfig.credentials = credentialsWithHeaders; } const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig, oauthAccessToken); @@ -217,6 +226,7 @@ export const useTestMCPConnection = ({ canFetchTools, staticHeadersKey, credentialsKey, + secretHeadersKey, ]); return { 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.",