Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .claude/mcp/android-device-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Dependencies
node_modules/
package-lock.json

# Build output
build/
dist/
*.js
*.js.map
*.d.ts
*.d.ts.map

# Keep source TypeScript files
!src/**/*.ts

# Environment variables
.env
.env.local
.env.*.local

# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store

# Logs
*.log

# Testing
coverage/

# Temporary files
tmp/
temp/
34 changes: 34 additions & 0 deletions .claude/mcp/android-device-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@bitwarden/android-device-mcp",
"version": "1.0.0",
"description": "MCP server for Android device interaction via ADB β€” UI hierarchy capture, element finding with obstruction detection, tap, and navigation",
"type": "module",
"main": "build/index.js",
"bin": {
"android-device-mcp": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod +x build/index.js",
"watch": "tsc --watch",
"dev": "tsc && node build/index.js",
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": ["mcp", "android", "adb", "model-context-protocol", "ui-testing"],
"author": "Bitwarden",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "1.27.1",
"fast-xml-parser": "^4.5.0",
"zod": "3.24.2"
},
"devDependencies": {
"@types/node": "20.19.35",
"typescript": "5.8.3",
"vitest": "3.1.1"
},
"engines": {
"node": ">=18.0.0"
}
}
60 changes: 60 additions & 0 deletions .claude/mcp/android-device-server/src/adb/adb.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('node:fs', () => ({
existsSync: vi.fn(() => false),
}));

vi.mock('node:child_process', () => ({
execFileSync: vi.fn(() => { throw new Error('not found'); }),
execFile: vi.fn(),
}));

import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { findAdb, _resetCache } from './adb.js';

const mockExistsSync = vi.mocked(existsSync);
const mockExecFileSync = vi.mocked(execFileSync);

describe('findAdb', () => {
beforeEach(() => {
vi.clearAllMocks();
_resetCache();
// Default: which fails, nothing on disk
mockExecFileSync.mockImplementation(() => { throw new Error('not found'); });
mockExistsSync.mockReturnValue(false);
});

it('finds adb in PATH via which', () => {
mockExecFileSync.mockReturnValue('/usr/local/bin/adb\n' as any);
expect(findAdb()).toBe('/usr/local/bin/adb');
});

it('finds adb in Android SDK location', () => {
mockExistsSync.mockImplementation((path) =>
String(path).includes('Library/Android/sdk'),
);
expect(findAdb()).toContain('Library/Android/sdk/platform-tools/adb');
});

it('finds adb in /usr/local/bin', () => {
mockExistsSync.mockImplementation((path) =>
String(path) === '/usr/local/bin/adb',
);
expect(findAdb()).toBe('/usr/local/bin/adb');
});

it('throws when adb not found anywhere', () => {
expect(() => findAdb()).toThrow('ADB not found');
});

it('caches the result after first discovery', () => {
mockExistsSync.mockImplementation((path) =>
String(path) === '/usr/local/bin/adb',
);
findAdb();
findAdb();
// existsSync only called during first discovery, cached after
expect(mockExistsSync).toHaveBeenCalledTimes(2); // SDK path + /usr/local/bin
});
});
141 changes: 141 additions & 0 deletions .claude/mcp/android-device-server/src/adb/adb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* ADB client wrapper using child_process.execFile for safe command execution.
* Uses execFile (not exec) to prevent shell injection β€” arguments are passed
* as an array, never interpolated into a shell string.
*/

import { execFile as execFileCb, execFileSync } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

const execFile = promisify(execFileCb);

let cachedAdbPath: string | null = null;

/** Clear the cached ADB path (for testing). */
export function _resetCache(): void {
cachedAdbPath = null;
}

