跳转到内容

MCP 协议深度集成

AI-02: MCP 协议深度集成

学习目标

完成本章后,你将能够:

  1. 理解 Cherry Studio MCPService 的四层传输架构(Stdio / SSE / StreamableHTTP / In-Memory)
  2. 掌握 Electron 主进程中 MCP 客户端的生命周期管理(连接、缓存、断开、重启)
  3. 描述 MCP OAuth 认证的完整流程,包括 McpOAuthClientProvider + CallBackServer 的协同机制
  4. 识别 MCP 工具调用从渲染进程到主进程的完整 IPC 链路
  5. 解释 Cherry Studio 在敏感数据脱敏、日志缓冲、连接超时等方面的工程实践

前置知识

本章不重复编写上述通用知识,仅聚焦 Cherry Studio 的具体实现方案。

项目实践

1. MCPService 整体架构

MCPService 位于 src/main/services/MCPService.ts(约 46KB、1200 行),是 Cherry Studio 主进程(Electron Main Process)中负责 MCP 服务器管理的核心服务类。它基于 @modelcontextprotocol/sdk 1.27.1 构建,承担了以下职责:

  • MCP 服务器的连接管理(initClient / closeClient / restartServer
  • 工具、Prompt、资源的查询与调用
  • OAuth 认证流程的编排
  • 服务器日志缓冲与 IPC 通知推送
  • 与 DxtService 协同管理 DXT 插件中的 MCP 服务器

2. 四种传输方式的选择逻辑

Cherry Studio 通过 initTransport 内部函数实现传输方式的自动选择。决策树如下:

2.1 StdioClientTransport — 本地进程

当服务器配置了 command 字段时(如 npxuvxbun),MCPService 会:

  1. 解析命令路径:优先使用用户登录 Shell 环境中的系统命令;若不存在则回退到 Cherry Studio 内置的捆绑二进制(如 bun
  2. 处理环境变量:合并登录 Shell 环境变量与用户自定义的 server.env
  3. DXT 插件支持:若 server.dxtPath 存在,通过 DxtService.getResolvedMcpConfig() 获取平台覆盖后的最终配置
  4. 注册表配置:针对 npx 注入 NPM_CONFIG_REGISTRY;针对 uvx/uv 注入 UV_DEFAULT_INDEXPIP_INDEX_URL
  5. stderr 捕获:将 StdioClientTransport 的 stderr 输出写入 ServerLogBuffer

伪代码描述 Stdio 传输的初始化过程:

function initStdioTransport(server):
获取登录 Shell 环境变量 (getLoginShellEnvironment, 已 Memoize)
if server.dxtPath:
resolvedConfig = dxtService.getResolvedMcpConfig(server.dxtPath)
command = resolvedConfig.command
args = resolvedConfig.args
合并 resolvedConfig.env 到 server.env
if command == "npx":
npxPath = 在 Shell 环境中查找 npx
if npxPath 存在:
command = npxPath
else:
if bun 存在:
command = bundledBunPath
转换 args 为 bun x 格式 (-y, x)
else:
抛出 Error("npx 和 bun 均不可用")
if server.registryUrl:
env["NPM_CONFIG_REGISTRY"] = server.registryUrl
if command == "uvx" 或 "uv":
类似 npx 的解析流程
if server.registryUrl:
env["UV_DEFAULT_INDEX"] = server.registryUrl
env["PIP_INDEX_URL"] = server.registryUrl
if command 包含 "bun":
移除环境中的代理设置 (Bun 不支持代理)
transportOptions = {
command: command,
args: args,
env: 登录 Shell 环境变量 + server.env,
stderr: "pipe"
}
if server.dxtPath:
transportOptions.cwd = server.dxtPath
transport = new StdioClientTransport(transportOptions)
transport.stderr.on("data", 写入 ServerLogBuffer)
return transport

2.2 SSEClientTransport — SSE 远程

server.baseUrl 存在且 server.type === "sse" 时:

function initSSETransport(server, authProvider):
options = {
eventSourceInit: {
fetch: (url, init) => electron.net.fetch(url, init)
},
requestInit: {
headers: {
...defaultAppHeaders(),
...server.headers
}
},
authProvider: authProvider
}
return new SSEClientTransport(new URL(server.baseUrl), options)

关键点:使用 Electron 的 net.fetch 而非全局 fetch,以绕过 Node.js 的网络限制并利用 Electron 的网络栈。

2.3 StreamableHTTPClientTransport — StreamableHTTP 远程

server.baseUrl 存在且 server.type === "streamableHttp" 时,流程与 SSE 类似但使用 StreamableHTTPClientTransport 类。值得注意的是,内置服务器 nowledgeMemflomo 虽然标记为内置,但实际使用 StreamableHTTP 传输连接到固定 URL:

内置服务器目标 URL
nowledgeMemhttp://127.0.0.1:14242/mcp
flomohttps://flomoapp.com/mcp

2.4 InMemoryTransport — 进程内通信

对于非 nowledgeMem / flomo / mcpAutoInstall 的内置服务器,使用 InMemoryTransport.createLinkedPair() 创建一对链接的传输端:

function initInMemoryTransport(server):
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
inMemoryServer = createInMemoryMCPServer(server.name, args, server.env)
inMemoryServer.connect(serverTransport)
return clientTransport

这种方式使得 MCP 服务器作为进程内的 JavaScript 模块运行,无需启动外部子进程,适用于 Cherry Studio 自研的内置工具(如记忆系统、笔记系统等)。

3. 客户端生命周期管理

3.1 连接流程

3.2 连接超时策略

Cherry Studio 定义了连接超时地板值:

MCP_CONNECT_TIMEOUT_FLOOR_MS = 180_000 // 3 分钟

实际超时取 server.timeout * 1000 与地板值的较大者:

connectOptions.timeout = max(server.timeout * 1000, MCP_CONNECT_TIMEOUT_FLOOR_MS)

设计意图:MCP 连接每个会话仅初始化一次, generous 的超时地板值可以避免在慢速 SSE/streamableHttp 握手时产生误判,同时仍允许用户通过 server.timeout 配置更大的超时值。

3.3 缓存与去重

MCPService 使用三个 Map 管理客户端状态:

Map作用
clients: Map<string, Client>已连接的活跃客户端,key 为 getServerKey(server)
pendingClients: Map<string, Promise<Client>>正在连接中的客户端,防止并发重复初始化

getServerKey 基于服务器的 baseUrlcommandargsregistryUrlenvid 生成 JSON 字符串,确保相同配置共享同一个客户端实例。

function initClient(server):
serverKey = getServerKey(server)
// 防止并发重复初始化
if pendingClients.has(serverKey):
return pendingClients.get(serverKey) // 等待已有初始化
// 复用已有客户端
if clients.has(serverKey):
if client.ping({timeout: 1000}) 成功:
return clients.get(serverKey)
clients.delete(serverKey) // ping 失败,移除
// 创建新连接
initPromise = async ():
client = new Client({name: "Cherry Studio", version: app.getVersion()})
transport = await initTransport()
client.connect(transport, connectOptions)
setupNotificationHandlers(client, server)
clearServerCache(serverKey)
clients.set(serverKey, client)
return client
pendingClients.set(serverKey, initPromise)
return initPromise
最终: pendingClients.delete(serverKey)

3.4 缓存策略

工具、Prompt、资源的查询结果均通过 CacheService 缓存,各自有不同的 TTL:

资源类型缓存 Key 模式TTL
工具列表mcp:list_tool:{serverKey}5 分钟
Prompt 列表mcp:list_prompts:{serverKey}60 分钟
获取特定 Promptmcp:get_prompt:{serverKey}:{name}:{argsKey}30 分钟
资源列表mcp:list_resources:{serverKey}与工具列表共享管理

当收到 SDK 通知(如 ToolListChangedNotification)时,对应缓存会被清除。

4. MCP OAuth 认证实现

OAuth 认证组件位于 src/main/services/mcp/oauth/ 目录下,包含四个文件:

文件作用
provider.tsMcpOAuthClientProvider — 实现 MCP SDK 的 OAuthClientProvider 接口
callback.tsCallBackServer — 本地 HTTP 服务器,接收 OAuth 回调
storage.tsJsonFileStorage — 基于 JSON 文件的 OAuth 凭证持久化
types.ts类型定义与 Zod Schema

4.1 认证流程

4.2 McpOAuthClientProvider 核心能力

McpOAuthClientProvider 实现了 MCP SDK 的 OAuthClientProvider 接口,提供以下方法:

class McpOAuthClientProvider:
config:
serverUrlHash: MD5(server.baseUrl)
callbackPort: 12346 (默认)
callbackPath: "/oauth/callback"
clientName: "Cherry Studio"
clientUri: "https://github.com/CherryHQ/cherry-studio"
clientMetadata:
redirect_uris: ["http://127.0.0.1:{port}{path}"]
token_endpoint_auth_method: "none"
grant_types: ["authorization_code", "refresh_token"]
response_types: ["code"]
方法:
clientInformation() → 从 JsonFileStorage 读取客户端注册信息
saveClientInformation → 写入 JsonFileStorage
tokens() → 从 JsonFileStorage 读取 access/refresh token
saveTokens → 写入 JsonFileStorage
redirectToAuthorization → open(url) 打开系统浏览器
saveCodeVerifier → 保存 PKCE code verifier
codeVerifier → 读取 PKCE code verifier
invalidateCredentials(scope) → 按范围清除凭证:
"all" → 清除所有认证数据
"tokens" → 仅清除 token
"client" → 仅清除客户端注册信息
"verifier"→ 仅清除 PKCE verifier

4.3 CallBackServer — 本地回调服务器

CallBackServer 是一个轻量级的本地 HTTP 服务器:

  • 监听 127.0.0.1:{port}(默认 12346)
  • 仅处理 /oauth/callback 路径的请求
  • 通过 EventEmitter 将收到的 code 传递给等待方
  • 超时 5 分钟自动关闭
  • 返回 i18n 本地化的成功页面 HTML

4.4 JsonFileStorage — 凭证持久化

凭证存储在 {configDir}/mcp/oauth/{serverUrlHash}_oauth.json 中,文件包含:

  • clientInfo — OAuth 客户端注册信息
  • tokens — access_token 与 refresh_token
  • codeVerifier — PKCE code verifier
  • lastUpdated — 最后更新时间

写入时采用原子写入模式(先写 .tmp 文件,再 rename),确保数据一致性。

5. 工具调用链路

工具调用从渲染进程发起,经 IPC 到达主进程 MCPService:

工具调用的关键参数:

CallToolArgs = {
server: MCPServer, // 服务器配置(从 Redux 读取)
name: string, // 工具名称
args: any, // 工具参数(字符串会被 JSON.parse)
callId?: string // 调用 ID(用于进度追踪)
}

调用时的超时与长运行配置:

callTool 选项:
timeout: server.timeout * 1000 || 60000 // 默认 1 分钟
resetTimeoutOnProgress: server.longRunning // 长运行模式,进度时重置超时
maxTotalTimeout: server.longRunning ? 600000 : undefined // 长运行最大总超时 10 分钟
signal: AbortController.signal // 支持取消
onprogress: 发送进度事件到渲染进程

6. 工具名称构建

@shared/mcp.ts 中的 buildFunctionCallToolName 函数负责生成合法的 JavaScript 标识符:

buildFunctionCallToolName(serverName, toolName):
→ buildMcpToolName(serverName, toolName, {
prefix: "mcp__",
delimiter: "__",
maxLength: 63
})
示例:
buildFunctionCallToolName("github", "search_issues")
→ "mcp__github__searchIssues"

名称生成规则:

  • 两个部分均转换为 camelCase
  • 最终标识符以 mcp__ 为前缀
  • 最大长度 63 字符,超长时截断并添加后缀保证唯一性

7. 通知系统

MCPService 注册了五种 SDK 通知处理器:

通知类型处理行为
ToolListChangedNotification清除工具列表缓存
ResourceListChangedNotification清除资源列表缓存
PromptListChangedNotification清除 Prompt 列表缓存
ResourceUpdatedNotification清除资源相关缓存
LoggingMessageNotification写入 ServerLogBuffer 并通过 IPC 推送
CancelledNotification记录日志

8. ServerLogBuffer — 日志环形缓冲

class ServerLogBuffer(maxEntries = 200):
logs: Map<string, MCPServerLogEntry[]>
append(serverKey, entry):
将 entry 追加到该 server 的日志列表
如果超过 maxEntries,从头部删除多余条目
get(serverKey):
返回该 server 日志列表的副本
remove(serverKey):
删除该 server 的所有日志

每条日志通过 IpcChannel.Mcp_ServerLog 推送到渲染进程,供 MCP Trace 窗口展示。日志级别包括 debuginfowarnerrorstderrstdout

9. 敏感数据脱敏

redactSensitive 函数用于在日志中脱敏敏感字段:

function redactSensitive(input):
SENSITIVE_KEYS = ["authorization", "Authorization", "apiKey", "api_key", "apikey", "token", "access_token"]
MAX_STRING = 300
递归处理:
字符串 → 超过 300 字符时截断并附加 "…<N more>"
数组 → 递归处理每个元素
对象 → 对 SENSITIVE_KEYS 中的键替换为 "<redacted>",其余键递归处理
其他 → 原样返回

该函数在以下场景被调用:

  • 记录 StreamableHTTP 传输的 options(脱敏 headers)
  • 记录工具调用参数
  • 记录错误信息

10. DXT 插件中的 MCP 服务器

DxtService.ts(约 15KB)负责管理 DXT(Desktop Extension)插件格式中的 MCP 服务器:

  • .dxt 压缩包中读取 manifest.json
  • 解析 server.mcp_config 中的 commandargsenv
  • 支持 platform_overrides(按操作系统覆盖配置)
  • 提供 getResolvedMcpConfig(dxtPath) 方法返回最终配置
  • 提供 cleanupDxtServer(serverName) 清理临时解压目录

当 MCPService 检测到 server.dxtPath 时,会优先使用 DxtService 解析的配置,而非用户手动填写的配置。

11. Redux mcp slice 与 IPC 通道

MCP 服务器的配置由渲染进程的 Redux mcp slice 管理,通过 redux-persist 持久化。主进程通过 getMCPServersFromRedux() 函数读取 Redux 状态中的服务器列表。

IPC 通道定义于 packages/shared/IpcChannel.ts,MCP 相关通道包括:

IPC 通道方向用途
mcp.initClientRenderer → Main连接 MCP 服务器
mcp.listToolsRenderer → Main获取工具列表
mcp.callToolRenderer → Main调用工具
mcp.listPromptsRenderer → Main获取 Prompt 列表
mcp.getPromptRenderer → Main获取特定 Prompt
mcp.listResourcesRenderer → Main获取资源列表
mcp.getResourceRenderer → Main获取特定资源
mcp.stopServerRenderer → Main停止服务器
mcp.restartServerRenderer → Main重启服务器
mcp.removeServerRenderer → Main移除服务器
mcp.checkMcpConnectivityRenderer → Main检查连接性
mcp.getServerLogsRenderer → Main获取服务器日志
mcp.abortToolRenderer → Main取消工具调用
Mcp_ServerLogMain → Renderer推送服务器日志
Mcp_ProgressMain → Renderer推送工具调用进度

12. BuiltinMCPServerNames 常量

BuiltinMCPServerNames = {
nowledgeMem: "nowledge-mem", // 内置记忆系统
flomo: "flomo", // Flomo 集成
mcpAutoInstall: "mcp-auto-install" // 自动安装
}
isBuiltinMCPServer(server):
判断 server.name 是否在 BuiltinMCPServerNames 中

内置服务器走特殊逻辑,不走用户自定义的配置路径。

问题与规避

问题 1:并发初始化导致重复连接

场景:多个组件同时请求连接同一个 MCP 服务器,可能创建多个 Client 实例。

规避pendingClients Map 存储正在进行中的初始化 Promise。后续请求检测到已有 pending 时直接返回该 Promise,确保同一时刻只有一个连接过程在进行。

问题 2:OAuth 回调超时

场景:用户在浏览器中授权时间过长,CallBackServer 超时关闭后无法接收回调。

规避:设置 5 分钟超时;超时后关闭回调服务器并记录警告。用户需要重新发起连接,会触发新的 OAuth 流程。

问题 3:OAuth 凭证文件残留

场景:移除服务器后,OAuth 凭证文件({serverUrlHash}_oauth.json)仍留在磁盘上。

规避removeServer 中检查是否还有其他服务器使用相同的 baseUrl。若无,则删除对应的 OAuth 凭证文件。使用 md5(baseUrl) 作为存储键,确保相同域名的多个服务器共享同一份凭证。

问题 4:npx/uvx 命令不可用

场景:用户未安装 Node.js 或 Python uv 工具,MCP 服务器无法启动。

规避

  1. 优先在用户登录 Shell 环境中查找系统命令
  2. 回退到 Cherry Studio 内置的捆绑二进制(bun 用于替代 npx
  3. 若均不可用,抛出明确的错误信息引导用户安装

问题 5:Bun 不支持网络代理

场景:用户配置了 HTTP 代理,但 Bun 不支持代理环境变量。

规避:检测到命令包含 bun 时,调用 removeEnvProxy 移除代理环境变量。

问题 6:Ping 检测误判

场景:网络延迟导致 client.ping({timeout: 1000}) 超时,误认为客户端已断开。

规避:ping 失败时不立即报错,而是删除缓存后重新初始化客户端。重新初始化时会走完整的连接流程,包括 OAuth 认证。

问题 7:长运行工具调用超时

场景:某些 MCP 工具(如代码生成、文件处理)执行时间超过默认 1 分钟超时。

规避

  • 通过 server.longRunning 标志启用长运行模式
  • 长运行模式下,每次进度通知都会重置超时计时器
  • 设置 maxTotalTimeout: 10 * 60 * 1000(10 分钟)作为绝对上限
  • 通过 AbortController 支持用户主动取消

设计取舍

1. 为什么使用 Electron net.fetch 而非全局 fetch

SSE 和 StreamableHTTP 传输均使用 electron.net.fetch 替代 Node.js 全局 fetch。这确保了:

  • 遵循 Electron 的网络代理配置
  • 绕过 Node.js 的网络限制(如 NODE_TLS_REJECT_UNAUTHORIZED
  • 一致的 Cookie 和认证行为

2. 为什么设置 3 分钟连接超时地板值

MCP_CONNECT_TIMEOUT_FLOOR_MS = 180_000 的设计考量:

  • initialize 请求每个会话仅执行一次,成本较低
  • SSE 和 streamableHttp 握手可能需要较长时间(DNS 解析、TLS 握手、OAuth 重定向)
  • 3 分钟地板值足以覆盖绝大多数慢速连接场景
  • 用户仍可通过 server.timeout 配置更大的值

3. 为什么使用 JSON 文件而非数据库存储 OAuth 凭证

JsonFileStorage 选择文件存储而非 SQLite/IndexedDB:

  • OAuth 凭证数量极少(每个远程服务器一份)
  • JSON 文件便于调试和手动清理
  • 原子写入(.tmp + rename)保证数据一致性
  • 配置目录由 Electron 管理,随用户数据自动迁移

4. 为什么内置服务器使用特殊逻辑而非统一配置

内置服务器(nowledgeMemflomo)不走标准传输选择流程:

  • nowledgeMem 连接到本地 127.0.0.1:14242,需要绕过用户配置
  • flomo 连接到固定的第三方 URL
  • 这些服务器本质上是”特殊的远程服务器”,但作为内置功能暴露给用户

5. 为什么日志使用环形缓冲而非无限增长

ServerLogBuffer 限制每个服务器最多 200 条日志:

  • MCP 服务器可能产生大量日志(尤其是 LoggingMessageNotification
  • 环形缓冲避免内存泄漏
  • 200 条足以覆盖最近的异常场景
  • 渲染进程可通过 Mcp_ServerLog 通道实时接收新日志

6. 为什么工具调用使用 withSpanFunc 包装

所有工具调用均通过 withSpanFunc(来自 @mcp-trace/trace-core)包装:

  • 自动创建 OpenTelemetry Span
  • 关联 IPC 调用链路
  • 支持 MCP Trace 窗口的可视化追踪

参考来源