/ src / main / IPCs.ts
IPCs.ts
  1  import { ipcMain, shell, IpcMainEvent, dialog, BrowserWindow } from 'electron'
  2  import Constants from './utils/Constants'
  3  import {
  4    McpServerCapabilitySchemas,
  5    McpClientObject,
  6    McpFeatureObject,
  7    McpMetadataConfig,
  8    McpProgressCallback
  9  } from './mcp/types'
 10  
 11  import { manageRequests } from './mcp/client'
 12  
 13  import { spawn } from 'child_process'
 14  import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
 15  import { join, resolve, normalize } from 'path'
 16  import { pathToFileURL } from 'url'
 17  
 18  import { initClients } from './mcp/init'
 19  import { disconnect } from './mcp/connection'
 20  import { loadConfig } from './mcp/init'
 21  import { loadLlmFile } from './mcp/config'
 22  import { unpackDxt, getManifest } from './mcp/dxt'
 23  import { McpbManifestAny } from '@anthropic-ai/mcpb'
 24  
 25  import { closeCommandPicker } from './aid/commands'
 26  
 27  import { commandSelectionInvoke, mcpServersProcessCallback } from './index'
 28  import { getCachedText } from './aid/utils'
 29  import { McpClientResponse, CommandResponse, McpInitResponse } from './types'
 30  
 31  import { IpcFileTransferRequest, IpcFileTransferResponse } from '@/types/ipc'
 32  
 33  const handlerRegistry = new Map<string, Function>()
 34  
 35  interface ManifestResponse {
 36    status: 'success' | 'error'
 37    result?: Record<string, McpbManifestAny> // Object with string keys and DXT values
 38    error?: string
 39  }
 40  
 41  /*
 42   * IPC Communications
 43   * */
 44  export default class IPCs {
 45    static clients: McpClientObject[] = []
 46    static currentFeatures: McpFeatureObject[] = []
 47  
 48    static initialize(): void {
 49      // Get application version
 50      ipcMain.handle('msgRequestAppInfo', () => {
 51        return {
 52          version: Constants.APP_VERSION,
 53          homepage: Constants.APP_HOME_PAGE,
 54          platform: process.platform
 55        }
 56      })
 57  
 58      ipcMain.handle('msgRequestGetDxtUrl', () => {
 59        return pathToFileURL(normalize(resolve(Constants.ASSETS_PATH.mcpb))).toString()
 60      })
 61  
 62      ipcMain.handle('msgMcpServersStop', async () => {
 63        IPCs.stopAllServers()
 64  
 65        const configs = await loadConfig()
 66  
 67        const features = configs.map((params) => {
 68          return registerIpcHandlers(params)
 69        })
 70  
 71        this.currentFeatures = features
 72  
 73        return true
 74      })
 75  
 76      ipcMain.handle(
 77        'msgMcpServersInit',
 78        async (_event: IpcMainEvent, metadata: McpMetadataConfig): Promise<McpInitResponse> => {
 79          IPCs.stopAllServers()
 80  
 81          const progressCallback: McpProgressCallback = (name, message, status) => {
 82            mcpServersProcessCallback({ name, message, status })
 83          }
 84  
 85          const configs = await loadConfig()
 86  
 87          try {
 88            const newClients = await initClients(metadata, progressCallback)
 89            const activeClientNames = newClients.map((client) => client.name)
 90            const inactiveConfigs = configs.filter(
 91              (config) => !activeClientNames.includes(config.name)
 92            )
 93  
 94            const features = [
 95              ...newClients.map((params) => registerIpcHandlers(params)),
 96              ...inactiveConfigs.map((params) => registerIpcHandlers(params))
 97            ]
 98            IPCs.updateMCP(features)
 99            this.clients = newClients
100            return {
101              status: 'success'
102            }
103          } catch (error) {
104            const features = configs.map((params) => {
105              return registerIpcHandlers(params)
106            })
107  
108            IPCs.updateMCP(features)
109  
110            return {
111              status: 'error',
112              error: error instanceof Error ? error.message : String(error)
113            }
114          }
115        }
116      )
117  
118      // Open url via web browser
119      ipcMain.on('msgOpenExternalLink', async (event: IpcMainEvent, url: string) => {
120        await shell.openExternal(url)
121      })
122  
123      ipcMain.on('msgOpenDxtFilePath', async (event: IpcMainEvent, name: string) => {
124        shell.openPath(resolve(join(Constants.ASSETS_PATH.mcpb, name)))
125      })
126  
127      ipcMain.on('msgOpenPath', async (event: IpcMainEvent, name: string) => {
128        shell.openPath(resolve(join(Constants.ASSETS_PATH[name])))
129      })
130  
131      ipcMain.on('msgWindowReload', async (event: IpcMainEvent) => {
132        BrowserWindow.fromWebContents(event.sender).reload()
133      })
134  
135      ipcMain.handle('msgGetApiToken', async (event, cli) => {
136        return new Promise((resolve, reject) => {
137          const child = spawn(cli, { shell: true })
138          const cleanup = () => {
139            child.stdout?.destroy()
140            child.stderr?.destroy()
141            if (!child.killed) {
142              child.kill('SIGKILL')
143            }
144          }
145          try {
146            let stdoutData = ''
147  
148            child.stdout?.on('data', (data) => {
149              const output = data.toString()
150              stdoutData += output
151              event.sender.send('renderListenStdioProgress', output.trim()) // send real-time output
152            })
153  
154            child.stderr?.on('data', (data) => {
155              console.error('Error output:', data.toString())
156              reject(data.toString())
157            })
158  
159            child.on('close', (code) => {
160              if (code === 0) {
161                resolve(stdoutData.trim().split('\n').at(-1))
162              } else {
163                reject(new Error(`Process exited with code ${code}`))
164              }
165            })
166  
167            setTimeout(() => {
168              cleanup()
169              reject(new Error('Process timeout'))
170            }, 30000)
171          } catch (error) {
172            console.error('Error fetching token:', error.message)
173            cleanup()
174            reject(error)
175          }
176        })
177      })
178  
179      // Open file
180      ipcMain.handle('msgOpenFile', async (event: IpcMainEvent, filter: string) => {
181        const filters = []
182        if (filter === 'text') {
183          filters.push({ name: 'Text', extensions: ['txt', 'json'] })
184        } else if (filter === 'zip') {
185          filters.push({ name: 'Zip', extensions: ['zip'] })
186        }
187        const dialogResult = await dialog.showOpenDialog({
188          properties: ['openFile'],
189          filters
190        })
191        return dialogResult
192      })
193  
194      ipcMain.on(
195        'msgFileTransferRequest',
196        async (event: IpcMainEvent, { name, data }: IpcFileTransferRequest) => {
197          try {
198            const buffer = Buffer.from(data)
199            const saveOption = Constants.getDxtSource(name)
200            const filePath = saveOption.mcpbPath
201            const dirPath = saveOption.outputDir
202            if (!existsSync(dirPath)) {
203              mkdirSync(dirPath, { recursive: true })
204            }
205            console.log('MCP bundle to be saved in: ', filePath)
206  
207            writeFileSync(filePath, buffer, { encoding: null })
208  
209            console.log(saveOption)
210            await unpackDxt(saveOption)
211            // console.log(getManifest(dirPath))
212  
213            event.reply('msgFileTransferResponse', {
214              name,
215              success: true,
216              path: saveOption.outputDir
217            } as IpcFileTransferResponse)
218          } catch (err) {
219            event.reply('msgFileTransferResponse', {
220              name,
221              success: false,
222              reason: err.message
223            } as IpcFileTransferResponse)
224          }
225        }
226      )
227  
228      ipcMain.on(
229        'msgCommandSelectionResult',
230        async (_event: IpcMainEvent, response: CommandResponse) => {
231          closeCommandPicker()
232          const prompt = response.prompt
233          const request = { prompt, input: getCachedText(response.id) }
234          commandSelectionInvoke(request)
235        }
236      )
237  
238      ipcMain.handle('list-manifests', async (_event: IpcMainEvent): Promise<ManifestResponse> => {
239        const mcpbPath = Constants.ASSETS_PATH.mcpb
240  
241        try {
242          const entries = readdirSync(mcpbPath, { withFileTypes: true })
243          console.log(entries)
244  
245          // Transform the array into an object
246          const manifestsObject = entries
247            .filter((dirent) => dirent.isDirectory())
248            .reduce(
249              (acc, dirent) => {
250                acc[dirent.name] = getManifest(join(mcpbPath, dirent.name))
251                return acc
252              },
253              {} as Record<string, any>
254            ) // You can replace 'any' with your manifest type
255  
256          return {
257            status: 'success',
258            result: manifestsObject
259          }
260        } catch (err) {
261          return {
262            status: 'error',
263            error: err.message
264          }
265        }
266      })
267  
268      ipcMain.handle('list-llms', () => {
269        return loadLlmFile(Constants.ASSETS_PATH.llm)
270      })
271  
272      ipcMain.handle('list-popups', () => {
273        return loadLlmFile(Constants.ASSETS_PATH.popup)
274      })
275  
276      ipcMain.handle('list-startups', () => {
277        return loadLlmFile(Constants.ASSETS_PATH.startup)
278      })
279    }
280  
281    static updateMCP(newFeatures: McpFeatureObject[]): void {
282      this.currentFeatures = newFeatures
283    }
284  
285    static stopAllServers() {
286      this.clients.forEach((client: McpClientObject) => {
287        if (client.connection?.transport) {
288          disconnect(client.connection.transport)
289          delete client.connection.transport
290        }
291      })
292  
293      IPCs.removeAllHandlers()
294    }
295  
296    static removeAllHandlers() {
297      for (const [eventName] of handlerRegistry) {
298        ipcMain.removeHandler(eventName)
299      }
300      console.log(`handlerRegistry clear: ${handlerRegistry.size}`)
301      handlerRegistry.clear()
302    }
303  
304    static initializeMCP(initialFeatures: McpFeatureObject[]): void {
305      this.currentFeatures = initialFeatures
306      ipcMain.handle('list-clients', () => {
307        return this.currentFeatures
308      })
309    }
310  }
311  
312  export function listenOnceForRendererResponse(
313    responseChannel: string,
314    resolve: (_value: McpClientResponse) => void
315  ) {
316    ipcMain.once(responseChannel, (_event, response: McpClientResponse) => {
317      resolve(response)
318    })
319  }
320  
321  export function registerIpcHandlers({
322    name,
323    connection,
324    configJson
325  }: McpClientObject): McpFeatureObject {
326    const feature: McpFeatureObject = {
327      name,
328      config: configJson
329    }
330  
331    if (!connection) {
332      return feature
333    }
334  
335    const registerHandler = (method: string, schema: any) => {
336      const eventName = `${name}-${method}`
337      console.log(`IPC Main ${eventName}`)
338      const handler = async (
339        _event: Electron.IpcMainInvokeEvent,
340        request: { method: string; params: any }
341      ) => {
342        if (request?.method !== method) {
343          console.log(
344            `Request method not registered: ${request?.method}, fallback to use ${method}, please double check the invoker in Renderer.`
345          )
346        }
347        return await manageRequests(connection.client, `${method}`, schema, request?.params)
348      }
349      ipcMain.handle(eventName, handler)
350      handlerRegistry.set(eventName, handler)
351      return eventName
352    }
353  
354    for (const [type, actions] of Object.entries(McpServerCapabilitySchemas)) {
355      const capabilities = connection.client.getServerCapabilities()
356      if (capabilities?.[type]) {
357        feature[type] = {}
358        for (const [action, schema] of Object.entries(actions)) {
359          feature[type][action] = registerHandler(`${type}/${action}`, schema)
360        }
361      }
362    }
363  
364    const instructions = connection.client.getInstructions()
365  
366    const implementation = connection.client.getServerVersion()
367  
368    return {
369      ...feature,
370      description: {
371        instructions,
372        implementation
373      }
374    }
375  }