conduit-mcp 2.0.0 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-JDWBW44K.js → chunk-HI6KFLGA.js} +315 -56
- package/dist/chunk-HI6KFLGA.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/{cloud-HQIBAMRL.js → cloud-4AYJVND6.js} +2 -2
- package/dist/cloud-4AYJVND6.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/{rojo-TXV4K4PB.js → rojo-2CMKPTPS.js} +11 -2
- package/dist/rojo-2CMKPTPS.js.map +1 -0
- package/package.json +2 -2
- package/plugin/Conduit.rbxm +0 -0
- package/dist/chunk-JDWBW44K.js.map +0 -1
- package/dist/cloud-HQIBAMRL.js.map +0 -1
- package/dist/rojo-TXV4K4PB.js.map +0 -1
|
@@ -10,7 +10,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
|
|
10
10
|
// src/protocol.ts
|
|
11
11
|
import { randomUUID } from "crypto";
|
|
12
12
|
function generateId() {
|
|
13
|
-
return randomUUID().slice(0,
|
|
13
|
+
return randomUUID().slice(0, 16);
|
|
14
14
|
}
|
|
15
15
|
function isHeartbeat(msg) {
|
|
16
16
|
return typeof msg === "object" && msg !== null && "type" in msg && msg.type === "heartbeat";
|
|
@@ -41,8 +41,10 @@ var log = {
|
|
|
41
41
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
42
42
|
var HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
|
|
43
43
|
var HEARTBEAT_TIMEOUT_MS = 15e3;
|
|
44
|
+
var PING_INTERVAL_MS = 1e4;
|
|
44
45
|
var LONG_POLL_TIMEOUT_MS = 25e3;
|
|
45
46
|
var MAX_PORT_RETRIES = 10;
|
|
47
|
+
var REGISTRATION_TIMEOUT_MS = 1e4;
|
|
46
48
|
var Bridge = class extends EventEmitter {
|
|
47
49
|
constructor(port = 3200) {
|
|
48
50
|
super();
|
|
@@ -56,6 +58,7 @@ var Bridge = class extends EventEmitter {
|
|
|
56
58
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
57
59
|
lastHeartbeats = /* @__PURE__ */ new Map();
|
|
58
60
|
heartbeatTimer = null;
|
|
61
|
+
pingTimer = null;
|
|
59
62
|
actualPort = 0;
|
|
60
63
|
// HTTP-only studios (no WebSocket connection)
|
|
61
64
|
httpStudios = /* @__PURE__ */ new Map();
|
|
@@ -65,8 +68,10 @@ var Bridge = class extends EventEmitter {
|
|
|
65
68
|
httpPendingCommands = /* @__PURE__ */ new Map();
|
|
66
69
|
httpPollWaiters = /* @__PURE__ */ new Map();
|
|
67
70
|
get isConnected() {
|
|
68
|
-
const studio
|
|
69
|
-
|
|
71
|
+
for (const [, studio] of this.studios) {
|
|
72
|
+
if (studio.ws.readyState === WebSocket.OPEN) return true;
|
|
73
|
+
}
|
|
74
|
+
return this.httpStudios.size > 0;
|
|
70
75
|
}
|
|
71
76
|
get listeningPort() {
|
|
72
77
|
return this.actualPort;
|
|
@@ -92,7 +97,35 @@ var Bridge = class extends EventEmitter {
|
|
|
92
97
|
log.info(`Active studio set to: ${studioId}`);
|
|
93
98
|
}
|
|
94
99
|
// ── Server lifecycle ───────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Try to shut down a stale Conduit instance on the target port.
|
|
102
|
+
* Returns true if the port was freed (or was already free).
|
|
103
|
+
*/
|
|
104
|
+
async evictStaleInstance(port) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const req = http.request(
|
|
107
|
+
{
|
|
108
|
+
hostname: "127.0.0.1",
|
|
109
|
+
port,
|
|
110
|
+
path: "/shutdown",
|
|
111
|
+
method: "POST",
|
|
112
|
+
timeout: 2e3
|
|
113
|
+
},
|
|
114
|
+
(res) => {
|
|
115
|
+
res.resume();
|
|
116
|
+
setTimeout(() => resolve(true), 500);
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
req.on("error", () => resolve(false));
|
|
120
|
+
req.on("timeout", () => {
|
|
121
|
+
req.destroy();
|
|
122
|
+
resolve(false);
|
|
123
|
+
});
|
|
124
|
+
req.end();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
95
127
|
async start() {
|
|
128
|
+
await this.evictStaleInstance(this.port);
|
|
96
129
|
return new Promise((resolve, reject) => {
|
|
97
130
|
let attempts = 0;
|
|
98
131
|
const tryPort = (port) => {
|
|
@@ -102,6 +135,9 @@ var Bridge = class extends EventEmitter {
|
|
|
102
135
|
server.once("error", (err) => {
|
|
103
136
|
if (err.code === "EADDRINUSE" && attempts < MAX_PORT_RETRIES) {
|
|
104
137
|
attempts++;
|
|
138
|
+
log.warn(
|
|
139
|
+
`Port ${port} still in use after eviction \u2014 trying ${port + 1}`
|
|
140
|
+
);
|
|
105
141
|
tryPort(port + 1);
|
|
106
142
|
} else {
|
|
107
143
|
reject(err);
|
|
@@ -139,6 +175,7 @@ var Bridge = class extends EventEmitter {
|
|
|
139
175
|
clearInterval(this.heartbeatTimer);
|
|
140
176
|
this.heartbeatTimer = null;
|
|
141
177
|
}
|
|
178
|
+
this.stopPingInterval();
|
|
142
179
|
for (const [, waiters] of this.httpPollWaiters) {
|
|
143
180
|
for (const waiter of waiters) {
|
|
144
181
|
clearTimeout(waiter.timer);
|
|
@@ -209,30 +246,33 @@ var Bridge = class extends EventEmitter {
|
|
|
209
246
|
if (active && active.ws.readyState === WebSocket.OPEN) {
|
|
210
247
|
return active;
|
|
211
248
|
}
|
|
212
|
-
|
|
213
|
-
const [studioId, studio] = this.studios.entries().next().value;
|
|
249
|
+
for (const [studioId, studio] of this.studios) {
|
|
214
250
|
if (studio.ws.readyState === WebSocket.OPEN) {
|
|
215
251
|
this.activeStudioId = studioId;
|
|
252
|
+
log.info(`Auto-selected active studio: ${studioId}`);
|
|
216
253
|
return studio;
|
|
217
254
|
}
|
|
218
255
|
}
|
|
219
|
-
|
|
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
|
-
);
|
|
256
|
+
return void 0;
|
|
227
257
|
}
|
|
228
258
|
// ── WebSocket handling ─────────────────────────────────────────
|
|
229
259
|
handleWsConnection(ws) {
|
|
230
260
|
log.info("New WebSocket connection \u2014 waiting for registration");
|
|
231
261
|
this.pendingWs.add(ws);
|
|
262
|
+
const registrationTimer = setTimeout(() => {
|
|
263
|
+
if (this.pendingWs.has(ws)) {
|
|
264
|
+
log.warn(
|
|
265
|
+
"WebSocket failed to register within timeout \u2014 closing"
|
|
266
|
+
);
|
|
267
|
+
this.pendingWs.delete(ws);
|
|
268
|
+
ws.close(1008, "Registration timeout");
|
|
269
|
+
}
|
|
270
|
+
}, REGISTRATION_TIMEOUT_MS);
|
|
232
271
|
const onFirstMessage = (data) => {
|
|
233
272
|
try {
|
|
234
273
|
const msg = JSON.parse(data.toString());
|
|
235
274
|
if (isStudioRegistration(msg)) {
|
|
275
|
+
clearTimeout(registrationTimer);
|
|
236
276
|
ws.removeListener("message", onFirstMessage);
|
|
237
277
|
this.pendingWs.delete(ws);
|
|
238
278
|
this.registerStudio(ws, {
|
|
@@ -243,9 +283,10 @@ var Bridge = class extends EventEmitter {
|
|
|
243
283
|
});
|
|
244
284
|
return;
|
|
245
285
|
}
|
|
286
|
+
clearTimeout(registrationTimer);
|
|
246
287
|
ws.removeListener("message", onFirstMessage);
|
|
247
288
|
this.pendingWs.delete(ws);
|
|
248
|
-
const syntheticId =
|
|
289
|
+
const syntheticId = `studio-legacy-${Date.now()}`;
|
|
249
290
|
log.info(
|
|
250
291
|
"Legacy plugin detected (no registration), assigning ID: " + syntheticId
|
|
251
292
|
);
|
|
@@ -259,12 +300,17 @@ var Bridge = class extends EventEmitter {
|
|
|
259
300
|
}
|
|
260
301
|
};
|
|
261
302
|
ws.on("message", onFirstMessage);
|
|
262
|
-
|
|
303
|
+
const onEarlyClose = () => {
|
|
304
|
+
clearTimeout(registrationTimer);
|
|
263
305
|
this.pendingWs.delete(ws);
|
|
264
|
-
}
|
|
265
|
-
|
|
306
|
+
};
|
|
307
|
+
const onEarlyError = (err) => {
|
|
266
308
|
log.warn("WebSocket error:", err.message);
|
|
267
|
-
}
|
|
309
|
+
};
|
|
310
|
+
ws.on("close", onEarlyClose);
|
|
311
|
+
ws.on("error", onEarlyError);
|
|
312
|
+
ws.__earlyClose = onEarlyClose;
|
|
313
|
+
ws.__earlyError = onEarlyError;
|
|
268
314
|
}
|
|
269
315
|
registerStudio(ws, info) {
|
|
270
316
|
const { studioId } = info;
|
|
@@ -275,6 +321,10 @@ var Bridge = class extends EventEmitter {
|
|
|
275
321
|
}
|
|
276
322
|
this.studios.set(studioId, { ws, info });
|
|
277
323
|
this.lastHeartbeats.set(studioId, Date.now());
|
|
324
|
+
ws.isAlive = true;
|
|
325
|
+
ws.on("pong", () => {
|
|
326
|
+
ws.isAlive = true;
|
|
327
|
+
});
|
|
278
328
|
if (this.activeStudioId === null) {
|
|
279
329
|
this.activeStudioId = studioId;
|
|
280
330
|
}
|
|
@@ -283,11 +333,23 @@ var Bridge = class extends EventEmitter {
|
|
|
283
333
|
`Studio registered: ${studioId}` + (info.placeName ? ` (${info.placeName})` : "")
|
|
284
334
|
);
|
|
285
335
|
this.startHeartbeatMonitor();
|
|
336
|
+
this.startPingInterval();
|
|
337
|
+
if (ws.__earlyClose) {
|
|
338
|
+
ws.removeListener("close", ws.__earlyClose);
|
|
339
|
+
delete ws.__earlyClose;
|
|
340
|
+
}
|
|
341
|
+
if (ws.__earlyError) {
|
|
342
|
+
ws.removeListener("error", ws.__earlyError);
|
|
343
|
+
delete ws.__earlyError;
|
|
344
|
+
}
|
|
286
345
|
ws.on("message", (data) => {
|
|
287
346
|
try {
|
|
288
347
|
const msg = JSON.parse(data.toString());
|
|
289
348
|
if (isHeartbeat(msg)) {
|
|
290
349
|
this.lastHeartbeats.set(studioId, Date.now());
|
|
350
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
351
|
+
ws.send(JSON.stringify({ type: "heartbeat_ack" }));
|
|
352
|
+
}
|
|
291
353
|
return;
|
|
292
354
|
}
|
|
293
355
|
this.handlePluginMessage(msg);
|
|
@@ -303,7 +365,7 @@ var Bridge = class extends EventEmitter {
|
|
|
303
365
|
this.emit("studio-disconnected", info);
|
|
304
366
|
log.info(`Studio disconnected: ${studioId}`);
|
|
305
367
|
if (this.activeStudioId === studioId) {
|
|
306
|
-
if (this.studios.size
|
|
368
|
+
if (this.studios.size > 0) {
|
|
307
369
|
this.activeStudioId = this.studios.keys().next().value;
|
|
308
370
|
log.info(
|
|
309
371
|
`Auto-switched active studio to: ${this.activeStudioId}`
|
|
@@ -314,26 +376,27 @@ var Bridge = class extends EventEmitter {
|
|
|
314
376
|
}
|
|
315
377
|
if (this.studios.size === 0) {
|
|
316
378
|
this.stopHeartbeatMonitor();
|
|
379
|
+
this.stopPingInterval();
|
|
317
380
|
}
|
|
318
381
|
}
|
|
319
382
|
});
|
|
320
383
|
}
|
|
321
384
|
handlePluginMessage(msg) {
|
|
322
|
-
if (
|
|
385
|
+
if (isBridgeError(msg)) {
|
|
323
386
|
const pending = this.pendingRequests.get(msg.id);
|
|
324
387
|
if (pending) {
|
|
325
388
|
clearTimeout(pending.timer);
|
|
326
389
|
this.pendingRequests.delete(msg.id);
|
|
327
|
-
pending.
|
|
390
|
+
pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
|
|
328
391
|
}
|
|
329
392
|
return;
|
|
330
393
|
}
|
|
331
|
-
if (
|
|
394
|
+
if (isBridgeResponse(msg)) {
|
|
332
395
|
const pending = this.pendingRequests.get(msg.id);
|
|
333
396
|
if (pending) {
|
|
334
397
|
clearTimeout(pending.timer);
|
|
335
398
|
this.pendingRequests.delete(msg.id);
|
|
336
|
-
pending.
|
|
399
|
+
pending.resolve(msg.result);
|
|
337
400
|
}
|
|
338
401
|
return;
|
|
339
402
|
}
|
|
@@ -350,7 +413,8 @@ var Bridge = class extends EventEmitter {
|
|
|
350
413
|
log.warn(
|
|
351
414
|
`Heartbeat timeout for studio "${studioId}" \u2014 disconnecting`
|
|
352
415
|
);
|
|
353
|
-
studio.ws.
|
|
416
|
+
studio.ws.close(1001, "Heartbeat timeout");
|
|
417
|
+
this.lastHeartbeats.delete(studioId);
|
|
354
418
|
} else if (this.httpStudios.has(studioId)) {
|
|
355
419
|
log.warn(
|
|
356
420
|
`Heartbeat timeout for HTTP studio "${studioId}" \u2014 evicting`
|
|
@@ -374,9 +438,35 @@ var Bridge = class extends EventEmitter {
|
|
|
374
438
|
this.heartbeatTimer = null;
|
|
375
439
|
}
|
|
376
440
|
}
|
|
441
|
+
startPingInterval() {
|
|
442
|
+
if (this.pingTimer) return;
|
|
443
|
+
this.pingTimer = setInterval(() => {
|
|
444
|
+
for (const [studioId, studio] of this.studios) {
|
|
445
|
+
const ws = studio.ws;
|
|
446
|
+
if (ws.isAlive === false) {
|
|
447
|
+
log.warn(
|
|
448
|
+
`Ping timeout for studio "${studioId}" \u2014 terminating connection`
|
|
449
|
+
);
|
|
450
|
+
ws.terminate();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
ws.isAlive = false;
|
|
454
|
+
ws.ping();
|
|
455
|
+
}
|
|
456
|
+
}, PING_INTERVAL_MS);
|
|
457
|
+
}
|
|
458
|
+
stopPingInterval() {
|
|
459
|
+
if (this.pingTimer) {
|
|
460
|
+
clearInterval(this.pingTimer);
|
|
461
|
+
this.pingTimer = null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
377
464
|
// ── HTTP fallback handling ─────────────────────────────────────
|
|
378
465
|
handleHttp(req, res) {
|
|
379
|
-
|
|
466
|
+
const origin = req.headers.origin;
|
|
467
|
+
if (origin && (origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1"))) {
|
|
468
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
469
|
+
}
|
|
380
470
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
381
471
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
382
472
|
if (req.method === "OPTIONS") {
|
|
@@ -386,6 +476,20 @@ var Bridge = class extends EventEmitter {
|
|
|
386
476
|
}
|
|
387
477
|
const parsedUrl = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
388
478
|
const pathname = parsedUrl.pathname;
|
|
479
|
+
if (req.method === "POST" && pathname === "/shutdown") {
|
|
480
|
+
if (req.headers.origin) {
|
|
481
|
+
res.writeHead(403);
|
|
482
|
+
res.end("Forbidden");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
log.info("Received shutdown request from new Conduit instance");
|
|
486
|
+
res.writeHead(200);
|
|
487
|
+
res.end("ok");
|
|
488
|
+
setImmediate(() => {
|
|
489
|
+
this.emit("shutdown");
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
389
493
|
if (req.method === "GET" && pathname === "/health") {
|
|
390
494
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
391
495
|
res.end(
|
|
@@ -393,7 +497,8 @@ var Bridge = class extends EventEmitter {
|
|
|
393
497
|
status: "ok",
|
|
394
498
|
connected: this.isConnected,
|
|
395
499
|
port: this.actualPort,
|
|
396
|
-
studios: this.getStudios()
|
|
500
|
+
studios: this.getStudios(),
|
|
501
|
+
activeStudioId: this.activeStudioId
|
|
397
502
|
})
|
|
398
503
|
);
|
|
399
504
|
return;
|
|
@@ -404,6 +509,11 @@ var Bridge = class extends EventEmitter {
|
|
|
404
509
|
return;
|
|
405
510
|
}
|
|
406
511
|
if (req.method === "POST" && pathname === "/result") {
|
|
512
|
+
if (req.headers.origin) {
|
|
513
|
+
res.writeHead(403);
|
|
514
|
+
res.end("Forbidden");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
407
517
|
this.handleResult(req, res);
|
|
408
518
|
return;
|
|
409
519
|
}
|
|
@@ -418,8 +528,10 @@ var Bridge = class extends EventEmitter {
|
|
|
418
528
|
connectedAt: Date.now()
|
|
419
529
|
};
|
|
420
530
|
this.httpStudios.set(studioId, info);
|
|
531
|
+
this.lastHeartbeats.set(studioId, Date.now());
|
|
421
532
|
this.emit("studio-connected", info);
|
|
422
533
|
log.info(`HTTP-only studio registered: ${studioId}`);
|
|
534
|
+
this.startHeartbeatMonitor();
|
|
423
535
|
}
|
|
424
536
|
this.lastHeartbeats.set(studioId, Date.now());
|
|
425
537
|
if (this.activeStudioId === null) {
|
|
@@ -464,9 +576,22 @@ var Bridge = class extends EventEmitter {
|
|
|
464
576
|
this.httpPollWaiters.set(studioId, waiters);
|
|
465
577
|
}
|
|
466
578
|
handleResult(req, res) {
|
|
467
|
-
|
|
468
|
-
|
|
579
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
580
|
+
const chunks = [];
|
|
581
|
+
let totalSize = 0;
|
|
582
|
+
req.on("data", (chunk) => {
|
|
583
|
+
totalSize += chunk.length;
|
|
584
|
+
if (totalSize > MAX_BODY_SIZE) {
|
|
585
|
+
req.destroy();
|
|
586
|
+
res.writeHead(413);
|
|
587
|
+
res.end("Request body too large");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
chunks.push(chunk);
|
|
591
|
+
});
|
|
469
592
|
req.on("end", () => {
|
|
593
|
+
if (totalSize > MAX_BODY_SIZE) return;
|
|
594
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
470
595
|
try {
|
|
471
596
|
const msg = JSON.parse(body);
|
|
472
597
|
this.handlePluginMessage(msg);
|
|
@@ -520,7 +645,6 @@ function truncateToTokenBudget(text, budget) {
|
|
|
520
645
|
}
|
|
521
646
|
|
|
522
647
|
// src/utils/formatting.ts
|
|
523
|
-
var DEFAULT_TOKEN_BUDGET = 4e3;
|
|
524
648
|
function formatTree(data, depth = 0, indent = "") {
|
|
525
649
|
let line = `${indent}- **${data.name}** \`${data.className}\``;
|
|
526
650
|
if (data.properties && Object.keys(data.properties).length > 0) {
|
|
@@ -528,7 +652,7 @@ function formatTree(data, depth = 0, indent = "") {
|
|
|
528
652
|
line += ` (${props})`;
|
|
529
653
|
}
|
|
530
654
|
let result = line + "\n";
|
|
531
|
-
if (data.children && depth
|
|
655
|
+
if (data.children && depth >= 0) {
|
|
532
656
|
for (const child of data.children) {
|
|
533
657
|
result += formatTree(child, depth - 1, indent + " ");
|
|
534
658
|
}
|
|
@@ -552,8 +676,8 @@ ${source}
|
|
|
552
676
|
\`\`\``;
|
|
553
677
|
}
|
|
554
678
|
function applyTokenBudget(text, maxTokens) {
|
|
555
|
-
|
|
556
|
-
const { text: result } = truncateToTokenBudget(text,
|
|
679
|
+
if (maxTokens === void 0) return text;
|
|
680
|
+
const { text: result } = truncateToTokenBudget(text, maxTokens);
|
|
557
681
|
return result;
|
|
558
682
|
}
|
|
559
683
|
function formatValue(v) {
|
|
@@ -883,6 +1007,9 @@ function registerWrite(server, bridge) {
|
|
|
883
1007
|
sources: params.sources,
|
|
884
1008
|
targetParent: params.targetParent
|
|
885
1009
|
});
|
|
1010
|
+
if (result2.cloned.length === 0) {
|
|
1011
|
+
return { content: [{ type: "text", text: "No instances were cloned." }] };
|
|
1012
|
+
}
|
|
886
1013
|
const text2 = result2.cloned.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
|
|
887
1014
|
return { content: [{ type: "text", text: text2 }] };
|
|
888
1015
|
}
|
|
@@ -892,6 +1019,9 @@ function registerWrite(server, bridge) {
|
|
|
892
1019
|
const result = await bridge.send("create_instances", {
|
|
893
1020
|
operations: params.operations
|
|
894
1021
|
});
|
|
1022
|
+
if (result.created.length === 0) {
|
|
1023
|
+
return { content: [{ type: "text", text: "No instances were created." }] };
|
|
1024
|
+
}
|
|
895
1025
|
const text = result.created.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
|
|
896
1026
|
return { content: [{ type: "text", text }] };
|
|
897
1027
|
}
|
|
@@ -956,6 +1086,9 @@ function registerWrite(server, bridge) {
|
|
|
956
1086
|
const result = await bridge.send("modify_instances", {
|
|
957
1087
|
operations: params.operations
|
|
958
1088
|
});
|
|
1089
|
+
if (result.modified.length === 0) {
|
|
1090
|
+
return { content: [{ type: "text", text: "No instances were modified." }] };
|
|
1091
|
+
}
|
|
959
1092
|
const text = result.modified.map((r) => `- \`${r.path}\` \u2014 modified: ${r.modified.join(", ")}`).join("\n");
|
|
960
1093
|
return { content: [{ type: "text", text }] };
|
|
961
1094
|
}
|
|
@@ -979,6 +1112,9 @@ function registerWrite(server, bridge) {
|
|
|
979
1112
|
const result = await bridge.send("delete_instances", {
|
|
980
1113
|
paths: params.paths
|
|
981
1114
|
});
|
|
1115
|
+
if (result.deleted.length === 0) {
|
|
1116
|
+
return { content: [{ type: "text", text: "No instances were deleted." }] };
|
|
1117
|
+
}
|
|
982
1118
|
const text = result.deleted.map((p) => `- \`${p}\` \u2014 deleted`).join("\n");
|
|
983
1119
|
return { content: [{ type: "text", text }] };
|
|
984
1120
|
}
|
|
@@ -1016,6 +1152,12 @@ function registerReadScript(server, bridge) {
|
|
|
1016
1152
|
}
|
|
1017
1153
|
},
|
|
1018
1154
|
async (params) => {
|
|
1155
|
+
if (params.lineRange && params.lineRange.end < params.lineRange.start) {
|
|
1156
|
+
return {
|
|
1157
|
+
content: [{ type: "text", text: "lineRange.end must be >= lineRange.start." }],
|
|
1158
|
+
isError: true
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1019
1161
|
const result = await bridge.send("read_script", {
|
|
1020
1162
|
path: params.path,
|
|
1021
1163
|
lineRange: params.lineRange
|
|
@@ -1061,6 +1203,30 @@ function registerWriteTools(server, bridge) {
|
|
|
1061
1203
|
}
|
|
1062
1204
|
},
|
|
1063
1205
|
async (params) => {
|
|
1206
|
+
if (params.mode === "range" && params.edits && params.edits.some((e) => e.endLine < e.startLine)) {
|
|
1207
|
+
return {
|
|
1208
|
+
content: [{ type: "text", text: "Range edit has endLine < startLine." }],
|
|
1209
|
+
isError: true
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
if (params.mode === "full" && params.source === void 0) {
|
|
1213
|
+
return {
|
|
1214
|
+
content: [{ type: "text", text: "full mode requires a `source` parameter." }],
|
|
1215
|
+
isError: true
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
if (params.mode === "range" && (!params.edits || params.edits.length === 0)) {
|
|
1219
|
+
return {
|
|
1220
|
+
content: [{ type: "text", text: "range mode requires a non-empty `edits` array." }],
|
|
1221
|
+
isError: true
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
if (params.mode === "find_replace" && !params.find) {
|
|
1225
|
+
return {
|
|
1226
|
+
content: [{ type: "text", text: "find_replace mode requires a `find` parameter." }],
|
|
1227
|
+
isError: true
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1064
1230
|
if (params.mode === "multi_replace") {
|
|
1065
1231
|
if (!params.scripts || params.scripts.length === 0) {
|
|
1066
1232
|
return { content: [{ type: "text", text: "multi_replace mode requires a non-empty `scripts` array." }] };
|
|
@@ -1155,14 +1321,16 @@ ${outputText}
|
|
|
1155
1321
|
|
|
1156
1322
|
// src/tools/playtest.ts
|
|
1157
1323
|
import { z as z4 } from "zod";
|
|
1324
|
+
var playtestStartedAt = null;
|
|
1158
1325
|
function register4(server, bridge) {
|
|
1159
1326
|
server.registerTool(
|
|
1160
1327
|
"playtest",
|
|
1161
1328
|
{
|
|
1162
1329
|
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.",
|
|
1330
|
+
description: "Control Roblox Studio playtesting and simulate user input.\n\nActions:\n- `start`: Begin a playtest session. Defaults to Play mode (F5, full client with player character). Set mode='run' for Run mode (F8, server-only, no player).\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
1331
|
inputSchema: z4.object({
|
|
1165
1332
|
action: z4.enum(["start", "stop", "execute", "get_output", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up"]).describe("Playtest action"),
|
|
1333
|
+
mode: z4.enum(["play", "run"]).default("play").describe("Playtest mode: 'play' (F5, full client with player) or 'run' (F8, server-only). Default: play"),
|
|
1166
1334
|
code: z4.string().optional().describe("Lua code to execute (for 'execute' action)"),
|
|
1167
1335
|
// get_output params
|
|
1168
1336
|
messageTypes: z4.array(z4.string()).optional().describe("Filter by message types: 'MessageOutput', 'MessageWarning', 'MessageError', 'MessageInfo' (for 'get_output')"),
|
|
@@ -1193,9 +1361,10 @@ function register4(server, bridge) {
|
|
|
1193
1361
|
},
|
|
1194
1362
|
async (params) => {
|
|
1195
1363
|
if (params.action === "get_output") {
|
|
1364
|
+
const since = params.since ?? playtestStartedAt ?? void 0;
|
|
1196
1365
|
const result2 = await bridge.send("get_log_output", {
|
|
1197
1366
|
messageTypes: params.messageTypes,
|
|
1198
|
-
since
|
|
1367
|
+
since,
|
|
1199
1368
|
limit: params.limit
|
|
1200
1369
|
});
|
|
1201
1370
|
if (result2.logs.length === 0) {
|
|
@@ -1245,6 +1414,18 @@ ${result2.message}` : ""}`;
|
|
|
1245
1414
|
return { content: [{ type: "text", text: text2 }] };
|
|
1246
1415
|
}
|
|
1247
1416
|
if (params.action === "mouse_click" || params.action === "mouse_move" || params.action === "key_press" || params.action === "key_down" || params.action === "key_up") {
|
|
1417
|
+
if ((params.action === "mouse_click" || params.action === "mouse_move") && (params.x === void 0 || params.y === void 0)) {
|
|
1418
|
+
return {
|
|
1419
|
+
content: [{ type: "text", text: `${params.action} requires \`x\` and \`y\` parameters.` }],
|
|
1420
|
+
isError: true
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
if ((params.action === "key_press" || params.action === "key_down" || params.action === "key_up") && !params.key) {
|
|
1424
|
+
return {
|
|
1425
|
+
content: [{ type: "text", text: `${params.action} requires a \`key\` parameter.` }],
|
|
1426
|
+
isError: true
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1248
1429
|
const result2 = await bridge.send("virtual_input", {
|
|
1249
1430
|
action: params.action,
|
|
1250
1431
|
x: params.x,
|
|
@@ -1258,10 +1439,22 @@ ${result2.message}` : ""}`;
|
|
|
1258
1439
|
]
|
|
1259
1440
|
};
|
|
1260
1441
|
}
|
|
1442
|
+
if (params.action === "execute" && !params.code) {
|
|
1443
|
+
return {
|
|
1444
|
+
content: [{ type: "text", text: "execute action requires a `code` parameter." }],
|
|
1445
|
+
isError: true
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1261
1448
|
const result = await bridge.send("playtest", {
|
|
1262
1449
|
action: params.action,
|
|
1263
|
-
code: params.code
|
|
1450
|
+
code: params.code,
|
|
1451
|
+
mode: params.mode
|
|
1264
1452
|
});
|
|
1453
|
+
if (params.action === "start" && result.status === "started") {
|
|
1454
|
+
playtestStartedAt = Date.now() / 1e3;
|
|
1455
|
+
} else if (params.action === "stop" && result.status === "stopped") {
|
|
1456
|
+
playtestStartedAt = null;
|
|
1457
|
+
}
|
|
1265
1458
|
let text;
|
|
1266
1459
|
if (params.action === "execute") {
|
|
1267
1460
|
const lines = [];
|
|
@@ -1281,7 +1474,10 @@ ${outputText}
|
|
|
1281
1474
|
text = lines.length > 0 ? lines.join("\n\n") : `Execution completed (${result.status})`;
|
|
1282
1475
|
text = applyTokenBudget(text, void 0);
|
|
1283
1476
|
} else {
|
|
1284
|
-
|
|
1477
|
+
const modeInfo = result.mode ? ` (${result.mode} mode)` : "";
|
|
1478
|
+
const extra = result.message ? `
|
|
1479
|
+
${result.message}` : "";
|
|
1480
|
+
text = `Playtest ${params.action}${modeInfo}: ${result.status}${extra}`;
|
|
1285
1481
|
}
|
|
1286
1482
|
return { content: [{ type: "text", text }] };
|
|
1287
1483
|
}
|
|
@@ -1343,6 +1539,14 @@ function register5(server, bridge) {
|
|
|
1343
1539
|
const text2 = `Updated ${result2.modified?.length ?? 0} setting(s): ${result2.modified?.join(", ") ?? "none"}`;
|
|
1344
1540
|
return { content: [{ type: "text", text: text2 }] };
|
|
1345
1541
|
}
|
|
1542
|
+
if (params.action === "terrain_fill") {
|
|
1543
|
+
if (!params.region) {
|
|
1544
|
+
return { content: [{ type: "text", text: "terrain_fill requires a `region` parameter." }], isError: true };
|
|
1545
|
+
}
|
|
1546
|
+
if (!params.material) {
|
|
1547
|
+
return { content: [{ type: "text", text: "terrain_fill requires a `material` parameter." }], isError: true };
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1346
1550
|
const terrainAction = params.action.replace("terrain_", "");
|
|
1347
1551
|
const result = await bridge.send("terrain", {
|
|
1348
1552
|
action: terrainAction,
|
|
@@ -1666,6 +1870,10 @@ function formatSearchResults(results) {
|
|
|
1666
1870
|
|
|
1667
1871
|
// src/tools/utility.ts
|
|
1668
1872
|
function register7(server, bridge) {
|
|
1873
|
+
const transactionState = /* @__PURE__ */ new Map();
|
|
1874
|
+
bridge.on("studio-disconnected", (info) => {
|
|
1875
|
+
transactionState.delete(info.studioId);
|
|
1876
|
+
});
|
|
1669
1877
|
server.registerTool(
|
|
1670
1878
|
"undo_redo",
|
|
1671
1879
|
{
|
|
@@ -1754,7 +1962,7 @@ function register7(server, bridge) {
|
|
|
1754
1962
|
"transaction",
|
|
1755
1963
|
{
|
|
1756
1964
|
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
|
|
1965
|
+
description: "Group multiple mutating tool calls into a single Ctrl+Z undo point.\n\nUsage: ALWAYS call `begin` first, then make your changes, then call `commit` or `rollback`. Calling `commit` or `rollback` without a prior `begin` will error.\n\nActions:\n- `begin`: Start a transaction. All subsequent writes share one undo recording.\n- `commit`: Finish an open transaction and commit all changes as one undo point. Requires a prior `begin`.\n- `rollback`: Cancel an open transaction and undo all changes made since begin. Requires a prior `begin`.\n\nTransactions auto-rollback after 60 seconds if not committed.",
|
|
1758
1966
|
inputSchema: z7.object({
|
|
1759
1967
|
action: z7.enum(["begin", "commit", "rollback"]).describe("Transaction action"),
|
|
1760
1968
|
name: z7.string().optional().describe("Transaction name for the undo history (for 'begin' action)")
|
|
@@ -1767,31 +1975,53 @@ function register7(server, bridge) {
|
|
|
1767
1975
|
}
|
|
1768
1976
|
},
|
|
1769
1977
|
async (params) => {
|
|
1978
|
+
const studioId = bridge.getActiveStudioId() ?? "_default";
|
|
1770
1979
|
if (params.action === "begin") {
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1980
|
+
try {
|
|
1981
|
+
const result = await bridge.send("begin_transaction", {
|
|
1982
|
+
name: params.name
|
|
1983
|
+
});
|
|
1984
|
+
transactionState.set(studioId, true);
|
|
1985
|
+
return {
|
|
1986
|
+
content: [
|
|
1987
|
+
{ type: "text", text: `Transaction started: **${result.transactionId}**
|
|
1988
|
+
All subsequent writes will be grouped into one undo point. Call commit or rollback to finish.` }
|
|
1989
|
+
]
|
|
1990
|
+
};
|
|
1991
|
+
} catch (err) {
|
|
1992
|
+
transactionState.set(studioId, false);
|
|
1993
|
+
throw err;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (!transactionState.get(studioId)) {
|
|
1774
1997
|
return {
|
|
1775
1998
|
content: [
|
|
1776
|
-
{ type: "text", text: `
|
|
1777
|
-
All subsequent writes will be grouped into one undo point. Call commit or rollback to finish.` }
|
|
1999
|
+
{ type: "text", text: `No active transaction. Call \`begin\` first before calling \`${params.action}\`.` }
|
|
1778
2000
|
]
|
|
1779
2001
|
};
|
|
1780
2002
|
}
|
|
1781
2003
|
if (params.action === "commit") {
|
|
1782
|
-
|
|
2004
|
+
try {
|
|
2005
|
+
const result = await bridge.send("commit_transaction", {});
|
|
2006
|
+
return {
|
|
2007
|
+
content: [
|
|
2008
|
+
{ type: "text", text: `Transaction ${result.status}. All changes are now a single undo point.` }
|
|
2009
|
+
]
|
|
2010
|
+
};
|
|
2011
|
+
} finally {
|
|
2012
|
+
transactionState.set(studioId, false);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
const result = await bridge.send("rollback_transaction", {});
|
|
1783
2017
|
return {
|
|
1784
2018
|
content: [
|
|
1785
|
-
{ type: "text", text: `Transaction ${
|
|
2019
|
+
{ type: "text", text: `Transaction ${result.status}. All changes since begin have been reverted.` }
|
|
1786
2020
|
]
|
|
1787
2021
|
};
|
|
2022
|
+
} finally {
|
|
2023
|
+
transactionState.set(studioId, false);
|
|
1788
2024
|
}
|
|
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
2025
|
}
|
|
1796
2026
|
);
|
|
1797
2027
|
}
|
|
@@ -1853,7 +2083,15 @@ ${lines.join("\n")}`;
|
|
|
1853
2083
|
}
|
|
1854
2084
|
},
|
|
1855
2085
|
async (params) => {
|
|
1856
|
-
|
|
2086
|
+
try {
|
|
2087
|
+
bridge.setActiveStudio(params.studioId);
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2090
|
+
return {
|
|
2091
|
+
content: [{ type: "text", text: message }],
|
|
2092
|
+
isError: true
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
1857
2095
|
const studios = bridge.getStudios();
|
|
1858
2096
|
const studio = studios.find((s) => s.studioId === params.studioId);
|
|
1859
2097
|
const name = studio?.placeName ?? "unknown";
|
|
@@ -1883,7 +2121,13 @@ function ensureDir() {
|
|
|
1883
2121
|
mkdirSync(BUILDS_DIR, { recursive: true });
|
|
1884
2122
|
}
|
|
1885
2123
|
}
|
|
2124
|
+
function validateBuildName(name) {
|
|
2125
|
+
if (!/^[\w-]+$/.test(name)) {
|
|
2126
|
+
throw new Error(`Invalid build name "${name}". Names must match /^[\\w-]+$/.`);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
1886
2129
|
function saveBuild(name, root, description) {
|
|
2130
|
+
validateBuildName(name);
|
|
1887
2131
|
ensureDir();
|
|
1888
2132
|
const rootObj = root;
|
|
1889
2133
|
const data = {
|
|
@@ -1903,6 +2147,7 @@ function saveBuild(name, root, description) {
|
|
|
1903
2147
|
};
|
|
1904
2148
|
}
|
|
1905
2149
|
function loadBuild(name) {
|
|
2150
|
+
validateBuildName(name);
|
|
1906
2151
|
const filePath = join(BUILDS_DIR, `${name}.json`);
|
|
1907
2152
|
if (!existsSync(filePath)) {
|
|
1908
2153
|
throw new Error(`Build "${name}" not found. Use builds --action list to see available builds.`);
|
|
@@ -2027,7 +2272,7 @@ function register9(server, bridge) {
|
|
|
2027
2272
|
}
|
|
2028
2273
|
|
|
2029
2274
|
// src/tools/index.ts
|
|
2030
|
-
function registerAllTools(server, bridge, options = {}) {
|
|
2275
|
+
async function registerAllTools(server, bridge, options = {}) {
|
|
2031
2276
|
const mode = options.mode ?? "full";
|
|
2032
2277
|
register8(server, bridge);
|
|
2033
2278
|
register(server, bridge);
|
|
@@ -2044,10 +2289,20 @@ function registerAllTools(server, bridge, options = {}) {
|
|
|
2044
2289
|
registerReadOnly2(server, bridge);
|
|
2045
2290
|
}
|
|
2046
2291
|
if (options.withCloud) {
|
|
2047
|
-
|
|
2292
|
+
try {
|
|
2293
|
+
const mod = await import("./cloud-4AYJVND6.js");
|
|
2294
|
+
mod.register(server);
|
|
2295
|
+
} catch (err) {
|
|
2296
|
+
log.error("Failed to load cloud module:", err);
|
|
2297
|
+
}
|
|
2048
2298
|
}
|
|
2049
2299
|
if (options.withRojo) {
|
|
2050
|
-
|
|
2300
|
+
try {
|
|
2301
|
+
const mod = await import("./rojo-2CMKPTPS.js");
|
|
2302
|
+
mod.register(server);
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
log.error("Failed to load rojo module:", err);
|
|
2305
|
+
}
|
|
2051
2306
|
}
|
|
2052
2307
|
}
|
|
2053
2308
|
|
|
@@ -2088,7 +2343,7 @@ async function startServer(port = 3200, options = {}) {
|
|
|
2088
2343
|
withCloud: options.withCloud,
|
|
2089
2344
|
withRojo: options.withRojo
|
|
2090
2345
|
};
|
|
2091
|
-
registerAllTools(server, bridge, toolOptions);
|
|
2346
|
+
await registerAllTools(server, bridge, toolOptions);
|
|
2092
2347
|
bridge.on("studio-connected", (info) => {
|
|
2093
2348
|
log.info(
|
|
2094
2349
|
`Roblox Studio connected: ${info.studioId}` + (info.placeName ? ` (${info.placeName})` : "")
|
|
@@ -2104,12 +2359,16 @@ async function startServer(port = 3200, options = {}) {
|
|
|
2104
2359
|
const transport = new StdioServerTransport();
|
|
2105
2360
|
await server.connect(transport);
|
|
2106
2361
|
log.info("MCP server connected via stdio");
|
|
2362
|
+
let shuttingDown = false;
|
|
2107
2363
|
const shutdown = async () => {
|
|
2364
|
+
if (shuttingDown) return;
|
|
2365
|
+
shuttingDown = true;
|
|
2108
2366
|
log.info("Shutting down...");
|
|
2109
2367
|
await bridge.stop();
|
|
2110
2368
|
await server.close();
|
|
2111
2369
|
process.exit(0);
|
|
2112
2370
|
};
|
|
2371
|
+
bridge.on("shutdown", shutdown);
|
|
2113
2372
|
process.on("SIGINT", shutdown);
|
|
2114
2373
|
process.on("SIGTERM", shutdown);
|
|
2115
2374
|
}
|
|
@@ -2117,4 +2376,4 @@ async function startServer(port = 3200, options = {}) {
|
|
|
2117
2376
|
export {
|
|
2118
2377
|
startServer
|
|
2119
2378
|
};
|
|
2120
|
-
//# sourceMappingURL=chunk-
|
|
2379
|
+
//# sourceMappingURL=chunk-HI6KFLGA.js.map
|