@slack/radar-mcp 1.2.0 → 1.4.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/README.md +16 -13
- package/dist/mcp/index.js +568 -347
- package/dist/mcp/snapshot.d.ts +63 -0
- package/dist/mcp/snapshot.js +301 -0
- package/dist/mcp/tools.js +165 -10
- package/dist/shared/android.d.ts +3 -2
- package/dist/shared/android.js +8 -7
- package/dist/shared/constants.d.ts +6 -0
- package/dist/shared/constants.js +6 -0
- package/dist/shared/logcat-capture.d.ts +15 -0
- package/dist/shared/logcat-capture.js +118 -0
- package/dist/shared/transport.d.ts +9 -6
- package/dist/web/bin.js +45 -30
- package/dist/web/public/index.html +771 -726
- package/dist/web/server.d.ts +27 -0
- package/dist/web/server.js +549 -28
- package/dist/web/spec.d.ts +83 -0
- package/dist/web/spec.js +135 -0
- package/package.json +2 -1
|
@@ -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SOURCES, VIZ, SERIES_METRICS } from "../web/spec.js";
|
|
1
2
|
export const SERVER_INSTRUCTIONS = [
|
|
2
3
|
"Slack Radar provides on-device debugging tools for the Slack Android app via ADB.",
|
|
3
4
|
"",
|
|
@@ -22,6 +23,7 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
22
23
|
'- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
|
|
23
24
|
'- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
|
|
24
25
|
'- "check the debug logs" -> get_recent_logs(tag="...")',
|
|
26
|
+
'- "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
27
|
"",
|
|
26
28
|
"Key behaviors:",
|
|
27
29
|
"- Network/RTM buffers clear on shutdown. Clogs persist from app start.",
|
|
@@ -30,29 +32,80 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
30
32
|
"- RTM event types use subtypes when available (e.g. message_changed, not message).",
|
|
31
33
|
"- Use wait_for_events with idle_timeout_ms instead of polling loops.",
|
|
32
34
|
"- Use ping(timeout_minutes=N) to extend sessions. Monitor remaining_minutes proactively.",
|
|
35
|
+
"- ping reports which app/profile is bound (android_user_id, profile, package). The debug",
|
|
36
|
+
" build can run on more than one user (personal=0, plus a work profile or secondary user),",
|
|
37
|
+
" and only the first to bind the socket wins. If events seem to come from the wrong app, tell",
|
|
38
|
+
" the user which profile is bound and use list_profiles + activate_for_user to switch.",
|
|
33
39
|
"- Screenshots default to 720px. Use full_res=true only when detail matters.",
|
|
34
40
|
"- Setup: /slack-radar-setup",
|
|
35
41
|
"- Manual enable (no skill): adb shell am broadcast -a slack.debug.ENABLE_RADAR --ei timeout_minutes 5 -p com.Slack.internal.debug",
|
|
36
42
|
" Then: adb forward tcp:8099 localabstract:slack-radar",
|
|
37
43
|
"",
|
|
38
44
|
"WEB DASHBOARD (/radar-ui):",
|
|
39
|
-
"A browser-based dashboard (http://localhost:8100) shows the same live device data
|
|
40
|
-
"
|
|
45
|
+
"A browser-based dashboard (http://localhost:8100) shows the same live device data as",
|
|
46
|
+
"tabs: Live (everything), Network, RTM, Clogs, and Logs, each with counters, a graph, a",
|
|
47
|
+
"filterable feed, and a payload inspector. Launched via the /radar-ui skill, which runs",
|
|
48
|
+
"`npx @slack/radar-mcp web` (a global install also exposes it as `slack-radar`).",
|
|
41
49
|
"",
|
|
42
|
-
"
|
|
50
|
+
"PROACTIVELY offer the dashboard when it would help — do not wait to be asked. The moment a",
|
|
51
|
+
"device session turns visual or multi-step, say so and offer to open it, e.g.: \"This is",
|
|
52
|
+
"easier to watch live — want me to open the Radar dashboard?\" Offer (once per thread) when:",
|
|
43
53
|
'- User says "watch", "monitor", "let me see", "show me live" about device traffic',
|
|
44
54
|
"- User wants to share traffic with a colleague, pair, or teammate",
|
|
45
55
|
"- User is doing a multi-minute investigation and the transcript is getting noisy",
|
|
46
56
|
'- User explicitly asks about a GUI, UI, browser view, or dashboard',
|
|
47
57
|
"",
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
58
|
+
"OFFER, then open ONLY AFTER THE USER AGREES — opening a browser window is a user-visible",
|
|
59
|
+
"action; never pop one unbidden. On a yes, run the /radar-ui skill (or tell them to run",
|
|
60
|
+
"`npx @slack/radar-mcp web`) and hand over the URL. Nudge, do not auto-open.",
|
|
61
|
+
"DO NOT offer for single-query debugging or when the user is happy reading results in the",
|
|
62
|
+
"transcript. Offer when visual inspection adds real value.",
|
|
63
|
+
"",
|
|
64
|
+
"CUSTOM DASHBOARD (open_radar_dashboard):",
|
|
65
|
+
"The pre-built tabs (Live/Network/RTM/Clogs/Logs) cover the common cases. When the user",
|
|
66
|
+
"describes a live view the tabs do not show, SUGGEST building it; invoke open_radar_dashboard",
|
|
67
|
+
"only after they agree or explicitly ask for a custom view/dashboard (same discipline as",
|
|
68
|
+
"/radar-ui). To build, translate their words into a spec and call the tool; it renders your",
|
|
69
|
+
"spec as a 'Custom' tab on the dashboard (default http://localhost:8100) and returns the URL",
|
|
70
|
+
"for you to share. Cases worth suggesting it for:",
|
|
71
|
+
'- "graph calls per second vs average response time and flag the slow ones"',
|
|
72
|
+
'- "count every DM I receive and beep when one arrives"',
|
|
73
|
+
'- "show only RTM events on channel C... and let me click each for its payload"',
|
|
74
|
+
'- "watch network calls whose body contains <substring>"',
|
|
75
|
+
"Authoring rules:",
|
|
76
|
+
"- source is one of network/rtm/clog/log/all ('all' = one unified timeline).",
|
|
77
|
+
"- match filters on SUMMARY fields only, by source: network=url/method/status_code,",
|
|
78
|
+
" rtm=event_type/direction/channel, clog=event_name, log=tag/level/message/pid/package.",
|
|
79
|
+
" NEVER invent a field; a match on a field the stream does not carry silently matches",
|
|
80
|
+
" nothing. To filter on body text use bodyContains (network/all only), which fetches detail.",
|
|
81
|
+
"- viz composes counter/rate/sparkline/feed/inspector/graph. graph needs a series array",
|
|
82
|
+
" (1-2 entries; metric ratePerSec/count/avg/errorRate; avg needs a field like duration_ms).",
|
|
83
|
+
"- highlight {field, op, value} flags rows breaching a numeric threshold; alert {sound,flash,",
|
|
84
|
+
" say, at} fires on each live match (or once at the Nth). Author an honest one-line `summary`.",
|
|
85
|
+
"Calling it again replaces/refines the live custom dashboard. The dashboard reads a connected",
|
|
86
|
+
"device, so Radar must be armed to show data (it has its own Reconnect button). Debug builds",
|
|
87
|
+
"only; local only. Does not open a browser window for you; surface the returned URL to the user.",
|
|
88
|
+
"",
|
|
89
|
+
"CAPTURE_SNAPSHOT OUTPUT:",
|
|
90
|
+
"When capture_snapshot returns, tell the user:",
|
|
91
|
+
" 1. The result.zip_path if non-null (the single shareable file). Fall back to",
|
|
92
|
+
" result.path (a directory) if zip_path is null.",
|
|
93
|
+
" 2. A one-line summary of counts (network/rtm/clogs/log_lines/screenshot).",
|
|
94
|
+
" 3. Print this EXACT sentence as the privacy line, verbatim — no paraphrase, no",
|
|
95
|
+
" compression, no skipping even under time pressure:",
|
|
96
|
+
' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally."',
|
|
97
|
+
" 4. If result.log_lines_truncated is true, say: 'you got fewer log lines than",
|
|
98
|
+
" requested because the 1 MB read cap was the limiting factor.'",
|
|
99
|
+
" 5. If the user's original ask was 'file this as a bug' / 'attach to ticket' /",
|
|
100
|
+
" similar, offer to attach result.zip_path via the Jira MCP or Slack MCP as",
|
|
101
|
+
" the next step. Do NOT invoke the attach without asking. Just offer.",
|
|
102
|
+
" 6. If result.errors is non-empty, enumerate which parts failed so the user",
|
|
103
|
+
" knows what is missing before they ship the bundle.",
|
|
51
104
|
].join("\n");
|
|
52
105
|
export const TOOL_DEFINITIONS = [
|
|
53
106
|
{
|
|
54
107
|
name: "ping",
|
|
55
|
-
description: "Check if Slack Radar is running. Returns uptime, buffer sizes, remaining session time, and
|
|
108
|
+
description: "Check if Slack Radar is running. Returns uptime, buffer sizes, remaining session time, stream status, and which app/profile is bound: android_user_id (0 = personal, non-zero = a secondary user such as a work profile), profile ('personal'/'secondary'), and package.",
|
|
56
109
|
inputSchema: {
|
|
57
110
|
type: "object",
|
|
58
111
|
properties: {
|
|
@@ -189,19 +242,19 @@ export const TOOL_DEFINITIONS = [
|
|
|
189
242
|
},
|
|
190
243
|
{
|
|
191
244
|
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
|
|
245
|
+
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 a secondary user such as a work profile) or when logs/app state seem to point at the wrong profile. Returns each profile's userId (e.g. '0' for personal, '10' for a secondary user) and a human label.",
|
|
193
246
|
inputSchema: { type: "object", properties: {} },
|
|
194
247
|
annotations: { readOnlyHint: true },
|
|
195
248
|
},
|
|
196
249
|
{
|
|
197
250
|
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
|
|
251
|
+
description: "Activate Slack Radar on a specific Android user profile. Use after list_profiles to switch between the personal user and a secondary user (such as a work profile). 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
252
|
inputSchema: {
|
|
200
253
|
type: "object",
|
|
201
254
|
properties: {
|
|
202
255
|
user_id: {
|
|
203
256
|
type: "string",
|
|
204
|
-
description: "Android user ID from list_profiles (e.g. '0' for personal, '10' for
|
|
257
|
+
description: "Android user ID from list_profiles (e.g. '0' for personal, '10' for a secondary user).",
|
|
205
258
|
},
|
|
206
259
|
timeout_minutes: {
|
|
207
260
|
type: "number",
|
|
@@ -212,6 +265,30 @@ export const TOOL_DEFINITIONS = [
|
|
|
212
265
|
},
|
|
213
266
|
annotations: {},
|
|
214
267
|
},
|
|
268
|
+
{
|
|
269
|
+
name: "capture_snapshot",
|
|
270
|
+
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.",
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: "object",
|
|
273
|
+
properties: {
|
|
274
|
+
include_logs: {
|
|
275
|
+
type: "boolean",
|
|
276
|
+
description: "Include last N lines of the active logcat capture (default: true). No-op when no capture is active.",
|
|
277
|
+
},
|
|
278
|
+
include_screenshot: {
|
|
279
|
+
type: "boolean",
|
|
280
|
+
description: "Include a current screenshot (default: true). Requires a connected device.",
|
|
281
|
+
},
|
|
282
|
+
log_tail_lines: {
|
|
283
|
+
type: "number",
|
|
284
|
+
description: "How many trailing lines of the capture file to include (default: 500, min: 1, max: 10000). Truncation flagged in counts.log_lines_truncated.",
|
|
285
|
+
minimum: 1,
|
|
286
|
+
maximum: 10000,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
annotations: { readOnlyHint: false },
|
|
291
|
+
},
|
|
215
292
|
{
|
|
216
293
|
name: "screenshot",
|
|
217
294
|
description: "Capture a screenshot from the connected Android device.",
|
|
@@ -257,6 +334,79 @@ export const TOOL_DEFINITIONS = [
|
|
|
257
334
|
inputSchema: { type: "object", properties: {} },
|
|
258
335
|
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
259
336
|
},
|
|
337
|
+
{
|
|
338
|
+
name: "open_radar_dashboard",
|
|
339
|
+
description: "Build or refine a custom live dashboard tab in the Slack Radar web UI from a spec YOU author. " +
|
|
340
|
+
"Suggest this when the user describes a live view the pre-built tabs (Live/Network/RTM/Clogs/Logs) do not cover " +
|
|
341
|
+
"(e.g. 'graph calls per second vs response time and flag slow ones', 'beep when I get a DM'); invoke it once they agree. " +
|
|
342
|
+
"Translate the request into the spec fields below and call this tool; it ensures the web dashboard is " +
|
|
343
|
+
"running (default http://localhost:8100) and renders your spec as a 'Custom' tab, then returns the URL to share. " +
|
|
344
|
+
"Calling again replaces/refines it. Does not open a browser window for the user. The dashboard reads a connected " +
|
|
345
|
+
"device, so Radar must be armed to show data (the dashboard has its own Reconnect button). Debug builds only, local only.",
|
|
346
|
+
inputSchema: {
|
|
347
|
+
type: "object",
|
|
348
|
+
properties: {
|
|
349
|
+
title: { type: "string", description: "Short 1-3 word dashboard title; also the Custom tab label." },
|
|
350
|
+
summary: {
|
|
351
|
+
type: "string",
|
|
352
|
+
description: "ONE honest plain-English line describing what this shows, including any active filter.",
|
|
353
|
+
},
|
|
354
|
+
source: {
|
|
355
|
+
type: "string",
|
|
356
|
+
enum: [...SOURCES],
|
|
357
|
+
description: "Which device stream to read. 'all' is a unified timeline mixing network, RTM, and clog.",
|
|
358
|
+
},
|
|
359
|
+
match: {
|
|
360
|
+
type: "object",
|
|
361
|
+
description: "Case-insensitive substring filter on SUMMARY fields, keyed by source. network: url/method/status_code; " +
|
|
362
|
+
"rtm: event_type/direction/channel; clog: event_name; log: tag/level/package. NEVER invent a field " +
|
|
363
|
+
"(a match on an absent field silently matches nothing). For body text use bodyContains instead.",
|
|
364
|
+
},
|
|
365
|
+
bodyContains: {
|
|
366
|
+
type: "string",
|
|
367
|
+
description: "network/all only: keep calls whose request/response BODY contains this substring (fetches each call's detail).",
|
|
368
|
+
},
|
|
369
|
+
viz: {
|
|
370
|
+
type: "array",
|
|
371
|
+
items: { type: "string", enum: [...VIZ] },
|
|
372
|
+
description: "Widgets to compose. 'graph' requires a series array.",
|
|
373
|
+
},
|
|
374
|
+
rateWindowSec: {
|
|
375
|
+
type: "number",
|
|
376
|
+
description: "Sliding window in seconds for the rate widget (default 10).",
|
|
377
|
+
},
|
|
378
|
+
series: {
|
|
379
|
+
type: "array",
|
|
380
|
+
description: "Required when viz includes 'graph'. 1-2 entries.",
|
|
381
|
+
items: {
|
|
382
|
+
type: "object",
|
|
383
|
+
properties: {
|
|
384
|
+
label: { type: "string" },
|
|
385
|
+
metric: { type: "string", enum: [...SERIES_METRICS] },
|
|
386
|
+
field: { type: "string", description: "Required for the 'avg' metric, e.g. duration_ms." },
|
|
387
|
+
unit: { type: "string" },
|
|
388
|
+
},
|
|
389
|
+
required: ["metric"],
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
extract: {
|
|
393
|
+
type: "object",
|
|
394
|
+
description: "Pull CONTENT from a record body into the feed: {parse:'response_body'|'request_body'|'data', field:'dot.path', label}.",
|
|
395
|
+
},
|
|
396
|
+
highlight: {
|
|
397
|
+
type: "object",
|
|
398
|
+
description: "Flag feed rows whose numeric field breaches a threshold: {field, op:'>'|'>='|'<'|'<='|'==', value, label}.",
|
|
399
|
+
},
|
|
400
|
+
alert: {
|
|
401
|
+
type: "object",
|
|
402
|
+
description: "Sentry mode: {sound, flash, say, at}. 'at':N fires once when the match count reaches N; omit to fire on every match.",
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
required: ["title", "summary", "source", "viz"],
|
|
406
|
+
},
|
|
407
|
+
// Not read-only: it starts a local web server and pushes a spec to it.
|
|
408
|
+
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
409
|
+
},
|
|
260
410
|
];
|
|
261
411
|
/**
|
|
262
412
|
* Check whether a tool call should be blocked by the session gate.
|
|
@@ -271,6 +421,11 @@ export function sessionGate(toolName, sessionEnabled) {
|
|
|
271
421
|
if (toolName === "ping") {
|
|
272
422
|
return null;
|
|
273
423
|
}
|
|
424
|
+
// open_radar_dashboard launches an independent local web process with its own
|
|
425
|
+
// device-enable (Reconnect) button, so it does not require the MCP session enabled.
|
|
426
|
+
if (toolName === "open_radar_dashboard") {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
274
429
|
if (!sessionEnabled) {
|
|
275
430
|
return {
|
|
276
431
|
content: [
|
package/dist/shared/android.d.ts
CHANGED
|
@@ -13,8 +13,9 @@ export declare function resolveAdbPath(): string | null;
|
|
|
13
13
|
/**
|
|
14
14
|
* Parse the output of `ps -A -o USER,NAME` to extract the Android user ID
|
|
15
15
|
* for the debug build process. When multiple profiles are running, prefers
|
|
16
|
-
*
|
|
17
|
-
* Returns "0" for primary user,
|
|
16
|
+
* a secondary user (user ID > 0) since that's the typical debugging target.
|
|
17
|
+
* Returns "0" for the primary user, or the first secondary user id (commonly
|
|
18
|
+
* "10", which is often a work profile, but the id alone cannot prove that).
|
|
18
19
|
*/
|
|
19
20
|
export declare function parseUserFromPsOutput(psOutput: string): string;
|
|
20
21
|
export declare class AndroidTransport implements DeviceTransport {
|