@adiholick/nvstudio-mcp 2.0.4 → 2.1.1

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.4",
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,87 @@ 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
+ // Per-studio pending events queue: events yang belum diambil oleh Studio
22
+ const studioEventQueues = new Map();
23
+ function enqueueForStudio(studioId, event) {
24
+ if (studioId) {
25
+ // Event untuk studio tertentu
26
+ if (!studioEventQueues.has(studioId))
27
+ studioEventQueues.set(studioId, []);
28
+ studioEventQueues.get(studioId).push(event);
29
+ }
30
+ else {
31
+ // Broadcast ke semua studio yang terdaftar
32
+ for (const [sid] of studioEventQueues) {
33
+ studioEventQueues.get(sid).push(event);
34
+ }
35
+ }
36
+ }
37
+ const ssePeers = new Map();
38
+ // Apakah MCP agent (Antigravity/Cursor) sedang terhubung ke stdio transport?
39
+ let mcpConnected = false;
40
+ function setMcpConnected(connected) {
41
+ mcpConnected = connected;
42
+ broadcastStatus();
43
+ }
44
+ /**
45
+ * Kirim event SSE ke semua Studio peer yang terhubung.
46
+ */
47
+ function broadcastEvent(kind, payload) {
48
+ const data = JSON.stringify({ kind, ...payload });
49
+ for (const peer of ssePeers.values()) {
50
+ try {
51
+ peer.res.write(`data: ${data}\n\n`);
52
+ }
53
+ catch { /* peer disconnected */ }
54
+ }
55
+ }
56
+ /**
57
+ * Kirim event SSE ke satu peer tertentu.
58
+ */
59
+ function sendEvent(peer, kind, payload) {
60
+ const data = JSON.stringify({ kind, ...payload });
61
+ try {
62
+ peer.res.write(`data: ${data}\n\n`);
63
+ }
64
+ catch { /* peer disconnected */ }
65
+ }
66
+ /**
67
+ * Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
68
+ */
69
+ function broadcastStatus() {
70
+ const statusEvent = {
71
+ mcpConnected,
72
+ studioCount: ssePeers.size,
73
+ serverVersion: '2.1.0',
74
+ };
75
+ broadcastEvent('status', statusEvent);
76
+ // Juga enqueue untuk Studio yang belum polling saat ini
77
+ enqueueForStudio(null, { kind: 'status', ...statusEvent });
78
+ }
79
+ /**
80
+ * Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
81
+ * Hanya satu yang akan memprosesnya (first-come first-served via requestId).
82
+ */
83
+ function broadcastTask(task) {
84
+ const taskEvent = {
85
+ requestId: task.id,
86
+ command: task.command,
87
+ target: task.target,
88
+ data: task.data ?? null,
89
+ remainingMs: 29000,
90
+ };
91
+ broadcastEvent('request', taskEvent);
92
+ // Juga enqueue untuk Studio yang sedang tidak polling
93
+ enqueueForStudio(null, { kind: 'request', ...taskEvent });
94
+ }
95
+ // ── Logging ───────────────────────────────────────────────────────────────────
18
96
  function addLog(io, type, message) {
19
97
  const log = {
20
98
  id: Date.now().toString(),
@@ -30,7 +108,6 @@ function addLog(io, type, message) {
30
108
  }
31
109
  /**
32
110
  * Membunuh proses zombie yang masih menyandera port tertentu.
33
- * Ini menyelesaikan masalah EADDRINUSE yang terjadi setelah restart IDE.
34
111
  */
35
112
  function killZombieOnPort(port) {
36
113
  try {
@@ -51,7 +128,6 @@ function killZombieOnPort(port) {
51
128
  return true;
52
129
  }
53
130
  else {
54
- // macOS / Linux
55
131
  const output = (0, child_process_1.execSync)(`lsof -ti :${port}`, { encoding: 'utf8', timeout: 5000 });
56
132
  const pids = output.trim().split('\n');
57
133
  for (const pid of pids) {
@@ -66,128 +142,241 @@ function killZombieOnPort(port) {
66
142
  }
67
143
  }
68
144
  catch {
69
- // Tidak ada proses yang menyandera port — ini normal
70
145
  return false;
71
146
  }
72
147
  }
73
148
  function startBridgeServer(port = 3055) {
74
149
  const app = (0, express_1.default)();
75
150
  const httpServer = (0, http_1.createServer)(app);
76
- const io = new socket_io_1.Server(httpServer, { cors: { origin: "*" } });
151
+ const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
77
152
  app.use(express_1.default.json({ limit: '50mb' }));
78
153
  app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
79
154
  let dashboardOpened = false;
80
- let studioConnected = false;
81
- // Provide initial state to newly connected dashboard clients
155
+ // Sediakan initial state ke dashboard browser baru
82
156
  io.on('connection', (socket) => {
83
157
  socket.emit('init', {
84
158
  logs: activityLogs,
85
- stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected, port },
159
+ stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: ssePeers.size > 0, port },
86
160
  });
87
161
  });
88
- // API endpoint to get current stats
162
+ // ── /api/stats ─────────────────────────────────────────────────────────────
89
163
  app.get('/api/stats', (req, res) => {
90
- res.json({ totalTasks, successCount, errorCount, sessionStart, studioConnected, port });
164
+ res.json({
165
+ totalTasks, successCount, errorCount, sessionStart,
166
+ studioConnected: ssePeers.size > 0,
167
+ studioCount: ssePeers.size,
168
+ mcpConnected,
169
+ port,
170
+ });
91
171
  });
92
- // Endpoint Studio Instant Ping (Handshake cepat tanpa menunggu long-polling)
172
+ // ── /api/ping ─────────────────────────────────────────────────────────────
173
+ // Dipertahankan untuk backward-compat (installer check, health check).
93
174
  app.get('/api/ping', (req, res) => {
94
- if (!studioConnected) {
95
- studioConnected = true;
96
- io.emit('studio-status', { connected: true });
97
- addLog(io, 'system', 'Roblox Studio terhubung ke Bridge Server via Ping.');
98
- }
99
- if (!dashboardOpened) {
100
- dashboardOpened = true;
101
- const url = `http://localhost:${port}`;
102
- const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
103
- (0, child_process_1.exec)(startCmd, (error) => {
104
- if (error)
105
- console.error(`[Bridge] Gagal membuka browser:`, error);
106
- });
107
- console.error(`[Bridge] Pertama kali terhubung dengan Studio! Membuka Dashboard...`);
108
- }
109
- res.json({ status: 'ok', studioConnected: true, timestamp: Date.now() });
175
+ res.json({ status: 'ok', studioCount: ssePeers.size, mcpConnected, timestamp: Date.now() });
110
176
  });
111
- const waitingPollers = [];
112
- task_queue_1.taskEmitter.on('new_task', () => {
113
- if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
114
- const res = waitingPollers.shift();
115
- const task = task_queue_1.taskQueue.shift();
116
- totalTasks++;
117
- addLog(io, 'task', `Mengirim perintah '${task?.command}' ke Studio${task?.target ? ` → ${task.target}` : ''}.`);
118
- res?.json(task);
177
+ // ── /api/stream (Snapshot endpoint untuk Roblox GetAsync) ───────────────
178
+ // Roblox HttpService:GetAsync() TIDAK bisa streaming — ia block sampai
179
+ // koneksi ditutup. Endpoint ini: daftarkan studio, kirim semua events
180
+ // yang tertunda (status + queued tasks), lalu TUTUP koneksi.
181
+ // Plugin mengulang GET ini setiap ~0.5 detik (pseudo-polling via snapshot).
182
+ app.get('/api/stream', (req, res) => {
183
+ const studioId = req.query.studioId || `anon-${Date.now()}`;
184
+ // Daftarkan studio jika belum ada
185
+ if (!studioEventQueues.has(studioId)) {
186
+ studioEventQueues.set(studioId, []);
187
+ io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
188
+ addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
189
+ if (!dashboardOpened) {
190
+ dashboardOpened = true;
191
+ const url = `http://localhost:${port}`;
192
+ const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
193
+ const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
194
+ if (!isDaemon) {
195
+ (0, child_process_1.exec)(startCmd, (error) => {
196
+ if (error)
197
+ console.error(`[Bridge] Gagal membuka browser:`, error);
198
+ });
199
+ }
200
+ }
119
201
  }
120
- });
121
- // Endpoint Studio Polling
122
- app.get('/api/tasks', (req, res) => {
123
- if (!studioConnected) {
124
- studioConnected = true;
125
- io.emit('studio-status', { connected: true });
126
- addLog(io, 'system', 'Roblox Studio terhubung ke Bridge Server.');
127
- }
128
- if (!dashboardOpened) {
129
- dashboardOpened = true;
130
- const url = `http://localhost:${port}`;
131
- const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
132
- (0, child_process_1.exec)(startCmd, (error) => {
133
- if (error)
134
- console.error(`[Bridge] Gagal membuka browser:`, error);
135
- });
136
- console.error(`[Bridge] Pertama kali terhubung dengan Studio! Membuka Dashboard...`);
202
+ // Kumpulkan semua events yang belum dikirim ke studio ini
203
+ const queue = studioEventQueues.get(studioId) ?? [];
204
+ studioEventQueues.set(studioId, []); // kosongkan antrian
205
+ // Selalu sertakan status terkini
206
+ const events = [
207
+ { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.0' },
208
+ ];
209
+ // Tambahkan events tertunda (misalnya task requests)
210
+ for (const ev of queue) {
211
+ // Hindari duplikat status
212
+ if (ev.kind !== 'status')
213
+ events.push(ev);
137
214
  }
215
+ // Jika ada task di queue global, sertakan juga
138
216
  if (task_queue_1.taskQueue.length > 0) {
139
217
  const task = task_queue_1.taskQueue.shift();
140
218
  totalTasks++;
141
- addLog(io, 'task', `Mengirim perintah '${task?.command}' ke Studio${task?.target ? ` → ${task.target}` : ''}.`);
142
- res.json(task);
219
+ addLog(io, 'task', `Mengirim perintah '${task.command}' ke Studio (ID: ${studioId.substring(0, 8)}…).`);
220
+ events.push({
221
+ kind: 'request',
222
+ requestId: task.id,
223
+ command: task.command,
224
+ target: task.target,
225
+ data: task.data ?? null,
226
+ remainingMs: 29000,
227
+ });
228
+ }
229
+ // Format sebagai teks SSE (setiap event = satu baris "data: ...\n\n")
230
+ res.setHeader('Content-Type', 'text/plain');
231
+ res.setHeader('Cache-Control', 'no-cache, no-store');
232
+ const body = events
233
+ .map(ev => `data: ${JSON.stringify(ev)}`)
234
+ .join('\n');
235
+ res.send(body);
236
+ });
237
+ // ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
238
+ app.get('/api/events', (req, res) => {
239
+ const studioId = req.query.studioId || `browser-${Date.now()}`;
240
+ res.setHeader('Content-Type', 'text/event-stream');
241
+ res.setHeader('Cache-Control', 'no-cache');
242
+ res.setHeader('Connection', 'keep-alive');
243
+ res.setHeader('X-Accel-Buffering', 'no');
244
+ res.flushHeaders();
245
+ const heartbeatTimer = setInterval(() => {
246
+ try {
247
+ res.write(`data: ${JSON.stringify({ kind: 'heartbeat', timestamp: Date.now() })}\n\n`);
248
+ }
249
+ catch {
250
+ cleanup();
251
+ }
252
+ }, 5000);
253
+ const silenceTimer = setTimeout(() => cleanup(), 60000);
254
+ const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
255
+ ssePeers.set(studioId, peer);
256
+ sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.0' });
257
+ function cleanup() {
258
+ clearInterval(heartbeatTimer);
259
+ clearTimeout(silenceTimer);
260
+ ssePeers.delete(studioId);
261
+ try {
262
+ res.end();
263
+ }
264
+ catch { /* ignore */ }
265
+ }
266
+ req.on('close', cleanup);
267
+ req.on('error', cleanup);
268
+ });
269
+ // ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
270
+ app.post('/api/response/:taskId', (req, res) => {
271
+ const { taskId } = req.params;
272
+ const { status, result, error } = req.body;
273
+ if (status === 'success') {
274
+ successCount++;
275
+ addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
143
276
  }
144
277
  else {
145
- // Long-polling: tunggu maksimal 20 detik
146
- const timeoutId = setTimeout(() => {
147
- const index = waitingPollers.indexOf(res);
148
- if (index !== -1) {
149
- waitingPollers.splice(index, 1);
150
- res.json({ id: null });
151
- }
152
- }, 20000);
153
- waitingPollers.push(res);
154
- // Bersihkan jika terputus prematur
155
- req.on('close', () => {
156
- clearTimeout(timeoutId);
157
- const index = waitingPollers.indexOf(res);
158
- if (index !== -1) {
159
- waitingPollers.splice(index, 1);
160
- }
161
- });
278
+ errorCount++;
279
+ addLog(io, 'error', `Gagal: ${error}`);
162
280
  }
281
+ (0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
282
+ res.json({ ok: true });
163
283
  });
164
- // Endpoint Studio Response
284
+ // ── /api/response (legacy — lama, tetap didukung) ────────────────────────
165
285
  app.post('/api/response', (req, res) => {
166
286
  const { id, status, result, error } = req.body;
167
287
  if (status === 'success') {
168
288
  successCount++;
169
- addLog(io, 'response', `Perintah selesai: ${String(result).substring(0, 120)}`);
289
+ addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
170
290
  }
171
291
  else {
172
292
  errorCount++;
173
293
  addLog(io, 'error', `Gagal: ${error}`);
174
294
  }
175
295
  (0, task_queue_1.resolvePendingTask)(id, status, result, error);
176
- res.json({ message: "Response diterima" });
296
+ res.json({ ok: true });
297
+ });
298
+ // ── Notifikasi task baru ke Dashboard ────────────────────────────────────
299
+ // Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
300
+ // Di sini kita hanya log ke dashboard browser.
301
+ task_queue_1.taskEmitter.on('new_task', () => {
302
+ if (task_queue_1.taskQueue.length === 0)
303
+ return;
304
+ const task = task_queue_1.taskQueue[0]; // preview saja, tidak shift
305
+ addLog(io, 'task', `Task baru antri: '${task.command}'${task.target ? ` → ${task.target}` : ''}. Menunggu Studio polling...`);
306
+ });
307
+ // ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
308
+ app.post('/api/tasks/enqueue', async (req, res) => {
309
+ const { command, target, data } = req.body;
310
+ try {
311
+ const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
312
+ res.json({ status: 'success', result });
313
+ }
314
+ catch (err) {
315
+ res.json({ status: 'error', error: err.message });
316
+ }
177
317
  });
178
- // ── ANTI-ZOMBIE: Coba listen, jika gagal bunuh zombie lalu retry ──
318
+ // ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
319
+ // Studio lama yang belum update plugin masih bisa bekerja via polling.
320
+ const waitingPollers = [];
321
+ task_queue_1.taskEmitter.on('new_task_legacy', () => {
322
+ if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
323
+ const pollRes = waitingPollers.shift();
324
+ const task = task_queue_1.taskQueue.shift();
325
+ totalTasks++;
326
+ addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
327
+ pollRes?.json(task);
328
+ }
329
+ });
330
+ app.get('/api/tasks', (req, res) => {
331
+ if (task_queue_1.taskQueue.length > 0) {
332
+ const task = task_queue_1.taskQueue.shift();
333
+ totalTasks++;
334
+ addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
335
+ return res.json(task);
336
+ }
337
+ const timeoutId = setTimeout(() => {
338
+ const index = waitingPollers.indexOf(res);
339
+ if (index !== -1) {
340
+ waitingPollers.splice(index, 1);
341
+ res.json({ id: null });
342
+ }
343
+ }, 20000);
344
+ waitingPollers.push(res);
345
+ req.on('close', () => {
346
+ clearTimeout(timeoutId);
347
+ const index = waitingPollers.indexOf(res);
348
+ if (index !== -1)
349
+ waitingPollers.splice(index, 1);
350
+ });
351
+ });
352
+ // ── Port-sharing & anti-zombie ────────────────────────────────────────────
179
353
  function tryListen(retriesLeft = 2) {
180
354
  const server = httpServer.listen(port, () => {
181
- console.error(`[Bridge] Dashboard visual & server aktif di http://localhost:${port}`);
355
+ exports.isBridgeHosting = true;
356
+ console.error(`[Bridge] SSE Bridge Server aktif di http://localhost:${port}`);
182
357
  });
183
- server.on('error', (err) => {
184
- if (err.code === 'EADDRINUSE' && retriesLeft > 0) {
185
- console.error(`[Bridge] Port ${port} sedang digunakan oleh proses zombie. Mencoba membunuh...`);
186
- killZombieOnPort(port);
187
- setTimeout(() => {
188
- console.error(`[Bridge] Mencoba listen ulang pada port ${port}... (sisa ${retriesLeft - 1} percobaan)`);
189
- tryListen(retriesLeft - 1);
190
- }, 1500);
358
+ server.on('error', async (err) => {
359
+ if (err.code === 'EADDRINUSE') {
360
+ try {
361
+ const controller = new AbortController();
362
+ const timeoutId = setTimeout(() => controller.abort(), 1000);
363
+ const pingRes = await fetch(`http://localhost:${port}/api/ping`, { signal: controller.signal });
364
+ clearTimeout(timeoutId);
365
+ if (pingRes.ok) {
366
+ exports.isBridgeHosting = false;
367
+ console.error(`[Bridge] Port ${port} sudah digunakan oleh NVStudio Bridge aktif. Menggunakan instance yang sudah berjalan.`);
368
+ return;
369
+ }
370
+ }
371
+ catch { /* bukan NVStudio bridge */ }
372
+ if (retriesLeft > 0) {
373
+ console.error(`[Bridge] Port ${port} zombie. Mencoba membunuh...`);
374
+ killZombieOnPort(port);
375
+ setTimeout(() => tryListen(retriesLeft - 1), 1500);
376
+ }
377
+ else {
378
+ console.error(`[Bridge] FATAL: Port ${port} tidak dapat dibuka.`);
379
+ }
191
380
  }
192
381
  else {
193
382
  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.4",
3
+ "version": "2.1.1",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": {