Skip to content
Merged
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
9 changes: 8 additions & 1 deletion src/app/catalog/components/server-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { AddMcpToClientDropdown } from "@/components/add-mcp-to-client-dropdown";
import { CopyUrlButton } from "@/components/copy-url-button";
import { Badge } from "@/components/ui/badge";
import {
Expand Down Expand Up @@ -53,7 +54,13 @@ export function ServerCard({ server, serverUrl, onClick }: ServerCardProps) {
{description || "No description available"}
</p>
{serverUrl && (
<CopyUrlButton url={serverUrl} className="w-fit cursor-pointer" />
<div className="flex items-center gap-2">
<CopyUrlButton url={serverUrl} className="w-fit cursor-pointer" />
<AddMcpToClientDropdown
serverName={name ?? ""}
serverUrl={serverUrl}
/>
</div>
)}
</CardContent>
</Card>
Expand Down
80 changes: 80 additions & 0 deletions src/components/add-mcp-to-client-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAddMcpToClient } from "@/hooks/use-add-mcp-to-client";
import { MCP_CLIENTS } from "@/lib/mcp/client-configs";

interface AddMcpClientDropdownProps {
serverName: string;
serverUrl: string;
}

export function AddMcpToClientDropdown({
serverName,
serverUrl,
}: AddMcpClientDropdownProps) {
const { openInClient, copyCommand, copyJsonConfig } = useAddMcpToClient({
serverName,
config: { url: serverUrl },
});

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex h-10 items-center justify-between gap-2"
>
<span>Add to client</span>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent align="start" side="bottom" className="w-64">
<DropdownMenuItem
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
openInClient(MCP_CLIENTS.cursor);
}}
>
Cursor
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
copyCommand(MCP_CLIENTS.vscode);
}}
>
VS Code (copy CLI command)
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
copyJsonConfig(MCP_CLIENTS.vscode);
}}
>
VS Code (copy MCP JSON config)
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
copyCommand(MCP_CLIENTS.claudeCode);
}}
>
Claude Code (copy CLI command)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
134 changes: 134 additions & 0 deletions src/hooks/__tests__/use-add-mcp-to-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { renderHook } from "@testing-library/react";
import { toast } from "sonner";
import { describe, expect, it, vi } from "vitest";
import { useAddMcpToClient } from "@/hooks/use-add-mcp-to-client";
import {
buildCursorDeeplink,
buildVSCodeCommand,
buildVSCodeMcpJson,
MCP_CLIENTS,
type McpTransportConfig,
} from "@/lib/mcp/client-configs";

function mockClipboardWriteText() {
const writeText = vi
.fn<(text: string) => Promise<void>>()
.mockResolvedValue(undefined);

Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});

return writeText;
}

describe("useAddMcpToClient", () => {
it("opens Cursor deeplink via window.open", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);

const serverName = "my-server";
const config: McpTransportConfig = { url: "https://example.com/mcp" };

const { result } = renderHook(() =>
useAddMcpToClient({ serverName, config }),
);

result.current.openInClient(MCP_CLIENTS.cursor);

expect(openSpy).toHaveBeenCalledWith(
buildCursorDeeplink(serverName, config),
"_self",
);
});

it("copies VS Code --add-mcp command to clipboard", async () => {
const writeText = mockClipboardWriteText();

const serverName = "my-server";
const config: McpTransportConfig = { url: "https://example.com/mcp" };

const { result } = renderHook(() =>
useAddMcpToClient({ serverName, config }),
);

await result.current.copyCommand(MCP_CLIENTS.vscode);

expect(writeText).toHaveBeenCalledWith(
buildVSCodeCommand(serverName, config),
);
expect(toast.success).toHaveBeenCalledWith("VS Code command copied!");
});

it("copies VS Code JSON config to clipboard (pretty-printed)", async () => {
const writeText = mockClipboardWriteText();

const serverName = "my-server";
const config: McpTransportConfig = { url: "https://example.com/mcp" };

const { result } = renderHook(() =>
useAddMcpToClient({ serverName, config }),
);

await result.current.copyJsonConfig(MCP_CLIENTS.vscode);

const expectedJson = JSON.stringify(
buildVSCodeMcpJson(serverName, config),
null,
2,
);
expect(writeText).toHaveBeenCalledWith(expectedJson);
expect(toast.success).toHaveBeenCalledWith("VS Code config copied!");
});

it("shows an error when trying to open a client without deeplink (VS Code)", () => {
vi.spyOn(window, "open").mockImplementation(() => null);

const serverName = "my-server";
const config: McpTransportConfig = { url: "https://example.com/mcp" };

const { result } = renderHook(() =>
useAddMcpToClient({ serverName, config }),
);

result.current.openInClient(MCP_CLIENTS.vscode);

expect(toast.error).toHaveBeenCalledWith(
"VS Code doesn't support direct installation. Use the copy command instead.",
);
});

