Merge pull request #35694 from BerriAI/litellm_mcp_create_extract

refactor(ui): extract the MCP create form's logic and field groups
This commit is contained in:
yuneng-jiang 2026-08-03 16:22:50 -07:00 • committed by GitHub
commit 47d2e225b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 666 additions and 502 deletions

View file

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

View file

@ -0,0 +1,155 @@
import React from "react";
import { Form, Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
const AwsSigV4Fields: React.FC = () => (
<>
<p className="text-sm text-gray-500 mb-2">
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
<a
href="https://docs.litellm.ai/docs/mcp_aws_sigv4"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
View docs &rarr;
</a>
</p>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Region
<Tooltip title="AWS region for SigV4 signing (e.g., us-east-1)">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_region_name"]}
rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
>
<Input placeholder="us-east-1" className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" />
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Service Name
<Tooltip title="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_service_name"]}
>
<Input
placeholder="bedrock-agentcore"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Access Key ID
<Tooltip title="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
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();
},
}),
]}
>
<Input.Password
placeholder="AKIA... (optional — uses IAM role if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Secret Access Key
<Tooltip title="Optional. Required if AWS Access Key ID is provided.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
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();
},
}),
]}
>
<Input.Password
placeholder="Enter secret key (optional — uses IAM role if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Session Token
<Tooltip title="Optional. Only needed for temporary STS credentials.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_session_token"]}
>
<Input.Password
placeholder="Enter session token (optional)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Role ARN
<Tooltip title="Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials. Uses ambient credentials (IAM role, env vars) as the source identity unless explicit keys are also provided.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_role_name"]}
>
<Input
placeholder="arn:aws:iam::123456789012:role/MyRole (optional)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Session Name
<Tooltip title="Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_session_name"]}
>
<Input
placeholder="litellm-prod (optional, auto-generated if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
</>
);
export default AwsSigV4Fields;

View file

@ -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<string, string> => {
if (!Array.isArray(list)) return {};
return list.reduce((acc: Record<string, string>, entry: Record<string, string>) => {
const header = entry?.header?.trim();
if (header) acc[header] = (entry?.value ?? "").trim();
return acc;
}, {});
const payloadErrorMessage = (result: Exclude<BuildCreatePayloadResult, { kind: "ok" }>): 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<CreateMCPServerProps> = ({
@ -147,29 +143,18 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
};
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<CreateMCPServerProps> = ({
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);
const handleCreate = async (values: Record<string, any>) => {
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<string, unknown>) => {
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<string, any>, [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<string, any> | 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<string, any> = {
...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<CreateMCPServerProps> = ({
// 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<CreateMCPServerProps> = ({
)}
{/* BYOK toggle - only for OpenAPI */}
{transportType === TRANSPORT.OPENAPI && (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
BYOK (Bring Your Own Key)
<Tooltip title="When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.">
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="is_byok"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => 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" && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 shrink-0" />
<span>
User keys will be sent as:{" "}
<code className="font-mono bg-blue-100 px-1 rounded-sm">
{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}"}
</code>
{!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
</span>
</div>
)}
{!getFieldValue("auth_type") && (
<div className="mb-4 p-3 bg-yellow-50 rounded-lg text-sm text-yellow-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 shrink-0" />
<span>
Set the <strong>Authentication Type</strong> below to specify how user keys are sent
(e.g., Bearer Token, API Key header).
</span>
</div>
)}
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Access Description
<Tooltip title="List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_description"
>
<Select
mode="tags"
placeholder="Add access description items (press Enter after each)"
className="w-full"
tokenSeparators={[","]}
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
API Key Help URL
<Tooltip title="Optional link shown to users to help them find their API key">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_api_key_help_url"
>
<Input placeholder="https://docs.example.com/api-keys" />
</Form.Item>
</>
) : null
}
</Form.Item>
</>
)}
{transportType === TRANSPORT.OPENAPI && <OpenApiByokFields />}
<Form.Item
label={
@ -1157,162 +891,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
/>
)}
{transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && (
<>
<p className="text-sm text-gray-500 mb-2">
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
<a
href="https://docs.litellm.ai/docs/mcp_aws_sigv4"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
View docs &rarr;
</a>
</p>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Region
<Tooltip title="AWS region for SigV4 signing (e.g., us-east-1)">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_region_name"]}
rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
>
<Input
placeholder="us-east-1"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Service Name
<Tooltip title="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_service_name"]}
>
<Input
placeholder="bedrock-agentcore"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Access Key ID
<Tooltip title="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
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();
},
}),
]}
>
<Input.Password
placeholder="AKIA... (optional — uses IAM role if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Secret Access Key
<Tooltip title="Optional. Required if AWS Access Key ID is provided.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
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();
},
}),
]}
>
<Input.Password
placeholder="Enter secret key (optional — uses IAM role if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Session Token
<Tooltip title="Optional. Only needed for temporary STS credentials.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_session_token"]}
>
<Input.Password
placeholder="Enter session token (optional)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Role ARN
<Tooltip title="Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials. Uses ambient credentials (IAM role, env vars) as the source identity unless explicit keys are also provided.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_role_name"]}
>
<Input
placeholder="arn:aws:iam::123456789012:role/MyRole (optional)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Session Name
<Tooltip title="Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_session_name"]}
>
<Input
placeholder="litellm-prod (optional, auto-generated if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
</>
)}
{transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && <AwsSigV4Fields />}
{/* Stdio Configuration - only show for stdio transport */}
<StdioConfiguration isVisible={transportType === "stdio"} />

