#!/usr/bin/env node
/**
* Android Screenshot MCP Server
* Captures screenshots from Android devices via ADB over wireless debugging
*
* MCP Protocol Requirements:
* - Implements ListToolsRequestSchema and CallToolRequestSchema handlers
* - Returns content arrays with type: 'image' (base64 PNG) or type: 'text'
* - Error responses include isError: true
*
* Requires ADB installed and Android device with wireless debugging enabled
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const execAsync = promisify(exec);
// Track screenshot operations to prevent overlaps
let isScreenshotInProgress = false;
// Config file path for persistent connection storage
const CONFIG_DIR = path.join(os.homedir(), '.android-screenshot-mcp');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
// Helper functions for config management
async function ensureConfigDir() {
try {
await fs.mkdir(CONFIG_DIR, { recursive: true });
} catch (error) {
}
}
async function saveLastConnection(deviceAddress) {
try {
await ensureConfigDir();
const config = {
lastDevice: deviceAddress,
savedAt: new Date().toISOString()
};
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
} catch (error) {
console.error('Failed to save connection config:', error.message);
}
}
async function loadLastConnection() {
try {
const configData = await fs.readFile(CONFIG_FILE, 'utf-8');
const config = JSON.parse(configData);
return config.lastDevice;
} catch (error) {
return null;
}
}
// Attempts to reconnect to the last successfully connected device
// Returns device address if successful, false otherwise
async function tryReconnectToSaved() {
const savedDevice = await loadLastConnection();
if (!savedDevice) {
return false;
}
try {
// Try to connect to saved device
await execAsync(`adb connect ${savedDevice}`);
// Verify connection
const devices = await getConnectedDevices();
const isConnected = devices.some(d => d.address === savedDevice && d.status === 'device');
if (isConnected) {
return savedDevice;
}
// Disconnect and reconnect can fix stale connections
await execAsync(`adb disconnect ${savedDevice}`);
await execAsync(`adb connect ${savedDevice}`);
// Check again
const devicesAfterRetry = await getConnectedDevices();
return devicesAfterRetry.some(d => d.address === savedDevice && d.status === 'device') ? savedDevice : false;
} catch (error) {
return false;
}
}
const server = new Server(
{
name: 'android-screenshot-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// List available tools - MCP ListToolsRequestSchema handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'screenshot',
description: 'Take a screenshot of any connected Android device. Uses existing ADB connections or connects wirelessly if device info provided.',
inputSchema: {
type: 'object',
properties: {
deviceIP: {
type: 'string',
description: 'IP address of the Android device. Can include port like "192.168.1.100:12345"',
pattern: '^(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})(:\\d{1,5})?$',
},
debugPort: {
type: 'number',
description: 'Wireless debugging port (different from pairing port)',
minimum: 1,
maximum: 65535,
},
pairingPort: {
type: 'number',
description: 'Pairing port shown on device for initial pairing',
minimum: 1,
maximum: 65535,
},
pairingCode: {
type: 'string',
description: 'Pairing code shown on device for initial pairing',
pattern: '^\\d{6}$',
},
outputPath: {
type: 'string',
description: 'Path where to save the screenshot (optional)',
},
},
required: [],
},
},
{
name: 'connect_and_screenshot',
description: 'Connect to an Android device and take a screenshot. Use when no device is connected.',
inputSchema: {
type: 'object',
properties: {
deviceIP: {
type: 'string',
description: 'IP address of the Android device. Can include port like "192.168.1.100:12345"',
pattern: '^(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})(:\\d{1,5})?$',
},
debugPort: {
type: 'number',
description: 'Wireless debugging port (different from pairing port)',
minimum: 1,
maximum: 65535,
},
pairingPort: {
type: 'number',
description: 'Pairing port shown on device for initial pairing',
minimum: 1,
maximum: 65535,
},
pairingCode: {
type: 'string',
description: 'Pairing code shown on device for initial pairing',
pattern: '^\\d{6}$',
},
outputPath: {
type: 'string',
description: 'Path where to save the screenshot (optional)',
},
},
required: ['deviceIP'],
},
},
],
}));
// Handle tool calls - MCP CallToolRequestSchema handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const toolName = request?.params?.name;
const toolArgs = request?.params?.arguments || {};
if (!toolName) {
return {
isError: true,
content: [
{
type: 'text',
text: 'Tool name is required',
},
],
};
}
switch (toolName) {
case 'screenshot':
return await takeScreenshot(toolArgs);
case 'connect_and_screenshot':
return await connectAndTakeScreenshot(toolArgs);
default:
return {
isError: true,
content: [
{
type: 'text',
text: `Unknown tool: ${toolName}`,
},
],
};
}
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Request handler error: ${error.message}`,
},
],
};
}
});
// Helper functions to reduce nesting and prevent stack overflow
async function getConnectedDevices() {
const { stdout } = await execAsync('adb devices');
const lines = stdout.split('\n');
return lines.slice(1)
.filter(line => line.trim())
.map(line => {
const parts = line.split('\t');
return { address: parts[0], status: parts[1] };
});
}
// Disconnects offline devices to prevent connection conflicts
// ADB sometimes shows devices as offline when they're unreachable
async function cleanupOfflineDevices(devices) {
const offlineDevices = devices.filter(d => d.status === 'offline');
for (const device of offlineDevices) {
try {
await execAsync(`adb disconnect ${device.address}`);
await new Promise(resolve => setTimeout(resolve, 500));
} catch (e) {
}
}
}
async function connectToDevice(deviceIP, debugPort) {
const deviceAddress = `${deviceIP}:${debugPort}`;
await execAsync(`adb connect ${deviceAddress}`);
await new Promise(resolve => setTimeout(resolve, 2000));
const { stdout } = await execAsync('adb devices');
const isConnected = stdout.includes(deviceAddress) &&
stdout.includes('\tdevice');
if (!isConnected) {
throw new Error('Failed to establish connection. Device might need pairing.');
}
// Save successful connection
await saveLastConnection(deviceAddress);
}
async function pairAndConnect(deviceIP, debugPort, pairingPort, pairingCode) {
try {
await execAsync(`adb pair ${deviceIP}:${pairingPort} ${pairingCode}`);
} catch (pairError) {
if (!pairError.message.includes('Success')) {
throw pairError;
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
await connectToDevice(deviceIP, debugPort);
// Connection is saved in connectToDevice, no need to save again
}
async function takeScreenshot(args) {
// Prevent overlapping screenshot operations
if (isScreenshotInProgress) {
return {
content: [
{
type: 'text',
text: 'Screenshot operation already in progress. Please wait for it to complete.',
},
],
};
}
isScreenshotInProgress = true;
try {
const outputPath = args?.outputPath;
// Check for existing connections
const devices = await getConnectedDevices();
// Handle offline devices by disconnecting them
await cleanupOfflineDevices(devices);
// Check for online devices after cleanup
const onlineDevices = devices.filter(d => d.status === 'device');
if (onlineDevices.length === 0) {
// Try to reconnect to saved device
const reconnected = await tryReconnectToSaved();
if (reconnected) {
// Successfully reconnected, take screenshot
return await captureScreenshotFromDevice(outputPath);
}
return {
content: [
{
type: 'text',
text: 'No Android device connected. I need your device connection details to take a screenshot. Please provide:\n\n1. IP address and debug port from Settings > Developer Options > Wireless debugging\n2. Pairing code (6 digits) and pairing port from "Pair device with pairing code"\n\nOnce you give me all these details, I\'ll automatically connect and take the screenshot.',
},
],
};
}
// Capture screenshot
return await captureScreenshotFromDevice(outputPath);
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to check device status: ${error.message}`,
},
],
};
} finally {
isScreenshotInProgress = false;
}
}
async function connectAndTakeScreenshot(args) {
// Prevent overlapping screenshot operations
if (isScreenshotInProgress) {
return {
content: [
{
type: 'text',
text: 'Screenshot operation already in progress. Please wait for it to complete.',
},
],
};
}
isScreenshotInProgress = true;
try {
let deviceIP = args?.deviceIP;
let debugPort = args?.debugPort;
const pairingPort = args?.pairingPort;
const pairingCode = args?.pairingCode;
const outputPath = args?.outputPath;
// If no device info provided, try to use saved connection
if (!deviceIP && !debugPort) {
const reconnected = await tryReconnectToSaved();
if (reconnected) {
return await captureScreenshotFromDevice(outputPath);
}
}
// Parse IP:PORT format
if (deviceIP && deviceIP.includes(':')) {
const parts = deviceIP.split(':');
deviceIP = parts[0];
debugPort = debugPort || parseInt(parts[1]);
}
// Check existing connections first
const devices = await getConnectedDevices();
if (deviceIP && debugPort) {
const deviceAddress = `${deviceIP}:${debugPort}`;
const device = devices.find(d => d.address === deviceAddress);
if (device && device.status === 'device') {
return await captureScreenshotFromDevice(outputPath);
} else if (device && device.status === 'offline') {
await execAsync(`adb disconnect ${deviceAddress}`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
} else {
const onlineDevice = devices.find(d => d.status === 'device');
if (onlineDevice) {
return await captureScreenshotFromDevice(outputPath);
}
}
// Try to connect
if (deviceIP && debugPort) {
try {
await connectToDevice(deviceIP, debugPort);
return await captureScreenshotFromDevice(outputPath);
} catch (error) {
if (pairingPort && pairingCode) {
try {
await pairAndConnect(deviceIP, debugPort, pairingPort, pairingCode);
return await captureScreenshotFromDevice(outputPath);
} catch (pairingError) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to pair and connect: ${pairingError.message}. Please check your pairing code and ports.`,
},
],
};
}
} else {
return {
content: [
{
type: 'text',
text: `Failed to connect to ${deviceIP}:${debugPort}. I need the pairing code and pairing port. Please go to Settings > Developer Options > Wireless debugging > Pair device with pairing code, then provide me with the 6-digit pairing code and pairing port so I can connect automatically.`,
},
],
};
}
}
}
return {
content: [
{
type: 'text',
text: 'I need your Android device connection details to take a screenshot. Please provide:\n\n1. IP address and port from Settings > Developer Options > Wireless debugging (e.g., "192.168.1.100:12345")\n2. If first time connecting, also provide the pairing code and pairing port from "Pair device with pairing code"\n\nOnce you give me these details, I\'ll automatically connect and take the screenshot.',
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Error: ${error.message}`,
},
],
};
} finally {
isScreenshotInProgress = false;
}
}
async function captureScreenshotFromDevice(outputPath) {
let tempPath = null;
let finalOutputPath = null;
let targetDevice = null;
try {
// Get the first connected device
const devices = await getConnectedDevices();
const onlineDevices = devices.filter(d => d.status === 'device');
if (onlineDevices.length === 0) {
throw new Error('No connected Android devices found');
}
// Use the first available device
targetDevice = onlineDevices[0].address;
const timestamp = Date.now();
tempPath = `/sdcard/temp_screenshot_${timestamp}.png`;
const defaultOutputPath = path.join(os.tmpdir(), `screenshot-${timestamp}.png`);
finalOutputPath = outputPath || defaultOutputPath;
// Take screenshot with unique temp filename, specifying the device
await execAsync(`adb -s ${targetDevice} shell screencap -p ${tempPath}`);
// Pull screenshot to local machine, specifying the device
await execAsync(`adb -s ${targetDevice} pull ${tempPath} "${finalOutputPath}"`);
// Get file stats first
const stats = await fs.stat(finalOutputPath);
const fileSizeKB = (stats.size / 1024).toFixed(2);
// Clean up temp file on device immediately
try {
await execAsync(`adb -s ${targetDevice} shell rm ${tempPath}`);
} catch (cleanupError) {
}
// For images over 2MB, skip base64 encoding entirely to prevent call stack overflow
if (stats.size > 2 * 1024 * 1024) { // 2MB threshold
return {
content: [
{
type: 'text',
text: `Screenshot captured (${fileSizeKB} KB) and saved to: ${finalOutputPath}\n\nImage too large for inline display (>2MB). File saved to disk. This typically happens with photo wallpapers - consider using a simpler background.`,
},
],
};
}
// For smaller images, try base64 encoding with error handling
let base64Image;
try {
const imageBuffer = await fs.readFile(finalOutputPath);
base64Image = imageBuffer.toString('base64');
// Clear buffer immediately
imageBuffer.fill(0);
} catch (readError) {
// If base64 conversion fails, just return file path
return {
content: [
{
type: 'text',
text: `Screenshot captured (${fileSizeKB} KB) and saved to: ${finalOutputPath}\n\nCould not display image inline: ${readError.message}`,
},
],
};
}
// Clean up temp file if it was auto-generated
if (!outputPath) {
try {
await fs.unlink(finalOutputPath);
} catch (unlinkError) {
}
}
return {
content: [
{
type: 'text',
text: `Screenshot captured (${fileSizeKB} KB) and saved to: ${finalOutputPath}`,
},
{
type: 'image',
data: base64Image,
mimeType: 'image/png',
},
],
};
} catch (error) {
// Emergency cleanup on error
if (tempPath && targetDevice) {
try {
await execAsync(`adb -s ${targetDevice} shell rm ${tempPath}`);
} catch (e) {
}
}
if (finalOutputPath && !outputPath) {
try {
await fs.unlink(finalOutputPath);
} catch (e) {
}
}
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to capture screenshot: ${error.message}`,
},
],
};
}
}
// Start server with proper error handling
async function startServer() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
} catch (error) {
process.stderr.write(`Server error: ${error.message}\n`);
process.exit(1);
}
}
// Handle shutdown gracefully
process.on('SIGINT', () => {
process.exit(0);
});
process.on('SIGTERM', () => {
process.exit(0);
});
// Start the server
startServer();