@slack/radar-mcp 1.0.0 → 1.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # slack-radar-mcp
2
2
 
3
- Platform-agnostic MCP server for on-device debugging of mobile apps.
3
+ Platform-agnostic MCP server and web dashboard for on-device debugging of mobile apps.
4
4
 
5
5
  Gives Claude Code direct access to network calls, RTM events, analytics (clogs), screenshots, logs, and more from a running debug build. Ships with Android support (ADB); the `DeviceTransport` interface makes adding iOS or other platforms straightforward.
6
6
 
@@ -8,21 +8,45 @@ Gives Claude Code direct access to network calls, RTM events, analytics (clogs),
8
8
 
9
9
  ## Installation
10
10
 
11
+ The recommended path is via the marketplace plugin:
12
+
11
13
  ```bash
12
- git clone slack-github.com/slack/slack-radar-mcp
13
- cd slack-radar-mcp
14
- npm install
15
- npm run build
14
+ /plugin install slack-radar@slack-android-marketplace
15
+ /slack-radar-setup
16
16
  ```
17
17
 
18
- Register the server with Claude Code:
18
+ Or register manually:
19
19
 
20
20
  ```bash
21
- slack claude mcp add -s user slack-radar-mcp -- node /path/to/slack-radar-mcp/dist/index.js
21
+ slack claude mcp add -s user slack-radar-mcp -- npx -y @slack/radar-mcp@latest
22
22
  ```
23
23
 
24
24
  Restart your Claude Code session for the tools to appear.
25
25
 
26
+ ## Web Dashboard
27
+
28
+ The package also ships a browser-based dashboard that shows the same live device data without Claude Code in the loop.
29
+
30
+ ```bash
31
+ npx -p @slack/radar-mcp slack-radar-web
32
+ ```
33
+
34
+ Or with a global install:
35
+
36
+ ```bash
37
+ npm install -g @slack/radar-mcp
38
+ slack-radar-web
39
+ ```
40
+
41
+ Opens `http://localhost:8100` automatically. Set `SLACK_RADAR_WEB_PORT` to use a different port. Features:
42
+
43
+ * Live Network / RTM / Clogs tabs with real-time SSE streaming
44
+ * Filter bar for URL, channel, event name, payload text
45
+ * Detail pane with Copy JSON, Copy Body, and Copy cURL buttons
46
+ * Profile selector for personal vs work profile when both are running
47
+ * Auto-extend keeps the device-side server alive while the browser tab is open
48
+ * Enable Radar button handles ADB forwarding and device activation without Claude
49
+
26
50
  ## Prerequisites
27
51
 
28
52
  * Node.js 18+
@@ -72,15 +96,16 @@ Mobile debug build (device)
72
96
  |
73
97
  | port forwarding (adb forward / iproxy / etc.)
74
98
  |
75
- MCP server (Node.js, runs on developer machine)
76
- +-- DeviceTransport (AndroidTransport, future: IosTransport)
77
- +-- Stdio transport -> Claude Code
78
- +-- 16 tools: ping, network, rtm, clogs, search, ...
99
+ @slack/radar-mcp (Node.js, runs on developer machine)
100
+ +-- src/shared/ DeviceTransport, AndroidTransport, SSE stream, constants
101
+ +-- src/mcp/ MCP stdio server (Claude Code entry point)
102
+ +-- src/web/ HTTP proxy server + browser dashboard
103
+ +-- public/ Static HTML/CSS/JS served to the browser
79
104
  ```
80
105
 
81
- All device-specific logic lives behind the `DeviceTransport` interface (`src/transport.ts`). The MCP server, tools, SSE streaming, and session management are fully platform-agnostic.
106
+ All device-specific logic lives behind the `DeviceTransport` interface (`src/shared/transport.ts`). The MCP server, web dashboard, tools, SSE streaming, and session management are fully platform-agnostic.
82
107
 
83
- **Adding a new platform**: implement `DeviceTransport` in `src/<platform>.ts`, add a case to `createTransport()` in `src/index.ts`. No other files change.
108
+ **Adding a new platform**: implement `DeviceTransport` in `src/shared/<platform>.ts`, add a case to `createTransport()` in `src/mcp/index.ts`. No other files change.
84
109
 
85
110
  ## Security
86
111
 
@@ -106,7 +131,7 @@ This tool captures unredacted network traffic from your debug Slack build, inclu
106
131
  * URL query parameters (tokens can appear here)
107
132
  * `Authorization` headers and cookies
108
133
  * `Set-Cookie` response headers (session tokens)
109
- * Request and response bodies, up to 64 KB per body, for JSON, form, text, and XML content types
134
+ * Request and response bodies, up to 256 KB per body, for JSON, form, text, and XML content types
110
135
  * RTM WebSocket payloads (every sent and received message)
111
136
  * Analytics (clog) events serialized as JSON
112
137
  * Screenshots and short screen recordings on demand
@@ -122,8 +147,9 @@ The tool is intended for debugging the Slack Android app against your own sessio
122
147
 
123
148
  ```bash
124
149
  npm install
125
- npm run build # Compile TypeScript to dist/
126
- npm start # Run the server (stdio transport)
150
+ npm run build # Compile TypeScript + copy static assets to dist/
151
+ npm start # Run the MCP server (stdio transport)
152
+ npm run start:web # Run the web dashboard
127
153
  npm run watch # TypeScript compiler in watch mode
128
154
  npm test # Unit tests
129
155
  npm run test:smoke # Smoke tests
@@ -142,7 +168,10 @@ The device-side server may have auto-shut down. Use `/radar-enable` to restart i
142
168
  Two consecutive connection failures disable the session. Use `/radar-enable` to reconnect.
143
169
 
144
170
  **Work profile devices**
145
- The MCP server auto-detects the Android user ID via `ps` and targets the broadcast accordingly. No manual `--user` flag needed.
171
+ The MCP server auto-detects the Android user ID via `ps` and targets the broadcast accordingly. No manual `--user` flag needed. The web dashboard shows a profile selector when both personal and work profiles are running.
146
172
 
