mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
commit
47d2e225b7
6 changed files with 666 additions and 502 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
</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;
|
||||
|
|
@ -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 →
|
||||
</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"} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 }),
|
||||
},
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue