@slack/radar-mcp 1.5.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 +1 -1
- 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 +41 -2
- package/dist/shared/android.js +15 -3
- 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 +25 -2
- package/dist/shared/screen.js +62 -5
- package/dist/shared/transport.d.ts +10 -0
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +133 -19
- package/dist/web/public/index.html +67 -8
- 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 +188 -16
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -185,4 +185,4 @@ The MCP server auto-detects the Android user ID via `ps` and targets the broadca
|
|
|
185
185
|
Restart your Claude Code session. MCP servers are registered at session start.
|
|
186
186
|
|
|
187
187
|
**Port already in use (web dashboard)**
|
|
188
|
-
|
|
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
|
@@ -239,11 +239,11 @@ export const TOOL_DEFINITIONS = [
|
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
name: "get_recent_logs",
|
|
242
|
-
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.",
|
|
243
243
|
inputSchema: {
|
|
244
244
|
type: "object",
|
|
245
245
|
properties: {
|
|
246
|
-
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)" },
|
|
247
247
|
limit: { type: "number", description: "Max number of lines to return (default: 50)" },
|
|
248
248
|
grep: { type: "string", description: "Optional text to grep within the filtered output" },
|
|
249
249
|
},
|
|
@@ -418,6 +418,45 @@ export const TOOL_DEFINITIONS = [
|
|
|
418
418
|
// Not read-only: it starts a local web server and pushes a spec to it.
|
|
419
419
|
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
420
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
|
+
},
|
|
421
460
|
];
|
|
422
461
|
/**
|
|
423
462
|
* Check whether a tool call should be blocked by the session gate.
|
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";
|
|
@@ -199,8 +200,19 @@ export class AndroidTransport {
|
|
|
199
200
|
getRecentLogs(tag, limit, grep) {
|
|
200
201
|
if (!ADB)
|
|
201
202
|
throw new Error("adb not found on PATH or common SDK locations");
|
|
202
|
-
//
|
|
203
|
-
//
|
|
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.
|
|
204
216
|
if (isCapturing()) {
|
|
205
217
|
// Opportunistic rotation. Stop+rename+restart when the file grows
|
|
206
218
|
// past the threshold so the child never writes past a truncated EOF.
|
|
@@ -1,3 +1,11 @@
|
|
|
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. */
|
|
@@ -24,3 +32,10 @@ export declare const MAX_LOG_SESSION_BUFFER = 20000;
|
|
|
24
32
|
* `SLACK_RADAR_WEB_PORT` env override.
|
|
25
33
|
*/
|
|
26
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,3 +1,20 @@
|
|
|
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. */
|
|
@@ -24,3 +41,10 @@ export const MAX_LOG_SESSION_BUFFER = 20000;
|
|
|
24
41
|
* `SLACK_RADAR_WEB_PORT` env override.
|
|
25
42
|
*/
|
|
26
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";
|
package/dist/shared/db.d.ts
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
* cleaned on server start/stop (see `cleanupPulledDatabases`). The DB names are learned
|
|
14
14
|
* live from the device (`listDatabases`); none are baked into this file.
|
|
15
15
|
*/
|
|
16
|
-
/**
|
|
17
|
-
|
|
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;
|
|
18
22
|
export declare function availableUsers(): string[];
|
|
19
23
|
/**
|
|
20
24
|
* Reset cached device state. Clears the sqlite-binary path so a device swap
|
|
@@ -50,20 +54,33 @@ export declare function listDatabases(user?: string | null): {
|
|
|
50
54
|
*/
|
|
51
55
|
export declare function pullDatabase(name: string, user?: string | null, force?: boolean): Promise<string>;
|
|
52
56
|
/**
|
|
53
|
-
*
|
|
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.
|
|
54
62
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
|
|
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
|
|
62
69
|
* control; a keyword denylist alone is whack-a-mole.
|
|
63
|
-
*
|
|
64
|
-
* explicit reject of
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
67
84
|
*/
|
|
68
85
|
export declare function queryDatabase(name: string, sql: string, user?: string | null): Promise<unknown[]>;
|
|
69
86
|
/** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
|
package/dist/shared/db.js
CHANGED
|
@@ -19,13 +19,18 @@ import fs from "fs";
|
|
|
19
19
|
import os from "os";
|
|
20
20
|
import path from "path";
|
|
21
21
|
import { resolveAdbPath } from "./android.js";
|
|
22
|
+
import { SLACK_DEBUG_PKG } from "./constants.js";
|
|
22
23
|
/** The debug build whose sandbox we can `run-as` into. */
|
|
23
|
-
const DB_PKG =
|
|
24
|
-
/** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600.
|
|
24
|
+
const DB_PKG = SLACK_DEBUG_PKG;
|
|
25
|
+
/** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600. Scoped by a per-process key so concurrent readers do not collide. */
|
|
25
26
|
let DB_TMP = path.join(os.tmpdir(), "slack-radar-db");
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Initialize DB_TMP with a per-process-unique path. Call on startup. The web server scopes
|
|
29
|
+
* by its port; the MCP server scopes by process.pid (every MCP shares one fixed port, so a
|
|
30
|
+
* port key would collide between two concurrent MCP servers).
|
|
31
|
+
*/
|
|
32
|
+
export function initializeDatabasePath(scopeKey) {
|
|
33
|
+
DB_TMP = path.join(os.tmpdir(), `slack-radar-db-${scopeKey}`);
|
|
29
34
|
}
|
|
30
35
|
/** Every valid SQLite file begins with these 16 bytes. */
|
|
31
36
|
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "binary");
|
|
@@ -302,31 +307,32 @@ export async function pullDatabase(name, user, force = false) {
|
|
|
302
307
|
return local;
|
|
303
308
|
}
|
|
304
309
|
/**
|
|
305
|
-
*
|
|
310
|
+
* Read-only SQL gate. Throws on anything that is not a single read-only statement.
|
|
311
|
+
*
|
|
312
|
+
* Exported so a caller (the MCP query handler) can reject a bad query BEFORE pulling a
|
|
313
|
+
* multi-MB DB copy off the device. queryDatabase calls it too, so the boundary holds even
|
|
314
|
+
* if a caller forgets the pre-check.
|
|
306
315
|
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
|
|
316
|
+
* What actually keeps this safe (the boundary does NOT rest on this gate alone):
|
|
317
|
+
* 1. copy-based design — we only ever READ the device file (`exec-out cat`) and query a
|
|
318
|
+
* throwaway local copy; nothing is ever written back, so no query can mutate device
|
|
319
|
+
* state no matter what it does to the copy.
|
|
320
|
+
* 2. `sqlite3 -safe` — engine-level: forbids ATTACH, readfile/writefile/edit, and
|
|
321
|
+
* load_extension while still allowing ordinary SELECT/PRAGMA. The load-bearing exfil
|
|
314
322
|
* control; a keyword denylist alone is whack-a-mole.
|
|
315
|
-
*
|
|
316
|
-
* explicit reject of
|
|
317
|
-
*
|
|
318
|
-
*
|
|
323
|
+
* 3. this gate — a single SELECT/PRAGMA/WITH/EXPLAIN/VALUES statement (no `;`-chaining),
|
|
324
|
+
* a write-PRAGMA reject, and an explicit reject of file-access primitive names as
|
|
325
|
+
* defense in depth.
|
|
326
|
+
* NOTE: the copy is opened NORMALLY (not `-readonly`, not `immutable`) on purpose, so SQLite
|
|
327
|
+
* replays the `-wal` sidecar and recently-committed rows stay visible. `-readonly`/immutable
|
|
328
|
+
* would read the main file alone and silently drop them; writes to the throwaway copy are
|
|
329
|
+
* harmless. So `-readonly` is intentionally NOT passed — the read-only guarantee comes from
|
|
330
|
+
* (1) and (2), not from the open mode.
|
|
319
331
|
*/
|
|
320
|
-
export
|
|
321
|
-
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
// UX; it is NOT the security boundary. A `WITH ... DELETE` would slip the gate,
|
|
325
|
-
// but the device-safety property does not rest here — it rests on the copy-based
|
|
326
|
-
// design (the device file is `cat`'d to a throwaway and never written back), plus
|
|
327
|
-
// `-safe` (blocks file access), the single-statement gate, the write-PRAGMA
|
|
328
|
-
// reject, and the file-primitive denylist below. Worst case a smuggled write
|
|
329
|
-
// mutates the disposable copy; the device store is untouched.
|
|
332
|
+
export function assertReadOnlyQuery(sql) {
|
|
333
|
+
// Leading-keyword gate over the common read-query entry forms. This shapes UX; it is NOT
|
|
334
|
+
// the security boundary (a `WITH ... DELETE` would slip it, but that can only touch the
|
|
335
|
+
// throwaway copy). The boundary is the copy-based design + `-safe` above.
|
|
330
336
|
if (!/^\s*(select|pragma|with|explain|values)\b/i.test(sql)) {
|
|
331
337
|
throw new Error("only read-only statements are allowed (SELECT, WITH, EXPLAIN, VALUES, PRAGMA)");
|
|
332
338
|
}
|
|
@@ -337,11 +343,8 @@ export async function queryDatabase(name, sql, user) {
|
|
|
337
343
|
}
|
|
338
344
|
// Reject the common write PRAGMAs: an assigning form (`PRAGMA user_version=5`,
|
|
339
345
|
// `PRAGMA journal_mode=DELETE`) or a checkpoint (`PRAGMA wal_checkpoint(...)`).
|
|
340
|
-
//
|
|
341
|
-
// `
|
|
342
|
-
// guarantee comes from the copy-based design (we only ever read the device file
|
|
343
|
-
// and write a throwaway local copy), not from this gate. Worst case such a PRAGMA
|
|
344
|
-
// mutates the disposable copy; the device store is never written back.
|
|
346
|
+
// Not exhaustive — a few argless mutating PRAGMAs (`incremental_vacuum`, `optimize`,
|
|
347
|
+
// `shrink_memory`) still pass. Acceptable: worst case they mutate the disposable copy.
|
|
345
348
|
if (/^\s*pragma\b/i.test(sql) && (/=/.test(sql) || /\bwal_checkpoint\b/i.test(sql))) {
|
|
346
349
|
throw new Error("that PRAGMA writes; only read-only PRAGMAs are allowed");
|
|
347
350
|
}
|
|
@@ -349,6 +352,15 @@ export async function queryDatabase(name, sql, user) {
|
|
|
349
352
|
if (/\b(attach|readfile|writefile|edit|load_extension|fts3_tokenizer)\b/i.test(sql)) {
|
|
350
353
|
throw new Error("that function/statement is not allowed (read-only, no file access)");
|
|
351
354
|
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Run a read-only query against a (lazily pulled) local copy. The query is gated by
|
|
358
|
+
* assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
|
|
359
|
+
* own error message on failure.
|
|
360
|
+
*/
|
|
361
|
+
export async function queryDatabase(name, sql, user) {
|
|
362
|
+
assertSafeName(name);
|
|
363
|
+
assertReadOnlyQuery(sql);
|
|
352
364
|
const u = resolveUser(user);
|
|
353
365
|
const local = localPath(name, u);
|
|
354
366
|
if (!fs.existsSync(local))
|