robloxstudio-mcp 2.2.8 → 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 (56) hide show
  1. package/dist/index.js +3507 -1061
  2. package/package.json +6 -31
  3. package/studio-plugin/MCPPlugin.rbxmx +4 -33
  4. package/studio-plugin/{ts-src → src}/modules/Communication.ts +14 -1
  5. package/studio-plugin/src/modules/Recording.ts +28 -0
  6. package/studio-plugin/{ts-src → src}/modules/UI.ts +2 -32
  7. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +230 -0
  8. package/studio-plugin/src/modules/handlers/BuildHandlers.ts +476 -0
  9. package/studio-plugin/{ts-src → src}/modules/handlers/InstanceHandlers.ts +22 -16
  10. package/studio-plugin/{ts-src → src}/modules/handlers/MetadataHandlers.ts +54 -8
  11. package/studio-plugin/{ts-src → src}/modules/handlers/PropertyHandlers.ts +11 -12
  12. package/studio-plugin/{ts-src → src}/modules/handlers/QueryHandlers.ts +144 -1
  13. package/studio-plugin/{ts-src → src}/modules/handlers/ScriptHandlers.ts +38 -13
  14. package/studio-plugin/tsconfig.json +1 -1
  15. package/LICENSE +0 -21
  16. package/README.md +0 -71
  17. package/dist/__tests__/bridge-service.test.d.ts +0 -2
  18. package/dist/__tests__/bridge-service.test.d.ts.map +0 -1
  19. package/dist/__tests__/bridge-service.test.js +0 -95
  20. package/dist/__tests__/bridge-service.test.js.map +0 -1
  21. package/dist/__tests__/http-server.test.d.ts +0 -2
  22. package/dist/__tests__/http-server.test.d.ts.map +0 -1
  23. package/dist/__tests__/http-server.test.js +0 -176
  24. package/dist/__tests__/http-server.test.js.map +0 -1
  25. package/dist/__tests__/integration.test.d.ts +0 -2
  26. package/dist/__tests__/integration.test.d.ts.map +0 -1
  27. package/dist/__tests__/integration.test.js +0 -156
  28. package/dist/__tests__/integration.test.js.map +0 -1
  29. package/dist/__tests__/smoke.test.d.ts +0 -2
  30. package/dist/__tests__/smoke.test.d.ts.map +0 -1
  31. package/dist/__tests__/smoke.test.js +0 -53
  32. package/dist/__tests__/smoke.test.js.map +0 -1
  33. package/dist/bridge-service.d.ts +0 -17
  34. package/dist/bridge-service.d.ts.map +0 -1
  35. package/dist/bridge-service.js +0 -78
  36. package/dist/bridge-service.js.map +0 -1
  37. package/dist/http-server.d.ts +0 -4
  38. package/dist/http-server.d.ts.map +0 -1
  39. package/dist/http-server.js +0 -478
  40. package/dist/http-server.js.map +0 -1
  41. package/dist/index.d.ts +0 -3
  42. package/dist/index.d.ts.map +0 -1
  43. package/dist/index.js.map +0 -1
  44. package/dist/tools/index.d.ts +0 -273
  45. package/dist/tools/index.d.ts.map +0 -1
  46. package/dist/tools/index.js +0 -587
  47. package/dist/tools/index.js.map +0 -1
  48. package/dist/tools/studio-client.d.ts +0 -7
  49. package/dist/tools/studio-client.d.ts.map +0 -1
  50. package/dist/tools/studio-client.js +0 -19
  51. package/dist/tools/studio-client.js.map +0 -1
  52. /package/studio-plugin/{ts-src → src}/modules/State.ts +0 -0
  53. /package/studio-plugin/{ts-src → src}/modules/Utils.ts +0 -0
  54. /package/studio-plugin/{ts-src → src}/modules/handlers/TestHandlers.ts +0 -0
  55. /package/studio-plugin/{ts-src → src}/server/index.server.ts +0 -0
  56. /package/studio-plugin/{ts-src → src}/types/index.d.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robloxstudio-mcp",
3
- "version": "2.2.8",
3
+ "version": "2.4.0",
4
4
  "description": "MCP Server for Roblox Studio Integration - Access Studio data, scripts, and objects through AI tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -9,23 +9,12 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/**/*",
12
- "studio-plugin/**/*",
13
- "README.md"
12
+ "studio-plugin/**/*"
14
13
  ],
