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
6 changes: 6 additions & 0 deletions packages/cloud/src/CloudService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
CloudUserInfo,
TelemetryEvent,
OrganizationAllowList,
OrganizationSettings,
ClineMessage,
ShareVisibility,
} from "@roo-code/types"
Expand Down Expand Up @@ -174,6 +175,11 @@ export class CloudService {
return this.settingsService!.getAllowList()
}

public getOrganizationSettings(): OrganizationSettings | undefined {
this.ensureInitialized()
return this.settingsService!.getSettings()
}

// TelemetryClient

public captureEvent(event: TelemetryEvent): void {
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod"

import { globalSettingsSchema } from "./global-settings.js"
import { mcpMarketplaceItemSchema } from "./marketplace.js"

/**
* CloudUserInfo
Expand Down Expand Up @@ -110,6 +111,9 @@ export const organizationSettingsSchema = z.object({
cloudSettings: organizationCloudSettingsSchema.optional(),
defaultSettings: organizationDefaultSettingsSchema,
allowList: organizationAllowListSchema,
hiddenMcps: z.array(z.string()).optional(),
hideMarketplaceMcps: z.boolean().optional(),
mcps: z.array(mcpMarketplaceItemSchema).optional(),
})

export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>
Expand Down
12 changes: 8 additions & 4 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1328,10 +1328,10 @@ export class ClineProvider
*/
async fetchMarketplaceData() {
try {
const [marketplaceItems, marketplaceInstalledMetadata] = await Promise.all([
this.marketplaceManager.getCurrentItems().catch((error) => {
const [marketplaceResult, marketplaceInstalledMetadata] = await Promise.all([
this.marketplaceManager.getMarketplaceItems().catch((error) => {
console.error("Failed to fetch marketplace items:", error)
return [] as MarketplaceItem[]
return { organizationMcps: [], marketplaceItems: [], errors: [error.message] }
}),
this.marketplaceManager.getInstallationMetadata().catch((error) => {
console.error("Failed to fetch installation metadata:", error)
Expand All @@ -1342,16 +1342,20 @@ export class ClineProvider
// Send marketplace data separately
this.postMessageToWebview({
type: "marketplaceData",
marketplaceItems: marketplaceItems || [],
organizationMcps: marketplaceResult.organizationMcps || [],
marketplaceItems: marketplaceResult.marketplaceItems || [],
marketplaceInstalledMetadata: marketplaceInstalledMetadata || { project: {}, global: {} },
errors: marketplaceResult.errors,
})
} catch (error) {
console.error("Failed to fetch marketplace data:", error)
// Send empty data on error to prevent UI from hanging
this.postMessageToWebview({
type: "marketplaceData",
organizationMcps: [],
marketplaceItems: [],
marketplaceInstalledMetadata: { project: {}, global: {} },
errors: [error instanceof Error ? error.message : String(error)],
})

// Show user-friendly error notification for network issues
Expand Down
57 changes: 51 additions & 6 deletions src/services/marketplace/MarketplaceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ import * as path from "path"
import * as yaml from "yaml"
import { RemoteConfigLoader } from "./RemoteConfigLoader"
import { SimpleInstaller } from "./SimpleInstaller"
import type { MarketplaceItem, MarketplaceItemType } from "@roo-code/types"
import type { MarketplaceItem, MarketplaceItemType, McpMarketplaceItem, OrganizationSettings } from "@roo-code/types"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import { t } from "../../i18n"
import { TelemetryService } from "@roo-code/telemetry"
import type { CustomModesManager } from "../../core/config/CustomModesManager"
import { CloudService } from "@roo-code/cloud"

export interface MarketplaceItemsResponse {
organizationMcps: MarketplaceItem[]
marketplaceItems: MarketplaceItem[]
errors?: string[]
}

export class MarketplaceManager {
private configLoader: RemoteConfigLoader
Expand All @@ -23,25 +30,63 @@ export class MarketplaceManager {
this.installer = new SimpleInstaller(context, customModesManager)
}

async getMarketplaceItems(): Promise<{ items: MarketplaceItem[]; errors?: string[] }> {
async getMarketplaceItems(): Promise<MarketplaceItemsResponse> {
try {
const items = await this.configLoader.loadAllItems()
const errors: string[] = []

return { items }
let orgSettings: OrganizationSettings | undefined
try {
if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) {
orgSettings = CloudService.instance.getOrganizationSettings()
}
} catch (orgError) {
console.warn("Failed to load organization settings:", orgError)
const orgErrorMessage = orgError instanceof Error ? orgError.message : String(orgError)
errors.push(`Organization settings: ${orgErrorMessage}`)
}

const allMarketplaceItems = await this.configLoader.loadAllItems(orgSettings?.hideMarketplaceMcps)
let organizationMcps: MarketplaceItem[] = []
let marketplaceItems = allMarketplaceItems

if (orgSettings) {
if (orgSettings.mcps && orgSettings.mcps.length > 0) {
organizationMcps = orgSettings.mcps.map(
(mcp: McpMarketplaceItem): MarketplaceItem => ({
...mcp,
type: "mcp" as const,
}),
)
}

if (orgSettings.hiddenMcps && orgSettings.hiddenMcps.length > 0) {
const hiddenMcpIds = new Set(orgSettings.hiddenMcps)
marketplaceItems = allMarketplaceItems.filter(
(item) => item.type !== "mcp" || !hiddenMcpIds.has(item.id),
)
}
}

return {
organizationMcps,
marketplaceItems,
errors: errors.length > 0 ? errors : undefined,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error("Failed to load marketplace items:", error)

return {
items: [],
organizationMcps: [],
marketplaceItems: [],
errors: [errorMessage],
}
}
}

async getCurrentItems(): Promise<MarketplaceItem[]> {
const result = await this.getMarketplaceItems()
return result.items
return [...result.organizationMcps, ...result.marketplaceItems]
}

filterItems(
Expand Down
7 changes: 5 additions & 2 deletions src/services/marketplace/RemoteConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ export class RemoteConfigLoader {
this.apiBaseUrl = getRooCodeApiUrl()
}

async loadAllItems(): Promise<MarketplaceItem[]> {
async loadAllItems(hideMarketplaceMcps = false): Promise<MarketplaceItem[]> {
const items: MarketplaceItem[] = []

const [modes, mcps] = await Promise.all([this.fetchModes(), this.fetchMcps()])
const modesPromise = this.fetchModes()
const mcpsPromise = hideMarketplaceMcps ? Promise.resolve([]) : this.fetchMcps()

const [modes, mcps] = await Promise.all([modesPromise, mcpsPromise])

items.push(...modes, ...mcps)
return items
Expand Down
137 changes: 130 additions & 7 deletions src/services/marketplace/__tests__/MarketplaceManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ import type { MarketplaceItem } from "@roo-code/types"

import { MarketplaceManager } from "../MarketplaceManager"

// Mock axios
vi.mock("axios")

// Mock the cloud config
// Mock CloudService
vi.mock("@roo-code/cloud", () => ({
getRooCodeApiUrl: () => "https://test.api.com",
CloudService: {
hasInstance: vi.fn(),
instance: {
isAuthenticated: vi.fn(),
getOrganizationSettings: vi.fn(),
},
},
}))

// Mock axios
vi.mock("axios")

// Mock TelemetryService
vi.mock("../../../../packages/telemetry/src/TelemetryService", () => ({
TelemetryService: {
Expand Down Expand Up @@ -165,8 +172,9 @@ describe("MarketplaceManager", () => {

const result = await manager.getMarketplaceItems()

expect(result.items).toHaveLength(1)
expect(result.items[0].name).toBe("Test Mode")
expect(result.marketplaceItems).toHaveLength(1)
expect(result.marketplaceItems[0].name).toBe("Test Mode")
expect(result.organizationMcps).toHaveLength(0)
})

it("should handle API errors gracefully", async () => {
Expand All @@ -175,9 +183,124 @@ describe("MarketplaceManager", () => {

const result = await manager.getMarketplaceItems()

expect(result.items).toHaveLength(0)
expect(result.marketplaceItems).toHaveLength(0)
expect(result.organizationMcps).toHaveLength(0)
expect(result.errors).toEqual(["API request failed"])
})

it("should return organization MCPs when available", async () => {
const { CloudService } = await import("@roo-code/cloud")

// Mock CloudService to return organization settings
vi.mocked(CloudService.hasInstance).mockReturnValue(true)
vi.mocked(CloudService.instance.isAuthenticated).mockReturnValue(true)
vi.mocked(CloudService.instance.getOrganizationSettings).mockReturnValue({
version: 1,
mcps: [
{
id: "org-mcp-1",
name: "Organization MCP",
description: "An organization MCP",
url: "https://example.com/org-mcp",
content: '{"command": "node", "args": ["org-server.js"]}',
},
],
hiddenMcps: [],
allowList: { allowAll: true, providers: {} },
defaultSettings: {},
})

// Mock the config loader to return test data
const mockItems: MarketplaceItem[] = [
{
id: "test-mcp",
name: "Test MCP",
description: "A test MCP",
type: "mcp",
url: "https://example.com/test-mcp",
content: '{"command": "node", "args": ["server.js"]}',
},
]

vi.spyOn(manager["configLoader"], "loadAllItems").mockResolvedValue(mockItems)

const result = await manager.getMarketplaceItems()

expect(result.organizationMcps).toHaveLength(1)
expect(result.organizationMcps[0].name).toBe("Organization MCP")
expect(result.marketplaceItems).toHaveLength(1)
expect(result.marketplaceItems[0].name).toBe("Test MCP")
})

it("should filter out hidden MCPs from marketplace results", async () => {
const { CloudService } = await import("@roo-code/cloud")

// Mock CloudService to return organization settings with hidden MCPs
vi.mocked(CloudService.hasInstance).mockReturnValue(true)
vi.mocked(CloudService.instance.isAuthenticated).mockReturnValue(true)
vi.mocked(CloudService.instance.getOrganizationSettings).mockReturnValue({
version: 1,
mcps: [],
hiddenMcps: ["hidden-mcp"],
allowList: { allowAll: true, providers: {} },
defaultSettings: {},
})

// Mock the config loader to return test data including a hidden MCP
const mockItems: MarketplaceItem[] = [
{
id: "visible-mcp",
name: "Visible MCP",
description: "A visible MCP",
type: "mcp",
url: "https://example.com/visible-mcp",
content: '{"command": "node", "args": ["visible.js"]}',
},
{
id: "hidden-mcp",
name: "Hidden MCP",
description: "A hidden MCP",
type: "mcp",
url: "https://example.com/hidden-mcp",
content: '{"command": "node", "args": ["hidden.js"]}',
},
]

vi.spyOn(manager["configLoader"], "loadAllItems").mockResolvedValue(mockItems)

const result = await manager.getMarketplaceItems()

expect(result.marketplaceItems).toHaveLength(1)
expect(result.marketplaceItems[0].name).toBe("Visible MCP")
expect(result.organizationMcps).toHaveLength(0)
})

it("should handle CloudService not being available", async () => {
const { CloudService } = await import("@roo-code/cloud")

// Mock CloudService to not be available
vi.mocked(CloudService.hasInstance).mockReturnValue(false)

// Mock the config loader to return test data
const mockItems: MarketplaceItem[] = [
{
id: "test-mcp",
name: "Test MCP",
description: "A test MCP",
type: "mcp",
url: "https://example.com/test-mcp",
content: '{"command": "node", "args": ["server.js"]}',
},
]

vi.spyOn(manager["configLoader"], "loadAllItems").mockResolvedValue(mockItems)

const result = await manager.getMarketplaceItems()

expect(result.organizationMcps).toHaveLength(0)
expect(result.marketplaceItems).toHaveLength(1)
expect(result.marketplaceItems[0].name).toBe("Test MCP")
})
})

describe("installMarketplaceItem", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ export interface ExtensionMessage {
organizationAllowList?: OrganizationAllowList
tab?: string
marketplaceItems?: MarketplaceItem[]
organizationMcps?: MarketplaceItem[]
marketplaceInstalledMetadata?: MarketplaceInstalledMetadata
errors?: string[]
visibility?: ShareVisibility
rulesFolderPath?: string
settings?: any
Expand Down
Loading