@slack/radar-mcp 1.6.0 → 1.8.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 +4 -3
- package/dist/mcp/api-paths.d.ts +1 -0
- package/dist/mcp/api-paths.js +108 -0
- package/dist/mcp/index.js +52 -83
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/radar-response.d.ts +21 -0
- package/dist/mcp/radar-response.js +38 -0
- package/dist/mcp/snapshot.d.ts +4 -2
- package/dist/mcp/snapshot.js +25 -12
- package/dist/mcp/tools.js +34 -10
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +16 -11
- package/dist/shared/db.d.ts +13 -0
- package/dist/shared/db.js +30 -3
- package/dist/shared/screen.d.ts +10 -0
- package/dist/shared/screen.js +13 -1
- package/dist/shared/stream.d.ts +1 -1
- package/dist/shared/transport.d.ts +12 -4
- package/dist/web/public/index.html +242 -1126
- package/dist/web/public/next/app.js +210 -0
- package/dist/web/public/next/db.js +286 -0
- package/dist/web/public/next/esc.js +13 -0
- package/dist/web/public/next/feed.js +608 -0
- package/dist/web/public/next/filters.js +120 -0
- package/dist/web/public/next/hooks.js +57 -0
- package/dist/web/public/next/html.js +10 -0
- package/dist/web/public/next/inspector.js +258 -0
- package/dist/web/public/next/registry.js +65 -0
- package/dist/web/public/next/screen.js +180 -0
- package/dist/web/public/next/spec-types.js +1 -0
- package/dist/web/public/next/store.js +766 -0
- package/dist/web/public/next/widgets.js +194 -0
- package/dist/web/server.js +7 -1
- package/package.json +3 -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
|
|
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` |
|
|
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
|
-
*
|
|
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
|
|
|
@@ -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
|
+
}
|
package/dist/mcp/index.js
CHANGED
|
@@ -12,9 +12,12 @@ 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";
|
|
15
16
|
import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./db-tools.js";
|
|
16
17
|
import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
|
|
17
18
|
import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
|
|
19
|
+
import { buildApiPath } from "./api-paths.js";
|
|
20
|
+
import { interpretRadarResponse } from "./radar-response.js";
|
|
18
21
|
// --- Subcommand + bin-name dispatch ---
|
|
19
22
|
//
|
|
20
23
|
// One entrypoint, two front doors, chosen so each invocation does the obvious thing:
|
|
@@ -198,8 +201,31 @@ async function runMcpServer() {
|
|
|
198
201
|
process.once(sig, cleanupPulledDatabases);
|
|
199
202
|
}
|
|
200
203
|
let sessionEnabled = false;
|
|
204
|
+
// --- HTTP requests to the on-device radar server ---
|
|
205
|
+
// Host-side ceiling on a proxied device response. radarRequest forwards the device body straight
|
|
206
|
+
// into the model context, so an unbounded response (large trace/clog payloads) could flood it.
|
|
207
|
+
// When the cap trips we stop reading, tear the socket down, and return a truncation marker
|
|
208
|
+
// rather than a partial JSON blob the parser would choke on. 1MB is generous for a point query;
|
|
209
|
+
// bulk exports go through capture_snapshot, not this path.
|
|
210
|
+
const MAX_RADAR_RESPONSE_BYTES = 1024 * 1024;
|
|
201
211
|
function radarRequest(requestPath) {
|
|
202
212
|
return new Promise((resolve, reject) => {
|
|
213
|
+
// settled-guard: req.destroy() on the cap path fires "error" (ECONNRESET) AFTER we have
|
|
214
|
+
// already resolved the truncation, so every settle goes through these wrappers and the
|
|
215
|
+
// second one is a no-op. Without this the abort would reject an already-resolved promise.
|
|
216
|
+
let settled = false;
|
|
217
|
+
const done = (r) => {
|
|
218
|
+
if (settled)
|
|
219
|
+
return;
|
|
220
|
+
settled = true;
|
|
221
|
+
resolve(r);
|
|
222
|
+
};
|
|
223
|
+
const fail = (e) => {
|
|
224
|
+
if (settled)
|
|
225
|
+
return;
|
|
226
|
+
settled = true;
|
|
227
|
+
reject(e);
|
|
228
|
+
};
|
|
203
229
|
const req = http.get({
|
|
204
230
|
host: RADAR_HOST,
|
|
205
231
|
port: RADAR_PORT,
|
|
@@ -207,22 +233,31 @@ async function runMcpServer() {
|
|
|
207
233
|
timeout: 10000,
|
|
208
234
|
}, (res) => {
|
|
209
235
|
let data = "";
|
|
236
|
+
let bytes = 0;
|
|
210
237
|
res.on("data", (chunk) => {
|
|
238
|
+
if (settled)
|
|
239
|
+
return;
|
|
240
|
+
bytes += chunk.length; // byte length, not String.length (UTF-8 over the wire)
|
|
241
|
+
if (bytes > MAX_RADAR_RESPONSE_BYTES) {
|
|
242
|
+
// Resolve the truncation NOW (not on "end" — req.destroy() aborts the response so
|
|
243
|
+
// "end" never fires; it surfaces as an "error" the settled-guard then swallows).
|
|
244
|
+
done({
|
|
245
|
+
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.`,
|
|
246
|
+
_truncated: true,
|
|
247
|
+
});
|
|
248
|
+
req.destroy();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
211
251
|
data += chunk;
|
|
212
252
|
});
|
|
213
253
|
res.on("end", () => {
|
|
214
|
-
|
|
215
|
-
resolve(JSON.parse(data));
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
resolve({ raw: data });
|
|
219
|
-
}
|
|
254
|
+
done(interpretRadarResponse(res.statusCode ?? 0, data));
|
|
220
255
|
});
|
|
221
256
|
});
|
|
222
|
-
req.on("error",
|
|
257
|
+
req.on("error", fail);
|
|
223
258
|
req.on("timeout", () => {
|
|
224
259
|
req.destroy();
|
|
225
|
-
|
|
260
|
+
fail(new Error("Request timed out"));
|
|
226
261
|
});
|
|
227
262
|
});
|
|
228
263
|
}
|
|
@@ -427,18 +462,14 @@ async function runMcpServer() {
|
|
|
427
462
|
}
|
|
428
463
|
function handleGetRecentLogs(args) {
|
|
429
464
|
try {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
count: lines.length,
|
|
439
|
-
source_file: sourceFile,
|
|
440
|
-
source: sourceFile ? "capture" : "adb_logcat_d",
|
|
441
|
-
});
|
|
465
|
+
const tag = args.tag ?? "";
|
|
466
|
+
const limit = clampLogLimit(args.limit);
|
|
467
|
+
const result = device.getRecentLogs(tag, limit, args.grep);
|
|
468
|
+
// shapeLogsResult preserves the source label (app_buffer / logcat_capture / logcat_ring)
|
|
469
|
+
// that getRecentLogs returns, so the caller can tell a true-empty result from a silent
|
|
470
|
+
// fallback to an inferior source (0 lines from app_buffer means the tag is really absent;
|
|
471
|
+
// 0 from logcat_ring may mean the better source was unreachable).
|
|
472
|
+
return textResult(shapeLogsResult(tag, result));
|
|
442
473
|
}
|
|
443
474
|
catch (e) {
|
|
444
475
|
return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
|
|
@@ -517,70 +548,8 @@ async function runMcpServer() {
|
|
|
517
548
|
}, true);
|
|
518
549
|
}
|
|
519
550
|
}
|
|
520
|
-
function buildApiPath(name, args) {
|
|
521
|
-
switch (name) {
|
|
522
|
-
case "get_recent_network_calls": {
|
|
523
|
-
const a = args;
|
|
524
|
-
const params = new URLSearchParams();
|
|
525
|
-
if (a?.limit)
|
|
526
|
-
params.set("limit", String(a.limit));
|
|
527
|
-
if (a?.url_filter)
|
|
528
|
-
params.set("url_filter", a.url_filter);
|
|
529
|
-
if (a?.status_code)
|
|
530
|
-
params.set("status_code", String(a.status_code));
|
|
531
|
-
const qs = params.toString();
|
|
532
|
-
return `/api/network${qs ? "?" + qs : ""}`;
|
|
533
|
-
}
|
|
534
|
-
case "get_network_call_detail": {
|
|
535
|
-
const a = args;
|
|
536
|
-
return `/api/network/${a.id}`;
|
|
537
|
-
}
|
|
538
|
-
case "get_recent_rtm_events": {
|
|
539
|
-
const a = args;
|
|
540
|
-
const params = new URLSearchParams();
|
|
541
|
-
if (a?.limit)
|
|
542
|
-
params.set("limit", String(a.limit));
|
|
543
|
-
if (a?.event_type)
|
|
544
|
-
params.set("event_type", a.event_type);
|
|
545
|
-
if (a?.direction)
|
|
546
|
-
params.set("direction", a.direction);
|
|
547
|
-
if (a?.channel)
|
|
548
|
-
params.set("channel", a.channel);
|
|
549
|
-
const qs = params.toString();
|
|
550
|
-
return `/api/rtm${qs ? "?" + qs : ""}`;
|
|
551
|
-
}
|
|
552
|
-
case "get_rtm_event_detail": {
|
|
553
|
-
const a = args;
|
|
554
|
-
return `/api/rtm/${a.id}`;
|
|
555
|
-
}
|
|
556
|
-
case "get_recent_clogs": {
|
|
557
|
-
const a = args;
|
|
558
|
-
const params = new URLSearchParams();
|
|
559
|
-
if (a?.limit)
|
|
560
|
-
params.set("limit", String(a.limit));
|
|
561
|
-
if (a?.name)
|
|
562
|
-
params.set("name", a.name);
|
|
563
|
-
const qs = params.toString();
|
|
564
|
-
return `/api/clogs${qs ? "?" + qs : ""}`;
|
|
565
|
-
}
|
|
566
|
-
case "get_clog_detail": {
|
|
567
|
-
const a = args;
|
|
568
|
-
return `/api/clogs/${a.id}`;
|
|
569
|
-
}
|
|
570
|
-
case "search": {
|
|
571
|
-
const a = args;
|
|
572
|
-
const params = new URLSearchParams();
|
|
573
|
-
params.set("q", a.query);
|
|
574
|
-
if (a?.limit)
|
|
575
|
-
params.set("limit", String(a.limit));
|
|
576
|
-
return `/api/search?${params.toString()}`;
|
|
577
|
-
}
|
|
578
|
-
default:
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
551
|
// --- MCP Server ---
|
|
583
|
-
const server = new Server({ name: "slack-radar", version: "1.
|
|
552
|
+
const server = new Server({ name: "slack-radar", version: "1.8.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
584
553
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
585
554
|
tools: TOOL_DEFINITIONS,
|
|
586
555
|
}));
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface RadarResponse {
|
|
2
|
+
error?: string;
|
|
3
|
+
status?: number;
|
|
4
|
+
stream_connected?: boolean;
|
|
5
|
+
local_network_buffer?: number;
|
|
6
|
+
local_rtm_buffer?: number;
|
|
7
|
+
timeout_minutes?: number;
|
|
8
|
+
raw?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
|
|
13
|
+
* when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
|
|
14
|
+
* {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
|
|
15
|
+
* whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
|
|
16
|
+
* an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
|
|
17
|
+
* In that case synthesize a clear error naming the status so the model reports something useful
|
|
18
|
+
* (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
|
|
19
|
+
* body keeps the existing {raw} behavior, a real if unusual success payload.
|
|
20
|
+
*/
|
|
21
|
+
export declare function interpretRadarResponse(status: number, body: string): RadarResponse;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The shape a proxied device call resolves to, and how a raw (status, body) becomes one. Split
|
|
2
|
+
// out of index.ts so it is unit-testable: index.ts connects stdio at module load, so importing it
|
|
3
|
+
// from a test would hang.
|
|
4
|
+
/**
|
|
5
|
+
* Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
|
|
6
|
+
* when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
|
|
7
|
+
* {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
|
|
8
|
+
* whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
|
|
9
|
+
* an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
|
|
10
|
+
* In that case synthesize a clear error naming the status so the model reports something useful
|
|
11
|
+
* (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
|
|
12
|
+
* body keeps the existing {raw} behavior, a real if unusual success payload.
|
|
13
|
+
*/
|
|
14
|
+
export function interpretRadarResponse(status, body) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(body);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
if (status >= 400) {
|
|
21
|
+
const bodyPrefix = body.substring(0, 200).trim();
|
|
22
|
+
const bodyDetail = bodyPrefix ? ` Body: ${bodyPrefix}` : "";
|
|
23
|
+
return {
|
|
24
|
+
error: `Device returned HTTP ${status} with a non-JSON body. The route may not exist on this build (an older debug build can predate a newer tool), or an upstream error occurred.${bodyDetail}`,
|
|
25
|
+
status,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { raw: body };
|
|
29
|
+
}
|
|
30
|
+
// Device routes return JSON OBJECTS. A bare null/number/array/string is valid JSON but not a
|
|
31
|
+
// RadarResponse, and a downstream `result.error` read on a non-object (esp. null) would throw.
|
|
32
|
+
// Keep it as a raw payload instead of casting a lie. (The old code cast straight to
|
|
33
|
+
// RadarResponse and carried this hole; guarding here closes it.)
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
35
|
+
return { raw: body };
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
package/dist/mcp/snapshot.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface SnapshotResult {
|
|
|
13
13
|
network: number;
|
|
14
14
|
rtm: number;
|
|
15
15
|
clogs: number;
|
|
16
|
+
traces: number;
|
|
16
17
|
log_lines: number;
|
|
17
18
|
log_lines_truncated: boolean;
|
|
18
19
|
screenshot: boolean;
|
|
@@ -21,6 +22,7 @@ export interface SnapshotResult {
|
|
|
21
22
|
network: boolean;
|
|
22
23
|
rtm: boolean;
|
|
23
24
|
clogs: boolean;
|
|
25
|
+
traces: boolean;
|
|
24
26
|
};
|
|
25
27
|
errors: string[];
|
|
26
28
|
privacy_notice: string;
|
|
@@ -35,7 +37,7 @@ export declare function buildSnapshotDir(now?: number, root?: string): string;
|
|
|
35
37
|
* intentionally larger than point-query defaults — snapshots are bulk
|
|
36
38
|
* exports and the device-side ring buffers cap their own responses.
|
|
37
39
|
*/
|
|
38
|
-
export declare function buildBufferPath(kind: "network" | "rtm" | "clogs"): string;
|
|
40
|
+
export declare function buildBufferPath(kind: "network" | "rtm" | "clogs" | "traces"): string;
|
|
39
41
|
export interface LogTailResult {
|
|
40
42
|
text: string;
|
|
41
43
|
truncated: boolean;
|
|
@@ -60,4 +62,4 @@ export declare function readRecentLogTail(requestedLimit?: number): LogTailResul
|
|
|
60
62
|
* snapshot still lands on disk with whatever succeeded; failures are
|
|
61
63
|
* collected in `result.errors` so the model can tell the user.
|
|
62
64
|
*/
|
|
63
|
-
export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
|
|
65
|
+
export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs" | "traces") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
|
package/dist/mcp/snapshot.js
CHANGED
|
@@ -10,7 +10,7 @@ import { getCaptureFilePath, readTailBytes } from "../shared/logcat-capture.js";
|
|
|
10
10
|
* tool outputs.
|
|
11
11
|
*
|
|
12
12
|
* Snapshot contents:
|
|
13
|
-
* - `bundle.json` — device-side buffers (net + RTM + clogs) plus metadata
|
|
13
|
+
* - `bundle.json` — device-side buffers (net + RTM + clogs + traces) plus metadata
|
|
14
14
|
* - `screenshot.png` — current screen (optional)
|
|
15
15
|
* - `logcat.tail.log` — last N lines of the active capture file (optional)
|
|
16
16
|
*
|
|
@@ -33,19 +33,19 @@ const BUNDLE_README = `Slack Radar snapshot
|
|
|
33
33
|
This directory is a point-in-time bundle of Radar state.
|
|
34
34
|
|
|
35
35
|
Files:
|
|
36
|
-
- bundle.json: device-side network/RTM/clog buffers, counts, and metadata.
|
|
36
|
+
- bundle.json: device-side network/RTM/clog/trace buffers, counts, and metadata.
|
|
37
37
|
- logcat.tail.log (optional): last N lines of the on-device logcat capture.
|
|
38
38
|
- screenshot.png (optional): current screen.
|
|
39
39
|
|
|
40
40
|
Privacy: bundle.json contains raw API request/response bodies (auth headers,
|
|
41
|
-
tokens), RTM message content, user IDs, channel IDs,
|
|
42
|
-
Review before sharing externally. This bundle lives
|
|
43
|
-
nothing is uploaded anywhere by Radar.
|
|
41
|
+
tokens), RTM message content, user IDs, channel IDs, clog payloads, and
|
|
42
|
+
performance trace spans. Review before sharing externally. This bundle lives
|
|
43
|
+
on your local disk only; nothing is uploaded anywhere by Radar.
|
|
44
44
|
|
|
45
45
|
To inspect: cat bundle.json | jq .
|
|
46
46
|
To share: zip -r snapshot.zip . then attach the zip.
|
|
47
47
|
`;
|
|
48
|
-
const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and
|
|
48
|
+
const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally.";
|
|
49
49
|
/**
|
|
50
50
|
* Resolve the output directory for a snapshot. Exported for tests.
|
|
51
51
|
*/
|
|
@@ -137,7 +137,8 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
137
137
|
let network = null;
|
|
138
138
|
let rtm = null;
|
|
139
139
|
let clogs = null;
|
|
140
|
-
|
|
140
|
+
let traces = null;
|
|
141
|
+
const fetched = { network: false, rtm: false, clogs: false, traces: false };
|
|
141
142
|
// Device-side buffers. Failures are individually caught so one failing
|
|
142
143
|
// buffer does not nuke the whole snapshot. `fetched` distinguishes
|
|
143
144
|
// "empty because the buffer was empty" from "null because the fetch
|
|
@@ -163,6 +164,17 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
163
164
|
catch (e) {
|
|
164
165
|
errors.push(`clog buffer: ${e.message}`);
|
|
165
166
|
}
|
|
167
|
+
// Traces capture from app start (like clogs), so they are worth
|
|
168
|
+
// bundling. An older debug build without the /api/traces route fails
|
|
169
|
+
// this one fetch and it is recorded in errors[] like any other buffer;
|
|
170
|
+
// the rest of the snapshot still lands.
|
|
171
|
+
try {
|
|
172
|
+
traces = await fetchBuffer("traces");
|
|
173
|
+
fetched.traces = true;
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
errors.push(`trace buffer: ${e.message}`);
|
|
177
|
+
}
|
|
166
178
|
// Logcat tail.
|
|
167
179
|
let logTail = { text: "", truncated: false };
|
|
168
180
|
if (includeLogs) {
|
|
@@ -221,6 +233,7 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
221
233
|
network: countItems(network),
|
|
222
234
|
rtm: countItems(rtm),
|
|
223
235
|
clogs: countItems(clogs),
|
|
236
|
+
traces: countItems(traces),
|
|
224
237
|
log_lines: logTail.text
|
|
225
238
|
? logTail.text.split("\n").filter(Boolean).length
|
|
226
239
|
: 0,
|
|
@@ -228,12 +241,12 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
228
241
|
screenshot: screenshotCaptured,
|
|
229
242
|
};
|
|
230
243
|
const bundle = {
|
|
231
|
-
version:
|
|
244
|
+
version: 2,
|
|
232
245
|
timestamp: now,
|
|
233
246
|
platform: device.platform,
|
|
234
247
|
active_user_id: device.getActiveUserId(),
|
|
235
248
|
capture_file: getCaptureFilePath(),
|
|
236
|
-
buffers: { network, rtm, clogs },
|
|
249
|
+
buffers: { network, rtm, clogs, traces },
|
|
237
250
|
counts,
|
|
238
251
|
fetched,
|
|
239
252
|
errors,
|
|
@@ -285,14 +298,14 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
285
298
|
}
|
|
286
299
|
/**
|
|
287
300
|
* Count items inside a buffer response. Handles the `{ calls: [...] }`,
|
|
288
|
-
* `{ events: [...] }`, `{ clogs: [...] }`
|
|
289
|
-
* uses. Falls back to 0 on malformed responses.
|
|
301
|
+
* `{ events: [...] }`, `{ clogs: [...] }`, `{ traces: [...] }` shapes the
|
|
302
|
+
* radar HTTP server uses. Falls back to 0 on malformed responses.
|
|
290
303
|
*/
|
|
291
304
|
function countItems(body) {
|
|
292
305
|
if (!body || typeof body !== "object")
|
|
293
306
|
return 0;
|
|
294
307
|
const b = body;
|
|
295
|
-
for (const key of ["calls", "events", "clogs", "items"]) {
|
|
308
|
+
for (const key of ["calls", "events", "clogs", "traces", "items"]) {
|
|
296
309
|
const v = b[key];
|
|
297
310
|
if (Array.isArray(v))
|
|
298
311
|
return v.length;
|