From a70ca190ba5a65c29dba7524cd1e5ba0700bdc88 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Sat, 12 Apr 2025 10:27:48 +0800 Subject: [PATCH] Feat/mcp support MCP prompt (#4675) * Add MCP prompt listing and retrieval functionality * Add generic caching mechanism for MCP service methods Refactor caching strategy by implementing a higher-order withCache function to centralize cache logic and reduce code duplication. Separate implementation details from caching concerns in listTools, listPrompts and getPrompt methods. # Conflicts: # src/main/services/MCPService.ts * Add MCP prompts listing feature - Add IPC handlers for listing and getting prompts - Create UI component to display available prompts in settings tab - Improve error handling in MCP service methods * fix(McpService): add error handling for tool and prompt listing methods * feat(MCPSettings): enhance prompts and tools sections with improved UI and reset functionality * feat(i18n): add tabs and prompts sections to localization files * feat(MCPToolsButton): add MCP prompt list functionality to Inputbar * feat(McpSettings, NpxSearch): improve user feedback with success messages on server addition * feat(MCPService, MCPToolsButton): enhance prompt handling with caching and improved selection logic * feat(MCPToolsButton): enhance prompt handling with argument support and error management --------- Co-authored-by: Teo --- packages/shared/IpcChannel.ts | 2 + src/main/ipc.ts | 2 + src/main/services/MCPService.ts | 165 ++++++++++-- src/preload/index.d.ts | 10 + src/preload/index.ts | 5 +- src/renderer/src/i18n/locales/en-us.json | 13 + src/renderer/src/i18n/locales/ja-jp.json | 15 +- src/renderer/src/i18n/locales/ru-ru.json | 19 +- src/renderer/src/i18n/locales/zh-cn.json | 17 +- src/renderer/src/i18n/locales/zh-tw.json | 17 +- .../src/pages/home/Inputbar/Inputbar.tsx | 11 + .../pages/home/Inputbar/MCPToolsButton.tsx | 235 +++++++++++++++++- .../pages/settings/MCPSettings/McpPrompt.tsx | 96 +++++++ .../settings/MCPSettings/McpSettings.tsx | 113 +++++++-- .../pages/settings/MCPSettings/McpTool.tsx | 3 +- .../pages/settings/MCPSettings/NpxSearch.tsx | 2 + src/renderer/src/types/index.ts | 28 +++ 17 files changed, 686 insertions(+), 67 deletions(-) create mode 100644 src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index e5508390..3576b119 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -39,6 +39,8 @@ export enum IpcChannel { Mcp_StopServer = 'mcp:stop-server', Mcp_ListTools = 'mcp:list-tools', Mcp_CallTool = 'mcp:call-tool', + Mcp_ListPrompts = 'mcp:list-prompts', + Mcp_GetPrompt = 'mcp:get-prompt', Mcp_GetInstallInfo = 'mcp:get-install-info', Mcp_ServersChanged = 'mcp:servers-changed', Mcp_ServersUpdated = 'mcp:servers-updated', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 95d9f6f8..b4b84c63 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -262,6 +262,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer) ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools) ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool) + ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts) + ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt) ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo) ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 782b9cc9..52105be8 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -10,13 +10,47 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory' import { nanoid } from '@reduxjs/toolkit' -import { MCPServer, MCPTool } from '@types' +import { GetMCPPromptResponse, MCPPrompt, MCPServer, MCPTool } from '@types' import { app } from 'electron' import Logger from 'electron-log' import { CacheService } from './CacheService' import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient' +// Generic type for caching wrapped functions +type CachedFunction = (...args: T) => Promise + +/** + * Higher-order function to add caching capability to any async function + * @param fn The original function to be wrapped with caching + * @param getCacheKey Function to generate a cache key from the function arguments + * @param ttl Time to live for the cache entry in milliseconds + * @param logPrefix Prefix for log messages + * @returns The wrapped function with caching capability + */ +function withCache( + fn: (...args: T) => Promise, + getCacheKey: (...args: T) => string, + ttl: number, + logPrefix: string +): CachedFunction { + return async (...args: T): Promise => { + const cacheKey = getCacheKey(...args) + + if (CacheService.has(cacheKey)) { + Logger.info(`${logPrefix} loaded from cache`) + const cachedData = CacheService.get(cacheKey) + if (cachedData) { + return cachedData + } + } + + const result = await fn(...args) + CacheService.set(cacheKey, result, ttl) + return result + } +} + class McpService { private clients: Map = new Map() @@ -35,6 +69,8 @@ class McpService { this.initClient = this.initClient.bind(this) this.listTools = this.listTools.bind(this) this.callTool = this.callTool.bind(this) + this.listPrompts = this.listPrompts.bind(this) + this.getPrompt = this.getPrompt.bind(this) this.closeClient = this.closeClient.bind(this) this.removeServer = this.removeServer.bind(this) this.restartServer = this.restartServer.bind(this) @@ -216,31 +252,40 @@ class McpService { } } - async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { - const client = await this.initClient(server) - const serverKey = this.getServerKey(server) - const cacheKey = `mcp:list_tool:${serverKey}` - if (CacheService.has(cacheKey)) { - Logger.info(`[MCP] Tools from ${server.name} loaded from cache`) - const cachedTools = CacheService.get(cacheKey) - if (cachedTools && cachedTools.length > 0) { - return cachedTools - } - } + private async listToolsImpl(server: MCPServer): Promise { Logger.info(`[MCP] Listing tools for server: ${server.name}`) - const { tools } = await client.listTools() - const serverTools: MCPTool[] = [] - tools.map((tool: any) => { - const serverTool: MCPTool = { - ...tool, - id: `f${nanoid()}`, - serverId: server.id, - serverName: server.name - } - serverTools.push(serverTool) - }) - CacheService.set(cacheKey, serverTools, 5 * 60 * 1000) - return serverTools + const client = await this.initClient(server) + try { + const { tools } = await client.listTools() + const serverTools: MCPTool[] = [] + tools.map((tool: any) => { + const serverTool: MCPTool = { + ...tool, + id: `f${nanoid()}`, + serverId: server.id, + serverName: server.name + } + serverTools.push(serverTool) + }) + return serverTools + } catch (error) { + Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error) + return [] + } + } + + async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { + const cachedListTools = withCache<[MCPServer], MCPTool[]>( + this.listToolsImpl.bind(this), + (server) => { + const serverKey = this.getServerKey(server) + return `mcp:list_tool:${serverKey}` + }, + 5 * 60 * 1000, // 5 minutes TTL + `[MCP] Tools from ${server.name}` + ) + + return cachedListTools(server) } /** @@ -270,6 +315,76 @@ class McpService { return { dir, uvPath, bunPath } } + /** + * List prompts available on an MCP server + */ + private async listPromptsImpl(server: MCPServer): Promise { + Logger.info(`[MCP] Listing prompts for server: ${server.name}`) + const client = await this.initClient(server) + try { + const { prompts } = await client.listPrompts() + const serverPrompts = prompts.map((prompt: any) => ({ + ...prompt, + id: `p${nanoid()}`, + serverId: server.id, + serverName: server.name + })) + return serverPrompts + } catch (error) { + Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error) + return [] + } + } + + /** + * List prompts available on an MCP server with caching + */ + public async listPrompts(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise { + const cachedListPrompts = withCache<[MCPServer], MCPPrompt[]>( + this.listPromptsImpl.bind(this), + (server) => { + const serverKey = this.getServerKey(server) + return `mcp:list_prompts:${serverKey}` + }, + 60 * 60 * 1000, // 60 minutes TTL + `[MCP] Prompts from ${server.name}` + ) + return cachedListPrompts(server) + } + + /** + * Get a specific prompt from an MCP server (implementation) + */ + private async getPromptImpl( + server: MCPServer, + name: string, + args?: Record + ): Promise { + Logger.info(`[MCP] Getting prompt ${name} from server: ${server.name}`) + const client = await this.initClient(server) + return await client.getPrompt({ name, arguments: args }) + } + + /** + * Get a specific prompt from an MCP server with caching + */ + public async getPrompt( + _: Electron.IpcMainInvokeEvent, + { server, name, args }: { server: MCPServer; name: string; args?: Record } + ): Promise { + const cachedGetPrompt = withCache<[MCPServer, string, Record | undefined], GetMCPPromptResponse>( + this.getPromptImpl.bind(this), + (server, name, args) => { + const serverKey = this.getServerKey(server) + const argsKey = args ? JSON.stringify(args) : 'no-args' + return `mcp:get_prompt:${serverKey}:${name}:${argsKey}` + }, + 30 * 60 * 1000, // 30 minutes TTL + `[MCP] Prompt ${name} from ${server.name}` + ) + return await cachedGetPrompt(server, name, args) + } + /** * Get enhanced PATH including common tool locations */ diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a1cd1c0c..8ba792a3 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -151,6 +151,16 @@ declare global { stopServer: (server: MCPServer) => Promise listTools: (server: MCPServer) => Promise callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise + listPrompts: (server: MCPServer) => Promise + getPrompt: ({ + server, + name, + args + }: { + server: MCPServer + name: string + args?: Record + }) => Promise getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }> } copilot: { diff --git a/src/preload/index.ts b/src/preload/index.ts index a8dcf9d7..0928a56d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -130,8 +130,11 @@ const api = { restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server), stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server), listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server), - callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => + callTool: ({ server, name, args }: { server: MCPServer; name: string; args?: Record }) => ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }), + listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server), + getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record }) => + ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }), getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo) }, shell: { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5a27f32d..999c2301 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1089,11 +1089,24 @@ "url": "URL", "editMcpJson": "Edit MCP Configuration", "installHelp": "Get Installation Help", + "tabs": { + "general": "General", + "tools": "Tools", + "prompts": "Prompts", + "resources": "Resources" + }, "tools": { "inputSchema": "Input Schema", "availableTools": "Available Tools", "noToolsAvailable": "No tools available" }, + "prompts": { + "availablePrompts": "Available Prompts", + "noPromptsAvailable": "No prompts available", + "arguments": "Arguments", + "requiredField": "Required Field", + "genericError": "Get prompt Error" + }, "deleteServer": "Delete Server", "deleteServerConfirm": "Are you sure you want to delete this server?", "registry": "Package Registry", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 03d30316..57623ddd 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1088,10 +1088,23 @@ }, "editMcpJson": "MCP 設定を編集", "installHelp": "インストールヘルプを取得", + "tabs": { + "general": "一般", + "tools": "ツール", + "prompts": "プロンプト", + "resources": "リソース" + }, "tools": { "inputSchema": "入力スキーマ", "availableTools": "利用可能なツール", - "noToolsAvailable": "利用可能なツールはありません" + "noToolsAvailable": "利用可能なツールなし" + }, + "prompts": { + "availablePrompts": "利用可能なプロンプト", + "noPromptsAvailable": "利用可能なプロンプトはありません", + "arguments": "引数", + "requiredField": "必須フィールド", + "genericError": "プロンプト取得エラー" }, "deleteServer": "サーバーを削除", "deleteServerConfirm": "このサーバーを削除してもよろしいですか?", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 97a2f8e8..b2f4fc99 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1088,10 +1088,23 @@ "url": "URL", "editMcpJson": "Редактировать MCP", "installHelp": "Получить помощь по установке", + "tabs": { + "general": "Общие", + "tools": "Инструменты", + "prompts": "Подсказки", + "resources": "Ресурсы" + }, "tools": { - "inputSchema": "входные параметры", - "availableTools": "доступные инструменты", - "noToolsAvailable": "нет доступных инструментов" + "inputSchema": "Схема ввода", + "availableTools": "Доступные инструменты", + "noToolsAvailable": "Нет доступных инструментов" + }, + "prompts": { + "availablePrompts": "Доступные подсказки", + "noPromptsAvailable": "Нет доступных подсказок", + "arguments": "Аргументы", + "requiredField": "Обязательное поле", + "genericError": "Ошибка получения подсказки" }, "deleteServer": "Удалить сервер", "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index c7074215..14596ae1 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1089,10 +1089,23 @@ "url": "URL", "editMcpJson": "编辑 MCP 配置", "installHelp": "获取安装帮助", + "tabs": { + "general": "通用", + "tools": "工具", + "prompts": "提示", + "resources": "资源" + }, "tools": { - "inputSchema": "输入参数", + "inputSchema": "输入模式", "availableTools": "可用工具", - "noToolsAvailable": "没有可用工具" + "noToolsAvailable": "无可用工具" + }, + "prompts": { + "availablePrompts": "可用提示", + "noPromptsAvailable": "无可用提示", + "arguments": "参数", + "requiredField": "必填字段", + "genericError": "获取提示错误" }, "deleteServer": "删除服务器", "deleteServerConfirm": "确定要删除此服务器吗?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 1f5d71c5..0e09dfb3 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1088,10 +1088,23 @@ "url": "URL", "editMcpJson": "編輯 MCP 配置", "installHelp": "獲取安裝幫助", + "tabs": { + "general": "通用", + "tools": "工具", + "prompts": "提示", + "resources": "資源" + }, "tools": { - "inputSchema": "輸入參數", + "inputSchema": "輸入模式", "availableTools": "可用工具", - "noToolsAvailable": "沒有可用工具" + "noToolsAvailable": "無可用工具" + }, + "prompts": { + "availablePrompts": "可用提示", + "noPromptsAvailable": "無可用提示", + "arguments": "參數", + "requiredField": "必填欄位", + "genericError": "獲取提示錯誤" }, "deleteServer": "刪除伺服器", "deleteServerConfirm": "確定要刪除此伺服器嗎?", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 79f377e1..e5e5e0b7 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -358,6 +358,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = mcpToolsButtonRef.current?.openQuickPanel() } }, + { + label: 'MCP Prompt', + description: '', + icon: , + isMenu: true, + action: () => { + mcpToolsButtonRef.current?.openPromptList() + } + }, { label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), description: '', @@ -960,6 +969,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = enabledMCPs={enabledMCPs} toggelEnableMCP={toggelEnableMCP} ToolbarButton={ToolbarButton} + setInputValue={setText} + resizeTextArea={resizeTextArea} /> void + openPromptList: () => void } interface Props { ref?: React.RefObject enabledMCPs: MCPServer[] + setInputValue: React.Dispatch> + resizeTextArea: () => void toggelEnableMCP: (server: MCPServer) => void ToolbarButton: any } -const MCPToolsButton: FC = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarButton }) => { +const MCPToolsButton: FC = ({ + ref, + setInputValue, + resizeTextArea, + enabledMCPs, + toggelEnableMCP, + ToolbarButton +}) => { const { activedMcpServers } = useMCPServers() const { t } = useTranslation() const quickPanel = useQuickPanel() const navigate = useNavigate() + // Create form instance at the top level + const [form] = Form.useForm() const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id)) @@ -56,6 +68,220 @@ const MCPToolsButton: FC = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB } }) }, [menuItems, quickPanel, t]) + // Extract and format all content from the prompt response + const extractPromptContent = useCallback((response: any): string | null => { + // Handle string response (backward compatibility) + if (typeof response === 'string') { + return response + } + + // Handle GetMCPPromptResponse format + if (response && Array.isArray(response.messages)) { + let formattedContent = '' + + for (const message of response.messages) { + if (!message.content) continue + + // Add role prefix if available + const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' + + // Process different content types + switch (message.content.type) { + case 'text': + // Add formatted text content with role + formattedContent += `${rolePrefix}${message.content.text}\n\n` + break + + case 'image': + // Format image as markdown with proper attribution + if (message.content.data && message.content.mimeType) { + const imageData = message.content.data + const mimeType = message.content.mimeType + // Include role if available + if (rolePrefix) { + formattedContent += `${rolePrefix}\n` + } + formattedContent += `![Image](data:${mimeType};base64,${imageData})\n\n` + } + break + + case 'audio': + // Add indicator for audio content with role + formattedContent += `${rolePrefix}[Audio content available]\n\n` + break + + case 'resource': + // Add indicator for resource content with role + if (message.content.text) { + formattedContent += `${rolePrefix}${message.content.text}\n\n` + } else { + formattedContent += `${rolePrefix}[Resource content available]\n\n` + } + break + + default: + // Add text content if available with role, otherwise show placeholder + if (message.content.text) { + formattedContent += `${rolePrefix}${message.content.text}\n\n` + } + } + } + + return formattedContent.trim() + } + + // Fallback handling for single message format + if (response && response.messages && response.messages.length > 0) { + const message = response.messages[0] + if (message.content && message.content.text) { + const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' + return `${rolePrefix}${message.content.text}` + } + } + + return null + }, []) + + // Helper function to insert prompt into text area + const insertPromptIntoTextArea = useCallback( + (promptText: string) => { + setInputValue((prev) => { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + if (!textArea) return prev + promptText // Fallback if we can't find the textarea + + const cursorPosition = textArea.selectionStart + const selectionStart = cursorPosition + const selectionEndPosition = cursorPosition + promptText.length + const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition) + + setTimeout(() => { + textArea.focus() + textArea.setSelectionRange(selectionStart, selectionEndPosition) + resizeTextArea() + }, 10) + return newText + }) + }, + [setInputValue, resizeTextArea] + ) + + const handlePromptSelect = useCallback( + (prompt: MCPPrompt) => { + setTimeout(async () => { + const server = enabledMCPs.find((s) => s.id === prompt.serverId) + if (server) { + try { + // Check if the prompt has arguments + if (prompt.arguments && prompt.arguments.length > 0) { + // Reset form when opening a new modal + form.resetFields() + + Modal.confirm({ + title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`, + content: ( +
+ {prompt.arguments.map((arg, index) => ( + + + + ))} +
+ ), + onOk: async () => { + try { + // Validate and get form values + const values = await form.validateFields() + + const response = await window.api.mcp.getPrompt({ + server, + name: prompt.name, + args: values + }) + + // Extract and format prompt content from the response + const promptContent = extractPromptContent(response) + if (promptContent) { + insertPromptIntoTextArea(promptContent) + } else { + throw new Error('Invalid prompt response format') + } + + return Promise.resolve() + } catch (error: Error | any) { + if (error.errorFields) { + // This is a form validation error, handled by Ant Design + return Promise.reject(error) + } + + Modal.error({ + title: t('common.error'), + content: error.message || t('settings.mcp.prompts.genericError') + }) + return Promise.reject(error) + } + }, + okText: t('common.confirm'), + cancelText: t('common.cancel') + }) + } else { + // If no arguments, get the prompt directly + const response = await window.api.mcp.getPrompt({ + server, + name: prompt.name + }) + + // Extract and format prompt content from the response + const promptContent = extractPromptContent(response) + if (promptContent) { + insertPromptIntoTextArea(promptContent) + } else { + throw new Error('Invalid prompt response format') + } + } + } catch (error: Error | any) { + Modal.error({ + title: t('common.error'), + content: error.message || t('settings.mcp.prompt.genericError') + }) + } + } + }, 10) + }, + [enabledMCPs, form, t, extractPromptContent, insertPromptIntoTextArea] // Add form to dependencies + ) + + const promptList = useMemo(async () => { + const prompts: MCPPrompt[] = [] + + for (const server of enabledMCPs) { + const serverPrompts = await window.api.mcp.listPrompts(server) + prompts.push(...serverPrompts) + } + + return prompts.map((prompt) => ({ + label: prompt.name, + description: prompt.description, + icon: , + action: () => handlePromptSelect(prompt) + })) + }, [handlePromptSelect, enabledMCPs]) + + const openPromptList = useCallback(async () => { + const prompts = await promptList + quickPanel.open({ + title: t('settings.mcp.title'), + list: prompts, + symbol: 'mcp-prompt', + multiple: true + }) + }, [promptList, quickPanel, t]) const handleOpenQuickPanel = useCallback(() => { if (quickPanel.isVisible && quickPanel.symbol === 'mcp') { @@ -66,7 +292,8 @@ const MCPToolsButton: FC = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB }, [openQuickPanel, quickPanel]) useImperativeHandle(ref, () => ({ - openQuickPanel + openQuickPanel, + openPromptList })) if (activedMcpServers.length === 0) { diff --git a/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx b/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx new file mode 100644 index 00000000..641c8c41 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx @@ -0,0 +1,96 @@ +import { MCPPrompt } from '@renderer/types' +import { Collapse, Descriptions, Empty, Flex, Tag, Tooltip, Typography } from 'antd' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface MCPPromptsSectionProps { + prompts: MCPPrompt[] +} + +const MCPPromptsSection = ({ prompts }: MCPPromptsSectionProps) => { + const { t } = useTranslation() + + // Render prompt arguments + const renderPromptArguments = (prompt: MCPPrompt) => { + if (!prompt.arguments || prompt.arguments.length === 0) return null + + return ( +
+ {t('settings.mcp.tools.inputSchema')}: + + {prompt.arguments.map((arg, index) => ( + + {arg.name} + {arg.required && ( + + Required + + )} + + }> + + {arg.description && ( + + {arg.description} + + )} + + + ))} + +
+ ) + } + + return ( +
+ {t('settings.mcp.prompts.availablePrompts')} + {prompts.length > 0 ? ( + + {prompts.map((prompt) => ( + + + {prompt.name} + + {prompt.description && ( + + {prompt.description} + + )} + + }> + {renderPromptArguments(prompt)} + + ))} + + ) : ( + + )} +
+ ) +} + +const Section = styled.div` + margin-top: 8px; + padding-top: 8px; +` + +const SectionTitle = styled.h3` + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + color: var(--color-text-secondary); +` + +const SelectableContent = styled.div` + user-select: text; + padding: 0 12px; +` + +export default MCPPromptsSection diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index 8bc1b562..0c9c1f15 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -1,13 +1,14 @@ import { DeleteOutlined, SaveOutlined } from '@ant-design/icons' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { MCPServer, MCPTool } from '@renderer/types' -import { Button, Flex, Form, Input, Radio, Switch } from 'antd' +import { MCPPrompt, MCPServer, MCPTool } from '@renderer/types' +import { Button, Flex, Form, Input, Radio, Switch, Tabs } from 'antd' import TextArea from 'antd/es/input/TextArea' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..' +import MCPPromptsSection from './McpPrompt' import MCPToolsSection from './McpTool' interface Props { @@ -40,6 +41,8 @@ const PipRegistry: Registry[] = [ { name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' } ] +type TabKey = 'settings' | 'tools' | 'prompts' + const McpSettings: React.FC = ({ server }) => { const { t } = useTranslation() const { deleteMCPServer, updateMCPServer } = useMCPServers() @@ -48,8 +51,10 @@ const McpSettings: React.FC = ({ server }) => { const [loading, setLoading] = useState(false) const [isFormChanged, setIsFormChanged] = useState(false) const [loadingServer, setLoadingServer] = useState(null) + const [activeTab, setActiveTab] = useState('settings') const [tools, setTools] = useState([]) + const [prompts, setPrompts] = useState([]) const [isShowRegistry, setIsShowRegistry] = useState(false) const [registry, setRegistry] = useState() @@ -121,9 +126,28 @@ const McpSettings: React.FC = ({ server }) => { } } + const fetchPrompts = async () => { + if (server.isActive) { + try { + setLoadingServer(server.id) + const localPrompts = await window.api.mcp.listPrompts(server) + setPrompts(localPrompts) + } catch (error) { + window.message.error({ + content: t('settings.mcp.promptsLoadError') + formatError(error), + key: 'mcp-prompts-error' + }) + setPrompts([]) + } finally { + setLoadingServer(null) + } + } + } + useEffect(() => { if (server.isActive) { fetchTools() + fetchPrompts() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [server.id, server.isActive]) @@ -264,6 +288,9 @@ const McpSettings: React.FC = ({ server }) => { if (active) { const localTools = await window.api.mcp.listTools(server) setTools(localTools) + + const localPrompts = await window.api.mcp.listPrompts(server) + setPrompts(localPrompts) } else { await window.api.mcp.stopServer(server) } @@ -309,35 +336,16 @@ const McpSettings: React.FC = ({ server }) => { [server, updateMCPServer] ) - return ( - - - - - {server?.name} - {!(server.type === 'inMemory') && ( - - - - + const tabs = [ + { + key: 'settings', + label: t('settings.mcp.tabs.general'), + children: (
setIsFormChanged(true)} style={{ - // height: 'calc(100vh - var(--navbar-height) - 315px)', overflowY: 'auto', width: 'calc(100% + 10px)', paddingRight: '10px' @@ -440,7 +448,58 @@ const McpSettings: React.FC = ({ server }) => { )} - {server.isActive && } + ) + } + ] + + if (server.isActive) { + tabs.push( + { + key: 'tools', + label: t('settings.mcp.tabs.tools'), + children: + }, + { + key: 'prompts', + label: t('settings.mcp.tabs.prompts'), + children: + } + ) + } + + return ( + + + + + {server?.name} + + + + + + setActiveTab(key as TabKey)} + style={{ marginTop: 8 }} + /> ) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx index f181ecce..dfb0f032 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx @@ -112,7 +112,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps) {tool.description && ( - {tool.description} + {tool.description.length > 100 ? `${tool.description.substring(0, 100)}...` : tool.description} )} @@ -138,7 +138,6 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps) const Section = styled.div` margin-top: 8px; - border-top: 1px solid var(--color-border); padding-top: 8px; ` diff --git a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx index ec043585..99c00f7e 100644 --- a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx @@ -180,6 +180,7 @@ const NpxSearch: FC = () => { if (buildInServer) { addMCPServer(buildInServer) + window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' }) return } @@ -192,6 +193,7 @@ const NpxSearch: FC = () => { isActive: false, type: record.type }) + window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' }) }} /> diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 39919f32..49e06b4d 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -402,6 +402,34 @@ export interface MCPTool { inputSchema: MCPToolInputSchema } +export interface MCPPromptArguments { + name: string + description?: string + required?: boolean +} + +export interface MCPPrompt { + id: string + name: string + description?: string + arguments?: MCPPromptArguments[] + serverId: string + serverName: string +} + +export interface GetMCPPromptResponse { + description?: string + messages: { + role: string + content: { + type: 'text' | 'image' | 'audio' | 'resource' + text?: string + data?: string + mimeType?: string + } + }[] +} + export interface MCPConfig { servers: MCPServer[] }