Skip to content
227 changes: 227 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { McpMarketplaceCatalog, McpServer, McpDownloadResponse } from "../../shared/mcp"
import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider"

/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
Expand Down Expand Up @@ -712,6 +714,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpServers: this.mcpHub.getAllServers(),
})
}
this.prefetchMcpMarketplace()

const cacheDir = await this.ensureCacheDirectoryExists()

Expand Down Expand Up @@ -1114,6 +1117,28 @@ 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) {
await this.openMcpMarketplaceServerDetails(message.mcpId)
}
break
}

case "openCustomModesSettings": {
const customModesFilePath = await this.customModesManager.getCustomModesFilePath()
if (customModesFilePath) {
Expand Down Expand Up @@ -2094,6 +2119,208 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return undefined
}

// MCP Marketplace

private async fetchMcpMarketplaceFromApi(silent: boolean = false): Promise<McpMarketplaceCatalog | undefined> {
try {
const response = await axios.get("https://api.cline.bot/v1/mcp/marketplace", {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid hardcoding external API URLs (e.g. https://api.cline.bot/v1/mcp/marketplace). Consider placing endpoints in config or environment variables to ease deployment and testing. This follows our secure configuration best practices.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea, let me look into this.

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,
})
}
}

private async openMcpMarketplaceServerDetails(mcpId: string) {
const response = await fetch(`https://api.cline.bot/v1/mcp/marketplace/item?mcpId=${mcpId}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding error handling for the fetch call in openMcpMarketplaceServerDetails so that network failures or unexpected responses don’t lead to unhandled exceptions.

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,
})
}
}

// OpenRouter

async handleOpenRouterCallback(code: string) {
Expand Down
1 change: 1 addition & 0 deletions src/exports/roo-code.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export type GlobalStateKey =
| "showRooIgnoredFiles"
| "remoteBrowserEnabled"
| "language"
| "mcpMarketplaceCatalog"

export type ConfigurationKey = GlobalStateKey | SecretKey

Expand Down
7 changes: 6 additions & 1 deletion src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
import { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp"
import { McpServer, McpMarketplaceCatalog, McpDownloadResponse } from "./mcp"
import { GitCommit } from "../utils/git"
import { Mode, CustomModePrompts, ModeConfig } from "./modes"
import { CustomSupportPrompts } from "./support-prompt"
Expand Down Expand Up @@ -36,6 +36,8 @@ export interface ExtensionMessage {
| "requestyModels"
| "openAiModels"
| "mcpServers"
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
| "enhancedPrompt"
| "commitSearchResults"
| "listApiConfig"
Expand Down Expand Up @@ -81,6 +83,8 @@ export interface ExtensionMessage {
requestyModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
mcpMarketplaceCatalog?: McpMarketplaceCatalog
mcpDownloadDetails?: McpDownloadResponse
commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]
mode?: Mode
Expand All @@ -90,6 +94,7 @@ export interface ExtensionMessage {
values?: Record<string, any>
requestId?: string
promptText?: string
error?: string
}

export interface ApiConfigMeta {
Expand Down
5 changes: 5 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export interface WebviewMessage {
| "browserConnectionResult"
| "remoteBrowserEnabled"
| "language"
| "fetchMcpMarketplace"
| "downloadMcp"
| "openMcpMarketplaceServerDetails"
| "silentlyRefreshMcpMarketplace"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
Expand All @@ -132,6 +136,7 @@ export interface WebviewMessage {
payload?: WebViewMessagePayload
source?: "global" | "project"
requestId?: string
mcpId?: string
}

export const checkoutDiffPayloadSchema = z.object({
Expand Down
1 change: 1 addition & 0 deletions src/shared/globalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const GLOBAL_STATE_KEYS = [
"remoteBrowserEnabled",
"language",
"maxWorkspaceFiles",
"mcpMarketplaceCatalog",
] as const

type CheckGlobalStateKeysExhaustiveness =
Expand Down
36 changes: 36 additions & 0 deletions src/shared/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,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
}
Loading
Loading