mobile-debug-mcp 0.22.0 → 0.24.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/interact/classify.js +35 -0
- package/dist/interact/index.js +133 -57
- package/dist/network/index.js +232 -0
- package/dist/server/common.js +66 -0
- package/dist/server/tool-definitions.js +921 -0
- package/dist/server/tool-handlers.js +320 -0
- package/dist/server-core.js +4 -686
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/TOOLS.md +15 -7
- package/docs/tools/interact.md +270 -107
- package/docs/tools/manage.md +39 -38
- package/docs/tools/observe.md +30 -8
- package/docs/tools/system.md +1 -1
- package/package.json +1 -1
- package/src/interact/classify.ts +64 -0
- package/src/interact/index.ts +186 -58
- package/src/network/index.ts +268 -0
- package/src/server/common.ts +95 -0
- package/src/server/tool-definitions.ts +921 -0
- package/src/server/tool-handlers.ts +365 -0
- package/src/server-core.ts +4 -727
- package/src/types.ts +59 -6
- package/test/unit/interact/classify_action_outcome.test.ts +110 -0
- package/test/unit/interact/expect_tools.test.ts +77 -0
- package/test/unit/interact/tap_element.test.ts +23 -6
- package/test/unit/network/get_network_activity.test.ts +181 -0
- package/test/unit/server/contract.test.ts +26 -0
- package/test/unit/server/response_shapes.test.ts +69 -4
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { execAdb, parseLogLine } from '../utils/android/utils.js'
|
|
2
|
+
import { execCommand } from '../utils/ios/utils.js'
|
|
3
|
+
|
|
4
|
+
export type NetworkErrorCode =
|
|
5
|
+
| 'timeout'
|
|
6
|
+
| 'dns_error'
|
|
7
|
+
| 'tls_error'
|
|
8
|
+
| 'connection_refused'
|
|
9
|
+
| 'connection_reset'
|
|
10
|
+
| 'unknown_network_error'
|
|
11
|
+
|
|
12
|
+
export type NetworkActivityStatus = 'success' | 'failure' | 'retryable'
|
|
13
|
+
|
|
14
|
+
export interface NetworkEvent {
|
|
15
|
+
endpoint: string
|
|
16
|
+
method: string
|
|
17
|
+
statusCode: number | null
|
|
18
|
+
networkError: NetworkErrorCode | null
|
|
19
|
+
status: NetworkActivityStatus
|
|
20
|
+
durationMs: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GetNetworkActivityResult {
|
|
24
|
+
requests: NetworkEvent[]
|
|
25
|
+
count: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Module state ─────────────────────────────────────────────────────────────
|
|
29
|
+
// lastActionTimestamp: set when an action tool fires (tap, swipe, etc.)
|
|
30
|
+
// lastConsumedTimestamp: advanced after each get_network_activity call to prevent duplicates
|
|
31
|
+
let lastActionTimestamp = 0
|
|
32
|
+
let lastConsumedTimestamp = 0
|
|
33
|
+
|
|
34
|
+
export function notifyActionStart(): void {
|
|
35
|
+
lastActionTimestamp = Date.now()
|
|
36
|
+
lastConsumedTimestamp = 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Exposed for unit tests only. */
|
|
40
|
+
export function _setTimestampsForTests(actionTs: number, consumedTs: number): void {
|
|
41
|
+
lastActionTimestamp = actionTs
|
|
42
|
+
lastConsumedTimestamp = consumedTs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Parsing constants ────────────────────────────────────────────────────────
|
|
46
|
+
const URL_RE = /https?:\/\/[^\s"'\]\)><]+/
|
|
47
|
+
const PATH_RE = /\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+/
|
|
48
|
+
const METHOD_RE = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/
|
|
49
|
+
|
|
50
|
+
const NETWORK_ERROR_PATTERNS: Array<{ re: RegExp; code: NetworkErrorCode }> = [
|
|
51
|
+
{ re: /timed?\s*out|timeout/i, code: 'timeout' },
|
|
52
|
+
{ re: /dns|name[\s_]resolution|host\s*not\s*found|nodename/i, code: 'dns_error' },
|
|
53
|
+
{ re: /\btls\b|\bssl\b|certificate|handshake/i, code: 'tls_error' },
|
|
54
|
+
{ re: /connection\s*refused/i, code: 'connection_refused' },
|
|
55
|
+
{ re: /connection\s*reset|reset\s*by\s*peer/i, code: 'connection_reset' },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const BACKGROUND_TOKENS = ['/analytics', '/metrics', '/tracking', '/log', '/events', '/telemetry', '/ping', '/beacon']
|
|
59
|
+
const BACKGROUND_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.css', '.js', '.svg', '.ico', '.woff', '.ttf']
|
|
60
|
+
const FILESYSTEM_PREFIXES = ['/data/', '/system/', '/apex/', '/proc/', '/dev/', '/vendor/', '/product/', '/storage/', '/sdcard/', '/mnt/', '/odm/', '/cache/', '/metadata/', '/acct/', '/sys/']
|
|
61
|
+
const FILESYSTEM_EXTENSIONS = ['.apk', '.apex', '.odex', '.vdex', '.dex', '.so', '.jar', '.bin', '.img', '.db', '.sqlite', '.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.m', '.mm', '.kt', '.java', '.swift']
|
|
62
|
+
|
|
63
|
+
// ─── Parsing helpers ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function extractUrl(text: string): string | null {
|
|
66
|
+
const m = text.match(URL_RE)
|
|
67
|
+
return m ? m[0] : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isPlausibleEndpointPath(path: string): boolean {
|
|
71
|
+
const lower = path.toLowerCase()
|
|
72
|
+
if (!lower.startsWith('/')) return false
|
|
73
|
+
if (FILESYSTEM_PREFIXES.some((prefix) => lower.startsWith(prefix))) return false
|
|
74
|
+
if (FILESYSTEM_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractPath(text: string): string | null {
|
|
79
|
+
const m = text.match(PATH_RE)
|
|
80
|
+
if (!m) return null
|
|
81
|
+
return isPlausibleEndpointPath(m[0]) ? m[0] : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toStatusCode(value: string | undefined): number | null {
|
|
85
|
+
if (!value) return null
|
|
86
|
+
const code = Number(value)
|
|
87
|
+
return code >= 100 && code <= 599 ? code : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function escapeRegExp(value: string): string {
|
|
91
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function extractStatusCode(text: string, url: string | null, path: string | null, method: string | null): number | null {
|
|
95
|
+
const directHttpMatch = text.match(/\bHTTP\/\d(?:\.\d)?\s+([1-5]\d{2})\b/i) || text.match(/\bHTTP\s+([1-5]\d{2})\b/i)
|
|
96
|
+
if (directHttpMatch) return toStatusCode(directHttpMatch[1])
|
|
97
|
+
|
|
98
|
+
const endpointToken = url || path
|
|
99
|
+
const hasEndpointContext = endpointToken !== null
|
|
100
|
+
if (!hasEndpointContext && method === null) return null
|
|
101
|
+
|
|
102
|
+
const labeledMatch = text.match(/\b(?:status(?:\s*code)?|response(?:\s*code)?)\s*[:=]?\s*([1-5]\d{2})\b/i)
|
|
103
|
+
if (labeledMatch && hasEndpointContext) return toStatusCode(labeledMatch[1])
|
|
104
|
+
|
|
105
|
+
if (endpointToken) {
|
|
106
|
+
const escapedEndpoint = escapeRegExp(endpointToken)
|
|
107
|
+
const endpointThenCode = new RegExp(`${escapedEndpoint}[^\\n]*?\\b([1-5]\\d{2})\\b`, 'i')
|
|
108
|
+
const codeThenEndpoint = new RegExp(`\\b([1-5]\\d{2})\\b[^\\n]*?${escapedEndpoint}`, 'i')
|
|
109
|
+
const contextualMatch = text.match(endpointThenCode) || text.match(codeThenEndpoint)
|
|
110
|
+
if (contextualMatch) return toStatusCode(contextualMatch[1])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (method !== null && path !== null) {
|
|
114
|
+
const methodPathCodeMatch = text.match(/\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b[^\n]*?\b([1-5]\d{2})\b/i)
|
|
115
|
+
if (methodPathCodeMatch) return toStatusCode(methodPathCodeMatch[1])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractMethod(text: string): string | null {
|
|
122
|
+
const m = text.match(METHOD_RE)
|
|
123
|
+
return m ? m[1] : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function detectNetworkError(text: string): NetworkErrorCode | null {
|
|
127
|
+
for (const { re, code } of NETWORK_ERROR_PATTERNS) {
|
|
128
|
+
if (re.test(text)) return code
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function normalizeEndpoint(raw: string): string {
|
|
134
|
+
try {
|
|
135
|
+
const u = new URL(raw.startsWith('/') ? `https://x${raw}` : raw)
|
|
136
|
+
const p = u.pathname.toLowerCase().replace(/\/+$/, '')
|
|
137
|
+
return p || '/'
|
|
138
|
+
} catch {
|
|
139
|
+
return raw.toLowerCase().replace(/\?.*$/, '').replace(/\/+$/, '') || '/'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function classifyStatus(statusCode: number | null, networkError: NetworkErrorCode | null): NetworkActivityStatus {
|
|
144
|
+
if (networkError !== null) return 'retryable'
|
|
145
|
+
if (statusCode === null) return 'success' // request detected, no failure signal
|
|
146
|
+
if (statusCode >= 200 && statusCode <= 299) return 'success'
|
|
147
|
+
if (statusCode >= 400 && statusCode <= 499) return 'failure'
|
|
148
|
+
return 'retryable' // 5xx, 1xx, 3xx
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function meetsEmissionCriteria(url: string | null, path: string | null, statusCode: number | null, method: string | null): boolean {
|
|
152
|
+
if (url !== null) return true // condition 1: full http/https URL
|
|
153
|
+
if (statusCode !== null) return true // condition 2: valid HTTP status code
|
|
154
|
+
if (method !== null && path !== null) return true // condition 3: method + path
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function classifyEventType(endpoint: string): 'primary' | 'background' {
|
|
159
|
+
const lower = endpoint.toLowerCase()
|
|
160
|
+
if (BACKGROUND_TOKENS.some(t => lower.includes(t))) return 'background'
|
|
161
|
+
if (BACKGROUND_EXTENSIONS.some(e => lower.endsWith(e))) return 'background'
|
|
162
|
+
return 'primary'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function filterToSignificantEvents(events: NetworkEvent[]): NetworkEvent[] {
|
|
166
|
+
if (events.length === 0) return events
|
|
167
|
+
const hasPrimary = events.some(e => classifyEventType(e.endpoint) === 'primary')
|
|
168
|
+
return hasPrimary ? events.filter(e => classifyEventType(e.endpoint) === 'primary') : events
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Exported for unit testing. */
|
|
172
|
+
export function parseMessageToEvent(message: string): NetworkEvent | null {
|
|
173
|
+
const url = extractUrl(message)
|
|
174
|
+
const path = url ? null : extractPath(message)
|
|
175
|
+
const method = extractMethod(message)
|
|
176
|
+
const statusCode = extractStatusCode(message, url, path, method)
|
|
177
|
+
const networkError = detectNetworkError(message)
|
|
178
|
+
|
|
179
|
+
if (!meetsEmissionCriteria(url, path, statusCode, method)) return null
|
|
180
|
+
|
|
181
|
+
const rawEndpoint = url || path || 'unknown'
|
|
182
|
+
return {
|
|
183
|
+
endpoint: normalizeEndpoint(rawEndpoint),
|
|
184
|
+
method: method || 'unknown',
|
|
185
|
+
statusCode,
|
|
186
|
+
networkError,
|
|
187
|
+
status: classifyStatus(statusCode, networkError),
|
|
188
|
+
durationMs: 0
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Android ─────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async function getAndroidEvents(sinceMs: number, deviceId?: string): Promise<NetworkEvent[]> {
|
|
195
|
+
try {
|
|
196
|
+
const stdout = await execAdb(['logcat', '-d', '-v', 'threadtime', '*:V', '-t', '2000'], deviceId)
|
|
197
|
+
const lines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : []
|
|
198
|
+
|
|
199
|
+
const events: NetworkEvent[] = []
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const parsed = parseLogLine(line)
|
|
202
|
+
if (parsed._iso) {
|
|
203
|
+
const ts = new Date(parsed._iso).getTime()
|
|
204
|
+
if (ts > 0 && ts <= sinceMs) continue
|
|
205
|
+
}
|
|
206
|
+
const event = parseMessageToEvent(parsed.message || line)
|
|
207
|
+
if (event) events.push(event)
|
|
208
|
+
}
|
|
209
|
+
return events
|
|
210
|
+
} catch {
|
|
211
|
+
return []
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── iOS ─────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function getIOSEvents(sinceMs: number, deviceId = 'booted'): Promise<NetworkEvent[]> {
|
|
218
|
+
try {
|
|
219
|
+
const lookbackSeconds = Math.max(15, Math.ceil((Date.now() - sinceMs) / 1000) + 5)
|
|
220
|
+
const args = [
|
|
221
|
+
'simctl', 'spawn', deviceId, 'log', 'show',
|
|
222
|
+
'--last', `${lookbackSeconds}s`,
|
|
223
|
+
'--style', 'syslog',
|
|
224
|
+
'--predicate', 'eventMessage contains "http" OR eventMessage contains "URLSession" OR eventMessage contains "Task <" OR eventMessage contains "HTTP/"'
|
|
225
|
+
]
|
|
226
|
+
const result = await execCommand(args, deviceId)
|
|
227
|
+
const lines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
|
|
228
|
+
|
|
229
|
+
const events: NetworkEvent[] = []
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/)
|
|
232
|
+
if (tsMatch) {
|
|
233
|
+
const ts = new Date(tsMatch[1]).getTime()
|
|
234
|
+
if (ts > 0 && ts <= sinceMs) continue
|
|
235
|
+
}
|
|
236
|
+
const event = parseMessageToEvent(line)
|
|
237
|
+
if (event) events.push(event)
|
|
238
|
+
}
|
|
239
|
+
return events
|
|
240
|
+
} catch {
|
|
241
|
+
return []
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export class ToolsNetwork {
|
|
248
|
+
static notifyActionStart(): void {
|
|
249
|
+
notifyActionStart()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
static async getNetworkActivity(params: { platform: string; deviceId?: string }): Promise<GetNetworkActivityResult> {
|
|
253
|
+
const { platform, deviceId } = params
|
|
254
|
+
|
|
255
|
+
const sinceMs = lastConsumedTimestamp > lastActionTimestamp
|
|
256
|
+
? lastConsumedTimestamp
|
|
257
|
+
: lastActionTimestamp > 0 ? lastActionTimestamp : Date.now() - 30000
|
|
258
|
+
|
|
259
|
+
const raw = platform === 'android'
|
|
260
|
+
? await getAndroidEvents(sinceMs, deviceId)
|
|
261
|
+
: await getIOSEvents(sinceMs, deviceId)
|
|
262
|
+
|
|
263
|
+
const requests = filterToSignificantEvents(raw)
|
|
264
|
+
lastConsumedTimestamp = Date.now()
|
|
265
|
+
|
|
266
|
+
return { requests, count: requests.length }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionExecutionResult,
|
|
3
|
+
ActionFailureCode,
|
|
4
|
+
ActionTargetResolved
|
|
5
|
+
} from '../types.js'
|
|
6
|
+
import { ToolsObserve } from '../observe/index.js'
|
|
7
|
+
|
|
8
|
+
export function wrapResponse<T>(data: T) {
|
|
9
|
+
return {
|
|
10
|
+
content: [{
|
|
11
|
+
type: 'text' as const,
|
|
12
|
+
text: JSON.stringify(data, null, 2)
|
|
13
|
+
}]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ToolCallArgs = Record<string, unknown>
|
|
18
|
+
export type ToolCallResult = Awaited<ReturnType<typeof wrapResponse>> | {
|
|
19
|
+
content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }>
|
|
20
|
+
}
|
|
21
|
+
export type ToolHandler = (args: ToolCallArgs) => Promise<ToolCallResult>
|
|
22
|
+
|
|
23
|
+
let actionSequence = 0
|
|
24
|
+
|
|
25
|
+
export function nextActionId(actionType: string, timestamp: number) {
|
|
26
|
+
actionSequence += 1
|
|
27
|
+
return `${actionType}_${timestamp}_${actionSequence}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function captureActionFingerprint(platform?: 'android' | 'ios', deviceId?: string): Promise<string | null> {
|
|
31
|
+
if (!platform) return null
|
|
32
|
+
try {
|
|
33
|
+
const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
|
|
34
|
+
return result?.fingerprint ?? null
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeResolvedTarget(value: Partial<ActionTargetResolved> | null = null): ActionTargetResolved | null {
|
|
41
|
+
if (!value) return null
|
|
42
|
+
return {
|
|
43
|
+
elementId: value.elementId ?? null,
|
|
44
|
+
text: value.text ?? null,
|
|
45
|
+
resource_id: value.resource_id ?? null,
|
|
46
|
+
accessibility_id: value.accessibility_id ?? null,
|
|
47
|
+
class: value.class ?? null,
|
|
48
|
+
bounds: value.bounds ?? null,
|
|
49
|
+
index: value.index ?? null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function inferGenericFailure(message: string | undefined): { failureCode: ActionFailureCode; retryable: boolean } {
|
|
54
|
+
if (message && /timeout/i.test(message)) return { failureCode: 'TIMEOUT', retryable: true }
|
|
55
|
+
return { failureCode: 'UNKNOWN', retryable: false }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function inferScrollFailure(message: string | undefined): { failureCode: ActionFailureCode; retryable: boolean } {
|
|
59
|
+
if (message && /unchanged|no change|end of list/i.test(message)) return { failureCode: 'NAVIGATION_NO_CHANGE', retryable: true }
|
|
60
|
+
if (message && /timeout/i.test(message)) return { failureCode: 'TIMEOUT', retryable: true }
|
|
61
|
+
return { failureCode: 'UNKNOWN', retryable: false }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildActionExecutionResult({
|
|
65
|
+
actionType,
|
|
66
|
+
selector,
|
|
67
|
+
resolved,
|
|
68
|
+
success,
|
|
69
|
+
uiFingerprintBefore,
|
|
70
|
+
uiFingerprintAfter,
|
|
71
|
+
failure
|
|
72
|
+
}: {
|
|
73
|
+
actionType: string
|
|
74
|
+
selector: Record<string, unknown> | null
|
|
75
|
+
resolved?: Partial<ActionTargetResolved> | null
|
|
76
|
+
success: boolean
|
|
77
|
+
uiFingerprintBefore: string | null
|
|
78
|
+
uiFingerprintAfter: string | null
|
|
79
|
+
failure?: { failureCode: ActionFailureCode; retryable: boolean }
|
|
80
|
+
}): ActionExecutionResult {
|
|
81
|
+
const timestamp = Date.now()
|
|
82
|
+
return {
|
|
83
|
+
action_id: nextActionId(actionType, timestamp),
|
|
84
|
+
timestamp,
|
|
85
|
+
action_type: actionType,
|
|
86
|
+
target: {
|
|
87
|
+
selector,
|
|
88
|
+
resolved: normalizeResolvedTarget(resolved)
|
|
89
|
+
},
|
|
90
|
+
success,
|
|
91
|
+
...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
|
|
92
|
+
ui_fingerprint_before: uiFingerprintBefore,
|
|
93
|
+
ui_fingerprint_after: uiFingerprintAfter
|
|
94
|
+
}
|
|
95
|
+
}
|