Skip to content
27 changes: 27 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { getUri } from "./getUri"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { getWorkspacePath } from "../../utils/path"
import { McpMarketplaceService } from "../../services/mcp/McpMarketplaceService"

/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
Expand All @@ -91,6 +92,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
private contextProxy: ContextProxy
configManager: ConfigManager
customModesManager: CustomModesManager
private mcpMarketplaceService: McpMarketplaceService
get cwd() {
return getWorkspacePath()
}
Expand Down Expand Up @@ -122,6 +124,8 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
.catch((error) => {
this.outputChannel.appendLine(`Failed to initialize MCP Hub: ${error}`)
})

this.mcpMarketplaceService = new McpMarketplaceService(this)
}

// Adds a new Cline instance to clineStack, marking the start of a new task.
Expand Down Expand Up @@ -744,6 +748,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
mcpServers: this.mcpHub.getAllServers(),
})
}
this.mcpMarketplaceService.prefetchMcpMarketplace()

const cacheDir = await this.ensureCacheDirectoryExists()

Expand Down Expand Up @@ -1189,6 +1194,28 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
}
break
}

case "fetchMcpMarketplace": {
await this.mcpMarketplaceService.fetchMcpMarketplace(message.bool)
break
}
case "downloadMcp": {
if (message.mcpId) {
await this.mcpMarketplaceService.downloadMcp(this.mcpHub, message.mcpId)
}
break
}
case "silentlyRefreshMcpMarketplace": {
await this.mcpMarketplaceService.silentlyRefreshMcpMarketplace()
break
}
case "openMcpMarketplaceServerDetails": {
if (message.mcpId) {
await this.mcpMarketplaceService.openMcpMarketplaceServerDetails(message.mcpId)
}
break
}

case "openCustomModesSettings": {
const customModesFilePath = await this.customModesManager.getCustomModesFilePath()
if (customModesFilePath) {
Expand Down
5 changes: 5 additions & 0 deletions src/core/webview/__tests__/ClineProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ jest.mock(
{ virtual: true },
)

// Mock DiffViewProvider
jest.mock("../../../integrations/editor/DiffViewProvider", () => ({
DIFF_VIEW_URI_SCHEME: "DIFF_VIEW_URI_SCHEME",
}))

// Initialize mocks
const mockAddCustomInstructions = jest.fn().mockResolvedValue("Combined instructions")
;(jest.requireMock("../../prompts/sections/custom-instructions") as any).addCustomInstructions =
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 @@ -256,6 +256,7 @@ export type GlobalStateKey =
| "language"
| "maxReadFileLine"
| "fakeAi"
| "mcpMarketplaceCatalog"

export type ConfigurationKey = GlobalStateKey | SecretKey

Expand Down
210 changes: 210 additions & 0 deletions src/services/mcp/McpMarketplaceService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import axios from "axios"
import * as vscode from "vscode"
import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider"
import { McpMarketplaceCatalog, McpServer, McpDownloadResponse } from "../../shared/mcp"
import { McpHub } from "./McpHub"
import { ClineProvider } from "../../core/webview/ClineProvider"

export class McpMarketplaceService {
constructor(private provider: ClineProvider) {}

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.provider.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.provider.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.provider.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: catalog,
})
}
} catch (error) {
console.error("Failed to silently refresh MCP marketplace:", error)
}
}

async fetchMcpMarketplace(forceRefresh: boolean = false) {
try {
// Check if we have cached data
const cachedCatalog = (await this.provider.getGlobalState("mcpMarketplaceCatalog")) as
| McpMarketplaceCatalog
| undefined
if (!forceRefresh && cachedCatalog?.items) {
await this.provider.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: cachedCatalog,
})
return
}

const catalog = await this.fetchMcpMarketplaceFromApi(false)
if (catalog) {
await this.provider.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.provider.postMessageToWebview({
type: "mcpMarketplaceCatalog",
error: errorMessage,
})
vscode.window.showErrorMessage(errorMessage)
}
}

async downloadMcp(mcpHub: McpHub | undefined, mcpId: string) {
try {
// First check if we already have this MCP server installed
const servers = 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.provider.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.provider.initClineWithTask(task)
await this.provider.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.provider.postMessageToWebview({
type: "mcpDownloadDetails",
error: errorMessage,
})
}
}

async openMcpMarketplaceServerDetails(mcpId: string) {
const response = await fetch(`https://api.cline.bot/v1/mcp/marketplace/item?mcpId=${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,
})
}
}
}
6 changes: 5 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 @@ -85,6 +87,8 @@ export interface ExtensionMessage {
requestyModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
mcpMarketplaceCatalog?: McpMarketplaceCatalog
mcpDownloadDetails?: McpDownloadResponse
commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]
mode?: Mode
Expand Down
5 changes: 5 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ export interface WebviewMessage {
| "language"
| "maxReadFileLine"
| "searchFiles"
| "fetchMcpMarketplace"
| "downloadMcp"
| "openMcpMarketplaceServerDetails"
| "silentlyRefreshMcpMarketplace"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
Expand All @@ -141,6 +145,7 @@ export interface WebviewMessage {
source?: "global" | "project"
requestId?: string
ids?: 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 @@ -124,6 +124,7 @@ export const GLOBAL_STATE_KEYS = [
"maxWorkspaceFiles",
"maxReadFileLine",
"fakeAi",
"mcpMarketplaceCatalog",
] as const

export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const
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