@chrrxs/robloxstudio-mcp-inspector 2.14.0 → 2.15.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.
package/dist/index.js CHANGED
@@ -721,6 +721,7 @@ var init_http_server = __esm({
721
721
  simulate_keyboard_input: (tools, body) => tools.simulateKeyboardInput(body.keyCode, body.action, body.duration, body.text, body.target, body.instance_id),
722
722
  character_navigation: (tools, body) => tools.characterNavigation(body.position, body.instancePath, body.waitForCompletion, body.timeout, body.target, body.instance_id),
723
723
  get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
724
+ get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
724
725
  export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
725
726
  import_rbxm: (tools, body) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id),
726
727
  find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
@@ -2884,6 +2885,19 @@ var init_tools = __esm({
2884
2885
  const clientCount = this._clientRolesForInstance(instanceId).length;
2885
2886
  return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
2886
2887
  }
2888
+ async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
2889
+ const deadline = Date.now() + timeoutSec * 1e3;
2890
+ while (Date.now() < deadline) {
2891
+ const instances = this.bridge.getInstances().filter((i) => i.instanceId === instanceId);
2892
+ const roles = instances.map((i) => i.role);
2893
+ const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
2894
+ if (requiredRoles.every((role) => freshRoles.has(role))) {
2895
+ return { ok: true, roles, timedOut: false };
2896
+ }
2897
+ await sleep(250);
2898
+ }
2899
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
2900
+ }
2887
2901
  async getFileTree(path2 = "", instance_id) {
2888
2902
  const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
2889
2903
  return {
@@ -3532,12 +3546,37 @@ ${code}`
3532
3546
  throw new Error("start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.");
3533
3547
  }
3534
3548
  const data = { mode };
3535
- const response = await this._callSingle("/api/start-playtest", data, void 0, instance_id);
3549
+ const startedAt = Date.now();
3550
+ const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
3551
+ if (!resolved.ok)
3552
+ throw new RoutingFailure(resolved.error);
3553
+ if (resolved.mode !== "single") {
3554
+ throw new RoutingFailure({
3555
+ code: "target_role_not_present_on_instance",
3556
+ message: "This tool does not support target=all. Pick a specific role or omit target.",
3557
+ data: {
3558
+ instances: this.bridge.getPublicInstances(),
3559
+ count: this.bridge.getInstances().length
3560
+ }
3561
+ });
3562
+ }
3563
+ const response = await this.client.request("/api/start-playtest", data, resolved.targetInstanceId, resolved.targetRole);
3564
+ let wait;
3565
+ if (response?.success === true) {
3566
+ const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
3567
+ wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles);
3568
+ }
3569
+ const body = wait ? {
3570
+ ...response,
3571
+ runtimeReady: wait.ok,
3572
+ timedOut: wait.timedOut,
3573
+ roles: wait.roles
3574
+ } : response;
3536
3575
  return {
3537
3576
  content: [
3538
3577
  {
3539
3578
  type: "text",
3540
- text: JSON.stringify(response)
3579
+ text: JSON.stringify(body)
3541
3580
  }
3542
3581
  ]
3543
3582
  };
@@ -4602,6 +4641,39 @@ ${code}`
4602
4641
  }
4603
4642
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
4604
4643
  }
4644
+ async getSceneAnalysis(mode, target, topN, raw, instance_id) {
4645
+ const tgt = target ?? "all";
4646
+ const data = {};
4647
+ if (mode !== void 0)
4648
+ data.mode = mode;
4649
+ if (topN !== void 0)
4650
+ data.topN = topN;
4651
+ if (raw !== void 0)
4652
+ data.raw = raw;
4653
+ const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
4654
+ if (!resolved.ok)
4655
+ throw new RoutingFailure(resolved.error);
4656
+ if (resolved.mode === "single") {
4657
+ const response = await this.client.request("/api/get-scene-analysis", data, resolved.targetInstanceId, resolved.targetRole);
4658
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
4659
+ }
4660
+ const targets = resolved.targets.filter((t) => t.targetRole !== "edit-proxy");
4661
+ const responses = await Promise.allSettled(targets.map(async (t) => ({
4662
+ peer: t.targetRole,
4663
+ result: await this.client.request("/api/get-scene-analysis", data, t.targetInstanceId, t.targetRole)
4664
+ })));
4665
+ const body = {};
4666
+ for (let i = 0; i < responses.length; i++) {
4667
+ const r = responses[i];
4668
+ const peer = targets[i].targetRole;
4669
+ if (r.status === "fulfilled") {
4670
+ body[peer] = r.value.result;
4671
+ } else {
4672
+ body[peer] = { error: "disconnected" };
4673
+ }
4674
+ }
4675
+ return { content: [{ type: "text", text: JSON.stringify(body) }] };
4676
+ }
4605
4677
  async exportRbxm(instancePaths, outputPath, target, instance_id) {
4606
4678
  if (!Array.isArray(instancePaths) || instancePaths.length === 0) {
4607
4679
  throw new Error("instance_paths must be a non-empty array for export_rbxm");
@@ -6032,7 +6104,7 @@ var init_definitions = __esm({
6032
6104
  {
6033
6105
  name: "start_playtest",
6034
6106
  category: "write",
6035
- description: "Start a simple single-player Studio playtest in play or run mode. Captures print/warn/error via LogService. Poll with get_playtest_output, end with stop_playtest. For multi-client testing use multiplayer_test_start instead.",
6107
+ description: "Start a simple single-player Studio playtest in play or run mode, waiting until a runtime peer registers with MCP. Captures print/warn/error via LogService. Poll with get_playtest_output, end with stop_playtest. For multi-client testing use multiplayer_test_start instead.",
6036
6108
  inputSchema: {
6037
6109
  type: "object",
6038
6110
  properties: {
@@ -7010,6 +7082,39 @@ part(0,2,0,2,1,1,"b")`,
7010
7082
  }
7011
7083
  }
7012
7084
  },
7085
+ {
7086
+ name: "get_scene_analysis",
7087
+ category: "read",
7088
+ description: 'Read Roblox SceneAnalysisService data for attribution-focused performance analysis. Complements get_memory_breakdown: returns compact top-N entries for instance composition, script memory, unparented instances, triangle composition, animation memory, and audio memory. Requires the Studio Scene Analysis beta feature; if disabled, returns scene_analysis_not_enabled with betaFeatureRequired=true. target="all" (default) returns per-peer data; single-peer targets return that peer directly. raw=true includes the full nested Scene Analysis tree.',
7089
+ inputSchema: {
7090
+ type: "object",
7091
+ properties: {
7092
+ mode: {
7093
+ type: "string",
7094
+ enum: ["all", "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory"],
7095
+ description: 'Scene analysis mode to read. Defaults to "all".'
7096
+ },
7097
+ target: {
7098
+ type: "string",
7099
+ description: 'Peer to read from: "edit", "server", "client-N", or "all" (default).'
7100
+ },
7101
+ topN: {
7102
+ type: "number",
7103
+ minimum: 1,
7104
+ maximum: 100,
7105
+ description: "Number of flattened top entries to include per mode. Defaults to 10; plugin clamps to 1-100."
7106
+ },
7107
+ raw: {
7108
+ type: "boolean",
7109
+ description: "Include the full nested SceneAnalysisService tree in each mode result. Defaults to false."
7110
+ },
7111
+ instance_id: {
7112
+ type: "string",
7113
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7114
+ }
7115
+ }
7116
+ }
7117
+ },
7013
7118
  // === SerializationService round-trip ===
7014
7119
  {
7015
7120
  name: "export_rbxm",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "Read-only MCP server for inspecting and debugging Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -102,6 +102,7 @@ local RunService = _services.RunService
102
102
  local ServerStorage = _services.ServerStorage
103
103
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
104
104
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
105
+ local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
105
106
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
106
107
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
107
108
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
@@ -167,6 +168,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
167
168
  ["/api/execute-luau"] = true,
168
169
  ["/api/get-runtime-logs"] = true,
169
170
  ["/api/get-memory-breakdown"] = true,
171
+ ["/api/get-scene-analysis"] = true,
170
172
  ["/api/multiplayer-test-state"] = true,
171
173
  ["/api/multiplayer-test-leave-client"] = true,
172
174
  ["/api/capture-begin"] = true,
@@ -324,7 +326,7 @@ end
324
326
  local function setupClientBroker()
325
327
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
326
328
  if not rf or not rf:IsA("RemoteFunction") then
327
- warn(`[MCPFork] client: {BROKER_NAME} not found`)
329
+ warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
328
330
  return nil
329
331
  end
330
332
  rf.OnClientInvoke = function(payload)
@@ -340,6 +342,9 @@ local function setupClientBroker()
340
342
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
341
343
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
342
344
  end
345
+ if payload and payload.endpoint == "/api/get-scene-analysis" then
346
+ return SceneAnalysisHandlers.getSceneAnalysis(payload.data or {})
347
+ end
343
348
  if payload and payload.endpoint == "/api/multiplayer-test-state" then
344
349
  return handleMultiplayerTestState()
345
350
  end
@@ -453,7 +458,7 @@ local function registerProxy(player, rf)
453
458
  isRunning = RunService:IsRunning(),
454
459
  })
455
460
  if not ok or not res or not res.Success then
456
- warn(`[MCPFork] proxy register failed for {player.Name}`)
461
+ warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
457
462
  return nil
458
463
  end
459
464
  local body = HttpService:JSONDecode(res.Body)
@@ -551,6 +556,7 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
551
556
  local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
552
557
  local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
553
558
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
559
+ local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
554
560
  -- Per-plugin-load random GUID. Used as the /poll URL param so the server
555
561
  -- can tell our polls apart from any other plugin's polls. Not user-facing —
556
562
  -- MCP tools and the LLM operate on instanceId (the place identifier).
@@ -683,6 +689,7 @@ local routeMap = {
683
689
  ["/api/export-rbxm"] = SerializationHandlers.exportRbxm,
684
690
  ["/api/import-rbxm"] = SerializationHandlers.importRbxm,
685
691
  ["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
692
+ ["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
686
693
  }
687
694
  local function processRequest(request)
688
695
  local endpoint = request.endpoint
@@ -1256,9 +1263,9 @@ local function computeBridgeStamp()
1256
1263
  for i = 1, #combined do
1257
1264
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1258
1265
  end
1259
- -- "2.14.0" is replaced with the package version at package time
1266
+ -- "2.15.0" is replaced with the package version at package time
1260
1267
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1261
- return `{tostring(h)}-2.14.0`
1268
+ return `{tostring(h)}-2.15.0`
1262
1269
  end
1263
1270
  local BRIDGE_STAMP = computeBridgeStamp()
1264
1271
  local function setSource(scriptInst, source)
@@ -5070,6 +5077,255 @@ return {
5070
5077
  </Properties>
5071
5078
  </Item>
5072
5079
  <Item class="ModuleScript" referent="16">
5080
+ <Properties>
5081
+ <string name="Name">SceneAnalysisHandlers</string>
5082
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5083
+ local MODE_CONFIGS = {
5084
+ instance_composition = {
5085
+ method = "GetInstanceCompositionAsync",
5086
+ query = function(service)
5087
+ return service:GetInstanceCompositionAsync()
5088
+ end,
5089
+ },
5090
+ script_memory = {
5091
+ method = "GetScriptMemoryAsync",
5092
+ query = function(service)
5093
+ return service:GetScriptMemoryAsync()
5094
+ end,
5095
+ },
5096
+ unparented_instances = {
5097
+ method = "GetUnparentedInstancesAsync",
5098
+ query = function(service)
5099
+ return service:GetUnparentedInstancesAsync()
5100
+ end,
5101
+ },
5102
+ triangle_composition = {
5103
+ method = "GetTriangleCompositionAsync",
5104
+ query = function(service)
5105
+ return service:GetTriangleCompositionAsync()
5106
+ end,
5107
+ sortByTriangles = true,
5108
+ },
5109
+ animation_memory = {
5110
+ method = "GetAnimationMemoryAsync",
5111
+ query = function(service)
5112
+ return service:GetAnimationMemoryAsync()
5113
+ end,
5114
+ },
5115
+ audio_memory = {
5116
+ method = "GetAudioMemoryAsync",
5117
+ query = function(service)
5118
+ return service:GetAudioMemoryAsync()
5119
+ end,
5120
+ },
5121
+ }
5122
+ local ALL_MODES = { "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory" }
5123
+ local function betaDisabledError()
5124
+ return {
5125
+ error = "scene_analysis_not_enabled",
5126
+ message = "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
5127
+ betaFeatureRequired = true,
5128
+ }
5129
+ end
5130
+ local function isBetaDisabledError(value)
5131
+ local _value = value
5132
+ local _condition = type(_value) == "string"
5133
+ if _condition then
5134
+ _condition = (string.find(value, "SceneAnalysisService is not enabled", 1, true)) ~= nil
5135
+ end
5136
+ return _condition
5137
+ end
5138
+ local function getSceneAnalysisService()
5139
+ local provider = game
5140
+ local ok, service = pcall(function()
5141
+ return provider:GetService("SceneAnalysisService")
5142
+ end)
5143
+ if not ok or not service then
5144
+ return {
5145
+ error = "scene_analysis_unavailable",
5146
+ message = `SceneAnalysisService is unavailable: {tostring(service)}`,
5147
+ }
5148
+ end
5149
+ return service
5150
+ end
5151
+ local function normalizeMode(mode)
5152
+ if mode == nil or mode == "all" then
5153
+ return "all"
5154
+ end
5155
+ local _mode = mode
5156
+ local _condition = not (type(_mode) == "string")
5157
+ if not _condition then
5158
+ _condition = MODE_CONFIGS[mode] == nil
5159
+ end
5160
+ if _condition then
5161
+ return {
5162
+ error = "invalid_mode",
5163
+ message = `mode must be one of: all, {table.concat(ALL_MODES, ", ")}`,
5164
+ }
5165
+ end
5166
+ return mode
5167
+ end
5168
+ local function normalizeTopN(topN)
5169
+ local _topN = topN
5170
+ if not (type(_topN) == "number") then
5171
+ return 10
5172
+ end
5173
+ return math.clamp(math.floor(topN), 1, 100)
5174
+ end
5175
+ local function countLeaves(node)
5176
+ local children = node.Children
5177
+ if children and #children > 0 then
5178
+ local total = 0
5179
+ for _, child in children do
5180
+ total += countLeaves(child)
5181
+ end
5182
+ return total
5183
+ end
5184
+ return 1
5185
+ end
5186
+ local function flattenLeaves(node, out)
5187
+ local children = node.Children
5188
+ if children and #children > 0 then
5189
+ for _, child in children do
5190
+ flattenLeaves(child, out)
5191
+ end
5192
+ return nil
5193
+ end
5194
+ local _out = out
5195
+ local _node = node
5196
+ table.insert(_out, _node)
5197
+ end
5198
+ local function compactEntry(node)
5199
+ local entry = {
5200
+ name = node.Name,
5201
+ }
5202
+ if node.Size ~= nil then
5203
+ entry.size = node.Size
5204
+ end
5205
+ if node.Sizes ~= nil then
5206
+ entry.sizes = node.Sizes
5207
+ end
5208
+ if node.AssetId ~= nil then
5209
+ entry.asset_id = node.AssetId
5210
+ end
5211
+ return entry
5212
+ end
5213
+ local function compactRoot(node, leafCount)
5214
+ local children = node.Children
5215
+ local root = {
5216
+ name = node.Name,
5217
+ child_count = if children then #children else 0,
5218
+ leaf_count = leafCount,
5219
+ }
5220
+ if node.Size ~= nil then
5221
+ root.size = node.Size
5222
+ end
5223
+ if node.Sizes ~= nil then
5224
+ root.sizes = node.Sizes
5225
+ end
5226
+ return root
5227
+ end
5228
+ local function metric(node, sortByTriangles)
5229
+ if sortByTriangles then
5230
+ local sizes = node.Sizes
5231
+ local triangles = if sizes then sizes.Triangles else nil
5232
+ local _condition = triangles
5233
+ if _condition == nil then
5234
+ _condition = 0
5235
+ end
5236
+ return _condition
5237
+ end
5238
+ local _condition = node.Size
5239
+ if _condition == nil then
5240
+ _condition = 0
5241
+ end
5242
+ return _condition
5243
+ end
5244
+ local function summarizeMode(mode, config, service, topN, raw)
5245
+ local started = os.clock()
5246
+ local ok, result = pcall(function()
5247
+ return config.query(service)
5248
+ end)
5249
+ local elapsedMs = math.floor((os.clock() - started) * 1000)
5250
+ if not ok then
5251
+ if isBetaDisabledError(result) then
5252
+ return betaDisabledError()
5253
+ end
5254
+ return {
5255
+ error = "scene_analysis_query_failed",
5256
+ mode = mode,
5257
+ method = config.method,
5258
+ message = tostring(result),
5259
+ }
5260
+ end
5261
+ local tree = result
5262
+ local leaves = {}
5263
+ flattenLeaves(tree, leaves)
5264
+ table.sort(leaves, function(a, b)
5265
+ return metric(a, config.sortByTriangles == true) > metric(b, config.sortByTriangles == true)
5266
+ end)
5267
+ local top = {}
5268
+ do
5269
+ local i = 0
5270
+ local _shouldIncrement = false
5271
+ while true do
5272
+ if _shouldIncrement then
5273
+ i += 1
5274
+ else
5275
+ _shouldIncrement = true
5276
+ end
5277
+ if not (i < math.min(topN, #leaves)) then
5278
+ break
5279
+ end
5280
+ local _arg0 = compactEntry(leaves[i + 1])
5281
+ table.insert(top, _arg0)
5282
+ end
5283
+ end
5284
+ local body = {
5285
+ mode = mode,
5286
+ method = config.method,
5287
+ elapsed_ms = elapsedMs,
5288
+ root = compactRoot(tree, #leaves),
5289
+ top = top,
5290
+ }
5291
+ if raw then
5292
+ body.tree = tree
5293
+ end
5294
+ return body
5295
+ end
5296
+ local function getSceneAnalysis(requestData)
5297
+ local mode = normalizeMode(requestData.mode)
5298
+ if not (type(mode) == "string") then
5299
+ return mode
5300
+ end
5301
+ local serviceOrError = getSceneAnalysisService()
5302
+ local _value = serviceOrError.IsA
5303
+ if not (_value ~= 0 and _value == _value and _value ~= "" and _value) then
5304
+ return serviceOrError
5305
+ end
5306
+ local service = serviceOrError
5307
+ local topN = normalizeTopN(requestData.topN)
5308
+ local raw = requestData.raw == true
5309
+ if mode ~= "all" then
5310
+ return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw)
5311
+ end
5312
+ local body = {}
5313
+ for _, m in ALL_MODES do
5314
+ local result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw)
5315
+ if result.error == "scene_analysis_not_enabled" then
5316
+ return result
5317
+ end
5318
+ body[m] = result
5319
+ end
5320
+ return body
5321
+ end
5322
+ return {
5323
+ getSceneAnalysis = getSceneAnalysis,
5324
+ }
5325
+ ]]></string>
5326
+ </Properties>
5327
+ </Item>
5328
+ <Item class="ModuleScript" referent="17">
5073
5329
  <Properties>
5074
5330
  <string name="Name">ScriptHandlers</string>
5075
5331
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5765,7 +6021,7 @@ return {
5765
6021
  ]]></string>
5766
6022
  </Properties>
5767
6023
  </Item>
5768
- <Item class="ModuleScript" referent="17">
6024
+ <Item class="ModuleScript" referent="18">
5769
6025
  <Properties>
5770
6026
  <string name="Name">SerializationHandlers</string>
5771
6027
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5951,7 +6207,7 @@ return {
5951
6207
  ]]></string>
5952
6208
  </Properties>
5953
6209
  </Item>
5954
- <Item class="ModuleScript" referent="18">
6210
+ <Item class="ModuleScript" referent="19">
5955
6211
  <Properties>
5956
6212
  <string name="Name">TestHandlers</string>
5957
6213
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6584,7 +6840,7 @@ return {
6584
6840
  </Properties>
6585
6841
  </Item>
6586
6842
  </Item>
6587
- <Item class="ModuleScript" referent="19">
6843
+ <Item class="ModuleScript" referent="20">
6588
6844
  <Properties>
6589
6845
  <string name="Name">LuauExec</string>
6590
6846
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6902,7 +7158,7 @@ return {
6902
7158
  ]]></string>
6903
7159
  </Properties>
6904
7160
  </Item>
6905
- <Item class="ModuleScript" referent="20">
7161
+ <Item class="ModuleScript" referent="21">
6906
7162
  <Properties>
6907
7163
  <string name="Name">Recording</string>
6908
7164
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6932,7 +7188,7 @@ return {
6932
7188
  ]]></string>
6933
7189
  </Properties>
6934
7190
  </Item>
6935
- <Item class="ModuleScript" referent="21">
7191
+ <Item class="ModuleScript" referent="22">
6936
7192
  <Properties>
6937
7193
  <string name="Name">RenderMonitor</string>
6938
7194
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7000,7 +7256,7 @@ return {
7000
7256
  ]]></string>
7001
7257
  </Properties>
7002
7258
  </Item>
7003
- <Item class="ModuleScript" referent="22">
7259
+ <Item class="ModuleScript" referent="23">
7004
7260
  <Properties>
7005
7261
  <string name="Name">RuntimeLogBuffer</string>
7006
7262
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7181,11 +7437,11 @@ return {
7181
7437
  ]]></string>
7182
7438
  </Properties>
7183
7439
  </Item>
7184
- <Item class="ModuleScript" referent="23">
7440
+ <Item class="ModuleScript" referent="24">
7185
7441
  <Properties>
7186
7442
  <string name="Name">State</string>
7187
7443
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7188
- local CURRENT_VERSION = "2.14.0"
7444
+ local CURRENT_VERSION = "2.15.0"
7189
7445
  local MAX_CONNECTIONS = 5
7190
7446
  local BASE_PORT = 58741
7191
7447
  local activeTabIndex = 0
@@ -7277,7 +7533,7 @@ return {
7277
7533
  ]]></string>
7278
7534
  </Properties>
7279
7535
  </Item>
7280
- <Item class="ModuleScript" referent="24">
7536
+ <Item class="ModuleScript" referent="25">
7281
7537
  <Properties>
7282
7538
  <string name="Name">StopPlayMonitor</string>
7283
7539
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7422,7 +7678,7 @@ return {
7422
7678
  ]]></string>
7423
7679
  </Properties>
7424
7680
  </Item>
7425
- <Item class="ModuleScript" referent="25">
7681
+ <Item class="ModuleScript" referent="26">
7426
7682
  <Properties>
7427
7683
  <string name="Name">UI</string>
7428
7684
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8173,7 +8429,7 @@ return {
8173
8429
  ]]></string>
8174
8430
  </Properties>
8175
8431
  </Item>
8176
- <Item class="ModuleScript" referent="26">
8432
+ <Item class="ModuleScript" referent="27">
8177
8433
  <Properties>
8178
8434
  <string name="Name">Utils</string>
8179
8435
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8703,11 +8959,11 @@ return {
8703
8959
  </Properties>
8704
8960
  </Item>
8705
8961
  </Item>
8706
- <Item class="Folder" referent="30">
8962
+ <Item class="Folder" referent="31">
8707
8963
  <Properties>
8708
8964
  <string name="Name">include</string>
8709
8965
  </Properties>
8710
- <Item class="ModuleScript" referent="27">
8966
+ <Item class="ModuleScript" referent="28">
8711
8967
  <Properties>
8712
8968
  <string name="Name">Promise</string>
8713
8969
  <string name="Source"><![CDATA[--[[
@@ -10781,7 +11037,7 @@ return Promise
10781
11037
  ]]></string>
10782
11038
  </Properties>
10783
11039
  </Item>
10784
- <Item class="ModuleScript" referent="28">
11040
+ <Item class="ModuleScript" referent="29">
10785
11041
  <Properties>
10786
11042
  <string name="Name">RuntimeLib</string>
10787
11043
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -11048,15 +11304,15 @@ return TS
11048
11304
  </Properties>
11049
11305
  </Item>
11050
11306
  </Item>
11051
- <Item class="Folder" referent="31">
11307
+ <Item class="Folder" referent="32">
11052
11308
  <Properties>
11053
11309
  <string name="Name">node_modules</string>
11054
11310
  </Properties>
11055
- <Item class="Folder" referent="32">
11311
+ <Item class="Folder" referent="33">
11056
11312
  <Properties>
11057
11313
  <string name="Name">@rbxts</string>
11058
11314
  </Properties>
11059
- <Item class="ModuleScript" referent="29">
11315
+ <Item class="ModuleScript" referent="30">
11060
11316
  <Properties>
11061
11317
  <string name="Name">services</string>
11062
11318
  <string name="Source"><![CDATA[return setmetatable({}, {
@@ -102,6 +102,7 @@ local RunService = _services.RunService
102
102
  local ServerStorage = _services.ServerStorage
103
103
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
104
104
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
105
+ local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
105
106
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
106
107
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
107
108
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
@@ -167,6 +168,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
167
168
  ["/api/execute-luau"] = true,
168
169
  ["/api/get-runtime-logs"] = true,
169
170
  ["/api/get-memory-breakdown"] = true,
171
+ ["/api/get-scene-analysis"] = true,
170
172
  ["/api/multiplayer-test-state"] = true,
171
173
  ["/api/multiplayer-test-leave-client"] = true,
172
174
  ["/api/capture-begin"] = true,
@@ -324,7 +326,7 @@ end
324
326
  local function setupClientBroker()
325
327
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
326
328
  if not rf or not rf:IsA("RemoteFunction") then
327
- warn(`[MCPFork] client: {BROKER_NAME} not found`)
329
+ warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
328
330
  return nil
329
331
  end
330
332
  rf.OnClientInvoke = function(payload)
@@ -340,6 +342,9 @@ local function setupClientBroker()
340
342
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
341
343
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
342
344
  end
345
+ if payload and payload.endpoint == "/api/get-scene-analysis" then
346
+ return SceneAnalysisHandlers.getSceneAnalysis(payload.data or {})
347
+ end
343
348
  if payload and payload.endpoint == "/api/multiplayer-test-state" then
344
349
  return handleMultiplayerTestState()
345
350
  end
@@ -453,7 +458,7 @@ local function registerProxy(player, rf)
453
458
  isRunning = RunService:IsRunning(),
454
459
  })
455
460
  if not ok or not res or not res.Success then
456
- warn(`[MCPFork] proxy register failed for {player.Name}`)
461
+ warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
457
462
  return nil
458
463
  end
459
464
  local body = HttpService:JSONDecode(res.Body)
@@ -551,6 +556,7 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
551
556
  local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
552
557
  local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
553
558
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
559
+ local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
554
560
  -- Per-plugin-load random GUID. Used as the /poll URL param so the server
555
561
  -- can tell our polls apart from any other plugin's polls. Not user-facing —
556
562
  -- MCP tools and the LLM operate on instanceId (the place identifier).
@@ -683,6 +689,7 @@ local routeMap = {
683
689
  ["/api/export-rbxm"] = SerializationHandlers.exportRbxm,
684
690
  ["/api/import-rbxm"] = SerializationHandlers.importRbxm,
685
691
  ["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
692
+ ["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
686
693
  }
687
694
  local function processRequest(request)
688
695
  local endpoint = request.endpoint
@@ -1256,9 +1263,9 @@ local function computeBridgeStamp()
1256
1263
  for i = 1, #combined do
1257
1264
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1258
1265
  end
1259
- -- "2.14.0" is replaced with the package version at package time
1266
+ -- "2.15.0" is replaced with the package version at package time
1260
1267
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1261
- return `{tostring(h)}-2.14.0`
1268
+ return `{tostring(h)}-2.15.0`
1262
1269
  end
1263
1270
  local BRIDGE_STAMP = computeBridgeStamp()
1264
1271
  local function setSource(scriptInst, source)
@@ -5070,6 +5077,255 @@ return {
5070
5077
  </Properties>
5071
5078
  </Item>
5072
5079
  <Item class="ModuleScript" referent="16">
5080
+ <Properties>
5081
+ <string name="Name">SceneAnalysisHandlers</string>
5082
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5083
+ local MODE_CONFIGS = {
5084
+ instance_composition = {
5085
+ method = "GetInstanceCompositionAsync",
5086
+ query = function(service)
5087
+ return service:GetInstanceCompositionAsync()
5088
+ end,
5089
+ },
5090
+ script_memory = {
5091
+ method = "GetScriptMemoryAsync",
5092
+ query = function(service)
5093
+ return service:GetScriptMemoryAsync()
5094
+ end,
5095
+ },
5096
+ unparented_instances = {
5097
+ method = "GetUnparentedInstancesAsync",
5098
+ query = function(service)
5099
+ return service:GetUnparentedInstancesAsync()
5100
+ end,
5101
+ },
5102
+ triangle_composition = {
5103
+ method = "GetTriangleCompositionAsync",
5104
+ query = function(service)
5105
+ return service:GetTriangleCompositionAsync()
5106
+ end,
5107
+ sortByTriangles = true,
5108
+ },
5109
+ animation_memory = {
5110
+ method = "GetAnimationMemoryAsync",
5111
+ query = function(service)
5112
+ return service:GetAnimationMemoryAsync()
5113
+ end,
5114
+ },
5115
+ audio_memory = {
5116
+ method = "GetAudioMemoryAsync",
5117
+ query = function(service)
5118
+ return service:GetAudioMemoryAsync()
5119
+ end,
5120
+ },
5121
+ }
5122
+ local ALL_MODES = { "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory" }
5123
+ local function betaDisabledError()
5124
+ return {
5125
+ error = "scene_analysis_not_enabled",
5126
+ message = "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
5127
+ betaFeatureRequired = true,
5128
+ }
5129
+ end
5130
+ local function isBetaDisabledError(value)
5131
+ local _value = value
5132
+ local _condition = type(_value) == "string"
5133
+ if _condition then
5134
+ _condition = (string.find(value, "SceneAnalysisService is not enabled", 1, true)) ~= nil
5135
+ end
5136
+ return _condition
5137
+ end
5138
+ local function getSceneAnalysisService()
5139
+ local provider = game
5140
+ local ok, service = pcall(function()
5141
+ return provider:GetService("SceneAnalysisService")
5142
+ end)
5143
+ if not ok or not service then
5144
+ return {
5145
+ error = "scene_analysis_unavailable",
5146
+ message = `SceneAnalysisService is unavailable: {tostring(service)}`,
5147
+ }
5148
+ end
5149
+ return service
5150
+ end
5151
+ local function normalizeMode(mode)
5152
+ if mode == nil or mode == "all" then
5153
+ return "all"
5154
+ end
5155
+ local _mode = mode
5156
+ local _condition = not (type(_mode) == "string")
5157
+ if not _condition then
5158
+ _condition = MODE_CONFIGS[mode] == nil
5159
+ end
5160
+ if _condition then
5161
+ return {
5162
+ error = "invalid_mode",
5163
+ message = `mode must be one of: all, {table.concat(ALL_MODES, ", ")}`,
5164
+ }
5165
+ end
5166
+ return mode
5167
+ end
5168
+ local function normalizeTopN(topN)
5169
+ local _topN = topN
5170
+ if not (type(_topN) == "number") then
5171
+ return 10
5172
+ end
5173
+ return math.clamp(math.floor(topN), 1, 100)
5174
+ end
5175
+ local function countLeaves(node)
5176
+ local children = node.Children
5177
+ if children and #children > 0 then
5178
+ local total = 0
5179
+ for _, child in children do
5180
+ total += countLeaves(child)
5181
+ end
5182
+ return total
5183
+ end
5184
+ return 1
5185
+ end
5186
+ local function flattenLeaves(node, out)
5187
+ local children = node.Children
5188
+ if children and #children > 0 then
5189
+ for _, child in children do
5190
+ flattenLeaves(child, out)
5191
+ end
5192
+ return nil
5193
+ end
5194
+ local _out = out
5195
+ local _node = node
5196
+ table.insert(_out, _node)
5197
+ end
5198
+ local function compactEntry(node)
5199
+ local entry = {
5200
+ name = node.Name,
5201
+ }
5202
+ if node.Size ~= nil then
5203
+ entry.size = node.Size
5204
+ end
5205
+ if node.Sizes ~= nil then
5206
+ entry.sizes = node.Sizes
5207
+ end
5208
+ if node.AssetId ~= nil then
5209
+ entry.asset_id = node.AssetId
5210
+ end
5211
+ return entry
5212
+ end
5213
+ local function compactRoot(node, leafCount)
5214
+ local children = node.Children
5215
+ local root = {
5216
+ name = node.Name,
5217
+ child_count = if children then #children else 0,
5218
+ leaf_count = leafCount,
5219
+ }
5220
+ if node.Size ~= nil then
5221
+ root.size = node.Size
5222
+ end
5223
+ if node.Sizes ~= nil then
5224
+ root.sizes = node.Sizes
5225
+ end
5226
+ return root
5227
+ end
5228
+ local function metric(node, sortByTriangles)
5229
+ if sortByTriangles then
5230
+ local sizes = node.Sizes
5231
+ local triangles = if sizes then sizes.Triangles else nil
5232
+ local _condition = triangles
5233
+ if _condition == nil then
5234
+ _condition = 0
5235
+ end
5236
+ return _condition
5237
+ end
5238
+ local _condition = node.Size
5239
+ if _condition == nil then
5240
+ _condition = 0
5241
+ end
5242
+ return _condition
5243
+ end
5244
+ local function summarizeMode(mode, config, service, topN, raw)
5245
+ local started = os.clock()
5246
+ local ok, result = pcall(function()
5247
+ return config.query(service)
5248
+ end)
5249
+ local elapsedMs = math.floor((os.clock() - started) * 1000)
5250
+ if not ok then
5251
+ if isBetaDisabledError(result) then
5252
+ return betaDisabledError()
5253
+ end
5254
+ return {
5255
+ error = "scene_analysis_query_failed",
5256
+ mode = mode,
5257
+ method = config.method,
5258
+ message = tostring(result),
5259
+ }
5260
+ end
5261
+ local tree = result
5262
+ local leaves = {}
5263
+ flattenLeaves(tree, leaves)
5264
+ table.sort(leaves, function(a, b)
5265
+ return metric(a, config.sortByTriangles == true) > metric(b, config.sortByTriangles == true)
5266
+ end)
5267
+ local top = {}
5268
+ do
5269
+ local i = 0
5270
+ local _shouldIncrement = false
5271
+ while true do
5272
+ if _shouldIncrement then
5273
+ i += 1
5274
+ else
5275
+ _shouldIncrement = true
5276
+ end
5277
+ if not (i < math.min(topN, #leaves)) then
5278
+ break
5279
+ end
5280
+ local _arg0 = compactEntry(leaves[i + 1])
5281
+ table.insert(top, _arg0)
5282
+ end
5283
+ end
5284
+ local body = {
5285
+ mode = mode,
5286
+ method = config.method,
5287
+ elapsed_ms = elapsedMs,
5288
+ root = compactRoot(tree, #leaves),
5289
+ top = top,
5290
+ }
5291
+ if raw then
5292
+ body.tree = tree
5293
+ end
5294
+ return body
5295
+ end
5296
+ local function getSceneAnalysis(requestData)
5297
+ local mode = normalizeMode(requestData.mode)
5298
+ if not (type(mode) == "string") then
5299
+ return mode
5300
+ end
5301
+ local serviceOrError = getSceneAnalysisService()
5302
+ local _value = serviceOrError.IsA
5303
+ if not (_value ~= 0 and _value == _value and _value ~= "" and _value) then
5304
+ return serviceOrError
5305
+ end
5306
+ local service = serviceOrError
5307
+ local topN = normalizeTopN(requestData.topN)
5308
+ local raw = requestData.raw == true
5309
+ if mode ~= "all" then
5310
+ return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw)
5311
+ end
5312
+ local body = {}
5313
+ for _, m in ALL_MODES do
5314
+ local result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw)
5315
+ if result.error == "scene_analysis_not_enabled" then
5316
+ return result
5317
+ end
5318
+ body[m] = result
5319
+ end
5320
+ return body
5321
+ end
5322
+ return {
5323
+ getSceneAnalysis = getSceneAnalysis,
5324
+ }
5325
+ ]]></string>
5326
+ </Properties>
5327
+ </Item>
5328
+ <Item class="ModuleScript" referent="17">
5073
5329
  <Properties>
5074
5330
  <string name="Name">ScriptHandlers</string>
5075
5331
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5765,7 +6021,7 @@ return {
5765
6021
  ]]></string>
5766
6022
  </Properties>
5767
6023
  </Item>
5768
- <Item class="ModuleScript" referent="17">
6024
+ <Item class="ModuleScript" referent="18">
5769
6025
  <Properties>
5770
6026
  <string name="Name">SerializationHandlers</string>
5771
6027
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5951,7 +6207,7 @@ return {
5951
6207
  ]]></string>
5952
6208
  </Properties>
5953
6209
  </Item>
5954
- <Item class="ModuleScript" referent="18">
6210
+ <Item class="ModuleScript" referent="19">
5955
6211
  <Properties>
5956
6212
  <string name="Name">TestHandlers</string>
5957
6213
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6584,7 +6840,7 @@ return {
6584
6840
  </Properties>
6585
6841
  </Item>
6586
6842
  </Item>
6587
- <Item class="ModuleScript" referent="19">
6843
+ <Item class="ModuleScript" referent="20">
6588
6844
  <Properties>
6589
6845
  <string name="Name">LuauExec</string>
6590
6846
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6902,7 +7158,7 @@ return {
6902
7158
  ]]></string>
6903
7159
  </Properties>
6904
7160
  </Item>
6905
- <Item class="ModuleScript" referent="20">
7161
+ <Item class="ModuleScript" referent="21">
6906
7162
  <Properties>
6907
7163
  <string name="Name">Recording</string>
6908
7164
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6932,7 +7188,7 @@ return {
6932
7188
  ]]></string>
6933
7189
  </Properties>
6934
7190
  </Item>
6935
- <Item class="ModuleScript" referent="21">
7191
+ <Item class="ModuleScript" referent="22">
6936
7192
  <Properties>
6937
7193
  <string name="Name">RenderMonitor</string>
6938
7194
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7000,7 +7256,7 @@ return {
7000
7256
  ]]></string>
7001
7257
  </Properties>
7002
7258
  </Item>
7003
- <Item class="ModuleScript" referent="22">
7259
+ <Item class="ModuleScript" referent="23">
7004
7260
  <Properties>
7005
7261
  <string name="Name">RuntimeLogBuffer</string>
7006
7262
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7181,11 +7437,11 @@ return {
7181
7437
  ]]></string>
7182
7438
  </Properties>
7183
7439
  </Item>
7184
- <Item class="ModuleScript" referent="23">
7440
+ <Item class="ModuleScript" referent="24">
7185
7441
  <Properties>
7186
7442
  <string name="Name">State</string>
7187
7443
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7188
- local CURRENT_VERSION = "2.14.0"
7444
+ local CURRENT_VERSION = "2.15.0"
7189
7445
  local MAX_CONNECTIONS = 5
7190
7446
  local BASE_PORT = 58741
7191
7447
  local activeTabIndex = 0
@@ -7277,7 +7533,7 @@ return {
7277
7533
  ]]></string>
7278
7534
  </Properties>
7279
7535
  </Item>
7280
- <Item class="ModuleScript" referent="24">
7536
+ <Item class="ModuleScript" referent="25">
7281
7537
  <Properties>
7282
7538
  <string name="Name">StopPlayMonitor</string>
7283
7539
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7422,7 +7678,7 @@ return {
7422
7678
  ]]></string>
7423
7679
  </Properties>
7424
7680
  </Item>
7425
- <Item class="ModuleScript" referent="25">
7681
+ <Item class="ModuleScript" referent="26">
7426
7682
  <Properties>
7427
7683
  <string name="Name">UI</string>
7428
7684
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8173,7 +8429,7 @@ return {
8173
8429
  ]]></string>
8174
8430
  </Properties>
8175
8431
  </Item>
8176
- <Item class="ModuleScript" referent="26">
8432
+ <Item class="ModuleScript" referent="27">
8177
8433
  <Properties>
8178
8434
  <string name="Name">Utils</string>
8179
8435
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8703,11 +8959,11 @@ return {
8703
8959
  </Properties>
8704
8960
  </Item>
8705
8961
  </Item>
8706
- <Item class="Folder" referent="30">
8962
+ <Item class="Folder" referent="31">
8707
8963
  <Properties>
8708
8964
  <string name="Name">include</string>
8709
8965
  </Properties>
8710
- <Item class="ModuleScript" referent="27">
8966
+ <Item class="ModuleScript" referent="28">
8711
8967
  <Properties>
8712
8968
  <string name="Name">Promise</string>
8713
8969
  <string name="Source"><![CDATA[--[[
@@ -10781,7 +11037,7 @@ return Promise
10781
11037
  ]]></string>
10782
11038
  </Properties>
10783
11039
  </Item>
10784
- <Item class="ModuleScript" referent="28">
11040
+ <Item class="ModuleScript" referent="29">
10785
11041
  <Properties>
10786
11042
  <string name="Name">RuntimeLib</string>
10787
11043
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -11048,15 +11304,15 @@ return TS
11048
11304
  </Properties>
11049
11305
  </Item>
11050
11306
  </Item>
11051
- <Item class="Folder" referent="31">
11307
+ <Item class="Folder" referent="32">
11052
11308
  <Properties>
11053
11309
  <string name="Name">node_modules</string>
11054
11310
  </Properties>
11055
- <Item class="Folder" referent="32">
11311
+ <Item class="Folder" referent="33">
11056
11312
  <Properties>
11057
11313
  <string name="Name">@rbxts</string>
11058
11314
  </Properties>
11059
- <Item class="ModuleScript" referent="29">
11315
+ <Item class="ModuleScript" referent="30">
11060
11316
  <Properties>
11061
11317
  <string name="Name">services</string>
11062
11318
  <string name="Source"><![CDATA[return setmetatable({}, {
@@ -1,6 +1,7 @@
1
1
  import { HttpService, Players, ReplicatedStorage, RunService, ServerStorage } from "@rbxts/services";
2
2
  import RuntimeLogBuffer from "./RuntimeLogBuffer";
3
3
  import MemoryHandlers from "./handlers/MemoryHandlers";
4
+ import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
4
5
  import CaptureHandlers from "./handlers/CaptureHandlers";
5
6
  import InputHandlers from "./handlers/InputHandlers";
6
7
  import LuauExec from "./LuauExec";
@@ -87,6 +88,7 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
87
88
  "/api/execute-luau",
88
89
  "/api/get-runtime-logs",
89
90
  "/api/get-memory-breakdown",
91
+ "/api/get-scene-analysis",
90
92
  "/api/multiplayer-test-state",
91
93
  "/api/multiplayer-test-leave-client",
92
94
  // Screenshot capture must run in the client peer (CaptureService captures
@@ -224,7 +226,7 @@ function handleMultiplayerTestLeaveClient(): unknown {
224
226
  function setupClientBroker() {
225
227
  const rf = ReplicatedStorage.WaitForChild(BROKER_NAME, 10);
226
228
  if (!rf || !rf.IsA("RemoteFunction")) {
227
- warn(`[MCPFork] client: ${BROKER_NAME} not found`);
229
+ warn(`[robloxstudio-mcp] client: ${BROKER_NAME} not found`);
228
230
  return;
229
231
  }
230
232
  rf.OnClientInvoke = (payload: BrokerEnvelope | undefined) => {
@@ -240,6 +242,9 @@ function setupClientBroker() {
240
242
  if (payload && payload.endpoint === "/api/get-memory-breakdown") {
241
243
  return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
242
244
  }
245
+ if (payload && payload.endpoint === "/api/get-scene-analysis") {
246
+ return SceneAnalysisHandlers.getSceneAnalysis(payload.data ?? {});
247
+ }
243
248
  if (payload && payload.endpoint === "/api/multiplayer-test-state") {
244
249
  return handleMultiplayerTestState();
245
250
  }
@@ -326,7 +331,7 @@ function registerProxy(player: Player, rf: RemoteFunction) {
326
331
  isRunning: RunService.IsRunning(),
327
332
  });
328
333
  if (!ok || !res || !res.Success) {
329
- warn(`[MCPFork] proxy register failed for ${player.Name}`);
334
+ warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}`);
330
335
  return;
331
336
  }
332
337
  const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
@@ -16,6 +16,7 @@ import InputHandlers from "./handlers/InputHandlers";
16
16
  import LogHandlers from "./handlers/LogHandlers";
17
17
  import SerializationHandlers from "./handlers/SerializationHandlers";
18
18
  import MemoryHandlers from "./handlers/MemoryHandlers";
19
+ import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
19
20
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
20
21
 
21
22
  // Per-plugin-load random GUID. Used as the /poll URL param so the server
@@ -162,6 +163,7 @@ const routeMap: Record<string, Handler> = {
162
163
  "/api/import-rbxm": SerializationHandlers.importRbxm,
163
164
 
164
165
  "/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
166
+ "/api/get-scene-analysis": SceneAnalysisHandlers.getSceneAnalysis,
165
167
  };
166
168
 
167
169
  function processRequest(request: RequestPayload): unknown {
@@ -0,0 +1,216 @@
1
+ interface SceneAnalysisServiceLike extends Instance {
2
+ GetInstanceCompositionAsync(this: SceneAnalysisServiceLike): unknown;
3
+ GetScriptMemoryAsync(this: SceneAnalysisServiceLike): unknown;
4
+ GetUnparentedInstancesAsync(this: SceneAnalysisServiceLike): unknown;
5
+ GetTriangleCompositionAsync(this: SceneAnalysisServiceLike): unknown;
6
+ GetAnimationMemoryAsync(this: SceneAnalysisServiceLike): unknown;
7
+ GetAudioMemoryAsync(this: SceneAnalysisServiceLike): unknown;
8
+ }
9
+
10
+ interface SceneAnalysisNode {
11
+ Name?: string;
12
+ Size?: number;
13
+ Sizes?: Record<string, number>;
14
+ Children?: SceneAnalysisNode[];
15
+ AssetId?: string;
16
+ }
17
+
18
+ interface ModeConfig {
19
+ method: string;
20
+ query: (service: SceneAnalysisServiceLike) => unknown;
21
+ sortByTriangles?: boolean;
22
+ }
23
+
24
+ const MODE_CONFIGS: Record<string, ModeConfig> = {
25
+ instance_composition: {
26
+ method: "GetInstanceCompositionAsync",
27
+ query: (service) => service.GetInstanceCompositionAsync(),
28
+ },
29
+ script_memory: {
30
+ method: "GetScriptMemoryAsync",
31
+ query: (service) => service.GetScriptMemoryAsync(),
32
+ },
33
+ unparented_instances: {
34
+ method: "GetUnparentedInstancesAsync",
35
+ query: (service) => service.GetUnparentedInstancesAsync(),
36
+ },
37
+ triangle_composition: {
38
+ method: "GetTriangleCompositionAsync",
39
+ query: (service) => service.GetTriangleCompositionAsync(),
40
+ sortByTriangles: true,
41
+ },
42
+ animation_memory: {
43
+ method: "GetAnimationMemoryAsync",
44
+ query: (service) => service.GetAnimationMemoryAsync(),
45
+ },
46
+ audio_memory: {
47
+ method: "GetAudioMemoryAsync",
48
+ query: (service) => service.GetAudioMemoryAsync(),
49
+ },
50
+ };
51
+
52
+ const ALL_MODES = [
53
+ "instance_composition",
54
+ "script_memory",
55
+ "unparented_instances",
56
+ "triangle_composition",
57
+ "animation_memory",
58
+ "audio_memory",
59
+ ];
60
+
61
+ function betaDisabledError(): Record<string, unknown> {
62
+ return {
63
+ error: "scene_analysis_not_enabled",
64
+ message: "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
65
+ betaFeatureRequired: true,
66
+ };
67
+ }
68
+
69
+ function isBetaDisabledError(value: unknown): boolean {
70
+ return typeIs(value, "string") && string.find(value, "SceneAnalysisService is not enabled", 1, true)[0] !== undefined;
71
+ }
72
+
73
+ function getSceneAnalysisService(): SceneAnalysisServiceLike | Record<string, unknown> {
74
+ const provider = game as unknown as { GetService(serviceName: string): Instance };
75
+ const [ok, service] = pcall(() => provider.GetService("SceneAnalysisService") as SceneAnalysisServiceLike);
76
+ if (!ok || !service) {
77
+ return {
78
+ error: "scene_analysis_unavailable",
79
+ message: `SceneAnalysisService is unavailable: ${tostring(service)}`,
80
+ };
81
+ }
82
+ return service;
83
+ }
84
+
85
+ function normalizeMode(mode: unknown): string | Record<string, unknown> {
86
+ if (mode === undefined || mode === "all") return "all";
87
+ if (!typeIs(mode, "string") || MODE_CONFIGS[mode] === undefined) {
88
+ return {
89
+ error: "invalid_mode",
90
+ message: `mode must be one of: all, ${ALL_MODES.join(", ")}`,
91
+ };
92
+ }
93
+ return mode;
94
+ }
95
+
96
+ function normalizeTopN(topN: unknown): number {
97
+ if (!typeIs(topN, "number")) return 10;
98
+ return math.clamp(math.floor(topN), 1, 100);
99
+ }
100
+
101
+ function countLeaves(node: SceneAnalysisNode): number {
102
+ const children = node.Children;
103
+ if (children && children.size() > 0) {
104
+ let total = 0;
105
+ for (const child of children) total += countLeaves(child);
106
+ return total;
107
+ }
108
+ return 1;
109
+ }
110
+
111
+ function flattenLeaves(node: SceneAnalysisNode, out: SceneAnalysisNode[]): void {
112
+ const children = node.Children;
113
+ if (children && children.size() > 0) {
114
+ for (const child of children) flattenLeaves(child, out);
115
+ return;
116
+ }
117
+ out.push(node);
118
+ }
119
+
120
+ function compactEntry(node: SceneAnalysisNode): Record<string, unknown> {
121
+ const entry: Record<string, unknown> = {
122
+ name: node.Name,
123
+ };
124
+ if (node.Size !== undefined) entry.size = node.Size;
125
+ if (node.Sizes !== undefined) entry.sizes = node.Sizes;
126
+ if (node.AssetId !== undefined) entry.asset_id = node.AssetId;
127
+ return entry;
128
+ }
129
+
130
+ function compactRoot(node: SceneAnalysisNode, leafCount: number): Record<string, unknown> {
131
+ const children = node.Children;
132
+ const root: Record<string, unknown> = {
133
+ name: node.Name,
134
+ child_count: children ? children.size() : 0,
135
+ leaf_count: leafCount,
136
+ };
137
+ if (node.Size !== undefined) root.size = node.Size;
138
+ if (node.Sizes !== undefined) root.sizes = node.Sizes;
139
+ return root;
140
+ }
141
+
142
+ function metric(node: SceneAnalysisNode, sortByTriangles: boolean): number {
143
+ if (sortByTriangles) {
144
+ const sizes = node.Sizes;
145
+ const triangles = sizes ? sizes.Triangles : undefined;
146
+ return triangles ?? 0;
147
+ }
148
+ return node.Size ?? 0;
149
+ }
150
+
151
+ function summarizeMode(
152
+ mode: string,
153
+ config: ModeConfig,
154
+ service: SceneAnalysisServiceLike,
155
+ topN: number,
156
+ raw: boolean,
157
+ ): Record<string, unknown> {
158
+ const started = os.clock();
159
+ const [ok, result] = pcall(() => config.query(service) as SceneAnalysisNode);
160
+ const elapsedMs = math.floor((os.clock() - started) * 1000);
161
+
162
+ if (!ok) {
163
+ if (isBetaDisabledError(result)) return betaDisabledError();
164
+ return {
165
+ error: "scene_analysis_query_failed",
166
+ mode,
167
+ method: config.method,
168
+ message: tostring(result),
169
+ };
170
+ }
171
+
172
+ const tree = result as SceneAnalysisNode;
173
+ const leaves: SceneAnalysisNode[] = [];
174
+ flattenLeaves(tree, leaves);
175
+ leaves.sort((a, b) => metric(a, config.sortByTriangles === true) > metric(b, config.sortByTriangles === true));
176
+
177
+ const top: Record<string, unknown>[] = [];
178
+ for (let i = 0; i < math.min(topN, leaves.size()); i++) {
179
+ top.push(compactEntry(leaves[i]));
180
+ }
181
+
182
+ const body: Record<string, unknown> = {
183
+ mode,
184
+ method: config.method,
185
+ elapsed_ms: elapsedMs,
186
+ root: compactRoot(tree, leaves.size()),
187
+ top,
188
+ };
189
+ if (raw) body.tree = tree;
190
+ return body;
191
+ }
192
+
193
+ function getSceneAnalysis(requestData: Record<string, unknown>): unknown {
194
+ const mode = normalizeMode(requestData.mode);
195
+ if (!typeIs(mode, "string")) return mode;
196
+
197
+ const serviceOrError = getSceneAnalysisService();
198
+ if (!serviceOrError.IsA) return serviceOrError;
199
+ const service = serviceOrError as SceneAnalysisServiceLike;
200
+ const topN = normalizeTopN(requestData.topN);
201
+ const raw = requestData.raw === true;
202
+
203
+ if (mode !== "all") {
204
+ return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw);
205
+ }
206
+
207
+ const body: Record<string, unknown> = {};
208
+ for (const m of ALL_MODES) {
209
+ const result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw);
210
+ if (result.error === "scene_analysis_not_enabled") return result;
211
+ body[m] = result;
212
+ }
213
+ return body;
214
+ }
215
+
216
+ export = { getSceneAnalysis };