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:
mateo-berri 2026-06-06 21:16:55 +00:00
parent 95e3d136e1
commit b3229bc2e5
No known key found for this signature in database
5 changed files with 202 additions and 8 deletions

View file

@ -1312,7 +1312,7 @@
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 5
"count": 6
}
},
"src/components/mcp_tools/mcp_connect.tsx": {

View file

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

View file

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

View file

@ -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();
});
});

View file

@ -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);
}}
/>