@chrrxs/robloxstudio-mcp-inspector 2.8.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.
Files changed (30) hide show
  1. package/dist/index.js +4483 -0
  2. package/package.json +50 -0
  3. package/studio-plugin/INSTALLATION.md +150 -0
  4. package/studio-plugin/MCPInspectorPlugin.rbxmx +9074 -0
  5. package/studio-plugin/MCPPlugin.rbxmx +9074 -0
  6. package/studio-plugin/default.project.json +19 -0
  7. package/studio-plugin/dev.project.json +23 -0
  8. package/studio-plugin/inspector-icon.png +0 -0
  9. package/studio-plugin/package-lock.json +706 -0
  10. package/studio-plugin/package.json +19 -0
  11. package/studio-plugin/plugin.json +10 -0
  12. package/studio-plugin/src/modules/ClientBroker.ts +221 -0
  13. package/studio-plugin/src/modules/Communication.ts +399 -0
  14. package/studio-plugin/src/modules/Recording.ts +28 -0
  15. package/studio-plugin/src/modules/State.ts +94 -0
  16. package/studio-plugin/src/modules/UI.ts +725 -0
  17. package/studio-plugin/src/modules/Utils.ts +318 -0
  18. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
  19. package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
  20. package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +128 -0
  21. package/studio-plugin/src/modules/handlers/InputHandlers.ts +102 -0
  22. package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
  23. package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +391 -0
  24. package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
  25. package/studio-plugin/src/modules/handlers/QueryHandlers.ts +827 -0
  26. package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
  27. package/studio-plugin/src/modules/handlers/TestHandlers.ts +277 -0
  28. package/studio-plugin/src/server/index.server.ts +63 -0
  29. package/studio-plugin/src/types/index.d.ts +44 -0
  30. package/studio-plugin/tsconfig.json +20 -0
