@slack/radar-mcp 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/mcp/api-paths.d.ts +1 -0
- package/dist/mcp/api-paths.js +108 -0
- package/dist/mcp/index.js +51 -79
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/tools.js +31 -7
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +16 -11
- 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 +714 -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,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";
|
|
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";
|
|
18
20
|
// --- Subcommand + bin-name dispatch ---
|
|
19
21
|
//
|
|
20
22
|
// One entrypoint, two front doors, chosen so each invocation does the obvious thing:
|
|
@@ -198,8 +200,30 @@ async function runMcpServer() {
|
|
|
198
200
|
process.once(sig, cleanupPulledDatabases);
|
|
199
201
|
}
|
|
200
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;
|
|
201
209
|
function radarRequest(requestPath) {
|
|
202
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
|
+
};
|
|
203
227
|
const req = http.get({
|
|
204
228
|
host: RADAR_HOST,
|
|
205
229
|
port: RADAR_PORT,
|
|
@@ -207,22 +231,36 @@ async function runMcpServer() {
|
|
|
207
231
|
timeout: 10000,
|
|
208
232
|
}, (res) => {
|
|
209
233
|
let data = "";
|
|
234
|
+
let bytes = 0;
|
|
210
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
|
+
}
|
|
211
249
|
data += chunk;
|
|
212
250
|
});
|
|
213
251
|
res.on("end", () => {
|
|
214
252
|
try {
|
|
215
|
-
|
|
253
|
+
done(JSON.parse(data));
|
|
216
254
|
}
|
|
217
255
|
catch {
|
|
218
|
-
|
|
256
|
+
done({ raw: data });
|
|
219
257
|
}
|
|
220
258
|
});
|
|
221
259
|
});
|
|
222
|
-
req.on("error",
|
|
260
|
+
req.on("error", fail);
|
|
223
261
|
req.on("timeout", () => {
|
|
224
262
|
req.destroy();
|
|
225
|
-
|
|
263
|
+
fail(new Error("Request timed out"));
|
|
226
264
|
});
|
|
227
265
|
});
|
|
228
266
|
}
|
|
@@ -427,18 +465,14 @@ async function runMcpServer() {
|
|
|
427
465
|
}
|
|
428
466
|
function handleGetRecentLogs(args) {
|
|
429
467
|
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
|
-
});
|
|
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));
|
|
442
476
|
}
|
|
443
477
|
catch (e) {
|
|
444
478
|
return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
|
|
@@ -517,70 +551,8 @@ async function runMcpServer() {
|
|
|
517
551
|
}, true);
|
|
518
552
|
}
|
|
519
553
|
}
|
|
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
554
|
// --- MCP Server ---
|
|
583
|
-
const server = new Server({ name: "slack-radar", version: "1.
|
|
555
|
+
const server = new Server({ name: "slack-radar", version: "1.7.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
584
556
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
585
557
|
tools: TOOL_DEFINITIONS,
|
|
586
558
|
}));
|
|
@@ -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
|
+
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -20,9 +20,10 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
20
20
|
'- "why isn\'t this loading" / "this is broken" -> screenshot + get_recent_network_calls(status_code=400 or 500)',
|
|
21
21
|
'- "watch what happens when I..." -> clear_buffers then wait_for_events (DO NOT filter unless the user specifies a type)',
|
|
22
22
|
'- "did that clog fire" / "check analytics" -> get_recent_clogs(name="...")',
|
|
23
|
+
'- "why is this slow" / "how long did X take" / "show me the traces" / "check performance" -> get_recent_traces(name="...") then get_trace_detail',
|
|
23
24
|
'- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
|
|
24
25
|
'- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
|
|
25
|
-
'- "check the debug logs" -> get_recent_logs(tag="
|
|
26
|
+
'- "check the debug logs" -> get_recent_logs (tag is OPTIONAL: pass tag="FKDebug" to filter, or omit it for all recent lines)',
|
|
26
27
|
'- "save this" / "file this as a bug" / "share this" / "bundle this" / "attach to ticket" / "give me a repro" / "snapshot this" / "preserve this" / "hold this state" / "save what you just saw" -> capture_snapshot',
|
|
27
28
|
"",
|
|
28
29
|
"Key behaviors:",
|
|
@@ -203,6 +204,30 @@ export const TOOL_DEFINITIONS = [
|
|
|
203
204
|
},
|
|
204
205
|
annotations: { readOnlyHint: true },
|
|
205
206
|
},
|
|
207
|
+
{
|
|
208
|
+
name: "get_recent_traces",
|
|
209
|
+
description: "Get recent performance trace summaries (one row per completed span from the app's telemetry pipeline). Returns trace name, span name, and duration. Use get_trace_detail for the full span, including its tags and the parent/trace ids.",
|
|
210
|
+
inputSchema: {
|
|
211
|
+
type: "object",
|
|
212
|
+
properties: {
|
|
213
|
+
limit: { type: "number", description: "Max number of traces to return (default: 50)" },
|
|
214
|
+
name: { type: "string", description: "Filter by trace name or span name substring" },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
annotations: { readOnlyHint: true },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "get_trace_detail",
|
|
221
|
+
description: "Get full details of a specific performance trace by ID: trace and span names, the trace and parent ids, start/end/duration, and the span tags.",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
id: { type: "number", description: "The trace ID from get_recent_traces" },
|
|
226
|
+
},
|
|
227
|
+
required: ["id"],
|
|
228
|
+
},
|
|
229
|
+
annotations: { readOnlyHint: true },
|
|
230
|
+
},
|
|
206
231
|
{
|
|
207
232
|
name: "wait_for_events",
|
|
208
233
|
description: "Wait for matching events to arrive in real-time via the SSE stream.",
|
|
@@ -211,7 +236,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
211
236
|
properties: {
|
|
212
237
|
event_type: { type: "string", description: "RTM event type to wait for" },
|
|
213
238
|
channel: { type: "string", description: "Filter by channel ID" },
|
|
214
|
-
source: { type: "string", description: "
|
|
239
|
+
source: { type: "string", description: "Limit to one stream: network, rtm, clog, log, or trace (default: all)" },
|
|
215
240
|
timeout_ms: { type: "number", description: "Max time to wait in ms (default: 30000)" },
|
|
216
241
|
idle_timeout_ms: { type: "number", description: "Resolve after this many ms of silence (default: 3000)" },
|
|
217
242
|
},
|
|
@@ -220,7 +245,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
220
245
|
},
|
|
221
246
|
{
|
|
222
247
|
name: "search",
|
|
223
|
-
description: "Search across all captured network calls and
|
|
248
|
+
description: "Search across all captured network calls, RTM events, clogs, and performance traces.",
|
|
224
249
|
inputSchema: {
|
|
225
250
|
type: "object",
|
|
226
251
|
properties: {
|
|
@@ -239,15 +264,14 @@ export const TOOL_DEFINITIONS = [
|
|
|
239
264
|
},
|
|
240
265
|
{
|
|
241
266
|
name: "get_recent_logs",
|
|
242
|
-
description: "Get recent device logs for the Slack debug build
|
|
267
|
+
description: "Get recent device logs for the Slack debug build. 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. The result includes a `source` field (app_buffer / logcat_capture / logcat_ring) so you can tell a real empty result from a fallback. Leave `tag` out to get all recent lines. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
|
|
243
268
|
inputSchema: {
|
|
244
269
|
type: "object",
|
|
245
270
|
properties: {
|
|
246
|
-
tag: { type: "string", description: "
|
|
247
|
-
limit: { type: "number", description: "Max number of lines to return (default: 50)" },
|
|
271
|
+
tag: { type: "string", description: "Optional substring to filter log lines by (matched anywhere in the line, not an exact tag-column match). Omit to return all recent lines." },
|
|
272
|
+
limit: { type: "number", description: "Max number of lines to return (default: 50, capped at 1000)" },
|
|
248
273
|
grep: { type: "string", description: "Optional text to grep within the filtered output" },
|
|
249
274
|
},
|
|
250
|
-
required: ["tag"],
|
|
251
275
|
},
|
|
252
276
|
annotations: { readOnlyHint: true },
|
|
253
277
|
},
|
package/dist/shared/android.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeviceTransport } from "./transport.js";
|
|
1
|
+
import type { DeviceTransport, LogResult } from "./transport.js";
|
|
2
2
|
/**
|
|
3
3
|
* Resolve the adb binary. MCP servers spawned from GUI launchers (Spotlight,
|
|
4
4
|
* Raycast, Dock) do not inherit the user's shell PATH, so a bare `adb` call
|
|
@@ -44,7 +44,7 @@ export declare class AndroidTransport implements DeviceTransport {
|
|
|
44
44
|
screenshot(fullRes: boolean): string;
|
|
45
45
|
recordScreen(durationS: number): string;
|
|
46
46
|
getAppState(): string;
|
|
47
|
-
getRecentLogs(tag: string, limit: number, grep?: string):
|
|
47
|
+
getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
|
|
48
48
|
listProfiles(): {
|
|
49
49
|
userId: string;
|
|
50
50
|
label: string;
|
package/dist/shared/android.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSync } from "child_process";
|
|
1
|
+
import { execSync, execFileSync } from "child_process";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import os from "os";
|
|
4
4
|
import path from "path";
|
|
@@ -209,7 +209,7 @@ export class AndroidTransport {
|
|
|
209
209
|
// sandbox -- matching how the DB browser threads its user.
|
|
210
210
|
const appLog = readDebugLogBuffer(ADB, this.resolveActiveUser());
|
|
211
211
|
if (appLog !== null) {
|
|
212
|
-
return filterLogLines(appLog, tag, limit, grep);
|
|
212
|
+
return { lines: filterLogLines(appLog, tag, limit, grep), source: "app_buffer" };
|
|
213
213
|
}
|
|
214
214
|
// Fallback: read from the session capture file so lines cannot have been
|
|
215
215
|
// evicted from the device ring buffer between enable and now.
|
|
@@ -220,20 +220,25 @@ export class AndroidTransport {
|
|
|
220
220
|
rotateCaptureIfNeeded(ADB);
|
|
221
221
|
const lines = readRecentLogs(tag, limit, grep);
|
|
222
222
|
if (lines !== null)
|
|
223
|
-
return lines;
|
|
223
|
+
return { lines, source: "logcat_capture" };
|
|
224
224
|
}
|
|
225
225
|
// Fallback: device ring buffer snapshot. Used when capture is not running
|
|
226
|
-
// (activate() was skipped, child died, or the file was deleted).
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
// (activate() was skipped, child died, or the file was deleted). No shell:
|
|
227
|
+
// dump the ring with execFileSync (argv, so a tag/grep with a backtick,
|
|
228
|
+
// $(...), or trailing backslash cannot break out of a command string) and do
|
|
229
|
+
// the tag/grep/limit filtering in pure JS via the same filterLogLines the
|
|
230
|
+
// other two sources use. An empty tag means "no filter" (includes("") is
|
|
231
|
+
// true), so the whole tail is returned.
|
|
232
|
+
const output = execFileSync(ADB, ["logcat", "-d"], {
|
|
232
233
|
timeout: 10000,
|
|
233
|
-
shell: "/bin/zsh",
|
|
234
234
|
encoding: "utf-8",
|
|
235
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
235
236
|
});
|
|
236
|
-
|
|
237
|
+
const lines = output.split("\n").filter(Boolean);
|
|
238
|
+
return {
|
|
239
|
+
lines: filterLogLines(lines, tag, limit, grep),
|
|
240
|
+
source: "logcat_ring",
|
|
241
|
+
};
|
|
237
242
|
}
|
|
238
243
|
listProfiles() {
|
|
239
244
|
if (!ADB)
|
package/dist/shared/screen.d.ts
CHANGED
|
@@ -130,6 +130,16 @@ export declare function startLive(fps: number): LiveCast | null;
|
|
|
130
130
|
/** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
|
|
131
131
|
export declare function addViewer(L: LiveCast, res: NodeJS.WritableStream): void;
|
|
132
132
|
export declare function removeViewer(res: NodeJS.WritableStream): void;
|
|
133
|
+
/**
|
|
134
|
+
* Tear down the live cast when nothing is keeping it alive: no viewers AND no active
|
|
135
|
+
* recording. Either a viewer leaving or a recording stopping can be the last thing
|
|
136
|
+
* holding the cast, so both paths call this. The close-while-recording case needs it:
|
|
137
|
+
* the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
|
|
138
|
+
* resolves, so the viewer-leave finds recording still set and cannot reap; the cast
|
|
139
|
+
* would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
|
|
140
|
+
* after stopRecording closes that gap.
|
|
141
|
+
*/
|
|
142
|
+
export declare function reapLiveIfIdle(): void;
|
|
133
143
|
export declare function getLive(): LiveCast | null;
|
|
134
144
|
export declare function lastFrame(): Buffer | null;
|
|
135
145
|
export declare function primeIfNeeded(): Promise<void>;
|
package/dist/shared/screen.js
CHANGED
|
@@ -461,7 +461,19 @@ export function removeViewer(res) {
|
|
|
461
461
|
if (!scLive)
|
|
462
462
|
return;
|
|
463
463
|
scLive.viewers.delete(res);
|
|
464
|
-
|
|
464
|
+
reapLiveIfIdle();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Tear down the live cast when nothing is keeping it alive: no viewers AND no active
|
|
468
|
+
* recording. Either a viewer leaving or a recording stopping can be the last thing
|
|
469
|
+
* holding the cast, so both paths call this. The close-while-recording case needs it:
|
|
470
|
+
* the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
|
|
471
|
+
* resolves, so the viewer-leave finds recording still set and cannot reap; the cast
|
|
472
|
+
* would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
|
|
473
|
+
* after stopRecording closes that gap.
|
|
474
|
+
*/
|
|
475
|
+
export function reapLiveIfIdle() {
|
|
476
|
+
if (scLive && !scLive.viewers.size && !scLive.recording) {
|
|
465
477
|
cleanupLive(scLive);
|
|
466
478
|
scLive = null;
|
|
467
479
|
}
|
package/dist/shared/stream.d.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* device-specific operations. The MCP server and tool handlers
|
|
7
7
|
* interact only through this interface.
|
|
8
8
|
*/
|
|
9
|
+
/** Which underlying source served a getRecentLogs result. */
|
|
10
|
+
export type LogSource = "app_buffer" | "logcat_capture" | "logcat_ring";
|
|
11
|
+
/** Lines plus the source that produced them. */
|
|
12
|
+
export interface LogResult {
|
|
13
|
+
lines: string[];
|
|
14
|
+
source: LogSource;
|
|
15
|
+
}
|
|
9
16
|
export interface DeviceTransport {
|
|
10
17
|
/** Human-readable platform name (e.g. "android", "ios"). */
|
|
11
18
|
readonly platform: string;
|
|
@@ -47,14 +54,15 @@ export interface DeviceTransport {
|
|
|
47
54
|
*/
|
|
48
55
|
getAppState(): string;
|
|
49
56
|
/**
|
|
50
|
-
* Get recent log entries filtered by tag.
|
|
57
|
+
* Get recent log entries, optionally filtered by tag.
|
|
51
58
|
*
|
|
52
|
-
* @param tag Log tag to filter by
|
|
59
|
+
* @param tag Log tag to filter by. Empty string returns every line (no tag filter).
|
|
53
60
|
* @param limit Max number of lines
|
|
54
61
|
* @param grep Optional text to further filter
|
|
55
|
-
* @returns
|
|
62
|
+
* @returns The matching lines plus which source served them, so the caller can tell a
|
|
63
|
+
* true-empty result from a silent fallback to an inferior source.
|
|
56
64
|
*/
|
|
57
|
-
getRecentLogs(tag: string, limit: number, grep?: string):
|
|
65
|
+
getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
|
|
58
66
|
/**
|
|
59
67
|
* Returns the currently active user ID, or null if none is set. On Android
|
|
60
68
|
* this is the profile (e.g. "0" for personal, "10" for work); on iOS it
|