diff --git a/README.md b/README.md
index 75f37762f93..ca85189562d 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@
- [简体中文](locales/zh-CN/README.md)
- [繁體中文](locales/zh-TW/README.md)
- ...
-
+
---
@@ -66,10 +66,10 @@ Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) •
-| | | |
-| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
-|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing |
-|
Custom Modes |
Checkpoints |
Context Management |
+| | | |
+| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
+|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing |
+|
Custom Modes |
Checkpoints |
Context Management |
diff --git a/locales/ca/README.md b/locales/ca/README.md
index 0c09d8c6611..2b4873bf7c7 100644
--- a/locales/ca/README.md
+++ b/locales/ca/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-mode
| | | |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Instal·lant Roo Code | Configurant perfils | Indexació de la base de codi |
-| Modes personalitzats | Punts de control | Gestió de Context |
+| Modes personalitzats | Punts de control | Gestió de Context |
diff --git a/locales/de/README.md b/locales/de/README.md
index 526d601e70b..68f12484970 100644
--- a/locales/de/README.md
+++ b/locales/de/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes
| | | |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Roo Code installieren | Profile konfigurieren | Codebasis-Indizierung |
-| Benutzerdefinierte Modi | Checkpoints | Kontextverwaltung |
+| Benutzerdefinierte Modi | Checkpoints | Kontextverwaltung |
diff --git a/locales/es/README.md b/locales/es/README.md
index 9e378b7fd7c..7e9c864a2bf 100644
--- a/locales/es/README.md
+++ b/locales/es/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [M
| | | |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Instalando Roo Code | Configurando perfiles | Indexación de la base de código |
-| Modos personalizados | Checkpoints | Gestión de Contexto |
+| Modos personalizados | Checkpoints | Gestión de Contexto |
diff --git a/locales/fr/README.md b/locales/fr/README.md
index 5197e76f0e9..9114291154a 100644
--- a/locales/fr/README.md
+++ b/locales/fr/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Installer Roo Code | Configurer les profils | Indexation de la base de code |
-| Modes personnalisés | Checkpoints | Gestion du Contexte |
+| Modes personnalisés | Checkpoints | Gestion du Contexte |
diff --git a/locales/hi/README.md b/locales/hi/README.md
index 8d12b689944..d09a230210e 100644
--- a/locales/hi/README.md
+++ b/locales/hi/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| रू कोड इंस्टॉल करना | प्रोफाइल कॉन्फ़िगर करना | कोडबेस इंडेक्सिंग |
-| कस्टम मोड | चेकपॉइंट्स | संदर्भ प्रबंधन |
+| कस्टम मोड | चेकपॉइंट्स | संदर्भ प्रबंधन |
diff --git a/locales/id/README.md b/locales/id/README.md
index 657b1ab750f..2a2e2e4b533 100644
--- a/locales/id/README.md
+++ b/locales/id/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/u
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Menginstal Roo Code | Mengonfigurasi Profil | Pengindeksan Basis Kode |
-| Mode Kustom | Pos Pemeriksaan | Manajemen Konteks |
+| Mode Kustom | Pos Pemeriksaan | Manajemen Konteks |
diff --git a/locales/it/README.md b/locales/it/README.md
index 9bd5ce9e81d..25dba5af2bb 100644
--- a/locales/it/README.md
+++ b/locales/it/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Installazione di Roo Code | Configurazione dei profili | Indicizzazione della codebase |
-| Modalità personalizzate | Checkpoint | Gestione del Contesto |
+| Modalità personalizzate | Checkpoint | Gestione del Contesto |
diff --git a/locales/ja/README.md b/locales/ja/README.md
index 3b7a7a6e6ef..17df3acaec1 100644
--- a/locales/ja/README.md
+++ b/locales/ja/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Roo Codeは、あなたの働き方に合わせるように適応します。
| | | |
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Roo Codeのインストール | プロファイルの設定 | コードベースのインデックス作成 |
-| カスタムモード | チェックポイント | コンテキスト管理 |
+| カスタムモード | チェックポイント | コンテキスト管理 |
diff --git a/locales/ko/README.md b/locales/ko/README.md
index a53a1b965f0..9988667459e 100644
--- a/locales/ko/README.md
+++ b/locales/ko/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다.
| | | |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Roo Code 설치하기 | 프로필 구성하기 | 코드베이스 인덱싱 |
-| 사용자 지정 모드 | 체크포인트 | 컨텍스트 관리 |
+| 사용자 지정 모드 | 체크포인트 | 컨텍스트 관리 |
diff --git a/locales/nl/README.md b/locales/nl/README.md
index fa5454b9c54..9cb15c3a8e6 100644
--- a/locales/nl/README.md
+++ b/locales/nl/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
diff --git a/locales/pl/README.md b/locales/pl/README.md
index b8553a08c74..10433f2baf3 100644
--- a/locales/pl/README.md
+++ b/locales/pl/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-mode
| | | |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Instalacja Roo Code | Konfiguracja profili | Indeksowanie bazy kodu |
-| Tryby niestandardowe | Punkty kontrolne | Zarządzanie Kontekstem |
+| Tryby niestandardowe | Punkty kontrolne | Zarządzanie Kontekstem |
diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md
index 3b128b0fd39..ebe8a33912e 100644
--- a/locales/pt-BR/README.md
+++ b/locales/pt-BR/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [
| | | |
| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Instalando o Roo Code | Configurando perfis | Indexação da base de código |
-| Modos personalizados | Checkpoints | Gerenciamento de Contexto |
+| Modos personalizados | Checkpoints | Gerenciamento de Contexto |
diff --git a/locales/ru/README.md b/locales/ru/README.md
index 9abf4ae5110..c07b696bf19 100644
--- a/locales/ru/README.md
+++ b/locales/ru/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Roo Code адаптируется к вашему стилю работы, а н
| | | |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| Установка Roo Code | Настройка профилей | Индексация кодовой базы |
-| Пользовательские режимы | Контрольные точки | Управление Контекстом |
+| Пользовательские режимы | Контрольные точки | Управление Контекстом |
diff --git a/locales/tr/README.md b/locales/tr/README.md
index ac5a7884070..b13ecd664a9 100644
--- a/locales/tr/README.md
+++ b/locales/tr/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -66,10 +66,10 @@ Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes
-| | | |
-| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
-|
Roo Code Kurulumu |
Profilleri Yapılandırma |
Kod Tabanı İndeksleme |
-|
Özel Modlar |
Kontrol Noktaları |
Bağlam Yönetimi |
+| | | |
+| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
+|
Roo Code Kurulumu |
Profilleri Yapılandırma |
Kod Tabanı İndeksleme |
+|
Özel Modlar |
Kontrol Noktaları |
Bağlam Yönetimi |
diff --git a/locales/vi/README.md b/locales/vi/README.md
index bedaa3c26ac..5ecad516fdb 100644
--- a/locales/vi/README.md
+++ b/locales/vi/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -66,10 +66,10 @@ Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-
-| | | |
-| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
-|
Cài đặt Roo Code |
Định cấu hình Hồ sơ |
Lập chỉ mục cơ sở mã |
-|
Chế độ tùy chỉnh |
Điểm kiểm tra |
Quản lý Ngữ cảnh |
+| | | |
+| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
+|
Cài đặt Roo Code |
Định cấu hình Hồ sơ |
Lập chỉ mục cơ sở mã |
+|
Chế độ tùy chỉnh |
Điểm kiểm tra |
Quản lý Ngữ cảnh |
diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md
index a21e147f963..a38cc1fd94a 100644
--- a/locales/zh-CN/README.md
+++ b/locales/zh-CN/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -66,9 +66,9 @@ Roo Code 适应您的工作方式,而不是相反:
-| | | |
-| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
-|
安装 Roo Code |
配置个人资料 |
代码库索引 |
+| | | |
+| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: |
+|
安装 Roo Code |
配置个人资料 |
代码库索引 |
|
自定义模式 |
检查点 |
上下文管理 |
diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md
index 35febb23485..b872f1316ad 100644
--- a/locales/zh-TW/README.md
+++ b/locales/zh-TW/README.md
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -69,7 +69,7 @@ Roo Code 適應您的工作方式,而不是相反:
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| 安裝 Roo Code | 設定設定檔 | 程式碼庫索引 |
-| 自訂模式 | 檢查點 | 上下文管理 |
+| 自訂模式 | 檢查點 | 上下文管理 |
diff --git a/packages/types/src/mcp.ts b/packages/types/src/mcp.ts
index 92e238efbb1..36ea76127cc 100644
--- a/packages/types/src/mcp.ts
+++ b/packages/types/src/mcp.ts
@@ -11,6 +11,13 @@ export interface McpServerUse {
uri?: string
}
+/**
+ * Mode to Profile Mapping
+ * Maps mode slugs to arrays of MCP server names
+ * Example: { "debug": ["serverA"], "research": ["serverB", "serverC"] }
+ */
+export type ModeToProfileMapping = Record
+
/**
* McpExecutionStatus
*/
diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts
index 86d8b2ddbbe..d6ce0017e45 100644
--- a/packages/types/src/vscode-extension-host.ts
+++ b/packages/types/src/vscode-extension-host.ts
@@ -34,6 +34,7 @@ export interface ExtensionMessage {
| "invoke"
| "messageUpdated"
| "mcpServers"
+ | "modeToProfileMapping"
| "enhancedPrompt"
| "commitSearchResults"
| "listApiConfig"
@@ -144,6 +145,7 @@ export interface ExtensionMessage {
}>
}>
mcpServers?: McpServer[]
+ mapping?: Record // For modeToProfileMapping
commits?: GitCommit[]
listApiConfig?: ProviderSettingsEntry[]
mode?: string
@@ -422,6 +424,10 @@ export interface WebviewMessage {
| "toggleToolEnabledForPrompt"
| "toggleMcpServer"
| "updateMcpTimeout"
+ | "getModeToProfileMapping"
+ | "updateModeToProfileMapping"
+ | "setActiveModeForMcp"
+ | "modeToProfileMapping"
| "enhancePrompt"
| "enhancedPrompt"
| "draggedImages"
@@ -577,6 +583,7 @@ export interface WebviewMessage {
list?: string[] // For dismissedUpsells response
organizationId?: string | null // For organization switching
useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow
+ mapping?: Record // For MCP mode-to-profile mapping
codeIndexSettings?: {
// Global state settings
codebaseIndexEnabled: boolean
diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts
index 96ca18c5d3f..9f7406774c8 100644
--- a/src/core/tools/__tests__/editFileTool.spec.ts
+++ b/src/core/tools/__tests__/editFileTool.spec.ts
@@ -476,7 +476,10 @@ describe("editFileTool", () => {
)
expect(mockTask.consecutiveMistakeCountForEditFile.get(testFilePath)).toBe(2)
- expect(mockTask.say).toHaveBeenCalledWith("diff_error", expect.stringContaining("Occurrence count mismatch"))
+ expect(mockTask.say).toHaveBeenCalledWith(
+ "diff_error",
+ expect.stringContaining("Occurrence count mismatch"),
+ )
})
it("resets consecutive error counter on successful edit", async () => {
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 33fa12ca78c..71ce4c20f55 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -434,6 +434,12 @@ export class ClineProvider
}
async performPreparationTasks(cline: Task) {
+ // Set the active mode for MCP filtering
+ const mcpHub = this.getMcpHub()
+ if (mcpHub && cline.taskMode) {
+ await mcpHub.setActiveMode(cline.taskMode)
+ }
+
// LMStudio: We need to force model loading in order to read its context
// size; we do it now since we're starting a task with that model selected.
if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === "lmstudio") {
@@ -1322,6 +1328,12 @@ export class ClineProvider
this.emit(RooCodeEventName.ModeChanged, newMode)
+ // Update MCP Hub with the new active mode
+ const mcpHub = this.getMcpHub()
+ if (mcpHub) {
+ await mcpHub.setActiveMode(newMode)
+ }
+
// Load the saved API config for the new mode if it exists.
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
const listApiConfig = await this.providerSettingsManager.listConfig()
diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts
index ff186892f2e..0a133bdcf92 100644
--- a/src/core/webview/__tests__/ClineProvider.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.spec.ts
@@ -470,6 +470,8 @@ describe("ClineProvider", () => {
listResources: vi.fn().mockResolvedValue([]),
readResource: vi.fn().mockResolvedValue({ contents: [] }),
getAllServers: vi.fn().mockReturnValue([]),
+ setActiveMode: vi.fn(),
+ getModeToProfileMapping: vi.fn().mockReturnValue({}),
})
})
diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts
index 3f820aace15..88d99ce3c29 100644
--- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts
@@ -271,6 +271,7 @@ describe("ClineProvider - Sticky Mode", () => {
listResources: vi.fn().mockResolvedValue([]),
readResource: vi.fn().mockResolvedValue({ contents: [] }),
getAllServers: vi.fn().mockReturnValue([]),
+ setActiveMode: vi.fn(),
})
})
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index dbeb380d162..50b1da3aad4 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -458,11 +458,15 @@ export const webviewMessageHandler = async (
getTheme().then((theme) => provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }))
// If MCP Hub is already initialized, update the webview with
- // current server list.
+ // current server list and mode-to-profile mapping.
const mcpHub = provider.getMcpHub()
if (mcpHub) {
provider.postMessageToWebview({ type: "mcpServers", mcpServers: mcpHub.getAllServers() })
+ provider.postMessageToWebview({
+ type: "modeToProfileMapping",
+ mapping: mcpHub.getModeToProfileMapping(),
+ })
}
provider.providerSettingsManager
@@ -1450,6 +1454,34 @@ export const webviewMessageHandler = async (
break
}
+ case "getModeToProfileMapping": {
+ const mcpHub = provider.getMcpHub()
+ if (mcpHub) {
+ const mapping = mcpHub.getModeToProfileMapping()
+ await provider.postMessageToWebview({
+ type: "modeToProfileMapping",
+ mapping,
+ })
+ }
+ break
+ }
+
+ case "updateModeToProfileMapping": {
+ const mcpHub = provider.getMcpHub()
+ if (mcpHub && message.mapping) {
+ await mcpHub.updateModeToProfileMapping(message.mapping)
+ }
+ break
+ }
+
+ case "setActiveModeForMcp": {
+ const mcpHub = provider.getMcpHub()
+ if (mcpHub && message.text) {
+ await mcpHub.setActiveMode(message.text)
+ }
+ break
+ }
+
case "ttsEnabled":
const ttsEnabled = message.bool ?? true
await updateGlobalState("ttsEnabled", ttsEnabled)
diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts
index 52eb4a064bd..d4473fd7014 100644
--- a/src/services/mcp/McpHub.ts
+++ b/src/services/mcp/McpHub.ts
@@ -26,6 +26,7 @@ import type {
McpServer,
McpTool,
McpToolCallResponse,
+ ModeToProfileMapping,
} from "@roo-code/types"
import { t } from "../../i18n"
@@ -145,6 +146,7 @@ export const ServerConfigSchema = createServerTypeSchema()
// Settings schema
const McpSettingsSchema = z.object({
mcpServers: z.record(ServerConfigSchema),
+ modeToProfile: z.record(z.array(z.string())).optional(),
})
export class McpHub {
@@ -161,6 +163,8 @@ export class McpHub {
private isProgrammaticUpdate: boolean = false
private flagResetTimer?: NodeJS.Timeout
private sanitizedNameRegistry: Map = new Map()
+ private modeToProfile: ModeToProfileMapping = {}
+ private activeMode: string | undefined
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
@@ -456,7 +460,15 @@ export class McpHub {
// If existing is project and current is global, keep existing (project wins)
}
- return Array.from(serversByName.values())
+ let servers = Array.from(serversByName.values())
+
+ // Filter servers based on active mode if a mode is set
+ if (this.activeMode && this.modeToProfile[this.activeMode]) {
+ const allowedServerNames = this.modeToProfile[this.activeMode]
+ servers = servers.filter((server) => allowedServerNames.includes(server.name))
+ }
+
+ return servers
}
getAllServers(): McpServer[] {
@@ -546,6 +558,11 @@ export class McpHub {
const result = McpSettingsSchema.safeParse(config)
if (result.success) {
+ // Merge modeToProfile mappings (project takes precedence over global)
+ if (result.data.modeToProfile) {
+ this.mergeModeToProfileMapping(result.data.modeToProfile, source)
+ }
+
// Pass all servers including disabled ones - they'll be handled in updateServerConnections
await this.updateServerConnections(result.data.mcpServers || {}, source, false)
} else {
@@ -598,6 +615,106 @@ export class McpHub {
await this.initializeMcpServers("project")
}
+ /**
+ * Merges mode-to-profile mapping from config into the current mapping
+ * Project mappings take precedence over global mappings for the same mode
+ * @param mapping The mode-to-profile mapping from config
+ * @param source Whether this is from global or project config
+ */
+ private mergeModeToProfileMapping(mapping: ModeToProfileMapping, source: "global" | "project"): void {
+ if (source === "global") {
+ // For global, only add modes that don't already exist (project wins)
+ for (const [mode, servers] of Object.entries(mapping)) {
+ if (!this.modeToProfile[mode]) {
+ this.modeToProfile[mode] = servers
+ }
+ }
+ } else {
+ // For project, overwrite any existing mappings
+ for (const [mode, servers] of Object.entries(mapping)) {
+ this.modeToProfile[mode] = servers
+ }
+ }
+
+ // Validate that server names in modeToProfile exist in mcpServers
+ this.validateModeToProfileMapping()
+ }
+
+ /**
+ * Validates that all server names in modeToProfile mappings actually exist
+ * Logs warnings for invalid references but doesn't fail
+ */
+ private validateModeToProfileMapping(): void {
+ const allServerNames = new Set(this.connections.map((conn) => conn.server.name))
+
+ for (const [mode, serverNames] of Object.entries(this.modeToProfile)) {
+ for (const serverName of serverNames) {
+ if (!allServerNames.has(serverName)) {
+ console.warn(
+ `Mode "${mode}" references non-existent server "${serverName}" in modeToProfile mapping`,
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets the currently active mode for server filtering
+ * @param modeSlug The mode slug to set as active, or undefined to clear
+ */
+ public setActiveMode(modeSlug: string | undefined): void {
+ this.activeMode = modeSlug
+ }
+
+ /**
+ * Gets the current mode-to-profile mapping
+ * @returns The current mode-to-profile mapping
+ */
+ public getModeToProfileMapping(): ModeToProfileMapping {
+ return { ...this.modeToProfile }
+ }
+
+ /**
+ * Updates the mode-to-profile mapping in the config file
+ * Updates both global and project configs if project exists, otherwise only global
+ * @param mapping The new mode-to-profile mapping
+ */
+ public async updateModeToProfileMapping(mapping: ModeToProfileMapping): Promise {
+ try {
+ // Determine which config file(s) to update
+ const projectMcpPath = await this.getProjectMcpPath()
+ const configPath = projectMcpPath || (await this.getMcpSettingsFilePath())
+
+ // Read current config
+ const content = await fs.readFile(configPath, "utf-8")
+ const config = JSON.parse(content)
+
+ // Update modeToProfile in config
+ config.modeToProfile = mapping
+
+ // Set flag to prevent file watcher from triggering server restart
+ if (this.flagResetTimer) {
+ clearTimeout(this.flagResetTimer)
+ }
+ this.isProgrammaticUpdate = true
+
+ try {
+ await safeWriteJson(configPath, config)
+ // Update in-memory mapping
+ this.modeToProfile = { ...mapping }
+ } finally {
+ // Reset flag after watcher debounce period (non-blocking)
+ this.flagResetTimer = setTimeout(() => {
+ this.isProgrammaticUpdate = false
+ this.flagResetTimer = undefined
+ }, 600)
+ }
+ } catch (error) {
+ this.showErrorMessage("Failed to update mode-to-profile mapping", error)
+ throw error
+ }
+ }
+
/**
* Creates a placeholder connection for disabled servers or when MCP is globally disabled
* @param name The server name
diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts
index 2d895fdbca5..4098f5c75da 100644
--- a/src/services/mcp/__tests__/McpHub.spec.ts
+++ b/src/services/mcp/__tests__/McpHub.spec.ts
@@ -338,270 +338,354 @@ describe("McpHub", () => {
// Replace connections with disconnected one
mcpHub.connections = [disconnectedConnection]
- // Call tool should fail with disconnected server
- await expect(mcpHub.callTool("disabled-server", "test-tool", {})).rejects.toThrow(
+ // Should throw error when trying to call tool on disconnected server
+ await expect(mcpHub.callTool("disabled-server", "some-tool", {})).rejects.toThrow(
"No connection found for server: disabled-server",
)
})
})
- describe("File watcher cleanup", () => {
- it("should clean up file watchers when server is disabled", async () => {
- // Get the mocked chokidar
- const chokidar = (await import("chokidar")).default
- const mockWatcher = {
- on: vi.fn().mockReturnThis(),
- close: vi.fn(),
- }
- vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as any)
-
- // Mock StdioClientTransport
- const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
- const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
-
- const mockTransport = {
- start: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- stderr: {
- on: vi.fn(),
+ describe("toggleToolEnabledForPrompt", () => {
+ it("should add tool to disabledTools list when enabling", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ disabledTools: [],
+ },
},
- onerror: null,
- onclose: null,
}
- StdioClientTransport.mockImplementation(() => mockTransport)
-
- // Mock Client
- const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
- const Client = clientModule.Client as ReturnType
-
- const mockClient = {
- connect: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- getInstructions: vi.fn().mockReturnValue("test instructions"),
- request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ config: "test-server-config",
+ status: "connected",
+ source: "global",
+ },
+ client: {} as any,
+ transport: {} as any,
}
+ mcpHub.connections = [mockConnection]
- Client.mockImplementation(() => mockClient)
-
- // Create server with watchPaths
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "watcher-test-server": {
- command: "node",
- args: ["test.js"],
- watchPaths: ["/path/to/watch"],
- },
- },
- }),
- )
+ // Mock reading initial config
+ ;(fs.readFile as Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
+ await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "new-tool", false)
- // Verify watcher was created
- expect(chokidar.watch).toHaveBeenCalledWith(["/path/to/watch"], expect.any(Object))
+ // Verify the config was updated correctly
+ const writeCalls = (fs.writeFile as Mock).mock.calls
+ expect(writeCalls.length).toBeGreaterThan(0)
- // Now disable the server
- await mcpHub.toggleServerDisabled("watcher-test-server", true)
+ // Find the write call
+ const callToUse = writeCalls[writeCalls.length - 1]
+ expect(callToUse).toBeTruthy()
- // Verify watcher was closed
- expect(mockWatcher.close).toHaveBeenCalled()
+ // The path might be normalized differently on different platforms,
+ // so we'll just check that we have a call with valid content
+ const writtenConfig = JSON.parse(callToUse[1])
+ expect(writtenConfig.mcpServers).toBeDefined()
+ expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
+ expect(Array.isArray(writtenConfig.mcpServers["test-server"].enabledForPrompt)).toBe(false)
+ expect(writtenConfig.mcpServers["test-server"].disabledTools).toContain("new-tool")
})
- it("should clean up all file watchers when server is deleted", async () => {
- // Get the mocked chokidar
- const chokidar = (await import("chokidar")).default
- const mockWatcher1 = {
- on: vi.fn().mockReturnThis(),
- close: vi.fn(),
- }
- const mockWatcher2 = {
- on: vi.fn().mockReturnThis(),
- close: vi.fn(),
+ it("should remove tool from disabledTools list when disabling", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ disabledTools: ["existing-tool"],
+ },
+ },
}
- // Return different watchers for different paths
- let watcherIndex = 0
- vi.mocked(chokidar.watch).mockImplementation(() => {
- return (watcherIndex++ === 0 ? mockWatcher1 : mockWatcher2) as any
- })
-
- // Mock StdioClientTransport
- const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
- const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
-
- const mockTransport = {
- start: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- stderr: {
- on: vi.fn(),
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ config: "test-server-config",
+ status: "connected",
+ source: "global",
},
- onerror: null,
- onclose: null,
+ client: {} as any,
+ transport: {} as any,
}
+ mcpHub.connections = [mockConnection]
- StdioClientTransport.mockImplementation(() => mockTransport)
-
- // Mock Client
- const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
- const Client = clientModule.Client as ReturnType
-
- const mockClient = {
- connect: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- getInstructions: vi.fn().mockReturnValue("test instructions"),
- request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }
+ // Mock reading initial config
+ ;(fs.readFile as Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
- Client.mockImplementation(() => mockClient)
+ await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "existing-tool", true)
- // Create server with multiple watchPaths
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "multi-watcher-server": {
- command: "node",
- args: ["test.js", "build/index.js"], // This will create a watcher for build/index.js
- watchPaths: ["/path/to/watch1", "/path/to/watch2"],
- },
- },
- }),
- )
+ // Verify the config was updated correctly
+ const writeCalls = (fs.writeFile as Mock).mock.calls
+ expect(writeCalls.length).toBeGreaterThan(0)
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
+ // Find the write call
+ const callToUse = writeCalls[writeCalls.length - 1]
+ expect(callToUse).toBeTruthy()
- // Verify watchers were created
- expect(chokidar.watch).toHaveBeenCalled()
+ // The path might be normalized differently on different platforms,
+ // so we'll just check that we have a call with valid content
+ const writtenConfig = JSON.parse(callToUse[1])
+ expect(writtenConfig.mcpServers).toBeDefined()
+ expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
+ expect(Array.isArray(writtenConfig.mcpServers["test-server"].enabledForPrompt)).toBe(false)
+ expect(writtenConfig.mcpServers["test-server"].disabledTools).not.toContain("existing-tool")
+ })
- // Delete the connection (this should clean up all watchers)
- await mcpHub.deleteConnection("multi-watcher-server")
+ it("should initialize disabledTools if it does not exist", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ },
+ },
+ }
- // Verify all watchers were closed
- expect(mockWatcher1.close).toHaveBeenCalled()
- expect(mockWatcher2.close).toHaveBeenCalled()
- })
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ config: "test-server-config",
+ status: "connected",
+ source: "global",
+ },
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
- it("should not create file watchers for disabled servers on initialization", async () => {
- // Get the mocked chokidar
- const chokidar = (await import("chokidar")).default
+ // Mock reading initial config
+ ;(fs.readFile as Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
- // Create disabled server with watchPaths
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "disabled-watcher-server": {
- command: "node",
- args: ["test.js"],
- watchPaths: ["/path/to/watch"],
- disabled: true,
- },
- },
- }),
- )
+ // Call with false because of "true" is default value
+ await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "new-tool", false)
- vi.mocked(chokidar.watch).mockClear()
+ // Verify the config was updated with initialized disabledTools
+ // Find the write call with the normalized path
+ const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
+ const writeCalls = (fs.writeFile as Mock).mock.calls
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
+ // Find the write call with the normalized path
+ const writeCall = writeCalls.find((call) => call[0] === normalizedSettingsPath)
+ const callToUse = writeCall || writeCalls[0]
- // Verify no watcher was created for disabled server
- expect(chokidar.watch).not.toHaveBeenCalled()
+ const writtenConfig = JSON.parse(callToUse[1])
+ expect(writtenConfig.mcpServers["test-server"].disabledTools).toBeDefined()
+ expect(writtenConfig.mcpServers["test-server"].disabledTools).toContain("new-tool")
})
})
- describe("DisableReason enum usage", () => {
- it("should use MCP_DISABLED reason when MCP is globally disabled", async () => {
- // Mock provider with mcpEnabled: false
- mockProvider.getState = vi.fn().mockResolvedValue({ mcpEnabled: false })
-
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "mcp-disabled-server": {
- command: "node",
- args: ["test.js"],
- },
+ describe("server disabled state", () => {
+ it("should toggle server disabled state", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ disabled: false,
},
- }),
- )
+ },
+ }
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
+ // Mock reading initial config
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
- // Find the connection
- const connection = mcpHub.connections.find((conn) => conn.server.name === "mcp-disabled-server")
- expect(connection).toBeDefined()
- expect(connection?.type).toBe("disconnected")
- expect(connection?.server.status).toBe("disconnected")
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ disabled: false,
+ source: "global",
+ } as any,
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
+
+ await mcpHub.toggleServerDisabled("test-server", true)
+
+ // Verify the config was updated correctly
+ // Find the write call with the normalized path
+ const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
+ const writeCalls = vi.mocked(fs.writeFile).mock.calls
+
+ // Find the write call with the normalized path
+ const writeCall = writeCalls.find((call: any) => call[0] === normalizedSettingsPath)
+ const callToUse = writeCall || writeCalls[0]
- // The server should not be marked as disabled individually
- expect(connection?.server.disabled).toBeUndefined()
+ const writtenConfig = JSON.parse(callToUse[1] as string)
+ expect(writtenConfig.mcpServers["test-server"].disabled).toBe(true)
})
- it("should use SERVER_DISABLED reason when server is individually disabled", async () => {
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "server-disabled-server": {
- command: "node",
- args: ["test.js"],
- disabled: true,
- },
+ it("should filter out disabled servers from getServers", () => {
+ const mockConnections: McpConnection[] = [
+ {
+ type: "connected",
+ server: {
+ name: "enabled-server",
+ config: "{}",
+ status: "connected",
+ disabled: false,
},
- }),
- )
+ client: {} as any,
+ transport: {} as any,
+ } as ConnectedMcpConnection,
+ {
+ type: "disconnected",
+ server: {
+ name: "disabled-server",
+ config: "{}",
+ status: "disconnected",
+ disabled: true,
+ },
+ client: null,
+ transport: null,
+ } as DisconnectedMcpConnection,
+ ]
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
+ mcpHub.connections = mockConnections
+ const servers = mcpHub.getServers()
- // Find the connection
- const connection = mcpHub.connections.find((conn) => conn.server.name === "server-disabled-server")
- expect(connection).toBeDefined()
- expect(connection?.type).toBe("disconnected")
- expect(connection?.server.status).toBe("disconnected")
- expect(connection?.server.disabled).toBe(true)
+ expect(servers.length).toBe(1)
+ expect(servers[0].name).toBe("enabled-server")
})
- it("should handle both disable reasons correctly", async () => {
- // First test with MCP globally disabled
- mockProvider.getState = vi.fn().mockResolvedValue({ mcpEnabled: false })
+ it("should deduplicate servers by name with project servers taking priority", () => {
+ const mockConnections: McpConnection[] = [
+ {
+ type: "connected",
+ server: {
+ name: "shared-server",
+ config: '{"source":"global"}',
+ status: "connected",
+ disabled: false,
+ source: "global",
+ },
+ client: {} as any,
+ transport: {} as any,
+ } as ConnectedMcpConnection,
+ {
+ type: "connected",
+ server: {
+ name: "shared-server",
+ config: '{"source":"project"}',
+ status: "connected",
+ disabled: false,
+ source: "project",
+ },
+ client: {} as any,
+ transport: {} as any,
+ } as ConnectedMcpConnection,
+ {
+ type: "connected",
+ server: {
+ name: "unique-global-server",
+ config: "{}",
+ status: "connected",
+ disabled: false,
+ source: "global",
+ },
+ client: {} as any,
+ transport: {} as any,
+ } as ConnectedMcpConnection,
+ ]
+
+ mcpHub.connections = mockConnections
+ const servers = mcpHub.getServers()
+
+ // Should have 2 servers: deduplicated "shared-server" + "unique-global-server"
+ expect(servers.length).toBe(2)
+
+ // Find the shared-server - it should be the project version
+ const sharedServer = servers.find((s) => s.name === "shared-server")
+ expect(sharedServer).toBeDefined()
+ expect(sharedServer!.source).toBe("project")
+ expect(sharedServer!.config).toBe('{"source":"project"}')
+
+ // The unique global server should also be present
+ const uniqueServer = servers.find((s) => s.name === "unique-global-server")
+ expect(uniqueServer).toBeDefined()
+ })
+
+ it("should keep global server when no project server with same name exists", () => {
+ const mockConnections: McpConnection[] = [
+ {
+ type: "connected",
+ server: {
+ name: "global-only-server",
+ config: "{}",
+ status: "connected",
+ disabled: false,
+ source: "global",
+ },
+ client: {} as any,
+ transport: {} as any,
+ } as ConnectedMcpConnection,
+ ]
+
+ mcpHub.connections = mockConnections
+ const servers = mcpHub.getServers()
+
+ expect(servers.length).toBe(1)
+ expect(servers[0].name).toBe("global-only-server")
+ expect(servers[0].source).toBe("global")
+ })
+ it("should prevent calling tools on disabled servers", async () => {
+ // Mock fs.readFile to return a disabled server config
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "both-reasons-server": {
+ "disabled-server": {
command: "node",
args: ["test.js"],
- disabled: true, // Server is also individually disabled
+ disabled: true,
},
},
}),
)
const mcpHub = new McpHub(mockProvider as ClineProvider)
+
+ // Wait for initialization
await new Promise((resolve) => setTimeout(resolve, 100))
- // Find the connection
- const connection = mcpHub.connections.find((conn) => conn.server.name === "both-reasons-server")
+ // The server should be created as a disconnected connection
+ const connection = mcpHub.connections.find((conn) => conn.server.name === "disabled-server")
expect(connection).toBeDefined()
expect(connection?.type).toBe("disconnected")
-
- // When MCP is globally disabled, it takes precedence
- // The server's individual disabled state should be preserved
expect(connection?.server.disabled).toBe(true)
+
+ // Try to call tool on disabled server
+ await expect(mcpHub.callTool("disabled-server", "some-tool", {})).rejects.toThrow(
+ "No connection found for server: disabled-server",
+ )
})
- })
- describe("Null safety improvements", () => {
- it("should handle null client safely in disconnected connections", async () => {
+ it("should prevent reading resources from disabled servers", async () => {
// Mock fs.readFile to return a disabled server config
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "null-safety-server": {
+ "disabled-server": {
command: "node",
args: ["test.js"],
disabled: true,
@@ -615,992 +699,978 @@ describe("McpHub", () => {
// Wait for initialization
await new Promise((resolve) => setTimeout(resolve, 100))
- // The server should be created as a disconnected connection with null client/transport
- const connection = mcpHub.connections.find((conn) => conn.server.name === "null-safety-server")
+ // The server should be created as a disconnected connection
+ const connection = mcpHub.connections.find((conn) => conn.server.name === "disabled-server")
expect(connection).toBeDefined()
expect(connection?.type).toBe("disconnected")
+ expect(connection?.server.disabled).toBe(true)
- // Type guard to ensure it's a disconnected connection
- if (connection?.type === "disconnected") {
- expect(connection.client).toBeNull()
- expect(connection.transport).toBeNull()
- }
-
- // Try to call tool on disconnected server
- await expect(mcpHub.callTool("null-safety-server", "test-tool", {})).rejects.toThrow(
- "No connection found for server: null-safety-server",
- )
-
- // Try to read resource on disconnected server
- await expect(mcpHub.readResource("null-safety-server", "test-uri")).rejects.toThrow(
- "No connection found for server: null-safety-server",
+ // Try to read resource from disabled server
+ await expect(mcpHub.readResource("disabled-server", "some/uri")).rejects.toThrow(
+ "No connection found for server: disabled-server",
)
})
+ })
- it("should handle connection type checks safely", async () => {
- // Mock StdioClientTransport
- const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
- const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
-
- const mockTransport = {
- start: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- stderr: {
- on: vi.fn(),
+ describe("callTool", () => {
+ it("should execute tool successfully", async () => {
+ // Mock the connection with a minimal client implementation
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ config: JSON.stringify({}),
+ status: "connected" as const,
},
- onerror: null,
- onclose: null,
+ client: {
+ request: vi.fn().mockResolvedValue({ result: "success" }),
+ } as any,
+ transport: {
+ start: vi.fn(),
+ close: vi.fn(),
+ stderr: { on: vi.fn() },
+ } as any,
}
- StdioClientTransport.mockImplementation(() => mockTransport)
+ mcpHub.connections = [mockConnection]
- // Mock Client
- const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
- const Client = clientModule.Client as ReturnType
+ await mcpHub.callTool("test-server", "some-tool", {})
- const mockClient = {
- connect: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- getInstructions: vi.fn().mockReturnValue("test instructions"),
- request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }
-
- Client.mockImplementation(() => mockClient)
-
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "type-check-server": {
- command: "node",
- args: ["test.js"],
- },
+ // Verify the request was made with correct parameters
+ expect(mockConnection.client!.request).toHaveBeenCalledWith(
+ {
+ method: "tools/call",
+ params: {
+ name: "some-tool",
+ arguments: {},
},
- }),
+ },
+ expect.any(Object),
+ expect.objectContaining({ timeout: 60000 }), // Default 60 second timeout
)
-
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
-
- // Get the connection
- const connection = mcpHub.connections.find((conn) => conn.server.name === "type-check-server")
- expect(connection).toBeDefined()
-
- // Safe type checking
- if (connection?.type === "connected") {
- expect(connection.client).toBeDefined()
- expect(connection.transport).toBeDefined()
- } else if (connection?.type === "disconnected") {
- expect(connection.client).toBeNull()
- expect(connection.transport).toBeNull()
- }
})
- it("should handle missing connections safely", async () => {
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
-
- // Try operations on non-existent server
- await expect(mcpHub.callTool("non-existent-server", "test-tool", {})).rejects.toThrow(
- "No connection found for server: non-existent-server",
- )
-
- await expect(mcpHub.readResource("non-existent-server", "test-uri")).rejects.toThrow(
+ it("should throw error if server not found", async () => {
+ await expect(mcpHub.callTool("non-existent-server", "some-tool", {})).rejects.toThrow(
"No connection found for server: non-existent-server",
)
})
- it("should handle connection deletion safely", async () => {
- // Mock StdioClientTransport
- const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
- const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
+ describe("timeout configuration", () => {
+ it("should validate timeout values", () => {
+ // Test valid timeout values
+ const validConfig = {
+ type: "stdio",
+ command: "test",
+ timeout: 60,
+ }
+ expect(() => ServerConfigSchema.parse(validConfig)).not.toThrow()
- const mockTransport = {
- start: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- stderr: {
- on: vi.fn(),
- },
- onerror: null,
- onclose: null,
- }
+ // Test invalid timeout values
+ const invalidConfigs = [
+ { type: "stdio", command: "test", timeout: 0 }, // Too low
+ { type: "stdio", command: "test", timeout: 3601 }, // Too high
+ { type: "stdio", command: "test", timeout: -1 }, // Negative
+ ]
- StdioClientTransport.mockImplementation(() => mockTransport)
+ invalidConfigs.forEach((config) => {
+ expect(() => ServerConfigSchema.parse(config)).toThrow()
+ })
+ })
- // Mock Client
- const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
- const Client = clientModule.Client as ReturnType
+ it("should use default timeout of 60 seconds if not specified", async () => {
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ config: JSON.stringify({ type: "stdio", command: "test" }), // No timeout specified
+ status: "connected",
+ },
+ client: {
+ request: vi.fn().mockResolvedValue({ content: [] }),
+ } as any,
+ transport: {} as any,
+ }
- const mockClient = {
- connect: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- getInstructions: vi.fn().mockReturnValue("test instructions"),
- request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }
+ mcpHub.connections = [mockConnection]
+ await mcpHub.callTool("test-server", "test-tool")
- Client.mockImplementation(() => mockClient)
+ expect(mockConnection.client!.request).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({ timeout: 60000 }), // 60 seconds in milliseconds
+ )
+ })
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
+ it("should apply configured timeout to tool calls", async () => {
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ config: JSON.stringify({ type: "stdio", command: "test", timeout: 120 }), // 2 minutes
+ status: "connected",
+ },
+ client: {
+ request: vi.fn().mockResolvedValue({ content: [] }),
+ } as any,
+ transport: {} as any,
+ }
+
+ mcpHub.connections = [mockConnection]
+ await mcpHub.callTool("test-server", "test-tool")
+
+ expect(mockConnection.client!.request).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({ timeout: 120000 }), // 120 seconds in milliseconds
+ )
+ })
+ })
+
+ describe("updateServerTimeout", () => {
+ it("should update server timeout in settings file", async () => {
+ const mockConfig = {
mcpServers: {
- "delete-safety-server": {
+ "test-server": {
+ type: "stdio",
command: "node",
args: ["test.js"],
+ timeout: 60,
},
},
- }),
- )
-
- const mcpHub = new McpHub(mockProvider as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
-
- // Delete the connection
- await mcpHub.deleteConnection("delete-safety-server")
-
- // Verify connection is removed
- const connection = mcpHub.connections.find((conn) => conn.server.name === "delete-safety-server")
- expect(connection).toBeUndefined()
+ }
- // Verify transport and client were closed
- expect(mockTransport.close).toHaveBeenCalled()
- expect(mockClient.close).toHaveBeenCalled()
- })
- })
+ // Mock reading initial config
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
- describe("toggleToolAlwaysAllow", () => {
- it("should add tool to always allow list when enabling", async () => {
- const mockConfig = {
- mcpServers: {
- "test-server": {
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
type: "stdio",
command: "node",
args: ["test.js"],
- alwaysAllow: [],
- },
- },
- }
+ timeout: 60,
+ source: "global",
+ } as any,
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
- // Mock reading initial config
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ await mcpHub.updateServerTimeout("test-server", 120)
- // Set up mock connection without alwaysAllow
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- source: "global",
- } as any,
- client: {} as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnection]
+ // Verify the config was updated correctly
+ // Find the write call with the normalized path
+ const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
+ const writeCalls = vi.mocked(fs.writeFile).mock.calls
- await mcpHub.toggleToolAlwaysAllow("test-server", "global", "new-tool", true)
+ // Find the write call with the normalized path
+ const writeCall = writeCalls.find((call: any) => call[0] === normalizedSettingsPath)
+ const callToUse = writeCall || writeCalls[0]
- // Verify the config was updated correctly
- const writeCalls = vi.mocked(fs.writeFile).mock.calls
- expect(writeCalls.length).toBeGreaterThan(0)
+ const writtenConfig = JSON.parse(callToUse[1] as string)
+ expect(writtenConfig.mcpServers["test-server"].timeout).toBe(120)
+ })
- // Find the write call
- const callToUse = writeCalls[writeCalls.length - 1]
- expect(callToUse).toBeTruthy()
+ it("should fallback to default timeout when config has invalid timeout", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ timeout: 60,
+ },
+ },
+ }
- // The path might be normalized differently on different platforms,
- // so we'll just check that we have a call with valid content
- const writtenConfig = JSON.parse(callToUse[1] as string)
- expect(writtenConfig.mcpServers).toBeDefined()
- expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
- expect(Array.isArray(writtenConfig.mcpServers["test-server"].alwaysAllow)).toBe(true)
- expect(writtenConfig.mcpServers["test-server"].alwaysAllow).toContain("new-tool")
- })
+ // Mock initial read
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
- it("should remove tool from always allow list when disabling", async () => {
- const mockConfig = {
- mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- alwaysAllow: ["existing-tool"],
- },
- },
- }
+ // Set up mock connection before updating
+ const mockConnectionInitial: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ timeout: 60,
+ source: "global",
+ } as any,
+ client: {
+ request: vi.fn().mockResolvedValue({ content: [] }),
+ } as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnectionInitial]
- // Mock reading initial config
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ // Update with invalid timeout
+ await mcpHub.updateServerTimeout("test-server", 3601)
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- alwaysAllow: ["existing-tool"],
- source: "global",
- } as any,
- client: {} as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnection]
+ // Config is written
+ expect(fs.writeFile).toHaveBeenCalled()
- await mcpHub.toggleToolAlwaysAllow("test-server", "global", "existing-tool", false)
+ // Setup connection with invalid timeout
+ const mockConnectionInvalid: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ config: JSON.stringify({
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ timeout: 3601, // Invalid timeout
+ }),
+ status: "connected",
+ },
+ client: {
+ request: vi.fn().mockResolvedValue({ content: [] }),
+ } as any,
+ transport: {} as any,
+ }
- // Verify the config was updated correctly
- const writeCalls = vi.mocked(fs.writeFile).mock.calls
- expect(writeCalls.length).toBeGreaterThan(0)
+ mcpHub.connections = [mockConnectionInvalid]
- // Find the write call
- const callToUse = writeCalls[writeCalls.length - 1]
- expect(callToUse).toBeTruthy()
+ // Call tool - should use default timeout
+ await mcpHub.callTool("test-server", "test-tool")
- // The path might be normalized differently on different platforms,
- // so we'll just check that we have a call with valid content
- const writtenConfig = JSON.parse(callToUse[1] as string)
- expect(writtenConfig.mcpServers).toBeDefined()
- expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
- expect(Array.isArray(writtenConfig.mcpServers["test-server"].alwaysAllow)).toBe(true)
- expect(writtenConfig.mcpServers["test-server"].alwaysAllow).not.toContain("existing-tool")
- })
+ // Verify default timeout was used
+ expect(mockConnectionInvalid.client!.request).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({ timeout: 60000 }), // Default 60 seconds
+ )
+ })
- it("should initialize alwaysAllow if it does not exist", async () => {
- const mockConfig = {
- mcpServers: {
- "test-server": {
+ it("should accept valid timeout values", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ timeout: 60,
+ },
+ },
+ }
+
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
type: "stdio",
command: "node",
args: ["test.js"],
- },
- },
- }
+ timeout: 60,
+ source: "global",
+ } as any,
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
- // Mock reading initial config
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ // Test valid timeout values
+ const validTimeouts = [1, 60, 3600]
+ for (const timeout of validTimeouts) {
+ await mcpHub.updateServerTimeout("test-server", timeout)
+ expect(fs.writeFile).toHaveBeenCalled()
+ vi.clearAllMocks() // Reset for next iteration
+ ;(fs.readFile as any).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ }
+ })
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- alwaysAllow: [],
- source: "global",
- } as any,
- client: {} as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnection]
+ it("should notify webview after updating timeout", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ timeout: 60,
+ },
+ },
+ }
- await mcpHub.toggleToolAlwaysAllow("test-server", "global", "new-tool", true)
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
- // Verify the config was updated with initialized alwaysAllow
- // Find the write call with the normalized path
- const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
- const writeCalls = vi.mocked(fs.writeFile).mock.calls
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ timeout: 60,
+ source: "global",
+ } as any,
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
- // Find the write call with the normalized path
- const writeCall = writeCalls.find((call: any) => call[0] === normalizedSettingsPath)
- const callToUse = writeCall || writeCalls[0]
+ await mcpHub.updateServerTimeout("test-server", 120)
- const writtenConfig = JSON.parse(callToUse[1] as string)
- expect(writtenConfig.mcpServers["test-server"].alwaysAllow).toBeDefined()
- expect(writtenConfig.mcpServers["test-server"].alwaysAllow).toContain("new-tool")
+ expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "mcpServers",
+ }),
+ )
+ })
})
})
- describe("toggleToolEnabledForPrompt", () => {
- it("should add tool to disabledTools list when enabling", async () => {
- const mockConfig = {
- mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- disabledTools: [],
- },
- },
- }
+ describe("MCP global enable/disable", () => {
+ beforeEach(() => {
+ // Clear all mocks before each test
+ vi.clearAllMocks()
+ })
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- config: "test-server-config",
- status: "connected",
- source: "global",
+ it("should disconnect all servers when MCP is toggled from enabled to disabled", async () => {
+ // Mock StdioClientTransport
+ const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
+ const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
+
+ const mockTransport = {
+ start: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: {
+ on: vi.fn(),
},
- client: {} as any,
- transport: {} as any,
+ onerror: null,
+ onclose: null,
}
- mcpHub.connections = [mockConnection]
- // Mock reading initial config
- ;(fs.readFile as Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ StdioClientTransport.mockImplementation(() => mockTransport)
- await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "new-tool", false)
+ // Mock Client
+ const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
+ const Client = clientModule.Client as ReturnType
- // Verify the config was updated correctly
- const writeCalls = (fs.writeFile as Mock).mock.calls
- expect(writeCalls.length).toBeGreaterThan(0)
+ const mockClient = {
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInstructions: vi.fn().mockReturnValue("test instructions"),
+ request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ }
- // Find the write call
- const callToUse = writeCalls[writeCalls.length - 1]
- expect(callToUse).toBeTruthy()
+ Client.mockImplementation(() => mockClient)
- // The path might be normalized differently on different platforms,
- // so we'll just check that we have a call with valid content
- const writtenConfig = JSON.parse(callToUse[1])
- expect(writtenConfig.mcpServers).toBeDefined()
- expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
- expect(Array.isArray(writtenConfig.mcpServers["test-server"].enabledForPrompt)).toBe(false)
- expect(writtenConfig.mcpServers["test-server"].disabledTools).toContain("new-tool")
- })
+ // Start with MCP enabled
+ mockProvider.getState = vi.fn().mockResolvedValue({ mcpEnabled: true })
- it("should remove tool from disabledTools list when disabling", async () => {
- const mockConfig = {
- mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- disabledTools: ["existing-tool"],
+ // Mock the config file read
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
+ mcpServers: {
+ "toggle-test-server": {
+ command: "node",
+ args: ["test.js"],
+ },
},
- },
- }
+ }),
+ )
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- config: "test-server-config",
- status: "connected",
- source: "global",
- },
- client: {} as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnection]
+ // Create McpHub and let it initialize with MCP enabled
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+ await new Promise((resolve) => setTimeout(resolve, 100))
- // Mock reading initial config
- ;(fs.readFile as Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ // Verify server is connected
+ const connectedServer = mcpHub.connections.find((conn) => conn.server.name === "toggle-test-server")
+ expect(connectedServer).toBeDefined()
+ expect(connectedServer!.server.status).toBe("connected")
+ expect(connectedServer!.client).toBeDefined()
+ expect(connectedServer!.transport).toBeDefined()
- await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "existing-tool", true)
+ // Now simulate toggling MCP to disabled
+ mockProvider.getState = vi.fn().mockResolvedValue({ mcpEnabled: false })
- // Verify the config was updated correctly
- const writeCalls = (fs.writeFile as Mock).mock.calls
- expect(writeCalls.length).toBeGreaterThan(0)
+ // Manually trigger what would happen when MCP is disabled
+ // (normally this would be triggered by the webview message handler)
+ const existingConnections = [...mcpHub.connections]
+ for (const conn of existingConnections) {
+ await mcpHub.deleteConnection(conn.server.name, conn.server.source)
+ }
+ await mcpHub.refreshAllConnections()
- // Find the write call
- const callToUse = writeCalls[writeCalls.length - 1]
- expect(callToUse).toBeTruthy()
+ // Verify server is now tracked but disconnected
+ const disconnectedServer = mcpHub.connections.find((conn) => conn.server.name === "toggle-test-server")
+ expect(disconnectedServer).toBeDefined()
+ expect(disconnectedServer!.server.status).toBe("disconnected")
+ expect(disconnectedServer!.client).toBeNull()
+ expect(disconnectedServer!.transport).toBeNull()
- // The path might be normalized differently on different platforms,
- // so we'll just check that we have a call with valid content
- const writtenConfig = JSON.parse(callToUse[1])
- expect(writtenConfig.mcpServers).toBeDefined()
- expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
- expect(Array.isArray(writtenConfig.mcpServers["test-server"].enabledForPrompt)).toBe(false)
- expect(writtenConfig.mcpServers["test-server"].disabledTools).not.toContain("existing-tool")
+ // Verify close was called on the original client and transport
+ expect(mockClient.close).toHaveBeenCalled()
+ expect(mockTransport.close).toHaveBeenCalled()
})
- it("should initialize disabledTools if it does not exist", async () => {
- const mockConfig = {
- mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- },
- },
- }
-
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- config: "test-server-config",
- status: "connected",
- source: "global",
- },
- client: {} as any,
- transport: {} as any,
+ it("should not connect to servers when MCP is globally disabled", async () => {
+ // Mock provider with mcpEnabled: false
+ const disabledMockProvider = {
+ ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ postMessageToWebview: vi.fn(),
+ getState: vi.fn().mockResolvedValue({ mcpEnabled: false }),
+ context: mockProvider.context,
}
- mcpHub.connections = [mockConnection]
- // Mock reading initial config
- ;(fs.readFile as Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ // Mock the config file read with a different server name to avoid conflicts
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
+ mcpServers: {
+ "disabled-test-server": {
+ command: "node",
+ args: ["test.js"],
+ },
+ },
+ }),
+ )
- // Call with false because of "true" is default value
- await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "new-tool", false)
+ // Create a new McpHub instance with disabled MCP
+ const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider)
- // Verify the config was updated with initialized disabledTools
- // Find the write call with the normalized path
- const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
- const writeCalls = (fs.writeFile as Mock).mock.calls
+ // Wait for initialization
+ await new Promise((resolve) => setTimeout(resolve, 100))
- // Find the write call with the normalized path
- const writeCall = writeCalls.find((call) => call[0] === normalizedSettingsPath)
- const callToUse = writeCall || writeCalls[0]
+ // Find the disabled-test-server
+ const disabledServer = mcpHub.connections.find((conn) => conn.server.name === "disabled-test-server")
- const writtenConfig = JSON.parse(callToUse[1])
- expect(writtenConfig.mcpServers["test-server"].disabledTools).toBeDefined()
- expect(writtenConfig.mcpServers["test-server"].disabledTools).toContain("new-tool")
+ // Verify that the server is tracked but not connected
+ expect(disabledServer).toBeDefined()
+ expect(disabledServer!.server.status).toBe("disconnected")
+ expect(disabledServer!.client).toBeNull()
+ expect(disabledServer!.transport).toBeNull()
})
- })
- describe("server disabled state", () => {
- it("should toggle server disabled state", async () => {
- const mockConfig = {
- mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- disabled: false,
- },
- },
- }
+ it("should connect to servers when MCP is globally enabled", async () => {
+ // Clear all mocks
+ vi.clearAllMocks()
- // Mock reading initial config
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ // Mock StdioClientTransport
+ const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
+ const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- disabled: false,
- source: "global",
- } as any,
- client: {} as any,
- transport: {} as any,
+ const mockTransport = {
+ start: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: {
+ on: vi.fn(),
+ },
+ onerror: null,
+ onclose: null,
}
- mcpHub.connections = [mockConnection]
- await mcpHub.toggleServerDisabled("test-server", true)
+ StdioClientTransport.mockImplementation(() => mockTransport)
- // Verify the config was updated correctly
- // Find the write call with the normalized path
- const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
- const writeCalls = vi.mocked(fs.writeFile).mock.calls
+ // Mock Client
+ const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
+ const Client = clientModule.Client as ReturnType
- // Find the write call with the normalized path
- const writeCall = writeCalls.find((call: any) => call[0] === normalizedSettingsPath)
- const callToUse = writeCall || writeCalls[0]
+ Client.mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInstructions: vi.fn().mockReturnValue("test instructions"),
+ request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ }))
- const writtenConfig = JSON.parse(callToUse[1] as string)
- expect(writtenConfig.mcpServers["test-server"].disabled).toBe(true)
- })
+ // Mock provider with mcpEnabled: true
+ const enabledMockProvider = {
+ ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ postMessageToWebview: vi.fn(),
+ getState: vi.fn().mockResolvedValue({ mcpEnabled: true }),
+ context: mockProvider.context,
+ }
- it("should filter out disabled servers from getServers", () => {
- const mockConnections: McpConnection[] = [
- {
- type: "connected",
- server: {
- name: "enabled-server",
- config: "{}",
- status: "connected",
- disabled: false,
- },
- client: {} as any,
- transport: {} as any,
- } as ConnectedMcpConnection,
- {
- type: "disconnected",
- server: {
- name: "disabled-server",
- config: "{}",
- status: "disconnected",
- disabled: true,
+ // Mock the config file read with a different server name
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
+ mcpServers: {
+ "enabled-test-server": {
+ command: "node",
+ args: ["test.js"],
+ },
},
- client: null,
- transport: null,
- } as DisconnectedMcpConnection,
- ]
+ }),
+ )
- mcpHub.connections = mockConnections
- const servers = mcpHub.getServers()
+ // Create a new McpHub instance with enabled MCP
+ const mcpHub = new McpHub(enabledMockProvider as unknown as ClineProvider)
- expect(servers.length).toBe(1)
- expect(servers[0].name).toBe("enabled-server")
- })
-
- it("should deduplicate servers by name with project servers taking priority", () => {
- const mockConnections: McpConnection[] = [
- {
- type: "connected",
- server: {
- name: "shared-server",
- config: '{"source":"global"}',
- status: "connected",
- disabled: false,
- source: "global",
- },
- client: {} as any,
- transport: {} as any,
- } as ConnectedMcpConnection,
- {
- type: "connected",
- server: {
- name: "shared-server",
- config: '{"source":"project"}',
- status: "connected",
- disabled: false,
- source: "project",
- },
- client: {} as any,
- transport: {} as any,
- } as ConnectedMcpConnection,
- {
- type: "connected",
- server: {
- name: "unique-global-server",
- config: "{}",
- status: "connected",
- disabled: false,
- source: "global",
- },
- client: {} as any,
- transport: {} as any,
- } as ConnectedMcpConnection,
- ]
-
- mcpHub.connections = mockConnections
- const servers = mcpHub.getServers()
+ // Wait for initialization
+ await new Promise((resolve) => setTimeout(resolve, 100))
- // Should have 2 servers: deduplicated "shared-server" + "unique-global-server"
- expect(servers.length).toBe(2)
+ // Find the enabled-test-server
+ const enabledServer = mcpHub.connections.find((conn) => conn.server.name === "enabled-test-server")
- // Find the shared-server - it should be the project version
- const sharedServer = servers.find((s) => s.name === "shared-server")
- expect(sharedServer).toBeDefined()
- expect(sharedServer!.source).toBe("project")
- expect(sharedServer!.config).toBe('{"source":"project"}')
+ // Verify that the server is connected
+ expect(enabledServer).toBeDefined()
+ expect(enabledServer!.server.status).toBe("connected")
+ expect(enabledServer!.client).toBeDefined()
+ expect(enabledServer!.transport).toBeDefined()
- // The unique global server should also be present
- const uniqueServer = servers.find((s) => s.name === "unique-global-server")
- expect(uniqueServer).toBeDefined()
+ // Verify StdioClientTransport was called
+ expect(StdioClientTransport).toHaveBeenCalled()
})
- it("should keep global server when no project server with same name exists", () => {
- const mockConnections: McpConnection[] = [
- {
- type: "connected",
- server: {
- name: "global-only-server",
- config: "{}",
- status: "connected",
- disabled: false,
- source: "global",
- },
- client: {} as any,
- transport: {} as any,
- } as ConnectedMcpConnection,
- ]
-
- mcpHub.connections = mockConnections
- const servers = mcpHub.getServers()
-
- expect(servers.length).toBe(1)
- expect(servers[0].name).toBe("global-only-server")
- expect(servers[0].source).toBe("global")
- })
+ it("should handle refreshAllConnections when MCP is disabled", async () => {
+ // Mock provider with mcpEnabled: false
+ const disabledMockProvider = {
+ ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ postMessageToWebview: vi.fn(),
+ getState: vi.fn().mockResolvedValue({ mcpEnabled: false }),
+ context: mockProvider.context,
+ }
- it("should prevent calling tools on disabled servers", async () => {
- // Mock fs.readFile to return a disabled server config
+ // Mock the config file read
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "disabled-server": {
+ "refresh-test-server": {
command: "node",
args: ["test.js"],
- disabled: true,
},
},
}),
)
- const mcpHub = new McpHub(mockProvider as ClineProvider)
-
- // Wait for initialization
+ // Create McpHub with disabled MCP
+ const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider)
await new Promise((resolve) => setTimeout(resolve, 100))
- // The server should be created as a disconnected connection
- const connection = mcpHub.connections.find((conn) => conn.server.name === "disabled-server")
- expect(connection).toBeDefined()
- expect(connection?.type).toBe("disconnected")
- expect(connection?.server.disabled).toBe(true)
+ // Clear previous calls
+ vi.clearAllMocks()
- // Try to call tool on disabled server
- await expect(mcpHub.callTool("disabled-server", "some-tool", {})).rejects.toThrow(
- "No connection found for server: disabled-server",
+ // Call refreshAllConnections
+ await mcpHub.refreshAllConnections()
+
+ // Verify that servers are tracked but not connected
+ const server = mcpHub.connections.find((conn) => conn.server.name === "refresh-test-server")
+ expect(server).toBeDefined()
+ expect(server!.server.status).toBe("disconnected")
+ expect(server!.client).toBeNull()
+ expect(server!.transport).toBeNull()
+
+ // Verify postMessageToWebview was called to update the UI
+ expect(disabledMockProvider.postMessageToWebview).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "mcpServers",
+ }),
)
})
- it("should prevent reading resources from disabled servers", async () => {
- // Mock fs.readFile to return a disabled server config
+ it("should skip restarting connection when MCP is disabled", async () => {
+ // Mock provider with mcpEnabled: false
+ const disabledMockProvider = {
+ ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ postMessageToWebview: vi.fn(),
+ getState: vi.fn().mockResolvedValue({ mcpEnabled: false }),
+ context: mockProvider.context,
+ }
+
+ // Mock the config file read
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "disabled-server": {
+ "restart-test-server": {
command: "node",
args: ["test.js"],
- disabled: true,
},
},
}),
)
- const mcpHub = new McpHub(mockProvider as ClineProvider)
-
- // Wait for initialization
+ // Create McpHub with disabled MCP
+ const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider)
await new Promise((resolve) => setTimeout(resolve, 100))
- // The server should be created as a disconnected connection
- const connection = mcpHub.connections.find((conn) => conn.server.name === "disabled-server")
- expect(connection).toBeDefined()
- expect(connection?.type).toBe("disconnected")
- expect(connection?.server.disabled).toBe(true)
-
- // Try to read resource from disabled server
- await expect(mcpHub.readResource("disabled-server", "some/uri")).rejects.toThrow(
- "No connection found for server: disabled-server",
- )
- })
- })
-
- describe("callTool", () => {
- it("should execute tool successfully", async () => {
- // Mock the connection with a minimal client implementation
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- config: JSON.stringify({}),
- status: "connected" as const,
- },
- client: {
- request: vi.fn().mockResolvedValue({ result: "success" }),
- } as any,
- transport: {
- start: vi.fn(),
- close: vi.fn(),
- stderr: { on: vi.fn() },
- } as any,
- }
+ // Set isConnecting to false to ensure it's properly reset
+ mcpHub.isConnecting = false
- mcpHub.connections = [mockConnection]
+ // Try to restart a connection
+ await mcpHub.restartConnection("restart-test-server")
- await mcpHub.callTool("test-server", "some-tool", {})
+ // Verify that isConnecting was reset to false
+ expect(mcpHub.isConnecting).toBe(false)
- // Verify the request was made with correct parameters
- expect(mockConnection.client!.request).toHaveBeenCalledWith(
- {
- method: "tools/call",
- params: {
- name: "some-tool",
- arguments: {},
- },
- },
- expect.any(Object),
- expect.objectContaining({ timeout: 60000 }), // Default 60 second timeout
- )
+ // Verify that the server remains disconnected
+ const server = mcpHub.connections.find((conn) => conn.server.name === "restart-test-server")
+ expect(server).toBeDefined()
+ expect(server!.server.status).toBe("disconnected")
+ expect(server!.client).toBeNull()
+ expect(server!.transport).toBeNull()
})
+ })
- it("should throw error if server not found", async () => {
- await expect(mcpHub.callTool("non-existent-server", "some-tool", {})).rejects.toThrow(
- "No connection found for server: non-existent-server",
- )
- })
+ describe("Windows command wrapping", () => {
+ let StdioClientTransport: ReturnType
+ let Client: ReturnType
- describe("timeout configuration", () => {
- it("should validate timeout values", () => {
- // Test valid timeout values
- const validConfig = {
- type: "stdio",
- command: "test",
- timeout: 60,
- }
- expect(() => ServerConfigSchema.parse(validConfig)).not.toThrow()
+ beforeEach(async () => {
+ // Reset mocks
+ vi.clearAllMocks()
- // Test invalid timeout values
- const invalidConfigs = [
- { type: "stdio", command: "test", timeout: 0 }, // Too low
- { type: "stdio", command: "test", timeout: 3601 }, // Too high
- { type: "stdio", command: "test", timeout: -1 }, // Negative
- ]
+ // Get references to the mocked constructors
+ const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
+ const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
+ StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
+ Client = clientModule.Client as ReturnType
- invalidConfigs.forEach((config) => {
- expect(() => ServerConfigSchema.parse(config)).toThrow()
- })
+ // Mock Windows platform
+ Object.defineProperty(process, "platform", {
+ value: "win32",
+ writable: true,
+ enumerable: true,
+ configurable: true,
})
+ })
- it("should use default timeout of 60 seconds if not specified", async () => {
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- config: JSON.stringify({ type: "stdio", command: "test" }), // No timeout specified
- status: "connected",
- },
- client: {
- request: vi.fn().mockResolvedValue({ content: [] }),
- } as any,
- transport: {} as any,
- }
-
- mcpHub.connections = [mockConnection]
- await mcpHub.callTool("test-server", "test-tool")
+ it("should wrap commands with cmd.exe on Windows", async () => {
+ // Mock StdioClientTransport
+ const mockTransport = {
+ start: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: {
+ on: vi.fn(),
+ },
+ onerror: null,
+ onclose: null,
+ }
- expect(mockConnection.client!.request).toHaveBeenCalledWith(
- expect.anything(),
- expect.anything(),
- expect.objectContaining({ timeout: 60000 }), // 60 seconds in milliseconds
- )
+ StdioClientTransport.mockImplementation((config: any) => {
+ // Verify that cmd.exe wrapping is applied
+ expect(config.command).toBe("cmd.exe")
+ expect(config.args).toEqual([
+ "/c",
+ "npx",
+ "-y",
+ "@modelcontextprotocol/server-filesystem",
+ "/test/path",
+ ])
+ return mockTransport
})
- it("should apply configured timeout to tool calls", async () => {
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- config: JSON.stringify({ type: "stdio", command: "test", timeout: 120 }), // 2 minutes
- status: "connected",
- },
- client: {
- request: vi.fn().mockResolvedValue({ content: [] }),
- } as any,
- transport: {} as any,
- }
-
- mcpHub.connections = [mockConnection]
- await mcpHub.callTool("test-server", "test-tool")
+ // Mock Client
+ Client.mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInstructions: vi.fn().mockReturnValue("test instructions"),
+ request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ }))
- expect(mockConnection.client!.request).toHaveBeenCalledWith(
- expect.anything(),
- expect.anything(),
- expect.objectContaining({ timeout: 120000 }), // 120 seconds in milliseconds
- )
- })
- })
+ // Create a new McpHub instance
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
- describe("updateServerTimeout", () => {
- it("should update server timeout in settings file", async () => {
- const mockConfig = {
+ // Mock the config file read
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
+ "test-npx-server": {
+ command: "npx",
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
},
},
- }
-
- // Mock reading initial config
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ }),
+ )
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
- source: "global",
- } as any,
- client: {} as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnection]
+ // Initialize servers (this will trigger connectToServer)
+ await mcpHub["initializeGlobalMcpServers"]()
- await mcpHub.updateServerTimeout("test-server", 120)
+ // Verify StdioClientTransport was called with wrapped command
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: "cmd.exe",
+ args: ["/c", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
+ }),
+ )
+ })
- // Verify the config was updated correctly
- // Find the write call with the normalized path
- const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
- const writeCalls = vi.mocked(fs.writeFile).mock.calls
+ it("should not wrap commands on non-Windows platforms", async () => {
+ // Mock non-Windows platform
+ Object.defineProperty(process, "platform", {
+ value: "darwin",
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ })
- // Find the write call with the normalized path
- const writeCall = writeCalls.find((call: any) => call[0] === normalizedSettingsPath)
- const callToUse = writeCall || writeCalls[0]
+ // Mock StdioClientTransport
+ const mockTransport = {
+ start: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: {
+ on: vi.fn(),
+ },
+ onerror: null,
+ onclose: null,
+ }
- const writtenConfig = JSON.parse(callToUse[1] as string)
- expect(writtenConfig.mcpServers["test-server"].timeout).toBe(120)
+ StdioClientTransport.mockImplementation((config: any) => {
+ // Verify that no cmd.exe wrapping is applied
+ expect(config.command).toBe("npx")
+ expect(config.args).toEqual(["-y", "@modelcontextprotocol/server-filesystem", "/test/path"])
+ return mockTransport
})
- it("should fallback to default timeout when config has invalid timeout", async () => {
- const mockConfig = {
+ // Mock Client
+ Client.mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInstructions: vi.fn().mockReturnValue("test instructions"),
+ request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ }))
+
+ // Create a new McpHub instance
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+
+ // Mock the config file read
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
+ "test-npx-server": {
+ command: "npx",
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
},
},
- }
-
- // Mock initial read
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ }),
+ )
- // Set up mock connection before updating
- const mockConnectionInitial: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
- source: "global",
- } as any,
- client: {
- request: vi.fn().mockResolvedValue({ content: [] }),
- } as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnectionInitial]
+ // Initialize servers (this will trigger connectToServer)
+ await mcpHub["initializeGlobalMcpServers"]()
- // Update with invalid timeout
- await mcpHub.updateServerTimeout("test-server", 3601)
+ // Verify StdioClientTransport was called without wrapping
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: "npx",
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
+ }),
+ )
+ })
- // Config is written
- expect(fs.writeFile).toHaveBeenCalled()
+ it("should not double-wrap commands that are already cmd.exe", async () => {
+ // Mock Windows platform
+ Object.defineProperty(process, "platform", {
+ value: "win32",
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ })
- // Setup connection with invalid timeout
- const mockConnectionInvalid: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- config: JSON.stringify({
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 3601, // Invalid timeout
- }),
- status: "connected",
- },
- client: {
- request: vi.fn().mockResolvedValue({ content: [] }),
- } as any,
- transport: {} as any,
- }
+ // Mock StdioClientTransport
+ const mockTransport = {
+ start: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: {
+ on: vi.fn(),
+ },
+ onerror: null,
+ onclose: null,
+ }
- mcpHub.connections = [mockConnectionInvalid]
+ StdioClientTransport.mockImplementation((config: any) => {
+ // Verify that cmd.exe is not double-wrapped
+ expect(config.command).toBe("cmd.exe")
+ expect(config.args).toEqual(["/c", "echo", "test"])
+ return mockTransport
+ })
- // Call tool - should use default timeout
- await mcpHub.callTool("test-server", "test-tool")
+ // Mock Client
+ Client.mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInstructions: vi.fn().mockReturnValue("test instructions"),
+ request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ }))
- // Verify default timeout was used
- expect(mockConnectionInvalid.client!.request).toHaveBeenCalledWith(
- expect.anything(),
- expect.anything(),
- expect.objectContaining({ timeout: 60000 }), // Default 60 seconds
- )
- })
+ // Create a new McpHub instance
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
- it("should accept valid timeout values", async () => {
- const mockConfig = {
+ // Mock the config file read with cmd.exe already as command
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
+ "test-cmd-server": {
+ command: "cmd.exe",
+ args: ["/c", "echo", "test"],
},
},
- }
+ }),
+ )
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ // Initialize servers (this will trigger connectToServer)
+ await mcpHub["initializeGlobalMcpServers"]()
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
- source: "global",
- } as any,
- client: {} as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnection]
+ // Verify StdioClientTransport was called without double-wrapping
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: "cmd.exe",
+ args: ["/c", "echo", "test"],
+ }),
+ )
+ })
- // Test valid timeout values
- const validTimeouts = [1, 60, 3600]
- for (const timeout of validTimeouts) {
- await mcpHub.updateServerTimeout("test-server", timeout)
- expect(fs.writeFile).toHaveBeenCalled()
- vi.clearAllMocks() // Reset for next iteration
- ;(fs.readFile as any).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ it("should handle npx.ps1 scenario from node version managers", async () => {
+ // Mock Windows platform
+ Object.defineProperty(process, "platform", {
+ value: "win32",
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ })
+
+ // Mock StdioClientTransport to simulate the ENOENT error without wrapping
+ const mockTransport = {
+ start: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: {
+ on: vi.fn(),
+ },
+ onerror: null,
+ onclose: null,
+ }
+
+ let callCount = 0
+ StdioClientTransport.mockImplementation((config: any) => {
+ callCount++
+ // First call would fail with ENOENT if not wrapped
+ // Second call should be wrapped with cmd.exe
+ if (callCount === 1) {
+ // This simulates what would happen without wrapping
+ expect(config.command).toBe("cmd.exe")
+ expect(config.args[0]).toBe("/c")
+ expect(config.args[1]).toBe("npx")
}
+ return mockTransport
})
- it("should notify webview after updating timeout", async () => {
- const mockConfig = {
+ // Mock Client
+ Client.mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInstructions: vi.fn().mockReturnValue("test instructions"),
+ request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ }))
+
+ // Create a new McpHub instance
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+
+ // Mock the config file read - simulating fnm/nvm-windows scenario
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
mcpServers: {
- "test-server": {
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
+ "test-fnm-npx-server": {
+ command: "npx",
+ args: ["-y", "@modelcontextprotocol/server-example"],
+ env: {
+ // Simulate fnm environment
+ FNM_DIR: "C:\\Users\\test\\.fnm",
+ FNM_NODE_DIST_MIRROR: "https://nodejs.org/dist",
+ FNM_ARCH: "x64",
+ },
},
},
- }
+ }),
+ )
- vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
+ // Initialize servers (this will trigger connectToServer)
+ await mcpHub["initializeGlobalMcpServers"]()
- // Set up mock connection
- const mockConnection: ConnectedMcpConnection = {
- type: "connected",
- server: {
- name: "test-server",
- type: "stdio",
- command: "node",
- args: ["test.js"],
- timeout: 60,
- source: "global",
- } as any,
- client: {} as any,
- transport: {} as any,
- }
- mcpHub.connections = [mockConnection]
+ // Verify that the command was wrapped with cmd.exe
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: "cmd.exe",
+ args: ["/c", "npx", "-y", "@modelcontextprotocol/server-example"],
+ env: expect.objectContaining({
+ FNM_DIR: "C:\\Users\\test\\.fnm",
+ FNM_NODE_DIST_MIRROR: "https://nodejs.org/dist",
+ FNM_ARCH: "x64",
+ }),
+ }),
+ )
+ })
- await mcpHub.updateServerTimeout("test-server", 120)
+ it("should handle case-insensitive cmd command check", async () => {
+ // Mock Windows platform
+ Object.defineProperty(process, "platform", {
+ value: "win32",
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ })
- expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
- expect.objectContaining({
- type: "mcpServers",
- }),
- )
+ // Mock StdioClientTransport
+ const mockTransport = {
+ start: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: {
+ on: vi.fn(),
+ },
+ onerror: null,
+ onclose: null,
+ }
+
+ StdioClientTransport.mockImplementation((config: any) => {
+ // Verify that CMD (uppercase) is not double-wrapped
+ expect(config.command).toBe("CMD")
+ expect(config.args).toEqual(["/c", "echo", "test"])
+ return mockTransport
})
+
+ // Mock Client
+ Client.mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInstructions: vi.fn().mockReturnValue("test instructions"),
+ request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
+ }))
+
+ // Create a new McpHub instance
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+
+ // Mock the config file read with CMD (uppercase) as command
+ vi.mocked(fs.readFile).mockResolvedValue(
+ JSON.stringify({
+ mcpServers: {
+ "test-cmd-uppercase-server": {
+ command: "CMD",
+ args: ["/c", "echo", "test"],
+ },
+ },
+ }),
+ )
+
+ // Initialize servers (this will trigger connectToServer)
+ await mcpHub["initializeGlobalMcpServers"]()
+
+ // Verify StdioClientTransport was called without double-wrapping
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: "CMD",
+ args: ["/c", "echo", "test"],
+ }),
+ )
})
})
- describe("MCP global enable/disable", () => {
- beforeEach(() => {
- // Clear all mocks before each test
- vi.clearAllMocks()
- })
+ describe("File watcher cleanup", () => {
+ it("should clean up file watchers when server is disabled", async () => {
+ // Get the mocked chokidar
+ const chokidar = (await import("chokidar")).default
+ const mockWatcher = {
+ on: vi.fn().mockReturnThis(),
+ close: vi.fn(),
+ }
+ vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as any)
- it("should disconnect all servers when MCP is toggled from enabled to disabled", async () => {
// Mock StdioClientTransport
const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
@@ -1630,96 +1700,49 @@ describe("McpHub", () => {
Client.mockImplementation(() => mockClient)
- // Start with MCP enabled
- mockProvider.getState = vi.fn().mockResolvedValue({ mcpEnabled: true })
-
- // Mock the config file read
+ // Create server with watchPaths
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "toggle-test-server": {
+ "watcher-test-server": {
command: "node",
args: ["test.js"],
+ watchPaths: ["/path/to/watch"],
},
},
}),
)
- // Create McpHub and let it initialize with MCP enabled
const mcpHub = new McpHub(mockProvider as ClineProvider)
await new Promise((resolve) => setTimeout(resolve, 100))
- // Verify server is connected
- const connectedServer = mcpHub.connections.find((conn) => conn.server.name === "toggle-test-server")
- expect(connectedServer).toBeDefined()
- expect(connectedServer!.server.status).toBe("connected")
- expect(connectedServer!.client).toBeDefined()
- expect(connectedServer!.transport).toBeDefined()
-
- // Now simulate toggling MCP to disabled
- mockProvider.getState = vi.fn().mockResolvedValue({ mcpEnabled: false })
-
- // Manually trigger what would happen when MCP is disabled
- // (normally this would be triggered by the webview message handler)
- const existingConnections = [...mcpHub.connections]
- for (const conn of existingConnections) {
- await mcpHub.deleteConnection(conn.server.name, conn.server.source)
- }
- await mcpHub.refreshAllConnections()
+ // Verify watcher was created
+ expect(chokidar.watch).toHaveBeenCalledWith(["/path/to/watch"], expect.any(Object))
- // Verify server is now tracked but disconnected
- const disconnectedServer = mcpHub.connections.find((conn) => conn.server.name === "toggle-test-server")
- expect(disconnectedServer).toBeDefined()
- expect(disconnectedServer!.server.status).toBe("disconnected")
- expect(disconnectedServer!.client).toBeNull()
- expect(disconnectedServer!.transport).toBeNull()
+ // Now disable the server
+ await mcpHub.toggleServerDisabled("watcher-test-server", true)
- // Verify close was called on the original client and transport
- expect(mockClient.close).toHaveBeenCalled()
- expect(mockTransport.close).toHaveBeenCalled()
+ // Verify watcher was closed
+ expect(mockWatcher.close).toHaveBeenCalled()
})
- it("should not connect to servers when MCP is globally disabled", async () => {
- // Mock provider with mcpEnabled: false
- const disabledMockProvider = {
- ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- postMessageToWebview: vi.fn(),
- getState: vi.fn().mockResolvedValue({ mcpEnabled: false }),
- context: mockProvider.context,
+ it("should clean up all file watchers when server is deleted", async () => {
+ // Get the mocked chokidar
+ const chokidar = (await import("chokidar")).default
+ const mockWatcher1 = {
+ on: vi.fn().mockReturnThis(),
+ close: vi.fn(),
+ }
+ const mockWatcher2 = {
+ on: vi.fn().mockReturnThis(),
+ close: vi.fn(),
}
- // Mock the config file read with a different server name to avoid conflicts
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "disabled-test-server": {
- command: "node",
- args: ["test.js"],
- },
- },
- }),
- )
-
- // Create a new McpHub instance with disabled MCP
- const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider)
-
- // Wait for initialization
- await new Promise((resolve) => setTimeout(resolve, 100))
-
- // Find the disabled-test-server
- const disabledServer = mcpHub.connections.find((conn) => conn.server.name === "disabled-test-server")
-
- // Verify that the server is tracked but not connected
- expect(disabledServer).toBeDefined()
- expect(disabledServer!.server.status).toBe("disconnected")
- expect(disabledServer!.client).toBeNull()
- expect(disabledServer!.transport).toBeNull()
- })
-
- it("should connect to servers when MCP is globally enabled", async () => {
- // Clear all mocks
- vi.clearAllMocks()
+ // Return different watchers for different paths
+ let watcherIndex = 0
+ vi.mocked(chokidar.watch).mockImplementation(() => {
+ return (watcherIndex++ === 0 ? mockWatcher1 : mockWatcher2) as any
+ })
// Mock StdioClientTransport
const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
@@ -1741,169 +1764,117 @@ describe("McpHub", () => {
const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
const Client = clientModule.Client as ReturnType
- Client.mockImplementation(() => ({
+ const mockClient = {
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getInstructions: vi.fn().mockReturnValue("test instructions"),
request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }))
-
- // Mock provider with mcpEnabled: true
- const enabledMockProvider = {
- ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- postMessageToWebview: vi.fn(),
- getState: vi.fn().mockResolvedValue({ mcpEnabled: true }),
- context: mockProvider.context,
}
- // Mock the config file read with a different server name
+ Client.mockImplementation(() => mockClient)
+
+ // Create server with multiple watchPaths
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "enabled-test-server": {
+ "multi-watcher-server": {
command: "node",
- args: ["test.js"],
+ args: ["test.js", "build/index.js"], // This will create a watcher for build/index.js
+ watchPaths: ["/path/to/watch1", "/path/to/watch2"],
},
},
}),
)
- // Create a new McpHub instance with enabled MCP
- const mcpHub = new McpHub(enabledMockProvider as unknown as ClineProvider)
-
- // Wait for initialization
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
await new Promise((resolve) => setTimeout(resolve, 100))
- // Find the enabled-test-server
- const enabledServer = mcpHub.connections.find((conn) => conn.server.name === "enabled-test-server")
+ // Verify watchers were created
+ expect(chokidar.watch).toHaveBeenCalled()
- // Verify that the server is connected
- expect(enabledServer).toBeDefined()
- expect(enabledServer!.server.status).toBe("connected")
- expect(enabledServer!.client).toBeDefined()
- expect(enabledServer!.transport).toBeDefined()
+ // Delete the connection (this should clean up all watchers)
+ await mcpHub.deleteConnection("multi-watcher-server")
- // Verify StdioClientTransport was called
- expect(StdioClientTransport).toHaveBeenCalled()
+ // Verify all watchers were closed
+ expect(mockWatcher1.close).toHaveBeenCalled()
+ expect(mockWatcher2.close).toHaveBeenCalled()
})
- it("should handle refreshAllConnections when MCP is disabled", async () => {
- // Mock provider with mcpEnabled: false
- const disabledMockProvider = {
- ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- postMessageToWebview: vi.fn(),
- getState: vi.fn().mockResolvedValue({ mcpEnabled: false }),
- context: mockProvider.context,
- }
+ it("should not create file watchers for disabled servers on initialization", async () => {
+ // Get the mocked chokidar
+ const chokidar = (await import("chokidar")).default
- // Mock the config file read
+ // Create disabled server with watchPaths
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "refresh-test-server": {
+ "disabled-watcher-server": {
command: "node",
args: ["test.js"],
+ watchPaths: ["/path/to/watch"],
+ disabled: true,
},
},
}),
)
- // Create McpHub with disabled MCP
- const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider)
- await new Promise((resolve) => setTimeout(resolve, 100))
-
- // Clear previous calls
- vi.clearAllMocks()
-
- // Call refreshAllConnections
- await mcpHub.refreshAllConnections()
+ vi.mocked(chokidar.watch).mockClear()
- // Verify that servers are tracked but not connected
- const server = mcpHub.connections.find((conn) => conn.server.name === "refresh-test-server")
- expect(server).toBeDefined()
- expect(server!.server.status).toBe("disconnected")
- expect(server!.client).toBeNull()
- expect(server!.transport).toBeNull()
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+ await new Promise((resolve) => setTimeout(resolve, 100))
- // Verify postMessageToWebview was called to update the UI
- expect(disabledMockProvider.postMessageToWebview).toHaveBeenCalledWith(
- expect.objectContaining({
- type: "mcpServers",
- }),
- )
+ // Verify no watcher was created for disabled server
+ expect(chokidar.watch).not.toHaveBeenCalled()
})
+ })
- it("should skip restarting connection when MCP is disabled", async () => {
- // Mock provider with mcpEnabled: false
- const disabledMockProvider = {
- ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
- postMessageToWebview: vi.fn(),
- getState: vi.fn().mockResolvedValue({ mcpEnabled: false }),
- context: mockProvider.context,
- }
-
- // Mock the config file read
+ describe("Null safety improvements", () => {
+ it("should handle null client safely in disconnected connections", async () => {
+ // Mock fs.readFile to return a disabled server config
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "restart-test-server": {
+ "null-safety-server": {
command: "node",
args: ["test.js"],
+ disabled: true,
},
},
}),
)
- // Create McpHub with disabled MCP
- const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider)
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+
+ // Wait for initialization
await new Promise((resolve) => setTimeout(resolve, 100))
- // Set isConnecting to false to ensure it's properly reset
- mcpHub.isConnecting = false
+ // The server should be created as a disconnected connection with null client/transport
+ const connection = mcpHub.connections.find((conn) => conn.server.name === "null-safety-server")
+ expect(connection).toBeDefined()
+ expect(connection?.type).toBe("disconnected")
- // Try to restart a connection
- await mcpHub.restartConnection("restart-test-server")
+ // Type guard to ensure it's a disconnected connection
+ if (connection?.type === "disconnected") {
+ expect(connection.client).toBeNull()
+ expect(connection.transport).toBeNull()
+ }
- // Verify that isConnecting was reset to false
- expect(mcpHub.isConnecting).toBe(false)
+ // Try to call tool on disconnected server
+ await expect(mcpHub.callTool("null-safety-server", "test-tool", {})).rejects.toThrow(
+ "No connection found for server: null-safety-server",
+ )
- // Verify that the server remains disconnected
- const server = mcpHub.connections.find((conn) => conn.server.name === "restart-test-server")
- expect(server).toBeDefined()
- expect(server!.server.status).toBe("disconnected")
- expect(server!.client).toBeNull()
- expect(server!.transport).toBeNull()
+ // Try to read resource on disconnected server
+ await expect(mcpHub.readResource("null-safety-server", "test-uri")).rejects.toThrow(
+ "No connection found for server: null-safety-server",
+ )
})
- })
-
- describe("Windows command wrapping", () => {
- let StdioClientTransport: ReturnType
- let Client: ReturnType
-
- beforeEach(async () => {
- // Reset mocks
- vi.clearAllMocks()
- // Get references to the mocked constructors
+ it("should handle connection type checks safely", async () => {
+ // Mock StdioClientTransport
const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
- const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
- StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
- Client = clientModule.Client as ReturnType
-
- // Mock Windows platform
- Object.defineProperty(process, "platform", {
- value: "win32",
- writable: true,
- enumerable: true,
- configurable: true,
- })
- })
+ const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
- it("should wrap commands with cmd.exe on Windows", async () => {
- // Mock StdioClientTransport
const mockTransport = {
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
@@ -1914,126 +1885,68 @@ describe("McpHub", () => {
onclose: null,
}
- StdioClientTransport.mockImplementation((config: any) => {
- // Verify that cmd.exe wrapping is applied
- expect(config.command).toBe("cmd.exe")
- expect(config.args).toEqual([
- "/c",
- "npx",
- "-y",
- "@modelcontextprotocol/server-filesystem",
- "/test/path",
- ])
- return mockTransport
- })
+ StdioClientTransport.mockImplementation(() => mockTransport)
// Mock Client
- Client.mockImplementation(() => ({
+ const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
+ const Client = clientModule.Client as ReturnType
+
+ const mockClient = {
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getInstructions: vi.fn().mockReturnValue("test instructions"),
request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }))
+ }
- // Create a new McpHub instance
- const mcpHub = new McpHub(mockProvider as ClineProvider)
+ Client.mockImplementation(() => mockClient)
- // Mock the config file read
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "test-npx-server": {
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
+ "type-check-server": {
+ command: "node",
+ args: ["test.js"],
},
},
}),
)
- // Initialize servers (this will trigger connectToServer)
- await mcpHub["initializeGlobalMcpServers"]()
-
- // Verify StdioClientTransport was called with wrapped command
- expect(StdioClientTransport).toHaveBeenCalledWith(
- expect.objectContaining({
- command: "cmd.exe",
- args: ["/c", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
- }),
- )
- })
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+ await new Promise((resolve) => setTimeout(resolve, 100))
- it("should not wrap commands on non-Windows platforms", async () => {
- // Mock non-Windows platform
- Object.defineProperty(process, "platform", {
- value: "darwin",
- writable: true,
- enumerable: true,
- configurable: true,
- })
+ // Get the connection
+ const connection = mcpHub.connections.find((conn) => conn.server.name === "type-check-server")
+ expect(connection).toBeDefined()
- // Mock StdioClientTransport
- const mockTransport = {
- start: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- stderr: {
- on: vi.fn(),
- },
- onerror: null,
- onclose: null,
+ // Safe type checking
+ if (connection?.type === "connected") {
+ expect(connection.client).toBeDefined()
+ expect(connection.transport).toBeDefined()
+ } else if (connection?.type === "disconnected") {
+ expect(connection.client).toBeNull()
+ expect(connection.transport).toBeNull()
}
+ })
- StdioClientTransport.mockImplementation((config: any) => {
- // Verify that no cmd.exe wrapping is applied
- expect(config.command).toBe("npx")
- expect(config.args).toEqual(["-y", "@modelcontextprotocol/server-filesystem", "/test/path"])
- return mockTransport
- })
-
- // Mock Client
- Client.mockImplementation(() => ({
- connect: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- getInstructions: vi.fn().mockReturnValue("test instructions"),
- request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }))
-
- // Create a new McpHub instance
+ it("should handle missing connections safely", async () => {
const mcpHub = new McpHub(mockProvider as ClineProvider)
+ await new Promise((resolve) => setTimeout(resolve, 100))
- // Mock the config file read
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "test-npx-server": {
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
- },
- },
- }),
+ // Try operations on non-existent server
+ await expect(mcpHub.callTool("non-existent-server", "test-tool", {})).rejects.toThrow(
+ "No connection found for server: non-existent-server",
)
- // Initialize servers (this will trigger connectToServer)
- await mcpHub["initializeGlobalMcpServers"]()
-
- // Verify StdioClientTransport was called without wrapping
- expect(StdioClientTransport).toHaveBeenCalledWith(
- expect.objectContaining({
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
- }),
+ await expect(mcpHub.readResource("non-existent-server", "test-uri")).rejects.toThrow(
+ "No connection found for server: non-existent-server",
)
})
- it("should not double-wrap commands that are already cmd.exe", async () => {
- // Mock Windows platform
- Object.defineProperty(process, "platform", {
- value: "win32",
- writable: true,
- enumerable: true,
- configurable: true,
- })
-
+ it("should handle connection deletion safely", async () => {
// Mock StdioClientTransport
+ const stdioModule = await import("@modelcontextprotocol/sdk/client/stdio.js")
+ const StdioClientTransport = stdioModule.StdioClientTransport as ReturnType
+
const mockTransport = {
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
@@ -2044,188 +1957,488 @@ describe("McpHub", () => {
onclose: null,
}
- StdioClientTransport.mockImplementation((config: any) => {
- // Verify that cmd.exe is not double-wrapped
- expect(config.command).toBe("cmd.exe")
- expect(config.args).toEqual(["/c", "echo", "test"])
- return mockTransport
- })
+ StdioClientTransport.mockImplementation(() => mockTransport)
// Mock Client
- Client.mockImplementation(() => ({
+ const clientModule = await import("@modelcontextprotocol/sdk/client/index.js")
+ const Client = clientModule.Client as ReturnType
+
+ const mockClient = {
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getInstructions: vi.fn().mockReturnValue("test instructions"),
request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }))
+ }
- // Create a new McpHub instance
- const mcpHub = new McpHub(mockProvider as ClineProvider)
+ Client.mockImplementation(() => mockClient)
- // Mock the config file read with cmd.exe already as command
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
mcpServers: {
- "test-cmd-server": {
- command: "cmd.exe",
- args: ["/c", "echo", "test"],
+ "delete-safety-server": {
+ command: "node",
+ args: ["test.js"],
},
},
}),
)
- // Initialize servers (this will trigger connectToServer)
- await mcpHub["initializeGlobalMcpServers"]()
+ const mcpHub = new McpHub(mockProvider as ClineProvider)
+ await new Promise((resolve) => setTimeout(resolve, 100))
- // Verify StdioClientTransport was called without double-wrapping
- expect(StdioClientTransport).toHaveBeenCalledWith(
- expect.objectContaining({
- command: "cmd.exe",
- args: ["/c", "echo", "test"],
- }),
- )
- })
+ // Delete the connection
+ await mcpHub.deleteConnection("delete-safety-server")
- it("should handle npx.ps1 scenario from node version managers", async () => {
- // Mock Windows platform
- Object.defineProperty(process, "platform", {
- value: "win32",
- writable: true,
- enumerable: true,
- configurable: true,
- })
+ // Verify connection is removed
+ const connection = mcpHub.connections.find((conn) => conn.server.name === "delete-safety-server")
+ expect(connection).toBeUndefined()
- // Mock StdioClientTransport to simulate the ENOENT error without wrapping
- const mockTransport = {
- start: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- stderr: {
- on: vi.fn(),
+ // Verify transport and client were closed
+ expect(mockTransport.close).toHaveBeenCalled()
+ expect(mockClient.close).toHaveBeenCalled()
+ })
+ })
+
+ describe("toggleToolAlwaysAllow", () => {
+ it("should add tool to always allow list when enabling", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ alwaysAllow: [],
+ },
},
- onerror: null,
- onclose: null,
}
- let callCount = 0
- StdioClientTransport.mockImplementation((config: any) => {
- callCount++
- // First call would fail with ENOENT if not wrapped
- // Second call should be wrapped with cmd.exe
- if (callCount === 1) {
- // This simulates what would happen without wrapping
- expect(config.command).toBe("cmd.exe")
- expect(config.args[0]).toBe("/c")
- expect(config.args[1]).toBe("npx")
- }
- return mockTransport
- })
+ // Mock reading initial config
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
- // Mock Client
- Client.mockImplementation(() => ({
- connect: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- getInstructions: vi.fn().mockReturnValue("test instructions"),
- request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }))
+ // Set up mock connection without alwaysAllow
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ source: "global",
+ } as any,
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
- // Create a new McpHub instance
- const mcpHub = new McpHub(mockProvider as ClineProvider)
+ await mcpHub.toggleToolAlwaysAllow("test-server", "global", "new-tool", true)
- // Mock the config file read - simulating fnm/nvm-windows scenario
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "test-fnm-npx-server": {
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-example"],
- env: {
- // Simulate fnm environment
- FNM_DIR: "C:\\Users\\test\\.fnm",
- FNM_NODE_DIST_MIRROR: "https://nodejs.org/dist",
- FNM_ARCH: "x64",
- },
- },
- },
- }),
- )
+ // Verify the config was updated correctly
+ const writeCalls = vi.mocked(fs.writeFile).mock.calls
+ expect(writeCalls.length).toBeGreaterThan(0)
- // Initialize servers (this will trigger connectToServer)
- await mcpHub["initializeGlobalMcpServers"]()
+ // Find the write call
+ const callToUse = writeCalls[writeCalls.length - 1]
+ expect(callToUse).toBeTruthy()
- // Verify that the command was wrapped with cmd.exe
- expect(StdioClientTransport).toHaveBeenCalledWith(
- expect.objectContaining({
- command: "cmd.exe",
- args: ["/c", "npx", "-y", "@modelcontextprotocol/server-example"],
- env: expect.objectContaining({
- FNM_DIR: "C:\\Users\\test\\.fnm",
- FNM_NODE_DIST_MIRROR: "https://nodejs.org/dist",
- FNM_ARCH: "x64",
- }),
- }),
- )
+ // The path might be normalized differently on different platforms,
+ // so we'll just check that we have a call with valid content
+ const writtenConfig = JSON.parse(callToUse[1] as string)
+ expect(writtenConfig.mcpServers).toBeDefined()
+ expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
+ expect(Array.isArray(writtenConfig.mcpServers["test-server"].alwaysAllow)).toBe(true)
+ expect(writtenConfig.mcpServers["test-server"].alwaysAllow).toContain("new-tool")
})
- it("should handle case-insensitive cmd command check", async () => {
- // Mock Windows platform
- Object.defineProperty(process, "platform", {
- value: "win32",
- writable: true,
- enumerable: true,
- configurable: true,
- })
-
- // Mock StdioClientTransport
- const mockTransport = {
- start: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- stderr: {
- on: vi.fn(),
+ it("should remove tool from always allow list when disabling", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ alwaysAllow: ["existing-tool"],
+ },
},
- onerror: null,
- onclose: null,
}
- StdioClientTransport.mockImplementation((config: any) => {
- // Verify that CMD (uppercase) is not double-wrapped
- expect(config.command).toBe("CMD")
- expect(config.args).toEqual(["/c", "echo", "test"])
- return mockTransport
- })
+ // Mock reading initial config
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
- // Mock Client
- Client.mockImplementation(() => ({
- connect: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- getInstructions: vi.fn().mockReturnValue("test instructions"),
- request: vi.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
- }))
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ alwaysAllow: ["existing-tool"],
+ source: "global",
+ } as any,
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
- // Create a new McpHub instance
- const mcpHub = new McpHub(mockProvider as ClineProvider)
+ await mcpHub.toggleToolAlwaysAllow("test-server", "global", "existing-tool", false)
- // Mock the config file read with CMD (uppercase) as command
- vi.mocked(fs.readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: {
- "test-cmd-uppercase-server": {
- command: "CMD",
- args: ["/c", "echo", "test"],
- },
+ // Verify the config was updated correctly
+ const writeCalls = vi.mocked(fs.writeFile).mock.calls
+ expect(writeCalls.length).toBeGreaterThan(0)
+
+ // Find the write call
+ const callToUse = writeCalls[writeCalls.length - 1]
+ expect(callToUse).toBeTruthy()
+
+ // The path might be normalized differently on different platforms,
+ // so we'll just check that we have a call with valid content
+ const writtenConfig = JSON.parse(callToUse[1] as string)
+ expect(writtenConfig.mcpServers).toBeDefined()
+ expect(writtenConfig.mcpServers["test-server"]).toBeDefined()
+ expect(Array.isArray(writtenConfig.mcpServers["test-server"].alwaysAllow)).toBe(true)
+ expect(writtenConfig.mcpServers["test-server"].alwaysAllow).not.toContain("existing-tool")
+ })
+
+ it("should initialize alwaysAllow if it does not exist", async () => {
+ const mockConfig = {
+ mcpServers: {
+ "test-server": {
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
},
- }),
- )
+ },
+ }
- // Initialize servers (this will trigger connectToServer)
- await mcpHub["initializeGlobalMcpServers"]()
+ // Mock reading initial config
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
- // Verify StdioClientTransport was called without double-wrapping
- expect(StdioClientTransport).toHaveBeenCalledWith(
- expect.objectContaining({
- command: "CMD",
- args: ["/c", "echo", "test"],
- }),
+ // Set up mock connection
+ const mockConnection: ConnectedMcpConnection = {
+ type: "connected",
+ server: {
+ name: "test-server",
+ type: "stdio",
+ command: "node",
+ args: ["test.js"],
+ alwaysAllow: [],
+ source: "global",
+ } as any,
+ client: {} as any,
+ transport: {} as any,
+ }
+ mcpHub.connections = [mockConnection]
+
+ await mcpHub.toggleToolAlwaysAllow("test-server", "global", "new-tool", true)
+
+ // Verify the config was updated with initialized alwaysAllow
+ // Find the write call with the normalized path
+ const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json"
+ const writeCalls = vi.mocked(fs.writeFile).mock.calls
+
+ // Find the write call with the normalized path
+ const writeCall = writeCalls.find((call: any) => call[0] === normalizedSettingsPath)
+ const callToUse = writeCall || writeCalls[0]
+
+ const writtenConfig = JSON.parse(callToUse[1] as string)
+ expect(writtenConfig.mcpServers["test-server"].alwaysAllow).toBeDefined()
+ expect(writtenConfig.mcpServers["test-server"].alwaysAllow).toContain("new-tool")
+ })
+ })
+})
+
+describe("Mode-to-Profile Mapping (MCP Profile Filtering)", () => {
+ let mcpHub: McpHubType
+ let mockProvider: Partial
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ const mockUri: Uri = {
+ scheme: "file",
+ authority: "",
+ path: "/test/path",
+ query: "",
+ fragment: "",
+ fsPath: "/test/path",
+ with: vi.fn(),
+ toJSON: vi.fn(),
+ }
+ mockProvider = {
+ ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings/path"),
+ postMessageToWebview: vi.fn(),
+ getState: vi.fn().mockResolvedValue({ mcpEnabled: true }),
+ cwd: "/test/path",
+ context: {
+ subscriptions: [],
+ workspaceState: {} as any,
+ globalState: {} as any,
+ secrets: {} as any,
+ extensionUri: mockUri,
+ extensionPath: "/test/path",
+ storagePath: "/test/storage",
+ globalStoragePath: "/test/global-storage",
+ environmentVariableCollection: {} as any,
+ extension: {
+ id: "test-extension",
+ extensionUri: mockUri,
+ extensionPath: "/test/path",
+ extensionKind: 1,
+ isActive: true,
+ packageJSON: { version: "1.0.0" },
+ activate: vi.fn(),
+ exports: undefined,
+ } as any,
+ asAbsolutePath: (path: string) => path,
+ storageUri: mockUri,
+ globalStorageUri: mockUri,
+ logUri: mockUri,
+ extensionMode: 1,
+ logPath: "/test/path",
+ languageModelAccessInformation: {} as any,
+ } as ExtensionContext,
+ }
+ // Set up default empty config for constructor initialization
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: {} }))
+ vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
+ mcpHub = new McpHub(mockProvider as ClineProvider)
+ })
+
+ describe("Mode-to-Profile Mapping Loading", () => {
+ it("merges global mapping when empty", () => {
+ mcpHub["modeToProfile"] = {}
+ mcpHub["mergeModeToProfileMapping"]({ mode1: ["serverA"] }, "global")
+ expect(mcpHub.getModeToProfileMapping()).toEqual({ mode1: ["serverA"] })
+ })
+
+ it("merges different modes from global and project", () => {
+ mcpHub["modeToProfile"] = {}
+ mcpHub["mergeModeToProfileMapping"]({ mode1: ["serverA"] }, "global")
+ mcpHub["mergeModeToProfileMapping"]({ mode2: ["serverB"] }, "project")
+ expect(mcpHub.getModeToProfileMapping()).toEqual({ mode1: ["serverA"], mode2: ["serverB"] })
+ })
+
+ it("project config takes precedence over global for same mode", () => {
+ mcpHub["modeToProfile"] = {}
+ mcpHub["mergeModeToProfileMapping"]({ mode1: ["serverA"] }, "global")
+ mcpHub["mergeModeToProfileMapping"]({ mode1: ["serverB"] }, "project")
+ expect(mcpHub.getModeToProfileMapping()).toEqual({ mode1: ["serverB"] })
+ })
+
+ it("global does not overwrite existing project mapping", () => {
+ mcpHub["modeToProfile"] = {}
+ mcpHub["mergeModeToProfileMapping"]({ mode1: ["serverB"] }, "project")
+ mcpHub["mergeModeToProfileMapping"]({ mode1: ["serverA"] }, "global")
+ expect(mcpHub.getModeToProfileMapping()).toEqual({ mode1: ["serverB"] })
+ })
+
+ it("handles missing modeToProfile field (backward compatibility)", () => {
+ mcpHub["modeToProfile"] = {}
+ mcpHub["mergeModeToProfileMapping"]({}, "global")
+ expect(mcpHub.getModeToProfileMapping()).toEqual({})
+ })
+ })
+
+ describe("Server Filtering by Active Mode", () => {
+ beforeEach(() => {
+ mcpHub.connections = [
+ {
+ type: "connected",
+ server: { name: "serverA", config: "{}", status: "connected", disabled: false, source: "global" },
+ client: {} as any,
+ transport: {} as any,
+ },
+ {
+ type: "connected",
+ server: { name: "serverB", config: "{}", status: "connected", disabled: false, source: "project" },
+ client: {} as any,
+ transport: {} as any,
+ },
+ {
+ type: "connected",
+ server: { name: "serverC", config: "{}", status: "connected", disabled: false, source: "global" },
+ client: {} as any,
+ transport: {} as any,
+ },
+ ]
+ // Set up mapping
+ mcpHub["modeToProfile"] = {
+ mode1: ["serverA"],
+ mode2: ["serverB"],
+ mode3: ["nonexistent"],
+ mode4: [],
+ mode5: ["serverA", "serverB"],
+ mode6: ["serverA", "serverC"],
+ mode7: ["serverA", "serverB", "serverC"],
+ mode8: [],
+ mode9: ["serverD"],
+ mode10: ["serverA", "serverD"],
+ mode11: ["serverB"],
+ mode12: ["serverC"],
+ mode13: ["serverA", "serverB"],
+ mode14: ["serverB", "serverC"],
+ mode15: ["serverA", "serverB", "serverC"],
+ mode16: ["serverA", "serverB", "serverC", "serverD"],
+ mode17: ["serverA", "serverB", "serverC", "serverD", "serverE"],
+ mode18: ["serverA", "serverB", "serverC", "serverD", "serverE", "serverF"],
+ mode19: ["serverA", "serverB", "serverC", "serverD", "serverE", "serverF", "serverG"],
+ mode20: ["serverA", "serverB", "serverC", "serverD", "serverE", "serverF", "serverG", "serverH"],
+ }
+ })
+
+ it("returns all servers when no active mode is set", () => {
+ mcpHub.setActiveMode(undefined)
+ const servers = mcpHub.getServers()
+ expect(servers.map((s) => s.name).sort()).toEqual(["serverA", "serverB", "serverC"])
+ })
+
+ it("returns all servers when active mode is not in mapping", () => {
+ mcpHub.setActiveMode("unknown-mode")
+ const servers = mcpHub.getServers()
+ expect(servers.map((s) => s.name).sort()).toEqual(["serverA", "serverB", "serverC"])
+ })
+
+ it("returns only mapped servers when active mode is set", () => {
+ mcpHub.setActiveMode("mode1")
+ const servers = mcpHub.getServers()
+ expect(servers.map((s) => s.name)).toEqual(["serverA"])
+ mcpHub.setActiveMode("mode2")
+ expect(mcpHub.getServers().map((s) => s.name)).toEqual(["serverB"])
+ mcpHub.setActiveMode("mode5")
+ expect(
+ mcpHub
+ .getServers()
+ .map((s) => s.name)
+ .sort(),
+ ).toEqual(["serverA", "serverB"])
+ mcpHub.setActiveMode("mode6")
+ expect(
+ mcpHub
+ .getServers()
+ .map((s) => s.name)
+ .sort(),
+ ).toEqual(["serverA", "serverC"])
+ mcpHub.setActiveMode("mode7")
+ expect(
+ mcpHub
+ .getServers()
+ .map((s) => s.name)
+ .sort(),
+ ).toEqual(["serverA", "serverB", "serverC"])
+ })
+
+ it("filters correctly with both global and project servers", () => {
+ mcpHub.setActiveMode("mode6")
+ const servers = mcpHub.getServers()
+ expect(servers.map((s) => s.name).sort()).toEqual(["serverA", "serverC"])
+ })
+
+ it("preserves deduplication logic (project overrides global)", () => {
+ mcpHub.connections.push({
+ type: "connected",
+ server: { name: "serverA", config: "{}", status: "connected", disabled: false, source: "project" },
+ client: {} as any,
+ transport: {} as any,
+ })
+ mcpHub.setActiveMode("mode1")
+ const servers = mcpHub.getServers()
+ const serverA = servers.find((s) => s.name === "serverA")
+ expect(serverA?.source).toBe("project")
+ })
+ })
+
+ describe("setActiveMode", () => {
+ it("sets active mode to a valid mode slug", () => {
+ mcpHub.setActiveMode("mode1")
+ expect((mcpHub as any).activeMode).toBe("mode1")
+ })
+ it("sets active mode to undefined (clears filtering)", () => {
+ mcpHub.setActiveMode(undefined)
+ expect((mcpHub as any).activeMode).toBeUndefined()
+ })
+ it("switches between different modes", () => {
+ mcpHub.setActiveMode("mode1")
+ expect((mcpHub as any).activeMode).toBe("mode1")
+ mcpHub.setActiveMode("mode2")
+ expect((mcpHub as any).activeMode).toBe("mode2")
+ })
+ })
+
+ describe("getModeToProfileMapping", () => {
+ it("returns empty object when no mapping exists", () => {
+ mcpHub["modeToProfile"] = {}
+ expect(mcpHub.getModeToProfileMapping()).toEqual({})
+ })
+ it("returns correct mapping after loading config", () => {
+ mcpHub["modeToProfile"] = { mode1: ["serverA"] }
+ expect(mcpHub.getModeToProfileMapping()).toEqual({ mode1: ["serverA"] })
+ })
+ it("returns merged mapping (global + project)", () => {
+ mcpHub["modeToProfile"] = { mode1: ["serverA"], mode2: ["serverB"] }
+ expect(mcpHub.getModeToProfileMapping()).toEqual({ mode1: ["serverA"], mode2: ["serverB"] })
+ })
+ })
+
+ describe("updateModeToProfileMapping", () => {
+ it("updates mapping and persists to config file", async () => {
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: {}, modeToProfile: {} }))
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+ const mapping = { mode1: ["serverA"] }
+ await mcpHub.updateModeToProfileMapping(mapping)
+ expect(mcpHub.getModeToProfileMapping()).toEqual(mapping)
+ })
+ it("saves to project config when project config exists", async () => {
+ vi.mocked(fs.access).mockResolvedValueOnce(undefined)
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: {}, modeToProfile: {} }))
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+ const mapping = { mode2: ["serverB"] }
+ await mcpHub.updateModeToProfileMapping(mapping)
+ expect(mcpHub.getModeToProfileMapping()).toEqual(mapping)
+ })
+ it("saves to global config when no project config", async () => {
+ vi.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT"))
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: {}, modeToProfile: {} }))
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+ const mapping = { mode3: ["serverC"] }
+ await mcpHub.updateModeToProfileMapping(mapping)
+ expect(mcpHub.getModeToProfileMapping()).toEqual(mapping)
+ })
+ it("handles empty mapping update", async () => {
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: {}, modeToProfile: {} }))
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+ const mapping = {}
+ await mcpHub.updateModeToProfileMapping(mapping)
+ expect(mcpHub.getModeToProfileMapping()).toEqual({})
+ })
+ })
+
+ describe("Validation", () => {
+ it("warns about server names that don't exist in mcpServers", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+ mcpHub.connections = [
+ {
+ type: "connected",
+ server: { name: "serverA", config: "{}", status: "connected", disabled: false, source: "global" },
+ client: {} as any,
+ transport: {} as any,
+ },
+ ]
+ mcpHub["modeToProfile"] = { mode1: ["serverA", "nonexistent"] }
+ mcpHub["validateModeToProfileMapping"]()
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Mode "mode1" references non-existent server "nonexistent" in modeToProfile mapping',
)
+ warnSpy.mockRestore()
+ })
+ it("handles empty arrays in mapping", () => {
+ mcpHub["modeToProfile"] = { mode1: [] }
+ mcpHub["validateModeToProfileMapping"]()
+ // No warning should be thrown
+ })
+ it("handles invalid mapping structure gracefully", () => {
+ mcpHub["modeToProfile"] = { mode1: [123 as any] }
+ // Should not throw, but will not match any server
+ mcpHub["validateModeToProfileMapping"]()
})
})
})
diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts
new file mode 100644
index 00000000000..4511e619203
--- /dev/null
+++ b/src/shared/mcp.ts
@@ -0,0 +1,88 @@
+import type { ModeToProfileMapping } from "@roo-code/types"
+
+export type McpErrorEntry = {
+ message: string
+ timestamp: number
+ level: "error" | "warn" | "info"
+}
+
+// Re-export ModeToProfileMapping for backend usage
+export type { ModeToProfileMapping }
+
+export type McpServer = {
+ name: string
+ config: string
+ status: "connected" | "connecting" | "disconnected"
+ error?: string
+ errorHistory?: McpErrorEntry[]
+ tools?: McpTool[]
+ resources?: McpResource[]
+ resourceTemplates?: McpResourceTemplate[]
+ disabled?: boolean
+ timeout?: number
+ source?: "global" | "project"
+ projectPath?: string
+ instructions?: string
+}
+
+export type McpTool = {
+ name: string
+ description?: string
+ inputSchema?: object
+ alwaysAllow?: boolean
+ enabledForPrompt?: boolean
+}
+
+export type McpResource = {
+ uri: string
+ name: string
+ mimeType?: string
+ description?: string
+}
+
+export type McpResourceTemplate = {
+ uriTemplate: string
+ name: string
+ description?: string
+ mimeType?: string
+}
+
+export type McpResourceResponse = {
+ _meta?: Record
+ contents: Array<{
+ uri: string
+ mimeType?: string
+ text?: string
+ blob?: string
+ }>
+}
+
+export type McpToolCallResponse = {
+ _meta?: Record
+ content: Array<
+ | {
+ type: "text"
+ text: string
+ }
+ | {
+ type: "image"
+ data: string
+ mimeType: string
+ }
+ | {
+ type: "audio"
+ data: string
+ mimeType: string
+ }
+ | {
+ type: "resource"
+ resource: {
+ uri: string
+ mimeType?: string
+ text?: string
+ blob?: string
+ }
+ }
+ >
+ isError?: boolean
+}
diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx
index 94c50b80ab9..0ab5430eec1 100644
--- a/webview-ui/src/components/marketplace/MarketplaceView.tsx
+++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx
@@ -108,7 +108,7 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace
onClick={() => onDone?.()}
aria-label={t("settings:back")}>
- {t("settings:back")}
+ {t("settings:back")}
{t("marketplace:title")}
diff --git a/webview-ui/src/components/mcp/MCPServerRow.tsx b/webview-ui/src/components/mcp/MCPServerRow.tsx
new file mode 100644
index 00000000000..7500f46c097
--- /dev/null
+++ b/webview-ui/src/components/mcp/MCPServerRow.tsx
@@ -0,0 +1,441 @@
+import React, { useState } from "react"
+import { VSCodePanels, VSCodePanelTab, VSCodePanelView } from "@vscode/webview-ui-toolkit/react"
+
+import { McpServer } from "@roo/mcp"
+
+import { vscode } from "@src/utils/vscode"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ ToggleSwitch,
+} from "@src/components/ui"
+
+import McpToolRow from "./McpToolRow"
+import McpResourceRow from "./McpResourceRow"
+import { McpErrorRow } from "./McpErrorRow"
+
+interface MCPServerRowProps {
+ server: McpServer
+ alwaysAllowMcp?: boolean
+ // New props for simplified mode
+ simplified?: boolean // When true, hide expand/delete/refresh buttons
+ checked?: boolean // For simplified mode: whether the toggle is on
+ onToggle?: () => void // For simplified mode: custom toggle handler
+ "data-testid"?: string // For testing
+}
+
+const MCPServerRow = ({
+ server,
+ alwaysAllowMcp,
+ simplified,
+ checked,
+ onToggle,
+ "data-testid": dataTestId,
+}: MCPServerRowProps) => {
+ const { t } = useAppTranslation()
+ const [isExpanded, setIsExpanded] = useState(false)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const [timeoutValue, setTimeoutValue] = useState(() => {
+ // Skip timeout initialization in simplified mode
+ if (simplified) return 60
+ try {
+ const configTimeout = JSON.parse(server.config)?.timeout
+ return configTimeout ?? 60 // Default 1 minute (60 seconds)
+ } catch {
+ return 60
+ }
+ })
+
+ // Computed property to check if server is expandable (not in simplified mode)
+ const isExpandable = !simplified && server.status === "connected" && !server.disabled
+
+ const timeoutOptions = [
+ { value: 15, label: t("mcp:networkTimeout.options.15seconds") },
+ { value: 30, label: t("mcp:networkTimeout.options.30seconds") },
+ { value: 60, label: t("mcp:networkTimeout.options.1minute") },
+ { value: 300, label: t("mcp:networkTimeout.options.5minutes") },
+ { value: 600, label: t("mcp:networkTimeout.options.10minutes") },
+ { value: 900, label: t("mcp:networkTimeout.options.15minutes") },
+ { value: 1800, label: t("mcp:networkTimeout.options.30minutes") },
+ { value: 3600, label: t("mcp:networkTimeout.options.60minutes") },
+ ]
+
+ const getStatusColor = () => {
+ // Disabled servers should always show grey regardless of connection status
+ if (server.disabled) {
+ return "var(--vscode-descriptionForeground)"
+ }
+
+ switch (server.status) {
+ case "connected":
+ return "var(--vscode-testing-iconPassed)"
+ case "connecting":
+ return "var(--vscode-charts-yellow)"
+ case "disconnected":
+ return "var(--vscode-testing-iconFailed)"
+ }
+ }
+
+ const handleRowClick = () => {
+ // Only allow expansion for connected and enabled servers
+ if (isExpandable) {
+ setIsExpanded(!isExpanded)
+ }
+ }
+
+ const handleRestart = () => {
+ vscode.postMessage({
+ type: "restartMcpServer",
+ text: server.name,
+ source: server.source || "global",
+ })
+ }
+
+ const handleTimeoutChange = (event: React.ChangeEvent) => {
+ const seconds = parseInt(event.target.value)
+ setTimeoutValue(seconds)
+ vscode.postMessage({
+ type: "updateMcpTimeout",
+ serverName: server.name,
+ source: server.source || "global",
+ timeout: seconds,
+ })
+ }
+
+ const handleDelete = () => {
+ vscode.postMessage({
+ type: "deleteMcpServer",
+ serverName: server.name,
+ source: server.source || "global",
+ })
+ setShowDeleteConfirm(false)
+ }
+
+ return (
+
+
+ {!simplified && isExpandable && (
+
+ )}
+
+ {server.name}
+ {!simplified && server.source && (
+
+ {server.source}
+
+ )}
+
+ {!simplified && (
+
e.stopPropagation()}>
+ setShowDeleteConfirm(true)}
+ style={{ marginRight: "8px" }}>
+
+
+
+
+
+
+ )}
+ {!simplified && (
+
+ )}
+
simplified && e.stopPropagation()}>
+ {
+ /* no-op */
+ })
+ : () => {
+ vscode.postMessage({
+ type: "toggleMcpServer",
+ serverName: server.name,
+ source: server.source || "global",
+ disabled: !server.disabled,
+ })
+ }
+ }
+ size="medium"
+ aria-label={`Toggle ${server.name} server`}
+ />
+
+
+
+ {!simplified &&
+ (isExpandable
+ ? isExpanded && (
+
+
+
+ {t("mcp:tabs.tools")} ({server.tools?.length || 0})
+
+
+ {t("mcp:tabs.resources")} (
+ {[...(server.resourceTemplates || []), ...(server.resources || [])].length || 0}
+ )
+
+ {server.instructions && (
+ {t("mcp:instructions")}
+ )}
+
+ {t("mcp:tabs.logs")} ({server.errorHistory?.length || 0})
+
+
+
+ {server.tools && server.tools.length > 0 ? (
+
+ {server.tools.map((tool) => (
+
+ ))}
+
+ ) : (
+
+ {t("mcp:emptyState.noTools")}
+
+ )}
+
+
+
+ {(server.resources && server.resources.length > 0) ||
+ (server.resourceTemplates && server.resourceTemplates.length > 0) ? (
+
+ {[...(server.resourceTemplates || []), ...(server.resources || [])].map(
+ (item) => (
+
+ ),
+ )}
+
+ ) : (
+
+ {t("mcp:emptyState.noResources")}
+
+ )}
+
+
+ {server.instructions && (
+
+
+
+ {server.instructions}
+
+
+
+ )}
+
+
+ {server.errorHistory && server.errorHistory.length > 0 ? (
+
+ {[...server.errorHistory]
+ .sort((a, b) => b.timestamp - a.timestamp)
+ .map((error, index) => (
+
+ ))}
+
+ ) : (
+
+ {t("mcp:emptyState.noLogs")}
+
+ )}
+
+
+
+ {/* Network Timeout */}
+
+
+ {t("mcp:networkTimeout.label")}
+
+ {timeoutOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {t("mcp:networkTimeout.description")}
+
+
+
+ )
+ : // Only show error UI for non-disabled servers
+ !server.disabled && (
+
+
+ {server.error &&
+ server.error.split("\n").map((item, index) => (
+
+ {index > 0 && }
+ {item}
+
+ ))}
+
+
+ {server.status === "connecting"
+ ? t("mcp:serverStatus.retrying")
+ : t("mcp:serverStatus.retryConnection")}
+
+
+ ))}
+
+ {/* Delete Confirmation Dialog */}
+ {!simplified && (
+
+
+
+ {t("mcp:deleteDialog.title")}
+
+ {t("mcp:deleteDialog.description", { serverName: server.name })}
+
+
+
+ setShowDeleteConfirm(false)}>
+ {t("mcp:deleteDialog.cancel")}
+
+
+ {t("mcp:deleteDialog.delete")}
+
+
+
+
+ )}
+
+ )
+}
+
+export default MCPServerRow
diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx
index 6803e60baf4..ea33aa6c66d 100644
--- a/webview-ui/src/components/mcp/McpView.tsx
+++ b/webview-ui/src/components/mcp/McpView.tsx
@@ -1,37 +1,17 @@
-import React, { useState } from "react"
+import React from "react"
import { Trans } from "react-i18next"
-import {
- VSCodeCheckbox,
- VSCodeLink,
- VSCodePanels,
- VSCodePanelTab,
- VSCodePanelView,
-} from "@vscode/webview-ui-toolkit/react"
-
-import type { McpServer } from "@roo-code/types"
+import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useAppTranslation } from "@src/i18n/TranslationContext"
-import {
- Button,
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
- ToggleSwitch,
- StandardTooltip,
-} from "@src/components/ui"
+import { Button, StandardTooltip } from "@src/components/ui"
import { buildDocLink } from "@src/utils/docLinks"
import { Section } from "@src/components/settings/Section"
import { SectionHeader } from "@src/components/settings/SectionHeader"
-import McpToolRow from "./McpToolRow"
-import McpResourceRow from "./McpResourceRow"
import McpEnabledToggle from "./McpEnabledToggle"
-import { McpErrorRow } from "./McpErrorRow"
+import MCPServerRow from "./MCPServerRow"
const McpView = () => {
const {
@@ -103,7 +83,7 @@ const McpView = () => {
{servers.length > 0 && (
{servers.map((server) => (
-
{
)
}
-const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowMcp?: boolean }) => {
- const { t } = useAppTranslation()
- const [isExpanded, setIsExpanded] = useState(false)
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
- const [timeoutValue, setTimeoutValue] = useState(() => {
- const configTimeout = JSON.parse(server.config)?.timeout
- return configTimeout ?? 60 // Default 1 minute (60 seconds)
- })
-
- // Computed property to check if server is expandable
- const isExpandable = server.status === "connected" && !server.disabled
-
- const timeoutOptions = [
- { value: 15, label: t("mcp:networkTimeout.options.15seconds") },
- { value: 30, label: t("mcp:networkTimeout.options.30seconds") },
- { value: 60, label: t("mcp:networkTimeout.options.1minute") },
- { value: 300, label: t("mcp:networkTimeout.options.5minutes") },
- { value: 600, label: t("mcp:networkTimeout.options.10minutes") },
- { value: 900, label: t("mcp:networkTimeout.options.15minutes") },
- { value: 1800, label: t("mcp:networkTimeout.options.30minutes") },
- { value: 3600, label: t("mcp:networkTimeout.options.60minutes") },
- ]
-
- const getStatusColor = () => {
- // Disabled servers should always show grey regardless of connection status
- if (server.disabled) {
- return "var(--vscode-descriptionForeground)"
- }
-
- switch (server.status) {
- case "connected":
- return "var(--vscode-testing-iconPassed)"
- case "connecting":
- return "var(--vscode-charts-yellow)"
- case "disconnected":
- return "var(--vscode-testing-iconFailed)"
- }
- }
-
- const handleRowClick = () => {
- // Only allow expansion for connected and enabled servers
- if (isExpandable) {
- setIsExpanded(!isExpanded)
- }
- }
-
- const handleRestart = () => {
- vscode.postMessage({
- type: "restartMcpServer",
- text: server.name,
- source: server.source || "global",
- })
- }
-
- const handleTimeoutChange = (event: React.ChangeEvent) => {
- const seconds = parseInt(event.target.value)
- setTimeoutValue(seconds)
- vscode.postMessage({
- type: "updateMcpTimeout",
- serverName: server.name,
- source: server.source || "global",
- timeout: seconds,
- })
- }
-
- const handleDelete = () => {
- vscode.postMessage({
- type: "deleteMcpServer",
- serverName: server.name,
- source: server.source || "global",
- })
- setShowDeleteConfirm(false)
- }
-
- return (
-
-
- {isExpandable && (
-
- )}
-
- {server.name}
- {server.source && (
-
- {server.source}
-
- )}
-
-
e.stopPropagation()}>
- setShowDeleteConfirm(true)}
- style={{ marginRight: "8px" }}>
-
-
-
-
-
-
-
-
- {
- vscode.postMessage({
- type: "toggleMcpServer",
- serverName: server.name,
- source: server.source || "global",
- disabled: !server.disabled,
- })
- }}
- size="medium"
- aria-label={`Toggle ${server.name} server`}
- />
-
-
-
- {isExpandable
- ? isExpanded && (
-
-
-
- {t("mcp:tabs.tools")} ({server.tools?.length || 0})
-
-
- {t("mcp:tabs.resources")} (
- {[...(server.resourceTemplates || []), ...(server.resources || [])].length || 0})
-
- {server.instructions && (
- {t("mcp:instructions")}
- )}
-
- {t("mcp:tabs.logs")} ({server.errorHistory?.length || 0})
-
-
-
- {server.tools && server.tools.length > 0 ? (
-
- {server.tools.map((tool) => (
-
- ))}
-
- ) : (
-
- {t("mcp:emptyState.noTools")}
-
- )}
-
-
-
- {(server.resources && server.resources.length > 0) ||
- (server.resourceTemplates && server.resourceTemplates.length > 0) ? (
-
- {[...(server.resourceTemplates || []), ...(server.resources || [])].map(
- (item) => (
-
- ),
- )}
-
- ) : (
-
- {t("mcp:emptyState.noResources")}
-
- )}
-
-
- {server.instructions && (
-
-
-
- {server.instructions}
-
-
-
- )}
-
-
- {server.errorHistory && server.errorHistory.length > 0 ? (
-
- {[...server.errorHistory]
- .sort((a, b) => b.timestamp - a.timestamp)
- .map((error, index) => (
-
- ))}
-
- ) : (
-
- {t("mcp:emptyState.noLogs")}
-
- )}
-
-
-
- {/* Network Timeout */}
-
-
- {t("mcp:networkTimeout.label")}
-
- {timeoutOptions.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
- {t("mcp:networkTimeout.description")}
-
-
-
- )
- : // Only show error UI for non-disabled servers
- !server.disabled && (
-
-
- {server.error &&
- server.error.split("\n").map((item, index) => (
-
- {index > 0 && }
- {item}
-
- ))}
-
-
- {server.status === "connecting"
- ? t("mcp:serverStatus.retrying")
- : t("mcp:serverStatus.retryConnection")}
-
-
- )}
-
- {/* Delete Confirmation Dialog */}
-
-
-
- {t("mcp:deleteDialog.title")}
-
- {t("mcp:deleteDialog.description", { serverName: server.name })}
-
-
-
- setShowDeleteConfirm(false)}>
- {t("mcp:deleteDialog.cancel")}
-
-
- {t("mcp:deleteDialog.delete")}
-
-
-
-
-
- )
-}
-
export default McpView
diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx
index 15e70f0ebce..8f52cd6db69 100644
--- a/webview-ui/src/components/modes/ModesView.tsx
+++ b/webview-ui/src/components/modes/ModesView.tsx
@@ -29,6 +29,7 @@ import { buildDocLink } from "@src/utils/docLinks"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { Section } from "@src/components/settings/Section"
+import MCPServerRow from "@src/components/mcp/MCPServerRow"
import {
Button,
Select,
@@ -74,6 +75,8 @@ const ModesView = () => {
customInstructions,
setCustomInstructions,
customModes,
+ mcpServers,
+ mcpEnabled,
} = useExtensionState()
// Use a local state to track the visually active mode
@@ -123,6 +126,11 @@ const ModesView = () => {
// Display list that overlays optimistic names
const displayModes = (modes || []).map((m) => (localRenames[m.slug] ? { ...m, name: localRenames[m.slug] } : m))
+ // MCP server selection state
+ const [modeToProfile, setModeToProfile] = useState>({})
+ const [selectedMcpServers, setSelectedMcpServers] = useState([])
+ const [mcpSelectionMode, setMcpSelectionMode] = useState<"all" | "selected">("all")
+
// Direct update functions
const updateAgentPrompt = useCallback(
(mode: Mode, promptData: PromptComponent) => {
@@ -217,6 +225,87 @@ const ModesView = () => {
setVisualMode(mode)
}, [mode])
+ // Load modeToProfile mapping on mount
+ useEffect(() => {
+ vscode.postMessage({ type: "getModeToProfileMapping" })
+ }, [])
+
+ // Update selected MCP servers when mode changes
+ useEffect(() => {
+ if (visualMode && modeToProfile) {
+ // Check if mode is in the mapping
+ const hasMapping = visualMode in modeToProfile
+ const servers = modeToProfile[visualMode] || []
+ setSelectedMcpServers(servers)
+ // Set selection mode:
+ // - If mode is in mapping (even with empty array) → "selected" mode
+ // - If mode is not in mapping → "all" mode (default)
+ setMcpSelectionMode(hasMapping ? "selected" : "all")
+ }
+ }, [visualMode, modeToProfile])
+
+ // Handle radio button change for MCP selection mode
+ const handleMcpSelectionModeChange = useCallback(
+ (mode: "all" | "selected") => {
+ setMcpSelectionMode(mode)
+
+ if (mode === "all") {
+ // Clear the selection - remove this mode from the mapping
+ const newMapping = { ...modeToProfile }
+ delete newMapping[visualMode]
+
+ setModeToProfile(newMapping)
+ setSelectedMcpServers([])
+
+ // Send update to backend
+ vscode.postMessage({
+ type: "updateModeToProfileMapping",
+ mapping: newMapping,
+ })
+ // If switching to 'selected' mode, keep current selections (or start with empty)
+ } else {
+ // Add mode to mapping with current selection (or empty array)
+ const newMapping = {
+ ...modeToProfile,
+ [visualMode]: selectedMcpServers,
+ }
+ setModeToProfile(newMapping)
+ vscode.postMessage({
+ type: "updateModeToProfileMapping",
+ mapping: newMapping,
+ })
+ }
+ },
+ [visualMode, modeToProfile, selectedMcpServers],
+ )
+
+ // Handle MCP server selection changes
+ const handleMcpServerToggle = useCallback(
+ (serverName: string) => {
+ const newSelection = selectedMcpServers.includes(serverName)
+ ? selectedMcpServers.filter((name) => name !== serverName)
+ : [...selectedMcpServers, serverName]
+
+ setSelectedMcpServers(newSelection)
+
+ // Update the mapping - keep empty array to indicate "selected mode with no servers"
+ // This is different from missing/undefined which means "use all servers"
+ const newMapping = {
+ ...modeToProfile,
+ [visualMode]: newSelection,
+ }
+
+ setModeToProfile(newMapping)
+
+ // Send update to backend
+ vscode.postMessage({
+ type: "updateModeToProfileMapping",
+ mapping: newMapping,
+ })
+ },
+ [visualMode, modeToProfile, selectedMcpServers],
+ )
+
// Handler for popover open state change
const onOpenChange = useCallback((open: boolean) => {
setOpen(open)
@@ -556,6 +645,11 @@ const ModesView = () => {
...prev,
[message.slug]: message.hasContent,
}))
+ } else if (message.type === "modeToProfileMapping") {
+ // Received the mode-to-profile mapping from backend
+ if (message.mapping) {
+ setModeToProfile(message.mapping)
+ }
} else if (message.type === "deleteCustomModeCheck") {
// Handle the check response
// Use the ref to get the current modeToDelete value
@@ -1189,6 +1283,90 @@ const ModesView = () => {
>
+ {/* MCP Servers section */}
+ {mcpEnabled &&
+ mcpServers &&
+ mcpServers.length > 0 &&
+ (() => {
+ const currentMode = getCurrentMode()
+ const enabledGroups = currentMode?.groups || []
+ return enabledGroups.some((group) => getGroupName(group) === "mcp")
+ })() && (
+
+
{t("prompts:mcpServers.title")}
+
+ {t("prompts:mcpServers.description")}
+
+
+ {/* Radio button selection */}
+
+
+
+ handleMcpSelectionModeChange(e.target.value as "all" | "selected")
+ }
+ className="cursor-pointer"
+ />
+
+ {t("prompts:mcpServers.useAllServers")}
+
+
+
+
+ handleMcpSelectionModeChange(e.target.value as "all" | "selected")
+ }
+ className="cursor-pointer"
+ />
+
+ {t("prompts:mcpServers.useSelectedServers")}
+
+
+
+
+ {/* Server selection list - only shown when "selected" mode is active */}
+ {mcpSelectionMode === "selected" && (
+ <>
+
+ {mcpServers
+ .slice()
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((server) => {
+ const isSelected = selectedMcpServers.includes(server.name)
+ return (
+ handleMcpServerToggle(server.name)}
+ data-testid={`mcp-server-${server.name}`}
+ />
+ )
+ })}
+
+ {selectedMcpServers.length > 0 ? (
+
+ Selected: {selectedMcpServers.join(", ")}
+
+ ) : (
+
+ No servers selected - mode will have no MCP access
+
+ )}
+ >
+ )}
+
+ )}
+
{/* Role definition for both built-in and custom modes */}
diff --git a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx b/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx
index 25dc7fb8e16..99660f986ed 100644
--- a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx
+++ b/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx
@@ -12,6 +12,46 @@ vitest.mock("@src/utils/vscode", () => ({
},
}))
+// Mock i18n TranslationContext
+vitest.mock("@src/i18n/TranslationContext", () => ({
+ useAppTranslation: () => ({
+ t: (key: string) => {
+ // Return actual English translations for MCP server keys
+ const translations: Record = {
+ "prompts:mcpServers.title": "MCP Servers",
+ "prompts:mcpServers.description": "Configure which MCP servers this mode can access.",
+ "prompts:mcpServers.useAllServers": "Use all servers (default)",
+ "prompts:mcpServers.useSelectedServers": "Use selected servers only",
+ }
+ return translations[key] || key
+ },
+ }),
+}))
+
+const baseMcpServers = [{ name: "serverA" }, { name: "serverB" }, { name: "serverC" }]
+
+// Mock modes with mcp group enabled
+const mockModesWithMcp = [
+ {
+ slug: "code",
+ name: "Code",
+ roleDefinition: "You are a code assistant",
+ groups: ["read", "edit", "browser", "command", "mcp"] as const,
+ },
+ {
+ slug: "ask",
+ name: "Ask",
+ roleDefinition: "You are a helpful assistant",
+ groups: ["read", "mcp"] as const,
+ },
+ {
+ slug: "architect",
+ name: "Architect",
+ roleDefinition: "You are an architect",
+ groups: ["read", "mcp"] as const,
+ },
+]
+
const mockExtensionState = {
customModePrompts: {},
listApiConfigMeta: [
@@ -21,11 +61,13 @@ const mockExtensionState = {
enhancementApiConfigId: "",
setEnhancementApiConfigId: vitest.fn(),
mode: "code",
- customModes: [],
+ customModes: mockModesWithMcp,
customSupportPrompts: [],
currentApiConfigName: "",
customInstructions: "Initial instructions",
setCustomInstructions: vitest.fn(),
+ mcpServers: baseMcpServers,
+ mcpEnabled: true,
}
const renderPromptsView = (props = {}) => {
@@ -36,6 +78,8 @@ const renderPromptsView = (props = {}) => {
)
}
+const renderModesView = renderPromptsView
+
Element.prototype.scrollIntoView = vitest.fn()
describe("PromptsView", () => {
@@ -92,7 +136,8 @@ describe("PromptsView", () => {
})
it("handles prompt changes correctly", async () => {
- renderPromptsView()
+ // Use customModes: [] to ensure code is treated as a built-in mode for this test
+ renderPromptsView({ customModes: [] })
// Get the textarea
const textarea = await waitFor(() => screen.getByTestId("code-prompt-textarea"))
@@ -265,3 +310,338 @@ describe("PromptsView", () => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "false")
})
})
+
+describe("ModesView MCP Server Selection UI", () => {
+ beforeEach(() => {
+ vitest.clearAllMocks()
+ })
+
+ describe("Rendering Tests", () => {
+ it("renders MCP servers section when enabled and servers exist", () => {
+ renderModesView()
+ expect(screen.getByText("MCP Servers")).toBeInTheDocument()
+ // Radio buttons should be present
+ expect(screen.getByLabelText(/Use all servers/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/Use selected servers only/i)).toBeInTheDocument()
+ // Server list is hidden by default (Use all servers mode)
+ expect(screen.queryByTestId("mcp-server-serverA")).not.toBeInTheDocument()
+ })
+
+ it("does not render MCP servers section when MCP disabled", () => {
+ renderModesView({ mcpEnabled: false })
+ expect(screen.queryByText("MCP Servers")).not.toBeInTheDocument()
+ })
+
+ it("does not render MCP servers section when no servers", () => {
+ renderModesView({ mcpServers: [] })
+ expect(screen.queryByText("MCP Servers")).not.toBeInTheDocument()
+ })
+
+ it('defaults to "Use all servers" mode', () => {
+ renderModesView({ mcpServers: baseMcpServers, mcpEnabled: true })
+ const allRadio = screen.getByLabelText(/Use all servers/i)
+ expect(allRadio).toBeChecked()
+ })
+
+ it('shows "Use selected servers only" option', () => {
+ renderModesView({ mcpServers: baseMcpServers, mcpEnabled: true })
+ expect(screen.getByLabelText(/Use selected servers only/i)).toBeInTheDocument()
+ })
+
+ it("hides server list when 'Use all servers' is selected", () => {
+ renderModesView({ mcpServers: baseMcpServers, mcpEnabled: true })
+ const allRadio = screen.getByLabelText(/Use all servers/i)
+ expect(allRadio).toBeChecked()
+ // Server list should not be visible
+ expect(screen.queryByTestId("mcp-server-serverA")).not.toBeInTheDocument()
+ })
+
+ it("shows server list when 'Use selected servers' is selected", async () => {
+ renderModesView({ mcpServers: baseMcpServers, mcpEnabled: true })
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ expect(screen.getByTestId("mcp-server-serverB")).toBeInTheDocument()
+ expect(screen.getByTestId("mcp-server-serverC")).toBeInTheDocument()
+ })
+ })
+
+ it("shows selected server names when servers are selected", async () => {
+ renderModesView({ mcpServers: baseMcpServers, mcpEnabled: true })
+ // Switch to selected mode
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+
+ const serverRowA = screen.getByTestId("mcp-server-serverA")
+ const serverRowB = screen.getByTestId("mcp-server-serverB")
+ const toggleA = serverRowA.querySelector('[role="switch"]') as HTMLElement
+ const toggleB = serverRowB.querySelector('[role="switch"]') as HTMLElement
+
+ fireEvent.click(toggleA)
+ fireEvent.click(toggleB)
+
+ await waitFor(() => {
+ expect(screen.getByText("Selected: serverA, serverB")).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe("Selection Tests", () => {
+ it("clicking a server toggle in selected mode toggles selection", async () => {
+ renderModesView()
+ // Switch to selected mode first
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+
+ const serverRow = screen.getByTestId("mcp-server-serverA")
+ const toggle = serverRow.querySelector('[role="switch"]') as HTMLElement
+ expect(toggle).toHaveAttribute("aria-checked", "false")
+
+ fireEvent.click(toggle)
+ await waitFor(() => {
+ expect(toggle).toHaveAttribute("aria-checked", "true")
+ })
+
+ fireEvent.click(toggle)
+ await waitFor(() => {
+ expect(toggle).toHaveAttribute("aria-checked", "false")
+ })
+ })
+
+ it("selected toggles have correct state", async () => {
+ renderModesView()
+ // Switch to selected mode
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverB")).toBeInTheDocument()
+ })
+
+ const serverRow = screen.getByTestId("mcp-server-serverB")
+ const toggle = serverRow.querySelector('[role="switch"]') as HTMLElement
+ expect(toggle).toHaveAttribute("aria-checked", "false")
+
+ fireEvent.click(toggle)
+ await waitFor(() => {
+ expect(toggle).toHaveAttribute("aria-checked", "true")
+ })
+ })
+
+ it("multiple servers can be selected", async () => {
+ renderModesView()
+ // Switch to selected mode
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+
+ const serverRowA = screen.getByTestId("mcp-server-serverA")
+ const serverRowB = screen.getByTestId("mcp-server-serverB")
+ const toggleA = serverRowA.querySelector('[role="switch"]') as HTMLElement
+ const toggleB = serverRowB.querySelector('[role="switch"]') as HTMLElement
+
+ fireEvent.click(toggleA)
+ fireEvent.click(toggleB)
+ await waitFor(() => {
+ expect(toggleA).toHaveAttribute("aria-checked", "true")
+ expect(toggleB).toHaveAttribute("aria-checked", "true")
+ })
+ })
+
+ it('switching back to "Use all servers" clears selection', async () => {
+ renderModesView()
+ // Switch to selected mode
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+
+ // Select a server
+ const serverRow = screen.getByTestId("mcp-server-serverA")
+ const toggle = serverRow.querySelector('[role="switch"]') as HTMLElement
+ fireEvent.click(toggle)
+
+ // Switch back to all mode
+ const allRadio = screen.getByLabelText(/Use all servers/i)
+ fireEvent.click(allRadio)
+
+ await waitFor(() => {
+ // Server list should be hidden
+ expect(screen.queryByTestId("mcp-server-serverA")).not.toBeInTheDocument()
+ })
+ })
+
+ it("stays in selected mode when last server is toggled off", async () => {
+ renderModesView()
+ // Switch to selected mode
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+
+ // Select one server
+ const serverRow = screen.getByTestId("mcp-server-serverA")
+ const toggle = serverRow.querySelector('[role="switch"]') as HTMLElement
+ fireEvent.click(toggle)
+
+ await waitFor(() => {
+ expect(toggle).toHaveAttribute("aria-checked", "true")
+ })
+
+ // Toggle it off again
+ fireEvent.click(toggle)
+
+ await waitFor(() => {
+ expect(toggle).toHaveAttribute("aria-checked", "false")
+ // Should still be in selected mode (not switch back to "all")
+ const selectedRadioAfter = screen.getByLabelText(/Use selected servers only/i)
+ expect(selectedRadioAfter).toBeChecked()
+ // Server list should still be visible
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe("Mode Switching Tests", () => {
+ it("loads correct server selection when switching modes", async () => {
+ const mapping = { code: ["serverA"], ask: ["serverB"] }
+ const { unmount } = renderModesView({ mode: "code" })
+
+ // Simulate backend response with mapping
+ window.dispatchEvent(new MessageEvent("message", { data: { type: "modeToProfileMapping", mapping } }))
+
+ // code mode should be in "selected" mode with serverA selected
+ await waitFor(() => {
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ expect(selectedRadio).toBeChecked()
+ })
+
+ // Cleanup before switching modes
+ unmount()
+
+ // Switch to ask mode
+ renderModesView({ mode: "ask" })
+
+ // Simulate backend response with mapping
+ window.dispatchEvent(new MessageEvent("message", { data: { type: "modeToProfileMapping", mapping } }))
+
+ await waitFor(() => {
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ expect(selectedRadio).toBeChecked()
+ })
+ })
+
+ it("shows all mode for modes not in mapping", async () => {
+ const mapping = { code: ["serverA"] }
+ renderModesView({ mode: "ask" })
+
+ // Simulate backend response with mapping
+ window.dispatchEvent(new MessageEvent("message", { data: { type: "modeToProfileMapping", mapping } }))
+
+ await waitFor(() => {
+ const allRadio = screen.getByLabelText(/Use all servers/i)
+ expect(allRadio).toBeChecked()
+ })
+ })
+
+ it("handles undefined mode correctly", async () => {
+ renderModesView({ mode: undefined })
+ // MCP section should not show for undefined mode (no mcp group)
+ expect(screen.queryByText("MCP Servers")).not.toBeInTheDocument()
+ })
+ })
+
+ describe("Backend Communication Tests", () => {
+ it("sends getModeToProfileMapping on mount", () => {
+ renderModesView()
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "getModeToProfileMapping" })
+ })
+
+ it("sends updateModeToProfileMapping when selection changes", async () => {
+ renderModesView()
+ // Switch to selected mode first
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ fireEvent.click(selectedRadio)
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+
+ const serverRow = screen.getByTestId("mcp-server-serverA")
+ const toggle = serverRow.querySelector('[role="switch"]') as HTMLElement
+ fireEvent.click(toggle)
+
+ await waitFor(() => {
+ expect(vscode.postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "updateModeToProfileMapping" }),
+ )
+ })
+ })
+
+ it("handles modeToProfileMapping response correctly", async () => {
+ renderModesView()
+ const mapping = { code: ["serverA"] }
+ window.dispatchEvent(new MessageEvent("message", { data: { type: "modeToProfileMapping", mapping } }))
+ await waitFor(() => {
+ // Should switch to selected mode
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ expect(selectedRadio).toBeChecked()
+ // Server list should be visible
+ expect(screen.getByTestId("mcp-server-serverA")).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe("Edge Cases", () => {
+ it("handles empty modeToProfile mapping", () => {
+ renderModesView({ modeToProfile: {} })
+ // Should default to "Use all servers" mode
+ const allRadio = screen.getByLabelText(/Use all servers/i)
+ expect(allRadio).toBeChecked()
+ })
+
+ it("handles mode not in mapping", () => {
+ renderModesView({ mode: "nonexistent", modeToProfile: { code: ["serverA"] } })
+ // MCP section should not show for nonexistent mode (no mcp group)
+ expect(screen.queryByText("MCP Servers")).not.toBeInTheDocument()
+ })
+
+ it("handles invalid server names gracefully", async () => {
+ const mapping = { code: ["invalidServer"] }
+ renderModesView({ mode: "code" })
+
+ // Simulate backend response with mapping containing invalid server
+ window.dispatchEvent(new MessageEvent("message", { data: { type: "modeToProfileMapping", mapping } }))
+
+ // Should be in selected mode
+ await waitFor(() => {
+ const selectedRadio = screen.getByLabelText(/Use selected servers only/i)
+ expect(selectedRadio).toBeChecked()
+ })
+
+ // All valid servers should still be rendered
+ await waitFor(() => {
+ baseMcpServers.forEach((s) => {
+ expect(screen.getByTestId(`mcp-server-${s.name}`)).toBeInTheDocument()
+ })
+ })
+ })
+ })
+})
diff --git a/webview-ui/src/i18n/locales/ca/prompts.json b/webview-ui/src/i18n/locales/ca/prompts.json
index 66cd1c76884..909a009dcb1 100644
--- a/webview-ui/src/i18n/locales/ca/prompts.json
+++ b/webview-ui/src/i18n/locales/ca/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Esteu segur que voleu suprimir aquest mode personalitzat?",
"confirm": "Suprimeix",
"cancel": "Cancel·la"
+ },
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
}
}
diff --git a/webview-ui/src/i18n/locales/de/prompts.json b/webview-ui/src/i18n/locales/de/prompts.json
index 9588ff66512..5abec7de736 100644
--- a/webview-ui/src/i18n/locales/de/prompts.json
+++ b/webview-ui/src/i18n/locales/de/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "Keine"
},
+ "mcpServers": {
+ "title": "MCP-Server",
+ "description": "Konfigurieren Sie, auf welche MCP-Server dieser Modus zugreifen kann.",
+ "useAllServers": "Alle Server verwenden (Standard)",
+ "useSelectedServers": "Nur ausgewählte Server verwenden"
+ },
"roleDefinition": {
"title": "Rollendefinition",
"resetToDefault": "Auf Standardwerte zurücksetzen",
diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json
index 19837c6b2c3..69716e9df97 100644
--- a/webview-ui/src/i18n/locales/en/prompts.json
+++ b/webview-ui/src/i18n/locales/en/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "None"
},
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
+ },
"roleDefinition": {
"title": "Role Definition",
"resetToDefault": "Reset to default",
diff --git a/webview-ui/src/i18n/locales/es/prompts.json b/webview-ui/src/i18n/locales/es/prompts.json
index e800cee888c..84f65595c04 100644
--- a/webview-ui/src/i18n/locales/es/prompts.json
+++ b/webview-ui/src/i18n/locales/es/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "Ninguna"
},
+ "mcpServers": {
+ "title": "Servidores MCP",
+ "description": "Configura qué servidores MCP puede acceder este modo.",
+ "useAllServers": "Usar todos los servidores (predeterminado)",
+ "useSelectedServers": "Usar solo servidores seleccionados"
+ },
"roleDefinition": {
"title": "Definición de rol",
"resetToDefault": "Restablecer a valores predeterminados",
diff --git a/webview-ui/src/i18n/locales/fr/prompts.json b/webview-ui/src/i18n/locales/fr/prompts.json
index 527109e136e..6dfb90e511f 100644
--- a/webview-ui/src/i18n/locales/fr/prompts.json
+++ b/webview-ui/src/i18n/locales/fr/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "Aucun"
},
+ "mcpServers": {
+ "title": "Serveurs MCP",
+ "description": "Configurez quels serveurs MCP ce mode peut accéder.",
+ "useAllServers": "Utiliser tous les serveurs (par défaut)",
+ "useSelectedServers": "Utiliser uniquement les serveurs sélectionnés"
+ },
"roleDefinition": {
"title": "Définition du rôle",
"resetToDefault": "Réinitialiser aux valeurs par défaut",
diff --git a/webview-ui/src/i18n/locales/hi/prompts.json b/webview-ui/src/i18n/locales/hi/prompts.json
index d493aa3430c..2d9deace57d 100644
--- a/webview-ui/src/i18n/locales/hi/prompts.json
+++ b/webview-ui/src/i18n/locales/hi/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "क्या आप वाकई इस कस्टम मोड को हटाना चाहते हैं?",
"confirm": "हटाएं",
"cancel": "रद्द करें"
+ },
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
}
}
diff --git a/webview-ui/src/i18n/locales/id/prompts.json b/webview-ui/src/i18n/locales/id/prompts.json
index 58bf91eb8b4..97ae8614284 100644
--- a/webview-ui/src/i18n/locales/id/prompts.json
+++ b/webview-ui/src/i18n/locales/id/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Apakah Anda yakin ingin menghapus mode kustom ini?",
"confirm": "Hapus",
"cancel": "Batal"
+ },
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
}
}
diff --git a/webview-ui/src/i18n/locales/it/prompts.json b/webview-ui/src/i18n/locales/it/prompts.json
index 2a0503ef71f..946e9cb5403 100644
--- a/webview-ui/src/i18n/locales/it/prompts.json
+++ b/webview-ui/src/i18n/locales/it/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Sei sicuro di voler eliminare questa modalità personalizzata?",
"confirm": "Elimina",
"cancel": "Annulla"
+ },
+ "mcpServers": {
+ "title": "Server MCP",
+ "description": "Configura quali server MCP questo modo può accedere.",
+ "useAllServers": "Usa tutti i server (predefinito)",
+ "useSelectedServers": "Usa solo i server selezionati"
}
}
diff --git a/webview-ui/src/i18n/locales/ja/prompts.json b/webview-ui/src/i18n/locales/ja/prompts.json
index 2aba5bff776..03ff0878592 100644
--- a/webview-ui/src/i18n/locales/ja/prompts.json
+++ b/webview-ui/src/i18n/locales/ja/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "なし"
},
+ "mcpServers": {
+ "title": "MCPサーバー",
+ "description": "このモードがアクセスできるMCPサーバーを設定します。",
+ "useAllServers": "すべてのサーバーを使用(デフォルト)",
+ "useSelectedServers": "選択したサーバーのみを使用"
+ },
"roleDefinition": {
"title": "役割の定義",
"resetToDefault": "デフォルトにリセット",
diff --git a/webview-ui/src/i18n/locales/ko/prompts.json b/webview-ui/src/i18n/locales/ko/prompts.json
index fd0505df73c..ed86b6f851f 100644
--- a/webview-ui/src/i18n/locales/ko/prompts.json
+++ b/webview-ui/src/i18n/locales/ko/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "없음"
},
+ "mcpServers": {
+ "title": "MCP 서버",
+ "description": "이 모드가 액세스할 수 있는 MCP 서버를 구성합니다.",
+ "useAllServers": "모든 서버 사용 (기본값)",
+ "useSelectedServers": "선택한 서버만 사용"
+ },
"roleDefinition": {
"title": "역할 정의",
"resetToDefault": "기본값으로 재설정",
diff --git a/webview-ui/src/i18n/locales/nl/prompts.json b/webview-ui/src/i18n/locales/nl/prompts.json
index 3fafb466b9f..39a91c0482a 100644
--- a/webview-ui/src/i18n/locales/nl/prompts.json
+++ b/webview-ui/src/i18n/locales/nl/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Weet je zeker dat je deze aangepaste modus wilt verwijderen?",
"confirm": "Verwijderen",
"cancel": "Annuleren"
+ },
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
}
}
diff --git a/webview-ui/src/i18n/locales/pl/prompts.json b/webview-ui/src/i18n/locales/pl/prompts.json
index ab85673c252..b1383414969 100644
--- a/webview-ui/src/i18n/locales/pl/prompts.json
+++ b/webview-ui/src/i18n/locales/pl/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Czy na pewno chcesz usunąć ten niestandardowy tryb?",
"confirm": "Usuń",
"cancel": "Anuluj"
+ },
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
}
}
diff --git a/webview-ui/src/i18n/locales/pt-BR/prompts.json b/webview-ui/src/i18n/locales/pt-BR/prompts.json
index 75d9316eee6..964a92e5cdc 100644
--- a/webview-ui/src/i18n/locales/pt-BR/prompts.json
+++ b/webview-ui/src/i18n/locales/pt-BR/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Tem certeza de que deseja excluir este modo personalizado?",
"confirm": "Excluir",
"cancel": "Cancelar"
+ },
+ "mcpServers": {
+ "title": "Servidores MCP",
+ "description": "Configure quais servidores MCP este modo pode acessar.",
+ "useAllServers": "Usar todos os servidores (padrão)",
+ "useSelectedServers": "Usar apenas servidores selecionados"
}
}
diff --git a/webview-ui/src/i18n/locales/ru/prompts.json b/webview-ui/src/i18n/locales/ru/prompts.json
index 097e1691737..9ee6a9deb51 100644
--- a/webview-ui/src/i18n/locales/ru/prompts.json
+++ b/webview-ui/src/i18n/locales/ru/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "Отсутствуют"
},
+ "mcpServers": {
+ "title": "MCP серверы",
+ "description": "Настройте, к каким серверам MCP этот режим может получить доступ.",
+ "useAllServers": "Использовать все серверы (по умолчанию)",
+ "useSelectedServers": "Использовать только выбранные серверы"
+ },
"roleDefinition": {
"title": "Определение роли",
"resetToDefault": "Сбросить по умолчанию",
diff --git a/webview-ui/src/i18n/locales/tr/prompts.json b/webview-ui/src/i18n/locales/tr/prompts.json
index 611b16eecba..0f3f73339cc 100644
--- a/webview-ui/src/i18n/locales/tr/prompts.json
+++ b/webview-ui/src/i18n/locales/tr/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Bu özel modu silmek istediğinizden emin misiniz?",
"confirm": "Sil",
"cancel": "İptal"
+ },
+ "mcpServers": {
+ "title": "MCP Sunucuları",
+ "description": "Bu modun hangi MCP sunucularına erişebileceğini yapılandırın.",
+ "useAllServers": "Tüm sunucuları kullan (varsayılan)",
+ "useSelectedServers": "Yalnızca seçili sunucuları kullan"
}
}
diff --git a/webview-ui/src/i18n/locales/vi/prompts.json b/webview-ui/src/i18n/locales/vi/prompts.json
index 9c9f59c78ac..0ed1d6e5134 100644
--- a/webview-ui/src/i18n/locales/vi/prompts.json
+++ b/webview-ui/src/i18n/locales/vi/prompts.json
@@ -207,5 +207,11 @@
"descriptionNoRules": "Bạn có chắc chắn muốn xóa chế độ tùy chỉnh này không?",
"confirm": "Xóa",
"cancel": "Hủy"
+ },
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
}
}
diff --git a/webview-ui/src/i18n/locales/zh-CN/prompts.json b/webview-ui/src/i18n/locales/zh-CN/prompts.json
index c73f2be4a2a..574a89b9740 100644
--- a/webview-ui/src/i18n/locales/zh-CN/prompts.json
+++ b/webview-ui/src/i18n/locales/zh-CN/prompts.json
@@ -31,6 +31,12 @@
},
"noTools": "无"
},
+ "mcpServers": {
+ "title": "MCP 服务器",
+ "description": "配置此模式可以访问哪些 MCP 服务器。",
+ "useAllServers": "使用所有服务器(默认)",
+ "useSelectedServers": "仅使用选定的服务器"
+ },
"roleDefinition": {
"title": "角色定义",
"resetToDefault": "重置为默认值",
diff --git a/webview-ui/src/i18n/locales/zh-TW/prompts.json b/webview-ui/src/i18n/locales/zh-TW/prompts.json
index e21f4070e82..6e55a92435d 100644
--- a/webview-ui/src/i18n/locales/zh-TW/prompts.json
+++ b/webview-ui/src/i18n/locales/zh-TW/prompts.json
@@ -206,5 +206,11 @@
"descriptionNoRules": "您確定要刪除此自訂模式嗎?",
"confirm": "刪除",
"cancel": "取消"
+ },
+ "mcpServers": {
+ "title": "MCP Servers",
+ "description": "Configure which MCP servers this mode can access.",
+ "useAllServers": "Use all servers (default)",
+ "useSelectedServers": "Use selected servers only"
}
}