@adiholick/nvstudio-mcp 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,8 +32,8 @@ curl -fsSL https://raw.githubusercontent.com/Adiholick/nvstudio-mcp/main/install
32
32
  **Apa saja yang dilakukan installer ini secara otomatis?**
33
33
  1. 📦 Mengunduh source code, memasang dependensi Node.js, dan mengkompilasi TypeScript (*Bridge Server*).
34
34
  2. 🧩 **Memasang Plugin Roblox Studio (`nvstudio_mcp.rbxmx`) otomatis** ke folder sistem Roblox Studio Anda (`%LOCALAPPDATA%\Roblox\Plugins` di Windows atau `~/Documents/ROBLOX/Plugins` di macOS).
35
- 3. 🤖 Mengintegrasikan *AI Agent Skills* anti-halusinasi langsung ke **Antigravity IDE** (`~/.gemini/config/plugins/nvstudio-mcp`).
36
- 4. ⚙️ Melakukan auto-konfigurasi jika mendeteksi editor **Cursor** (`.cursor/mcp.json`).
35
+ 3. 🧠 **Universal AI Skill Installer**: Secara cerdas memindai dan mengunduh library skill 29+ *roblox-brain* dan *nvstudio-mcp-guide*, lalu menginjeksinya langsung ke dalam struktur `skills/` dari **AI Agent apapun** yang Anda gunakan (mendukung **Antigravity IDE**, **Cursor**, **Claude Code**, **Codex**, **OpenCode**, dan **Roo Code**).
36
+ 4. ⚙️ Melakukan auto-konfigurasi jika mendeteksi editor **Cursor** (`.cursor/mcp.json`) atau **Antigravity** (`mcp_config.json`).
37
37
 
38
38
  ---
39
39
 
@@ -126,9 +126,15 @@ Installer `install.sh` sebelumnya mengkloning repositori dan menginstal Node.js
126
126
  rm -rf ~/.nvstudio-mcp
