@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.
Files changed (46) hide show
  1. package/README.md +5 -4
  2. package/dist/mcp/api-paths.d.ts +1 -0
  3. package/dist/mcp/api-paths.js +108 -0
  4. package/dist/mcp/db-tools.d.ts +46 -0
  5. package/dist/mcp/db-tools.js +160 -0
  6. package/dist/mcp/index.js +84 -80
  7. package/dist/mcp/logs-result.d.ts +9 -0
  8. package/dist/mcp/logs-result.js +15 -0
  9. package/dist/mcp/tools.js +70 -7
  10. package/dist/shared/android.d.ts +2 -2
  11. package/dist/shared/android.js +30 -13
  12. package/dist/shared/constants.d.ts +15 -0
  13. package/dist/shared/constants.js +24 -0
  14. package/dist/shared/db.d.ts +31 -14
  15. package/dist/shared/db.js +44 -32
  16. package/dist/shared/debug-log.d.ts +50 -0
  17. package/dist/shared/debug-log.js +108 -0
  18. package/dist/shared/screen.d.ts +35 -2
  19. package/dist/shared/screen.js +75 -6
  20. package/dist/shared/stream.d.ts +1 -1
  21. package/dist/shared/transport.d.ts +22 -4
  22. package/dist/web/bin.d.ts +16 -1
  23. package/dist/web/bin.js +133 -19
  24. package/dist/web/public/index.html +244 -1069
  25. package/dist/web/public/next/app.js +210 -0
  26. package/dist/web/public/next/db.js +286 -0
  27. package/dist/web/public/next/esc.js +13 -0
  28. package/dist/web/public/next/feed.js +608 -0
  29. package/dist/web/public/next/filters.js +120 -0
  30. package/dist/web/public/next/hooks.js +57 -0
  31. package/dist/web/public/next/html.js +10 -0
  32. package/dist/web/public/next/inspector.js +258 -0
  33. package/dist/web/public/next/registry.js +65 -0
  34. package/dist/web/public/next/screen.js +180 -0
  35. package/dist/web/public/next/spec-types.js +1 -0
  36. package/dist/web/public/next/store.js +714 -0
  37. package/dist/web/public/next/widgets.js +194 -0
  38. package/dist/web/public/vendor/README.md +25 -0
  39. package/dist/web/public/vendor/hooks.module.js +2 -0
  40. package/dist/web/public/vendor/htm.LICENSE +202 -0
  41. package/dist/web/public/vendor/htm.module.js +1 -0
  42. package/dist/web/public/vendor/preact.LICENSE +21 -0
  43. package/dist/web/public/vendor/preact.module.js +2 -0
  44. package/dist/web/server.d.ts +17 -0
  45. package/dist/web/server.js +194 -16
  46. package/package.json +6 -2
package/README.md CHANGED
@@ -45,9 +45,10 @@ Opens `http://localhost:8100` automatically. Set `SLACK_RADAR_WEB_PORT` to use a
45
45
  * **RTM** — real-time websocket events
46
46
  * **Clogs** — analytics events, click one to see its payload
47
47
  * **Logs** — device logcat, filtered to the Slack app (switch app or clear the filter)
48
+ * **Database** — read-only browser for the on-device SQLite stores (pick a database, then a table, or run a read-only query). See the data-handling note below.
48
49
  * **Custom** — a tab Claude builds on request. Ask a Claude Code session in your terminal for a view the pre-built tabs do not cover (e.g. "graph calls per second and flag the slow ones"); it authors the view via the `open_radar_dashboard` tool and it appears here. Persists until you refresh the page.
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
+ Each tab has a 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. The dashboard follows your system light/dark setting by default; the theme toggle in the header overrides it and is remembered.
51
52
 
52
53
  ### Screen overlay
53
54
 
@@ -89,7 +90,7 @@ Radar starts **disabled** each session. All tools except `ping` return an error
89
90
  | `wait_for_events` | Block until matching events arrive via SSE stream. Idle-timeout based, not polling. |
90
91
  | `search` | Full-text search across network, RTM, and clog buffers. |
91
92
  | `get_app_state` | Current Activity/Fragment state via `dumpsys`. Shows visible screen, parameters, tab state. |