147
173
  **Tools not appearing after setup**
148
174
  Restart your Claude Code session. MCP servers are registered at session start.
175
+
176
+ **Port already in use (web dashboard)**
177
+ Another instance may be running. Kill it with `lsof -ti :8100 | xargs kill` or set a different port with `SLACK_RADAR_WEB_PORT=8101 slack-radar-web`.
@@ -4,9 +4,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import fs from "fs";
6
6
  import http from "http";
7
- import { AndroidTransport } from "./android.js";
8
- import { RADAR_HOST, RADAR_PORT } from "./constants.js";
9
- import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "./stream.js";
7
+ import { AndroidTransport } from "../shared/android.js";
8
+ import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
9
+ import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
10
10
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS } from "./tools.js";
11
11
  // --- Platform selection ---
12
12
  function createTransport() {
@@ -7,6 +7,11 @@ export interface ToolDefinition {
7
7
  properties: Record<string, unknown>;
8
8
  required?: string[];
9
9
  };
10
+ annotations?: {
11
+ readOnlyHint?: boolean;
12
+ destructiveHint?: boolean;
13
+ idempotentHint?: boolean;
14
+ };
10
15
  }
11
16
  export declare const TOOL_DEFINITIONS: ToolDefinition[];
12
17
  /**
@@ -46,6 +46,7 @@ export const TOOL_DEFINITIONS = [
46
46
  },
47
47
  },
48
48
  },
49
+ annotations: { readOnlyHint: true, idempotentHint: true },
49
50
  },
50
51
  {
51
52
  name: "get_recent_network_calls",
@@ -58,6 +59,7 @@ export const TOOL_DEFINITIONS = [
58
59
  status_code: { type: "number", description: "Filter by HTTP status code" },
59
60
  },
60
61
  },
62
+ annotations: { readOnlyHint: true },
61
63
  },
62
64
  {
63
65
  name: "get_network_call_detail",
@@ -69,6 +71,7 @@ export const TOOL_DEFINITIONS = [
69
71
  },
70
72
  required: ["id"],
71
73
  },
74
+ annotations: { readOnlyHint: true },
72
75
  },
73
76
  {
74
77
  name: "get_recent_rtm_events",
@@ -82,6 +85,7 @@ export const TOOL_DEFINITIONS = [
82
85
  channel: { type: "string", description: "Filter by channel ID" },
83
86
  },
84
87
  },
88
+ annotations: { readOnlyHint: true },
85
89
  },
86
90
  {
87
91
  name: "get_rtm_event_detail",
@@ -93,6 +97,7 @@ export const TOOL_DEFINITIONS = [
93
97
  },
94
98
  required: ["id"],
95
99
  },
100
+ annotations: { readOnlyHint: true },
96
101
  },
97
102
  {
98
103
  name: "get_recent_clogs",
@@ -104,6 +109,7 @@ export const TOOL_DEFINITIONS = [
104
109
  name: { type: "string", description: "Filter by event name substring" },
105
110
  },
106
111
  },
112
+ annotations: { readOnlyHint: true },
107
113
  },
108
114
  {
109
115
  name: "get_clog_detail",
@@ -115,6 +121,7 @@ export const TOOL_DEFINITIONS = [
115
121
  },
116
122
  required: ["id"],
117
123
  },
124
+ annotations: { readOnlyHint: true },
118
125
  },
119
126
  {
120
127
  name: "wait_for_events",
@@ -129,6 +136,7 @@ export const TOOL_DEFINITIONS = [
129
136
  idle_timeout_ms: { type: "number", description: "Resolve after this many ms of silence (default: 3000)" },
130
137
  },
131
138
  },
139
+ annotations: { readOnlyHint: true },
132
140
  },
133
141
  {
134
142
  name: "search",
@@ -141,11 +149,13 @@ export const TOOL_DEFINITIONS = [
141
149
  },
142
150
  required: ["query"],
143
151
  },
152
+ annotations: { readOnlyHint: true },
144
153
  },
145
154
  {
146
155
  name: "get_app_state",
147
156
  description: "Get the current app state from the device.",
148
157
  inputSchema: { type: "object", properties: {} },
158
+ annotations: { readOnlyHint: true },
149
159
  },
150
160
  {
151
161
  name: "get_recent_logs",
@@ -159,6 +169,7 @@ export const TOOL_DEFINITIONS = [
159
169
  },
160
170
  required: ["tag"],
161
171
  },
172
+ annotations: { readOnlyHint: true },
162
173
  },
163
174
  {
164
175
  name: "screenshot",
@@ -169,6 +180,7 @@ export const TOOL_DEFINITIONS = [
169
180
  full_res: { type: "boolean", description: "Capture at full resolution (default: false)" },
170
181
  },
171
182
  },
183
+ annotations: { readOnlyHint: true },
172
184
  },
173
185
  {
174
186
  name: "record_screen",
@@ -179,11 +191,13 @@ export const TOOL_DEFINITIONS = [
179
191
  duration_s: { type: "number", description: "Recording duration in seconds (default: 5, max: 30)" },
180
192
  },
181
193
  },
194
+ annotations: { readOnlyHint: true },
182
195
  },
183
196
  {
184
197
  name: "clear_buffers",
185
198
  description: "Clear all captured network calls and RTM events from both device and local buffers.",
186
199
  inputSchema: { type: "object", properties: {} },
200
+ annotations: { destructiveHint: true },
187
201
  },
188
202
  {
189
203
  name: "_radar_session_enable",
@@ -194,11 +208,13 @@ export const TOOL_DEFINITIONS = [
194
208
  timeout_minutes: { type: "number", description: "Device-side auto-shutdown timeout in minutes (default: 5)" },
195
209
  },
196
210
  },
211
+ annotations: { readOnlyHint: false, idempotentHint: true },
197
212
  },
198
213
  {
199
214
  name: "_radar_session_disable",
200
215
  description: "Disable Slack Radar for this session. Tears down the SSE stream and clears local buffers. Called by /radar-disable skill.",
201
216
  inputSchema: { type: "object", properties: {} },
217
+ annotations: { readOnlyHint: false, idempotentHint: true },
202
218
  },
203
219
  ];
204
220
  /**
@@ -21,5 +21,10 @@ export declare class AndroidTransport implements DeviceTransport {
21
21
  recordScreen(durationS: number): string;
22
22
  getAppState(): string;
23
23
  getRecentLogs(tag: string, limit: number, grep?: string): string[];
24
+ listProfiles(): {
25
+ userId: string;
26
+ label: string;
27
+ }[];
28
+ activateForUser(userId: string, timeoutMinutes: number): void;
24
29
  private detectDebugBuildUser;
25
30
  }
@@ -112,6 +112,39 @@ export class AndroidTransport {
112
112
  });
113
113
  return output.trim().split("\n").filter(Boolean);
114
114
  }
115
+ listProfiles() {
116
+ try {
117
+ const ps = execSync(`adb shell "ps -A -o USER,NAME 2>/dev/null | grep Slack | grep debug"`, { timeout: 5000, encoding: "utf-8" }).trim();
118
+ if (!ps)
119
+ return [{ userId: "0", label: "Personal (user 0)" }];
120
+ const seen = new Set();
121
+ const profiles = [];
122
+ for (const line of ps.split("\n")) {
123
+ const match = line.match(/u(\d+)_/);
124
+ if (match && !seen.has(match[1])) {
125
+ seen.add(match[1]);
126
+ const id = match[1];
127
+ profiles.push({
128
+ userId: id,
129
+ label: id === "0" ? "Personal (user 0)" : `Work profile (user ${id})`,
130
+ });
131
+ }
132
+ }
133
+ return profiles.length > 0
134
+ ? profiles
135
+ : [{ userId: "0", label: "Personal (user 0)" }];
136
+ }
137
+ catch {
138
+ return [{ userId: "0", label: "Personal (user 0)" }];
139
+ }
140
+ }
141
+ activateForUser(userId, timeoutMinutes) {
142
+ const timeout = timeoutMinutes || this.currentTimeoutMinutes;
143
+ execSync(`adb shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
144
+ this.detectedUserId = userId;
145
+ this.currentTimeoutMinutes = timeout;
146
+ this.radarEnabled = true;
147
+ }
115
148
  detectDebugBuildUser() {
116
149
  if (this.detectedUserId !== null)
117
150
  return this.detectedUserId;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { createServer, WEB_PORT } from "./server.js";
3
+ import { execSync } from "child_process";
4
+ const server = createServer();
5
+ server.on("error", (err) => {
6
+ if (err.code === "EADDRINUSE") {
7
+ console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
8
+ `Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
9
+ `Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar-web`);
10
+ process.exit(1);
11
+ }
12
+ throw err;
13
+ });
14
+ server.listen(WEB_PORT, () => {
15
+ const url = `http://localhost:${WEB_PORT}`;
16
+ console.log(`Slack Radar Web running at ${url}`);
17
+ try {
18
+ if (process.platform === "darwin") {
19
+ execSync(`open ${url}`, { stdio: "ignore" });
20
+ }
21
+ else if (process.platform === "linux") {
22
+ execSync(`xdg-open ${url}`, { stdio: "ignore" });
23
+ }
24
+ }
25
+ catch {
26
+ // Browser open is best-effort
27
+ }
28
+ });
29
+ function shutdown() {
30
+ console.log("\nShutting down…");
31
+ server.close(() => process.exit(0));
32
+ setTimeout(() => process.exit(0), 2000);
33
+ }
34
+ process.on("SIGINT", shutdown);
35
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,695 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Slack Radar</title>
6
+ <style>
7
+ * { box-sizing: border-box; }
8
+ html, body {
9
+ margin: 0;
10
+ padding: 0;
11
+ height: 100%;
12
+ overflow: hidden;
13
+ }
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
16
+ font-size: 13px;
17
+ background: #ffffff;
18
+ color: #1d1c1d;
19
+ display: flex;
20
+ flex-direction: column;
21
+ }
22
+ header {
23
+ display: flex;
24
+ align-items: center;
25
+ gap: 12px;
26
+ padding: 10px 16px;
27
+ background: #f8f8f8;
28
+ border-bottom: 1px solid #e0e0e0;
29
+ }
30
+ header h1 {
31
+ margin: 0;
32
+ font-size: 15px;
33
+ font-weight: 600;
34
+ color: #1d1c1d;
35
+ }
36
+ .badge {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 6px;
40
+ font-size: 11px;
41
+ color: #616061;
42
+ padding: 3px 10px;
43
+ background: #eeeeee;
44
+ border-radius: 10px;
45
+ }
46
+ .status-dot {
47
+ width: 8px;
48
+ height: 8px;
49
+ border-radius: 50%;
50
+ background: #e01e5a;
51
+ }
52
+ .status-dot.connected { background: #2bac76; }
53
+ .status-dot.paused { background: #de9f19; }
54
+ .tabs {
55
+ display: flex;
56
+ background: #f8f8f8;
57
+ border-bottom: 1px solid #e0e0e0;
58
+ }
59
+ .tab {
60
+ padding: 10px 16px;
61
+ cursor: pointer;
62
+ color: #616061;
63
+ border-bottom: 2px solid transparent;
64
+ user-select: none;
65
+ font-weight: 500;
66
+ }
67
+ .tab:hover { color: #1d1c1d; }
68
+ .tab.active {
69
+ color: #1d1c1d;
70
+ border-bottom-color: #1264a3;
71
+ }
72
+ .tab .count {
73
+ margin-left: 6px;
74
+ font-size: 11px;
75
+ color: #868686;
76
+ }
77
+ .toolbar {
78
+ display: flex;
79
+ gap: 8px;
80
+ padding: 8px 16px;
81
+ background: #ffffff;
82
+ border-bottom: 1px solid #e0e0e0;
83
+ align-items: center;
84
+ }
85
+ .toolbar input, .toolbar button, .toolbar select {
86
+ padding: 6px 10px;
87
+ border: 1px solid #d0d0d0;
88
+ border-radius: 4px;
89
+ background: #ffffff;
90
+ color: #1d1c1d;
91
+ font-size: 12px;
92
+ font-family: inherit;
93
+ }
94
+ .toolbar input { flex: 1; }
95
+ .toolbar input:focus { outline: none; border-color: #1264a3; }
96
+ .toolbar button {
97
+ cursor: pointer;
98
+ user-select: none;
99
+ }
100
+ .toolbar button:hover { background: #f0f0f0; }
101
+ .toolbar button.active { background: #1264a3; border-color: #1264a3; color: #fff; }
102
+ .main {
103
+ flex: 1;
104
+ min-height: 0;
105
+ display: flex;
106
+ overflow: hidden;
107
+ }
108
+ .list {
109
+ flex: 1;
110
+ min-height: 0;
111
+ overflow-y: auto;
112
+ background: #ffffff;
113
+ }
114
+ .row {
115
+ display: grid;
116
+ grid-template-columns: 90px 60px 1fr 120px;
117
+ gap: 12px;
118
+ padding: 6px 16px;
119
+ border-bottom: 1px solid #f0f0f0;
120
+ cursor: pointer;
121
+ font-family: "SF Mono", Menlo, monospace;
122
+ font-size: 12px;
123
+ align-items: center;
124
+ }
125
+ .row:hover { background: #f7f7f7; }
126
+ .row.selected { background: #e3f1fa; }
127
+ .row .time { color: #868686; }
128
+ .row .status { text-align: right; color: #868686; }
129
+ .row .status.ok { color: #2bac76; }
130
+ .row .status.err { color: #e01e5a; }
131
+ .row .status.warn { color: #de9f19; }
132
+ .row .main-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
133
+ .row .method, .row .direction {
134
+ font-weight: 600;
135
+ color: #616061;
136
+ }
137
+ .row .method.POST { color: #1264a3; }
138
+ .row .method.GET { color: #2bac76; }
139
+ .row .method.PUT { color: #de9f19; }
140
+ .row .method.DELETE { color: #e01e5a; }
141
+ .row .direction.IN { color: #2bac76; }
142
+ .row .direction.OUT { color: #1264a3; }
143
+ .detail {
144
+ flex: 1;
145
+ min-height: 0;
146
+ overflow-y: auto;
147
+ background: #fafafa;
148
+ border-left: 1px solid #e0e0e0;
149
+ padding: 16px;
150
+ font-family: "SF Mono", Menlo, monospace;
151
+ font-size: 12px;
152
+ white-space: pre-wrap;
153
+ word-break: break-word;
154
+ color: #1d1c1d;
155
+ }
156
+ .detail .placeholder {
157
+ color: #868686;
158
+ font-family: inherit;
159
+ font-style: italic;
160
+ }
161
+ .detail h3 {
162
+ margin: 12px 0 6px 0;
163
+ font-family: inherit;
164
+ font-size: 12px;
165
+ font-weight: 600;
166
+ color: #616061;
167
+ text-transform: uppercase;
168
+ letter-spacing: 0.5px;
169
+ }
170
+ .detail h3:first-child { margin-top: 0; }
171
+ .detail-actions {
172
+ position: sticky;
173
+ top: 0;
174
+ background: #fafafa;
175
+ padding: 4px 0 8px;
176
+ z-index: 1;
177
+ display: flex;
178
+ gap: 6px;
179
+ }
180
+ .detail-actions button {
181
+ font-size: 11px;
182
+ padding: 3px 10px;
183
+ cursor: pointer;
184
+ background: #e8e8e8;
185
+ border: 1px solid #ccc;
186
+ border-radius: 4px;
187
+ color: #333;
188
+ }
189
+ .detail-actions button:hover { background: #ddd; }
190
+ .detail-actions button.copied { background: #2bac76; color: #fff; border-color: #2bac76; }
191
+ .empty {
192
+ padding: 40px 16px;
193
+ text-align: center;
194
+ color: #868686;
195
+ }
196
+ .marker {
197
+ padding: 6px 16px;
198
+ color: #868686;
199
+ font-style: italic;
200
+ font-size: 11px;
201
+ text-align: center;
202
+ border-top: 1px dashed #d0d0d0;
203
+ border-bottom: 1px dashed #d0d0d0;
204
+ background: #fafafa;
205
+ }
206
+ .tab-panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
207
+ .tab-panel.active { display: flex; }
208
+ </style>
209
+ </head>
210
+ <body>
211
+ <header>
212
+ <h1>Slack Radar</h1>
213
+ <span class="badge"><span id="status-dot" class="status-dot"></span> <span id="status-text">connecting</span></span>
214
+ <span class="badge" id="uptime-badge"></span>
215
+ <button id="extend-btn" style="display: none; font-size: 11px; padding: 2px 8px; margin-left: 4px; cursor: pointer; background: #2bac76; color: #fff; border: none; border-radius: 10px;">Extend 5m</button>
216
+ </header>
217
+
218
+ <div class="tabs">
219
+ <div class="tab active" data-tab="network">Network <span class="count" id="network-count">0</span></div>
220
+ <div class="tab" data-tab="rtm">RTM <span class="count" id="rtm-count">0</span></div>
221
+ <div class="tab" data-tab="clog">Clogs <span class="count" id="clog-count">0</span></div>
222
+ </div>
223
+
224
+ <div class="toolbar">
225
+ <input type="text" id="filter" placeholder="Filter by URL, channel, event name, payload..." />
226
+ <select id="profile-select" style="display: none; margin-right: 4px; font-size: 12px; padding: 2px 4px; background: #2a2a2a; color: #ccc; border: 1px solid #444; border-radius: 3px;"></select>
227
+ <button id="enable-btn" style="display: none;">Enable Radar</button>
228
+ <button id="pause-btn">Pause</button>
229
+ <button id="clear-btn">Clear</button>
230
+ </div>
231
+
232
+ <div class="main">
233
+ <div class="list" id="list"></div>
234
+ <div class="detail" id="detail">
235
+ <div class="placeholder">Select a row to see the full payload.</div>
236
+ </div>
237
+ </div>
238
+
239
+ <script>
240
+ const state = {
241
+ tab: "network",
242
+ filter: "",
243
+ paused: false,
244
+ connected: false,
245
+ hadConnection: false, // true once we've ever connected successfully
246
+ selected: null, // { type, id }
247
+ buffers: {
248
+ network: [],
249
+ rtm: [],
250
+ clog: [],
251
+ },
252
+ MAX_BUFFER: 1000,
253
+ };
254
+
255
+ const listEl = document.getElementById("list");
256
+ const detailEl = document.getElementById("detail");
257
+ const filterEl = document.getElementById("filter");
258
+ const enableBtn = document.getElementById("enable-btn");
259
+ const profileSelect = document.getElementById("profile-select");
260
+ const pauseBtn = document.getElementById("pause-btn");
261
+ const clearBtn = document.getElementById("clear-btn");
262
+ const statusDot = document.getElementById("status-dot");
263
+ const statusText = document.getElementById("status-text");
264
+ const uptimeBadge = document.getElementById("uptime-badge");
265
+
266
+ // --- Tabs ---
267
+ document.querySelectorAll(".tab").forEach((el) => {
268
+ el.addEventListener("click", () => {
269
+ state.tab = el.dataset.tab;
270
+ document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === el));
271
+ state.selected = null;
272
+ renderList();
273
+ renderDetail();
274
+ });
275
+ });
276
+
277
+ // --- Toolbar ---
278
+ filterEl.addEventListener("input", () => {
279
+ state.filter = filterEl.value.toLowerCase();
280
+ renderList();
281
+ });
282
+
283
+ pauseBtn.addEventListener("click", () => {
284
+ state.paused = !state.paused;
285
+ pauseBtn.textContent = state.paused ? "Resume" : "Pause";
286
+ pauseBtn.classList.toggle("active", state.paused);
287
+ statusDot.classList.toggle("paused", state.paused);
288
+ statusText.textContent = state.paused ? "paused" : (state.connected ? "connected" : "disconnected");
289
+ });
290
+
291
+ clearBtn.addEventListener("click", async () => {
292
+ state.buffers.network = [];
293
+ state.buffers.rtm = [];
294
+ state.buffers.clog = [];
295
+ state.selected = null;
296
+ try {
297
+ await fetch("/api/clear");
298
+ } catch (e) {
299
+ // Ignore — device may have shut down
300
+ }
301
+ updateCounts();
302
+ renderList();
303
+ renderDetail();
304
+ });
305
+
306
+ enableBtn.addEventListener("click", async () => {
307
+ enableBtn.disabled = true;
308
+ enableBtn.textContent = "Enabling…";
309
+ try {
310
+ const selectedUser = profileSelect.value;
311
+ const qs = selectedUser ? `?user_id=${selectedUser}` : "";
312
+ const res = await fetch(`/_control/activate${qs}`, { method: "POST" });
313
+ const data = await res.json();
314
+ if (!data.ok) {
315
+ statusText.textContent = "enable failed";
316
+ alert(data.error || "Failed to enable radar");
317
+ } else {
318
+ statusText.textContent = "connecting";
319
+ }
320
+ } catch (e) {
321
+ alert("Failed to reach server: " + e.message);
322
+ } finally {
323
+ enableBtn.disabled = false;
324
+ enableBtn.textContent = "Enable Radar";
325
+ }
326
+ });
327
+
328
+ async function fetchProfiles() {
329
+ try {
330
+ const res = await fetch("/_control/profiles");
331
+ const data = await res.json();
332
+ const profiles = data.profiles || [];
333
+ profileSelect.innerHTML = "";
334
+ if (profiles.length > 1) {
335
+ for (const p of profiles) {
336
+ const opt = document.createElement("option");
337
+ opt.value = p.userId;
338
+ opt.textContent = p.label;
339
+ profileSelect.appendChild(opt);
340
+ }
341
+ profileSelect.style.display = "";
342
+ } else {
343
+ profileSelect.style.display = "none";
344
+ }
345
+ } catch {
346
+ profileSelect.style.display = "none";
347
+ }
348
+ }
349
+
350
+ function setDisconnectedUi(disconnected) {
351
+ enableBtn.style.display = disconnected ? "" : "none";
352
+ if (disconnected) {
353
+ fetchProfiles();
354
+ } else {
355
+ profileSelect.style.display = "none";
356
+ }
357
+ }
358
+
359
+ // --- SSE ---
360
+ function insertReconnectMarker() {
361
+ const marker = {
362
+ __marker: true,
363
+ timestamp: Date.now(),
364
+ label: `reconnected @ ${new Date().toLocaleTimeString("en-US", { hour12: false })}`,
365
+ };
366
+ for (const type of ["network", "rtm", "clog"]) {
367
+ const buf = state.buffers[type];
368
+ if (buf.length > 0) buf.push({ ...marker }); // avoid empty-list markers
369
+ }
370
+ }
371
+
372
+ function connectStream() {
373
+ const es = new EventSource("/api/stream");
374
+ es.onopen = () => {
375
+ const wasDisconnected = !state.connected && state.hadConnection;
376
+ state.connected = true;
377
+ state.hadConnection = true;
378
+ if (wasDisconnected) insertReconnectMarker();
379
+ statusDot.classList.add("connected");
380
+ statusText.textContent = state.paused ? "paused" : "connected";
381
+ setDisconnectedUi(false);
382
+ renderList();
383
+ };
384
+ es.onmessage = (ev) => {
385
+ if (state.paused) return;
386
+ try {
387
+ const parsed = JSON.parse(ev.data);
388
+ ingest(parsed.type, parsed.event);
389
+ } catch (e) {
390
+ // Skip malformed
391
+ }
392
+ };
393
+ es.onerror = () => {
394
+ state.connected = false;
395
+ statusDot.classList.remove("connected");
396
+ statusText.textContent = "disconnected";
397
+ setDisconnectedUi(true);
398
+ renderList();
399
+ es.close();
400
+ setTimeout(connectStream, 2000);
401
+ };
402
+ }
403
+
404
+ function ingest(type, event) {
405
+ const buf = state.buffers[type];
406
+ if (!buf) return;
407
+ buf.push(event);
408
+ if (buf.length > state.MAX_BUFFER) buf.shift();
409
+ updateCounts();
410
+ if (state.tab === type) renderList();
411
+ }
412
+
413
+ function updateCounts() {
414
+ document.getElementById("network-count").textContent = state.buffers.network.length;
415
+ document.getElementById("rtm-count").textContent = state.buffers.rtm.length;
416
+ document.getElementById("clog-count").textContent = state.buffers.clog.length;
417
+ }
418
+
419
+ // --- Rendering ---
420
+ function filterRows(rows) {
421
+ if (!state.filter) return rows;
422
+ return rows.filter((r) => {
423
+ if (r.__marker) return true; // always keep markers so history stays anchored
424
+ return JSON.stringify(r).toLowerCase().includes(state.filter);
425
+ });
426
+ }
427
+
428
+ function renderList() {
429
+ const rows = filterRows(state.buffers[state.tab]);
430
+ // Count real (non-marker) rows when deciding empty-state
431
+ const nonMarkerCount = rows.filter((r) => !r.__marker).length;
432
+ if (nonMarkerCount === 0) {
433
+ let msg;
434
+ if (state.filter) {
435
+ msg = "No matches. Clear the filter or try a different query.";
436
+ } else if (!state.connected) {
437
+ msg = "Radar is not connected. Click Enable Radar in the toolbar to activate.";
438
+ } else {
439
+ msg = `No ${state.tab} events yet. Interact with the app to capture events.`;
440
+ }
441
+ listEl.innerHTML = `<div class="empty">${msg}</div>`;
442
+ return;
443
+ }
444
+ // Newest first
445
+ const html = rows.slice().reverse().map((row) => {
446
+ if (row.__marker) return `<div class="marker">─── ${row.label} ───</div>`;
447
+ return renderRow(state.tab, row);
448
+ }).join("");
449
+ listEl.innerHTML = html;
450
+ listEl.querySelectorAll(".row").forEach((el) => {
451
+ el.addEventListener("click", () => {
452
+ const id = Number(el.dataset.id);
453
+ state.selected = { type: state.tab, id };
454
+ listEl.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r === el));
455
+ renderDetail();
456
+ });
457
+ });
458
+ }
459
+
460
+ function renderRow(type, row) {
461
+ const time = new Date(row.timestamp).toLocaleTimeString("en-US", { hour12: false });
462
+ const selected = state.selected && state.selected.type === type && state.selected.id === row.id ? "selected" : "";
463
+ if (type === "network") {
464
+ const statusClass = row.status_code >= 500 ? "err" : row.status_code >= 400 ? "warn" : "ok";
465
+ return `
466
+ <div class="row ${selected}" data-id="${row.id}">
467
+ <span class="time">${time}</span>
468
+ <span class="method ${row.method}">${row.method}</span>
469
+ <span class="main-col">${escapeHtml(row.url)}</span>
470
+ <span class="status ${statusClass}">${row.status_code} · ${row.duration_ms}ms</span>
471
+ </div>`;
472
+ }
473
+ if (type === "rtm") {
474
+ return `
475
+ <div class="row ${selected}" data-id="${row.id}">
476
+ <span class="time">${time}</span>
477
+ <span class="direction ${row.direction === "INCOMING" ? "IN" : "OUT"}">${row.direction === "INCOMING" ? "←" : "→"}</span>
478
+ <span class="main-col">${escapeHtml(row.event_type ?? "(no type)")}${row.channel ? " · " + escapeHtml(row.channel) : ""}</span>
479
+ <span class="status"></span>
480
+ </div>`;
481
+ }
482
+ // clog
483
+ return `
484
+ <div class="row ${selected}" data-id="${row.id}">
485
+ <span class="time">${time}</span>
486
+ <span></span>
487
+ <span class="main-col">${escapeHtml(row.event_name ?? "(unknown)")}</span>
488
+ <span class="status"></span>
489
+ </div>`;
490
+ }
491
+
492
+ let lastDetailData = null;
493
+
494
+ async function renderDetail() {
495
+ if (!state.selected) {
496
+ lastDetailData = null;
497
+ detailEl.innerHTML = '<div class="placeholder">Select a row to see the full payload.</div>';
498
+ return;
499
+ }
500
+ const { type, id } = state.selected;
501
+ detailEl.innerHTML = '<div class="placeholder">Loading…</div>';
502
+ try {
503
+ const endpoint = type === "network" ? "/api/network/" : type === "rtm" ? "/api/rtm/" : "/api/clogs/";
504
+ const res = await fetch(endpoint + id);
505
+ const data = await res.json();
506
+ lastDetailData = data;
507
+ detailEl.innerHTML = detailActions() + formatDetail(type, data);
508
+ wireDetailActions();
509
+ } catch (e) {
510
+ lastDetailData = null;
511
+ detailEl.innerHTML = '<div class="placeholder">Failed to load detail: ' + escapeHtml(e.message) + '</div>';
512
+ }
513
+ }
514
+
515
+ function detailActions() {
516
+ const isNetwork = state.selected && state.selected.type === "network";
517
+ return `<div class="detail-actions">
518
+ <button id="copy-json-btn">Copy JSON</button>
519
+ <button id="copy-body-btn">Copy Body</button>
520
+ ${isNetwork ? '<button id="copy-curl-btn">Copy cURL</button>' : ''}
521
+ </div>`;
522
+ }
523
+
524
+ function wireDetailActions() {
525
+ const copyJsonBtn = document.getElementById("copy-json-btn");
526
+ const copyBodyBtn = document.getElementById("copy-body-btn");
527
+ if (copyJsonBtn) {
528
+ copyJsonBtn.addEventListener("click", () => {
529
+ if (!lastDetailData) return;
530
+ navigator.clipboard.writeText(JSON.stringify(lastDetailData, null, 2));
531
+ copyJsonBtn.textContent = "Copied!";
532
+ copyJsonBtn.classList.add("copied");
533
+ setTimeout(() => { copyJsonBtn.textContent = "Copy JSON"; copyJsonBtn.classList.remove("copied"); }, 1500);
534
+ });
535
+ }
536
+ if (copyBodyBtn) {
537
+ copyBodyBtn.addEventListener("click", () => {
538
+ if (!lastDetailData) return;
539
+ const body = lastDetailData.response_body ?? lastDetailData.data ?? lastDetailData.request_body ?? "";
540
+ const text = tryPretty(body);
541
+ navigator.clipboard.writeText(text);
542
+ copyBodyBtn.textContent = "Copied!";
543
+ copyBodyBtn.classList.add("copied");
544
+ setTimeout(() => { copyBodyBtn.textContent = "Copy Body"; copyBodyBtn.classList.remove("copied"); }, 1500);
545
+ });
546
+ }
547
+ const copyCurlBtn = document.getElementById("copy-curl-btn");
548
+ if (copyCurlBtn) {
549
+ copyCurlBtn.addEventListener("click", () => {
550
+ if (!lastDetailData) return;
551
+ navigator.clipboard.writeText(buildCurl(lastDetailData));
552
+ copyCurlBtn.textContent = "Copied!";
553
+ copyCurlBtn.classList.add("copied");
554
+ setTimeout(() => { copyCurlBtn.textContent = "Copy cURL"; copyCurlBtn.classList.remove("copied"); }, 1500);
555
+ });
556
+ }
557
+ }
558
+
559
+ function shellEscape(s) {
560
+ if (!s) return "''";
561
+ return "'" + s.replace(/'/g, "'\\''") + "'";
562
+ }
563
+
564
+ function buildCurl(d) {
565
+ const parts = [`curl -X ${d.method} ${shellEscape(d.url)}`];
566
+ const headers = d.request_headers ?? {};
567
+ let hasAuth = false;
568
+ for (const [k, v] of Object.entries(headers)) {
569
+ const lower = k.toLowerCase();
570
+ if (lower === "accept-encoding" || lower === "host" || lower === "content-length") continue;
571
+ if (lower === "authorization" || lower === "cookie") hasAuth = true;
572
+ parts.push(` -H ${shellEscape(k + ": " + v)}`);
573
+ }
574
+ if (d.request_body) {
575
+ parts.push(` --data-raw ${shellEscape(d.request_body)}`);
576
+ }
577
+ let cmd = parts.join(" \\\n");
578
+ if (!hasAuth) {
579
+ cmd += "\n# Note: auth cookies are injected by OkHttp after the app interceptor and are not captured. Add -b 'cookie=...' manually to replay.";
580
+ }
581
+ return cmd;
582
+ }
583
+
584
+ function formatDetail(type, data) {
585
+ if (type === "network") {
586
+ return `
587
+ <h3>Request</h3>
588
+ ${escapeHtml(data.method)} ${escapeHtml(data.url)}
589
+
590
+ <h3>Request Headers</h3>
591
+ ${escapeHtml(JSON.stringify(data.request_headers ?? {}, null, 2))}
592
+
593
+ <h3>Request Body</h3>
594
+ ${escapeHtml(data.request_body ?? "(none)")}
595
+
596
+ <h3>Response · ${data.status_code} · ${data.duration_ms}ms</h3>
597
+
598
+ <h3>Response Headers</h3>
599
+ ${escapeHtml(JSON.stringify(data.response_headers ?? {}, null, 2))}
600
+
601
+ <h3>Response Body</h3>
602
+ ${escapeHtml(tryPretty(data.response_body))}
603
+ `;
604
+ }
605
+ if (type === "rtm") {
606
+ return `
607
+ <h3>${escapeHtml(data.direction)} · ${escapeHtml(data.event_type ?? "(no type)")}</h3>
608
+ Channel: ${escapeHtml(data.channel ?? "(none)")}
609
+ Timestamp: ${new Date(data.timestamp).toISOString()}
610
+ Base64: ${data.is_base64}
611
+
612
+ <h3>Payload</h3>
613
+ ${escapeHtml(tryPretty(data.data))}
614
+ `;
615
+ }
616
+ // clog
617
+ return `
618
+ <h3>${escapeHtml(data.event_name)}</h3>
619
+ Timestamp: ${new Date(data.timestamp).toISOString()}
620
+
621
+ <h3>Payload</h3>
622
+ ${escapeHtml(tryPretty(data.data))}
623
+ `;
624
+ }
625
+
626
+ function tryPretty(s) {
627
+ if (!s) return "(empty)";
628
+ try {
629
+ return JSON.stringify(JSON.parse(s), null, 2);
630
+ } catch {
631
+ return s;
632
+ }
633
+ }
634
+
635
+ function escapeHtml(s) {
636
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
637
+ }
638
+
639
+ // --- Uptime ping ---
640
+ const extendBtn = document.getElementById("extend-btn");
641
+
642
+ function formatUptime(ms) {
643
+ const totalSec = Math.floor(ms / 1000);
644
+ const h = Math.floor(totalSec / 3600);
645
+ const m = Math.floor((totalSec % 3600) / 60);
646
+ const s = totalSec % 60;
647
+ if (h > 0) return `up ${h}h ${m}m`;
648
+ if (m > 0) return `up ${m}m ${s}s`;
649
+ return `up ${s}s`;
650
+ }
651
+
652
+ async function extendSession() {
653
+ try {
654
+ await fetch("/api/ping?timeout_minutes=5");
655
+ } catch {}
656
+ }
657
+
658
+ extendBtn.addEventListener("click", async () => {
659
+ extendBtn.disabled = true;
660
+ extendBtn.textContent = "Extending…";
661
+ await extendSession();
662
+ await pollPing();
663
+ extendBtn.disabled = false;
664
+ extendBtn.textContent = "Extend 5m";
665
+ });
666
+
667
+ async function pollPing() {
668
+ try {
669
+ const res = await fetch("/api/ping");
670
+ const data = await res.json();
671
+ if (data.uptime_ms != null) {
672
+ const remaining = data.remaining_minutes ?? 0;
673
+ const timerLabel = remaining >= 4 ? "active" : `${remaining}m left`;
674
+ uptimeBadge.textContent = `${formatUptime(data.uptime_ms)} · ${timerLabel}`;
675
+ extendBtn.style.display = state.connected ? "" : "none";
676
+ extendBtn.style.background = remaining < 4 ? "#de9f19" : "#2bac76";
677
+ if (remaining <= 1 && state.connected) {
678
+ await extendSession();
679
+ }
680
+ }
681
+ } catch {
682
+ uptimeBadge.textContent = "";
683
+ extendBtn.style.display = "none";
684
+ }
685
+ }
686
+ pollPing();
687
+ setInterval(pollPing, 5000);
688
+
689
+ // --- Go ---
690
+ connectStream();
691
+ updateCounts();
692
+ renderList();
693
+ </script>
694
+ </body>
695
+ </html>
@@ -0,0 +1,4 @@
1
+ import http from "http";
2
+ declare const WEB_PORT: number;
3
+ export declare function createServer(): http.Server;
4
+ export { WEB_PORT };
@@ -0,0 +1,106 @@
1
+ import http from "http";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { AndroidTransport } from "../shared/android.js";
6
+ import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? 8100);
9
+ const INDEX_PATH = path.join(__dirname, "public", "index.html");
10
+ const transport = new AndroidTransport();
11
+ export function createServer() {
12
+ const server = http.createServer((req, res) => {
13
+ if (req.url === "/" || req.url === "/index.html") {
14
+ try {
15
+ const html = fs.readFileSync(INDEX_PATH);
16
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
17
+ res.end(html);
18
+ }
19
+ catch (e) {
20
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
21
+ res.end(`Failed to read ${INDEX_PATH}: ${e.message}`);
22
+ }
23
+ return;
24
+ }
25
+ if (req.url === "/_control/profiles" && req.method === "GET") {
26
+ listProfiles(res);
27
+ return;
28
+ }
29
+ if (req.url?.startsWith("/_control/activate") && req.method === "POST") {
30
+ const url = new URL(req.url, `http://localhost:${WEB_PORT}`);
31
+ const userId = url.searchParams.get("user_id") ?? undefined;
32
+ activateRadar(res, userId);
33
+ return;
34
+ }
35
+ if (req.url?.startsWith("/api/")) {
36
+ proxyToRadar(req, res);
37
+ return;
38
+ }
39
+ res.writeHead(404);
40
+ res.end("Not found");
41
+ });
42
+ return server;
43
+ }
44
+ function listProfiles(res) {
45
+ const profiles = transport.listProfiles();
46
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
47
+ res.end(JSON.stringify({ profiles }));
48
+ }
49
+ function activateRadar(res, userId) {
50
+ const forwarded = transport.ensureForward();
51
+ if (!forwarded) {
52
+ res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
53
+ res.end(JSON.stringify({
54
+ ok: false,
55
+ error: "ADB forward failed. Is a device connected?",
56
+ }));
57
+ return;
58
+ }
59
+ try {
60
+ if (userId) {
61
+ transport.activateForUser(userId, 5);
62
+ }
63
+ else {
64
+ transport.activate(5);
65
+ }
66
+ }
67
+ catch (e) {
68
+ res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
69
+ res.end(JSON.stringify({
70
+ ok: false,
71
+ error: `Broadcast failed: ${e.message}. Debug build not running?`,
72
+ }));
73
+ return;
74
+ }
75
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
76
+ res.end(JSON.stringify({ ok: true, user_id: userId ?? "auto" }));
77
+ }
78
+ function proxyToRadar(req, res) {
79
+ const upstream = http.request({
80
+ host: RADAR_HOST,
81
+ port: RADAR_PORT,
82
+ path: req.url,
83
+ method: req.method,
84
+ headers: req.headers,
85
+ timeout: 0,
86
+ }, (upstreamRes) => {
87
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
88
+ upstreamRes.on("data", (chunk) => res.write(chunk));
89
+ upstreamRes.on("end", () => res.end());
90
+ upstreamRes.on("error", () => res.end());
91
+ });
92
+ upstream.on("error", (err) => {
93
+ res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
94
+ res.end(JSON.stringify({
95
+ error: `Cannot reach radar at ${RADAR_HOST}:${RADAR_PORT}. Is ADB forwarding active? ${err.message}`,
96
+ }));
97
+ });
98
+ req.on("close", () => upstream.destroy());
99
+ if (req.method !== "GET" && req.method !== "HEAD") {
100
+ req.pipe(upstream);
101
+ }
102
+ else {
103
+ upstream.end();
104
+ }
105
+ }
106
+ export { WEB_PORT };
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.0.0",
4
- "description": "MCP server for on-device debugging of the Slack Android app via ADB",
3
+ "version": "1.1.0",
4
+ "description": "MCP server and web dashboard for on-device debugging of the Slack Android app via ADB",
5
5
  "type": "module",
6
- "main": "dist/index.js",
6
+ "main": "dist/mcp/index.js",
7
7
  "bin": {
8
- "slack-radar-mcp": "dist/index.js"
8
+ "slack-radar-mcp": "dist/mcp/index.js",
9
+ "slack-radar-web": "dist/web/bin.js"
9
10
  },
10
11
  "files": [
11
12
  "dist"
@@ -13,8 +14,9 @@
13
14
  "scripts": {
14
15
  "clean": "rm -rf dist",
15
16
  "prebuild": "npm run clean",
16
- "build": "tsc && chmod +x dist/index.js",
17
- "start": "node dist/index.js",
17
+ "build": "tsc && cp -r src/web/public dist/web/public && chmod +x dist/mcp/index.js dist/web/bin.js",
18
+ "start": "node dist/mcp/index.js",
19
+ "start:web": "node dist/web/bin.js",
18
20
  "watch": "tsc --watch",
19
21
  "test": "npm run build && node --test tests/*.test.mjs",
20
22
  "test:smoke": "npm run build && bash tests/smoke.sh",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes