mobile-debug-mcp 0.21.2 → 0.21.3

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.
@@ -219,6 +219,23 @@ export class ToolsInteract {
219
219
  // Backwards-compatible wrapper that delegates to the core waitForUICore implementation
220
220
  return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
221
221
  }
222
+ // Helper: normalize various log objects into plain message strings for comparison
223
+ static _logsToMessages(logsArr) {
224
+ if (!Array.isArray(logsArr))
225
+ return [];
226
+ return logsArr.map((l) => {
227
+ if (typeof l === 'string')
228
+ return l;
229
+ if (l && (l.message || l.msg))
230
+ return l.message || l.msg;
231
+ try {
232
+ return JSON.stringify(l);
233
+ }
234
+ catch {
235
+ return String(l);
236
+ }
237
+ });
238
+ }
222
239
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
223
240
  const start = Date.now();
224
241
  let lastFingerprint = null;
@@ -275,7 +292,9 @@ export class ToolsInteract {
275
292
  initialFingerprint = fpRes.fingerprint ?? null;
276
293
  if (gl) {
277
294
  const logsArr = Array.isArray(gl.logs) ? gl.logs : [];
278
- baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null;
295
+ // Normalize to last message string for baseline comparison
296
+ const msgs = ToolsInteract._logsToMessages(logsArr);
297
+ baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null;
279
298
  }
280
299
  }
281
300
  catch (err) {
@@ -354,13 +373,15 @@ export class ToolsInteract {
354
373
  }
355
374
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 });
356
375
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : [];
376
+ // Normalize to messages for comparison
377
+ const msgs = ToolsInteract._logsToMessages(logsArr);
357
378
  let startIndex = 0;
358
379
  if (baselineLastLine) {
359
- const idx = logsArr.lastIndexOf(baselineLastLine);
380
+ const idx = msgs.lastIndexOf(baselineLastLine);
360
381
  startIndex = idx >= 0 ? idx + 1 : 0;
361
382
  }
362
- for (let i = startIndex; i < logsArr.length; i++) {
363
- const line = logsArr[i];
383
+ for (let i = startIndex; i < msgs.length; i++) {
384
+ const line = msgs[i];
364
385
  if (q && String(line).includes(q)) {
365
386
  const now2 = Date.now();
366
387
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true };
@@ -80,23 +80,98 @@ export class AndroidObserve {
80
80
  };
81
81
  }
82
82
  }