@@ -0,0 +1,530 @@
1
+ import Utils from "../Utils";
2
+ import Recording from "../Recording";
3
+
4
+ const ScriptEditorService = game.GetService("ScriptEditorService");
5
+
6
+ const { getInstancePath, getInstanceByPath, readScriptSource, splitLines, joinLines } = Utils;
7
+ const { beginRecording, finishRecording } = Recording;
8
+
9
+ function normalizeEscapes(s: string): string {
10
+ let result = s;
11
+ result = result.gsub("\\\\", "\x01")[0];
12
+ result = result.gsub("\\n", "\n")[0];
13
+ result = result.gsub("\\t", "\t")[0];
14
+ result = result.gsub("\\r", "\r")[0];
15
+ result = result.gsub('\\"', '"')[0];
16
+ result = result.gsub("\x01", "\\")[0];
17
+ return result;
18
+ }
19
+
20
+ function getScriptSource(requestData: Record<string, unknown>) {
21
+ const instancePath = requestData.instancePath as string;
22
+ const startLine = requestData.startLine as number | undefined;
23
+ const endLine = requestData.endLine as number | undefined;
24
+
25
+ if (!instancePath) return { error: "Instance path is required" };
26
+
27
+ const instance = getInstanceByPath(instancePath);
28
+ if (!instance) return { error: `Instance not found: ${instancePath}` };
29
+ if (!instance.IsA("LuaSourceContainer")) {
30
+ return { error: `Instance is not a script-like object: ${instance.ClassName}` };
31
+ }
32
+
33
+ const [success, result] = pcall(() => {
34
+ const fullSource = readScriptSource(instance);
35
+ const [lines, hasTrailingNewline] = splitLines(fullSource);
36
+ const totalLineCount = lines.size();
37
+
38
+ let sourceToReturn = fullSource;
39
+ let returnedStartLine = 1;
40
+ let returnedEndLine = totalLineCount;
41
+
42
+ if (startLine !== undefined || endLine !== undefined) {
43
+ const actualStartLine = math.max(1, startLine ?? 1);
44
+ const actualEndLine = math.min(lines.size(), endLine ?? lines.size());
45
+
46
+ const selectedLines: string[] = [];
47
+ for (let i = actualStartLine; i <= actualEndLine; i++) {
48
+ selectedLines.push(lines[i - 1] ?? "");
49
+ }
50
+
51
+ sourceToReturn = selectedLines.join("\n");
52
+ if (hasTrailingNewline && actualEndLine === lines.size() && sourceToReturn.sub(-1) !== "\n") {
53
+ sourceToReturn += "\n";
54
+ }
55
+ returnedStartLine = actualStartLine;
56
+ returnedEndLine = actualEndLine;
57
+ }
58
+
59
+ const numberedLines: string[] = [];
60
+ const linesToNumber = startLine !== undefined ? splitLines(sourceToReturn)[0] : lines;
61
+ const lineOffset = returnedStartLine - 1;
62
+ for (let i = 0; i < linesToNumber.size(); i++) {
63
+ numberedLines.push(`${i + 1 + lineOffset}: ${linesToNumber[i]}`);
64
+ }
65
+ const numberedSource = numberedLines.join("\n");
66
+
67
+ const resp: Record<string, unknown> = {
68
+ instancePath,
69
+ className: instance.ClassName,
70
+ name: instance.Name,
71
+ source: sourceToReturn,
72
+ numberedSource,
73
+ sourceLength: fullSource.size(),
74
+ lineCount: totalLineCount,
75
+ startLine: returnedStartLine,
76
+ endLine: returnedEndLine,
77
+ isPartial: startLine !== undefined || endLine !== undefined,
78
+ truncated: false,
79
+ };
80
+
81
+ if (startLine === undefined && endLine === undefined && fullSource.size() > 50000) {
82
+ const truncatedLines: string[] = [];
83
+ const truncatedNumberedLines: string[] = [];
84
+ const maxLines = math.min(1000, lines.size());
85
+ for (let i = 0; i < maxLines; i++) {
86
+ truncatedLines.push(lines[i]);
87
+ truncatedNumberedLines.push(`${i + 1}: ${lines[i]}`);
88
+ }
89
+ resp.source = truncatedLines.join("\n");
90
+ resp.numberedSource = truncatedNumberedLines.join("\n");
91
+ resp.truncated = true;
92
+ resp.endLine = maxLines;
93
+ resp.note = "Script truncated to first 1000 lines. Use startLine/endLine parameters to read specific sections.";
94
+ }
95
+
96
+ if (instance.IsA("BaseScript")) {
97
+ resp.enabled = instance.Enabled;
98
+ }
99
+
100
+ let topServiceInst: Instance = instance;
101
+ while (topServiceInst.Parent && topServiceInst.Parent !== game) {
102
+ topServiceInst = topServiceInst.Parent;
103
+ }
104
+ resp.topService = topServiceInst.Name;
105
+
106
+ return resp;
107
+ });
108
+
109
+ if (success) {
110
+ return result;
111
+ } else {
112
+ return { error: `Failed to get script source: ${result}` };
113
+ }
114
+ }
115
+
116
+ function setScriptSource(requestData: Record<string, unknown>) {
117
+ const instancePath = requestData.instancePath as string;
118
+ const newSource = requestData.source as string;
119
+
120
+ if (!instancePath || !newSource) return { error: "Instance path and source are required" };
121
+
122
+ const instance = getInstanceByPath(instancePath);
123
+ if (!instance) return { error: `Instance not found: ${instancePath}` };
124
+ if (!instance.IsA("LuaSourceContainer")) {
125
+ return { error: `Instance is not a script-like object: ${instance.ClassName}` };
126
+ }
127
+
128
+ const sourceToSet = normalizeEscapes(newSource);
129
+ const recordingId = beginRecording(`Set script source: ${instance.Name}`);
130
+
131
+ const [updateSuccess, updateResult] = pcall(() => {
132
+ const oldSourceLength = readScriptSource(instance).size();
133
+
134
+ ScriptEditorService.UpdateSourceAsync(instance, () => sourceToSet);
135
+
136
+ return {
137
+ success: true, instancePath,
138
+ oldSourceLength, newSourceLength: sourceToSet.size(),
139
+ method: "UpdateSourceAsync",
140
+ message: "Script source updated successfully (editor-safe)",
141
+ };
142
+ });
143
+
144
+ if (updateSuccess) {
145
+ finishRecording(recordingId, true);
146
+ return updateResult;
147
+ }
148
+
149
+ const [directSuccess, directResult] = pcall(() => {
150
+ const oldSource = (instance as unknown as { Source: string }).Source;
151
+ (instance as unknown as { Source: string }).Source = sourceToSet;
152
+
153
+ return {
154
+ success: true, instancePath,
155
+ oldSourceLength: oldSource.size(), newSourceLength: sourceToSet.size(),
156
+ method: "direct",
157
+ message: "Script source updated successfully (direct assignment)",
158
+ };
159
+ });
160
+
161
+ if (directSuccess) {
162
+ finishRecording(recordingId, true);
163
+ return directResult;
164
+ }
165
+
166
+ const [replaceSuccess, replaceResult] = pcall(() => {
167
+ const parent = instance.Parent;
168
+ const name = instance.Name;
169
+ const className = instance.ClassName;
170
+ const wasBaseScript = instance.IsA("BaseScript");
171
+ const enabled = wasBaseScript ? instance.Enabled : undefined;
172
+
173
+ const newScript = new Instance(className as keyof CreatableInstances) as LuaSourceContainer;
174
+ newScript.Name = name;
175
+ (newScript as unknown as { Source: string }).Source = sourceToSet;
176
+ if (wasBaseScript && enabled !== undefined) {
177
+ (newScript as BaseScript).Enabled = enabled;
178
+ }
179
+
180
+ newScript.Parent = parent;
181
+ instance.Destroy();
182
+
183
+ return {
184
+ success: true,
185
+ instancePath: getInstancePath(newScript),
186
+ method: "replace",
187
+ message: "Script replaced successfully with new source",
188
+ };
189
+ });
190
+
191
+ if (replaceSuccess) {
192
+ finishRecording(recordingId, true);
193
+ return replaceResult;
194
+ }
195
+
196
+ finishRecording(recordingId, false);
197
+ return {
198
+ error: `Failed to set script source. UpdateSourceAsync failed: ${updateResult}. Direct assignment failed: ${directResult}. Replace method failed: ${replaceResult}`,
199
+ };
200
+ }
201
+
202
+ function editScriptLines(requestData: Record<string, unknown>) {
203
+ const instancePath = requestData.instancePath as string;
204
+ let oldString = requestData.old_string as string;
205
+ let newString = requestData.new_string as string;
206
+ const startLine = requestData.startLine as number | undefined;
207
+
208
+ if (!instancePath || oldString === undefined || newString === undefined) {
209
+ return { error: "Instance path, old_string, and new_string are required" };
210
+ }
211
+
212
+ oldString = normalizeEscapes(oldString);
213
+ newString = normalizeEscapes(newString);
214
+
215
+ const instance = getInstanceByPath(instancePath);
216
+ if (!instance) return { error: `Instance not found: ${instancePath}` };
217
+ if (!instance.IsA("LuaSourceContainer")) {
218
+ return { error: `Instance is not a script-like object: ${instance.ClassName}` };
219
+ }
220
+
221
+ const recordingId = beginRecording(`Edit script: ${instance.Name}`);
222
+
223
+ const [success, result] = pcall(() => {
224
+ const source = readScriptSource(instance);
225
+ const searchLen = oldString.size();
226
+ let matchStart: number;
227
+
228
+ if (startLine !== undefined) {
229
+ if (startLine < 1) error(`startLine must be >= 1 (got ${startLine})`);
230
+
231
+ let lineStartByte = 1;
232
+ let currentLine = 1;
233
+ while (currentLine < startLine) {
234
+ const [nlPos] = string.find(source, "\n", lineStartByte, true);
235
+ if (nlPos === undefined) {
236
+ error(`startLine ${startLine} is past end of script (${currentLine} lines)`);
237
+ }
238
+ lineStartByte = (nlPos as number) + 1;
239
+ currentLine++;
240
+ }
241
+
242
+ const candidate = string.sub(source, lineStartByte, lineStartByte + searchLen - 1);
243
+ if (candidate !== oldString) {
244
+ error(`old_string does not match at line ${startLine}. Use get_script_source to verify the exact text at that line.`);
245
+ }
246
+ matchStart = lineStartByte;
247
+ } else {
248
+ let count = 0;
249
+ let searchPos = 1;
250
+ let firstMatch: number | undefined;
251
+ while (true) {
252
+ const [foundStart] = string.find(source, oldString, searchPos, true);
253
+ if (foundStart === undefined) break;
254
+ if (firstMatch === undefined) firstMatch = foundStart;
255
+ count++;
256
+ if (count > 1) break;
257
+ searchPos = foundStart + searchLen;
258
+ }
259
+ if (count === 0) error("old_string not found in script. If old_string contains repeated patterns (e.g. closing braces), pass startLine to anchor the edit.");
260
+ if (count > 1) error("old_string matches multiple locations. Provide more surrounding context, or pass startLine to anchor the edit to a specific line.");
261
+ matchStart = firstMatch as number;
262
+ }
263
+
264
+ // Byte-slice replacement avoids Lua pattern escaping (safe for multi-byte chars like em dashes).
265
+ const newSource = string.sub(source, 1, matchStart - 1) + newString + string.sub(source, matchStart + searchLen);
266
+
267
+ ScriptEditorService.UpdateSourceAsync(instance, () => newSource);
268
+
269
+ return {
270
+ success: true,
271
+ instancePath,
272
+ message: "Script edited successfully",
273
+ };
274
+ });
275
+
276
+ if (success) {
277
+ finishRecording(recordingId, true);
278
+ return result;
279
+ }
280
+ finishRecording(recordingId, false);
281
+ return { error: `Failed to edit script: ${result}` };
282
+ }
283
+
284
+ function insertScriptLines(requestData: Record<string, unknown>) {
285
+ const instancePath = requestData.instancePath as string;
286
+ const afterLine = (requestData.afterLine as number) ?? 0;
287
+ let newContent = requestData.newContent as string;
288
+
289
+ if (!instancePath || !newContent) return { error: "Instance path and newContent are required" };
290
+
291
+ newContent = normalizeEscapes(newContent);
292
+
293
+ const instance = getInstanceByPath(instancePath);
294
+ if (!instance) return { error: `Instance not found: ${instancePath}` };
295
+ if (!instance.IsA("LuaSourceContainer")) {
296
+ return { error: `Instance is not a script-like object: ${instance.ClassName}` };
297
+ }
298
+
299
+ const recordingId = beginRecording(`Insert script lines after line ${afterLine}: ${instance.Name}`);
300
+
301
+ const [success, result] = pcall(() => {
302
+ const [lines, hadTrailingNewline] = splitLines(readScriptSource(instance));
303
+ const totalLines = lines.size();
304
+
305
+ if (afterLine < 0 || afterLine > totalLines) error(`afterLine out of range (0-${totalLines})`);
306
+
307
+ const [newLines] = splitLines(newContent);
308
+ const resultLines: string[] = [];
309
+
310
+ for (let i = 0; i < afterLine; i++) resultLines.push(lines[i]);
311
+ for (const line of newLines) resultLines.push(line);
312
+ for (let i = afterLine; i < totalLines; i++) resultLines.push(lines[i]);
313
+
314
+ const newSource = joinLines(resultLines, hadTrailingNewline);
315
+ ScriptEditorService.UpdateSourceAsync(instance, () => newSource);
316
+
317
+ return {
318
+ success: true, instancePath,
319
+ insertedAfterLine: afterLine,
320
+ linesInserted: newLines.size(),
321
+ newLineCount: resultLines.size(),
322
+ message: "Script lines inserted successfully",
323
+ };
324
+ });
325
+
326
+ if (success) {
327
+ finishRecording(recordingId, true);
328
+ return result;
329
+ }
330
+ finishRecording(recordingId, false);
331
+ return { error: `Failed to insert script lines: ${result}` };
332
+ }
333
+
334
+ function deleteScriptLines(requestData: Record<string, unknown>) {
335
+ const instancePath = requestData.instancePath as string;
336
+ const startLine = requestData.startLine as number;
337
+ const endLine = requestData.endLine as number;
338
+
339
+ if (!instancePath || !startLine || !endLine) {
340
+ return { error: "Instance path, startLine, and endLine are required" };
341
+ }
342
+
343
+ const instance = getInstanceByPath(instancePath);
344
+ if (!instance) return { error: `Instance not found: ${instancePath}` };
345
+ if (!instance.IsA("LuaSourceContainer")) {
346
+ return { error: `Instance is not a script-like object: ${instance.ClassName}` };
347
+ }
348
+
349
+ const recordingId = beginRecording(`Delete script lines ${startLine}-${endLine}: ${instance.Name}`);
350
+
351
+ const [success, result] = pcall(() => {
352
+ const [lines, hadTrailingNewline] = splitLines(readScriptSource(instance));
353
+ const totalLines = lines.size();
354
+
355
+ if (startLine < 1 || startLine > totalLines) error(`startLine out of range (1-${totalLines})`);
356
+ if (endLine < startLine || endLine > totalLines) error(`endLine out of range (${startLine}-${totalLines})`);
357
+
358
+ const resultLines: string[] = [];
359
+ for (let i = 0; i < startLine - 1; i++) resultLines.push(lines[i]);
360
+ for (let i = endLine; i < totalLines; i++) resultLines.push(lines[i]);
361
+
362
+ const newSource = joinLines(resultLines, hadTrailingNewline);
363
+ ScriptEditorService.UpdateSourceAsync(instance, () => newSource);
364
+
365
+ return {
366
+ success: true, instancePath,
367
+ deletedLines: { startLine, endLine },
368
+ linesDeleted: endLine - startLine + 1,
369
+ newLineCount: resultLines.size(),
370
+ message: "Script lines deleted successfully",
371
+ };
372
+ });
373
+
374
+ if (success) {
375
+ finishRecording(recordingId, true);
376
+ return result;
377
+ }
378
+ finishRecording(recordingId, false);
379
+ return { error: `Failed to delete script lines: ${result}` };
380
+ }
381
+
382
+ function escapeLuaPattern(s: string): string {
383
+ return s.gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")[0];
384
+ }
385
+
386
+ function escapeLuaReplacement(s: string): string {
387
+ return s.gsub("%%", "%%%%")[0];
388
+ }
389
+
390
+ function caseInsensitiveLiteralReplace(src: string, searchStr: string, repl: string): [string, number] {
391
+ const lowerSrc = src.lower();
392
+ const lowerSearch = searchStr.lower();
393
+ const parts: string[] = [];
394
+ let lastEnd = 1;
395
+ const searchLen = lowerSearch.size();
396
+ let pos = 1;
397
+ let replCount = 0;
398
+
399
+ while (true) {
400
+ const [foundStart] = string.find(lowerSrc, lowerSearch, pos, true);
401
+ if (foundStart === undefined) break;
402
+ parts.push(string.sub(src, lastEnd, foundStart - 1));
403
+ parts.push(repl);
404
+ lastEnd = foundStart + searchLen;
405
+ pos = foundStart + searchLen;
406
+ replCount++;
407
+ }
408
+ parts.push(string.sub(src, lastEnd));
409
+ return [parts.join(""), replCount];
410
+ }
411
+
412
+ function findAndReplaceInScripts(requestData: Record<string, unknown>) {
413
+ const searchPattern = requestData.pattern as string;
414
+ const replacement = requestData.replacement as string;
415
+
416
+ if (!searchPattern) return { error: "pattern is required" };
417
+ if (replacement === undefined) return { error: "replacement is required" };
418
+
419
+ const caseSensitive = (requestData.caseSensitive as boolean) ?? false;
420
+ const usePattern = (requestData.usePattern as boolean) ?? false;
421
+ const searchPath = (requestData.path as string) ?? "";
422
+ const classFilter = requestData.classFilter as string | undefined;
423
+ const dryRun = (requestData.dryRun as boolean) ?? false;
424
+ const maxReplacements = (requestData.maxReplacements as number) ?? 1000;
425
+
426
+ if (!caseSensitive && usePattern) {
427
+ return { error: "Case-insensitive Lua pattern replacement is not supported. Use caseSensitive: true with usePattern: true, or use literal matching." };
428
+ }
429
+
430
+ const startInstance = searchPath !== "" ? getInstanceByPath(searchPath) : game;
431
+ if (!startInstance) return { error: `Path not found: ${searchPath}` };
432
+
433
+ interface ScriptChange {
434
+ instancePath: string;
435
+ name: string;
436
+ className: string;
437
+ replacements: number;
438
+ }
439
+
440
+ const changes: ScriptChange[] = [];
441
+ let totalReplacements = 0;
442
+ let scriptsSearched = 0;
443
+ let hitLimit = false;
444
+
445
+ const recordingId = dryRun ? undefined : beginRecording("Find and replace in scripts");
446
+
447
+ function processInstance(instance: Instance) {
448
+ if (hitLimit) return;
449
+
450
+ if (instance.IsA("LuaSourceContainer")) {
451
+ if (classFilter && !instance.ClassName.lower().find(classFilter.lower())[0]) return;
452
+
453
+ scriptsSearched++;
454
+ const source = readScriptSource(instance);
455
+
456
+ let newSource: string;
457
+ let replCount: number;
458
+
459
+ if (usePattern) {
460
+ const [result, count] = string.gsub(source, searchPattern, replacement);
461
+ newSource = result;
462
+ replCount = count;
463
+ } else if (caseSensitive) {
464
+ const escaped = escapeLuaPattern(searchPattern);
465
+ const escapedRepl = escapeLuaReplacement(replacement);
466
+ const [result, count] = string.gsub(source, escaped, escapedRepl);
467
+ newSource = result;
468
+ replCount = count;
469
+ } else {
470
+ [newSource, replCount] = caseInsensitiveLiteralReplace(source, searchPattern, replacement);
471
+ }
472
+
473
+ if (replCount > 0) {
474
+ if (totalReplacements + replCount > maxReplacements) {
475
+ hitLimit = true;
476
+ return;
477
+ }
478
+ totalReplacements += replCount;
479
+
480
+ if (!dryRun) {
481
+ const [ok] = pcall(() => {
482
+ ScriptEditorService.UpdateSourceAsync(instance, () => newSource);
483
+ });
484
+ if (!ok) {
485
+ (instance as unknown as { Source: string }).Source = newSource;
486
+ }
487
+ }
488
+
489
+ changes.push({
490
+ instancePath: getInstancePath(instance),
491
+ name: instance.Name,
492
+ className: instance.ClassName,
493
+ replacements: replCount,
494
+ });
495
+ }
496
+ }
497
+
498
+ for (const child of instance.GetChildren()) {
499
+ if (hitLimit) return;
500
+ processInstance(child);
501
+ }
502
+ }
503
+
504
+ processInstance(startInstance);
505
+
506
+ if (recordingId !== undefined) {
507
+ finishRecording(recordingId, changes.size() > 0);
508
+ }
509
+
510
+ return {
511
+ success: true,
512
+ dryRun,
513
+ pattern: searchPattern,
514
+ replacement,
515
+ totalReplacements,
516
+ scriptsSearched,
517
+ scriptsModified: changes.size(),
518
+ changes,
519
+ truncated: hitLimit,
520
+ };
521
+ }
522
+
523
+ export = {
524
+ getScriptSource,
525
+ setScriptSource,
526
+ editScriptLines,
527
+ insertScriptLines,
528
+ deleteScriptLines,
529
+ findAndReplaceInScripts,
530
+ };