mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 105a17292a into 2112645192
This commit is contained in:
commit
02bcf5d0b8
19 changed files with 411 additions and 16 deletions
|
|
@ -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"} />
|
||||
|
|
|
|||
|
|
@ -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" }] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const AUTH_TYPES = [
|
|||
"oauth2_token_exchange",
|
||||
"oauth2_id_jag",
|
||||
"aws_sigv4",
|
||||
"secret_headers",
|
||||
"true_passthrough",
|
||||
"oauth_delegate",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface McpEnvVarRow {
|
|||
|
||||
export interface McpListValues {
|
||||
static_headers: McpStaticHeaderRow[];
|
||||
secret_headers: McpStaticHeaderRow[];
|
||||
env_vars: McpEnvVarRow[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
ui/litellm-dashboard/src/hooks/useTestMCPConnection.test.tsx
Normal file
48
ui/litellm-dashboard/src/hooks/useTestMCPConnection.test.tsx
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue