Merge pull request #1841 from cline/pashpashpash/mcp-marketplace

Add MCP Marketplace
This commit is contained in:
pashpashpash 2025-02-17 20:43:12 -08:00 committed by GitHub
commit ee50e9710a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1030 additions and 62 deletions

View file

@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add MCP Marketplace

View file

@ -14,6 +14,7 @@ import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { McpDownloadResponse, McpMarketplaceCatalog, McpMarketplaceItem, McpServer } from "../../shared/mcp"
import { FirebaseAuthManager, UserInfo } from "../../services/auth/FirebaseAuthManager"
import { ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
@ -28,6 +29,7 @@ import { getUri } from "./getUri"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider"
import { searchCommits } from "../../utils/git"
/*
@ -89,6 +91,7 @@ type GlobalStateKey =
| "qwenApiLine"
| "requestyModelId"
| "togetherModelId"
| "mcpMarketplaceCatalog"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
@ -401,6 +404,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter)
// Prefetch marketplace and OpenRouter models
this.prefetchMcpMarketplace()
this.refreshOpenRouterModels().then(async (openRouterModels) => {
if (openRouterModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
@ -414,6 +420,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
})
break
case "newTask":
// Code that should run in response to the hello message command
@ -780,6 +787,55 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "fetchMcpMarketplace": {
await this.fetchMcpMarketplace(message.bool)
break
}
case "downloadMcp": {
if (message.mcpId) {
await this.downloadMcp(message.mcpId)
}
break
}
case "silentlyRefreshMcpMarketplace": {
await this.silentlyRefreshMcpMarketplace()
break
}
case "openMcpMarketplaceServerDetails": {
if (message.mcpId) {
const response = await fetch(`https://api.cline.bot/v1/mcp/marketplace/item?mcpId=${message.mcpId}`)
const details: McpDownloadResponse = await response.json()
if (details.readmeContent) {
// Disable markdown preview markers
const config = vscode.workspace.getConfiguration("markdown")
await config.update("preview.markEditorSelection", false, true)
// Create URI with base64 encoded markdown content
const uri = vscode.Uri.parse(
`${DIFF_VIEW_URI_SCHEME}:${details.name} README?${Buffer.from(details.readmeContent).toString("base64")}`,
)
// close existing
const tabs = vscode.window.tabGroups.all
.flatMap((tg) => tg.tabs)
.filter((tab) => tab.label && tab.label.includes("README") && tab.label.includes("Preview"))
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
}
// Show only the preview
await vscode.commands.executeCommand("markdown.showPreview", uri, {
sideBySide: true,
preserveFocus: true,
})
}
}
this.postMessageToWebview({ type: "relinquishControl" })
break
}
case "toggleMcpServer": {
try {
await this.mcpHub?.toggleServerDisabled(message.serverName!, message.disabled!)
@ -1018,6 +1074,176 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
// MCP Marketplace
private async fetchMcpMarketplaceFromApi(silent: boolean = false): Promise<McpMarketplaceCatalog | undefined> {
try {
const response = await axios.get("https://api.cline.bot/v1/mcp/marketplace", {
headers: {
"Content-Type": "application/json",
},
})
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
const catalog: McpMarketplaceCatalog = {
items: (response.data || []).map((item: any) => ({
...item,
githubStars: item.githubStars ?? 0,
downloadCount: item.downloadCount ?? 0,
tags: item.tags ?? [],
})),
}
// Store in global state
await this.updateGlobalState("mcpMarketplaceCatalog", catalog)
return catalog
} catch (error) {
console.error("Failed to fetch MCP marketplace:", error)
if (!silent) {
const errorMessage = error instanceof Error ? error.message : "Failed to fetch MCP marketplace"
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
error: errorMessage,
})
vscode.window.showErrorMessage(errorMessage)
}
return undefined
}
}
async prefetchMcpMarketplace() {
try {
await this.fetchMcpMarketplaceFromApi(true)
} catch (error) {
console.error("Failed to prefetch MCP marketplace:", error)
}
}
async silentlyRefreshMcpMarketplace() {
try {
const catalog = await this.fetchMcpMarketplaceFromApi(true)
if (catalog) {
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: catalog,
})
}
} catch (error) {
console.error("Failed to silently refresh MCP marketplace:", error)
}
}
private async fetchMcpMarketplace(forceRefresh: boolean = false) {
try {
// Check if we have cached data
const cachedCatalog = (await this.getGlobalState("mcpMarketplaceCatalog")) as McpMarketplaceCatalog | undefined
if (!forceRefresh && cachedCatalog?.items) {
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: cachedCatalog,
})
return
}
const catalog = await this.fetchMcpMarketplaceFromApi(false)
if (catalog) {
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: catalog,
})
}
} catch (error) {
console.error("Failed to handle cached MCP marketplace:", error)
const errorMessage = error instanceof Error ? error.message : "Failed to handle cached MCP marketplace"
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
error: errorMessage,
})
vscode.window.showErrorMessage(errorMessage)
}
}
private async downloadMcp(mcpId: string) {
try {
// First check if we already have this MCP server installed
const servers = this.mcpHub?.getServers() || []
const isInstalled = servers.some((server: McpServer) => server.name === mcpId)
if (isInstalled) {
throw new Error("This MCP server is already installed")
}
// Fetch server details from marketplace
const response = await axios.post<McpDownloadResponse>(
"https://api.cline.bot/v1/mcp/download",
{ mcpId },
{
headers: { "Content-Type": "application/json" },
timeout: 10000,
},
)
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
console.log("[downloadMcp] Response from download API", { response })
const mcpDetails = response.data
// Validate required fields
if (!mcpDetails.githubUrl) {
throw new Error("Missing GitHub URL in MCP download response")
}
if (!mcpDetails.readmeContent) {
throw new Error("Missing README content in MCP download response")
}
// Send details to webview
await this.postMessageToWebview({
type: "mcpDownloadDetails",
mcpDownloadDetails: mcpDetails,
})
// Create task with context from README
const task = `Set up the MCP server from ${mcpDetails.githubUrl}. Use "${mcpDetails.mcpId}" as the server name in cline_mcp_settings.json. Here is the project's README to help you get started:\n\n${mcpDetails.readmeContent}\n${mcpDetails.llmsInstallationContent}`
// Initialize task and show chat view
await this.initClineWithTask(task)
await this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
} catch (error) {
console.error("Failed to download MCP:", error)
let errorMessage = "Failed to download MCP"
if (axios.isAxiosError(error)) {
if (error.code === "ECONNABORTED") {
errorMessage = "Request timed out. Please try again."
} else if (error.response?.status === 404) {
errorMessage = "MCP server not found in marketplace."
} else if (error.response?.status === 500) {
errorMessage = "Internal server error. Please try again later."
} else if (!error.response && error.request) {
errorMessage = "Network error. Please check your internet connection."
}
} else if (error instanceof Error) {
errorMessage = error.message
}
// Show error in both notification and marketplace UI
vscode.window.showErrorMessage(errorMessage)
await this.postMessageToWebview({
type: "mcpDownloadDetails",
error: errorMessage,
})
}
}
// OpenAi
async getOpenAiModels(baseUrl?: string, apiKey?: string) {

View file

@ -6,7 +6,7 @@ import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { BrowserSettings } from "./BrowserSettings"
import { ChatSettings } from "./ChatSettings"
import { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp"
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
// webview will hold state
export interface ExtensionMessage {
@ -27,6 +27,8 @@ export interface ExtensionMessage {
| "vsCodeLmModels"
| "requestVsCodeLmModels"
| "emailSubscribed"
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
| "commitSearchResults"
text?: string
action?:
@ -48,6 +50,9 @@ export interface ExtensionMessage {
openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
mcpMarketplaceCatalog?: McpMarketplaceCatalog
error?: string
mcpDownloadDetails?: McpDownloadResponse
commits?: GitCommit[]
}

View file

@ -43,6 +43,10 @@ export interface WebviewMessage {
| "accountLoginClicked"
| "accountLogoutClicked"
| "subscribeEmail"
| "fetchMcpMarketplace"
| "downloadMcp"
| "openMcpMarketplaceServerDetails"
| "silentlyRefreshMcpMarketplace"
| "searchCommits"
// | "relaunchChromeDebugMode"
text?: string
@ -56,6 +60,7 @@ export interface WebviewMessage {
browserSettings?: BrowserSettings
chatSettings?: ChatSettings
chatContent?: ChatContent
mcpId?: string
// For toggleToolAutoApprove
serverName?: string

View file

@ -66,3 +66,39 @@ export type McpToolCallResponse = {
>
isError?: boolean
}
export interface McpMarketplaceItem {
mcpId: string
githubUrl: string
name: string
author: string
description: string
codiconIcon: string
logoUrl: string
category: string
tags: string[]
requiresApiKey: boolean
readmeContent?: string
llmsInstallationContent?: string
isRecommended: boolean
githubStars: number
downloadCount: number
createdAt: string
updatedAt: string
lastGithubSync: string
}
export interface McpMarketplaceCatalog {
items: McpMarketplaceItem[]
}
export interface McpDownloadResponse {
mcpId: string
githubUrl: string
name: string
author: string
description: string
readmeContent: string
llmsInstallationContent: string
requiresApiKey: boolean
}

View file

@ -1,10 +1,12 @@
import { VSCodeButton, VSCodeLink, VSCodePanels, VSCodePanelTab, VSCodePanelView } from "@vscode/webview-ui-toolkit/react"
import { useState } from "react"
import { useState, useEffect } from "react"
import { vscode } from "../../utils/vscode"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { McpServer } from "../../../../src/shared/mcp"
import McpToolRow from "./McpToolRow"
import McpResourceRow from "./McpResourceRow"
import McpMarketplaceView from "./marketplace/McpMarketplaceView"
import styled from "styled-components"
type McpViewProps = {
onDone: () => void
@ -12,6 +14,15 @@ type McpViewProps = {
const McpView = ({ onDone }: McpViewProps) => {
const { mcpServers: servers } = useExtensionState()
const [activeTab, setActiveTab] = useState("marketplace")
const handleTabChange = (tab: string) => {
setActiveTab(tab)
}
useEffect(() => {
vscode.postMessage({ type: "silentlyRefreshMcpMarketplace" })
}, [])
// const [servers, setServers] = useState<McpServer[]>([
// // Add some mock servers for testing
@ -90,83 +101,141 @@ const McpView = ({ onDone }: McpViewProps) => {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 17px 10px 20px",
padding: "10px 17px 5px 20px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>MCP Servers</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}>
<div style={{ flex: 1, overflow: "auto" }}>
{/* Tabs container */}
<div
style={{
color: "var(--vscode-foreground)",
fontSize: "13px",
marginBottom: "16px",
marginTop: "5px",
display: "flex",
gap: "1px",
padding: "0 20px 0 20px",
borderBottom: "1px solid var(--vscode-panel-border)",
}}>
The{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol" style={{ display: "inline" }}>
Model Context Protocol
</VSCodeLink>{" "}
enables communication with locally running MCP servers that provide additional tools and resources to extend
Cline's capabilities. You can use{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
community-made servers
</VSCodeLink>{" "}
or ask Cline to create new tools specific to your workflow (e.g., "add a tool that gets the latest npm docs").{" "}
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
See a demo here.
</VSCodeLink>
<TabButton isActive={activeTab === "marketplace"} onClick={() => handleTabChange("marketplace")}>
Marketplace
</TabButton>
<TabButton isActive={activeTab === "installed"} onClick={() => handleTabChange("installed")}>
Installed
</TabButton>
</div>
{servers.length > 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
}}>
{servers.map((server) => (
<ServerRow key={server.name} server={server} />
))}
</div>
)}
{/* Content container */}
<div style={{ width: "100%" }}>
{activeTab === "marketplace" && <McpMarketplaceView />}
{activeTab === "installed" && (
<div style={{ padding: "16px 20px" }}>
<div
style={{
color: "var(--vscode-foreground)",
fontSize: "13px",
marginBottom: "16px",
marginTop: "5px",
}}>
The{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol" style={{ display: "inline" }}>
Model Context Protocol
</VSCodeLink>{" "}
enables communication with locally running MCP servers that provide additional tools and resources
to extend Cline's capabilities. You can use{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
community-made servers
</VSCodeLink>{" "}
or ask Cline to create new tools specific to your workflow (e.g., "add a tool that gets the latest
npm docs").{" "}
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
See a demo here.
</VSCodeLink>
</div>
{/* Server Configuration Button */}
{servers.length > 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
}}>
{servers.map((server) => (
<ServerRow key={server.name} server={server} />
))}
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "12px",
marginTop: "20px",
color: "var(--vscode-descriptionForeground)",
}}>
<div>No MCP servers installed yet</div>
<VSCodeButton appearance="primary" onClick={() => setActiveTab("marketplace")}>
<span className="codicon codicon-cloud-download" style={{ marginRight: "6px" }} />
Browse Marketplace
</VSCodeButton>
</div>
)}
<div style={{ marginTop: "10px", width: "100%" }}>
<VSCodeButton
appearance="secondary"
style={{ width: "100%" }}
onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
<span className="codicon codicon-server" style={{ marginRight: "6px" }}></span>
Configure MCP Servers
</VSCodeButton>
{/* Settings Section */}
<div style={{ marginBottom: "20px", marginTop: 10 }}>
<VSCodeButton
appearance="secondary"
style={{ width: "100%", marginBottom: "5px" }}
onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
<span className="codicon codicon-server" style={{ marginRight: "6px" }}></span>
Configure MCP Servers
</VSCodeButton>
<div style={{ textAlign: "center" }}>
<VSCodeLink
onClick={() => {
vscode.postMessage({
type: "openExtensionSettings",
text: "cline.mcp",
})
}}
style={{ fontSize: "12px" }}>
Advanced MCP Settings
</VSCodeLink>
</div>
</div>
</div>
)}
</div>
{/* Advanced Settings Link */}
<div style={{ textAlign: "center", marginTop: "5px" }}>
<VSCodeLink
onClick={() => {
vscode.postMessage({
type: "openExtensionSettings",
text: "cline.mcp",
})
}}
style={{ fontSize: "12px" }}>
Advanced MCP Settings
</VSCodeLink>
</div>
{/* Bottom padding */}
<div style={{ height: "20px" }} />
</div>
</div>
)
}
const StyledTabButton = styled.button<{ isActive: boolean }>`
background: none;
border: none;
border-bottom: 2px solid ${(props) => (props.isActive ? "var(--vscode-foreground)" : "transparent")};
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
margin-bottom: -1px;
font-family: inherit;
&:hover {
color: var(--vscode-foreground);
}
`
const TabButton = ({ children, isActive, onClick }: { children: React.ReactNode; isActive: boolean; onClick: () => void }) => (
<StyledTabButton isActive={isActive} onClick={onClick}>
{children}
</StyledTabButton>
)
// Server Row Component
const ServerRow = ({ server }: { server: McpServer }) => {
const [isExpanded, setIsExpanded] = useState(false)
@ -211,7 +280,7 @@ const ServerRow = ({ server }: { server: McpServer }) => {
{!server.error && (
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`} style={{ marginRight: "8px" }} />
)}
<span style={{ flex: 1 }}>{server.name}</span>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{server.name}</span>
<div style={{ display: "flex", alignItems: "center", marginRight: "8px" }} onClick={(e) => e.stopPropagation()}>
<div
role="switch"

View file

@ -0,0 +1,284 @@
import { useCallback, useState, useRef } from "react"
import styled from "styled-components"
import { McpMarketplaceItem, McpServer } from "../../../../../src/shared/mcp"
import { vscode } from "../../../utils/vscode"
import { useEvent } from "react-use"
interface McpMarketplaceCardProps {
item: McpMarketplaceItem
installedServers: McpServer[]
}
const McpMarketplaceCard = ({ item, installedServers }: McpMarketplaceCardProps) => {
const isInstalled = installedServers.some((server) => server.name === item.mcpId)
const [isDownloading, setIsDownloading] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const githubLinkRef = useRef<HTMLDivElement>(null)
const handleMessage = useCallback((event: MessageEvent) => {
const message = event.data
switch (message.type) {
case "mcpDownloadDetails":
setIsDownloading(false)
break
case "relinquishControl":
setIsLoading(false)
break
}
}, [])
useEvent("message", handleMessage)
return (
<>
<style>
{`
.mcp-card {
cursor: pointer;
}
.mcp-card:hover {
background-color: var(--vscode-list-hoverBackground);
}
`}
</style>
<div
className="mcp-card"
onClick={(e) => {
if (githubLinkRef.current?.contains(e.target as Node)) {
return
}
console.log("Card clicked:", item.mcpId)
setIsLoading(true)
vscode.postMessage({
type: "openMcpMarketplaceServerDetails",
mcpId: item.mcpId,
})
}}
style={{
padding: "14px 16px",
display: "flex",
flexDirection: "column",
gap: 12,
cursor: isLoading ? "wait" : "pointer",
}}>
{/* Main container with logo and content */}
<div style={{ display: "flex", gap: "12px" }}>
{/* Logo */}
{item.logoUrl && (
<img
src={item.logoUrl}
alt={`${item.name} logo`}
style={{
width: 42,
height: 42,
borderRadius: 4,
}}
/>
)}
{/* Content section */}
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}>
{/* First row: name and install button */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "16px",
}}>
<h3
style={{
margin: 0,
fontSize: "13px",
fontWeight: 600,
}}>
{item.name}
</h3>
<div
onClick={(e) => {
e.stopPropagation() // Prevent card click when clicking install
if (!isInstalled && !isDownloading) {
setIsDownloading(true)
vscode.postMessage({
type: "downloadMcp",
mcpId: item.mcpId,
})
}
}}
style={{}}>
<StyledInstallButton disabled={isInstalled || isDownloading} $isInstalled={isInstalled}>
{isInstalled ? "Installed" : isDownloading ? "Installing..." : "Install"}
</StyledInstallButton>
</div>
</div>
{/* Second row: metadata */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
flexWrap: "wrap",
minWidth: 0,
rowGap: 0, // Add this to remove vertical gap
}}>
<a
href={item.githubUrl}
style={{
display: "flex",
alignItems: "center",
color: "var(--vscode-foreground)",
minWidth: 0,
opacity: 0.5,
textDecoration: "none",
border: "none !important",
}}
className="github-link"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
e.currentTarget.style.color = "var(--link-active-foreground)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "0.5"
e.currentTarget.style.color = "var(--vscode-foreground)"
}}>
<div style={{ display: "flex", gap: "4px", alignItems: "center" }} ref={githubLinkRef}>
<span className="codicon codicon-github" style={{ fontSize: "14px" }} />
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-all",
minWidth: 0,
}}>
{item.author}
</span>
</div>
</a>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
minWidth: 0,
flexShrink: 0,
}}>
<span className="codicon codicon-star-full" />
<span style={{ wordBreak: "break-all" }}>{item.githubStars?.toLocaleString() ?? 0}</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
minWidth: 0,
flexShrink: 0,
}}>
<span className="codicon codicon-cloud-download" />
<span style={{ wordBreak: "break-all" }}>{item.downloadCount?.toLocaleString() ?? 0}</span>
</div>
{item.requiresApiKey && (
<span className="codicon codicon-key" title="Requires API key" style={{ flexShrink: 0 }} />
)}
{item.isRecommended && (
<span className="codicon codicon-verified" title="Recommended" style={{ flexShrink: 0 }} />
)}
</div>
</div>
</div>
{/* Description and tags */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<p style={{ fontSize: "13px", margin: 0 }}>{item.description}</p>
<div
style={{
display: "flex",
gap: "6px",
flexWrap: "nowrap",
overflow: "hidden",
position: "relative",
}}>
<span
style={{
fontSize: "10px",
padding: "1px 4px",
borderRadius: "3px",
border: "1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 50%, transparent)",
color: "var(--vscode-descriptionForeground)",
whiteSpace: "nowrap",
}}>
{item.category}
</span>
{item.tags.map((tag, index) => (
<span
key={tag}
style={{
fontSize: "10px",
padding: "1px 4px",
borderRadius: "3px",
border: "1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 50%, transparent)",
color: "var(--vscode-descriptionForeground)",
whiteSpace: "nowrap",
display: "inline-flex",
}}>
{tag}
{index === item.tags.length - 1 ? "" : ""}
</span>
))}
<div
style={{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "32px",
background: "linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
pointerEvents: "none",
}}
/>
</div>
</div>
</div>
</>
)
}
const StyledInstallButton = styled.button<{ $isInstalled?: boolean }>`
font-size: 12px;
font-weight: 500;
padding: 2px 6px;
border-radius: 2px;
border: none;
cursor: pointer;
background: ${(props) =>
props.$isInstalled ? "var(--vscode-button-secondaryBackground)" : "var(--vscode-button-background)"};
color: var(--vscode-button-foreground);
&:hover:not(:disabled) {
background: ${(props) =>
props.$isInstalled ? "var(--vscode-button-secondaryHoverBackground)" : "var(--vscode-button-hoverBackground)"};
}
&:active:not(:disabled) {
background: ${(props) =>
props.$isInstalled ? "var(--vscode-button-secondaryBackground)" : "var(--vscode-button-background)"};
opacity: 0.7;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
`
export default McpMarketplaceCard

View file

@ -0,0 +1,287 @@
import { useEffect, useMemo, useState } from "react"
import {
VSCodeButton,
VSCodeProgressRing,
VSCodeRadioGroup,
VSCodeRadio,
VSCodeDropdown,
VSCodeOption,
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react"
import { McpMarketplaceItem } from "../../../../../src/shared/mcp"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
import McpMarketplaceCard from "./McpMarketplaceCard"
import McpSubmitCard from "./McpSubmitCard"
const McpMarketplaceView = () => {
const { mcpServers } = useExtensionState()
const [items, setItems] = useState<McpMarketplaceItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<"downloadCount" | "stars" | "name" | "newest">("downloadCount")
const categories = useMemo(() => {
const uniqueCategories = new Set(items.map((item) => item.category))
return Array.from(uniqueCategories).sort()
}, [items])
const filteredItems = useMemo(() => {
return items
.filter((item) => {
const matchesSearch =
searchQuery === "" ||
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesCategory = !selectedCategory || item.category === selectedCategory
return matchesSearch && matchesCategory
})
.sort((a, b) => {
switch (sortBy) {
case "downloadCount":
return b.downloadCount - a.downloadCount
case "stars":
return b.githubStars - a.githubStars
case "name":
return a.name.localeCompare(b.name)
case "newest":
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
default:
return 0
}
})
}, [items, searchQuery, selectedCategory, sortBy])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "mcpMarketplaceCatalog") {
if (message.error) {
setError(message.error)
} else {
setItems(message.mcpMarketplaceCatalog?.items || [])
setError(null)
}
setIsLoading(false)
setIsRefreshing(false)
} else if (message.type === "mcpDownloadDetails") {
if (message.error) {
setError(message.error)
}
}
}
window.addEventListener("message", handleMessage)
// Fetch marketplace catalog
fetchMarketplace()
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const fetchMarketplace = (forceRefresh: boolean = false) => {
if (forceRefresh) {
setIsRefreshing(true)
} else {
setIsLoading(true)
}
setError(null)
vscode.postMessage({ type: "fetchMcpMarketplace", bool: forceRefresh })
}
if (isLoading || isRefreshing) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
padding: "20px",
}}>
<VSCodeProgressRing />
</div>
)
}
if (error) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100%",
padding: "20px",
gap: "12px",
}}>
<div style={{ color: "var(--vscode-errorForeground)" }}>{error}</div>
<VSCodeButton appearance="secondary" onClick={() => fetchMarketplace(true)}>
<span className="codicon codicon-refresh" style={{ marginRight: "6px" }} />
Retry
</VSCodeButton>
</div>
)
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}>
<div style={{ padding: "20px 20px 5px", display: "flex", flexDirection: "column", gap: "16px" }}>
{/* Search row */}
<VSCodeTextField
style={{ width: "100%" }}
placeholder="Search MCPs..."
value={searchQuery}
onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}>
<div
slot="start"
className="codicon codicon-search"
style={{
fontSize: 13,
opacity: 0.8,
}}
/>
{searchQuery && (
<div
className="codicon codicon-close"
aria-label="Clear search"
onClick={() => setSearchQuery("")}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
cursor: "pointer",
}}
/>
)}
</VSCodeTextField>
{/* Filter row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}>
<span
style={{
fontSize: "11px",
color: "var(--vscode-descriptionForeground)",
textTransform: "uppercase",
fontWeight: 500,
flexShrink: 0,
}}>
Filter:
</span>
<div
style={{
position: "relative",
zIndex: 2,
flex: 1,
}}>
<VSCodeDropdown
style={{
width: "100%",
}}
value={selectedCategory || ""}
onChange={(e) => setSelectedCategory((e.target as HTMLSelectElement).value || null)}>
<VSCodeOption value="">All Categories</VSCodeOption>
{categories.map((category) => (
<VSCodeOption key={category} value={category}>
{category}
</VSCodeOption>
))}
</VSCodeDropdown>
</div>
</div>
{/* Sort row */}
<div
style={{
display: "flex",
gap: "8px",
}}>
<span
style={{
fontSize: "11px",
color: "var(--vscode-descriptionForeground)",
textTransform: "uppercase",
fontWeight: 500,
marginTop: "3px",
}}>
Sort:
</span>
<VSCodeRadioGroup
style={{
display: "flex",
flexWrap: "wrap",
marginTop: "-2.5px",
}}
value={sortBy}
onChange={(e) => setSortBy((e.target as HTMLInputElement).value as typeof sortBy)}>
<VSCodeRadio value="downloadCount">Most Installs</VSCodeRadio>
<VSCodeRadio value="stars">Most Stars</VSCodeRadio>
<VSCodeRadio value="newest">Newest</VSCodeRadio>
<VSCodeRadio value="name">Name</VSCodeRadio>
</VSCodeRadioGroup>
</div>
</div>
<style>
{`
.mcp-search-input,
.mcp-select {
box-sizing: border-box;
}
.mcp-search-input {
min-width: 140px;
}
.mcp-search-input:focus,
.mcp-select:focus {
border-color: var(--vscode-focusBorder) !important;
}
.mcp-search-input:hover,
.mcp-select:hover {
opacity: 0.9;
}
`}
</style>
<div style={{ display: "flex", flexDirection: "column" }}>
{filteredItems.length === 0 ? (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
padding: "20px",
color: "var(--vscode-descriptionForeground)",
borderBottom: "1px solid var(--vscode-list-inactiveSelectionBackground)",
}}>
{searchQuery || selectedCategory
? "No matching MCP servers found"
: "No MCP servers found in the marketplace"}
</div>
) : (
filteredItems.map((item) => <McpMarketplaceCard key={item.mcpId} item={item} installedServers={mcpServers} />)
)}
<McpSubmitCard />
</div>
</div>
)
}
export default McpMarketplaceView

View file

@ -0,0 +1,51 @@
const McpSubmitCard = () => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "24px",
padding: "32px 20px",
marginTop: "16px",
}}>
{/* Logo */}
<img
src="https://storage.googleapis.com/cline_public_images/cline.png"
alt="Cline bot logo"
style={{
width: 64,
height: 64,
borderRadius: 8,
}}
/>
{/* Content */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "12px",
textAlign: "center",
maxWidth: "480px",
}}>
<h3
style={{
margin: 0,
fontSize: "16px",
fontWeight: 600,
}}>
Is something missing?
</h3>
<p style={{ fontSize: "13px", margin: 0, color: "var(--vscode-descriptionForeground)" }}>
Submit your own MCP servers to the marketplace by{" "}
<a href="https://github.com/cline/mcp-marketplace">submitting an issue</a> on the official MCP Marketplace
repo on GitHub.
</p>
</div>
</div>
)
}
export default McpSubmitCard