在Electron中使用本地MCP

9 阅读5分钟

(# Model Context Protocol(MCP)初试)[juejin.cn/post/759970…]

在 Electron 中使用本地 MCP 可以实现,比如操作、分析本地文件以建立基于本地的语料库;抓取分析网页数据;社交平台辅助运营等

需要在主进程启动 MCP 服务并调用,使用的 SDK 是 @modelcontextprotocol/sdk

MCP 服务常用的调用方法有:stdio、http、sse,stdio 只能在主进程调用,其余 2 种在纯浏览器下会错,所以全放主进程来调用

两个 MCP 服务市场魔搭 MCP 市场MCP 市场

MCP 服务大多为 node 或 python 编写,所以需要在 Electron 中安装对应环境,以 electron-forge 框架为例:

安装依赖包并处理签名

  1. 将 node 环境和 python 环境做为 extraResource 集成到 Electron 最终包里(node 下载地址 nodejs.cn/download/,u… 下载地址 github.com/astral-sh/u…

     // package.json
     "extraResource": {
       "node": {
         "darwin_arm64": "node-v22.18.0-darwin-arm64.tar.gz",
         "darwin_x64": "node-v22.18.0-darwin-x64.tar.gz",
         "win": "node-v22.18.0-win-x64.zip"
       },
       "uv": {
         "darwin_arm64": "uv-aarch64-apple-darwin.tar.gz",
         "darwin_x64": "uv-x86_64-apple-darwin.tar.gz",
         "win": "uv-aarch64-pc-windows-msvc.zip"
       }
     },
    
     // forge.config.ts
     packagerConfig: {
       extraResource: [
         path.join('/some-path/', packageJson.extraResource.node),
         path.join('/some-path/', packageJson.extraResource.uv)
       ],
     },
    
  2. 单独对 uv.tar.gz 签名

    // forge.config.ts
    hooks: {
      preMake: async () => {
        if (process.platform == 'darwin') {
          await signPython()
          await signMac()
        }
      }
    }
    
    function signPython() {
      const sourcesDir = path.join(appPath, 'Contents/Resources')
      const uvZipDir = path.join(sourcesDir, packageJson.extraResource.uv)
      const binDir = path.join(sourcesDir, 'uv')
      console.info(`【python签名开始】:`, sourcesDir, uvZipDir)
      return spawnPromise('tar', ['-xzf', uvZipDir, '-C', sourcesDir])
        .then(() => {
          fs.renameSync(uvZipDir.replace('.tar.gz', ''), binDir)
          fs.rmSync(uvZipDir)
          console.info(`【pythonZip】: 解压成功`)
          return execPromise(`xattr -cr ${binDir}`)
        })
        .then(() => {
          return execPromise(
            `codesign --force --deep --timestamp --options runtime --entitlements ${ETM_DIR} --sign "${process.env.CERT_NAME}" --verbose=2 -v ${binDir}`
          )
        })
        .then((resList) => {
          console.info('【python签名结束】', resList)
          return resList
        })
    }
    

在应用启动时检查并安装环境

  • 组装 MCP 需要使用的环境变量
const LOCAL_RESOURCE_DIR = path.join(app.getAppPath(), '../')
const platform_arch = process.platform + '_' + process.arch

const nodeZips = packageJson.extraResource.node
let nodeZip = path.join(LOCAL_RESOURCE_DIR, nodeZips[platform_arch] || nodeZips.win)
let nodePath = path.join(LOCAL_RESOURCE_DIR, 'node')

const uvZips = packageJson.extraResource.uv
let uvZip = path.join(LOCAL_RESOURCE_DIR, uvZips[platform_arch] || uvZips.win)
let uvPath = path.join(LOCAL_RESOURCE_DIR, 'uv')

export function getTempGlobalPath() {
  let paths = {
    PATH: '/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:' + process.env.PATH,
    UV_DEFAULT_INDEX: 'https://pypi.tuna.tsinghua.edu.cn/simple',
    UV_PYTHON_INSTALL_MIRROR:
      'https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/',
  }
  if (uvPath) paths.PATH = `${uvPath}:${paths.PATH}`
  if (nodePath) paths.PATH = `${nodePath}/bin:${paths.PATH}`
  return paths
}
  • 处理 Node 环境
let nodeState: 'installing' | 'done' | 'fail' = 'installing'

export async function installNodeEnv() {
  console.info('安装node环境', process.platform, process.arch, nodePath)
  const isInstalled = await fileIsExist(nodePath)
  console.info('node是否已存在', isInstalled)
  if (isInstalled) return (nodeState = 'done')

  if (process.platform == 'darwin') {
    console.info('解压node安装包', nodeZip, nodePath)
    spawnPromise('tar', ['-xzf', nodeZip, '-C', LOCAL_RESOURCE_DIR])
      .then((out) => {
        console.info('node解压成功', out)
      })
      .then(() => {
        console.info('重命名', nodeZip.replace('.tar.gz', ''), nodePath)
        return renameDir(nodeZip.replace('.tar.gz', ''), nodePath)
      })
      .then(() => {
        nodeState = 'done'
        spawnPromise('npm', ['config', 'set', 'registry', 'https://registry.npmmirror.com/'], {
          env: getTempGlobalPath(),
        })
          .then((out) => {
            console.info('node安装结果测试成功', out)
          })
          .catch((err) => {
            console.info('node安装结果测试失败', err)
          })
      })
      .catch((err) => {
        nodeState = 'fail'
        console.info('node解压失败', err)
      })
  } else {
    unzip(nodeZip, nodePath)
  }
}
  • 处理 Python 环境
let uvState: 'installing' | 'done' | 'fail' = 'installing'

export async function installPythonEnv() {
  if (!app.isPackaged) return (uvPath = '')
  if (process.platform == 'darwin') {
    console.info('安装python环境', process.platform, process.arch, uvPath)
    spawnPromise('uv', ['run', 'python', '--version'], { env: getTempGlobalPath() })
      .then((out) => {
        console.info('python 当前版本', out)
        if (/3\.10\.\d/.test(out)) return
        return spawnPromise('uv', ['python', 'install', '3.12'], { env: getTempGlobalPath() })
      })
      .then((res) => {
        res && console.info('python安装结果', res)
      })
      .catch((err) => {
        console.info('python安装结果', err)
      })
      .then(() => {
        // 有些老mac没有必须的realpath系统指令
        getSpawnErrs('realpath', [], { env: getTempGlobalPath() })
          .then(() => {
            uvState = 'done'
          })
          .catch((err) => {
            if (typeof err === 'string' && err.includes('missing operand')) {
              uvState = 'done'
            } else {
              uvState = 'fail'
              console.info('检查 realpath 失败', err)
              dialog.showMessageBox({ message: `当前系统不支持本应用!`, type: 'error' })
            }
          })
      })
  } else {
    unzip(uvZip, uvPath)
  }
}

启动 MCP Client

import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import {
  StdioClientTransport,
  StdioServerParameters,
} from '@modelcontextprotocol/sdk/client/stdio.js'
import {
  SSEClientTransport,
  SSEClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/sse.js'
import type { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'
import type { Transport } from '@modelcontextprotocol/sdk/dist/esm/shared/transport'

class McpClient {
  mcp: Client
  transport: Transport
  status: '' | 'connecting' | 'connected' = ''
  tools: McpTool[]

  constructor() {}

  connectToServer = async (params: StdioServerParameters) => {
    // @ts-ignore
    let timeout = params.timeout || 59000 // 默认 60000
    let tempPath = getTempGlobalPath()
    params.env = params.env || {}
    params.env = { ...tempPath, ...params.env }
    console.log('MCP Client 开始连接', params)
    try {
      this.status = 'connecting'
      this.mcp = new Client({ name: 'mcp-client', version: '0.0.1' })
      this.transport = new StdioClientTransport(params)
      this.transport.onerror = (err) => console.log('MCP连接失败(transport onerror)', err)
      this.mcp.onerror = (err) => console.log('MCP连接失败(mcp onerror)', err)
      await this.mcp.connect(this.transport, { timeout }) // 60000
      console.log('MCP连接成功')
      this.status = 'connected'
    } catch (err) {
      if (err.message.includes('-32001') || err.message.includes('timed out')) {
        this.cleanup(false)
        console.log('MCP连接超时')
      } else {
        this.cleanup()
        console.log('MCP连接失败', err)
        getSpawnErrs(params.command, params.args, { env: params.env }).catch((err) => {
          console.info('MCP连接失败原因', err, '[配置]:', params)
        })
      }
      throw new Error(err)
    }
  }

  connectToSseServer = async (url: string, opt: SSEClientTransportOptions) => {
    console.log('MCP Client 开始连接')
    try {
      this.status = 'connecting'
      this.mcp = new Client({ name: 'mcp-client', version: '0.0.1' })
      this.transport = new SSEClientTransport(new URL(url), opt)
      await this.mcp.connect(this.transport)
      console.log('MCP连接成功')
      this.status = 'connected'
    } catch (err) {
      console.log('MCP连接失败', url, err)
      this.cleanup()
      throw err
    }
  }

  async getTools() {
    if (this.tools) return this.tools
    const toolsResult = await this.mcp.listTools()
    this.tools = toolsResult.tools.map((tool) => {
      return {
        type: 'function', // 添加工具类型
        function: {
          name: tool.name,
          type: 'function', // 添加工具类型
          description: tool.description,
          inputSchema: tool.inputSchema,
          // openai的function参数格式,和mcp的inputSchema格式不同,需要转换
          parameters: tool.inputSchema.properties,
          annotations: tool.annotations,
        },
      }
    })
    return this.tools
  }

  async cleanup(force = true) {
    console.log('MCP关闭成功')
    await this.mcp?.close()
    if (force) await this.transport?.close()
    this.mcp = undefined
    this.status = ''
    this.tools = undefined
  }
}

const client = new McpClient()
// stdio 服务
client.connectToServer(params)
// sse 服务
client.connectToSseServer(params.url, params)
// 获取工具列表
client.getTools()
// 调用工具
client.mcp.callTool(params)

使用 MCP 服务

从 MCP 服务市场中找到合适的服务,比如 www.modelscope.cn/mcp/servers…

await client.connectToServer({
  command: 'npx',
  args: ['-y', '@modelcontextprotocol/server-filesystem', '/Users/username/Desktop'],
})

const allTools = await client.getTools()
// allTools 给 openai 的 tools 参数,ai 模型会根据需要调用某个工具

// 调用工具示例:
const result = await client.mcp.callTool({
  name: 'get_file_info',
  arguments: { path: '/Users/username/Desktop/test.txt' },
})

如果是 Electron 直接调用大模型:

import OpenAI from 'openai'

const messages = [
  { role: 'system', content: '系统提示词' },
  { role: 'user', content: '用户提示词' },
]

const ai = new OpenAI({ apiKey, baseURL })
const completion = await ai.chat.completions.create({
  model: 'doubao-1-5-pro-32k-250115', // 模型名称
  messages,
  temperature: 0,
  tools: allTools, // 告诉模型,都有哪些工具
})
const content = completion.choices[0]
if (content.finish_reason !== 'tool_calls') {
  // 非工具调用,返回结果
  return completion.choices[0].message.content
}

// https://github.com/openai/openai-node/blob/master/helpers.md
// 工具消息应该是对工具调用的直接响应,而不能独立存在或作为其他消息类型的响应。
messages.push(content.message)
for (const toolCall of content.message.tool_calls!) {
  const toolName = toolCall.function.name
  const toolArgs = JSON.parse(toolCall.function.arguments.trim())
  console.log('【工具名】', toolName, '【参数】', toolArgs)

  // 调用工具
  const result = await client.mcp.callTool({ name: toolName, arguments: toolArgs })
  console.log('【工具响应】', result.content)

  messages.push({
    role: 'tool', // 工具消息的角色应该是 tool
    content: result.content, // 工具返回的结果
    tool_call_id: toolCall.id,
    name: toolName
  })
}
// 带着工具执行结果和上下文再问一次
const response = await ai.chat.completions.create({
  model: 'doubao-1-5-pro-32k-250115', // 模型名称
  messages,
  tools: allTools,
})
return response.choices[0].message.content

如果是大模型由后端调用,要求渲染进程发起 sse 请求去使用时,可以封装一些 IPC

// main
const mcp = new McpClient()
ipcMain.handle('mcp:start', async (e, params: StdioServerParameters) => {
  const status = getDependsEnvironmentState()
  if (status === 'fail') return Promise.reject('fail')
  if (status === 'installing') return Promise.reject('installing')
  if (mcp.status) return
  // params.args = params.args.filter((item) => item !== '--headless')
  await mcp.connectToServer(params)
})
ipcMain.handle('mcp:start-sse', async (e, params: SSEClientTransportOptions & { url: string }) => {
  if (mcp.status) return
  await mcp.connectToSseServer(params.url, params)
})
ipcMain.on('mcp:stop', (e) => {
  mcp.cleanup()
})
ipcMain.handle('mcp:tools', async (e) => {
  return await mcp.getTools()
})
ipcMain.handle('mcp:callTool', async (e, params: CallToolRequest['params']) => {
  return await mcp.mcp.callTool(params)
})

// preload
const mcpIPC = {
  startServer: (params: StdioServerParameters) => {
    if (!params.command)
      return Promise.reject('参数异常:' + JSON.stringify(params, undefined, '  '))
    return ipcRenderer.invoke('mcp:start', params)
  },
  startSseServer: (params: SSEClientTransportOptions & { url: string }) => {
    if (!params.url) return Promise.reject('参数异常:' + JSON.stringify(params, undefined, '  '))
    return ipcRenderer.invoke('mcp:start-sse', params)
  },
  callTool: (params: CallToolRequest['params']) => {
    if (!params.name) return Promise.reject('参数异常:' + JSON.stringify(params, undefined, '  '))
    return ipcRenderer.invoke('mcp:callTool', params)
  },
  getTools: (): Promise<McpTool[]> => ipcRenderer.invoke('mcp:tools'),
  stopServer: () => ipcRenderer.send('mcp:stop'),
}
contextBridge.exposeInMainWorld('mcpIPC', mcpIPC)