/**
* Discover ADB binary location.
* Checks: PATH β†’ ~/Library/Android/sdk/platform-tools/adb β†’ /usr/local/bin/adb
*/
export function findAdb(): string {
if (cachedAdbPath) return cachedAdbPath;

// Check PATH via `which`
try {
const result = execFileSync('which', ['adb'], { encoding: 'utf-8' }).trim();
if (result) {
cachedAdbPath = result;
return result;
}
} catch {
// Not in PATH, try common locations
}

const candidates = [
join(homedir(), 'Library', 'Android', 'sdk', 'platform-tools', 'adb'),
'/usr/local/bin/adb',
];

for (const candidate of candidates) {
if (existsSync(candidate)) {
cachedAdbPath = candidate;
return candidate;
}
}

throw new Error(
'ADB not found. Install the Android SDK or add platform-tools to PATH.',
);
}

/**
* Execute an ADB command and return stdout.
*/
export async function exec(args: string[]): Promise<string> {
const adb = findAdb();
const { stdout } = await execFile(adb, args, {
maxBuffer: 10 * 1024 * 1024, // 10MB for large dumps
encoding: 'utf-8',
});
return stdout;
}

/**
* Execute an ADB shell command.
*/
export async function shell(command: string): Promise<string> {
return exec(['shell', command]);
}

/**
* Dump UI hierarchy to device, then pull to local path.
*/
export async function dumpHierarchy(outputPath: string): Promise<void> {
await shell('uiautomator dump /sdcard/view.xml');
await exec(['pull', '/sdcard/view.xml', outputPath]);
}

/**
* Capture screenshot to device, then pull to local path.
*/
export async function screenshot(outputPath: string): Promise<void> {
await shell('screencap -p /sdcard/screen.png');
await exec(['pull', '/sdcard/screen.png', outputPath]);
}

/**
* Tap at screen coordinates.
*/
export async function tap(x: number, y: number): Promise<void> {
await shell(`input tap ${Math.floor(x)} ${Math.floor(y)}`);
}

/**
* Send a key event.
*/
export async function keyevent(code: number): Promise<void> {
await shell(`input keyevent ${code}`);
}

/**
* Perform a swipe gesture.
*/
export async function swipe(
x1: number,
y1: number,
x2: number,
y2: number,
durationMs: number,
): Promise<void> {
await shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`);
}

/**
* Get screen dimensions.
*/
export async function getScreenSize(): Promise<{ width: number; height: number }> {
const output = await shell('wm size');
const match = output.match(/(\d+)x(\d+)/);
if (!match) throw new Error(`Could not parse screen size from: ${output}`);
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
}

/**
* Get raw dumpsys window windows output.
*/
export async function dumpsysWindows(): Promise<string> {
return shell('dumpsys window windows');
}

/**
* Wait for a specified duration (seconds).
*/
export function sleep(seconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
}
54 changes: 54 additions & 0 deletions .claude/mcp/android-device-server/src/geometry/bounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Geometric primitives for UI element bounds and point operations.
*/

export interface Point {
x: number;
y: number;
}

export interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}

export function center(r: Rect): Point {
return {
x: Math.floor((r.left + r.right) / 2),
y: Math.floor((r.top + r.bottom) / 2),
};
}

export function area(r: Rect): number {
const w = r.right - r.left;
const h = r.bottom - r.top;
return w > 0 && h > 0 ? w * h : 0;
}

export function containsPoint(r: Rect, p: Point): boolean {
return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
}

export function overlaps(a: Rect, b: Rect): boolean {
return !(a.left >= b.right || a.right <= b.left || a.top >= b.bottom || a.bottom <= b.top);
}

export function boundsEqual(a: Rect, b: Rect): boolean {
return a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom;
}

/**
* Parse Android bounds string "[left,top][right,bottom]" into a Rect.
*/
export function parseBounds(bounds: string): Rect | null {
const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (!match) return null;
return {
left: parseInt(match[1], 10),
top: parseInt(match[2], 10),
right: parseInt(match[3], 10),
bottom: parseInt(match[4], 10),
};
}
Loading
Loading