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 <cheesen.xu@gmail.com>
This commit is contained in:
LiuVaayne 2025-04-12 10:27:48 +08:00 committed by GitHub
parent 7c39116351
commit a70ca190ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 686 additions and 67 deletions

View File

@ -39,6 +39,8 @@ export enum IpcChannel {
Mcp_StopServer = 'mcp:stop-server', Mcp_StopServer = 'mcp:stop-server',
Mcp_ListTools = 'mcp:list-tools', Mcp_ListTools = 'mcp:list-tools',
Mcp_CallTool = 'mcp:call-tool', Mcp_CallTool = 'mcp:call-tool',
Mcp_ListPrompts = 'mcp:list-prompts',
Mcp_GetPrompt = 'mcp:get-prompt',
Mcp_GetInstallInfo = 'mcp:get-install-info', Mcp_GetInstallInfo = 'mcp:get-install-info',
Mcp_ServersChanged = 'mcp:servers-changed', Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated', Mcp_ServersUpdated = 'mcp:servers-updated',

View File

@ -262,6 +262,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer) ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools) ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool) 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.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))

View File

@ -10,13 +10,47 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
import { nanoid } from '@reduxjs/toolkit' import { nanoid } from '@reduxjs/toolkit'
import { MCPServer, MCPTool } from '@types' import { GetMCPPromptResponse, MCPPrompt, MCPServer, MCPTool } from '@types'
import { app } from 'electron' import { app } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
import { CacheService } from './CacheService' import { CacheService } from './CacheService'
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient' import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
// Generic type for caching wrapped functions
type CachedFunction<T extends unknown[], R> = (...args: T) => Promise<R>
/**
* 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<T extends unknown[], R>(
fn: (...args: T) => Promise<R>,
getCacheKey: (...args: T) => string,
ttl: number,
logPrefix: string
): CachedFunction<T, R> {
return async (...args: T): Promise<R> => {
const cacheKey = getCacheKey(...args)
if (CacheService.has(cacheKey)) {
Logger.info(`${logPrefix} loaded from cache`)
const cachedData = CacheService.get<R>(cacheKey)
if (cachedData) {
return cachedData
}
}
const result = await fn(...args)
CacheService.set(cacheKey, result, ttl)
return result
}
}
class McpService { class McpService {
private clients: Map<string, Client> = new Map() private clients: Map<string, Client> = new Map()
@ -35,6 +69,8 @@ class McpService {
this.initClient = this.initClient.bind(this) this.initClient = this.initClient.bind(this)
this.listTools = this.listTools.bind(this) this.listTools = this.listTools.bind(this)
this.callTool = this.callTool.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.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this) this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this) this.restartServer = this.restartServer.bind(this)
@ -216,18 +252,10 @@ class McpService {
} }
} }
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
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<MCPTool[]>(cacheKey)
if (cachedTools && cachedTools.length > 0) {
return cachedTools
}
}
Logger.info(`[MCP] Listing tools for server: ${server.name}`) Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const client = await this.initClient(server)
try {
const { tools } = await client.listTools() const { tools } = await client.listTools()
const serverTools: MCPTool[] = [] const serverTools: MCPTool[] = []
tools.map((tool: any) => { tools.map((tool: any) => {
@ -239,8 +267,25 @@ class McpService {
} }
serverTools.push(serverTool) serverTools.push(serverTool)
}) })
CacheService.set(cacheKey, serverTools, 5 * 60 * 1000)
return serverTools 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 } return { dir, uvPath, bunPath }
} }
/**
* List prompts available on an MCP server
*/
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
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<MCPPrompt[]> {
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<string, any>
): Promise<GetMCPPromptResponse> {
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<string, any> }
): Promise<GetMCPPromptResponse> {
const cachedGetPrompt = withCache<[MCPServer, string, Record<string, any> | 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 * Get enhanced PATH including common tool locations
*/ */

View File

@ -151,6 +151,16 @@ declare global {
stopServer: (server: MCPServer) => Promise<void> stopServer: (server: MCPServer) => Promise<void>
listTools: (server: MCPServer) => Promise<MCPTool[]> listTools: (server: MCPServer) => Promise<MCPTool[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any> callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
listPrompts: (server: MCPServer) => Promise<MCPPrompt[]>
getPrompt: ({
server,
name,
args
}: {
server: MCPServer
name: string
args?: Record<string, any>
}) => Promise<GetMCPPromptResponse>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }> getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
} }
copilot: { copilot: {

View File

@ -130,8 +130,11 @@ const api = {
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server), restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server), stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, 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<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }), 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<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo) getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
}, },
shell: { shell: {

View File

@ -1089,11 +1089,24 @@
"url": "URL", "url": "URL",
"editMcpJson": "Edit MCP Configuration", "editMcpJson": "Edit MCP Configuration",
"installHelp": "Get Installation Help", "installHelp": "Get Installation Help",
"tabs": {
"general": "General",
"tools": "Tools",
"prompts": "Prompts",
"resources": "Resources"
},
"tools": { "tools": {
"inputSchema": "Input Schema", "inputSchema": "Input Schema",
"availableTools": "Available Tools", "availableTools": "Available Tools",
"noToolsAvailable": "No tools available" "noToolsAvailable": "No tools available"
}, },
"prompts": {
"availablePrompts": "Available Prompts",
"noPromptsAvailable": "No prompts available",
"arguments": "Arguments",
"requiredField": "Required Field",
"genericError": "Get prompt Error"
},
"deleteServer": "Delete Server", "deleteServer": "Delete Server",
"deleteServerConfirm": "Are you sure you want to delete this server?", "deleteServerConfirm": "Are you sure you want to delete this server?",
"registry": "Package Registry", "registry": "Package Registry",

View File

@ -1088,10 +1088,23 @@
}, },
"editMcpJson": "MCP 設定を編集", "editMcpJson": "MCP 設定を編集",
"installHelp": "インストールヘルプを取得", "installHelp": "インストールヘルプを取得",
"tabs": {
"general": "一般",
"tools": "ツール",
"prompts": "プロンプト",
"resources": "リソース"
},
"tools": { "tools": {
"inputSchema": "入力スキーマ", "inputSchema": "入力スキーマ",
"availableTools": "利用可能なツール", "availableTools": "利用可能なツール",
"noToolsAvailable": "利用可能なツールはありません" "noToolsAvailable": "利用可能なツールなし"
},
"prompts": {
"availablePrompts": "利用可能なプロンプト",
"noPromptsAvailable": "利用可能なプロンプトはありません",
"arguments": "引数",
"requiredField": "必須フィールド",
"genericError": "プロンプト取得エラー"
}, },
"deleteServer": "サーバーを削除", "deleteServer": "サーバーを削除",
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?", "deleteServerConfirm": "このサーバーを削除してもよろしいですか?",