83
- async getLogs(appId, lines = 200, deviceId) {
83
+ async getLogs(filters = {}) {
84
+ const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
84
85
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
85
86
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
86
87
  try {
87
- const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId);
88
- const allLogs = stdout.split('\n');
89
- let filteredLogs = allLogs;
88
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
89
+ // If appId provided, try to get pid to filter at source
90
+ let pidArg = null;
90
91
  if (appId) {
91
- const matchingLogs = allLogs.filter(line => line.includes(appId));
92
- if (matchingLogs.length > 0) {
93
- filteredLogs = matchingLogs;
92
+ try {
93
+ const pidOut = await execAdb(['shell', 'pidof', appId], deviceId).catch(() => '');
94
+ const pidTrim = (pidOut || '').trim();
95
+ if (pidTrim)
96
+ pidArg = pidTrim.split('\n')[0];
94
97
  }
95
- else {
96
- filteredLogs = allLogs;
98
+ catch (err) {
99
+ // Log a warning so failures to detect PID are visible during debugging
100
+ try {
101
+ console.warn(`getLogs: pid detection failed for appId=${appId}:`, err instanceof Error ? err.message : String(err));
102
+ }
103
+ catch { }
97
104
  }
98
105
  }
99
- return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length };
106
+ const args = ['logcat', '-d', '-v', 'threadtime'];
107
+ // Apply pid filter via --pid if available
108
+ if (pidArg)
109
+ args.push(`--pid=${pidArg}`);
110
+ else if (pid)
111
+ args.push(`--pid=${pid}`);
112
+ // Apply tag/level filter if provided
113
+ if (tag && level) {
114
+ // Map verbose/debug/info/warn/error to single-letter levels
115
+ const levelMap = { 'VERBOSE': 'V', 'DEBUG': 'D', 'INFO': 'I', 'WARN': 'W', 'ERROR': 'E' };
116
+ const L = (levelMap[(level || '').toUpperCase()] || 'V');
117
+ args.push(`${tag}:${L}`);
118
+ }
119
+ else {
120
+ // Default: show all levels
121
+ args.push('*:V');
122
+ }
123
+ // Use -t to limit lines (apply early)
124
+ args.push('-t', effectiveLimit.toString());
125
+ const stdout = await execAdb(args, deviceId);
126
+ const allLines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : [];
127
+ // Parse lines into structured entries
128
+ const parsed = allLines.map(l => {
129
+ const entry = parseLogLine(l);
130
+ // normalize level
131
+ const levelChar = (entry.level || '').toUpperCase();
132
+ const levelMapChar = { 'V': 'VERBOSE', 'D': 'DEBUG', 'I': 'INFO', 'W': 'WARN', 'E': 'ERROR' };
133
+ const normLevel = levelMapChar[levelChar] || (entry.level && String(entry.level).toUpperCase()) || 'INFO';
134
+ const iso = entry._iso || (() => {
135
+ const d = new Date(entry.timestamp || '');
136
+ if (!isNaN(d.getTime()))
137
+ return d.toISOString();
138
+ return null;
139
+ })();
140
+ let pidNum = null;
141
+ if (entry.pid)
142
+ pidNum = Number(entry.pid);
143
+ else if (pidArg)
144
+ pidNum = Number(pidArg);
145
+ const pidVal = (pidNum !== null && !isNaN(pidNum)) ? pidNum : null;
146
+ return { timestamp: iso, level: normLevel, tag: entry.tag || '', pid: pidVal, message: entry.message || '' };
147
+ });
148
+ // Apply client-side filters: contains, since_seconds
149
+ let filtered = parsed;
150
+ if (contains)
151
+ filtered = filtered.filter(e => e.message && e.message.includes(contains));
152
+ if (since_seconds) {
153
+ const sinceMs = Date.now() - (since_seconds * 1000);
154
+ filtered = filtered.filter(e => {
155
+ if (!e.timestamp)
156
+ return false;
157
+ const t = new Date(e.timestamp).getTime();
158
+ return t >= sinceMs;
159
+ });
160
+ }
161
+ // If appId provided and no pidArg, try to filter by appId substring in message/tag
162
+ if (appId && !pidArg) {
163
+ const matched = filtered.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
164
+ if (matched.length > 0)
165
+ filtered = matched;
166
+ }
167
+ // Ensure ordering oldest -> newest (by timestamp when available)
168
+ filtered.sort((a, b) => {
169
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
170
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
171
+ return ta - tb;
172
+ });
173
+ const limited = filtered.slice(-Math.max(0, effectiveLimit));
174
+ return { device: deviceInfo, logs: limited, logCount: limited.length };
100
175
  }
101
176
  catch (e) {
102
177
  console.error("Error fetching logs:", e);
@@ -33,19 +33,30 @@ export class ToolsObserve {
33
33
  // getCurrentScreen is Android-specific
34
34
  return await observe.getCurrentScreen(resolved.id);
35
35
  }
36
- static async getLogsHandler({ platform, appId, deviceId, lines }) {
36
+ static async getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines }) {
37
37
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
38
+ const filters = { appId, deviceId: resolved.id, pid, tag, level, contains, since_seconds, limit: limit ?? lines };
39
+ // Validate filters
40
+ if (level && !['VERBOSE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].includes(level.toString().toUpperCase())) {
41
+ return { device: resolved, logs: [], crashLines: [], logCount: 0, error: { code: 'INVALID_FILTER', message: `Unsupported level filter: ${level}` } };
42
+ }
38
43
  if (observe instanceof AndroidObserve) {
39
- const response = await observe.getLogs(appId, lines ?? 200, resolved.id);
44
+ const response = await observe.getLogs(filters);
40
45
  const logs = Array.isArray(response.logs) ? response.logs : [];
41
- const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'));
42
- return { device: response.device, logs, crashLines };
46
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
47
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
48
+ if (anyFilterApplied && logs.length === 0)
49
+ return { device: response.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
50
+ return { device: response.device, logs, crashLines, logCount: response.logCount };
43
51
  }
44
52
  else {
45
- const resp = await observe.getLogs(appId, resolved.id);
53
+ const resp = await observe.getLogs(filters);
46
54
  const logs = Array.isArray(resp.logs) ? resp.logs : [];
47
- const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
48
- return { device: resp.device, logs, crashLines };
55
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
56
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
57
+ if (anyFilterApplied && logs.length === 0)
58
+ return { device: resp.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
59
+ return { device: resp.device, logs, crashLines, logCount: resp.logCount };
49
60
  }
50
61
  }
51
62
  static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
@@ -154,9 +165,20 @@ export class ToolsObserve {
154
165
  if (!entries || entries.length === 0) {
155
166
  const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines });
156
167
  const raw = (gl && gl.logs) ? gl.logs : [];
157
- entries = raw.slice(-Math.max(0, logLines)).map(line => {
158
- const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO';
159
- return { timestamp: null, level, message: line };
168
+ // raw may be structured entries or strings
169
+ entries = raw.slice(-Math.max(0, logLines)).map(item => {
170
+ if (!item)
171
+ return { timestamp: null, level: 'INFO', message: '' };
172
+ if (typeof item === 'string') {
173
+ const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(item) ? 'ERROR' : /\b(WARN| W )\b/i.test(item) ? 'WARN' : 'INFO';
174
+ return { timestamp: null, level, message: item };
175
+ }
176
+ const msg = item.message || item.msg || JSON.stringify(item);
177
+ const levelRaw = item.level || item.levelName || item._level || '';
178
+ const level = (levelRaw && String(levelRaw)).toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO');
179
+ const ts = item.timestamp || item._iso || null;
180
+ const tsNum = (ts && typeof ts === 'string') ? (isNaN(new Date(ts).getTime()) ? null : new Date(ts).getTime()) : (typeof ts === 'number' ? ts : null);
181
+ return { timestamp: tsNum, level, message: msg };
160
182
  });
161
183
  }
162
184
  else {
@@ -88,20 +88,128 @@ export class iOSObserve {
88
88
  async getDeviceMetadata(deviceId = "booted") {
89
89
  return getIOSDeviceMetadata(deviceId);
90
90
  }
91
- async getLogs(appId, deviceId = "booted") {
92
- const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m'];
91
+ async getLogs(filters = {}) {
92
+ const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters;
93
+ const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog'];
94
+ // Default to last N seconds if no since_seconds provided; limit lines handled after parsing
95
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
96
+ if (since_seconds) {
97
+ // log show accepts --last <time>
98
+ args.push('--last', `${since_seconds}s`);
99
+ }
100
+ else {
101
+ // default to last 60s to keep quick
102
+ args.push('--last', '60s');
103
+ }
93
104
  if (appId) {
94
105
  validateBundleId(appId);
106
+ // constrain to subsystem or process matching appId
95
107
  args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
96
108
  }
97
- const result = await execCommand(args, deviceId);
98
- const device = await getIOSDeviceMetadata(deviceId);
99
- const logs = result.output ? result.output.split('\n') : [];
100
- return {
101
- device,
102
- logs,
103
- logCount: logs.length,
104
- };
109
+ else if (tag) {
110
+ // predicate by subsystem/category
111
+ args.push('--predicate', `subsystem contains "${tag}"`);
112
+ }
113
+ try {
114
+ const result = await execCommand(args, deviceId);
115
+ const device = await getIOSDeviceMetadata(deviceId);
116
+ const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
117
+ // Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
118
+ const parsed = rawLines.map(line => {
119
+ // Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
120
+ // Simpler approach: try to extract ISO timestamp at start
121
+ let ts = null;
122
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/);
123
+ if (tsMatch) {
124
+ const d = new Date(tsMatch[1]);
125
+ if (!isNaN(d.getTime()))
126
+ ts = d.toISOString();
127
+ }
128
+ // level mapping
129
+ let lvl = 'INFO';
130
+ const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i);
131
+ if (lvlMatch) {
132
+ const map = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' };
133
+ lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO';
134
+ }
135
+ // subsystem/category -> tag
136
+ let tagVal = '';
137
+ const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/);
138
+ if (tagMatch)
139
+ tagVal = tagMatch[1];
140
+ // pid extraction
141
+ let pidNum = null;
142
+ const pidMatch = line.match(/\[(\d+):\d+\]/);
143
+ if (pidMatch)
144
+ pidNum = Number(pidMatch[1]);
145
+ // message: extract robustly after the subsystem/category token when available
146
+ let message = line;
147
+ if (tagMatch) {
148
+ // tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
149
+ const marker = tagMatch[0];
150
+ const idx = line.indexOf(marker);
151
+ if (idx !== -1) {
152
+ message = line.slice(idx + marker.length).trim();
153
+ }
154
+ else {
155
+ // fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
156
+ const afterPidMatch = line.match(/\]\s+/);
157
+ if (afterPidMatch) {
158
+ const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length;
159
+ message = line.slice(afterPidIdx).trim();
160
+ }
161
+ else {
162
+ // remove leading level tokens like <Debug> and keep remainder
163
+ message = line.replace(/^.*?<.*?>\s*/, '').trim();
164
+ }
165
+ }
166
+ }
167
+ else {
168
+ // No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
169
+ message = line.replace(/^.*?<.*?>\s*/, '').trim();
170
+ }
171
+ return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message };
172
+ });
173
+ // Apply contains filter
174
+ let filtered = parsed;
175
+ if (contains)
176
+ filtered = filtered.filter(e => e.message && e.message.includes(contains));
177
+ // Apply since_seconds already applied by log show, but double-check timestamps
178
+ if (since_seconds) {
179
+ const sinceMs = Date.now() - (since_seconds * 1000);
180
+ filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs));
181
+ }
182
+ // level filter
183
+ if (level) {
184
+ const L = level.toUpperCase();
185
+ filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L);
186
+ }
187
+ // tag filter
188
+ if (tag)
189
+ filtered = filtered.filter(e => e.tag && e.tag.includes(tag));
190
+ // pid filter
191
+ if (pid)
192
+ filtered = filtered.filter(e => e.pid === pid);
193
+ // If appId present but no predicate returned lines, try substring match
194
+ if (appId && filtered.length === 0) {
195
+ const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
196
+ if (matched.length > 0)
197
+ filtered = matched;
198
+ }
199
+ // Order oldest -> newest
200
+ filtered.sort((a, b) => {
201
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
202
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
203
+ return ta - tb;
204
+ });
205
+ const limited = filtered.slice(-Math.max(0, effectiveLimit));
206
+ return { device, logs: limited, logCount: limited.length };
207
+ }
208
+ catch (err) {
209
+ console.error('iOS getLogs failed:', err);
210
+ const device = await getIOSDeviceMetadata(deviceId);
211
+ return { device, logs: [], logCount: 0 };
212
+ }
105
213
  }
106
214
  async captureScreenshot(deviceId = "booted") {
107
215
  const device = await getIOSDeviceMetadata(deviceId);
package/dist/server.js CHANGED
@@ -148,7 +148,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
148
148
  },
149
149
  {
150
150
  name: "get_logs",
151
- description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
151
+ description: "Get recent logs from Android or iOS simulator. Returns device metadata and structured logs suitable for AI consumption.",
152
152
  inputSchema: {
153
153
  type: "object",
154
154
  properties: {
@@ -164,9 +164,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
164
164
  type: "string",
165
165
  description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
166
166
  },
167
+ pid: { type: "number", description: "Filter by process id" },
168
+ tag: { type: "string", description: "Filter by tag (Android) or subsystem/category (iOS)" },
169
+ level: { type: "string", description: "Log level filter (VERBOSE, DEBUG, INFO, WARN, ERROR)" },
170
+ contains: { type: "string", description: "Substring to match in log message" },
171
+ since_seconds: { type: "number", description: "Only return logs from the last N seconds" },
172
+ limit: { type: "number", description: "Override default number of returned lines" },
167
173
  lines: {
168
174
  type: "number",
169
- description: "Number of log lines (android only)"
175
+ description: "Legacy - number of log lines (android only)"
170
176
  }
171
177
  },
172
178
  required: ["platform"]
@@ -563,12 +569,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
563
569
  };
564
570
  }
