@sensaiorg/adapter-android 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android-adapter.d.ts.map +1 -0
- package/dist/android-adapter.js +89 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/tools/accessibility.d.ts.map +1 -0
- package/dist/tools/accessibility.js +85 -0
- package/dist/tools/adb.d.ts.map +1 -0
- package/dist/tools/adb.js +66 -0
- package/dist/tools/app-state.d.ts.map +1 -0
- package/dist/tools/app-state.js +173 -0
- package/dist/tools/diagnose.d.ts.map +1 -0
- package/dist/tools/diagnose.js +128 -0
- package/dist/tools/hot-reload.d.ts.map +1 -0
- package/dist/tools/hot-reload.js +97 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/interaction.d.ts.map +1 -0
- package/dist/tools/interaction.js +395 -0
- package/dist/tools/logcat.d.ts.map +1 -0
- package/dist/tools/logcat.js +216 -0
- package/dist/tools/network.d.ts.map +1 -0
- package/dist/tools/network.js +123 -0
- package/dist/tools/performance.d.ts.map +1 -0
- package/dist/tools/performance.js +143 -0
- package/dist/tools/recording.d.ts.map +1 -0
- package/dist/tools/recording.js +102 -0
- package/dist/tools/rn-tools.d.ts.map +1 -0
- package/dist/tools/rn-tools.js +120 -0
- package/dist/tools/smart-actions.d.ts.map +1 -0
- package/dist/tools/smart-actions.js +506 -0
- package/dist/tools/ui-tree.d.ts.map +1 -0
- package/dist/tools/ui-tree.js +226 -0
- package/dist/transport/adb-client.d.ts.map +1 -0
- package/dist/transport/adb-client.js +124 -0
- package/dist/transport/adb-client.test.d.ts.map +1 -0
- package/dist/transport/adb-client.test.js +153 -0
- package/dist/transport/agent-client.d.ts.map +1 -0
- package/dist/transport/agent-client.js +157 -0
- package/dist/transport/agent-client.test.d.ts.map +1 -0
- package/dist/transport/agent-client.test.js +199 -0
- package/dist/transport/connection-manager.d.ts.map +1 -0
- package/dist/transport/connection-manager.js +119 -0
- package/dist/util/logcat-parser.d.ts.map +1 -0
- package/dist/util/logcat-parser.js +79 -0
- package/dist/util/safety.d.ts.map +1 -0
- package/dist/util/safety.js +132 -0
- package/dist/util/safety.test.d.ts.map +1 -0
- package/dist/util/safety.test.js +205 -0
- package/dist/util/text-extractor.d.ts.map +1 -0
- package/dist/util/text-extractor.js +71 -0
- package/dist/util/ui-tree-cache.d.ts.map +1 -0
- package/dist/util/ui-tree-cache.js +46 -0
- package/dist/util/ui-tree-cache.test.d.ts.map +1 -0
- package/dist/util/ui-tree-cache.test.js +84 -0
- package/dist/util/ui-tree-parser.d.ts.map +1 -0
- package/dist/util/ui-tree-parser.js +123 -0
- package/dist/util/ui-tree-parser.test.d.ts.map +1 -0
- package/dist/util/ui-tree-parser.test.js +167 -0
- package/package.json +22 -0
- package/src/android-adapter.ts +124 -0
- package/src/index.ts +8 -0
- package/src/tools/accessibility.ts +94 -0
- package/src/tools/adb.ts +75 -0
- package/src/tools/app-state.ts +193 -0
- package/src/tools/diagnose.ts +146 -0
- package/src/tools/hot-reload.ts +103 -0
- package/src/tools/index.ts +66 -0
- package/src/tools/interaction.ts +448 -0
- package/src/tools/logcat.ts +252 -0
- package/src/tools/network.ts +145 -0
- package/src/tools/performance.ts +169 -0
- package/src/tools/recording.ts +123 -0
- package/src/tools/rn-tools.ts +143 -0
- package/src/tools/smart-actions.ts +593 -0
- package/src/tools/ui-tree.ts +258 -0
- package/src/transport/adb-client.test.ts +228 -0
- package/src/transport/adb-client.ts +139 -0
- package/src/transport/agent-client.test.ts +267 -0
- package/src/transport/agent-client.ts +188 -0
- package/src/transport/connection-manager.ts +140 -0
- package/src/util/logcat-parser.ts +94 -0
- package/src/util/safety.test.ts +251 -0
- package/src/util/safety.ts +143 -0
- package/src/util/text-extractor.ts +87 -0
- package/src/util/ui-tree-cache.test.ts +105 -0
- package/src/util/ui-tree-cache.ts +54 -0
- package/src/util/ui-tree-parser.test.ts +182 -0
- package/src/util/ui-tree-parser.ts +169 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction Tools - Simulate user input on the device.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - tap: Tap by text, resourceId, contentDescription, or coordinates
|
|
6
|
+
* - type_text: Enter text into the focused field
|
|
7
|
+
* - swipe: Swipe gesture between two points
|
|
8
|
+
* - press_key: Press Android key events (BACK, HOME, ENTER, etc.)
|
|
9
|
+
* - long_press: Long press (press and hold) on element or coordinates
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import type { ConnectionManager } from "../transport/connection-manager.js";
|
|
15
|
+
import { flattenTree } from "../util/ui-tree-parser.js";
|
|
16
|
+
import { getCachedTree } from "./ui-tree.js";
|
|
17
|
+
|
|
18
|
+
/** Android key event codes for common keys. */
|
|
19
|
+
const KEY_CODES: Record<string, number> = {
|
|
20
|
+
BACK: 4,
|
|
21
|
+
HOME: 3,
|
|
22
|
+
ENTER: 66,
|
|
23
|
+
DELETE: 67,
|
|
24
|
+
TAB: 61,
|
|
25
|
+
ESCAPE: 111,
|
|
26
|
+
VOLUME_UP: 24,
|
|
27
|
+
VOLUME_DOWN: 25,
|
|
28
|
+
POWER: 26,
|
|
29
|
+
MENU: 82,
|
|
30
|
+
SEARCH: 84,
|
|
31
|
+
DPAD_UP: 19,
|
|
32
|
+
DPAD_DOWN: 20,
|
|
33
|
+
DPAD_LEFT: 21,
|
|
34
|
+
DPAD_RIGHT: 22,
|
|
35
|
+
DPAD_CENTER: 23,
|
|
36
|
+
APP_SWITCH: 187,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find element coordinates by searching the UI tree (uses cache).
|
|
41
|
+
*/
|
|
42
|
+
async function findElementCenter(
|
|
43
|
+
cm: ConnectionManager,
|
|
44
|
+
selector: { text?: string; resourceId?: string; contentDescription?: string },
|
|
45
|
+
): Promise<{ x: number; y: number; matched: string } | null> {
|
|
46
|
+
const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
|
|
47
|
+
const flat = flattenTree(tree);
|
|
48
|
+
|
|
49
|
+
for (const node of flat) {
|
|
50
|
+
let matched = false;
|
|
51
|
+
let matchedBy = "";
|
|
52
|
+
|
|
53
|
+
if (selector.text && node.text.toLowerCase().includes(selector.text.toLowerCase())) {
|
|
54
|
+
matched = true;
|
|
55
|
+
matchedBy = `text="${node.text}"`;
|
|
56
|
+
}
|
|
57
|
+
if (selector.resourceId && node.resourceId.includes(selector.resourceId)) {
|
|
58
|
+
matched = true;
|
|
59
|
+
matchedBy = `resourceId="${node.resourceId}"`;
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
selector.contentDescription &&
|
|
63
|
+
node.contentDescription.toLowerCase().includes(selector.contentDescription.toLowerCase())
|
|
64
|
+
) {
|
|
65
|
+
matched = true;
|
|
66
|
+
matchedBy = `contentDescription="${node.contentDescription}"`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (matched && node.bounds) {
|
|
70
|
+
return {
|
|
71
|
+
x: Math.round((node.bounds.left + node.bounds.right) / 2),
|
|
72
|
+
y: Math.round((node.bounds.top + node.bounds.bottom) / 2),
|
|
73
|
+
matched: matchedBy,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function registerInteractionTools(server: McpServer, cm: ConnectionManager): void {
|
|
82
|
+
/**
|
|
83
|
+
* tap - Tap on an element or coordinates.
|
|
84
|
+
*/
|
|
85
|
+
server.tool(
|
|
86
|
+
"tap",
|
|
87
|
+
"Tap on a UI element found by text, resourceId, or contentDescription; or tap directly at x,y coordinates. The element is found in the current UI hierarchy and tapped at its center.",
|
|
88
|
+
{
|
|
89
|
+
text: z.string().optional().describe("Find and tap element containing this text"),
|
|
90
|
+
resourceId: z.string().optional().describe("Find and tap element with this resource ID"),
|
|
91
|
+
contentDescription: z.string().optional().describe("Find and tap element with this content description"),
|
|
92
|
+
x: z.number().optional().describe("X coordinate for direct tap"),
|
|
93
|
+
y: z.number().optional().describe("Y coordinate for direct tap"),
|
|
94
|
+
},
|
|
95
|
+
async (params) => {
|
|
96
|
+
try {
|
|
97
|
+
let tapX: number;
|
|
98
|
+
let tapY: number;
|
|
99
|
+
let matchInfo = "";
|
|
100
|
+
|
|
101
|
+
if (params.x !== undefined && params.y !== undefined) {
|
|
102
|
+
// Direct coordinate tap
|
|
103
|
+
tapX = params.x;
|
|
104
|
+
tapY = params.y;
|
|
105
|
+
matchInfo = `direct coordinates (${tapX}, ${tapY})`;
|
|
106
|
+
} else if (params.text || params.resourceId || params.contentDescription) {
|
|
107
|
+
// Find element by selector
|
|
108
|
+
const result = await findElementCenter(cm, {
|
|
109
|
+
text: params.text,
|
|
110
|
+
resourceId: params.resourceId,
|
|
111
|
+
contentDescription: params.contentDescription,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!result) {
|
|
115
|
+
return {
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: "text" as const,
|
|
119
|
+
text: JSON.stringify({
|
|
120
|
+
success: false,
|
|
121
|
+
error: "Element not found",
|
|
122
|
+
selector: {
|
|
123
|
+
text: params.text,
|
|
124
|
+
resourceId: params.resourceId,
|
|
125
|
+
contentDescription: params.contentDescription,
|
|
126
|
+
},
|
|
127
|
+
hint: "Use get_screen_text or get_ui_tree to see available elements.",
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
tapX = result.x;
|
|
135
|
+
tapY = result.y;
|
|
136
|
+
matchInfo = result.matched;
|
|
137
|
+
} else {
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text" as const,
|
|
142
|
+
text: JSON.stringify({
|
|
143
|
+
success: false,
|
|
144
|
+
error: "No target specified. Provide text, resourceId, contentDescription, or x/y coordinates.",
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Invalidate cache — screen will change after tap
|
|
152
|
+
cm.uiCache.invalidate();
|
|
153
|
+
await cm.adb.shell(`input tap ${tapX} ${tapY}`);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
content: [
|
|
157
|
+
{
|
|
158
|
+
type: "text" as const,
|
|
159
|
+
text: JSON.stringify({
|
|
160
|
+
success: true,
|
|
161
|
+
tapped: { x: tapX, y: tapY },
|
|
162
|
+
matchedBy: matchInfo,
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: "text" as const,
|
|
172
|
+
text: `Error performing tap: ${err instanceof Error ? err.message : String(err)}`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
isError: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* type_text - Type text into the currently focused input field.
|
|
183
|
+
*/
|
|
184
|
+
server.tool(
|
|
185
|
+
"type_text",
|
|
186
|
+
"Type text into the currently focused input field on the device. Special characters are escaped for ADB input.",
|
|
187
|
+
{
|
|
188
|
+
text: z.string().describe("Text to type"),
|
|
189
|
+
clearFirst: z.boolean().optional().describe("Clear the field before typing (default: false)"),
|
|
190
|
+
},
|
|
191
|
+
async (params) => {
|
|
192
|
+
try {
|
|
193
|
+
// Optionally clear existing text
|
|
194
|
+
if (params.clearFirst) {
|
|
195
|
+
// Most reliable approach for React Native TextInput:
|
|
196
|
+
// Move to end, then send batch DEL key events to clear everything.
|
|
197
|
+
// Ctrl+A doesn't work reliably in RN, and shift+home is fragile.
|
|
198
|
+
await cm.adb.shell("input keyevent 123"); // KEYCODE_MOVE_END
|
|
199
|
+
// Send 50 DEL events in one call (enough for any reasonable field)
|
|
200
|
+
await cm.adb.shell("input keyevent " + Array(50).fill("67").join(" "));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Escape special characters for ADB input text
|
|
204
|
+
const escaped = params.text
|
|
205
|
+
.replace(/\\/g, "\\\\")
|
|
206
|
+
.replace(/ /g, "%s")
|
|
207
|
+
.replace(/'/g, "\\'")
|
|
208
|
+
.replace(/"/g, '\\"')
|
|
209
|
+
.replace(/&/g, "\\&")
|
|
210
|
+
.replace(/</g, "\\<")
|
|
211
|
+
.replace(/>/g, "\\>")
|
|
212
|
+
.replace(/\|/g, "\\|")
|
|
213
|
+
.replace(/;/g, "\\;")
|
|
214
|
+
.replace(/\(/g, "\\(")
|
|
215
|
+
.replace(/\)/g, "\\)")
|
|
216
|
+
.replace(/\$/g, "\\$")
|
|
217
|
+
.replace(/`/g, "\\`");
|
|
218
|
+
|
|
219
|
+
// Invalidate cache — screen content changes after typing
|
|
220
|
+
cm.uiCache.invalidate();
|
|
221
|
+
await cm.adb.shell(`input text ${escaped}`);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "text" as const,
|
|
227
|
+
text: JSON.stringify({ ok: true }),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return {
|
|
233
|
+
content: [
|
|
234
|
+
{
|
|
235
|
+
type: "text" as const,
|
|
236
|
+
text: `Error typing text: ${err instanceof Error ? err.message : String(err)}`,
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
isError: true,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* swipe - Perform a swipe gesture.
|
|
247
|
+
*/
|
|
248
|
+
server.tool(
|
|
249
|
+
"swipe",
|
|
250
|
+
"Perform a swipe gesture on the device from (startX, startY) to (endX, endY) over the specified duration.",
|
|
251
|
+
{
|
|
252
|
+
startX: z.number().describe("Start X coordinate"),
|
|
253
|
+
startY: z.number().describe("Start Y coordinate"),
|
|
254
|
+
endX: z.number().describe("End X coordinate"),
|
|
255
|
+
endY: z.number().describe("End Y coordinate"),
|
|
256
|
+
durationMs: z.number().optional().describe("Swipe duration in milliseconds (default: 300)"),
|
|
257
|
+
},
|
|
258
|
+
async (params) => {
|
|
259
|
+
try {
|
|
260
|
+
const duration = params.durationMs ?? 300;
|
|
261
|
+
// Invalidate cache — screen will change after swipe
|
|
262
|
+
cm.uiCache.invalidate();
|
|
263
|
+
await cm.adb.shell(
|
|
264
|
+
`input swipe ${params.startX} ${params.startY} ${params.endX} ${params.endY} ${duration}`,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
content: [
|
|
269
|
+
{
|
|
270
|
+
type: "text" as const,
|
|
271
|
+
text: JSON.stringify({ ok: true }),
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return {
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: "text" as const,
|
|
280
|
+
text: `Error performing swipe: ${err instanceof Error ? err.message : String(err)}`,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
isError: true,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* press_key - Press an Android key event.
|
|
291
|
+
*/
|
|
292
|
+
server.tool(
|
|
293
|
+
"press_key",
|
|
294
|
+
"Press an Android key event. Supports named keys (BACK, HOME, ENTER, etc.) or numeric key codes.",
|
|
295
|
+
{
|
|
296
|
+
key: z
|
|
297
|
+
.string()
|
|
298
|
+
.describe(
|
|
299
|
+
`Key name or numeric code. Named keys: ${Object.keys(KEY_CODES).join(", ")}`,
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
async (params) => {
|
|
303
|
+
try {
|
|
304
|
+
const keyUpper = params.key.toUpperCase();
|
|
305
|
+
const keyCode = KEY_CODES[keyUpper] ?? parseInt(params.key, 10);
|
|
306
|
+
|
|
307
|
+
if (isNaN(keyCode)) {
|
|
308
|
+
return {
|
|
309
|
+
content: [
|
|
310
|
+
{
|
|
311
|
+
type: "text" as const,
|
|
312
|
+
text: JSON.stringify({
|
|
313
|
+
success: false,
|
|
314
|
+
error: `Unknown key: ${params.key}`,
|
|
315
|
+
availableKeys: Object.keys(KEY_CODES),
|
|
316
|
+
}),
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Invalidate cache — key press may change screen
|
|
323
|
+
cm.uiCache.invalidate();
|
|
324
|
+
await cm.adb.shell(`input keyevent ${keyCode}`);
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
content: [
|
|
328
|
+
{
|
|
329
|
+
type: "text" as const,
|
|
330
|
+
text: JSON.stringify({ ok: true }),
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
};
|
|
334
|
+
} catch (err) {
|
|
335
|
+
return {
|
|
336
|
+
content: [
|
|
337
|
+
{
|
|
338
|
+
type: "text" as const,
|
|
339
|
+
text: `Error pressing key: ${err instanceof Error ? err.message : String(err)}`,
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
isError: true,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* long_press - Long press (press and hold) on an element or coordinates.
|
|
350
|
+
*/
|
|
351
|
+
server.tool(
|
|
352
|
+
"long_press",
|
|
353
|
+
"Long press (press and hold) at coordinates or on an element found by text. Useful for context menus and drag initiation.",
|
|
354
|
+
{
|
|
355
|
+
text: z.string().optional().describe("Visible text to long-press"),
|
|
356
|
+
resourceId: z.string().optional().describe("Resource ID to long-press"),
|
|
357
|
+
contentDescription: z.string().optional().describe("Content description to long-press"),
|
|
358
|
+
x: z.number().optional().describe("X coordinate"),
|
|
359
|
+
y: z.number().optional().describe("Y coordinate"),
|
|
360
|
+
durationMs: z.number().optional().describe("Hold duration in ms (default: 1500)"),
|
|
361
|
+
},
|
|
362
|
+
async (params) => {
|
|
363
|
+
try {
|
|
364
|
+
let pressX: number;
|
|
365
|
+
let pressY: number;
|
|
366
|
+
let matchInfo = "";
|
|
367
|
+
|
|
368
|
+
if (params.x !== undefined && params.y !== undefined) {
|
|
369
|
+
pressX = params.x;
|
|
370
|
+
pressY = params.y;
|
|
371
|
+
matchInfo = `direct coordinates (${pressX}, ${pressY})`;
|
|
372
|
+
} else if (params.text || params.resourceId || params.contentDescription) {
|
|
373
|
+
const result = await findElementCenter(cm, {
|
|
374
|
+
text: params.text,
|
|
375
|
+
resourceId: params.resourceId,
|
|
376
|
+
contentDescription: params.contentDescription,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (!result) {
|
|
380
|
+
return {
|
|
381
|
+
content: [
|
|
382
|
+
{
|
|
383
|
+
type: "text" as const,
|
|
384
|
+
text: JSON.stringify({
|
|
385
|
+
success: false,
|
|
386
|
+
error: "Element not found",
|
|
387
|
+
selector: {
|
|
388
|
+
text: params.text,
|
|
389
|
+
resourceId: params.resourceId,
|
|
390
|
+
contentDescription: params.contentDescription,
|
|
391
|
+
},
|
|
392
|
+
hint: "Use get_screen_text or get_ui_tree to see available elements.",
|
|
393
|
+
}),
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
pressX = result.x;
|
|
400
|
+
pressY = result.y;
|
|
401
|
+
matchInfo = result.matched;
|
|
402
|
+
} else {
|
|
403
|
+
return {
|
|
404
|
+
content: [
|
|
405
|
+
{
|
|
406
|
+
type: "text" as const,
|
|
407
|
+
text: JSON.stringify({
|
|
408
|
+
success: false,
|
|
409
|
+
error: "No target specified. Provide text, resourceId, contentDescription, or x/y coordinates.",
|
|
410
|
+
}),
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const duration = params.durationMs ?? 1500;
|
|
417
|
+
|
|
418
|
+
// A zero-distance swipe = long press
|
|
419
|
+
cm.uiCache.invalidate();
|
|
420
|
+
await cm.adb.shell(`input swipe ${pressX} ${pressY} ${pressX} ${pressY} ${duration}`);
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
content: [
|
|
424
|
+
{
|
|
425
|
+
type: "text" as const,
|
|
426
|
+
text: JSON.stringify({
|
|
427
|
+
success: true,
|
|
428
|
+
longPressed: { x: pressX, y: pressY },
|
|
429
|
+
durationMs: duration,
|
|
430
|
+
matchedBy: matchInfo,
|
|
431
|
+
}),
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return {
|
|
437
|
+
content: [
|
|
438
|
+
{
|
|
439
|
+
type: "text" as const,
|
|
440
|
+
text: `Error performing long press: ${err instanceof Error ? err.message : String(err)}`,
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
isError: true,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logcat Tools - Read and filter Android system logs.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - get_logcat: Filtered log retrieval with level/tag/grep/since/maxLines
|
|
6
|
+
* - get_crash_info: Extract crash reports (JS exceptions, native crashes, ANRs)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import type { ConnectionManager } from "../transport/connection-manager.js";
|
|
12
|
+
import {
|
|
13
|
+
parseLogcat,
|
|
14
|
+
filterByLevel,
|
|
15
|
+
filterByTag,
|
|
16
|
+
filterByGrep,
|
|
17
|
+
formatEntry,
|
|
18
|
+
type LogLevel,
|
|
19
|
+
} from "../util/logcat-parser.js";
|
|
20
|
+
|
|
21
|
+
/** Tags commonly associated with React Native errors. */
|
|
22
|
+
const RN_ERROR_TAGS = [
|
|
23
|
+
"ReactNativeJS",
|
|
24
|
+
"ReactNative",
|
|
25
|
+
"hermes",
|
|
26
|
+
"HermesVM",
|
|
27
|
+
"com.facebook.react",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Tags for native Android crashes. */
|
|
31
|
+
const CRASH_TAGS = [
|
|
32
|
+
"AndroidRuntime",
|
|
33
|
+
"FATAL",
|
|
34
|
+
"DEBUG",
|
|
35
|
+
"tombstoned",
|
|
36
|
+
"crash_dump",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function registerLogcatTools(server: McpServer, cm: ConnectionManager): void {
|
|
40
|
+
/**
|
|
41
|
+
* get_logcat - Retrieve filtered Android logs.
|
|
42
|
+
*/
|
|
43
|
+
server.tool(
|
|
44
|
+
"get_logcat",
|
|
45
|
+
"Get Android logcat entries with optional filtering by log level, tag, text pattern, and time range. Returns structured log entries.",
|
|
46
|
+
{
|
|
47
|
+
level: z
|
|
48
|
+
.enum(["V", "D", "I", "W", "E", "F"])
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("Minimum log level (V=verbose, D=debug, I=info, W=warn, E=error, F=fatal)"),
|
|
51
|
+
tag: z.string().optional().describe("Filter by tag (case-insensitive substring)"),
|
|
52
|
+
since: z.string().optional().describe("Only logs since this time (e.g., '5s', '1m', '30m', '1h')"),
|
|
53
|
+
grep: z.string().optional().describe("Regex pattern to filter log messages"),
|
|
54
|
+
maxLines: z.number().optional().describe("Maximum number of entries to return (default: 200)"),
|
|
55
|
+
packageOnly: z.boolean().optional().describe("Only show logs from the target package"),
|
|
56
|
+
deduplicate: z.boolean().optional().describe("Collapse consecutive identical messages into '[repeated Nx] message' (default: true)"),
|
|
57
|
+
},
|
|
58
|
+
async (params) => {
|
|
59
|
+
try {
|
|
60
|
+
const maxLines = params.maxLines ?? 200;
|
|
61
|
+
const shouldDeduplicate = params.deduplicate !== false; // default: true
|
|
62
|
+
const targetPackage = process.env.TARGET_PACKAGE ?? "com.emudebug.target";
|
|
63
|
+
|
|
64
|
+
// Build logcat command
|
|
65
|
+
const cmdParts = ["logcat", "-d", "-v", "threadtime"];
|
|
66
|
+
|
|
67
|
+
// Time filter
|
|
68
|
+
if (params.since) {
|
|
69
|
+
cmdParts.push("-T", `'${params.since}'`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Package filter via PID
|
|
73
|
+
let pid: string | null = null;
|
|
74
|
+
if (params.packageOnly) {
|
|
75
|
+
try {
|
|
76
|
+
const pidOutput = await cm.adb.shell(`pidof ${targetPackage}`);
|
|
77
|
+
pid = pidOutput.trim();
|
|
78
|
+
} catch {
|
|
79
|
+
// Package might not be running
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const raw = await cm.adb.shell(cmdParts.join(" "), 15_000);
|
|
84
|
+
let entries = parseLogcat(raw);
|
|
85
|
+
|
|
86
|
+
// Apply filters
|
|
87
|
+
if (pid) {
|
|
88
|
+
entries = entries.filter((e) => e.pid === pid);
|
|
89
|
+
}
|
|
90
|
+
if (params.level) {
|
|
91
|
+
entries = filterByLevel(entries, params.level as LogLevel);
|
|
92
|
+
}
|
|
93
|
+
if (params.tag) {
|
|
94
|
+
entries = filterByTag(entries, params.tag);
|
|
95
|
+
}
|
|
96
|
+
if (params.grep) {
|
|
97
|
+
entries = filterByGrep(entries, params.grep);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Deduplicate consecutive identical messages
|
|
101
|
+
let deduplicatedCount = 0;
|
|
102
|
+
if (shouldDeduplicate && entries.length > 0) {
|
|
103
|
+
const deduped: typeof entries = [];
|
|
104
|
+
let repeatCount = 1;
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < entries.length; i++) {
|
|
107
|
+
const next = entries[i + 1];
|
|
108
|
+
if (next && entries[i].message === next.message && entries[i].tag === next.tag) {
|
|
109
|
+
repeatCount++;
|
|
110
|
+
deduplicatedCount++;
|
|
111
|
+
} else {
|
|
112
|
+
if (repeatCount > 1) {
|
|
113
|
+
deduped.push({
|
|
114
|
+
...entries[i],
|
|
115
|
+
message: `[repeated ${repeatCount}x] ${entries[i].message}`,
|
|
116
|
+
});
|
|
117
|
+
} else {
|
|
118
|
+
deduped.push(entries[i]);
|
|
119
|
+
}
|
|
120
|
+
repeatCount = 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
entries = deduped;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Limit results (take most recent)
|
|
127
|
+
const limited = entries.slice(-maxLines);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "text" as const,
|
|
133
|
+
text: JSON.stringify(
|
|
134
|
+
{
|
|
135
|
+
totalMatches: entries.length,
|
|
136
|
+
returned: limited.length,
|
|
137
|
+
truncated: entries.length > maxLines,
|
|
138
|
+
deduplicated: deduplicatedCount,
|
|
139
|
+
entries: limited,
|
|
140
|
+
},
|
|
141
|
+
null,
|
|
142
|
+
2,
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: "text" as const,
|
|
152
|
+
text: `Error reading logcat: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
isError: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* get_crash_info - Extract crash reports from logs.
|
|
163
|
+
*/
|
|
164
|
+
server.tool(
|
|
165
|
+
"get_crash_info",
|
|
166
|
+
"Extract crash information from Android logs: JavaScript exceptions (React Native), native crashes (tombstones), and ANRs. Focuses on the target app.",
|
|
167
|
+
{
|
|
168
|
+
since: z.string().optional().describe("Only crashes since this time (e.g., '5m', '1h')"),
|
|
169
|
+
includeAllApps: z.boolean().optional().describe("Include crashes from all apps, not just the target"),
|
|
170
|
+
},
|
|
171
|
+
async (params) => {
|
|
172
|
+
try {
|
|
173
|
+
const targetPackage = process.env.TARGET_PACKAGE ?? "com.emudebug.target";
|
|
174
|
+
|
|
175
|
+
// Get recent logcat
|
|
176
|
+
const cmdParts = ["logcat", "-d", "-v", "threadtime"];
|
|
177
|
+
if (params.since) {
|
|
178
|
+
cmdParts.push("-T", `'${params.since}'`);
|
|
179
|
+
}
|
|
180
|
+
const raw = await cm.adb.shell(cmdParts.join(" "), 15_000);
|
|
181
|
+
const allEntries = parseLogcat(raw);
|
|
182
|
+
|
|
183
|
+
// Find target app PID(s) for filtering
|
|
184
|
+
let targetPids = new Set<string>();
|
|
185
|
+
if (!params.includeAllApps) {
|
|
186
|
+
try {
|
|
187
|
+
const pidOutput = await cm.adb.shell(`pidof ${targetPackage}`);
|
|
188
|
+
pidOutput.trim().split(/\s+/).forEach((p) => targetPids.add(p));
|
|
189
|
+
} catch {
|
|
190
|
+
// If no PID found, we'll still look for tagged entries
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Extract JS exceptions (React Native)
|
|
195
|
+
const jsErrors = allEntries.filter((e) => {
|
|
196
|
+
const isRnTag = RN_ERROR_TAGS.some((t) => e.tag.includes(t));
|
|
197
|
+
const isError = e.level === "E" || e.level === "F";
|
|
198
|
+
const isTargetPid = targetPids.size === 0 || targetPids.has(e.pid);
|
|
199
|
+
return isRnTag && isError && (params.includeAllApps || isTargetPid);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Extract native crashes
|
|
203
|
+
const nativeCrashes = allEntries.filter((e) => {
|
|
204
|
+
const isCrashTag = CRASH_TAGS.some((t) => e.tag.includes(t));
|
|
205
|
+
const isError = e.level === "E" || e.level === "F";
|
|
206
|
+
return isCrashTag && isError;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Extract ANRs
|
|
210
|
+
const anrs = allEntries.filter(
|
|
211
|
+
(e) => e.tag === "ActivityManager" && e.message.includes("ANR in"),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Check for ANR traces file
|
|
215
|
+
let anrTraces = "";
|
|
216
|
+
if (anrs.length > 0) {
|
|
217
|
+
try {
|
|
218
|
+
anrTraces = await cm.adb.shell("cat /data/anr/traces.txt | head -100");
|
|
219
|
+
} catch {
|
|
220
|
+
anrTraces = "(ANR traces not accessible without root)";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = {
|
|
225
|
+
summary: {
|
|
226
|
+
jsExceptions: jsErrors.length,
|
|
227
|
+
nativeCrashes: nativeCrashes.length,
|
|
228
|
+
anrs: anrs.length,
|
|
229
|
+
},
|
|
230
|
+
jsExceptions: jsErrors.map(formatEntry),
|
|
231
|
+
nativeCrashes: nativeCrashes.map(formatEntry),
|
|
232
|
+
anrs: anrs.map(formatEntry),
|
|
233
|
+
anrTraces: anrTraces || undefined,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
238
|
+
};
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text" as const,
|
|
244
|
+
text: `Error getting crash info: ${err instanceof Error ? err.message : String(err)}`,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
isError: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
}
|