@slack/radar-mcp 1.5.0 → 1.7.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 +5 -4
- package/dist/mcp/api-paths.d.ts +1 -0
- package/dist/mcp/api-paths.js +108 -0
- package/dist/mcp/db-tools.d.ts +46 -0
- package/dist/mcp/db-tools.js +160 -0
- package/dist/mcp/index.js +84 -80
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/tools.js +70 -7
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +30 -13
- package/dist/shared/constants.d.ts +15 -0
- package/dist/shared/constants.js +24 -0
- package/dist/shared/db.d.ts +31 -14
- package/dist/shared/db.js +44 -32
- package/dist/shared/debug-log.d.ts +50 -0
- package/dist/shared/debug-log.js +108 -0
- package/dist/shared/screen.d.ts +35 -2
- package/dist/shared/screen.js +75 -6
- package/dist/shared/stream.d.ts +1 -1
- package/dist/shared/transport.d.ts +22 -4
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +133 -19
- package/dist/web/public/index.html +244 -1069
- package/dist/web/public/next/app.js +210 -0
- package/dist/web/public/next/db.js +286 -0
- package/dist/web/public/next/esc.js +13 -0
- package/dist/web/public/next/feed.js +608 -0
- package/dist/web/public/next/filters.js +120 -0
- package/dist/web/public/next/hooks.js +57 -0
- package/dist/web/public/next/html.js +10 -0
- package/dist/web/public/next/inspector.js +258 -0
- package/dist/web/public/next/registry.js +65 -0
- package/dist/web/public/next/screen.js +180 -0
- package/dist/web/public/next/spec-types.js +1 -0
- package/dist/web/public/next/store.js +714 -0
- package/dist/web/public/next/widgets.js +194 -0
- package/dist/web/public/vendor/README.md +25 -0
- package/dist/web/public/vendor/hooks.module.js +2 -0
- package/dist/web/public/vendor/htm.LICENSE +202 -0
- package/dist/web/public/vendor/htm.module.js +1 -0
- package/dist/web/public/vendor/preact.LICENSE +21 -0
- package/dist/web/public/vendor/preact.module.js +2 -0
- package/dist/web/server.d.ts +17 -0
- package/dist/web/server.js +194 -16
- package/package.json +6 -2
package/dist/web/server.js
CHANGED
|
@@ -5,11 +5,11 @@ import path from "path";
|
|
|
5
5
|
import { spawn } from "child_process";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { AndroidTransport, resolveAdbPath } from "../shared/android.js";
|
|
8
|
-
import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js";
|
|
8
|
+
import { RADAR_HOST, RADAR_PORT, RADAR_VERSION, WEB_PORT_DEFAULT } from "../shared/constants.js";
|
|
9
9
|
import { cleanupPulledDatabases, initializeDatabasePath, listDatabases, pullDatabase, pulledSize, queryDatabase, resetDbState, } from "../shared/db.js";
|
|
10
10
|
import { LogSession } from "./log-session.js";
|
|
11
11
|
import { BLANK_SPEC, coerceSpec, validateSpec } from "./spec.js";
|
|
12
|
-
import { screenCapabilities, grantScreenConsent, hasScreenConsent, detectDisplay, displayNote, captureScreenshot, startLive, addViewer, removeViewer, primeIfNeeded, startRecording, stopRecording, recordingPath, resetScreenState, registerScreenCleanup, resolveFfmpeg, ffmpegInstallHint, SCREEN_BOUNDARY, } from "../shared/screen.js";
|
|
12
|
+
import { screenCapabilities, refreshScreenCapabilities, grantScreenConsent, hasScreenConsent, detectDisplay, displayNote, captureScreenshot, startLive, addViewer, removeViewer, reapLiveIfIdle, primeIfNeeded, startRecording, stopRecording, recordingPath, resetScreenState, registerScreenCleanup, resolveFfmpeg, ffmpegInstallHint, SCREEN_BOUNDARY, } from "../shared/screen.js";
|
|
13
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? WEB_PORT_DEFAULT);
|
|
15
15
|
const INDEX_PATH = path.join(__dirname, "public", "index.html");
|
|
@@ -104,25 +104,58 @@ function initialSpec() {
|
|
|
104
104
|
return BLANK_SPEC;
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Write the starting spec for a server that is taking ownership of this port. MUST be
|
|
109
|
+
* called only AFTER a successful listen() bind, never at module load: importing this
|
|
110
|
+
* module (which bin.ts does before it knows whether the port is free) must not touch
|
|
111
|
+
* the shared spec file, or a second launcher would wipe the spec of the dashboard that
|
|
112
|
+
* already owns the port. The post-bind caller is the sole legitimate writer.
|
|
113
|
+
*/
|
|
114
|
+
export function seedInitialSpec() {
|
|
115
|
+
writeSpec(initialSpec());
|
|
116
|
+
}
|
|
117
|
+
// Initialize the database path with the web server port, so concurrent dashboards on
|
|
118
|
+
// different ports do not collide in tmpdir. This only COMPUTES a path string (no filesystem
|
|
119
|
+
// write), so it is safe to run at module load even for a launcher that loses the port bind.
|
|
110
120
|
initializeDatabasePath(WEB_PORT);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Wipe stale pulled-DB copies and arm the cleanup-on-exit handlers for THIS process. MUST be
|
|
123
|
+
* called only AFTER a successful listen() bind, never at module load: the pulled-DB tmp dir is
|
|
124
|
+
* scoped by WEB_PORT, so it is SHARED by every launcher that imports this module on the same
|
|
125
|
+
* port. A second launcher that loses the bind must not rmSync that dir, or it wipes the
|
|
126
|
+
* running dashboard's pulled-DB cache and breaks its open DB-browser tab until it re-pulls.
|
|
127
|
+
* Same post-bind discipline as seedInitialSpec: the port owner is the sole legitimate writer.
|
|
128
|
+
*/
|
|
129
|
+
export function cleanupOnBind() {
|
|
130
|
+
// Pulled DB copies are plaintext message stores; never let them linger across runs.
|
|
131
|
+
cleanupPulledDatabases();
|
|
132
|
+
for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
|
|
133
|
+
process.once(sig, cleanupPulledDatabases);
|
|
134
|
+
}
|
|
116
135
|
}
|
|
117
136
|
export function createServer() {
|
|
118
137
|
registerScreenCleanup(); // tear down any live screen cast on process exit/signals
|
|
119
138
|
const server = http.createServer((req, res) => {
|
|
120
139
|
const url = req.url ?? "";
|
|
121
|
-
|
|
140
|
+
const urlPath = url.split("?")[0];
|
|
141
|
+
if (urlPath === "/" || urlPath === "/index.html") {
|
|
142
|
+
// The Preact dashboard is the default (and only) UI. The old vanilla dashboard + its
|
|
143
|
+
// ?next opt-in seam were retired in the flip; index.html IS the Preact shell now.
|
|
122
144
|
serveIndex(res);
|
|
123
145
|
return;
|
|
124
146
|
}
|
|
147
|
+
// Dashboard client modules (vendor/, next/). GET serves the body; HEAD serves headers
|
|
148
|
+
// only (a proxy/cache validator hitting a real asset should not get a 404).
|
|
149
|
+
const inm = req.headers["if-none-match"];
|
|
150
|
+
if ((req.method === "GET" || req.method === "HEAD") && serveStatic(urlPath, res, req.method === "HEAD", Array.isArray(inm) ? inm[0] : inm)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
125
153
|
if (url === "/spec" && req.method === "GET") {
|
|
154
|
+
// Version marker for a second launcher's adopt path: it can compare this against its own
|
|
155
|
+
// RADAR_VERSION and warn on a mismatch (it still adopts; it never kills the holder).
|
|
156
|
+
// A header, not a body field, so the /spec fingerprint and the client's change-detection
|
|
157
|
+
// key (JSON.stringify of the body) stay byte-identical.
|
|
158
|
+
res.setHeader("X-Radar-Version", RADAR_VERSION);
|
|
126
159
|
sendJson(res, 200, loadSpec());
|
|
127
160
|
return;
|
|
128
161
|
}
|
|
@@ -196,6 +229,88 @@ function serveIndex(res) {
|
|
|
196
229
|
res.end(`Failed to read ${INDEX_PATH}: ${e.message}`);
|
|
197
230
|
}
|
|
198
231
|
}
|
|
232
|
+
// Static assets for the dashboard client. The dashboard was historically one
|
|
233
|
+
// self-contained index.html; the UI is being migrated to Preact components (container by
|
|
234
|
+
// container), which the browser fetches as ES modules over HTTP. This serves only the
|
|
235
|
+
// vendored runtime (vendor/) and the UI modules (next/): locked to those two dirs, .js
|
|
236
|
+
// only, no path traversal, from the known public root. The listener is already
|
|
237
|
+
// loopback-only (bin.ts), so this adds no network exposure.
|
|
238
|
+
const PUBLIC_DIR = path.join(__dirname, "public");
|
|
239
|
+
// vendor/ exists today; next/ is populated by the in-progress Preact UI migration (the very
|
|
240
|
+
// next PRs). Keeping it here now means those module fetches resolve the moment they land; until
|
|
241
|
+
// then a /next/* request is a harmless 404 (the dir does not exist, readFileSync misses).
|
|
242
|
+
const STATIC_DIRS = ["vendor", "next"];
|
|
243
|
+
// Canonicalized public root, resolved once at module load. The realpath containment check
|
|
244
|
+
// below compares a resolved file path against this; if we compared against the raw PUBLIC_DIR
|
|
245
|
+
// while the install prefix itself is a symlink (a global npm prefix, or macOS /tmp ->
|
|
246
|
+
// /private/tmp), realpathSync(full) would resolve the prefix too and never start with the
|
|
247
|
+
// un-resolved PUBLIC_DIR, so EVERY legitimate module would 403 and the UI would silently fail
|
|
248
|
+
// to load. Resolve the root the same way so the comparison is apples-to-apples. Falls back to
|
|
249
|
+
// PUBLIC_DIR if the dir does not exist yet (e.g. a build without vendored assets).
|
|
250
|
+
const PUBLIC_REAL = (() => {
|
|
251
|
+
try {
|
|
252
|
+
return fs.realpathSync(PUBLIC_DIR);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return PUBLIC_DIR;
|
|
256
|
+
}
|
|
257
|
+
})();
|
|
258
|
+
// Cache policy for vendored modules: revalidate every time (no-cache), but serve a cheap 304
|
|
259
|
+
// when the file is unchanged via an ETag derived from its size+mtime. This cuts redundant
|
|
260
|
+
// transfers WITHOUT the stale-after-upgrade footgun a plain max-age would create: the module
|
|
261
|
+
// URLs are version-less and have no content hash, so an npm upgrade that swaps vendor/ would
|
|
262
|
+
// otherwise serve stale JS from a long-lived cache for up to the max-age window.
|
|
263
|
+
// `clean` is the already-query-stripped request path. `isHead` sends headers with no body.
|
|
264
|
+
function serveStatic(clean, res, isHead, ifNoneMatch) {
|
|
265
|
+
const top = clean.split("/")[1];
|
|
266
|
+
if (!STATIC_DIRS.includes(top))
|
|
267
|
+
return false;
|
|
268
|
+
if (!clean.endsWith(".js"))
|
|
269
|
+
return false; // only JS modules; no arbitrary file types
|
|
270
|
+
const full = path.normalize(path.join(PUBLIC_DIR, clean));
|
|
271
|
+
// Lexical containment: the normalized path must stay under PUBLIC_DIR (defeats ../).
|
|
272
|
+
if (full !== PUBLIC_DIR && !full.startsWith(PUBLIC_DIR + path.sep)) {
|
|
273
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
274
|
+
res.end("forbidden");
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
// path.normalize is lexical only; a symlink inside vendor/ could still point outside the
|
|
279
|
+
// root. Resolve the real path and re-check containment against the canonicalized root
|
|
280
|
+
// (PUBLIC_REAL, not the raw PUBLIC_DIR) before reading.
|
|
281
|
+
const real = fs.realpathSync(full);
|
|
282
|
+
if (real !== PUBLIC_REAL && !real.startsWith(PUBLIC_REAL + path.sep)) {
|
|
283
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
284
|
+
res.end("forbidden");
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
// Weak ETag from size + mtime: changes whenever the file is rebuilt/upgraded, so a stale
|
|
288
|
+
// browser copy revalidates to a fresh body instead of being served from cache.
|
|
289
|
+
const st = fs.statSync(real);
|
|
290
|
+
const etag = `W/"${st.size.toString(16)}-${Math.round(st.mtimeMs).toString(16)}"`;
|
|
291
|
+
if (ifNoneMatch === etag) {
|
|
292
|
+
res.writeHead(304, { ETag: etag, "Cache-Control": "no-cache" });
|
|
293
|
+
res.end();
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
const body = fs.readFileSync(real);
|
|
297
|
+
// Set Content-Length explicitly so a HEAD mirrors the GET's framing (RFC 7230): a
|
|
298
|
+
// cache validator doing HEAD gets the size, and GET returns a real length not chunked.
|
|
299
|
+
// no-cache = always revalidate (cheap 304 above); never serve a stale module after upgrade.
|
|
300
|
+
res.writeHead(200, {
|
|
301
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
302
|
+
"Content-Length": body.length,
|
|
303
|
+
"Cache-Control": "no-cache",
|
|
304
|
+
ETag: etag,
|
|
305
|
+
});
|
|
306
|
+
res.end(isHead ? undefined : body);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
310
|
+
res.end("not found");
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
199
314
|
function sendJson(res, status, payload) {
|
|
200
315
|
// A device probe can fire both "end" and "error"/"timeout" for one request, so its
|
|
201
316
|
// callbacks may call sendJson twice. The second writeHead would throw
|
|
@@ -253,11 +368,20 @@ function handleHealth(res) {
|
|
|
253
368
|
// drop), so guard the response to exactly one write. Without this the second sendJson
|
|
254
369
|
// does a second writeHead -> ERR_HTTP_HEADERS_SENT -> uncaught -> the whole server dies.
|
|
255
370
|
let replied = false;
|
|
256
|
-
|
|
371
|
+
// The bound profile, read from the device ping body. This is the TRUTH about which
|
|
372
|
+
// profile owns the socket (the app that bound first), unlike the host-side activate
|
|
373
|
+
// guess in getActiveUserId() which can drift. The dashboard shows this so it never
|
|
374
|
+
// claims a profile you are not actually seeing.
|
|
375
|
+
const reply = (device, boundUserId, boundProfile) => {
|
|
257
376
|
if (replied)
|
|
258
377
|
return;
|
|
259
378
|
replied = true;
|
|
260
|
-
sendJson(res, 200, {
|
|
379
|
+
sendJson(res, 200, {
|
|
380
|
+
device,
|
|
381
|
+
screen: screenCapabilities(),
|
|
382
|
+
boundUserId: boundUserId ?? null,
|
|
383
|
+
boundProfile: boundProfile ?? null,
|
|
384
|
+
});
|
|
261
385
|
};
|
|
262
386
|
const probe = http.request({
|
|
263
387
|
host: RADAR_HOST,
|
|
@@ -266,8 +390,38 @@ function handleHealth(res) {
|
|
|
266
390
|
method: "GET",
|
|
267
391
|
timeout: 2500,
|
|
268
392
|
}, (up) => {
|
|
269
|
-
|
|
270
|
-
|
|
393
|
+
// The ping body is the device's own small JSON status. Bound the accumulation anyway so
|
|
394
|
+
// a misbehaving/compromised local endpoint cannot grow it without limit (matches the
|
|
395
|
+
// bounded-read posture elsewhere in this file). 64KB is far above any real ping body;
|
|
396
|
+
// once tripped we stop appending and parse what we have (a truncated body simply fails
|
|
397
|
+
// the JSON.parse below and falls back to no bound profile, which is the safe default).
|
|
398
|
+
const MAX_PING_BODY = 64 * 1024;
|
|
399
|
+
let body = "";
|
|
400
|
+
up.on("data", (c) => {
|
|
401
|
+
if (body.length < MAX_PING_BODY)
|
|
402
|
+
body += c;
|
|
403
|
+
});
|
|
404
|
+
up.on("end", () => {
|
|
405
|
+
const ok = up.statusCode === 200;
|
|
406
|
+
let uid = null;
|
|
407
|
+
let profile = null;
|
|
408
|
+
// Only trust the body on a 200. A device 5xx-ing with a stale ping body must NOT
|
|
409
|
+
// surface a bound profile while device:false — that would let the dashboard show a
|
|
410
|
+
// confident profile label for an unhealthy device.
|
|
411
|
+
if (ok) {
|
|
412
|
+
try {
|
|
413
|
+
const p = JSON.parse(body);
|
|
414
|
+
if (p.android_user_id != null)
|
|
415
|
+
uid = String(p.android_user_id);
|
|
416
|
+
if (typeof p.profile === "string")
|
|
417
|
+
profile = p.profile;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// non-JSON / partial body: device is up but we cannot name the profile
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
reply(ok, uid, profile);
|
|
424
|
+
});
|
|
271
425
|
up.on("error", () => reply(false));
|
|
272
426
|
});
|
|
273
427
|
probe.on("error", () => reply(false));
|
|
@@ -546,8 +700,28 @@ async function handleDbQuery(req, res) {
|
|
|
546
700
|
function handleScreen(req, res) {
|
|
547
701
|
const url = req.url ?? "";
|
|
548
702
|
// Capabilities + consent state. Always available; drives the overlay's degrade UI.
|
|
703
|
+
// ?refresh=1 re-probes ffmpeg/scrcpy (asynchronously, so it never blocks the event
|
|
704
|
+
// loop / live stream) before reading, so a user who installs ffmpeg then reopens the
|
|
705
|
+
// overlay is detected without a server restart. Without refresh it reads the cache.
|
|
549
706
|
if (url.startsWith("/screen/caps")) {
|
|
550
|
-
|
|
707
|
+
const refresh = new URL(url, "http://localhost").searchParams.get("refresh") === "1";
|
|
708
|
+
if (refresh) {
|
|
709
|
+
void (async () => {
|
|
710
|
+
// refreshScreenCapabilities cannot throw today, but a future edit that lets a rejection
|
|
711
|
+
// escape would leave this request hanging until the client's 3s timeout. Guard it: on a
|
|
712
|
+
// failed re-probe, still answer with the (cached) capabilities rather than nothing.
|
|
713
|
+
try {
|
|
714
|
+
await refreshScreenCapabilities();
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// fall through to the cached capabilities below
|
|
718
|
+
}
|
|
719
|
+
sendJson(res, 200, { ...screenCapabilities(), consent: hasScreenConsent() });
|
|
720
|
+
})();
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
sendJson(res, 200, { ...screenCapabilities(), consent: hasScreenConsent() });
|
|
724
|
+
}
|
|
551
725
|
return;
|
|
552
726
|
}
|
|
553
727
|
// Grant consent for this process. Required before any pixels are mirrored.
|
|
@@ -625,6 +799,10 @@ function handleScreen(req, res) {
|
|
|
625
799
|
if (url === "/screen/record/stop" && req.method === "POST") {
|
|
626
800
|
void (async () => {
|
|
627
801
|
const p = await stopRecording();
|
|
802
|
+
// If the viewer already left (the close-while-recording path drops the <img> before
|
|
803
|
+
// this POST lands), the recording was the only thing keeping the cast alive — reap it
|
|
804
|
+
// now so screenrecord + ffmpeg do not run on with no viewer.
|
|
805
|
+
reapLiveIfIdle();
|
|
628
806
|
const file = p ? path.basename(p) : null;
|
|
629
807
|
let sizeBytes = 0;
|
|
630
808
|
if (p) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@slack/radar-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"clean": "rm -rf dist",
|
|
18
18
|
"prebuild": "npm run clean",
|
|
19
|
-
"
|
|
19
|
+
"copy-vendor": "node scripts/copy-vendor.mjs",
|
|
20
|
+
"build:web": "tsc -p tsconfig.web.json",
|
|
21
|
+
"build": "tsc && npm run build:web && node scripts/copy-public.mjs && npm run copy-vendor && chmod +x dist/mcp/index.js dist/web/bin.js",
|
|
20
22
|
"start": "node dist/mcp/index.js",
|
|
21
23
|
"start:web": "node dist/web/bin.js",
|
|
22
24
|
"watch": "tsc --watch",
|
|
@@ -49,6 +51,8 @@
|
|
|
49
51
|
},
|
|
50
52
|
"devDependencies": {
|
|
51
53
|
"@types/node": "^22.10.5",
|
|
54
|
+
"htm": "^3.1.1",
|
|
55
|
+
"preact": "^10.29.3",
|
|
52
56
|
"typescript": "^5.7.2"
|
|
53
57
|
}
|
|
54
58
|
}
|