feat: add Model Context Protocol (MCP) support (#2809)

*  feat: add Model Context Protocol (MCP) server configuration (main)

- Added `@modelcontextprotocol/sdk` dependency for MCP integration.
- Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities.
- Created `useMCPServers` hook to manage MCP server state and actions.
- Added i18n support for MCP settings with translation keys.
- Integrated MCP settings into the application's settings navigation and routing.
- Implemented Redux state management for MCP servers.
- Updated `yarn.lock` with new dependencies and their resolutions.

* 🌟 feat: implement mcp service and integrate with ipc handlers

- Added `MCPService` class to manage Model Context Protocol servers.
- Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers.
- Integrated MCP related types into existing type declarations for consistency across the application.
- Updated `preload` to expose new MCP related APIs to the renderer process.
- Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states.
- Introduced selectors in the MCP Redux slice for fetching active and all servers from the store.
- Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application.

* feat: enhance MCPService initialization to prevent recursive calls and improve error handling

* feat: enhance MCP integration by adding MCPTool type and updating related methods

* feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing
This commit is contained in:
LiuVaayne
2025-03-05 17:59:08 +08:00
committed by kangfenmao
parent 4a8307195b
commit eda82fa23e
18 changed files with 1609 additions and 97 deletions
+44 -3
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs'
import path from 'node:path'
import { Shortcut, ThemeMode } from '@types'
import { MCPServer, Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
import log from 'electron-log'
@@ -14,17 +14,18 @@ import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import MCPService from './services/mcp'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { getResourcePath } from './utils'
import { decrypt } from './utils/aes'
import { encrypt } from './utils/aes'
import { decrypt, encrypt } from './utils/aes'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
const mcpService = new MCPService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
@@ -210,4 +211,44 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) =>
decrypt(encryptedData, iv, secretKey)
)
// Register MCP handlers
ipcMain.handle('mcp:list-servers', async () => {
return mcpService.listAvailableServices()
})
ipcMain.handle('mcp:add-server', async (_, server: MCPServer) => {
return mcpService.addServer(server)
})
ipcMain.handle('mcp:update-server', async (_, server: MCPServer) => {
return mcpService.updateServer(server)
})
ipcMain.handle('mcp:delete-server', async (_, serverName: string) => {
return mcpService.deleteServer(serverName)
})
ipcMain.handle('mcp:set-server-active', async (_, { name, isActive }) => {
return mcpService.setServerActive({ name, isActive })
})
// According to preload, this should take no parameters, but our implementation accepts
// an optional serverName for better flexibility
ipcMain.handle('mcp:list-tools', async (_, serverName?: string) => {
return mcpService.listTools(serverName)
})
ipcMain.handle('mcp:call-tool', async (_, params: { client: string; name: string; args: any }) => {
return mcpService.callTool(params)
})
ipcMain.handle('mcp:cleanup', async () => {
return mcpService.cleanup()
})
// Clean up MCP services when app quits
app.on('before-quit', async () => {
await mcpService.cleanup()
})
}
+337
View File
@@ -0,0 +1,337 @@
import { MCPServer, MCPTool } from '@types'
import log from 'electron-log'
import Store from 'electron-store'
import { EventEmitter } from 'events'
const store = new Store()
export default class MCPService extends EventEmitter {
private activeServers: Map<string, any> = new Map()
private clients: { [key: string]: any } = {}
private Client: any
private Transport: any
private initialized = false
private initPromise: Promise<void> | null = null
constructor() {
super()
this.init().catch((err) => {
log.error('[MCP] Failed to initialize MCP service:', err)
})
}
private getServersFromStore(): MCPServer[] {
return store.get('mcp.servers', []) as MCPServer[]
}
public async init() {
// If already initialized, return immediately
if (this.initialized) return
// If initialization is in progress, return that promise
if (this.initPromise) return this.initPromise
// Create and store the initialization promise
this.initPromise = (async () => {
try {
log.info('[MCP] Starting initialization')
this.Client = await this.importClient()
this.Transport = await this.importTransport()
// Mark as initialized before loading servers to prevent recursive initialization
this.initialized = true
await this.load(this.getServersFromStore())
log.info('[MCP] Initialization completed successfully')
} catch (err) {
this.initialized = false // Reset flag on error
log.error('[MCP] Failed to initialize:', err)
throw err
} finally {
this.initPromise = null
}
})()
return this.initPromise
}
private async importClient() {
try {
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
return Client
} catch (err) {
log.error('[MCP] Failed to import Client:', err)
throw err
}
}
private async importTransport() {
try {
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
return StdioClientTransport
} catch (err) {
log.error('[MCP] Failed to import Transport:', err)
throw err
}
}
public async listAvailableServices(): Promise<MCPServer[]> {
await this.ensureInitialized()
return this.getServersFromStore()
}
private async ensureInitialized() {
if (!this.initialized) {
log.debug('[MCP] Ensuring initialization')
await this.init()
}
}
public async addServer(server: MCPServer): Promise<void> {
await this.ensureInitialized()
try {
const servers = this.getServersFromStore()
if (servers.some((s) => s.name === server.name)) {
throw new Error(`Server with name ${server.name} already exists`)
}
servers.push(server)
store.set('mcp.servers', servers)
if (server.isActive) {
await this.activate(server)
}
} catch (error) {
log.error('Failed to add MCP server:', error)
throw error
}
}
public async updateServer(server: MCPServer): Promise<void> {
await this.ensureInitialized()
try {
const servers = this.getServersFromStore()
const index = servers.findIndex((s) => s.name === server.name)
if (index === -1) {
throw new Error(`Server ${server.name} not found`)
}
const wasActive = servers[index].isActive
if (wasActive && !server.isActive) {
await this.deactivate(server.name)
} else if (!wasActive && server.isActive) {
await this.activate(server)
}
servers[index] = server
store.set('mcp.servers', servers)
} catch (error) {
log.error('Failed to update MCP server:', error)
throw error
}
}
public async deleteServer(serverName: string): Promise<void> {
await this.ensureInitialized()
try {
if (this.clients[serverName]) {
await this.deactivate(serverName)
}
const servers = this.getServersFromStore()
const filteredServers = servers.filter((s) => s.name !== serverName)
store.set('mcp.servers', filteredServers)
} catch (error) {
log.error('Failed to delete MCP server:', error)
throw error
}
}
public async setServerActive(params: { name: string; isActive: boolean }): Promise<void> {
await this.ensureInitialized()
try {
const { name, isActive } = params
const servers = this.getServersFromStore()
const server = servers.find((s) => s.name === name)
if (!server) {
throw new Error(`Server ${name} not found`)
}
server.isActive = isActive
store.set('mcp.servers', servers)
if (isActive) {
await this.activate(server)
} else {
await this.deactivate(name)
}
} catch (error) {
log.error('Failed to set MCP server active status:', error)
throw error
}
}
public async activate(server: MCPServer): Promise<void> {
await this.ensureInitialized()
try {
const { name, command, args, env } = server
if (this.clients[name]) {
log.info(`[MCP] Server ${name} is already running`)
return
}
let cmd: string = command
if (command === 'npx') {
cmd = process.platform === 'win32' ? `${command}.cmd` : command
}
const mergedEnv = {
...env,
PATH: process.env.PATH
}
const client = new this.Client(
{
name: name,
version: '1.0.0'
},
{
capabilities: {}
}
)
const transport = new this.Transport({
command: cmd,
args,
stderr: process.platform === 'win32' ? 'pipe' : 'inherit',
env: mergedEnv
})
await client.connect(transport)
this.clients[name] = client
this.activeServers.set(name, { client, server })
log.info(`[MCP] Server ${name} started successfully`)
this.emit('server-started', { name })
} catch (error) {
log.error('[MCP] Error activating server:', error)
throw error
}
}
public async deactivate(name: string): Promise<void> {
await this.ensureInitialized()
try {
if (this.clients[name]) {
log.info(`[MCP] Stopping server: ${name}`)
await this.clients[name].close()
delete this.clients[name]
this.activeServers.delete(name)
this.emit('server-stopped', { name })
} else {
log.warn(`[MCP] Server ${name} is not running`)
}
} catch (error) {
log.error('[MCP] Error deactivating server:', error)
throw error
}
}
public async listTools(serverName?: string): Promise<MCPTool[]> {
await this.ensureInitialized()
try {
if (serverName) {
if (!this.clients[serverName]) {
throw new Error(`MCP Client ${serverName} not found`)
}
const { tools } = await this.clients[serverName].listTools()
return tools.map((tool: any) => {
return tool
})
} else {
let allTools: MCPTool[] = []
for (const clientName in this.clients) {
try {
const { tools } = await this.clients[clientName].listTools()
log.info(`[MCP] Tools for ${clientName}:`, tools)
allTools = allTools.concat(
tools.map((tool: MCPTool) => {
tool.serverName = clientName
return tool
})
)
} catch (error) {
log.error(`[MCP] Error listing tools for ${clientName}:`, error)
}
}
log.info(`[MCP] Total tools listed: ${allTools.length}`)
return allTools
}
} catch (error) {
log.error('[MCP] Error listing tools:', error)
return []
}
}
public async callTool(params: { client: string; name: string; args: any }): Promise<any> {
await this.ensureInitialized()
try {
const { client, name, args } = params
if (!this.clients[client]) {
throw new Error(`MCP Client ${client} not found`)
}
log.info('[MCP] Calling:', client, name, args)
const result = await this.clients[client].callTool({
name,
arguments: args
})
return result
} catch (error) {
log.error(`[MCP] Error calling tool ${params.name} on ${params.client}:`, error)
throw error
}
}
public async cleanup(): Promise<void> {
try {
for (const name in this.clients) {
await this.deactivate(name).catch((err) => {
log.error(`[MCP] Error during cleanup of ${name}:`, err)
})
}
this.clients = {}
this.activeServers.clear()
log.info('[MCP] All servers cleaned up')
} catch (error) {
log.error('[MCP] Failed to clean up servers:', error)
throw error
}
}
public async load(servers: MCPServer[]): Promise<void> {
log.info(`[MCP] Loading ${servers.length} servers`)
const activeServers = servers.filter((server) => server.isActive)
if (activeServers.length === 0) {
log.info('[MCP] No active servers to load')
return
}
for (const server of activeServers) {
log.info(`[MCP] Activating server: ${server.name}`)
try {
await this.activate(server)
log.info(`[MCP] Successfully activated server: ${server.name}`)
} catch (error) {
log.error(`[MCP] Failed to activate server ${server.name}:`, error)
this.emit('server-error', { name: server.name, error })
}
}
log.info(`[MCP] Loaded and activated ${Object.keys(this.clients).length} servers`)
}
}
+14 -3
View File
@@ -1,9 +1,7 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { FileType } from '@renderer/types'
import { WebDavConfig } from '@renderer/types'
import { AppInfo, KnowledgeBaseParams, KnowledgeItem, LanguageVarious } from '@renderer/types'
import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
import type { LoaderReturn } from '@shared/config/types'
import type { OpenDialogOptions } from 'electron'
import type { UpdateInfo } from 'electron-updater'
@@ -123,6 +121,19 @@ declare global {
shell: {
openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>
}
mcp: {
// servers
listServers: () => Promise<MCPServer[]>
addServer: (server: MCPServer) => Promise<void>
updateServer: (server: MCPServer) => Promise<void>
deleteServer: (serverName: string) => Promise<void>
setServerActive: (name: string, isActive: boolean) => Promise<void>
// tools
listTools: () => Promise<MCPTool>
callTool: ({ client, name, args }: { client: string; name: string; args: any }) => Promise<any>
// status
cleanup: () => Promise<void>
}
}
}
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { electronAPI } from '@electron-toolkit/preload'
import { FileType, KnowledgeBaseParams, KnowledgeItem, Shortcut, WebDavConfig } from '@types'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
// Custom APIs for renderer
@@ -106,6 +106,17 @@ const api = {
decrypt: (encryptedData: string, iv: string, secretKey: string) =>
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
},
mcp: {
listServers: () => ipcRenderer.invoke('mcp:list-servers'),
addServer: (server: MCPServer) => ipcRenderer.invoke('mcp:add-server', server),
updateServer: (server: MCPServer) => ipcRenderer.invoke('mcp:update-server', server),
deleteServer: (serverName: string) => ipcRenderer.invoke('mcp:delete-server', serverName),
setServerActive: (name: string, isActive: boolean) =>
ipcRenderer.invoke('mcp:set-server-active', { name, isActive }),
listTools: (serverName?: string) => ipcRenderer.invoke('mcp:list-tools', serverName),
callTool: (params: { client: string; name: string; args: any }) => ipcRenderer.invoke('mcp:call-tool', params),
cleanup: () => ipcRenderer.invoke('mcp:cleanup')
},
shell: {
openExternal: shell.openExternal
}
+1 -2
View File
@@ -19,8 +19,7 @@ import {
updateItemProcessingStatus,
updateNotes
} from '@renderer/store/knowledge'
import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types'
import { KnowledgeItem } from '@renderer/types'
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
+42
View File
@@ -0,0 +1,42 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addMCPServer as _addMCPServer,
deleteMCPServer as _deleteMCPServer,
setMCPServerActive as _setMCPServerActive,
updateMCPServer as _updateMCPServer
} from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
export const useMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers)
const dispatch = useAppDispatch()
const addMCPServer = (server: MCPServer) => {
dispatch(_addMCPServer(server))
}
const updateMCPServer = (server: MCPServer) => {
dispatch(_updateMCPServer(server))
}
const deleteMCPServer = (name: string) => {
dispatch(_deleteMCPServer(name))
}
const setMCPServerActive = (name: string, isActive: boolean) => {
dispatch(_setMCPServerActive({ name, isActive }))
}
const getActiveMCPServers = () => {
return mcpServers.filter((server) => server.isActive)
}
return {
mcpServers,
addMCPServer,
updateMCPServer,
deleteMCPServer,
setMCPServerActive,
getActiveMCPServers
}
}
+26
View File
@@ -859,6 +859,32 @@
"blacklist_tooltip": "Please use the following format (separated by line breaks)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"search_max_result": "Number of search results",
"search_result_default": "Default"
},
"mcp": {
"title": "MCP Servers",
"config_description": "Configure Model Context Protocol servers",
"description": "Description",
"addServer": "Add Server",
"editServer": "Edit Server",
"name": "Name",
"command": "Command",
"args": "Arguments",
"argsTooltip": "Each argument on a new line",
"env": "Environment Variables",
"envTooltip": "Format: KEY=value, one per line",
"active": "Active",
"actions": "Actions",
"noServers": "No servers configured",
"nameRequired": "Please enter a server name",
"commandRequired": "Please enter a command",
"confirmDelete": "Delete Server",
"confirmDeleteMessage": "Are you sure you want to delete the server?",
"addSuccess": "Server added successfully",
"updateSuccess": "Server updated successfully",
"deleteSuccess": "Server deleted successfully",
"duplicateName": "A server with this name already exists",
"serverSingular": "server",
"serverPlural": "servers"
}
},
"translate": {
@@ -0,0 +1,299 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServerActive, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { Button, Card, Form, Input, message, Modal, Space, Switch, Table, Tooltip, Typography } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
interface MCPFormValues {
name: string
command: string
description?: string
args: string
env?: string
isActive: boolean
}
const MCPSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const { Paragraph, Text } = Typography
const dispatch = useAppDispatch()
const mcpServers = useAppSelector((state) => state.mcp.servers)
const [isModalVisible, setIsModalVisible] = useState(false)
const [editingServer, setEditingServer] = useState<MCPServer | null>(null)
const [loading, setLoading] = useState(false)
const [form] = Form.useForm<MCPFormValues>()
const showAddModal = () => {
form.resetFields()
setEditingServer(null)
setIsModalVisible(true)
}
const showEditModal = (server: MCPServer) => {
setEditingServer(server)
form.setFieldsValue({
name: server.name,
command: server.command,
description: server.description,
args: server.args.join('\n'),
env: server.env
? Object.entries(server.env)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
: '',
isActive: server.isActive
})
setIsModalVisible(true)
}
const handleCancel = () => {
setIsModalVisible(false)
form.resetFields()
}
const handleSubmit = () => {
setLoading(true)
form
.validateFields()
.then((values) => {
const args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
const env: Record<string, string> = {}
if (values.env) {
values.env.split('\n').forEach((line) => {
if (line.trim()) {
const [key, value] = line.split('=')
if (key && value) {
env[key.trim()] = value.trim()
}
}
})
}
const mcpServer: MCPServer = {
name: values.name,
command: values.command,
description: values.description,
args,
env: Object.keys(env).length > 0 ? env : undefined,
isActive: values.isActive
}
if (editingServer) {
window.api.mcp
.updateServer(mcpServer)
.then(() => {
message.success(t('settings.mcp.updateSuccess'))
setLoading(false)
setIsModalVisible(false)
form.resetFields()
})
.catch((error) => {
message.error(`${t('settings.mcp.updateError')}: ${error.message}`)
setLoading(false)
})
dispatch(updateMCPServer(mcpServer))
} else {
// Check for duplicate name
if (mcpServers.some((server: MCPServer) => server.name === mcpServer.name)) {
message.error(t('settings.mcp.duplicateName'))
setLoading(false)
return
}
window.api.mcp
.addServer(mcpServer)
.then(() => {
message.success(t('settings.mcp.addSuccess'))
setLoading(false)
setIsModalVisible(false)
form.resetFields()
})
.catch((error) => {
message.error(`${t('settings.mcp.addError')}: ${error.message}`)
setLoading(false)
})
dispatch(addMCPServer(mcpServer))
}
})
.catch(() => {
setLoading(false)
})
}
const handleDelete = (serverName: string) => {
Modal.confirm({
title: t('settings.mcp.confirmDelete'),
content: t('settings.mcp.confirmDeleteMessage'),
okText: t('common.delete'),
okButtonProps: { danger: true },
cancelText: t('common.cancel'),
onOk: () => {
window.api.mcp
.deleteServer(serverName)
.then(() => {
message.success(t('settings.mcp.deleteSuccess'))
})
.catch((error) => {
message.error(`${t('settings.mcp.deleteError')}: ${error.message}`)
})
dispatch(deleteMCPServer(serverName))
}
})
}
const handleToggleActive = (name: string, isActive: boolean) => {
window.api.mcp
.setServerActive(name, isActive)
.then(() => {
// Optional: Show success message or update UI
})
.catch((error) => {
message.error(`${t('settings.mcp.toggleError')}: ${error.message}`)
})
dispatch(setMCPServerActive({ name, isActive }))
}
const columns = [
{
title: t('settings.mcp.name'),
dataIndex: 'name',
key: 'name',
width: '20%',
render: (text: string, record: MCPServer) => <Text strong={record.isActive}>{text}</Text>
},
{
title: t('settings.mcp.description'),
dataIndex: 'description',
key: 'description',
width: '40%',
render: (text: string) =>
text || (
<Text type="secondary" italic>
{t('common.description')}
</Text>
)
},
{
title: t('settings.mcp.active'),
dataIndex: 'isActive',
key: 'isActive',
width: '15%',
render: (isActive: boolean, record: MCPServer) => (
<Switch checked={isActive} onChange={(checked) => handleToggleActive(record.name, checked)} />
)
},
{
title: t('settings.mcp.actions'),
key: 'actions',
width: '25%',
render: (_: any, record: MCPServer) => (
<Space>
<Tooltip title={t('common.edit')}>
<Button type="primary" ghost icon={<EditOutlined />} onClick={() => showEditModal(record)} />
</Tooltip>
<Tooltip title={t('common.delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.name)} />
</Tooltip>
</Space>
)
}
]
// Create a CSS class for inactive rows instead of using jsx global
const inactiveRowStyle = {
opacity: 0.7,
backgroundColor: theme === 'dark' ? '#1a1a1a' : '#f5f5f5'
}
return (
<SettingContainer theme={theme}>
<SettingGroup theme={theme}>
<SettingTitle>
{t('settings.mcp.title')}
<Tooltip title={t('settings.mcp.config_description')}>
<QuestionCircleOutlined style={{ marginLeft: 8, fontSize: 14 }} />
</Tooltip>
</SettingTitle>
<SettingDivider />
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
{t('settings.mcp.config_description')}
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={showAddModal}>
{t('settings.mcp.addServer')}
</Button>
<Text type="secondary">
{mcpServers.length}{' '}
{mcpServers.length === 1 ? t('settings.mcp.serverSingular') : t('settings.mcp.serverPlural')}
</Text>
</div>
<Card bordered={false} style={{ background: theme === 'dark' ? '#1f1f1f' : '#fff' }}>
<Table
dataSource={mcpServers}
columns={columns}
rowKey="name"
pagination={false}
locale={{ emptyText: t('settings.mcp.noServers') }}
rowClassName={(record) => (!record.isActive ? 'inactive-row' : '')}
onRow={(record) => ({
style: !record.isActive ? inactiveRowStyle : {}
})}
/>
</Card>
<Modal
title={editingServer ? t('settings.mcp.editServer') : t('settings.mcp.addServer')}
open={isModalVisible}
onCancel={handleCancel}
onOk={handleSubmit}
confirmLoading={loading}
width={600}>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('settings.mcp.name')}
rules={[{ required: true, message: t('settings.mcp.nameRequired') }]}>
<Input disabled={!!editingServer} placeholder={t('common.name')} />
</Form.Item>
<Form.Item name="description" label={t('settings.mcp.description')}>
<TextArea rows={2} placeholder={t('common.description')} />
</Form.Item>
<Form.Item
name="command"
label={t('settings.mcp.command')}
rules={[{ required: true, message: t('settings.mcp.commandRequired') }]}>
<Input placeholder="python script.py" />
</Form.Item>
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
<TextArea rows={3} placeholder="{--param1}\n{--param2 value}" style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
<TextArea rows={3} placeholder="KEY1=value1\nKEY2=value2" style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="isActive" label={t('settings.mcp.active')} valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
</SettingGroup>
</SettingContainer>
)
}
export default MCPSettings
@@ -1,5 +1,6 @@
import {
CloudOutlined,
CodeOutlined,
GlobalOutlined,
InfoCircleOutlined,
LayoutOutlined,
@@ -20,6 +21,7 @@ import AboutSettings from './AboutSettings'
import DataSettings from './DataSettings/DataSettings'
import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings'
import MCPSettings from './MCPSettings'
import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings'
import ShortcutSettings from './ShortcutSettings'
@@ -60,6 +62,12 @@ const SettingsPage: FC = () => {
{t('settings.websearch.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/mcp">
<MenuItem className={isRoute('/settings/mcp')}>
<CodeOutlined />
{t('settings.mcp.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/general">
<MenuItem className={isRoute('/settings/general')}>
<SettingOutlined />
@@ -102,6 +110,7 @@ const SettingsPage: FC = () => {
<Route path="provider" element={<ProvidersList />} />
<Route path="model" element={<ModelSettings />} />
<Route path="web-search" element={<WebSearchSettings />} />
<Route path="mcp" element={<MCPSettings />} />
<Route path="general/*" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} />
<Route path="data/*" element={<DataSettings />} />
+8 -2
View File
@@ -16,8 +16,14 @@ export default class AiProvider {
return this.sdk.fakeCompletions(params)
}
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
public async completions({
messages,
assistant,
onChunk,
onFilterMessages,
mcpTools
}: CompletionsParams): Promise<void> {
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages, mcpTools })
}
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
+182 -57
View File
@@ -11,14 +11,27 @@ import i18n from '@renderer/i18n'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { filterContextMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService'
import { Assistant, FileTypes, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
import {
Assistant,
FileTypes,
GenerateImageParams,
MCPTool,
Message,
Model,
Provider,
Suggestion
} from '@renderer/types'
import { removeSpecialCharacters } from '@renderer/utils'
import { takeRight } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import {
ChatCompletionAssistantMessageParam,
ChatCompletionContentPart,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionMessageParam
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
ChatCompletionToolMessageParam
} from 'openai/resources'
import { CompletionsParams } from '.'
@@ -213,7 +226,37 @@ export default class OpenAIProvider extends BaseProvider {
return model.id.startsWith('o1')
}
async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
private mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {
return mcpTools.map((tool) => ({
type: 'function',
function: {
name: `mcp.${tool.serverName}.${tool.name}`,
description: tool.description,
parameters: {
type: 'object',
properties: tool.inputSchema
}
}
}))
}
private openAIToolsToMcpTool(tool: ChatCompletionMessageToolCall): MCPTool | undefined {
const parts = tool.function.name.split('.')
if (parts[0] !== 'mcp') {
console.log('Invalid tool name', tool.function.name)
return undefined
}
const serverName = parts[1]
const name = parts[2]
return {
serverName: serverName,
name: name,
inputSchema: JSON.parse(tool.function.arguments)
} as MCPTool
}
async completions({ messages, assistant, onChunk, onFilterMessages, mcpTools }: CompletionsParams): Promise<void> {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
@@ -285,17 +328,151 @@ export default class OpenAIProvider extends BaseProvider {
const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id)
const { signal } = abortController
const tools = mcpTools ? this.mcpToolsToOpenAITools(mcpTools) : undefined
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
Boolean
) as ChatCompletionMessageParam[]
const processStream = async (stream: any) => {
if (!isSupportStreamOutput()) {
const time_completion_millsec = new Date().getTime() - start_time_millsec
return onChunk({
text: stream.choices[0].message?.content || '',
usage: stream.usage,
metrics: {
completion_tokens: stream.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec: 0
}
})
}
const toolCalls: ChatCompletionMessageToolCall[] = []
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
const delta = chunk.choices[0]?.delta
if (delta?.reasoning_content || delta?.reasoning) {
hasReasoningContent = true
}
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
time_first_content_millsec = new Date().getTime()
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
// Extract citations from the raw response if available
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
if (delta?.tool_calls) {
const chunkToolCalls: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta.ToolCall[] = delta.tool_calls
if (chunk.choices[0].finish_reason !== 'tool_calls') {
if (toolCalls.length === 0) {
for (const toolCall of chunkToolCalls) {
toolCalls.push(toolCall as ChatCompletionMessageToolCall)
}
} else {
for (let i = 0; i < chunkToolCalls.length; i++) {
toolCalls[i].function.arguments += chunkToolCalls[i].function?.arguments || ''
}
}
continue
}
}
if (chunk.choices[0].finish_reason === 'tool_calls') {
console.log('start invoke tools', toolCalls)
reqMessages.push({
role: 'assistant',
tool_calls: toolCalls
} as ChatCompletionAssistantMessageParam)
for (const toolCall of toolCalls) {
const mcpTool = this.openAIToolsToMcpTool(toolCall)
console.log('mcpTool', JSON.stringify(mcpTool, null, 2))
if (!mcpTool) {
console.log('Invalid tool', toolCall)
continue
}
const toolCallResponse = await window.api.mcp.callTool({
client: mcpTool.serverName,
name: mcpTool.name,
args: mcpTool.inputSchema
})
console.log(`Tool ${mcpTool.serverName} - ${mcpTool.name} Call Response:`)
console.log(toolCallResponse)
reqMessages.push({
role: 'tool',
content: JSON.stringify(toolCallResponse, null, 2),
tool_call_id: toolCall.id
} as ChatCompletionToolMessageParam)
}
const newStream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
...this.getCustomParameters(assistant)
},
{
signal
}
)
.finally(cleanup)
await processStream(newStream)
}
onChunk({
text: delta?.content || '',
// @ts-ignore key is not typed
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec,
time_thinking_millsec
},
citations
})
}
}
const stream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
@@ -307,59 +484,7 @@ export default class OpenAIProvider extends BaseProvider {
)
.finally(cleanup)
if (!isSupportStreamOutput()) {
const time_completion_millsec = new Date().getTime() - start_time_millsec
return onChunk({
text: stream.choices[0].message?.content || '',
usage: stream.usage,
metrics: {
completion_tokens: stream.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec: 0
}
})
}
// @ts-expect-error `stream` is not typed
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
const delta = chunk.choices[0]?.delta
if (delta?.reasoning_content || delta?.reasoning) {
hasReasoningContent = true
}
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
time_first_content_millsec = new Date().getTime()
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
// Extract citations from the raw response if available
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
onChunk({
text: delta?.content || '',
// @ts-ignore key is not typed
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec,
time_thinking_millsec
},
citations
})
}
await processStream(stream)
}
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
+1
View File
@@ -15,4 +15,5 @@ interface CompletionsParams {
assistant: Assistant
onChunk: ({ text, reasoning_content, usage, metrics, search, citations }: ChunkCallbackData) => void
onFilterMessages: (messages: Message[]) => void
mcpTools?: MCPTool[]
}
+6 -1
View File
@@ -77,6 +77,10 @@ export async function fetchChatCompletion({
}
}
const allMCPTools = await window.api.mcp.listTools()
if (allMCPTools.length > 0) {
console.log('Available MCP tools:', allMCPTools)
}
await AI.completions({
messages: filterUsefulMessages(messages),
assistant,
@@ -104,7 +108,8 @@ export async function fetchChatCompletion({
}
onResponse({ ...message, status: 'pending' })
}
},
mcpTools: allMCPTools
})
message.status = 'success'
+3 -1
View File
@@ -7,6 +7,7 @@ import agents from './agents'
import assistants from './assistants'
import knowledge from './knowledge'
import llm from './llm'
import mcp from './mcp'
import migrate from './migrate'
import minapps from './minapps'
import paintings from './paintings'
@@ -25,7 +26,8 @@ const rootReducer = combineReducers({
shortcuts,
knowledge,
minapps,
websearch
websearch,
mcp
})
const persistedReducer = persistReducer(
+51
View File
@@ -0,0 +1,51 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { MCPConfig, MCPServer } from '@renderer/types'
const initialState: MCPConfig = {
servers: []
}
const mcpSlice = createSlice({
name: 'mcp',
initialState,
reducers: {
setMCPServers: (state, action: PayloadAction<MCPServer[]>) => {
state.servers = action.payload
},
addMCPServer: (state, action: PayloadAction<MCPServer>) => {
state.servers.push(action.payload)
},
updateMCPServer: (state, action: PayloadAction<MCPServer>) => {
const index = state.servers.findIndex((server) => server.name === action.payload.name)
if (index !== -1) {
state.servers[index] = action.payload
}
},
deleteMCPServer: (state, action: PayloadAction<string>) => {
state.servers = state.servers.filter((server) => server.name !== action.payload)
},
setMCPServerActive: (state, action: PayloadAction<{ name: string; isActive: boolean }>) => {
const index = state.servers.findIndex((server) => server.name === action.payload.name)
if (index !== -1) {
state.servers[index].isActive = action.payload.isActive
}
}
},
selectors: {
getActiveServers: (state) => {
return state.servers.filter((server) => server.isActive)
},
getAllServers: (state) => state.servers
}
})
export const { setMCPServers, addMCPServer, updateMCPServer, deleteMCPServer, setMCPServerActive } = mcpSlice.actions
// Export the generated selectors from the slice
export const { getActiveServers, getAllServers } = mcpSlice.selectors
// Type-safe selector for accessing this slice from the root state
export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp
// Export the reducer as default export
export default mcpSlice.reducer
+39
View File
@@ -301,3 +301,42 @@ export type KnowledgeReference = {
type: KnowledgeItemType
file?: FileType
}
export type MCPArgType = 'string' | 'list' | 'number'
export type MCPEnvType = 'string' | 'number'
export type MCPArgParameter = { [key: string]: MCPArgType }
export type MCPEnvParameter = { [key: string]: MCPEnvType }
export interface MCPServerParameter {
name: string
type: MCPArgType | MCPEnvType
description: string
}
export interface MCPServer {
name: string
command: string
description?: string
args: string[]
env?: Record<string, string>
isActive: boolean
}
export interface MCPToolInputSchema {
type: string
title: string
description?: string
required?: string[]
properties: Record<string, object>
}
export interface MCPTool {
serverName: string
name: string
description?: string
inputSchema: MCPToolInputSchema
}
export interface MCPConfig {
servers: MCPServer[]
}