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";