@adiholick/nvstudio-mcp 1.0.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const PLUGIN_DIR = path_1.default.join(process.cwd(), 'studio-plugin');
10
+ const OUTPUT_FILE = path_1.default.join(PLUGIN_DIR, 'nvstudio_mcp.rbxmx');
11
+ // Helper to escape XML special characters
12
+ function escapeXml(unsafe) {
13
+ return unsafe.replace(/[<>&'"]/g, function (c) {
14
+ switch (c) {
15
+ case '<': return '&lt;';
16
+ case '>': return '&gt;';
17
+ case '&': return '&amp;';
18
+ case '\'': return '&apos;';
19
+ case '"': return '&quot;';
20
+ default: return c;
21
+ }
22
+ });
23
+ }
24
+ function buildRbxmx() {
25
+ console.log('[Build] Memulai kompilasi nvstudio_mcp.rbxmx...');
26
+ if (!fs_1.default.existsSync(PLUGIN_DIR)) {
27
+ console.error(`[Build] Folder ${PLUGIN_DIR} tidak ditemukan.`);
28
+ process.exit(1);
29
+ }
30
+ const files = fs_1.default.readdirSync(PLUGIN_DIR).filter(file => file.endsWith('.lua'));
31
+ // RBXMX Header
32
+ let xml = `<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">\n`;
33
+ xml += `\t<External>null</External>\n\t<External>nil</External>\n`;
34
+ // Main Folder
35
+ xml += `\t<Item class="Folder" referent="RBX0497167F99814CA6A2096BB729707905">\n`;
36
+ xml += `\t\t<Properties>\n`;
37
+ xml += `\t\t\t<BinaryString name="AttributesSerialize"></BinaryString>\n`;
38
+ xml += `\t\t\t<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>\n`;
39
+ xml += `\t\t\t<bool name="DefinesCapabilities">false</bool>\n`;
40
+ xml += `\t\t\t<string name="Name">nvstudio_mcp</string>\n`;
41
+ xml += `\t\t\t<int64 name="SourceAssetId">-1</int64>\n`;
42
+ xml += `\t\t\t<SharedString name="Tags">yuZpQdnvvUBOTYh1jqZ2cA==</SharedString>\n`;
43
+ xml += `\t\t</Properties>\n`;
44
+ // Process files
45
+ for (const file of files) {
46
+ const filePath = path_1.default.join(PLUGIN_DIR, file);
47
+ const source = fs_1.default.readFileSync(filePath, 'utf-8');
48
+ const name = file.replace('.lua', '');
49
+ // Main.server.lua is a Script, others are ModuleScripts
50
+ const isMain = name === 'Main.server';
51
+ const className = isMain ? 'Script' : 'ModuleScript';
52
+ // Use a simple hash-like referent
53
+ const referent = `RBX_${name.replace(/[^a-zA-Z0-9]/g, '_')}`;
54
+ xml += `\t\t<Item class="${className}" referent="${referent}">\n`;
55
+ xml += `\t\t\t<Properties>\n`;
56
+ xml += `\t\t\t\t<BinaryString name="AttributesSerialize"></BinaryString>\n`;
57
+ xml += `\t\t\t\t<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>\n`;
58
+ xml += `\t\t\t\t<bool name="DefinesCapabilities">false</bool>\n`;
59
+ xml += `\t\t\t\t<bool name="Disabled">false</bool>\n`;
60
+ xml += `\t\t\t\t<Content name="LinkedSource"><null></null></Content>\n`;
61
+ xml += `\t\t\t\t<string name="Name">${name}</string>\n`;
62
+ xml += `\t\t\t\t<int64 name="SourceAssetId">-1</int64>\n`;
63
+ xml += `\t\t\t\t<SharedString name="Tags"></SharedString>\n`;
64
+ if (isMain) {
65
+ xml += `\t\t\t\t<token name="RunContext">0</token>\n`;
66
+ }
67
+ // Wrap source in CDATA to avoid XML escaping issues for the code itself
68
+ // Ensure no ]]> appears inside the CDATA
69
+ const safeSource = source.replace(/\]\]>/g, ']]]]><![CDATA[>');
70
+ xml += `\t\t\t\t<ProtectedString name="Source"><![CDATA[${safeSource}]]></ProtectedString>\n`;
71
+ xml += `\t\t\t</Properties>\n`;
72
+ xml += `\t\t</Item>\n`;
73
+ }
74
+ // Close Folder
75
+ xml += `\t</Item>\n`;
76
+ // Shared Strings definition (required by Roblox XML format if Tags is used)
77
+ xml += `\t<SharedStrings>\n`;
78
+ xml += `\t\t<SharedString md5="yuZpQdnvvUBOTYh1jqZ2cA=="></SharedString>\n`;
79
+ xml += `\t</SharedStrings>\n`;
80
+ // Close Roblox
81
+ xml += `</roblox>\n`;
82
+ fs_1.default.writeFileSync(OUTPUT_FILE, xml, 'utf-8');
83
+ console.log(`[Build] Berhasil membuat ${OUTPUT_FILE}`);
84
+ copyToRobloxPlugins();
85
+ }
86
+ function copyToRobloxPlugins() {
87
+ let pluginsDir = '';
88
+ if (process.platform === 'win32') {
89
+ const localAppData = process.env.LOCALAPPDATA;
90
+ if (localAppData) {
91
+ pluginsDir = path_1.default.join(localAppData, 'Roblox', 'Plugins');
92
+ }
93
+ }
94
+ else if (process.platform === 'darwin') {
95
+ pluginsDir = path_1.default.join(os_1.default.homedir(), 'Documents', 'Roblox', 'Plugins');
96
+ }
97
+ if (pluginsDir) {
98
+ if (!fs_1.default.existsSync(pluginsDir)) {
99
+ console.log(`[Build] Folder plugin Roblox tidak ditemukan di ${pluginsDir}. Melewati penyalinan otomatis.`);
100
+ return;
101
+ }
102
+ const targetFile = path_1.default.join(pluginsDir, 'nvstudio_mcp.rbxmx');
103
+ try {
104
+ fs_1.default.copyFileSync(OUTPUT_FILE, targetFile);
105
+ console.log(`[Build] ✅ Berhasil menyalin nvstudio_mcp.rbxmx ke ${targetFile}`);
106
+ }
107
+ catch (err) {
108
+ console.error(`[Build] Gagal menyalin ke ${targetFile}:`, err.message);
109
+ }
110
+ }
111
+ else {
112
+ console.log(`[Build] Platform ${process.platform} tidak didukung untuk auto-copy plugin.`);
113
+ }
114
+ }
115
+ buildRbxmx();
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ const roblox_bridge_1 = require("./roblox-bridge");
11
11
  const task_queue_1 = require("./task-queue");
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
+ const luau_validator_1 = require("./luau-validator");
14
15
  const HISTORY_DIR = path_1.default.join(process.cwd(), '.history');
