@slack/radar-mcp 1.3.0 → 1.5.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/dist/mcp/index.js CHANGED
@@ -2,74 +2,220 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { spawn } from "child_process";
5
6
  import fs from "fs";
6
7
  import http from "http";
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
7
10
  import { AndroidTransport } from "../shared/android.js";
8
- import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
11
+ import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js";
9
12
  import { stopCapture } from "../shared/logcat-capture.js";
10
13
  import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
11
14
  import { buildBufferPath, createSnapshot } from "./snapshot.js";
12
- import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS } from "./tools.js";
13
- // --- Platform selection ---
14
- function createTransport() {
15
- const platformArg = process.argv.find((a) => a.startsWith("--platform="));
16
- const platform = platformArg?.split("=")[1] ?? "android";
17
- switch (platform) {
18
- case "android":
19
- return new AndroidTransport();
20
- default:
21
- throw new Error(`Unsupported platform: ${platform}. Supported: android`);
22
- }
15
+ import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
16
+ // --- Subcommand + bin-name dispatch ---
17
+ //
18
+ // One entrypoint, two front doors, chosen so each invocation does the obvious thing:
19
+ //
20
+ // slack-radar -> web dashboard (the human launcher: bare = open the UI)
21
+ // slack-radar web -> web dashboard
22
+ // slack-radar mcp -> stdio MCP server (explicit, if a human ever needs it)
23
+ // radar-mcp -> stdio MCP server (the name MCP hosts configure; no arg)
24
+ // slack-radar-mcp -> stdio MCP server
25
+ // npx @slack/radar-mcp web -> web dashboard (npx picks the radar-mcp bin; "web" arg)
26
+ // slack-radar-web -> web dashboard (dedicated bin, still works)
27
+ //
28
+ // Rationale: a person typing `slack-radar` wants the UI, not a process that blocks
29
+ // on stdin looking hung. But `radar-mcp`/`slack-radar-mcp` are what editor/agent MCP
30
+ // configs launch with no args, so those MUST stay stdio. We disambiguate on the
31
+ // invoked bin name (argv[1]) and allow an explicit subcommand to override either way.
32
+ const sub = process.argv[2];
33
+ const binName = (process.argv[1] ?? "").split("/").pop() ?? "";
34
+ const wantsWeb = sub === "web" || (binName === "slack-radar" && sub !== "mcp");
35
+ // --- Web dashboard bridge (open_radar_dashboard) ---
36
+ //
37
+ // The MCP server and the web dashboard are separate processes. open_radar_dashboard
38
+ // ensures the web server is up (spawning it detached if not) and POSTs the authored
39
+ // spec to its /setspec route — the same route the pre-built tabs use — so it appears
40
+ // as a "Custom" tab. Both sides resolve the port from SLACK_RADAR_WEB_PORT (default
41
+ // WEB_PORT_DEFAULT) so they never drift.
42
+ const WEB_BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "web", "bin.js");
43
+ const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? WEB_PORT_DEFAULT);
44
+ const WEB_BASE = `http://localhost:${WEB_PORT}`;
45
+ function webAlive() {
46
+ return new Promise((resolve) => {
47
+ const req = http.request({ host: "localhost", port: WEB_PORT, path: "/health", method: "GET", timeout: 1500 }, (res) => {
48
+ res.resume();
49
+ resolve(true);
50
+ });
51
+ req.on("error", () => resolve(false));
52
+ req.on("timeout", () => {
53
+ req.destroy();
54
+ resolve(false);
55
+ });
56
+ req.end();
57
+ });
23
58
  }
