@slack/radar-mcp 1.6.0 → 1.8.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/mcp/tools.js CHANGED
@@ -20,9 +20,10 @@ export const SERVER_INSTRUCTIONS = [
20
20
  '- "why isn\'t this loading" / "this is broken" -> screenshot + get_recent_network_calls(status_code=400 or 500)',
21
21
  '- "watch what happens when I..." -> clear_buffers then wait_for_events (DO NOT filter unless the user specifies a type)',
22
22
  '- "did that clog fire" / "check analytics" -> get_recent_clogs(name="...")',
23
+ '- "why is this slow" / "how long did X take" / "show me the traces" / "check performance" -> get_recent_traces(name="...") then get_trace_detail',
23
24
  '- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
24
25
  '- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
25
- '- "check the debug logs" -> get_recent_logs(tag="...")',
26
+ '- "check the debug logs" -> get_recent_logs (tag is OPTIONAL: pass tag="FKDebug" to filter, or omit it for all recent lines)',
26
27
  '- "save this" / "file this as a bug" / "share this" / "bundle this" / "attach to ticket" / "give me a repro" / "snapshot this" / "preserve this" / "hold this state" / "save what you just saw" -> capture_snapshot',
27
28
  "",
28
29
  "Key behaviors:",
@@ -101,10 +102,10 @@ export const SERVER_INSTRUCTIONS = [
101
102
  "When capture_snapshot returns, tell the user:",
102
103
  " 1. The result.zip_path if non-null (the single shareable file). Fall back to",
103
104
  " result.path (a directory) if zip_path is null.",
104
- " 2. A one-line summary of counts (network/rtm/clogs/log_lines/screenshot).",
105
+ " 2. A one-line summary of counts (network/rtm/clogs/traces/log_lines/screenshot).",
105
106
  " 3. Print this EXACT sentence as the privacy line, verbatim — no paraphrase, no",
106
107
  " compression, no skipping even under time pressure:",
107
- ' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally."',
108
+ ' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally."',
108
109
  " 4. If result.log_lines_truncated is true, say: 'you got fewer log lines than",
109
110
  " requested because the 1 MB read cap was the limiting factor.'",
110
111
  " 5. If the user's original ask was 'file this as a bug' / 'attach to ticket' /",
@@ -203,6 +204,30 @@ export const TOOL_DEFINITIONS = [
203
204
  },
204
205
  annotations: { readOnlyHint: true },
205
206
  },
207
+ {
208
+ name: "get_recent_traces",
209
+ description: "Get recent performance trace summaries (one row per completed span from the app's telemetry pipeline). Returns trace name, span name, and duration. Use get_trace_detail for the full span, including its tags and the parent/trace ids.",
210
+ inputSchema: {
211
+ type: "object",
212
+ properties: {
213
+ limit: { type: "number", description: "Max number of traces to return (default: 50)" },
214
+ name: { type: "string", description: "Filter by trace name or span name substring" },
215
+ },
216
+ },
217
+ annotations: { readOnlyHint: true },
218
+ },
219
+ {
220
+ name: "get_trace_detail",
221
+ description: "Get full details of a specific performance trace by ID: trace and span names, the trace and parent ids, start/end/duration, and the span tags.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ id: { type: "number", description: "The trace ID from get_recent_traces" },
226
+ },
227
+ required: ["id"],
228
+ },
229
+ annotations: { readOnlyHint: true },
230
+ },
206
231
  {
207
232
  name: "wait_for_events",
208
233
  description: "Wait for matching events to arrive in real-time via the SSE stream.",
@@ -211,7 +236,7 @@ export const TOOL_DEFINITIONS = [
211
236
  properties: {
212
237
  event_type: { type: "string", description: "RTM event type to wait for" },
213
238
  channel: { type: "string", description: "Filter by channel ID" },
214
- source: { type: "string", description: "Wait for network calls or RTM events (default: both)" },
239
+ source: { type: "string", description: "Limit to one stream: network, rtm, clog, log, or trace (default: all)" },
215
240
  timeout_ms: { type: "number", description: "Max time to wait in ms (default: 30000)" },
216
241
  idle_timeout_ms: { type: "number", description: "Resolve after this many ms of silence (default: 3000)" },
217
242
  },
@@ -220,7 +245,7 @@ export const TOOL_DEFINITIONS = [
220
245
  },
221
246
  {
222
247
  name: "search",
223
- description: "Search across all captured network calls and RTM events.",
248
+ description: "Search across all captured network calls, RTM events, clogs, and performance traces.",
224
249
  inputSchema: {
225
250
  type: "object",
226
251
  properties: {
@@ -239,15 +264,14 @@ export const TOOL_DEFINITIONS = [
239
264
  },
240
265
  {
241
266
  name: "get_recent_logs",
242
- description: "Get recent device logs for the Slack debug build, filtered by tag. Reads the app's own log buffer first (the DEBUG-and-above Timber lines the in-app Log Viewer shows, this app only), and falls back to an adb logcat snapshot when that buffer cannot be read. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
267
+ description: "Get recent device logs for the Slack debug build. Reads the app's own log buffer first (the DEBUG-and-above Timber lines the in-app Log Viewer shows, this app only), and falls back to an adb logcat snapshot when that buffer cannot be read. The result includes a `source` field (app_buffer / logcat_capture / logcat_ring) so you can tell a real empty result from a fallback. Leave `tag` out to get all recent lines. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
243
268
  inputSchema: {
244
269
  type: "object",
245
270
  properties: {
246
- tag: { type: "string", description: "Substring to filter log lines by (matched anywhere in the line, not an exact tag-column match)" },
247
- limit: { type: "number", description: "Max number of lines to return (default: 50)" },
271
+ tag: { type: "string", description: "Optional substring to filter log lines by (matched anywhere in the line, not an exact tag-column match). Omit to return all recent lines." },
272
+ limit: { type: "number", description: "Max number of lines to return (default: 50, capped at 1000)" },
248
273
  grep: { type: "string", description: "Optional text to grep within the filtered output" },
249
274
  },
250
- required: ["tag"],
251
275
  },
252
276
  annotations: { readOnlyHint: true },
253
277
  },
@@ -278,7 +302,7 @@ export const TOOL_DEFINITIONS = [
278
302
  },
279
303
  {
280
304
  name: "capture_snapshot",
281
- description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs, and clog payloads — review before sharing externally.",
305
+ description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog + trace buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs, clog payloads, and performance trace spans — review before sharing externally.",
282
306
  inputSchema: {
283
307
  type: "object",
284
308
  properties: {
@@ -1,4 +1,4 @@
1
- import type { DeviceTransport } from "./transport.js";
1
+ import type { DeviceTransport, LogResult } from "./transport.js";
2
2
  /**
3
3
  * Resolve the adb binary. MCP servers spawned from GUI launchers (Spotlight,
4
4
  * Raycast, Dock) do not inherit the user's shell PATH, so a bare `adb` call
@@ -44,7 +44,7 @@ export declare class AndroidTransport implements DeviceTransport {
44
44
  screenshot(fullRes: boolean): string;
45
45
  recordScreen(durationS: number): string;
46
46
  getAppState(): string;
47
- getRecentLogs(tag: string, limit: number, grep?: string): string[];
47
+ getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
48
48
  listProfiles(): {
49
49
  userId: string;
50
50
  label: string;
@@ -1,4 +1,4 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, execFileSync } from "child_process";
2
2
  import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
@@ -209,7 +209,7 @@ export class AndroidTransport {
209
209
  // sandbox -- matching how the DB browser threads its user.
210
210
  const appLog = readDebugLogBuffer(ADB, this.resolveActiveUser());
211
211
  if (appLog !== null) {
212
- return filterLogLines(appLog, tag, limit, grep);
212
+ return { lines: filterLogLines(appLog, tag, limit, grep), source: "app_buffer" };
213
213
  }
214
214
  // Fallback: read from the session capture file so lines cannot have been
215
215
  // evicted from the device ring buffer between enable and now.
@@ -220,20 +220,25 @@ export class AndroidTransport {
220
220
  rotateCaptureIfNeeded(ADB);
221
221
  const lines = readRecentLogs(tag, limit, grep);
222
222
  if (lines !== null)
223
- return lines;
223
+ return { lines, source: "logcat_capture" };
224
224
  }
225
225
  // Fallback: device ring buffer snapshot. Used when capture is not running
226
- // (activate() was skipped, child died, or the file was deleted).
227
- let cmd = `${ADB} logcat -d | grep -F "${tag}" | tail -${limit}`;
228
- if (grep) {
229
- cmd = `${ADB} logcat -d | grep -F "${tag}" | grep -i "${grep.replace(/"/g, '\\"')}" | tail -${limit}`;
230
- }
231
- const output = execSync(cmd, {
226
+ // (activate() was skipped, child died, or the file was deleted). No shell:
227
+ // dump the ring with execFileSync (argv, so a tag/grep with a backtick,
228
+ // $(...), or trailing backslash cannot break out of a command string) and do
229
+ // the tag/grep/limit filtering in pure JS via the same filterLogLines the
230
+ // other two sources use. An empty tag means "no filter" (includes("") is
231
+ // true), so the whole tail is returned.
232
+ const output = execFileSync(ADB, ["logcat", "-d"], {
232
233
  timeout: 10000,
233
- shell: "/bin/zsh",
234
234
  encoding: "utf-8",
235
+ maxBuffer: 64 * 1024 * 1024,
235
236
  });
236
- return output.trim().split("\n").filter(Boolean);
237
+ const lines = output.split("\n").filter(Boolean);
238
+ return {
239
+ lines: filterLogLines(lines, tag, limit, grep),
240
+ source: "logcat_ring",
241
+ };
237
242
  }
238
243
  listProfiles() {
239
244
  if (!ADB)
@@ -77,6 +77,19 @@ export declare function pullDatabase(name: string, user?: string | null, force?:
77
77
  * (1) and (2), not from the open mode.
78
78
  */
79
79
  export declare function assertReadOnlyQuery(sql: string): void;
80
+ /**
81
+ * True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
82
+ * Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
83
+ * comment-prefixed queries before this routing matters.
84
+ */
85
+ export declare function isExplainQuery(sql: string): boolean;
86
+ /**
87
+ * sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
88
+ * Shape it into row objects so the web grid renders the plan readably.
89
+ */
90
+ export declare function parseExplainOutput(out: string): {
91
+ plan: string;
92
+ }[];
80
93
  /**
81
94
  * Run a read-only query against a (lazily pulled) local copy. The query is gated by
82
95
  * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
package/dist/shared/db.js CHANGED
@@ -353,6 +353,24 @@ export function assertReadOnlyQuery(sql) {
353
353
  throw new Error("that function/statement is not allowed (read-only, no file access)");
354
354
  }
355
355
  }
356
+ /**
357
+ * True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
358
+ * Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
359
+ * comment-prefixed queries before this routing matters.
360
+ */
361
+ export function isExplainQuery(sql) {
362
+ return /^\s*explain\b/i.test(sql);
363
+ }
364
+ /**
365
+ * sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
366
+ * Shape it into row objects so the web grid renders the plan readably.
367
+ */
368
+ export function parseExplainOutput(out) {
369
+ return out
370
+ .split("\n")
371
+ .filter((line) => line.trim().length > 0)
372
+ .map((line) => ({ plan: line }));
373
+ }
356
374
  /**
357
375
  * Run a read-only query against a (lazily pulled) local copy. The query is gated by
358
376
  * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
@@ -365,6 +383,12 @@ export async function queryDatabase(name, sql, user) {
365
383
  const local = localPath(name, u);
366
384
  if (!fs.existsSync(local))
367
385
  await pullDatabase(name, u);
386
+ // EXPLAIN / EXPLAIN QUERY PLAN are read-query-shaped (they pass the gate) but sqlite3 IGNORES
387
+ // -json for them: it prints a fixed-width opcode/plan TEXT table, not JSON, so JSON.parse below
388
+ // would throw "Unexpected token" and the user sees a confusing parse error for a query that
389
+ // actually ran. Handle them as text: one result row per output line under a single column, so
390
+ // the existing grid renders the plan readably instead of choking.
391
+ const explain = isExplainQuery(sql);
368
392
  let out;
369
393
  try {
370
394
  // Open the pulled copy NORMALLY (not `immutable`, not `-readonly`): pullDatabase brought
@@ -375,14 +399,15 @@ export async function queryDatabase(name, sql, user) {
375
399
  // is touched. `-safe` is the load-bearing read-only/exfil control (blocks ATTACH/readfile/
376
400
  // writefile/load_extension at the engine level) and holds in normal mode too. The path is
377
401
  // URI-encoded so spaces/specials are safe. Async exec so a large query does not block the
378
- // event loop.
402
+ // event loop. -json for normal queries; plain text for EXPLAIN (sqlite ignores -json there).
379
403
  const uri = `file:${encodeURI(local)}`;
380
- const r = await execFileP(sqliteBin(), ["-safe", "-json", uri, sql], {
404
+ const args = explain ? ["-safe", uri, sql] : ["-safe", "-json", uri, sql];
405
+ const r = await execFileP(sqliteBin(), args, {
381
406
  timeout: 15000,
382
407
  maxBuffer: SQLITE_MAX_BUFFER,
383
408
  encoding: "utf8",
384
409
  });
385
- out = String(r.stdout) || "[]";
410
+ out = String(r.stdout) || (explain ? "" : "[]");
386
411
  }
387
412
  catch (e) {
388
413
  const err = e;
@@ -390,6 +415,8 @@ export async function queryDatabase(name, sql, user) {
390
415
  const m = stderr.match(/(no such table:.*|no such column:.*|near ".*|syntax error.*|.*error.*)/i);
391
416
  throw new Error(m ? m[0].trim() : stderr.split("\n")[0] || "query failed");
392
417
  }
418
+ if (explain)
419
+ return parseExplainOutput(out);
393
420
  return JSON.parse(out);
394
421
  }
395
422
  /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
@@ -130,6 +130,16 @@ export declare function startLive(fps: number): LiveCast | null;
130
130
  /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
131
131
  export declare function addViewer(L: LiveCast, res: NodeJS.WritableStream): void;
132
132
  export declare function removeViewer(res: NodeJS.WritableStream): void;
133
+ /**
134
+ * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
135
+ * recording. Either a viewer leaving or a recording stopping can be the last thing
136
+ * holding the cast, so both paths call this. The close-while-recording case needs it:
137
+ * the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
138
+ * resolves, so the viewer-leave finds recording still set and cannot reap; the cast
139
+ * would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
140
+ * after stopRecording closes that gap.
141
+ */
142
+ export declare function reapLiveIfIdle(): void;
133
143
  export declare function getLive(): LiveCast | null;
134
144
  export declare function lastFrame(): Buffer | null;
135
145
  export declare function primeIfNeeded(): Promise<void>;
@@ -461,7 +461,19 @@ export function removeViewer(res) {
461
461
  if (!scLive)
462
462
  return;
463
463
  scLive.viewers.delete(res);
464
- if (!scLive.viewers.size && !scLive.recording) {
464
+ reapLiveIfIdle();
465
+ }
466
+ /**
467
+ * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
468
+ * recording. Either a viewer leaving or a recording stopping can be the last thing
469
+ * holding the cast, so both paths call this. The close-while-recording case needs it:
470
+ * the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
471
+ * resolves, so the viewer-leave finds recording still set and cannot reap; the cast
472
+ * would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
473
+ * after stopRecording closes that gap.
474
+ */
475
+ export function reapLiveIfIdle() {
476
+ if (scLive && !scLive.viewers.size && !scLive.recording) {
465
477
  cleanupLive(scLive);
466
478
  scLive = null;
467
479
  }
@@ -6,7 +6,7 @@ export interface RadarEvent {
6
6
  [key: string]: unknown;
7
7
  }
8
8
  export interface ParsedSSE {
9
- type: "network" | "rtm";
9
+ type: "network" | "rtm" | "clog" | "log" | "trace";
10
10
  event: RadarEvent;
11
11
  }
12
12
  export declare const localNetworkBuffer: RadarEvent[];
@@ -6,6 +6,13 @@
6
6
  * device-specific operations. The MCP server and tool handlers
7
7
  * interact only through this interface.
8
8
  */
9
+ /** Which underlying source served a getRecentLogs result. */
10
+ export type LogSource = "app_buffer" | "logcat_capture" | "logcat_ring";
11
+ /** Lines plus the source that produced them. */
12
+ export interface LogResult {
13
+ lines: string[];
14
+ source: LogSource;
15
+ }
9
16
  export interface DeviceTransport {
10
17
  /** Human-readable platform name (e.g. "android", "ios"). */
11
18
  readonly platform: string;
@@ -47,14 +54,15 @@ export interface DeviceTransport {
47
54
  */
48
55
  getAppState(): string;
49
56
  /**
50
- * Get recent log entries filtered by tag.
57
+ * Get recent log entries, optionally filtered by tag.
51
58
  *
52
- * @param tag Log tag to filter by
59
+ * @param tag Log tag to filter by. Empty string returns every line (no tag filter).
53
60
  * @param limit Max number of lines
54
61
  * @param grep Optional text to further filter
55
- * @returns Array of matching log lines
62
+ * @returns The matching lines plus which source served them, so the caller can tell a
63
+ * true-empty result from a silent fallback to an inferior source.
56
64
  */
57
- getRecentLogs(tag: string, limit: number, grep?: string): string[];
65
+ getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
58
66
  /**
59
67
  * Returns the currently active user ID, or null if none is set. On Android
60
68
  * this is the profile (e.g. "0" for personal, "10" for work); on iOS it