it("shows an error when copying command for a client that doesn't expose one (Cursor)", async () => {
const writeText = mockClipboardWriteText();

const serverName = "my-server";
const config: McpTransportConfig = { url: "https://example.com/mcp" };

const { result } = renderHook(() =>
useAddMcpToClient({ serverName, config }),
);

await result.current.copyCommand(MCP_CLIENTS.cursor);

expect(writeText).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("No command available for Cursor");
});

it("shows an error when copying JSON config for a client that doesn't expose one (Cursor)", async () => {
const writeText = mockClipboardWriteText();

const serverName = "my-server";
const config: McpTransportConfig = { url: "https://example.com/mcp" };

const { result } = renderHook(() =>
useAddMcpToClient({ serverName, config }),
);

await result.current.copyJsonConfig(MCP_CLIENTS.cursor);

expect(writeText).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith(
"No JSON config available for Cursor",
);
});
});
129 changes: 129 additions & 0 deletions src/hooks/use-add-mcp-to-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

import { useCallback } from "react";
import { toast } from "sonner";
import {
buildClaudeCodeCommand,
buildCursorDeeplink,
buildVSCodeCommand,
buildVSCodeMcpJson,
CLIENT_METADATA,
MCP_CLIENTS,
type McpClientType,
type McpTransportConfig,
} from "@/lib/mcp/client-configs";

interface UseAddToClientOptions {
serverName: string;
config: McpTransportConfig;
}

interface ClientConfig {
deeplink?: string | null;
command?: string;
jsonConfig?: object | null;
metadata: (typeof CLIENT_METADATA)[McpClientType];
}

interface UseAddToClientReturn {
/** Open deeplink (Cursor only) */
openInClient: (client: McpClientType) => void;
/** Copy command to clipboard */
copyCommand: (client: McpClientType) => Promise<void>;
/** Copy JSON config to clipboard (VS Code only) */
copyJsonConfig: (client: McpClientType) => Promise<void>;
}

async function copyToClipboard(text: string, successMessage: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch {
toast.error("Failed to copy to clipboard");
}
}

const buildClientConfigs = (
serverName: string,
config: McpTransportConfig,
): Record<McpClientType, ClientConfig> => {
return {
[MCP_CLIENTS.cursor]: {
deeplink: buildCursorDeeplink(serverName, config),
metadata: CLIENT_METADATA[MCP_CLIENTS.cursor],
},
[MCP_CLIENTS.vscode]: {
command: buildVSCodeCommand(serverName, config),
jsonConfig: buildVSCodeMcpJson(serverName, config),
metadata: CLIENT_METADATA[MCP_CLIENTS.vscode],
},
[MCP_CLIENTS.claudeCode]: {
command: buildClaudeCodeCommand(serverName, config),
metadata: CLIENT_METADATA[MCP_CLIENTS.claudeCode],
},
};
};

/**
* Hook for adding MCP servers to different clients.
* Exposes helper actions to open/copy client-specific MCP install artifacts (Cursor deeplink,
* VS Code/Claude Code commands, VS Code JSON config). Artifacts are generated on demand.
*/
export function useAddMcpToClient({
serverName,
config,
}: UseAddToClientOptions): UseAddToClientReturn {
const openInClient = useCallback(
(client: McpClientType) => {
const clientConfig = buildClientConfigs(serverName, config)[client];

if (clientConfig.deeplink) {
window.open(clientConfig.deeplink, "_self");
} else {
toast.error(
`${clientConfig.metadata.name} doesn't support direct installation. Use the copy command instead.`,
);
}
},
[serverName, config],
);

const copyCommand = useCallback(
async (client: McpClientType) => {
const clientConfig = buildClientConfigs(serverName, config)[client];
if (!clientConfig.command) {
toast.error(`No command available for ${clientConfig.metadata.name}`);
return;
}
await copyToClipboard(
clientConfig.command,
`${clientConfig.metadata.name} command copied!`,
);
},
[serverName, config],
);

const copyJsonConfig = useCallback(
async (client: McpClientType) => {
const clientConfig = buildClientConfigs(serverName, config)[client];
if (!clientConfig.jsonConfig) {
toast.error(
`No JSON config available for ${clientConfig.metadata.name}`,
);
return;
}
const clientJsonConfig = JSON.stringify(clientConfig.jsonConfig, null, 2);
await copyToClipboard(
clientJsonConfig,
`${clientConfig.metadata.name} config copied!`,
);
},
[serverName, config],
);

return {
openInClient,
copyCommand,
copyJsonConfig,
};
}
Loading
Loading