diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index 6c67f593aec..107f66b8f1a 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -779,6 +779,11 @@
"count": 1
}
},
+ "src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx": {
+ "no-restricted-imports": {
+ "count": 1
+ }
+ },
"src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx": {
"max-lines": {
"count": 1
@@ -870,6 +875,11 @@
"count": 1
}
},
+ "src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx": {
+ "no-restricted-imports": {
+ "count": 1
+ }
+ },
"src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx": {
"no-restricted-imports": {
"count": 1
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx
new file mode 100644
index 00000000000..d4ae537bffa
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx
@@ -0,0 +1,155 @@
+import React from "react";
+import { Form, Input, Tooltip } from "antd";
+import { InfoCircleOutlined } from "@ant-design/icons";
+
+const AwsSigV4Fields: React.FC = () => (
+ <>
+
+ For MCP servers hosted on AWS Bedrock AgentCore.{" "}
+
+ View docs →
+
+
+
+ AWS Region
+
+
+
+
+ }
+ name={["credentials", "aws_region_name"]}
+ rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
+ >
+
+
+
+ AWS Service Name
+
+
+
+
+ }
+ name={["credentials", "aws_service_name"]}
+ >
+
+
+
+ AWS Access Key ID
+
+
+
+
+ }
+ name={["credentials", "aws_access_key_id"]}
+ dependencies={[["credentials", "aws_secret_access_key"]]}
+ rules={[
+ ({ getFieldValue }) => ({
+ validator(_, value) {
+ const secretKey = getFieldValue(["credentials", "aws_secret_access_key"]);
+ if (secretKey && !value) {
+ return Promise.reject(new Error("Access Key ID is required when Secret Access Key is provided"));
+ }
+ return Promise.resolve();
+ },
+ }),
+ ]}
+ >
+
+
+
+ AWS Secret Access Key
+
+
+
+
+ }
+ name={["credentials", "aws_secret_access_key"]}
+ dependencies={[["credentials", "aws_access_key_id"]]}
+ rules={[
+ ({ getFieldValue }) => ({
+ validator(_, value) {
+ const accessKeyId = getFieldValue(["credentials", "aws_access_key_id"]);
+ if (accessKeyId && !value) {
+ return Promise.reject(new Error("Secret Access Key is required when Access Key ID is provided"));
+ }
+ return Promise.resolve();
+ },
+ }),
+ ]}
+ >
+
+
+
+ AWS Session Token
+
+
+
+
+ }
+ name={["credentials", "aws_session_token"]}
+ >
+
+
+
+ AWS Role ARN
+
+
+
+
+ }
+ name={["credentials", "aws_role_name"]}
+ >
+
+
+
+ AWS Session Name
+
+
+
+
+ }
+ name={["credentials", "aws_session_name"]}
+ >
+
+
+ >
+);
+
+export default AwsSigV4Fields;
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 1d0262acdca..0785dd142ff 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
@@ -1,5 +1,5 @@
import React, { useState } from "react";
-import { Modal, Tooltip, Form, Select, Input, InputNumber, Switch, Collapse } from "antd";
+import { Modal, Tooltip, Form, Select, Input, InputNumber, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "@/components/networking";
@@ -13,15 +13,22 @@ import {
TRANSPORT,
getMcpOAuthMode,
MCP_OAUTH2_FLOW_M2M,
- MCP_OAUTH2_FLOW_INTERACTIVE,
isClientForwardedTokenMode,
getOAuthAuthorizationIdentity,
CLEARED_ON_INVALIDATION,
isHeldOAuthTokenStale,
preservedAdminCredentials,
preservedDeclaredAppCredentials,
- withoutMintedTokenCredentials,
} from "@/components/mcp_tools/types";
+import {
+ AUTH_TYPES_REQUIRING_AUTH_VALUE,
+ BuildCreatePayloadResult,
+ buildCreateServerPayload,
+ reduceStaticHeaders,
+} from "./createServerPayload";
+import { readCreateUiSnapshot, writeCreateUiSnapshot } from "./createOAuthUiState";
+import AwsSigV4Fields from "./AwsSigV4Fields";
+import OpenApiByokFields from "./OpenApiByokFields";
import OAuthFormFields from "./OAuthFormFields";
import TruePassthroughWarning from "./TruePassthroughWarning";
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
@@ -36,11 +43,10 @@ import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import { isAdminRole } from "@/utils/roles";
-import { validateMCPServerUrl, validateMCPServerName, normalizeEnvVars, TOOL_DISPLAY_NAME_PATTERN } from "./utils";
+import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
-import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
import mcpLogo from "../../../../../public/assets/logos/mcp_logo.png";
export const mcpLogoImg = mcpLogo.src;
@@ -57,25 +63,15 @@ interface CreateMCPServerProps {
onBackToDiscovery?: () => void;
}
-const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC];
-const AUTH_TYPES_REQUIRING_CREDENTIALS = [
- ...AUTH_TYPES_REQUIRING_AUTH_VALUE,
- AUTH_TYPE.OAUTH2,
- AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
- AUTH_TYPE.OAUTH2_ID_JAG,
- AUTH_TYPE.AWS_SIGV4,
- AUTH_TYPE.TRUE_PASSTHROUGH,
- AUTH_TYPE.OAUTH_DELEGATE,
-];
-const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state";
-
-const reduceStaticHeaders = (list: unknown): Record => {
- if (!Array.isArray(list)) return {};
- return list.reduce((acc: Record, entry: Record) => {
- const header = entry?.header?.trim();
- if (header) acc[header] = (entry?.value ?? "").trim();
- return acc;
- }, {});
+const payloadErrorMessage = (result: Exclude): string => {
+ switch (result.kind) {
+ case "invalid_tool_display_name":
+ return `Tool display name "${result.displayName}" is invalid. Only letters, digits, underscores, and hyphens are allowed (no spaces).`;
+ case "invalid_stdio_json":
+ return "Invalid JSON in stdio configuration";
+ case "invalid_token_validation_json":
+ return "Invalid JSON in Token Validation Rules";
+ }
};
const CreateMCPServer: React.FC = ({
@@ -147,29 +143,18 @@ const CreateMCPServer: React.FC = ({
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
const persistCreateUiState = () => {
- if (typeof window === "undefined") {
- return;
- }
- try {
- const values = form.getFieldsValue(true);
- const uiState = {
- modalVisible: isModalVisible,
- formValues: values,
- transportType,
- costConfig,
- allowedTools,
- hasToolAllowlistInteraction,
- searchValue,
- aliasManuallyEdited,
- logoUrl,
- // Persist the identity so invalidation stays armed across the OAuth redirect round trip: a
- // post-restore url/mode edit must still discard the held token instead of silently keeping it.
- authorizedIdentity,
- };
- setSecureItem(CREATE_OAUTH_UI_STATE_KEY, JSON.stringify(uiState));
- } catch (err) {
- console.warn("Failed to persist MCP create state", err);
- }
+ writeCreateUiSnapshot({
+ modalVisible: isModalVisible,
+ formValues: form.getFieldsValue(true),
+ transportType,
+ costConfig,
+ allowedTools,
+ hasToolAllowlistInteraction,
+ searchValue,
+ aliasManuallyEdited,
+ logoUrl,
+ authorizedIdentity,
+ });
};
const {
@@ -308,59 +293,39 @@ const CreateMCPServer: React.FC = ({
};
React.useEffect(() => {
- if (typeof window === "undefined") {
+ const restored = readCreateUiSnapshot();
+ if (!restored) {
return;
}
- const storedState = getSecureItem(CREATE_OAUTH_UI_STATE_KEY);
- if (!storedState) {
- return;
+ if (restored.modalVisible) {
+ setModalVisible(true);
}
-
- try {
- const parsed = JSON.parse(storedState);
- if (parsed.modalVisible) {
- setModalVisible(true);
- }
- const restoredTransport = parsed.formValues?.transport || parsed.transportType || "";
- if (restoredTransport) {
- setTransportType(restoredTransport);
- }
- if (parsed.formValues) {
- // Assign the cleaned credentials (strip minted token material so a stale token never rehydrates);
- // the declared app the admin typed is kept. Create has no server-side stored app to merge.
- const restoredValues = {
- ...parsed.formValues,
- credentials: withoutMintedTokenCredentials(parsed.formValues.credentials),
- };
- setPendingRestoredValues({ values: restoredValues, transport: restoredTransport });
- }
- if (typeof parsed.authorizedIdentity === "string") {
- // Re-arm invalidation: without this the remounted form has authorizedIdentity=undefined, so a
- // post-restore mode/url edit would never fire the stale-token discard.
- setAuthorizedIdentity(parsed.authorizedIdentity);
- }
- if (parsed.costConfig) {
- setCostConfig(parsed.costConfig);
- }
- if (parsed.allowedTools) {
- setAllowedTools(parsed.allowedTools);
- }
- if (typeof parsed.hasToolAllowlistInteraction === "boolean") {
- setHasToolAllowlistInteraction(parsed.hasToolAllowlistInteraction);
- }
- if (parsed.searchValue) {
- setSearchValue(parsed.searchValue);
- }
- if (typeof parsed.aliasManuallyEdited === "boolean") {
- setAliasManuallyEdited(parsed.aliasManuallyEdited);
- }
- if (parsed.logoUrl) {
- setLogoUrl(parsed.logoUrl);
- }
- } catch (err) {
- console.error("Failed to restore MCP create state", err);
- } finally {
- window.sessionStorage.removeItem(CREATE_OAUTH_UI_STATE_KEY);
+ if (restored.transportType) {
+ setTransportType(restored.transportType);
+ }
+ if (restored.formValues) {
+ setPendingRestoredValues({ values: restored.formValues, transport: restored.transportType });
+ }
+ if (restored.authorizedIdentity !== undefined) {
+ setAuthorizedIdentity(restored.authorizedIdentity);
+ }
+ if (restored.costConfig) {
+ setCostConfig(restored.costConfig);
+ }
+ if (restored.allowedTools) {
+ setAllowedTools([...restored.allowedTools]);
+ }
+ if (restored.hasToolAllowlistInteraction !== undefined) {
+ setHasToolAllowlistInteraction(restored.hasToolAllowlistInteraction);
+ }
+ if (restored.searchValue) {
+ setSearchValue(restored.searchValue);
+ }
+ if (restored.aliasManuallyEdited !== undefined) {
+ setAliasManuallyEdited(restored.aliasManuallyEdited);
+ }
+ if (restored.logoUrl) {
+ setLogoUrl(restored.logoUrl);
}
}, [form, setModalVisible]);
@@ -422,169 +387,25 @@ const CreateMCPServer: React.FC = ({
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);
- const handleCreate = async (values: Record) => {
- const invalidDisplayName = Object.entries(toolNameToDisplayName).find(
- ([, displayName]) => displayName && !TOOL_DISPLAY_NAME_PATTERN.test(displayName),
- );
- if (invalidDisplayName) {
- NotificationsManager.fromBackend(
- `Tool display name "${invalidDisplayName[1]}" is invalid. Only letters, digits, underscores, and hyphens are allowed (no spaces).`,
- );
+ const handleCreate = async (values: Record) => {
+ const built = buildCreateServerPayload(values, {
+ transportType,
+ costConfig,
+ allowedTools,
+ hasToolAllowlistInteraction,
+ toolNameToDisplayName,
+ toolNameToDescription,
+ logoUrl,
+ dcrClient: dcrClientRef.current,
+ });
+ if (built.kind !== "ok") {
+ NotificationsManager.fromBackend(payloadErrorMessage(built));
return;
}
+ const payload = built.payload;
+
setIsLoading(true);
try {
- const {
- static_headers: staticHeadersList,
- env_vars: envVarsList,
- stdio_config: rawStdioConfig,
- credentials: credentialValues,
- allow_all_keys: allowAllKeysRaw,
- available_on_public_internet: availableOnPublicInternetRaw,
- delegate_auth_to_upstream: delegateAuthToUpstreamRaw,
- oauth_passthrough: oauthPassthroughRaw,
- dcr_bridge: dcrBridgeRaw,
- token_validation_json: rawTokenValidationJson,
- ...restValues
- } = values;
-
- // Transform access groups into objects with name property
- const accessGroups = restValues.mcp_access_groups;
-
- const staticHeaders = reduceStaticHeaders(staticHeadersList);
- const envVars = normalizeEnvVars(envVarsList);
-
- const credentialsPayload =
- credentialValues && typeof credentialValues === "object"
- ? Object.entries(credentialValues).reduce((acc: Record, [key, value]) => {
- if (value === undefined || value === null || value === "") {
- return acc;
- }
- if (key === "scopes") {
- if (Array.isArray(value)) {
- const filteredScopes = value.filter((scope) => scope != null && scope !== "");
- if (filteredScopes.length > 0) {
- acc[key] = filteredScopes;
- }
- }
- } else {
- acc[key] = value;
- }
- return acc;
- }, {})
- : undefined;
-
- // Process stdio configuration if present
- let stdioFields = {};
- if (rawStdioConfig && transportType === "stdio") {
- try {
- const stdioConfig = JSON.parse(rawStdioConfig);
-
- // Handle both formats:
- // 1. Full mcpServers structure: {"mcpServers": {"server-name": {...}}}
- // 2. Direct config: {"command": "...", "args": [...], "env": {...}}
-
- let actualConfig = stdioConfig;
-
- // If it's the full mcpServers structure, extract the first server config
- if (stdioConfig.mcpServers && typeof stdioConfig.mcpServers === "object") {
- const serverNames = Object.keys(stdioConfig.mcpServers);
- if (serverNames.length > 0) {
- const firstServerName = serverNames[0];
- actualConfig = stdioConfig.mcpServers[firstServerName];
-
- // If no alias is provided, use the server name from the JSON
- if (!restValues.server_name) {
- restValues.server_name = firstServerName.replace(/-/g, "_"); // Replace hyphens with underscores
- }
- }
- }
-
- stdioFields = {
- command: actualConfig.command,
- args: actualConfig.args,
- env: actualConfig.env,
- };
- } catch (error) {
- NotificationsManager.fromBackend("Invalid JSON in stdio configuration");
- return;
- }
- }
-
- // Map "openapi" transport to "http" for the backend
- if (restValues.transport === TRANSPORT.OPENAPI) {
- restValues.transport = "http";
- }
-
- // Parse token_validation JSON if provided
- let tokenValidation: Record | null = null;
- if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") {
- try {
- tokenValidation = JSON.parse(rawTokenValidationJson);
- } catch {
- NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules");
- setIsLoading(false);
- return;
- }
- }
-
- // Prepare the payload with cost configuration and allowed tools
- const payload: Record = {
- ...restValues,
- ...stdioFields,
- // Remove the raw stdio_config field as we've extracted its components
- stdio_config: undefined,
- mcp_info: {
- server_name: restValues.server_name || restValues.url,
- description: restValues.description,
- logo_url: logoUrl || undefined,
- mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null,
- tool_allowlist_enforced: hasToolAllowlistInteraction || allowedTools.length > 0,
- },
- mcp_access_groups: accessGroups,
- alias: restValues.alias,
- allowed_tools: allowedTools,
- tool_name_to_display_name: toolNameToDisplayName,
- tool_name_to_description: toolNameToDescription,
- allow_all_keys: Boolean(allowAllKeysRaw),
- available_on_public_internet: Boolean(availableOnPublicInternetRaw),
- delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw),
- oauth_passthrough: Boolean(oauthPassthroughRaw),
- // ``dcr_bridge`` is only meaningful for the client-forwarded token
- // modes (true_passthrough / oauth_delegate) and defaults on when the
- // toggle is shown; force false for any other auth type so a stale
- // ``true`` is never persisted. Mirrors the sibling flags above.
- dcr_bridge: isClientForwardedTokenMode(restValues.auth_type) ? Boolean(dcrBridgeRaw ?? true) : false,
- ...(restValues.auth_type === AUTH_TYPE.OAUTH2
- ? {
- oauth2_flow:
- values.oauth_flow_type === OAUTH_FLOW.M2M ? MCP_OAUTH2_FLOW_M2M : MCP_OAUTH2_FLOW_INTERACTIVE,
- }
- : {}),
- static_headers: staticHeaders,
- env_vars: envVars,
- ...(tokenValidation !== null && { token_validation: tokenValidation }),
- };
-
- const includeCredentials =
- restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
-
- // 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.
- const submitCredentials = isClientForwardedTokenMode(restValues.auth_type)
- ? preservedAdminCredentials(credentialsPayload)
- : credentialsPayload;
-
- if (includeCredentials && submitCredentials && Object.keys(submitCredentials).length > 0) {
- payload.credentials = submitCredentials;
- }
-
- // An interactive (oauth2) create persists its DCR-minted client from the ref (kept out of the
- // form store); reuse a re-authorize's registered client instead of re-registering.
- if (restValues.auth_type === AUTH_TYPE.OAUTH2 && dcrClientRef.current) {
- payload.credentials = { ...(payload.credentials ?? {}), ...dcrClientRef.current };
- }
-
if (accessToken != null) {
const response = isAdmin
? await createMCPServer(accessToken, payload)
@@ -596,9 +417,9 @@ const CreateMCPServer: React.FC = ({
// forwards a browser-held token, so it stays in sessionStorage only.
if (oauthTokenResponse?.access_token && response?.server_id) {
const oauthMode = getMcpOAuthMode({
- auth_type: restValues.auth_type,
+ auth_type: values.auth_type as string | undefined,
oauth2_flow: values.oauth_flow_type === OAUTH_FLOW.M2M ? MCP_OAUTH2_FLOW_M2M : null,
- delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw),
+ delegate_auth_to_upstream: Boolean(values.delegate_auth_to_upstream),
});
if (oauthMode === "authorization_code") {
const scope = oauthTokenResponse.scope;
@@ -953,94 +774,7 @@ const CreateMCPServer: React.FC = ({
)}
{/* BYOK toggle - only for OpenAPI */}
- {transportType === TRANSPORT.OPENAPI && (
- <>
-
- BYOK (Bring Your Own Key)
-
-
-
-
- }
- name="is_byok"
- valuePropName="checked"
- >
-
-
-
- prev.is_byok !== cur.is_byok || prev.auth_type !== cur.auth_type}
- >
- {({ getFieldValue }) =>
- getFieldValue("is_byok") ? (
- <>
- {/* Auth format hint */}
- {getFieldValue("auth_type") && getFieldValue("auth_type") !== "none" && (
-
-
-
- User keys will be sent as:{" "}
-
- {getFieldValue("auth_type") === "bearer_token" && "Authorization: Bearer {key}"}
- {getFieldValue("auth_type") === "token" && "Authorization: token {key}"}
- {getFieldValue("auth_type") === "api_key" && "x-api-key: {key}"}
- {getFieldValue("auth_type") === "basic" && "Authorization: Basic {key}"}
- {getFieldValue("auth_type") === "authorization" && "Authorization: {key}"}
-
- {!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
-
-
- )}
- {!getFieldValue("auth_type") && (
-
-
-
- Set the Authentication Type below to specify how user keys are sent
- (e.g., Bearer Token, API Key header).
-
-
- )}
-
- Access Description
-
-
-
-
- }
- name="byok_description"
- >
-
-
-
-
- API Key Help URL
-
-
-
-
- }
- name="byok_api_key_help_url"
- >
-
-
- >
- ) : null
- }
-
- >
- )}
+ {transportType === TRANSPORT.OPENAPI && }
= ({
/>
)}
- {transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && (
- <>
-
- For MCP servers hosted on AWS Bedrock AgentCore.{" "}
-
- View docs →
-
-
-
- AWS Region
-
-
-
-
- }
- name={["credentials", "aws_region_name"]}
- rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
- >
-
-
-
- AWS Service Name
-
-
-
-
- }
- name={["credentials", "aws_service_name"]}
- >
-
-
-
- AWS Access Key ID
-
-
-
-
- }
- name={["credentials", "aws_access_key_id"]}
- dependencies={[["credentials", "aws_secret_access_key"]]}
- rules={[
- ({ getFieldValue }) => ({
- validator(_, value) {
- const secretKey = getFieldValue(["credentials", "aws_secret_access_key"]);
- if (secretKey && !value) {
- return Promise.reject(
- new Error("Access Key ID is required when Secret Access Key is provided"),
- );
- }
- return Promise.resolve();
- },
- }),
- ]}
- >
-
-
-
- AWS Secret Access Key
-
-
-
-
- }
- name={["credentials", "aws_secret_access_key"]}
- dependencies={[["credentials", "aws_access_key_id"]]}
- rules={[
- ({ getFieldValue }) => ({
- validator(_, value) {
- const accessKeyId = getFieldValue(["credentials", "aws_access_key_id"]);
- if (accessKeyId && !value) {
- return Promise.reject(
- new Error("Secret Access Key is required when Access Key ID is provided"),
- );
- }
- return Promise.resolve();
- },
- }),
- ]}
- >
-
-
-
- AWS Session Token
-
-
-
-
- }
- name={["credentials", "aws_session_token"]}
- >
-
-
-
- AWS Role ARN
-
-
-
-
- }
- name={["credentials", "aws_role_name"]}
- >
-
-
-
- AWS Session Name
-
-
-
-
- }
- name={["credentials", "aws_session_name"]}
- >
-
-
- >
- )}
+ {transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && }
{/* Stdio Configuration - only show for stdio transport */}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
new file mode 100644
index 00000000000..2ac4279e20a
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
@@ -0,0 +1,91 @@
+import React from "react";
+import { Form, Input, Select, Switch, Tooltip } from "antd";
+import { InfoCircleOutlined } from "@ant-design/icons";
+
+const OpenApiByokFields: React.FC = () => (
+ <>
+
+ BYOK (Bring Your Own Key)
+
+
+
+
+ }
+ name="is_byok"
+ valuePropName="checked"
+ >
+
+
+
+ prev.is_byok !== cur.is_byok || prev.auth_type !== cur.auth_type}>
+ {({ getFieldValue }) =>
+ getFieldValue("is_byok") ? (
+ <>
+ {/* Auth format hint */}
+ {getFieldValue("auth_type") && getFieldValue("auth_type") !== "none" && (
+
+
+
+ User keys will be sent as:{" "}
+
+ {getFieldValue("auth_type") === "bearer_token" && "Authorization: Bearer {key}"}
+ {getFieldValue("auth_type") === "token" && "Authorization: token {key}"}
+ {getFieldValue("auth_type") === "api_key" && "x-api-key: {key}"}
+ {getFieldValue("auth_type") === "basic" && "Authorization: Basic {key}"}
+ {getFieldValue("auth_type") === "authorization" && "Authorization: {key}"}
+
+ {!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
+
+
+ )}
+ {!getFieldValue("auth_type") && (
+
+
+
+ Set the Authentication Type below to specify how user keys are sent (e.g., Bearer
+ Token, API Key header).
+
+
+ )}
+
+ Access Description
+
+
+
+
+ }
+ name="byok_description"
+ >
+
+
+
+
+ API Key Help URL
+
+
+
+
+ }
+ name="byok_api_key_help_url"
+ >
+
+
+ >
+ ) : null
+ }
+
+ >
+);
+
+export default OpenApiByokFields;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createOAuthUiState.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createOAuthUiState.ts
new file mode 100644
index 00000000000..f6475b97830
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createOAuthUiState.ts
@@ -0,0 +1,96 @@
+import { MCPServerCostInfo, withoutMintedTokenCredentials } from "@/components/mcp_tools/types";
+import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
+
+const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state";
+
+// Everything the create modal needs to look untouched after the OAuth authorize redirect reloads the
+// page. `authorizedIdentity` is part of it so invalidation stays armed across the round trip: without
+// it the remounted form starts with no identity, and a post-restore url/mode edit would never fire the
+// stale-token discard.
+export interface CreateUiSnapshot {
+ readonly modalVisible: boolean;
+ readonly formValues: Record;
+ readonly transportType: string;
+ readonly costConfig: MCPServerCostInfo;
+ readonly allowedTools: readonly string[];
+ readonly hasToolAllowlistInteraction: boolean;
+ readonly searchValue: string;
+ readonly aliasManuallyEdited: boolean;
+ readonly logoUrl: string | undefined;
+ readonly authorizedIdentity: string | undefined;
+}
+
+// Only the fields that survived their own presence check. A key absent here means "leave the freshly
+// mounted state alone", which is why every field is optional rather than defaulted.
+export type RestoredUiSnapshot = {
+ readonly modalVisible?: boolean;
+ readonly formValues?: Record;
+ readonly transportType?: string;
+ readonly costConfig?: MCPServerCostInfo;
+ readonly allowedTools?: readonly string[];
+ readonly hasToolAllowlistInteraction?: boolean;
+ readonly searchValue?: string;
+ readonly aliasManuallyEdited?: boolean;
+ readonly logoUrl?: string;
+ readonly authorizedIdentity?: string;
+};
+
+export const writeCreateUiSnapshot = (snapshot: CreateUiSnapshot): void => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ setSecureItem(CREATE_OAUTH_UI_STATE_KEY, JSON.stringify(snapshot));
+ } catch (err) {
+ console.warn("Failed to persist MCP create state", err);
+ }
+};
+
+/**
+ * Read and validate the snapshot left before the authorize redirect, then drop it so a later mount
+ * cannot replay it. Returns null when there is nothing to restore (or the payload was unparseable),
+ * in which case the stored value is left in place for an in-flight flow to time out naturally.
+ */
+export const readCreateUiSnapshot = (): RestoredUiSnapshot | null => {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ const storedState = getSecureItem(CREATE_OAUTH_UI_STATE_KEY);
+ if (!storedState) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(storedState);
+ const restoredTransport = parsed.formValues?.transport || parsed.transportType || "";
+
+ return {
+ ...(parsed.modalVisible ? { modalVisible: true } : {}),
+ ...(restoredTransport ? { transportType: restoredTransport } : {}),
+ ...(parsed.formValues
+ ? {
+ // Strip minted token material so a stale token never rehydrates; the declared app the
+ // admin typed is kept. Create has no server-side stored app to merge.
+ formValues: {
+ ...parsed.formValues,
+ credentials: withoutMintedTokenCredentials(parsed.formValues.credentials),
+ },
+ }
+ : {}),
+ ...(typeof parsed.authorizedIdentity === "string" ? { authorizedIdentity: parsed.authorizedIdentity } : {}),
+ ...(parsed.costConfig ? { costConfig: parsed.costConfig } : {}),
+ ...(parsed.allowedTools ? { allowedTools: parsed.allowedTools } : {}),
+ ...(typeof parsed.hasToolAllowlistInteraction === "boolean"
+ ? { hasToolAllowlistInteraction: parsed.hasToolAllowlistInteraction }
+ : {}),
+ ...(parsed.searchValue ? { searchValue: parsed.searchValue } : {}),
+ ...(typeof parsed.aliasManuallyEdited === "boolean" ? { aliasManuallyEdited: parsed.aliasManuallyEdited } : {}),
+ ...(parsed.logoUrl ? { logoUrl: parsed.logoUrl } : {}),
+ };
+ } catch (err) {
+ console.error("Failed to restore MCP create state", err);
+ return null;
+ } finally {
+ window.sessionStorage.removeItem(CREATE_OAUTH_UI_STATE_KEY);
+ }
+};
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
new file mode 100644
index 00000000000..f45857fcc8b
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.ts
@@ -0,0 +1,233 @@
+import {
+ AUTH_TYPE,
+ MCPServerCostInfo,
+ MCP_OAUTH2_FLOW_INTERACTIVE,
+ MCP_OAUTH2_FLOW_M2M,
+ OAUTH_FLOW,
+ TRANSPORT,
+ isClientForwardedTokenMode,
+ preservedAdminCredentials,
+} from "@/components/mcp_tools/types";
+import { TOOL_DISPLAY_NAME_PATTERN, normalizeEnvVars } from "./utils";
+
+export const AUTH_TYPES_REQUIRING_AUTH_VALUE = [
+ AUTH_TYPE.API_KEY,
+ AUTH_TYPE.BEARER_TOKEN,
+ AUTH_TYPE.TOKEN,
+ AUTH_TYPE.BASIC,
+];
+
+export const AUTH_TYPES_REQUIRING_CREDENTIALS = [
+ ...AUTH_TYPES_REQUIRING_AUTH_VALUE,
+ AUTH_TYPE.OAUTH2,
+ AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
+ AUTH_TYPE.OAUTH2_ID_JAG,
+ AUTH_TYPE.AWS_SIGV4,
+ AUTH_TYPE.TRUE_PASSTHROUGH,
+ AUTH_TYPE.OAUTH_DELEGATE,
+];
+
+export interface DcrClient {
+ readonly client_id: string;
+ readonly client_secret?: string;
+}
+
+export interface CreateServerUiState {
+ readonly transportType: string;
+ readonly costConfig: MCPServerCostInfo;
+ readonly allowedTools: readonly string[];
+ readonly hasToolAllowlistInteraction: boolean;
+ readonly toolNameToDisplayName: Readonly>;
+ readonly toolNameToDescription: Readonly>;
+ readonly logoUrl: string | undefined;
+ readonly dcrClient: DcrClient | null;
+}
+
+export type BuildCreatePayloadResult =
+ | { readonly kind: "ok"; readonly payload: Record }
+ | { readonly kind: "invalid_tool_display_name"; readonly displayName: string }
+ | { readonly kind: "invalid_stdio_json" }
+ | { readonly kind: "invalid_token_validation_json" };
+
+export type StdioParseResult =
+ | { readonly kind: "ok"; readonly fields: Record; readonly derivedServerName?: string }
+ | { readonly kind: "invalid" };
+
+type JsonParseResult =
+ | { readonly kind: "ok"; readonly value: Record | null }
+ | { readonly kind: "invalid" };
+
+const tryParseJson = (raw: string): JsonParseResult => {
+ try {
+ return { kind: "ok", value: JSON.parse(raw) };
+ } catch {
+ return { kind: "invalid" };
+ }
+};
+
+export const reduceStaticHeaders = (list: unknown): Record => {
+ if (!Array.isArray(list)) return {};
+ return list.reduce((acc: Record, entry: Record) => {
+ const header = entry?.header?.trim();
+ if (header) acc[header] = (entry?.value ?? "").trim();
+ return acc;
+ }, {});
+};
+
+// 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.
+export const parseStdioConfig = (raw: string): StdioParseResult => {
+ try {
+ const stdioConfig = JSON.parse(raw);
+ const nestedName =
+ stdioConfig.mcpServers && typeof stdioConfig.mcpServers === "object"
+ ? Object.keys(stdioConfig.mcpServers)[0]
+ : undefined;
+ const actualConfig = nestedName === undefined ? stdioConfig : stdioConfig.mcpServers[nestedName];
+
+ return {
+ kind: "ok",
+ fields: { command: actualConfig.command, args: actualConfig.args, env: actualConfig.env },
+ // The JSON's own server key is the fallback name when the admin left the field blank.
+ ...(nestedName === undefined ? {} : { derivedServerName: nestedName.replace(/-/g, "_") }),
+ };
+ } catch {
+ return { kind: "invalid" };
+ }
+};
+
+const filterCredentials = (credentialValues: unknown): Record | undefined => {
+ if (!credentialValues || typeof credentialValues !== "object") return undefined;
+ return Object.entries(credentialValues as Record).reduce(
+ (acc: Record, [key, value]) => {
+ if (value === undefined || value === null || value === "") {
+ return acc;
+ }
+ if (key === "scopes") {
+ if (Array.isArray(value)) {
+ const filteredScopes = value.filter((scope) => scope != null && scope !== "");
+ if (filteredScopes.length > 0) {
+ acc[key] = filteredScopes;
+ }
+ }
+ } else {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {},
+ );
+};
+
+const firstInvalidToolDisplayName = (toolNameToDisplayName: Readonly>): string | undefined =>
+ Object.entries(toolNameToDisplayName).find(
+ ([, displayName]) => displayName && !TOOL_DISPLAY_NAME_PATTERN.test(displayName),
+ )?.[1];
+
+export const buildCreateServerPayload = (
+ values: Record,
+ ui: CreateServerUiState,
+): BuildCreatePayloadResult => {
+ const badDisplayName = firstInvalidToolDisplayName(ui.toolNameToDisplayName);
+ if (badDisplayName !== undefined) {
+ return { kind: "invalid_tool_display_name", displayName: badDisplayName };
+ }
+
+ const {
+ static_headers: staticHeadersList,
+ env_vars: envVarsList,
+ stdio_config: rawStdioConfig,
+ credentials: credentialValues,
+ allow_all_keys: allowAllKeysRaw,
+ available_on_public_internet: availableOnPublicInternetRaw,
+ delegate_auth_to_upstream: delegateAuthToUpstreamRaw,
+ oauth_passthrough: oauthPassthroughRaw,
+ dcr_bridge: dcrBridgeRaw,
+ token_validation_json: rawTokenValidationJson,
+ ...restValues
+ } = values;
+
+ const stdio: StdioParseResult =
+ rawStdioConfig && ui.transportType === "stdio"
+ ? parseStdioConfig(rawStdioConfig as string)
+ : { kind: "ok", fields: {} };
+ if (stdio.kind === "invalid") {
+ return { kind: "invalid_stdio_json" };
+ }
+
+ const rawTokenValidation = rawTokenValidationJson as string | undefined;
+ const tokenValidationResult: JsonParseResult =
+ rawTokenValidation && rawTokenValidation.trim() !== ""
+ ? tryParseJson(rawTokenValidation)
+ : { kind: "ok", value: null };
+ if (tokenValidationResult.kind === "invalid") {
+ return { kind: "invalid_token_validation_json" };
+ }
+ const tokenValidation = tokenValidationResult.value;
+
+ const serverName = (restValues.server_name as string | undefined) || stdio.derivedServerName;
+ // "openapi" is a UI-only transport; the backend stores those servers as plain http.
+ const transport = restValues.transport === TRANSPORT.OPENAPI ? "http" : restValues.transport;
+ const authType = restValues.auth_type as string | undefined;
+
+ const credentialsPayload = filterCredentials(credentialValues);
+ 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.
+ const submitCredentials = isClientForwardedTokenMode(authType)
+ ? preservedAdminCredentials(credentialsPayload)
+ : credentialsPayload;
+ const persistedCredentials =
+ includeCredentials && submitCredentials && Object.keys(submitCredentials).length > 0
+ ? submitCredentials
+ : undefined;
+ // An interactive (oauth2) create persists its DCR-minted client from the ref (kept out of the
+ // form store); reuse a re-authorize's registered client instead of re-registering.
+ const credentials =
+ authType === AUTH_TYPE.OAUTH2 && ui.dcrClient
+ ? { ...(persistedCredentials ?? {}), ...ui.dcrClient }
+ : persistedCredentials;
+
+ return {
+ kind: "ok",
+ payload: {
+ ...restValues,
+ ...stdio.fields,
+ ...(serverName === restValues.server_name ? {} : { server_name: serverName }),
+ ...(transport === restValues.transport ? {} : { transport }),
+ // Remove the raw stdio_config field as we've extracted its components
+ stdio_config: undefined,
+ mcp_info: {
+ server_name: serverName || restValues.url,
+ description: restValues.description,
+ logo_url: ui.logoUrl || undefined,
+ mcp_server_cost_info: Object.keys(ui.costConfig).length > 0 ? ui.costConfig : null,
+ tool_allowlist_enforced: ui.hasToolAllowlistInteraction || ui.allowedTools.length > 0,
+ },
+ mcp_access_groups: restValues.mcp_access_groups,
+ alias: restValues.alias,
+ allowed_tools: [...ui.allowedTools],
+ tool_name_to_display_name: ui.toolNameToDisplayName,
+ tool_name_to_description: ui.toolNameToDescription,
+ allow_all_keys: Boolean(allowAllKeysRaw),
+ available_on_public_internet: Boolean(availableOnPublicInternetRaw),
+ delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw),
+ oauth_passthrough: Boolean(oauthPassthroughRaw),
+ // ``dcr_bridge`` is only meaningful for the client-forwarded token
+ // modes (true_passthrough / oauth_delegate) and defaults on when the
+ // toggle is shown; force false for any other auth type so a stale
+ // ``true`` is never persisted. Mirrors the sibling flags above.
+ dcr_bridge: isClientForwardedTokenMode(authType) ? Boolean(dcrBridgeRaw ?? true) : false,
+ ...(authType === AUTH_TYPE.OAUTH2
+ ? {
+ oauth2_flow: values.oauth_flow_type === OAUTH_FLOW.M2M ? MCP_OAUTH2_FLOW_M2M : MCP_OAUTH2_FLOW_INTERACTIVE,
+ }
+ : {}),
+ static_headers: reduceStaticHeaders(staticHeadersList),
+ env_vars: normalizeEnvVars(envVarsList),
+ ...(tokenValidation !== null && { token_validation: tokenValidation }),
+ ...(credentials === undefined ? {} : { credentials }),
+ },
+ };
+};