15
16
  if (!fs_1.default.existsSync(HISTORY_DIR)) {
16
17
  fs_1.default.mkdirSync(HISTORY_DIR, { recursive: true });
@@ -26,7 +27,7 @@ function backupScript(target, code) {
26
27
  // 2. Inisialisasi server MCP
27
28
  const server = new index_js_1.Server({
28
29
  name: "nvstudio-mcp",
29
- version: "1.0.0",
30
+ version: "2.0.0",
30
31
  }, {
31
32
  capabilities: {
32
33
  tools: {},
@@ -38,21 +39,21 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
38
39
  tools: [
39
40
  {
40
41
  name: "call_mcp_tool",
41
- description: "ALAT MUTLAK UNTUK ROBLOX. DILARANG KERAS menggunakan PowerShell atau skrip bypass (fetch_mcp_source.js). WAJIB memanggil 'get_children' terlebih dahulu sebelum 'get_script_source' untuk memverifikasi tipe instance agar tidak terjadi error teks kosong.",
42
+ description: "ALAT MUTLAK UNTUK ROBLOX. Command: patch_script_source, get_children, get_script_source, update_script_source, rollback_script, get_logs, create_instance, search_instance, generate_terrain, insert_asset, delete_instance, get_properties, script_grep, execute_luau, search_asset.",
42
43
  inputSchema: {
43
44
  type: "object",
44
45
  properties: {
45
46
  command: {
46
47
  type: "string",
47
- description: "Perintah yang akan dieksekusi (contoh: 'get_children', 'get_script_source', 'update_script_source', 'rollback_script', 'get_logs').",
48
+ description: "Perintah yang dieksekusi (contoh: 'patch_script_source', 'get_script_source', 'update_script_source').",
48
49
  },
49
50
  target: {
50
51
  type: "string",
51
- description: "Path absolut dari objek target di Roblox Studio (contoh: 'Workspace.Map.Script' atau 'ServerScriptService.MainModule').",
52
+ description: "Path absolut dari objek target di Roblox Studio (contoh: 'Workspace.Map.Script').",
52
53
  },
53
54
  data: {
54
55
  type: "string",
55
- description: "Data tambahan opsional, misalnya isi source code baru saat menjalankan perintah 'update_script_source'.",
56
+ description: "Data tambahan (misal payload JSON untuk patch_script_source, atau source code penuh untuk update_script_source).",
56
57
  },
57
58
  },
58
59
  required: ["command", "target"],
@@ -82,20 +83,58 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
82
83
  isError: true,
83
84
  };
84
85
  }
85
- // 6. 🔴 GUARDRAIL MUTLAK: Cegah tebakan path buta
86
- if (command === "get_script_source") {
87
- const genericTargets = ["Workspace", "ServerScriptService", "ReplicatedStorage", "StarterGui", "StarterPack", "game"];
88
- if (genericTargets.includes(target) || !target.includes(".")) {
86
+ // Pre-commit validation for full source replacement
87
+ if (command === "update_script_source" && data) {
88
+ const validation = (0, luau_validator_1.validateLuauSyntax)(String(data));
89
+ if (!validation.valid) {
89
90
  return {
90
91
  content: [{
91
92
  type: "text",
92
- text: "🔴 SERVER GUARDRAIL BLOCKED: DILARANG mengekstrak kode secara buta dari direktori utama atau target yang tidak spesifik. Anda WAJIB memanggil perintah 'get_children' pada '" + target + "' terlebih dahulu untuk memverifikasi nama dan ClassName (Pastikan target adalah LuaSourceContainer)."
93
+ text: JSON.stringify({
94
+ status: "error",
95
+ error: `Syntax error terdeteksi (Pre-commit check Node.js): ${validation.error} pada baris ${validation.line || 'unknown'}`
96
+ })
93
97
  }],
94
98
  isError: true
95
99
  };
96
100
  }
101
+ backupScript(target, String(data));
97
102
  }
98
- // 7. Teruskan ke antrean dan kembalikan hasilnya sebagai string JSON
103
+ // Intercept command yang dieksekusi di server lokal (Node.js) alih-alih di Studio
104
+ if (command === "search_asset" && data) {
105
+ try {
106
+ const queryParams = typeof data === "string" ? JSON.parse(data) : data;
107
+ const keyword = encodeURIComponent(queryParams.keyword || "");
108
+ let assetType = "10"; // Default: Model
109
+ if (queryParams.category === "Audio") {
110
+ assetType = "9"; // Audio
111
+ }
112
+ else if (queryParams.category === "Decal" || queryParams.category === "Image") {
113
+ assetType = "13"; // Decal
114
+ }
115
+ const url = `https://catalog.roblox.com/v1/search/items?category=All&keyword=${keyword}&limit=10&itemTypes=Asset&assetTypes=${assetType}`;
116
+ const res = await fetch(url);
117
+ const jsonRes = await res.json();
118
+ return {
119
+ content: [
120
+ {
121
+ type: "text",
122
+ text: JSON.stringify({
123
+ status: "success",
124
+ result: jsonRes.data || jsonRes
125
+ }, null, 2)
126
+ }
127
+ ]
128
+ };
129
+ }
130
+ catch (err) {
131
+ return {
132
+ content: [{ type: "text", text: JSON.stringify({ status: "error", error: "Gagal mencari aset: " + err.message }) }],
133
+ isError: true
134
+ };
135
+ }
136
+ }
137
+ // 8. Teruskan ke antrean dan kembalikan hasilnya sebagai string JSON
99
138
  try {
100
139
  if (command === "update_script_source" && data) {
101
140
  backupScript(target, String(data));
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateLuauSyntax = validateLuauSyntax;
4
+ function validateLuauSyntax(source) {
5
+ const lines = source.split('\n');
6
+ // Stack for blocks: function, do, if, for, while, repeat
7
+ // We'll keep it simple: just count block openers vs block closers (end, until)
8
+ // Note: This is a heuristic pre-commit check. It doesn't replace a full AST parser,
9
+ // but catches 90% of AI hallucination syntax errors (missing 'end', etc.)
10
+ let blockDepth = 0;
11
+ let repeatDepth = 0;
12
+ // Bracket stacks
13
+ let parenDepth = 0; // ()
14
+ let braceDepth = 0; // {}
15
+ let bracketDepth = 0; // []
16
+ let inString = false;
17
+ let stringChar = '';
18
+ let inMultilineString = false;
19
+ let multilineEqCount = 0;
20
+ let inMultilineComment = false;
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i];
23
+ let j = 0;
24
+ while (j < line.length) {
25
+ const char = line[j];
26
+ const nextChar = line[j + 1] || '';
27
+ // Handle multiline comments
28
+ if (!inString && !inMultilineString && !inMultilineComment) {
29
+ if (char === '-' && nextChar === '-') {
30
+ const nextNextChar = line[j + 2] || '';
31
+ if (nextNextChar === '[') {
32
+ let k = j + 3;
33
+ let eq = 0;
34
+ while (line[k] === '=') {
35
+ eq++;
36
+ k++;
37
+ }
38
+ if (line[k] === '[') {
39
+ inMultilineComment = true;
40
+ multilineEqCount = eq;
41
+ j = k + 1;
42
+ continue;
43
+ }
44
+ }
45
+ // Single line comment, skip rest of line
46
+ break;
47
+ }
48
+ }
49
+ if (inMultilineComment) {
50
+ if (char === ']') {
51
+ let k = j + 1;
52
+ let eq = 0;
53
+ while (line[k] === '=') {
54
+ eq++;
55
+ k++;
56
+ }
57
+ if (line[k] === ']' && eq === multilineEqCount) {
58
+ inMultilineComment = false;
59
+ j = k + 1;
60
+ continue;
61
+ }
62
+ }
63
+ j++;
64
+ continue;
65
+ }
66
+ // Handle strings
67
+ if (!inString && !inMultilineString) {
68
+ if (char === '"' || char === "'") {
69
+ inString = true;
70
+ stringChar = char;
71
+ j++;
72
+ continue;
73
+ }
74
+ if (char === '[') {
75
+ let k = j + 1;
76
+ let eq = 0;
77
+ while (line[k] === '=') {
78
+ eq++;
79
+ k++;
80
+ }
81
+ if (line[k] === '[') {
82
+ inMultilineString = true;
83
+ multilineEqCount = eq;
84
+ j = k + 1;
85
+ continue;
86
+ }
87
+ }
88
+ }
89
+ if (inString) {
90
+ if (char === '\\' && (nextChar === '"' || nextChar === "'" || nextChar === '\\')) {
91
+ j += 2;
92
+ continue;
93
+ }
94
+ if (char === stringChar) {
95
+ inString = false;
96
+ }
97
+ j++;
98
+ continue;
99
+ }
100
+ if (inMultilineString) {
101
+ if (char === ']') {
102
+ let k = j + 1;
103
+ let eq = 0;
104
+ while (line[k] === '=') {
105
+ eq++;
106
+ k++;
107
+ }
108
+ if (line[k] === ']' && eq === multilineEqCount) {
109
+ inMultilineString = false;
110
+ j = k + 1;
111
+ continue;
112
+ }
113
+ }
114
+ j++;
115
+ continue;
116
+ }
117
+ // At this point, we are outside strings and comments.
118
+ // Check brackets
119
+ if (char === '(')
120
+ parenDepth++;
121
+ else if (char === ')') {
122
+ parenDepth--;
123
+ if (parenDepth < 0)
124
+ return { valid: false, error: 'Kelebihan tutup kurung ")"', line: i + 1 };
125
+ }
126
+ else if (char === '{')
127
+ braceDepth++;
128
+ else if (char === '}') {
129
+ braceDepth--;
130
+ if (braceDepth < 0)
131
+ return { valid: false, error: 'Kelebihan tutup kurawal "}"', line: i + 1 };
132
+ }
133
+ else if (char === '[')
134
+ bracketDepth++;
135
+ else if (char === ']') {
136
+ bracketDepth--;
137
+ if (bracketDepth < 0)
138
+ return { valid: false, error: 'Kelebihan tutup siku "]"', line: i + 1 };
139
+ }
140
+ j++;
141
+ }
142
+ if (inString) {
143
+ return { valid: false, error: 'String tidak ditutup (unclosed string literal)', line: i + 1 };
144
+ }
145
+ // Tokenize line to check blocks (very simplified heuristic)
146
+ // We only check for keywords bounded by word boundaries
147
+ if (!inMultilineString && !inMultilineComment) {
148
+ // Remove string literals and comments for naive keyword counting
149
+ let cleanLine = line.replace(/(--.*)/, ''); // remove single line comments
150
+ cleanLine = cleanLine.replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, ''); // remove inline strings
151
+ // Avoid false positives like `local func = "do"` by looking at word boundaries
152
+ // This is a naive regex matching.
153
+ // In Luau: 'do', 'if', 'function', 'for', 'while' need an 'end'.
154
+ // 'repeat' needs 'until'.
155
+ const words = cleanLine.split(/[^a-zA-Z0-9_]+/).filter(w => w.length > 0);
156
+ for (let w = 0; w < words.length; w++) {
157
+ const word = words[w];
158
+ if (word === 'if' || word === 'function' || word === 'for' || word === 'while' || word === 'do') {
159
+ // 'elseif' does not create a new block.
160
+ // 'if' does.
161
+ blockDepth++;
162
+ }
163
+ else if (word === 'end') {
164
+ blockDepth--;
165
+ }
166
+ else if (word === 'repeat') {
167
+ repeatDepth++;
168
+ }
169
+ else if (word === 'until') {
170
+ repeatDepth--;
171
+ }
172
+ }
173
+ }
174
+ }
175
+ if (inMultilineString)
176
+ return { valid: false, error: 'Multiline string tidak ditutup (unclosed [[ ]])' };
177
+ if (inMultilineComment)
178
+ return { valid: false, error: 'Multiline comment tidak ditutup (unclosed --[[ ]])' };
179
+ if (parenDepth > 0)
180
+ return { valid: false, error: 'Kekurangan tutup kurung ")"' };
181
+ if (braceDepth > 0)
182
+ return { valid: false, error: 'Kekurangan tutup kurawal "}"' };
183
+ if (bracketDepth > 0)
184
+ return { valid: false, error: 'Kekurangan tutup siku "]"' };
185
+ // We can't strictly enforce blockDepth == 0 because our heuristic is very naive
186
+ // and might be thrown off by complex inline patterns, but it catches massive missing 'end's.
187
+ // So we'll return a warning instead of blocking if blockDepth != 0,
188
+ // or we can just let it pass to Studio which will output the real error.
189
+ // For now, let's just use it as a basic check.
190
+ return { valid: true };
191
+ }
@@ -89,6 +89,16 @@ function startBridgeServer(port = 3055) {
89
89
  app.get('/api/stats', (req, res) => {
90
90
  res.json({ totalTasks, successCount, errorCount, sessionStart, studioConnected, port });
91
91
  });
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
+ }
101
+ });
92
102
  // Endpoint Studio Polling
93
103
  app.get('/api/tasks', (req, res) => {
94
104
  if (!studioConnected) {
@@ -113,7 +123,23 @@ function startBridgeServer(port = 3055) {
113
123
  res.json(task);
114
124
  }
115
125
  else {
116
- res.json({ id: null });
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
+ });
117
143
  }
118
144
  });
119
145
  // Endpoint Studio Response
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pendingTasks = exports.taskQueue = void 0;
3
+ exports.pendingTasks = exports.taskEmitter = exports.taskQueue = void 0;
4
4
  exports.addTaskToQueue = addTaskToQueue;
5
5
  exports.resolvePendingTask = resolvePendingTask;
6
6
  const uuid_1 = require("uuid");
7
+ const events_1 = require("events");
7
8
  // 1. Array untuk menampung antrean tugas (task queue)
8
9
  exports.taskQueue = [];
10
+ exports.taskEmitter = new events_1.EventEmitter();
9
11
  // 2. Map untuk menyimpan Promise dari tugas yang sedang menunggu (pending tasks)
10
12
  exports.pendingTasks = new Map();
11
13
  /**
@@ -24,6 +26,7 @@ function addTaskToQueue(command, target, data) {
24
26
  }, 30000);
25
27
  exports.pendingTasks.set(id, { resolve, reject, timer });
26
28
  exports.taskQueue.push(task);
29
+ exports.taskEmitter.emit('new_task');
27
30
  });
28
31
  }
29
32
  /**
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "1.0.4",
3
+ "version": "2.0.0",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": "dist/index.js",
7
7
  "files": [
8
8
  "dist",
9
- "public"
9
+ "public",
10
+ "studio-plugin"
10
11
  ],
11
12
  "scripts": {
12
- "build": "tsc",
13
+ "build": "tsc && npm run build:plugin",
14
+ "build:plugin": "node --experimental-strip-types src/build-plugin.ts",
13
15
  "start": "node dist/index.js",
14
16
  "dev": "ts-node src/index.ts"
15
17
  },