@adiholick/nvstudio-mcp 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -22,12 +22,13 @@ function backupScript(target, code) {
22
22
  const fileName = `${safeTargetName}_${timestamp}.luau`;
23
23
  fs_1.default.writeFileSync(path_1.default.join(HISTORY_DIR, fileName), code);
24
24
  }
25
+ const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
25
26
  // 1. Impor dan jalankan server Express lokal di port 3055
26
27
  (0, roblox_bridge_1.startBridgeServer)(3055);
27
28
  // 2. Inisialisasi server MCP
28
29
  const server = new index_js_1.Server({
29
30
  name: "nvstudio-mcp",
30
- version: "2.0.1",
31
+ version: "2.1.0",
31
32
  }, {
32
33
  capabilities: {
33
34
  tools: {},
@@ -134,12 +135,46 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
134
135
  };
135
136
  }
136
137
  }
137
- // 8. Teruskan ke antrean dan kembalikan hasilnya sebagai string JSON
138
+ // 8. Teruskan ke antrean tugas
138
139
  try {
139
140
  if (command === "update_script_source" && data) {
140
141
  backupScript(target, String(data));
141
142
  }
142
- const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
143
+ let result;
144
+ if (roblox_bridge_1.isBridgeHosting) {
145
+ // Instance ini sendiri yang meng-host bridge server
146
+ result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
147
+ }
148
+ else {
149
+ // Bridge server aktif di proses background lain (port 3055)
150
+ let forwardedToBridge = false;
151
+ try {
152
+ const controller = new AbortController();
153
+ const timeoutId = setTimeout(() => controller.abort(), 35000);
154
+ const bridgeRes = await fetch("http://localhost:3055/api/tasks/enqueue", {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify({ command, target, data }),
158
+ signal: controller.signal
159
+ });
160
+ clearTimeout(timeoutId);
161
+ if (bridgeRes.ok) {
162
+ forwardedToBridge = true;
163
+ const bridgeJson = await bridgeRes.json();
164
+ if (bridgeJson.status === "error") {
165
+ throw new Error(bridgeJson.error);
166
+ }
167
+ result = bridgeJson.result;
168
+ }
169
+ }
170
+ catch (err) {
171
+ if (forwardedToBridge) {
172
+ throw err;
173
+ }
174
+ // Fallback in-process jika HTTP gagal dihubungi
175
+ result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
176
+ }
177
+ }
143
178
  return {
144
179
  content: [
145
180
  {
@@ -164,16 +199,19 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
164
199
  };
165
200
  }
166
201
  });
