AIRI 如何通过 MCP 协议让 AI 桌面伙伴操控 Android 手机:Tauri MCP 插件开发全记录

原创2026-09-09 21:15:411,672 阅读
文章标签:人工智能AI 应用大模型AI Agent交互助手语音数字人后端前端桌面应用移动开发GUI 自动化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 那样"看见屏幕、操作设备"。要实现这一目标需要解决两个问题:

  1. 前半部分:让 AIRI 能连接并操作 Android 手机——作者用一周时间编写了 MCP 服务器 [AIRI-android],基于 ADB(Android Debug Bridge)为 AIRI 提供基础的手机交互能力;
  2. 后半部分:让 AIRI(作为 MCP 客户端)能够真正与这类 MCP 服务器对话——这就是本文要展开的 Tauri 插件开发过程。

作者在开发过程中画了一张架构图,清晰描述了 LLM 调用 Android 手机的整条链路:

AIRI 通过 MCP 协议操作 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)与当前仓库,这些方向大多已陆续落地:

  1. 支持图片返回值:让 AIRI 通过视觉能力"看"手机屏幕内容再决定如何交互,类似前一篇 DevLog(DevLog 2025.04.22)中 Cursor 的截图—定位—操作循环;
  2. 设备使用方式自学习:避免为每种设备手写提示词——若设备种类繁多,人工编写提示的工作量将不可接受;
  3. 多 MCP 服务器支持:MCP 提供通用接口,AIRI 不应止步于操作手机。这一点已在 07.18 的 DevLog 中体现——团队为 Factorio AI Agent 项目开发了 mcp-launcher,用 MCP 服务器替换了原先"LLM 生成 Lua 代码 + RCON 执行"的复杂链路;当前仓库的 McpStdioManager 亦已支持多服务器并行;
  4. 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/,可供复现与验证。)

登录后查看全文
airi