diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 762678f22..de673827c 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -135,6 +135,46 @@ const Sidebar = ({ [toast], ); + const copyTextToClipboard = useCallback(async (text: string) => { + const fallbackCopy = () => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + document.body.appendChild(textarea); + let copied = false; + try { + textarea.focus(); + textarea.select(); + copied = document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } + + if (!copied) { + throw new Error("Clipboard copy failed"); + } + }; + + if (!navigator.clipboard?.writeText) { + fallbackCopy(); + return; + } + + try { + await navigator.clipboard.writeText(text); + } catch (error) { + try { + fallbackCopy(); + } catch (fallbackError) { + throw fallbackError instanceof Error ? fallbackError : error; + } + } + }, []); + // Shared utility function to generate server config const generateServerConfig = useCallback(() => { if (transportType === "stdio") { @@ -183,8 +223,7 @@ const Sidebar = ({ const handleCopyServerEntry = useCallback(() => { try { const configJson = generateMCPServerEntry(); - navigator.clipboard - .writeText(configJson) + void copyTextToClipboard(configJson) .then(() => { setCopiedServerEntry(true); @@ -202,19 +241,22 @@ const Sidebar = ({ setCopiedServerEntry(false); }, 2000); }) - .catch((error) => { - reportError(error); - }); + .catch(reportError); } catch (error) { reportError(error); } - }, [generateMCPServerEntry, transportType, toast, reportError]); + }, [ + copyTextToClipboard, + generateMCPServerEntry, + transportType, + toast, + reportError, + ]); const handleCopyServerFile = useCallback(() => { try { const configJson = generateMCPServerFile(); - navigator.clipboard - .writeText(configJson) + void copyTextToClipboard(configJson) .then(() => { setCopiedServerFile(true); @@ -228,13 +270,11 @@ const Sidebar = ({ setCopiedServerFile(false); }, 2000); }) - .catch((error) => { - reportError(error); - }); + .catch(reportError); } catch (error) { reportError(error); } - }, [generateMCPServerFile, toast, reportError]); + }, [copyTextToClipboard, generateMCPServerFile, toast, reportError]); return (
diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 460161e59..84f1ed407 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -27,6 +27,7 @@ Object.defineProperty(navigator, "clipboard", { writeText: mockClipboardWrite, }, }); +const originalExecCommand = document.execCommand; // Setup fake timers jest.useFakeTimers(); @@ -76,6 +77,10 @@ describe("Sidebar", () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); + Object.defineProperty(document, "execCommand", { + value: originalExecCommand, + configurable: true, + }); }); describe("Command and arguments", () => { @@ -457,6 +462,66 @@ describe("Sidebar", () => { }); }); + it("should fall back when clipboard writes are rejected", async () => { + const mockExecCommand = jest.fn(() => true); + Object.defineProperty(document, "execCommand", { + value: mockExecCommand, + configurable: true, + }); + mockClipboardWrite.mockRejectedValueOnce(new Error("NotAllowedError")); + + renderSidebar({ + transportType: "stdio", + command: "node", + args: "server.js", + }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + await Promise.resolve(); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + expect(mockToast).toHaveBeenCalledWith({ + title: "Config entry copied", + description: + "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name.", + }); + }); + + it("should show an error when clipboard copy and fallback both fail", async () => { + const mockExecCommand = jest.fn(() => false); + Object.defineProperty(document, "execCommand", { + value: mockExecCommand, + configurable: true, + }); + mockClipboardWrite.mockRejectedValueOnce(new Error("NotAllowedError")); + + renderSidebar({ + transportType: "stdio", + command: "node", + args: "server.js", + }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + await Promise.resolve(); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + expect(mockToast).toHaveBeenCalledWith({ + title: "Error", + description: "Failed to copy config: Clipboard copy failed", + variant: "destructive", + }); + }); + it("should copy servers file configuration to clipboard for STDIO transport", async () => { const command = "node"; const args = "--inspect server.js";