92
- | `get_recent_logs` | Logcat entries filtered by tag. For Timber debug logs (`FKDebug`, `SlackRadar`, etc.). |
93
+ | `get_recent_logs` | Recent device logs, optionally filtered by tag (omit `tag` for all lines). Reads the app's own debug log buffer first, falling back to logcat; the result's `source` field (`app_buffer` / `logcat_capture` / `logcat_ring`) names which one served it. For Timber debug logs (`FKDebug`, `SlackRadar`, etc.). |
93
94
  | `screenshot` | Capture device screen as PNG. 720px default, `full_res=true` for full resolution. |
94
95
  | `record_screen` | Record short screen video (MP4). Default 5s, max 30s. |
95
96
  | `clear_buffers` | Clear all captured network calls and RTM events from device and local buffers. |
@@ -146,7 +147,7 @@ This tool captures unredacted network traffic from your debug Slack build, inclu
146
147
  * RTM WebSocket payloads (every sent and received message)
147
148
  * Analytics (clog) events serialized as JSON
148
149
  * Screenshots and short screen recordings on demand
149
- * Logcat entries filtered by tag
150
+ * Device logs (the app's own debug buffer, with a logcat fallback), optionally filtered by tag
150
151
 
151
152
  Auth tokens and session cookies will appear in captured data. Captured data flows to your MCP client (Claude Code), which may send relevant portions to the configured AI provider per its own data handling policy.
152
153
 
@@ -185,4 +186,4 @@ The MCP server auto-detects the Android user ID via `ps` and targets the broadca
185
186
  Restart your Claude Code session. MCP servers are registered at session start.
186
187
 
187
188
  **Port already in use (web dashboard)**
188
- Another instance may be running. Kill it with `lsof -ti :8100 | xargs kill` or set a different port with `SLACK_RADAR_WEB_PORT=8101 npx @slack/radar-mcp web`.
189
+ 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 @@
1
+ export declare function buildApiPath(name: string, args: Record<string, unknown>): string | null;
@@ -0,0 +1,108 @@
1
+ // Maps an MCP tool name + its args to the device HTTP path the MCP server proxies it to.
2
+ // Extracted from index.ts (where it was nested inside runMcpServer, so unreachable by a unit
3
+ // test) into its own module so the device-route contract for EVERY proxied tool -- network,
4
+ // rtm, clog, trace, search, and the detail-by-id variants -- is pinned by a table test
5
+ // (tests/api-paths.test.mjs). Pure: no I/O, no device, just string building.
6
+ //
7
+ // The arg shapes here are the query-param subset each tool sends; they intentionally duplicate
8
+ // the (wider) handler arg interfaces in index.ts rather than couple this pure module to them.
9
+ // Build the device path for a detail-by-id tool. The MCP SDK casts args without enforcing the
10
+ // JSON Schema (no zod), so `id` can arrive as a string carrying "/" or ".." even though the
11
+ // schema says number. All four detail tools (network/rtm/clog/trace) read the device's OWN
12
+ // forwarded server with no shell/fs, so the blast radius is small, but an un-encoded id is still
13
+ // a path-traversal smell. Accept only a non-negative integer id; anything else returns null
14
+ // (the caller reports "unknown tool"/no path rather than firing a malformed device request).
15
+ function detailPath(resource, id) {
16
+ let n;
17
+ if (typeof id === "number") {
18
+ n = id;
19
+ }
20
+ else if (typeof id === "string" && /^\d+$/.test(id.trim())) {
21
+ // a clean digit string only -- Number("") and Number(" ") are 0 and Number("1/../x") is NaN,
22
+ // but "1e3"/"0x10"/" 5 " would also coerce; the \d+ test rejects all of those outright.
23
+ n = Number(id.trim());
24
+ }
25
+ else {
26
+ return null;
27
+ }
28
+ if (!Number.isInteger(n) || n < 0)
29
+ return null;
30
+ return `/api/${resource}/${n}`;
31
+ }
32
+ export function buildApiPath(name, args) {
33
+ switch (name) {
34
+ case "get_recent_network_calls": {
35
+ const a = args;
36
+ const params = new URLSearchParams();
37
+ if (a?.limit)
38
+ params.set("limit", String(a.limit));
39
+ if (a?.url_filter)
40
+ params.set("url_filter", a.url_filter);
41
+ if (a?.status_code)
42
+ params.set("status_code", String(a.status_code));
43
+ const qs = params.toString();
44
+ return `/api/network${qs ? "?" + qs : ""}`;
45
+ }
46
+ case "get_network_call_detail": {
47
+ const a = args;
48
+ return detailPath("network", a.id);
49
+ }
50
+ case "get_recent_rtm_events": {
51
+ const a = args;
52
+ const params = new URLSearchParams();
53
+ if (a?.limit)
54
+ params.set("limit", String(a.limit));
55
+ if (a?.event_type)
56
+ params.set("event_type", a.event_type);
57
+ if (a?.direction)
58
+ params.set("direction", a.direction);
59
+ if (a?.channel)
60
+ params.set("channel", a.channel);
61
+ const qs = params.toString();
62
+ return `/api/rtm${qs ? "?" + qs : ""}`;
63
+ }
64
+ case "get_rtm_event_detail": {
65
+ const a = args;
66
+ return detailPath("rtm", a.id);
67
+ }
68
+ case "get_recent_clogs": {
69
+ const a = args;
70
+ const params = new URLSearchParams();
71
+ if (a?.limit)
72
+ params.set("limit", String(a.limit));
73
+ if (a?.name)
74
+ params.set("name", a.name);
75
+ const qs = params.toString();
76
+ return `/api/clogs${qs ? "?" + qs : ""}`;
77
+ }
78
+ case "get_clog_detail": {
79
+ const a = args;
80
+ return detailPath("clogs", a.id);
81
+ }
82
+ case "get_recent_traces": {
83
+ const a = args;
84
+ const params = new URLSearchParams();
85
+ // Default the limit when absent: traces can be large and an omitted limit would otherwise
86
+ // rely on an unverified device-side default and stream an unbounded result into the model
87
+ // context. 50 matches the other recent-* tools' practical page size.
88
+ params.set("limit", String(a?.limit && a.limit > 0 ? a.limit : 50));
89
+ if (a?.name)
90
+ params.set("name", a.name);
91
+ return `/api/traces?${params.toString()}`;
92
+ }
93
+ case "get_trace_detail": {
94
+ const a = args;
95
+ return detailPath("traces", a.id);
96
+ }
97
+ case "search": {
98
+ const a = args;
99
+ const params = new URLSearchParams();
100
+ params.set("q", a.query);
101
+ if (a?.limit)
102
+ params.set("limit", String(a.limit));
103
+ return `/api/search?${params.toString()}`;
104
+ }
105
+ default:
106
+ return null;
107
+ }
108
+ }
@@ -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,7 +12,11 @@ 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 { clampLogLimit, shapeLogsResult } from "./logs-result.js";
16
+ import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./db-tools.js";
17
+ import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
15
18
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
19
+ import { buildApiPath } from "./api-paths.js";
16
20
  // --- Subcommand + bin-name dispatch ---
17
21
  //
18
22
  // One entrypoint, two front doors, chosen so each invocation does the obvious thing:
@@ -164,7 +168,8 @@ async function openDashboardSpec(spec) {
164
168
  return r.json;
165
169
  }
166
170
  if (wantsWeb) {
167
- await import("../web/bin.js");
171
+ const { startWebServer } = await import("../web/bin.js");
172
+ startWebServer();
168
173
  }
169
174
  else {
170
175
  await runMcpServer();
@@ -183,9 +188,42 @@ async function runMcpServer() {
183
188
  }
184
189
  const device = createTransport();
185
190
  setTransport(device);
191
+ // Scope pulled-DB copies to THIS process by pid, then clean them on exit (mirrors the web
192
+ // server's lifecycle). The DB tools write plaintext message-store copies to a 0600 tmp dir;
193
+ // pid-scoping keeps two concurrent Radar MCP servers from sharing a dir and clobbering each
194
+ // other's live pulls (a fixed port would collide, since every MCP uses the same RADAR_PORT).
195
+ // A hard crash leaks this process's dir until the OS sweeps tmp; the files stay 0600 in a
196
+ // 0700 dir, so that is a bounded exposure, not a cross-user leak.
197
+ initializeDatabasePath(process.pid);
198
+ cleanupPulledDatabases();
199
+ for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
200
+ process.once(sig, cleanupPulledDatabases);
201
+ }
186
202
  let sessionEnabled = false;
203
+ // Host-side ceiling on a proxied device response. radarRequest forwards the device body straight
204
+ // into the model context, so an unbounded response (large trace/clog payloads) could flood it.
205
+ // When the cap trips we stop reading, tear the socket down, and return a truncation marker
206
+ // rather than a partial JSON blob the parser would choke on. 1MB is generous for a point query;
207
+ // bulk exports go through capture_snapshot, not this path.
208
+ const MAX_RADAR_RESPONSE_BYTES = 1024 * 1024;
187
209
  function radarRequest(requestPath) {
188
210
  return new Promise((resolve, reject) => {
211
+ // settled-guard: req.destroy() on the cap path fires "error" (ECONNRESET) AFTER we have
212
+ // already resolved the truncation, so every settle goes through these wrappers and the
213
+ // second one is a no-op. Without this the abort would reject an already-resolved promise.
214
+ let settled = false;
215
+ const done = (r) => {
216
+ if (settled)
217
+ return;
218
+ settled = true;
219
+ resolve(r);
220
+ };
221
+ const fail = (e) => {
222
+ if (settled)
223
+ return;
224
+ settled = true;
225
+ reject(e);
226
+ };
189
227
  const req = http.get({
190
228
  host: RADAR_HOST,
191
229
  port: RADAR_PORT,
@@ -193,22 +231,36 @@ async function runMcpServer() {
193
231
  timeout: 10000,
194
232
  }, (res) => {
195
233
  let data = "";
234
+ let bytes = 0;
196
235
  res.on("data", (chunk) => {
236
+ if (settled)
237
+ return;
238
+ bytes += chunk.length; // byte length, not String.length (UTF-8 over the wire)
239
+ if (bytes > MAX_RADAR_RESPONSE_BYTES) {
240
+ // Resolve the truncation NOW (not on "end" — req.destroy() aborts the response so
241
+ // "end" never fires; it surfaces as an "error" the settled-guard then swallows).
242
+ done({
243
+ error: `Device response exceeded the ${Math.round(MAX_RADAR_RESPONSE_BYTES / 1024)}KB response budget and was dropped to protect the model context. Narrow the query (lower limit, add a name/filter), or use capture_snapshot for a bulk export.`,
244
+ _truncated: true,
245
+ });
246
+ req.destroy();
247
+ return;
248
+ }
197
249
  data += chunk;
198
250
  });
199
251
  res.on("end", () => {
200
252
  try {
201
- resolve(JSON.parse(data));
253
+ done(JSON.parse(data));
202
254
  }
203
255
  catch {
204
- resolve({ raw: data });
256
+ done({ raw: data });
205
257
  }
206
258
  });
207
259
  });
208
- req.on("error", reject);
260
+ req.on("error", fail);
209
261
  req.on("timeout", () => {
210
262
  req.destroy();
211
- reject(new Error("Request timed out"));
263
+ fail(new Error("Request timed out"));
212
264
  });
213
265
  });
214
266
  }
@@ -260,6 +312,10 @@ async function runMcpServer() {
260
312
  async function handleSessionEnable(args) {
261
313
  const timeout = args?.timeout_minutes || 5;
262
314
  sessionEnabled = true;
315
+ // A re-enable may be a different device or a re-armed profile, so drop the cached DB
316
+ // user set + sqlite path; the next DB tool call re-detects the profiles fresh. Mirrors
317
+ // the web server's handleEnable (server.ts), which resets for the same reason.
318
+ resetDbState();
263
319
  const forwarded = device.ensureForward();
264
320
  if (!forwarded) {
265
321
  sessionEnabled = false;
@@ -409,18 +465,14 @@ async function runMcpServer() {
409
465
  }
410
466
  function handleGetRecentLogs(args) {
411
467
  try {
412
- const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
413
- // Surface the capture file path so the model knows which on-disk file
414
- // produced these lines. Helps avoid cross-session staleness (e.g. the
415
- // model remembering an earlier profile's logs after a profile switch).
416
- const sourceFile = device.getCaptureFilePath?.() ?? null;
417
- return textResult({
418
- tag: args.tag,
419
- lines,
420
- count: lines.length,
421
- source_file: sourceFile,
422
- source: sourceFile ? "capture" : "adb_logcat_d",
423
- });
468
+ const tag = args.tag ?? "";
469
+ const limit = clampLogLimit(args.limit);
470
+ const result = device.getRecentLogs(tag, limit, args.grep);
471
+ // shapeLogsResult preserves the source label (app_buffer / logcat_capture / logcat_ring)
472
+ // that getRecentLogs returns, so the caller can tell a true-empty result from a silent
473
+ // fallback to an inferior source (0 lines from app_buffer means the tag is really absent;
474
+ // 0 from logcat_ring may mean the better source was unreachable).
475
+ return textResult(shapeLogsResult(tag, result));
424
476
  }
425
477
  catch (e) {
426
478
  return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
@@ -499,70 +551,8 @@ async function runMcpServer() {
499
551
  }, true);
500
552
  }
501
553
  }
502
- function buildApiPath(name, args) {
503
- switch (name) {
504
- case "get_recent_network_calls": {
505
- const a = args;
506
- const params = new URLSearchParams();
507
- if (a?.limit)
508
- params.set("limit", String(a.limit));
509
- if (a?.url_filter)
510
- params.set("url_filter", a.url_filter);
511
- if (a?.status_code)
512
- params.set("status_code", String(a.status_code));
513
- const qs = params.toString();
514
- return `/api/network${qs ? "?" + qs : ""}`;
515
- }
516
- case "get_network_call_detail": {
517
- const a = args;
518
- return `/api/network/${a.id}`;
519
- }
520
- case "get_recent_rtm_events": {
521
- const a = args;
522
- const params = new URLSearchParams();
523
- if (a?.limit)
524
- params.set("limit", String(a.limit));
525
- if (a?.event_type)
526
- params.set("event_type", a.event_type);
527
- if (a?.direction)
528
- params.set("direction", a.direction);
529
- if (a?.channel)
530
- params.set("channel", a.channel);
531
- const qs = params.toString();
532
- return `/api/rtm${qs ? "?" + qs : ""}`;
533
- }
534
- case "get_rtm_event_detail": {
535
- const a = args;
536
- return `/api/rtm/${a.id}`;
537
- }
538
- case "get_recent_clogs": {
539
- const a = args;
540
- const params = new URLSearchParams();
541
- if (a?.limit)
542
- params.set("limit", String(a.limit));
543
- if (a?.name)
544
- params.set("name", a.name);
545
- const qs = params.toString();
546
- return `/api/clogs${qs ? "?" + qs : ""}`;
547
- }
548
- case "get_clog_detail": {
549
- const a = args;
550
- return `/api/clogs/${a.id}`;
551
- }
552
- case "search": {
553
- const a = args;
554
- const params = new URLSearchParams();
555
- params.set("q", a.query);
556
- if (a?.limit)
557
- params.set("limit", String(a.limit));
558
- return `/api/search?${params.toString()}`;
559
- }
560
- default:
561
- return null;
562
- }
563
- }
564
554
  // --- MCP Server ---
565
- const server = new Server({ name: "slack-radar", version: "1.5.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
555
+ const server = new Server({ name: "slack-radar", version: "1.7.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
566
556
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
567
557
  tools: TOOL_DEFINITIONS,
568
558
  }));
@@ -617,6 +607,20 @@ async function runMcpServer() {
617
607
  if (name === "ping") {
618
608
  return handlePing(toolArgs);
619
609
  }
610
+ if (name === "list_databases") {
611
+ const { data, isError } = handleListDatabases(device);
612
+ return textResult(data, isError);
613
+ }
614
+ if (name === "list_tables") {
615
+ const { db, refresh } = toolArgs;
616
+ const { data, isError } = await handleListTables(device, db ?? "", refresh === true);
617
+ return textResult(data, isError);
618
+ }
619
+ if (name === "query_database") {
620
+ const { db, sql, refresh } = toolArgs;
621
+ const { data, isError } = await handleQueryDatabase(device, db ?? "", sql ?? "", refresh === true);
622
+ return textResult(data, isError);
623
+ }
620
624
  const apiPath = buildApiPath(name, toolArgs);
621
625
  if (apiPath === null) {
622
626
  return textResult(`Unknown tool: ${name}`, true);
@@ -0,0 +1,9 @@
1
+ import type { LogResult } from "../shared/transport.js";
2
+ export interface LogsToolResult {
3
+ tag: string;
4
+ lines: string[];
5
+ count: number;
6
+ source?: string;
7
+ }
8
+ export declare function clampLogLimit(limit: number | undefined): number;
9
+ export declare function shapeLogsResult(tag: string, result: LogResult): LogsToolResult;
@@ -0,0 +1,15 @@
1
+ // Clamp the requested line count. With tag optional, a no-tag call returns the whole buffer
2
+ // (thousands of lines, ~1.5MB), so an unbounded limit would flood the model context with
3
+ // potentially PII-bearing log lines. 1000 is a generous ceiling for a debug read; default 50.
4
+ export function clampLogLimit(limit) {
5
+ return Math.min(Math.max(limit || 50, 1), 1000);
6
+ }
7
+ // Shape the device result into the tool payload, preserving the source label verbatim.
8
+ export function shapeLogsResult(tag, result) {
9
+ return {
10
+ tag,
11
+ lines: result.lines,
12
+ count: result.lines.length,
13
+ source: result.source,
14
+ };
15
+ }