@slack/radar-mcp 1.4.0 → 1.6.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 +10 -2
- package/dist/mcp/db-tools.d.ts +46 -0
- package/dist/mcp/db-tools.js +160 -0
- package/dist/mcp/index.js +34 -2
- package/dist/mcp/tools.js +52 -2
- package/dist/shared/android.d.ts +8 -0
- package/dist/shared/android.js +25 -3
- package/dist/shared/constants.d.ts +29 -0
- package/dist/shared/constants.js +38 -0
- package/dist/shared/db.d.ts +92 -0
- package/dist/shared/db.js +415 -0
- package/dist/shared/debug-log.d.ts +50 -0
- package/dist/shared/debug-log.js +108 -0
- package/dist/shared/screen.d.ts +155 -0
- package/dist/shared/screen.js +633 -0
- package/dist/shared/transport.d.ts +10 -0
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +153 -20
- package/dist/web/log-session.d.ts +89 -0
- package/dist/web/log-session.js +144 -0
- package/dist/web/public/index.html +381 -14
- 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 +30 -11
- package/dist/web/server.js +575 -52
- package/dist/web/spec.d.ts +13 -5
- package/dist/web/spec.js +23 -5
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -49,6 +49,13 @@ Opens `http://localhost:8100` automatically. Set `SLACK_RADAR_WEB_PORT` to use a
|
|
|
49
49
|
|
|
50
50
|
Each tab has a counter, rate, sparkline, graph, a filter / sort / pause feed, and a payload inspector. Switching tabs replays what you have already seen this session (the browser caches it, bounded by count and age) so the view does not reset to empty. ADB forwarding and device activation are handled for you; a reconnect control re-arms after a device sleep.
|
|
51
51
|
|
|
52
|
+
### Screen overlay
|
|
53
|
+
|
|
54
|
+
A **▣ Screen** button opens a side panel that mirrors the live device screen, with a full-resolution screenshot and a screen recording. It is off by default and opens to a consent notice first, because it shows whatever is on the phone (open DMs, message content, notifications).
|
|
55
|
+
|
|
56
|
+
* **Screenshot** works on any machine (pure `screencap`, no extra tools).
|
|
57
|
+
* **Live mirror and recording** need `ffmpeg` on your machine. If it is missing, the panel shows the install command for your platform and the screenshot still works. ffmpeg is not bundled and is never installed for you.
|
|
58
|
+
|
|
52
59
|
## Prerequisites
|
|
53
60
|
|
|
54
61
|
* Node.js 18+
|
|
@@ -124,8 +131,9 @@ What gets captured depends on the in-app server implementation. The MCP server i
|
|
|
124
131
|
* **Network calls**: OkHttp interceptor. Ring buffer: 200 calls.
|
|
125
132
|
* **RTM events**: WebSocket listener. Ring buffer: 500 events.
|
|
126
133
|
* **Clogs**: Analytics interceptor. Ring buffer: 500 events. Persists across radar activation cycles.
|
|
134
|
+
* **SQLite database** (Database tab, web dashboard only): when you open a database, a read-only **plaintext** copy of that on-device store (message text, request/response bodies, user/channel/team IDs) is pulled to a temp directory **on your own machine** (`os.tmpdir()/slack-radar-db-<port>`, files mode 0600). Auto-deleted when the dashboard process stops.
|
|
127
135
|
|
|
128
|
-
|
|
136
|
+
The network, RTM, and clog captures are in-memory ring buffers. The Database tab is the exception: it writes a plaintext copy of the selected on-device database to a temp file on your host for the duration of the session. Review before sharing anything from it.
|
|
129
137
|
|
|
130
138
|
### What gets captured, in detail
|
|
131
139
|
|
|
@@ -177,4 +185,4 @@ The MCP server auto-detects the Android user ID via `ps` and targets the broadca
|
|
|
177
185
|
Restart your Claude Code session. MCP servers are registered at session start.
|
|
178
186
|
|
|
179
187
|
**Port already in use (web dashboard)**
|
|
180
|
-
|
|
188
|
+
If a Radar dashboard is already running on the port, launching again just opens it. If the port is held by another program, run Radar on a different port with `SLACK_RADAR_WEB_PORT=8101 npx @slack/radar-mcp web`.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool handlers for the on-device SQLite browser.
|
|
3
|
+
*
|
|
4
|
+
* These expose the same read-only DB access the web dashboard already has (src/web/server.ts),
|
|
5
|
+
* but over MCP so terminal Claude can inspect the device store during a debugging session
|
|
6
|
+
* without opening the dashboard. All the work lives in shared/db.ts; these handlers are thin
|
|
7
|
+
* wrappers that resolve the bound profile and shape the result for MCP.
|
|
8
|
+
*
|
|
9
|
+
* Read-only is enforced in shared/db.ts: the copy-based design (the device file is `cat`'d to
|
|
10
|
+
* a throwaway local copy and never written back) plus `sqlite3 -safe` (blocks ATTACH/readfile/
|
|
11
|
+
* writefile/load_extension at the engine level) plus the single-statement read-only gate
|
|
12
|
+
* (assertReadOnlyQuery). NOTE: the copy is opened WITHOUT `-readonly` on purpose, so SQLite
|
|
13
|
+
* replays the WAL sidecar; the read-only guarantee does not depend on the open mode. This
|
|
14
|
+
* handler calls assertReadOnlyQuery() up front so a bad query is rejected BEFORE the device
|
|
15
|
+
* pull, and results are capped by both row count and serialized bytes (see capQueryRows) so
|
|
16
|
+
* neither a `SELECT *` nor a one-row `group_concat` dump can stream the whole plaintext store
|
|
17
|
+
* into the model context.
|
|
18
|
+
*/
|
|
19
|
+
import type { DeviceTransport } from "../shared/transport.js";
|
|
20
|
+
/**
|
|
21
|
+
* Bound a result set for the model context by BOTH row count and serialized bytes. Pure (no
|
|
22
|
+
* I/O) so it is unit-testable without a device. Returns the rows to actually return plus the
|
|
23
|
+
* honest counters. truncated is true if EITHER ceiling trips. A single row larger than the
|
|
24
|
+
* byte budget cannot be split, so it is dropped (returnedRows 0) rather than blowing the
|
|
25
|
+
* budget — the note says so, and total_rows still reports the true count so the model knows
|
|
26
|
+
* data exists and can re-query a narrower projection.
|
|
27
|
+
*/
|
|
28
|
+
export declare function capQueryRows(rows: unknown[]): {
|
|
29
|
+
rows: unknown[];
|
|
30
|
+
rowCount: number;
|
|
31
|
+
totalRows: number;
|
|
32
|
+
truncated: boolean;
|
|
33
|
+
note?: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function handleListDatabases(transport: DeviceTransport): {
|
|
36
|
+
data: unknown;
|
|
37
|
+
isError: boolean;
|
|
38
|
+
};
|
|
39
|
+
export declare function handleListTables(transport: DeviceTransport, db: string, refresh?: boolean): Promise<{
|
|
40
|
+
data: unknown;
|
|
41
|
+
isError: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
export declare function handleQueryDatabase(transport: DeviceTransport, db: string, sql: string, refresh?: boolean): Promise<{
|
|
44
|
+
data: unknown;
|
|
45
|
+
isError: boolean;
|
|
46
|
+
}>;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool handlers for the on-device SQLite browser.
|
|
3
|
+
*
|
|
4
|
+
* These expose the same read-only DB access the web dashboard already has (src/web/server.ts),
|
|
5
|
+
* but over MCP so terminal Claude can inspect the device store during a debugging session
|
|
6
|
+
* without opening the dashboard. All the work lives in shared/db.ts; these handlers are thin
|
|
7
|
+
* wrappers that resolve the bound profile and shape the result for MCP.
|
|
8
|
+
*
|
|
9
|
+
* Read-only is enforced in shared/db.ts: the copy-based design (the device file is `cat`'d to
|
|
10
|
+
* a throwaway local copy and never written back) plus `sqlite3 -safe` (blocks ATTACH/readfile/
|
|
11
|
+
* writefile/load_extension at the engine level) plus the single-statement read-only gate
|
|
12
|
+
* (assertReadOnlyQuery). NOTE: the copy is opened WITHOUT `-readonly` on purpose, so SQLite
|
|
13
|
+
* replays the WAL sidecar; the read-only guarantee does not depend on the open mode. This
|
|
14
|
+
* handler calls assertReadOnlyQuery() up front so a bad query is rejected BEFORE the device
|
|
15
|
+
* pull, and results are capped by both row count and serialized bytes (see capQueryRows) so
|
|
16
|
+
* neither a `SELECT *` nor a one-row `group_concat` dump can stream the whole plaintext store
|
|
17
|
+
* into the model context.
|
|
18
|
+
*/
|
|
19
|
+
import { assertReadOnlyQuery, listDatabases, pullDatabase, queryDatabase, pulledSize, } from "../shared/db.js";
|
|
20
|
+
/**
|
|
21
|
+
* Caps on what query_database streams into the MODEL CONTEXT. The on-device store holds tens
|
|
22
|
+
* of thousands of plaintext message/PII rows; an unbounded result would dump the whole store
|
|
23
|
+
* into the transcript (db.ts only bounds the raw sqlite output at 64MB).
|
|
24
|
+
*
|
|
25
|
+
* Two independent ceilings, because bounding rows alone is not enough: a single
|
|
26
|
+
* `SELECT group_concat(text) FROM messages` collapses the entire table into ONE row whose
|
|
27
|
+
* lone column is every message body — that sails past a row-count cap untouched. So we cap
|
|
28
|
+
* BOTH the row count AND the serialized byte size of the returned payload.
|
|
29
|
+
*
|
|
30
|
+
* The web dashboard is intentionally exempt (handleDbQuery in web/server.ts returns the
|
|
31
|
+
* uncapped set): it renders to a consenting local human behind a privacy banner, not into an
|
|
32
|
+
* LLM transcript, so a large render is a UI annoyance, not the context/PII harm this guards.
|
|
33
|
+
* (It does not paginate; the exemption is about WHO consumes the rows, not how they are shown.)
|
|
34
|
+
*/
|
|
35
|
+
const MAX_QUERY_ROWS = 500;
|
|
36
|
+
const MAX_QUERY_BYTES = 256 * 1024; // serialized cap on rows returned to the model (~256KB)
|
|
37
|
+
/**
|
|
38
|
+
* Bound a result set for the model context by BOTH row count and serialized bytes. Pure (no
|
|
39
|
+
* I/O) so it is unit-testable without a device. Returns the rows to actually return plus the
|
|
40
|
+
* honest counters. truncated is true if EITHER ceiling trips. A single row larger than the
|
|
41
|
+
* byte budget cannot be split, so it is dropped (returnedRows 0) rather than blowing the
|
|
42
|
+
* budget — the note says so, and total_rows still reports the true count so the model knows
|
|
43
|
+
* data exists and can re-query a narrower projection.
|
|
44
|
+
*/
|
|
45
|
+
export function capQueryRows(rows) {
|
|
46
|
+
const total = rows.length;
|
|
47
|
+
const byCount = rows.length > MAX_QUERY_ROWS ? rows.slice(0, MAX_QUERY_ROWS) : rows.slice();
|
|
48
|
+
// Then trim by serialized size: keep prefix rows until the JSON budget is hit. Measure UTF-8
|
|
49
|
+
// BYTES (Buffer.byteLength), not String.length: the MCP layer emits this payload as UTF-8 over
|
|
50
|
+
// stdio, and .length counts UTF-16 code units, so an emoji/CJK row (surrogate pairs: 2 code
|
|
51
|
+
// units, 4 UTF-8 bytes) would otherwise pass at up to ~2x the real wire size.
|
|
52
|
+
const kept = [];
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
for (const r of byCount) {
|
|
55
|
+
const sz = Buffer.byteLength(JSON.stringify(r) ?? "", "utf8");
|
|
56
|
+
if (kept.length > 0 && bytes + sz > MAX_QUERY_BYTES)
|
|
57
|
+
break;
|
|
58
|
+
kept.push(r);
|
|
59
|
+
bytes += sz;
|
|
60
|
+
if (bytes > MAX_QUERY_BYTES)
|
|
61
|
+
break; // a single oversized first row trips here; dropped below
|
|
62
|
+
}
|
|
63
|
+
// If even the first kept row blew the budget, drop it: do not stream a >256KB blob.
|
|
64
|
+
const overBudget = kept.length === 1 && bytes > MAX_QUERY_BYTES;
|
|
65
|
+
const finalRows = overBudget ? [] : kept;
|
|
66
|
+
const truncated = finalRows.length < total;
|
|
67
|
+
let note;
|
|
68
|
+
if (overBudget) {
|
|
69
|
+
note = `first row alone exceeds the ${Math.round(MAX_QUERY_BYTES / 1024)}KB response budget; returned no rows. Select fewer/narrower columns (avoid group_concat/blob dumps).`;
|
|
70
|
+
}
|
|
71
|
+
else if (truncated) {
|
|
72
|
+
note = `result truncated to ${finalRows.length} of ${total} rows (row or byte budget); narrow with WHERE/LIMIT or fewer columns.`;
|
|
73
|
+
}
|
|
74
|
+
return { rows: finalRows, rowCount: finalRows.length, totalRows: total, truncated, note };
|
|
75
|
+
}
|
|
76
|
+
/** Resolve the Android profile Radar is bound to. Returns undefined so db.ts falls back to its first available user. */
|
|
77
|
+
function resolveUser(transport) {
|
|
78
|
+
try {
|
|
79
|
+
return transport.resolveActiveUser();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function handleListDatabases(transport) {
|
|
86
|
+
try {
|
|
87
|
+
const { user, availableUsers, names } = listDatabases(resolveUser(transport));
|
|
88
|
+
return { data: { user, available_users: availableUsers, databases: names }, isError: false };
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
return { data: { error: e.message, databases: [] }, isError: true };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function handleListTables(transport, db, refresh = false) {
|
|
95
|
+
if (!db)
|
|
96
|
+
return { data: { error: "db is required" }, isError: true };
|
|
97
|
+
const user = resolveUser(transport);
|
|
98
|
+
try {
|
|
99
|
+
// force=refresh re-pulls the live device copy; default reuses the cached pull so a
|
|
100
|
+
// multi-MB store is not re-streamed on every call.
|
|
101
|
+
await pullDatabase(db, user, refresh);
|
|
102
|
+
const tableRows = (await queryDatabase(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", user));
|
|
103
|
+
const tables = [];
|
|
104
|
+
for (const t of tableRows) {
|
|
105
|
+
let rows = "?";
|
|
106
|
+
try {
|
|
107
|
+
rows = (await queryDatabase(db, `SELECT COUNT(*) c FROM "${t.name}"`, user))[0].c;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// a view or virtual table may not COUNT cleanly; leave "?"
|
|
111
|
+
}
|
|
112
|
+
tables.push({ name: t.name, rows });
|
|
113
|
+
}
|
|
114
|
+
return { data: { db, size_bytes: pulledSize(db, user), tables }, isError: false };
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return { data: { error: e.message, tables: [] }, isError: true };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function handleQueryDatabase(transport, db, sql, refresh = false) {
|
|
121
|
+
if (!db)
|
|
122
|
+
return { data: { error: "db is required" }, isError: true };
|
|
123
|
+
if (!sql)
|
|
124
|
+
return { data: { error: "sql is required" }, isError: true };
|
|
125
|
+
// Gate the SQL BEFORE the device pull: a rejected write/exfil query must not trigger a
|
|
126
|
+
// multi-MB `exec-out cat` first. queryDatabase re-checks, so this is an early-out, not the
|
|
127
|
+
// sole boundary. Running device-free, it also makes the read-only gate unit-testable here.
|
|
128
|
+
try {
|
|
129
|
+
assertReadOnlyQuery(sql);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { data: { error: e.message, rows: [] }, isError: true };
|
|
133
|
+
}
|
|
134
|
+
const user = resolveUser(transport);
|
|
135
|
+
try {
|
|
136
|
+
// force=refresh re-pulls the live device copy; default reuses the cached pull. Pass
|
|
137
|
+
// refresh:true when the device state may have changed since the first query this session.
|
|
138
|
+
await pullDatabase(db, user, refresh);
|
|
139
|
+
const rows = await queryDatabase(db, sql, user);
|
|
140
|
+
// Cap what goes into the model context by BOTH row count and serialized bytes: the store
|
|
141
|
+
// is plaintext PII and a broad SELECT (or a group_concat that collapses the table into one
|
|
142
|
+
// huge row) would otherwise dump it into the transcript. See capQueryRows.
|
|
143
|
+
const capped = capQueryRows(rows);
|
|
144
|
+
return {
|
|
145
|
+
data: {
|
|
146
|
+
db,
|
|
147
|
+
sql,
|
|
148
|
+
row_count: capped.rowCount,
|
|
149
|
+
total_rows: capped.totalRows,
|
|
150
|
+
truncated: capped.truncated,
|
|
151
|
+
...(capped.note ? { note: capped.note } : {}),
|
|
152
|
+
rows: capped.rows,
|
|
153
|
+
},
|
|
154
|
+
isError: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
return { data: { error: e.message, rows: [] }, isError: true };
|
|
159
|
+
}
|
|
160
|
+
}
|
package/dist/mcp/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js
|
|
|
12
12
|
import { stopCapture } from "../shared/logcat-capture.js";
|
|
13
13
|
import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
|
|
14
14
|
import { buildBufferPath, createSnapshot } from "./snapshot.js";
|
|
15
|
+
import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./db-tools.js";
|
|
16
|
+
import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
|
|
15
17
|
import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
|
|
16
18
|
// --- Subcommand + bin-name dispatch ---
|
|
17
19
|
//
|
|
@@ -164,7 +166,8 @@ async function openDashboardSpec(spec) {
|
|
|
164
166
|
return r.json;
|
|
165
167
|
}
|
|
166
168
|
if (wantsWeb) {
|
|
167
|
-
await import("../web/bin.js");
|
|
169
|
+
const { startWebServer } = await import("../web/bin.js");
|
|
170
|
+
startWebServer();
|
|
168
171
|
}
|
|
169
172
|
else {
|
|
170
173
|
await runMcpServer();
|
|
@@ -183,6 +186,17 @@ async function runMcpServer() {
|
|
|
183
186
|
}
|
|
184
187
|
const device = createTransport();
|
|
185
188
|
setTransport(device);
|
|
189
|
+
// Scope pulled-DB copies to THIS process by pid, then clean them on exit (mirrors the web
|
|
190
|
+
// server's lifecycle). The DB tools write plaintext message-store copies to a 0600 tmp dir;
|
|
191
|
+
// pid-scoping keeps two concurrent Radar MCP servers from sharing a dir and clobbering each
|
|
192
|
+
// other's live pulls (a fixed port would collide, since every MCP uses the same RADAR_PORT).
|
|
193
|
+
// A hard crash leaks this process's dir until the OS sweeps tmp; the files stay 0600 in a
|
|
194
|
+
// 0700 dir, so that is a bounded exposure, not a cross-user leak.
|
|
195
|
+
initializeDatabasePath(process.pid);
|
|
196
|
+
cleanupPulledDatabases();
|
|
197
|
+
for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
|
|
198
|
+
process.once(sig, cleanupPulledDatabases);
|
|
199
|
+
}
|
|
186
200
|
let sessionEnabled = false;
|
|
187
201
|
function radarRequest(requestPath) {
|
|
188
202
|
return new Promise((resolve, reject) => {
|
|
@@ -260,6 +274,10 @@ async function runMcpServer() {
|
|
|
260
274
|
async function handleSessionEnable(args) {
|
|
261
275
|
const timeout = args?.timeout_minutes || 5;
|
|
262
276
|
sessionEnabled = true;
|
|
277
|
+
// A re-enable may be a different device or a re-armed profile, so drop the cached DB
|
|
278
|
+
// user set + sqlite path; the next DB tool call re-detects the profiles fresh. Mirrors
|
|
279
|
+
// the web server's handleEnable (server.ts), which resets for the same reason.
|
|
280
|
+
resetDbState();
|
|
263
281
|
const forwarded = device.ensureForward();
|
|
264
282
|
if (!forwarded) {
|
|
265
283
|
sessionEnabled = false;
|
|
@@ -562,7 +580,7 @@ async function runMcpServer() {
|
|
|
562
580
|
}
|
|
563
581
|
}
|
|
564
582
|
// --- MCP Server ---
|
|
565
|
-
const server = new Server({ name: "slack-radar", version: "1.
|
|
583
|
+
const server = new Server({ name: "slack-radar", version: "1.6.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
566
584
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
567
585
|
tools: TOOL_DEFINITIONS,
|
|
568
586
|
}));
|
|
@@ -617,6 +635,20 @@ async function runMcpServer() {
|
|
|
617
635
|
if (name === "ping") {
|
|
618
636
|
return handlePing(toolArgs);
|
|
619
637
|
}
|
|
638
|
+
if (name === "list_databases") {
|
|
639
|
+
const { data, isError } = handleListDatabases(device);
|
|
640
|
+
return textResult(data, isError);
|
|
641
|
+
}
|
|
642
|
+
if (name === "list_tables") {
|
|
643
|
+
const { db, refresh } = toolArgs;
|
|
644
|
+
const { data, isError } = await handleListTables(device, db ?? "", refresh === true);
|
|
645
|
+
return textResult(data, isError);
|
|
646
|
+
}
|
|
647
|
+
if (name === "query_database") {
|
|
648
|
+
const { db, sql, refresh } = toolArgs;
|
|
649
|
+
const { data, isError } = await handleQueryDatabase(device, db ?? "", sql ?? "", refresh === true);
|
|
650
|
+
return textResult(data, isError);
|
|
651
|
+
}
|
|
620
652
|
const apiPath = buildApiPath(name, toolArgs);
|
|
621
653
|
if (apiPath === null) {
|
|
622
654
|
return textResult(`Unknown tool: ${name}`, true);
|
package/dist/mcp/tools.js
CHANGED
|
@@ -61,6 +61,17 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
61
61
|
"DO NOT offer for single-query debugging or when the user is happy reading results in the",
|
|
62
62
|
"transcript. Offer when visual inspection adds real value.",
|
|
63
63
|
"",
|
|
64
|
+
"SCREEN OVERLAY (in the dashboard):",
|
|
65
|
+
"The dashboard has a '▣ Screen' button that mirrors the LIVE device screen in a side panel,",
|
|
66
|
+
"plus a full-resolution screenshot and screen recording. It is OFF by default and opens to a",
|
|
67
|
+
"consent notice first, because it shows real on-screen content (open DMs, messages). The live",
|
|
68
|
+
"mirror needs ffmpeg on the user's machine; the screenshot does not. If the user wants the live",
|
|
69
|
+
"screen and ffmpeg is missing, tell them plainly that live mirroring needs ffmpeg and give the",
|
|
70
|
+
"platform install command (mac: `brew install ffmpeg`; debian/ubuntu: `sudo apt install ffmpeg`;",
|
|
71
|
+
"windows: `winget install ffmpeg`), and note the screenshot works without it. NEVER run the",
|
|
72
|
+
"install for them. The dashboard's /health response reports screen.ffmpeg / screen.liveCast /",
|
|
73
|
+
"screen.installHint, so you can check before suggesting.",
|
|
74
|
+
"",
|
|
64
75
|
"CUSTOM DASHBOARD (open_radar_dashboard):",
|
|
65
76
|
"The pre-built tabs (Live/Network/RTM/Clogs/Logs) cover the common cases. When the user",
|
|
66
77
|
"describes a live view the tabs do not show, SUGGEST building it; invoke open_radar_dashboard",
|
|
@@ -228,11 +239,11 @@ export const TOOL_DEFINITIONS = [
|
|
|
228
239
|
},
|
|
229
240
|
{
|
|
230
241
|
name: "get_recent_logs",
|
|
231
|
-
description: "Get recent
|
|
242
|
+
description: "Get recent device logs for the Slack debug build, filtered by tag. Reads the app's own log buffer first (the DEBUG-and-above Timber lines the in-app Log Viewer shows, this app only), and falls back to an adb logcat snapshot when that buffer cannot be read. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
|
|
232
243
|
inputSchema: {
|
|
233
244
|
type: "object",
|
|
234
245
|
properties: {
|
|
235
|
-
tag: { type: "string", description: "
|
|
246
|
+
tag: { type: "string", description: "Substring to filter log lines by (matched anywhere in the line, not an exact tag-column match)" },
|
|
236
247
|
limit: { type: "number", description: "Max number of lines to return (default: 50)" },
|
|
237
248
|
grep: { type: "string", description: "Optional text to grep within the filtered output" },
|
|
238
249
|
},
|
|
@@ -407,6 +418,45 @@ export const TOOL_DEFINITIONS = [
|
|
|
407
418
|
// Not read-only: it starts a local web server and pushes a spec to it.
|
|
408
419
|
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
409
420
|
},
|
|
421
|
+
{
|
|
422
|
+
name: "list_databases",
|
|
423
|
+
description: "List the app's on-device SQLite databases (the message store, telemetry, etc.) for the bound profile.",
|
|
424
|
+
inputSchema: { type: "object", properties: {} },
|
|
425
|
+
annotations: { readOnlyHint: true },
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "list_tables",
|
|
429
|
+
description: "List the tables (with row counts) in one on-device database. Pass a name from list_databases.",
|
|
430
|
+
inputSchema: {
|
|
431
|
+
type: "object",
|
|
432
|
+
properties: {
|
|
433
|
+
db: { type: "string", description: "Database name from list_databases" },
|
|
434
|
+
refresh: {
|
|
435
|
+
type: "boolean",
|
|
436
|
+
description: "Re-pull a fresh copy from the device instead of reusing the cached pull (default false)",
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
required: ["db"],
|
|
440
|
+
},
|
|
441
|
+
annotations: { readOnlyHint: true },
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: "query_database",
|
|
445
|
+
description: "Run a read-only SQL query against an on-device database and get the rows back. SELECT/PRAGMA/WITH only (writes and file access are blocked). Use it to answer 'is this row actually in the DB?' during a debug session. Results are capped by row count and total size (the store is plaintext PII); when trimmed the response sets truncated:true with total_rows, so narrow with WHERE/LIMIT and avoid group_concat/blob dumps.",
|
|
446
|
+
inputSchema: {
|
|
447
|
+
type: "object",
|
|
448
|
+
properties: {
|
|
449
|
+
db: { type: "string", description: "Database name from list_databases" },
|
|
450
|
+
sql: { type: "string", description: "A single read-only statement (SELECT, WITH, PRAGMA, EXPLAIN)" },
|
|
451
|
+
refresh: {
|
|
452
|
+
type: "boolean",
|
|
453
|
+
description: "Re-pull a fresh copy from the device before querying (default false reuses this session's cached pull)",
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
required: ["db", "sql"],
|
|
457
|
+
},
|
|
458
|
+
annotations: { readOnlyHint: true },
|
|
459
|
+
},
|
|
410
460
|
];
|
|
411
461
|
/**
|
|
412
462
|
* Check whether a tool call should be blocked by the session gate.
|
package/dist/shared/android.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export declare class AndroidTransport implements DeviceTransport {
|
|
|
32
32
|
isForwarded(): boolean;
|
|
33
33
|
getTimeoutMinutes(): number;
|
|
34
34
|
getActiveUserId(): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the Android user the debug build is actually running as: the already-detected
|
|
37
|
+
* user if known, else a `ps`-based detection (which prefers the non-zero/secondary profile
|
|
38
|
+
* the work build runs under). Unlike getActiveUserId() this never returns null, so callers
|
|
39
|
+
* that need a concrete target (the DB browser) default to the profile Radar is bound to
|
|
40
|
+
* rather than blindly to user 0.
|
|
41
|
+
*/
|
|
42
|
+
resolveActiveUser(): string;
|
|
35
43
|
getCaptureFilePath(): string | null;
|
|
36
44
|
screenshot(fullRes: boolean): string;
|
|
37
45
|
recordScreen(durationS: number): string;
|
package/dist/shared/android.js
CHANGED
|
@@ -2,7 +2,8 @@ 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 {
|
|
5
|
+
import { readDebugLogBuffer } from "./debug-log.js";
|
|
6
|
+
import { filterLogLines, getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
|
|
6
7
|
const RADAR_PORT = 8099;
|
|
7
8
|
const SOCKET_NAME = "slack-radar";
|
|
8
9
|
const ENABLE_ACTION = "slack.debug.ENABLE_RADAR";
|
|
@@ -149,6 +150,16 @@ export class AndroidTransport {
|
|
|
149
150
|
getActiveUserId() {
|
|
150
151
|
return this.detectedUserId;
|
|
151
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the Android user the debug build is actually running as: the already-detected
|
|
155
|
+
* user if known, else a `ps`-based detection (which prefers the non-zero/secondary profile
|
|
156
|
+
* the work build runs under). Unlike getActiveUserId() this never returns null, so callers
|
|
157
|
+
* that need a concrete target (the DB browser) default to the profile Radar is bound to
|
|
158
|
+
* rather than blindly to user 0.
|
|
159
|
+
*/
|
|
160
|
+
resolveActiveUser() {
|
|
161
|
+
return this.detectDebugBuildUser();
|
|
162
|
+
}
|
|
152
163
|
getCaptureFilePath() {
|
|
153
164
|
return getCaptureFilePath();
|
|
154
165
|
}
|
|
@@ -189,8 +200,19 @@ export class AndroidTransport {
|
|
|
189
200
|
getRecentLogs(tag, limit, grep) {
|
|
190
201
|
if (!ADB)
|
|
191
202
|
throw new Error("adb not found on PATH or common SDK locations");
|
|
192
|
-
//
|
|
193
|
-
//
|
|
203
|
+
// Primary source: the app's own DebugLogger buffer, read off the sandbox via
|
|
204
|
+
// run-as. This is the app's structured log (exact tags, no logcat column
|
|
205
|
+
// padding) and survives the radar enable/disable cycle, so it does not lose
|
|
206
|
+
// lines to device-logcat ring eviction between enable and read. Target the
|
|
207
|
+
// profile Radar is bound to (resolveActiveUser prefers the non-zero/work
|
|
208
|
+
// profile), not a blind user 0, so a dual-profile device reads the right
|
|
209
|
+
// sandbox -- matching how the DB browser threads its user.
|
|
210
|
+
const appLog = readDebugLogBuffer(ADB, this.resolveActiveUser());
|
|
211
|
+
if (appLog !== null) {
|
|
212
|
+
return filterLogLines(appLog, tag, limit, grep);
|
|
213
|
+
}
|
|
214
|
+
// Fallback: read from the session capture file so lines cannot have been
|
|
215
|
+
// evicted from the device ring buffer between enable and now.
|
|
194
216
|
if (isCapturing()) {
|
|
195
217
|
// Opportunistic rotation. Stop+rename+restart when the file grows
|
|
196
218
|
// past the threshold so the child never writes past a truncated EOF.
|
|
@@ -1,12 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package version, read once from package.json at load — NOT a hardcoded copy, so it cannot
|
|
3
|
+
* drift from the published version. Surfaced as the `X-Radar-Version` header on /spec so a
|
|
4
|
+
* second launcher can tell whether the dashboard already holding the port is a different
|
|
5
|
+
* Radar version than itself (the adopt path notices a mismatch; it still adopts, never kills).
|
|
6
|
+
* Falls back to "unknown" if package.json cannot be read (should not happen in a real install).
|
|
7
|
+
*/
|
|
8
|
+
export declare const RADAR_VERSION: string;
|
|
1
9
|
/** Port used to reach the on-device radar HTTP server via forwarding. */
|
|
2
10
|
export declare const RADAR_PORT = 8099;
|
|
3
11
|
/** Host address for the forwarded connection. */
|
|
4
12
|
export declare const RADAR_HOST = "127.0.0.1";
|
|
5
13
|
/** Max events to retain in each local SSE buffer. */
|
|
6
14
|
export declare const MAX_LOCAL_BUFFER = 500;
|
|
15
|
+
/**
|
|
16
|
+
* Max parsed logcat frames the web dashboard retains for the whole server
|
|
17
|
+
* session. The device does not buffer logcat host-side, so the dashboard keeps
|
|
18
|
+
* its own session ring and replays it to every newly connected stream — this is
|
|
19
|
+
* what lets a tab switch (which reopens the SSE stream) show the full history
|
|
20
|
+
* instead of only the lines that arrive after the reconnect. Bounded so a long,
|
|
21
|
+
* chatty session cannot grow without limit. logcat is captured for ALL apps (the
|
|
22
|
+
* dashboard filters by package client-side), so the depth is sized for an all-app
|
|
23
|
+
* stream: at typical line sizes this keeps the buffer well under ~10 MB (a
|
|
24
|
+
* stack-trace-heavy session runs larger per line, so treat this as a typical, not
|
|
25
|
+
* a hard, ceiling). Like Android Studio's logcat, retention is a bounded ring —
|
|
26
|
+
* the oldest lines age out once the cap is reached, not kept forever.
|
|
27
|
+
*/
|
|
28
|
+
export declare const MAX_LOG_SESSION_BUFFER = 20000;
|
|
7
29
|
/**
|
|
8
30
|
* Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
|
|
9
31
|
* tool POSTs its spec to the same port the web server listens on; both honor the
|
|
10
32
|
* `SLACK_RADAR_WEB_PORT` env override.
|
|
11
33
|
*/
|
|
12
34
|
export declare const WEB_PORT_DEFAULT = 8100;
|
|
35
|
+
/**
|
|
36
|
+
* The Slack debug build whose sandbox the host can `run-as` into. Both the
|
|
37
|
+
* on-device SQLite browser (db.ts) and the app log-buffer reader (debug-log.ts)
|
|
38
|
+
* read sandbox files through `adb exec-out "run-as <pkg> ..."`, which only works
|
|
39
|
+
* on a debuggable build. Defined once here so the two readers cannot drift.
|
|
40
|
+
*/
|
|
41
|
+
export declare const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
|
package/dist/shared/constants.js
CHANGED
|
@@ -1,12 +1,50 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
/**
|
|
3
|
+
* Package version, read once from package.json at load — NOT a hardcoded copy, so it cannot
|
|
4
|
+
* drift from the published version. Surfaced as the `X-Radar-Version` header on /spec so a
|
|
5
|
+
* second launcher can tell whether the dashboard already holding the port is a different
|
|
6
|
+
* Radar version than itself (the adopt path notices a mismatch; it still adopts, never kills).
|
|
7
|
+
* Falls back to "unknown" if package.json cannot be read (should not happen in a real install).
|
|
8
|
+
*/
|
|
9
|
+
export const RADAR_VERSION = (() => {
|
|
10
|
+
try {
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
return require("../../package.json").version ?? "unknown";
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return "unknown";
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
1
18
|
/** Port used to reach the on-device radar HTTP server via forwarding. */
|
|
2
19
|
export const RADAR_PORT = 8099;
|
|
3
20
|
/** Host address for the forwarded connection. */
|
|
4
21
|
export const RADAR_HOST = "127.0.0.1";
|
|
5
22
|
/** Max events to retain in each local SSE buffer. */
|
|
6
23
|
export const MAX_LOCAL_BUFFER = 500;
|
|
24
|
+
/**
|
|
25
|
+
* Max parsed logcat frames the web dashboard retains for the whole server
|
|
26
|
+
* session. The device does not buffer logcat host-side, so the dashboard keeps
|
|
27
|
+
* its own session ring and replays it to every newly connected stream — this is
|
|
28
|
+
* what lets a tab switch (which reopens the SSE stream) show the full history
|
|
29
|
+
* instead of only the lines that arrive after the reconnect. Bounded so a long,
|
|
30
|
+
* chatty session cannot grow without limit. logcat is captured for ALL apps (the
|
|
31
|
+
* dashboard filters by package client-side), so the depth is sized for an all-app
|
|
32
|
+
* stream: at typical line sizes this keeps the buffer well under ~10 MB (a
|
|
33
|
+
* stack-trace-heavy session runs larger per line, so treat this as a typical, not
|
|
34
|
+
* a hard, ceiling). Like Android Studio's logcat, retention is a bounded ring —
|
|
35
|
+
* the oldest lines age out once the cap is reached, not kept forever.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_LOG_SESSION_BUFFER = 20000;
|
|
7
38
|
/**
|
|
8
39
|
* Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
|
|
9
40
|
* tool POSTs its spec to the same port the web server listens on; both honor the
|
|
10
41
|
* `SLACK_RADAR_WEB_PORT` env override.
|
|
11
42
|
*/
|
|
12
43
|
export const WEB_PORT_DEFAULT = 8100;
|
|
44
|
+
/**
|
|
45
|
+
* The Slack debug build whose sandbox the host can `run-as` into. Both the
|
|
46
|
+
* on-device SQLite browser (db.ts) and the app log-buffer reader (debug-log.ts)
|
|
47
|
+
* read sandbox files through `adb exec-out "run-as <pkg> ..."`, which only works
|
|
48
|
+
* on a debuggable build. Defined once here so the two readers cannot drift.
|
|
49
|
+
*/
|
|
50
|
+
export const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-device SQLite browser (host-side, read-only).
|
|
3
|
+
*
|
|
4
|
+
* Slack's on-device databases are plaintext SQLite (not SQLCipher) on a debug build,
|
|
5
|
+
* which is what makes host-side reading possible at all: `adb exec-out "run-as <pkg>
|
|
6
|
+
* cat databases/<db>"` streams the file to the host, where the platform-tools `sqlite3`
|
|
7
|
+
* binary runs read-only SELECT/PRAGMA against a local copy. Nothing is written on the
|
|
8
|
+
* device; no app rebuild is needed. If a future build encrypts these DBs, the magic-header
|
|
9
|
+
* check in `pullDatabase` fails fast with a clear message rather than producing garbage.
|
|
10
|
+
*
|
|
11
|
+
* SECURITY: a pulled DB is the full message store in plaintext (request bodies, message
|
|
12
|
+
* text, user/channel IDs). Pulled copies are written 0600 under a dedicated tmp dir and
|
|
13
|
+
* cleaned on server start/stop (see `cleanupPulledDatabases`). The DB names are learned
|
|
14
|
+
* live from the device (`listDatabases`); none are baked into this file.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Initialize DB_TMP with a per-process-unique path. Call on startup. The web server scopes
|
|
18
|
+
* by its port; the MCP server scopes by process.pid (every MCP shares one fixed port, so a
|
|
19
|
+
* port key would collide between two concurrent MCP servers).
|
|
20
|
+
*/
|
|
21
|
+
export declare function initializeDatabasePath(scopeKey: number): void;
|
|
22
|
+
export declare function availableUsers(): string[];
|
|
23
|
+
/**
|
|
24
|
+
* Reset cached device state. Clears the sqlite-binary path so a device swap
|
|
25
|
+
* re-detects it. The targeted Android user is passed explicitly to every call
|
|
26
|
+
* (sourced from the dashboard's bound profile) rather than guessed, so list / pull /
|
|
27
|
+
* query can never silently disagree on which profile's store they touch.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resetDbState(): void;
|
|
30
|
+
/**
|
|
31
|
+
* List the on-device databases for the given Android user (the dashboard's bound
|
|
32
|
+
* profile). Filters out WAL/SHM/journal sidecar files. Returns the resolved user and
|
|
33
|
+
* the full set of profiles that have the build, so the UI can show which profile this
|
|
34
|
+
* store belongs to and offer a switch when more than one exists.
|
|
35
|
+
*/
|
|
36
|
+
export declare function listDatabases(user?: string | null): {
|
|
37
|
+
user: string;
|
|
38
|
+
availableUsers: string[];
|
|
39
|
+
names: string[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Stream a database off the device to a local 0600 copy (scoped to the target user) and
|
|
43
|
+
* return its path. `run-as ... cp` into shared storage fails silently (sandbox), so the
|
|
44
|
+
* working path is `exec-out cat` piped to a host file. Validates the SQLite magic header
|
|
45
|
+
* so a missing DB (whose `cat` emits an error string, not file bytes) fails with a clear
|
|
46
|
+
* message instead of being written as a junk file that later trips an opaque parse error.
|
|
47
|
+
*
|
|
48
|
+
* WAL: Slack's on-device DBs are WAL-mode and the live app holds write connections open,
|
|
49
|
+
* so recently-committed rows live in the `-wal` sidecar, NOT yet in the main file. Pulling
|
|
50
|
+
* the main file alone (and reading it `immutable`) silently drops those recent rows — the
|
|
51
|
+
* exact data a debugger wants. So we also pull the `-wal` and `-shm` sidecars next to the
|
|
52
|
+
* main copy (best-effort; absent if the DB was checkpointed), and queryDatabase opens the
|
|
53
|
+
* copy NORMALLY (not immutable) so SQLite replays the WAL into the view.
|
|
54
|
+
*/
|
|
55
|
+
export declare function pullDatabase(name: string, user?: string | null, force?: boolean): Promise<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Read-only SQL gate. Throws on anything that is not a single read-only statement.
|
|
58
|
+
*
|
|
59
|
+
* Exported so a caller (the MCP query handler) can reject a bad query BEFORE pulling a
|
|
60
|
+
* multi-MB DB copy off the device. queryDatabase calls it too, so the boundary holds even
|
|
61
|
+
* if a caller forgets the pre-check.
|
|
62
|
+
*
|
|
63
|
+
* What actually keeps this safe (the boundary does NOT rest on this gate alone):
|
|
64
|
+
* 1. copy-based design — we only ever READ the device file (`exec-out cat`) and query a
|
|
65
|
+
* throwaway local copy; nothing is ever written back, so no query can mutate device
|
|
66
|
+
* state no matter what it does to the copy.
|
|
67
|
+
* 2. `sqlite3 -safe` — engine-level: forbids ATTACH, readfile/writefile/edit, and
|
|
68
|
+
* load_extension while still allowing ordinary SELECT/PRAGMA. The load-bearing exfil
|
|
69
|
+
* control; a keyword denylist alone is whack-a-mole.
|
|
70
|
+
* 3. this gate — a single SELECT/PRAGMA/WITH/EXPLAIN/VALUES statement (no `;`-chaining),
|
|
71
|
+
* a write-PRAGMA reject, and an explicit reject of file-access primitive names as
|
|
72
|
+
* defense in depth.
|
|
73
|
+
* NOTE: the copy is opened NORMALLY (not `-readonly`, not `immutable`) on purpose, so SQLite
|
|
74
|
+
* replays the `-wal` sidecar and recently-committed rows stay visible. `-readonly`/immutable
|
|
75
|
+
* would read the main file alone and silently drop them; writes to the throwaway copy are
|
|
76
|
+
* harmless. So `-readonly` is intentionally NOT passed — the read-only guarantee comes from
|
|
77
|
+
* (1) and (2), not from the open mode.
|
|
78
|
+
*/
|
|
79
|
+
export declare function assertReadOnlyQuery(sql: string): void;
|
|
80
|
+
/**
|
|
81
|
+
* Run a read-only query against a (lazily pulled) local copy. The query is gated by
|
|
82
|
+
* assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
|
|
83
|
+
* own error message on failure.
|
|
84
|
+
*/
|
|
85
|
+
export declare function queryDatabase(name: string, sql: string, user?: string | null): Promise<unknown[]>;
|
|
86
|
+
/** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
|
|
87
|
+
export declare function pulledSize(name: string, user?: string | null): number;
|
|
88
|
+
/**
|
|
89
|
+
* Remove all pulled database copies. Pulled DBs are plaintext message stores, so they
|
|
90
|
+
* must not linger; call on server start and stop. Best-effort.
|
|
91
|
+
*/
|
|
92
|
+
export declare function cleanupPulledDatabases(): void;
|