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
88 changes: 50 additions & 38 deletions client/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ import { useToast } from "../lib/hooks/useToast";
import IconDisplay, { WithIcons } from "./IconDisplay";
import { validateRedirectUrl } from "@/utils/urlValidation";

const copyToClipboard = async (text: string) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}

const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.top = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();

try {
if (!document.execCommand("copy")) {
throw new Error("Copy command was not successful");
}
} finally {
document.body.removeChild(textArea);
}
};

interface SidebarProps {
connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse" | "streamable-http";
Expand Down Expand Up @@ -180,57 +204,45 @@ const Sidebar = ({
}, [generateServerConfig]);

// Memoized copy handlers
const handleCopyServerEntry = useCallback(() => {
const handleCopyServerEntry = useCallback(async () => {
try {
const configJson = generateMCPServerEntry();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerEntry(true);
await copyToClipboard(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.",
});
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);
});
setTimeout(() => {
setCopiedServerEntry(false);
}, 2000);
} catch (error) {
reportError(error);
}
}, [generateMCPServerEntry, transportType, toast, reportError]);

const handleCopyServerFile = useCallback(() => {
const handleCopyServerFile = useCallback(async () => {
try {
const configJson = generateMCPServerFile();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerFile(true);
await copyToClipboard(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'",
});
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);
});
setTimeout(() => {
setCopiedServerFile(false);
}, 2000);
} catch (error) {
reportError(error);
}
Expand Down
49 changes: 43 additions & 6 deletions client/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,9 @@ jest.mock("@/lib/hooks/useToast", () => ({
}),
}));

// Mock navigator clipboard
const mockClipboardWrite = jest.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockClipboardWrite,
},
});
const originalClipboard = navigator.clipboard;
const mockExecCommand = jest.fn(() => true);

// Setup fake timers
jest.useFakeTimers();
Expand Down Expand Up @@ -76,6 +72,23 @@ describe("Sidebar", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockClipboardWrite,
},
configurable: true,
});
Object.defineProperty(document, "execCommand", {
value: mockExecCommand,
configurable: true,
});
});

afterAll(() => {
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
configurable: true,
});
});

describe("Command and arguments", () => {
Expand Down Expand Up @@ -630,6 +643,30 @@ describe("Sidebar", () => {
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});

it("should fall back to execCommand when clipboard API is unavailable", async () => {
Object.defineProperty(navigator, "clipboard", {
value: undefined,
configurable: true,
});
const command = "node";
const args = "server.js";
renderSidebar({ transportType: "stdio", command, args });

await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});

expect(mockExecCommand).toHaveBeenCalledWith("copy");
expect(mockClipboardWrite).not.toHaveBeenCalled();
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.",
});
});
});

describe("Authentication", () => {
Expand Down