AIRI 如何通过 MCP 协议让 AI 桌面伙伴操控 Android 手机:Tauri MCP 插件开发全记录
AIRI 如何通过 MCP 协议让 AI 桌面伙伴操控 Android 手机:Tauri MCP 插件开发全记录
导读
本篇技术指南以 AIRI 开发日志(DevLog 2025.04.28)为主线,完整还原了作者 LemonNeko 为 AIRI 打通「LLM → MCP 客户端 → MCP 服务器 → ADB → Android 手机」整条调用链的实战过程:从编写 Python 版 MCP 服务器 airi-android,到用 Rust 编写 Tauri 插件把 MCP 客户端接入桌面应用,再到解决参数序列化、工具调用、权限机制等关键工程问题。读完本文,你将掌握 MCP 双端(服务器端与客户端)的开发套路、Tauri 2 插件化开发的完整流程,以及 AIRI 当前仓库中 MCP 能力的最新落地形态。
背景:让 AIRI 拥有"操作手机"的能力
AIRI 是一款自托管的 AI 桌面伙伴应用,作者一直希望它能像 Cursor 那样"看见屏幕、操作设备"。要实现这一目标需要解决两个问题:
- 前半部分:让 AIRI 能连接并操作 Android 手机——作者用一周时间编写了 MCP 服务器 [AIRI-android],基于 ADB(Android Debug Bridge)为 AIRI 提供基础的手机交互能力;
- 后半部分:让 AIRI(作为 MCP 客户端)能够真正与这类 MCP 服务器对话——这就是本文要展开的 Tauri 插件开发过程。
作者在开发过程中画了一张架构图,清晰描述了 LLM 调用 Android 手机的整条链路:
从图(及后续实现)可以看出:LLM 发起工具调用请求 → MCP 客户端(位于桌面应用中)转发 → MCP 服务器接收 → 底层通过 ADB 与 Android 手机交互,返回值再沿原路返回给 LLM。
第一步:编写 Python 侧 MCP 服务器(airi-android)
MCP(Model Context Protocol,模型上下文协议)是标准化"应用如何向 LLM 提供上下文"的开放协议,核心概念包括 Resources(服务器以数据/内容形式向 LLM 提供上下文)、Prompts(可复用的提示模板与工作流)和 Tools(允许 LLM 通过服务器执行动作)。
airi-android 采用 Python 的 FastMCP 框架实现,配合 ppadb(纯 Python 的 ADB 客户端)。以滑动操作为例,一个 MCP 工具的定义非常简洁:
# 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)
这段代码有两点值得注意:
- 类型注解即参数 Schema:
x1、y1、x2、y2为必填的int,duration带默认值500,FastMCP 会自动据此生成 JSON Schema,供 LLM 在调用时按规则填参; - 函数体即执行逻辑:直接转发给
ppadb的 ADB 客户端,屏蔽了底层 adb 协议的复杂度。
第二步:Tauri 插件开发——从"暴露命令"到"完整插件"
初版思路:直接在 JS 侧暴露命令
作者最初的设想非常简单:不想写一个完整的 Tauri 插件,只想在 JavaScript 侧暴露几个命令。Rust 侧定义一个 #[Tauri::command]:
#[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 客户端必须由 Tauri 管理状态
很快作者发现一个关键问题:如果要在命令中使用 MCP 客户端,就需要把 MCP 客户端作为 Tauri 应用状态的一部分来管理,否则命令之间无法共享同一个客户端连接。Tauri 提供了 State + manage 的官方状态管理模式:
// 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>> 的含义是:初始为 None(尚未连接),在 async 命令中通过 State 注入获取锁后使用。这是 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")
}
}
]
只需改动一行:命令名从 list_tools 变为 plugin:mcp|list_tools(插件前缀 plugin: + 插件名 + 命令名)。
Tauri 2 权限机制:build.rs 自动生成权限清单
Tauri 2 引入了权限机制。插件必须在 build.rs 中声明命令列表,构建时自动生成权限声明文件:
const COMMANDS: &[&str] = &[
"list_tools",
];
fn main() {
Tauri_plugin::Builder::new(COMMANDS).build();
}
这样每次构建都会在项目根目录生成 permissions 文件夹,包含权限声明、描述等文件。
开发插曲:作者第二次构建时升级了
Tauri-plugin版本,新版本改变了生成模板,删掉了一些空格,导致生成文件看起来"像是被格式化过"。作者花了一小时排查到底是什么在做"格式化",最后才意识到文件是被重新生成的。这也提醒我们:构建时自动生成的文件不要手动修改,版本升级后模板变化属正常现象。
第三步:打通参数传递链路——从 LLM 到 Python MCP 服务器
架构图中最关键的一环是:LLM 调用 MCP 工具时,参数如何一路传递到 Python 侧的 MCP 服务器?
以 input_swipe 为例。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> 即可:
#[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 } })
返回值的处理策略:由于 Tauri 命令的返回值也可以是任意可 JSON 序列化的对象,作者直接"把工具返回值原样抛给 LLM"——相信 LLM 有能力自行理解返回内容。这种"信任 LLM 消化"的简化策略,避免了为每个工具手写返回值解析器。
从仓库源码看 MCP 能力的后续演进
开发日志发布时,MCP 能力以 Tauri 插件形式存在(PR #144)。从当前仓库源码看,这套能力已经演化为更完整的形态,可以作为理解本文主题的延伸参照:
1. 配置化多服务器管理(McpStdioManager)
在 apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts 中,实现了 McpStdioManager,负责对多个 stdio 型 MCP 服务器进行全生命周期管理:
- 配置持久化:配置文件
mcp.json存放在 Electron 的userData目录(getConfigPath()返回join(app.getPath('userData'), 'mcp.json')),首次启动自动写入{ "mcpServers": {} }默认结构; - 启动与重启:
applyAndRestart()读取配置后停止旧会话、逐一起动新服务器,并按enabled: false跳过禁用项,返回started/failed/skipped三类结果; - 连接实现:使用官方 TypeScript SDK(
@modelcontextprotocol/sdk)的Client+StdioClientTransport,通过子进程的 stdin/stdout 与服务器通信,同时把服务器 stderr 接入日志系统(transport.stderr?.on('data', ...)); - 工具发现:
listTools()向每个会话调用client.listTools,把工具名规范化为服务器名::工具名(源码中toolNameSeparator = '::'),并附带description与inputSchema; - 工具调用:
callTool()解析限定名serverName::toolName,转发给对应会话,并带 10 秒请求超时与 15 秒总超时(mcpRequestTimeoutMsec/mcpRequestMaxTotalTimeoutMsec);失败时还会尝试用归一化工具名重试一次; - 连接测试:
testServer()提供"启动并列出工具"的冒烟测试,用于设置界面即时验证服务器配置是否正确,失败时截取 stderr 末尾 16000 字符返回给 UI。
对应测试见 apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.test.ts,其中验证了"连接失败时捕获 stderr 并包含在测试结果中"这一行为。
2. mcp.json 配置 Schema(Zod 严格校验)
apps/stage-tamagotchi/src/shared/mcp-config.ts 用 Zod 定义了持久化配置的严格结构,主进程与渲染进程共用同一套校验入口:
- 根对象只允许
mcpServers字段(.strict()); - 每个服务器条目支持
command(必填非空字符串)、args(字符串数组)、env(字符串键值对)、cwd(工作目录)、enabled(布尔开关)等字段; - 校验失败时会把
mcpServers.fs.command: Too small...这样的路径化错误信息返回给 UI,便于定位问题。
一个典型的配置示例:
{
"mcpServers": {
"airi-android": {
"command": "uvx",
"args": ["airi-android"],
"env": { "ADB_HOST": "127.0.0.1" },
"enabled": true
}
}
}
设置界面也围绕这些能力展开,参见 apps/stage-tamagotchi/src/renderer/pages/settings/modules/mcp.vue 及其组件(McpServerForm.vue、McpJsonEditor.vue、McpConnectionTestPanel.vue)。
3. MCP 工具注册进 LLM 工具存储
在 packages/stage-ui/src/tools/mcp.ts 中,createMcpTools(runtime) 把 MCP 能力包装成两个对 LLM 暴露的 xsai 工具:
builtIn_mcpListTools:列出所有可用 MCP 工具,供 LLM 先发现再调用;builtIn_mcpCallTool:按<serverName>::<toolName>格式调用指定工具,参数以 JSON 字符串形式传入(arguments是z.string(),注释说明了原因:z.unknown()与z.record()的 schema 输出会被 OpenAI 拒绝)。
渲染进程侧则由 apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts 中的 tamagotchi-mcp-tools store 负责:refresh() 拉取工具列表并注册进共享的 llmToolsStore(工具 ID 以 mcp: 为前缀),dispose() 则按需移除。
这些实现印证了 DevLog 中讨论的"工具列表动态获取"方向:当前实现采用 LLM 先调用 builtIn_mcpListTools 再调用 builtIn_mcpCallTool 的动态发现模式,而非把工具列表硬编码进系统提示。
作者的三个开放性问题
问题一:工具列表该何时获取?
Demo 视频里,作者在会话中先让 AIRI 获取工具列表,再让其输入文字。那么能否在初始化时就获取工具列表并直接追加进系统提示?
- Cursor 的做法是后者:修改 MCP 服务器工具列表后必须重启 Cursor 才能生效;
- 把工具列表放进系统提示会牺牲灵活性,但普通用户真的会频繁修改工具列表吗?
两种方案各有利弊:动态发现(当前仓库采用的模式)灵活性高、与服务器实时状态一致;静态注入则省去一次工具发现调用,但对长会话更友好。
问题二:是否允许同时连接多台手机?
允许 AIRI 同时连接多台手机,意味着 McpClient 状态要从单例变为多实例集合,工具名也需要区分设备。从当前仓库实现看,McpStdioManager 已经用 Map<string, McpServerSession> 按服务器名管理多个会话,工具名带服务器前缀,天然支持多服务器并存——这为多设备场景打下了基础。
问题三:Tauri 应用与 Tauri 插件的仓库与版本管理?
仓库里同时存在 Tauri 应用与 Tauri 插件时,如何管理仓库边界、配置 CI、同步 Rust 侧与 JavaScript 侧的版本号?插件发布需要同时处理 crates.io 与 npm 两个生态,版本同步是常见的痛点(AIRI 仓库本身采用 pnpm workspace + turbo 的 monorepo 结构,可参考 package.json 与 turbo.json)。
未来计划:从"操作手机"走向"万物互联"
作者在 DevLog 末尾列出了四项后续规划,结合后续开发日志(DevLog 2025.07.18)与当前仓库,这些方向大多已陆续落地:
- 支持图片返回值:让 AIRI 通过视觉能力"看"手机屏幕内容再决定如何交互,类似前一篇 DevLog(DevLog 2025.04.22)中 Cursor 的截图—定位—操作循环;
- 设备使用方式自学习:避免为每种设备手写提示词——若设备种类繁多,人工编写提示的工作量将不可接受;
- 多 MCP 服务器支持:MCP 提供通用接口,AIRI 不应止步于操作手机。这一点已在 07.18 的 DevLog 中体现——团队为 Factorio AI Agent 项目开发了
mcp-launcher,用 MCP 服务器替换了原先"LLM 生成 Lua 代码 + RCON 执行"的复杂链路;当前仓库的McpStdioManager亦已支持多服务器并行; - SSE 支持:让浏览器内的 AIRI 也能使用 MCP 服务器(stdio 传输依赖本地子进程,浏览器环境需要基于 HTTP/SSE 的远程传输)。
结语
从一周前的 Python MCP 服务器,到两天后的 Tauri 插件,作者用极短的时间打通了"LLM → MCP → ADB → Android 手机"的完整链路。这条开发路径对任何想为 AI 应用接入外部设备控制能力的开发者都有参考价值:先用 FastMCP 快速实现服务器端,再用 Tauri 状态管理 + 权限机制把客户端嵌入桌面应用,最后用"JSON 全链路直传"简化参数与返回值的处理。
值得一提的是,DevLog 中"我试图理解 LLM 反馈来调整代码、和 LLM 一起思考解决问题"的体验——当开发者以"让 AI 使用我的工具"为目标编码时,AI 既是客户也是同事,这种协作方式本身也是本系列开发日志想传递的价值。
(本文以 docs/content/en/blog/DevLog-2025.04.28/index.md 为主干,结合当前仓库 MCP 相关源码撰写。演示视频 airi-mcp-settings.mp4 与 airi-mcp-input-text.mp4 位于 docs/content/en/blog/DevLog-2025.04.28/assets/,可供复现与验证。)