View File

@ -1088,10 +1088,23 @@
"url": "URL", "url": "URL",
"editMcpJson": "Редактировать MCP", "editMcpJson": "Редактировать MCP",
"installHelp": "Получить помощь по установке", "installHelp": "Получить помощь по установке",
"tabs": {
"general": "Общие",
"tools": "Инструменты",
"prompts": "Подсказки",
"resources": "Ресурсы"
},
"tools": { "tools": {
"inputSchema": "входные параметры", "inputSchema": "Схема ввода",
"availableTools": "доступные инструменты", "availableTools": "Доступные инструменты",
"noToolsAvailable": "нет доступных инструментов" "noToolsAvailable": "Нет доступных инструментов"
},
"prompts": {
"availablePrompts": "Доступные подсказки",
"noPromptsAvailable": "Нет доступных подсказок",
"arguments": "Аргументы",
"requiredField": "Обязательное поле",
"genericError": "Ошибка получения подсказки"
}, },
"deleteServer": "Удалить сервер", "deleteServer": "Удалить сервер",
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?", "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",

View File

@ -1089,10 +1089,23 @@
"url": "URL", "url": "URL",
"editMcpJson": "编辑 MCP 配置", "editMcpJson": "编辑 MCP 配置",
"installHelp": "获取安装帮助", "installHelp": "获取安装帮助",
"tabs": {
"general": "通用",
"tools": "工具",
"prompts": "提示",
"resources": "资源"
},
"tools": { "tools": {
"inputSchema": "输入参数", "inputSchema": "输入模式",
"availableTools": "可用工具", "availableTools": "可用工具",
"noToolsAvailable": "没有可用工具" "noToolsAvailable": "无可用工具"
},
"prompts": {
"availablePrompts": "可用提示",
"noPromptsAvailable": "无可用提示",
"arguments": "参数",
"requiredField": "必填字段",
"genericError": "获取提示错误"
}, },
"deleteServer": "删除服务器", "deleteServer": "删除服务器",
"deleteServerConfirm": "确定要删除此服务器吗?", "deleteServerConfirm": "确定要删除此服务器吗?",

