rbxstudio-mcp 2.3.1 → 2.4.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 (175) hide show
  1. package/README.md +67 -14
  2. package/dist/__tests__/bridge-service.test.js +25 -13
  3. package/dist/__tests__/bridge-service.test.js.map +1 -1
  4. package/dist/__tests__/bridge-session.test.d.ts +2 -0
  5. package/dist/__tests__/bridge-session.test.d.ts.map +1 -0
  6. package/dist/__tests__/bridge-session.test.js +171 -0
  7. package/dist/__tests__/bridge-session.test.js.map +1 -0
  8. package/dist/__tests__/chunker.test.d.ts +2 -0
  9. package/dist/__tests__/chunker.test.d.ts.map +1 -0
  10. package/dist/__tests__/chunker.test.js +201 -0
  11. package/dist/__tests__/chunker.test.js.map +1 -0
  12. package/dist/__tests__/docs-core.test.d.ts +2 -0
  13. package/dist/__tests__/docs-core.test.d.ts.map +1 -0
  14. package/dist/__tests__/docs-core.test.js +137 -0
  15. package/dist/__tests__/docs-core.test.js.map +1 -0
  16. package/dist/__tests__/docs-fetcher.test.d.ts +2 -0
  17. package/dist/__tests__/docs-fetcher.test.d.ts.map +1 -0
  18. package/dist/__tests__/docs-fetcher.test.js +173 -0
  19. package/dist/__tests__/docs-fetcher.test.js.map +1 -0
  20. package/dist/__tests__/helpers.d.ts +8 -0
  21. package/dist/__tests__/helpers.d.ts.map +1 -0
  22. package/dist/__tests__/helpers.js +23 -0
  23. package/dist/__tests__/helpers.js.map +1 -0
  24. package/dist/__tests__/http-routes.test.d.ts +2 -0
  25. package/dist/__tests__/http-routes.test.d.ts.map +1 -0
  26. package/dist/__tests__/http-routes.test.js +233 -0
  27. package/dist/__tests__/http-routes.test.js.map +1 -0
  28. package/dist/__tests__/http-server.test.js +13 -6
  29. package/dist/__tests__/http-server.test.js.map +1 -1
  30. package/dist/__tests__/integration.test.js +9 -4
  31. package/dist/__tests__/integration.test.js.map +1 -1
  32. package/dist/__tests__/semantic-search.test.d.ts +2 -0
  33. package/dist/__tests__/semantic-search.test.d.ts.map +1 -0
  34. package/dist/__tests__/semantic-search.test.js +202 -0
  35. package/dist/__tests__/semantic-search.test.js.map +1 -0
  36. package/dist/__tests__/smoke.test.js +7 -3
  37. package/dist/__tests__/smoke.test.js.map +1 -1
  38. package/dist/__tests__/studio-client.test.d.ts +2 -0
  39. package/dist/__tests__/studio-client.test.d.ts.map +1 -0
  40. package/dist/__tests__/studio-client.test.js +25 -0
  41. package/dist/__tests__/studio-client.test.js.map +1 -0
  42. package/dist/__tests__/tool-nudges.test.d.ts +2 -0
  43. package/dist/__tests__/tool-nudges.test.d.ts.map +1 -0
  44. package/dist/__tests__/tool-nudges.test.js +60 -0
  45. package/dist/__tests__/tool-nudges.test.js.map +1 -0
  46. package/dist/__tests__/tool-registry.test.d.ts +2 -0
  47. package/dist/__tests__/tool-registry.test.d.ts.map +1 -0
  48. package/dist/__tests__/tool-registry.test.js +365 -0
  49. package/dist/__tests__/tool-registry.test.js.map +1 -0
  50. package/dist/__tests__/tools-bridge.test.d.ts +2 -0
  51. package/dist/__tests__/tools-bridge.test.d.ts.map +1 -0
  52. package/dist/__tests__/tools-bridge.test.js +396 -0
  53. package/dist/__tests__/tools-bridge.test.js.map +1 -0
  54. package/dist/__tests__/tools-docs.test.d.ts +2 -0
  55. package/dist/__tests__/tools-docs.test.d.ts.map +1 -0
  56. package/dist/__tests__/tools-docs.test.js +112 -0
  57. package/dist/__tests__/tools-docs.test.js.map +1 -0
  58. package/dist/__tests__/tools-guards.test.d.ts +2 -0
  59. package/dist/__tests__/tools-guards.test.d.ts.map +1 -0
  60. package/dist/__tests__/tools-guards.test.js +131 -0
  61. package/dist/__tests__/tools-guards.test.js.map +1 -0
  62. package/dist/__tests__/tools-runtime.test.d.ts +2 -0
  63. package/dist/__tests__/tools-runtime.test.d.ts.map +1 -0
  64. package/dist/__tests__/tools-runtime.test.js +214 -0
  65. package/dist/__tests__/tools-runtime.test.js.map +1 -0
  66. package/dist/__tests__/tools-visual.test.d.ts +2 -0
  67. package/dist/__tests__/tools-visual.test.d.ts.map +1 -0
  68. package/dist/__tests__/tools-visual.test.js +149 -0
  69. package/dist/__tests__/tools-visual.test.js.map +1 -0
  70. package/dist/bridge-service.d.ts +99 -12
  71. package/dist/bridge-service.d.ts.map +1 -1
  72. package/dist/bridge-service.js +238 -21
  73. package/dist/bridge-service.js.map +1 -1
  74. package/dist/docs/cache.d.ts +50 -0
  75. package/dist/docs/cache.d.ts.map +1 -0
  76. package/dist/docs/cache.js +123 -0
  77. package/dist/docs/cache.js.map +1 -0
  78. package/dist/docs/embeddings/chunker.d.ts +120 -0
  79. package/dist/docs/embeddings/chunker.d.ts.map +1 -0
  80. package/dist/docs/embeddings/chunker.js +395 -0
  81. package/dist/docs/embeddings/chunker.js.map +1 -0
  82. package/dist/docs/embeddings/embedder.d.ts +41 -0
  83. package/dist/docs/embeddings/embedder.d.ts.map +1 -0
  84. package/dist/docs/embeddings/embedder.js +113 -0
  85. package/dist/docs/embeddings/embedder.js.map +1 -0
  86. package/dist/docs/embeddings/index.d.ts +102 -0
  87. package/dist/docs/embeddings/index.d.ts.map +1 -0
  88. package/dist/docs/embeddings/index.js +250 -0
  89. package/dist/docs/embeddings/index.js.map +1 -0
  90. package/dist/docs/embeddings/manager.d.ts +68 -0
  91. package/dist/docs/embeddings/manager.d.ts.map +1 -0
  92. package/dist/docs/embeddings/manager.js +97 -0
  93. package/dist/docs/embeddings/manager.js.map +1 -0
  94. package/dist/docs/fetcher.d.ts +29 -0
  95. package/dist/docs/fetcher.d.ts.map +1 -0
  96. package/dist/docs/fetcher.js +244 -0
  97. package/dist/docs/fetcher.js.map +1 -0
  98. package/dist/docs/reference.d.ts +37 -0
  99. package/dist/docs/reference.d.ts.map +1 -0
  100. package/dist/docs/reference.js +108 -0
  101. package/dist/docs/reference.js.map +1 -0
  102. package/dist/docs/search.d.ts +194 -0
  103. package/dist/docs/search.d.ts.map +1 -0
  104. package/dist/docs/search.js +733 -0
  105. package/dist/docs/search.js.map +1 -0
  106. package/dist/http-server.d.ts.map +1 -1
  107. package/dist/http-server.js +52 -5
  108. package/dist/http-server.js.map +1 -1
  109. package/dist/index.d.ts +8 -9
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +35 -1035
  112. package/dist/index.js.map +1 -1
  113. package/dist/instructions.d.ts +15 -0
  114. package/dist/instructions.d.ts.map +1 -0
  115. package/dist/instructions.js +26 -0
  116. package/dist/instructions.js.map +1 -0
  117. package/dist/tools/defs/attributes.d.ts +6 -0
  118. package/dist/tools/defs/attributes.d.ts.map +1 -0
  119. package/dist/tools/defs/attributes.js +85 -0
  120. package/dist/tools/defs/attributes.js.map +1 -0
  121. package/dist/tools/defs/docs.d.ts +17 -0
  122. package/dist/tools/defs/docs.d.ts.map +1 -0
  123. package/dist/tools/defs/docs.js +151 -0
  124. package/dist/tools/defs/docs.js.map +1 -0
  125. package/dist/tools/defs/execute.d.ts +6 -0
  126. package/dist/tools/defs/execute.d.ts.map +1 -0
  127. package/dist/tools/defs/execute.js +21 -0
  128. package/dist/tools/defs/execute.js.map +1 -0
  129. package/dist/tools/defs/inspection.d.ts +7 -0
  130. package/dist/tools/defs/inspection.d.ts.map +1 -0
  131. package/dist/tools/defs/inspection.js +202 -0
  132. package/dist/tools/defs/inspection.js.map +1 -0
  133. package/dist/tools/defs/objects.d.ts +6 -0
  134. package/dist/tools/defs/objects.d.ts.map +1 -0
  135. package/dist/tools/defs/objects.js +111 -0
  136. package/dist/tools/defs/objects.js.map +1 -0
  137. package/dist/tools/defs/properties.d.ts +6 -0
  138. package/dist/tools/defs/properties.d.ts.map +1 -0
  139. package/dist/tools/defs/properties.js +71 -0
  140. package/dist/tools/defs/properties.js.map +1 -0
  141. package/dist/tools/defs/runtime.d.ts +6 -0
  142. package/dist/tools/defs/runtime.d.ts.map +1 -0
  143. package/dist/tools/defs/runtime.js +145 -0
  144. package/dist/tools/defs/runtime.js.map +1 -0
  145. package/dist/tools/defs/scripts.d.ts +18 -0
  146. package/dist/tools/defs/scripts.d.ts.map +1 -0
  147. package/dist/tools/defs/scripts.js +163 -0
  148. package/dist/tools/defs/scripts.js.map +1 -0
  149. package/dist/tools/defs/tags.d.ts +6 -0
  150. package/dist/tools/defs/tags.d.ts.map +1 -0
  151. package/dist/tools/defs/tags.js +74 -0
  152. package/dist/tools/defs/tags.js.map +1 -0
  153. package/dist/tools/defs/visual.d.ts +7 -0
  154. package/dist/tools/defs/visual.d.ts.map +1 -0
  155. package/dist/tools/defs/visual.js +208 -0
  156. package/dist/tools/defs/visual.js.map +1 -0
  157. package/dist/tools/index.d.ts +101 -25
  158. package/dist/tools/index.d.ts.map +1 -1
  159. package/dist/tools/index.js +580 -63
  160. package/dist/tools/index.js.map +1 -1
  161. package/dist/tools/nudges.d.ts +25 -0
  162. package/dist/tools/nudges.d.ts.map +1 -0
  163. package/dist/tools/nudges.js +34 -0
  164. package/dist/tools/nudges.js.map +1 -0
  165. package/dist/tools/registry.d.ts +20 -0
  166. package/dist/tools/registry.d.ts.map +1 -0
  167. package/dist/tools/registry.js +65 -0
  168. package/dist/tools/registry.js.map +1 -0
  169. package/dist/tools/types.d.ts +24 -0
  170. package/dist/tools/types.d.ts.map +1 -0
  171. package/dist/tools/types.js +2 -0
  172. package/dist/tools/types.js.map +1 -0
  173. package/package.json +7 -6
  174. package/studio-plugin/MCPPlugin.rbxmx +3 -238
  175. package/studio-plugin/plugin.luau +2041 -365
