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:
parent
7c39116351
commit
a70ca190ba
@ -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',
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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<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 {
|
||||
private clients: Map<string, Client> = 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<MCPTool[]>(cacheKey)
|
||||
if (cachedTools && cachedTools.length > 0) {
|
||||
return cachedTools
|
||||
}
|
||||
}
|
||||
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
|
||||
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<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
|
||||
*/
|
||||
|
||||
10
src/preload/index.d.ts
vendored
10
src/preload/index.d.ts
vendored
@ -151,6 +151,16 @@ declare global {
|
||||
stopServer: (server: MCPServer) => Promise<void>
|
||||
listTools: (server: MCPServer) => Promise<MCPTool[]>
|
||||
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 }>
|
||||
}
|
||||
copilot: {
|
||||
|
||||
@ -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<string, any> }) =>
|
||||
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)
|
||||
},
|
||||
shell: {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "このサーバーを削除してもよろしいですか?",
|
||||
|
||||
@ -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": "Вы уверены, что хотите удалить этот сервер?",
|
||||
|
||||
@ -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": "确定要删除此服务器吗?",
|
||||
|
||||
@ -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": "確定要刪除此伺服器嗎?",
|
||||
|
||||
@ -358,6 +358,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
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'),
|
||||
description: '',
|
||||
@ -960,6 +969,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
enabledMCPs={enabledMCPs}
|
||||
toggelEnableMCP={toggelEnableMCP}
|
||||
ToolbarButton={ToolbarButton}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
/>
|
||||
<GenerateImageButton
|
||||
model={model}
|
||||
|
||||
@ -1,28 +1,40 @@
|
||||
import { CodeOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { MCPPrompt, MCPServer } from '@renderer/types'
|
||||
import { Form, Input, Modal, Tooltip } from 'antd'
|
||||
import { FC, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface MCPToolsButtonRef {
|
||||
openQuickPanel: () => void
|
||||
openPromptList: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<MCPToolsButtonRef | null>
|
||||
enabledMCPs: MCPServer[]
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
toggelEnableMCP: (server: MCPServer) => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
||||
const MCPToolsButton: FC<Props> = ({
|
||||
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<Props> = ({ 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 += `\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(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
|
||||
@ -66,7 +292,8 @@ const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
openQuickPanel,
|
||||
openPromptList
|
||||
}))
|
||||
|
||||
if (activedMcpServers.length === 0) {
|
||||
|
||||
96
src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx
Normal file
96
src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx
Normal 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
|
||||
@ -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<Props> = ({ server }) => {
|
||||
const { t } = useTranslation()
|
||||
const { deleteMCPServer, updateMCPServer } = useMCPServers()
|
||||
@ -48,8 +51,10 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isFormChanged, setIsFormChanged] = useState(false)
|
||||
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('settings')
|
||||
|
||||
const [tools, setTools] = useState<MCPTool[]>([])
|
||||
const [prompts, setPrompts] = useState<MCPPrompt[]>([])
|
||||
const [isShowRegistry, setIsShowRegistry] = useState(false)
|
||||
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(() => {
|
||||
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<Props> = ({ 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<Props> = ({ server }) => {
|
||||
[server, updateMCPServer]
|
||||
)
|
||||
|
||||
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>
|
||||
{!(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 />
|
||||
const tabs = [
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('settings.mcp.tabs.general'),
|
||||
children: (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={() => 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<Props> = ({ server }) => {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</SettingContainer>
|
||||
)
|
||||
|
||||
@ -112,7 +112,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
|
||||
</Flex>
|
||||
{tool.description && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
|
||||
{tool.description}
|
||||
{tool.description.length > 100 ? `${tool.description.substring(0, 100)}...` : tool.description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
@ -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;
|
||||
`
|
||||
|
||||
|
||||
@ -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' })
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user