From f6049ebac1344ab844473eef0d1a9291112e6e0b Mon Sep 17 00:00:00 2001 From: MumuTW Date: Fri, 6 Mar 2026 05:49:42 +0000 Subject: [PATCH 1/2] fix: add clipboard fallback for server config copy buttons --- client/src/components/Sidebar.tsx | 111 +++++++++++------- .../src/components/__tests__/Sidebar.test.tsx | 52 ++++++++ 2 files changed, 119 insertions(+), 44 deletions(-) diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 13a4f24f4..da77b4049 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -133,6 +133,39 @@ const Sidebar = ({ [toast], ); + const fallbackCopyText = useCallback((text: string) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + + const copied = document.execCommand("copy"); + document.body.removeChild(textarea); + + if (!copied) { + throw new Error("Clipboard copy fallback failed"); + } + }, []); + + const copyTextToClipboard = useCallback( + async (text: string) => { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fallback to execCommand in non-secure contexts or restricted permissions. + } + } + + fallbackCopyText(text); + }, + [fallbackCopyText], + ); + // Shared utility function to generate server config const generateServerConfig = useCallback(() => { if (transportType === "stdio") { @@ -178,61 +211,51 @@ const Sidebar = ({ }, [generateServerConfig]); // Memoized copy handlers - const handleCopyServerEntry = useCallback(() => { + const handleCopyServerEntry = useCallback(async () => { try { const configJson = generateMCPServerEntry(); - navigator.clipboard - .writeText(configJson) - .then(() => { - setCopiedServerEntry(true); - - toast({ - title: "Config entry copied", - description: - transportType === "stdio" - ? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name." - : transportType === "streamable-http" - ? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client." - : "SSE URL has been copied. Use this URL directly in your MCP Client.", - }); - - setTimeout(() => { - setCopiedServerEntry(false); - }, 2000); - }) - .catch((error) => { - reportError(error); - }); + await copyTextToClipboard(configJson); + + setCopiedServerEntry(true); + + toast({ + title: "Config entry copied", + description: + transportType === "stdio" + ? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name." + : transportType === "streamable-http" + ? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client." + : "SSE URL has been copied. Use this URL directly in your MCP Client.", + }); + + setTimeout(() => { + setCopiedServerEntry(false); + }, 2000); } catch (error) { reportError(error); } - }, [generateMCPServerEntry, transportType, toast, reportError]); + }, [generateMCPServerEntry, copyTextToClipboard, transportType, toast, reportError]); - const handleCopyServerFile = useCallback(() => { + const handleCopyServerFile = useCallback(async () => { try { const configJson = generateMCPServerFile(); - navigator.clipboard - .writeText(configJson) - .then(() => { - setCopiedServerFile(true); - - toast({ - title: "Servers file copied", - description: - "Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'", - }); - - setTimeout(() => { - setCopiedServerFile(false); - }, 2000); - }) - .catch((error) => { - reportError(error); - }); + await copyTextToClipboard(configJson); + + setCopiedServerFile(true); + + toast({ + title: "Servers file copied", + description: + "Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'", + }); + + setTimeout(() => { + setCopiedServerFile(false); + }, 2000); } catch (error) { reportError(error); } - }, [generateMCPServerFile, toast, reportError]); + }, [generateMCPServerFile, copyTextToClipboard, toast, reportError]); return (
diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 03e898ca9..12934e8ea 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -27,6 +27,11 @@ Object.defineProperty(navigator, "clipboard", { writeText: mockClipboardWrite, }, }); +const mockExecCommand = jest.fn(() => true); +Object.defineProperty(document, "execCommand", { + value: mockExecCommand, + writable: true, +}); // Setup fake timers jest.useFakeTimers(); @@ -76,6 +81,8 @@ describe("Sidebar", () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); + mockClipboardWrite.mockResolvedValue(undefined); + mockExecCommand.mockReturnValue(true); }); describe("Command and arguments", () => { @@ -630,6 +637,51 @@ describe("Sidebar", () => { ); expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); }); + + it("should fallback to execCommand when clipboard write fails for server entry", async () => { + mockClipboardWrite.mockRejectedValueOnce(new Error("NotAllowedError")); + renderSidebar({ + transportType: "sse", + sseUrl: "http://localhost:3000/events", + }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + expect(mockToast).toHaveBeenCalledWith({ + title: "Config entry copied", + description: + "SSE URL has been copied. Use this URL directly in your MCP Client.", + }); + }); + + it("should fallback to execCommand when clipboard write fails for servers file", async () => { + mockClipboardWrite.mockRejectedValueOnce(new Error("NotAllowedError")); + renderSidebar({ + transportType: "stdio", + command: "node", + args: "server.js", + }); + + await act(async () => { + const { serversFile } = getCopyButtons(); + fireEvent.click(serversFile); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + expect(mockToast).toHaveBeenCalledWith({ + title: "Servers file copied", + description: + "Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'", + }); + }); }); describe("Authentication", () => { From 269b152cbdcbf0d61ef3ecc2a3c047d759f7e1ae Mon Sep 17 00:00:00 2001 From: MumuTW Date: Fri, 6 Mar 2026 07:33:56 +0000 Subject: [PATCH 2/2] Fix stale JSON params when rerunning tools --- client/src/components/DynamicJsonForm.tsx | 14 +++++---- client/src/components/ToolsTab.tsx | 18 +++++++++-- .../components/__tests__/ToolsTab.test.tsx | 30 +++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index ecd150f22..4c3718394 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -27,7 +27,11 @@ interface DynamicJsonFormProps { } export interface DynamicJsonFormRef { - validateJson: () => { isValid: boolean; error: string | null }; + validateJson: () => { + isValid: boolean; + error: string | null; + value: JsonValue; + }; hasJsonError: () => boolean; } @@ -211,10 +215,10 @@ const DynamicJsonForm = forwardRef( }; const validateJson = () => { - if (!isJsonMode) return { isValid: true, error: null }; + if (!isJsonMode) return { isValid: true, error: null, value }; try { const jsonStr = rawJsonValue?.trim(); - if (!jsonStr) return { isValid: true, error: null }; + if (!jsonStr) return { isValid: true, error: null, value }; const parsed = JSON.parse(jsonStr); // Clear any pending debounced update and immediately update parent if (timeoutRef.current) { @@ -222,12 +226,12 @@ const DynamicJsonForm = forwardRef( } onChange(parsed); setJsonError(undefined); - return { isValid: true, error: null }; + return { isValid: true, error: null, value: parsed }; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Invalid JSON"; setJsonError(errorMessage); - return { isValid: false, error: errorMessage }; + return { isValid: false, error: errorMessage, value }; } }; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index febea1d8f..944fa6bb6 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -805,8 +805,20 @@ const ToolsTab = ({ )}