conduit-mcp 2.0.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.
@@ -0,0 +1,2120 @@
1
+ // src/index.ts
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+
5
+ // src/bridge.ts
6
+ import { EventEmitter } from "events";
7
+ import http from "http";
8
+ import { WebSocketServer, WebSocket } from "ws";
9
+
10
+ // src/protocol.ts
11
+ import { randomUUID } from "crypto";
12
+ function generateId() {
13
+ return randomUUID().slice(0, 8);
14
+ }
15
+ function isHeartbeat(msg) {
16
+ return typeof msg === "object" && msg !== null && "type" in msg && msg.type === "heartbeat";
17
+ }
18
+ function isStudioRegistration(msg) {
19
+ return typeof msg === "object" && msg !== null && "type" in msg && msg.type === "register" && "studioId" in msg;
20
+ }
21
+ function isBridgeError(msg) {
22
+ return typeof msg === "object" && msg !== null && "id" in msg && "error" in msg;
23
+ }
24
+ function isBridgeResponse(msg) {
25
+ return typeof msg === "object" && msg !== null && "id" in msg && "result" in msg;
26
+ }
27
+
28
+ // src/utils/logger.ts
29
+ var log = {
30
+ info: (...args) => console.error("[conduit]", ...args),
31
+ warn: (...args) => console.error("[conduit:warn]", ...args),
32
+ error: (...args) => console.error("[conduit:error]", ...args),
33
+ debug: (...args) => {
34
+ if (process.env.CONDUIT_DEBUG) {
35
+ console.error("[conduit:debug]", ...args);
36
+ }
37
+ }
38
+ };
39
+
40
+ // src/bridge.ts
41
+ var REQUEST_TIMEOUT_MS = 3e4;
42
+ var HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
43
+ var HEARTBEAT_TIMEOUT_MS = 15e3;
44
+ var LONG_POLL_TIMEOUT_MS = 25e3;
45
+ var MAX_PORT_RETRIES = 10;
46
+ var Bridge = class extends EventEmitter {
47
+ constructor(port = 3200) {
48
+ super();
49
+ this.port = port;
50
+ }
51
+ port;
52
+ httpServer = null;
53
+ wsServer = null;
54
+ studios = /* @__PURE__ */ new Map();
55
+ activeStudioId = null;
56
+ pendingRequests = /* @__PURE__ */ new Map();
57
+ lastHeartbeats = /* @__PURE__ */ new Map();
58
+ heartbeatTimer = null;
59
+ actualPort = 0;
60
+ // HTTP-only studios (no WebSocket connection)
61
+ httpStudios = /* @__PURE__ */ new Map();
62
+ // Unregistered WebSocket connections waiting for a registration message
63
+ pendingWs = /* @__PURE__ */ new Set();
64
+ // HTTP fallback state (per-studio)
65
+ httpPendingCommands = /* @__PURE__ */ new Map();
66
+ httpPollWaiters = /* @__PURE__ */ new Map();
67
+ get isConnected() {
68
+ const studio = this.getActiveStudio();
69
+ return studio !== void 0 && studio.ws.readyState === WebSocket.OPEN;
70
+ }
71
+ get listeningPort() {
72
+ return this.actualPort;
73
+ }
74
+ // ── Public studio management ───────────────────────────────────
75
+ getStudios() {
76
+ const wsStudios = Array.from(this.studios.values()).map((s) => s.info);
77
+ const httpOnly = Array.from(this.httpStudios.values()).filter(
78
+ (s) => !this.studios.has(s.studioId)
79
+ );
80
+ return [...wsStudios, ...httpOnly];
81
+ }
82
+ getActiveStudioId() {
83
+ return this.activeStudioId;
84
+ }
85
+ setActiveStudio(studioId) {
86
+ if (!this.studios.has(studioId) && !this.httpStudios.has(studioId)) {
87
+ throw new Error(
88
+ `Studio "${studioId}" is not connected. Connected studios: ${this.getStudios().map((s) => s.studioId).join(", ") || "none"}`
89
+ );
90
+ }
91
+ this.activeStudioId = studioId;
92
+ log.info(`Active studio set to: ${studioId}`);
93
+ }
94
+ // ── Server lifecycle ───────────────────────────────────────────
95
+ async start() {
96
+ return new Promise((resolve, reject) => {
97
+ let attempts = 0;
98
+ const tryPort = (port) => {
99
+ const server = http.createServer(
100
+ (req, res) => this.handleHttp(req, res)
101
+ );
102
+ server.once("error", (err) => {
103
+ if (err.code === "EADDRINUSE" && attempts < MAX_PORT_RETRIES) {
104
+ attempts++;
105
+ tryPort(port + 1);
106
+ } else {
107
+ reject(err);
108
+ }
109
+ });
110
+ server.listen(port, "127.0.0.1", () => {
111
+ this.httpServer = server;
112
+ this.actualPort = port;
113
+ this.wsServer = new WebSocketServer({ server });
114
+ this.wsServer.on("connection", (ws) => this.handleWsConnection(ws));
115
+ log.info(`Bridge listening on 127.0.0.1:${port}`);
116
+ resolve(port);
117
+ });
118
+ };
119
+ tryPort(this.port);
120
+ });
121
+ }
122
+ async stop() {
123
+ for (const [id, pending] of this.pendingRequests) {
124
+ clearTimeout(pending.timer);
125
+ pending.reject(new Error("Bridge shutting down"));
126
+ this.pendingRequests.delete(id);
127
+ }
128
+ for (const [, studio] of this.studios) {
129
+ studio.ws.close();
130
+ }
131
+ this.studios.clear();
132
+ this.httpStudios.clear();
133
+ this.activeStudioId = null;
134
+ for (const ws of this.pendingWs) {
135
+ ws.close();
136
+ }
137
+ this.pendingWs.clear();
138
+ if (this.heartbeatTimer) {
139
+ clearInterval(this.heartbeatTimer);
140
+ this.heartbeatTimer = null;
141
+ }
142
+ for (const [, waiters] of this.httpPollWaiters) {
143
+ for (const waiter of waiters) {
144
+ clearTimeout(waiter.timer);
145
+ waiter.res.writeHead(503);
146
+ waiter.res.end();
147
+ }
148
+ }
149
+ this.httpPollWaiters.clear();
150
+ this.httpPendingCommands.clear();
151
+ await new Promise((resolve) => {
152
+ if (this.wsServer) {
153
+ this.wsServer.close(() => {
154
+ this.wsServer = null;
155
+ if (this.httpServer) {
156
+ this.httpServer.close(() => {
157
+ this.httpServer = null;
158
+ resolve();
159
+ });
160
+ } else {
161
+ resolve();
162
+ }
163
+ });
164
+ } else if (this.httpServer) {
165
+ this.httpServer.close(() => {
166
+ this.httpServer = null;
167
+ resolve();
168
+ });
169
+ } else {
170
+ resolve();
171
+ }
172
+ });
173
+ }
174
+ async send(method, params) {
175
+ const id = generateId();
176
+ const request = { id, method, params };
177
+ const json = JSON.stringify(request);
178
+ const studio = this.resolveTargetStudio();
179
+ return new Promise((resolve, reject) => {
180
+ const timer = setTimeout(() => {
181
+ this.pendingRequests.delete(id);
182
+ reject(
183
+ new Error(
184
+ `Request ${method} timed out after ${REQUEST_TIMEOUT_MS}ms \u2014 is the Conduit plugin running in Roblox Studio?`
185
+ )
186
+ );
187
+ }, REQUEST_TIMEOUT_MS);
188
+ this.pendingRequests.set(id, { resolve, reject, timer });
189
+ if (studio) {
190
+ studio.ws.send(json);
191
+ } else {
192
+ const studioId = this.activeStudioId ?? "_default";
193
+ const queue = this.httpPendingCommands.get(studioId) ?? [];
194
+ queue.push(request);
195
+ this.httpPendingCommands.set(studioId, queue);
196
+ this.flushHttpPollers(studioId);
197
+ }
198
+ });
199
+ }
200
+ // ── Internal helpers ───────────────────────────────────────────
201
+ getActiveStudio() {
202
+ if (this.activeStudioId) {
203
+ return this.studios.get(this.activeStudioId);
204
+ }
205
+ return void 0;
206
+ }
207
+ resolveTargetStudio() {
208
+ const active = this.getActiveStudio();
209
+ if (active && active.ws.readyState === WebSocket.OPEN) {
210
+ return active;
211
+ }
212
+ if (this.studios.size === 1) {
213
+ const [studioId, studio] = this.studios.entries().next().value;
214
+ if (studio.ws.readyState === WebSocket.OPEN) {
215
+ this.activeStudioId = studioId;
216
+ return studio;
217
+ }
218
+ }
219
+ if (this.studios.size === 0) {
220
+ return void 0;
221
+ }
222
+ throw new Error(
223
+ `Multiple Studio instances connected but none is active. Use set_active_studio to choose one. Connected: ${Array.from(
224
+ this.studios.values()
225
+ ).map((s) => `${s.info.studioId} (${s.info.placeName ?? "unknown"})`).join(", ")}`
226
+ );
227
+ }
228
+ // ── WebSocket handling ─────────────────────────────────────────
229
+ handleWsConnection(ws) {
230
+ log.info("New WebSocket connection \u2014 waiting for registration");
231
+ this.pendingWs.add(ws);
232
+ const onFirstMessage = (data) => {
233
+ try {
234
+ const msg = JSON.parse(data.toString());
235
+ if (isStudioRegistration(msg)) {
236
+ ws.removeListener("message", onFirstMessage);
237
+ this.pendingWs.delete(ws);
238
+ this.registerStudio(ws, {
239
+ studioId: msg.studioId,
240
+ placeId: msg.placeId,
241
+ placeName: msg.placeName,
242
+ connectedAt: Date.now()
243
+ });
244
+ return;
245
+ }
246
+ ws.removeListener("message", onFirstMessage);
247
+ this.pendingWs.delete(ws);
248
+ const syntheticId = "studio-1";
249
+ log.info(
250
+ "Legacy plugin detected (no registration), assigning ID: " + syntheticId
251
+ );
252
+ this.registerStudio(ws, {
253
+ studioId: syntheticId,
254
+ connectedAt: Date.now()
255
+ });
256
+ this.handlePluginMessage(msg);
257
+ } catch (err) {
258
+ log.warn("Failed to parse first plugin message:", err);
259
+ }
260
+ };
261
+ ws.on("message", onFirstMessage);
262
+ ws.on("close", () => {
263
+ this.pendingWs.delete(ws);
264
+ });
265
+ ws.on("error", (err) => {
266
+ log.warn("WebSocket error:", err.message);
267
+ });
268
+ }
269
+ registerStudio(ws, info) {
270
+ const { studioId } = info;
271
+ const existing = this.studios.get(studioId);
272
+ if (existing) {
273
+ log.warn(`Studio "${studioId}" reconnected \u2014 closing old connection`);
274
+ existing.ws.close();
275
+ }
276
+ this.studios.set(studioId, { ws, info });
277
+ this.lastHeartbeats.set(studioId, Date.now());
278
+ if (this.activeStudioId === null) {
279
+ this.activeStudioId = studioId;
280
+ }
281
+ this.emit("studio-connected", info);
282
+ log.info(
283
+ `Studio registered: ${studioId}` + (info.placeName ? ` (${info.placeName})` : "")
284
+ );
285
+ this.startHeartbeatMonitor();
286
+ ws.on("message", (data) => {
287
+ try {
288
+ const msg = JSON.parse(data.toString());
289
+ if (isHeartbeat(msg)) {
290
+ this.lastHeartbeats.set(studioId, Date.now());
291
+ return;
292
+ }
293
+ this.handlePluginMessage(msg);
294
+ } catch (err) {
295
+ log.warn("Failed to parse plugin message:", err);
296
+ }
297
+ });
298
+ ws.on("close", () => {
299
+ const current = this.studios.get(studioId);
300
+ if (current && current.ws === ws) {
301
+ this.studios.delete(studioId);
302
+ this.lastHeartbeats.delete(studioId);
303
+ this.emit("studio-disconnected", info);
304
+ log.info(`Studio disconnected: ${studioId}`);
305
+ if (this.activeStudioId === studioId) {
306
+ if (this.studios.size === 1) {
307
+ this.activeStudioId = this.studios.keys().next().value;
308
+ log.info(
309
+ `Auto-switched active studio to: ${this.activeStudioId}`
310
+ );
311
+ } else {
312
+ this.activeStudioId = null;
313
+ }
314
+ }
315
+ if (this.studios.size === 0) {
316
+ this.stopHeartbeatMonitor();
317
+ }
318
+ }
319
+ });
320
+ }
321
+ handlePluginMessage(msg) {
322
+ if (isBridgeResponse(msg)) {
323
+ const pending = this.pendingRequests.get(msg.id);
324
+ if (pending) {
325
+ clearTimeout(pending.timer);
326
+ this.pendingRequests.delete(msg.id);
327
+ pending.resolve(msg.result);
328
+ }
329
+ return;
330
+ }
331
+ if (isBridgeError(msg)) {
332
+ const pending = this.pendingRequests.get(msg.id);
333
+ if (pending) {
334
+ clearTimeout(pending.timer);
335
+ this.pendingRequests.delete(msg.id);
336
+ pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
337
+ }
338
+ return;
339
+ }
340
+ log.debug("Unknown message from plugin:", msg);
341
+ }
342
+ startHeartbeatMonitor() {
343
+ if (this.heartbeatTimer) return;
344
+ this.heartbeatTimer = setInterval(() => {
345
+ const now = Date.now();
346
+ for (const [studioId, lastBeat] of this.lastHeartbeats) {
347
+ if (now - lastBeat > HEARTBEAT_TIMEOUT_MS) {
348
+ const studio = this.studios.get(studioId);
349
+ if (studio) {
350
+ log.warn(
351
+ `Heartbeat timeout for studio "${studioId}" \u2014 disconnecting`
352
+ );
353
+ studio.ws.terminate();
354
+ } else if (this.httpStudios.has(studioId)) {
355
+ log.warn(
356
+ `Heartbeat timeout for HTTP studio "${studioId}" \u2014 evicting`
357
+ );
358
+ const info = this.httpStudios.get(studioId);
359
+ this.httpStudios.delete(studioId);
360
+ this.lastHeartbeats.delete(studioId);
361
+ this.emit("studio-disconnected", info);
362
+ if (this.activeStudioId === studioId) {
363
+ const remaining = this.getStudios();
364
+ this.activeStudioId = remaining.length > 0 ? remaining[0].studioId : null;
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }, HEARTBEAT_CHECK_INTERVAL_MS);
370
+ }
371
+ stopHeartbeatMonitor() {
372
+ if (this.heartbeatTimer) {
373
+ clearInterval(this.heartbeatTimer);
374
+ this.heartbeatTimer = null;
375
+ }
376
+ }
377
+ // ── HTTP fallback handling ─────────────────────────────────────
378
+ handleHttp(req, res) {
379
+ res.setHeader("Access-Control-Allow-Origin", "*");
380
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
381
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
382
+ if (req.method === "OPTIONS") {
383
+ res.writeHead(204);
384
+ res.end();
385
+ return;
386
+ }
387
+ const parsedUrl = new URL(req.url ?? "/", `http://127.0.0.1`);
388
+ const pathname = parsedUrl.pathname;
389
+ if (req.method === "GET" && pathname === "/health") {
390
+ res.writeHead(200, { "Content-Type": "application/json" });
391
+ res.end(
392
+ JSON.stringify({
393
+ status: "ok",
394
+ connected: this.isConnected,
395
+ port: this.actualPort,
396
+ studios: this.getStudios()
397
+ })
398
+ );
399
+ return;
400
+ }
401
+ if (req.method === "GET" && pathname === "/poll") {
402
+ const studioId = parsedUrl.searchParams.get("studioId") ?? this.activeStudioId ?? "_default";
403
+ this.handlePoll(studioId, res);
404
+ return;
405
+ }
406
+ if (req.method === "POST" && pathname === "/result") {
407
+ this.handleResult(req, res);
408
+ return;
409
+ }
410
+ res.writeHead(404);
411
+ res.end("Not found");
412
+ }
413
+ handlePoll(studioId, res) {
414
+ if (!this.studios.has(studioId) && studioId !== "_default") {
415
+ if (!this.httpStudios.has(studioId)) {
416
+ const info = {
417
+ studioId,
418
+ connectedAt: Date.now()
419
+ };
420
+ this.httpStudios.set(studioId, info);
421
+ this.emit("studio-connected", info);
422
+ log.info(`HTTP-only studio registered: ${studioId}`);
423
+ }
424
+ this.lastHeartbeats.set(studioId, Date.now());
425
+ if (this.activeStudioId === null) {
426
+ this.activeStudioId = studioId;
427
+ }
428
+ }
429
+ this.lastHeartbeats.set(studioId, Date.now());
430
+ const queue = this.httpPendingCommands.get(studioId) ?? [];
431
+ if (queue.length > 0) {
432
+ const cmd = queue.shift();
433
+ if (queue.length === 0) {
434
+ this.httpPendingCommands.delete(studioId);
435
+ }
436
+ res.writeHead(200, { "Content-Type": "application/json" });
437
+ res.end(JSON.stringify(cmd));
438
+ return;
439
+ }
440
+ if (studioId !== "_default") {
441
+ const defaultQueue = this.httpPendingCommands.get("_default") ?? [];
442
+ if (defaultQueue.length > 0) {
443
+ const cmd = defaultQueue.shift();
444
+ if (defaultQueue.length === 0) {
445
+ this.httpPendingCommands.delete("_default");
446
+ }
447
+ res.writeHead(200, { "Content-Type": "application/json" });
448
+ res.end(JSON.stringify(cmd));
449
+ return;
450
+ }
451
+ }
452
+ const timer = setTimeout(() => {
453
+ const waiters2 = this.httpPollWaiters.get(studioId);
454
+ if (waiters2) {
455
+ const idx = waiters2.findIndex((w) => w.res === res);
456
+ if (idx !== -1) waiters2.splice(idx, 1);
457
+ if (waiters2.length === 0) this.httpPollWaiters.delete(studioId);
458
+ }
459
+ res.writeHead(204);
460
+ res.end();
461
+ }, LONG_POLL_TIMEOUT_MS);
462
+ const waiters = this.httpPollWaiters.get(studioId) ?? [];
463
+ waiters.push({ res, timer });
464
+ this.httpPollWaiters.set(studioId, waiters);
465
+ }
466
+ handleResult(req, res) {
467
+ let body = "";
468
+ req.on("data", (chunk) => body += chunk);
469
+ req.on("end", () => {
470
+ try {
471
+ const msg = JSON.parse(body);
472
+ this.handlePluginMessage(msg);
473
+ res.writeHead(200);
474
+ res.end("ok");
475
+ } catch {
476
+ res.writeHead(400);
477
+ res.end("Invalid JSON");
478
+ }
479
+ });
480
+ }
481
+ flushHttpPollers(studioId) {
482
+ const waiters = this.httpPollWaiters.get(studioId) ?? [];
483
+ const queue = this.httpPendingCommands.get(studioId) ?? [];
484
+ while (waiters.length > 0 && queue.length > 0) {
485
+ const waiter = waiters.shift();
486
+ const cmd = queue.shift();
487
+ clearTimeout(waiter.timer);
488
+ waiter.res.writeHead(200, { "Content-Type": "application/json" });
489
+ waiter.res.end(JSON.stringify(cmd));
490
+ }
491
+ if (waiters.length === 0) this.httpPollWaiters.delete(studioId);
492
+ if (queue.length === 0) this.httpPendingCommands.delete(studioId);
493
+ }
494
+ };
495
+
496
+ // src/tools/explore.ts
497
+ import { z } from "zod";
498
+
499
+ // src/utils/tokens.ts
500
+ var CHARS_PER_TOKEN = 4;
501
+ function estimateTokens(text) {
502
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
503
+ }
504
+ function truncateToTokenBudget(text, budget) {
505
+ const maxChars = budget * CHARS_PER_TOKEN;
506
+ if (text.length <= maxChars) {
507
+ return { text, truncated: false };
508
+ }
509
+ const totalEstimate = estimateTokens(text);
510
+ const truncated = text.slice(0, maxChars);
511
+ const lastNewline = truncated.lastIndexOf("\n");
512
+ const cleanCut = lastNewline > maxChars * 0.8 ? truncated.slice(0, lastNewline) : truncated;
513
+ return {
514
+ text: cleanCut + `
515
+
516
+ ---
517
+ *Truncated \u2014 showing ~${budget} of ~${totalEstimate} tokens. Narrow your query with filter, depth, or lineRange.*`,
518
+ truncated: true
519
+ };
520
+ }
521
+
522
+ // src/utils/formatting.ts
523
+ var DEFAULT_TOKEN_BUDGET = 4e3;
524
+ function formatTree(data, depth = 0, indent = "") {
525
+ let line = `${indent}- **${data.name}** \`${data.className}\``;
526
+ if (data.properties && Object.keys(data.properties).length > 0) {
527
+ const props = Object.entries(data.properties).map(([k, v]) => `${k}=${formatValue(v)}`).join(", ");
528
+ line += ` (${props})`;
529
+ }
530
+ let result = line + "\n";
531
+ if (data.children && depth > 0) {
532
+ for (const child of data.children) {
533
+ result += formatTree(child, depth - 1, indent + " ");
534
+ }
535
+ } else {
536
+ const count = data.children?.length ?? data.childCount ?? 0;
537
+ if (count > 0) {
538
+ result += `${indent} *\u2026 ${count} children*
539
+ `;
540
+ }
541
+ }
542
+ return result;
543
+ }
544
+ function formatInstanceList(instances) {
545
+ if (instances.length === 0) return "*No instances found.*";
546
+ return instances.map((i) => `- **${i.path}** \`${i.className}\``).join("\n");
547
+ }
548
+ function formatScript(source, path) {
549
+ return `### ${path}
550
+ \`\`\`lua
551
+ ${source}
552
+ \`\`\``;
553
+ }
554
+ function applyTokenBudget(text, maxTokens) {
555
+ const budget = maxTokens ?? DEFAULT_TOKEN_BUDGET;
556
+ const { text: result } = truncateToTokenBudget(text, budget);
557
+ return result;
558
+ }
559
+ function formatValue(v) {
560
+ if (v === null || v === void 0) return "nil";
561
+ if (typeof v === "string") return `"${v}"`;
562
+ if (typeof v === "object") {
563
+ const obj = v;
564
+ if (obj.Type) return `${obj.Type}(\u2026)`;
565
+ return JSON.stringify(v);
566
+ }
567
+ return String(v);
568
+ }
569
+
570
+ // src/tools/explore.ts
571
+ function register(server, bridge) {
572
+ server.registerTool(
573
+ "explore",
574
+ {
575
+ title: "Explore Instance Tree",
576
+ description: "Browse the Roblox instance hierarchy, get/set the current selection, or list top-level services.\n\nActions:\n- `tree` (default): Browse DataModel tree from a root path with depth, filter, and property options.\n- `get_selection`: Return the currently selected instances in Studio.\n- `set_selection`: Select specific instances in Studio by path.\n- `services`: List all top-level game services.\n- `state`: Get current Studio state (playtest status, place info, undo availability).",
577
+ inputSchema: z.object({
578
+ action: z.enum(["tree", "get_selection", "set_selection", "services", "state"]).default("tree").describe("Action to perform"),
579
+ path: z.string().default("game").describe("Root path to explore (for 'tree' action)"),
580
+ depth: z.number().int().min(0).max(10).default(2).describe("How many levels deep to recurse (for 'tree' action)"),
581
+ filter: z.string().optional().describe("Only include instances whose ClassName matches (for 'tree' action)"),
582
+ includeProperties: z.boolean().default(false).describe("Include property values in output (for 'tree' action)"),
583
+ paths: z.array(z.string()).optional().describe("Instance paths to select (for 'set_selection' action)"),
584
+ maxTokens: z.number().optional().describe("Maximum token budget for the response")
585
+ }),
586
+ annotations: {
587
+ readOnlyHint: false,
588
+ destructiveHint: false,
589
+ idempotentHint: true,
590
+ openWorldHint: false
591
+ }
592
+ },
593
+ async (params) => {
594
+ if (params.action === "get_selection") {
595
+ const result2 = await bridge.send("get_selection", {});
596
+ const text2 = formatInstanceList(result2.selection);
597
+ return {
598
+ content: [
599
+ { type: "text", text: applyTokenBudget(text2, params.maxTokens) }
600
+ ]
601
+ };
602
+ }
603
+ if (params.action === "set_selection") {
604
+ if (!params.paths || params.paths.length === 0) {
605
+ return {
606
+ content: [
607
+ { type: "text", text: "set_selection requires a non-empty `paths` array." }
608
+ ]
609
+ };
610
+ }
611
+ const result2 = await bridge.send("set_selection", {
612
+ paths: params.paths
613
+ });
614
+ return {
615
+ content: [
616
+ { type: "text", text: `Selected ${result2.selected} instance(s).` }
617
+ ]
618
+ };
619
+ }
620
+ if (params.action === "services") {
621
+ const result2 = await bridge.send("get_services", {});
622
+ const text2 = result2.services.length === 0 ? "*No services found.*" : result2.services.map((s) => `- **${s.name}** \`${s.className}\``).join("\n");
623
+ return {
624
+ content: [
625
+ { type: "text", text: applyTokenBudget(text2, params.maxTokens) }
626
+ ]
627
+ };
628
+ }
629
+ if (params.action === "state") {
630
+ const result2 = await bridge.send("get_studio_state", {});
631
+ let mode = "Edit";
632
+ if (result2.isRunning) {
633
+ if (result2.isClient) mode = "Playtest (Client)";
634
+ else if (result2.isServer) mode = "Playtest (Server)";
635
+ else mode = "Running";
636
+ }
637
+ const lines = [
638
+ "### Studio State",
639
+ `- **Mode:** ${mode}`,
640
+ `- **Place ID:** ${result2.placeId}${result2.placeName ? ` \u2014 ${result2.placeName}` : ""}`,
641
+ `- **Game ID:** ${result2.gameId}`
642
+ ];
643
+ if (result2.canUndo !== void 0) {
644
+ lines.push(`- **Can Undo:** ${result2.canUndo ? "Yes" : "No"}`);
645
+ }
646
+ if (result2.canRedo !== void 0) {
647
+ lines.push(`- **Can Redo:** ${result2.canRedo ? "Yes" : "No"}`);
648
+ }
649
+ return {
650
+ content: [{ type: "text", text: lines.join("\n") }]
651
+ };
652
+ }
653
+ const result = await bridge.send("explore", {
654
+ path: params.path,
655
+ depth: params.depth,
656
+ filter: params.filter,
657
+ includeProperties: params.includeProperties
658
+ });
659
+ const text = formatTree(result, params.depth);
660
+ return {
661
+ content: [
662
+ { type: "text", text: applyTokenBudget(text, params.maxTokens) }
663
+ ]
664
+ };
665
+ }
666
+ );
667
+ server.registerTool(
668
+ "get_info",
669
+ {
670
+ title: "Get Instance Info",
671
+ description: "Get detailed information about a single instance: class, parent, children count, properties, attributes, tags, and optionally a typed property list \u2014 all in one call.",
672
+ inputSchema: z.object({
673
+ path: z.string().describe("Path to the instance, e.g. 'game.Workspace.Part'"),
674
+ includeProperties: z.boolean().default(true).describe("Include property values"),
675
+ includeAttributes: z.boolean().default(true).describe("Include custom attributes"),
676
+ includeTags: z.boolean().default(true).describe("Include CollectionService tags"),
677
+ includePropertyList: z.boolean().default(false).describe("Include a typed list of all discoverable properties with types and values"),
678
+ propertyFilter: z.string().optional().describe("Filter property names by substring match (for property list)"),
679
+ maxTokens: z.number().optional().describe("Maximum token budget for the response")
680
+ }),
681
+ annotations: {
682
+ readOnlyHint: true,
683
+ destructiveHint: false,
684
+ idempotentHint: true,
685
+ openWorldHint: false
686
+ }
687
+ },
688
+ async (params) => {
689
+ const result = await bridge.send("get_info", {
690
+ path: params.path,
691
+ includeProperties: params.includeProperties,
692
+ includeAttributes: params.includeAttributes,
693
+ includeTags: params.includeTags,
694
+ includePropertyList: params.includePropertyList,
695
+ propertyFilter: params.propertyFilter
696
+ });
697
+ const lines = [
698
+ `### ${result.name} \`${result.className}\``,
699
+ `- **Path:** \`${result.path}\``,
700
+ `- **Parent:** ${result.parent ? `\`${result.parent}\`` : "none"}`,
701
+ `- **Children:** ${result.childCount}`
702
+ ];
703
+ if (result.tags && result.tags.length > 0) {
704
+ lines.push(`- **Tags:** ${result.tags.join(", ")}`);
705
+ }
706
+ if (result.attributes && Object.keys(result.attributes).length > 0) {
707
+ lines.push("", "**Attributes:**");
708
+ for (const [k, v] of Object.entries(result.attributes)) {
709
+ lines.push(`- ${k} = \`${JSON.stringify(v)}\``);
710
+ }
711
+ }
712
+ if (result.properties && Object.keys(result.properties).length > 0) {
713
+ lines.push("", "**Properties:**");
714
+ for (const [k, v] of Object.entries(result.properties)) {
715
+ lines.push(`- ${k} = \`${JSON.stringify(v)}\``);
716
+ }
717
+ }
718
+ if (result.propertyList && result.propertyList.length > 0) {
719
+ lines.push("", "**Property List:**");
720
+ for (const p of result.propertyList) {
721
+ lines.push(`- **${p.name}** \`${p.type}\` = \`${JSON.stringify(p.value)}\``);
722
+ }
723
+ }
724
+ const text = lines.join("\n");
725
+ return {
726
+ content: [
727
+ { type: "text", text: applyTokenBudget(text, params.maxTokens) }
728
+ ]
729
+ };
730
+ }
731
+ );
732
+ }
733
+
734
+ // src/tools/instances.ts
735
+ import { z as z2 } from "zod";
736
+ function registerReadOnly(server, bridge) {
737
+ registerQuery(server, bridge);
738
+ }
739
+ function register2(server, bridge) {
740
+ registerQuery(server, bridge);
741
+ registerWrite(server, bridge);
742
+ }
743
+ function registerQuery(server, bridge) {
744
+ server.registerTool(
745
+ "query",
746
+ {
747
+ title: "Query Instances & Scripts",
748
+ description: "Find instances or search script source code.\n\nActions:\n- `instances` (default): Find instances by class, tag, attribute, or name pattern.\n- `scripts`: Search across all script sources for a text pattern (grep).",
749
+ inputSchema: z2.object({
750
+ action: z2.enum(["instances", "scripts"]).default("instances").describe("Query mode"),
751
+ // Instance query params
752
+ basePath: z2.string().default("game").describe("Root path to search from"),
753
+ filters: z2.object({
754
+ className: z2.string().optional().describe("Only include instances of this class"),
755
+ tag: z2.string().optional().describe("Only include instances with this tag"),
756
+ attribute: z2.object({
757
+ name: z2.string().describe("Attribute name"),
758
+ value: z2.unknown().optional().describe("Required attribute value")
759
+ }).optional().describe("Filter by attribute"),
760
+ namePattern: z2.string().optional().describe("Lua pattern to match against instance Name")
761
+ }).optional().describe("Filters for instance query (AND-combined)"),
762
+ limit: z2.number().int().default(50).describe("Max results"),
763
+ // Script grep params
764
+ pattern: z2.string().optional().describe("Text pattern to search for (for 'scripts' action)"),
765
+ caseSensitive: z2.boolean().default(false).describe("Case-sensitive search (for 'scripts' action)"),
766
+ contextLines: z2.number().int().default(1).describe("Lines of context around each match (for 'scripts' action)"),
767
+ maxTokens: z2.number().optional().describe("Maximum token budget for the response")
768
+ }),
769
+ annotations: {
770
+ readOnlyHint: true,
771
+ destructiveHint: false,
772
+ idempotentHint: true,
773
+ openWorldHint: false
774
+ }
775
+ },
776
+ async (params) => {
777
+ if (params.action === "scripts") {
778
+ if (!params.pattern) {
779
+ return {
780
+ content: [{ type: "text", text: "scripts action requires a `pattern` parameter." }]
781
+ };
782
+ }
783
+ const result2 = await bridge.send("grep_scripts", {
784
+ basePath: params.basePath,
785
+ pattern: params.pattern,
786
+ caseSensitive: params.caseSensitive,
787
+ contextLines: params.contextLines,
788
+ limit: params.limit
789
+ });
790
+ if (result2.results.length === 0) {
791
+ return {
792
+ content: [
793
+ {
794
+ type: "text",
795
+ text: `*No matches found for "${params.pattern}" across ${result2.scriptsSearched} scripts.*`
796
+ }
797
+ ]
798
+ };
799
+ }
800
+ const lines = result2.results.map(
801
+ (r) => `- \`${r.scriptPath}:${r.lineNumber}\`: ${r.line.trim()}`
802
+ );
803
+ let text2 = lines.join("\n");
804
+ if (result2.totalMatches > result2.results.length) {
805
+ text2 += `
806
+
807
+ *Showing ${result2.results.length} of ${result2.totalMatches} matches across ${result2.scriptsSearched} scripts.*`;
808
+ } else {
809
+ text2 += `
810
+
811
+ *${result2.totalMatches} match(es) across ${result2.scriptsSearched} scripts.*`;
812
+ }
813
+ return {
814
+ content: [
815
+ { type: "text", text: applyTokenBudget(text2, params.maxTokens) }
816
+ ]
817
+ };
818
+ }
819
+ const result = await bridge.send("query_instances", {
820
+ basePath: params.basePath,
821
+ filters: params.filters ?? {},
822
+ limit: params.limit
823
+ });
824
+ let text;
825
+ if (result.results.length === 0) {
826
+ text = "*No instances matched the query.*";
827
+ } else {
828
+ const lines = result.results.map(
829
+ (r) => `- \`${r.path}\` (${r.className})`
830
+ );
831
+ text = lines.join("\n");
832
+ if (result.total > result.results.length) {
833
+ text += `
834
+
835
+ *Showing ${result.results.length} of ${result.total} matches \u2014 increase limit or narrow filters.*`;
836
+ }
837
+ }
838
+ return {
839
+ content: [
840
+ { type: "text", text: applyTokenBudget(text, params.maxTokens) }
841
+ ]
842
+ };
843
+ }
844
+ );
845
+ }
846
+ function registerWrite(server, bridge) {
847
+ server.registerTool(
848
+ "create",
849
+ {
850
+ title: "Create or Clone Instances",
851
+ description: "Create new instances or clone existing ones.\n\nActions:\n- `new` (default): Create instances with className, parent, name, and properties.\n- `clone`: Clone existing instances to a target parent.",
852
+ inputSchema: z2.object({
853
+ action: z2.enum(["new", "clone"]).default("new").describe("Create mode"),
854
+ // For 'new' action
855
+ operations: z2.array(
856
+ z2.object({
857
+ className: z2.string().describe("Roblox class, e.g. 'Part'"),
858
+ parent: z2.string().describe("Parent path, e.g. 'game.Workspace'"),
859
+ name: z2.string().describe("Instance name"),
860
+ properties: z2.record(z2.unknown()).optional().describe("Initial properties")
861
+ })
862
+ ).optional().describe("Instances to create (for 'new' action)"),
863
+ // For 'clone' action
864
+ sources: z2.array(z2.string()).optional().describe("Paths of instances to clone (for 'clone' action)"),
865
+ targetParent: z2.string().optional().describe("Parent to place clones under (for 'clone' action)")
866
+ }),
867
+ annotations: {
868
+ readOnlyHint: false,
869
+ destructiveHint: false,
870
+ idempotentHint: false,
871
+ openWorldHint: false
872
+ }
873
+ },
874
+ async (params) => {
875
+ if (params.action === "clone") {
876
+ if (!params.sources || params.sources.length === 0) {
877
+ return { content: [{ type: "text", text: "clone action requires a non-empty `sources` array." }] };
878
+ }
879
+ if (!params.targetParent) {
880
+ return { content: [{ type: "text", text: "clone action requires a `targetParent`." }] };
881
+ }
882
+ const result2 = await bridge.send("clone_instances", {
883
+ sources: params.sources,
884
+ targetParent: params.targetParent
885
+ });
886
+ const text2 = result2.cloned.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
887
+ return { content: [{ type: "text", text: text2 }] };
888
+ }
889
+ if (!params.operations || params.operations.length === 0) {
890
+ return { content: [{ type: "text", text: "new action requires a non-empty `operations` array." }] };
891
+ }
892
+ const result = await bridge.send("create_instances", {
893
+ operations: params.operations
894
+ });
895
+ const text = result.created.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
896
+ return { content: [{ type: "text", text }] };
897
+ }
898
+ );
899
+ server.registerTool(
900
+ "modify",
901
+ {
902
+ title: "Modify Instances",
903
+ description: "Modify existing instances.\n\nModes:\n- `targeted` (default): Per-instance modifications with properties, attributes, tags, name, parent.\n- `bulk`: Set one property to the same value across many instances.",
904
+ inputSchema: z2.object({
905
+ mode: z2.enum(["targeted", "bulk"]).default("targeted").describe("Modification mode"),
906
+ // For 'targeted' mode
907
+ operations: z2.array(
908
+ z2.object({
909
+ path: z2.string().describe("Path to instance"),
910
+ properties: z2.record(z2.unknown()).optional().describe("Properties to set"),
911
+ attributes: z2.record(z2.unknown()).optional().describe("Attributes to set"),
912
+ tags: z2.object({
913
+ add: z2.array(z2.string()).optional().describe("Tags to add"),
914
+ remove: z2.array(z2.string()).optional().describe("Tags to remove")
915
+ }).optional().describe("Tag modifications"),
916
+ name: z2.string().optional().describe("New name"),
917
+ parent: z2.string().optional().describe("New parent path")
918
+ })
919
+ ).optional().describe("Per-instance modifications (for 'targeted' mode)"),
920
+ // For 'bulk' mode
921
+ paths: z2.array(z2.string()).optional().describe("Instance paths (for 'bulk' mode)"),
922
+ property: z2.string().optional().describe("Property name (for 'bulk' mode)"),
923
+ value: z2.unknown().optional().describe("Property value (for 'bulk' mode)"),
924
+ strict: z2.boolean().default(false).describe("If true, any single failure rolls back the entire batch (for 'bulk' mode)")
925
+ }),
926
+ annotations: {
927
+ readOnlyHint: false,
928
+ destructiveHint: false,
929
+ idempotentHint: true,
930
+ openWorldHint: false
931
+ }
932
+ },
933
+ async (params) => {
934
+ if (params.mode === "bulk") {
935
+ if (!params.paths || params.paths.length === 0) {
936
+ return { content: [{ type: "text", text: "bulk mode requires a non-empty `paths` array." }] };
937
+ }
938
+ if (!params.property) {
939
+ return { content: [{ type: "text", text: "bulk mode requires a `property` name." }] };
940
+ }
941
+ const result2 = await bridge.send("batch_modify", {
942
+ paths: params.paths,
943
+ property: params.property,
944
+ value: params.value,
945
+ strict: params.strict
946
+ });
947
+ let text2 = `Modified **${params.property}** on ${result2.modified} instance(s).`;
948
+ if (result2.failed.length > 0) {
949
+ text2 += "\n\nFailed:\n" + result2.failed.map((f) => `- \`${f.path}\`: ${f.error}`).join("\n");
950
+ }
951
+ return { content: [{ type: "text", text: text2 }] };
952
+ }
953
+ if (!params.operations || params.operations.length === 0) {
954
+ return { content: [{ type: "text", text: "targeted mode requires a non-empty `operations` array." }] };
955
+ }
956
+ const result = await bridge.send("modify_instances", {
957
+ operations: params.operations
958
+ });
959
+ const text = result.modified.map((r) => `- \`${r.path}\` \u2014 modified: ${r.modified.join(", ")}`).join("\n");
960
+ return { content: [{ type: "text", text }] };
961
+ }
962
+ );
963
+ server.registerTool(
964
+ "delete",
965
+ {
966
+ title: "Delete Instances",
967
+ description: "Permanently delete one or more instances by path. Destructive \u2014 use undo_redo to revert.",
968
+ inputSchema: z2.object({
969
+ paths: z2.array(z2.string()).describe("Paths of instances to delete")
970
+ }),
971
+ annotations: {
972
+ readOnlyHint: false,
973
+ destructiveHint: true,
974
+ idempotentHint: true,
975
+ openWorldHint: false
976
+ }
977
+ },
978
+ async (params) => {
979
+ const result = await bridge.send("delete_instances", {
980
+ paths: params.paths
981
+ });
982
+ const text = result.deleted.map((p) => `- \`${p}\` \u2014 deleted`).join("\n");
983
+ return { content: [{ type: "text", text }] };
984
+ }
985
+ );
986
+ }
987
+
988
+ // src/tools/scripts.ts
989
+ import { z as z3 } from "zod";
990
+ function registerReadOnly2(server, bridge) {
991
+ registerReadScript(server, bridge);
992
+ }
993
+ function register3(server, bridge) {
994
+ registerReadScript(server, bridge);
995
+ registerWriteTools(server, bridge);
996
+ }
997
+ function registerReadScript(server, bridge) {
998
+ server.registerTool(
999
+ "read_script",
1000
+ {
1001
+ title: "Read Script Source",
1002
+ description: "Read the Lua source code of a script instance. Optionally restrict to a line range.",
1003
+ inputSchema: z3.object({
1004
+ path: z3.string().describe("Path to the script instance"),
1005
+ lineRange: z3.object({
1006
+ start: z3.number().int().min(1).describe("Start line (1-based)"),
1007
+ end: z3.number().int().min(1).describe("End line (1-based, inclusive)")
1008
+ }).optional().describe("Optional line range to read"),
1009
+ maxTokens: z3.number().optional().describe("Maximum token budget for the response")
1010
+ }),
1011
+ annotations: {
1012
+ readOnlyHint: true,
1013
+ destructiveHint: false,
1014
+ idempotentHint: true,
1015
+ openWorldHint: false
1016
+ }
1017
+ },
1018
+ async (params) => {
1019
+ const result = await bridge.send("read_script", {
1020
+ path: params.path,
1021
+ lineRange: params.lineRange
1022
+ });
1023
+ const text = formatScript(result.source, params.path);
1024
+ return {
1025
+ content: [
1026
+ { type: "text", text: applyTokenBudget(text, params.maxTokens) }
1027
+ ]
1028
+ };
1029
+ }
1030
+ );
1031
+ }
1032
+ function registerWriteTools(server, bridge) {
1033
+ server.registerTool(
1034
+ "edit_script",
1035
+ {
1036
+ title: "Edit Script Source",
1037
+ description: "Edit the Lua source code of a script. Supports four modes: 'full' replaces the entire source, 'range' replaces specific line/column ranges, 'find_replace' does text find-and-replace on one script, and 'multi_replace' does find-and-replace across multiple scripts in one undoable operation.",
1038
+ inputSchema: z3.object({
1039
+ path: z3.string().describe("Path to the script instance"),
1040
+ mode: z3.enum(["full", "range", "find_replace", "multi_replace"]).describe("Edit mode: full, range, find_replace, or multi_replace"),
1041
+ source: z3.string().optional().describe("Complete new source (for 'full' mode)"),
1042
+ edits: z3.array(
1043
+ z3.object({
1044
+ startLine: z3.number().int().describe("Start line (1-based)"),
1045
+ startColumn: z3.number().int().describe("Start column (1-based)"),
1046
+ endLine: z3.number().int().describe("End line (1-based)"),
1047
+ endColumn: z3.number().int().describe("End column (1-based)"),
1048
+ text: z3.string().describe("Replacement text")
1049
+ })
1050
+ ).optional().describe("Range edits (for 'range' mode)"),
1051
+ find: z3.string().optional().describe("Text or pattern to find (for 'find_replace' mode)"),
1052
+ replace: z3.string().optional().describe("Replacement text (for 'find_replace' mode)"),
1053
+ regex: z3.boolean().optional().describe("Treat 'find' as a Lua pattern (for 'find_replace' and 'multi_replace' modes)"),
1054
+ scripts: z3.array(z3.string()).optional().describe("Array of script paths to apply find/replace across (for 'multi_replace' mode)")
1055
+ }),
1056
+ annotations: {
1057
+ readOnlyHint: false,
1058
+ destructiveHint: false,
1059
+ idempotentHint: false,
1060
+ openWorldHint: false
1061
+ }
1062
+ },
1063
+ async (params) => {
1064
+ if (params.mode === "multi_replace") {
1065
+ if (!params.scripts || params.scripts.length === 0) {
1066
+ return { content: [{ type: "text", text: "multi_replace mode requires a non-empty `scripts` array." }] };
1067
+ }
1068
+ if (!params.find) {
1069
+ return { content: [{ type: "text", text: "multi_replace mode requires a `find` parameter." }] };
1070
+ }
1071
+ const result2 = await bridge.send("multi_replace_scripts", {
1072
+ scripts: params.scripts,
1073
+ find: params.find,
1074
+ replace: params.replace ?? "",
1075
+ regex: params.regex
1076
+ });
1077
+ const lines = [
1078
+ `**Multi-script find/replace** \u2014 ${result2.totalReplacements} replacements across ${result2.scriptsModified} script(s)`,
1079
+ ""
1080
+ ];
1081
+ for (const r of result2.results) {
1082
+ lines.push(`- \`${r.path}\`: ${r.replacements} replacement(s)`);
1083
+ }
1084
+ if (result2.errors && result2.errors.length > 0) {
1085
+ lines.push("", "**Errors:**");
1086
+ for (const e of result2.errors) {
1087
+ lines.push(`- \`${e.path}\`: ${e.error}`);
1088
+ }
1089
+ }
1090
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1091
+ }
1092
+ const result = await bridge.send("edit_script", {
1093
+ path: params.path,
1094
+ mode: params.mode,
1095
+ source: params.source,
1096
+ edits: params.edits,
1097
+ find: params.find,
1098
+ replace: params.replace,
1099
+ regex: params.regex
1100
+ });
1101
+ let detail = `mode=${result.mode}, ${result.totalLines} total lines`;
1102
+ if (result.appliedEdits !== void 0) {
1103
+ detail += `, ${result.appliedEdits} edits applied`;
1104
+ }
1105
+ if (result.replacements !== void 0) {
1106
+ detail += `, ${result.replacements} replacements`;
1107
+ }
1108
+ const text = `Script **${params.path}** updated (${detail}).`;
1109
+ return { content: [{ type: "text", text }] };
1110
+ }
1111
+ );
1112
+ server.registerTool(
1113
+ "execute_lua",
1114
+ {
1115
+ title: "Execute Luau Code",
1116
+ description: "Run arbitrary Luau code in the Studio plugin context (edit mode \u2014 no active playtest required). Use this as an escape hatch for any operation the other tools don't cover. Has access to all Studio APIs and services.",
1117
+ inputSchema: z3.object({
1118
+ code: z3.string().describe("Luau code to execute"),
1119
+ maxTokens: z3.number().optional().describe("Maximum token budget for the response")
1120
+ }),
1121
+ annotations: {
1122
+ readOnlyHint: false,
1123
+ destructiveHint: true,
1124
+ idempotentHint: false,
1125
+ openWorldHint: true
1126
+ }
1127
+ },
1128
+ async (params) => {
1129
+ const result = await bridge.send("execute_lua", {
1130
+ code: params.code
1131
+ });
1132
+ const lines = [];
1133
+ if (result.error) {
1134
+ lines.push(`**Error:** ${result.error}`);
1135
+ }
1136
+ if (result.result) {
1137
+ lines.push(`**Return value:** ${result.result}`);
1138
+ }
1139
+ if (result.output && result.output.length > 0) {
1140
+ const outputText = result.output.map((o) => `[${o.messageType}] ${o.message}`).join("\n");
1141
+ lines.push(`**Output:**
1142
+ \`\`\`
1143
+ ${outputText}
1144
+ \`\`\``);
1145
+ }
1146
+ const text = lines.length > 0 ? lines.join("\n\n") : `Execution completed (${result.status})`;
1147
+ return {
1148
+ content: [
1149
+ { type: "text", text: applyTokenBudget(text, params.maxTokens) }
1150
+ ]
1151
+ };
1152
+ }
1153
+ );
1154
+ }
1155
+
1156
+ // src/tools/playtest.ts
1157
+ import { z as z4 } from "zod";
1158
+ function register4(server, bridge) {
1159
+ server.registerTool(
1160
+ "playtest",
1161
+ {
1162
+ title: "Playtest Control & Virtual Input",
1163
+ description: "Control Roblox Studio playtesting and simulate user input.\n\nActions:\n- `start`: Begin a playtest session.\n- `stop`: End the current playtest.\n- `execute`: Run Lua code in the running game context.\n- `get_output`: Get console/log output from Studio (works in edit mode and during playtest).\n- `inspect`: Evaluate a Luau expression and return the typed result (requires active playtest).\n- `navigate`: Walk the player character to a position using PathfindingService (requires client playtest).\n- `mouse_click`: Simulate a mouse click at screen coordinates.\n- `mouse_move`: Move the virtual mouse to screen coordinates.\n- `key_press`: Press and release a key.\n- `key_down`: Hold a key down.\n- `key_up`: Release a held key.",
1164
+ inputSchema: z4.object({
1165
+ action: z4.enum(["start", "stop", "execute", "get_output", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up"]).describe("Playtest action"),
1166
+ code: z4.string().optional().describe("Lua code to execute (for 'execute' action)"),
1167
+ // get_output params
1168
+ messageTypes: z4.array(z4.string()).optional().describe("Filter by message types: 'MessageOutput', 'MessageWarning', 'MessageError', 'MessageInfo' (for 'get_output')"),
1169
+ since: z4.number().optional().describe("Only return logs with timestamp >= this value (for 'get_output')"),
1170
+ limit: z4.number().optional().describe("Maximum number of log entries to return (for 'get_output')"),
1171
+ // inspect params
1172
+ expression: z4.string().optional().describe("Luau expression to evaluate, e.g. 'game.Players.Player1.Character.Humanoid.Health' (for 'inspect')"),
1173
+ // navigate params
1174
+ target: z4.object({
1175
+ x: z4.number(),
1176
+ y: z4.number(),
1177
+ z: z4.number()
1178
+ }).optional().describe("Target position to navigate to (for 'navigate')"),
1179
+ targetPath: z4.string().optional().describe("Instance path to navigate to \u2014 uses its Position (for 'navigate')"),
1180
+ timeout: z4.number().optional().describe("Navigation timeout in seconds, default 15 (for 'navigate')"),
1181
+ // Virtual input params
1182
+ x: z4.number().optional().describe("Screen X coordinate (for mouse actions)"),
1183
+ y: z4.number().optional().describe("Screen Y coordinate (for mouse actions)"),
1184
+ button: z4.enum(["Left", "Right", "Middle"]).default("Left").describe("Mouse button (for 'mouse_click')"),
1185
+ key: z4.string().optional().describe("Key name matching Enum.KeyCode, e.g. 'W', 'Space', 'Return' (for key actions)")
1186
+ }),
1187
+ annotations: {
1188
+ readOnlyHint: false,
1189
+ destructiveHint: false,
1190
+ idempotentHint: false,
1191
+ openWorldHint: true
1192
+ }
1193
+ },
1194
+ async (params) => {
1195
+ if (params.action === "get_output") {
1196
+ const result2 = await bridge.send("get_log_output", {
1197
+ messageTypes: params.messageTypes,
1198
+ since: params.since,
1199
+ limit: params.limit
1200
+ });
1201
+ if (result2.logs.length === 0) {
1202
+ return { content: [{ type: "text", text: "No log output found." }] };
1203
+ }
1204
+ const header = `**Console Output** (${result2.logs.length}${result2.logs.length < result2.total ? ` of ${result2.total}` : ""} entries)`;
1205
+ const logText = result2.logs.map((l) => `[${l.messageType}] ${l.message}`).join("\n");
1206
+ const text2 = `${header}
1207
+
1208
+ \`\`\`
1209
+ ${logText}
1210
+ \`\`\``;
1211
+ return {
1212
+ content: [{ type: "text", text: applyTokenBudget(text2, void 0) }]
1213
+ };
1214
+ }
1215
+ if (params.action === "inspect") {
1216
+ if (!params.expression) {
1217
+ return {
1218
+ content: [{ type: "text", text: "inspect action requires an `expression` parameter." }]
1219
+ };
1220
+ }
1221
+ const result2 = await bridge.send("playtest_inspect", {
1222
+ expression: params.expression
1223
+ });
1224
+ const valueStr = typeof result2.value === "object" ? JSON.stringify(result2.value, null, 2) : String(result2.value);
1225
+ const text2 = `**Expression:** \`${result2.expression}\`
1226
+ **Type:** \`${result2.type}\`
1227
+ **Value:** ${valueStr}`;
1228
+ return { content: [{ type: "text", text: text2 }] };
1229
+ }
1230
+ if (params.action === "navigate") {
1231
+ if (!params.target && !params.targetPath) {
1232
+ return {
1233
+ content: [{ type: "text", text: "navigate action requires either `target` or `targetPath`." }]
1234
+ };
1235
+ }
1236
+ const result2 = await bridge.send("playtest_navigate", {
1237
+ target: params.target,
1238
+ targetPath: params.targetPath,
1239
+ timeout: params.timeout
1240
+ });
1241
+ const posStr = result2.position ? `(${result2.position.x.toFixed(1)}, ${result2.position.y.toFixed(1)}, ${result2.position.z.toFixed(1)})` : "unknown";
1242
+ const text2 = `**Navigation:** ${result2.status}
1243
+ **Final position:** ${posStr}${result2.message ? `
1244
+ ${result2.message}` : ""}`;
1245
+ return { content: [{ type: "text", text: text2 }] };
1246
+ }
1247
+ if (params.action === "mouse_click" || params.action === "mouse_move" || params.action === "key_press" || params.action === "key_down" || params.action === "key_up") {
1248
+ const result2 = await bridge.send("virtual_input", {
1249
+ action: params.action,
1250
+ x: params.x,
1251
+ y: params.y,
1252
+ button: params.button,
1253
+ key: params.key
1254
+ });
1255
+ return {
1256
+ content: [
1257
+ { type: "text", text: result2.message ?? `Virtual input ${params.action}: ${result2.status}` }
1258
+ ]
1259
+ };
1260
+ }
1261
+ const result = await bridge.send("playtest", {
1262
+ action: params.action,
1263
+ code: params.code
1264
+ });
1265
+ let text;
1266
+ if (params.action === "execute") {
1267
+ const lines = [];
1268
+ if (result.error) {
1269
+ lines.push(`**Error:** ${result.error}`);
1270
+ }
1271
+ if (result.result) {
1272
+ lines.push(`**Return value:** ${result.result}`);
1273
+ }
1274
+ if (result.output && result.output.length > 0) {
1275
+ const outputText = result.output.map((o) => `[${o.messageType}] ${o.message}`).join("\n");
1276
+ lines.push(`**Output:**
1277
+ \`\`\`
1278
+ ${outputText}
1279
+ \`\`\``);
1280
+ }
1281
+ text = lines.length > 0 ? lines.join("\n\n") : `Execution completed (${result.status})`;
1282
+ text = applyTokenBudget(text, void 0);
1283
+ } else {
1284
+ text = `Playtest ${params.action}: ${result.status}`;
1285
+ }
1286
+ return { content: [{ type: "text", text }] };
1287
+ }
1288
+ );
1289
+ }
1290
+
1291
+ // src/tools/environment.ts
1292
+ import { z as z5 } from "zod";
1293
+ var Vector3Schema = z5.object({
1294
+ X: z5.number().describe("X coordinate"),
1295
+ Y: z5.number().describe("Y coordinate"),
1296
+ Z: z5.number().describe("Z coordinate")
1297
+ });
1298
+ var RegionSchema = z5.object({
1299
+ min: Vector3Schema.describe("Minimum corner of the region"),
1300
+ max: Vector3Schema.describe("Maximum corner of the region")
1301
+ });
1302
+ function register5(server, bridge) {
1303
+ server.registerTool(
1304
+ "environment",
1305
+ {
1306
+ title: "Environment & Terrain",
1307
+ description: "Manage terrain and workspace/lighting settings.\n\nActions:\n- `terrain_fill`: Fill a region with a material.\n- `terrain_clear`: Clear terrain in a region (or all).\n- `terrain_read`: Read terrain data in a region.\n- `settings_get`: Get Workspace and Lighting properties.\n- `settings_set`: Set Workspace and Lighting properties.",
1308
+ inputSchema: z5.object({
1309
+ action: z5.enum(["terrain_fill", "terrain_clear", "terrain_read", "settings_get", "settings_set"]).describe("Environment action"),
1310
+ // Terrain params
1311
+ region: RegionSchema.optional().describe("Region for terrain operations"),
1312
+ material: z5.string().optional().describe("Terrain material name (for 'terrain_fill'), e.g. 'Grass'"),
1313
+ size: Vector3Schema.optional().describe("Size override for terrain fill"),
1314
+ // Settings params
1315
+ settings: z5.record(z5.unknown()).optional().describe("Settings to set (for 'settings_set'). Keys: 'Workspace.Gravity', 'Lighting.ClockTime', etc.")
1316
+ }),
1317
+ annotations: {
1318
+ readOnlyHint: false,
1319
+ destructiveHint: true,
1320
+ idempotentHint: false,
1321
+ openWorldHint: false
1322
+ }
1323
+ },
1324
+ async (params) => {
1325
+ if (params.action === "settings_get") {
1326
+ const result2 = await bridge.send("workspace_settings", {
1327
+ action: "get"
1328
+ });
1329
+ const lines = ["**Current Settings:**", ""];
1330
+ for (const [k, v] of Object.entries(result2.settings)) {
1331
+ lines.push(`- **${k}** = \`${JSON.stringify(v)}\``);
1332
+ }
1333
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1334
+ }
1335
+ if (params.action === "settings_set") {
1336
+ if (!params.settings) {
1337
+ return { content: [{ type: "text", text: "settings_set requires a `settings` object." }] };
1338
+ }
1339
+ const result2 = await bridge.send("workspace_settings", {
1340
+ action: "set",
1341
+ settings: params.settings
1342
+ });
1343
+ const text2 = `Updated ${result2.modified?.length ?? 0} setting(s): ${result2.modified?.join(", ") ?? "none"}`;
1344
+ return { content: [{ type: "text", text: text2 }] };
1345
+ }
1346
+ const terrainAction = params.action.replace("terrain_", "");
1347
+ const result = await bridge.send("terrain", {
1348
+ action: terrainAction,
1349
+ region: params.region,
1350
+ material: params.material,
1351
+ size: params.size
1352
+ });
1353
+ let text;
1354
+ if (terrainAction === "read" && result.materials) {
1355
+ const data = {
1356
+ region: result.region,
1357
+ resolution: result.resolution,
1358
+ totalVoxels: result.totalVoxels,
1359
+ filledVoxels: result.filledVoxels,
1360
+ materials: result.materials
1361
+ };
1362
+ text = `**Terrain data:**
1363
+ \`\`\`json
1364
+ ${JSON.stringify(data, null, 2)}
1365
+ \`\`\``;
1366
+ text = applyTokenBudget(text, void 0);
1367
+ } else {
1368
+ text = `Terrain ${terrainAction}: ${result.status}`;
1369
+ }
1370
+ return { content: [{ type: "text", text }] };
1371
+ }
1372
+ );
1373
+ }
1374
+
1375
+ // src/tools/assets.ts
1376
+ import { z as z6 } from "zod";
1377
+ function register6(server, bridge) {
1378
+ server.registerTool(
1379
+ "assets",
1380
+ {
1381
+ title: "Asset Search & Insert",
1382
+ description: "Search the Roblox asset catalog or insert assets by ID.\n\nActions:\n- `search`: Search for models, meshes, images, audio.\n- `insert`: Insert an asset into the game by ID.",
1383
+ inputSchema: z6.object({
1384
+ action: z6.enum(["search", "insert"]).describe("Asset action"),
1385
+ // Search params
1386
+ query: z6.string().optional().describe("Search query (for 'search' action)"),
1387
+ category: z6.string().optional().describe("Asset category: 'models', 'decals', 'images', etc. (for 'search')"),
1388
+ maxResults: z6.number().int().default(10).describe("Max search results (for 'search')"),
1389
+ // Insert params
1390
+ assetId: z6.number().int().optional().describe("Roblox asset ID (for 'insert' action)"),
1391
+ parent: z6.string().optional().describe("Parent path to insert under (for 'insert' action)")
1392
+ }),
1393
+ annotations: {
1394
+ readOnlyHint: false,
1395
+ destructiveHint: false,
1396
+ idempotentHint: false,
1397
+ openWorldHint: true
1398
+ }
1399
+ },
1400
+ async (params) => {
1401
+ if (params.action === "insert") {
1402
+ if (!params.assetId) {
1403
+ return { content: [{ type: "text", text: "insert action requires an `assetId`." }] };
1404
+ }
1405
+ if (!params.parent) {
1406
+ return { content: [{ type: "text", text: "insert action requires a `parent` path." }] };
1407
+ }
1408
+ const result2 = await bridge.send("insert_asset", {
1409
+ assetId: params.assetId,
1410
+ parent: params.parent
1411
+ });
1412
+ const text2 = result2.inserted.length === 1 ? `Inserted asset **${params.assetId}** at \`${result2.inserted[0].path}\`` : `Inserted asset **${params.assetId}**:
1413
+ ` + result2.inserted.map((i) => `- \`${i.path}\` (${i.className})`).join("\n");
1414
+ return { content: [{ type: "text", text: text2 }] };
1415
+ }
1416
+ if (!params.query) {
1417
+ return { content: [{ type: "text", text: "search action requires a `query`." }] };
1418
+ }
1419
+ const result = await bridge.send("search_assets", {
1420
+ query: params.query,
1421
+ category: params.category,
1422
+ maxResults: params.maxResults
1423
+ });
1424
+ let text;
1425
+ if (result.results.length === 0) {
1426
+ text = "*No assets found matching your query.*";
1427
+ } else {
1428
+ text = result.results.map((a) => `- **${a.name}** (ID: ${a.assetId}) by ${a.creatorName}`).join("\n");
1429
+ }
1430
+ return {
1431
+ content: [{ type: "text", text: applyTokenBudget(text, void 0) }]
1432
+ };
1433
+ }
1434
+ );
1435
+ }
1436
+
1437
+ // src/tools/utility.ts
1438
+ import { z as z7 } from "zod";
1439
+
1440
+ // src/context/api-data.json
1441
+ var api_data_default = {
1442
+ classes: [
1443
+ {
1444
+ name: "Instance",
1445
+ properties: [
1446
+ { name: "Name", type: "string" },
1447
+ { name: "ClassName", type: "string", tags: ["ReadOnly"] },
1448
+ { name: "Parent", type: "Instance?" }
1449
+ ],
1450
+ methods: [
1451
+ { name: "FindFirstChild", parameters: [{ name: "name", type: "string" }, { name: "recursive", type: "boolean?" }], returnType: "Instance?" },
1452
+ { name: "GetChildren", parameters: [], returnType: "{Instance}" },
1453
+ { name: "GetDescendants", parameters: [], returnType: "{Instance}" },
1454
+ { name: "Destroy", parameters: [], returnType: "void" },
1455
+ { name: "Clone", parameters: [], returnType: "Instance" },
1456
+ { name: "IsA", parameters: [{ name: "className", type: "string" }], returnType: "boolean" },
1457
+ { name: "SetAttribute", parameters: [{ name: "name", type: "string" }, { name: "value", type: "any" }], returnType: "void" },
1458
+ { name: "GetAttribute", parameters: [{ name: "name", type: "string" }], returnType: "any" },
1459
+ { name: "GetAttributes", parameters: [], returnType: "{[string]: any}" }
1460
+ ],
1461
+ events: [
1462
+ { name: "ChildAdded" },
1463
+ { name: "ChildRemoved" },
1464
+ { name: "Changed" }
1465
+ ]
1466
+ },
1467
+ {
1468
+ name: "BasePart",
1469
+ superclass: "PVInstance",
1470
+ properties: [
1471
+ { name: "Position", type: "Vector3" },
1472
+ { name: "Size", type: "Vector3" },
1473
+ { name: "CFrame", type: "CFrame" },
1474
+ { name: "Color", type: "Color3" },
1475
+ { name: "BrickColor", type: "BrickColor" },
1476
+ { name: "Material", type: "Enum.Material" },
1477
+ { name: "Transparency", type: "number" },
1478
+ { name: "Anchored", type: "boolean" },
1479
+ { name: "CanCollide", type: "boolean" },
1480
+ { name: "Massless", type: "boolean" }
1481
+ ],
1482
+ methods: [],
1483
+ events: [{ name: "Touched" }]
1484
+ },
1485
+ {
1486
+ name: "Part",
1487
+ superclass: "BasePart",
1488
+ properties: [
1489
+ { name: "Shape", type: "Enum.PartType" }
1490
+ ],
1491
+ methods: [],
1492
+ events: []
1493
+ },
1494
+ {
1495
+ name: "Model",
1496
+ superclass: "PVInstance",
1497
+ properties: [
1498
+ { name: "PrimaryPart", type: "BasePart?" }
1499
+ ],
1500
+ methods: [
1501
+ { name: "GetBoundingBox", parameters: [], returnType: "(CFrame, Vector3)" },
1502
+ { name: "MoveTo", parameters: [{ name: "position", type: "Vector3" }], returnType: "void" }
1503
+ ],
1504
+ events: []
1505
+ },
1506
+ {
1507
+ name: "Workspace",
1508
+ superclass: "WorldRoot",
1509
+ properties: [
1510
+ { name: "CurrentCamera", type: "Camera?" },
1511
+ { name: "Gravity", type: "number" },
1512
+ { name: "Terrain", type: "Terrain" }
1513
+ ],
1514
+ methods: [
1515
+ { name: "Raycast", parameters: [{ name: "origin", type: "Vector3" }, { name: "direction", type: "Vector3" }, { name: "raycastParams", type: "RaycastParams?" }], returnType: "RaycastResult?" }
1516
+ ],
1517
+ events: []
1518
+ },
1519
+ {
1520
+ name: "Script",
1521
+ superclass: "BaseScript",
1522
+ properties: [
1523
+ { name: "Source", type: "string" }
1524
+ ],
1525
+ methods: [],
1526
+ events: []
1527
+ },
1528
+ {
1529
+ name: "LocalScript",
1530
+ superclass: "BaseScript",
1531
+ properties: [
1532
+ { name: "Source", type: "string" }
1533
+ ],
1534
+ methods: [],
1535
+ events: []
1536
+ },
1537
+ {
1538
+ name: "ModuleScript",
1539
+ superclass: "LuaSourceContainer",
1540
+ properties: [
1541
+ { name: "Source", type: "string" }
1542
+ ],
1543
+ methods: [],
1544
+ events: []
1545
+ }
1546
+ ],
1547
+ enums: [
1548
+ {
1549
+ name: "Material",
1550
+ items: [
1551
+ { name: "Plastic", value: 256 },
1552
+ { name: "Wood", value: 512 },
1553
+ { name: "Slate", value: 800 },
1554
+ { name: "Concrete", value: 816 },
1555
+ { name: "CorrodedMetal", value: 1040 },
1556
+ { name: "DiamondPlate", value: 1056 },
1557
+ { name: "Foil", value: 1072 },
1558
+ { name: "Grass", value: 1280 },
1559
+ { name: "Ice", value: 1536 },
1560
+ { name: "Marble", value: 784 },
1561
+ { name: "Granite", value: 832 },
1562
+ { name: "Brick", value: 848 },
1563
+ { name: "Pebble", value: 864 },
1564
+ { name: "Sand", value: 1296 },
1565
+ { name: "Fabric", value: 1312 },
1566
+ { name: "SmoothPlastic", value: 272 },
1567
+ { name: "Metal", value: 1024 },
1568
+ { name: "WoodPlanks", value: 528 },
1569
+ { name: "Neon", value: 288 },
1570
+ { name: "Glass", value: 1568 }
1571
+ ]
1572
+ },
1573
+ {
1574
+ name: "PartType",
1575
+ items: [
1576
+ { name: "Ball", value: 0 },
1577
+ { name: "Block", value: 1 },
1578
+ { name: "Cylinder", value: 2 }
1579
+ ]
1580
+ }
1581
+ ]
1582
+ };
1583
+
1584
+ // src/context/api-index.ts
1585
+ var apiData = api_data_default;
1586
+ function searchApi(query, maxResults = 20) {
1587
+ const data = apiData;
1588
+ const q = query.toLowerCase();
1589
+ const results = [];
1590
+ for (const cls of data.classes) {
1591
+ if (cls.name.toLowerCase().includes(q)) {
1592
+ const props = cls.properties.map((p) => p.name).join(", ");
1593
+ const methods = cls.methods.map((m) => m.name).join(", ");
1594
+ results.push({
1595
+ type: "class",
1596
+ name: cls.name,
1597
+ detail: `Inherits: ${cls.superclass ?? "none"}
1598
+ Properties: ${props || "none"}
1599
+ Methods: ${methods || "none"}`
1600
+ });
1601
+ }
1602
+ for (const prop of cls.properties) {
1603
+ if (prop.name.toLowerCase().includes(q)) {
1604
+ results.push({
1605
+ type: "property",
1606
+ className: cls.name,
1607
+ name: prop.name,
1608
+ detail: `${cls.name}.${prop.name}: ${prop.type ?? "unknown"}${prop.tags?.length ? ` [${prop.tags.join(", ")}]` : ""}`
1609
+ });
1610
+ }
1611
+ }
1612
+ for (const method of cls.methods) {
1613
+ if (method.name.toLowerCase().includes(q)) {
1614
+ const params = method.parameters?.map((p) => `${p.name}: ${p.type}`).join(", ") ?? "";
1615
+ results.push({
1616
+ type: "method",
1617
+ className: cls.name,
1618
+ name: method.name,
1619
+ detail: `${cls.name}:${method.name}(${params}): ${method.returnType ?? "void"}`
1620
+ });
1621
+ }
1622
+ }
1623
+ for (const event of cls.events) {
1624
+ if (event.name.toLowerCase().includes(q)) {
1625
+ results.push({
1626
+ type: "event",
1627
+ className: cls.name,
1628
+ name: event.name,
1629
+ detail: `${cls.name}.${event.name}`
1630
+ });
1631
+ }
1632
+ }
1633
+ if (results.length >= maxResults * 3) break;
1634
+ }
1635
+ for (const en of data.enums) {
1636
+ if (en.name.toLowerCase().includes(q)) {
1637
+ const items = en.items.map((i) => i.name).join(", ");
1638
+ results.push({
1639
+ type: "enum",
1640
+ name: en.name,
1641
+ detail: `Enum.${en.name}: ${items}`
1642
+ });
1643
+ }
1644
+ }
1645
+ results.sort((a, b) => {
1646
+ const aExact = a.name.toLowerCase() === q ? 0 : 1;
1647
+ const bExact = b.name.toLowerCase() === q ? 0 : 1;
1648
+ if (aExact !== bExact) return aExact - bExact;
1649
+ return a.name.length - b.name.length;
1650
+ });
1651
+ return results.slice(0, maxResults);
1652
+ }
1653
+ function formatSearchResults(results) {
1654
+ if (results.length === 0) {
1655
+ return "*No results found. Try a different search term.*";
1656
+ }
1657
+ const lines = [];
1658
+ for (const r of results) {
1659
+ const prefix = r.type.charAt(0).toUpperCase() + r.type.slice(1);
1660
+ lines.push(`### ${prefix}: ${r.name}`);
1661
+ lines.push(r.detail);
1662
+ lines.push("");
1663
+ }
1664
+ return lines.join("\n");
1665
+ }
1666
+
1667
+ // src/tools/utility.ts
1668
+ function register7(server, bridge) {
1669
+ server.registerTool(
1670
+ "undo_redo",
1671
+ {
1672
+ title: "Undo / Redo",
1673
+ description: "Trigger undo or redo in Roblox Studio's change history. Supports repeating multiple times with the count parameter.",
1674
+ inputSchema: z7.object({
1675
+ action: z7.enum(["undo", "redo"]).describe("Whether to undo or redo"),
1676
+ count: z7.number().int().min(1).default(1).describe("Number of times to repeat the action")
1677
+ }),
1678
+ annotations: {
1679
+ readOnlyHint: false,
1680
+ destructiveHint: false,
1681
+ idempotentHint: false,
1682
+ openWorldHint: false
1683
+ }
1684
+ },
1685
+ async (params) => {
1686
+ const result = await bridge.send("undo_redo", {
1687
+ action: params.action,
1688
+ count: params.count
1689
+ });
1690
+ const text = `${params.action === "undo" ? "Undo" : "Redo"} x${result.count}: ${result.status}`;
1691
+ return { content: [{ type: "text", text }] };
1692
+ }
1693
+ );
1694
+ server.registerTool(
1695
+ "screenshot",
1696
+ {
1697
+ title: "Take Screenshot",
1698
+ description: "Capture a screenshot of the current Roblox Studio viewport. Returns base64 image data when available for vision model consumption.",
1699
+ inputSchema: z7.object({}),
1700
+ annotations: {
1701
+ readOnlyHint: true,
1702
+ destructiveHint: false,
1703
+ idempotentHint: true,
1704
+ openWorldHint: false
1705
+ }
1706
+ },
1707
+ async () => {
1708
+ const result = await bridge.send("screenshot", {});
1709
+ if (result.imageBase64 && result.mimeType) {
1710
+ return {
1711
+ content: [
1712
+ {
1713
+ type: "image",
1714
+ data: result.imageBase64,
1715
+ mimeType: result.mimeType
1716
+ }
1717
+ ]
1718
+ };
1719
+ }
1720
+ return {
1721
+ content: [
1722
+ { type: "text", text: `Screenshot: ${result.message ?? result.status}` }
1723
+ ]
1724
+ };
1725
+ }
1726
+ );
1727
+ server.registerTool(
1728
+ "lookup_api",
1729
+ {
1730
+ title: "Lookup Roblox API",
1731
+ description: "Search the Roblox engine API reference for classes, properties, methods, events, and enums. Runs locally \u2014 no Studio connection required.",
1732
+ inputSchema: z7.object({
1733
+ query: z7.string().describe("Search query, e.g. 'BasePart', 'Touched', 'TweenService'"),
1734
+ maxTokens: z7.number().optional().describe("Maximum token budget for the response")
1735
+ }),
1736
+ annotations: {
1737
+ readOnlyHint: true,
1738
+ destructiveHint: false,
1739
+ idempotentHint: true,
1740
+ openWorldHint: false
1741
+ }
1742
+ },
1743
+ async (params) => {
1744
+ const results = searchApi(params.query);
1745
+ const text = formatSearchResults(results);
1746
+ return {
1747
+ content: [
1748
+ { type: "text", text: applyTokenBudget(text, params.maxTokens) }
1749
+ ]
1750
+ };
1751
+ }
1752
+ );
1753
+ server.registerTool(
1754
+ "transaction",
1755
+ {
1756
+ title: "Transaction Control",
1757
+ description: "Group multiple mutating tool calls into a single Ctrl+Z undo point.\n\nActions:\n- `begin`: Start a transaction. All subsequent writes share one undo recording.\n- `commit`: Finish the transaction and commit all changes as one undo point.\n- `rollback`: Cancel the transaction and undo all changes made since begin.\n\nTransactions auto-rollback after 60 seconds if not committed.",
1758
+ inputSchema: z7.object({
1759
+ action: z7.enum(["begin", "commit", "rollback"]).describe("Transaction action"),
1760
+ name: z7.string().optional().describe("Transaction name for the undo history (for 'begin' action)")
1761
+ }),
1762
+ annotations: {
1763
+ readOnlyHint: false,
1764
+ destructiveHint: false,
1765
+ idempotentHint: false,
1766
+ openWorldHint: false
1767
+ }
1768
+ },
1769
+ async (params) => {
1770
+ if (params.action === "begin") {
1771
+ const result2 = await bridge.send("begin_transaction", {
1772
+ name: params.name
1773
+ });
1774
+ return {
1775
+ content: [
1776
+ { type: "text", text: `Transaction started: **${result2.transactionId}**
1777
+ All subsequent writes will be grouped into one undo point. Call commit or rollback to finish.` }
1778
+ ]
1779
+ };
1780
+ }
1781
+ if (params.action === "commit") {
1782
+ const result2 = await bridge.send("commit_transaction", {});
1783
+ return {
1784
+ content: [
1785
+ { type: "text", text: `Transaction ${result2.status}. All changes are now a single undo point.` }
1786
+ ]
1787
+ };
1788
+ }
1789
+ const result = await bridge.send("rollback_transaction", {});
1790
+ return {
1791
+ content: [
1792
+ { type: "text", text: `Transaction ${result.status}. All changes since begin have been reverted.` }
1793
+ ]
1794
+ };
1795
+ }
1796
+ );
1797
+ }
1798
+
1799
+ // src/tools/studio.ts
1800
+ import { z as z8 } from "zod";
1801
+ function register8(server, bridge) {
1802
+ server.registerTool(
1803
+ "list_studios",
1804
+ {
1805
+ title: "List Connected Studios",
1806
+ description: "List all Roblox Studio instances currently connected to Conduit. Shows studio ID, place name, place ID, and which studio is active. Use set_active_studio to switch between them.",
1807
+ inputSchema: z8.object({}),
1808
+ annotations: {
1809
+ readOnlyHint: true,
1810
+ destructiveHint: false,
1811
+ idempotentHint: true,
1812
+ openWorldHint: false
1813
+ }
1814
+ },
1815
+ async () => {
1816
+ const studios = bridge.getStudios();
1817
+ const activeId = bridge.getActiveStudioId();
1818
+ if (studios.length === 0) {
1819
+ return {
1820
+ content: [
1821
+ {
1822
+ type: "text",
1823
+ text: "*No Roblox Studio instances connected.* Make sure the Conduit plugin is installed and running."
1824
+ }
1825
+ ]
1826
+ };
1827
+ }
1828
+ const lines = studios.map((s) => {
1829
+ const active = s.studioId === activeId ? " **\u2190 active**" : "";
1830
+ const place = s.placeName ? ` \u2014 ${s.placeName}` : "";
1831
+ const placeId = s.placeId ? ` (Place ID: ${s.placeId})` : "";
1832
+ const duration = Math.floor((Date.now() - s.connectedAt) / 1e3);
1833
+ return `- \`${s.studioId}\`${place}${placeId} \u2014 connected ${duration}s ago${active}`;
1834
+ });
1835
+ const text = `**Connected Studios (${studios.length}):**
1836
+ ${lines.join("\n")}`;
1837
+ return { content: [{ type: "text", text }] };
1838
+ }
1839
+ );
1840
+ server.registerTool(
1841
+ "set_active_studio",
1842
+ {
1843
+ title: "Set Active Studio",
1844
+ description: "Switch which Roblox Studio instance receives tool commands. Use list_studios to see available IDs.",
1845
+ inputSchema: z8.object({
1846
+ studioId: z8.string().describe("The studio ID to set as active (from list_studios)")
1847
+ }),
1848
+ annotations: {
1849
+ readOnlyHint: false,
1850
+ destructiveHint: false,
1851
+ idempotentHint: true,
1852
+ openWorldHint: false
1853
+ }
1854
+ },
1855
+ async (params) => {
1856
+ bridge.setActiveStudio(params.studioId);
1857
+ const studios = bridge.getStudios();
1858
+ const studio = studios.find((s) => s.studioId === params.studioId);
1859
+ const name = studio?.placeName ?? "unknown";
1860
+ const text = `Active studio set to \`${params.studioId}\` (${name}).`;
1861
+ return { content: [{ type: "text", text }] };
1862
+ }
1863
+ );
1864
+ }
1865
+
1866
+ // src/tools/builds.ts
1867
+ import { z as z9 } from "zod";
1868
+
1869
+ // src/utils/builds.ts
1870
+ import { homedir } from "os";
1871
+ import { join } from "path";
1872
+ import {
1873
+ existsSync,
1874
+ mkdirSync,
1875
+ readFileSync,
1876
+ writeFileSync,
1877
+ readdirSync,
1878
+ statSync
1879
+ } from "fs";
1880
+ var BUILDS_DIR = join(homedir(), ".conduit", "builds");
1881
+ function ensureDir() {
1882
+ if (!existsSync(BUILDS_DIR)) {
1883
+ mkdirSync(BUILDS_DIR, { recursive: true });
1884
+ }
1885
+ }
1886
+ function saveBuild(name, root, description) {
1887
+ ensureDir();
1888
+ const rootObj = root;
1889
+ const data = {
1890
+ name,
1891
+ description,
1892
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1893
+ root
1894
+ };
1895
+ const filePath = join(BUILDS_DIR, `${name}.json`);
1896
+ writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
1897
+ return {
1898
+ name,
1899
+ description,
1900
+ createdAt: data.createdAt,
1901
+ rootClassName: rootObj.className ?? "unknown",
1902
+ childCount: rootObj.children?.length ?? 0
1903
+ };
1904
+ }
1905
+ function loadBuild(name) {
1906
+ const filePath = join(BUILDS_DIR, `${name}.json`);
1907
+ if (!existsSync(filePath)) {
1908
+ throw new Error(`Build "${name}" not found. Use builds --action list to see available builds.`);
1909
+ }
1910
+ return JSON.parse(readFileSync(filePath, "utf-8"));
1911
+ }
1912
+ function listBuilds() {
1913
+ ensureDir();
1914
+ const files = readdirSync(BUILDS_DIR).filter((f) => f.endsWith(".json"));
1915
+ return files.map((f) => {
1916
+ const filePath = join(BUILDS_DIR, f);
1917
+ try {
1918
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
1919
+ const rootObj = data.root;
1920
+ return {
1921
+ name: data.name,
1922
+ description: data.description,
1923
+ createdAt: data.createdAt,
1924
+ rootClassName: rootObj.className ?? "unknown",
1925
+ childCount: rootObj.children?.length ?? 0
1926
+ };
1927
+ } catch {
1928
+ const stat = statSync(filePath);
1929
+ return {
1930
+ name: f.replace(".json", ""),
1931
+ createdAt: stat.mtime.toISOString(),
1932
+ rootClassName: "unknown",
1933
+ childCount: 0
1934
+ };
1935
+ }
1936
+ });
1937
+ }
1938
+
1939
+ // src/tools/builds.ts
1940
+ function register9(server, bridge) {
1941
+ server.registerTool(
1942
+ "builds",
1943
+ {
1944
+ title: "Build Library",
1945
+ description: "Export, import, or list reusable instance trees (build library).\n\nActions:\n- `export`: Serialize an instance tree and save it as a named build.\n- `import`: Reconstruct a saved build under a target parent.\n- `list`: List all saved builds.",
1946
+ inputSchema: z9.object({
1947
+ action: z9.enum(["export", "import", "list"]).describe("Build library action"),
1948
+ // Export params
1949
+ path: z9.string().optional().describe("Path to instance to export (for 'export' action)"),
1950
+ name: z9.string().optional().describe("Build name (for 'export' and 'import' actions)"),
1951
+ description: z9.string().optional().describe("Build description (for 'export' action)"),
1952
+ // Import params
1953
+ targetParent: z9.string().optional().describe("Parent path to import under (for 'import' action)")
1954
+ }),
1955
+ annotations: {
1956
+ readOnlyHint: false,
1957
+ destructiveHint: false,
1958
+ idempotentHint: false,
1959
+ openWorldHint: false
1960
+ }
1961
+ },
1962
+ async (params) => {
1963
+ if (params.action === "list") {
1964
+ const builds = listBuilds();
1965
+ if (builds.length === 0) {
1966
+ return {
1967
+ content: [
1968
+ { type: "text", text: "*No builds saved. Use `builds --action export` to save one.*" }
1969
+ ]
1970
+ };
1971
+ }
1972
+ const lines = builds.map(
1973
+ (b) => `- **${b.name}** \`${b.rootClassName}\` (${b.childCount} children) \u2014 ${b.createdAt}${b.description ? `
1974
+ ${b.description}` : ""}`
1975
+ );
1976
+ return {
1977
+ content: [{ type: "text", text: lines.join("\n") }]
1978
+ };
1979
+ }
1980
+ if (params.action === "export") {
1981
+ if (!params.path) {
1982
+ return { content: [{ type: "text", text: "export action requires a `path`." }] };
1983
+ }
1984
+ if (!params.name) {
1985
+ return { content: [{ type: "text", text: "export action requires a `name`." }] };
1986
+ }
1987
+ const serialized = await bridge.send("export_build", {
1988
+ path: params.path
1989
+ });
1990
+ const meta = saveBuild(params.name, serialized, params.description);
1991
+ return {
1992
+ content: [
1993
+ {
1994
+ type: "text",
1995
+ text: `Build **${meta.name}** saved.
1996
+ - Root: \`${meta.rootClassName}\`
1997
+ - Children: ${meta.childCount}
1998
+ - Created: ${meta.createdAt}`
1999
+ }
2000
+ ]
2001
+ };
2002
+ }
2003
+ if (params.action === "import") {
2004
+ if (!params.name) {
2005
+ return { content: [{ type: "text", text: "import action requires a `name`." }] };
2006
+ }
2007
+ if (!params.targetParent) {
2008
+ return { content: [{ type: "text", text: "import action requires a `targetParent`." }] };
2009
+ }
2010
+ const build = loadBuild(params.name);
2011
+ const result = await bridge.send("import_build", {
2012
+ tree: build.root,
2013
+ targetParent: params.targetParent
2014
+ });
2015
+ return {
2016
+ content: [
2017
+ {
2018
+ type: "text",
2019
+ text: `Build **${params.name}** imported at \`${result.path}\` (${result.className}, ${result.childCount} children).`
2020
+ }
2021
+ ]
2022
+ };
2023
+ }
2024
+ return { content: [{ type: "text", text: "Unknown builds action." }] };
2025
+ }
2026
+ );
2027
+ }
2028
+
2029
+ // src/tools/index.ts
2030
+ function registerAllTools(server, bridge, options = {}) {
2031
+ const mode = options.mode ?? "full";
2032
+ register8(server, bridge);
2033
+ register(server, bridge);
2034
+ register7(server, bridge);
2035
+ if (mode === "full") {
2036
+ register2(server, bridge);
2037
+ register3(server, bridge);
2038
+ register4(server, bridge);
2039
+ register5(server, bridge);
2040
+ register6(server, bridge);
2041
+ register9(server, bridge);
2042
+ } else {
2043
+ registerReadOnly(server, bridge);
2044
+ registerReadOnly2(server, bridge);
2045
+ }
2046
+ if (options.withCloud) {
2047
+ import("./cloud-HQIBAMRL.js").then((mod) => mod.register(server));
2048
+ }
2049
+ if (options.withRojo) {
2050
+ import("./rojo-TXV4K4PB.js").then((mod) => mod.register(server));
2051
+ }
2052
+ }
2053
+
2054
+ // src/index.ts
2055
+ async function startServer(port = 3200, options = {}) {
2056
+ const bridge = new Bridge(port);
2057
+ const server = new McpServer(
2058
+ {
2059
+ name: "conduit-mcp",
2060
+ version: "2.0.0"
2061
+ },
2062
+ {
2063
+ instructions: [
2064
+ "Conduit bridges AI assistants to Roblox Studio via WebSocket.",
2065
+ "The Conduit plugin must be running in Roblox Studio and connected.",
2066
+ "",
2067
+ "Workflow tips:",
2068
+ "- Use `explore` first to understand the DataModel before making changes.",
2069
+ "- Use `read_script` to read source before editing with `edit_script`.",
2070
+ "- All mutations are undoable via Ctrl+Z in Studio (ChangeHistoryService).",
2071
+ "- Use `lookup_api` to check Roblox API signatures before writing Luau code.",
2072
+ "- Batch operations: `create` and `modify` accept arrays of operations.",
2073
+ "- Token budgets: pass `maxTokens` to limit response size on large DataModels.",
2074
+ "- Use `query` to find instances by class, tag, or attribute.",
2075
+ "- Use `query --action scripts` to grep across all script sources.",
2076
+ "- Use `execute_lua` as an escape hatch for anything tools don't cover.",
2077
+ "- Use `explore --action set_selection` to select instances in Studio.",
2078
+ "",
2079
+ "Multi-Studio:",
2080
+ "- Multiple Studio instances can connect simultaneously.",
2081
+ "- Use `list_studios` to see connected instances and `set_active_studio` to switch.",
2082
+ "- With a single Studio, routing is automatic."
2083
+ ].join("\n")
2084
+ }
2085
+ );
2086
+ const toolOptions = {
2087
+ mode: options.mode,
2088
+ withCloud: options.withCloud,
2089
+ withRojo: options.withRojo
2090
+ };
2091
+ registerAllTools(server, bridge, toolOptions);
2092
+ bridge.on("studio-connected", (info) => {
2093
+ log.info(
2094
+ `Roblox Studio connected: ${info.studioId}` + (info.placeName ? ` (${info.placeName})` : "")
2095
+ );
2096
+ });
2097
+ bridge.on("studio-disconnected", (info) => {
2098
+ log.warn(
2099
+ `Roblox Studio disconnected: ${info.studioId}` + (info.placeName ? ` (${info.placeName})` : "")
2100
+ );
2101
+ });
2102
+ const actualPort = await bridge.start();
2103
+ log.info(`Bridge listening on port ${actualPort}`);
2104
+ const transport = new StdioServerTransport();
2105
+ await server.connect(transport);
2106
+ log.info("MCP server connected via stdio");
2107
+ const shutdown = async () => {
2108
+ log.info("Shutting down...");
2109
+ await bridge.stop();
2110
+ await server.close();
2111
+ process.exit(0);
2112
+ };
2113
+ process.on("SIGINT", shutdown);
2114
+ process.on("SIGTERM", shutdown);
2115
+ }
2116
+
2117
+ export {
2118
+ startServer
2119
+ };
2120
+ //# sourceMappingURL=chunk-JDWBW44K.js.map