From 0902c660a032be5489e229054df3a98c00a41e8d Mon Sep 17 00:00:00 2001 From: Naved Merchant Date: Mon, 17 Aug 2026 22:36:58 -0700 Subject: [PATCH 1/2] feat(mcp): offer one-time Exa installation --- src/core/webview/ClineProvider.ts | 4 +- src/i18n/locales/ca/mcp.json | 8 +- src/i18n/locales/de/mcp.json | 8 +- src/i18n/locales/en/mcp.json | 8 +- src/i18n/locales/es/mcp.json | 8 +- src/i18n/locales/fr/mcp.json | 8 +- src/i18n/locales/hi/mcp.json | 8 +- src/i18n/locales/id/mcp.json | 8 +- src/i18n/locales/it/mcp.json | 8 +- src/i18n/locales/ja/mcp.json | 8 +- src/i18n/locales/ko/mcp.json | 8 +- src/i18n/locales/nl/mcp.json | 8 +- src/i18n/locales/pl/mcp.json | 8 +- src/i18n/locales/pt-BR/mcp.json | 8 +- src/i18n/locales/ru/mcp.json | 8 +- src/i18n/locales/tr/mcp.json | 8 +- src/i18n/locales/vi/mcp.json | 8 +- src/i18n/locales/zh-CN/mcp.json | 8 +- src/i18n/locales/zh-TW/mcp.json | 8 +- src/services/mcp/McpHub.ts | 60 +++++- src/services/mcp/__tests__/McpHub.spec.ts | 226 ++++++++++++++++++++++ src/services/mcp/promptToInstallExaMcp.ts | 33 ++++ 22 files changed, 422 insertions(+), 45 deletions(-) create mode 100644 src/services/mcp/promptToInstallExaMcp.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1d232d1dcb..a63623dbb3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -82,6 +82,7 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" +import { promptToInstallExaMcp } from "../../services/mcp/promptToInstallExaMcp" import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" import { CodeIndexManager } from "../../services/code-index/manager" @@ -356,9 +357,10 @@ export class ClineProvider // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) - .then((hub) => { + .then(async (hub) => { this.mcpHub = hub this.mcpHub.registerClient() + await promptToInstallExaMcp(this.context, hub) }) .catch((error) => { this.log(`Failed to initialize MCP Hub: ${error}`) diff --git a/src/i18n/locales/ca/mcp.json b/src/i18n/locales/ca/mcp.json index 697f78a31b..1bda69158b 100644 --- a/src/i18n/locales/ca/mcp.json +++ b/src/i18n/locales/ca/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Ha fallat la desconnexió de {{count}} servidor(s) MCP. Comprova la sortida per més detalls.", "toolNotFound": "L'eina '{{toolName}}' no existeix al servidor '{{serverName}}'. Eines disponibles: {{availableTools}}", "serverNotFound": "El servidor MCP '{{serverName}}' no està configurat. Servidors disponibles: {{availableServers}}", - "toolDisabled": "L'eina '{{toolName}}' del servidor '{{serverName}}' està desactivada. Eines activades disponibles: {{availableTools}}" + "toolDisabled": "L'eina '{{toolName}}' del servidor '{{serverName}}' està desactivada. Eines activades disponibles: {{availableTools}}", + "exa_install_failed": "No s'ha pogut instal·lar Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Reiniciant el servidor MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "Els servidors MCP ja s'estan actualitzant.", "refreshing_all": "Actualitzant tots els servidors MCP...", "all_refreshed": "Tots els servidors MCP han estat actualitzats.", - "project_config_deleted": "Fitxer de configuració MCP del projecte eliminat. Tots els servidors MCP del projecte han estat desconnectats." + "project_config_deleted": "Fitxer de configuració MCP del projecte eliminat. Tots els servidors MCP del projecte han estat desconnectats.", + "exa_install_prompt": "Vols instal·lar el servidor MCP gratuït d'Exa perquè Zoo pugui cercar i obtenir contingut web?", + "exa_install_action": "Instal·la Exa MCP", + "exa_install_success": "Exa MCP s'ha instal·lat correctament." }, "oauth": { "callback": { diff --git a/src/i18n/locales/de/mcp.json b/src/i18n/locales/de/mcp.json index c21374d958..52c6d38b57 100644 --- a/src/i18n/locales/de/mcp.json +++ b/src/i18n/locales/de/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Fehler beim Trennen von {{count}} MCP-Server(n). Überprüfe die Ausgabe für Details.", "toolNotFound": "Tool '{{toolName}}' existiert nicht auf Server '{{serverName}}'. Verfügbare Tools: {{availableTools}}", "serverNotFound": "MCP-Server '{{serverName}}' ist nicht konfiguriert. Verfügbare Server: {{availableServers}}", - "toolDisabled": "Tool '{{toolName}}' auf Server '{{serverName}}' ist deaktiviert. Verfügbare aktivierte Tools: {{availableTools}}" + "toolDisabled": "Tool '{{toolName}}' auf Server '{{serverName}}' ist deaktiviert. Verfügbare aktivierte Tools: {{availableTools}}", + "exa_install_failed": "Exa MCP konnte nicht installiert werden: {{errorMessage}}" }, "info": { "server_restarting": "MCP-Server {{serverName}} wird neu gestartet...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP-Server werden bereits aktualisiert.", "refreshing_all": "Alle MCP-Server werden aktualisiert...", "all_refreshed": "Alle MCP-Server wurden aktualisiert.", - "project_config_deleted": "Projekt-MCP-Konfigurationsdatei gelöscht. Alle Projekt-MCP-Server wurden getrennt." + "project_config_deleted": "Projekt-MCP-Konfigurationsdatei gelöscht. Alle Projekt-MCP-Server wurden getrennt.", + "exa_install_prompt": "Möchtest du den kostenlosen Exa MCP-Server installieren, damit Zoo das Web durchsuchen und Webinhalte abrufen kann?", + "exa_install_action": "Exa MCP installieren", + "exa_install_success": "Exa MCP wurde erfolgreich installiert." }, "oauth": { "callback": { diff --git a/src/i18n/locales/en/mcp.json b/src/i18n/locales/en/mcp.json index 565fba4caf..1237768a52 100644 --- a/src/i18n/locales/en/mcp.json +++ b/src/i18n/locales/en/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Failed to disconnect {{count}} MCP server(s). Check the output for details.", "toolNotFound": "Tool '{{toolName}}' does not exist on server '{{serverName}}'. Available tools: {{availableTools}}", "serverNotFound": "MCP server '{{serverName}}' is not configured. Available servers: {{availableServers}}", - "toolDisabled": "Tool '{{toolName}}' on server '{{serverName}}' is disabled. Available enabled tools: {{availableTools}}" + "toolDisabled": "Tool '{{toolName}}' on server '{{serverName}}' is disabled. Available enabled tools: {{availableTools}}", + "exa_install_failed": "Failed to install Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Restarting {{serverName}} MCP server...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP servers are already refreshing.", "refreshing_all": "Refreshing all MCP servers...", "all_refreshed": "All MCP servers have been refreshed.", - "project_config_deleted": "Project MCP configuration file deleted. All project MCP servers have been disconnected." + "project_config_deleted": "Project MCP configuration file deleted. All project MCP servers have been disconnected.", + "exa_install_prompt": "Install the free Exa MCP server to give Zoo web search and web fetching capabilities?", + "exa_install_action": "Install Exa MCP", + "exa_install_success": "Exa MCP installed successfully." }, "oauth": { "callback": { diff --git a/src/i18n/locales/es/mcp.json b/src/i18n/locales/es/mcp.json index 8c11fd7364..e62f2eb78a 100644 --- a/src/i18n/locales/es/mcp.json +++ b/src/i18n/locales/es/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Error al desconectar {{count}} servidor(es) MCP. Revisa la salida para más detalles.", "toolNotFound": "La herramienta '{{toolName}}' no existe en el servidor '{{serverName}}'. Herramientas disponibles: {{availableTools}}", "serverNotFound": "El servidor MCP '{{serverName}}' no está configurado. Servidores disponibles: {{availableServers}}", - "toolDisabled": "La herramienta '{{toolName}}' del servidor '{{serverName}}' está desactivada. Herramientas activadas disponibles: {{availableTools}}" + "toolDisabled": "La herramienta '{{toolName}}' del servidor '{{serverName}}' está desactivada. Herramientas activadas disponibles: {{availableTools}}", + "exa_install_failed": "No se pudo instalar Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Reiniciando el servidor MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "Los servidores MCP ya se están actualizando.", "refreshing_all": "Actualizando todos los servidores MCP...", "all_refreshed": "Todos los servidores MCP han sido actualizados.", - "project_config_deleted": "Archivo de configuración MCP del proyecto eliminado. Todos los servidores MCP del proyecto han sido desconectados." + "project_config_deleted": "Archivo de configuración MCP del proyecto eliminado. Todos los servidores MCP del proyecto han sido desconectados.", + "exa_install_prompt": "¿Quieres instalar el servidor MCP gratuito de Exa para que Zoo pueda buscar y obtener contenido web?", + "exa_install_action": "Instalar Exa MCP", + "exa_install_success": "Exa MCP se instaló correctamente." }, "oauth": { "callback": { diff --git a/src/i18n/locales/fr/mcp.json b/src/i18n/locales/fr/mcp.json index 7d302b708e..abb65d674f 100644 --- a/src/i18n/locales/fr/mcp.json +++ b/src/i18n/locales/fr/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Échec de la déconnexion de {{count}} serveur(s) MCP. Vérifiez la sortie pour plus de détails.", "toolNotFound": "L'outil '{{toolName}}' n'existe pas sur le serveur '{{serverName}}'. Outils disponibles : {{availableTools}}", "serverNotFound": "Le serveur MCP '{{serverName}}' n'est pas configuré. Serveurs disponibles : {{availableServers}}", - "toolDisabled": "L'outil '{{toolName}}' sur le serveur '{{serverName}}' est désactivé. Outils activés disponibles : {{availableTools}}" + "toolDisabled": "L'outil '{{toolName}}' sur le serveur '{{serverName}}' est désactivé. Outils activés disponibles : {{availableTools}}", + "exa_install_failed": "Impossible d'installer Exa MCP : {{errorMessage}}" }, "info": { "server_restarting": "Redémarrage du serveur MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "Les serveurs MCP sont déjà en cours de rafraîchissement.", "refreshing_all": "Rafraîchissement de tous les serveurs MCP...", "all_refreshed": "Tous les serveurs MCP ont été rafraîchis.", - "project_config_deleted": "Fichier de configuration MCP du projet supprimé. Tous les serveurs MCP du projet ont été déconnectés." + "project_config_deleted": "Fichier de configuration MCP du projet supprimé. Tous les serveurs MCP du projet ont été déconnectés.", + "exa_install_prompt": "Veux-tu installer le serveur MCP gratuit d'Exa pour permettre à Zoo de rechercher et récupérer du contenu web ?", + "exa_install_action": "Installer Exa MCP", + "exa_install_success": "Exa MCP a été installé." }, "oauth": { "callback": { diff --git a/src/i18n/locales/hi/mcp.json b/src/i18n/locales/hi/mcp.json index 6a93128779..625cf0a3fa 100644 --- a/src/i18n/locales/hi/mcp.json +++ b/src/i18n/locales/hi/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "{{count}} MCP सर्वर डिस्कनेक्ट करने में विफल। विवरण के लिए आउटपुट देखें।", "toolNotFound": "टूल '{{toolName}}' सर्वर '{{serverName}}' पर मौजूद नहीं है। उपलब्ध टूल: {{availableTools}}", "serverNotFound": "MCP सर्वर '{{serverName}}' कॉन्फ़िगर नहीं है। उपलब्ध सर्वर: {{availableServers}}", - "toolDisabled": "सर्वर '{{serverName}}' पर टूल '{{toolName}}' अक्षम है। उपलब्ध सक्षम टूल: {{availableTools}}" + "toolDisabled": "सर्वर '{{serverName}}' पर टूल '{{toolName}}' अक्षम है। उपलब्ध सक्षम टूल: {{availableTools}}", + "exa_install_failed": "Exa MCP इंस्टॉल नहीं हो सका: {{errorMessage}}" }, "info": { "server_restarting": "{{serverName}} MCP सर्वर पुनः प्रारंभ हो रहा है...", @@ -23,7 +24,10 @@ "already_refreshing": "एमसीपी सर्वर पहले से ही रीफ्रेश हो रहे हैं।", "refreshing_all": "सभी एमसीपी सर्वर रीफ्रेश हो रहे हैं...", "all_refreshed": "सभी एमसीपी सर्वर रीफ्रेश हो गए हैं।", - "project_config_deleted": "प्रोजेक्ट एमसीपी कॉन्फ़िगरेशन फ़ाइल हटा दी गई है। सभी प्रोजेक्ट एमसीपी सर्वर डिस्कनेक्ट कर दिए गए हैं।" + "project_config_deleted": "प्रोजेक्ट एमसीपी कॉन्फ़िगरेशन फ़ाइल हटा दी गई है। सभी प्रोजेक्ट एमसीपी सर्वर डिस्कनेक्ट कर दिए गए हैं।", + "exa_install_prompt": "क्या Zoo को वेब खोज और वेब सामग्री प्राप्त करने की सुविधा देने के लिए मुफ़्त Exa MCP सर्वर इंस्टॉल करना है?", + "exa_install_action": "Exa MCP इंस्टॉल करें", + "exa_install_success": "Exa MCP सफलतापूर्वक इंस्टॉल हो गया।" }, "oauth": { "callback": { diff --git a/src/i18n/locales/id/mcp.json b/src/i18n/locales/id/mcp.json index 83dcae0504..ad3b0ff90c 100644 --- a/src/i18n/locales/id/mcp.json +++ b/src/i18n/locales/id/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Gagal memutus koneksi {{count}} server MCP. Periksa output untuk detailnya.", "toolNotFound": "Tool '{{toolName}}' tidak ada di server '{{serverName}}'. Tool yang tersedia: {{availableTools}}", "serverNotFound": "Server MCP '{{serverName}}' tidak dikonfigurasi. Server yang tersedia: {{availableServers}}", - "toolDisabled": "Tool '{{toolName}}' di server '{{serverName}}' dinonaktifkan. Tool aktif yang tersedia: {{availableTools}}" + "toolDisabled": "Tool '{{toolName}}' di server '{{serverName}}' dinonaktifkan. Tool aktif yang tersedia: {{availableTools}}", + "exa_install_failed": "Gagal menginstal Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Merestart server MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "Server MCP sudah sedang di-refresh.", "refreshing_all": "Me-refresh semua server MCP...", "all_refreshed": "Semua server MCP telah di-refresh.", - "project_config_deleted": "File konfigurasi MCP proyek dihapus. Semua server MCP proyek telah diputus koneksinya." + "project_config_deleted": "File konfigurasi MCP proyek dihapus. Semua server MCP proyek telah diputus koneksinya.", + "exa_install_prompt": "Instal server MCP Exa gratis agar Zoo dapat mencari dan mengambil konten web?", + "exa_install_action": "Instal Exa MCP", + "exa_install_success": "Exa MCP berhasil diinstal." }, "oauth": { "callback": { diff --git a/src/i18n/locales/it/mcp.json b/src/i18n/locales/it/mcp.json index 5c82a7369e..7fca8d9289 100644 --- a/src/i18n/locales/it/mcp.json +++ b/src/i18n/locales/it/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Impossibile disconnettere {{count}} server MCP. Controlla l'output per i dettagli.", "toolNotFound": "Lo strumento '{{toolName}}' non esiste sul server '{{serverName}}'. Strumenti disponibili: {{availableTools}}", "serverNotFound": "Il server MCP '{{serverName}}' non è configurato. Server disponibili: {{availableServers}}", - "toolDisabled": "Lo strumento '{{toolName}}' sul server '{{serverName}}' è disabilitato. Strumenti abilitati disponibili: {{availableTools}}" + "toolDisabled": "Lo strumento '{{toolName}}' sul server '{{serverName}}' è disabilitato. Strumenti abilitati disponibili: {{availableTools}}", + "exa_install_failed": "Impossibile installare Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Riavvio del server MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "I server MCP sono già in aggiornamento.", "refreshing_all": "Aggiornamento di tutti i server MCP...", "all_refreshed": "Tutti i server MCP sono stati aggiornati.", - "project_config_deleted": "File di configurazione MCP del progetto eliminato. Tutti i server MCP del progetto sono stati disconnessi." + "project_config_deleted": "File di configurazione MCP del progetto eliminato. Tutti i server MCP del progetto sono stati disconnessi.", + "exa_install_prompt": "Vuoi installare il server MCP gratuito di Exa per consentire a Zoo di cercare e recuperare contenuti web?", + "exa_install_action": "Installa Exa MCP", + "exa_install_success": "Exa MCP è stato installato correttamente." }, "oauth": { "callback": { diff --git a/src/i18n/locales/ja/mcp.json b/src/i18n/locales/ja/mcp.json index e63ca86d56..8c11a73989 100644 --- a/src/i18n/locales/ja/mcp.json +++ b/src/i18n/locales/ja/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "{{count}}個のMCPサーバーの切断に失敗しました。詳細は出力を確認してください。", "toolNotFound": "ツール '{{toolName}}' はサーバー '{{serverName}}' に存在しません。利用可能なツール: {{availableTools}}", "serverNotFound": "MCPサーバー '{{serverName}}' は設定されていません。利用可能なサーバー: {{availableServers}}", - "toolDisabled": "サーバー '{{serverName}}' のツール '{{toolName}}' は無効です。利用可能な有効なツール: {{availableTools}}" + "toolDisabled": "サーバー '{{serverName}}' のツール '{{toolName}}' は無効です。利用可能な有効なツール: {{availableTools}}", + "exa_install_failed": "Exa MCPをインストールできませんでした: {{errorMessage}}" }, "info": { "server_restarting": "MCPサーバー{{serverName}}を再起動中...", @@ -23,7 +24,10 @@ "already_refreshing": "MCPサーバーはすでに更新中です。", "refreshing_all": "すべてのMCPサーバーを更新しています...", "all_refreshed": "すべてのMCPサーバーが更新されました。", - "project_config_deleted": "プロジェクトMCP設定ファイルが削除されました。すべてのプロジェクトMCPサーバーが切断されました。" + "project_config_deleted": "プロジェクトMCP設定ファイルが削除されました。すべてのプロジェクトMCPサーバーが切断されました。", + "exa_install_prompt": "無料のExa MCPサーバーをインストールして、ZooでWeb検索とWebコンテンツ取得を利用しますか?", + "exa_install_action": "Exa MCPをインストール", + "exa_install_success": "Exa MCPをインストールしました。" }, "oauth": { "callback": { diff --git a/src/i18n/locales/ko/mcp.json b/src/i18n/locales/ko/mcp.json index f31e234764..85c146a344 100644 --- a/src/i18n/locales/ko/mcp.json +++ b/src/i18n/locales/ko/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "{{count}}개의 MCP 서버 연결 해제 실패. 자세한 내용은 출력을 확인하세요.", "toolNotFound": "도구 '{{toolName}}'이(가) 서버 '{{serverName}}'에 존재하지 않습니다. 사용 가능한 도구: {{availableTools}}", "serverNotFound": "MCP 서버 '{{serverName}}'이(가) 구성되지 않았습니다. 사용 가능한 서버: {{availableServers}}", - "toolDisabled": "서버 '{{serverName}}'의 도구 '{{toolName}}'이(가) 비활성화되었습니다. 사용 가능한 활성화된 도구: {{availableTools}}" + "toolDisabled": "서버 '{{serverName}}'의 도구 '{{toolName}}'이(가) 비활성화되었습니다. 사용 가능한 활성화된 도구: {{availableTools}}", + "exa_install_failed": "Exa MCP를 설치하지 못했습니다: {{errorMessage}}" }, "info": { "server_restarting": "{{serverName}} MCP 서버를 재시작하는 중...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP 서버가 이미 새로 고쳐지고 있습니다.", "refreshing_all": "모든 MCP 서버를 새로 고치는 중...", "all_refreshed": "모든 MCP 서버가 새로 고쳐졌습니다.", - "project_config_deleted": "프로젝트 MCP 구성 파일이 삭제되었습니다. 모든 프로젝트 MCP 서버가 연결 해제되었습니다." + "project_config_deleted": "프로젝트 MCP 구성 파일이 삭제되었습니다. 모든 프로젝트 MCP 서버가 연결 해제되었습니다.", + "exa_install_prompt": "무료 Exa MCP 서버를 설치하여 Zoo에서 웹 검색과 웹 콘텐츠 가져오기를 사용하시겠어요?", + "exa_install_action": "Exa MCP 설치", + "exa_install_success": "Exa MCP가 설치되었습니다." }, "oauth": { "callback": { diff --git a/src/i18n/locales/nl/mcp.json b/src/i18n/locales/nl/mcp.json index da502e075f..1bbbf5c7a9 100644 --- a/src/i18n/locales/nl/mcp.json +++ b/src/i18n/locales/nl/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Loskoppelen van {{count}} MCP-server(s) mislukt. Controleer de uitvoer voor details.", "toolNotFound": "Tool '{{toolName}}' bestaat niet op server '{{serverName}}'. Beschikbare tools: {{availableTools}}", "serverNotFound": "MCP-server '{{serverName}}' is niet geconfigureerd. Beschikbare servers: {{availableServers}}", - "toolDisabled": "Tool '{{toolName}}' op server '{{serverName}}' is uitgeschakeld. Beschikbare ingeschakelde tools: {{availableTools}}" + "toolDisabled": "Tool '{{toolName}}' op server '{{serverName}}' is uitgeschakeld. Beschikbare ingeschakelde tools: {{availableTools}}", + "exa_install_failed": "Exa MCP kon niet worden geïnstalleerd: {{errorMessage}}" }, "info": { "server_restarting": "{{serverName}} MCP-server wordt opnieuw gestart...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP-servers worden al vernieuwd.", "refreshing_all": "Alle MCP-servers worden vernieuwd...", "all_refreshed": "Alle MCP-servers zijn vernieuwd.", - "project_config_deleted": "Project MCP-configuratiebestand verwijderd. Alle project MCP-servers zijn losgekoppeld." + "project_config_deleted": "Project MCP-configuratiebestand verwijderd. Alle project MCP-servers zijn losgekoppeld.", + "exa_install_prompt": "Wil je de gratis Exa MCP-server installeren zodat Zoo het web kan doorzoeken en webinhoud kan ophalen?", + "exa_install_action": "Exa MCP installeren", + "exa_install_success": "Exa MCP is geïnstalleerd." }, "oauth": { "callback": { diff --git a/src/i18n/locales/pl/mcp.json b/src/i18n/locales/pl/mcp.json index 8ca454a735..23b6edf5de 100644 --- a/src/i18n/locales/pl/mcp.json +++ b/src/i18n/locales/pl/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Nie udało się odłączyć {{count}} serwera(ów) MCP. Sprawdź dane wyjściowe, aby uzyskać szczegóły.", "toolNotFound": "Narzędzie '{{toolName}}' nie istnieje na serwerze '{{serverName}}'. Dostępne narzędzia: {{availableTools}}", "serverNotFound": "Serwer MCP '{{serverName}}' nie jest skonfigurowany. Dostępne serwery: {{availableServers}}", - "toolDisabled": "Narzędzie '{{toolName}}' na serwerze '{{serverName}}' jest wyłączone. Dostępne włączone narzędzia: {{availableTools}}" + "toolDisabled": "Narzędzie '{{toolName}}' na serwerze '{{serverName}}' jest wyłączone. Dostępne włączone narzędzia: {{availableTools}}", + "exa_install_failed": "Nie udało się zainstalować Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Ponowne uruchamianie serwera MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "Serwery MCP są już odświeżane.", "refreshing_all": "Odświeżanie wszystkich serwerów MCP...", "all_refreshed": "Wszystkie serwery MCP zostały odświeżone.", - "project_config_deleted": "Plik konfiguracyjny MCP projektu został usunięty. Wszystkie serwery MCP projektu zostały odłączone." + "project_config_deleted": "Plik konfiguracyjny MCP projektu został usunięty. Wszystkie serwery MCP projektu zostały odłączone.", + "exa_install_prompt": "Zainstalować bezpłatny serwer Exa MCP, aby Zoo mógł przeszukiwać internet i pobierać treści z sieci?", + "exa_install_action": "Zainstaluj Exa MCP", + "exa_install_success": "Exa MCP został zainstalowany." }, "oauth": { "callback": { diff --git a/src/i18n/locales/pt-BR/mcp.json b/src/i18n/locales/pt-BR/mcp.json index 03d848d20f..67c4ed891a 100644 --- a/src/i18n/locales/pt-BR/mcp.json +++ b/src/i18n/locales/pt-BR/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Falha ao desconectar {{count}} servidor(es) MCP. Verifique a saída para detalhes.", "toolNotFound": "A ferramenta '{{toolName}}' não existe no servidor '{{serverName}}'. Ferramentas disponíveis: {{availableTools}}", "serverNotFound": "O servidor MCP '{{serverName}}' não está configurado. Servidores disponíveis: {{availableServers}}", - "toolDisabled": "A ferramenta '{{toolName}}' no servidor '{{serverName}}' está desabilitada. Ferramentas habilitadas disponíveis: {{availableTools}}" + "toolDisabled": "A ferramenta '{{toolName}}' no servidor '{{serverName}}' está desabilitada. Ferramentas habilitadas disponíveis: {{availableTools}}", + "exa_install_failed": "Não foi possível instalar o Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Reiniciando o servidor MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "Os servidores MCP já estão atualizando.", "refreshing_all": "Atualizando todos os servidores MCP...", "all_refreshed": "Todos os servidores MCP foram atualizados.", - "project_config_deleted": "Arquivo de configuração MCP do projeto excluído. Todos os servidores MCP do projeto foram desconectados." + "project_config_deleted": "Arquivo de configuração MCP do projeto excluído. Todos os servidores MCP do projeto foram desconectados.", + "exa_install_prompt": "Quer instalar o servidor MCP gratuito da Exa para que o Zoo possa pesquisar e buscar conteúdo na web?", + "exa_install_action": "Instalar Exa MCP", + "exa_install_success": "Exa MCP instalado com sucesso." }, "oauth": { "callback": { diff --git a/src/i18n/locales/ru/mcp.json b/src/i18n/locales/ru/mcp.json index 98ad90dff5..b6c45a42fc 100644 --- a/src/i18n/locales/ru/mcp.json +++ b/src/i18n/locales/ru/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Не удалось отключить {{count}} MCP сервер(ов). Проверьте вывод для получения подробностей.", "toolNotFound": "Инструмент '{{toolName}}' не существует на сервере '{{serverName}}'. Доступные инструменты: {{availableTools}}", "serverNotFound": "MCP сервер '{{serverName}}' не настроен. Доступные серверы: {{availableServers}}", - "toolDisabled": "Инструмент '{{toolName}}' на сервере '{{serverName}}' отключен. Доступные включенные инструменты: {{availableTools}}" + "toolDisabled": "Инструмент '{{toolName}}' на сервере '{{serverName}}' отключен. Доступные включенные инструменты: {{availableTools}}", + "exa_install_failed": "Не удалось установить Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Перезапуск сервера MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP серверы уже обновляются.", "refreshing_all": "Обновление всех MCP серверов...", "all_refreshed": "Все MCP серверы обновлены.", - "project_config_deleted": "Файл конфигурации MCP проекта удален. Все MCP серверы проекта отключены." + "project_config_deleted": "Файл конфигурации MCP проекта удален. Все MCP серверы проекта отключены.", + "exa_install_prompt": "Установить бесплатный сервер Exa MCP, чтобы Zoo мог искать и получать контент из интернета?", + "exa_install_action": "Установить Exa MCP", + "exa_install_success": "Exa MCP успешно установлен." }, "oauth": { "callback": { diff --git a/src/i18n/locales/tr/mcp.json b/src/i18n/locales/tr/mcp.json index f3c39ebf60..a05ac5d392 100644 --- a/src/i18n/locales/tr/mcp.json +++ b/src/i18n/locales/tr/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "{{count}} MCP sunucusu bağlantısı kesilemedi. Ayrıntılar için çıktıyı kontrol edin.", "toolNotFound": "Araç '{{toolName}}' sunucu '{{serverName}}' üzerinde mevcut değil. Mevcut araçlar: {{availableTools}}", "serverNotFound": "MCP sunucusu '{{serverName}}' yapılandırılmamış. Mevcut sunucular: {{availableServers}}", - "toolDisabled": "Sunucu '{{serverName}}' üzerindeki araç '{{toolName}}' devre dışı. Mevcut etkin araçlar: {{availableTools}}" + "toolDisabled": "Sunucu '{{serverName}}' üzerindeki araç '{{toolName}}' devre dışı. Mevcut etkin araçlar: {{availableTools}}", + "exa_install_failed": "Exa MCP yüklenemedi: {{errorMessage}}" }, "info": { "server_restarting": "{{serverName}} MCP sunucusu yeniden başlatılıyor...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP sunucuları zaten yenileniyor.", "refreshing_all": "Tüm MCP sunucuları yenileniyor...", "all_refreshed": "Tüm MCP sunucuları yenilendi.", - "project_config_deleted": "Proje MCP yapılandırma dosyası silindi. Tüm proje MCP sunucuları bağlantısı kesildi." + "project_config_deleted": "Proje MCP yapılandırma dosyası silindi. Tüm proje MCP sunucuları bağlantısı kesildi.", + "exa_install_prompt": "Zoo'ya web arama ve web içeriği getirme özellikleri kazandırmak için ücretsiz Exa MCP sunucusunu yüklemek ister misin?", + "exa_install_action": "Exa MCP'yi yükle", + "exa_install_success": "Exa MCP başarıyla yüklendi." }, "oauth": { "callback": { diff --git a/src/i18n/locales/vi/mcp.json b/src/i18n/locales/vi/mcp.json index 83c5f80cfb..26cc7e13fe 100644 --- a/src/i18n/locales/vi/mcp.json +++ b/src/i18n/locales/vi/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "Không thể ngắt kết nối {{count}} máy chủ MCP. Kiểm tra đầu ra để biết chi tiết.", "toolNotFound": "Công cụ '{{toolName}}' không tồn tại trên máy chủ '{{serverName}}'. Công cụ có sẵn: {{availableTools}}", "serverNotFound": "Máy chủ MCP '{{serverName}}' chưa được cấu hình. Máy chủ có sẵn: {{availableServers}}", - "toolDisabled": "Công cụ '{{toolName}}' trên máy chủ '{{serverName}}' đã bị vô hiệu hóa. Công cụ đã kích hoạt có sẵn: {{availableTools}}" + "toolDisabled": "Công cụ '{{toolName}}' trên máy chủ '{{serverName}}' đã bị vô hiệu hóa. Công cụ đã kích hoạt có sẵn: {{availableTools}}", + "exa_install_failed": "Không thể cài đặt Exa MCP: {{errorMessage}}" }, "info": { "server_restarting": "Đang khởi động lại máy chủ MCP {{serverName}}...", @@ -23,7 +24,10 @@ "already_refreshing": "Các máy chủ MCP đã đang làm mới.", "refreshing_all": "Đang làm mới tất cả các máy chủ MCP...", "all_refreshed": "Tất cả các máy chủ MCP đã được làm mới.", - "project_config_deleted": "Tệp cấu hình MCP của dự án đã bị xóa. Tất cả các máy chủ MCP của dự án đã bị ngắt kết nối." + "project_config_deleted": "Tệp cấu hình MCP của dự án đã bị xóa. Tất cả các máy chủ MCP của dự án đã bị ngắt kết nối.", + "exa_install_prompt": "Cài đặt máy chủ Exa MCP miễn phí để Zoo có thể tìm kiếm và lấy nội dung web?", + "exa_install_action": "Cài đặt Exa MCP", + "exa_install_success": "Đã cài đặt Exa MCP thành công." }, "oauth": { "callback": { diff --git a/src/i18n/locales/zh-CN/mcp.json b/src/i18n/locales/zh-CN/mcp.json index acf32fb1d7..7f1ec176f2 100644 --- a/src/i18n/locales/zh-CN/mcp.json +++ b/src/i18n/locales/zh-CN/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "断开 {{count}} 个 MCP 服务器失败。请查看输出了解详情。", "toolNotFound": "工具 '{{toolName}}' 在服务器 '{{serverName}}' 上不存在。可用工具: {{availableTools}}", "serverNotFound": "MCP 服务器 '{{serverName}}' 未配置。可用服务器: {{availableServers}}", - "toolDisabled": "服务器 '{{serverName}}' 上的工具 '{{toolName}}' 已禁用。可用的已启用工具: {{availableTools}}" + "toolDisabled": "服务器 '{{serverName}}' 上的工具 '{{toolName}}' 已禁用。可用的已启用工具: {{availableTools}}", + "exa_install_failed": "无法安装 Exa MCP:{{errorMessage}}" }, "info": { "server_restarting": "正在重启{{serverName}}MCP服务器...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP 服务器已在刷新中。", "refreshing_all": "正在刷新所有 MCP 服务器...", "all_refreshed": "所有 MCP 服务器已刷新。", - "project_config_deleted": "项目MCP配置文件已删除。所有项目MCP服务器已断开连接。" + "project_config_deleted": "项目MCP配置文件已删除。所有项目MCP服务器已断开连接。", + "exa_install_prompt": "是否安装免费的 Exa MCP 服务器,让 Zoo 能够搜索和获取网页内容?", + "exa_install_action": "安装 Exa MCP", + "exa_install_success": "Exa MCP 安装成功。" }, "oauth": { "callback": { diff --git a/src/i18n/locales/zh-TW/mcp.json b/src/i18n/locales/zh-TW/mcp.json index 9fa07b5f11..36791002c0 100644 --- a/src/i18n/locales/zh-TW/mcp.json +++ b/src/i18n/locales/zh-TW/mcp.json @@ -11,7 +11,8 @@ "disconnect_servers_partial": "斷開 {{count}} 個 MCP 伺服器失敗。請查看輸出了解詳情。", "toolNotFound": "工具 '{{toolName}}' 在伺服器 '{{serverName}}' 上不存在。可用工具: {{availableTools}}", "serverNotFound": "MCP 伺服器 '{{serverName}}' 未設定。可用伺服器: {{availableServers}}", - "toolDisabled": "伺服器 '{{serverName}}' 上的工具 '{{toolName}}' 已停用。可用的已啟用工具: {{availableTools}}" + "toolDisabled": "伺服器 '{{serverName}}' 上的工具 '{{toolName}}' 已停用。可用的已啟用工具: {{availableTools}}", + "exa_install_failed": "無法安裝 Exa MCP:{{errorMessage}}" }, "info": { "server_restarting": "正在重啟{{serverName}}MCP 伺服器...", @@ -23,7 +24,10 @@ "already_refreshing": "MCP 伺服器已在重新整理中。", "refreshing_all": "正在重新整理所有 MCP 伺服器...", "all_refreshed": "所有 MCP 伺服器已重新整理。", - "project_config_deleted": "專案MCP設定檔案已刪除。所有專案MCP伺服器已斷開連接。" + "project_config_deleted": "專案MCP設定檔案已刪除。所有專案MCP伺服器已斷開連接。", + "exa_install_prompt": "要安裝免費的 Exa MCP 伺服器,讓 Zoo 能搜尋及擷取網頁內容嗎?", + "exa_install_action": "安裝 Exa MCP", + "exa_install_success": "Exa MCP 已成功安裝。" }, "oauth": { "callback": { diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 1374e430fe..846a01242b 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -152,6 +152,12 @@ const McpSettingsSchema = z.object({ mcpServers: z.record(ServerConfigSchema), }) +export const EXA_MCP_SERVER_CONFIG = { + type: "streamable-http", + url: "https://mcp.exa.ai/mcp", + alwaysAllow: ["web_search_exa", "web_fetch_exa"], +} as const + export class McpHub { private providerRef: WeakRef private disposables: vscode.Disposable[] = [] @@ -506,18 +512,56 @@ export class McpHub { ) const fileExists = await fileExistsAtPath(mcpSettingsFilePath) if (!fileExists) { - await fs.writeFile( - mcpSettingsFilePath, - `{ - "mcpServers": { - - } -}`, - ) + await safeWriteJson(mcpSettingsFilePath, { mcpServers: {} }, { prettyPrint: true }) } return mcpSettingsFilePath } + public hasExaServer(): boolean { + return this.connections.some((connection) => { + if (connection.server.name.toLowerCase() === "exa") { + return true + } + + try { + const config: unknown = JSON.parse(connection.server.config) + return ( + typeof config === "object" && + config !== null && + "url" in config && + config.url === EXA_MCP_SERVER_CONFIG.url + ) + } catch { + return false + } + }) + } + + public async installExaServer(): Promise { + if (this.hasExaServer()) { + return + } + + const configPath = await this.getMcpSettingsFilePath() + const content = await fs.readFile(configPath, "utf-8") + const config: unknown = JSON.parse(content) + + if (!config || typeof config !== "object") { + throw new Error("Invalid config structure") + } + + const mcpSettings = config as { mcpServers?: Record } + mcpSettings.mcpServers ??= {} + + if (Object.keys(mcpSettings.mcpServers).some((name) => name.toLowerCase() === "exa")) { + return + } + + mcpSettings.mcpServers.exa = EXA_MCP_SERVER_CONFIG + await safeWriteJson(configPath, { mcpServers: mcpSettings.mcpServers }, { prettyPrint: true }) + await this.updateServerConnections(mcpSettings.mcpServers, "global") + } + private async watchMcpSettingsFile(): Promise { // Skip if test environment is detected or VSCode APIs are not available if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) { diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 96589d8dd6..aecd904799 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1,4 +1,6 @@ import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" import type { Mock } from "vitest" import type { ExtensionContext, Uri } from "vscode" @@ -8,6 +10,7 @@ import type { ClineProvider } from "../../../core/webview/ClineProvider" import type { McpHub as McpHubType, McpConnection, ConnectedMcpConnection, DisconnectedMcpConnection } from "../McpHub" import { ServerConfigSchema, McpHub } from "../McpHub" import { OAUTH_FLOW_TIMEOUT_MS } from "../constants" +import { EXA_MCP_PROMPT_SHOWN_KEY, promptToInstallExaMcp } from "../promptToInstallExaMcp" import { t } from "../../../i18n" type McpHubPrivate = { @@ -212,6 +215,229 @@ describe("McpHub", () => { watchSpy.mockRestore() }) + describe("Exa MCP", () => { + const settingsPath = path.join("/mock/settings/path", "mcp_settings.json") + + it("creates an empty settings file when one does not exist", async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT")) + + const settingsPath = await mcpHub.getMcpSettingsFilePath() + + expect(settingsPath).toBe(path.join("/mock/settings/path", "mcp_settings.json")) + expect(safeWriteJson).toHaveBeenCalledWith(settingsPath, { mcpServers: {} }, { prettyPrint: true }) + }) + + it("detects Exa configured under a custom server name", () => { + mcpHub.connections = [ + { + type: "disconnected", + server: { + name: "web-search", + config: JSON.stringify({ type: "streamable-http", url: "https://mcp.exa.ai/mcp" }), + status: "disconnected", + source: "global", + }, + client: null, + transport: null, + }, + ] + + expect(mcpHub.hasExaServer()).toBe(true) + }) + + it("detects Exa by server name without parsing its config", () => { + mcpHub.connections = [ + { + type: "disconnected", + server: { + name: "EXA", + config: "not-json", + status: "disconnected", + source: "project", + }, + client: null, + transport: null, + }, + ] + + expect(mcpHub.hasExaServer()).toBe(true) + }) + + it("ignores malformed non-Exa server configs", () => { + mcpHub.connections = [ + { + type: "disconnected", + server: { + name: "other", + config: "not-json", + status: "disconnected", + source: "global", + }, + client: null, + transport: null, + }, + ] + + expect(mcpHub.hasExaServer()).toBe(false) + }) + + it("installs Exa without replacing existing servers", async () => { + const existingConfig = { + mcpServers: { + existing: { type: "stdio", command: "node" }, + }, + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingConfig)) + vi.spyOn(mcpHub, "updateServerConnections").mockResolvedValue(undefined) + + await mcpHub.installExaServer() + + expect(safeWriteJson).toHaveBeenCalledWith( + settingsPath, + { + mcpServers: { + existing: { type: "stdio", command: "node" }, + exa: { + type: "streamable-http", + url: "https://mcp.exa.ai/mcp", + alwaysAllow: ["web_search_exa", "web_fetch_exa"], + }, + }, + }, + { prettyPrint: true }, + ) + }) + + it("does not install Exa when the hub already contains it", async () => { + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(true) + const readCount = vi.mocked(fs.readFile).mock.calls.length + const writeCount = vi.mocked(safeWriteJson).mock.calls.length + + await mcpHub.installExaServer() + + expect(fs.readFile).toHaveBeenCalledTimes(readCount) + expect(safeWriteJson).toHaveBeenCalledTimes(writeCount) + }) + + it("does not overwrite an Exa entry found directly in the settings file", async () => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + mcpServers: { + ExA: { type: "streamable-http", url: "https://custom.example.com/mcp" }, + }, + }), + ) + + await mcpHub.installExaServer() + + expect(safeWriteJson).not.toHaveBeenCalled() + }) + + it("creates the MCP servers object when installing into a valid empty config", async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({})) + vi.spyOn(mcpHub, "updateServerConnections").mockResolvedValue(undefined) + + await mcpHub.installExaServer() + + expect(safeWriteJson).toHaveBeenCalledWith( + settingsPath, + { mcpServers: { exa: expect.objectContaining({ url: "https://mcp.exa.ai/mcp" }) } }, + { prettyPrint: true }, + ) + }) + + it("rejects invalid settings structures", async () => { + vi.mocked(fs.readFile).mockResolvedValue("null") + + await expect(mcpHub.installExaServer()).rejects.toThrow("Invalid config structure") + expect(safeWriteJson).not.toHaveBeenCalled() + }) + }) + + describe("Exa MCP installation prompt", () => { + const createPromptContext = (wasPrompted = false) => ({ + globalState: { + get: vi.fn().mockReturnValue(wasPrompted), + update: vi.fn().mockResolvedValue(undefined), + }, + }) + + it("does not prompt when Exa is already configured", async () => { + const context = createPromptContext() + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(true) + + await promptToInstallExaMcp(context, mcpHub) + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() + expect(context.globalState.update).not.toHaveBeenCalled() + }) + + it("does not prompt after it has already been shown", async () => { + const context = createPromptContext(true) + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(false) + + await promptToInstallExaMcp(context, mcpHub) + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() + }) + + it("records dismissal so the prompt is only shown once", async () => { + const context = createPromptContext() + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(false) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(undefined) + const installSpy = vi.spyOn(mcpHub, "installExaServer").mockResolvedValue(undefined) + + await promptToInstallExaMcp(context, mcpHub) + + expect(context.globalState.update).toHaveBeenCalledWith(EXA_MCP_PROMPT_SHOWN_KEY, true) + expect(installSpy).not.toHaveBeenCalled() + }) + + it("installs Exa when the user accepts", async () => { + const context = createPromptContext() + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(false) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce({ + title: t("mcp:info.exa_install_action"), + }) + const installSpy = vi.spyOn(mcpHub, "installExaServer").mockResolvedValue(undefined) + + await promptToInstallExaMcp(context, mcpHub) + + expect(installSpy).toHaveBeenCalledOnce() + expect(context.globalState.update).toHaveBeenCalledWith(EXA_MCP_PROMPT_SHOWN_KEY, true) + }) + + it("shows the installation error when installation fails", async () => { + const context = createPromptContext() + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(false) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce({ + title: t("mcp:info.exa_install_action"), + }) + vi.spyOn(mcpHub, "installExaServer").mockRejectedValue(new Error("network unavailable")) + + await promptToInstallExaMcp(context, mcpHub) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + t("mcp:errors.exa_install_failed", { errorMessage: "network unavailable" }), + ) + }) + + it("formats non-Error installation failures", async () => { + const context = createPromptContext() + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(false) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce({ + title: t("mcp:info.exa_install_action"), + }) + vi.spyOn(mcpHub, "installExaServer").mockRejectedValue("installation failed") + + await promptToInstallExaMcp(context, mcpHub) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + t("mcp:errors.exa_install_failed", { errorMessage: "installation failed" }), + ) + }) + }) + describe("Discriminated union type handling", () => { it("should create connected connections with proper type", async () => { // Mock StdioClientTransport diff --git a/src/services/mcp/promptToInstallExaMcp.ts b/src/services/mcp/promptToInstallExaMcp.ts new file mode 100644 index 0000000000..7229bfbdac --- /dev/null +++ b/src/services/mcp/promptToInstallExaMcp.ts @@ -0,0 +1,33 @@ +import * as vscode from "vscode" + +import { t } from "../../i18n" +import type { McpHub } from "./McpHub" + +export const EXA_MCP_PROMPT_SHOWN_KEY = "exaMcpInstallPromptShown" + +type ExaMcpPromptContext = { + globalState: Pick +} + +export async function promptToInstallExaMcp(context: ExaMcpPromptContext, mcpHub: McpHub): Promise { + if (mcpHub.hasExaServer() || context.globalState.get(EXA_MCP_PROMPT_SHOWN_KEY, false)) { + return + } + + await context.globalState.update(EXA_MCP_PROMPT_SHOWN_KEY, true) + + const installAction: vscode.MessageItem = { title: t("mcp:info.exa_install_action") } + const selection = await vscode.window.showInformationMessage(t("mcp:info.exa_install_prompt"), installAction) + + if (selection?.title !== installAction.title) { + return + } + + try { + await mcpHub.installExaServer() + void vscode.window.showInformationMessage(t("mcp:info.exa_install_success")) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + void vscode.window.showErrorMessage(t("mcp:errors.exa_install_failed", { errorMessage })) + } +} From 1d25ae2f7f95ecadc81107317e8696154ae51cf9 Mon Sep 17 00:00:00 2001 From: Naved Date: Sun, 30 Aug 2026 21:13:04 -0700 Subject: [PATCH 2/2] fix(mcp): address Exa installation review feedback --- src/assets/marketplace/mcps.yml | 47 ++----------- src/services/mcp/McpHub.ts | 70 ++++++++++++++++--- src/services/mcp/__tests__/McpHub.spec.ts | 83 +++++++++++++++++++++-- src/services/mcp/promptToInstallExaMcp.ts | 11 ++- src/utils/__tests__/safeWriteJson.test.ts | 24 +++++++ src/utils/safeWriteJson.ts | 21 ++++++ 6 files changed, 196 insertions(+), 60 deletions(-) diff --git a/src/assets/marketplace/mcps.yml b/src/assets/marketplace/mcps.yml index 47b6fd57d5..8b7ee1273a 100644 --- a/src/assets/marketplace/mcps.yml +++ b/src/assets/marketplace/mcps.yml @@ -837,8 +837,7 @@ items: placeholder: your_esignatures_api_secret_token - id: exa name: Exa Search - description: Enables AI assistants to perform real-time web searches using Exa's AI Search API, providing structured - results with titles, URLs, and content snippets. + description: Enables AI assistants to search the web and fetch page content through Exa's free hosted MCP server. author: exa-labs url: https://github.com/exa-labs/exa-mcp-server tags: @@ -847,51 +846,13 @@ items: - real-time-data - content-discovery - information-retrieval - prerequisites: - - Exa account content: - - name: NPX - prerequisites: - - Node.js - content: | - { - "command": "npx", - "args": ["-y", "exa-mcp-server"], - "env": { - "EXA_API_KEY": "{{EXA_API_KEY}}" - } - } - - name: NPX with Specific Tools - prerequisites: - - Node.js + - name: Hosted MCP content: | { - "command": "npx", - "args": ["-y", "exa-mcp-server", "--tools=web_search_exa,research_paper_search,company_research"], - "env": { - "EXA_API_KEY": "{{EXA_API_KEY}}" - } - } - - name: Remote MCP - prerequisites: - - Node.js - content: | - { - "command": "npx", - "args": ["-y", "mcp-remote", "https://mcp.exa.ai/mcp?exaApiKey={{EXA_API_KEY}}"] - } - - name: Smithery Installation - prerequisites: - - Node.js - content: | - { - "command": "npx", - "args": ["-y", "@smithery/cli", "install", "exa", "--client", "claude"] + "type": "streamable-http", + "url": "https://mcp.exa.ai/mcp" } - parameters: - - name: Exa API Key - key: EXA_API_KEY - placeholder: your_exa_api_key_here - id: figma-dev-mode name: Figma Dev Mode description: Brings Figma directly into your workflow by providing important design information and context to AI agents diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 846a01242b..b2ed39997d 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -155,7 +155,7 @@ const McpSettingsSchema = z.object({ export const EXA_MCP_SERVER_CONFIG = { type: "streamable-http", url: "https://mcp.exa.ai/mcp", - alwaysAllow: ["web_search_exa", "web_fetch_exa"], + alwaysAllow: [], } as const export class McpHub { @@ -510,9 +510,8 @@ export class McpHub { await provider.ensureSettingsDirectoryExists(), GlobalFileNames.mcpSettings, ) - const fileExists = await fileExistsAtPath(mcpSettingsFilePath) - if (!fileExists) { - await safeWriteJson(mcpSettingsFilePath, { mcpServers: {} }, { prettyPrint: true }) + if (!(await fileExistsAtPath(mcpSettingsFilePath))) { + await safeWriteJson(mcpSettingsFilePath, { mcpServers: {} }, { createOnly: true, prettyPrint: true }) } return mcpSettingsFilePath } @@ -550,16 +549,67 @@ export class McpHub { throw new Error("Invalid config structure") } - const mcpSettings = config as { mcpServers?: Record } - mcpSettings.mcpServers ??= {} + const mcpSettings = config as { mcpServers?: unknown } + if (mcpSettings.mcpServers === undefined) { + mcpSettings.mcpServers = {} + } else if ( + mcpSettings.mcpServers === null || + typeof mcpSettings.mcpServers !== "object" || + Array.isArray(mcpSettings.mcpServers) + ) { + throw new Error("Invalid MCP servers structure") + } + + const currentServers = mcpSettings.mcpServers as Record - if (Object.keys(mcpSettings.mcpServers).some((name) => name.toLowerCase() === "exa")) { + if (Object.keys(currentServers).some((name) => name.toLowerCase() === "exa")) { return } - mcpSettings.mcpServers.exa = EXA_MCP_SERVER_CONFIG - await safeWriteJson(configPath, { mcpServers: mcpSettings.mcpServers }, { prettyPrint: true }) - await this.updateServerConnections(mcpSettings.mcpServers, "global") + let updatedServers: Record = { ...currentServers, exa: EXA_MCP_SERVER_CONFIG } + if (this.flagResetTimer) { + clearTimeout(this.flagResetTimer) + } + this.isProgrammaticUpdate = true + try { + await safeWriteJson( + configPath, + { mcpServers: updatedServers }, + { + prettyPrint: true, + merge: (existing) => { + if (!existing || typeof existing !== "object" || Array.isArray(existing)) { + throw new Error("Invalid config structure") + } + + const existingServers = (existing as { mcpServers?: unknown }).mcpServers + if ( + existingServers !== undefined && + (existingServers === null || + typeof existingServers !== "object" || + Array.isArray(existingServers)) + ) { + throw new Error("Invalid MCP servers structure") + } + + const servers = (existingServers ?? {}) as Record + if (Object.keys(servers).some((name) => name.toLowerCase() === "exa")) { + updatedServers = servers + return { mcpServers: servers } + } + + updatedServers = { ...servers, exa: EXA_MCP_SERVER_CONFIG } + return { mcpServers: updatedServers } + }, + }, + ) + } finally { + this.flagResetTimer = setTimeout(() => { + this.isProgrammaticUpdate = false + this.flagResetTimer = undefined + }, 600) + } + await this.updateServerConnections(updatedServers, "global") } private async watchMcpSettingsFile(): Promise { diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index aecd904799..16cfdc8a37 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -37,10 +37,18 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" // Mock safeWriteJson vi.mock("../../../utils/safeWriteJson", () => ({ - safeWriteJson: vi.fn(async (filePath, data) => { + safeWriteJson: vi.fn(async (filePath, data, options?: { createOnly?: boolean }) => { // Instead of trying to write to the file system, just call fs.writeFile mock // This avoids the complex file locking and temp file operations const fs = await import("fs/promises") + if (options?.createOnly) { + try { + await fs.access(filePath) + return + } catch { + // The target is missing, so initialization may create it. + } + } return fs.writeFile(filePath, JSON.stringify(data), "utf8") }), })) @@ -188,6 +196,7 @@ describe("McpHub", () => { mcpHub = new McpHub(mockProvider as ClineProvider) await mcpHub.waitUntilReady() + vi.mocked(safeWriteJson).mockClear() }) afterEach(() => { @@ -224,7 +233,11 @@ describe("McpHub", () => { const settingsPath = await mcpHub.getMcpSettingsFilePath() expect(settingsPath).toBe(path.join("/mock/settings/path", "mcp_settings.json")) - expect(safeWriteJson).toHaveBeenCalledWith(settingsPath, { mcpServers: {} }, { prettyPrint: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + settingsPath, + { mcpServers: {} }, + { createOnly: true, prettyPrint: true }, + ) }) it("detects Exa configured under a custom server name", () => { @@ -288,7 +301,7 @@ describe("McpHub", () => { }, } vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingConfig)) - vi.spyOn(mcpHub, "updateServerConnections").mockResolvedValue(undefined) + const updateConnectionsSpy = vi.spyOn(mcpHub, "updateServerConnections").mockResolvedValue(undefined) await mcpHub.installExaServer() @@ -300,11 +313,18 @@ describe("McpHub", () => { exa: { type: "streamable-http", url: "https://mcp.exa.ai/mcp", - alwaysAllow: ["web_search_exa", "web_fetch_exa"], + alwaysAllow: [], }, }, }, - { prettyPrint: true }, + expect.objectContaining({ prettyPrint: true, merge: expect.any(Function) }), + ) + expect(updateConnectionsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + existing: { type: "stdio", command: "node" }, + exa: expect.objectContaining({ url: "https://mcp.exa.ai/mcp", alwaysAllow: [] }), + }), + "global", ) }) @@ -342,7 +362,7 @@ describe("McpHub", () => { expect(safeWriteJson).toHaveBeenCalledWith( settingsPath, { mcpServers: { exa: expect.objectContaining({ url: "https://mcp.exa.ai/mcp" }) } }, - { prettyPrint: true }, + expect.objectContaining({ prettyPrint: true, merge: expect.any(Function) }), ) }) @@ -352,6 +372,32 @@ describe("McpHub", () => { await expect(mcpHub.installExaServer()).rejects.toThrow("Invalid config structure") expect(safeWriteJson).not.toHaveBeenCalled() }) + + it.each([[], "invalid", 42])("rejects invalid MCP server containers (%j)", async (mcpServers) => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers })) + + await expect(mcpHub.installExaServer()).rejects.toThrow("Invalid MCP servers structure") + expect(safeWriteJson).not.toHaveBeenCalled() + }) + + it("suppresses file-watcher handling during the settings write", async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: {} })) + let releaseWrite: (() => void) | undefined + vi.mocked(safeWriteJson).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseWrite = resolve + }), + ) + vi.spyOn(mcpHub, "updateServerConnections").mockResolvedValue(undefined) + + const installation = mcpHub.installExaServer() + await vi.waitFor(() => expect(safeWriteJson).toHaveBeenCalled()) + + expect(mcpHub["isProgrammaticUpdate"]).toBe(true) + releaseWrite?.() + await installation + }) }) describe("Exa MCP installation prompt", () => { @@ -393,6 +439,31 @@ describe("McpHub", () => { expect(installSpy).not.toHaveBeenCalled() }) + it("shows only one notification for concurrent prompt attempts", async () => { + let releaseUpdate: (() => void) | undefined + const context = { + globalState: { + get: vi.fn().mockReturnValue(false), + update: vi.fn( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ), + }, + } + vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(false) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(undefined) + + const firstPrompt = promptToInstallExaMcp(context, mcpHub) + await vi.waitFor(() => expect(context.globalState.update).toHaveBeenCalledOnce()) + const secondPrompt = promptToInstallExaMcp(context, mcpHub) + releaseUpdate?.() + + await Promise.all([firstPrompt, secondPrompt]) + expect(vscode.window.showInformationMessage).toHaveBeenCalledOnce() + }) + it("installs Exa when the user accepts", async () => { const context = createPromptContext() vi.spyOn(mcpHub, "hasExaServer").mockReturnValue(false) diff --git a/src/services/mcp/promptToInstallExaMcp.ts b/src/services/mcp/promptToInstallExaMcp.ts index 7229bfbdac..b11e22b9ec 100644 --- a/src/services/mcp/promptToInstallExaMcp.ts +++ b/src/services/mcp/promptToInstallExaMcp.ts @@ -9,7 +9,9 @@ type ExaMcpPromptContext = { globalState: Pick } -export async function promptToInstallExaMcp(context: ExaMcpPromptContext, mcpHub: McpHub): Promise { +let promptInFlight: Promise | undefined + +async function promptToInstallExaMcpOnce(context: ExaMcpPromptContext, mcpHub: McpHub): Promise { if (mcpHub.hasExaServer() || context.globalState.get(EXA_MCP_PROMPT_SHOWN_KEY, false)) { return } @@ -31,3 +33,10 @@ export async function promptToInstallExaMcp(context: ExaMcpPromptContext, mcpHub void vscode.window.showErrorMessage(t("mcp:errors.exa_install_failed", { errorMessage })) } } + +export function promptToInstallExaMcp(context: ExaMcpPromptContext, mcpHub: McpHub): Promise { + promptInFlight ??= promptToInstallExaMcpOnce(context, mcpHub).finally(() => { + promptInFlight = undefined + }) + return promptInFlight +} diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..9ebea48ea6 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -2,6 +2,7 @@ import * as fsSyncActual from "fs" import { Writable } from "stream" import * as path from "path" import * as os from "os" +import * as lockfile from "proper-lockfile" import { safeWriteJson } from "../safeWriteJson" @@ -292,6 +293,29 @@ describe("safeWriteJson", () => { expect(content).toEqual(data) }) + test("should not overwrite a file created while waiting for a create-only lock", async () => { + const filePath = path.join(tempDir, "create-only.json") + const releaseLock = await lockfile.lock(filePath, { realpath: false }) + + const initialization = safeWriteJson(filePath, { mcpServers: {} }, { createOnly: true }) + const userSettings = { mcpServers: { existing: { command: "node" } } } + await fsPromisesActuals.writeFile!(filePath, JSON.stringify(userSettings)) + await releaseLock() + + await initialization + + expect(await readFileContent(filePath)).toEqual(userSettings) + }) + + test("should create a missing file when createOnly is enabled", async () => { + const filePath = path.join(tempDir, "create-only.json") + const data = { mcpServers: {} } + + await safeWriteJson(filePath, data, { createOnly: true }) + + expect(await readFileContent(filePath)).toEqual(data) + }) + test("should handle failure when deleting tempBackupFilePath (filePath exists, all renames succeed)", async () => { const initialData = { message: "Initial content" } const newData = { message: "Successfully written new content" } diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 957a0bb20f..0fbafe4c0c 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -16,6 +16,14 @@ export interface SafeWriteJsonOptions { */ prettyPrint?: boolean + /** + * Only writes when the target does not exist at the time the advisory lock + * is held. This is useful for initializing files without overwriting content + * created by another process between a caller's existence check and write. + * @default false + */ + createOnly?: boolean + /** * When provided, the current file is read under the advisory lock * and passed to this function along with the incoming data. The @@ -93,6 +101,19 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso let actualTempBackupFilePath: string | null = null try { + if (options?.createOnly) { + try { + await fs.access(absoluteFilePath) + return + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined + if (code !== "ENOENT") { + throw error + } + } + } + // If a merge callback was provided, read the current file under the lock // and let the caller merge before we write. Must be inside try/finally // so a throwing merge still releases the lock.