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/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/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 61c81c030c5..b423a310b13 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", };