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
14 changes: 9 additions & 5 deletions client/src/components/DynamicJsonForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -211,23 +215,23 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
};

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) {
clearTimeout(timeoutRef.current);
}
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 };
}
};

Expand Down
111 changes: 67 additions & 44 deletions client/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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 (
<div className="bg-card border-r border-border flex flex-col h-full">
Expand Down
18 changes: 15 additions & 3 deletions client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -805,8 +805,20 @@ const ToolsTab = ({
)}
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors(true)) return;
const validatedParams: Record<string, unknown> = {
...params,
};
const hasErrors = Object.entries(formRefs.current).some(
([key, ref]) => {
if (!ref) return false;
const validation = ref.validateJson();
if (!validation.isValid) return true;
validatedParams[key] = validation.value;
return false;
},
);
setHasValidationErrors(hasErrors);
if (hasErrors) return;

try {
setIsToolRunning(true);
Expand All @@ -826,7 +838,7 @@ const ToolsTab = ({
}, {});
await callTool(
selectedTool.name,
params,
validatedParams,
Object.keys(metadata).length ? metadata : undefined,
runAsTask,
);
Expand Down
52 changes: 52 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,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();
Expand Down Expand Up @@ -76,6 +81,8 @@ describe("Sidebar", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
mockClipboardWrite.mockResolvedValue(undefined);
mockExecCommand.mockReturnValue(true);
});

describe("Command and arguments", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
30 changes: 30 additions & 0 deletions client/src/components/__tests__/ToolsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,36 @@ describe("ToolsTab", () => {
expect(mockCallTool).toHaveBeenCalled();
});

it("should run with latest JSON edits even before debounce completes", async () => {
const mockCallTool = jest.fn();
renderToolsTab({
tools: [toolWithJsonParams],
selectedTool: toolWithJsonParams,
callTool: mockCallTool,
});

const textareas = screen.getAllByRole("textbox");
expect(textareas.length).toBe(2);

fireEvent.change(textareas[0], {
target: { value: '{ "setting": "latest" }' },
});

const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});

expect(mockCallTool).toHaveBeenCalledWith(
toolWithJsonParams.name,
expect.objectContaining({
config: { setting: "latest" },
}),
undefined,
false,
);
});

it("should handle mixed valid and invalid JSON parameters", async () => {
const mockCallTool = jest.fn();
renderToolsTab({
Expand Down