565
571
  if (name === "get_logs") {
566
- const { platform, appId, deviceId, lines } = args;
567
- const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines });
572
+ const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args;
573
+ const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines });
574
+ const filtered = !!(pid || tag || level || contains || since_seconds || appId);
568
575
  return {
569
576
  content: [
570
- { type: 'text', text: JSON.stringify({ device: res.device, result: { lines: res.logs.length, crashLines: res.crashLines.length > 0 ? res.crashLines : undefined } }, null, 2) },
571
- { type: 'text', text: (res.logs || []).join('\n') }
577
+ { type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
578
+ { type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
572
579
  ]
573
580
  };
574
581
  }
@@ -13,7 +13,7 @@ async function main() {
13
13
  const shot = await obs.captureScreenshot(deviceId);
14
14
  console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0);
15
15
  console.log('[3] getLogs');
16
- const logs = await obs.getLogs(appId, undefined);
16
+ const logs = await obs.getLogs({ appId, deviceId });
17
17
  console.log('logs count:', logs.logCount);
18
18
  console.log('[4] terminateApp');
19
19
  const term = await manage.terminateApp(appId, deviceId);
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.21.3]
6
+ - Added structured logs
7
+
5
8
  ## [0.21.2]
6
9
  - Fixed screenshots not working, imnproved tool
