(# Model Context Protocol(MCP)初试)[juejin.cn/post/759970…]
在 Electron 中使用本地 MCP 可以实现,比如操作、分析本地文件以建立基于本地的语料库;抓取分析网页数据;社交平台辅助运营等
需要在主进程启动 MCP 服务并调用,使用的 SDK 是 @modelcontextprotocol/sdk。
MCP 服务常用的调用方法有:stdio、http、sse,stdio 只能在主进程调用,其余 2 种在纯浏览器下会错,所以全放主进程来调用
MCP 服务大多为 node 或 python 编写,所以需要在 Electron 中安装对应环境,以 electron-forge 框架为例:
安装依赖包并处理签名
-
将 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) ], }, -
单独对 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)