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.
@@ -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
+ }