7
10
 
@@ -3,27 +3,32 @@
3
3
  Tools that retrieve device state, logs, screenshots and UI hierarchies.
4
4
 
5
5
  ## get_logs
6
- Fetch recent logs from the app or device.
7
6
 
8
- Input:
7
+ Fetch recent logs as structured entries optimized for AI agents.
8
+
9
+ Input (example):
9
10
 
10
11
  ```json
11
- { "platform": "android", "appId": "com.example.app", "deviceId": "emulator-5554", "lines": 200 }
12
+ { "platform": "android|ios", "appId": "com.example.app", "deviceId": "emulator-5554", "pid": 1234, "tag": "MyTag", "level": "ERROR", "contains": "timeout", "since_seconds": 60, "limit": 50 }
12
13
  ```
13
14
 
14
- Response (metadata):
15
+ Defaults:
16
+
17
+ - No filters → return the most recent 50 log entries (app-scoped if appId provided), across all levels.
18
+
19
+ Response (structured):
15
20
 
16
21
  ```json
17
- { "entries": 200, "crash_summary": { "crash_detected": false } }
22
+ { "device": { "platform": "android", "id": "emulator-5554" }, "logs": [ { "timestamp": "2026-03-30T16:00:00.000Z", "level": "ERROR", "tag": "MyTag", "pid": 1234, "message": "Something failed" } ], "count": 1, "filtered": true }
18
23
  ```
19
24
 
20
- Followed by a raw log plain text block.
21
-
22
25
  Notes:
23
- - Android log parsing includes basic crash detection (searching for "FATAL EXCEPTION" and exception names).
24
- - Use `lines` to control how many log lines are returned from `adb logcat`.
25
26
 
26
- ---
27
+ - Each log entry: timestamp (ISO), level (VERBOSE|DEBUG|INFO|WARN|ERROR), tag (string), pid (number|null), message (string).
28
+ - Logs ordered oldest → newest. count equals number of entries returned. filtered is true if any filter was applied.
29
+ - Supported filters: pid, tag, level, contains, since_seconds, limit.
30
+ - Platform behaviour: Android uses `adb logcat` with source-side filters where possible; iOS uses unified logging (`log show`/simctl) and maps subsystem/category → tag.
31
+ - Errors are returned as structured objects with `error.code` and `error.message`. Possible codes: LOGS_UNAVAILABLE, INVALID_FILTER, PLATFORM_NOT_SUPPORTED, INTERNAL_ERROR.
27
32
 
28
33
  ## capture_screenshot
29
34
  Capture screen. Returns JSON metadata then an image/png block with base64 PNG data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.21.2",
3
+ "version": "0.21.3",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -223,6 +223,16 @@ export class ToolsInteract {
223
223
  return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
224
224
  }
225
225
 
226
+ // Helper: normalize various log objects into plain message strings for comparison
227
+ private static _logsToMessages(logsArr: any[]): string[] {
228
+ if (!Array.isArray(logsArr)) return []
229
+ return logsArr.map((l: any) => {
230
+ if (typeof l === 'string') return l
231
+ if (l && (l.message || l.msg)) return l.message || l.msg
232
+ try { return JSON.stringify(l) } catch { return String(l) }
233
+ })
234
+ }
235
+
226
236
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
227
237
  const start = Date.now()
228
238
  let lastFingerprint: string | null = null
