AIRI 开发日志:用 Tauri MCP 插件打通 LLM 与安卓手机的调用链路
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
本篇技术指南来自 AIRI 项目 2025.04.28 的官方开发日志(DevLog-2025.04.28),由项目成员 @LemonNeko 撰写,核心讲述了为 Tauri 桌面应用编写 MCP(Model Context Protocol)插件的完整过程:从最朴素的 Tauri command 出发,逐步演进为可发布的官方插件,最终让 AIRI 能够与任意 MCP 服务器交互、并通过 ADB 操作安卓手机。读完本文,你将掌握 Tauri 2 插件化改造、状态管理、权限系统、JSON 参数透传等关键实战技巧,以及 LLM → Tauri → MCP 服务器 → 设备的完整调用链路设计思路。
原日志用一张架构图总结了整条链路,即"从 LLM 调用安卓手机":
背景:MCP 服务器的"前半部分"与"后半部分"
在 上一篇 DevLog-2025.04.22 中,作者介绍了 AIRI 操作手机的前半部分:用 Python 的FastMCP编写了一个名为airi-android的 MCP 服务器 Demo,通过ppadb(Python ADB 客户端)为 LLM 提供基础的手机交互能力,例如查询设备列表、截屏、点击、滑动等,并且打包成了 Docker 镜像提交到了 MCP 服务器列表。
但仅有 MCP 服务器是不够的——AIRI 还需要能与 MCP 服务器交互,即"后半部分"。在 2025.04.28 这两天,作者完成了这一部分:给 Tauri 写了一个 MCP 插件(对应当时的 PR #144),现在 AIRI 可以与现有的所有 MCP 服务器交互了。
在理解这段开发故事之前,有必要先明确 MCP 的核心概念。根据上一篇日志的总结,MCP 尝试标准化"应用如何给 LLM 提供上下文",并提出了三类核心原语:Resources(资源)、Prompts(提示词)和Tools(工具)。airi-android 暴露给 LLM 的就是 Tools——LLM 通过调用
input_swipe这类工具来完成滑动屏幕等动作。
如果你想直观感受效果,可以参考日志附带的两段演示视频:AIRI 的 MCP 服务器设置演示 与 AIRI 在手机上输入Hello World演示。
起点:先别写插件,从暴露命令开始
作者一开始并没有打算写一个完整的 Tauri 插件,只是想给 JavaScript 侧暴露一些命令。最初的 Rust 侧代码非常简单:
#[Tauri::command] fn list_tools() -> Vec<String> { // 之后再实现 }JavaScript 侧则写一些工具函数来调用它们:
import { invoke } from '@Tauri-apps/api/core' export const mcp = [ { name: 'list_tools', description: 'List all tools', execute: async () => { return await invoke('list_tools') } } ]但很快作者意识到一个问题:如果想在命令中使用 MCP 客户端,就需要让 MCP 客户端作为状态的一部分由 Tauri 来管理。MCP 客户端需要在整个应用生命周期内保持连接(会话、工具列表等都是有状态的),而 Tauri 的 command 是无状态的函数调用,因此必须借助 Tauri 的State机制:
// main.rs fn main() { Tauri::Builder::default() .setup(|app| { app.manage(State::new(Mutex::new::<Option<McpClient>>(None))); // 管理状态 }) .run(Tauri::generate_context!()) } // mcp.rs #[Tauri::command] async fn list_tools(state: State<'_, Mutex<Option<McpClient>>>) -> Result<Vec<Tool>, String> { // 可以在参数中拿到状态 // ...rest code }这里有一个值得注意的实现细节:状态被包在Mutex<Option<McpClient>>里,Mutex保证多线程下的并发安全,Option则表达"客户端可能尚未初始化/连接失败"的语义;异步命令中通过state.lock().await获取锁。这为后面插件化打下了基础。
演进:把命令打包成 Tauri 插件
有了命令、有了状态,作者决定将其正式做成一个 Tauri 插件,这样既能公开发布,也让代码的组织方式更规范。但插件化带来了一系列连锁改动。
调用方式的改变
成为一个插件后,命令的调用方式变了,需要通过插件命名空间来调用:
import { invoke } from '@Tauri-apps/api/core' export mcp = [ { name: "list_tools", description: "List all tools", execute: async () => { - return await invoke("list_tools") + return await invoke("plugin:mcp|list_tools") } } ]plugin:mcp|list_tools是 Tauri 插件命令的标准调用格式,前缀plugin:表明这是插件提供的命令,mcp是插件名,list_tools是命令名。从 diff 看,改动其实只有一行,还算轻松。
Tauri 2 的权限机制
然而 Tauri 2 引入了权限(permissions)机制,插件作者需要在build.rs中声明插件的命令,以便在构建时自动生成权限列表:
const COMMANDS: &[&str] = &[ "list_tools", ]; fn main() { Tauri_plugin::Builder::new(COMMANDS).build(); }这样在构建时,项目根目录下会生成permissions文件夹,其中包含权限声明、描述等文件。凡是想要在应用侧调用插件命令,都必须显式授予对应权限——这为应用的安全性提供了细粒度的控制。
小插曲:第二次构建时作者升级了
tauri-plugin的版本,新版本中生成模板发生了变化,一些空格被删掉了,看起来就像文件"被格式化"过一样。作者花了整整一个小时到处寻找是"什么东西在格式化它",最后才发现只是文件被重新生成了。作者用 🤡 表情纪念这个被吃掉的一小时——这也是所有插件开发者都可能踩到的坑:构建生成物(generated files)不要手工维护,也不要为它们的外观变化感到困惑,检查生成逻辑即可。
打通参数链路:从 LLM 到 Python MCP 服务器的完整旅程
根据架构图,当 LLM 调用 MCP 工具时,参数最终会被传递给 Python 侧的 MCP 服务器。以input_swipe为例,airi-android 的服务端是这样定义的:
# mcp_server.py from mcp.server.fastmcp import FastMCP from ppadb.client import Client mcp = FastMCP("airi-android") adb_client = Client() @mcp.tool() def input_swipe(x1: int, y1: int, x2: int, y2: int, duration: int = 500): return adb_client.input_swipe(x1, y1, x2, y2, duration)那么问题来了:Rust 侧怎么把这些参数组装成 MCP 协议要求的格式?MCP Rust SDK(rmcp)中的CallToolRequestParam是这样定义的:
pub struct CallToolRequestParam { pub name: Cow<'static, str>, pub arguments: Option<JsonObject>, }关键在arguments: Option<JsonObject>——参数就是一个 JSON 对象。而 Tauri 命令的参数可以是任何能被序列化成 JSON 的对象,所以作者选择直接给命令传一个Map<String, Value>,实现"JSON 到 JSON"的无缝透传:
#[Tauri::command] async fn call_tool(state: State<'_, Mutex<Option<McpClient>>>, name: String, args: Option<Map<String, Value>>) -> Result<(), ()> { let client = state.lock().await.unwrap(); client.call_tool(CallToolRequestParam { name: name.into(), arguments: args }).await.unwrap(); Ok(()) }JavaScript 侧只需要传一个普通对象即可,完全不需要关心类型转换的细节:
import { invoke } from '@Tauri-apps/api/core' invoke('call_tool', { name: 'input_swipe', args: { x1: 100, y1: 100, x2: 200, y2: 200, duration: 500 } })作者对这套方案的评价是"超方便!"——因为整个参数链路从 LLM 的 JSON 到 JavaScript 对象、再到 Rust 的Map<String, Value>、最后到 MCP 协议的JsonObject,全程都是 JSON 形态,几乎没有类型转换成本。
返回值:相信 LLM 会处理好
参数传递之外,还需要接收 MCP 工具的返回值。既然 Tauri 命令的返回值也可以是任何能被序列化成 JSON 的对象,作者选择"摆烂":把 MCP 工具返回的原始结果整个丢给 LLM,相信 LLM 会处理好。
这个设计思路在如今仓库的代码中得到了印证:在 MCP stdio 管理器 中,callTool会把 MCP 响应的content、structuredContent、isError、toolResult等字段原样提取后返回,不做任何语义加工;渲染进程侧则把工具列表动态注册进 LLM 的工具存储(见 渲染进程 MCP 工具 store),工具 ID 统一使用mcp:前缀,与createMcpTools生成的可执行工具描述一起提供给 LLM。这正呼应了日志中"把工具的返回整个丢给 LLM"的理念:LLM 是最终的理解者,MCP 链路只负责无损透传。
源码印证:如今仓库里的 MCP 实现
有意思的是,日志发表时 AIRI 还在使用 Tauri;而当前仓库中的桌面端 stage-tamagotchi 已经演化为 Electron + Vue 架构,MCP 支持也变得更加完整。这些实现正好可以作为日志中所描述思路的"后日谈"佐证:
- 会话管理:
createMcpStdioManager使用StdioClientTransport拉起 MCP 服务器子进程,并用@modelcontextprotocol/sdk的Client建立连接;每个服务器对应一个 session,统一存放在sessionsMap 中,退出应用时通过生命周期钩子统一stopAll。 - 配置与校验:配置持久化在用户数据目录下的
mcp.json,由 mcp-config.ts 中定义的 Zod schema 做严格校验。每个服务器条目支持command、args、env、cwd、enabled五个字段——command必填且非空,其余均可选,这实际上是 MCP stdio 服务器配置的完整字段集。 - 运行时状态:管理器维护每个服务器的运行状态(
running/stopped/error),记录 PID、启动命令和最后一次错误信息,供设置界面展示。 - 容错与超时:所有 MCP 请求都设置了超时(如 10 秒请求超时、15 秒总超时),
listTools失败时只记录警告而不中断整体流程;callTool在工具名带传输前缀或分隔符时还会尝试规范化名称后重试一次。 - 连通性测试:
testServer会临时拉起一个独立 transport 并执行listTools,把成功获取的工具名列表或失败时的 stderr 尾部(最多 16,000 字符)返回给设置页——对应日志中"AIRI 的 MCP 服务器设置"演示视频里的连接测试能力。 - UI 支持:设置页提供了 McpServerForm.vue、McpJsonEditor.vue、McpConnectionTestPanel.vue 等组件,并配套 mcp-config.test.ts、mcp.test.ts 等测试用例,保证了配置解析与工具注册逻辑的正确性。
此外,工具名的跨服务器命名空间也很值得一提:listTools会把工具名组装为${serverName}::${toolName}的限定形式,从而支持多 MCP 服务器共存而不产生命名冲突——这实际上已经部分回答了日志末尾"多 MCP 服务器支持"的展望。
开放问题:作者留给社区的讨论
日志中作者抛出了三个值得深思的问题,这里完整保留,供读者在理解架构后继续思考:
- 工具列表能否预加载进系统提示词?从演示视频可以看到,对话中 AIRI 先是获取了一下工具列表,再执行输入文本。那能不能在初始化的时候就去获取工具列表,直接追加到系统提示词中?
- Cursor 就是这样做的:开发 MCP 服务器时,每次改动工具列表都需要重启 Cursor 才能生效。
- 这样做也许会牺牲灵活性,但普通用户会频繁改动工具列表吗?
- 要允许 AIRI 同时连接到多个手机吗?AIRI 可能会想使用多台手机吗?(作者调侃道:她会不会想拿去做电信诈骗?——此处是玩笑)
- 仓库结构如何演进?现在 AIRI 仓库中已经有了 Tauri 应用和 Tauri 插件,要怎么管理比较好?CI 要怎么配置?如何同步 Tauri 插件的 Rust 侧和 JavaScript 侧的版本号?
从如今的仓库代码看,第一个问题已经在实践中得到了倾向性答案:MCP 工具是通过运行时动态refresh注册进 LLM 工具存储的(见 mcp.ts),而不是写死在系统提示词中,这样工具的增删改无需重启应用即可生效;第三个问题的版本管理难题,在 monorepo 环境下也演变成了更常规的包版本同步问题。
未来计划:日志中的 Roadmap
日志结尾,作者列出了一份清晰的未来计划:
- 支持图片返回值:这样 AIRI 就可以像 上一篇 DevLog 中展示的 Cursor 那样,直接通过视觉能力看到手机上的内容,然后再决定用什么方式来交互。
- 让 AIRI 自己学习设备的使用方法:如果每种设备都要单独写提示词,工作量是巨大的。
- 多 MCP 服务器支持:MCP 提供了一种通用的接口,可以允许 AIRI 做各种各样的事,AIRI 应该不会满足于只操作手机吧。
- SSE 支持:这样浏览器中的 AIRI 也可以使用 MCP 服务器了。
值得一提的是,日志发表前的 DevLog-2025.04.22 中已经讨论过 AI 操作手机的具体流程(截屏了解内容 → UI 自动化获取元素位置 → 点击或滑动 → 重复),以及游戏画面无法被 UI 自动化工具解析、复杂操作需要多步触发、动画场景下截屏时机、操作安全性等问题。将这些讨论与本篇日志对照阅读,可以完整看到 AIRI 从"写一个 MCP 服务器 Demo"到"让桌宠真正接入 MCP"的演进脉络。
小结
这篇日志的价值在于完整记录了一个 Tauri 插件从 0 到 1 的真实开发过程:从"先暴露命令"的最小可行方案,到发现状态管理需求,再到升级为插件并适配 Tauri 2 的权限体系;从 MCP 协议参数结构的分析,到利用"JSON 透传"巧妙打通 LLM 与 Python MCP 服务器的调用链。其核心设计哲学——"链路只做无损透传,理解交给 LLM"——在今天的 AIRI 仓库实现中依然清晰可见。如果你正在为桌面应用接入 MCP,或想了解如何让 LLM 通过统一协议操作外部设备,这篇日志是一个很好的起点。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考