24
- const device = createTransport();
25
- setTransport(device);
26
- let sessionEnabled = false;
27
- function radarRequest(requestPath) {
59
+ function startWeb(initialSpec) {
60
+ // Detached + unref so the dashboard outlives this MCP process; inherit env so the
61
+ // child honors the same SLACK_RADAR_WEB_PORT. Reuse the current node binary rather
62
+ // than assuming `node` is on PATH (MCP hosts often launch with a bare PATH).
63
+ //
64
+ // Seed the spec via SLACK_RADAR_INITIAL_SPEC so the freshly-spawned server writes the
65
+ // custom spec at startup, BEFORE the browser opens and runs bootDefault(). Otherwise
66
+ // bootDefault (fires only on a blank spec) and our /setspec POST race, and a lost race
67
+ // leaves the Live preset showing after we already reported the Custom tab is live.
68
+ // SLACK_RADAR_NO_OPEN: this is a bridge-spawned dashboard, so the server must NOT
69
+ // auto-open a browser (the Claude Code session returns the URL; popping a window unbidden would
70
+ // violate the no-auto-open-browser rule the SERVER_INSTRUCTIONS state for /radar-ui).
71
+ const child = spawn(process.execPath, [WEB_BIN], {
72
+ detached: true,
73
+ stdio: "ignore",
74
+ env: {
75
+ ...process.env,
76
+ SLACK_RADAR_NO_OPEN: "1",
77
+ ...(initialSpec ? { SLACK_RADAR_INITIAL_SPEC: JSON.stringify(initialSpec) } : {}),
78
+ },
79
+ });
80
+ child.unref();
81
+ }
82
+ function postSpec(spec) {
28
83
  return new Promise((resolve, reject) => {
29
- const req = http.get({
30
- host: RADAR_HOST,
31
- port: RADAR_PORT,
32
- path: requestPath,
33
- timeout: 10000,
84
+ const body = JSON.stringify(spec);
85
+ const req = http.request({
86
+ host: "localhost",
87
+ port: WEB_PORT,
88
+ path: "/setspec",
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
91
+ timeout: 4000,
34
92
  }, (res) => {
35
- let data = "";
36
- res.on("data", (chunk) => {
37
- data += chunk;
38
- });
93
+ let b = "";
94
+ res.on("data", (c) => (b += c));
39
95
  res.on("end", () => {
40
96
  try {
41
- resolve(JSON.parse(data));
97
+ resolve({ status: res.statusCode ?? 0, json: JSON.parse(b) });
42
98
  }
43
99
  catch {
44
- resolve({ raw: data });
100
+ resolve({ status: res.statusCode ?? 0, json: {} });
45
101
  }
46
102
  });
47
103
  });
48
104
  req.on("error", reject);
49
105
  req.on("timeout", () => {
50
106
  req.destroy();
51
- reject(new Error("Request timed out"));
107
+ reject(new Error("timed out posting spec to the dashboard"));
52
108
  });
109
+ req.write(body);
110
+ req.end();
53
111
  });
54
112
  }
55
- async function callRadar(requestPath) {
56
- if (!device.ensureForward()) {
57
- return {
58
- error: "Port forwarding failed. Ensure a device is connected and the debug build is running.",
59
- };
113
+ function getSpecTitle() {
114
+ return new Promise((resolve) => {
115
+ const req = http.request({ host: "localhost", port: WEB_PORT, path: "/spec", method: "GET", timeout: 2000 }, (res) => {
116
+ let b = "";
117
+ res.on("data", (c) => (b += c));
118
+ res.on("end", () => {
119
+ try {
120
+ const s = JSON.parse(b);
121
+ resolve(s.blank ? null : (s.title ?? ""));
122
+ }
123
+ catch {
124
+ resolve(null);
125
+ }
126
+ });
127
+ });
128
+ req.on("error", () => resolve(null));
129
+ req.on("timeout", () => {
130
+ req.destroy();
131
+ resolve(null);
132
+ });
133
+ req.end();
134
+ });
135
+ }
136
+ async function openDashboardSpec(spec) {
137
+ const cold = !(await webAlive());
138
+ if (cold) {
139
+ // Seed the spec into the spawned server so it is the startup writer and the browser's
140
+ // bootDefault() never overwrites it with the Live preset (see startWeb / server.ts).
141
+ startWeb(spec);
142
+ // Poll for the freshly-spawned server to start listening (~5s budget).
143
+ for (let i = 0; i < 20; i++) {
144
+ await new Promise((r) => setTimeout(r, 250));
145
+ if (await webAlive())
146
+ break;
147
+ }
60
148
  }
61
- device.activate(device.getTimeoutMinutes());
62
- connectStream();
63
- try {
64
- return await radarRequest(requestPath);
149
+ const r = await postSpec(spec);
150
+ if (r.status !== 200) {
151
+ throw new Error(r.json.error ?? `dashboard rejected the spec (HTTP ${r.status})`);
152
+ }
153
+ // Confirm OUR spec is the one stored, not a racing writer's (a browser bootDefault on a
154
+ // cold load, or another open_radar_dashboard call). The seed makes the cold path safe by
155
+ // construction; this verify is the backstop and also covers the warm path. Retry the POST
156
+ // once if a racer won, then trust the result rather than blocking forever.
157
+ const want = spec.title ?? "";
158
+ for (let i = 0; i < 2; i++) {
159
+ const stored = await getSpecTitle();
160
+ if (stored === want)
161
+ break;
162
+ await postSpec(spec);
163
+ }
164
+ return r.json;
165
+ }
166
+ if (wantsWeb) {
167
+ await import("../web/bin.js");
168
+ }
169
+ else {
170
+ await runMcpServer();
171
+ }
172
+ async function runMcpServer() {
173
+ // --- Platform selection ---
174
+ function createTransport() {
175
+ const platformArg = process.argv.find((a) => a.startsWith("--platform="));
176
+ const platform = platformArg?.split("=")[1] ?? "android";
177
+ switch (platform) {
178
+ case "android":
179
+ return new AndroidTransport();
180
+ default:
181
+ throw new Error(`Unsupported platform: ${platform}. Supported: android`);
182
+ }
65
183
  }
66
- catch {
67
- device.resetState();
68
- destroyStream();
184
+ const device = createTransport();
185
+ setTransport(device);
186
+ let sessionEnabled = false;
187
+ function radarRequest(requestPath) {
188
+ return new Promise((resolve, reject) => {
189
+ const req = http.get({
190
+ host: RADAR_HOST,
191
+ port: RADAR_PORT,
192
+ path: requestPath,
193
+ timeout: 10000,
194
+ }, (res) => {
195
+ let data = "";
196
+ res.on("data", (chunk) => {
197
+ data += chunk;
198
+ });
199
+ res.on("end", () => {
200
+ try {
201
+ resolve(JSON.parse(data));
202
+ }
203
+ catch {
204
+ resolve({ raw: data });
205
+ }
206
+ });
207
+ });
208
+ req.on("error", reject);
209
+ req.on("timeout", () => {
210
+ req.destroy();
211
+ reject(new Error("Request timed out"));
212
+ });
213
+ });
214
+ }
215
+ async function callRadar(requestPath) {
69
216
  if (!device.ensureForward()) {
70
- sessionEnabled = false;
71
217
  return {
72
- error: "Device not connected or radar not running. Session disabled. Use /radar-enable to reconnect.",
218
+ error: "Port forwarding failed. Ensure a device is connected and the debug build is running.",
73
219
  };
74
220
  }
75
221
  device.activate(device.getTimeoutMinutes());
@@ -77,362 +223,407 @@ async function callRadar(requestPath) {
77
223
  try {
78
224
  return await radarRequest(requestPath);
79
225
  }
80
- catch (e2) {
226
+ catch {
227
+ device.resetState();
228
+ destroyStream();
229
+ if (!device.ensureForward()) {
230
+ sessionEnabled = false;
231
+ return {
232
+ error: "Device not connected or radar not running. Session disabled. Use /radar-enable to reconnect.",
233
+ };
234
+ }
235
+ device.activate(device.getTimeoutMinutes());
236
+ connectStream();
237
+ try {
238
+ return await radarRequest(requestPath);
239
+ }
240
+ catch (e2) {
241
+ sessionEnabled = false;
242
+ return {
243
+ error: `Radar not responding: ${e2.message}. Session disabled. Use /radar-enable to reconnect.`,
244
+ };
245
+ }
246
+ }
247
+ }
248
+ function textResult(data, isError = false) {
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
254
+ },
255
+ ],
256
+ ...(isError ? { isError: true } : {}),
257
+ };
258
+ }
259
+ // --- Tool handlers ---
260
+ async function handleSessionEnable(args) {
261
+ const timeout = args?.timeout_minutes || 5;
262
+ sessionEnabled = true;
263
+ const forwarded = device.ensureForward();
264
+ if (!forwarded) {
81
265
  sessionEnabled = false;
82
- return {
83
- error: `Radar not responding: ${e2.message}. Session disabled. Use /radar-enable to reconnect.`,
84
- };
266
+ const reason = device.getLastForwardError?.() ?? null;
267
+ return textResult({
268
+ error: reason ??
269
+ "Port forwarding failed. Ensure a device is connected.",
270
+ enabled: false,
271
+ platform: device.platform,
272
+ }, true);
273
+ }
274
+ device.activate(timeout);
275
+ connectStream();
276
+ await new Promise((r) => setTimeout(r, 500));
277
+ try {
278
+ const status = await radarRequest("/api/ping");
279
+ return textResult({
280
+ enabled: true,
281
+ platform: device.platform,
282
+ stream_connected: isStreamConnected(),
283
+ ...status,
284
+ });
285
+ }
286
+ catch {
287
+ return textResult({
288
+ enabled: true,
289
+ platform: device.platform,
290
+ stream_connected: isStreamConnected(),
291
+ ping: "failed",
292
+ note: "Radar activated but ping failed. Device may need a moment.",
293
+ });
85
294
  }
86
295
  }
87
- }
88
- function textResult(data, isError = false) {
89
- return {
90
- content: [
91
- {
92
- type: "text",
93
- text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
94
- },
95
- ],
96
- ...(isError ? { isError: true } : {}),
97
- };
98
- }
99
- // --- Tool handlers ---
100
- async function handleSessionEnable(args) {
101
- const timeout = args?.timeout_minutes || 5;
102
- sessionEnabled = true;
103
- const forwarded = device.ensureForward();
104
- if (!forwarded) {
296
+ function handleSessionDisable() {
105
297
  sessionEnabled = false;
106
- const reason = device.getLastForwardError?.() ?? null;
298
+ destroyStream();
299
+ clearLocalBuffers();
300
+ // Explicit disable stops the logcat capture immediately. Without this, the
301
+ // capture would linger up to the capture-idle timeout (15 min) after a
302
+ // deliberate user action saying "I am done", which is surprising and
303
+ // wastes disk + an adb logcat child.
304
+ stopCapture();
107
305
  return textResult({
108
- error: reason ??
109
- "Port forwarding failed. Ensure a device is connected.",
110
306
  enabled: false,
111
- platform: device.platform,
112
- }, true);
113
- }
114
- device.activate(timeout);
115
- connectStream();
116
- await new Promise((r) => setTimeout(r, 500));
117
- try {
118
- const status = await radarRequest("/api/ping");
119
- return textResult({
120
- enabled: true,
121
- platform: device.platform,
122
- stream_connected: isStreamConnected(),
123
- ...status,
307
+ note: "Radar disabled. Use /radar-enable to reconnect.",
124
308
  });
125
309
  }
126
- catch {
310
+ async function handleWaitForEvents(args) {
311
+ if (!isStreamConnected()) {
312
+ if (!device.ensureForward()) {
313
+ return textResult({ error: "Not connected. Ensure device is connected and radar is enabled." }, true);
314
+ }
315
+ device.activate(device.getTimeoutMinutes());
316
+ connectStream();
317
+ await new Promise((r) => setTimeout(r, 1000));
318
+ }
319
+ const events = await waitForEvents({
320
+ event_type: args?.event_type,
321
+ channel: args?.channel,
322
+ source: args?.source,
323
+ timeout_ms: args?.timeout_ms,
324
+ idle_timeout_ms: args?.idle_timeout_ms,
325
+ });
127
326
  return textResult({
128
- enabled: true,
129
- platform: device.platform,
327
+ event_count: events.length,
328
+ events,
130
329
  stream_connected: isStreamConnected(),
131
- ping: "failed",
132
- note: "Radar activated but ping failed. Device may need a moment.",
133
330
  });
134
331
  }
135
- }
136
- function handleSessionDisable() {
137
- sessionEnabled = false;
138
- destroyStream();
139
- clearLocalBuffers();
140
- // Explicit disable stops the logcat capture immediately. Without this, the
141
- // capture would linger up to the capture-idle timeout (15 min) after a
142
- // deliberate user action saying "I am done", which is surprising and
143
- // wastes disk + an adb logcat child.
144
- stopCapture();
145
- return textResult({
146
- enabled: false,
147
- note: "Radar disabled. Use /radar-enable to reconnect.",
148
- });
149
- }
150
- async function handleWaitForEvents(args) {
151
- if (!isStreamConnected()) {
152
- if (!device.ensureForward()) {
153
- return textResult({ error: "Not connected. Ensure device is connected and radar is enabled." }, true);
332
+ function handleGetAppState() {
333
+ try {
334
+ const output = device.getAppState();
335
+ return textResult(output);
336
+ }
337
+ catch (e) {
338
+ return textResult({ error: `App state query failed: ${e.message}` }, true);
154
339
  }
155
- device.activate(device.getTimeoutMinutes());
156
- connectStream();
157
- await new Promise((r) => setTimeout(r, 1000));
158
- }
159
- const events = await waitForEvents({
160
- event_type: args?.event_type,
161
- channel: args?.channel,
162
- source: args?.source,
163
- timeout_ms: args?.timeout_ms,
164
- idle_timeout_ms: args?.idle_timeout_ms,
165
- });
166
- return textResult({
167
- event_count: events.length,
168
- events,
169
- stream_connected: isStreamConnected(),
170
- });
171
- }
172
- function handleGetAppState() {
173
- try {
174
- const output = device.getAppState();
175
- return textResult(output);
176
340
  }
177
- catch (e) {
178
- return textResult({ error: `App state query failed: ${e.message}` }, true);
341
+ function handleListProfiles() {
342
+ try {
343
+ // Narrow to implementations that expose `listProfiles`; it is optional on `DeviceTransport`.
344
+ const maybe = device;
345
+ if (typeof maybe.listProfiles !== "function") {
346
+ return textResult({ error: `Profile listing not supported on platform ${device.platform}` }, true);
347
+ }
348
+ const profiles = maybe.listProfiles();
349
+ return textResult({
350
+ profiles,
351
+ active_user_id: device.getActiveUserId(),
352
+ });
353
+ }
354
+ catch (e) {
355
+ return textResult({ error: `Profile list failed: ${e.message}` }, true);
356
+ }
179
357
  }
180
- }
181
- function handleListProfiles() {
182
- try {
183
- // Narrow to implementations that expose `listProfiles`; it is optional on `DeviceTransport`.
358
+ async function handleActivateForUser(args) {
184
359
  const maybe = device;
185
- if (typeof maybe.listProfiles !== "function") {
186
- return textResult({ error: `Profile listing not supported on platform ${device.platform}` }, true);
360
+ if (typeof maybe.activateForUser !== "function") {
361
+ return textResult({ error: `Profile switching not supported on platform ${device.platform}` }, true);
362
+ }
363
+ try {
364
+ const timeout = args?.timeout_minutes || device.getTimeoutMinutes() || 5;
365
+ maybe.activateForUser(args.user_id, timeout);
366
+ // Restart the SSE stream so network/RTM/clog events from the newly
367
+ // activated profile start flowing again.
368
+ destroyStream();
369
+ connectStream();
370
+ // Give the newly spawned adb logcat child a moment to emit its first
371
+ // lines to the capture file. Matches the warmup delay used by
372
+ // handleSessionEnable so an immediate get_recent_logs call does not
373
+ // read an empty file.
374
+ await new Promise((r) => setTimeout(r, 500));
375
+ return textResult({
376
+ user_id: args.user_id,
377
+ timeout_minutes: timeout,
378
+ capture_file: device.getCaptureFilePath?.() ?? null,
379
+ note: "Profile activated. Log capture restarted on a new file; old capture preserved for 7 days.",
380
+ });
381
+ }
382
+ catch (e) {
383
+ return textResult({ error: `Profile activation failed: ${e.message}` }, true);
187
384
  }
188
- const profiles = maybe.listProfiles();
189
- return textResult({
190
- profiles,
191
- active_user_id: device.getActiveUserId(),
192
- });
193
- }
194
- catch (e) {
195
- return textResult({ error: `Profile list failed: ${e.message}` }, true);
196
- }
197
- }
198
- async function handleActivateForUser(args) {
199
- const maybe = device;
200
- if (typeof maybe.activateForUser !== "function") {
201
- return textResult({ error: `Profile switching not supported on platform ${device.platform}` }, true);
202
- }
203
- try {
204
- const timeout = args?.timeout_minutes || device.getTimeoutMinutes() || 5;
205
- maybe.activateForUser(args.user_id, timeout);
206
- // Restart the SSE stream so network/RTM/clog events from the newly
207
- // activated profile start flowing again.
208
- destroyStream();
209
- connectStream();
210
- // Give the newly spawned adb logcat child a moment to emit its first
211
- // lines to the capture file. Matches the warmup delay used by
212
- // handleSessionEnable so an immediate get_recent_logs call does not
213
- // read an empty file.
214
- await new Promise((r) => setTimeout(r, 500));
215
- return textResult({
216
- user_id: args.user_id,
217
- timeout_minutes: timeout,
218
- capture_file: device.getCaptureFilePath?.() ?? null,
219
- note: "Profile activated. Log capture restarted on a new file; old capture preserved for 7 days.",
220
- });
221
- }
222
- catch (e) {
223
- return textResult({ error: `Profile activation failed: ${e.message}` }, true);
224
- }
225
- }
226
- async function handleCaptureSnapshot(args) {
227
- try {
228
- // Adapt `callRadar` (device HTTP + retry + session gate) into the
229
- // simple `fetchBuffer(kind)` contract `createSnapshot` expects. Any
230
- // `res.error` from callRadar is rethrown so the composer's per-buffer
231
- // try/catch can record it in `errors[]` without bringing down the
232
- // whole snapshot.
233
- const fetchBuffer = async (kind) => {
234
- const res = await callRadar(buildBufferPath(kind));
235
- if (res.error)
236
- throw new Error(res.error);
237
- return res;
238
- };
239
- const result = await createSnapshot(device, fetchBuffer, {
240
- includeLogs: args?.include_logs !== false,
241
- includeScreenshot: args?.include_screenshot !== false,
242
- logTailLines: args?.log_tail_lines,
243
- });
244
- return textResult(result, result.errors.length > 0 && result.files.length === 0);
245
- }
246
- catch (e) {
247
- return textResult({ error: `Snapshot failed: ${e.message}` }, true);
248
- }
249
- }
250
- function handleGetRecentLogs(args) {
251
- try {
252
- const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
253
- // Surface the capture file path so the model knows which on-disk file
254
- // produced these lines. Helps avoid cross-session staleness (e.g. the
255
- // model remembering an earlier profile's logs after a profile switch).
256
- const sourceFile = device.getCaptureFilePath?.() ?? null;
257
- return textResult({
258
- tag: args.tag,
259
- lines,
260
- count: lines.length,
261
- source_file: sourceFile,
262
- source: sourceFile ? "capture" : "adb_logcat_d",
263
- });
264
385
  }
265
- catch (e) {
266
- return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
386
+ async function handleCaptureSnapshot(args) {
387
+ try {
388
+ // Adapt `callRadar` (device HTTP + retry + session gate) into the
389
+ // simple `fetchBuffer(kind)` contract `createSnapshot` expects. Any
390
+ // `res.error` from callRadar is rethrown so the composer's per-buffer
391
+ // try/catch can record it in `errors[]` without bringing down the
392
+ // whole snapshot.
393
+ const fetchBuffer = async (kind) => {
394
+ const res = await callRadar(buildBufferPath(kind));
395
+ if (res.error)
396
+ throw new Error(res.error);
397
+ return res;
398
+ };
399
+ const result = await createSnapshot(device, fetchBuffer, {
400
+ includeLogs: args?.include_logs !== false,
401
+ includeScreenshot: args?.include_screenshot !== false,
402
+ logTailLines: args?.log_tail_lines,
403
+ });
404
+ return textResult(result, result.errors.length > 0 && result.files.length === 0);
405
+ }
406
+ catch (e) {
407
+ return textResult({ error: `Snapshot failed: ${e.message}` }, true);
408
+ }
267
409
  }
268
- }
269
- function handleScreenshot(args) {
270
- try {
271
- const fullRes = args?.full_res === true;
272
- const tmpFile = device.screenshot(fullRes);
273
- const stats = fs.statSync(tmpFile);
274
- return textResult({
275
- file: tmpFile,
276
- size_bytes: stats.size,
277
- full_res: fullRes,
278
- hint: "Use the Read tool to view this image.",
279
- });
410
+ function handleGetRecentLogs(args) {
411
+ 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
+ });
424
+ }
425
+ catch (e) {
426
+ return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
427
+ }
280
428
  }
281
- catch (e) {
282
- return textResult({ error: `Screenshot failed: ${e.message}` }, true);
429
+ function handleScreenshot(args) {
430
+ try {
431
+ const fullRes = args?.full_res === true;
432
+ const tmpFile = device.screenshot(fullRes);
433
+ const stats = fs.statSync(tmpFile);
434
+ return textResult({
435
+ file: tmpFile,
436
+ size_bytes: stats.size,
437
+ full_res: fullRes,
438
+ hint: "Use the Read tool to view this image.",
439
+ });
440
+ }
441
+ catch (e) {
442
+ return textResult({ error: `Screenshot failed: ${e.message}` }, true);
443
+ }
283
444
  }
284
- }
285
- function handleRecordScreen(args) {
286
- try {
287
- const duration = Math.min(Math.max(args?.duration_s || 5, 1), 30);
288
- const tmpFile = device.recordScreen(duration);
289
- const stats = fs.statSync(tmpFile);
290
- return textResult({
291
- file: tmpFile,
292
- size_bytes: stats.size,
293
- duration_s: duration,
294
- });
445
+ function handleRecordScreen(args) {
446
+ try {
447
+ const duration = Math.min(Math.max(args?.duration_s || 5, 1), 30);
448
+ const tmpFile = device.recordScreen(duration);
449
+ const stats = fs.statSync(tmpFile);
450
+ return textResult({
451
+ file: tmpFile,
452
+ size_bytes: stats.size,
453
+ duration_s: duration,
454
+ });
455
+ }
456
+ catch (e) {
457
+ return textResult({ error: `Recording failed: ${e.message}` }, true);
458
+ }
295
459
  }
296
- catch (e) {
297
- return textResult({ error: `Recording failed: ${e.message}` }, true);
460
+ async function handleClearBuffers() {
461
+ clearLocalBuffers();
462
+ const result = await callRadar("/api/clear");
463
+ return textResult(result, !!result.error);
298
464
  }
299
- }
300
- async function handleClearBuffers() {
301
- clearLocalBuffers();
302
- const result = await callRadar("/api/clear");
303
- return textResult(result, !!result.error);
304
- }
305
- async function handlePing(args) {
306
- if (args?.timeout_minutes) {
307
- device.activate(args.timeout_minutes);
465
+ async function handlePing(args) {
466
+ if (args?.timeout_minutes) {
467
+ device.activate(args.timeout_minutes);
468
+ }
469
+ const result = await callRadar("/api/ping");
470
+ if (!result.error) {
471
+ result.stream_connected = isStreamConnected();
472
+ result.local_network_buffer = localNetworkBuffer.length;
473
+ result.local_rtm_buffer = localRtmBuffer.length;
474
+ result.timeout_minutes = device.getTimeoutMinutes();
475
+ result.platform = device.platform;
476
+ }
477
+ return textResult(result, !!result.error);
308
478
  }
309
- const result = await callRadar("/api/ping");
310
- if (!result.error) {
311
- result.stream_connected = isStreamConnected();
312
- result.local_network_buffer = localNetworkBuffer.length;
313
- result.local_rtm_buffer = localRtmBuffer.length;
314
- result.timeout_minutes = device.getTimeoutMinutes();
315
- result.platform = device.platform;
479
+ async function handleOpenDashboard(args) {
480
+ try {
481
+ // The web server runs its own coerce+validate on /setspec, so a thin pass of
482
+ // the authored args is enough; a bad spec comes back as an HTTP 400 with its
483
+ // reason, which we surface verbatim rather than rendering silent zeros.
484
+ const out = await openDashboardSpec(args);
485
+ const url = out.url ?? WEB_BASE;
486
+ return textResult({
487
+ ok: true,
488
+ url,
489
+ title: out.title ?? args.title ?? "",
490
+ note: `Custom dashboard "${out.title ?? args.title ?? ""}" is live at ${url} (the 'Custom' tab). ` +
491
+ "It reads the connected device, so Slack Radar must be enabled on it — the dashboard has " +
492
+ "its own Reconnect button if the device is not yet armed. Call open_radar_dashboard again to refine it.",
493
+ });
494
+ }
495
+ catch (e) {
496
+ return textResult({
497
+ error: `Could not open the dashboard: ${e.message}`,
498
+ hint: "The web dashboard runs locally on this machine. Check that the spec is valid and try again.",
499
+ }, true);
500
+ }
316
501
  }
317
- return textResult(result, !!result.error);
318
- }
319
- function buildApiPath(name, args) {
320
- switch (name) {
321
- case "get_recent_network_calls": {
322
- const a = args;
323
- const params = new URLSearchParams();
324
- if (a?.limit)
325
- params.set("limit", String(a.limit));
326
- if (a?.url_filter)
327
- params.set("url_filter", a.url_filter);
328
- if (a?.status_code)
329
- params.set("status_code", String(a.status_code));
330
- const qs = params.toString();
331
- return `/api/network${qs ? "?" + qs : ""}`;
332
- }
333
- case "get_network_call_detail": {
334
- const a = args;
335
- return `/api/network/${a.id}`;
336
- }
337
- case "get_recent_rtm_events": {
338
- const a = args;
339
- const params = new URLSearchParams();
340
- if (a?.limit)
341
- params.set("limit", String(a.limit));
342
- if (a?.event_type)
343
- params.set("event_type", a.event_type);
344
- if (a?.direction)
345
- params.set("direction", a.direction);
346
- if (a?.channel)
347
- params.set("channel", a.channel);
348
- const qs = params.toString();
349
- return `/api/rtm${qs ? "?" + qs : ""}`;
350
- }
351
- case "get_rtm_event_detail": {
352
- const a = args;
353
- return `/api/rtm/${a.id}`;
354
- }
355
- case "get_recent_clogs": {
356
- const a = args;
357
- const params = new URLSearchParams();
358
- if (a?.limit)
359
- params.set("limit", String(a.limit));
360
- if (a?.name)
361
- params.set("name", a.name);
362
- const qs = params.toString();
363
- return `/api/clogs${qs ? "?" + qs : ""}`;
364
- }
365
- case "get_clog_detail": {
366
- const a = args;
367
- return `/api/clogs/${a.id}`;
368
- }
369
- case "search": {
370
- const a = args;
371
- const params = new URLSearchParams();
372
- params.set("q", a.query);
373
- if (a?.limit)
374
- params.set("limit", String(a.limit));
375
- return `/api/search?${params.toString()}`;
376
- }
377
- default:
378
- return null;
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
+ }
379
563
  }
564
+ // --- MCP Server ---
565
+ const server = new Server({ name: "slack-radar", version: "1.5.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
566
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
567
+ tools: TOOL_DEFINITIONS,
568
+ }));
569
+ server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
570
+ const { name, arguments: args } = request.params;
571
+ const toolArgs = (args ?? {});
572
+ if (name === "_radar_session_enable") {
573
+ return handleSessionEnable(toolArgs);
574
+ }
575
+ if (name === "_radar_session_disable") {
576
+ return handleSessionDisable();
577
+ }
578
+ // Single source of truth for the session gate (also unit-tested in tools.test.mjs).
579
+ // Returns an error result when a tool requires an enabled session and there is none;
580
+ // null when the tool is allowed (ping, the session enable/disable verbs, and
581
+ // open_radar_dashboard — the dashboard is an independent local process with its own
582
+ // device-enable Reconnect button, so opening it does not need an enabled MCP session).
583
+ const gate = sessionGate(name, sessionEnabled);
584
+ if (gate) {
585
+ return textResult(JSON.parse(gate.content[0].text), gate.isError);
586
+ }
587
+ if (name === "open_radar_dashboard") {
588
+ return handleOpenDashboard(toolArgs);
589
+ }
590
+ if (name === "wait_for_events") {
591
+ return handleWaitForEvents(toolArgs);
592
+ }
593
+ if (name === "get_app_state") {
594
+ return handleGetAppState();
595
+ }
596
+ if (name === "list_profiles") {
597
+ return handleListProfiles();
598
+ }
599
+ if (name === "activate_for_user") {
600
+ return handleActivateForUser(toolArgs);
601
+ }
602
+ if (name === "get_recent_logs") {
603
+ return handleGetRecentLogs(toolArgs);
604
+ }
605
+ if (name === "capture_snapshot") {
606
+ return handleCaptureSnapshot(toolArgs);
607
+ }
608
+ if (name === "screenshot") {
609
+ return handleScreenshot(toolArgs);
610
+ }
611
+ if (name === "record_screen") {
612
+ return handleRecordScreen(toolArgs);
613
+ }
614
+ if (name === "clear_buffers") {
615
+ return handleClearBuffers();
616
+ }
617
+ if (name === "ping") {
618
+ return handlePing(toolArgs);
619
+ }
620
+ const apiPath = buildApiPath(name, toolArgs);
621
+ if (apiPath === null) {
622
+ return textResult(`Unknown tool: ${name}`, true);
623
+ }
624
+ const result = await callRadar(apiPath);
625
+ return textResult(result, !!result.error);
626
+ });
627
+ const transport = new StdioServerTransport();
628
+ await server.connect(transport);
380
629
  }
381
- // --- MCP Server ---
382
- const server = new Server({ name: "slack-radar", version: "1.3.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
383
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
384
- tools: TOOL_DEFINITIONS,
385
- }));
386
- server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
387
- const { name, arguments: args } = request.params;
388
- const toolArgs = (args ?? {});
389
- if (name === "_radar_session_enable") {
390
- return handleSessionEnable(toolArgs);
391
- }
392
- if (name === "_radar_session_disable") {
393
- return handleSessionDisable();
394
- }
395
- if (!sessionEnabled && name !== "ping") {
396
- return textResult({
397
- error: "Radar is not active. Use /radar-enable to connect, or ask about the device to auto-enable.",
398
- }, true);
399
- }
400
- if (name === "wait_for_events") {
401
- return handleWaitForEvents(toolArgs);
402
- }
403
- if (name === "get_app_state") {
404
- return handleGetAppState();
405
- }
406
- if (name === "list_profiles") {
407
- return handleListProfiles();
408
- }
409
- if (name === "activate_for_user") {
410
- return handleActivateForUser(toolArgs);
411
- }
412
- if (name === "get_recent_logs") {
413
- return handleGetRecentLogs(toolArgs);
414
- }
415
- if (name === "capture_snapshot") {
416
- return handleCaptureSnapshot(toolArgs);
417
- }
418
- if (name === "screenshot") {
419
- return handleScreenshot(toolArgs);
420
- }
421
- if (name === "record_screen") {
422
- return handleRecordScreen(toolArgs);
423
- }
424
- if (name === "clear_buffers") {
425
- return handleClearBuffers();
426
- }
427
- if (name === "ping") {
428
- return handlePing(toolArgs);
429
- }
430
- const apiPath = buildApiPath(name, toolArgs);
431
- if (apiPath === null) {
432
- return textResult(`Unknown tool: ${name}`, true);
433
- }
434
- const result = await callRadar(apiPath);
435
- return textResult(result, !!result.error);
436
- });
437
- const transport = new StdioServerTransport();
438
- await server.connect(transport);