Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 52 additions & 12 deletions client/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fallbackCopy calls document.execCommand("copy") without checking that execCommand exists/is callable. In browsers/environments where execCommand is missing, this will throw a TypeError and surface a confusing error message (and bypass the intended "Clipboard copy failed" UX). Consider guarding with typeof document.execCommand === "function" and throwing a consistent Error("Clipboard copy failed") (or similar) when unavailable.

Suggested change
copied = document.execCommand("copy");
if (typeof document.execCommand !== "function") {
throw new Error("Clipboard copy failed");
}
try {
copied = document.execCommand("copy");
} catch {
throw new Error("Clipboard copy failed");
}

Copilot uses AI. Check for mistakes.
} finally {
document.body.removeChild(textarea);
}

if (!copied) {
throw new Error("Clipboard copy failed");
}
};

if (!navigator.clipboard?.writeText) {
fallbackCopy();
return;
}
Comment on lines +162 to +165
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new branch that handles navigator.clipboard.writeText being unavailable (if (!navigator.clipboard?.writeText)) isn't covered by a regression test. Adding a test case where navigator.clipboard (or writeText) is missing would help ensure the fallback path works in insecure contexts/older browsers.

Copilot uses AI. Check for mistakes.

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") {
Expand Down Expand Up @@ -183,8 +223,7 @@ const Sidebar = ({
const handleCopyServerEntry = useCallback(() => {
try {
const configJson = generateMCPServerEntry();
navigator.clipboard
.writeText(configJson)
void copyTextToClipboard(configJson)
.then(() => {
setCopiedServerEntry(true);

Expand All @@ -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);

Expand All @@ -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 (
<div className="bg-card border-r border-border flex flex-col h-full">
Expand Down
65 changes: 65 additions & 0 deletions client/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Object.defineProperty(navigator, "clipboard", {
writeText: mockClipboardWrite,
},
});
const originalExecCommand = document.execCommand;

// Setup fake timers
jest.useFakeTimers();
Expand Down Expand Up @@ -76,6 +77,10 @@ describe("Sidebar", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
Object.defineProperty(document, "execCommand", {
value: originalExecCommand,
configurable: true,
});
});

describe("Command and arguments", () => {
Expand Down Expand Up @@ -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";
Expand Down
Loading