Skip to main content
Glama
adamlj
by adamlj
index.js18.1 kB
#!/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();

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adamlj/android-screenshot-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server