
1. 为什么要在 Electron 里做双进程桥接Electron 应用做 AI 工具调用绕不开一个结构性问题渲染进程负责 UI主进程负责系统能力而 MCP 工具链通常跑在独立的 Python 服务里。三者之间的数据流如果设计得随意后期加一个工具就要改三处代码调用日志散落在不同终端里出了问题根本不知道是哪一层断的。这篇要落地的方案是Electron 主进程通过 child_process 拉起一个 FastAPI 侧的 MCP server渲染进程只通过 IPC 发指令主进程负责转发和追踪。7 个工具全部给出可复制的代码骨架每个工具调用都带 trace_id日志能串起来看。TaoToken 在这里的角色是统一 Key 网关——你不需要在 Electron 里散落多个厂商的 API Key所有模型请求走一个入口配置集中管理。适合谁看已经在写 Electron 桌面应用、想接入 MCP 工具链但被多进程通信卡住的开发者或者手上有一堆 FastAPI 接口想包装成 MCP 工具给 AI 调用的后端同学。前置要求是你会写基础的 Python 和 JavaScript不需要精通 asyncio。整个链路是这样的渲染进程ipcRenderer.invoke→ 主进程ipcMain.handle→ HTTP 请求打到本地 MCP server → MCP server 调 FastAPI 业务接口 → 结果原路返回。追踪埋点放在主进程和 MCP server 两侧用同一个 trace_id 关联。2. TaoToken 统一 Key 接入前置在写桥接代码之前先把 Key 的事情理清楚。Electron 应用如果直接在每个工具里硬编码不同厂商的 Key会有三个麻烦Key 泄露风险高打包后能被反编译、换模型要改多处代码、用量统计分散。TaoToken 的做法是提供一个统一的 API 入口你只需要在配置里填一个 Key模型对话、coding plan、工具调用都走这个入口。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。接入步骤不复杂但有几个细节容易踩第一步在控制台创建 API Key。访问 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 登录后进 API Keys 页面生成。生成的 Key 形如sk-开头的一串字符只显示一次记得存好。第二步把 Key 写进 Electron 主进程的环境变量不要写进渲染进程。渲染进程的代码最终会打包进 asar反编译成本极低。主进程可以通过process.env读取或者用 electron-store 加密存储。第三步在 MCP server 侧配置模型调用。如果你用的是 Claude Code 这类编码工具可以参考 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 里的接入说明。如果是自己写 FastAPI 调模型直接在请求头里带Authorization: Bearer 你的Key即可。注意Key 不要提交到 Git 仓库。建议在项目根目录放.env.example做模板真实.env加进.gitignore。配置骨架用config.toml管理 MCP server 的参数用settings.json管理 Electron 侧的行为。两个文件分开是因为 MCP server 是独立 Python 进程读 TOML 更自然Electron 侧读 JSON 更顺手。# config.toml — MCP server 配置 [mcp] name electron-mcp-bridge host 127.0.0.1 port 53716 transport streamable-http [backend] base_url http://127.0.0.1:8100 timeout 30.0 [taotoken] api_base https://taotoken.net/api api_key_env TAOTOKEN_API_KEY # 从环境变量读取不写明文 [tracing] enabled true log_level INFO max_records 500{ mcp: { autoStart: true, port: 53716, restartLimit: 3, healthCheckIntervalMs: 5000 }, taotoken: { apiBase: https://taotoken.net/api, keyEnvVar: TAOTOKEN_API_KEY }, tracing: { enabled: true, redactFields: [phone, text, api_key] } }这两个文件放在项目根目录MCP server 启动时读config.tomlElectron 主进程启动时读settings.json。Key 本身不写进任何配置文件只通过环境变量注入。3. 可复制配置主进程桥接与 7 个工具骨架3.1 Electron 主进程启动 MCP 子进程主进程的核心职责是生命周期管理启动、健康检查、崩溃重启、退出清理。下面这段代码可以直接放进main/mcp-launcher.js。// main/mcp-launcher.js const { spawn } require(child_process); const path require(path); const http require(http); const net require(net); const MCP_HOST 127.0.0.1; const MCP_PORT 53716; let mcpProcess null; let restartCount 0; function checkPort(port) { return new Promise((resolve) { const server net.createServer(); server.once(error, () resolve(false)); server.once(listening, () { server.close(); resolve(true); }); server.listen(port, MCP_HOST); }); } function waitForPort(port, timeoutMs) { const deadline Date.now() timeoutMs; return new Promise((resolve, reject) { (function poll() { const req http.get( { host: MCP_HOST, port, path: /mcp, timeout: 1000 }, (res) { res.resume(); if (res.statusCode 200 || res.statusCode 404) { resolve(); } else if (Date.now() deadline) { setTimeout(poll, 500); } else { reject(new Error(MCP port not ready)); } } ); req.on(error, () { if (Date.now() deadline) setTimeout(poll, 500); else reject(new Error(MCP port unreachable)); }); req.on(timeout, () req.destroy()); })(); }); } async function startMCPServer() { const free await checkPort(MCP_PORT); if (!free) { console.warn([MCP] Port ${MCP_PORT} occupied, waiting for release...); await new Promise((r) setTimeout(r, 1500)); } const scriptPath path.join(__dirname, mcp, server.py); mcpProcess spawn(python3, [scriptPath], { stdio: [pipe, pipe, pipe], env: { ...process.env, MCP_HOST, MCP_PORT: String(MCP_PORT), BACKEND_URL: http://127.0.0.1:8100, TAOTOKEN_API_KEY: process.env.TAOTOKEN_API_KEY || , }, }); mcpProcess.stdout.on(data, (d) console.log([MCP-STDOUT], d.toString().trim()) ); mcpProcess.stderr.on(data, (d) console.error([MCP-STDERR], d.toString().trim()) ); mcpProcess.on(exit, (code) { console.log([MCP] exited with code ${code}); mcpProcess null; if (restartCount 3) { restartCount; console.log([MCP] restarting (${restartCount}/3)...); setTimeout(startMCPServer, 3000); } }); await waitForPort(MCP_PORT, 15000); console.log([MCP] ready on ${MCP_HOST}:${MCP_PORT}); return MCP_PORT; } function stopMCPServer() { if (mcpProcess) { mcpProcess.kill(SIGTERM); mcpProcess null; } } module.exports { startMCPServer, stopMCPServer };关键点checkPort用 TCP 绑定探测端口是否空闲比直接kill -9安全得多。waitForPort轮询/mcp端点返回 404 也算活着因为不同版本的 MCP SDK 路径可能不同。重启限制 3 次防止 Python 层持续崩溃导致无限拉起。3.2 IPC 桥接层渲染进程不能直接发 HTTP 请求到 MCP server必须经过主进程。这样做的原因是渲染进程有 CSP 限制而且跨域预检在 Electron 里行为不一致。IPC 桥接层放在main/ipc-bridge.js。// main/ipc-bridge.js const { ipcMain } require(electron); const http require(http); const MCP_HOST 127.0.0.1; const MCP_PORT 53716; function callMCP(method, params, traceId) { return new Promise((resolve, reject) { const body JSON.stringify({ jsonrpc: 2.0, id: traceId, method, params, }); const req http.request( { host: MCP_HOST, port: MCP_PORT, path: /mcp, method: POST, headers: { Content-Type: application/json, X-Trace-Id: traceId, }, timeout: 30000, }, (res) { let data ; res.on(data, (chunk) (data chunk)); res.on(end, () { try { resolve(JSON.parse(data)); } catch (e) { reject(new Error(Invalid JSON response: ${data.slice(0, 200)})); } }); } ); req.on(error, reject); req.on(timeout, () { req.destroy(); reject(new Error(MCP request timeout)); }); req.write(body); req.end(); }); } function registerIpcHandlers() { ipcMain.handle(mcp:call, async (_event, { tool, args, traceId }) { const start Date.now(); console.log([IPC] ${traceId} - ${tool}, JSON.stringify(args)); try { const result await callMCP(tools/call, { name: tool, arguments: args }, traceId); console.log([IPC] ${traceId} - ${tool} ${Date.now() - start}ms); return { ok: true, data: result }; } catch (err) { console.error([IPC] ${traceId} !! ${tool} ${err.message}); return { ok: false, error: err.message }; } }); ipcMain.handle(mcp:list-tools, async () { return callMCP(tools/list, {}, list- Date.now()); }); } module.exports { registerIpcHandlers };渲染进程侧只需要这样调用// renderer/mcp-client.js async function callTool(tool, args) { const traceId Math.random().toString(36).slice(2, 10); const res await window.electronAPI.invoke(mcp:call, { tool, args, traceId }); if (!res.ok) throw new Error(res.error); return res.data; } // 使用示例 const quota await callTool(get_account_quota, { account_id: wa_main_01 });3.3 7 个 MCP 工具完整骨架MCP server 用 FastMCP 注册工具每个工具都是async def内部用httpx.AsyncClient调 FastAPI 后端。下面是 7 个工具的完整代码放在main/mcp/server.py。# main/mcp/server.py import os import json import time import uuid import logging from datetime import datetime, timezone from typing import Optional import httpx from fastmcp import FastMCP BACKEND_URL os.environ.get(BACKEND_URL, http://127.0.0.1:8100) TIMEOUT 30.0 logging.basicConfig(levellogging.INFO, format%(asctime)s %(levelname)s %(message)s) logger logging.getLogger(mcp) mcp FastMCP( nameelectron-mcp-bridge, instructions( 你是桌面应用的 AI 工具接口。调用发送类工具前必须先调 get_account_quota 确认配额。 search_contacts 返回列表需用 contact_id 精确定位后再操作。 配额不足时告知用户不要重试。 ), ) # ---------- 工具 1配额查询 ---------- mcp.tool() async def get_account_quota(account_id: str) - dict: 查询指定账号的当日消息配额余量。发送类工具调用前必须先调本工具。 Args: account_id: 账号标识符如 wa_main_01 Returns: {remaining: int, reset_at: str, status: ok|limited} async with httpx.AsyncClient(timeoutTIMEOUT) as client: resp await client.get(f{BACKEND_URL}/api/quota/{account_id}) resp.raise_for_status() data resp.json() return { remaining: data[remaining], reset_at: data[reset_at], status: ok if data[remaining] 0 else limited, } # ---------- 工具 2发送消息 ---------- mcp.tool() async def send_message(account_id: str, contact_id: str, text: str) - dict: 通过指定账号向联系人发送消息。 Args: account_id: 发送账号 ID contact_id: 目标联系人 ID text: 消息正文 Returns: {success: bool, msg_id: str|None, error: str|None} async with httpx.AsyncClient(timeoutTIMEOUT) as client: resp await client.post( f{BACKEND_URL}/api/{account_id}/send, json{contact_id: contact_id, text: text}, ) resp.raise_for_status() result resp.json() return { success: result.get(success, False), msg_id: result.get(msg_id), error: result.get(error), } # ---------- 工具 3最近会话列表 ---------- mcp.tool() async def list_recent_chats(account_id: str, limit: int 20) - dict: 列出指定账号最近的会话按时间倒序。 Args: account_id: 账号 ID limit: 返回条数上限默认 20最大 100 Returns: {chats: [{chat_id, contact_name, last_message_time, unread_count}]} limit min(max(limit, 1), 100) async with httpx.AsyncClient(timeoutTIMEOUT) as client: resp await client.get( f{BACKEND_URL}/api/{account_id}/chats, params{limit: limit} ) resp.raise_for_status() return resp.json() # ---------- 工具 4搜索联系人 ---------- mcp.tool() async def search_contacts(query: str, limit: int 10) - dict: 在客户档案中模糊搜索联系人支持姓名、手机号、标签。 Args: query: 搜索关键词 limit: 返回条数上限默认 10最大 50 Returns: {contacts: [{contact_id, name, phone, tags, last_active}]} limit min(max(limit, 1), 50) async with httpx.AsyncClient(timeoutTIMEOUT) as client: resp await client.get( f{BACKEND_URL}/api/contacts/search, params{q: query, limit: limit}, ) resp.raise_for_status() return resp.json() # ---------- 工具 5创建联系人 ---------- mcp.tool() async def create_contact(name: str, phone: str, tags: Optional[str] None) - dict: 新建一条客户档案记录。 Args: name: 联系人姓名 phone: 手机号国际格式 tags: 可选逗号分隔的标签字符串 Returns: {contact_id: str, success: bool} payload {name: name, phone: phone} if tags: payload[tags] [t.strip() for t in tags.split(,)] async with httpx.AsyncClient(timeoutTIMEOUT) as client: resp await client.post(f{BACKEND_URL}/api/contacts, jsonpayload) resp.raise_for_status() return resp.json() # ---------- 工具 6更新联系人 ---------- mcp.tool() async def update_contact(contact_id: str, fields_json: str) - dict: 更新指定联系人的信息只更新传入的字段。 Args: contact_id: 联系人 ID fields_json: JSON 字符串如 {name:新名称,tags:[VIP]} Returns: {success: bool, updated_fields: list} try: fields json.loads(fields_json) except json.JSONDecodeError: return {success: False, error: fields_json 不是合法 JSON} async with httpx.AsyncClient(timeoutTIMEOUT) as client: resp await client.patch( f{BACKEND_URL}/api/contacts/{contact_id}, jsonfields ) resp.raise_for_status() return resp.json() # ---------- 工具 7翻译并发送 ---------- mcp.tool() async def translate_and_send( account_id: str, contact_id: str, text: str, target_lang: str en ) - dict: 将文本翻译成目标语言后发送原子操作。 Args: account_id: 发送账号 ID contact_id: 目标联系人 ID text: 原文 target_lang: 目标语言代码默认 en Returns: {success: bool, msg_id: str|None, translated_text: str|None} async with httpx.AsyncClient(timeoutTIMEOUT) as client: trans_resp await client.post( f{BACKEND_URL}/api/translate, json{text: text, target_lang: target_lang}, ) trans_resp.raise_for_status() translated trans_resp.json()[translated_text] send_resp await client.post( f{BACKEND_URL}/api/{account_id}/send, json{contact_id: contact_id, text: translated}, ) send_resp.raise_for_status() result send_resp.json() return { success: result.get(success, False), msg_id: result.get(msg_id), translated_text: translated, } if __name__ __main__: host os.environ.get(MCP_HOST, 127.0.0.1) port int(os.environ.get(MCP_PORT, 53716)) mcp.settings.host host mcp.settings.port port mcp.run(transportstreamable-http)3.4 调用追踪中间件追踪的核心是给每次工具调用生成一个 trace_id记录开始时间、参数脱敏后、耗时、结果状态。下面这个装饰器可以直接套在任意mcp.tool()上方。# main/mcp/tracker.py import functools import json import logging import uuid from datetime import datetime, timezone logger logging.getLogger(mcp.tracker) SENSITIVE_FIELDS {phone, text, api_key, translated_text} class CallTracker: def __init__(self, max_records: int 500): self.calls [] self.max_records max_records def _sanitize(self, args: dict) - dict: return { k: (***REDACTED*** if k in SENSITIVE_FIELDS else v) for k, v in args.items() } def before_call(self, tool_name: str, arguments: dict) - str: trace_id str(uuid.uuid4())[:8] entry { trace_id: trace_id, tool_name: tool_name, arguments: self._sanitize(arguments), started_at: datetime.now(timezone.utc).isoformat(), status: running, duration_ms: None, error: None, } self.calls.append(entry) if len(self.calls) self.max_records: self.calls self.calls[-self.max_records:] logger.info( [MCP-CALL] START %s %s args%s, trace_id, tool_name, json.dumps(entry[arguments], ensure_asciiFalse), ) return trace_id def after_call(self, trace_id: str, error: Exception None): for entry in reversed(self.calls): if entry[trace_id] trace_id: started datetime.fromisoformat(entry[started_at]) entry[duration_ms] round( (datetime.now(timezone.utc) - started).total_seconds() * 1000, 1 ) entry[status] error if error else ok entry[error] str(error) if error else None level logging.ERROR if error else logging.INFO logger.log( level, [MCP-CALL] END %s %s %.0fms %s, trace_id, entry[tool_name], entry[duration_ms], fERR:{error} if error else OK, ) break tracker CallTracker() def track_call(func): functools.wraps(func) async def wrapper(*args, **kwargs): trace_id tracker.before_call(func.__name__, kwargs) try: result await func(*args, **kwargs) tracker.after_call(trace_id) return result except Exception as e: tracker.after_call(trace_id, errore) raise return wrapper使用方式是在每个工具函数上方加一行track_call注意顺序track_call mcp.tool() async def send_message(account_id: str, contact_id: str, text: str) - dict: ...装饰器从下往上执行track_call在外层才能包裹住mcp.tool()注册的逻辑。写反了追踪不到调用。4. 验证请求与成功结果配置写完后按顺序验证三层MCP server 是否起来、工具列表是否可读、单个工具是否可调。4.1 验证 MCP server 启动先单独跑 Python 进程确认端口监听正常cd main/mcp MCP_HOST127.0.0.1 MCP_PORT53716 BACKEND_URLhttp://127.0.0.1:8100 python3 server.py预期输出INFO: Started server process [12345] INFO: Uvicorn running on http://127.0.0.1:53716 (Press CTRLC to quit)如果报Address already in use说明上次进程没退干净用lsof -i :53716找到 PID 后精确关闭。4.2 验证工具列表另开一个终端用 curl 发tools/list请求curl -s -X POST http://127.0.0.1:53716/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:list-1,method:tools/list,params:{}} \ | python3 -m json.tool成功返回的 JSON 里result.tools数组应该有 7 个元素每个元素包含name、description、inputSchema。如果只有 6 个检查是不是某个工具的mcp.tool()装饰器漏了。4.3 验证单个工具调用调get_account_quota这个只读工具风险最低curl -s -X POST http://127.0.0.1:53716/mcp \ -H Content-Type: application/json \ -H X-Trace-Id: test-001 \ -d { jsonrpc:2.0, id:test-001, method:tools/call, params:{name:get_account_quota,arguments:{account_id:wa_main_01}} } | python3 -m json.tool预期返回{ jsonrpc: 2.0, id: test-001, result: { content: [ { type: text, text: {\remaining\: 150, \reset_at\: \2025-01-15T00:00:00Z\, \status\: \ok\} } ] } }同时 MCP server 的终端会打印追踪日志2025-01-14 10:23:45 INFO [MCP-CALL] START a3f9c21e get_account_quota args{account_id: wa_main_01} 2025-01-14 10:23:45 INFO [MCP-CALL] END a3f9c21e get_account_quota 42ms OK4.4 验证 Electron 侧 IPC 链路启动 Electron 应用后在渲染进程的控制台执行const res await window.electronAPI.invoke(mcp:call, { tool: get_account_quota, args: { account_id: wa_main_01 }, traceId: renderer-test-01 }); console.log(res);主进程终端应该同时出现[IPC] renderer-test-01 - get_account_quota和[IPC] renderer-test-01 - get_account_quota 45ms两行日志。如果只有第一行没有第二行说明 HTTP 请求卡住了检查 MCP server 是否还在运行。4.5 验证 TaoToken 模型调用如果你的工具链里需要调模型比如翻译工具走的是模型接口验证方式是用 curl 直接打 TaoToken 的 APIcurl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-3-5-sonnet, messages: [{role: user, content: ping}], max_tokens: 10 } | python3 -m json.tool返回里有choices[0].message.content就说明 Key 有效。如果返回 401检查环境变量是否注入成功返回 429 说明触发了限流等一会儿再试。5. 本篇常见错排查5.1 端口被占用但找不到进程现象启动 MCP server 报Address already in use但lsof -i :53716查不到。原因通常是上一次的 Python 进程处于 TIME_WAIT 状态或者被其他用户启动的进程占着。解决方式是换一个端口在config.toml和settings.json里同步改掉。不要盲目kill -9可能误杀其他应用。5.2 工具注册了但模型不调用现象tools/list能看到 7 个工具但 AI 对话时一个都不调。排查顺序先看工具的 docstring 第一段是否清晰描述了什么时候该调。模型靠这段文字决定是否调用写得含糊它就不调。其次检查instructions字段是否太长超过 400 字模型会截断。最后确认参数类型注解是否完整缺类型注解会导致 JSON Schema 生成失败。5.3 async def 里用了 requests 库现象单个工具调用正常但并发两个请求时全部卡死。原因是requests是同步阻塞库在 asyncio 事件循环里调用会冻结整个 server。统一改用httpx.AsyncClient。如果某个第三方库只有同步版本用await asyncio.to_thread(sync_func, args)包一层。5.4 子进程 stdout 缓冲区满导致死锁现象MCP server 运行一段时间后无响应但进程还在。原因是 Python 子进程往 stdout 输出了大量日志Node 侧没有及时消费管道缓冲区满了之后子进程的write阻塞。解决方式是在spawn时绑定data事件处理器哪怕只是console.log。上面的mcp-launcher.js已经处理了这一点。5.5 追踪装饰器顺序写反现象工具能调通但追踪日志里没有记录。检查track_call和mcp.tool()的顺序。正确写法是track_call在上、mcp.tool()在下。Python 装饰器从下往上执行track_call在外层才能包裹住注册逻辑。5.6 返回值过大导致上下文溢出现象list_recent_chats调用后模型报上下文超限。原因是没加 limit 限制一个活跃账号返回几千条记录。所有列表类工具都要带分页参数默认值不超过 20上限不超过 100。上面的代码里已经用min(max(limit, 1), 100)做了边界保护。5.7 CORS 预检被拦现象Electron 渲染进程直接发 HTTP 请求到 MCP server 时报 CORS 错误。这个问题的根因是渲染进程不应该直接发请求必须走 IPC。如果你确实需要直连比如调试在 FastMCP 底层加 CORS 中间件from starlette.middleware.cors import CORSMiddleware mcp.app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[*], allow_headers[*], )生产环境要把allow_origins限制为已知来源不要用*。6. 下一步把 Key 和追踪接进你的项目到这里Electron 双进程桥接的骨架、7 个工具的完整代码、调用追踪的埋点都已经铺开了。你可以直接把mcp-launcher.js、ipc-bridge.js、server.py、tracker.py四个文件复制进项目改掉BACKEND_URL和端口号就能跑。Key 的管理建议集中到 TaoToken 一个入口。如果你还在用多个厂商的 Key 散落在不同工具里可以到 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 生成一个统一 Key然后在 Electron 主进程的环境变量里注入。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 里面有不同语言的调用示例。如果你主要用 Claude Code 做编码可以看看 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 里的套餐说明把编码工具的模型调用也走同一个 Key。想先验证模型对话是否通直接到 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 发一条消息试试。下一篇会写 MCP 调用失败的排查实录把连接层、协议层、业务层、模型层的典型报错按树状图展开每一层附真实错误信息和解决命令。如果你现在正在跟某个 MCP 报错搏斗那篇基本就是为你写的。