@@ -1,5 +1,9 @@
1
1
  import { StudioHttpClient } from './studio-client.js';
2
2
  import * as zlib from 'zlib';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { ensureDocsCache } from '../docs/fetcher.js';
5
+ import { listDocs, readDocFile, searchDocs, } from '../docs/search.js';
6
+ import { resolveReference } from '../docs/reference.js';
3
7
  // PNG encoding utilities
4
8
  function createPNG(rgbaData, width, height) {
5
9
  // PNG signature
@@ -70,6 +74,54 @@ export class RobloxStudioTools {
70
74
  this.bridge = bridge;
71
75
  this.client = new StudioHttpClient(bridge);
72
76
  }
77
+ // ============================================
78
+ // MCP-INTERNAL SAFETY GUARDS
79
+ //
80
+ // run_live_lua relies on injected companion Scripts that live in
81
+ // ServerScriptService and StarterPlayer.StarterPlayerScripts. We don't
82
+ // want a confused AI to accidentally delete/edit/disable them via the
83
+ // generic destructive tools (set_property, delete_object, set_script_source,
84
+ // edit_script, find_and_replace_in_scripts, move_instance) — those would
85
+ // silently break the bridge.
86
+ //
87
+ // The plugin already does opportunistic sweeping by tag/name on activate
88
+ // and at the start/end of each play_solo. This guard is the second layer:
89
+ // refuse the operation outright with a friendly explanation, so the AI
90
+ // gets explicit feedback instead of mysterious "no eval reply" errors.
91
+ // ============================================
92
+ static MCP_INTERNAL_NAMES = [
93
+ '_MCPTestCompanion',
94
+ '_MCPTestCompanion_Client',
95
+ ];
96
+ isMCPInternal(path) {
97
+ if (!path || typeof path !== 'string')
98
+ return false;
99
+ for (const name of RobloxStudioTools.MCP_INTERNAL_NAMES) {
100
+ // Match as a path segment (preceded/followed by '.' or end-of-string),
101
+ // not as a substring inside an arbitrary user-named instance.
102
+ if (path === name ||
103
+ path.endsWith('.' + name) ||
104
+ path.includes('.' + name + '.') ||
105
+ path.startsWith(name + '.')) {
106
+ return true;
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+ mcpInternalRefusal(action, instancePath) {
112
+ return {
113
+ content: [
114
+ {
115
+ type: 'text',
116
+ text: JSON.stringify({
117
+ success: false,
118
+ error: 'mcp_internal',
119
+ message: `Refusing to ${action} "${instancePath}" — this is an MCP-internal companion Script used by run_live_lua / play_solo. The plugin manages its lifecycle automatically; if you really want it gone, run stop_play and the plugin will sweep it on the next activation. (Names reserved: _MCPTestCompanion, _MCPTestCompanion_Client.)`,
120
+ }, null, 2),
121
+ },
122
+ ],
123
+ };
124
+ }
73
125
  // File System Tools
74
126
  async getFileTree(path = '') {
75
127
  const response = await this.client.request('/api/file-tree', { path });
@@ -207,11 +259,57 @@ export class RobloxStudioTools {
207
259
  ]
208
260
  };
209
261
  }
262
+ /**
263
+ * grep — Claude Code-style search across the instance tree.
264
+ *
265
+ * Forwards to the Studio plugin which does a single `GetDescendants` walk
266
+ * scoped to `path`, filters by `type` (via `IsA`) and `glob` (Lua pattern
267
+ * over Name), then scans `Source` line-by-line (or full-source if
268
+ * `multiline`) for `pattern`. Designed to mirror the parameter surface of
269
+ * Claude Code's Grep tool 1:1 so the LLM can reuse muscle memory.
270
+ */
271
+ async grep(pattern, options = {}) {
272
+ if (typeof pattern !== 'string' || pattern.length === 0) {
273
+ throw new Error('pattern is required for grep (use ".*" for name-only searches)');
274
+ }
275
+ const response = await this.client.request('/api/grep', {
276
+ pattern,
277
+ path: options.path ?? 'game',
278
+ glob: options.glob,
279
+ type: options.type ?? ['LuaSourceContainer'],
280
+ caseInsensitive: options.caseInsensitive ?? false,
281
+ after: options.after ?? 0,
282
+ before: options.before ?? 0,
283
+ context: options.context ?? 0,
284
+ outputMode: options.outputMode ?? 'files_with_matches',
285
+ headLimit: options.headLimit ?? 0,
286
+ multiline: options.multiline ?? false,
287
+ });
288
+ return {
289
+ content: [
290
+ {
291
+ type: 'text',
292
+ text: JSON.stringify(response, null, 2),
293
+ },
294
+ ],
295
+ };
296
+ }
210
297
  // Property Modification Tools
211
298
  async setProperty(instancePath, propertyName, propertyValue) {
212
299
  if (!instancePath || !propertyName) {
213
300
  throw new Error('Instance path and property name are required for set_property');
214
301
  }
302
+ // Refuse to mutate MCP-internal companions — only the destructive
303
+ // properties; reading/altering benign metadata (e.g. Source via the
304
+ // dedicated API) is already gated below.
305
+ if (this.isMCPInternal(instancePath)) {
306
+ const dangerous = new Set([
307
+ 'Disabled', 'Source', 'Parent', 'Name', 'Archivable', 'RunContext', 'Enabled',
308
+ ]);
309
+ if (dangerous.has(propertyName)) {
310
+ return this.mcpInternalRefusal(`set ${propertyName} on`, instancePath);
311
+ }
312
+ }
215
313
  const response = await this.client.request('/api/set-property', {
216
314
  instancePath,
217
315
  propertyName,
@@ -331,6 +429,9 @@ export class RobloxStudioTools {
331
429
  if (!instancePath) {
332
430
  throw new Error('Instance path is required for delete_object');
333
431
  }
432
+ if (this.isMCPInternal(instancePath)) {
433
+ return this.mcpInternalRefusal('delete', instancePath);
434
+ }
334
435
  const response = await this.client.request('/api/delete-object', { instancePath });
335
436
  return {
336
437
  content: [
@@ -416,7 +517,14 @@ export class RobloxStudioTools {
416
517
  ]
417
518
  };
418
519
  }
419
- // Script Management Tools
520
+ // ============================================
521
+ // SCRIPT MANAGEMENT TOOLS
522
+ //
523
+ // The legacy line-based partial editors (edit_script_lines /
524
+ // insert_script_lines / delete_script_lines) were removed in favor of
525
+ // the string-based editScript() below — line numbers shift after every
526
+ // edit, which makes line-based editing unreliable for AI workflows.
527
+ // ============================================
420
528
  async getScriptSource(instancePath, startLine, endLine) {
421
529
  if (!instancePath) {
422
530
  throw new Error('Instance path is required for get_script_source');
@@ -435,50 +543,10 @@ export class RobloxStudioTools {
435
543
  if (!instancePath || typeof source !== 'string') {
436
544
  throw new Error('Instance path and source code string are required for set_script_source');
437
545
  }
438
- const response = await this.client.request('/api/set-script-source', { instancePath, source });
439
- return {
440
- content: [
441
- {
442
- type: 'text',
443
- text: JSON.stringify(response, null, 2)
444
- }
445
- ]
446
- };
447
- }
448
- // Partial Script Editing Tools
449
- async editScriptLines(instancePath, startLine, endLine, newContent) {
450
- if (!instancePath || !startLine || !endLine || typeof newContent !== 'string') {
451
- throw new Error('Instance path, startLine, endLine, and newContent are required for edit_script_lines');
452
- }
453
- const response = await this.client.request('/api/edit-script-lines', { instancePath, startLine, endLine, newContent });
454
- return {
455
- content: [
456
- {
457
- type: 'text',
458
- text: JSON.stringify(response, null, 2)
459
- }
460
- ]
461
- };
462
- }
463
- async insertScriptLines(instancePath, afterLine, newContent) {
464
- if (!instancePath || typeof newContent !== 'string') {
465
- throw new Error('Instance path and newContent are required for insert_script_lines');
466
- }
467
- const response = await this.client.request('/api/insert-script-lines', { instancePath, afterLine: afterLine || 0, newContent });
468
- return {
469
- content: [
470
- {
471
- type: 'text',
472
- text: JSON.stringify(response, null, 2)
473
- }
474
- ]
475
- };
476
- }
477
- async deleteScriptLines(instancePath, startLine, endLine) {
478
- if (!instancePath || !startLine || !endLine) {
479
- throw new Error('Instance path, startLine, and endLine are required for delete_script_lines');
546
+ if (this.isMCPInternal(instancePath)) {
547
+ return this.mcpInternalRefusal('rewrite source of', instancePath);
480
548
  }
481
- const response = await this.client.request('/api/delete-script-lines', { instancePath, startLine, endLine });
549
+ const response = await this.client.request('/api/set-script-source', { instancePath, source });
482
550
  return {
483
551
  content: [
484
552
  {
@@ -488,12 +556,9 @@ export class RobloxStudioTools {
488
556
  ]
489
557
  };
490
558
  }
491
- // ============================================
492
- // CLAUDE CODE-STYLE SCRIPT EDITING TOOLS
493
- // ============================================
494
559
  /**
495
- * edit_script - String-based script editing like Claude Code's Edit tool
496
- * Find exact text and replace it - no line numbers needed!
560
+ * edit_script - String-based script editing like Claude Code's Edit tool.
561
+ * Find exact text and replace it - no line numbers needed.
497
562
  */
498
563
  async editScript(instancePath, oldString, newString, replaceAll = false, validateAfter = true) {
499
564
  if (!instancePath) {
@@ -508,6 +573,9 @@ export class RobloxStudioTools {
508
573
  if (oldString === newString) {
509
574
  throw new Error('old_string and new_string must be different');
510
575
  }
576
+ if (this.isMCPInternal(instancePath)) {
577
+ return this.mcpInternalRefusal('edit', instancePath);
578
+ }
511
579
  const response = await this.client.request('/api/edit-script', {
512
580
  instancePath,
513
581
  oldString,
@@ -525,7 +593,7 @@ export class RobloxStudioTools {
525
593
  };
526
594
  }
527
595
  /**
528
- * search_script - Search for patterns within a script (like grep)
596
+ * search_script - Search for patterns within a script (like grep).
529
597
  */
530
598
  async searchScript(instancePath, pattern, useRegex = false, contextLines = 0) {
531
599
  if (!instancePath || !pattern) {
@@ -547,7 +615,7 @@ export class RobloxStudioTools {
547
615
  };
548
616
  }
549
617
  /**
550
- * get_script_function - Extract a specific function from a script by name
618
+ * get_script_function - Extract a specific function from a script by name.
551
619
  */
552
620
  async getScriptFunction(instancePath, functionName) {
553
621
  if (!instancePath || !functionName) {
@@ -567,7 +635,7 @@ export class RobloxStudioTools {
567
635
  };
568
636
  }
569
637
  /**
570
- * find_and_replace_in_scripts - Find and replace across multiple scripts
638
+ * find_and_replace_in_scripts - Batch find-and-replace across multiple scripts.
571
639
  */
572
640
  async findAndReplaceInScripts(paths, oldString, newString, validateAfter = true) {
573
641
  if (!paths || paths.length === 0) {
@@ -576,12 +644,43 @@ export class RobloxStudioTools {
576
644
  if (typeof oldString !== 'string' || typeof newString !== 'string') {
577
645
  throw new Error('old_string and new_string are required');
578
646
  }
647
+ // Filter out MCP-internal paths and report which ones we skipped, so
648
+ // the AI doesn't silently include companions in a batch rename.
649
+ const blocked = [];
650
+ const allowed = [];
651
+ for (const p of paths) {
652
+ if (this.isMCPInternal(p)) {
653
+ blocked.push(p);
654
+ }
655
+ else {
656
+ allowed.push(p);
657
+ }
658
+ }
659
+ if (allowed.length === 0) {
660
+ return {
661
+ content: [
662
+ {
663
+ type: 'text',
664
+ text: JSON.stringify({
665
+ success: false,
666
+ error: 'mcp_internal',
667
+ message: `All targeted paths are MCP-internal companion Scripts. Refusing to edit. Blocked: ${blocked.join(', ')}`,
668
+ }, null, 2),
669
+ },
670
+ ],
671
+ };
672
+ }
579
673
  const response = await this.client.request('/api/find-and-replace-in-scripts', {
580
- paths,
674
+ paths: allowed,
581
675
  oldString,
582
676
  newString,
583
677
  validateAfter
584
678
  });
679
+ if (blocked.length > 0) {
680
+ const responseObj = response;
681
+ responseObj.skippedMCPInternal = blocked;
682
+ responseObj.note = `${blocked.length} MCP-internal companion Script(s) were skipped: ${blocked.join(', ')}`;
683
+ }
585
684
  return {
586
685
  content: [
587
686
  {
@@ -742,6 +841,9 @@ export class RobloxStudioTools {
742
841
  if (!sourcePath || !targetParent) {
743
842
  throw new Error('Source path and target parent are required for clone_instance');
744
843
  }
844
+ if (this.isMCPInternal(sourcePath)) {
845
+ return this.mcpInternalRefusal('clone', sourcePath);
846
+ }
745
847
  const response = await this.client.request('/api/clone-instance', {
746
848
  sourcePath,
747
849
  targetParent,
@@ -760,6 +862,9 @@ export class RobloxStudioTools {
760
862
  if (!instancePath || !newParent) {
761
863
  throw new Error('Instance path and new parent are required for move_instance');
762
864
  }
865
+ if (this.isMCPInternal(instancePath)) {
866
+ return this.mcpInternalRefusal('move', instancePath);
867
+ }
763
868
  const response = await this.client.request('/api/move-instance', {
764
869
  instancePath,
765
870
  newParent
@@ -796,26 +901,54 @@ export class RobloxStudioTools {
796
901
  // ============================================
797
902
  // UNDO/REDO TOOLS
798
903
  // ============================================
799
- async undo() {
800
- const response = await this.client.request('/api/undo', {});
904
+ async undo(count) {
905
+ // Default + clamp client-side so the plugin can stay strict. Studio's
906
+ // own undo stack is the real source of truth; the plugin stops early if
907
+ // it runs out, regardless of what we ask for.
908
+ const safeCount = typeof count === 'number' && Number.isFinite(count)
909
+ ? Math.max(1, Math.min(100, Math.floor(count)))
910
+ : 1;
911
+ const response = await this.client.request('/api/undo', { count: safeCount });
801
912
  return {
802
913
  content: [
803
914
  {
804
915
  type: 'text',
805
- text: JSON.stringify(response, null, 2)
806
- }
807
- ]
916
+ text: JSON.stringify(response, null, 2),
917
+ },
918
+ ],
808
919
  };
809
920
  }
810
- async redo() {
811
- const response = await this.client.request('/api/redo', {});
921
+ async redo(count) {
922
+ const safeCount = typeof count === 'number' && Number.isFinite(count)
923
+ ? Math.max(1, Math.min(100, Math.floor(count)))
924
+ : 1;
925
+ const response = await this.client.request('/api/redo', { count: safeCount });
812
926
  return {
813
927
  content: [
814
928
  {
815
929
  type: 'text',
816
- text: JSON.stringify(response, null, 2)
817
- }
818
- ]
930
+ text: JSON.stringify(response, null, 2),
931
+ },
932
+ ],
933
+ };
934
+ }
935
+ async getHistory(limit, includeDetails) {
936
+ // 20 is a sweet spot: enough for "what did I just do?" recall without
937
+ // bloating context. Plugin caps at MAX_ACTION_HISTORY (100).
938
+ const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
939
+ ? Math.max(1, Math.min(100, Math.floor(limit)))
940
+ : 20;
941
+ const response = await this.client.request('/api/get-history', {
942
+ limit: safeLimit,
943
+ includeDetails: includeDetails === true,
944
+ });
945
+ return {
946
+ content: [
947
+ {
948
+ type: 'text',
949
+ text: JSON.stringify(response, null, 2),
950
+ },
951
+ ],
819
952
  };
820
953
  }
821
954
  // ============================================
@@ -974,6 +1107,170 @@ export class RobloxStudioTools {
974
1107
  };
975
1108
  }
976
1109
  // ============================================
1110
+ // RUN_LIVE_LUA — execute code inside the running play test
1111
+ //
1112
+ // Flow:
1113
+ // 1. Pre-flight: bridge.getTestSessionStatus() — fail fast with a
1114
+ // structured `error` field (no thrown exception) for the common
1115
+ // cases the AI needs to handle differently (no playtest, ended,
1116
+ // companion not yet connected, no clients on a client-target call,
1117
+ // loadstring disabled).
1118
+ // 2. Generate a replyId, register a watchdog slot.
1119
+ // 3. Enqueue an "eval" command on the appropriate companion's queue.
1120
+ // The companion picks it up on its next poll (~400ms cadence),
1121
+ // runs it under loadstring + xpcall + LogService capture, then
1122
+ // POSTs the result to /test-session/eval-result.
1123
+ // 4. Await the slot — resolves with the companion's reply, or with a
1124
+ // synthetic timeout / companion_error if the watchdog fires.
1125
+ //
1126
+ // This method NEVER throws — all failure paths come back as
1127
+ // `{ success: false, error: <enum>, message: ... }` so the AI can branch
1128
+ // on the error type without try/catch ceremony.
1129
+ // ============================================
1130
+ async runLiveLua(code, target = 'server', playerName, timeoutMs = 5000, captureLogs = true) {
1131
+ // Helper to wrap structured failures consistently.
1132
+ const fail = (error, message, extra = {}) => ({
1133
+ content: [
1134
+ {
1135
+ type: 'text',
1136
+ text: JSON.stringify({ success: false, error, message, target, ...extra }, null, 2),
1137
+ },
1138
+ ],
1139
+ });
1140
+ // ---- Argument validation (still no throws — friendly errors) -------
1141
+ if (typeof code !== 'string' || code.length === 0) {
1142
+ return fail('invalid_args', 'code is required and must be a non-empty string.');
1143
+ }
1144
+ if (target !== 'server' && target !== 'client') {
1145
+ return fail('invalid_args', `target must be "server" or "client", got "${target}".`);
1146
+ }
1147
+ const tMs = Math.max(1000, Math.min(30000, Math.floor(Number(timeoutMs) || 5000)));
1148
+ // ---- Pre-flight on the bridge state --------------------------------
1149
+ const status = this.bridge.getTestSessionStatus();
1150
+ if (!status) {
1151
+ return fail('no_playtest', 'No play test is running. Call play_solo first to start one, then retry run_live_lua.');
1152
+ }
1153
+ if (status.status === 'ended') {
1154
+ return fail('playtest_ended', `The play test has already ended. Start a new one with play_solo before running live code.`);
1155
+ }
1156
+ // ---- Resolve target slot -------------------------------------------
1157
+ // Architecture note: HttpService is server-only in Roblox, so the client
1158
+ // companion CANNOT POST eval-results directly. Instead, target='client'
1159
+ // commands ride the SERVER queue with a `forwardTo` metadata field; the
1160
+ // server companion picks them up, dispatches via clientRelay:InvokeClient,
1161
+ // and POSTs the result on the client's behalf. This means the bridge's
1162
+ // reply slot is always 'server' regardless of logical target.
1163
+ let resolvedClientName = null;
1164
+ let resolvedClientUserId = null;
1165
+ const evalArgs = {
1166
+ // replyId added below
1167
+ code,
1168
+ timeoutMs: tMs,
1169
+ captureLogs: !!captureLogs,
1170
+ };
1171
+ if (target === 'server') {
1172
+ if (!status.serverReady) {
1173
+ return fail('companion_not_ready', 'Server companion has not connected yet. The play test was started but the in-test Script has not made its first poll. Wait ~1s and retry.');
1174
+ }
1175
+ if (!status.serverLoadstringReady) {
1176
+ return fail('loadstring_disabled', 'ServerScriptService.LoadStringEnabled is false in this place, so the server companion cannot loadstring user code. Enable LoadStringEnabled in Studio (Game Settings → Security → Allow HTTP Requests / LoadString) BEFORE starting the test, then re-run play_solo.');
1177
+ }
1178
+ }
1179
+ else {
1180
+ // target === 'client'
1181
+ const clients = status.clients;
1182
+ if (clients.length === 0) {
1183
+ return fail('no_clients_connected', 'No client companion has registered yet. Either no Player has joined the test, the LocalScript hasn\'t fired its hello RemoteEvent yet, or the server companion isn\'t set up. Wait briefly, or fall back to target="server".');
1184
+ }
1185
+ let chosen = clients[0];
1186
+ if (playerName) {
1187
+ const match = clients.find((c) => c.name === playerName || c.name.toLowerCase() === playerName.toLowerCase());
1188
+ if (!match) {
1189
+ return fail('no_such_player', `No connected client named "${playerName}". Connected players: ${clients
1190
+ .map((c) => `${c.name} (userId=${c.userId})`)
1191
+ .join(', ')}.`, { connectedClients: clients });
1192
+ }
1193
+ chosen = match;
1194
+ }
1195
+ else if (clients.length > 1) {
1196
+ // Multi-client play test, ambiguous — bail loudly.
1197
+ return fail('multiple_clients', `Multiple clients are connected; pass playerName to disambiguate. Connected: ${clients
1198
+ .map((c) => `${c.name} (userId=${c.userId})`)
1199
+ .join(', ')}.`, { connectedClients: clients });
1200
+ }
1201
+ if (!chosen.ready) {
1202
+ return fail('companion_not_ready', `Client companion for ${chosen.name} (userId=${chosen.userId}) has not finished its hello yet. Wait briefly and retry.`);
1203
+ }
1204
+ // Loadstring is core (always enabled in client LocalScripts), but we
1205
+ // surface the flag for completeness if the companion ever reports
1206
+ // false.
1207
+ if (!chosen.loadstringReady) {
1208
+ return fail('loadstring_disabled', `Client companion for ${chosen.name} reported loadstring unavailable. (This is unusual on the client; check that the LocalScript was injected correctly.)`);
1209
+ }
1210
+ resolvedClientName = chosen.name;
1211
+ resolvedClientUserId = chosen.userId;
1212
+ // forwardTo tells the server companion to InvokeClient this player
1213
+ // instead of running the code locally on the server.
1214
+ evalArgs.forwardTo = { userId: chosen.userId, playerName: chosen.name };
1215
+ }
1216
+ // ---- Register slot, then enqueue the command ----------------------
1217
+ // Use the bridge's enqueue first because if it returns false we want
1218
+ // to skip allocating a watcher promise. But we need the replyId baked
1219
+ // into the enqueued args, so generate it up front.
1220
+ const replyId = randomUUID();
1221
+ evalArgs.replyId = replyId;
1222
+ // Register the watchdog slot BEFORE enqueueing. We always use 'server'
1223
+ // as the slot target because the server companion is what actually
1224
+ // POSTs /test-session/eval-result back to us (even for client-target
1225
+ // evals — see forwardTo dispatch in the server companion template).
1226
+ const replyPromise = this.bridge.registerEvalReply(replyId, 'server', tMs);
1227
+ const queued = this.bridge.enqueueTestCommand({
1228
+ cmd: 'eval',
1229
+ args: evalArgs,
1230
+ }, 'server');
1231
+ if (!queued) {
1232
+ // Bridge couldn't accept (session ended between status check and
1233
+ // enqueue, or invalid target). The watchdog will eventually trigger
1234
+ // a companion_error reply, but we'd rather not wait a full grace
1235
+ // window — return immediately.
1236
+ return fail('companion_error', 'Failed to enqueue eval command on the bridge (session may have just ended). Re-check play_solo state.');
1237
+ }
1238
+ // Await the reply (will always resolve — watchdog guarantees it).
1239
+ const startedAt = Date.now();
1240
+ const reply = await replyPromise;
1241
+ const totalDurationMs = Date.now() - startedAt;
1242
+ // ---- Map the reply into the canonical Result shape ----------------
1243
+ const baseResult = {
1244
+ success: !!reply.ok,
1245
+ target,
1246
+ timeoutMs: tMs,
1247
+ durationMs: typeof reply.durationMs === 'number' ? reply.durationMs : totalDurationMs,
1248
+ };
1249
+ if (resolvedClientName)
1250
+ baseResult.player = { name: resolvedClientName, userId: resolvedClientUserId };
1251
+ if (reply.ok) {
1252
+ baseResult.values = reply.values ?? [];
1253
+ if (reply.logs && reply.logs.length > 0)
1254
+ baseResult.logs = reply.logs;
1255
+ }
1256
+ else {
1257
+ baseResult.error = reply.errorType ?? 'companion_error';
1258
+ baseResult.message = reply.error ?? 'Unknown eval failure.';
1259
+ if (reply.traceback)
1260
+ baseResult.traceback = reply.traceback;
1261
+ if (reply.logs && reply.logs.length > 0)
1262
+ baseResult.logs = reply.logs;
1263
+ }
1264
+ return {
1265
+ content: [
1266
+ {
1267
+ type: 'text',
1268
+ text: JSON.stringify(baseResult, null, 2),
1269
+ },
1270
+ ],
1271
+ };
1272
+ }
1273
+ // ============================================
977
1274
  // SCREENSHOT TOOL
978
1275
  // ============================================
979
1276
  async captureScreenshot(maxWidth, maxHeight) {
@@ -1122,6 +1419,80 @@ export class RobloxStudioTools {
1122
1419
  ],
1123
1420
  };
1124
1421
  }
1422
+ /**
1423
+ * render_gui - Render a 2D GUI (ScreenGui / GuiObject) to a PNG image.
1424
+ *
1425
+ * ViewportFrame is 3D-only, so GUIs are captured plugin-side by cloning the
1426
+ * target's real ScreenGui into CoreGui (faithful placement), taking a
1427
+ * full-screen CaptureService screenshot, then cropping. region "element"
1428
+ * (default) crops tight to the element's rect; "screen" returns the whole
1429
+ * viewport so on-screen placement can be verified. Here we just convert the
1430
+ * returned RGBA to PNG, mirroring renderObjectView.
1431
+ */
1432
+ async renderGui(instancePath, options) {
1433
+ if (!instancePath) {
1434
+ throw new Error('Instance path is required for render_gui');
1435
+ }
1436
+ const response = await this.client.request('/api/render-gui', {
1437
+ instancePath,
1438
+ region: options?.region || 'element',
1439
+ maxWidth: options?.maxWidth,
1440
+ maxHeight: options?.maxHeight,
1441
+ });
1442
+ const responseData = response;
1443
+ if (responseData.success && responseData.base64) {
1444
+ try {
1445
+ const rgbaBuffer = Buffer.from(responseData.base64, 'base64');
1446
+ const width = responseData.width;
1447
+ const height = responseData.height;
1448
+ const expectedSize = width * height * 4;
1449
+ if (rgbaBuffer.length !== expectedSize) {
1450
+ throw new Error(`Buffer size mismatch: got ${rgbaBuffer.length}, expected ${expectedSize}`);
1451
+ }
1452
+ const pngBuffer = createPNG(rgbaBuffer, width, height);
1453
+ const pngBase64 = pngBuffer.toString('base64');
1454
+ return {
1455
+ content: [
1456
+ {
1457
+ type: 'text',
1458
+ text: JSON.stringify({
1459
+ success: true,
1460
+ message: responseData.message,
1461
+ guiInfo: responseData.guiInfo,
1462
+ format: 'PNG',
1463
+ }, null, 2),
1464
+ },
1465
+ {
1466
+ type: 'image',
1467
+ data: pngBase64,
1468
+ mimeType: 'image/png',
1469
+ },
1470
+ ],
1471
+ };
1472
+ }
1473
+ catch (err) {
1474
+ return {
1475
+ content: [
1476
+ {
1477
+ type: 'text',
1478
+ text: JSON.stringify({
1479
+ success: false,
1480
+ error: `PNG conversion failed: ${err instanceof Error ? err.message : String(err)}`,
1481
+ }, null, 2),
1482
+ },
1483
+ ],
1484
+ };
1485
+ }
1486
+ }
1487
+ return {
1488
+ content: [
1489
+ {
1490
+ type: 'text',
1491
+ text: JSON.stringify(response, null, 2),
1492
+ },
1493
+ ],
1494
+ };
1495
+ }
1125
1496
  // ============================================
1126
1497
  // CAMERA CONTROL SYSTEM
1127
1498
  // ============================================
@@ -1165,5 +1536,151 @@ export class RobloxStudioTools {
1165
1536
  ]
1166
1537
  };
1167
1538
  }
1539
+ // ============================================
1540
+ // ROBLOX CREATOR-DOCS TOOLS
1541
+ //
1542
+ // These tools mirror github.com/Roblox/creator-docs to a local cache
1543
+ // and let the model search/read it like a local repo. They do NOT go
1544
+ // through the Studio HTTP bridge — they're pure server-side.
1545
+ //
1546
+ // The cache is downloaded lazily on first use (~5s, ~30MB) and
1547
+ // refreshed at most once every 24h with a SHA short-circuit. See
1548
+ // src/docs/fetcher.ts for the strategy.
1549
+ // ============================================
1550
+ async searchRobloxDocs(query, options = {}) {
1551
+ if (!query || typeof query !== 'string') {
1552
+ throw new Error('query is required for search_roblox_docs');
1553
+ }
1554
+ const ensured = await ensureDocsCache();
1555
+ // Plumb the docs SHA through so hybrid mode can locate / build the
1556
+ // matching semantic index. Without this, hybrid mode degrades to
1557
+ // pure keyword (semanticUsed=false in the response).
1558
+ const summary = await searchDocs(ensured.cacheDir, query, options, {
1559
+ docsSha: ensured.meta.sha,
1560
+ });
1561
+ return {
1562
+ content: [
1563
+ {
1564
+ type: 'text',
1565
+ text: JSON.stringify({
1566
+ cache: {
1567
+ action: ensured.action,
1568
+ sha: ensured.meta.sha,
1569
+ downloadedAt: ensured.meta.downloadedAt,
1570
+ durationMs: ensured.durationMs,
1571
+ },
1572
+ query,
1573
+ options,
1574
+ ...summary,
1575
+ }, null, 2),
1576
+ },
1577
+ ],
1578
+ };
1579
+ }
1580
+ async getRobloxDoc(relPath) {
1581
+ if (!relPath || typeof relPath !== 'string') {
1582
+ throw new Error('path is required for get_roblox_doc');
1583
+ }
1584
+ const ensured = await ensureDocsCache();
1585
+ const doc = await readDocFile(ensured.cacheDir, relPath);
1586
+ if (!doc) {
1587
+ return {
1588
+ content: [
1589
+ {
1590
+ type: 'text',
1591
+ text: JSON.stringify({
1592
+ error: `Doc not found: "${relPath}". Use list_roblox_docs to discover paths, or search_roblox_docs to find a hit.`,
1593
+ cache: {
1594
+ sha: ensured.meta.sha,
1595
+ fileCount: ensured.meta.fileCount,
1596
+ cacheDir: ensured.cacheDir,
1597
+ },
1598
+ }, null, 2),
1599
+ },
1600
+ ],
1601
+ };
1602
+ }
1603
+ return {
1604
+ content: [
1605
+ {
1606
+ type: 'text',
1607
+ text: JSON.stringify({
1608
+ path: doc.path,
1609
+ bytes: doc.bytes,
1610
+ cache: {
1611
+ action: ensured.action,
1612
+ sha: ensured.meta.sha,
1613
+ },
1614
+ content: doc.content,
1615
+ }, null, 2),
1616
+ },
1617
+ ],
1618
+ };
1619
+ }
1620
+ async listRobloxDocs(relPath = '', options = {}) {
1621
+ const ensured = await ensureDocsCache();
1622
+ const listing = await listDocs(ensured.cacheDir, relPath, options);
1623
+ if (!listing) {
1624
+ return {
1625
+ content: [
1626
+ {
1627
+ type: 'text',
1628
+ text: JSON.stringify({
1629
+ error: `Path not found: "${relPath}".`,
1630
+ cache: { sha: ensured.meta.sha, fileCount: ensured.meta.fileCount },
1631
+ }, null, 2),
1632
+ },
1633
+ ],
1634
+ };
1635
+ }
1636
+ return {
1637
+ content: [
1638
+ {
1639
+ type: 'text',
1640
+ text: JSON.stringify({
1641
+ cache: {
1642
+ action: ensured.action,
1643
+ sha: ensured.meta.sha,
1644
+ },
1645
+ ...listing,
1646
+ }, null, 2),
1647
+ },
1648
+ ],
1649
+ };
1650
+ }
1651
+ async getRobloxApiReference(name, category) {
1652
+ if (!name || typeof name !== 'string') {
1653
+ throw new Error('name is required for get_roblox_api_reference');
1654
+ }
1655
+ const ensured = await ensureDocsCache();
1656
+ const result = await resolveReference(ensured.cacheDir, name, category);
1657
+ if (!result) {
1658
+ return {
1659
+ content: [
1660
+ {
1661
+ type: 'text',
1662
+ text: JSON.stringify({
1663
+ error: `No API reference found for "${name}"${category ? ` in category "${category}"` : ''}. Try search_roblox_docs to find similar names.`,
1664
+ cache: { sha: ensured.meta.sha, fileCount: ensured.meta.fileCount },
1665
+ }, null, 2),
1666
+ },
1667
+ ],
1668
+ };
1669
+ }
1670
+ return {
1671
+ content: [
1672
+ {
1673
+ type: 'text',
1674
+ text: JSON.stringify({
1675
+ cache: {
1676
+ action: ensured.action,
1677
+ sha: ensured.meta.sha,
1678
+ },
1679
+ ...result,
1680
+ }, null, 2),
1681
+ },
1682
+ ],
1683
+ };
1684
+ }
1168
1685
  }
1169
1686
  //# sourceMappingURL=index.js.map