View File

@ -1088,10 +1088,23 @@
"url": "URL", "url": "URL",
"editMcpJson": "編輯 MCP 配置", "editMcpJson": "編輯 MCP 配置",
"installHelp": "獲取安裝幫助", "installHelp": "獲取安裝幫助",
"tabs": {
"general": "通用",
"tools": "工具",
"prompts": "提示",
"resources": "資源"
},
"tools": { "tools": {
"inputSchema": "輸入參數", "inputSchema": "輸入模式",
"availableTools": "可用工具", "availableTools": "可用工具",
"noToolsAvailable": "沒有可用工具" "noToolsAvailable": "無可用工具"
},
"prompts": {
"availablePrompts": "可用提示",
"noPromptsAvailable": "無可用提示",
"arguments": "參數",
"requiredField": "必填欄位",
"genericError": "獲取提示錯誤"
}, },
"deleteServer": "刪除伺服器", "deleteServer": "刪除伺服器",
"deleteServerConfirm": "確定要刪除此伺服器嗎?", "deleteServerConfirm": "確定要刪除此伺服器嗎?",

View File

@ -358,6 +358,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
mcpToolsButtonRef.current?.openQuickPanel() mcpToolsButtonRef.current?.openQuickPanel()
} }
}, },
{
label: 'MCP Prompt',
description: '',
icon: <CodeOutlined />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openPromptList()
}
},
{ {
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '', description: '',
@ -960,6 +969,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
enabledMCPs={enabledMCPs} enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP} toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton} ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}
/> />
<GenerateImageButton <GenerateImageButton
model={model} model={model}

View File