167
- // 8. Jalankan koneksi MCP menggunakan StdioServerTransport
202
+ // 8. Jalankan koneksi MCP menggunakan StdioServerTransport (hanya jika bukan mode Daemon)
168
203
  async function main() {
169
204
  const transport = new stdio_js_1.StdioServerTransport();
170
205
  await server.connect(transport);
171
206
  console.error("[nvstudio-mcp] MCP Server aktif melalui StdioServerTransport.");
207
+ // Beritahu SSE plugin bahwa MCP agent sekarang aktif
208
+ (0, roblox_bridge_1.setMcpConnected)(true);
172
209
  let isShuttingDown = false;
173
210
  const shutdown = () => {
174
211
  if (isShuttingDown)
175
212
  return;
176
213
  isShuttingDown = true;
214
+ (0, roblox_bridge_1.setMcpConnected)(false); // Beritahu SSE plugin bahwa MCP agent sudah menutup koneksi
177
215
  console.error("[nvstudio-mcp] Koneksi ditutup oleh IDE. Mematikan server...");
178
216
  process.exit(0);
179
217
  };
@@ -182,7 +220,6 @@ async function main() {
182
220
  process.stdin.on('end', shutdown);
183
221
  process.stdin.on('error', shutdown);
184
222
  // Fallback khusus Windows: cek apakah stdin masih readable secara berkala
185
- // process.kill(ppid, 0) tidak reliable di Windows, jadi kita pakai cara ini
186
223
  setInterval(() => {
187
224
  if (!process.stdin.readable) {
188
225
  console.error("[nvstudio-mcp] stdin tidak readable lagi. IDE kemungkinan sudah ditutup.");
@@ -190,7 +227,14 @@ async function main() {
190
227
  }
191
228
  }, 3000);
192
229
  }
193
- main().catch((err) => {
194
- console.error("[nvstudio-mcp] Gagal menjalankan MCP server:", err);
195
- process.exit(1);
196
- });
230
+ if (isDaemon) {
231
+ console.error("[nvstudio-mcp] Berjalan dalam mode DAEMON (Background Bridge Server di port 3055).");
232
+ process.on('SIGINT', () => process.exit(0));
233
+ process.on('SIGTERM', () => process.exit(0));
234
+ }
235
+ else {
236
+ main().catch((err) => {
237
+ console.error("[nvstudio-mcp] Gagal menjalankan MCP server:", err);
238
+ process.exit(1);
239
+ });
240
+ }
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isBridgeHosting = void 0;
7
+ exports.setMcpConnected = setMcpConnected;
6
8
  exports.startBridgeServer = startBridgeServer;
7
9
  const express_1 = __importDefault(require("express"));
8
10
  const http_1 = require("http");
@@ -10,11 +12,65 @@ const socket_io_1 = require("socket.io");
10
12
  const path_1 = __importDefault(require("path"));
11
13
  const child_process_1 = require("child_process");
12
14
  const task_queue_1 = require("./task-queue");
15
+ exports.isBridgeHosting = false;
13
16
  const activityLogs = [];
14
17
  let totalTasks = 0;
15
18
  let successCount = 0;
16
19
  let errorCount = 0;
17
20
  const sessionStart = new Date().toISOString();
21
+ const ssePeers = new Map();
22
+ // Apakah MCP agent (Antigravity/Cursor) sedang terhubung ke stdio transport?
23
+ let mcpConnected = false;
24
+ function setMcpConnected(connected) {
25
+ mcpConnected = connected;
26
+ broadcastStatus();
27
+ }
28
+ /**
29
+ * Kirim event SSE ke semua Studio peer yang terhubung.
30
+ */
31
+ function broadcastEvent(kind, payload) {
32
+ const data = JSON.stringify({ kind, ...payload });
33
+ for (const peer of ssePeers.values()) {
34
+ try {
35
+ peer.res.write(`data: ${data}\n\n`);
36
+ }
37
+ catch { /* peer disconnected */ }
38
+ }
39
+ }
40
+ /**
41
+ * Kirim event SSE ke satu peer tertentu.
42
+ */
43
+ function sendEvent(peer, kind, payload) {
44
+ const data = JSON.stringify({ kind, ...payload });
45
+ try {
46
+ peer.res.write(`data: ${data}\n\n`);
47
+ }
48
+ catch { /* peer disconnected */ }
49
+ }
50
+ /**
51
+ * Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
52
+ */
53
+ function broadcastStatus() {
54
+ broadcastEvent('status', {
55
+ mcpConnected,
56
+ studioCount: ssePeers.size,
57
+ serverVersion: '2.1.0',
58
+ });
59
+ }
60
+ /**
61
+ * Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
62
+ * Hanya satu yang akan memprosesnya (first-come first-served via requestId).
63
+ */
64
+ function broadcastTask(task) {
65
+ broadcastEvent('request', {
66
+ requestId: task.id,
67
+ command: task.command,
68
+ target: task.target,
69
+ data: task.data ?? null,
70
+ remainingMs: 29000,
71
+ });
72
+ }
73
+ // ── Logging ───────────────────────────────────────────────────────────────────
18
74
  function addLog(io, type, message) {
19
75
  const log = {
20
76
  id: Date.now().toString(),
@@ -30,7 +86,6 @@ function addLog(io, type, message) {
30
86
  }
31
87
  /**
32
88
  * Membunuh proses zombie yang masih menyandera port tertentu.
33
- * Ini menyelesaikan masalah EADDRINUSE yang terjadi setelah restart IDE.
34
89
  */
35
90
  function killZombieOnPort(port) {
36
91
  try {
@@ -51,7 +106,6 @@ function killZombieOnPort(port) {
51
106
  return true;
52
107
  }
53
108
  else {
54
- // macOS / Linux
55
109
  const output = (0, child_process_1.execSync)(`lsof -ti :${port}`, { encoding: 'utf8', timeout: 5000 });
56
110
  const pids = output.trim().split('\n');
57
111
  for (const pid of pids) {
@@ -66,109 +120,232 @@ function killZombieOnPort(port) {
66
120
  }
67
121
  }
68
122
  catch {
69
- // Tidak ada proses yang menyandera port — ini normal
70
123
  return false;
71
124
  }
72
125
  }
73
126
  function startBridgeServer(port = 3055) {
74
127
  const app = (0, express_1.default)();
75
128
  const httpServer = (0, http_1.createServer)(app);
76
- const io = new socket_io_1.Server(httpServer, { cors: { origin: "*" } });
129
+ const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
77
130
  app.use(express_1.default.json({ limit: '50mb' }));
78
131
  app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
79
132
  let dashboardOpened = false;
80
- let studioConnected = false;
81
- // Provide initial state to newly connected dashboard clients
133
+ // Sediakan initial state ke dashboard browser baru
82
134
  io.on('connection', (socket) => {
83
135
  socket.emit('init', {
84
136
  logs: activityLogs,
85
- stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected, port },
137
+ stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: ssePeers.size > 0, port },
86
138
  });
87
139
  });
88
- // API endpoint to get current stats
140
+ // ── /api/stats ─────────────────────────────────────────────────────────────
89
141
  app.get('/api/stats', (req, res) => {
90
- res.json({ totalTasks, successCount, errorCount, sessionStart, studioConnected, port });
142
+ res.json({
143
+ totalTasks, successCount, errorCount, sessionStart,
144
+ studioConnected: ssePeers.size > 0,
145
+ studioCount: ssePeers.size,
146
+ mcpConnected,
147
+ port,
148
+ });
91
149
  });
92
- const waitingPollers = [];
93
- task_queue_1.taskEmitter.on('new_task', () => {
94
- if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
95
- const res = waitingPollers.shift();
96
- const task = task_queue_1.taskQueue.shift();
97
- totalTasks++;
98
- addLog(io, 'task', `Mengirim perintah '${task?.command}' ke Studio${task?.target ? ` → ${task.target}` : ''}.`);
99
- res?.json(task);
100
- }
150
+ // ── /api/ping ─────────────────────────────────────────────────────────────
151
+ // Dipertahankan untuk backward-compat (installer check, health check).
152
+ app.get('/api/ping', (req, res) => {
153
+ res.json({ status: 'ok', studioCount: ssePeers.size, mcpConnected, timestamp: Date.now() });
101
154
  });
102
- // Endpoint Studio Polling
103
- app.get('/api/tasks', (req, res) => {
104
- if (!studioConnected) {
105
- studioConnected = true;
106
- io.emit('studio-status', { connected: true });
107
- addLog(io, 'system', 'Roblox Studio terhubung ke Bridge Server.');
155
+ // ── /api/stream (SSE endpoint utama) ─────────────────────────────────────
156
+ app.get('/api/stream', (req, res) => {
157
+ const studioId = req.query.studioId || `anon-${Date.now()}`;
158
+ // Set SSE headers
159
+ res.setHeader('Content-Type', 'text/event-stream');
160
+ res.setHeader('Cache-Control', 'no-cache');
161
+ res.setHeader('Connection', 'keep-alive');
162
+ res.setHeader('X-Accel-Buffering', 'no'); // Untuk Nginx proxy
163
+ res.flushHeaders();
164
+ // Heartbeat setiap 5 detik agar koneksi tidak timeout
165
+ const heartbeatTimer = setInterval(() => {
166
+ try {
167
+ res.write(`data: ${JSON.stringify({ kind: 'heartbeat', timestamp: Date.now() })}\n\n`);
168
+ }
169
+ catch {
170
+ cleanup();
171
+ }
172
+ }, 5000);
173
+ // Silence timeout: jika kita sendiri tidak bisa kirim apapun dalam 30 detik, bersihkan
174
+ let silenceTimer = setTimeout(() => cleanup(), 30000);
175
+ const resetSilence = () => {
176
+ clearTimeout(silenceTimer);
177
+ silenceTimer = setTimeout(() => cleanup(), 30000);
178
+ };
179
+ const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
180
+ ssePeers.set(studioId, peer);
181
+ // Kirim status awal sesaat setelah koneksi
182
+ sendEvent(peer, 'status', {
183
+ mcpConnected,
184
+ studioCount: ssePeers.size,
185
+ serverVersion: '2.1.0',
186
+ });
187
+ // Jika ada task yang sudah antri sebelum Studio terhubung, kirim sekarang
188
+ if (task_queue_1.taskQueue.length > 0) {
189
+ const task = task_queue_1.taskQueue[0]; // preview saja, jangan shift di sini (broadcastTask akan handle)
190
+ sendEvent(peer, 'request', {
191
+ requestId: task.id,
192
+ command: task.command,
193
+ target: task.target,
194
+ data: task.data ?? null,
195
+ remainingMs: 29000,
196
+ });
108
197
  }
198
+ io.emit('studio-status', { connected: true, studioCount: ssePeers.size, studioId });
199
+ addLog(io, 'system', `Roblox Studio terhubung via SSE. (ID: ${studioId.substring(0, 8)}…, Peer aktif: ${ssePeers.size})`);
109
200
  if (!dashboardOpened) {
110
201
  dashboardOpened = true;
111
202
  const url = `http://localhost:${port}`;
112
203
  const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
113
- (0, child_process_1.exec)(startCmd, (error) => {
114
- if (error)
115
- console.error(`[Bridge] Gagal membuka browser:`, error);
116
- });
117
- console.error(`[Bridge] Pertama kali terhubung dengan Studio! Membuka Dashboard...`);
204
+ const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
205
+ if (!isDaemon) {
206
+ (0, child_process_1.exec)(startCmd, (error) => {
207
+ if (error)
208
+ console.error(`[Bridge] Gagal membuka browser:`, error);
209
+ });
210
+ console.error(`[Bridge] Studio terhubung pertama kali! Membuka Dashboard...`);
211
+ }
118
212
  }
119
- if (task_queue_1.taskQueue.length > 0) {
120
- const task = task_queue_1.taskQueue.shift();
121
- totalTasks++;
122
- addLog(io, 'task', `Mengirim perintah '${task?.command}' ke Studio${task?.target ? ` → ${task.target}` : ''}.`);
123
- res.json(task);
213
+ function cleanup() {
214
+ clearInterval(heartbeatTimer);
215
+ clearTimeout(silenceTimer);
216
+ ssePeers.delete(studioId);
217
+ io.emit('studio-status', { connected: ssePeers.size > 0, studioCount: ssePeers.size, studioId });
218
+ addLog(io, 'system', `Roblox Studio terputus. (ID: ${studioId.substring(0, 8)}…, Peer tersisa: ${ssePeers.size})`);
219
+ try {
220
+ res.end();
221
+ }
222
+ catch { /* sudah tutup */ }
223
+ }
224
+ req.on('close', cleanup);
225
+ req.on('error', cleanup);
226
+ // Semua write berhasil → reset silence timer
227
+ const origWrite = res.write.bind(res);
228
+ res.write = (...args) => {
229
+ const result = origWrite(...args);
230
+ resetSilence();
231
+ return result;
232
+ };
233
+ });
234
+ // ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
235
+ app.post('/api/response/:taskId', (req, res) => {
236
+ const { taskId } = req.params;
237
+ const { status, result, error } = req.body;
238
+ if (status === 'success') {
239
+ successCount++;
240
+ addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
124
241
  }
125
242
  else {
126
- // Long-polling: tunggu maksimal 20 detik
127
- const timeoutId = setTimeout(() => {
128
- const index = waitingPollers.indexOf(res);
129
- if (index !== -1) {
130
- waitingPollers.splice(index, 1);
131
- res.json({ id: null });
132
- }
133
- }, 20000);
134
- waitingPollers.push(res);
135
- // Bersihkan jika terputus prematur
136
- req.on('close', () => {
137
- clearTimeout(timeoutId);
138
- const index = waitingPollers.indexOf(res);
139
- if (index !== -1) {
140
- waitingPollers.splice(index, 1);
141
- }
142
- });
243
+ errorCount++;
244
+ addLog(io, 'error', `Gagal: ${error}`);
143
245
  }
246
+ (0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
247
+ res.json({ ok: true });
144
248
  });
145
- // Endpoint Studio Response
249
+ // ── /api/response (legacy — lama, tetap didukung) ────────────────────────
146
250
  app.post('/api/response', (req, res) => {
147
251
  const { id, status, result, error } = req.body;
148
252
  if (status === 'success') {
149
253
  successCount++;
150
- addLog(io, 'response', `Perintah selesai: ${String(result).substring(0, 120)}`);
254
+ addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
151
255
  }
152
256
  else {
153
257
  errorCount++;
154
258
  addLog(io, 'error', `Gagal: ${error}`);
155
259
  }
156
260
  (0, task_queue_1.resolvePendingTask)(id, status, result, error);
157
- res.json({ message: "Response diterima" });
261
+ res.json({ ok: true });
262
+ });
263
+ // ── Push task ke SSE stream saat ada task baru ────────────────────────────
264
+ task_queue_1.taskEmitter.on('new_task', () => {
265
+ if (task_queue_1.taskQueue.length === 0)
266
+ return;
267
+ if (ssePeers.size === 0) {
268
+ // Tidak ada Studio terhubung — task tetap antri, akan dikirim saat Studio connect
269
+ return;
270
+ }
271
+ const task = task_queue_1.taskQueue.shift();
272
+ totalTasks++;
273
+ addLog(io, 'task', `Mengirim perintah '${task.command}' ke Studio${task.target ? ` → ${task.target}` : ''}.`);
274
+ broadcastTask(task);
158
275
  });
159
- // ── ANTI-ZOMBIE: Coba listen, jika gagal bunuh zombie lalu retry ──
276
+ // ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
277
+ app.post('/api/tasks/enqueue', async (req, res) => {
278
+ const { command, target, data } = req.body;
279
+ try {
280
+ const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
281
+ res.json({ status: 'success', result });
282
+ }
283
+ catch (err) {
284
+ res.json({ status: 'error', error: err.message });
285
+ }
286
+ });
287
+ // ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
288
+ // Studio lama yang belum update plugin masih bisa bekerja via polling.
289
+ const waitingPollers = [];
290
+ task_queue_1.taskEmitter.on('new_task_legacy', () => {
291
+ if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
292
+ const pollRes = waitingPollers.shift();
293
+ const task = task_queue_1.taskQueue.shift();
294
+ totalTasks++;
295
+ addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
296
+ pollRes?.json(task);
297
+ }
298
+ });
299
+ app.get('/api/tasks', (req, res) => {
300
+ if (task_queue_1.taskQueue.length > 0) {
301
+ const task = task_queue_1.taskQueue.shift();
302
+ totalTasks++;
303
+ addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
304
+ return res.json(task);
305
+ }
306
+ const timeoutId = setTimeout(() => {
307
+ const index = waitingPollers.indexOf(res);
308
+ if (index !== -1) {
309
+ waitingPollers.splice(index, 1);
310
+ res.json({ id: null });
311
+ }
312
+ }, 20000);
313
+ waitingPollers.push(res);
314
+ req.on('close', () => {
315
+ clearTimeout(timeoutId);
316
+ const index = waitingPollers.indexOf(res);
317
+ if (index !== -1)
318
+ waitingPollers.splice(index, 1);
319
+ });
320
+ });
321
+ // ── Port-sharing & anti-zombie ────────────────────────────────────────────
160
322
  function tryListen(retriesLeft = 2) {
161
323
  const server = httpServer.listen(port, () => {
162
- console.error(`[Bridge] Dashboard visual & server aktif di http://localhost:${port}`);
324
+ exports.isBridgeHosting = true;
325
+ console.error(`[Bridge] SSE Bridge Server aktif di http://localhost:${port}`);
163
326
  });
164
- server.on('error', (err) => {
165
- if (err.code === 'EADDRINUSE' && retriesLeft > 0) {
166
- console.error(`[Bridge] Port ${port} sedang digunakan oleh proses zombie. Mencoba membunuh...`);
167
- killZombieOnPort(port);
168
- setTimeout(() => {
169
- console.error(`[Bridge] Mencoba listen ulang pada port ${port}... (sisa ${retriesLeft - 1} percobaan)`);
170
- tryListen(retriesLeft - 1);
171
- }, 1500);
327
+ server.on('error', async (err) => {
328
+ if (err.code === 'EADDRINUSE') {
329
+ try {
330
+ const controller = new AbortController();
331
+ const timeoutId = setTimeout(() => controller.abort(), 1000);
332
+ const pingRes = await fetch(`http://localhost:${port}/api/ping`, { signal: controller.signal });
333
+ clearTimeout(timeoutId);
334
+ if (pingRes.ok) {
335
+ exports.isBridgeHosting = false;
336
+ console.error(`[Bridge] Port ${port} sudah digunakan oleh NVStudio Bridge aktif. Menggunakan instance yang sudah berjalan.`);
337
+ return;
338
+ }
339
+ }
340
+ catch { /* bukan NVStudio bridge */ }
341
+ if (retriesLeft > 0) {
342
+ console.error(`[Bridge] Port ${port} zombie. Mencoba membunuh...`);
343
+ killZombieOnPort(port);
344
+ setTimeout(() => tryListen(retriesLeft - 1), 1500);
345
+ }
346
+ else {
347
+ console.error(`[Bridge] FATAL: Port ${port} tidak dapat dibuka.`);
348
+ }
172
349
  }
173
350
  else {
174
351
  console.error(`[Bridge] FATAL: Gagal mendengarkan port ${port}:`, err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": {