Skip to content

Commit 6a42e91

Browse files
authored
Cloud: add organization MCP controls (#6378)
- Add option to hide mcps from the marketplace - Add ability to define new organization mcps
1 parent 43021d3 commit 6a42e91

28 files changed

+379
-40
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: 51 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, OrganizationSettings } 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,63 @@ 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+
const errors: string[] = []
2936

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

3579
return {
36-
items: [],
80+
organizationMcps: [],
81+
marketplaceItems: [],
3782
errors: [errorMessage],
3883
}
3984
}
4085
}
4186

4287
async getCurrentItems(): Promise<MarketplaceItem[]> {
4388
const result = await this.getMarketplaceItems()
44-
return result.items
89+
return [...result.organizationMcps, ...result.marketplaceItems]
4590
}
4691

4792
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)