@@ -279,7 +289,9 @@ export class ToolsInteract {
279
289
  if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
280
290
  if (gl) {
281
291
  const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
282
- baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
292
+ // Normalize to last message string for baseline comparison
293
+ const msgs = ToolsInteract._logsToMessages(logsArr)
294
+ baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null
283
295
  }
284
296
  } catch (err) {
285
297
  try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
@@ -348,13 +360,15 @@ export class ToolsInteract {
348
360
 
349
361
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
350
362
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
363
+ // Normalize to messages for comparison
364
+ const msgs = ToolsInteract._logsToMessages(logsArr)
351
365
  let startIndex = 0
352
366
  if (baselineLastLine) {
353
- const idx = logsArr.lastIndexOf(baselineLastLine)
367
+ const idx = msgs.lastIndexOf(baselineLastLine)
354
368
  startIndex = idx >= 0 ? idx + 1 : 0
355
369
  }
356
- for (let i = startIndex; i < logsArr.length; i++) {
357
- const line = logsArr[i]
370
+ for (let i = startIndex; i < msgs.length; i++) {
371
+ const line = msgs[i]
358
372
  if (q && String(line).includes(q)) {
359
373
  const now2 = Date.now()
360
374
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
@@ -93,26 +93,97 @@ export class AndroidObserve {
93
93
  }
94
94
  }
95
95
 
96
- async getLogs(appId?: string, lines = 200, deviceId?: string): Promise<GetLogsResponse> {
96
+ async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
97
+ const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters
97
98
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId)
98
99
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
99
100
 
100
101
  try {
101
- const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId)
102
- const allLogs = stdout.split('\n')
103
-
104
- let filteredLogs = allLogs
102
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50
103
+
104
+ // If appId provided, try to get pid to filter at source
105
+ let pidArg: string | null = null
105
106
  if (appId) {
106
- const matchingLogs = allLogs.filter(line => line.includes(appId))
107
-
108
- if (matchingLogs.length > 0) {
109
- filteredLogs = matchingLogs
110
- } else {
111
- filteredLogs = allLogs
112
- }
107
+ try {
108
+ const pidOut = await execAdb(['shell', 'pidof', appId], deviceId).catch(() => '')
109
+ const pidTrim = (pidOut || '').trim()
110
+ if (pidTrim) pidArg = pidTrim.split('\n')[0]
111
+ } catch (err) {
112
+ // Log a warning so failures to detect PID are visible during debugging
113
+ try { console.warn(`getLogs: pid detection failed for appId=${appId}:`, err instanceof Error ? err.message : String(err)) } catch { }
114
+ }
113
115
  }
114
-
115
- return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length }
116
+
117
+ const args = ['logcat', '-d', '-v', 'threadtime']
118
+ // Apply pid filter via --pid if available
119
+ if (pidArg) args.push(`--pid=${pidArg}`)
120
+ else if (pid) args.push(`--pid=${pid}`)
121
+
122
+ // Apply tag/level filter if provided
123
+ if (tag && level) {
124
+ // Map verbose/debug/info/warn/error to single-letter levels
125
+ const levelMap: Record<string, string> = { 'VERBOSE': 'V', 'DEBUG': 'D', 'INFO': 'I', 'WARN': 'W', 'ERROR': 'E' }
126
+ const L = (levelMap[(level || '').toUpperCase()] || 'V')
127
+ args.push(`${tag}:${L}`)
128
+ } else {
129
+ // Default: show all levels
130
+ args.push('*:V')
131
+ }
132
+
133
+ // Use -t to limit lines (apply early)
134
+ args.push('-t', effectiveLimit.toString())
135
+
136
+ const stdout = await execAdb(args, deviceId)
137
+ const allLines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : []
138
+
139
+ // Parse lines into structured entries
140
+ const parsed = allLines.map(l => {
141
+ const entry = parseLogLine(l)
142
+ // normalize level
143
+ const levelChar = (entry.level || '').toUpperCase()
144
+ const levelMapChar: Record<string,string> = { 'V':'VERBOSE','D':'DEBUG','I':'INFO','W':'WARN','E':'ERROR' }
145
+ const normLevel = levelMapChar[levelChar] || (entry.level && String(entry.level).toUpperCase()) || 'INFO'
146
+ const iso = entry._iso || (() => {
147
+ const d = new Date(entry.timestamp || '')
148
+ if (!isNaN(d.getTime())) return d.toISOString()
149
+ return null
150
+ })()
151
+ let pidNum: number | null = null
152
+ if (entry.pid) pidNum = Number(entry.pid)
153
+ else if (pidArg) pidNum = Number(pidArg)
154
+ const pidVal = (pidNum !== null && !isNaN(pidNum)) ? pidNum : null
155
+ return { timestamp: iso, level: normLevel, tag: entry.tag || '', pid: pidVal, message: entry.message || '' }
156
+ })
157
+
158
+ // Apply client-side filters: contains, since_seconds
159
+ let filtered = parsed
160
+ if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
161
+
162
+ if (since_seconds) {
163
+ const sinceMs = Date.now() - (since_seconds * 1000)
164
+ filtered = filtered.filter(e => {
165
+ if (!e.timestamp) return false
166
+ const t = new Date(e.timestamp).getTime()
167
+ return t >= sinceMs
168
+ })
169
+ }
170
+
171
+ // If appId provided and no pidArg, try to filter by appId substring in message/tag
172
+ if (appId && !pidArg) {
173
+ const matched = filtered.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)))
174
+ if (matched.length > 0) filtered = matched
175
+ }
176
+
177
+ // Ensure ordering oldest -> newest (by timestamp when available)
178
+ filtered.sort((a,b) => {
179
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
180
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
181
+ return ta - tb
182
+ })
183
+
184
+ const limited = filtered.slice(-Math.max(0, effectiveLimit))
185
+
186
+ return { device: deviceInfo, logs: limited, logCount: limited.length }
116
187
  } catch (e) {
117
188
  console.error("Error fetching logs:", e)
118
189
  return { device: deviceInfo, logs: [], logCount: 0 }
@@ -38,18 +38,29 @@ export class ToolsObserve {
38
38
  return await (observe as AndroidObserve).getCurrentScreen(resolved.id)
39
39
  }
40
40
 
41
- static async getLogsHandler({ platform, appId, deviceId, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, lines?: number }) {
41
+ static async getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number, lines?: number }) {
42
42
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
43
+ const filters = { appId, deviceId: resolved.id, pid, tag, level, contains, since_seconds, limit: limit ?? lines }
44
+
45
+ // Validate filters
46
+ if (level && !['VERBOSE','DEBUG','INFO','WARN','ERROR'].includes(level.toString().toUpperCase())) {
47
+ return { device: resolved, logs: [], crashLines: [], logCount: 0, error: { code: 'INVALID_FILTER', message: `Unsupported level filter: ${level}` } } as any
48
+ }
49
+
43
50
  if (observe instanceof AndroidObserve) {
44
- const response = await observe.getLogs(appId, lines ?? 200, resolved.id)
51
+ const response = await observe.getLogs(filters)
45
52
  const logs = Array.isArray(response.logs) ? response.logs : []
46
- const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'))
47
- return { device: response.device, logs, crashLines }
53
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
54
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
55
+ if (anyFilterApplied && logs.length === 0) return { device: response.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
56
+ return { device: response.device, logs, crashLines, logCount: response.logCount }
48
57
  } else {
49
- const resp = await (observe as iOSObserve).getLogs(appId, resolved.id)
58
+ const resp = await (observe as iOSObserve).getLogs(filters)
50
59
  const logs = Array.isArray(resp.logs) ? resp.logs : []
51
- const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'))
52
- return { device: resp.device, logs, crashLines }
60
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
61
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
62
+ if (anyFilterApplied && logs.length === 0) return { device: resp.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
63
+ return { device: resp.device, logs, crashLines, logCount: resp.logCount }
53
64
  }
54
65
  }
55
66
 
@@ -96,7 +107,7 @@ export class ToolsObserve {
96
107
 
97
108
  // Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
98
109
  const sid = sessionId || 'default'
99
- const tasks = {
110
+ const tasks: Record<string, Promise<any>> = {
100
111
  screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
101
112
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
102
113
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
@@ -145,10 +156,20 @@ export class ToolsObserve {
145
156
  let entries: any[] = Array.isArray(out._streamEntries) ? out._streamEntries : []
146
157
  if (!entries || entries.length === 0) {
147
158
  const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines })
148
- const raw: string[] = (gl && (gl as any).logs) ? (gl as any).logs : []
149
- entries = raw.slice(-Math.max(0, logLines)).map(line => {
150
- const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO'
151
- return { timestamp: null, level, message: line }
159
+ const raw: any[] = (gl && (gl as any).logs) ? (gl as any).logs : []
160
+ // raw may be structured entries or strings
161
+ entries = raw.slice(-Math.max(0, logLines)).map(item => {
162
+ if (!item) return { timestamp: null, level: 'INFO', message: '' }
163
+ if (typeof item === 'string') {
164
+ const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(item) ? 'ERROR' : /\b(WARN| W )\b/i.test(item) ? 'WARN' : 'INFO'
165
+ return { timestamp: null, level, message: item }
166
+ }
167
+ const msg = item.message || item.msg || JSON.stringify(item)
168
+ const levelRaw = item.level || item.levelName || item._level || ''
169
+ const level = (levelRaw && String(levelRaw)).toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO')
170
+ const ts = item.timestamp || item._iso || null
171
+ const tsNum = (ts && typeof ts === 'string') ? (isNaN(new Date(ts).getTime()) ? null : new Date(ts).getTime()) : (typeof ts === 'number' ? ts : null)
172
+ return { timestamp: tsNum, level, message: msg }
152
173
  })
153
174
  } else {
154
175
  entries = entries.map(ent => {
@@ -121,20 +121,132 @@ export class iOSObserve {
121
121
  return getIOSDeviceMetadata(deviceId);
122
122
  }
123
123
 
124
- async getLogs(appId?: string, deviceId: string = "booted"): Promise<GetLogsResponse> {
125
- const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
124
+ async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
125
+ const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters
126
+ const args: string[] = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog']
127
+
128
+ // Default to last N seconds if no since_seconds provided; limit lines handled after parsing
129
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50
130
+
131
+ if (since_seconds) {
132
+ // log show accepts --last <time>
133
+ args.push('--last', `${since_seconds}s`)
134
+ } else {
135
+ // default to last 60s to keep quick
136
+ args.push('--last', '60s')
137
+ }
138
+
126
139
  if (appId) {
127
140
  validateBundleId(appId)
141
+ // constrain to subsystem or process matching appId
128
142
  args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`)
143
+ } else if (tag) {
144
+ // predicate by subsystem/category
145
+ args.push('--predicate', `subsystem contains "${tag}"`)
129
146
  }
130
-
131
- const result = await execCommand(args, deviceId)
132
- const device = await getIOSDeviceMetadata(deviceId)
133
- const logs = result.output ? result.output.split('\n') : []
134
- return {
135
- device,
136
- logs,
137
- logCount: logs.length,
147
+
148
+ try {
149
+ const result = await execCommand(args, deviceId)
150
+ const device = await getIOSDeviceMetadata(deviceId)
151
+ const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
152
+
153
+ // Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
154
+ const parsed = rawLines.map(line => {
155
+ // Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
156
+ // Simpler approach: try to extract ISO timestamp at start
157
+ let ts: string | null = null
158
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)
159
+ if (tsMatch) {
160
+ const d = new Date(tsMatch[1])
161
+ if (!isNaN(d.getTime())) ts = d.toISOString()
162
+ }
163
+
164
+ // level mapping
165
+ let lvl = 'INFO'
166
+ const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i)
167
+ if (lvlMatch) {
168
+ const map: Record<string, string> = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' }
169
+ lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO'
170
+ }
171
+
172
+ // subsystem/category -> tag
173
+ let tagVal = ''
174
+ const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/)
175
+ if (tagMatch) tagVal = tagMatch[1]
176
+
177
+ // pid extraction
178
+ let pidNum: number | null = null
179
+ const pidMatch = line.match(/\[(\d+):\d+\]/)
180
+ if (pidMatch) pidNum = Number(pidMatch[1])
181
+
182
+ // message: extract robustly after the subsystem/category token when available
183
+ let message = line
184
+ if (tagMatch) {
185
+ // tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
186
+ const marker = tagMatch[0]
187
+ const idx = line.indexOf(marker)
188
+ if (idx !== -1) {
189
+ message = line.slice(idx + marker.length).trim()
190
+ } else {
191
+ // fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
192
+ const afterPidMatch = line.match(/\]\s+/)
193
+ if (afterPidMatch) {
194
+ const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length
195
+ message = line.slice(afterPidIdx).trim()
196
+ } else {
197
+ // remove leading level tokens like <Debug> and keep remainder
198
+ message = line.replace(/^.*?<.*?>\s*/,'').trim()
199
+ }
200
+ }
201
+ } else {
202
+ // No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
203
+ message = line.replace(/^.*?<.*?>\s*/,'').trim()
204
+ }
205
+
206
+ return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message }
207
+ })
208
+
209
+ // Apply contains filter
210
+ let filtered = parsed
211
+ if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
212
+
213
+ // Apply since_seconds already applied by log show, but double-check timestamps
214
+ if (since_seconds) {
215
+ const sinceMs = Date.now() - (since_seconds * 1000)
216
+ filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
217
+ }
218
+
219
+ // level filter
220
+ if (level) {
221
+ const L = level.toUpperCase()
222
+ filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L)
223
+ }
224
+
225
+ // tag filter
226
+ if (tag) filtered = filtered.filter(e => e.tag && e.tag.includes(tag))
227
+
228
+ // pid filter
229
+ if (pid) filtered = filtered.filter(e => e.pid === pid)
230
+
231
+ // If appId present but no predicate returned lines, try substring match
232
+ if (appId && filtered.length === 0) {
233
+ const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)))
234
+ if (matched.length > 0) filtered = matched
235
+ }
236
+
237
+ // Order oldest -> newest
238
+ filtered.sort((a,b) => {
239
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
240
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
241
+ return ta - tb
242
+ })
243
+
244
+ const limited = filtered.slice(-Math.max(0, effectiveLimit))
245
+ return { device, logs: limited, logCount: limited.length }
246
+ } catch (err) {
247
+ console.error('iOS getLogs failed:', err)
248
+ const device = await getIOSDeviceMetadata(deviceId)
249
+ return { device, logs: [], logCount: 0 }
138
250
  }
139
251
  }
140
252
 
package/src/server.ts CHANGED
@@ -171,7 +171,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
171
171
 
172
172
  {
173
173
  name: "get_logs",
174
- description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
174
+ description: "Get recent logs from Android or iOS simulator. Returns device metadata and structured logs suitable for AI consumption.",
175
175
  inputSchema: {
176
176
  type: "object",
177
177
  properties: {
@@ -187,9 +187,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
187
187
  type: "string",
188
188
  description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
189
189
  },
190
+ pid: { type: "number", description: "Filter by process id" },
191
+ tag: { type: "string", description: "Filter by tag (Android) or subsystem/category (iOS)" },
192
+ level: { type: "string", description: "Log level filter (VERBOSE, DEBUG, INFO, WARN, ERROR)" },
193
+ contains: { type: "string", description: "Substring to match in log message" },
194
+ since_seconds: { type: "number", description: "Only return logs from the last N seconds" },
195
+ limit: { type: "number", description: "Override default number of returned lines" },
190
196
  lines: {
191
197
  type: "number",
192
- description: "Number of log lines (android only)"
198
+ description: "Legacy - number of log lines (android only)"
193
199
  }
194
200
  },
195
201
  required: ["platform"]
@@ -603,12 +609,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
603
609
 
604
610
 
605
611
  if (name === "get_logs") {
606
- const { platform, appId, deviceId, lines } = args as any
607
- const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines })
612
+ const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args as any
613
+ const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines })
614
+ const filtered = !!(pid || tag || level || contains || since_seconds || appId)
608
615
  return {
609
616
  content: [
610
- { type: 'text', text: JSON.stringify({ device: res.device, result: { lines: res.logs.length, crashLines: res.crashLines.length > 0 ? res.crashLines : undefined } }, null, 2) },
611
- { type: 'text', text: (res.logs || []).join('\n') }
617
+ { type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
618
+ { type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
612
619
  ]
613
620
  }
614
621
  }
package/src/types.ts CHANGED
@@ -36,9 +36,17 @@ export interface ResetAppDataResponse {
36
36
  diagnostics?: any;
37
37
  }
38
38
 
39
+ export interface StructuredLogEntry {
40
+ timestamp: string | null; // ISO string
41
+ level: string; // VERBOSE, DEBUG, INFO, WARN, ERROR
42
+ tag: string;
43
+ pid: number | null;
44
+ message: string;
45
+ }
46
+
39
47
  export interface GetLogsResponse {
40
48
  device: DeviceInfo;
41
- logs: string[];
49
+ logs: StructuredLogEntry[];
42
50
  logCount: number;
43
51
  }
44
52
 
@@ -17,7 +17,7 @@ async function main() {
17
17
  console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0)
18
18
 
19
19
  console.log('[3] getLogs')
20
- const logs = await obs.getLogs(appId, undefined);
20
+ const logs = await obs.getLogs({ appId, deviceId });
21
21
  console.log('logs count:', logs.logCount)
22
22
 
23
23
  console.log('[4] terminateApp')
File without changes
@@ -0,0 +1,20 @@
1
+ import { ToolsObserve } from '../../src/observe/index.js'
2
+ import minimist from 'minimist'
3
+
4
+ async function main() {
5
+ const args = minimist(process.argv.slice(2))
6
+ const platform = args.platform || args.p || 'android'
7
+ const id = args.id || args.device || args.deviceId || 'default'
8
+ const limit = typeof args.limit === 'number' ? args.limit : (typeof args.lines === 'number' ? args.lines : 50)
9
+
10
+ try {
11
+ const res = await ToolsObserve.getLogsHandler({ platform, id, limit })
12
+ console.log(JSON.stringify(res))
13
+ process.exit(0)
14
+ } catch (err: any) {
15
+ console.error(JSON.stringify({ error: { message: err.message || String(err) } }))
16
+ process.exit(2)
17
+ }
18
+ }
19
+
20
+ main()
@@ -0,0 +1,35 @@
1
+ import fs from 'fs'
2
+ import { execSync } from 'child_process'
3
+
4
+ function log(msg: string) { console.log(msg) }
5
+
6
+ if (process.env.SKIP_DEVICE_TESTS === '1') {
7
+ log('SKIP_DEVICE_TESTS=1 detected - skipping android device smoke test')
8
+ process.exit(0)
9
+ }
10
+
11
+ // Ensure helper script exists
12
+ const helperScript = 'test/helpers/run-get-logs.ts'
13
+ if (!fs.existsSync(helperScript)) {
14
+ console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
15
+ process.exit(1)
16
+ }
17
+
18
+ try {
19
+ // Run the helper smoke script for android
20
+ const cmd = `tsx ${helperScript} --platform android --id default --limit 20`
21
+ const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
22
+ const parsed = JSON.parse(out)
23
+
24
+ if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
25
+ const count = parsed.count ?? parsed.logs.length
26
+ if (count !== parsed.logs.length) throw new Error('count mismatch')
27
+ if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
28
+
29
+ log('Android device smoke test: PASS')
30
+ process.exit(0)
31
+ } catch (err: any) {
32
+ console.error('Android device smoke test: FAIL')
33
+ console.error(err && err.message ? err.message : err)
34
+ process.exit(2)
35
+ }
@@ -0,0 +1,33 @@
1
+ import fs from 'fs'
2
+ import { execSync } from 'child_process'
3
+
4
+ function log(msg: string) { console.log(msg) }
5
+
6
+ if (process.env.SKIP_DEVICE_TESTS === '1') {
7
+ log('SKIP_DEVICE_TESTS=1 detected - skipping ios device smoke test')
8
+ process.exit(0)
9
+ }
10
+
11
+ const helperScript = 'test/helpers/run-get-logs.ts'
12
+ if (!fs.existsSync(helperScript)) {
13
+ console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
14
+ process.exit(1)
15
+ }
16
+
17
+ try {
18
+ const cmd = `tsx ${helperScript} --platform ios --id booted --limit 20`
19
+ const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
20
+ const parsed = JSON.parse(out)
21
+
22
+ if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
23
+ const count = parsed.count ?? parsed.logs.length
24
+ if (count !== parsed.logs.length) throw new Error('count mismatch')
25
+ if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
26
+
27
+ log('iOS device smoke test: PASS')
28
+ process.exit(0)
29
+ } catch (err: any) {
30
+ console.error('iOS device smoke test: FAIL')
31
+ console.error(err && err.message ? err.message : err)
32
+ process.exit(2)
33
+ }
@@ -0,0 +1,58 @@
1
+ import { StructuredLogEntry } from '../../../src/types.js'
2
+
3
+ function assert(cond: boolean, msg?: string) { if (!cond) throw new Error(msg || 'Assertion failed') }
4
+
5
+ function applyFilters(entries: StructuredLogEntry[], opts: { contains?: string, level?: string, tag?: string, pid?: number, since_seconds?: number, limit?: number }) {
6
+ let filtered = entries.slice()
7
+ if (opts.contains) filtered = filtered.filter(e => e.message && e.message.includes(opts.contains!))
8
+ if (opts.since_seconds) {
9
+ const sinceMs = Date.now() - (opts.since_seconds * 1000)
10
+ filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
11
+ }
12
+ if (opts.level) filtered = filtered.filter(e => e.level && e.level.toUpperCase() === opts.level!.toUpperCase())
13
+ if (opts.tag) filtered = filtered.filter(e => e.tag && e.tag.includes(opts.tag!))
14
+ if (typeof opts.pid === 'number') filtered = filtered.filter(e => e.pid === opts.pid)
15
+ // oldest -> newest
16
+ filtered.sort((a,b) => (a.timestamp? new Date(a.timestamp).getTime():0) - (b.timestamp? new Date(b.timestamp).getTime():0))
17
+ const lim = typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : 50
18
+ return filtered.slice(-Math.max(0, lim))
19
+ }
20
+
21
+ function run() {
22
+ const now = Date.now()
23
+ const entries: StructuredLogEntry[] = [
24
+ { timestamp: new Date(now - 60000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'startup complete' },
25
+ { timestamp: new Date(now - 45000).toISOString(), level: 'WARN', tag: 'B', pid: 124, message: 'slow response' },
26
+ { timestamp: new Date(now - 30000).toISOString(), level: 'ERROR', tag: 'A', pid: 123, message: 'Unhandled exception' },
27
+ { timestamp: new Date(now - 15000).toISOString(), level: 'DEBUG', tag: 'C', pid: 125, message: 'debug info' },
28
+ { timestamp: new Date(now - 5000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'user action happened' }
29
+ ]
30
+
31
+ // contains filter
32
+ const c1 = applyFilters(entries, { contains: 'user' })
33
+ assert(c1.length === 1 && c1[0].message.includes('user'), 'contains filter failed')
34
+
35
+ // level filter
36
+ const e1 = applyFilters(entries, { level: 'ERROR' })
37
+ assert(e1.length === 1 && e1[0].level === 'ERROR', 'level filter failed')
38
+
39
+ // tag filter
40
+ const t1 = applyFilters(entries, { tag: 'A' })
41
+ assert(t1.length === 3, 'tag filter failed')
42
+
43
+ // pid filter
44
+ const p1 = applyFilters(entries, { pid: 123 })
45
+ assert(p1.length === 3, 'pid filter failed')
46
+
47
+ // since_seconds filter (last 20s) should include last two entries
48
+ const s1 = applyFilters(entries, { since_seconds: 20 })
49
+ if (s1.length !== 2) throw new Error('since_seconds filter expected 2 entries, got ' + s1.length)
50
+
51
+ // limit
52
+ const l1 = applyFilters(entries, { limit: 2 })
53
+ assert(l1.length === 2 && l1[0].timestamp <= l1[1].timestamp, 'limit or ordering failed')
54
+
55
+ console.log('get_logs unit tests passed')
56
+ }
57
+
58
+ run()