Skip to content
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ How we help to scale mobile automation:
- **`mobile_press_button`** - Press device buttons (HOME, BACK, VOLUME_UP/DOWN, ENTER, etc.)
- **`mobile_open_url`** - Open URLs in the device browser

### Diagnostics & Debugging
- **`mobile_logcat_dump`** - Dump recent Android logcat lines for debugging automation runs (optional package/pid, buffers, priority, regex filters)

### Platform Support
- **iOS**: Simulators and real devices via native accessibility and WebDriverAgent
- **Android**: Emulators and real devices via ADB and UI Automator
Expand Down
1 change: 1 addition & 0 deletions src/Untitled
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
logcatDump
173 changes: 173 additions & 0 deletions src/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ export interface AndroidDevice {
deviceType: "tv" | "mobile";
}

export interface LogcatDumpOptions {
lines?: number; // default 200, max 500
format?: LogcatFormat; // default threadtime
buffers?: LogcatBuffer[]; // default ["main", "crash"]
minPriority?: LogcatPriority; // default I
pid?: number;
packageName?: string;
includeRegex?: string;
excludeRegex?: string;
}


interface UiAutomatorXmlNode {
node: UiAutomatorXmlNode[];
class?: string;
Expand Down Expand Up @@ -69,8 +81,24 @@ const BUTTON_MAP: Record<Button, string> = {
const TIMEOUT = 30000;
const MAX_BUFFER_SIZE = 1024 * 1024 * 4;

const clampInt = (value: any, defaultValue: number, min: number, max: number): number => {
const n = Number(value);
if (!Number.isFinite(n)) {return defaultValue;}
return Math.max(min, Math.min(max, Math.floor(n)));
};

const truncateText = (text: string, maxChars: number): { text: string; truncated: boolean } => {
if (text.length <= maxChars) {return { text, truncated: false };}
return { text: text.slice(0, maxChars) + "\n\n[OUTPUT TRUNCATED - Use packageName, minPriority, includeRegex, or excludeRegex filters to reduce output]\n", truncated: true };
};

type AndroidDeviceType = "tv" | "mobile";

type LogcatBuffer = "main" | "crash" | "system";
type LogcatFormat = "threadtime" | "time" | "brief";
type LogcatPriority = "V" | "D" | "I" | "W" | "E" | "F";


export class AndroidRobot implements Robot {

public constructor(private deviceId: string) {
Expand Down Expand Up @@ -501,6 +529,151 @@ export class AndroidRobot implements Robot {
height: bottom - top,
};
}

public async logcatDump(opts: LogcatDumpOptions): Promise<{
text: string;
truncated: boolean;
meta: {
linesRequested: number;
format: LogcatFormat;
buffers: LogcatBuffer[];
minPriority: LogcatPriority;
pidRequested?: number;
pidResolved?: number;
pidFilterMode?: "logcat--pid" | "client-side" | "none";
};
}> {
const linesRequested = clampInt(opts.lines, 200, 1, 500);
const format: LogcatFormat = opts.format ?? "threadtime";
const buffers: LogcatBuffer[] = (opts.buffers && opts.buffers.length > 0) ? opts.buffers : ["main", "crash"];
const minPriority: LogcatPriority = opts.minPriority ?? "I";

let pidResolved: number | undefined = opts.pid;

// Resolve PID from packageName if requested
if (!pidResolved && opts.packageName) {
pidResolved = await this.tryResolvePid(opts.packageName);
}

const baseArgs: string[] = ["shell", "logcat", "-d", "-v", format];
for (const b of buffers) {
baseArgs.push("-b", b);
}
baseArgs.push("-t", String(linesRequested), `*:${minPriority}`);

let output = "";
let pidFilterMode: "logcat--pid" | "client-side" | "none" = "none";

// First try: use logcat --pid if pid is available (not supported on all Android builds)
if (pidResolved) {
try {
const buf = this.silentAdb(...baseArgs, "--pid", String(pidResolved));
output = buf.toString();
pidFilterMode = "logcat--pid";
} catch (e) {
// Fallback: run without --pid and filter client-side
const buf = this.silentAdb(...baseArgs);
output = buf.toString();
pidFilterMode = "client-side";
}
} else {
const buf = this.silentAdb(...baseArgs);
output = buf.toString();
pidFilterMode = "none";
}

// Normalize + filter lines
let lines = output.split("\n").filter(l => l.length > 0);

// Client-side PID filter fallback (only supported for threadtime format)
if (pidResolved && pidFilterMode === "client-side") {
if (format === "threadtime") {
// threadtime format: "MM-DD HH:MM:SS.mmm PID TID PRIORITY/TAG: message"
// PID is column index 2 (0-indexed) when split by whitespace
lines = lines.filter(l => {
const cols = l.trim().split(/\s+/);
if (cols.length < 3) {return false;}
const linePid = Number(cols[2]);
return linePid === pidResolved;
});
} else {
// For non-threadtime formats (time, brief), PID column position varies or is absent
// Skip client-side filtering to avoid incorrect matches
pidFilterMode = "none";
console.warn(`Client-side PID filtering is only supported for threadtime format, skipping for format "${format}".`);
}
}

// include/exclude regex filters
if (opts.includeRegex) {
try {
const re = new RegExp(opts.includeRegex);
lines = lines.filter(l => re.test(l));
} catch (e) {
console.error(`Invalid includeRegex pattern "${opts.includeRegex}": ${(e as Error).message}. Skipping include filter.`);
}
}
if (opts.excludeRegex) {
try {
const re = new RegExp(opts.excludeRegex);
lines = lines.filter(l => !re.test(l));
} catch (e) {
console.error(`Invalid excludeRegex pattern "${opts.excludeRegex}": ${(e as Error).message}. Skipping exclude filter.`);
}
}

const finalText = lines.join("\n") + (lines.length ? "\n" : "");
const { text, truncated } = truncateText(finalText, 80_000);

return {
text,
truncated,
meta: {
linesRequested,
format,
buffers,
minPriority,
pidRequested: opts.pid,
pidResolved,
pidFilterMode,
},
};
}

private async tryResolvePid(packageName: string): Promise<number | undefined> {
// 1) pidof <package>
try {
const out = this.silentAdb("shell", "pidof", packageName).toString().trim();
// pidof may return multiple PIDs separated by spaces
const first = out.split(/\s+/)[0];
const pid = Number(first);
if (Number.isFinite(pid)) {return pid;}
} catch (e) {
// ignore
}

// 2) fallback: ps -A | grep <package>
try {
const out = this.silentAdb("shell", "ps", "-A").toString();
// ps output: USER PID ... NAME (NAME is the last column)
// Require exact match on NAME column to avoid partial package name matches
const line = out.split("\n").find(l => {
const cols = l.trim().split(/\s+/);
if (cols.length < 2) {return false;}
const name = cols[cols.length - 1];
return name === packageName;
});
if (!line) {return undefined;}

const cols = line.trim().split(/\s+/);
const pid = Number(cols[1]);
if (Number.isFinite(pid)) {return pid;}

return undefined;
} catch (e) {
return undefined;
}
}
}

export class AndroidDeviceManager {
Expand Down
37 changes: 37 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,42 @@ export const createMcpServer = (): McpServer => {
}
);

tool(
"mobile_logcat_dump",
"Logcat Dump (Android)",
"Dump recent Android logcat lines for debugging automation flows (crashes, permission errors, intent handling, etc). Optional filtering by package/pid, buffers, priority, and regex.",
{
device: z.string().describe("Android device id (from mobile_list_available_devices)"),
lines: z.number().optional().describe("Number of lines to dump (default 200, max 500)"),
format: z.enum(["threadtime", "time", "brief"]).optional().describe("Log line format (default threadtime)"),
buffers: z.array(z.enum(["main", "crash", "system"])).optional().describe("Log buffers to include (default [main, crash])"),
minPriority: z.enum(["V", "D", "I", "W", "E", "F"]).optional().describe("Minimum log priority (default I)"),
pid: z.number().optional().describe("PID to filter on (optional)"),
packageName: z.string().optional().describe("Package name; will resolve PID and filter (optional)"),
includeRegex: z.string().optional().describe("Only include lines matching this regex (optional)"),
excludeRegex: z.string().optional().describe("Exclude lines matching this regex (optional)"),
},
{ readOnlyHint: true },
async args => {
const robot = getRobotFromDevice(args.device);
if (!(robot instanceof AndroidRobot)) {
throw new ActionableError("mobile_logcat_dump is only supported on Android devices");
}

const result = await robot.logcatDump({
lines: args.lines,
format: args.format,
buffers: args.buffers,
minPriority: args.minPriority,
pid: args.pid,
packageName: args.packageName,
includeRegex: args.includeRegex,
excludeRegex: args.excludeRegex,
});

// Return structured JSON (agent-friendly)
return JSON.stringify(result, null, 2);
}
);
return server;
};
43 changes: 43 additions & 0 deletions test/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,47 @@ describe("android", () => {
// screen size should not have changed
assert.deepEqual(screenSize1, screenSize2);
});


it("should be able to dump logcat for a package", async function() {
hasOneAndroidDevice || this.skip();

const res = await android.logcatDump({
packageName: "com.android.settings",
lines: 120,
format: "threadtime",
buffers: ["main", "crash"],
minPriority: "I",
});

assert.ok(typeof res.text === "string");
assert.ok(res.text.length > 0);
assert.equal(res.truncated, false);
assert.ok(res.meta.pidResolved !== undefined, "Expected pidResolved to be set");
assert.ok(res.meta.pidResolved > 0, "Expected pidResolved to be > 0");
assert.ok(
res.meta.pidFilterMode === "logcat--pid" || res.meta.pidFilterMode === "client-side" || res.meta.pidFilterMode === "none",
"Unexpected pidFilterMode"
);

// logcat often prints this marker line
assert.ok(res.text.includes("beginning of"), "Expected log output to include buffer marker");
});

it("should support includeRegex / excludeRegex filtering in logcat dump", async function() {
hasOneAndroidDevice || this.skip();

const res = await android.logcatDump({
packageName: "com.android.settings",
lines: 400,
includeRegex: "SecurityException|FATAL|Exception|ANR|E ",
excludeRegex: "Choreographer",
});

assert.ok(typeof res.text === "string");
// If there are no matches, text may be empty; but tool should still succeed and return meta.
assert.ok(res.meta.pidResolved !== undefined, "Expected pidResolved to be set");
assert.ok(res.meta.pidResolved > 0, "Expected pidResolved to be > 0");
});

});