@slack/radar-mcp 1.1.2 → 1.2.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
@@ -171,10 +171,69 @@ function handleGetAppState() {
171
171
  return textResult({ error: `App state query failed: ${e.message}` }, true);
172
172
  }
173
173
  }
174
+ function handleListProfiles() {
175
+ try {
176
+ // Narrow to implementations that expose listProfiles; the base
177
+ // DeviceTransport interface leaves it platform-specific.
178
+ const maybe = device;
179
+ if (typeof maybe.listProfiles !== "function") {
180
+ return textResult({ error: `Profile listing not supported on platform ${device.platform}` }, true);
181
+ }
182
+ const profiles = maybe.listProfiles();
183
+ const activeUserId = typeof device.getActiveUserId === "function"
184
+ ? device.getActiveUserId()
185
+ : null;
186
+ return textResult({ profiles, active_user_id: activeUserId });
187
+ }
188
+ catch (e) {
189
+ return textResult({ error: `Profile list failed: ${e.message}` }, true);
190
+ }
191
+ }
192
+ async function handleActivateForUser(args) {
193
+ const maybe = device;
194
+ if (typeof maybe.activateForUser !== "function") {
195
+ return textResult({ error: `Profile switching not supported on platform ${device.platform}` }, true);
196
+ }
197
+ try {
198
+ const timeout = args?.timeout_minutes || device.getTimeoutMinutes() || 5;
199
+ maybe.activateForUser(args.user_id, timeout);
200
+ // Restart the SSE stream so network/RTM/clog events from the newly
201
+ // activated profile start flowing again.
202
+ destroyStream();
203
+ connectStream();
204
+ // Give the newly spawned adb logcat child a moment to emit its first
205
+ // lines to the capture file. Matches the warmup delay used by
206
+ // handleSessionEnable so an immediate get_recent_logs call does not
207
+ // read an empty file.
208
+ await new Promise((r) => setTimeout(r, 500));
209
+ const sourceFile = typeof device.getCaptureFilePath === "function"
210
+ ? device.getCaptureFilePath()
211
+ : null;
212
+ return textResult({
213
+ user_id: args.user_id,
214
+ timeout_minutes: timeout,
215
+ capture_file: sourceFile,
216
+ note: "Profile activated. Log capture restarted on a new file; old capture preserved for 7 days.",
217
+ });
218
+ }
219
+ catch (e) {
220
+ return textResult({ error: `Profile activation failed: ${e.message}` }, true);
221
+ }
222
+ }
174
223
  function handleGetRecentLogs(args) {
175
224
  try {
176
225
  const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
177
- return textResult({ tag: args.tag, lines, count: lines.length });
226
+ // Surface the capture file path so the model knows which on-disk file
227
+ // produced these lines. Helps avoid cross-session staleness (e.g. the
228
+ // model remembering an earlier profile's logs after a profile switch).
229
+ const sourceFile = device.getCaptureFilePath?.() ?? null;
230
+ return textResult({
231
+ tag: args.tag,
232
+ lines,
233
+ count: lines.length,
234
+ source_file: sourceFile,
235
+ source: sourceFile ? "capture" : "adb_logcat_d",
236
+ });
178
237
  }
179
238
  catch (e) {
180
239
  return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
@@ -293,7 +352,7 @@ function buildApiPath(name, args) {
293
352
  }
294
353
  }
295
354
  // --- MCP Server ---
296
- const server = new Server({ name: "slack-radar", version: "1.1.2" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
355
+ const server = new Server({ name: "slack-radar", version: "1.2.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
297
356
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
298
357
  tools: TOOL_DEFINITIONS,
299
358
  }));
@@ -317,6 +376,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
317
376
  if (name === "get_app_state") {
318
377
  return handleGetAppState();
319
378
  }
379
+ if (name === "list_profiles") {
380
+ return handleListProfiles();
381
+ }
382
+ if (name === "activate_for_user") {
383
+ return handleActivateForUser(toolArgs);
384
+ }
320
385
  if (name === "get_recent_logs") {
321
386
  return handleGetRecentLogs(toolArgs);
322
387
  }
package/dist/mcp/tools.js CHANGED
@@ -32,6 +32,22 @@ export const SERVER_INSTRUCTIONS = [
32
32
  "- Use ping(timeout_minutes=N) to extend sessions. Monitor remaining_minutes proactively.",
33
33
  "- Screenshots default to 720px. Use full_res=true only when detail matters.",
34
34
  "- Setup: /slack-radar-setup",
35
+ "- Manual enable (no skill): adb shell am broadcast -a slack.debug.ENABLE_RADAR --ei timeout_minutes 5 -p com.Slack.internal.debug",
36
+ " Then: adb forward tcp:8099 localabstract:slack-radar",
37
+ "",
38
+ "WEB DASHBOARD (/radar-ui):",
39
+ "A browser-based dashboard (http://localhost:8100) shows the same live device data in a",
40
+ "visual UI. Launched via the /radar-ui skill, which wraps `npx -p @slack/radar-mcp slack-radar-web`.",
41
+ "",
42
+ "When to suggest /radar-ui (mention it ONCE per thread, do not auto-invoke):",
43
+ '- User says "watch", "monitor", "let me see", "show me live" about device traffic',
44
+ "- User wants to share traffic with a colleague, pair, or teammate",
45
+ "- User is doing a multi-minute investigation and the transcript is getting noisy",
46
+ '- User explicitly asks about a GUI, UI, browser view, or dashboard',
47
+ "",
48
+ "DO NOT invoke /radar-ui yourself. Opening a browser window is a user-visible action.",
49
+ "DO NOT suggest /radar-ui for single-query debugging or when the user is happy reading",
50
+ "results in the transcript. Suggest it when visual inspection adds real value.",
35
51
  ].join("\n");
36
52
  export const TOOL_DEFINITIONS = [
37
53
  {
@@ -171,6 +187,31 @@ export const TOOL_DEFINITIONS = [
171
187
  },
172
188
  annotations: { readOnlyHint: true },
173
189
  },
190
+ {
191
+ name: "list_profiles",
192
+ description: "List Android user profiles that have the Slack debug build running. Use this before activate_for_user when the user asks to switch profiles (personal vs work) or when logs/app state seem to point at the wrong profile. Returns each profile's userId (e.g. '0' for personal, '10' for work) and a human label.",
193
+ inputSchema: { type: "object", properties: {} },
194
+ annotations: { readOnlyHint: true },
195
+ },
196
+ {
197
+ name: "activate_for_user",
198
+ description: "Activate Slack Radar on a specific Android user profile. Use after list_profiles to switch between personal and work profile targets. Switching profiles restarts log capture with a new session-scoped file and reconnects the SSE event stream so events come from the newly activated profile; old captures remain on disk for up to 7 days. Call this when already connected and only the target profile needs to change.",
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ user_id: {
203
+ type: "string",
204
+ description: "Android user ID from list_profiles (e.g. '0' for personal, '10' for work).",
205
+ },
206
+ timeout_minutes: {
207
+ type: "number",
208
+ description: "Auto-shutdown timeout in minutes (default: 5).",
209
+ },
210
+ },
211
+ required: ["user_id"],
212
+ },
213
+ annotations: {},
214
+ },
174
215
  {
175
216
  name: "screenshot",
176
217
  description: "Capture a screenshot from the connected Android device.",
@@ -31,6 +31,7 @@ export declare class AndroidTransport implements DeviceTransport {
31
31
  isForwarded(): boolean;
32
32
  getTimeoutMinutes(): number;
33
33
  getActiveUserId(): string | null;
34
+ getCaptureFilePath(): string | null;
34
35
  screenshot(fullRes: boolean): string;
35
36
  recordScreen(durationS: number): string;
36
37
  getAppState(): string;
@@ -2,6 +2,7 @@ import { execSync } from "child_process";
2
2
  import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
+ import { getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
5
6
  const RADAR_PORT = 8099;
6
7
  const SOCKET_NAME = "slack-radar";
7
8
  const ENABLE_ACTION = "slack.debug.ENABLE_RADAR";
@@ -122,6 +123,10 @@ export class AndroidTransport {
122
123
  execSync(`${ADB} shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
123
124
  this.currentTimeoutMinutes = timeout;
124
125
  this.radarEnabled = true;
126
+ // Start streaming logcat to a session-scoped file so reads are not
127
+ // subject to the device ring buffer rotating under load.
128
+ registerCleanup();
129
+ startCapture(ADB);
125
130
  }
126
131
  catch {
127
132
  // Broadcast failed — device may not be connected
@@ -132,6 +137,7 @@ export class AndroidTransport {
132
137
  this.detectedUserId = null;
133
138
  this.radarEnabled = false;
134
139
  this.lastForwardError = null;
140
+ stopCapture();
135
141
  }
136
142
  isForwarded() {
137
143
  return this.portForwarded;
@@ -142,6 +148,9 @@ export class AndroidTransport {
142
148
  getActiveUserId() {
143
149
  return this.detectedUserId;
144
150
  }
151
+ getCaptureFilePath() {
152
+ return getCaptureFilePath();
153
+ }
145
154
  screenshot(fullRes) {
146
155
  if (!ADB)
147
156
  throw new Error("adb not found on PATH or common SDK locations");
@@ -179,6 +188,19 @@ export class AndroidTransport {
179
188
  getRecentLogs(tag, limit, grep) {
180
189
  if (!ADB)
181
190
  throw new Error("adb not found on PATH or common SDK locations");
191
+ // Preferred path: read from the session capture file so lines cannot
192
+ // have been evicted from the device ring buffer between enable and now.
193
+ if (isCapturing()) {
194
+ // Opportunistic rotation. Stop+rename+restart when the file grows
195
+ // past the threshold so the child never writes past a truncated EOF.
196
+ // Bounded to the read cadence; no extra timer.
197
+ rotateCaptureIfNeeded(ADB);
198
+ const lines = readRecentLogs(tag, limit, grep);
199
+ if (lines !== null)
200
+ return lines;
201
+ }
202
+ // Fallback: device ring buffer snapshot. Used when capture is not running
203
+ // (activate() was skipped, child died, or the file was deleted).
182
204
  let cmd = `${ADB} logcat -d | grep -F "${tag}" | tail -${limit}`;
183
205
  if (grep) {
184
206
  cmd = `${ADB} logcat -d | grep -F "${tag}" | grep -i "${grep.replace(/"/g, '\\"')}" | tail -${limit}`;
@@ -222,10 +244,18 @@ export class AndroidTransport {
222
244
  if (!ADB)
223
245
  throw new Error("adb not found on PATH or common SDK locations");
224
246
  const timeout = timeoutMinutes || this.currentTimeoutMinutes;
247
+ const profileChanged = this.detectedUserId !== null && this.detectedUserId !== userId;
225
248
  execSync(`${ADB} shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
226
249
  this.detectedUserId = userId;
227
250
  this.currentTimeoutMinutes = timeout;
228
251
  this.radarEnabled = true;
252
+ // Profile switch means the interesting scope changed; drop the old
253
+ // capture so the new session starts from a fresh file scoped to this
254
+ // profile's debug run.
255
+ if (profileChanged)
256
+ stopCapture();
257
+ registerCleanup();
258
+ startCapture(ADB);
229
259
  }
230
260
  detectDebugBuildUser() {
231
261
  if (this.detectedUserId !== null)
@@ -0,0 +1,73 @@
1
+ import { ChildProcess } from "child_process";
2
+ export interface CaptureState {
3
+ child: ChildProcess | null;
4
+ filePath: string | null;
5
+ startedAt: number | null;
6
+ }
7
+ /**
8
+ * Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
9
+ * disk usage without losing recent forensic history. Extracted for test.
10
+ */
11
+ export declare function gcOldCaptures(dir?: string, now?: number): number;
12
+ export declare function buildCaptureFilePath(now?: number): string;
13
+ export declare function isCapturing(): boolean;
14
+ export declare function getCaptureFilePath(): string | null;
15
+ /**
16
+ * Start streaming logcat to a session-scoped file. Idempotent: a second call
17
+ * while the first capture is live is a no-op. If a prior capture's child is
18
+ * dead (crashed, killed), it is cleared first so this call starts fresh.
19
+ */
20
+ export declare function startCapture(adbPath: string): string | null;
21
+ export declare function stopCapture(): void;
22
+ /**
23
+ * Check whether the file at `filePath` exceeds ROTATION_BYTES.
24
+ *
25
+ * The caller is responsible for performing rotation — the old approach of
26
+ * truncating in-place was unsafe because the child `adb logcat` process
27
+ * still holds an open fd at its previous write offset; after a truncate
28
+ * the next write lands past the new EOF and produces a sparse file with a
29
+ * null-byte hole. See `rotateCaptureIfNeeded` for the safe rotation path:
30
+ * stop the child, rename the old file, start a new child with a fresh fd.
31
+ *
32
+ * Extracted so the size check is testable without running adb.
33
+ */
34
+ export declare function needsRotation(filePath: string): boolean;
35
+ /**
36
+ * If the active capture has grown past the rotation threshold, cleanly
37
+ * rotate it: stop the child (closing its fd), rename the old file with a
38
+ * `.rotated-<ts>` suffix so it remains readable by consumers for a while
39
+ * (GC reclaims it after the 7-day TTL), and start a fresh child writing
40
+ * to a new file.
41
+ *
42
+ * Only has effect if a capture is currently active. Returns the new file
43
+ * path (or null if no rotation happened or rotation failed).
44
+ */
45
+ export declare function rotateCaptureIfNeeded(adbPath: string): string | null;
46
+ /**
47
+ * Filter captured log lines by tag substring (logcat threadtime format puts
48
+ * the tag after the PID/TID columns), optional grep (case-insensitive), and
49
+ * tail to `limit` most-recent matches.
50
+ *
51
+ * Extracted so the pure logic can be tested without a running adb.
52
+ */
53
+ export declare function filterLogLines(lines: string[], tag: string, limit: number, grep?: string): string[];
54
+ /**
55
+ * Read the last `maxBytes` of a file without loading the whole thing. Returns
56
+ * the tail as a UTF-8 string, dropping any partial first line so the result
57
+ * starts at a line boundary. Keeps memory bounded regardless of file size,
58
+ * which matters once the capture file grows (and once the web dashboard
59
+ * starts polling for logs in a later PR).
60
+ *
61
+ * Extracted for testability.
62
+ */
63
+ export declare function readTailBytes(filePath: string, maxBytes: number): string;
64
+ /**
65
+ * Read recent captured lines matching tag/grep, tailed to `limit`. Returns
66
+ * null when no capture is active (caller falls back to `adb logcat -d`).
67
+ *
68
+ * Reads only the last DEFAULT_TAIL_BYTES of the capture file so cost stays
69
+ * bounded as the file grows. For typical logcat traffic this window covers
70
+ * several thousand lines, which safely exceeds practical `limit` values.
71
+ */
72
+ export declare function readRecentLogs(tag: string, limit: number, grep?: string): string[] | null;
73
+ export declare function registerCleanup(): void;
@@ -0,0 +1,296 @@
1
+ import { spawn } from "child_process";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ /**
6
+ * Streams `adb logcat` to a per-session file so log queries read from a
7
+ * local, non-rotating store instead of the device's ring buffer at query time.
8
+ *
9
+ * The device-side logcat buffer is ~256KB-1MB per buffer and rotates under
10
+ * load; a chatty session can overwrite lines a user wanted to inspect hours
11
+ * later. Capturing to a local file from session-enable onward preserves
12
+ * everything from the enable moment forward.
13
+ *
14
+ * Lifecycle: startCapture on activate, stopCapture on resetState/disable.
15
+ */
16
+ const LOG_DIR = path.join(os.homedir(), ".slack-radar", "logs");
17
+ const ROTATION_BYTES = 100 * 1024 * 1024;
18
+ const ROTATION_KEEP_BYTES = 50 * 1024 * 1024;
19
+ const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
+ const state = {
21
+ child: null,
22
+ filePath: null,
23
+ startedAt: null,
24
+ };
25
+ function ensureLogDir() {
26
+ if (!fs.existsSync(LOG_DIR)) {
27
+ fs.mkdirSync(LOG_DIR, { recursive: true });
28
+ }
29
+ }
30
+ /**
31
+ * Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
32
+ * disk usage without losing recent forensic history. Extracted for test.
33
+ */
34
+ export function gcOldCaptures(dir = LOG_DIR, now = Date.now()) {
35
+ if (!fs.existsSync(dir))
36
+ return 0;
37
+ let removed = 0;
38
+ for (const name of fs.readdirSync(dir)) {
39
+ if (!name.startsWith("radar-") || !name.endsWith(".log"))
40
+ continue;
41
+ const full = path.join(dir, name);
42
+ try {
43
+ const stat = fs.statSync(full);
44
+ if (now - stat.mtimeMs > GC_TTL_MS) {
45
+ fs.unlinkSync(full);
46
+ removed += 1;
47
+ }
48
+ }
49
+ catch {
50
+ // ignore; file may have vanished between readdir and stat
51
+ }
52
+ }
53
+ return removed;
54
+ }
55
+ export function buildCaptureFilePath(now = Date.now()) {
56
+ return path.join(LOG_DIR, `radar-${now}.log`);
57
+ }
58
+ export function isCapturing() {
59
+ return state.child !== null && state.child.exitCode === null;
60
+ }
61
+ export function getCaptureFilePath() {
62
+ return state.filePath;
63
+ }
64
+ /**
65
+ * Start streaming logcat to a session-scoped file. Idempotent: a second call
66
+ * while the first capture is live is a no-op. If a prior capture's child is
67
+ * dead (crashed, killed), it is cleared first so this call starts fresh.
68
+ */
69
+ export function startCapture(adbPath) {
70
+ if (isCapturing())
71
+ return state.filePath;
72
+ // Stale child: clear before restarting.
73
+ if (state.child !== null)
74
+ stopCapture();
75
+ ensureLogDir();
76
+ gcOldCaptures();
77
+ const filePath = buildCaptureFilePath();
78
+ const out = fs.openSync(filePath, "a");
79
+ try {
80
+ const child = spawn(adbPath, ["logcat", "-v", "threadtime"], {
81
+ stdio: ["ignore", out, out],
82
+ detached: false,
83
+ });
84
+ child.on("exit", () => {
85
+ try {
86
+ fs.closeSync(out);
87
+ }
88
+ catch {
89
+ // already closed
90
+ }
91
+ if (state.child === child) {
92
+ state.child = null;
93
+ }
94
+ });
95
+ child.on("error", () => {
96
+ try {
97
+ fs.closeSync(out);
98
+ }
99
+ catch {
100
+ // already closed
101
+ }
102
+ if (state.child === child) {
103
+ state.child = null;
104
+ state.filePath = null;
105
+ state.startedAt = null;
106
+ }
107
+ });
108
+ state.child = child;
109
+ state.filePath = filePath;
110
+ state.startedAt = Date.now();
111
+ // Child bring-up warmup is handled at the MCP handler boundary
112
+ // (handleSessionEnable / handleActivateForUser add a small async delay
113
+ // before returning). Doing it here would require either blocking the
114
+ // single-threaded MCP event loop with a busy-wait or making startCapture
115
+ // async, neither of which earns the complexity.
116
+ return filePath;
117
+ }
118
+ catch {
119
+ try {
120
+ fs.closeSync(out);
121
+ }
122
+ catch {
123
+ // ignore
124
+ }
125
+ return null;
126
+ }
127
+ }
128
+ export function stopCapture() {
129
+ const child = state.child;
130
+ state.child = null;
131
+ state.filePath = null;
132
+ state.startedAt = null;
133
+ if (child && child.exitCode === null) {
134
+ try {
135
+ child.kill("SIGTERM");
136
+ }
137
+ catch {
138
+ // already dead
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * Check whether the file at `filePath` exceeds ROTATION_BYTES.
144
+ *
145
+ * The caller is responsible for performing rotation — the old approach of
146
+ * truncating in-place was unsafe because the child `adb logcat` process
147
+ * still holds an open fd at its previous write offset; after a truncate
148
+ * the next write lands past the new EOF and produces a sparse file with a
149
+ * null-byte hole. See `rotateCaptureIfNeeded` for the safe rotation path:
150
+ * stop the child, rename the old file, start a new child with a fresh fd.
151
+ *
152
+ * Extracted so the size check is testable without running adb.
153
+ */
154
+ export function needsRotation(filePath) {
155
+ try {
156
+ const stat = fs.statSync(filePath);
157
+ return stat.size > ROTATION_BYTES;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
163
+ /**
164
+ * If the active capture has grown past the rotation threshold, cleanly
165
+ * rotate it: stop the child (closing its fd), rename the old file with a
166
+ * `.rotated-<ts>` suffix so it remains readable by consumers for a while
167
+ * (GC reclaims it after the 7-day TTL), and start a fresh child writing
168
+ * to a new file.
169
+ *
170
+ * Only has effect if a capture is currently active. Returns the new file
171
+ * path (or null if no rotation happened or rotation failed).
172
+ */
173
+ export function rotateCaptureIfNeeded(adbPath) {
174
+ const current = state.filePath;
175
+ if (!current || !isCapturing())
176
+ return null;
177
+ if (!needsRotation(current))
178
+ return null;
179
+ stopCapture();
180
+ try {
181
+ const archived = `${current}.rotated-${Date.now()}`;
182
+ fs.renameSync(current, archived);
183
+ }
184
+ catch {
185
+ // Rename failure is non-fatal: the file may have vanished. We still
186
+ // want to start a fresh capture, so fall through to startCapture.
187
+ }
188
+ return startCapture(adbPath);
189
+ }
190
+ /**
191
+ * Filter captured log lines by tag substring (logcat threadtime format puts
192
+ * the tag after the PID/TID columns), optional grep (case-insensitive), and
193
+ * tail to `limit` most-recent matches.
194
+ *
195
+ * Extracted so the pure logic can be tested without a running adb.
196
+ */
197
+ export function filterLogLines(lines, tag, limit, grep) {
198
+ const tagMatches = lines.filter((line) => line.includes(tag));
199
+ const grepMatches = grep
200
+ ? tagMatches.filter((line) => line.toLowerCase().includes(grep.toLowerCase()))
201
+ : tagMatches;
202
+ if (grepMatches.length <= limit)
203
+ return grepMatches;
204
+ return grepMatches.slice(grepMatches.length - limit);
205
+ }
206
+ /**
207
+ * Read the last `maxBytes` of a file without loading the whole thing. Returns
208
+ * the tail as a UTF-8 string, dropping any partial first line so the result
209
+ * starts at a line boundary. Keeps memory bounded regardless of file size,
210
+ * which matters once the capture file grows (and once the web dashboard
211
+ * starts polling for logs in a later PR).
212
+ *
213
+ * Extracted for testability.
214
+ */
215
+ export function readTailBytes(filePath, maxBytes) {
216
+ let stat;
217
+ try {
218
+ stat = fs.statSync(filePath);
219
+ }
220
+ catch {
221
+ return "";
222
+ }
223
+ if (stat.size === 0)
224
+ return "";
225
+ const readSize = Math.min(stat.size, maxBytes);
226
+ const start = stat.size - readSize;
227
+ const fd = fs.openSync(filePath, "r");
228
+ try {
229
+ const buf = Buffer.alloc(readSize);
230
+ fs.readSync(fd, buf, 0, readSize, start);
231
+ const text = buf.toString("utf-8");
232
+ // If we read from mid-file, drop the partial first line so the caller
233
+ // does not see half-parsed content.
234
+ if (start > 0) {
235
+ const firstNewline = text.indexOf("\n");
236
+ if (firstNewline !== -1)
237
+ return text.slice(firstNewline + 1);
238
+ }
239
+ return text;
240
+ }
241
+ finally {
242
+ fs.closeSync(fd);
243
+ }
244
+ }
245
+ // Bounded tail size for tool reads. Large enough to cover most `limit <= 500`
246
+ // queries on chatty traffic; small enough to keep per-read memory predictable.
247
+ // Tuned for the radar-ui polling case (later PR) where many reads per second
248
+ // are plausible.
249
+ const DEFAULT_TAIL_BYTES = 4 * 1024 * 1024;
250
+ /**
251
+ * Read recent captured lines matching tag/grep, tailed to `limit`. Returns
252
+ * null when no capture is active (caller falls back to `adb logcat -d`).
253
+ *
254
+ * Reads only the last DEFAULT_TAIL_BYTES of the capture file so cost stays
255
+ * bounded as the file grows. For typical logcat traffic this window covers
256
+ * several thousand lines, which safely exceeds practical `limit` values.
257
+ */
258
+ export function readRecentLogs(tag, limit, grep) {
259
+ const filePath = state.filePath;
260
+ if (!filePath)
261
+ return null;
262
+ // Note: rotation is not triggered here. Rotating requires an adb path to
263
+ // start the replacement child, and the read path is intentionally sync +
264
+ // adb-free. Callers that have adb (AndroidTransport.getRecentLogs) can
265
+ // invoke rotateCaptureIfNeeded before reading.
266
+ let tail;
267
+ try {
268
+ tail = readTailBytes(filePath, DEFAULT_TAIL_BYTES);
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ const lines = tail.split("\n").filter(Boolean);
274
+ return filterLogLines(lines, tag, limit, grep);
275
+ }
276
+ /**
277
+ * Install once at process start so the child adb logcat is killed when the
278
+ * MCP server exits. Prevents orphaned adb streams that would hold the
279
+ * device and accumulate across restarts.
280
+ */
281
+ let cleanupRegistered = false;
282
+ export function registerCleanup() {
283
+ if (cleanupRegistered)
284
+ return;
285
+ cleanupRegistered = true;
286
+ const cleanup = () => stopCapture();
287
+ process.on("exit", cleanup);
288
+ process.on("SIGINT", () => {
289
+ cleanup();
290
+ process.exit(130);
291
+ });
292
+ process.on("SIGTERM", () => {
293
+ cleanup();
294
+ process.exit(143);
295
+ });
296
+ }
@@ -62,4 +62,36 @@ export interface DeviceTransport {
62
62
  * "device not connected" message. Optional; implementations may omit it.
63
63
  */
64
64
  getLastForwardError?(): string | null;
65
+ /**
66
+ * Returns the path of the session-scoped log capture file, or null if no
67
+ * capture is running. Surfaced in tool responses so the model can reason
68
+ * about which file's contents it just saw and detect staleness across
69
+ * profile switches or session restarts. Optional; platforms without
70
+ * file-backed capture may omit it.
71
+ */
72
+ getCaptureFilePath?(): string | null;
73
+ /**
74
+ * List profiles the debug build is running under. Android uses this to
75
+ * expose personal vs work profile; iOS will be a no-op or single entry.
76
+ * Optional; platforms without multi-user models may omit it.
77
+ */
78
+ listProfiles?(): {
79
+ userId: string;
80
+ label: string;
81
+ }[];
82
+ /**
83
+ * Activate the on-device radar server for a specific user profile.
84
+ * Optional; only implementations that implement listProfiles should
85
+ * implement this.
86
+ *
87
+ * @param userId The user ID from listProfiles
88
+ * @param timeoutMinutes Auto-shutdown timeout in minutes
89
+ */
90
+ 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;
65
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.1.2",
3
+ "version": "1.2.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",