This commit is contained in:
shrey 2026-11-06 22:54:17 +00:00 • committed by GitHub
commit 02bcf5d0b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 411 additions and 16 deletions

View file

@ -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<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
}}
>
{(control) => (
<Select {...selectControl<string>(control)} items={AUTH_TYPE_ITEMS}>
<Select {...selectControl<string>(control)} items={authTypeItemsFor(transportType)}>
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
<SelectValue placeholder={t("mcpServers.createMcpServer.auth.typePlaceholder")} />
</SelectTrigger>
<SelectContent>
{AUTH_TYPE_ITEMS.map((item) => (
{authTypeItemsFor(transportType).map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -958,6 +960,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
{transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && <AwsSigV4Fields />}
{transportType !== "stdio" &&
transportType !== "" &&
transportType !== TRANSPORT.OPENAPI &&
isSecretHeadersAuthType && <SecretHeadersFields />}
{/* Stdio Configuration - only show for stdio transport */}
{transportType === "stdio" && !stdioEnabled && <StdioDisabledBanner />}
<StdioConfiguration isVisible={transportType === "stdio"} />

View file

@ -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(
<WithForm>
<SecretHeadersFields />
</WithForm>,
);
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(
<React.StrictMode>
<WithForm>
<SecretHeadersFields />
</WithForm>
</React.StrictMode>,
);
expect(screen.getAllByPlaceholderText(NAME_PLACEHOLDER)).toHaveLength(1);
});
it("starts an existing server with no rows, since the stored headers are never returned", () => {
render(
<WithForm>
<SecretHeadersFields isEditing />
</WithForm>,
);
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(
<WithForm onFinish={onFinish}>
<SecretHeadersFields isEditing />
</WithForm>,
);
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" }] });
});
});

View file

@ -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<MountedFormValues>();
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 (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{t(isEditing ? "mcpServers.secretHeadersFields.editIntro" : "mcpServers.secretHeadersFields.intro")}
</p>
{fields.map((item, index) => (
<div key={item.id} className="flex w-full items-baseline gap-4">
<MountedFormField
name={["secret_headers", String(index), "header"]}
className="flex-1"
rules={{ validate: { required: nonBlankRule(t("mcpServers.secretHeadersFields.headerNameRequired")) } }}
>
{(control) => (
<Input
{...textControl(control)}
placeholder={t("mcpServers.secretHeadersFields.headerNamePlaceholder")}
className={fieldClassName}
/>
)}
</MountedFormField>
<MountedFormField
name={["secret_headers", String(index), "value"]}
className="flex-1"
rules={{ validate: { required: nonBlankRule(t("mcpServers.secretHeadersFields.headerValueRequired")) } }}
>
{(control) => (
<PasswordInput
{...textControl(control)}
placeholder={t("mcpServers.secretHeadersFields.headerValuePlaceholder")}
autoComplete="new-password"
groupClassName={fieldClassName}
/>
)}
</MountedFormField>
<CircleMinus
aria-label={t("mcpServers.secretHeadersFields.remove")}
onClick={() => remove(index)}
className="size-4 text-muted-foreground hover:text-destructive cursor-pointer"
/>
</div>
))}
<Button variant="outline" className="w-full border-dashed" onClick={() => append({})}>
<Plus />
{t("mcpServers.secretHeadersFields.add")}
</Button>
</div>
);
};
export default SecretHeadersFields;

View file

@ -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);

View file

@ -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<string, string> => {
}, {});
};
export const withSecretHeaders = (
credentials: Readonly<Record<string, unknown>> | undefined,
authType: string | undefined,
secretHeadersList: unknown,
): Readonly<Record<string, unknown>> | 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.

View file

@ -87,6 +87,7 @@ const AUTH_TYPES = [
"oauth2_token_exchange",
"oauth2_id_jag",
"aws_sigv4",
"secret_headers",
"true_passthrough",
"oauth_delegate",
] as const;

View file

@ -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;

View file

@ -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"

View file

@ -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");
});
});
});

View file

@ -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<Record<string, string>>,
) => {
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<Record<string, unknown>>) => {
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<Record<string, unknown>>) => {
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",

View file

@ -69,6 +69,7 @@ export interface McpEnvVarRow {
export interface McpListValues {
static_headers: McpStaticHeaderRow[];
secret_headers: McpStaticHeaderRow[];
env_vars: McpEnvVarRow[];
}

View file

@ -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<MCPServerEditProps> = ({
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<MCPServerEditProps> = ({
}}
>
{(control) => (
<Select {...selectControl<string>(control)} items={AUTH_TYPE_ITEMS}>
<Select {...selectControl<string>(control)} items={authTypeItemsFor(transportType)}>
<SelectTrigger {...selectTriggerControl(control)} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTH_TYPE_ITEMS.map((item) => (
{authTypeItemsFor(transportType).map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -1155,6 +1157,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
{!isStdioTransport && isIdJagAuthType && <IdJagFormFields isEditing />}
{!isStdioTransport && !isOpenAPITransport && isSecretHeadersAuthType && (
<SecretHeadersFields isEditing={mcpServer.auth_type === AUTH_TYPE.SECRET_HEADERS} />
)}
{!isStdioTransport && isAwsSigV4AuthType && (
<>
<p className="text-sm text-muted-foreground mb-2">

View file

@ -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([

View file

@ -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 [];
};

View file

@ -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);
});
});

View file

@ -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

View file

@ -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();
},
);
});

View file

@ -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<string, string>;
};
}
@ -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 {

View file

@ -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.",