127
127
  ```
128
128
 
129
- ### 4. Hapus Konfigurasi dari Editor AI Anda
129
+ ### 4. Hapus Konfigurasi dan Skills dari Editor AI Anda
130
130
  Jika Anda memasukkan konfigurasi MCP secara otomatis ke Cursor atau secara manual ke Claude Desktop, buka file pengaturan `mcp.json` tersebut dan **hapus blok `"nvstudio-mcp"`** dari dalam objek `mcpServers`.
131
131
 
132
+ **Untuk membersihkan skill bawaan (Universal Skill Installer):**
133
+ Anda bisa menghapus manual symlink/folder shortcut (yang bernama awalan `roblox-`) dari dalam folder konfigurasi agen Anda:
134
+ - **Cursor / Workspace Lokal**: Hapus isi `.cursor/skills` dan `.cursor/roblox-brain.md`
135
+ - **Claude Code**: Hapus dari `~/.claude/skills`
136
+ - **Codex / OpenCode**: Hapus dari direktori lokal atau global (`~/.codex/skills`, `~/.opencode/skills`).
137
+
132
138
  ---
133
139
 
134
140
  **Selamat Menciptakan Game Impian Anda dengan AI! 🎮🤖**
package/dist/index.js CHANGED
@@ -66,6 +66,7 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
66
66
  });
67
67
  // 5. Handler untuk menangani pemanggilan call_mcp_tool
68
68
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
69
+ await roblox_bridge_1.bridgeReadyPromise;
69
70
  if (request.params.name !== "call_mcp_tool") {
70
71
  throw new Error(`Tool tidak dikenali: ${request.params.name}`);
71
72
  }
@@ -140,6 +141,27 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
140
141
  let result;
141
142
  if (roblox_bridge_1.isBridgeHosting) {
142
143
  // Instance ini sendiri yang meng-host bridge server
144
+ if (!(0, roblox_bridge_1.isStudioConnected)()) {
145
+ for (let i = 0; i < 6; i++) {
146
+ await new Promise(r => setTimeout(r, 50));
147
+ if ((0, roblox_bridge_1.isStudioConnected)())
148
+ break;
149
+ }
150
+ if (!(0, roblox_bridge_1.isStudioConnected)()) {
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: JSON.stringify({
156
+ status: "error",
157
+ error: "Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.",
158
+ }),
159
+ },
160
+ ],
161
+ isError: true,
162
+ };
163
+ }
164
+ }
143
165
  result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
144
166
  }
145
167
  else {
@@ -3,7 +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;
6
+ exports.bridgeReadyPromise = exports.isBridgeHosting = void 0;
7
+ exports.isStudioConnected = isStudioConnected;
7
8
  exports.setMcpConnected = setMcpConnected;
8
9
  exports.startBridgeServer = startBridgeServer;
9
10
  const express_1 = __importDefault(require("express"));
@@ -14,6 +15,10 @@ const child_process_1 = require("child_process");
14
15
  const task_queue_1 = require("./task-queue");
15
16
  const { version: SERVER_VERSION } = require('../package.json');
16
17
  exports.isBridgeHosting = false;
18
+ let resolveBridgeReady;
19
+ exports.bridgeReadyPromise = new Promise((resolve) => {
20
+ resolveBridgeReady = resolve;
21
+ });
17
22
  // Global process error handlers agar server bridge tidak mati mendadak
18
23
  process.on('uncaughtException', (err) => {
19
24
  console.error('[Bridge] Uncaught Exception:', err);
@@ -29,15 +34,58 @@ const sessionStart = new Date().toISOString();
29
34
  // Per-studio pending events queue: events yang belum diambil oleh Studio
30
35
  const studioEventQueues = new Map();
31
36
  const studioLastSeen = new Map();
37
+ const activeStudioPollers = new Map();
32
38
  setInterval(() => {
33
39
  const now = Date.now();
40
+ let changed = false;
34
41
  for (const [sid, lastSeen] of studioLastSeen.entries()) {
35
- if (now - lastSeen > 60000) {
42
+ if (now - lastSeen > 35000) {
36
43
  studioEventQueues.delete(sid);
37
44
  studioLastSeen.delete(sid);
45
+ const poller = activeStudioPollers.get(sid);
46
+ if (poller) {
47
+ clearTimeout(poller.timer);
48
+ try {
49
+ poller.res.end();
50
+ }
51
+ catch { }
52
+ activeStudioPollers.delete(sid);
53
+ }
54
+ changed = true;
38
55
  }
39
56
  }
40
- }, 30000);
57
+ if (changed) {
58
+ broadcastStatus();
59
+ }
60
+ }, 5000);
61
+ function isStudioConnected() {
62
+ return studioEventQueues.size > 0 || activeStudioPollers.size > 0;
63
+ }
64
+ function flushEventsToStudio(sid) {
65
+ const poller = activeStudioPollers.get(sid);
66
+ if (!poller)
67
+ return false;
68
+ const queue = studioEventQueues.get(sid) ?? [];
69
+ if (queue.length === 0)
70
+ return false;
71
+ studioEventQueues.set(sid, []); // kosongkan antrian
72
+ clearTimeout(poller.timer);
73
+ activeStudioPollers.delete(sid);
74
+ const events = [
75
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
76
+ ...queue
77
+ ];
78
+ try {
79
+ poller.res.setHeader('Content-Type', 'text/plain');
80
+ poller.res.setHeader('Cache-Control', 'no-cache, no-store');
81
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
82
+ poller.res.send(body);
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
41
89
  function enqueueForStudio(studioId, event) {
42
90
  if (studioId) {
43
91
  // Event untuk studio tertentu
@@ -47,6 +95,7 @@ function enqueueForStudio(studioId, event) {
47
95
  q.push(event);
48
96
  if (q.length > 100)
49
97
  q.shift();
98
+ flushEventsToStudio(studioId);
50
99
  }
51
100
  else {
52
101
  // Broadcast ke semua studio yang terdaftar
@@ -54,6 +103,7 @@ function enqueueForStudio(studioId, event) {
54
103
  q.push(event);
55
104
  if (q.length > 100)
56
105
  q.shift();
106
+ flushEventsToStudio(sid);
57
107
  }
58
108
  }
59
109
  }
@@ -92,7 +142,7 @@ function sendEvent(peer, kind, payload) {
92
142
  function broadcastStatus() {
93
143
  const statusEvent = {
94
144
  mcpConnected,
95
- studioCount: Math.max(ssePeers.size, studioEventQueues.size),
145
+ studioCount: studioEventQueues.size,
96
146
  serverVersion: SERVER_VERSION,
97
147
  };
98
148
  broadcastEvent('status', statusEvent);
@@ -179,12 +229,12 @@ function startBridgeServer(port, isDaemon = false) {
179
229
  io.on('connection', (socket) => {
180
230
  socket.emit('init', {
181
231
  logs: activityLogs,
182
- stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: Math.max(ssePeers.size, studioEventQueues.size) > 0, port },
232
+ stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: studioEventQueues.size > 0, port },
183
233
  });
184
234
  });
185
235
  // ── /api/stats ─────────────────────────────────────────────────────────────
186
236
  app.get('/api/stats', (req, res) => {
187
- const activeStudios = Math.max(ssePeers.size, studioEventQueues.size);
237
+ const activeStudios = studioEventQueues.size;
188
238
  res.json({
189
239
  totalTasks, successCount, errorCount, sessionStart,
190
240
  studioConnected: activeStudios > 0,
@@ -196,7 +246,7 @@ function startBridgeServer(port, isDaemon = false) {
196
246
  // ── /api/ping ─────────────────────────────────────────────────────────────
197
247
  app.get('/api/ping', (req, res) => {
198
248
  const uptime = Math.floor((Date.now() - new Date(sessionStart).getTime()) / 1000);
199
- const activeStudios = Math.max(ssePeers.size, studioEventQueues.size);
249
+ const activeStudios = studioEventQueues.size;
200
250
  res.json({
201
251
  status: 'ok',
202
252
  version: SERVER_VERSION,
@@ -205,18 +255,18 @@ function startBridgeServer(port, isDaemon = false) {
205
255
  activeStudios: activeStudios
206
256
  });
207
257
  });
208
- // ── /api/stream (Snapshot endpoint untuk Roblox GetAsync) ───────────────
209
- // Roblox HttpService:GetAsync() TIDAK bisa streaming — ia block sampai
210
- // koneksi ditutup. Endpoint ini: daftarkan studio, kirim semua events
211
- // yang tertunda (status + queued tasks), lalu TUTUP koneksi.
212
- // Plugin mengulang GET ini setiap ~0.5 detik (pseudo-polling via snapshot).
258
+ // ── /api/stream (True Long-Polling endpoint untuk Roblox GetAsync) ─────
259
+ // Roblox HttpService:GetAsync() menahan koneksi sampai server merespons.
260
+ // Jika ada task yang menunggu: kirim seketika (<1ms).
261
+ // Jika tidak ada task: tahan koneksi (hanging GET) hingga task baru tiba
262
+ // atau timeout 15 detik (heartbeat) untuk menjaga koneksi tetap segar.
213
263
  app.get('/api/stream', (req, res) => {
214
264
  const studioId = req.query.studioId || `anon-${Date.now()}`;
215
265
  studioLastSeen.set(studioId, Date.now());
216
266
  // Daftarkan studio jika belum ada
217
267
  if (!studioEventQueues.has(studioId)) {
218
268
  studioEventQueues.set(studioId, []);
219
- io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
269
+ io.emit('studio-status', { connected: true, studioCount: Math.max(studioEventQueues.size, 1), studioId });
220
270
  addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
221
271
  if (!isDaemon && !dashboardOpened) {
222
272
  dashboardOpened = true;
@@ -228,26 +278,55 @@ function startBridgeServer(port, isDaemon = false) {
228
278
  });
229
279
  }
230
280
  }
281
+ // Jika poller lama untuk studio ini masih aktif, selesaikan dulu
282
+ const oldPoller = activeStudioPollers.get(studioId);
283
+ if (oldPoller) {
284
+ clearTimeout(oldPoller.timer);
285
+ try {
286
+ oldPoller.res.end();
287
+ }
288
+ catch { }
289
+ activeStudioPollers.delete(studioId);
290
+ }
231
291
  // Kumpulkan semua events yang belum dikirim ke studio ini
232
292
  const queue = studioEventQueues.get(studioId) ?? [];
233
- studioEventQueues.set(studioId, []); // kosongkan antrian
234
- // Selalu sertakan status terkini
235
- const events = [
236
- { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
237
- ];
238
- // Tambahkan events tertunda (misalnya task requests)
239
- for (const ev of queue) {
240
- // Hindari duplikat status
241
- if (ev.kind !== 'status')
242
- events.push(ev);
293
+ if (queue.length > 0) {
294
+ studioEventQueues.set(studioId, []); // kosongkan antrian
295
+ const events = [
296
+ { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
297
+ ];
298
+ for (const ev of queue) {
299
+ if (ev.kind !== 'status')
300
+ events.push(ev);
301
+ }
302
+ res.setHeader('Content-Type', 'text/plain');
303
+ res.setHeader('Cache-Control', 'no-cache, no-store');
304
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
305
+ return res.send(body);
243
306
  }
244
- // Format sebagai teks SSE (setiap event = satu baris "data: ...\n\n")
245
- res.setHeader('Content-Type', 'text/plain');
246
- res.setHeader('Cache-Control', 'no-cache, no-store');
247
- const body = events
248
- .map(ev => `data: ${JSON.stringify(ev)}`)
249
- .join('\n');
250
- res.send(body);
307
+ // Jika antrean kosong, TAHAN koneksi (Long-Polling) maksimal 15 detik
308
+ const timer = setTimeout(() => {
309
+ activeStudioPollers.delete(studioId);
310
+ try {
311
+ const events = [
312
+ { kind: 'heartbeat', timestamp: Date.now() },
313
+ { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION }
314
+ ];
315
+ res.setHeader('Content-Type', 'text/plain');
316
+ res.setHeader('Cache-Control', 'no-cache, no-store');
317
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
318
+ res.send(body);
319
+ }
320
+ catch { }
321
+ }, 15000);
322
+ activeStudioPollers.set(studioId, { res, timer });
323
+ req.on('close', () => {
324
+ const current = activeStudioPollers.get(studioId);
325
+ if (current && current.res === res) {
326
+ clearTimeout(current.timer);
327
+ activeStudioPollers.delete(studioId);
328
+ }
329
+ });
251
330
  });
252
331
  // ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
253
332
  app.get('/api/events', (req, res) => {
@@ -329,6 +408,20 @@ function startBridgeServer(port, isDaemon = false) {
329
408
  // ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
330
409
  app.post('/api/tasks/enqueue', async (req, res) => {
331
410
  const { command, target, data } = req.body;
411
+ // Fast-check: pastikan setidaknya 1 Studio aktif polling
412
+ if (!isStudioConnected()) {
413
+ for (let i = 0; i < 6; i++) {
414
+ await new Promise(r => setTimeout(r, 50));
415
+ if (isStudioConnected())
416
+ break;
417
+ }
418
+ if (!isStudioConnected()) {
419
+ return res.json({
420
+ status: 'error',
421
+ error: 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
422
+ });
423
+ }
424
+ }
332
425
  try {
333
426
  const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
334
427
  res.json({ status: 'success', result });
@@ -374,6 +467,7 @@ function startBridgeServer(port, isDaemon = false) {
374
467
  const server = httpServer.listen(port, () => {
375
468
  exports.isBridgeHosting = true;
376
469
  console.error(`[Bridge] SSE Bridge Server aktif di http://localhost:${port}`);