@ -1,28 +1,40 @@
import { CodeOutlined, PlusOutlined } from '@ant-design/icons' import { CodeOutlined, PlusOutlined } from '@ant-design/icons'
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer } from '@renderer/types' import { MCPPrompt, MCPServer } from '@renderer/types'
import { Tooltip } from 'antd' import { Form, Input, Modal, Tooltip } from 'antd'
import { FC, useCallback, useImperativeHandle, useMemo } from 'react' import { FC, useCallback, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
export interface MCPToolsButtonRef { export interface MCPToolsButtonRef {
openQuickPanel: () => void openQuickPanel: () => void
openPromptList: () => void
} }
interface Props { interface Props {
ref?: React.RefObject<MCPToolsButtonRef | null> ref?: React.RefObject<MCPToolsButtonRef | null>
enabledMCPs: MCPServer[] enabledMCPs: MCPServer[]
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
toggelEnableMCP: (server: MCPServer) => void toggelEnableMCP: (server: MCPServer) => void
ToolbarButton: any ToolbarButton: any
} }
const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarButton }) => { const MCPToolsButton: FC<Props> = ({
ref,
setInputValue,
resizeTextArea,
enabledMCPs,
toggelEnableMCP,
ToolbarButton
}) => {
const { activedMcpServers } = useMCPServers() const { activedMcpServers } = useMCPServers()
const { t } = useTranslation() const { t } = useTranslation()
const quickPanel = useQuickPanel() const quickPanel = useQuickPanel()
const navigate = useNavigate() 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)) const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id))
@ -56,6 +68,220 @@ const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB
} }
}) })
}, [menuItems, quickPanel, t]) }, [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: (
<Form form={form} layout="vertical">
{prompt.arguments.map((arg, index) => (
<Form.Item
key={index}
name={arg.name}
label={`${arg.name}${arg.required ? ' *' : ''}`}
tooltip={arg.description}
rules={
arg.required ? [{ required: true, message: t('settings.mcp.prompts.requiredField') }] : []
}>
<Input placeholder={arg.description || arg.name} />
</Form.Item>
))}
</Form>
),
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: <CodeOutlined />,
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(() => { const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') { if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
@ -66,7 +292,8 @@ const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB
}, [openQuickPanel, quickPanel]) }, [openQuickPanel, quickPanel])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
openQuickPanel openQuickPanel,
openPromptList
})) }))
if (activedMcpServers.length === 0) { if (activedMcpServers.length === 0) {

View File

@ -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 (
<div style={{ marginTop: 12 }}>
<Typography.Title level={5}>{t('settings.mcp.tools.inputSchema')}:</Typography.Title>
<Descriptions bordered size="small" column={1} style={{ marginTop: 8 }}>
{prompt.arguments.map((arg, index) => (
<Descriptions.Item
key={index}
label={
<Flex align="center" gap={8}>
<Typography.Text strong>{arg.name}</Typography.Text>
{arg.required && (
<Tooltip title="Required field">
<Tag color="red">Required</Tag>
</Tooltip>
)}
</Flex>
}>
<Flex vertical gap={4}>
{arg.description && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{arg.description}
</Typography.Paragraph>
)}
</Flex>
</Descriptions.Item>
))}
</Descriptions>
</div>
)
}
return (
<Section>
<SectionTitle>{t('settings.mcp.prompts.availablePrompts')}</SectionTitle>
{prompts.length > 0 ? (
<Collapse bordered={false} ghost>
{prompts.map((prompt) => (
<Collapse.Panel
key={prompt.id || prompt.name}
header={
<Flex vertical align="flex-start">
<Flex align="center" style={{ width: '100%' }}>
<Typography.Text strong>{prompt.name}</Typography.Text>
</Flex>
{prompt.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{prompt.description}
</Typography.Text>
)}
</Flex>
}>
<SelectableContent>{renderPromptArguments(prompt)}</SelectableContent>
</Collapse.Panel>
))}
</Collapse>
) : (
<Empty description={t('settings.mcp.prompts.noPromptsAvailable')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Section>
)
}
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

View File

@ -1,13 +1,14 @@
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons' import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer, MCPTool } from '@renderer/types' import { MCPPrompt, MCPServer, MCPTool } from '@renderer/types'
import { Button, Flex, Form, Input, Radio, Switch } from 'antd' import { Button, Flex, Form, Input, Radio, Switch, Tabs } from 'antd'
import TextArea from 'antd/es/input/TextArea' import TextArea from 'antd/es/input/TextArea'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
import MCPPromptsSection from './McpPrompt'
import MCPToolsSection from './McpTool' import MCPToolsSection from './McpTool'
interface Props { interface Props {
@ -40,6 +41,8 @@ const PipRegistry: Registry[] = [
{ name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' } { name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' }
] ]
type TabKey = 'settings' | 'tools' | 'prompts'
const McpSettings: React.FC<Props> = ({ server }) => { const McpSettings: React.FC<Props> = ({ server }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { deleteMCPServer, updateMCPServer } = useMCPServers() const { deleteMCPServer, updateMCPServer } = useMCPServers()
@ -48,8 +51,10 @@ const McpSettings: React.FC<Props> = ({ server }) => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [isFormChanged, setIsFormChanged] = useState(false) const [isFormChanged, setIsFormChanged] = useState(false)
const [loadingServer, setLoadingServer] = useState<string | null>(null) const [loadingServer, setLoadingServer] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabKey>('settings')
const [tools, setTools] = useState<MCPTool[]>([]) const [tools, setTools] = useState<MCPTool[]>([])
const [prompts, setPrompts] = useState<MCPPrompt[]>([])
const [isShowRegistry, setIsShowRegistry] = useState(false) const [isShowRegistry, setIsShowRegistry] = useState(false)
const [registry, setRegistry] = useState<Registry[]>() const [registry, setRegistry] = useState<Registry[]>()
@ -121,9 +126,28 @@ const McpSettings: React.FC<Props> = ({ 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(() => { useEffect(() => {
if (server.isActive) { if (server.isActive) {
fetchTools() fetchTools()
fetchPrompts()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.id, server.isActive]) }, [server.id, server.isActive])
@ -264,6 +288,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
if (active) { if (active) {
const localTools = await window.api.mcp.listTools(server) const localTools = await window.api.mcp.listTools(server)
setTools(localTools) setTools(localTools)
const localPrompts = await window.api.mcp.listPrompts(server)
setPrompts(localPrompts)
} else { } else {
await window.api.mcp.stopServer(server) await window.api.mcp.stopServer(server)
} }
@ -309,35 +336,16 @@ const McpSettings: React.FC<Props> = ({ server }) => {
[server, updateMCPServer] [server, updateMCPServer]
) )
return ( const tabs = [
<SettingContainer> {
<SettingGroup style={{ marginBottom: 0 }}> key: 'settings',
<SettingTitle> label: t('settings.mcp.tabs.general'),
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}> children: (
<ServerName className="text-nowrap">{server?.name}</ServerName>
{!(server.type === 'inMemory') && (
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
)}
</Flex>
<Flex align="center" gap={16}>
<Switch
value={server.isActive}
key={server.id}
loading={loadingServer === server.id}
onChange={onToggleActive}
/>
<Button type="primary" icon={<SaveOutlined />} onClick={onSave} loading={loading} disabled={!isFormChanged}>
{t('common.save')}
</Button>
</Flex>
</SettingTitle>
<SettingDivider />
<Form <Form
form={form} form={form}
layout="vertical" layout="vertical"
onValuesChange={() => setIsFormChanged(true)} onValuesChange={() => setIsFormChanged(true)}
style={{ style={{
// height: 'calc(100vh - var(--navbar-height) - 315px)',
overflowY: 'auto', overflowY: 'auto',
width: 'calc(100% + 10px)', width: 'calc(100% + 10px)',
paddingRight: '10px' paddingRight: '10px'
@ -440,7 +448,58 @@ const McpSettings: React.FC<Props> = ({ server }) => {
</> </>
)} )}
</Form> </Form>
{server.isActive && <MCPToolsSection tools={tools} server={server} onToggleTool={handleToggleTool} />} )
}
]
if (server.isActive) {
tabs.push(
{
key: 'tools',
label: t('settings.mcp.tabs.tools'),
children: <MCPToolsSection tools={tools} server={server} onToggleTool={handleToggleTool} />
},
{
key: 'prompts',
label: t('settings.mcp.tabs.prompts'),
children: <MCPPromptsSection prompts={prompts} />
}
)
}
return (
<SettingContainer>
<SettingGroup style={{ marginBottom: 0 }}>
<SettingTitle>
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}>
<ServerName className="text-nowrap">{server?.name}</ServerName>
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
</Flex>
<Flex align="center" gap={16}>
<Switch
value={server.isActive}
key={server.id}
loading={loadingServer === server.id}
onChange={onToggleActive}
/>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={onSave}
loading={loading}
disabled={!isFormChanged || activeTab !== 'settings'}>
{t('common.save')}
</Button>
</Flex>
</SettingTitle>
<SettingDivider />
<Tabs
defaultActiveKey="settings"
items={tabs}
onChange={(key) => setActiveTab(key as TabKey)}
style={{ marginTop: 8 }}
/>
</SettingGroup> </SettingGroup>
</SettingContainer> </SettingContainer>
) )

View File

@ -112,7 +112,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
</Flex> </Flex>
{tool.description && ( {tool.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}> <Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{tool.description} {tool.description.length > 100 ? `${tool.description.substring(0, 100)}...` : tool.description}
</Typography.Text> </Typography.Text>
)} )}
</Flex> </Flex>
@ -138,7 +138,6 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
const Section = styled.div` const Section = styled.div`
margin-top: 8px; margin-top: 8px;
border-top: 1px solid var(--color-border);
padding-top: 8px; padding-top: 8px;
` `

View File

@ -180,6 +180,7 @@ const NpxSearch: FC = () => {
if (buildInServer) { if (buildInServer) {
addMCPServer(buildInServer) addMCPServer(buildInServer)
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
return return
} }
@ -192,6 +193,7 @@ const NpxSearch: FC = () => {
isActive: false, isActive: false,
type: record.type type: record.type
}) })
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
}} }}
/> />
</Flex> </Flex>

View File

@ -402,6 +402,34 @@ export interface MCPTool {
inputSchema: MCPToolInputSchema 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 { export interface MCPConfig {
servers: MCPServer[] servers: MCPServer[]
} }