View file

@ -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 = () => (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
BYOK (Bring Your Own Key)
<Tooltip title="When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.">
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="is_byok"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => 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" && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 shrink-0" />
<span>
User keys will be sent as:{" "}
<code className="font-mono bg-blue-100 px-1 rounded-sm">
{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}"}
</code>
{!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
</span>
</div>
)}
{!getFieldValue("auth_type") && (
<div className="mb-4 p-3 bg-yellow-50 rounded-lg text-sm text-yellow-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 shrink-0" />
<span>
Set the <strong>Authentication Type</strong> below to specify how user keys are sent (e.g., Bearer
Token, API Key header).
</span>
</div>
)}
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Access Description
<Tooltip title="List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_description"
>
<Select
mode="tags"
placeholder="Add access description items (press Enter after each)"
className="w-full"
tokenSeparators={[","]}
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
API Key Help URL
<Tooltip title="Optional link shown to users to help them find their API key">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_api_key_help_url"
>
<Input placeholder="https://docs.example.com/api-keys" />
</Form.Item>
</>
) : null
}
</Form.Item>
</>
);
export default OpenApiByokFields;

View file

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

View file

@ -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<Record<string, string>>;
readonly toolNameToDescription: Readonly<Record<string, string>>;
readonly logoUrl: string | undefined;
readonly dcrClient: DcrClient | null;
}
export type BuildCreatePayloadResult =
| { readonly kind: "ok"; readonly payload: Record<string, unknown> }
| { 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<string, unknown>; readonly derivedServerName?: string }
| { readonly kind: "invalid" };
type JsonParseResult =
| { readonly kind: "ok"; readonly value: Record<string, unknown> | 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<string, string> => {
if (!Array.isArray(list)) return {};
return list.reduce((acc: Record<string, string>, entry: Record<string, string>) => {
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<string, unknown> | undefined => {
if (!credentialValues || typeof credentialValues !== "object") return undefined;
return Object.entries(credentialValues as Record<string, unknown>).reduce(
(acc: Record<string, unknown>, [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<Record<string, string>>): string | undefined =>
Object.entries(toolNameToDisplayName).find(
([, displayName]) => displayName && !TOOL_DISPLAY_NAME_PATTERN.test(displayName),
)?.[1];
export const buildCreateServerPayload = (
values: Record<string, unknown>,
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 }),
},
};
};