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

const copyTextToClipboard = async (text: string) => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
} catch (error) {
if (copyTextWithTextarea(text)) {
return;
}
throw error;
}

if (!copyTextWithTextarea(text)) {
throw new Error("Clipboard API is not available");
}
};

const copyTextWithTextarea = (text: string) => {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";

document.body.appendChild(textarea);
textarea.focus();
textarea.select();

try {
return document.execCommand("copy");
} finally {
document.body.removeChild(textarea);
}
};

interface SidebarProps {
connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse" | "streamable-http";
Expand Down Expand Up @@ -180,57 +217,47 @@ 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]);

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);
}
Expand Down
36 changes: 36 additions & 0 deletions client/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ jest.mock("@/lib/hooks/useToast", () => ({
// Mock navigator clipboard
const mockClipboardWrite = jest.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: mockClipboardWrite,
},
});
const mockExecCommand = jest.fn(() => true);
Object.defineProperty(document, "execCommand", {
configurable: true,
value: mockExecCommand,
});

// Setup fake timers
jest.useFakeTimers();
Expand Down Expand Up @@ -76,6 +82,8 @@ describe("Sidebar", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
mockClipboardWrite.mockResolvedValue(undefined);
mockExecCommand.mockReturnValue(true);
});

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

it("should fall back when clipboard write is rejected for servers file export", async () => {
mockClipboardWrite.mockRejectedValueOnce(new Error("NotAllowedError"));
const command = "node";
const args = "server.js";

renderSidebar({
transportType: "stdio",
command,
args,
});

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

expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
expect(mockExecCommand).toHaveBeenCalledWith("copy");
expect(document.querySelector("textarea")).toBeNull();
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'",
});
});

it("should copy server entry configuration to clipboard for SSE transport", async () => {
const sseUrl = "http://localhost:3000/events";
renderSidebar({ transportType: "sse", sseUrl });
Expand Down