Skip to content

Commit c27b7ff

Browse files
committed
Cloud: add organization MCP controls
- Add option to hide mcps from the marketplace - Add ability to define new organization mcps
1 parent 82a3212 commit c27b7ff

28 files changed

+385
-39
lines changed

packages/cloud/src/CloudService.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CloudUserInfo,
55
TelemetryEvent,
66
OrganizationAllowList,
7+
OrganizationSettings,
78
ClineMessage,
89
ShareVisibility,
910
} from "@roo-code/types"
@@ -174,6 +175,11 @@ export class CloudService {
174175
return this.settingsService!.getAllowList()
175176
}
176177

178+
public getOrganizationSettings(): OrganizationSettings | undefined {
179+
this.ensureInitialized()
180+
return this.settingsService!.getSettings()
181+
}
182+
177183
// TelemetryClient
178184

179185
public captureEvent(event: TelemetryEvent): void {

packages/types/src/cloud.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod"
22

33
import { globalSettingsSchema } from "./global-settings.js"
4+
import { mcpMarketplaceItemSchema } from "./marketplace.js"
45

56
/**
67
* CloudUserInfo
@@ -110,6 +111,9 @@ export const organizationSettingsSchema = z.object({
110111
cloudSettings: organizationCloudSettingsSchema.optional(),
111112
defaultSettings: organizationDefaultSettingsSchema,
112113
allowList: organizationAllowListSchema,
114+
hiddenMcps: z.array(z.string()).optional(),
115+
hideMarketplaceMcps: z.boolean().optional(),
116+
mcps: z.array(mcpMarketplaceItemSchema).optional(),
113117
})
114118

115119
export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>

src/core/webview/ClineProvider.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,10 +1328,10 @@ export class ClineProvider
13281328
*/
13291329
async fetchMarketplaceData() {
13301330
try {
1331-
const [marketplaceItems, marketplaceInstalledMetadata] = await Promise.all([
1332-
this.marketplaceManager.getCurrentItems().catch((error) => {
1331+
const [marketplaceResult, marketplaceInstalledMetadata] = await Promise.all([
1332+
this.marketplaceManager.getMarketplaceItems().catch((error) => {
13331333
console.error("Failed to fetch marketplace items:", error)
1334-
return [] as MarketplaceItem[]
1334+
return { organizationMcps: [], marketplaceItems: [], errors: [error.message] }
13351335
}),
13361336
this.marketplaceManager.getInstallationMetadata().catch((error) => {
13371337
console.error("Failed to fetch installation metadata:", error)
@@ -1342,16 +1342,20 @@ export class ClineProvider
13421342
// Send marketplace data separately
13431343
this.postMessageToWebview({
13441344
type: "marketplaceData",
1345-
marketplaceItems: marketplaceItems || [],
1345+
organizationMcps: marketplaceResult.organizationMcps || [],
1346+
marketplaceItems: marketplaceResult.marketplaceItems || [],
13461347
marketplaceInstalledMetadata: marketplaceInstalledMetadata || { project: {}, global: {} },
1348+
errors: marketplaceResult.errors,
13471349
})
13481350
} catch (error) {
13491351
console.error("Failed to fetch marketplace data:", error)
13501352
// Send empty data on error to prevent UI from hanging
13511353
this.postMessageToWebview({
13521354
type: "marketplaceData",
1355+
organizationMcps: [],
13531356
marketplaceItems: [],
13541357
marketplaceInstalledMetadata: { project: {}, global: {} },
1358+
errors: [error instanceof Error ? error.message : String(error)],
13551359
})
13561360

13571361
// Show user-friendly error notification for network issues

src/services/marketplace/MarketplaceManager.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@ import * as path from "path"
44
import * as yaml from "yaml"
55
import { RemoteConfigLoader } from "./RemoteConfigLoader"
66
import { SimpleInstaller } from "./SimpleInstaller"
7-
import type { MarketplaceItem, MarketplaceItemType } from "@roo-code/types"
7+
import type { MarketplaceItem, MarketplaceItemType, McpMarketplaceItem } from "@roo-code/types"
88
import { GlobalFileNames } from "../../shared/globalFileNames"
99
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
1010
import { t } from "../../i18n"
1111
import { TelemetryService } from "@roo-code/telemetry"
1212
import type { CustomModesManager } from "../../core/config/CustomModesManager"
13+
import { CloudService } from "@roo-code/cloud"
14+
15+
export interface MarketplaceItemsResponse {
16+
organizationMcps: MarketplaceItem[]
17+
marketplaceItems: MarketplaceItem[]
18+
errors?: string[]
19+
}
1320

1421
export class MarketplaceManager {
1522
private configLoader: RemoteConfigLoader
@@ -23,25 +30,72 @@ export class MarketplaceManager {
2330
this.installer = new SimpleInstaller(context, customModesManager)
2431
}
2532

26-
async getMarketplaceItems(): Promise<{ items: MarketplaceItem[]; errors?: string[] }> {
33+
async getMarketplaceItems(): Promise<MarketplaceItemsResponse> {
2734
try {
28-
const items = await this.configLoader.loadAllItems()
35+
let shouldHideMarketplaceMcps = false
36+
let orgSettings: ReturnType<typeof CloudService.instance.getOrganizationSettings> | null = null
2937

30-
return { items }
38+
// Check organization settings first to determine if we should load MCPs
39+
try {
40+
if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) {
41+
orgSettings = CloudService.instance.getOrganizationSettings()
42+
if (orgSettings?.hideMarketplaceMcps) {
43+
shouldHideMarketplaceMcps = true
44+
}
45+
}
46+
} catch (orgError) {
47+
console.warn("Failed to load organization settings:", orgError)
48+
}
49+
50+
const allMarketplaceItems = await this.configLoader.loadAllItems(shouldHideMarketplaceMcps)
51+
let organizationMcps: MarketplaceItem[] = []
52+
let marketplaceItems = allMarketplaceItems
53+
const errors: string[] = []
54+
55+
try {
56+
if (orgSettings) {
57+
if (orgSettings.mcps && orgSettings.mcps.length > 0) {
58+
organizationMcps = orgSettings.mcps.map(
59+
(mcp: McpMarketplaceItem): MarketplaceItem => ({
60+
...mcp,
61+
type: "mcp" as const,
62+
}),
63+
)
64+
}
65+
66+
if (orgSettings.hiddenMcps && orgSettings.hiddenMcps.length > 0) {
67+
const hiddenMcpIds = new Set(orgSettings.hiddenMcps)
68+
marketplaceItems = allMarketplaceItems.filter(
69+
(item) => item.type !== "mcp" || !hiddenMcpIds.has(item.id),
70+
)
71+
}
72+
}
73+
} catch (orgError) {
74+
console.warn("Failed to load organization settings:", orgError)
75+
const orgErrorMessage = orgError instanceof Error ? orgError.message : String(orgError)
76+
errors.push(`Organization settings: ${orgErrorMessage}`)
77+
}
78+
79+
return {
80+
organizationMcps,
81+
marketplaceItems,
82+
errors: errors.length > 0 ? errors : undefined,
83+
}
3184
} catch (error) {
3285
const errorMessage = error instanceof Error ? error.message : String(error)
3386
console.error("Failed to load marketplace items:", error)
3487

3588
return {
36-
items: [],
89+
organizationMcps: [],
90+
marketplaceItems: [],
3791
errors: [errorMessage],
3892
}
3993
}
4094
}
4195

4296
async getCurrentItems(): Promise<MarketplaceItem[]> {
4397
const result = await this.getMarketplaceItems()
44-
return result.items
98+
return [...result.organizationMcps, ...result.marketplaceItems]
4599
}
46100

47101
filterItems(

src/services/marketplace/RemoteConfigLoader.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ export class RemoteConfigLoader {
2323
this.apiBaseUrl = getRooCodeApiUrl()
2424
}
2525

26-
async loadAllItems(): Promise<MarketplaceItem[]> {
26+
async loadAllItems(hideMarketplaceMcps = false): Promise<MarketplaceItem[]> {
2727
const items: MarketplaceItem[] = []
2828

29-
const [modes, mcps] = await Promise.all([this.fetchModes(), this.fetchMcps()])
29+
const modesPromise = this.fetchModes()
30+
const mcpsPromise = hideMarketplaceMcps ? Promise.resolve([]) : this.fetchMcps()
31+
32+
const [modes, mcps] = await Promise.all([modesPromise, mcpsPromise])
3033

3134
items.push(...modes, ...mcps)
3235
return items

src/services/marketplace/__tests__/MarketplaceManager.spec.ts

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,21 @@ import type { MarketplaceItem } from "@roo-code/types"
44

55
import { MarketplaceManager } from "../MarketplaceManager"
66

7-
// Mock axios
8-
vi.mock("axios")
9-
10-
// Mock the cloud config
7+
// Mock CloudService
118
vi.mock("@roo-code/cloud", () => ({
129
getRooCodeApiUrl: () => "https://test.api.com",
10+
CloudService: {
11+
hasInstance: vi.fn(),
12+
instance: {
13+
isAuthenticated: vi.fn(),
14+
getOrganizationSettings: vi.fn(),
15+
},
16+
},
1317
}))
1418

19+
// Mock axios
20+
vi.mock("axios")
21+
1522
// Mock TelemetryService
1623
vi.mock("../../../../packages/telemetry/src/TelemetryService", () => ({
1724
TelemetryService: {
@@ -165,8 +172,9 @@ describe("MarketplaceManager", () => {
165172

166173
const result = await manager.getMarketplaceItems()
167174

168-
expect(result.items).toHaveLength(1)
169-
expect(result.items[0].name).toBe("Test Mode")
175+
expect(result.marketplaceItems).toHaveLength(1)
176+
expect(result.marketplaceItems[0].name).toBe("Test Mode")
177+
expect(result.organizationMcps).toHaveLength(0)
170178
})
171179

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

176184
const result = await manager.getMarketplaceItems()
177185

178-
expect(result.items).toHaveLength(0)
186+
expect(result.marketplaceItems).toHaveLength(0)
187+
expect(result.organizationMcps).toHaveLength(0)
179188
expect(result.errors).toEqual(["API request failed"])
180189
})
190+
191+
it("should return organization MCPs when available", async () => {
192+
const { CloudService } = await import("@roo-code/cloud")
193+
194+
// Mock CloudService to return organization settings
195+
vi.mocked(CloudService.hasInstance).mockReturnValue(true)
196+
vi.mocked(CloudService.instance.isAuthenticated).mockReturnValue(true)
197+
vi.mocked(CloudService.instance.getOrganizationSettings).mockReturnValue({
198+
version: 1,
199+
mcps: [
200+
{
201+
id: "org-mcp-1",
202+
name: "Organization MCP",
203+
description: "An organization MCP",
204+
url: "https://example.com/org-mcp",
205+
content: '{"command": "node", "args": ["org-server.js"]}',
206+
},
207+
],
208+
hiddenMcps: [],
209+
allowList: { allowAll: true, providers: {} },
210+
defaultSettings: {},
211+
})
212+
213+
// Mock the config loader to return test data
214+
const mockItems: MarketplaceItem[] = [
215+
{
216+
id: "test-mcp",
217+
name: "Test MCP",
218+
description: "A test MCP",
219+
type: "mcp",
220+
url: "https://example.com/test-mcp",
221+
content: '{"command": "node", "args": ["server.js"]}',
222+
},
223+
]
224+
225+
vi.spyOn(manager["configLoader"], "loadAllItems").mockResolvedValue(mockItems)
226+
227+
const result = await manager.getMarketplaceItems()
228+
229+
expect(result.organizationMcps).toHaveLength(1)
230+
expect(result.organizationMcps[0].name).toBe("Organization MCP")
231+
expect(result.marketplaceItems).toHaveLength(1)
232+
expect(result.marketplaceItems[0].name).toBe("Test MCP")
233+
})
234+
235+
it("should filter out hidden MCPs from marketplace results", async () => {
236+
const { CloudService } = await import("@roo-code/cloud")
237+
238+
// Mock CloudService to return organization settings with hidden MCPs
239+
vi.mocked(CloudService.hasInstance).mockReturnValue(true)
240+
vi.mocked(CloudService.instance.isAuthenticated).mockReturnValue(true)
241+
vi.mocked(CloudService.instance.getOrganizationSettings).mockReturnValue({
242+
version: 1,
243+
mcps: [],
244+
hiddenMcps: ["hidden-mcp"],
245+
allowList: { allowAll: true, providers: {} },
246+
defaultSettings: {},
247+
})
248+
249+
// Mock the config loader to return test data including a hidden MCP
250+
const mockItems: MarketplaceItem[] = [
251+
{
252+
id: "visible-mcp",
253+
name: "Visible MCP",
254+
description: "A visible MCP",
255+
type: "mcp",
256+
url: "https://example.com/visible-mcp",
257+
content: '{"command": "node", "args": ["visible.js"]}',
258+
},
259+
{
260+
id: "hidden-mcp",
261+
name: "Hidden MCP",
262+
description: "A hidden MCP",
263+
type: "mcp",
264+
url: "https://example.com/hidden-mcp",
265+
content: '{"command": "node", "args": ["hidden.js"]}',
266+
},
267+
]
268+
269+
vi.spyOn(manager["configLoader"], "loadAllItems").mockResolvedValue(mockItems)
270+
271+
const result = await manager.getMarketplaceItems()
272+
273+
expect(result.marketplaceItems).toHaveLength(1)
274+
expect(result.marketplaceItems[0].name).toBe("Visible MCP")
275+
expect(result.organizationMcps).toHaveLength(0)
276+
})
277+
278+
it("should handle CloudService not being available", async () => {
279+
const { CloudService } = await import("@roo-code/cloud")
280+
281+
// Mock CloudService to not be available
282+
vi.mocked(CloudService.hasInstance).mockReturnValue(false)
283+
284+
// Mock the config loader to return test data
285+
const mockItems: MarketplaceItem[] = [
286+
{
287+
id: "test-mcp",
288+
name: "Test MCP",
289+
description: "A test MCP",
290+
type: "mcp",
291+
url: "https://example.com/test-mcp",
292+
content: '{"command": "node", "args": ["server.js"]}',
293+
},
294+
]
295+
296+
vi.spyOn(manager["configLoader"], "loadAllItems").mockResolvedValue(mockItems)
297+
298+
const result = await manager.getMarketplaceItems()
299+
300+
expect(result.organizationMcps).toHaveLength(0)
301+
expect(result.marketplaceItems).toHaveLength(1)
302+
expect(result.marketplaceItems[0].name).toBe("Test MCP")
303+
})
181304
})
182305

183306
describe("installMarketplaceItem", () => {

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ export interface ExtensionMessage {
185185
organizationAllowList?: OrganizationAllowList
186186
tab?: string
187187
marketplaceItems?: MarketplaceItem[]
188+
organizationMcps?: MarketplaceItem[]
188189
marketplaceInstalledMetadata?: MarketplaceInstalledMetadata
190+
errors?: string[]
189191
visibility?: ShareVisibility
190192
rulesFolderPath?: string
191193
settings?: any

0 commit comments

Comments
 (0)