聚水潭
118.20M · 2026-03-13
在 AI 浪潮席卷软件开发的今天,我们 Android 工程师的工具箱也迎来了新的可能。除了传统的 Android Studio,各类 AI 助手和 Agent 正逐渐成为我们日常编码、调试、测试的得力伙伴。然而,要让这些“聪明的同事”真正理解并操作我们的 Android 应用,我们需要一座桥梁——这便是 MCP (Model-Controlled Procedures) 服务器。
本文将带你从零开始,使用 Python 构建一个专为 Android 开发设计的简易 MCP 服务器。我们将不仅探讨“如何做”,更会深入“为何如此做”,并分享在实践中总结的最佳实践与安全考量。
当我们与 AI Agent 协作时,无论是修复一个 UI bug,还是验证一个新功能,都离不开频繁的反馈循环。我们可能需要反复向 Agent 描述“按钮在哪里”、“点击后界面变成了什么样”、“Logcat 打印了什么错误”。这个过程是繁琐且低效的。
MCP 服务器通过为 AI Agent 提供一套可调用的“工具集”(Tools),允许它自主地与外部环境交互,从而打破这种僵局。对于 Android 开发而言,这意味着 AI Agent 可以:
那么,为何不直接使用网上现成的 MCP 服务呢?
自己动手,丰衣足食。通过构建自己的 MCP 服务器,我们不仅能收获一个强大、安全的 AI 协作工具,更能深入理解 AI Agent 的工作范式,为将来探索更高级的自动化流程打下坚实基础。
在我们动手之前,花几分钟快速理解 MCP 的几个核心概念至关重要。
Server/Client 模型:MCP 的核心是一个 C/S 架构。Server 是我们即将构建的服务,它定义并实现了一系列工具(如 get_screenshot)。Client 通常是 AI Agent 的一部分,它会发现 Server 提供的工具,并在需要时发起调用。这种解耦设计让能力可以被灵活替换和组合。
工具 (Tools):工具是 MCP Server 能力的原子化体现。每个工具都是一个函数,拥有明确的名称、描述、输入参数和输出。AI Agent 正是根据这些元信息来理解并决定何时、如何使用某个工具。
传输模式 (Transport Modes):Client 与 Server 之间需要通信。MCP 规范定义了多种传输协议,以适应不同场景:
stdio:通过标准输入/输出进行通信。这是最简单的方式,非常适合本地进程间通信,例如在 MCP Inspector 中进行快速调试。streamable-http / sse (Server-Sent Events):基于 HTTP。当 Client 和 Server 运行在不同机器上,或者需要通过网络进行更复杂的交互时,会采用这些模式。理解了这些,我们就可以开始规划我们的技术选型了。
尽管我们是 Android 开发者,更熟悉 Kotlin/Java,但在当前生态下,Python 是构建 MCP 服务器的更成熟选择。
FastMCP 等高层抽象,让我们能专注于工具逻辑而非底层协议实现。venv + pip。adb 命令来实现。请确保它已正确安装并加入了系统 PATH。npx 运行,需要 Node.js 环境。我们的目标工具集:
为了构建一个功能虽简但五脏俱全的服务器,我们将实现以下工具:
get_logcat_output:使用 logcat 获取设备日志。get_screenshot:捕获当前屏幕内容。get_ui_dump:获取 XML 格式的 UI 视图层级。tap_screen:在屏幕指定坐标执行点击。swipe_screen:执行滑动操作。send_text:模拟键盘输入文本。perform_system_action:执行返回、Home 等系统级操作。现在,环境就绪,蓝图已定,让我们卷起袖子开始编码!
我们将遵循“项目初始化 → 依赖配置 → 启动参数设计 → 工具实现 → 整合启动”的清晰路径。
首先,打开你的终端,创建一个新项目。
# 使用 uv 初始化项目,它会自动创建一个名为 android-mcp-server 的目录和虚拟环境
uv init android-mcp-server
cd android-mcp-server
接着,编辑项目根目录下的 pyproject.toml 文件,声明我们的项目信息和依赖。
[project]
name = "android-dev-mcp-server"
version = "1.0.0"
description = "An MCP Server for Android Development"
readme = "README.md"
requires-python = ">=3.10" # 建议使用较新的 Python 版本
dependencies = [
"mcp[cli]==1.22.0", # 请根据需要锁定或更新版本
"Pillow==10.3.0",
]
配置完成后,回到终端,使用 uv 同步依赖。
uv sync
一个健壮的命令行工具需要灵活的启动参数。在项目根目录创建 main.py,我们先来实现参数解析部分。
# main.py
import argparse
def parse_args() -> tuple[str, str, int]:
parser = argparse.ArgumentParser(description="Android Development MCP Server")
parser.add_argument(
"--mode",
dest="mode",
type=str,
choices=["stdio", "streamable-http", "sse"],
required=True,
help="The mode to run the MCP server in.",
)
parser.add_argument(
"--temp-dir",
dest="temp_dir",
type=str,
required=True,
help="Absolute path to a temporary directory for the MCP server.",
)
parser.add_argument(
"--port",
dest="port",
type=int,
default=3001,
help="The port to run the MCP server on for HTTP-based modes.",
)
args = parser.parse_args()
return args.mode, args.temp_dir, args.port
这段代码定义了三个关键参数:--mode 用于选择传输模式,--temp-dir 用于存放截图等临时文件,--port 则为 HTTP 模式指定端口。
现在是核心部分。我们将在 main.py 中继续添加代码,实现所有工具。所有工具都将定义在 start_server 函数内部,以便访问 mcp 实例和共享的 temp_dir。
首先,引入所有必要的模块,并创建一个 adb 辅助函数。
# main.py (继续添加)
import io
import os
import subprocess
import shlex
import xml.etree.ElementTree as ET
from mcp.server.fastmcp import FastMCP, Image
from mcp.server.fastmcp.exceptions import ToolError
from PIL import Image as PILImage
from pydantic import Field
# ... parse_args() 函数 ...
def call_adb_silent(args: list[str]):
"""一个静默执行 adb 命令的辅助函数,不关心其输出。"""
subprocess.run(["adb"] + args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def start_server(mode: str, temp_dir: str, port: int):
# 确保临时目录存在
os.makedirs(temp_dir, exist_ok=True)
mcp = FastMCP(
name="Android Development MCP Server",
port=port,
)
# --- 在这里定义所有工具 ---
get_logcat_output:读取日志此工具通过 adb logcat 获取日志,并增加了按包名和日志级别过滤的功能。
@mcp.tool(structured_output=True)
def get_logcat_output(
app_package: str = Field(description="The base package of the app to get the logs from."),
log_level: str = Field(description="The log level to filter (DEBUG, WARNING, ERROR).", default="DEBUG")
) -> str:
"""Retrieves the last 100 lines of logs from the connected Android device."""
# ... (此处省略了原文中通过 pidof 查找进程的逻辑,简化为按 tag 过滤)
# 在实际工程中,结合 pid 过滤会更精确
log_level_map = {"DEBUG": "D", "WARNING": "W", "ERROR": "E"}
if log_level.upper() not in log_level_map:
raise ToolError(f"Invalid log level: {log_level}.")
try:
# -d 表示 dump a log and exit
result = subprocess.run(
["adb", "logcat", "-d", "-t", "100", f"*:{log_level_map[log_level.upper()]}"],
capture_output=True, text=True, check=True
)
# 简单的按包名过滤
filtered_lines = [line for line in result.stdout.splitlines() if app_package in line]
return "n".join(filtered_lines)
except subprocess.CalledProcessError as e:
raise ToolError(f"Error getting logcat output: {e.stderr}")
get_screenshot:捕获屏幕该工具使用 adb shell screencap 截图,并用 Pillow 库缩放图片,以减少传给模型的流量和计算负担。
@mcp.tool()
def get_screenshot() -> Image:
"""
Gets a screenshot of the connected Android device.
Use this to check the visual appearance. Prefer get_ui_dump for element identification.
"""
try:
screenshot_path = os.path.join(temp_dir, "screenshot.png")
# 截图并保存到设备
call_adb_silent(["shell", "screencap", "-p", "/sdcard/screenshot.png"])
# 从设备拉取到本地
call_adb_silent(["pull", "/sdcard/screenshot.png", screenshot_path])
# 删除设备上的临时文件
call_adb_silent(["shell", "rm", "/sdcard/screenshot.png"])
# 使用 Pillow 缩放图片
with PILImage.open(screenshot_path) as img:
scale_factor = 0.5 # 缩放比例可配置
new_width = int(img.width * scale_factor)
new_height = int(img.height * scale_factor)
resized_img = img.resize((new_width, new_height))
buffered = io.BytesIO()
resized_img.save(buffered, format="PNG")
img_bytes = buffered.getvalue()
os.remove(screenshot_path) # 清理本地临时文件
return Image(data=img_bytes, format="png")
except Exception as e:
raise ToolError(f"Error getting screenshot: {e}")
get_ui_dump:获取 UI 布局这是 AI Agent 理解屏幕结构的关键。它调用 uiautomator dump 获取 XML,并允许 Agent 指定只返回感兴趣的节点属性,以精简信息。
@mcp.tool(structured_output=True)
def get_ui_dump(
returned_attributes: str = Field(description="Comma-separated attributes to return, e.g., 'bounds,class,text,clickable'.")
) -> str:
"""Gets the UI hierarchy dump as an XML string."""
if not returned_attributes:
raise ToolError("The 'returned_attributes' argument cannot be empty.")
attributes_to_keep = {attr.strip() for attr in returned_attributes.split(',')}
try:
dump_path = os.path.join(temp_dir, "window_dump.xml")
call_adb_silent(["shell", "uiautomator", "dump"])
call_adb_silent(["pull", "/sdcard/window_dump.xml", dump_path])
call_adb_silent(["shell", "rm", "/sdcard/window_dump.xml"])
with open(dump_path, "r", encoding="utf-8") as f:
ui_dump = f.read()
os.remove(dump_path)
# 解析 XML 并移除不需要的属性
root = ET.fromstring(ui_dump)
for node in root.iter():
unwanted_attrs = [attr for attr in node.attrib if attr not in attributes_to_keep]
for attr in unwanted_attrs:
del node.attrib[attr]
return ET.tostring(root, encoding="unicode")
except Exception as e:
raise ToolError(f"Error getting UI dump: {e}")
tap_screen, swipe_screen, send_text, perform_system_action这几个操作类工具的实现相对直接,都是对 adb shell input 命令的封装。
@mcp.tool(structured_output=True)
def tap_screen(x: int = Field(description="x-coordinate"), y: int = Field(description="y-coordinate")) -> str:
"""Taps on the screen at the given coordinates."""
try:
call_adb_silent(["shell", "input", "tap", str(x), str(y)])
return f"Tapped at ({x}, {y})."
except Exception as e:
raise ToolError(f"Error tapping on screen: {e}")
@mcp.tool(structured_output=True)
def swipe_screen(x1: int, y1: int, x2: int, y2: int) -> str:
"""Swipes on the screen from a starting point to an ending point."""
try:
call_adb_silent(["shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2)])
return f"Swiped from ({x1}, {y1}) to ({x2}, {y2})."
except Exception as e:
raise ToolError(f"Error swiping on screen: {e}")
@mcp.tool(structured_output=True)
def send_text(text_to_send: str = Field(description="The text to send.")) -> str:
"""Sends the given text, as if typed on a keyboard."""
if not text_to_send:
raise ToolError("Text cannot be empty.")
try:
# 使用 shlex.quote 来正确处理特殊字符和空格
escaped_text = shlex.quote(text_to_send)
call_adb_silent(["shell", "input", "text", escaped_text])
return f"Sent text: {text_to_send}"
except Exception as e:
raise ToolError(f"Error sending text: {e}")
@mcp.tool(structured_output=True)
def perform_system_action(action: str = Field(description="System action: BACK, HOME, or RECENT_APPS.")) -> str:
"""Performs a system action like back, home, or recent apps."""
action_map = {"BACK": "KEYCODE_BACK", "HOME": "KEYCODE_HOME", "RECENT_APPS": "KEYCODE_APP_SWITCH"}
if action.upper() not in action_map:
raise ToolError(f"Invalid action: {action}. Possible actions: BACK, HOME, RECENT_APPS.")
try:
call_adb_silent(["shell", "input", "keyevent", action_map[action.upper()]])
return f"Performed action: {action}."
except Exception as e:
raise ToolError(f"Error performing system action: {e}")
万事俱备,只欠东风。在 start_server 函数的末尾,我们根据 --mode 参数来启动服务器。并在 main.py 的最后,加上标准的 Python 脚本入口。
# main.py (在 start_server 函数末尾添加)
# --- 所有工具定义结束 ---
# 根据模式启动服务器
print(f"Starting Android MCP Server in '{mode}' mode...")
if mode == "stdio":
mcp.run(transport="stdio")
elif mode == "streamable-http":
print(f"Running on :{port}/mcp")
mcp.run(transport="streamable-http")
elif mode == "sse":
print(f"Running on :{port}/sse")
mcp.run(transport="sse")
else:
# 理论上 argparse 会处理,但作为兜底
print(f"Unsupported mode: {mode}")
# 在文件末尾添加主入口
if __name__ == "__main__":
mode_arg, temp_dir_arg, port_arg = parse_args()
start_server(mode=mode_arg, temp_dir=temp_dir_arg, port=port_arg)
至此,我们的 main.py 已经是一个功能完整的 MCP 服务器了!
代码写完,必须测试。mcp-inspector 是我们最好的朋友。
首先,在项目根目录创建一个 mcp-inspector-config.json 配置文件,方便我们快速启动。
{
"mcpServers": {
"android-stdio": {
"command": "uv",
"args": [
"run",
"main.py",
"--mode",
"stdio",
"--temp-dir",
"/tmp/android_mcp" // Linux/macOS. Windows 用户请改为 "C:/Temp/android_mcp" 之类的有效路径
]
},
"android-http": {
"type": "streamable-http",
"url": "http://127.0.0.1:3001/mcp"
}
}
}
现在,在终端中启动 Inspector,并连接到我们的 stdio 服务。
npx @modelcontextprotocol/inspector@latest --config mcp-inspector-config.json --server android-stdio
如果一切顺利,一个 Web 界面会自动打开。你将在左侧看到我们定义的所有工具。点击任意一个,比如 get_ui_dump,在右侧填入参数(如 bounds,text,clickable,resource-id),然后点击 "Run"。稍等片刻,你应该就能在下方看到从你连接的 Android 设备上获取到的、经过处理的 UI XML 数据。
逐个尝试我们实现的所有工具,确保它们都按预期工作。
构建一个能工作的服务器只是第一步,构建一个健壮、安全的服务器才是工程落地的关键。
execute_shell_command 这样过于宽泛和危险的工具。我们的设计已经遵循了这一原则。adb 是一个强大的后门。确保你的 adb server 没有暴露在不安全的网络中(例如,不要轻易使用 adb tcpip 并连接到公共 Wi-Fi)。我们的 MCP 服务器应在可信的开发环境中运行。adb 命令失败(如设备未连接、权限不足)时,我们的工具应该捕获 subprocess.CalledProcessError 异常,并将其转换为信息明确的 ToolError。这能帮助 AI Agent 理解失败原因并尝试修复或调整策略。get_screenshot 和 get_ui_dump 这样会产生临时文件的工具,务必在操作完成后(无论成功还是失败)清理这些文件。使用 try...finally 结构或 Python 的 with 语句是保证清理逻辑被执行的好方法。从权限和通道的角度,可以把这件事拆成几个工程点来看:
input tap、input swipe、input text 以及系统按键等;手机侧则通过 adb 回传 logcat 日志、截图文件、UI Dump XML 等原始数据。服务器再把这些结果整理成结构化输出,返回给 Agent 使用。 的 HTTP 模式运行,让 Agent 通过本机回环地址访问。如果确实需要跨机器部署(例如挂在一台专门的真机机房服务器上),务必加上鉴权(Token/MTLS 等)、网络隔离(VPC/防火墙策略)和操作白名单,只开放必要的少量工具。adb tcpip 端口;如果必须启用,需配合内网隔离和访问控制策略使用。你可以用一段极简的命令行来验证:所有“远程控制手机”的能力最终都来自 adb,而 MCP 只是把这些能力以工具形式暴露给 Agent:
# 验证设备授权
adb devices
# 执行一次点击(演示能力来源于 adb)
adb shell input tap 100 200
我们构建的只是一个基础版本。基于这个框架,你可以轻松地进行扩展:
adb。同时,可以为 HTTP 模式的服务器增加鉴权中间件。open_app_and_login(appName, username, password),它内部会依次调用 tap_screen, send_text 等基础工具来完成一个完整的业务流程。uiautomator dump 在不同设备或系统版本上输出的 XML 可能有细微差异。这是正常的。在提示(Prompt)中引导 AI Agent 编写具有一定鲁棒性的解析逻辑是关键。input text 无法输入特殊字符或中文? 确保你使用了 shlex.quote 进行转义。对于复杂的文本输入,有时需要 adb-unicode-py-client 这样的第三方库。get_ui_dump 的 bounds 属性中动态计算出目标的中心点坐标,然后再调用 tap_screen。screencap 和 uiautomator dump 本身有一定耗时。可以通过优化截图尺寸和压缩率(Pillow)、减少 get_ui_dump 请求的属性数量来提升性能。恭喜你!至此,你已经完整地走过了一个从零构建 Android MCP 服务器的全过程。我们不仅理解了其背后的原理,掌握了核心的技术栈,还亲手实现了一个功能齐备的工具集,并探讨了将其投入实际生产所需考虑的工程问题。
这不仅仅是完成了一个玩具项目,更是为你打开了一扇通往 AI 驱动的 Android 开发新世界的大门。