Skip to content

Commit 8c41882

Browse files
peppescgclaude
andauthored
feat: add mcp client dropdown configuration (#248)
* feat: add mcp client dropdown configuration * refactor: option name * refactor: use MCP_CLIENTS constants instead of hardcoded strings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add unit tests for client-configs utility functions Add direct tests for stdio config and remote config with headers, covering code paths that weren't tested through the hook tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e9d0e4f commit 8c41882

File tree

6 files changed

+641
-1
lines changed

6 files changed

+641
-1
lines changed

src/app/catalog/components/server-card.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { AddMcpToClientDropdown } from "@/components/add-mcp-to-client-dropdown";
34
import { CopyUrlButton } from "@/components/copy-url-button";
45
import { Badge } from "@/components/ui/badge";
56
import {
@@ -53,7 +54,13 @@ export function ServerCard({ server, serverUrl, onClick }: ServerCardProps) {
5354
{description || "No description available"}
5455
</p>
5556
{serverUrl && (
56-
<CopyUrlButton url={serverUrl} className="w-fit cursor-pointer" />
57+
<div className="flex items-center gap-2">
58+
<CopyUrlButton url={serverUrl} className="w-fit cursor-pointer" />
59+
<AddMcpToClientDropdown
60+
serverName={name ?? ""}
61+
serverUrl={serverUrl}
62+
/>
63+
</div>
5764
)}
5865
</CardContent>
5966
</Card>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"use client";
2+
import { ChevronDown } from "lucide-react";
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuItem,
8+
DropdownMenuTrigger,
9+
} from "@/components/ui/dropdown-menu";
10+
import { useAddMcpToClient } from "@/hooks/use-add-mcp-to-client";
11+
import { MCP_CLIENTS } from "@/lib/mcp/client-configs";
12+
13+
interface AddMcpClientDropdownProps {
14+
serverName: string;
15+
serverUrl: string;
16+
}
17+
18+
export function AddMcpToClientDropdown({
19+
serverName,
20+
serverUrl,
21+
}: AddMcpClientDropdownProps) {
22+
const { openInClient, copyCommand, copyJsonConfig } = useAddMcpToClient({
23+
serverName,
24+
config: { url: serverUrl },
25+
});
26+
27+
return (
28+
<DropdownMenu>
29+
<DropdownMenuTrigger asChild>
30+
<Button
31+
variant="outline"
32+
size="sm"
33+
className="flex h-10 items-center justify-between gap-2"
34+
>
35+
<span>Add to client</span>
36+
<ChevronDown className="size-4" />
37+
</Button>
38+
</DropdownMenuTrigger>
39+
40+
<DropdownMenuContent align="start" side="bottom" className="w-64">
41+
<DropdownMenuItem
42+
className="cursor-pointer"
43+
onClick={(e) => {
44+
e.stopPropagation();
45+
openInClient(MCP_CLIENTS.cursor);
46+
}}
47+
>
48+
Cursor
49+
</DropdownMenuItem>
50+
<DropdownMenuItem
51+
className="cursor-pointer"
52+
onClick={(e) => {
53+
e.stopPropagation();
54+
copyCommand(MCP_CLIENTS.vscode);
55+
}}
56+
>
57+
VS Code (copy CLI command)
58+
</DropdownMenuItem>
59+
<DropdownMenuItem
60+
className="cursor-pointer"
61+
onClick={(e) => {
62+
e.stopPropagation();
63+
copyJsonConfig(MCP_CLIENTS.vscode);
64+
}}
65+
>
66+
VS Code (copy MCP JSON config)
67+
</DropdownMenuItem>
68+
<DropdownMenuItem
69+
className="cursor-pointer"
70+
onClick={(e) => {
71+
e.stopPropagation();
72+
copyCommand(MCP_CLIENTS.claudeCode);
73+
}}
74+
>
75+
Claude Code (copy CLI command)
76+
</DropdownMenuItem>
77+
</DropdownMenuContent>
78+
</DropdownMenu>
79+
);
80+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { toast } from "sonner";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { useAddMcpToClient } from "@/hooks/use-add-mcp-to-client";
5+
import {
6+
buildCursorDeeplink,
7+
buildVSCodeCommand,
8+
buildVSCodeMcpJson,
9+
MCP_CLIENTS,
10+
type McpTransportConfig,
11+
} from "@/lib/mcp/client-configs";
12+
13+
function mockClipboardWriteText() {
14+
const writeText = vi
15+
.fn<(text: string) => Promise<void>>()
16+
.mockResolvedValue(undefined);
17+
18+
Object.defineProperty(navigator, "clipboard", {
19+
value: { writeText },
20+
configurable: true,
21+
});
22+
23+
return writeText;
24+
}
25+
26+
describe("useAddMcpToClient", () => {
27+
it("opens Cursor deeplink via window.open", () => {
28+
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
29+
30+
const serverName = "my-server";
31+
const config: McpTransportConfig = { url: "https://example.com/mcp" };
32+
33+
const { result } = renderHook(() =>
34+
useAddMcpToClient({ serverName, config }),
35+
);
36+
37+
result.current.openInClient(MCP_CLIENTS.cursor);
38+
39+
expect(openSpy).toHaveBeenCalledWith(
40+
buildCursorDeeplink(serverName, config),
41+
"_self",
42+
);
43+
});
44+
45+
it("copies VS Code --add-mcp command to clipboard", async () => {
46+
const writeText = mockClipboardWriteText();
47+
48+
const serverName = "my-server";
49+
const config: McpTransportConfig = { url: "https://example.com/mcp" };
50+
51+
const { result } = renderHook(() =>
52+
useAddMcpToClient({ serverName, config }),
53+
);
54+
55+
await result.current.copyCommand(MCP_CLIENTS.vscode);
56+
57+
expect(writeText).toHaveBeenCalledWith(
58+
buildVSCodeCommand(serverName, config),
59+
);
60+
expect(toast.success).toHaveBeenCalledWith("VS Code command copied!");
61+
});
62+
63+
it("copies VS Code JSON config to clipboard (pretty-printed)", async () => {
64+
const writeText = mockClipboardWriteText();
65+
66+
const serverName = "my-server";
67+
const config: McpTransportConfig = { url: "https://example.com/mcp" };
68+
69+
const { result } = renderHook(() =>
70+
useAddMcpToClient({ serverName, config }),
71+
);
72+
73+
await result.current.copyJsonConfig(MCP_CLIENTS.vscode);
74+
75+
const expectedJson = JSON.stringify(
76+
buildVSCodeMcpJson(serverName, config),
77+
null,
78+
2,
79+
);
80+
expect(writeText).toHaveBeenCalledWith(expectedJson);
81+
expect(toast.success).toHaveBeenCalledWith("VS Code config copied!");
82+
});
83+
84+
it("shows an error when trying to open a client without deeplink (VS Code)", () => {
85+
vi.spyOn(window, "open").mockImplementation(() => null);
86+
87+
const serverName = "my-server";
88+
const config: McpTransportConfig = { url: "https://example.com/mcp" };
89+
90+
const { result } = renderHook(() =>
91+
useAddMcpToClient({ serverName, config }),
92+
);
93+
94+
result.current.openInClient(MCP_CLIENTS.vscode);
95+
96+
expect(toast.error).toHaveBeenCalledWith(
97+
"VS Code doesn't support direct installation. Use the copy command instead.",
98+
);
99+
});
100+
101+
it("shows an error when copying command for a client that doesn't expose one (Cursor)", async () => {
102+
const writeText = mockClipboardWriteText();
103+
104+
const serverName = "my-server";
105+
const config: McpTransportConfig = { url: "https://example.com/mcp" };
106+
107+
const { result } = renderHook(() =>
108+
useAddMcpToClient({ serverName, config }),
109+
);
110+
111+
await result.current.copyCommand(MCP_CLIENTS.cursor);
112+
113+
expect(writeText).not.toHaveBeenCalled();
114+
expect(toast.error).toHaveBeenCalledWith("No command available for Cursor");
115+
});
116+
117+
it("shows an error when copying JSON config for a client that doesn't expose one (Cursor)", async () => {
118+
const writeText = mockClipboardWriteText();
119+
120+
const serverName = "my-server";
121+
const config: McpTransportConfig = { url: "https://example.com/mcp" };
122+
123+
const { result } = renderHook(() =>
124+
useAddMcpToClient({ serverName, config }),
125+
);
126+
127+
await result.current.copyJsonConfig(MCP_CLIENTS.cursor);
128+
129+
expect(writeText).not.toHaveBeenCalled();
130+
expect(toast.error).toHaveBeenCalledWith(
131+
"No JSON config available for Cursor",
132+
);
133+
});
134+
});

src/hooks/use-add-mcp-to-client.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use client";
2+
3+
import { useCallback } from "react";
4+
import { toast } from "sonner";
5+
import {
6+
buildClaudeCodeCommand,
7+
buildCursorDeeplink,
8+
buildVSCodeCommand,
9+
buildVSCodeMcpJson,
10+
CLIENT_METADATA,
11+
MCP_CLIENTS,
12+
type McpClientType,
13+
type McpTransportConfig,
14+
} from "@/lib/mcp/client-configs";
15+
16+
interface UseAddToClientOptions {
17+
serverName: string;
18+
config: McpTransportConfig;
19+
}
20+
21+
interface ClientConfig {
22+
deeplink?: string | null;
23+
command?: string;
24+
jsonConfig?: object | null;
25+
metadata: (typeof CLIENT_METADATA)[McpClientType];
26+
}
27+
28+
interface UseAddToClientReturn {
29+
/** Open deeplink (Cursor only) */
30+
openInClient: (client: McpClientType) => void;
31+
/** Copy command to clipboard */
32+
copyCommand: (client: McpClientType) => Promise<void>;
33+
/** Copy JSON config to clipboard (VS Code only) */
34+
copyJsonConfig: (client: McpClientType) => Promise<void>;
35+
}
36+
37+
async function copyToClipboard(text: string, successMessage: string) {
38+
try {
39+
await navigator.clipboard.writeText(text);
40+
toast.success(successMessage);
41+
} catch {
42+
toast.error("Failed to copy to clipboard");
43+
}
44+
}
45+
46+
const buildClientConfigs = (
47+
serverName: string,
48+
config: McpTransportConfig,
49+
): Record<McpClientType, ClientConfig> => {
50+
return {
51+
[MCP_CLIENTS.cursor]: {
52+
deeplink: buildCursorDeeplink(serverName, config),
53+
metadata: CLIENT_METADATA[MCP_CLIENTS.cursor],
54+
},
55+
[MCP_CLIENTS.vscode]: {
56+
command: buildVSCodeCommand(serverName, config),
57+
jsonConfig: buildVSCodeMcpJson(serverName, config),
58+
metadata: CLIENT_METADATA[MCP_CLIENTS.vscode],
59+
},
60+
[MCP_CLIENTS.claudeCode]: {
61+
command: buildClaudeCodeCommand(serverName, config),
62+
metadata: CLIENT_METADATA[MCP_CLIENTS.claudeCode],
63+
},
64+
};
65+
};
66+
67+
/**
68+
* Hook for adding MCP servers to different clients.
69+
* Exposes helper actions to open/copy client-specific MCP install artifacts (Cursor deeplink,
70+
* VS Code/Claude Code commands, VS Code JSON config). Artifacts are generated on demand.
71+
*/
72+
export function useAddMcpToClient({
73+
serverName,
74+
config,
75+
}: UseAddToClientOptions): UseAddToClientReturn {
76+
const openInClient = useCallback(
77+
(client: McpClientType) => {
78+
const clientConfig = buildClientConfigs(serverName, config)[client];
79+
80+
if (clientConfig.deeplink) {
81+
window.open(clientConfig.deeplink, "_self");
82+
} else {
83+
toast.error(
84+
`${clientConfig.metadata.name} doesn't support direct installation. Use the copy command instead.`,
85+
);
86+
}
87+
},
88+
[serverName, config],
89+
);
90+
91+
const copyCommand = useCallback(
92+
async (client: McpClientType) => {
93+
const clientConfig = buildClientConfigs(serverName, config)[client];
94+
if (!clientConfig.command) {
95+
toast.error(`No command available for ${clientConfig.metadata.name}`);
96+
return;
97+
}
98+
await copyToClipboard(
99+
clientConfig.command,
100+
`${clientConfig.metadata.name} command copied!`,
101+
);
102+
},
103+
[serverName, config],
104+
);
105+
106+
const copyJsonConfig = useCallback(
107+
async (client: McpClientType) => {
108+
const clientConfig = buildClientConfigs(serverName, config)[client];
109+
if (!clientConfig.jsonConfig) {
110+
toast.error(
111+
`No JSON config available for ${clientConfig.metadata.name}`,
112+
);
113+
return;
114+
}
115+
const clientJsonConfig = JSON.stringify(clientConfig.jsonConfig, null, 2);
116+
await copyToClipboard(
117+
clientJsonConfig,
118+
`${clientConfig.metadata.name} config copied!`,
119+
);
120+
},
121+
[serverName, config],
122+
);
123+
124+
return {
125+
openInClient,
126+
copyCommand,
127+
copyJsonConfig,
128+
};
129+
}

0 commit comments

Comments
 (0)