15
14
  "scripts": {
16
- "build": "tsc",
17
- "build:plugin": "node scripts/build-plugin.mjs",
18
- "build:all": "npm run build && npm run build:plugin",
19
- "dev": "tsx src/index.ts",
20
- "start": "node dist/index.js",
21
- "lint": "eslint src/**/*.ts",
22
- "typecheck": "tsc --noEmit",
23
- "test": "jest",
24
- "test:watch": "jest --watch",
25
- "test:coverage": "jest --coverage",
26
- "test:e2e": "lune run tests/luau/e2e.luau",
27
- "test:all": "npm test && npm run test:e2e",
28
- "prepublishOnly": "npm run build:all"
15
+ "build": "tsup",
16
+ "prepack": "node ../../scripts/prepack.mjs",
17
+ "postpack": "node ../../scripts/postpack.mjs"
29
18
  },
30
19
  "keywords": [
31
20
  "mcp",
@@ -54,20 +43,6 @@
54
43
  "ws": "^8.14.2"
55
44
  },
56
45
  "devDependencies": {
57
- "@types/cors": "^2.8.17",
58
- "@types/express": "^4.17.21",
59
- "@types/jest": "^29.5.11",
60
- "@types/node": "^20.10.0",
61
- "@types/supertest": "^6.0.2",
62
- "@types/uuid": "^9.0.7",
63
- "@types/ws": "^8.5.10",
64
- "@typescript-eslint/eslint-plugin": "^7.0.0",
65
- "@typescript-eslint/parser": "^7.0.0",
66
- "eslint": "^8.57.0",
67
- "jest": "^29.7.0",
68
- "supertest": "^6.3.3",
69
- "ts-jest": "^29.1.1",
70
- "tsx": "^4.6.0",
71
- "typescript": "^5.3.2"
46
+ "@robloxstudio-mcp/core": "*"
72
47
  }
73
48
  }
@@ -440,7 +440,7 @@ local function checkForUpdates()
440
440
  local latestVersion = data.version
441
441
  if Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0 then
442
442
  local ui = UI.getElements()
443
- ui.updateBannerText.Text = `Update available: v{latestVersion}`
443
+ ui.updateBannerText.Text = `v{latestVersion} available - github.com/boshyxd/robloxstudio-mcp`
444
444
  ui.updateBanner.Visible = true
445
445
  ui.contentFrame.Position = UDim2.new(0, 8, 0, 92)
446
446
  ui.contentFrame.Size = UDim2.new(1, -16, 1, -100)
@@ -3173,7 +3173,7 @@ return {
3173
3173
  <Properties>
3174
3174
  <string name="Name">State</string>
3175
3175
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
3176
- local CURRENT_VERSION = "2.2.8"
3176
+ local CURRENT_VERSION = "2.4.0"
3177
3177
  local MAX_CONNECTIONS = 5
3178
3178
  local BASE_PORT = 58741
3179
3179
  local activeTabIndex = 0
@@ -3606,28 +3606,15 @@ local function init(pluginRef)
3606
3606
  updateBannerCorner.CornerRadius = UDim.new(0, 3)
3607
3607
  updateBannerCorner.Parent = updateBanner
3608
3608
  local updateBannerText = Instance.new("TextLabel")
3609
- updateBannerText.Size = UDim2.new(1, -54, 1, 0)
3609
+ updateBannerText.Size = UDim2.new(1, -16, 1, 0)
3610
3610
  updateBannerText.Position = UDim2.new(0, 8, 0, 0)
3611
3611
  updateBannerText.BackgroundTransparency = 1
3612
- updateBannerText.Text = "Update available: v0.0.0"
3612
+ updateBannerText.Text = ""
3613
3613
  updateBannerText.TextColor3 = C.yellow
3614
3614
  updateBannerText.TextSize = 9
3615
3615
  updateBannerText.Font = Enum.Font.GothamMedium
3616
3616
  updateBannerText.TextXAlignment = Enum.TextXAlignment.Left
3617
3617
  updateBannerText.Parent = updateBanner
3618
- local updateLink = Instance.new("TextButton")
3619
- updateLink.Size = UDim2.new(0, 40, 0, 16)
3620
- updateLink.Position = UDim2.new(1, -46, 0, 4)
3621
- updateLink.BackgroundColor3 = C.yellow
3622
- updateLink.BackgroundTransparency = 0.85
3623
- updateLink.Text = "Get"
3624
- updateLink.TextColor3 = C.yellow
3625
- updateLink.TextSize = 9
3626
- updateLink.Font = Enum.Font.GothamBold
3627
- updateLink.Parent = updateBanner
3628
- local updateLinkCorner = Instance.new("UICorner")
3629
- updateLinkCorner.CornerRadius = UDim.new(0, 3)
3630
- updateLinkCorner.Parent = updateLink
3631
3618
  local contentY = 66
3632
3619
  local contentFrame = Instance.new("ScrollingFrame")
3633
3620
  contentFrame.Size = UDim2.new(1, -16, 1, -(contentY + 8))
@@ -3810,22 +3797,6 @@ local function init(pluginRef)
3810
3797
  setButtonConnect(connectButton, connectStroke)
3811
3798
  end
3812
3799
  end)
