import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { loadConfig } from "./config.js";
import { listDevices, getDeviceProps, tap, swipe, inputText, keyevent, startApp, stopApp, screencapPng, dumpUiHierarchy, longPress, pinch, dragDrop, shellCommand, pushFile, pullFile, listDirectory, getClipboard, setClipboard, listInstalledApps, getNotifications, getCurrentActivity, wakeScreen, sleepScreen, isScreenOn, unlockScreen, connectWifi, disconnectWifi, enableTcpip, getDeviceIp } from "./adb.js";
import { ScrcpySession } from "./scrcpySession.js";
type SessionEntry = {
session: ScrcpySession;
resourceHandle?: { remove: () => void };
};
const cfg = loadConfig();
const server = new McpServer({
name: "mcp-scrcpy-vision",
version: "1.0.0",
});
// ---- State ----
const sessionsBySerial = new Map<string, SessionEntry>();
// ---- Helpers ----
function requireStreamingDeps() {
if (!cfg.scrcpyServerPath || !cfg.scrcpyServerVersion) {
throw new Error(
"Streaming requires SCRCPY_SERVER_PATH and SCRCPY_SERVER_VERSION env vars."
);
}
}
function base64(buf: Buffer): string {
return buf.toString("base64");
}
function safeJson(obj: unknown): string {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
function sendResourceUpdated(uri: string) {
const proto = (server as any).server;
if (proto?.sendResourceUpdated) {
// ServerProtocol method (SDK)
proto.sendResourceUpdated({ uri });
} else if (proto?.notification) {
// fallback: send the notification directly
proto.notification({ method: "notifications/resources/updated", params: { uri } });
}
}
// ---- Resources ----
// Static devices resource
server.registerResource(
"android-devices",
"android://devices",
{
title: "Connected Android devices",
description: "List of devices from adb devices -l",
mimeType: "application/json",
},
async () => {
const devices = await listDevices(cfg.adbPath);
return {
contents: [
{
uri: "android://devices",
mimeType: "application/json",
text: safeJson({ devices }),
},
],
};
}
);
// ---- Tools ----
server.registerTool(
"android.devices.list",
{
title: "List connected Android devices",
description: "Returns the output of adb devices -l (parsed).",
inputSchema: z.object({}).strict(),
},
async () => {
const devices = await listDevices(cfg.adbPath);
return {
content: [{ type: "text", text: safeJson({ devices }) }],
structuredContent: { devices },
};
}
);
server.registerTool(
"android.devices.info",
{
title: "Get device info via getprop",
description: "Returns a subset of getprop values for a device.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const props = await getDeviceProps(cfg.adbPath, serial);
const pick = (k: string) => props[k];
const info = {
serial,
model: pick("ro.product.model"),
brand: pick("ro.product.brand"),
manufacturer: pick("ro.product.manufacturer"),
device: pick("ro.product.device"),
sdk: pick("ro.build.version.sdk"),
release: pick("ro.build.version.release"),
};
return {
content: [{ type: "text", text: safeJson(info) }],
structuredContent: info,
};
}
);
server.registerTool(
"android.vision.startStream",
{
title: "Start a continuous vision stream for a device",
description:
"Uses scrcpy standalone server raw H.264 stream + ffmpeg decoding. Creates/updates resource android://device/<serial>/frame/latest.jpg.",
inputSchema: z
.object({
serial: z.string().min(1),
maxSize: z.number().int().positive().optional(),
maxFps: z.number().int().positive().optional(),
frameFps: z.number().int().positive().optional(),
})
.strict(),
},
async ({ serial, maxSize, maxFps, frameFps }) => {
requireStreamingDeps();
if (sessionsBySerial.has(serial)) {
const entry = sessionsBySerial.get(serial)!;
const health = await entry.session.health();
return {
content: [
{
type: "text",
text: safeJson({
status: "already_running",
serial,
resourceUri: entry.session.resourceUri,
health,
}),
},
],
structuredContent: {
status: "already_running",
serial,
resourceUri: entry.session.resourceUri,
health,
},
};
}
const sessionId = `${serial}-${Date.now()}`;
const session = new ScrcpySession(serial, sessionId, {
adbPath: cfg.adbPath,
ffmpegPath: cfg.ffmpegPath,
scrcpyServerPath: cfg.scrcpyServerPath!,
scrcpyServerVersion: cfg.scrcpyServerVersion!,
maxSize: maxSize ?? cfg.defaultMaxSize,
maxFps: maxFps ?? cfg.defaultMaxFps,
frameFps: frameFps ?? cfg.defaultFrameFps,
socketPrefix: cfg.scrcpySocketPrefix,
rawStreamArg: cfg.rawStreamArg,
});
// Register frame resource for this session
const frameUri = session.resourceUri;
const resourceHandle = server.registerResource(
`android-frame-${serial}`,
frameUri,
{
title: `Latest frame (${serial})`,
description: `Latest JPEG frame for device ${serial}. Updated whenever a new frame is decoded.`,
mimeType: "image/jpeg",
},
async () => {
const latest = sessionsBySerial.get(serial)?.session.latest;
if (!latest) {
return {
contents: [
{
uri: frameUri,
mimeType: "text/plain",
text: "No frame available yet (stream starting).",
},
],
};
}
return {
contents: [
{
uri: frameUri,
mimeType: "image/jpeg",
blob: base64(latest.jpeg),
},
],
};
}
);
sessionsBySerial.set(serial, { session, resourceHandle });
session.onFrame(() => {
// notify clients that the resource content changed
sendResourceUpdated(frameUri);
});
await session.start();
const out = {
status: "started",
serial,
sessionId,
resourceUri: frameUri,
note: "Read the resource to get the latest frame. Some clients may also subscribe to resource updates.",
};
return {
content: [{ type: "text", text: safeJson(out) }],
structuredContent: out,
};
}
);
server.registerTool(
"android.vision.stopStream",
{
title: "Stop an active vision stream for a device",
description: "Stops scrcpy + ffmpeg pipeline and removes the frame resource.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const entry = sessionsBySerial.get(serial);
if (!entry) {
return {
content: [{ type: "text", text: safeJson({ status: "not_running", serial }) }],
structuredContent: { status: "not_running", serial },
};
}
await entry.session.stop();
entry.resourceHandle?.remove();
sessionsBySerial.delete(serial);
return {
content: [{ type: "text", text: safeJson({ status: "stopped", serial }) }],
structuredContent: { status: "stopped", serial },
};
}
);
server.registerTool(
"android.vision.snapshot",
{
title: "Take a snapshot screenshot (PNG) from a device",
description:
"Uses adb exec-out screencap -p. Works without scrcpy/ffmpeg. Returns image/png.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const png = await screencapPng(cfg.adbPath, serial);
return {
content: [
{
type: "image",
mimeType: "image/png",
data: base64(png),
},
],
};
}
);
server.registerTool(
"android.input.tap",
{
title: "Tap on the device screen",
description: "Taps at coordinates (x,y). Uses fast scrcpy control protocol (~5ms) when stream is active, otherwise falls back to adb shell input (~100-300ms).",
inputSchema: z
.object({
serial: z.string().min(1),
x: z.number().int().nonnegative(),
y: z.number().int().nonnegative(),
})
.strict(),
},
async ({ serial, x, y }) => {
// Try fast input via scrcpy control if stream is active
const entry = sessionsBySerial.get(serial);
if (entry?.session.controlReady) {
const success = entry.session.fastTap(x, y);
if (success) {
return { content: [{ type: "text", text: `Fast tapped ${x},${y} on ${serial} (via scrcpy)` }] };
}
}
// Fallback to adb shell input
await tap(cfg.adbPath, serial, x, y);
return { content: [{ type: "text", text: `Tapped ${x},${y} on ${serial}` }] };
}
);
server.registerTool(
"android.input.swipe",
{
title: "Swipe on the device screen",
description: "Swipes from (x1,y1) to (x2,y2). Uses fast scrcpy control protocol when stream is active, otherwise falls back to adb shell input.",
inputSchema: z
.object({
serial: z.string().min(1),
x1: z.number().int().nonnegative(),
y1: z.number().int().nonnegative(),
x2: z.number().int().nonnegative(),
y2: z.number().int().nonnegative(),
durationMs: z.number().int().nonnegative().default(300),
})
.strict(),
},
async ({ serial, x1, y1, x2, y2, durationMs }) => {
// Try fast input via scrcpy control if stream is active
const entry = sessionsBySerial.get(serial);
if (entry?.session.controlReady) {
// Convert duration to steps and delay
const steps = Math.max(10, Math.min(50, Math.floor(durationMs / 10)));
const delayMs = durationMs / steps;
const success = await entry.session.fastSwipe(x1, y1, x2, y2, steps, delayMs);
if (success) {
return {
content: [{ type: "text", text: `Fast swiped (${x1},${y1})->(${x2},${y2}) on ${serial} (via scrcpy)` }],
};
}
}
// Fallback to adb shell input
await swipe(cfg.adbPath, serial, x1, y1, x2, y2, durationMs);
return {
content: [{ type: "text", text: `Swiped (${x1},${y1})->(${x2},${y2}) on ${serial} (${durationMs}ms)` }],
};
}
);
server.registerTool(
"android.input.text",
{
title: "Type text on the device",
description: "Types text. Uses fast scrcpy text injection when stream is active (instant, no encoding issues), otherwise falls back to adb shell input text (slower, spaces encoded as %s).",
inputSchema: z
.object({
serial: z.string().min(1),
text: z.string().min(1),
})
.strict(),
},
async ({ serial, text }) => {
// Try fast input via scrcpy control if stream is active
const entry = sessionsBySerial.get(serial);
if (entry?.session.controlReady) {
const success = entry.session.fastText(text);
if (success) {
return { content: [{ type: "text", text: `Fast typed text on ${serial} (via scrcpy)` }] };
}
}
// Fallback to adb shell input
await inputText(cfg.adbPath, serial, text);
return { content: [{ type: "text", text: `Typed text on ${serial}` }] };
}
);
server.registerTool(
"android.input.keyevent",
{
title: "Send a keyevent on the device",
description: "Sends a keycode event. Uses fast scrcpy control protocol when stream is active, otherwise falls back to adb shell input keyevent. Common keycodes: HOME=3, BACK=4, VOLUME_UP=24, VOLUME_DOWN=25, POWER=26, ENTER=66, DELETE=67.",
inputSchema: z
.object({
serial: z.string().min(1),
keycode: z.number().int().nonnegative(),
})
.strict(),
},
async ({ serial, keycode }) => {
// Try fast input via scrcpy control if stream is active
const entry = sessionsBySerial.get(serial);
if (entry?.session.controlReady) {
const success = entry.session.fastKey(keycode);
if (success) {
return { content: [{ type: "text", text: `Fast sent keyevent ${keycode} on ${serial} (via scrcpy)` }] };
}
}
// Fallback to adb shell input
await keyevent(cfg.adbPath, serial, keycode);
return { content: [{ type: "text", text: `Sent keyevent ${keycode} on ${serial}` }] };
}
);
server.registerTool(
"android.input.longPress",
{
title: "Long press on the device screen",
description: "Performs a long press at coordinates (x,y) for specified duration. Uses fast scrcpy control protocol when stream is active, otherwise falls back to adb shell input.",
inputSchema: z
.object({
serial: z.string().min(1),
x: z.number().int().nonnegative(),
y: z.number().int().nonnegative(),
durationMs: z.number().int().positive().default(1000),
})
.strict(),
},
async ({ serial, x, y, durationMs }) => {
// Try fast input via scrcpy control if stream is active
const entry = sessionsBySerial.get(serial);
if (entry?.session.controlReady) {
const success = await entry.session.fastLongPress(x, y, durationMs);
if (success) {
return { content: [{ type: "text", text: `Fast long pressed at ${x},${y} on ${serial} for ${durationMs}ms (via scrcpy)` }] };
}
}
// Fallback to adb shell input
await longPress(cfg.adbPath, serial, x, y, durationMs);
return { content: [{ type: "text", text: `Long pressed at ${x},${y} on ${serial} for ${durationMs}ms` }] };
}
);
server.registerTool(
"android.input.pinch",
{
title: "Pinch gesture (zoom in/out)",
description: "Simulates a pinch gesture at center point with specified distances. Note: This uses a single-finger swipe simulation. True multi-touch requires device-specific commands or scrcpy control protocol. Pinch in: startDistance > endDistance. Pinch out: startDistance < endDistance.",
inputSchema: z
.object({
serial: z.string().min(1),
centerX: z.number().int().nonnegative(),
centerY: z.number().int().nonnegative(),
startDistance: z.number().int().positive(),
endDistance: z.number().int().positive(),
durationMs: z.number().int().positive().default(500),
})
.strict(),
},
async ({ serial, centerX, centerY, startDistance, endDistance, durationMs }) => {
await pinch(cfg.adbPath, serial, centerX, centerY, startDistance, endDistance, durationMs);
const direction = startDistance > endDistance ? "in" : "out";
return {
content: [
{
type: "text",
text: `Pinch ${direction} at (${centerX},${centerY}) on ${serial} (${startDistance}→${endDistance}px, ${durationMs}ms)`,
},
],
};
}
);
server.registerTool(
"android.input.dragDrop",
{
title: "Drag and drop gesture",
description: "Drags from start coordinates to end coordinates. Uses adb shell input draganddrop on Android 7+, falls back to swipe on older versions.",
inputSchema: z
.object({
serial: z.string().min(1),
startX: z.number().int().nonnegative(),
startY: z.number().int().nonnegative(),
endX: z.number().int().nonnegative(),
endY: z.number().int().nonnegative(),
durationMs: z.number().int().positive().default(500),
})
.strict(),
},
async ({ serial, startX, startY, endX, endY, durationMs }) => {
await dragDrop(cfg.adbPath, serial, startX, startY, endX, endY, durationMs);
return {
content: [
{
type: "text",
text: `Dragged from (${startX},${startY}) to (${endX},${endY}) on ${serial} (${durationMs}ms)`,
},
],
};
}
);
server.registerTool(
"android.app.start",
{
title: "Start an app",
description: "Starts an Android app by package (optionally activity).",
inputSchema: z
.object({
serial: z.string().min(1),
packageName: z.string().min(1),
activity: z.string().optional(),
})
.strict(),
},
async ({ serial, packageName, activity }) => {
await startApp(cfg.adbPath, serial, packageName, activity);
return { content: [{ type: "text", text: `Started ${packageName} on ${serial}` }] };
}
);
server.registerTool(
"android.app.stop",
{
title: "Stop an app",
description: "Force-stops an Android app by package name.",
inputSchema: z
.object({
serial: z.string().min(1),
packageName: z.string().min(1),
})
.strict(),
},
async ({ serial, packageName }) => {
await stopApp(cfg.adbPath, serial, packageName);
return { content: [{ type: "text", text: `Stopped ${packageName} on ${serial}` }] };
}
);
server.registerTool(
"android.ui.dump",
{
title: "Dump UI hierarchy",
description: "Dumps the current UI hierarchy using uiautomator. Returns XML with all visible elements, their bounds [left,top][right,bottom], text, resource-id, content-desc, and class. Useful for finding tap targets.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const xml = await dumpUiHierarchy(cfg.adbPath, serial);
return { content: [{ type: "text", text: xml }] };
}
);
server.registerTool(
"android.ui.findElement",
{
title: "Find UI elements with filters",
description: "Finds UI elements in the current screen by text, resource-id, class, or content-desc. Returns matching elements with their center coordinates for easy tapping.",
inputSchema: z
.object({
serial: z.string().min(1),
text: z.string().optional(),
resourceId: z.string().optional(),
className: z.string().optional(),
contentDesc: z.string().optional(),
})
.strict(),
},
async ({ serial, text, resourceId, className, contentDesc }) => {
const xml = await dumpUiHierarchy(cfg.adbPath, serial);
// Parse XML to find matching elements
const elements: Array<{
text?: string;
resourceId?: string;
className?: string;
contentDesc?: string;
bounds: string;
centerX: number;
centerY: number;
}> = [];
// Simple regex-based XML parsing (good enough for uiautomator output)
const nodeRegex = /<node[^>]*>/g;
const matches = xml.matchAll(nodeRegex);
for (const match of matches) {
const nodeStr = match[0];
// Extract attributes
const getAttr = (name: string) => {
const regex = new RegExp(`${name}="([^"]*)"`, 'i');
const m = nodeStr.match(regex);
return m ? m[1] : undefined;
};
const nodeText = getAttr("text");
const nodeResourceId = getAttr("resource-id");
const nodeClassName = getAttr("class");
const nodeContentDesc = getAttr("content-desc");
const nodeBounds = getAttr("bounds");
// Apply filters
if (text && (!nodeText || !nodeText.includes(text))) continue;
if (resourceId && (!nodeResourceId || !nodeResourceId.includes(resourceId))) continue;
if (className && (!nodeClassName || !nodeClassName.includes(className))) continue;
if (contentDesc && (!nodeContentDesc || !nodeContentDesc.includes(contentDesc))) continue;
// Parse bounds [left,top][right,bottom]
if (nodeBounds) {
const boundsMatch = nodeBounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (boundsMatch) {
const left = parseInt(boundsMatch[1]);
const top = parseInt(boundsMatch[2]);
const right = parseInt(boundsMatch[3]);
const bottom = parseInt(boundsMatch[4]);
const centerX = Math.floor((left + right) / 2);
const centerY = Math.floor((top + bottom) / 2);
elements.push({
text: nodeText,
resourceId: nodeResourceId,
className: nodeClassName,
contentDesc: nodeContentDesc,
bounds: nodeBounds,
centerX,
centerY,
});
}
}
}
return {
content: [{ type: "text", text: safeJson({ matchCount: elements.length, elements }) }],
structuredContent: { matchCount: elements.length, elements },
};
}
);
server.registerTool(
"android.shell.exec",
{
title: "Execute shell command on device",
description: "WARNING: Executes arbitrary shell command via adb shell. Can perform any operation on the device. Use with extreme caution. Returns stdout, stderr, and exit code.",
inputSchema: z
.object({
serial: z.string().min(1),
command: z.string().min(1),
})
.strict(),
},
async ({ serial, command }) => {
const result = await shellCommand(cfg.adbPath, serial, command);
return {
content: [
{
type: "text",
text: safeJson({
command,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
}),
},
],
structuredContent: {
command,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
},
};
}
);
server.registerTool(
"android.file.push",
{
title: "Push file to device",
description: "WARNING: Transfers a local file to the device filesystem using adb push. Can modify device storage. Ensure paths are correct and you have proper permissions. Local path must exist and remote path must be writable.",
inputSchema: z
.object({
serial: z.string().min(1),
localPath: z.string().min(1),
remotePath: z.string().min(1),
})
.strict(),
},
async ({ serial, localPath, remotePath }) => {
await pushFile(cfg.adbPath, serial, localPath, remotePath);
return {
content: [
{
type: "text",
text: `Successfully pushed ${localPath} to ${remotePath} on device ${serial}`,
},
],
};
}
);
server.registerTool(
"android.file.pull",
{
title: "Pull file from device",
description: "Transfers a file from the device to the local filesystem using adb pull. Remote path must exist and be readable. Local directory must be writable.",
inputSchema: z
.object({
serial: z.string().min(1),
remotePath: z.string().min(1),
localPath: z.string().min(1),
})
.strict(),
},
async ({ serial, remotePath, localPath }) => {
await pullFile(cfg.adbPath, serial, remotePath, localPath);
return {
content: [
{
type: "text",
text: `Successfully pulled ${remotePath} from device ${serial} to ${localPath}`,
},
],
};
}
);
server.registerTool(
"android.file.list",
{
title: "List directory contents on device",
description: "Lists files and directories on the device using adb shell ls -la. Returns detailed file information including permissions, ownership, size, and modification time.",
inputSchema: z
.object({
serial: z.string().min(1),
path: z.string().min(1),
})
.strict(),
},
async ({ serial, path }) => {
const listing = await listDirectory(cfg.adbPath, serial, path);
return {
content: [
{
type: "text",
text: listing,
},
],
};
}
);
server.registerTool(
"android.clipboard.get",
{
title: "Get clipboard content from device",
description: "Retrieves the current clipboard content using dumpsys clipboard. Note: May have limitations on Android 10+ due to privacy restrictions. Returns empty string if clipboard is empty.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const clipboardText = await getClipboard(cfg.adbPath, serial);
return {
content: [
{
type: "text",
text: clipboardText || "(clipboard is empty)",
},
],
structuredContent: { clipboardText },
};
}
);
server.registerTool(
"android.clipboard.set",
{
title: "Set clipboard content on device",
description: "Attempts to set clipboard content via ADB. WARNING: Direct clipboard setting is restricted on most Android devices due to security policies. This may not work reliably across all Android versions. Consider using UI automation to paste text instead as a fallback.",
inputSchema: z
.object({
serial: z.string().min(1),
text: z.string().min(1),
})
.strict(),
},
async ({ serial, text }) => {
await setClipboard(cfg.adbPath, serial, text);
return {
content: [
{
type: "text",
text: `Attempted to set clipboard on ${serial}. Note: This may not work on all devices due to security restrictions.`,
},
],
};
}
);
server.registerTool(
"android.apps.list",
{
title: "List installed apps on device",
description: "Lists all installed packages using pm list packages. Can filter by system apps only, third-party apps only, or show all apps (default).",
inputSchema: z
.object({
serial: z.string().min(1),
system: z.boolean().optional(),
})
.strict(),
},
async ({ serial, system }) => {
const packages = await listInstalledApps(cfg.adbPath, serial, { system });
return {
content: [
{
type: "text",
text: safeJson({
count: packages.length,
filter: system === true ? "system only" : system === false ? "third-party only" : "all",
packages,
}),
},
],
structuredContent: {
count: packages.length,
filter: system === true ? "system only" : system === false ? "third-party only" : "all",
packages,
},
};
}
);
server.registerTool(
"android.notifications.get",
{
title: "Get current notifications from device",
description: "Dumps all current notifications using dumpsys notification --noredact. Returns detailed notification information including text, package names, and metadata. Note: May require special permissions on Android 10+ devices.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const notificationDump = await getNotifications(cfg.adbPath, serial);
return {
content: [
{
type: "text",
text: notificationDump,
},
],
};
}
);
server.registerTool(
"android.activity.current",
{
title: "Get current foreground activity",
description: "Retrieves the currently focused/resumed activity and package name. Useful for determining which app is in the foreground. Returns package name and activity name.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const activity = await getCurrentActivity(cfg.adbPath, serial);
return {
content: [
{
type: "text",
text: safeJson(activity),
},
],
structuredContent: activity,
};
}
);
server.registerTool(
"android.screen.wake",
{
title: "Wake device screen",
description: "Wakes the device screen using KEYCODE_WAKEUP (224). Turns on the screen if it's currently off.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
await wakeScreen(cfg.adbPath, serial);
return {
content: [
{
type: "text",
text: `Screen woken on device ${serial}`,
},
],
};
}
);
server.registerTool(
"android.screen.sleep",
{
title: "Put device screen to sleep",
description: "Puts the device screen to sleep using KEYCODE_SLEEP (223). Turns off the screen.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
await sleepScreen(cfg.adbPath, serial);
return {
content: [
{
type: "text",
text: `Screen put to sleep on device ${serial}`,
},
],
};
}
);
server.registerTool(
"android.screen.isOn",
{
title: "Check if device screen is on",
description: "Checks if the device screen is currently on using dumpsys power/display. Returns true if screen is on, false if off.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const screenOn = await isScreenOn(cfg.adbPath, serial);
return {
content: [
{
type: "text",
text: safeJson({ serial, screenOn }),
},
],
structuredContent: { serial, screenOn },
};
}
);
server.registerTool(
"android.screen.unlock",
{
title: "Unlock device screen",
description: "Unlocks the device screen. First wakes the screen if off, then attempts to unlock using KEYCODE_MENU (82) or swipe gesture. WARNING: Only works for devices without secure lock (no PIN/password/pattern). Devices with secure locks cannot be unlocked via ADB for security reasons.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
await unlockScreen(cfg.adbPath, serial);
return {
content: [
{
type: "text",
text: `Screen unlocked on device ${serial}. Note: This only works for devices without secure lock.`,
},
],
};
}
);
server.registerTool(
"android.adb.connectWifi",
{
title: "Connect to device via WiFi",
description: "Connects to an Android device over WiFi using ADB. Device must already have TCP/IP mode enabled (use android.adb.enableTcpip first while connected via USB). Default port is 5555. Use android.adb.getDeviceIp to get the device's IP address.",
inputSchema: z
.object({
ipAddress: z.string().min(7).describe("Device IP address (e.g., 192.168.1.100)"),
port: z.number().int().positive().default(5555).describe("TCP/IP port (default: 5555)"),
})
.strict(),
},
async ({ ipAddress, port }) => {
await connectWifi(cfg.adbPath, ipAddress, port);
return {
content: [
{
type: "text",
text: `Successfully connected to device at ${ipAddress}:${port}. You can now use this IP:port as the serial for other commands.`,
},
],
};
}
);
server.registerTool(
"android.adb.disconnectWifi",
{
title: "Disconnect WiFi ADB connection",
description: "Disconnects from a WiFi ADB connection. If ipAddress is provided, disconnects from that specific device. If omitted, disconnects from all WiFi devices.",
inputSchema: z
.object({
ipAddress: z.string().optional().describe("Device IP address to disconnect (optional, omit to disconnect all)"),
})
.strict(),
},
async ({ ipAddress }) => {
await disconnectWifi(cfg.adbPath, ipAddress);
const message = ipAddress
? `Disconnected from device at ${ipAddress}`
: "Disconnected from all WiFi devices";
return {
content: [
{
type: "text",
text: message,
},
],
};
}
);
server.registerTool(
"android.adb.enableTcpip",
{
title: "Enable TCP/IP mode for WiFi debugging",
description: "Enables TCP/IP mode on the device for WiFi debugging. Device must be connected via USB first. Default port is 5555. After enabling, use android.adb.getDeviceIp to get the device's IP address, then use android.adb.connectWifi to connect wirelessly. You can then disconnect the USB cable.",
inputSchema: z
.object({
serial: z.string().min(1).describe("Device serial number (USB connection required)"),
port: z.number().int().positive().default(5555).describe("TCP/IP port (default: 5555)"),
})
.strict(),
},
async ({ serial, port }) => {
await enableTcpip(cfg.adbPath, serial, port);
return {
content: [
{
type: "text",
text: `TCP/IP mode enabled on device ${serial} on port ${port}. Use android.adb.getDeviceIp to get the device's IP address, then use android.adb.connectWifi to connect wirelessly.`,
},
],
};
}
);
server.registerTool(
"android.adb.getDeviceIp",
{
title: "Get device WiFi IP address",
description: "Gets the device's WiFi IP address. Useful for connecting to the device wirelessly after enabling TCP/IP mode. Returns the IP address or null if device is not connected to WiFi.",
inputSchema: z
.object({
serial: z.string().min(1),
})
.strict(),
},
async ({ serial }) => {
const ipAddress = await getDeviceIp(cfg.adbPath, serial);
if (ipAddress) {
return {
content: [
{
type: "text",
text: safeJson({ serial, ipAddress }),
},
],
structuredContent: { serial, ipAddress },
};
} else {
return {
content: [
{
type: "text",
text: `Device ${serial} is not connected to WiFi or IP address could not be determined.`,
},
],
structuredContent: { serial, ipAddress: null },
};
}
}
);
// ---- Run ----
const transport = new StdioServerTransport();
await server.connect(transport);
// Clean up on process exit
process.on("SIGINT", () => {
for (const entry of sessionsBySerial.values()) {
void entry.session.stop().catch(() => {});
entry.resourceHandle?.remove();
}
process.exit(0);
});