✨ 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:
+44
-3
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user