3813
- updateLink.Activated:Connect(function()
3814
- local url = "https://github.com/boshyxd/robloxstudio-mcp/releases"
3815
- local env = getfenv(0)
3816
- local _value = env.setclipboard
3817
- if _value ~= 0 and _value == _value and _value ~= "" and _value then
3818
- pcall(function()
3819
- env.setclipboard(url)
3820
- end)
3821
- end
3822
- updateBannerText.Text = "Link copied!"
3823
- task.delay(3, function()
3824
- if updateBanner.Visible then
3825
- updateBannerText.Text = "Update available - check GitHub"
3826
- end
3827
- end)
3828
- end)
3829
3800
  elements = {
3830
3801
  screenGui = screenGui,
3831
3802
  mainFrame = mainFrame,
@@ -8,6 +8,8 @@ import InstanceHandlers from "./handlers/InstanceHandlers";
8
8
  import ScriptHandlers from "./handlers/ScriptHandlers";
9
9
  import MetadataHandlers from "./handlers/MetadataHandlers";
10
10
  import TestHandlers from "./handlers/TestHandlers";
11
+ import BuildHandlers from "./handlers/BuildHandlers";
12
+ import AssetHandlers from "./handlers/AssetHandlers";
11
13
  import { Connection, RequestPayload, PollResponse } from "../types";
12
14
 
13
15
  type Handler = (data: Record<string, unknown>) => unknown;
@@ -24,6 +26,7 @@ const routeMap: Record<string, Handler> = {
24
26
  "/api/search-by-property": QueryHandlers.searchByProperty,
25
27
  "/api/class-info": QueryHandlers.getClassInfo,
26
28
  "/api/project-structure": QueryHandlers.getProjectStructure,
29
+ "/api/grep-scripts": QueryHandlers.grepScripts,
27
30
 
28
31
  "/api/set-property": PropertyHandlers.setProperty,
29
32
  "/api/mass-set-property": PropertyHandlers.massSetProperty,
@@ -54,10 +57,20 @@ const routeMap: Record<string, Handler> = {
54
57
  "/api/get-tagged": MetadataHandlers.getTagged,
55
58
  "/api/get-selection": MetadataHandlers.getSelection,
56
59
  "/api/execute-luau": MetadataHandlers.executeLuau,
60
+ "/api/undo": MetadataHandlers.undo,
61
+ "/api/redo": MetadataHandlers.redo,
57
62
 
58
63
  "/api/start-playtest": TestHandlers.startPlaytest,
59
64
  "/api/stop-playtest": TestHandlers.stopPlaytest,
60
65
  "/api/get-playtest-output": TestHandlers.getPlaytestOutput,
66
+
67
+ "/api/export-build": BuildHandlers.exportBuild,
68
+ "/api/import-build": BuildHandlers.importBuild,
69
+ "/api/import-scene": BuildHandlers.importScene,
70
+ "/api/search-materials": BuildHandlers.searchMaterials,
71
+
72
+ "/api/insert-asset": AssetHandlers.insertAsset,
73
+ "/api/preview-asset": AssetHandlers.previewAsset,
61
74
  };
62
75
 
63
76
  function processRequest(request: RequestPayload): unknown {
@@ -365,7 +378,7 @@ function checkForUpdates() {
365
378
  const latestVersion = data.version;
366
379
  if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
367
380
  const ui = UI.getElements();
368
- ui.updateBannerText.Text = `Update available: v${latestVersion}`;
381
+ ui.updateBannerText.Text = `v${latestVersion} available - github.com/boshyxd/robloxstudio-mcp`;
369
382
  ui.updateBanner.Visible = true;
370
383
  ui.contentFrame.Position = new UDim2(0, 8, 0, 92);
371
384
  ui.contentFrame.Size = new UDim2(1, -16, 1, -100);
@@ -0,0 +1,28 @@
1
+ const ChangeHistoryService = game.GetService("ChangeHistoryService");
2
+
3
+ type RecordingId = string | undefined;
4
+
5
+ function beginRecording(actionName: string): RecordingId {
6
+ const [success, result] = pcall(() => ChangeHistoryService.TryBeginRecording(`MCP: ${actionName}`));
7
+ if (success) {
8
+ return result as RecordingId;
9
+ }
10
+ return undefined;
11
+ }
12
+
13
+ function finishRecording(recordingId: RecordingId, shouldCommit: boolean) {
14
+ if (recordingId === undefined) return;
15
+
16
+ const operation = shouldCommit
17
+ ? Enum.FinishRecordingOperation.Commit
18
+ : Enum.FinishRecordingOperation.Cancel;
19
+
20
+ pcall(() => {
21
+ ChangeHistoryService.FinishRecording(recordingId, operation);
22
+ });
23
+ }
24
+
25
+ export = {
26
+ beginRecording,
27
+ finishRecording,
28
+ };
@@ -362,31 +362,16 @@ function init(pluginRef: Plugin) {
362
362
  updateBannerCorner.Parent = updateBanner;
363
363
 
364
364
  const updateBannerText = new Instance("TextLabel");
365
- updateBannerText.Size = new UDim2(1, -54, 1, 0);
365
+ updateBannerText.Size = new UDim2(1, -16, 1, 0);
366
366
  updateBannerText.Position = new UDim2(0, 8, 0, 0);
367
367
  updateBannerText.BackgroundTransparency = 1;
368
- updateBannerText.Text = "Update available: v0.0.0";
368
+ updateBannerText.Text = "";
369
369
  updateBannerText.TextColor3 = C.yellow;
370
370
  updateBannerText.TextSize = 9;
371
371
  updateBannerText.Font = Enum.Font.GothamMedium;
372
372
  updateBannerText.TextXAlignment = Enum.TextXAlignment.Left;
373
373
  updateBannerText.Parent = updateBanner;
374
374
 
375
- const updateLink = new Instance("TextButton");
376
- updateLink.Size = new UDim2(0, 40, 0, 16);
377
- updateLink.Position = new UDim2(1, -46, 0, 4);
378
- updateLink.BackgroundColor3 = C.yellow;
379
- updateLink.BackgroundTransparency = 0.85;
380
- updateLink.Text = "Get";
381
- updateLink.TextColor3 = C.yellow;
382
- updateLink.TextSize = 9;
383
- updateLink.Font = Enum.Font.GothamBold;
384
- updateLink.Parent = updateBanner;
385
-
386
- const updateLinkCorner = new Instance("UICorner");
387
- updateLinkCorner.CornerRadius = new UDim(0, 3);
388
- updateLinkCorner.Parent = updateLink;
389
-
390
375
  const contentY = 66;
391
376
  const contentFrame = new Instance("ScrollingFrame");
392
377
  contentFrame.Size = new UDim2(1, -16, 1, -(contentY + 8));
@@ -580,21 +565,6 @@ function init(pluginRef: Plugin) {
580
565
  }
581
566
  });
582
567
 
583
- updateLink.Activated.Connect(() => {
584
- const url = "https://github.com/boshyxd/robloxstudio-mcp/releases";
585
- const env = getfenv(0) as unknown as Record<string, unknown>;
586
- if (env["setclipboard"]) {
587
- pcall(() => {
588
- (env["setclipboard"] as (s: string) => void)(url);
589
- });
590
- }
591
- updateBannerText.Text = "Link copied!";
592
- task.delay(3, () => {
593
- if (updateBanner.Visible) {
594
- updateBannerText.Text = "Update available - check GitHub";
595
- }
596
- });
597
- });
598
568
 
599
569
  elements = {
600
570
  screenGui, mainFrame, contentFrame, statusLabel, detailStatusLabel,
@@ -0,0 +1,230 @@
1
+ import Utils from "../Utils";
2
+ import Recording from "../Recording";
3
+
4
+ const AssetService = game.GetService("AssetService");
5
+ const ChangeHistoryService = game.GetService("ChangeHistoryService");
6
+ const Selection = game.GetService("Selection");
7
+
8
+ const { getInstancePath, getInstanceByPath } = Utils;
9
+ const { beginRecording, finishRecording } = Recording;
10
+
11
+ function insertAsset(requestData: Record<string, unknown>) {
12
+ const assetId = requestData.assetId as number;
13
+ const parentPath = (requestData.parentPath as string) ?? "game.Workspace";
14
+ const position = requestData.position as { x: number; y: number; z: number } | undefined;
15
+
16
+ if (!assetId) {
17
+ return { error: "assetId is required" };
18
+ }
19
+
20
+ const parentInstance = getInstanceByPath(parentPath);
21
+ if (!parentInstance) {
22
+ return { error: `Parent instance not found: ${parentPath}` };
23
+ }
24
+
25
+ const recordingId = beginRecording(`Insert asset ${assetId}`);
26
+
27
+ const [loadSuccess, wrapperModel] = pcall(() => {
28
+ return (AssetService as unknown as { LoadAssetAsync(id: number): Instance }).LoadAssetAsync(assetId);
29
+ });
30
+
31
+ if (!loadSuccess || !wrapperModel) {
32
+ finishRecording(recordingId, false);
33
+ return { error: `Failed to load asset ${assetId}: ${tostring(wrapperModel)}` };
34
+ }
35
+
36
+ const insertedInstances: Instance[] = [];
37
+ const children = (wrapperModel as Instance).GetChildren();
38
+
39
+ for (const child of children) {
40
+ child.Parent = parentInstance;
41
+ insertedInstances.push(child);
42
+ }
43
+
44
+ // Apply position if provided
45
+ if (position) {
46
+ const pos = new Vector3(position.x ?? 0, position.y ?? 0, position.z ?? 0);
47
+ for (const inst of insertedInstances) {
48
+ if (inst.IsA("BasePart")) {
49
+ inst.Position = pos;
50
+ } else if (inst.IsA("Model")) {
51
+ if (inst.PrimaryPart) {
52
+ inst.PivotTo(new CFrame(pos));
53
+ } else {
54
+ // Find first BasePart descendant to pivot around
55
+ const firstPart = inst.FindFirstChildWhichIsA("BasePart", true);
56
+ if (firstPart) {
57
+ inst.PivotTo(new CFrame(pos));
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ // Destroy the wrapper model
65
+ (wrapperModel as Instance).Destroy();
66
+
67
+ // Set undo waypoint
68
+ finishRecording(recordingId, true);
69
+
70
+ // Select inserted instances
71
+ pcall(() => {
72
+ Selection.Set(insertedInstances);
73
+ });
74
+
75
+ const resultInstances = insertedInstances.map((inst) => ({
76
+ name: inst.Name,
77
+ className: inst.ClassName,
78
+ path: getInstancePath(inst),
79
+ }));
80
+
81
+ return {
82
+ success: true,
83
+ assetId,
84
+ parentPath,
85
+ insertedCount: insertedInstances.size(),
86
+ instances: resultInstances,
87
+ };
88
+ }
89
+
90
+ function previewAsset(requestData: Record<string, unknown>) {
91
+ const assetId = requestData.assetId as number;
92
+ const includeProperties = (requestData.includeProperties as boolean) ?? true;
93
+ const maxDepth = (requestData.maxDepth as number) ?? 10;
94
+
95
+ if (!assetId) {
96
+ return { error: "assetId is required" };
97
+ }
98
+
99
+ const [loadSuccess, wrapperModel] = pcall(() => {
100
+ return (AssetService as unknown as { LoadAssetAsync(id: number): Instance }).LoadAssetAsync(assetId);
101
+ });
102
+
103
+ if (!loadSuccess || !wrapperModel) {
104
+ return { error: `Failed to load asset ${assetId}: ${tostring(wrapperModel)}` };
105
+ }
106
+
107
+ // Stats tracking
108
+ let totalInstances = 0;
109
+ const classCounts: Record<string, number> = {};
110
+ let hasScripts = false;
111
+ let hasAnimations = false;
112
+ let hasSounds = false;
113
+ let hasParticles = false;
114
+
115
+ function buildHierarchy(instance: Instance, depth: number): Record<string, unknown> {
116
+ totalInstances++;
117
+
118
+ const className = instance.ClassName;
119
+ classCounts[className] = (classCounts[className] ?? 0) + 1;
120
+
121
+ if (instance.IsA("LuaSourceContainer")) hasScripts = true;
122
+ if (className === "Animation" || className === "AnimationController" || className === "Animator") hasAnimations = true;
123
+ if (instance.IsA("Sound")) hasSounds = true;
124
+ if (className === "ParticleEmitter" || className === "Fire" || className === "Smoke" || className === "Sparkles") hasParticles = true;
125
+
126
+ const node: Record<string, unknown> = {
127
+ name: instance.Name,
128
+ className,
129
+ };
130
+
131
+ if (includeProperties) {
132
+ const props: Record<string, unknown> = {};
133
+
134
+ if (instance.IsA("BasePart")) {
135
+ props.size = { x: instance.Size.X, y: instance.Size.Y, z: instance.Size.Z };
136
+ props.position = { x: instance.Position.X, y: instance.Position.Y, z: instance.Position.Z };
137
+ props.material = tostring(instance.Material);
138
+ props.color = `${instance.Color.R}, ${instance.Color.G}, ${instance.Color.B}`;
139
+ props.transparency = instance.Transparency;
140
+ props.anchored = instance.Anchored;
141
+ }
142
+
143
+ if (instance.IsA("MeshPart")) {
144
+ const meshPart = instance as MeshPart;
145
+ props.meshId = meshPart.MeshId;
146
+ props.textureId = meshPart.TextureID;
147
+ }
148
+
149
+ if (instance.IsA("Model")) {
150
+ const model = instance as Model;
151
+ if (model.PrimaryPart) {
152
+ props.primaryPart = model.PrimaryPart.Name;
153
+ }
154
+ }
155
+
156
+ if (instance.IsA("LuaSourceContainer")) {
157
+ const [ok, src] = pcall(() => (instance as unknown as { Source: string }).Source);
158
+ if (ok && src) {
159
+ const preview = string.sub(src, 1, 200);
160
+ props.sourcePreview = preview;
161
+ props.sourceLength = src.size();
162
+ }
163
+ }
164
+
165
+ if (className === "Decal" || className === "Texture") {
166
+ const [ok, texId] = pcall(() => (instance as unknown as { Texture: string }).Texture);
167
+ if (ok) props.texture = texId;
168
+ }
169
+
170
+ if (instance.IsA("Sound")) {
171
+ props.soundId = (instance as Sound).SoundId;
172
+ }
173
+
174
+ // Only include props if there are any
175
+ let hasProps = false;
176
+ for (const _ of pairs(props)) {
177
+ hasProps = true;
178
+ break;
179
+ }
180
+ if (hasProps) {
181
+ node.properties = props;
182
+ }
183
+ }
184
+
185
+ if (depth < maxDepth) {
186
+ const childNodes: Record<string, unknown>[] = [];
187
+ for (const child of instance.GetChildren()) {
188
+ childNodes.push(buildHierarchy(child, depth + 1));
189
+ }
190
+ if (childNodes.size() > 0) {
191
+ node.children = childNodes;
192
+ }
193
+ } else {
194
+ const childCount = instance.GetChildren().size();
195
+ if (childCount > 0) {
196
+ node.childCount = childCount;
197
+ node.truncated = true;
198
+ }
199
+ }
200
+
201
+ return node;
202
+ }
203
+
204
+ const hierarchyRoots: Record<string, unknown>[] = [];
205
+ for (const child of (wrapperModel as Instance).GetChildren()) {
206
+ hierarchyRoots.push(buildHierarchy(child, 0));
207
+ }
208
+
209
+ // CRITICAL: Destroy wrapper to prevent memory leak
210
+ (wrapperModel as Instance).Destroy();
211
+
212
+ return {
213
+ success: true,
214
+ assetId,
215
+ hierarchy: hierarchyRoots,
216
+ summary: {
217
+ totalInstances,
218
+ classCounts,
219
+ hasScripts,
220
+ hasAnimations,
221
+ hasSounds,
222
+ hasParticles,
223
+ },
224
+ };
225
+ }
226
+
227
+ export = {
228
+ insertAsset,
229
+ previewAsset,
230
+ };