470
+ resolveBridgeReady(true);
377
471
  });
378
472
  server.on('error', async (err) => {
379
473
  if (err.code === 'EADDRINUSE') {
@@ -385,6 +479,7 @@ function startBridgeServer(port, isDaemon = false) {
385
479
  if (pingRes.ok) {
386
480
  exports.isBridgeHosting = false;
387
481
  console.error(`[Bridge] Port ${port} sudah digunakan oleh NVStudio Bridge aktif. Menggunakan instance yang sudah berjalan.`);
482
+ resolveBridgeReady(false);
388
483
  return;
389
484
  }
390
485
  }
@@ -392,14 +487,16 @@ function startBridgeServer(port, isDaemon = false) {
392
487
  if (retriesLeft > 0) {
393
488
  console.error(`[Bridge] Port ${port} zombie. Mencoba membunuh...`);
394
489
  killZombieOnPort(port);
395
- setTimeout(() => tryListen(retriesLeft - 1), 1500);
490
+ setTimeout(() => tryListen(retriesLeft - 1), 500);
396
491
  }
397
492
  else {
398
493
  console.error(`[Bridge] FATAL: Port ${port} tidak dapat dibuka.`);
494
+ resolveBridgeReady(false);
399
495
  }
400
496
  }
401
497
  else {
402
498
  console.error(`[Bridge] FATAL: Gagal mendengarkan port ${port}:`, err.message);
499
+ resolveBridgeReady(false);
403
500
  process.exit(1);
404
501
  }
405
502
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -335,7 +335,7 @@ function startConnectionLoop()
335
335
  handleSSELine(line)
336
336
  end
337
337
  end
338
- task.wait(0.8)
338
+ task.wait(0.05) -- Instant loop: server menahan request (hanging GET) saat idle
339
339
  else
340
340
  consecutiveErrors = consecutiveErrors + 1
341
341
  -- Toleransi 2x gagal sebelum menandai server unreachable
@@ -824,7 +824,7 @@ function startConnectionLoop()
824
824
  handleSSELine(line)
825
825
  end
826
826
  end
827
- task.wait(0.8)
827
+ task.wait(0.05) -- Instant loop: server menahan request (hanging GET) saat idle
828
828
  else
829
829
  consecutiveErrors = consecutiveErrors + 1
830
830
  -- Toleransi 2x gagal sebelum menandai server unreachable
@@ -1088,7 +1088,8 @@ end
1088
1088
  if targetInstance and targetInstance ~= game then
1089
1089
  local descendants = {}
1090
1090
  pcall(function() descendants = targetInstance:GetDescendants() end)
1091
- for _, desc in ipairs(descendants) do
1091
+ for i, desc in ipairs(descendants) do
1092
+ if i % 300 == 0 then task.wait() end
1092
1093
  if matchCount >= MAX_MATCHES then break end
1093
1094
  if desc:IsA("LuaSourceContainer") then
1094
1095
  if searchInScript(desc) then
@@ -1100,7 +1101,8 @@ end
1100
1101
  for _, service in ipairs(searchableServices) do
1101
1102
  local descendants = {}
1102
1103
  pcall(function() descendants = service:GetDescendants() end)
1103
- for _, desc in ipairs(descendants) do
1104
+ for i, desc in ipairs(descendants) do
1105
+ if i % 300 == 0 then task.wait() end
1104
1106
  if matchCount >= MAX_MATCHES then break end
1105
1107
  if desc:IsA("LuaSourceContainer") then
1106
1108
  if searchInScript(desc) then
@@ -48,7 +48,8 @@ return function(targetInstance, data, ctx, targetPath)
48
48
  if targetInstance and targetInstance ~= game then
49
49
  local descendants = {}
50
50
  pcall(function() descendants = targetInstance:GetDescendants() end)
51
- for _, desc in ipairs(descendants) do
51
+ for i, desc in ipairs(descendants) do
52
+ if i % 300 == 0 then task.wait() end
52
53
  if matchCount >= MAX_MATCHES then break end
53
54
  if desc:IsA("LuaSourceContainer") then
54
55
  if searchInScript(desc) then
@@ -60,7 +61,8 @@ return function(targetInstance, data, ctx, targetPath)
60
61
  for _, service in ipairs(searchableServices) do
61
62
  local descendants = {}
62
63
  pcall(function() descendants = service:GetDescendants() end)
63
- for _, desc in ipairs(descendants) do
64
+ for i, desc in ipairs(descendants) do
65
+ if i % 300 == 0 then task.wait() end
64
66
  if matchCount >= MAX_MATCHES then break end
65
67
  if desc:IsA("LuaSourceContainer") then
66
68
  if searchInScript(desc) then