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/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0a251aba5f..9e65330b3e 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" @@ -359,9 +360,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..d8d4b45c6a 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, allotjat per Exa, perquè Zoo pugui cercar i obtenir contingut web? Les sol·licituds de cerca i obtenció web s'enviaran a Exa.", + "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..295711ea91 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, von Exa gehosteten Exa MCP-Server installieren, damit Zoo das Web durchsuchen und Webinhalte abrufen kann? Websuch- und Abrufanfragen werden an Exa gesendet.", + "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..568001cfa4 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, hosted by Exa, to give Zoo web search and web fetching capabilities? Web search and fetch requests will be sent to Exa.", + "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..7e0a1d165d 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, alojado por Exa, para que Zoo pueda buscar y obtener contenido web? Las solicitudes de búsqueda y obtención web se enviarán a Exa.", + "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..813da714ee 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, hébergé par Exa, pour permettre à Zoo de rechercher et récupérer du contenu web ? Les requêtes de recherche et de récupération web seront envoyées à Exa.", + "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..a7b1f1df30 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 द्वारा होस्ट किया गया मुफ़्त Exa MCP सर्वर इंस्टॉल करना है? वेब खोज और सामग्री प्राप्त करने के अनुरोध Exa को भेजे जाएंगे।", + "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..f596e45991 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 yang dihosting oleh Exa agar Zoo dapat mencari dan mengambil konten web? Permintaan pencarian dan pengambilan web akan dikirim ke Exa.", + "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..9c47ccdf55 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, ospitato da Exa, per consentire a Zoo di cercare e recuperare contenuti web? Le richieste di ricerca e recupero web verranno inviate a Exa.", + "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..cc076c7236 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がホストする無料のExa MCPサーバーをインストールして、ZooでWeb検索とWebコンテンツ取得を利用しますか?Web検索と取得のリクエストはExaに送信されます。", + "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..a831bda966 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에서 호스팅하는 무료 Exa MCP 서버를 설치하여 Zoo에서 웹 검색과 웹 콘텐츠 가져오기를 사용하시겠어요? 웹 검색 및 가져오기 요청은 Exa로 전송됩니다.", + "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..c152107edb 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, door Exa gehoste Exa MCP-server installeren zodat Zoo het web kan doorzoeken en webinhoud kan ophalen? Zoek- en ophaalverzoeken worden naar Exa verzonden.", + "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..5e45b718be 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 hostowany przez Exa, aby Zoo mógł przeszukiwać internet i pobierać treści z sieci? Żądania wyszukiwania i pobierania treści będą wysyłane do Exa.", + "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..cc54d6b406 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, hospedado pela Exa, para que o Zoo possa pesquisar e buscar conteúdo na web? As solicitações de pesquisa e busca na web serão enviadas à Exa.", + "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..57a2b55742 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, размещённый на стороне Exa, чтобы Zoo мог искать и получать контент из интернета? Запросы на поиск и получение веб-контента будут отправляться в Exa.", + "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..2b6e3b6671 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 Exa tarafından barındırılan ücretsiz Exa MCP sunucusunu yüklemek ister misin? Web arama ve getirme istekleri Exa'ya gönderilir.", + "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..8596a1b6a0 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í do Exa lưu trữ để Zoo có thể tìm kiếm và lấy nội dung web? Các yêu cầu tìm kiếm và lấy nội dung web sẽ được gửi đến Exa.", + "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..50c0553bd7 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 托管的免费 Exa MCP 服务器,让 Zoo 能够搜索和获取网页内容?网页搜索和获取请求将发送给 Exa。", + "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..0d7cbc334f 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 託管的免費 Exa MCP 伺服器,讓 Zoo 能搜尋及擷取網頁內容嗎?網頁搜尋與擷取請求將傳送給 Exa。", + "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..cade08be69 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -152,6 +152,20 @@ const McpSettingsSchema = z.object({ mcpServers: z.record(ServerConfigSchema), }) +export const EXA_MCP_SERVER_CONFIG = { + type: "streamable-http", + url: "https://mcp.exa.ai/mcp", + alwaysAllow: [], +} as const + +const isExaServerEntry = (name: string, config: unknown): boolean => + name.toLowerCase() === "exa" || + (typeof config === "object" && + config !== null && + !Array.isArray(config) && + "url" in config && + config.url === EXA_MCP_SERVER_CONFIG.url) + export class McpHub { private providerRef: WeakRef private disposables: vscode.Disposable[] = [] @@ -504,20 +518,103 @@ export class McpHub { await provider.ensureSettingsDirectoryExists(), GlobalFileNames.mcpSettings, ) - const fileExists = await fileExistsAtPath(mcpSettingsFilePath) - if (!fileExists) { - await fs.writeFile( - mcpSettingsFilePath, - `{ - "mcpServers": { - - } -}`, - ) + if (!(await fileExistsAtPath(mcpSettingsFilePath))) { + await safeWriteJson(mcpSettingsFilePath, { mcpServers: {} }, { createOnly: true, prettyPrint: true }) } return mcpSettingsFilePath } + public hasExaServer(): boolean { + return this.connections.some((connection) => { + try { + const config: unknown = JSON.parse(connection.server.config) + return isExaServerEntry(connection.server.name, config) + } catch { + return isExaServerEntry(connection.server.name, undefined) + } + }) + } + + 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?: 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.entries(currentServers).some(([name, server]) => isExaServerEntry(name, server))) { + return + } + + 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.entries(servers).some(([name, server]) => isExaServerEntry(name, server))) { + 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) + } + const result = McpSettingsSchema.safeParse({ mcpServers: updatedServers }) + if (!result.success) { + throw new Error(`Invalid MCP servers structure: ${result.error.message}`) + } + await this.updateServerConnections(result.data.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..1fd787360f 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 = { @@ -34,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") }), })) @@ -185,6 +196,7 @@ describe("McpHub", () => { mcpHub = new McpHub(mockProvider as ClineProvider) await mcpHub.waitUntilReady() + vi.mocked(safeWriteJson).mockClear() }) afterEach(() => { @@ -212,6 +224,333 @@ 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: {} }, + { createOnly: true, 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)) + const updateConnectionsSpy = 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: [], + }, + }, + }, + expect.objectContaining({ prettyPrint: true, merge: expect.any(Function) }), + ) + expect(updateConnectionsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + existing: expect.objectContaining({ + type: "stdio", + command: "node", + timeout: 60, + alwaysAllow: [], + disabledTools: [], + }), + exa: expect.objectContaining({ + url: "https://mcp.exa.ai/mcp", + timeout: 60, + alwaysAllow: [], + disabledTools: [], + }), + }), + "global", + ) + }) + + 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("does not add Exa when the locked merge finds its endpoint under a custom name", async () => { + const initialServers = { existing: { type: "stdio", command: "node" } } + const serversAtMerge = { + ...initialServers, + "web-search": { type: "streamable-http", url: "https://mcp.exa.ai/mcp" }, + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: initialServers })) + vi.mocked(safeWriteJson).mockImplementationOnce(async (_filePath, data, options) => { + const merged = options?.merge?.({ mcpServers: serversAtMerge }, data) + expect(merged).toEqual({ mcpServers: serversAtMerge }) + }) + const updateConnectionsSpy = vi.spyOn(mcpHub, "updateServerConnections").mockResolvedValue(undefined) + + await mcpHub.installExaServer() + + expect(updateConnectionsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + existing: expect.objectContaining({ timeout: 60, alwaysAllow: [], disabledTools: [] }), + "web-search": expect.objectContaining({ timeout: 60, alwaysAllow: [], disabledTools: [] }), + }), + "global", + ) + expect(updateConnectionsSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ exa: expect.anything() }), + "global", + ) + }) + + 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" }) } }, + expect.objectContaining({ prettyPrint: true, merge: expect.any(Function) }), + ) + }) + + 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() + }) + + 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", () => { + 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("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) + 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" }), + ) + expect(context.globalState.update).toHaveBeenNthCalledWith(1, EXA_MCP_PROMPT_SHOWN_KEY, true) + expect(context.globalState.update).toHaveBeenNthCalledWith(2, EXA_MCP_PROMPT_SHOWN_KEY, false) + }) + + 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" }), + ) + expect(context.globalState.update).toHaveBeenLastCalledWith(EXA_MCP_PROMPT_SHOWN_KEY, false) + }) + }) + 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..e90a675112 --- /dev/null +++ b/src/services/mcp/promptToInstallExaMcp.ts @@ -0,0 +1,43 @@ +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 +} + +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 + } + + 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) { + await context.globalState.update(EXA_MCP_PROMPT_SHOWN_KEY, false) + const errorMessage = error instanceof Error ? error.message : String(error) + 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.