mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat(ui): add duplicate button for MCP servers
Adds a duplicate button to the MCP servers table that allows admin users to create a copy of an existing server. The duplicate pre-fills the create modal with all server configuration including transport, auth, access groups, allowed tools, and cost config. The new server gets a "_copy" suffix on the name and alias. Slack thread: https://berriaillm.slack.com/archives/C04HP96S19D/p1780778932520689?thread_ts=1780778908.977159&cid=C04HP96S19D https://claude.ai/code/session_01Wx49QQnsCngnJ7hsfaC1yC
This commit is contained in:
parent
95e3d136e1
commit
b3229bc2e5
5 changed files with 202 additions and 8 deletions
|
|
@ -1312,7 +1312,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 5
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/mcp_connect.tsx": {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ interface CreateMCPServerProps {
|
|||
setModalVisible: (visible: boolean) => void;
|
||||
availableAccessGroups: string[];
|
||||
prefillData?: DiscoverableMCPServer | null;
|
||||
duplicateServer?: MCPServer | null;
|
||||
onBackToDiscovery?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
setModalVisible,
|
||||
availableAccessGroups,
|
||||
prefillData,
|
||||
duplicateServer,
|
||||
onBackToDiscovery,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
|
|
@ -284,6 +286,98 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
setAliasManuallyEdited(false);
|
||||
}, [isModalVisible, prefillData, form]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isModalVisible || !duplicateServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = duplicateServer.spec_path
|
||||
? TRANSPORT.OPENAPI
|
||||
: duplicateServer.transport || "";
|
||||
setTransportType(transport);
|
||||
|
||||
const copySuffix = "_copy";
|
||||
const baseName = duplicateServer.server_name || duplicateServer.alias || "";
|
||||
const newName = baseName + copySuffix;
|
||||
const newAlias = (duplicateServer.alias || baseName) + copySuffix;
|
||||
|
||||
const prefillValues: Record<string, any> = {
|
||||
server_name: newName,
|
||||
alias: newAlias,
|
||||
description: duplicateServer.description || "",
|
||||
transport: transport,
|
||||
auth_type: duplicateServer.auth_type || "none",
|
||||
mcp_access_groups: duplicateServer.mcp_access_groups || [],
|
||||
allow_all_keys: duplicateServer.allow_all_keys ?? false,
|
||||
available_on_public_internet: duplicateServer.available_on_public_internet ?? false,
|
||||
delegate_auth_to_upstream: duplicateServer.delegate_auth_to_upstream ?? false,
|
||||
oauth_passthrough: duplicateServer.oauth_passthrough ?? false,
|
||||
source_url: duplicateServer.source_url || "",
|
||||
};
|
||||
|
||||
if (transport === "stdio") {
|
||||
const stdioObj: Record<string, any> = {};
|
||||
if (duplicateServer.command) stdioObj.command = duplicateServer.command;
|
||||
if (duplicateServer.args && duplicateServer.args.length > 0) stdioObj.args = duplicateServer.args;
|
||||
if (duplicateServer.env && Object.keys(duplicateServer.env).length > 0) {
|
||||
stdioObj.env = duplicateServer.env;
|
||||
}
|
||||
if (Object.keys(stdioObj).length > 0) {
|
||||
prefillValues.stdio_config = JSON.stringify(stdioObj, null, 2);
|
||||
}
|
||||
} else if (transport === TRANSPORT.OPENAPI && duplicateServer.spec_path) {
|
||||
prefillValues.spec_path = duplicateServer.spec_path;
|
||||
} else if (duplicateServer.url) {
|
||||
prefillValues.url = duplicateServer.url;
|
||||
}
|
||||
|
||||
if (duplicateServer.static_headers && Object.keys(duplicateServer.static_headers).length > 0) {
|
||||
prefillValues.static_headers = Object.entries(duplicateServer.static_headers).map(
|
||||
([header, value]) => ({ header, value })
|
||||
);
|
||||
}
|
||||
|
||||
if (duplicateServer.is_byok) {
|
||||
prefillValues.is_byok = true;
|
||||
prefillValues.byok_description = duplicateServer.byok_description || [];
|
||||
prefillValues.byok_api_key_help_url = duplicateServer.byok_api_key_help_url || "";
|
||||
}
|
||||
|
||||
if (duplicateServer.authorization_url) {
|
||||
prefillValues.authorization_url = duplicateServer.authorization_url;
|
||||
}
|
||||
if (duplicateServer.token_url) {
|
||||
prefillValues.token_url = duplicateServer.token_url;
|
||||
}
|
||||
if (duplicateServer.registration_url) {
|
||||
prefillValues.registration_url = duplicateServer.registration_url;
|
||||
}
|
||||
|
||||
if (duplicateServer.mcp_info?.logo_url) {
|
||||
setLogoUrl(duplicateServer.mcp_info.logo_url);
|
||||
}
|
||||
|
||||
if (duplicateServer.allowed_tools && duplicateServer.allowed_tools.length > 0) {
|
||||
setAllowedTools(duplicateServer.allowed_tools);
|
||||
setHasToolAllowlistInteraction(true);
|
||||
}
|
||||
|
||||
if (duplicateServer.tool_name_to_display_name) {
|
||||
setToolNameToDisplayName(duplicateServer.tool_name_to_display_name);
|
||||
}
|
||||
if (duplicateServer.tool_name_to_description) {
|
||||
setToolNameToDescription(duplicateServer.tool_name_to_description);
|
||||
}
|
||||
|
||||
if (duplicateServer.mcp_info?.mcp_server_cost_info) {
|
||||
setCostConfig(duplicateServer.mcp_info.mcp_server_cost_info);
|
||||
}
|
||||
|
||||
form.setFieldsValue(prefillValues);
|
||||
setFormValues(prefillValues);
|
||||
setAliasManuallyEdited(false);
|
||||
}, [isModalVisible, duplicateServer, form]);
|
||||
|
||||
const handleCreate = async (values: Record<string, any>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from "react";
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { MCPServer } from "./types";
|
||||
import { Icon } from "@tremor/react";
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { DuplicateIcon, PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { getMaskedAndFullUrl } from "./utils";
|
||||
import { Tooltip } from "antd";
|
||||
import { CheckOutlined } from "@ant-design/icons";
|
||||
|
|
@ -90,6 +90,7 @@ export const mcpServerColumns = (
|
|||
onByokConnect?: (server: MCPServer) => void,
|
||||
onRecheckHealth?: (serverId: string) => void,
|
||||
recheckingServerIds?: Set<string>,
|
||||
onDuplicate?: (server: MCPServer) => void,
|
||||
): ColumnDef<MCPServer>[] => [
|
||||
{
|
||||
accessorKey: "server_id",
|
||||
|
|
@ -307,6 +308,17 @@ export const mcpServerColumns = (
|
|||
<Icon icon={PencilAltIcon} size="sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{onDuplicate && (
|
||||
<Tooltip title="Duplicate">
|
||||
<button
|
||||
onClick={() => onDuplicate(row.original)}
|
||||
aria-label="Duplicate"
|
||||
className="p-1.5 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<Icon icon={DuplicateIcon} size="sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete">
|
||||
<button
|
||||
onClick={() => onDelete(row.original.server_id)}
|
||||
|
|
|
|||
|
|
@ -427,4 +427,76 @@ describe("MCPServers", () => {
|
|||
// The server list refresh must NOT trigger a second health check
|
||||
expect(networking.fetchMCPServerHealth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show duplicate button in actions column for admin users", async () => {
|
||||
const mockServers = [
|
||||
{
|
||||
server_id: "server-1",
|
||||
server_name: "Test_Server",
|
||||
alias: "test_server",
|
||||
url: "https://example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "none",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "user-1",
|
||||
teams: [],
|
||||
mcp_access_groups: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue(mockServers);
|
||||
vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([]);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MCPServers {...defaultProps} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test_Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const duplicateButton = screen.getByRole("button", { name: /duplicate/i });
|
||||
expect(duplicateButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show duplicate button for non-admin users", async () => {
|
||||
const mockServers = [
|
||||
{
|
||||
server_id: "server-1",
|
||||
server_name: "Test_Server",
|
||||
alias: "test_server",
|
||||
url: "https://example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "none",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "user-1",
|
||||
teams: [],
|
||||
mcp_access_groups: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue(mockServers);
|
||||
vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([]);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MCPServers accessToken="123" userRole="Internal User" userID="user-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test_Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const duplicateButton = screen.queryByRole("button", { name: /duplicate/i });
|
||||
expect(duplicateButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
const [isModalVisible, setModalVisible] = useState(false);
|
||||
const [isDiscoveryVisible, setDiscoveryVisible] = useState(false);
|
||||
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
|
||||
const [duplicateServer, setDuplicateServer] = useState<MCPServer | null>(null);
|
||||
const [isDeletingServer, setIsDeletingServer] = useState(false);
|
||||
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
|
||||
const isInternalUser = userRole === "Internal User";
|
||||
|
|
@ -164,6 +165,17 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
filterServers(selectedTeam, selectedMcpAccessGroup);
|
||||
}, [serversWithHealth, selectedTeam, selectedMcpAccessGroup, filterServers]);
|
||||
|
||||
function handleDelete(server_id: string) {
|
||||
setServerToDelete(server_id);
|
||||
setIsDeleteModalOpen(true);
|
||||
}
|
||||
|
||||
const handleDuplicate = (server: MCPServer) => {
|
||||
setDuplicateServer(server);
|
||||
setPrefillData(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
mcpServerColumns(
|
||||
|
|
@ -181,15 +193,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
(server: MCPServer) => setByokModalServer(server),
|
||||
recheckServerHealth,
|
||||
recheckingServerIds,
|
||||
isAdminRole(userRole ?? "") ? handleDuplicate : undefined,
|
||||
),
|
||||
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds],
|
||||
);
|
||||
|
||||
function handleDelete(server_id: string) {
|
||||
setServerToDelete(server_id);
|
||||
setIsDeleteModalOpen(true);
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (serverIdToDelete == null || accessToken == null) {
|
||||
return;
|
||||
|
|
@ -221,6 +229,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
const handleCreateSuccess = (newMcpServer: MCPServer) => {
|
||||
setFilteredServers((prev) => [...prev, newMcpServer]);
|
||||
setModalVisible(false);
|
||||
setDuplicateServer(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
|
|
@ -305,12 +314,19 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
accessToken={accessToken}
|
||||
onCreateSuccess={handleCreateSuccess}
|
||||
isModalVisible={isModalVisible}
|
||||
setModalVisible={setModalVisible}
|
||||
setModalVisible={(visible) => {
|
||||
setModalVisible(visible);
|
||||
if (!visible) {
|
||||
setDuplicateServer(null);
|
||||
}
|
||||
}}
|
||||
availableAccessGroups={uniqueMcpAccessGroups}
|
||||
prefillData={prefillData}
|
||||
duplicateServer={duplicateServer}
|
||||
onBackToDiscovery={() => {
|
||||
setModalVisible(false);
|
||||
setPrefillData(null);
|
||||
setDuplicateServer(null);
|
||||
setDiscoveryVisible(true);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue