@slack/radar-mcp 1.2.0 → 1.4.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 +16 -13
- package/dist/mcp/index.js +568 -347
- package/dist/mcp/snapshot.d.ts +63 -0
- package/dist/mcp/snapshot.js +301 -0
- package/dist/mcp/tools.js +165 -10
- package/dist/shared/android.d.ts +3 -2
- package/dist/shared/android.js +8 -7
- package/dist/shared/constants.d.ts +6 -0
- package/dist/shared/constants.js +6 -0
- package/dist/shared/logcat-capture.d.ts +15 -0
- package/dist/shared/logcat-capture.js +118 -0
- package/dist/shared/transport.d.ts +9 -6
- package/dist/web/bin.js +45 -30
- package/dist/web/public/index.html +771 -726
- package/dist/web/server.d.ts +27 -0
- package/dist/web/server.js +549 -28
- package/dist/web/spec.d.ts +83 -0
- package/dist/web/spec.js +135 -0
- package/package.json +2 -1
package/dist/mcp/index.js
CHANGED
|
@@ -2,72 +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";
|
|
12
|
+
import { stopCapture } from "../shared/logcat-capture.js";
|
|
9
13
|
import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
import { buildBufferPath, createSnapshot } from "./snapshot.js";
|
|
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
|
+
});
|
|
58
|
+
}
|
|
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();
|
|
21
81
|
}
|
|
22
|
-
|
|
23
|
-
setTransport(device);
|
|
24
|
-
let sessionEnabled = false;
|
|
25
|
-
function radarRequest(requestPath) {
|
|
82
|
+
function postSpec(spec) {
|
|
26
83
|
return new Promise((resolve, reject) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
32
92
|
}, (res) => {
|
|
33
|
-
let
|
|
34
|
-
res.on("data", (
|
|
35
|
-
data += chunk;
|
|
36
|
-
});
|
|
93
|
+
let b = "";
|
|
94
|
+
res.on("data", (c) => (b += c));
|
|
37
95
|
res.on("end", () => {
|
|
38
96
|
try {
|
|
39
|
-
resolve(JSON.parse(
|
|
97
|
+
resolve({ status: res.statusCode ?? 0, json: JSON.parse(b) });
|
|
40
98
|
}
|
|
41
99
|
catch {
|
|
42
|
-
resolve({
|
|
100
|
+
resolve({ status: res.statusCode ?? 0, json: {} });
|
|
43
101
|
}
|
|
44
102
|
});
|
|
45
103
|
});
|
|
46
104
|
req.on("error", reject);
|
|
47
105
|
req.on("timeout", () => {
|
|
48
106
|
req.destroy();
|
|
49
|
-
reject(new Error("
|
|
107
|
+
reject(new Error("timed out posting spec to the dashboard"));
|
|
50
108
|
});
|
|
109
|
+
req.write(body);
|
|
110
|
+
req.end();
|
|
51
111
|
});
|
|
52
112
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
148
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|
|
63
183
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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) {
|
|
67
216
|
if (!device.ensureForward()) {
|
|
68
|
-
sessionEnabled = false;
|
|
69
217
|
return {
|
|
70
|
-
error: "
|
|
218
|
+
error: "Port forwarding failed. Ensure a device is connected and the debug build is running.",
|
|
71
219
|
};
|
|
72
220
|
}
|
|
73
221
|
device.activate(device.getTimeoutMinutes());
|
|
@@ -75,334 +223,407 @@ async function callRadar(requestPath) {
|
|
|
75
223
|
try {
|
|
76
224
|
return await radarRequest(requestPath);
|
|
77
225
|
}
|
|
78
|
-
catch
|
|
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) {
|
|
79
265
|
sessionEnabled = false;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
});
|
|
83
294
|
}
|
|
84
295
|
}
|
|
85
|
-
|
|
86
|
-
function textResult(data, isError = false) {
|
|
87
|
-
return {
|
|
88
|
-
content: [
|
|
89
|
-
{
|
|
90
|
-
type: "text",
|
|
91
|
-
text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
-
...(isError ? { isError: true } : {}),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
// --- Tool handlers ---
|
|
98
|
-
async function handleSessionEnable(args) {
|
|
99
|
-
const timeout = args?.timeout_minutes || 5;
|
|
100
|
-
sessionEnabled = true;
|
|
101
|
-
const forwarded = device.ensureForward();
|
|
102
|
-
if (!forwarded) {
|
|
296
|
+
function handleSessionDisable() {
|
|
103
297
|
sessionEnabled = false;
|
|
104
|
-
|
|
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();
|
|
105
305
|
return textResult({
|
|
106
|
-
error: reason ??
|
|
107
|
-
"Port forwarding failed. Ensure a device is connected.",
|
|
108
306
|
enabled: false,
|
|
109
|
-
|
|
110
|
-
}, true);
|
|
111
|
-
}
|
|
112
|
-
device.activate(timeout);
|
|
113
|
-
connectStream();
|
|
114
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
115
|
-
try {
|
|
116
|
-
const status = await radarRequest("/api/ping");
|
|
117
|
-
return textResult({
|
|
118
|
-
enabled: true,
|
|
119
|
-
platform: device.platform,
|
|
120
|
-
stream_connected: isStreamConnected(),
|
|
121
|
-
...status,
|
|
307
|
+
note: "Radar disabled. Use /radar-enable to reconnect.",
|
|
122
308
|
});
|
|
123
309
|
}
|
|
124
|
-
|
|
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
|
+
});
|
|
125
326
|
return textResult({
|
|
126
|
-
|
|
127
|
-
|
|
327
|
+
event_count: events.length,
|
|
328
|
+
events,
|
|
128
329
|
stream_connected: isStreamConnected(),
|
|
129
|
-
ping: "failed",
|
|
130
|
-
note: "Radar activated but ping failed. Device may need a moment.",
|
|
131
330
|
});
|
|
132
331
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
note: "Radar disabled. Use /radar-enable to reconnect.",
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
async function handleWaitForEvents(args) {
|
|
144
|
-
if (!isStreamConnected()) {
|
|
145
|
-
if (!device.ensureForward()) {
|
|
146
|
-
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);
|
|
147
339
|
}
|
|
148
|
-
device.activate(device.getTimeoutMinutes());
|
|
149
|
-
connectStream();
|
|
150
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
151
|
-
}
|
|
152
|
-
const events = await waitForEvents({
|
|
153
|
-
event_type: args?.event_type,
|
|
154
|
-
channel: args?.channel,
|
|
155
|
-
source: args?.source,
|
|
156
|
-
timeout_ms: args?.timeout_ms,
|
|
157
|
-
idle_timeout_ms: args?.idle_timeout_ms,
|
|
158
|
-
});
|
|
159
|
-
return textResult({
|
|
160
|
-
event_count: events.length,
|
|
161
|
-
events,
|
|
162
|
-
stream_connected: isStreamConnected(),
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
function handleGetAppState() {
|
|
166
|
-
try {
|
|
167
|
-
const output = device.getAppState();
|
|
168
|
-
return textResult(output);
|
|
169
340
|
}
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|
|
172
357
|
}
|
|
173
|
-
|
|
174
|
-
function handleListProfiles() {
|
|
175
|
-
try {
|
|
176
|
-
// Narrow to implementations that expose listProfiles; the base
|
|
177
|
-
// DeviceTransport interface leaves it platform-specific.
|
|
358
|
+
async function handleActivateForUser(args) {
|
|
178
359
|
const maybe = device;
|
|
179
|
-
if (typeof maybe.
|
|
180
|
-
return textResult({ error: `Profile
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// Give the newly spawned adb logcat child a moment to emit its first
|
|
205
|
-
// lines to the capture file. Matches the warmup delay used by
|
|
206
|
-
// handleSessionEnable so an immediate get_recent_logs call does not
|
|
207
|
-
// read an empty file.
|
|
208
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
209
|
-
const sourceFile = typeof device.getCaptureFilePath === "function"
|
|
210
|
-
? device.getCaptureFilePath()
|
|
211
|
-
: null;
|
|
212
|
-
return textResult({
|
|
213
|
-
user_id: args.user_id,
|
|
214
|
-
timeout_minutes: timeout,
|
|
215
|
-
capture_file: sourceFile,
|
|
216
|
-
note: "Profile activated. Log capture restarted on a new file; old capture preserved for 7 days.",
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
catch (e) {
|
|
220
|
-
return textResult({ error: `Profile activation failed: ${e.message}` }, true);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
function handleGetRecentLogs(args) {
|
|
224
|
-
try {
|
|
225
|
-
const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
|
|
226
|
-
// Surface the capture file path so the model knows which on-disk file
|
|
227
|
-
// produced these lines. Helps avoid cross-session staleness (e.g. the
|
|
228
|
-
// model remembering an earlier profile's logs after a profile switch).
|
|
229
|
-
const sourceFile = device.getCaptureFilePath?.() ?? null;
|
|
230
|
-
return textResult({
|
|
231
|
-
tag: args.tag,
|
|
232
|
-
lines,
|
|
233
|
-
count: lines.length,
|
|
234
|
-
source_file: sourceFile,
|
|
235
|
-
source: sourceFile ? "capture" : "adb_logcat_d",
|
|
236
|
-
});
|
|
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);
|
|
384
|
+
}
|
|
237
385
|
}
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
|
240
409
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
|
253
428
|
}
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
}
|
|
256
444
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
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
|
+
}
|
|
268
459
|
}
|
|
269
|
-
|
|
270
|
-
|
|
460
|
+
async function handleClearBuffers() {
|
|
461
|
+
clearLocalBuffers();
|
|
462
|
+
const result = await callRadar("/api/clear");
|
|
463
|
+
return textResult(result, !!result.error);
|
|
271
464
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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);
|
|
281
478
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
}
|
|
289
501
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
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
|
+
}
|
|
352
563
|
}
|
|
564
|
+
// --- MCP Server ---
|
|
565
|
+
const server = new Server({ name: "slack-radar", version: "1.4.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);
|
|
353
629
|
}
|
|
354
|
-
// --- MCP Server ---
|
|
355
|
-
const server = new Server({ name: "slack-radar", version: "1.2.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
356
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
357
|
-
tools: TOOL_DEFINITIONS,
|
|
358
|
-
}));
|
|
359
|
-
server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
|
|
360
|
-
const { name, arguments: args } = request.params;
|
|
361
|
-
const toolArgs = (args ?? {});
|
|
362
|
-
if (name === "_radar_session_enable") {
|
|
363
|
-
return handleSessionEnable(toolArgs);
|
|
364
|
-
}
|
|
365
|
-
if (name === "_radar_session_disable") {
|
|
366
|
-
return handleSessionDisable();
|
|
367
|
-
}
|
|
368
|
-
if (!sessionEnabled && name !== "ping") {
|
|
369
|
-
return textResult({
|
|
370
|
-
error: "Radar is not active. Use /radar-enable to connect, or ask about the device to auto-enable.",
|
|
371
|
-
}, true);
|
|
372
|
-
}
|
|
373
|
-
if (name === "wait_for_events") {
|
|
374
|
-
return handleWaitForEvents(toolArgs);
|
|
375
|
-
}
|
|
376
|
-
if (name === "get_app_state") {
|
|
377
|
-
return handleGetAppState();
|
|
378
|
-
}
|
|
379
|
-
if (name === "list_profiles") {
|
|
380
|
-
return handleListProfiles();
|
|
381
|
-
}
|
|
382
|
-
if (name === "activate_for_user") {
|
|
383
|
-
return handleActivateForUser(toolArgs);
|
|
384
|
-
}
|
|
385
|
-
if (name === "get_recent_logs") {
|
|
386
|
-
return handleGetRecentLogs(toolArgs);
|
|
387
|
-
}
|
|
388
|
-
if (name === "screenshot") {
|
|
389
|
-
return handleScreenshot(toolArgs);
|
|
390
|
-
}
|
|
391
|
-
if (name === "record_screen") {
|
|
392
|
-
return handleRecordScreen(toolArgs);
|
|
393
|
-
}
|
|
394
|
-
if (name === "clear_buffers") {
|
|
395
|
-
return handleClearBuffers();
|
|
396
|
-
}
|
|
397
|
-
if (name === "ping") {
|
|
398
|
-
return handlePing(toolArgs);
|
|
399
|
-
}
|
|
400
|
-
const apiPath = buildApiPath(name, toolArgs);
|
|
401
|
-
if (apiPath === null) {
|
|
402
|
-
return textResult(`Unknown tool: ${name}`, true);
|
|
403
|
-
}
|
|
404
|
-
const result = await callRadar(apiPath);
|
|
405
|
-
return textResult(result, !!result.error);
|
|
406
|
-
});
|
|
407
|
-
const transport = new StdioServerTransport();
|
|
408
|
-
await server.connect(transport);
|