@slack/radar-mcp 1.2.0 → 1.3.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/index.js CHANGED
@@ -6,7 +6,9 @@ import fs from "fs";
6
6
  import http from "http";
7
7
  import { AndroidTransport } from "../shared/android.js";
8
8
  import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
9
+ import { stopCapture } from "../shared/logcat-capture.js";
9
10
  import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
11
+ import { buildBufferPath, createSnapshot } from "./snapshot.js";
10
12
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS } from "./tools.js";
11
13
  // --- Platform selection ---
12
14
  function createTransport() {
@@ -135,6 +137,11 @@ function handleSessionDisable() {
135
137
  sessionEnabled = false;
136
138
  destroyStream();
137
139
  clearLocalBuffers();
140
+ // Explicit disable stops the logcat capture immediately. Without this, the
141
+ // capture would linger up to the capture-idle timeout (15 min) after a
142
+ // deliberate user action saying "I am done", which is surprising and
143
+ // wastes disk + an adb logcat child.
144
+ stopCapture();
138
145
  return textResult({
139
146
  enabled: false,
140
147
  note: "Radar disabled. Use /radar-enable to reconnect.",
@@ -173,17 +180,16 @@ function handleGetAppState() {
173
180
  }
174
181
  function handleListProfiles() {
175
182
  try {
176
- // Narrow to implementations that expose listProfiles; the base
177
- // DeviceTransport interface leaves it platform-specific.
183
+ // Narrow to implementations that expose `listProfiles`; it is optional on `DeviceTransport`.
178
184
  const maybe = device;
179
185
  if (typeof maybe.listProfiles !== "function") {
180
186
  return textResult({ error: `Profile listing not supported on platform ${device.platform}` }, true);
181
187
  }
182
188
  const profiles = maybe.listProfiles();
183
- const activeUserId = typeof device.getActiveUserId === "function"
184
- ? device.getActiveUserId()
185
- : null;
186
- return textResult({ profiles, active_user_id: activeUserId });
189
+ return textResult({
190
+ profiles,
191
+ active_user_id: device.getActiveUserId(),
192
+ });
187
193
  }
188
194
  catch (e) {
189
195
  return textResult({ error: `Profile list failed: ${e.message}` }, true);
@@ -206,13 +212,10 @@ async function handleActivateForUser(args) {
206
212
  // handleSessionEnable so an immediate get_recent_logs call does not
207
213
  // read an empty file.
208
214
  await new Promise((r) => setTimeout(r, 500));
209
- const sourceFile = typeof device.getCaptureFilePath === "function"
210
- ? device.getCaptureFilePath()
211
- : null;
212
215
  return textResult({
213
216
  user_id: args.user_id,
214
217
  timeout_minutes: timeout,
215
- capture_file: sourceFile,
218
+ capture_file: device.getCaptureFilePath?.() ?? null,
216
219
  note: "Profile activated. Log capture restarted on a new file; old capture preserved for 7 days.",
217
220
  });
218
221
  }
@@ -220,6 +223,30 @@ async function handleActivateForUser(args) {
220
223
  return textResult({ error: `Profile activation failed: ${e.message}` }, true);
221
224
  }
222
225
  }
226
+ async function handleCaptureSnapshot(args) {
227
+ try {
228
+ // Adapt `callRadar` (device HTTP + retry + session gate) into the
229
+ // simple `fetchBuffer(kind)` contract `createSnapshot` expects. Any
230
+ // `res.error` from callRadar is rethrown so the composer's per-buffer
231
+ // try/catch can record it in `errors[]` without bringing down the
232
+ // whole snapshot.
233
+ const fetchBuffer = async (kind) => {
234
+ const res = await callRadar(buildBufferPath(kind));
235
+ if (res.error)
236
+ throw new Error(res.error);
237
+ return res;
238
+ };
239
+ const result = await createSnapshot(device, fetchBuffer, {
240
+ includeLogs: args?.include_logs !== false,
241
+ includeScreenshot: args?.include_screenshot !== false,
242
+ logTailLines: args?.log_tail_lines,
243
+ });
244
+ return textResult(result, result.errors.length > 0 && result.files.length === 0);
245
+ }
246
+ catch (e) {
247
+ return textResult({ error: `Snapshot failed: ${e.message}` }, true);
248
+ }
249
+ }
223
250
  function handleGetRecentLogs(args) {
224
251
  try {
225
252
  const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
@@ -352,7 +379,7 @@ function buildApiPath(name, args) {
352
379
  }
353
380
  }
354
381
  // --- MCP Server ---
355
- const server = new Server({ name: "slack-radar", version: "1.2.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
382
+ const server = new Server({ name: "slack-radar", version: "1.3.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
356
383
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
357
384
  tools: TOOL_DEFINITIONS,
358
385
  }));
@@ -385,6 +412,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
385
412
  if (name === "get_recent_logs") {
386
413
  return handleGetRecentLogs(toolArgs);
387
414
  }
415
+ if (name === "capture_snapshot") {
416
+ return handleCaptureSnapshot(toolArgs);
417
+ }
388
418
  if (name === "screenshot") {
389
419
  return handleScreenshot(toolArgs);
390
420
  }
@@ -0,0 +1,63 @@
1
+ import type { DeviceTransport } from "../shared/transport.js";
2
+ export interface SnapshotOptions {
3
+ includeLogs?: boolean;
4
+ includeScreenshot?: boolean;
5
+ logTailLines?: number;
6
+ }
7
+ export interface SnapshotResult {
8
+ path: string;
9
+ files: string[];
10
+ zip_path: string | null;
11
+ timestamp: number;
12
+ counts: {
13
+ network: number;
14
+ rtm: number;
15
+ clogs: number;
16
+ log_lines: number;
17
+ log_lines_truncated: boolean;
18
+ screenshot: boolean;
19
+ };
20
+ fetched: {
21
+ network: boolean;
22
+ rtm: boolean;
23
+ clogs: boolean;
24
+ };
25
+ errors: string[];
26
+ privacy_notice: string;
27
+ }
28
+ /**
29
+ * Resolve the output directory for a snapshot. Exported for tests.
30
+ */
31
+ export declare function buildSnapshotDir(now?: number, root?: string): string;
32
+ /**
33
+ * Build the device-side URL for a given buffer kind. Extracted so the
34
+ * mapping is testable without standing up a device. `limit=1000` is
35
+ * intentionally larger than point-query defaults — snapshots are bulk
36
+ * exports and the device-side ring buffers cap their own responses.
37
+ */
38
+ export declare function buildBufferPath(kind: "network" | "rtm" | "clogs"): string;
39
+ export interface LogTailResult {
40
+ text: string;
41
+ truncated: boolean;
42
+ }
43
+ /**
44
+ * Take the last N lines from the active capture file. Returns an empty
45
+ * result if no capture is active or the file is unreadable. Bounded read
46
+ * so we do not load a 100 MB capture into memory.
47
+ *
48
+ * `truncated` is true when the 1 MB file-read cap was the limiting factor
49
+ * (i.e. the user asked for more lines than fit), so the caller can tell
50
+ * the user they got fewer lines than requested.
51
+ */
52
+ export declare function readRecentLogTail(requestedLimit?: number): LogTailResult;
53
+ /**
54
+ * Write a snapshot bundle to disk. The caller supplies `fetchBuffer` (a
55
+ * callback that hits the device's HTTP endpoint for a given buffer name)
56
+ * so this module does not need to know how `radarRequest` works — easier
57
+ * to test and keeps the device-HTTP plumbing in `index.ts`.
58
+ *
59
+ * On partial failure (e.g. screenshot fails because ADB is flaky), the
60
+ * snapshot still lands on disk with whatever succeeded; failures are
61
+ * collected in `result.errors` so the model can tell the user.
62
+ */
63
+ export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
@@ -0,0 +1,301 @@
1
+ import { spawnSync } from "child_process";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { getCaptureFilePath, readTailBytes } from "../shared/logcat-capture.js";
6
+ /**
7
+ * `capture_snapshot` composer: bundles everything Radar has seen into a
8
+ * single on-disk artifact so a user can attach it to a Jira, drop it in a
9
+ * Slack thread, or share it with a teammate without copying individual
10
+ * tool outputs.
11
+ *
12
+ * Snapshot contents:
13
+ * - `bundle.json` — device-side buffers (net + RTM + clogs) plus metadata
14
+ * - `screenshot.png` — current screen (optional)
15
+ * - `logcat.tail.log` — last N lines of the active capture file (optional)
16
+ *
17
+ * Written to `~/.slack-radar/snapshots/snapshot-<ts>/`. No cleanup: snapshots
18
+ * are user-triggered, small, and should persist for post-hoc inspection.
19
+ * If that becomes a disk-growth concern, add a TTL GC like `logcat-capture.ts`.
20
+ */
21
+ const SNAPSHOT_DIR = path.join(os.homedir(), ".slack-radar", "snapshots");
22
+ const DEFAULT_LOG_TAIL_LINES = 500;
23
+ const MAX_LOG_TAIL_LINES = 10_000;
24
+ const DEFAULT_LOG_TAIL_BYTES = 1 * 1024 * 1024; // 1 MB bound on file read
25
+ // Restrictive permissions on the snapshot dir and its files. The bundle
26
+ // contains network request/response bodies (including auth headers),
27
+ // message content, and user IDs — readable only by the owning user.
28
+ const DIR_MODE = 0o700;
29
+ const FILE_MODE = 0o600;
30
+ const BUNDLE_README = `Slack Radar snapshot
31
+ ====================
32
+
33
+ This directory is a point-in-time bundle of Radar state.
34
+
35
+ Files:
36
+ - bundle.json: device-side network/RTM/clog buffers, counts, and metadata.
37
+ - logcat.tail.log (optional): last N lines of the on-device logcat capture.
38
+ - screenshot.png (optional): current screen.
39
+
40
+ Privacy: bundle.json contains raw API request/response bodies (auth headers,
41
+ tokens), RTM message content, user IDs, channel IDs, and clog payloads.
42
+ Review before sharing externally. This bundle lives on your local disk only;
43
+ nothing is uploaded anywhere by Radar.
44
+
45
+ To inspect: cat bundle.json | jq .
46
+ To share: zip -r snapshot.zip . then attach the zip.
47
+ `;
48
+ const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally.";
49
+ /**
50
+ * Resolve the output directory for a snapshot. Exported for tests.
51
+ */
52
+ export function buildSnapshotDir(now = Date.now(), root = SNAPSHOT_DIR) {
53
+ return path.join(root, `snapshot-${now}`);
54
+ }
55
+ /**
56
+ * Build the device-side URL for a given buffer kind. Extracted so the
57
+ * mapping is testable without standing up a device. `limit=1000` is
58
+ * intentionally larger than point-query defaults — snapshots are bulk
59
+ * exports and the device-side ring buffers cap their own responses.
60
+ */
61
+ export function buildBufferPath(kind) {
62
+ return `/api/${kind}?limit=1000`;
63
+ }
64
+ /**
65
+ * Take the last N lines from the active capture file. Returns an empty
66
+ * result if no capture is active or the file is unreadable. Bounded read
67
+ * so we do not load a 100 MB capture into memory.
68
+ *
69
+ * `truncated` is true when the 1 MB file-read cap was the limiting factor
70
+ * (i.e. the user asked for more lines than fit), so the caller can tell
71
+ * the user they got fewer lines than requested.
72
+ */
73
+ export function readRecentLogTail(requestedLimit = DEFAULT_LOG_TAIL_LINES) {
74
+ // Defensive clamp — caller validates at the handler boundary too, but
75
+ // a direct caller cannot accidentally get "entire file" by passing NaN.
76
+ const limit = Number.isFinite(requestedLimit) &&
77
+ requestedLimit > 0 &&
78
+ requestedLimit <= MAX_LOG_TAIL_LINES
79
+ ? Math.floor(requestedLimit)
80
+ : DEFAULT_LOG_TAIL_LINES;
81
+ const filePath = getCaptureFilePath();
82
+ if (!filePath)
83
+ return { text: "", truncated: false };
84
+ const raw = readTailBytes(filePath, DEFAULT_LOG_TAIL_BYTES);
85
+ if (!raw)
86
+ return { text: "", truncated: false };
87
+ const lines = raw.split("\n");
88
+ // If the raw read hit the 1 MB byte cap, the FIRST line is likely
89
+ // partial — readTailBytes drops it — and the user got whatever fits.
90
+ // "Truncated" means "user asked for more lines than the byte cap can
91
+ // contain AND we delivered fewer lines than requested."
92
+ const truncated = lines.length < limit && raw.length >= DEFAULT_LOG_TAIL_BYTES;
93
+ const tail = lines.slice(Math.max(0, lines.length - limit));
94
+ return { text: tail.join("\n"), truncated };
95
+ }
96
+ /**
97
+ * Write a snapshot bundle to disk. The caller supplies `fetchBuffer` (a
98
+ * callback that hits the device's HTTP endpoint for a given buffer name)
99
+ * so this module does not need to know how `radarRequest` works — easier
100
+ * to test and keeps the device-HTTP plumbing in `index.ts`.
101
+ *
102
+ * On partial failure (e.g. screenshot fails because ADB is flaky), the
103
+ * snapshot still lands on disk with whatever succeeded; failures are
104
+ * collected in `result.errors` so the model can tell the user.
105
+ */
106
+ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.now(), snapshotRoot = SNAPSHOT_DIR) {
107
+ const includeLogs = opts.includeLogs !== false;
108
+ const includeScreenshot = opts.includeScreenshot !== false;
109
+ // Clamp log_tail_lines at the composer boundary. The handler and schema
110
+ // validate too; this is defense-in-depth so no downstream consumer sees
111
+ // a pathological value.
112
+ const logTailLines = typeof opts.logTailLines === "number" &&
113
+ Number.isFinite(opts.logTailLines) &&
114
+ opts.logTailLines > 0 &&
115
+ opts.logTailLines <= MAX_LOG_TAIL_LINES
116
+ ? Math.floor(opts.logTailLines)
117
+ : DEFAULT_LOG_TAIL_LINES;
118
+ const dir = buildSnapshotDir(now, snapshotRoot);
119
+ fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
120
+ // `mkdirSync({ recursive: true, mode })` only applies the mode to
121
+ // directories it CREATES. If `~/.slack-radar/` or `.../snapshots/`
122
+ // already existed (e.g. created by a pre-R1 build or by the logcat
123
+ // capture module with default umask → 0o755), they keep old perms.
124
+ // Tighten them to 0o700 on every snapshot so the parent tree cannot
125
+ // leak directory-listing on a shared host.
126
+ for (const p of [snapshotRoot, path.dirname(snapshotRoot)]) {
127
+ try {
128
+ if (fs.existsSync(p))
129
+ fs.chmodSync(p, DIR_MODE);
130
+ }
131
+ catch {
132
+ // Best effort; not fatal.
133
+ }
134
+ }
135
+ const files = [];
136
+ const errors = [];
137
+ let network = null;
138
+ let rtm = null;
139
+ let clogs = null;
140
+ const fetched = { network: false, rtm: false, clogs: false };
141
+ // Device-side buffers. Failures are individually caught so one failing
142
+ // buffer does not nuke the whole snapshot. `fetched` distinguishes
143
+ // "empty because the buffer was empty" from "null because the fetch
144
+ // failed" — counts alone cannot tell these apart.
145
+ try {
146
+ network = await fetchBuffer("network");
147
+ fetched.network = true;
148
+ }
149
+ catch (e) {
150
+ errors.push(`network buffer: ${e.message}`);
151
+ }
152
+ try {
153
+ rtm = await fetchBuffer("rtm");
154
+ fetched.rtm = true;
155
+ }
156
+ catch (e) {
157
+ errors.push(`rtm buffer: ${e.message}`);
158
+ }
159
+ try {
160
+ clogs = await fetchBuffer("clogs");
161
+ fetched.clogs = true;
162
+ }
163
+ catch (e) {
164
+ errors.push(`clog buffer: ${e.message}`);
165
+ }
166
+ // Logcat tail.
167
+ let logTail = { text: "", truncated: false };
168
+ if (includeLogs) {
169
+ try {
170
+ logTail = readRecentLogTail(logTailLines);
171
+ if (logTail.text) {
172
+ const logFile = path.join(dir, "logcat.tail.log");
173
+ fs.writeFileSync(logFile, logTail.text, { mode: FILE_MODE });
174
+ files.push(logFile);
175
+ }
176
+ }
177
+ catch (e) {
178
+ errors.push(`logcat tail: ${e.message}`);
179
+ }
180
+ }
181
+ // Screenshot. 720px default is plenty for bug reports; full-res is
182
+ // 5-8x larger on disk and rarely adds forensic value.
183
+ let screenshotCaptured = false;
184
+ if (includeScreenshot) {
185
+ try {
186
+ const tmp = device.screenshot(false);
187
+ const dest = path.join(dir, "screenshot.png");
188
+ fs.copyFileSync(tmp, dest);
189
+ // copyFileSync preserves source mode by default on POSIX; chmod to
190
+ // 0600 so the copy is owner-only regardless of source permissions.
191
+ try {
192
+ fs.chmodSync(dest, FILE_MODE);
193
+ }
194
+ catch (e) {
195
+ // Non-fatal (worst case: screenshot has source mode, typically
196
+ // 0644 on macOS /tmp pulls) but user should know — a bundle they
197
+ // share externally has a world-readable PNG file if this path
198
+ // hits.
199
+ errors.push(`screenshot chmod failed: ${e.message} (file keeps source mode)`);
200
+ }
201
+ files.push(dest);
202
+ screenshotCaptured = true;
203
+ }
204
+ catch (e) {
205
+ errors.push(`screenshot: ${e.message}`);
206
+ }
207
+ }
208
+ // README. One-paragraph explainer so a receiver who opens the dir for
209
+ // the first time knows what each file is and what privacy exposure it
210
+ // carries.
211
+ try {
212
+ const readmeFile = path.join(dir, "README.txt");
213
+ fs.writeFileSync(readmeFile, BUNDLE_README, { mode: FILE_MODE });
214
+ files.push(readmeFile);
215
+ }
216
+ catch (e) {
217
+ errors.push(`README: ${e.message}`);
218
+ }
219
+ // Bundle JSON. Written last so counts reflect everything captured.
220
+ const counts = {
221
+ network: countItems(network),
222
+ rtm: countItems(rtm),
223
+ clogs: countItems(clogs),
224
+ log_lines: logTail.text
225
+ ? logTail.text.split("\n").filter(Boolean).length
226
+ : 0,
227
+ log_lines_truncated: logTail.truncated,
228
+ screenshot: screenshotCaptured,
229
+ };
230
+ const bundle = {
231
+ version: 1,
232
+ timestamp: now,
233
+ platform: device.platform,
234
+ active_user_id: device.getActiveUserId(),
235
+ capture_file: getCaptureFilePath(),
236
+ buffers: { network, rtm, clogs },
237
+ counts,
238
+ fetched,
239
+ errors,
240
+ privacy_notice: PRIVACY_NOTICE,
241
+ };
242
+ const bundleFile = path.join(dir, "bundle.json");
243
+ fs.writeFileSync(bundleFile, JSON.stringify(bundle, null, 2), {
244
+ mode: FILE_MODE,
245
+ });
246
+ files.unshift(bundleFile);
247
+ // Auto-zip: collapse the 4-file directory into a single shareable
248
+ // artifact. Users universally want to attach ONE file to Jira or drop
249
+ // ONE file in Slack, not drag a directory. Best-effort: if `zip` is
250
+ // missing on the user's PATH (rare — ships with macOS and most Linux
251
+ // distros) we just record the failure and the user can zip manually.
252
+ let zipPath = null;
253
+ const zipCandidate = `${dir}.zip`;
254
+ try {
255
+ const zipResult = spawnSync("zip", ["-rq", zipCandidate, "."], {
256
+ cwd: dir,
257
+ timeout: 10000,
258
+ });
259
+ if (zipResult.status === 0) {
260
+ try {
261
+ fs.chmodSync(zipCandidate, FILE_MODE);
262
+ }
263
+ catch {
264
+ // Leave mode as-is; already a zip copy of owner-readable files.
265
+ }
266
+ zipPath = zipCandidate;
267
+ }
268
+ else {
269
+ errors.push(`zip failed with status ${zipResult.status ?? "null"}${zipResult.stderr ? ": " + zipResult.stderr.toString().trim() : ""}`);
270
+ }
271
+ }
272
+ catch (e) {
273
+ errors.push(`zip not available: ${e.message}`);
274
+ }
275
+ return {
276
+ path: dir,
277
+ files,
278
+ zip_path: zipPath,
279
+ timestamp: now,
280
+ counts,
281
+ fetched,
282
+ errors,
283
+ privacy_notice: PRIVACY_NOTICE,
284
+ };
285
+ }
286
+ /**
287
+ * Count items inside a buffer response. Handles the `{ calls: [...] }`,
288
+ * `{ events: [...] }`, `{ clogs: [...] }` shapes the radar HTTP server
289
+ * uses. Falls back to 0 on malformed responses.
290
+ */
291
+ function countItems(body) {
292
+ if (!body || typeof body !== "object")
293
+ return 0;
294
+ const b = body;
295
+ for (const key of ["calls", "events", "clogs", "items"]) {
296
+ const v = b[key];
297
+ if (Array.isArray(v))
298
+ return v.length;
299
+ }
300
+ return 0;
301
+ }
package/dist/mcp/tools.js CHANGED
@@ -22,6 +22,7 @@ export const SERVER_INSTRUCTIONS = [
22
22
  '- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
23
23
  '- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
24
24
  '- "check the debug logs" -> get_recent_logs(tag="...")',
25
+ '- "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',
25
26
  "",
26
27
  "Key behaviors:",
27
28
  "- Network/RTM buffers clear on shutdown. Clogs persist from app start.",
@@ -48,6 +49,22 @@ export const SERVER_INSTRUCTIONS = [
48
49
  "DO NOT invoke /radar-ui yourself. Opening a browser window is a user-visible action.",
49
50
  "DO NOT suggest /radar-ui for single-query debugging or when the user is happy reading",
50
51
  "results in the transcript. Suggest it when visual inspection adds real value.",
52
+ "",
53
+ "CAPTURE_SNAPSHOT OUTPUT:",
54
+ "When capture_snapshot returns, tell the user:",
55
+ " 1. The result.zip_path if non-null (the single shareable file). Fall back to",
56
+ " result.path (a directory) if zip_path is null.",
57
+ " 2. A one-line summary of counts (network/rtm/clogs/log_lines/screenshot).",
58
+ " 3. Print this EXACT sentence as the privacy line, verbatim — no paraphrase, no",
59
+ " compression, no skipping even under time pressure:",
60
+ ' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally."',
61
+ " 4. If result.log_lines_truncated is true, say: 'you got fewer log lines than",
62
+ " requested because the 1 MB read cap was the limiting factor.'",
63
+ " 5. If the user's original ask was 'file this as a bug' / 'attach to ticket' /",
64
+ " similar, offer to attach result.zip_path via the Jira MCP or Slack MCP as",
65
+ " the next step. Do NOT invoke the attach without asking. Just offer.",
66
+ " 6. If result.errors is non-empty, enumerate which parts failed so the user",
67
+ " knows what is missing before they ship the bundle.",
51
68
  ].join("\n");
52
69
  export const TOOL_DEFINITIONS = [
53
70
  {
@@ -212,6 +229,30 @@ export const TOOL_DEFINITIONS = [
212
229
  },
213
230
  annotations: {},
214
231
  },
232
+ {
233
+ name: "capture_snapshot",
234
+ 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.",
235
+ inputSchema: {
236
+ type: "object",
237
+ properties: {
238
+ include_logs: {
239
+ type: "boolean",
240
+ description: "Include last N lines of the active logcat capture (default: true). No-op when no capture is active.",
241
+ },
242
+ include_screenshot: {
243
+ type: "boolean",
244
+ description: "Include a current screenshot (default: true). Requires a connected device.",
245
+ },
246
+ log_tail_lines: {
247
+ type: "number",
248
+ description: "How many trailing lines of the capture file to include (default: 500, min: 1, max: 10000). Truncation flagged in counts.log_lines_truncated.",
249
+ minimum: 1,
250
+ maximum: 10000,
251
+ },
252
+ },
253
+ },
254
+ annotations: { readOnlyHint: false },
255
+ },
215
256
  {
216
257
  name: "screenshot",
217
258
  description: "Capture a screenshot from the connected Android device.",
@@ -137,7 +137,7 @@ export class AndroidTransport {
137
137
  this.detectedUserId = null;
138
138
  this.radarEnabled = false;
139
139
  this.lastForwardError = null;
140
- stopCapture();
140
+ // Capture has its own idle timer; not stopped on activation reset (see logcat-capture.ts).
141
141
  }
142
142
  isForwarded() {
143
143
  return this.portForwarded;
@@ -3,6 +3,9 @@ export interface CaptureState {
3
3
  child: ChildProcess | null;
4
4
  filePath: string | null;
5
5
  startedAt: number | null;
6
+ lastReadAt: number | null;
7
+ idleTimer: ReturnType<typeof setTimeout> | null;
8
+ captureIdleMs: number;
6
9
  }
7
10
  /**
8
11
  * Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
@@ -12,6 +15,18 @@ export declare function gcOldCaptures(dir?: string, now?: number): number;
12
15
  export declare function buildCaptureFilePath(now?: number): string;
13
16
  export declare function isCapturing(): boolean;
14
17
  export declare function getCaptureFilePath(): string | null;
18
+ /**
19
+ * Override the default capture-idle timeout. Internal — not currently exposed
20
+ * to MCP callers; reserved for future tuning. Values <= 0, NaN, or past
21
+ * `MAX_SETTIMEOUT_MS` fall back to the default so the timer can never
22
+ * pathologically collapse to an immediate-fire.
23
+ */
24
+ export declare function setCaptureIdleMs(ms: number): void;
25
+ export declare function _getCaptureIdleMsForTest(): number;
26
+ export declare function _hasIdleTimerForTest(): boolean;
27
+ export declare function _rescheduleIdleTimerForTest(): void;
28
+ export declare function _touchLastReadForTest(now?: number): void;
29
+ export declare function _setStateForTest(patch: Partial<CaptureState>): void;
15
30
  /**
16
31
  * Start streaming logcat to a session-scoped file. Idempotent: a second call
17
32
  * while the first capture is live is a no-op. If a prior capture's child is
@@ -17,10 +17,14 @@ const LOG_DIR = path.join(os.homedir(), ".slack-radar", "logs");
17
17
  const ROTATION_BYTES = 100 * 1024 * 1024;
18
18
  const ROTATION_KEEP_BYTES = 50 * 1024 * 1024;
19
19
  const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
+ const DEFAULT_CAPTURE_IDLE_MS = 15 * 60 * 1000;
20
21
  const state = {
21
22
  child: null,
22
23
  filePath: null,
23
24
  startedAt: null,
25
+ lastReadAt: null,
26
+ idleTimer: null,
27
+ captureIdleMs: DEFAULT_CAPTURE_IDLE_MS,
24
28
  };
25
29
  function ensureLogDir() {
26
30
  if (!fs.existsSync(LOG_DIR)) {
@@ -61,6 +65,88 @@ export function isCapturing() {
61
65
  export function getCaptureFilePath() {
62
66
  return state.filePath;
63
67
  }
68
+ // Node's setTimeout treats values past 2^31-1 ms as 1 ms (fires immediately),
69
+ // which would instantly kill capture. Clamp to the max safe value here.
70
+ const MAX_SETTIMEOUT_MS = 2_147_483_647;
71
+ /**
72
+ * Schedule (or reschedule) the capture-idle timer to fire when
73
+ * `state.captureIdleMs` elapses without a new read. Called on every
74
+ * successful `readRecentLogs` and at `startCapture` boot so the timer is
75
+ * always pointing at the most recent activity.
76
+ *
77
+ * The scheduled callback closes over the timer handle it created and only
78
+ * fires `stopCapture()` if the module's current `state.idleTimer` is
79
+ * still THAT same handle. This defends against the case where a previously
80
+ * scheduled (but not yet fired) callback gets queued to run AFTER a
81
+ * subsequent reschedule or `stopCapture` — without identity comparison it
82
+ * would incorrectly kill a freshly-rescheduled capture.
83
+ */
84
+ function rescheduleIdleTimer() {
85
+ if (state.idleTimer)
86
+ clearTimeout(state.idleTimer);
87
+ const delay = Math.min(state.captureIdleMs, MAX_SETTIMEOUT_MS);
88
+ const thisTimer = setTimeout(() => {
89
+ // Identity check: if state was cleared or a newer timer replaced us,
90
+ // do nothing. This prevents a stale queued callback from tearing down
91
+ // a fresh capture.
92
+ if (state.idleTimer !== thisTimer)
93
+ return;
94
+ stopCapture();
95
+ }, delay);
96
+ state.idleTimer = thisTimer;
97
+ // Node timers keep the event loop alive. For a long-running MCP this is
98
+ // fine, but for short-lived callers we do not want the timer to be the
99
+ // last thing keeping the process alive.
100
+ if (typeof thisTimer.unref === "function")
101
+ thisTimer.unref();
102
+ }
103
+ // Record that a read of the capture file just happened. Keeps the capture
104
+ // idle timer sliding forward so an actively-used capture does not time out.
105
+ function touchLastRead(now = Date.now()) {
106
+ state.lastReadAt = now;
107
+ if (isCapturing())
108
+ rescheduleIdleTimer();
109
+ }
110
+ /**
111
+ * Override the default capture-idle timeout. Internal — not currently exposed
112
+ * to MCP callers; reserved for future tuning. Values <= 0, NaN, or past
113
+ * `MAX_SETTIMEOUT_MS` fall back to the default so the timer can never
114
+ * pathologically collapse to an immediate-fire.
115
+ */
116
+ export function setCaptureIdleMs(ms) {
117
+ const valid = typeof ms === "number" && ms > 0 && ms <= MAX_SETTIMEOUT_MS;
118
+ state.captureIdleMs = valid ? ms : DEFAULT_CAPTURE_IDLE_MS;
119
+ if (isCapturing())
120
+ rescheduleIdleTimer();
121
+ }
122
+ // Test-only accessors so unit tests can assert on state that is otherwise
123
+ // module-private. Must never be called from production code paths.
124
+ export function _getCaptureIdleMsForTest() {
125
+ return state.captureIdleMs;
126
+ }
127
+ export function _hasIdleTimerForTest() {
128
+ return state.idleTimer !== null;
129
+ }
130
+ export function _rescheduleIdleTimerForTest() {
131
+ rescheduleIdleTimer();
132
+ }
133
+ export function _touchLastReadForTest(now) {
134
+ touchLastRead(now);
135
+ }
136
+ // Enforces the state-machine invariants the tests rely on so `Object.assign`
137
+ // cannot silently produce an impossible configuration (e.g. an idle timer
138
+ // pointing at a dead capture). Throws on misuse; callers that legitimately
139
+ // want to clear state should pass matched pairs.
140
+ export function _setStateForTest(patch) {
141
+ const next = { ...state, ...patch };
142
+ if (next.idleTimer !== null && next.child === null) {
143
+ throw new Error("_setStateForTest invariant: idleTimer must be null when child is null");
144
+ }
145
+ if (next.filePath !== null && next.child === null) {
146
+ throw new Error("_setStateForTest invariant: filePath must be null when child is null");
147
+ }
148
+ Object.assign(state, patch);
149
+ }
64
150
  /**
65
151
  * Start streaming logcat to a session-scoped file. Idempotent: a second call
66
152
  * while the first capture is live is a no-op. If a prior capture's child is
@@ -90,6 +176,11 @@ export function startCapture(adbPath) {
90
176
  }
91
177
  if (state.child === child) {
92
178
  state.child = null;
179
+ // Clear the idle timer: no capture to kill anymore.
180
+ if (state.idleTimer) {
181
+ clearTimeout(state.idleTimer);
182
+ state.idleTimer = null;
183
+ }
93
184
  }
94
185
  });
95
186
  child.on("error", () => {
@@ -103,11 +194,23 @@ export function startCapture(adbPath) {
103
194
  state.child = null;
104
195
  state.filePath = null;
105
196
  state.startedAt = null;
197
+ state.lastReadAt = null;
198
+ // Clear the idle timer we created for this (now-dead) capture so it
199
+ // cannot fire against stale state.
200
+ if (state.idleTimer) {
201
+ clearTimeout(state.idleTimer);
202
+ state.idleTimer = null;
203
+ }
106
204
  }
107
205
  });
108
206
  state.child = child;
109
207
  state.filePath = filePath;
110
208
  state.startedAt = Date.now();
209
+ state.lastReadAt = state.startedAt;
210
+ // Capture-idle timer starts fresh with the new child. Activation may
211
+ // time out (5 min) long before this fires, but capture keeps running
212
+ // so reads into recent history remain possible.
213
+ rescheduleIdleTimer();
111
214
  // Child bring-up warmup is handled at the MCP handler boundary
112
215
  // (handleSessionEnable / handleActivateForUser add a small async delay
113
216
  // before returning). Doing it here would require either blocking the
@@ -127,9 +230,14 @@ export function startCapture(adbPath) {
127
230
  }
128
231
  export function stopCapture() {
129
232
  const child = state.child;
233
+ const timer = state.idleTimer;
130
234
  state.child = null;
131
235
  state.filePath = null;
132
236
  state.startedAt = null;
237
+ state.lastReadAt = null;
238
+ state.idleTimer = null;
239
+ if (timer)
240
+ clearTimeout(timer);
133
241
  if (child && child.exitCode === null) {
134
242
  try {
135
243
  child.kill("SIGTERM");
@@ -270,6 +378,16 @@ export function readRecentLogs(tag, limit, grep) {
270
378
  catch {
271
379
  return null;
272
380
  }
381
+ // Slide the capture-idle timer forward whenever a read hits a HEALTHY
382
+ // capture child. A freshly spawned child may have written zero bytes to
383
+ // the file if there is no device traffic yet — that is not a sign of
384
+ // death. `isCapturing()` checks the child process itself (spawned, not
385
+ // exited), which is the authoritative health signal. The `exit` /
386
+ // `error` handlers will null out the child if it dies, and those same
387
+ // handlers clear the idle timer so no stale callback can fire.
388
+ if (isCapturing()) {
389
+ touchLastRead();
390
+ }
273
391
  const lines = tail.split("\n").filter(Boolean);
274
392
  return filterLogLines(lines, tag, limit, grep);
275
393
  }
@@ -55,6 +55,15 @@ export interface DeviceTransport {
55
55
  * @returns Array of matching log lines
56
56
  */
57
57
  getRecentLogs(tag: string, limit: number, grep?: string): string[];
58
+ /**
59
+ * Returns the currently active user ID, or null if none is set. On Android
60
+ * this is the profile (e.g. "0" for personal, "10" for work); on iOS it
61
+ * is typically null (single-user device) or a synthetic identifier.
62
+ *
63
+ * Required so snapshot tooling and other callers do not fall back to
64
+ * unchecked casts when asking "which profile did this capture?".
65
+ */
66
+ getActiveUserId(): string | null;
58
67
  /**
59
68
  * Returns the last error message from an `ensureForward()` failure, or null
60
69
  * if the last attempt succeeded or has not happened. Lets callers surface
@@ -88,10 +97,4 @@ export interface DeviceTransport {
88
97
  * @param timeoutMinutes Auto-shutdown timeout in minutes
89
98
  */
90
99
  activateForUser?(userId: string, timeoutMinutes: number): void;
91
- /**
92
- * Returns the currently active user ID, or null if not set. Surfaced in
93
- * list_profiles output so callers can tell which profile the current
94
- * session is targeting without making a second call. Optional.
95
- */
96
- getActiveUserId?(): string | null;
97
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server and web dashboard for on-device debugging of the Slack Android app via ADB",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/index.js",