From 04e6f2c1ad5b869ac78d713e74cda9c121910639 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Wed, 25 Jun 2025 18:21:10 +0800 Subject: [PATCH] feat: implement Python MCP server using existing Pyodide infrastructure (#7506) --- packages/shared/IpcChannel.ts | 3 + src/main/ipc.ts | 12 +++ src/main/mcpServers/factory.ts | 4 + src/main/mcpServers/python.ts | 113 ++++++++++++++++++++ src/main/services/PythonService.ts | 102 ++++++++++++++++++ src/preload/index.ts | 4 + src/renderer/src/services/PyodideService.ts | 33 ++++++ src/renderer/src/store/mcp.ts | 8 ++ 8 files changed, 279 insertions(+) create mode 100644 src/main/mcpServers/python.ts create mode 100644 src/main/services/PythonService.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index a3988d1c42..b77fd5d430 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -69,6 +69,9 @@ export enum IpcChannel { Mcp_ServersUpdated = 'mcp:servers-updated', Mcp_CheckConnectivity = 'mcp:check-connectivity', + // Python + Python_Execute = 'python:execute', + //copilot Copilot_GetAuthMessage = 'copilot:get-auth-message', Copilot_GetCopilotToken = 'copilot:get-copilot-token', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index bcb90ee5d6..8d8690a54e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -25,6 +25,7 @@ import NotificationService from './services/NotificationService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' +import { pythonService } from './services/PythonService' import { searchService } from './services/SearchService' import { SelectionService } from './services/SelectionService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' @@ -48,6 +49,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) const notificationService = new NotificationService(mainWindow) + // Initialize Python service with main window + pythonService.setMainWindow(mainWindow) + ipcMain.handle(IpcChannel.App_Info, () => ({ version: app.getVersion(), isPackaged: app.isPackaged, @@ -431,6 +435,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo) ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity) + // Register Python execution handler + ipcMain.handle( + IpcChannel.Python_Execute, + async (_, script: string, context?: Record, timeout?: number) => { + return await pythonService.executeScript(script, context, timeout) + } + ) + ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name)) ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js')) diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 1c508f8844..2376ef223e 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -6,6 +6,7 @@ import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' import FileSystemServer from './filesystem' import MemoryServer from './memory' +import PythonServer from './python' import ThinkingServer from './sequentialthinking' export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record = {}): Server { @@ -31,6 +32,9 @@ export function createInMemoryMCPServer(name: string, args: string[] = [], envs: const difyKey = envs.DIFY_KEY return new DifyKnowledgeServer(difyKey, args).server } + case '@cherry/python': { + return new PythonServer().server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/main/mcpServers/python.ts b/src/main/mcpServers/python.ts new file mode 100644 index 0000000000..6fe0b80db1 --- /dev/null +++ b/src/main/mcpServers/python.ts @@ -0,0 +1,113 @@ +import { pythonService } from '@main/services/PythonService' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js' +import Logger from 'electron-log' + +/** + * Python MCP Server for executing Python code using Pyodide + */ +class PythonServer { + public server: Server + + constructor() { + this.server = new Server( + { + name: 'python-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ) + + this.setupRequestHandlers() + } + + private setupRequestHandlers() { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'python_execute', + description: `Execute Python code using Pyodide in a sandboxed environment. Supports most Python standard library and scientific packages. +The code will be executed with Python 3.12. +Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start +with a comment of the form: +# /// script +# dependencies = ['pydantic'] +# /// +print('python code here')`, + inputSchema: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'The Python code to execute' + }, + context: { + type: 'object', + description: 'Optional context variables to pass to the Python execution environment', + additionalProperties: true + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds (default: 60000)', + default: 60000 + } + }, + required: ['code'] + } + } + ] + } + }) + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + if (name !== 'python_execute') { + throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`) + } + + try { + const { + code, + context = {}, + timeout = 60000 + } = args as { + code: string + context?: Record + timeout?: number + } + + if (!code || typeof code !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'Code parameter is required and must be a string') + } + + Logger.info('Executing Python code via Pyodide') + + const result = await pythonService.executeScript(code, context, timeout) + + return { + content: [ + { + type: 'text', + text: result + } + ] + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + Logger.error('Python execution error:', errorMessage) + + throw new McpError(ErrorCode.InternalError, `Python execution failed: ${errorMessage}`) + } + }) + } +} + +export default PythonServer diff --git a/src/main/services/PythonService.ts b/src/main/services/PythonService.ts new file mode 100644 index 0000000000..13b4546e56 --- /dev/null +++ b/src/main/services/PythonService.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' + +import { BrowserWindow, ipcMain } from 'electron' + +interface PythonExecutionRequest { + id: string + script: string + context: Record + timeout: number +} + +interface PythonExecutionResponse { + id: string + result?: string + error?: string +} + +/** + * Service for executing Python code by communicating with the PyodideService in the renderer process + */ +export class PythonService { + private static instance: PythonService | null = null + private mainWindow: BrowserWindow | null = null + private pendingRequests = new Map void; reject: (error: Error) => void }>() + + private constructor() { + // Private constructor for singleton pattern + this.setupIpcHandlers() + } + + public static getInstance(): PythonService { + if (!PythonService.instance) { + PythonService.instance = new PythonService() + } + return PythonService.instance + } + + private setupIpcHandlers() { + // Handle responses from renderer + ipcMain.on('python-execution-response', (_, response: PythonExecutionResponse) => { + const request = this.pendingRequests.get(response.id) + if (request) { + this.pendingRequests.delete(response.id) + if (response.error) { + request.reject(new Error(response.error)) + } else { + request.resolve(response.result || '') + } + } + }) + } + + public setMainWindow(mainWindow: BrowserWindow) { + this.mainWindow = mainWindow + } + + /** + * Execute Python code by sending request to renderer PyodideService + */ + public async executeScript( + script: string, + context: Record = {}, + timeout: number = 60000 + ): Promise { + if (!this.mainWindow) { + throw new Error('Main window not set in PythonService') + } + + return new Promise((resolve, reject) => { + const requestId = randomUUID() + + // Store the request + this.pendingRequests.set(requestId, { resolve, reject }) + + // Set up timeout + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(requestId) + reject(new Error('Python execution timed out')) + }, timeout + 5000) // Add 5s buffer for IPC communication + + // Update resolve/reject to clear timeout + const originalResolve = resolve + const originalReject = reject + this.pendingRequests.set(requestId, { + resolve: (value: string) => { + clearTimeout(timeoutId) + originalResolve(value) + }, + reject: (error: Error) => { + clearTimeout(timeoutId) + originalReject(error) + } + }) + + // Send request to renderer + const request: PythonExecutionRequest = { id: requestId, script, context, timeout } + this.mainWindow?.webContents.send('python-execution-request', request) + }) + } +} + +export const pythonService = PythonService.getInstance() diff --git a/src/preload/index.ts b/src/preload/index.ts index b6c87259ce..3e9ae532b2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -182,6 +182,10 @@ const api = { getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo), checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server) }, + python: { + execute: (script: string, context?: Record, timeout?: number) => + ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout) + }, shell: { openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options) }, diff --git a/src/renderer/src/services/PyodideService.ts b/src/renderer/src/services/PyodideService.ts index ff2ac77467..374561ec02 100644 --- a/src/renderer/src/services/PyodideService.ts +++ b/src/renderer/src/services/PyodideService.ts @@ -229,3 +229,36 @@ class PyodideService { // 创建并导出单例实例 export const pyodideService = PyodideService.getInstance() + +// Set up IPC handler for main process requests +if (typeof window !== 'undefined' && window.electron?.ipcRenderer) { + interface PythonExecutionRequest { + id: string + script: string + context: Record + timeout: number + } + + interface PythonExecutionResponse { + id: string + result?: string + error?: string + } + + window.electron.ipcRenderer.on('python-execution-request', async (_, request: PythonExecutionRequest) => { + try { + const result = await pyodideService.runScript(request.script, request.context, request.timeout) + const response: PythonExecutionResponse = { + id: request.id, + result + } + window.electron.ipcRenderer.send('python-execution-response', response) + } catch (error) { + const response: PythonExecutionResponse = { + id: request.id, + error: error instanceof Error ? error.message : String(error) + } + window.electron.ipcRenderer.send('python-execution-response', response) + } + }) +} diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 4894d93474..05caf291c4 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -137,6 +137,14 @@ export const builtinMCPServers: MCPServer[] = [ DIFY_KEY: 'YOUR_DIFY_KEY' }, provider: 'CherryAI' + }, + { + id: nanoid(), + name: '@cherry/python', + type: 'inMemory', + description: '在安全的沙盒环境中执行 Python 代码。使用 Pyodide 运行 Python,支持大多数标准库和科学计算包', + isActive: false, + provider: 'CherryAI' } ]