roblox-mcp-pro 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3513 @@
1
+ <roblox version="4">
2
+ <Item class="Script" referent="0">
3
+ <Properties>
4
+ <string name="Name">RobloxMcpPro</string>
5
+ <token name="RunContext">0</token>
6
+ <string name="Source"><![CDATA[--!strict
7
+ -- roblox-mcp-pro Studio plugin entry point.
8
+ -- Adds a toolbar toggle that connects to the local MCP bridge and long-polls
9
+ -- for commands, dispatching each to its handler.
10
+
11
+ local HttpService = game:GetService("HttpService")
12
+
13
+ local Bridge = require(script.Bridge)
14
+ local Dispatcher = require(script.Dispatcher)
15
+ local UI = require(script.UI)
16
+
17
+ local DEFAULT_URL = "http://127.0.0.1:3690"
18
+ local SETTING_URL = "roblox_mcp_pro_url"
19
+ local SETTING_TOKEN = "roblox_mcp_pro_token"
20
+ local SETTING_AUTOCONNECT = "roblox_mcp_pro_autoconnect"
21
+ local SETTING_SYNCMODE = "roblox_mcp_pro_syncmode"
22
+
23
+ -- Resolve config from saved plugin settings (falls back to defaults).
24
+ local url = plugin:GetSetting(SETTING_URL) or DEFAULT_URL
25
+ local token = plugin:GetSetting(SETTING_TOKEN)
26
+ local autoConnect = plugin:GetSetting(SETTING_AUTOCONNECT) == true
27
+ local syncMode = plugin:GetSetting(SETTING_SYNCMODE) or "two-way"
28
+ Bridge.configure(url, token)
29
+
30
+ local toolbar = plugin:CreateToolbar("Roblox MCP Pro")
31
+ local button = toolbar:CreateButton(
32
+ "RobloxMcpProToggle",
33
+ "Toggle the Roblox MCP Pro control panel panel",
34
+ "",
35
+ "MCP"
36
+ )
37
+ button.ClickableWhenViewportHidden = true
38
+
39
+ -- Initialize DockWidget GUI
40
+ local widgetInfo = DockWidgetPluginGuiInfo.new(
41
+ Enum.InitialDockState.Right, -- initial dock state
42
+ false, -- initially disabled (closed by default)
43
+ false, -- override previous enabled state (remember layout)
44
+ 300, -- default width
45
+ 300, -- default height
46
+ 250, -- minimum width
47
+ 200 -- minimum height
48
+ )
49
+ local widget = plugin:CreateDockWidgetPluginGui("RobloxMcpProPanel", widgetInfo)
50
+
51
+ -- Create the UI panel inside the widget
52
+ UI.create(widget, { url = url, token = token, autoConnect = autoConnect, syncMode = syncMode })
53
+
54
+ local connected = false
55
+ local isSyncing = false
56
+
57
+ local function log(message: string)
58
+ print("[roblox-mcp-pro] " .. message)
59
+ end
60
+
61
+ local RETRY_WAIT = 5 -- seconds to back off while the server is unreachable
62
+
63
+ local function pollLoop()
64
+ -- Best-effort enable HTTP for localhost requests.
65
+ pcall(function()
66
+ HttpService.HttpEnabled = true
67
+ end)
68
+
69
+ -- Track reachability so we only log on state changes (no per-cycle spam).
70
+ local reachable = true
71
+ local announcedDown = false
72
+
73
+ -- Reflect reachability immediately. Otherwise the panel sits on "Connecting"
74
+ -- until the first /dequeue returns, which the long-poll can hold open for up
75
+ -- to ~25s when no command is waiting.
76
+ if connected and Bridge.health() then
77
+ UI.setStatus("Connected")
78
+ end
79
+
80
+ while connected do
81
+ local command, err = Bridge.dequeue()
82
+ if not connected then break end -- Safeguard against connection closed while yielding
83
+
84
+ if command then
85
+ if not reachable then
86
+ log("reconnected to " .. url)
87
+ end
88
+ reachable = true
89
+ announcedDown = false
90
+ UI.setStatus("Connected")
91
+
92
+ local ok, result, errMsg = Dispatcher.dispatch(command.tool, command.args)
93
+ Bridge.respond(command.id, ok, result, errMsg)
94
+
95
+ -- Feed execution result to UI console (but not broker-internal probes
96
+ -- like the dashboard's periodic system_info — only real agent commands).
97
+ if not command.internal then
98
+ UI.addLog(command.tool, ok, if ok then nil else errMsg)
99
+ end
100
+ elseif err then
101
+ -- Transport error (server down, HTTP disabled). Warn once per outage.
102
+ reachable = false
103
+ UI.setStatus("Offline")
104
+ if not announcedDown then
105
+ announcedDown = true
106
+ warn(
107
+ "[roblox-mcp-pro] MCP server unreachable at "
108
+ .. url
109
+ .. " — retrying every "
110
+ .. RETRY_WAIT
111
+ .. "s. Start it (node dist/index.js) and ensure 'Allow HTTP Requests' is on."
112
+ )
113
+ end
114
+ task.wait(RETRY_WAIT)
115
+ else
116
+ -- nil command, no error = long-poll timed out; server is up. Loop again.
117
+ if not reachable then
118
+ log("reconnected to " .. url)
119
+ end
120
+ reachable = true
121
+ announcedDown = false
122
+ UI.setStatus("Connected")
123
+ end
124
+ end
125
+ end
126
+
127
+ -- Reflect a sync-status result from the broker in the UI.
128
+ local function applySyncResult(result: any)
129
+ isSyncing = result.running == true
130
+ UI.setSyncStatus(isSyncing, result.mode, result.scriptCount)
131
+ end
132
+
133
+ -- While connected, poll the broker's sync status so the Sync card stays in step
134
+ -- (including sync started by an AI agent rather than this panel).
135
+ local function startSyncRefresher()
136
+ task.spawn(function()
137
+ while connected do
138
+ local st = Bridge.syncStatus()
139
+ if st then
140
+ applySyncResult(st)
141
+ end
142
+ task.wait(5)
143
+ end
144
+ end)
145
+ end
146
+
147
+ local function setConnected(value: boolean)
148
+ connected = value
149
+ -- Tell the broker right away so the dashboard reflects it within ~1s.
150
+ task.spawn(function()
151
+ Bridge.notifyPresence(value)
152
+ end)
153
+ if value then
154
+ log("connecting to " .. url)
155
+ UI.setStatus("Connecting")
156
+ task.spawn(pollLoop)
157
+ startSyncRefresher()
158
+ else
159
+ log("disconnected")
160
+ UI.setStatus("Disconnected")
161
+ isSyncing = false
162
+ UI.setSyncStatus(false, syncMode, 0)
163
+ end
164
+ end
165
+
166
+ -- Connect UI Callbacks
167
+ UI.onConnectionToggled = function(connect: boolean)
168
+ setConnected(connect)
169
+ end
170
+
171
+ UI.onAutoConnectChanged = function(enabled: boolean)
172
+ plugin:SetSetting(SETTING_AUTOCONNECT, enabled)
173
+ log("auto-connect " .. (if enabled then "enabled" else "disabled"))
174
+ end
175
+
176
+ UI.onSyncModeChanged = function(mode: string)
177
+ syncMode = mode
178
+ plugin:SetSetting(SETTING_SYNCMODE, mode)
179
+ -- If sync is already running, restart it in the new direction.
180
+ if connected and isSyncing then
181
+ task.spawn(function()
182
+ local result, err = Bridge.syncControl("start", mode)
183
+ if result then
184
+ applySyncResult(result)
185
+ else
186
+ warn("[roblox-mcp-pro] sync mode change failed: " .. tostring(err))
187
+ end
188
+ end)
189
+ end
190
+ end
191
+
192
+ UI.onSyncToggled = function(enabled: boolean, mode: string)
193
+ if not connected then
194
+ UI.setSyncStatus(false, mode, 0)
195
+ warn("[roblox-mcp-pro] Connect to the server before enabling sync.")
196
+ return
197
+ end
198
+ task.spawn(function()
199
+ local action = if enabled then "start" else "stop"
200
+ local result, err = Bridge.syncControl(action, mode)
201
+ if result then
202
+ applySyncResult(result)
203
+ else
204
+ isSyncing = false
205
+ UI.setSyncStatus(false, mode, 0)
206
+ warn("[roblox-mcp-pro] sync " .. action .. " failed: " .. tostring(err))
207
+ end
208
+ end)
209
+ end
210
+
211
+ UI.onSettingsSaved = function(newUrl: string, newToken: string?)
212
+ url = newUrl
213
+ token = newToken
214
+ plugin:SetSetting(SETTING_URL, newUrl)
215
+ plugin:SetSetting(SETTING_TOKEN, newToken)
216
+ Bridge.configure(newUrl, newToken)
217
+ log("settings updated, URL: " .. newUrl)
218
+
219
+ -- Restart connection if active to apply new URL/Token
220
+ if connected then
221
+ setConnected(false)
222
+ task.wait(0.1)
223
+ setConnected(true)
224
+ end
225
+ end
226
+
227
+ -- Toggle widget visibility via toolbar button
228
+ button.Click:Connect(function()
229
+ widget.Enabled = not widget.Enabled
230
+ end)
231
+
232
+ -- Sync button state with widget visibility
233
+ widget:GetPropertyChangedSignal("Enabled"):Connect(function()
234
+ button:SetActive(widget.Enabled)
235
+ end)
236
+ button:SetActive(widget.Enabled)
237
+
238
+ plugin.Unloading:Connect(function()
239
+ -- Best-effort: tell the broker we're going so the dashboard updates fast
240
+ -- (Studio closing may cut this off; the liveness timeout is the fallback).
241
+ if connected then
242
+ Bridge.notifyPresence(false)
243
+ end
244
+ connected = false
245
+ end)
246
+
247
+ -- Auto-connect only when the user opted in (default off). Fixes the old
248
+ -- behavior where the plugin reconnected on its own after a single connect.
249
+ log(("loaded %d tools."):format(Dispatcher.toolCount))
250
+ if autoConnect then
251
+ log("auto-connect is on; connecting…")
252
+ setConnected(true)
253
+ else
254
+ log("click the 'MCP' button on the toolbar or open the panel to connect.")
255
+ UI.setStatus("Disconnected")
256
+ end
257
+ ]]></string>
258
+ </Properties>
259
+ <Item class="ModuleScript" referent="1">
260
+ <Properties>
261
+ <string name="Name">Bridge</string>
262
+ <string name="Source"><![CDATA[--!strict
263
+ -- Bridge: HTTP client that talks to the local roblox-mcp-pro server.
264
+ -- Long-polls GET /dequeue for commands and POSTs results to /respond.
265
+
266
+ local HttpService = game:GetService("HttpService")
267
+
268
+ local Bridge = {}
269
+
270
+ local baseUrl = "http://127.0.0.1:3690"
271
+ local authToken: string? = nil
272
+
273
+ function Bridge.configure(url: string, token: string?)
274
+ baseUrl = url
275
+ authToken = token
276
+ end
277
+
278
+ local function headers(): { [string]: string }
279
+ local h = { ["Content-Type"] = "application/json" }
280
+ if authToken and authToken ~= "" then
281
+ h["x-auth-token"] = authToken
282
+ end
283
+ return h
284
+ end
285
+
286
+ -- Long-poll for the next command.
287
+ -- Returns: command table, or nil if the poll timed out (204), or false + message on error.
288
+ function Bridge.dequeue(): (any, string?)
289
+ local ok, response = pcall(function()
290
+ return HttpService:RequestAsync({
291
+ Url = baseUrl .. "/dequeue",
292
+ Method = "GET",
293
+ Headers = headers(),
294
+ })
295
+ end)
296
+ if not ok then
297
+ return false, tostring(response)
298
+ end
299
+ if response.StatusCode == 200 then
300
+ local decoded = HttpService:JSONDecode(response.Body)
301
+ return decoded, nil
302
+ elseif response.StatusCode == 204 then
303
+ return nil, nil
304
+ else
305
+ return false, ("HTTP %d: %s"):format(response.StatusCode, tostring(response.Body))
306
+ end
307
+ end
308
+
309
+ -- Post a command result back to the server.
310
+ function Bridge.respond(id: string, success: boolean, result: any, errMsg: string?)
311
+ local payload = {
312
+ id = id,
313
+ ok = success,
314
+ result = result,
315
+ error = errMsg,
316
+ }
317
+ pcall(function()
318
+ HttpService:RequestAsync({
319
+ Url = baseUrl .. "/respond",
320
+ Method = "POST",
321
+ Headers = headers(),
322
+ Body = HttpService:JSONEncode(payload),
323
+ })
324
+ end)
325
+ end
326
+
327
+ -- Push a Studio->server change event (used by sync).
328
+ function Bridge.postEvent(event: { [string]: any })
329
+ pcall(function()
330
+ HttpService:RequestAsync({
331
+ Url = baseUrl .. "/event",
332
+ Method = "POST",
333
+ Headers = headers(),
334
+ Body = HttpService:JSONEncode(event),
335
+ })
336
+ end)
337
+ end
338
+
339
+ -- Drive the shared sync engine on the broker from the plugin UI.
340
+ -- action = "start" | "stop" | "status" | "pull" | "push"
341
+ -- mode = "two-way" | "studio-to-disk" | "disk-to-studio" (for "start")
342
+ -- Returns: result table (sync status) on success, or nil + error message.
343
+ function Bridge.syncControl(action: string, mode: string?, roots: { string }?): (any, string?)
344
+ local ok, response = pcall(function()
345
+ return HttpService:RequestAsync({
346
+ Url = baseUrl .. "/plugin/sync",
347
+ Method = "POST",
348
+ Headers = headers(),
349
+ Body = HttpService:JSONEncode({ action = action, mode = mode, roots = roots }),
350
+ })
351
+ end)
352
+ if not ok then
353
+ return nil, tostring(response)
354
+ end
355
+ if response.StatusCode == 200 then
356
+ local decoded = HttpService:JSONDecode(response.Body)
357
+ if decoded.ok then
358
+ return decoded.result, nil
359
+ end
360
+ return nil, tostring(decoded.error)
361
+ elseif response.StatusCode == 404 then
362
+ return nil, "This server version doesn't support plugin-driven sync (update the MCP server)."
363
+ end
364
+ return nil, ("HTTP %d"):format(response.StatusCode)
365
+ end
366
+
367
+ -- Fetch current sync status from the broker.
368
+ function Bridge.syncStatus(): (any, string?)
369
+ local ok, response = pcall(function()
370
+ return HttpService:RequestAsync({
371
+ Url = baseUrl .. "/plugin/sync",
372
+ Method = "GET",
373
+ Headers = headers(),
374
+ })
375
+ end)
376
+ if not ok then
377
+ return nil, tostring(response)
378
+ end
379
+ if response.StatusCode == 200 then
380
+ local decoded = HttpService:JSONDecode(response.Body)
381
+ return decoded.result, nil
382
+ end
383
+ return nil, ("HTTP %d"):format(response.StatusCode)
384
+ end
385
+
386
+ -- Tell the broker we just connected/disconnected so the dashboard updates
387
+ -- immediately instead of waiting out the long-poll liveness window.
388
+ function Bridge.notifyPresence(connected: boolean)
389
+ pcall(function()
390
+ HttpService:RequestAsync({
391
+ Url = baseUrl .. "/plugin/status",
392
+ Method = "POST",
393
+ Headers = headers(),
394
+ Body = HttpService:JSONEncode({ connected = connected }),
395
+ })
396
+ end)
397
+ end
398
+
399
+ -- Check server reachability. Returns true if /health responds.
400
+ function Bridge.health(): boolean
401
+ local ok, response = pcall(function()
402
+ return HttpService:RequestAsync({
403
+ Url = baseUrl .. "/health",
404
+ Method = "GET",
405
+ Headers = headers(),
406
+ })
407
+ end)
408
+ return ok and response.StatusCode == 200
409
+ end
410
+
411
+ return Bridge
412
+ ]]></string>
413
+ </Properties>
414
+ </Item>
415
+ <Item class="ModuleScript" referent="2">
416
+ <Properties>
417
+ <string name="Name">Dispatcher</string>
418
+ <string name="Source"><![CDATA[--!strict
419
+ -- Dispatcher: route an incoming command to its handler.
420
+ -- Handlers are loaded defensively: one broken handler disables only its own
421
+ -- tool rather than breaking the whole plugin.
422
+
423
+ local Handlers = script.Parent.Handlers
424
+
425
+ local HANDLER_NAMES = {
426
+ execute_luau = "Execute",
427
+ query_instances = "Query",
428
+ find_instances = "FindInstances",
429
+ scene_overview = "SceneOverview",
430
+ describe_instance = "DescribeInstance",
431
+ mutate_instances = "Mutate",
432
+ manage_properties = "Properties",
433
+ system_info = "System",
434
+ sync_snapshot = "SyncSnapshot",
435
+ sync_watch = "SyncWatch",
436
+ sync_apply = "SyncApply",
437
+ batch_execute = "Batch",
438
+ spatial_query = "Spatial",
439
+ manage_terrain = "Terrain",
440
+ manage_ui = "UI",
441
+ ui_preview = "UIPreview",
442
+ manage_audio = "Audio",
443
+ manage_animation = "Animation",
444
+ manage_lighting = "Lighting",
445
+ manage_effects = "Effects",
446
+ manage_physics = "Physics",
447
+ manage_camera = "Camera",
448
+ manage_tween = "Tween",
449
+ manage_assets = "Assets",
450
+ manage_scripts = "Scripts",
451
+ manage_selection = "Selection",
452
+ manage_studio = "Studio",
453
+ manage_logs = "Logs",
454
+ workspace_state = "WorkspaceState",
455
+ }
456
+
457
+ local map: { [string]: (any) -> any } = {}
458
+ for tool, moduleName in HANDLER_NAMES do
459
+ local ok, handler = pcall(function()
460
+ return require(Handlers[moduleName])
461
+ end)
462
+ if ok and type(handler) == "function" then
463
+ map[tool] = handler
464
+ else
465
+ warn(("[roblox-mcp-pro] failed to load handler '%s': %s"):format(moduleName, tostring(handler)))
466
+ end
467
+ end
468
+
469
+ local Dispatcher = {}
470
+
471
+ -- Number of tools successfully loaded (for startup diagnostics).
472
+ Dispatcher.toolCount = 0
473
+ for _ in map do
474
+ Dispatcher.toolCount += 1
475
+ end
476
+
477
+ -- Returns: success (boolean), result (any), errorMessage (string?)
478
+ function Dispatcher.dispatch(tool: string, args: any): (boolean, any, string?)
479
+ local handler = map[tool]
480
+ if not handler then
481
+ return false, nil, "Unknown or unavailable tool: " .. tostring(tool)
482
+ end
483
+ local ok, result = pcall(handler, args)
484
+ if ok then
485
+ return true, result, nil
486
+ else
487
+ return false, nil, tostring(result)
488
+ end
489
+ end
490
+
491
+ return Dispatcher
492
+ ]]></string>
493
+ </Properties>
494
+ </Item>
495
+ <Item class="Folder" referent="3">
496
+ <Properties>
497
+ <string name="Name">Handlers</string>
498
+ </Properties>
499
+ <Item class="ModuleScript" referent="4">
500
+ <Properties>
501
+ <string name="Name">Animation</string>
502
+ <string name="Source"><![CDATA[--!strict
503
+ -- Handler for manage_animation: manage Animation instances and best-effort preview.
504
+
505
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
506
+ local Serialize = require(script.Parent.Parent.Serialize)
507
+
508
+ return function(args: any): any
509
+ local action = args.action or "create"
510
+
511
+ if action == "create" then
512
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_animation create")
513
+ local parent = Serialize.resolve(args.parent or "ReplicatedStorage")
514
+ if not parent then
515
+ return { ok = false, error = "parent not found: " .. tostring(args.parent) }
516
+ end
517
+ local anim = Instance.new("Animation")
518
+ if args.name then
519
+ anim.Name = args.name
520
+ end
521
+ if args.animation_id then
522
+ anim.AnimationId = tostring(args.animation_id)
523
+ end
524
+ local err = Serialize.applyProperties(anim, args.properties)
525
+ anim.Parent = parent
526
+ if recording then
527
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
528
+ end
529
+ return { ok = err == nil, path = Serialize.path(anim), error = err }
530
+ elseif action == "set" then
531
+ local inst = args.path and Serialize.resolve(args.path) or nil
532
+ if not inst then
533
+ return { ok = false, error = "path not found: " .. tostring(args.path) }
534
+ end
535
+ local err = Serialize.applyProperties(inst, args.properties)
536
+ return { ok = err == nil, path = Serialize.path(inst), error = err }
537
+ elseif action == "play" then
538
+ -- Best effort: load+play on a Humanoid/AnimationController's Animator.
539
+ local target = args.target_path and Serialize.resolve(args.target_path) or nil
540
+ if not target then
541
+ return { ok = false, error = "target_path (a Humanoid or model) not found" }
542
+ end
543
+ local animator = target:FindFirstChildWhichIsA("Animator", true)
544
+ if not animator then
545
+ local humanoid = target:FindFirstChildWhichIsA("Humanoid")
546
+ if humanoid then
547
+ animator = humanoid:FindFirstChildWhichIsA("Animator")
548
+ end
549
+ end
550
+ if not animator then
551
+ return { ok = false, error = "no Animator found under target; play requires a rig with a Humanoid/Animator" }
552
+ end
553
+ local anim = Instance.new("Animation")
554
+ anim.AnimationId = tostring(args.animation_id or "")
555
+ local ok, track = pcall(function()
556
+ return (animator :: Animator):LoadAnimation(anim)
557
+ end)
558
+ if not ok or not track then
559
+ return { ok = false, error = "failed to load animation (check AnimationId): " .. tostring(track) }
560
+ end
561
+ track:Play()
562
+ return {
563
+ ok = true,
564
+ note = "Playing. Visible preview of programmatic playback may require Play mode.",
565
+ length = track.Length,
566
+ }
567
+ else
568
+ return { ok = false, error = "unknown manage_animation action: " .. tostring(action) }
569
+ end
570
+ end
571
+ ]]></string>
572
+ </Properties>
573
+ </Item>
574
+ <Item class="ModuleScript" referent="5">
575
+ <Properties>
576
+ <string name="Name">Assets</string>
577
+ <string name="Source"><![CDATA[--!strict
578
+ -- Handler for manage_assets: insert marketplace assets by id.
579
+
580
+ local InsertService = game:GetService("InsertService")
581
+ local MarketplaceService = game:GetService("MarketplaceService")
582
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
583
+ local Serialize = require(script.Parent.Parent.Serialize)
584
+
585
+ return function(args: any): any
586
+ local action = args.action or "insert"
587
+
588
+ if action == "insert" then
589
+ local assetId = tonumber(args.asset_id)
590
+ if not assetId then
591
+ return { ok = false, error = "asset_id (a number) is required" }
592
+ end
593
+ local parent = Serialize.resolve(args.parent or "Workspace") or workspace
594
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_assets insert")
595
+ local ok, model = pcall(function()
596
+ return InsertService:LoadAsset(assetId)
597
+ end)
598
+ if not ok or not model then
599
+ if recording then
600
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
601
+ end
602
+ return { ok = false, error = "LoadAsset failed (check id/permissions): " .. tostring(model) }
603
+ end
604
+ local inserted = {}
605
+ for _, child in model:GetChildren() do
606
+ child.Parent = parent
607
+ table.insert(inserted, Serialize.path(child))
608
+ end
609
+ model:Destroy()
610
+ if recording then
611
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
612
+ end
613
+ return { ok = true, inserted = inserted }
614
+ elseif action == "info" then
615
+ local assetId = tonumber(args.asset_id)
616
+ if not assetId then
617
+ return { ok = false, error = "asset_id (a number) is required" }
618
+ end
619
+ local ok, info = pcall(function()
620
+ return MarketplaceService:GetProductInfo(assetId)
621
+ end)
622
+ if not ok then
623
+ return { ok = false, error = "GetProductInfo failed: " .. tostring(info) }
624
+ end
625
+ return { ok = true, info = { name = info.Name, description = info.Description, creator = info.Creator and info.Creator.Name } }
626
+ else
627
+ return { ok = false, error = "unknown manage_assets action: " .. tostring(action) }
628
+ end
629
+ end
630
+ ]]></string>
631
+ </Properties>
632
+ </Item>
633
+ <Item class="ModuleScript" referent="6">
634
+ <Properties>
635
+ <string name="Name">Audio</string>
636
+ <string name="Source"><![CDATA[--!strict
637
+ -- Handler for manage_audio: create, configure, and preview Sound instances.
638
+
639
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
640
+ local Serialize = require(script.Parent.Parent.Serialize)
641
+
642
+ return function(args: any): any
643
+ local action = args.action or "create"
644
+
645
+ if action == "create" then
646
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_audio create")
647
+ local parent = Serialize.resolve(args.parent or "Workspace")
648
+ if not parent then
649
+ return { ok = false, error = "parent not found: " .. tostring(args.parent) }
650
+ end
651
+ local sound = Instance.new("Sound")
652
+ if args.name then
653
+ sound.Name = args.name
654
+ end
655
+ local err = Serialize.applyProperties(sound, args.properties)
656
+ sound.Parent = parent
657
+ if recording then
658
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
659
+ end
660
+ return { ok = err == nil, path = Serialize.path(sound), error = err }
661
+ end
662
+
663
+ -- Remaining actions operate on an existing Sound.
664
+ local inst = args.path and Serialize.resolve(args.path) or nil
665
+ if not inst or not inst:IsA("Sound") then
666
+ return { ok = false, error = "Sound not found at: " .. tostring(args.path) }
667
+ end
668
+ local sound = inst :: Sound
669
+
670
+ if action == "set" then
671
+ local err = Serialize.applyProperties(sound, args.properties)
672
+ return { ok = err == nil, path = Serialize.path(sound), error = err }
673
+ elseif action == "play" then
674
+ sound:Play()
675
+ return { ok = true, path = Serialize.path(sound), isPlaying = sound.IsPlaying }
676
+ elseif action == "stop" then
677
+ sound:Stop()
678
+ return { ok = true, path = Serialize.path(sound) }
679
+ elseif action == "pause" then
680
+ sound:Pause()
681
+ return { ok = true, path = Serialize.path(sound) }
682
+ elseif action == "resume" then
683
+ sound:Resume()
684
+ return { ok = true, path = Serialize.path(sound) }
685
+ else
686
+ return { ok = false, error = "unknown manage_audio action: " .. tostring(action) }
687
+ end
688
+ end
689
+ ]]></string>
690
+ </Properties>
691
+ </Item>
692
+ <Item class="ModuleScript" referent="7">
693
+ <Properties>
694
+ <string name="Name">Batch</string>
695
+ <string name="Source"><![CDATA[--!strict
696
+ -- Handler for batch_execute: run several sub-commands in one round-trip,
697
+ -- wrapped in a single undo recording.
698
+
699
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
700
+
701
+ return function(args: any): any
702
+ -- Deferred require avoids a load-time cycle (Dispatcher requires this module).
703
+ local Dispatcher = require(script.Parent.Parent.Dispatcher)
704
+
705
+ local operations = args.operations or {}
706
+ local stopOnError = args.stop_on_error == true
707
+
708
+ local recording = ChangeHistoryService:TryBeginRecording("MCP batch_execute")
709
+ local results: { any } = {}
710
+ for _, op in operations do
711
+ local ok, result, errMsg = Dispatcher.dispatch(op.tool, op.args)
712
+ table.insert(results, { ok = ok, result = result, error = errMsg })
713
+ if not ok and stopOnError then
714
+ break
715
+ end
716
+ end
717
+ if recording then
718
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
719
+ end
720
+ return { results = results }
721
+ end
722
+ ]]></string>
723
+ </Properties>
724
+ </Item>
725
+ <Item class="ModuleScript" referent="8">
726
+ <Properties>
727
+ <string name="Name">Camera</string>
728
+ <string name="Source"><![CDATA[--!strict
729
+ -- Handler for manage_camera: read/control Workspace.CurrentCamera.
730
+
731
+ local Workspace = game:GetService("Workspace")
732
+ local Serialize = require(script.Parent.Parent.Serialize)
733
+
734
+ local function toVector3(t: any, default: Vector3?): Vector3
735
+ if type(t) == "table" then
736
+ return Vector3.new(t.x or t[1] or 0, t.y or t[2] or 0, t.z or t[3] or 0)
737
+ end
738
+ return default or Vector3.zero
739
+ end
740
+
741
+ return function(args: any): any
742
+ local camera = Workspace.CurrentCamera
743
+ if not camera then
744
+ return { ok = false, error = "no CurrentCamera" }
745
+ end
746
+ local action = args.action or "get"
747
+
748
+ if action == "get" then
749
+ return {
750
+ ok = true,
751
+ cframe = Serialize.value(camera.CFrame),
752
+ position = Serialize.value(camera.CFrame.Position),
753
+ fieldOfView = camera.FieldOfView,
754
+ cameraType = camera.CameraType.Name,
755
+ }
756
+ elseif action == "set" then
757
+ local err = Serialize.applyProperties(camera, args.properties)
758
+ return { ok = err == nil, error = err }
759
+ elseif action == "focus" then
760
+ -- Point the camera from `position` toward a target point or instance.
761
+ local targetPos: Vector3
762
+ if args.target_path then
763
+ local inst = Serialize.resolve(args.target_path)
764
+ if inst and inst:IsA("BasePart") then
765
+ targetPos = inst.Position
766
+ elseif inst and inst:IsA("Model") then
767
+ targetPos = inst:GetPivot().Position
768
+ else
769
+ return { ok = false, error = "target_path not found or has no position" }
770
+ end
771
+ else
772
+ targetPos = toVector3(args.look_at)
773
+ end
774
+ local fromPos = args.position and toVector3(args.position) or (targetPos + Vector3.new(0, 10, 20))
775
+ camera.CFrame = CFrame.lookAt(fromPos, targetPos)
776
+ return { ok = true, cframe = Serialize.value(camera.CFrame) }
777
+ else
778
+ return { ok = false, error = "unknown manage_camera action: " .. tostring(action) }
779
+ end
780
+ end
781
+ ]]></string>
782
+ </Properties>
783
+ </Item>
784
+ <Item class="ModuleScript" referent="9">
785
+ <Properties>
786
+ <string name="Name">DescribeInstance</string>
787
+ <string name="Source"><![CDATA[--!strict
788
+ -- Handler for describe_instance: everything about one instance in a single call —
789
+ -- properties (optionally projected), child list, and ancestry — so an agent
790
+ -- doesn't need separate query/property/parent round-trips.
791
+
792
+ local Serialize = require(script.Parent.Parent.Serialize)
793
+
794
+ return function(args: any): any
795
+ local path = args.path
796
+ if not path then
797
+ error("path is required")
798
+ end
799
+ local inst = Serialize.resolve(path)
800
+ if not inst then
801
+ error("Path not found: " .. tostring(path))
802
+ end
803
+
804
+ local props = args.props
805
+ local hasProjection = type(props) == "table" and #props > 0
806
+
807
+ local out: any = {
808
+ path = Serialize.path(inst),
809
+ name = inst.Name,
810
+ className = inst.ClassName,
811
+ childCount = #inst:GetChildren(),
812
+ properties = Serialize.properties(inst, if hasProjection then props else nil),
813
+ }
814
+
815
+ -- Ancestry chain (name + class), root-first, excluding `game`.
816
+ local ancestors: { any } = {}
817
+ local cur: Instance? = inst.Parent
818
+ while cur and cur ~= game do
819
+ table.insert(ancestors, 1, { name = cur.Name, class = cur.ClassName })
820
+ cur = cur.Parent
821
+ end
822
+ out.ancestors = ancestors
823
+
824
+ if args.children ~= false then
825
+ local kids = inst:GetChildren()
826
+ local maxKids = args.max_children or 100
827
+ local children: { any } = {}
828
+ for i = 1, math.min(#kids, maxKids) do
829
+ local k = kids[i]
830
+ table.insert(children, { name = k.Name, class = k.ClassName, n = #k:GetChildren() })
831
+ end
832
+ out.children = children
833
+ if #kids > maxKids then
834
+ out.moreChildren = #kids - maxKids
835
+ end
836
+ end
837
+
838
+ return out
839
+ end
840
+ ]]></string>
841
+ </Properties>
842
+ </Item>
843
+ <Item class="ModuleScript" referent="10">
844
+ <Properties>
845
+ <string name="Name">Effects</string>
846
+ <string name="Source"><![CDATA[--!strict
847
+ -- Handler for manage_effects: create/configure post-processing & atmosphere
848
+ -- effects (BloomEffect, BlurEffect, ColorCorrectionEffect, DepthOfFieldEffect,
849
+ -- SunRaysEffect, Atmosphere, Sky) under Lighting by default.
850
+
851
+ local Lighting = game:GetService("Lighting")
852
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
853
+ local Serialize = require(script.Parent.Parent.Serialize)
854
+
855
+ return function(args: any): any
856
+ local action = args.action or "create"
857
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_effects")
858
+ local result: any
859
+
860
+ if action == "create" then
861
+ local parent = Serialize.resolve(args.parent or "Lighting") or Lighting
862
+ local okNew, inst = pcall(function()
863
+ return Instance.new(args.effect_type)
864
+ end)
865
+ if not okNew or not inst then
866
+ result = { ok = false, error = "invalid effect_type: " .. tostring(args.effect_type) }
867
+ else
868
+ if args.name then
869
+ inst.Name = args.name
870
+ end
871
+ local err = Serialize.applyProperties(inst, args.properties)
872
+ inst.Parent = parent
873
+ result = { ok = err == nil, path = Serialize.path(inst), error = err }
874
+ end
875
+ elseif action == "set" then
876
+ local inst = args.path and Serialize.resolve(args.path) or nil
877
+ if not inst then
878
+ result = { ok = false, error = "path not found: " .. tostring(args.path) }
879
+ else
880
+ local err = Serialize.applyProperties(inst, args.properties)
881
+ result = { ok = err == nil, path = Serialize.path(inst), error = err }
882
+ end
883
+ elseif action == "delete" then
884
+ local inst = args.path and Serialize.resolve(args.path) or nil
885
+ if not inst then
886
+ result = { ok = false, error = "path not found: " .. tostring(args.path) }
887
+ else
888
+ inst:Destroy()
889
+ result = { ok = true }
890
+ end
891
+ else
892
+ result = { ok = false, error = "unknown manage_effects action: " .. tostring(action) }
893
+ end
894
+
895
+ if recording then
896
+ ChangeHistoryService:FinishRecording(
897
+ recording,
898
+ result.ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
899
+ )
900
+ end
901
+ return result
902
+ end
903
+ ]]></string>
904
+ </Properties>
905
+ </Item>
906
+ <Item class="ModuleScript" referent="11">
907
+ <Properties>
908
+ <string name="Name">Execute</string>
909
+ <string name="Source"><![CDATA[--!strict
910
+ -- Handler for execute_luau: compile and run Luau, capturing output and returns.
911
+
912
+ local LogService = game:GetService("LogService")
913
+ local Serialize = require(script.Parent.Parent.Serialize)
914
+
915
+ return function(args: any): any
916
+ local code = args.code
917
+ local timeoutMs = args.timeout_ms or 5000
918
+
919
+ if type(code) ~= "string" or code == "" then
920
+ return { ok = false, returns = {}, output = "", error = "No code provided" }
921
+ end
922
+ if type(loadstring) ~= "function" then
923
+ return {
924
+ ok = false,
925
+ returns = {},
926
+ output = "",
927
+ error = "loadstring is unavailable in this Studio session. Enable it or run a different action.",
928
+ }
929
+ end
930
+
931
+ local chunk, compileErr = loadstring(code)
932
+ if not chunk then
933
+ return { ok = false, returns = {}, output = "", error = "Compile error: " .. tostring(compileErr) }
934
+ end
935
+
936
+ -- Capture print/warn output for the duration of execution.
937
+ local logs: { string } = {}
938
+ local conn = LogService.MessageOut:Connect(function(message: string)
939
+ table.insert(logs, message)
940
+ end)
941
+
942
+ local results: { any } = {}
943
+ local runOk = false
944
+ local runErr: string? = nil
945
+ local finished = false
946
+
947
+ local co = coroutine.create(function()
948
+ local packed = table.pack(pcall(chunk))
949
+ runOk = packed[1]
950
+ if runOk then
951
+ for i = 2, packed.n do
952
+ table.insert(results, packed[i])
953
+ end
954
+ else
955
+ runErr = tostring(packed[2])
956
+ end
957
+ finished = true
958
+ end)
959
+
960
+ coroutine.resume(co)
961
+
962
+ -- Wait up to the deadline for non-yielding or briefly-yielding code.
963
+ local deadline = os.clock() + (timeoutMs / 1000)
964
+ while not finished and os.clock() < deadline do
965
+ task.wait()
966
+ end
967
+
968
+ conn:Disconnect()
969
+
970
+ if not finished then
971
+ return {
972
+ ok = false,
973
+ returns = {},
974
+ output = table.concat(logs, "\n"),
975
+ error = ("Execution exceeded %dms (the code may be yielding)."):format(timeoutMs),
976
+ }
977
+ end
978
+
979
+ local serialized: { any } = {}
980
+ for _, v in results do
981
+ table.insert(serialized, Serialize.value(v))
982
+ end
983
+
984
+ return {
985
+ ok = runOk,
986
+ returns = serialized,
987
+ output = table.concat(logs, "\n"),
988
+ error = runErr,
989
+ }
990
+ end
991
+ ]]></string>
992
+ </Properties>
993
+ </Item>
994
+ <Item class="ModuleScript" referent="12">
995
+ <Properties>
996
+ <string name="Name">FindInstances</string>
997
+ <string name="Source"><![CDATA[--!strict
998
+ -- Handler for find_instances: a targeted search that cuts round-trips by
999
+ -- filtering on several criteria at once and returning a compact, grouped result
1000
+ -- (or paths / full) instead of dumping every match.
1001
+
1002
+ local CollectionService = game:GetService("CollectionService")
1003
+ local Serialize = require(script.Parent.Parent.Serialize)
1004
+
1005
+ -- Compare a live property value against a JSON-supplied expected value.
1006
+ -- Only primitives are compared directly; everything else goes through
1007
+ -- Serialize.value so e.g. Material "Neon" or Color [r,g,b] can match.
1008
+ local function propMatches(inst: Instance, key: string, expected: any): boolean
1009
+ local ok, val = pcall(function()
1010
+ return (inst :: any)[key]
1011
+ end)
1012
+ if not ok then
1013
+ return false
1014
+ end
1015
+ local t = typeof(val)
1016
+ if t == "boolean" or t == "number" or t == "string" then
1017
+ return val == expected
1018
+ end
1019
+ -- Structured value: compare serialized form (string compare via JSON-ish).
1020
+ local sv = Serialize.value(val)
1021
+ return Serialize.shallowEqual(sv, expected)
1022
+ end
1023
+
1024
+ return function(args: any): any
1025
+ local rootPath = args.path or "game"
1026
+ local className = args.class_name
1027
+ local nameSub = args.name
1028
+ local namePattern = args.name_pattern
1029
+ local tag = args.tag
1030
+ local matchProps = args.match_props
1031
+ local recursive = args.recursive
1032
+ if recursive == nil then
1033
+ recursive = true
1034
+ end
1035
+ local mode = args.mode or "grouped"
1036
+ local limit = args.limit or 200
1037
+ local projection = args.props
1038
+
1039
+ local root = Serialize.resolve(rootPath)
1040
+ if not root then
1041
+ error("Path not found: " .. tostring(rootPath))
1042
+ end
1043
+
1044
+ local candidates: { Instance }
1045
+ if tag then
1046
+ -- Tag search is global; intersect with the root subtree below.
1047
+ candidates = CollectionService:GetTagged(tag)
1048
+ else
1049
+ candidates = recursive and root:GetDescendants() or root:GetChildren()
1050
+ end
1051
+
1052
+ local matched: { Instance } = {}
1053
+ for _, inst in candidates do
1054
+ if tag and inst ~= root and not inst:IsDescendantOf(root) then
1055
+ continue
1056
+ end
1057
+ if className and inst.ClassName ~= className then
1058
+ continue
1059
+ end
1060
+ if nameSub and not string.find(inst.Name, nameSub, 1, true) then
1061
+ continue
1062
+ end
1063
+ if namePattern and not string.match(inst.Name, namePattern) then
1064
+ continue
1065
+ end
1066
+ if matchProps then
1067
+ local allOk = true
1068
+ for k, expected in matchProps do
1069
+ if not propMatches(inst, k, expected) then
1070
+ allOk = false
1071
+ break
1072
+ end
1073
+ end
1074
+ if not allOk then
1075
+ continue
1076
+ end
1077
+ end
1078
+ table.insert(matched, inst)
1079
+ end
1080
+
1081
+ local total = #matched
1082
+
1083
+ if mode == "count" then
1084
+ local byClass: { [string]: number } = {}
1085
+ for _, inst in matched do
1086
+ byClass[inst.ClassName] = (byClass[inst.ClassName] or 0) + 1
1087
+ end
1088
+ return { total = total, byClass = byClass }
1089
+ elseif mode == "paths" then
1090
+ local paths: { string } = {}
1091
+ for i = 1, math.min(limit, total) do
1092
+ table.insert(paths, Serialize.path(matched[i]))
1093
+ end
1094
+ return { total = total, paths = paths, truncated = total > limit }
1095
+ elseif mode == "full" then
1096
+ local instances: { any } = {}
1097
+ local hasProj = type(projection) == "table" and #projection > 0
1098
+ for i = 1, math.min(limit, total) do
1099
+ local inst = matched[i]
1100
+ table.insert(instances, {
1101
+ path = Serialize.path(inst),
1102
+ name = inst.Name,
1103
+ className = inst.ClassName,
1104
+ properties = Serialize.properties(inst, if hasProj then projection else nil),
1105
+ })
1106
+ end
1107
+ return { total = total, instances = instances, truncated = total > limit }
1108
+ else
1109
+ -- grouped (default): collapse by (parent path + class) -> count + sample.
1110
+ local order: { string } = {}
1111
+ local groups: { [string]: any } = {}
1112
+ for _, inst in matched do
1113
+ local parent = inst.Parent and Serialize.path(inst.Parent) or "?"
1114
+ local key = parent .. "\0" .. inst.ClassName
1115
+ local g = groups[key]
1116
+ if g then
1117
+ g.count += 1
1118
+ else
1119
+ g = { parent = parent, class = inst.ClassName, count = 1, sample = inst.Name }
1120
+ groups[key] = g
1121
+ table.insert(order, key)
1122
+ end
1123
+ end
1124
+ local out: { any } = {}
1125
+ for i = 1, math.min(limit, #order) do
1126
+ table.insert(out, groups[order[i]])
1127
+ end
1128
+ return { total = total, groups = out, truncated = #order > limit }
1129
+ end
1130
+ end
1131
+ ]]></string>
1132
+ </Properties>
1133
+ </Item>
1134
+ <Item class="ModuleScript" referent="13">
1135
+ <Properties>
1136
+ <string name="Name">Lighting</string>
1137
+ <string name="Source"><![CDATA[--!strict
1138
+ -- Handler for manage_lighting: read/write Lighting service properties.
1139
+
1140
+ local Lighting = game:GetService("Lighting")
1141
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
1142
+ local Serialize = require(script.Parent.Parent.Serialize)
1143
+
1144
+ local LIGHTING_PROPS = {
1145
+ "Ambient", "OutdoorAmbient", "Brightness", "ClockTime", "GeographicLatitude",
1146
+ "FogColor", "FogStart", "FogEnd", "ExposureCompensation", "EnvironmentDiffuseScale",
1147
+ "EnvironmentSpecularScale", "GlobalShadows", "ShadowSoftness", "Technology",
1148
+ }
1149
+
1150
+ return function(args: any): any
1151
+ local action = args.action or "get"
1152
+ if action == "get" then
1153
+ local props = {}
1154
+ for _, name in LIGHTING_PROPS do
1155
+ local ok, val = pcall(function()
1156
+ return (Lighting :: any)[name]
1157
+ end)
1158
+ if ok then
1159
+ props[name] = Serialize.value(val)
1160
+ end
1161
+ end
1162
+ return { ok = true, properties = props }
1163
+ elseif action == "set" then
1164
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_lighting")
1165
+ local err = Serialize.applyProperties(Lighting, args.properties)
1166
+ if recording then
1167
+ ChangeHistoryService:FinishRecording(
1168
+ recording,
1169
+ err == nil and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
1170
+ )
1171
+ end
1172
+ return { ok = err == nil, error = err }
1173
+ else
1174
+ return { ok = false, error = "unknown manage_lighting action: " .. tostring(action) }
1175
+ end
1176
+ end
1177
+ ]]></string>
1178
+ </Properties>
1179
+ </Item>
1180
+ <Item class="ModuleScript" referent="14">
1181
+ <Properties>
1182
+ <string name="Name">Logs</string>
1183
+ <string name="Source"><![CDATA[--!strict
1184
+ -- Handler for manage_logs: return recent Studio output log history.
1185
+
1186
+ local LogService = game:GetService("LogService")
1187
+
1188
+ local TYPE_NAMES = {
1189
+ [Enum.MessageType.MessageOutput] = "output",
1190
+ [Enum.MessageType.MessageInfo] = "info",
1191
+ [Enum.MessageType.MessageWarning] = "warning",
1192
+ [Enum.MessageType.MessageError] = "error",
1193
+ }
1194
+
1195
+ return function(args: any): any
1196
+ local limit = args.limit or 100
1197
+ local filter = args.message_type -- optional: "output"|"info"|"warning"|"error"
1198
+
1199
+ local history = LogService:GetLogHistory()
1200
+ local out = {}
1201
+ -- Walk newest-first.
1202
+ for i = #history, 1, -1 do
1203
+ local entry = history[i]
1204
+ local typeName = TYPE_NAMES[entry.messageType] or "output"
1205
+ if not filter or typeName == filter then
1206
+ table.insert(out, { message = entry.message, type = typeName, timestamp = entry.timestamp })
1207
+ if #out >= limit then
1208
+ break
1209
+ end
1210
+ end
1211
+ end
1212
+ return { ok = true, count = #out, logs = out }
1213
+ end
1214
+ ]]></string>
1215
+ </Properties>
1216
+ </Item>
1217
+ <Item class="ModuleScript" referent="15">
1218
+ <Properties>
1219
+ <string name="Name">Mutate</string>
1220
+ <string name="Source"><![CDATA[--!strict
1221
+ -- Handler for mutate_instances: create/edit/move/clone/delete instances.
1222
+
1223
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
1224
+ local Serialize = require(script.Parent.Parent.Serialize)
1225
+
1226
+ local PROTECTED = {
1227
+ CoreGui = true,
1228
+ CorePackages = true,
1229
+ CoreScripts = true,
1230
+ }
1231
+
1232
+ local function isProtected(inst: Instance?): boolean
1233
+ local current = inst
1234
+ while current and current ~= game do
1235
+ if PROTECTED[current.Name] then
1236
+ return true
1237
+ end
1238
+ current = current.Parent
1239
+ end
1240
+ return false
1241
+ end
1242
+
1243
+ local applyProperties = Serialize.applyProperties
1244
+
1245
+ local function doOperation(op: any): any
1246
+ local action = op.action
1247
+
1248
+ if action == "create" then
1249
+ local className = op.class_name
1250
+ if type(className) ~= "string" then
1251
+ return { ok = false, action = action, error = "create requires class_name" }
1252
+ end
1253
+ local parentPath = op.parent or op.path
1254
+ local parent = parentPath and Serialize.resolve(parentPath) or nil
1255
+ if not parent then
1256
+ return { ok = false, action = action, error = "parent not found: " .. tostring(parentPath) }
1257
+ end
1258
+ if isProtected(parent) then
1259
+ return { ok = false, action = action, error = "parent is a protected service" }
1260
+ end
1261
+ local okNew, inst = pcall(function()
1262
+ return Instance.new(className)
1263
+ end)
1264
+ if not okNew then
1265
+ return { ok = false, action = action, error = "invalid ClassName: " .. tostring(className) }
1266
+ end
1267
+ if op.name then
1268
+ inst.Name = op.name
1269
+ end
1270
+ local propErr = applyProperties(inst, op.properties)
1271
+ inst.Parent = parent
1272
+ return {
1273
+ ok = propErr == nil,
1274
+ action = action,
1275
+ resultPath = Serialize.path(inst),
1276
+ error = propErr,
1277
+ }
1278
+ end
1279
+
1280
+ -- All other actions need an existing target.
1281
+ local target = op.path and Serialize.resolve(op.path) or nil
1282
+ if not target then
1283
+ return { ok = false, action = action, error = "path not found: " .. tostring(op.path) }
1284
+ end
1285
+ if isProtected(target) then
1286
+ return { ok = false, action = action, path = op.path, error = "target is a protected service" }
1287
+ end
1288
+
1289
+ if action == "set_properties" then
1290
+ local err = applyProperties(target, op.properties)
1291
+ return { ok = err == nil, action = action, path = Serialize.path(target), error = err }
1292
+ elseif action == "rename" then
1293
+ if type(op.name) ~= "string" then
1294
+ return { ok = false, action = action, error = "rename requires name" }
1295
+ end
1296
+ target.Name = op.name
1297
+ return { ok = true, action = action, resultPath = Serialize.path(target) }
1298
+ elseif action == "reparent" then
1299
+ local newParent = op.parent and Serialize.resolve(op.parent) or nil
1300
+ if not newParent then
1301
+ return { ok = false, action = action, error = "parent not found: " .. tostring(op.parent) }
1302
+ end
1303
+ target.Parent = newParent
1304
+ return { ok = true, action = action, resultPath = Serialize.path(target) }
1305
+ elseif action == "delete" then
1306
+ local p = Serialize.path(target)
1307
+ target:Destroy()
1308
+ return { ok = true, action = action, path = p }
1309
+ elseif action == "clone" then
1310
+ local clone = target:Clone()
1311
+ if not clone then
1312
+ return { ok = false, action = action, error = "instance is not Archivable" }
1313
+ end
1314
+ if op.name then
1315
+ clone.Name = op.name
1316
+ end
1317
+ local newParent = (op.parent and Serialize.resolve(op.parent)) or target.Parent
1318
+ clone.Parent = newParent
1319
+ return { ok = true, action = action, resultPath = Serialize.path(clone) }
1320
+ else
1321
+ return { ok = false, action = action, error = "unknown action: " .. tostring(action) }
1322
+ end
1323
+ end
1324
+
1325
+ return function(args: any): any
1326
+ local operations = args.operations or {}
1327
+ local recording = ChangeHistoryService:TryBeginRecording("MCP mutate_instances")
1328
+ local results: { any } = {}
1329
+ for _, op in operations do
1330
+ local ok, res = pcall(doOperation, op)
1331
+ if ok then
1332
+ table.insert(results, res)
1333
+ else
1334
+ table.insert(results, { ok = false, action = op.action, error = tostring(res) })
1335
+ end
1336
+ end
1337
+ if recording then
1338
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
1339
+ end
1340
+ return { results = results }
1341
+ end
1342
+ ]]></string>
1343
+ </Properties>
1344
+ </Item>
1345
+ <Item class="ModuleScript" referent="16">
1346
+ <Properties>
1347
+ <string name="Name">Physics</string>
1348
+ <string name="Source"><![CDATA[--!strict
1349
+ -- Handler for manage_physics: physical properties and simple welds.
1350
+
1351
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
1352
+ local Serialize = require(script.Parent.Parent.Serialize)
1353
+
1354
+ return function(args: any): any
1355
+ local action = args.action or "set"
1356
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_physics")
1357
+ local result: any
1358
+
1359
+ if action == "set" then
1360
+ local inst = args.path and Serialize.resolve(args.path) or nil
1361
+ if not inst or not inst:IsA("BasePart") then
1362
+ result = { ok = false, error = "BasePart not found at: " .. tostring(args.path) }
1363
+ else
1364
+ local part = inst :: BasePart
1365
+ local err = Serialize.applyProperties(part, args.properties)
1366
+ -- Optional custom physical properties.
1367
+ local pp = args.physical_properties
1368
+ if not err and type(pp) == "table" then
1369
+ local okPP = pcall(function()
1370
+ part.CustomPhysicalProperties = PhysicalProperties.new(
1371
+ pp.density or 0.7,
1372
+ pp.friction or 0.3,
1373
+ pp.elasticity or 0.5,
1374
+ pp.frictionWeight or 1,
1375
+ pp.elasticityWeight or 1
1376
+ )
1377
+ end)
1378
+ if not okPP then
1379
+ err = "failed to set CustomPhysicalProperties"
1380
+ end
1381
+ end
1382
+ result = { ok = err == nil, path = Serialize.path(part), error = err }
1383
+ end
1384
+ elseif action == "weld" then
1385
+ local a = args.part0 and Serialize.resolve(args.part0) or nil
1386
+ local b = args.part1 and Serialize.resolve(args.part1) or nil
1387
+ if not a or not a:IsA("BasePart") or not b or not b:IsA("BasePart") then
1388
+ result = { ok = false, error = "part0 and part1 must both be BaseParts" }
1389
+ else
1390
+ local weld = Instance.new("WeldConstraint")
1391
+ weld.Part0 = a :: BasePart
1392
+ weld.Part1 = b :: BasePart
1393
+ weld.Parent = a
1394
+ result = { ok = true, path = Serialize.path(weld) }
1395
+ end
1396
+ else
1397
+ result = { ok = false, error = "unknown manage_physics action: " .. tostring(action) }
1398
+ end
1399
+
1400
+ if recording then
1401
+ ChangeHistoryService:FinishRecording(
1402
+ recording,
1403
+ result.ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
1404
+ )
1405
+ end
1406
+ return result
1407
+ end
1408
+ ]]></string>
1409
+ </Properties>
1410
+ </Item>
1411
+ <Item class="ModuleScript" referent="17">
1412
+ <Properties>
1413
+ <string name="Name">Properties</string>
1414
+ <string name="Source"><![CDATA[--!strict
1415
+ -- Handler for manage_properties: read/write properties on any instance.
1416
+
1417
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
1418
+ local Serialize = require(script.Parent.Parent.Serialize)
1419
+
1420
+ return function(args: any): any
1421
+ local inst = args.path and Serialize.resolve(args.path) or nil
1422
+ if not inst then
1423
+ return { ok = false, error = "path not found: " .. tostring(args.path) }
1424
+ end
1425
+ local action = args.action or "get"
1426
+
1427
+ if action == "get" then
1428
+ if type(args.names) == "table" then
1429
+ local props: { [string]: any } = {}
1430
+ for _, name in args.names do
1431
+ local ok, val = pcall(function()
1432
+ return (inst :: any)[name]
1433
+ end)
1434
+ props[name] = ok and Serialize.value(val) or nil
1435
+ end
1436
+ return { ok = true, path = Serialize.path(inst), properties = props }
1437
+ end
1438
+ return { ok = true, path = Serialize.path(inst), properties = Serialize.properties(inst) }
1439
+ elseif action == "set" then
1440
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_properties")
1441
+ local err = Serialize.applyProperties(inst, args.properties)
1442
+ if recording then
1443
+ ChangeHistoryService:FinishRecording(
1444
+ recording,
1445
+ err == nil and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
1446
+ )
1447
+ end
1448
+ return { ok = err == nil, path = Serialize.path(inst), error = err }
1449
+ else
1450
+ return { ok = false, error = "unknown manage_properties action: " .. tostring(action) }
1451
+ end
1452
+ end
1453
+ ]]></string>
1454
+ </Properties>
1455
+ </Item>
1456
+ <Item class="ModuleScript" referent="18">
1457
+ <Properties>
1458
+ <string name="Name">Query</string>
1459
+ <string name="Source"><![CDATA[--!strict
1460
+ -- Handler for query_instances: search the DataModel and serialize matches.
1461
+
1462
+ local Serialize = require(script.Parent.Parent.Serialize)
1463
+
1464
+ return function(args: any): any
1465
+ local path = args.path or "game"
1466
+ local className = args.class_name
1467
+ local nameFilter = args.name
1468
+ local recursive = args.recursive
1469
+ if recursive == nil then
1470
+ recursive = true
1471
+ end
1472
+ local props = args.props -- optional { string } projection
1473
+ local hasProjection = type(props) == "table" and #props > 0
1474
+ local includeProps = args.include_properties == true or hasProjection
1475
+ local limit = args.limit or 100
1476
+ local offset = args.offset or 0
1477
+
1478
+ local root = Serialize.resolve(path)
1479
+ if not root then
1480
+ error("Path not found: " .. tostring(path))
1481
+ end
1482
+
1483
+ local candidates: { Instance } = recursive and root:GetDescendants() or root:GetChildren()
1484
+
1485
+ local matched: { Instance } = {}
1486
+ for _, inst in candidates do
1487
+ if className and inst.ClassName ~= className then
1488
+ continue
1489
+ end
1490
+ if nameFilter and not string.find(inst.Name, nameFilter, 1, true) then
1491
+ continue
1492
+ end
1493
+ table.insert(matched, inst)
1494
+ end
1495
+
1496
+ local total = #matched
1497
+ local instances: { any } = {}
1498
+ for i = offset + 1, math.min(offset + limit, total) do
1499
+ local inst = matched[i]
1500
+ local dto: any = {
1501
+ path = Serialize.path(inst),
1502
+ name = inst.Name,
1503
+ className = inst.ClassName,
1504
+ childCount = #inst:GetChildren(),
1505
+ }
1506
+ if includeProps then
1507
+ dto.properties = Serialize.properties(inst, if hasProjection then props else nil)
1508
+ end
1509
+ table.insert(instances, dto)
1510
+ end
1511
+
1512
+ return { total = total, instances = instances }
1513
+ end
1514
+ ]]></string>
1515
+ </Properties>
1516
+ </Item>
1517
+ <Item class="ModuleScript" referent="19">
1518
+ <Properties>
1519
+ <string name="Name">SceneOverview</string>
1520
+ <string name="Source"><![CDATA[--!strict
1521
+ -- Handler for scene_overview: one-call summary of the DataModel so an agent can
1522
+ -- orient itself without many query_instances round-trips. Returns a class
1523
+ -- histogram (counting only — cheap) plus a shallow, breadth-capped tree whose
1524
+ -- duplicate leaves are collapsed (Tile x50 -> one entry with count).
1525
+
1526
+ local Serialize = require(script.Parent.Parent.Serialize)
1527
+
1528
+ -- User-facing services worth summarizing by default. The dozens of internal
1529
+ -- Studio services (StatsItem, MemStorageConnection, ...) just bloat the output,
1530
+ -- so at game root we scope to these unless include_internal is set.
1531
+ local USER_SERVICES = {
1532
+ "Workspace", "Players", "Lighting", "ReplicatedFirst", "ReplicatedStorage",
1533
+ "ServerScriptService", "ServerStorage", "StarterGui", "StarterPack",
1534
+ "StarterPlayer", "SoundService", "Teams", "TextChatService", "MaterialService",
1535
+ }
1536
+
1537
+ return function(args: any): any
1538
+ local path = args.path or "game"
1539
+ local depth = args.depth or 2
1540
+ local maxPer = args.max_per_level or 50
1541
+ local includeInternal = args.include_internal == true
1542
+
1543
+ local root = Serialize.resolve(path)
1544
+ if not root then
1545
+ error("Path not found: " .. tostring(path))
1546
+ end
1547
+
1548
+ -- Decide the set of roots to summarize. At `game` we default to user
1549
+ -- services; elsewhere (or with include_internal) we use the resolved root.
1550
+ local scopeRoots: { Instance } = {}
1551
+ local scopedToServices = false
1552
+ if root == game and not includeInternal then
1553
+ scopedToServices = true
1554
+ for _, name in USER_SERVICES do
1555
+ local ok, svc = pcall(function()
1556
+ return game:GetService(name :: any)
1557
+ end)
1558
+ if ok and svc then
1559
+ table.insert(scopeRoots, svc)
1560
+ end
1561
+ end
1562
+ else
1563
+ table.insert(scopeRoots, root)
1564
+ end
1565
+
1566
+ -- Class histogram across the chosen scope (count only, no serialization).
1567
+ local classCounts: { [string]: number } = {}
1568
+ local total = 0
1569
+ for _, sr in scopeRoots do
1570
+ for _, d in sr:GetDescendants() do
1571
+ classCounts[d.ClassName] = (classCounts[d.ClassName] or 0) + 1
1572
+ total += 1
1573
+ end
1574
+ end
1575
+
1576
+ -- Build a node for one instance, collapsing duplicate leaf children.
1577
+ local function buildNode(inst: Instance, d: number): any
1578
+ local kids = inst:GetChildren()
1579
+ local node: any = {
1580
+ name = inst.Name,
1581
+ class = inst.ClassName,
1582
+ n = #kids,
1583
+ }
1584
+ if d <= 0 or #kids == 0 then
1585
+ return node
1586
+ end
1587
+
1588
+ -- Split children: branches (have their own children) are recursed and
1589
+ -- listed individually; leaves (no children) of identical class+name are
1590
+ -- collapsed into one entry carrying a `count`.
1591
+ local branches: { any } = {}
1592
+ local leafKeys: { string } = {}
1593
+ local leafGroups: { [string]: any } = {}
1594
+ for _, k in kids do
1595
+ if #k:GetChildren() > 0 then
1596
+ table.insert(branches, k)
1597
+ else
1598
+ local key = k.ClassName .. "\0" .. k.Name
1599
+ local g = leafGroups[key]
1600
+ if g then
1601
+ g.count += 1
1602
+ else
1603
+ g = { name = k.Name, class = k.ClassName, count = 1 }
1604
+ leafGroups[key] = g
1605
+ table.insert(leafKeys, key)
1606
+ end
1607
+ end
1608
+ end
1609
+
1610
+ local children: { any } = {}
1611
+ local shown = 0
1612
+ for _, b in branches do
1613
+ if shown >= maxPer then
1614
+ break
1615
+ end
1616
+ table.insert(children, buildNode(b, d - 1))
1617
+ shown += 1
1618
+ end
1619
+ for _, key in leafKeys do
1620
+ if shown >= maxPer then
1621
+ break
1622
+ end
1623
+ local g = leafGroups[key]
1624
+ if g.count == 1 then
1625
+ g.count = nil -- singletons read like a normal node
1626
+ end
1627
+ table.insert(children, g)
1628
+ shown += 1
1629
+ end
1630
+
1631
+ node.children = children
1632
+ local groupTotal = #branches + #leafKeys
1633
+ if groupTotal > shown then
1634
+ node.more = groupTotal - shown
1635
+ end
1636
+ return node
1637
+ end
1638
+
1639
+ local tree: any
1640
+ if scopedToServices then
1641
+ -- Synthetic game node whose children are the user services.
1642
+ local children: { any } = {}
1643
+ for _, sr in scopeRoots do
1644
+ table.insert(children, buildNode(sr, depth - 1))
1645
+ end
1646
+ tree = { name = "game", class = "DataModel", n = #children, children = children, scoped = "user-services" }
1647
+ else
1648
+ tree = buildNode(root, depth)
1649
+ end
1650
+
1651
+ return { total = total, classCounts = classCounts, tree = tree }
1652
+ end
1653
+ ]]></string>
1654
+ </Properties>
1655
+ </Item>
1656
+ <Item class="ModuleScript" referent="20">
1657
+ <Properties>
1658
+ <string name="Name">Scripts</string>
1659
+ <string name="Source"><![CDATA[--!strict
1660
+ -- Handler for manage_scripts: read/write script source and create scripts.
1661
+
1662
+ local ScriptEditorService = game:GetService("ScriptEditorService")
1663
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
1664
+ local Serialize = require(script.Parent.Parent.Serialize)
1665
+
1666
+ local SCRIPT_CLASSES = { Script = true, LocalScript = true, ModuleScript = true }
1667
+
1668
+ local function readSource(inst: Instance): string?
1669
+ local ok, src = pcall(function()
1670
+ return ScriptEditorService:GetEditorSource(inst)
1671
+ end)
1672
+ if ok and type(src) == "string" then
1673
+ return src
1674
+ end
1675
+ local ok2, src2 = pcall(function()
1676
+ return (inst :: any).Source
1677
+ end)
1678
+ return ok2 and src2 or nil
1679
+ end
1680
+
1681
+ local function writeSource(inst: Instance, source: string): (boolean, string?)
1682
+ local ok = pcall(function()
1683
+ ScriptEditorService:UpdateSourceAsync(inst, function()
1684
+ return source
1685
+ end)
1686
+ end)
1687
+ if ok then
1688
+ return true, nil
1689
+ end
1690
+ local ok2, err = pcall(function()
1691
+ (inst :: any).Source = source
1692
+ end)
1693
+ return ok2, ok2 and nil or tostring(err)
1694
+ end
1695
+
1696
+ return function(args: any): any
1697
+ local action = args.action or "get_source"
1698
+
1699
+ if action == "get_source" then
1700
+ local inst = args.path and Serialize.resolve(args.path) or nil
1701
+ if not inst or not SCRIPT_CLASSES[inst.ClassName] then
1702
+ return { ok = false, error = "script not found at: " .. tostring(args.path) }
1703
+ end
1704
+ return { ok = true, path = Serialize.path(inst), className = inst.ClassName, source = readSource(inst) }
1705
+ elseif action == "set_source" then
1706
+ local inst = args.path and Serialize.resolve(args.path) or nil
1707
+ if not inst or not SCRIPT_CLASSES[inst.ClassName] then
1708
+ return { ok = false, error = "script not found at: " .. tostring(args.path) }
1709
+ end
1710
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_scripts set_source")
1711
+ local ok, err = writeSource(inst, tostring(args.source))
1712
+ if recording then
1713
+ ChangeHistoryService:FinishRecording(
1714
+ recording,
1715
+ ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
1716
+ )
1717
+ end
1718
+ return { ok = ok, path = Serialize.path(inst), error = err }
1719
+ elseif action == "create" then
1720
+ local className = args.class_name or "ModuleScript"
1721
+ if not SCRIPT_CLASSES[className] then
1722
+ return { ok = false, error = "class_name must be Script/LocalScript/ModuleScript" }
1723
+ end
1724
+ local parent = args.parent and Serialize.resolve(args.parent) or nil
1725
+ if not parent then
1726
+ return { ok = false, error = "parent not found: " .. tostring(args.parent) }
1727
+ end
1728
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_scripts create")
1729
+ local inst = Instance.new(className)
1730
+ if args.name then
1731
+ inst.Name = args.name
1732
+ end
1733
+ if args.source then
1734
+ pcall(function()
1735
+ (inst :: any).Source = tostring(args.source)
1736
+ end)
1737
+ end
1738
+ inst.Parent = parent
1739
+ if recording then
1740
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
1741
+ end
1742
+ return { ok = true, path = Serialize.path(inst) }
1743
+ else
1744
+ return { ok = false, error = "unknown manage_scripts action: " .. tostring(action) }
1745
+ end
1746
+ end
1747
+ ]]></string>
1748
+ </Properties>
1749
+ </Item>
1750
+ <Item class="ModuleScript" referent="21">
1751
+ <Properties>
1752
+ <string name="Name">Selection</string>
1753
+ <string name="Source"><![CDATA[--!strict
1754
+ -- Handler for manage_selection: read/control the Studio Explorer selection.
1755
+
1756
+ local Selection = game:GetService("Selection")
1757
+ local Serialize = require(script.Parent.Parent.Serialize)
1758
+
1759
+ local function resolveMany(paths: any): { Instance }
1760
+ local out: { Instance } = {}
1761
+ if type(paths) == "table" then
1762
+ for _, p in paths do
1763
+ local inst = Serialize.resolve(p)
1764
+ if inst then
1765
+ table.insert(out, inst)
1766
+ end
1767
+ end
1768
+ end
1769
+ return out
1770
+ end
1771
+
1772
+ local function currentPaths(): { string }
1773
+ local out = {}
1774
+ for _, inst in Selection:Get() do
1775
+ table.insert(out, Serialize.path(inst))
1776
+ end
1777
+ return out
1778
+ end
1779
+
1780
+ return function(args: any): any
1781
+ local action = args.action or "get"
1782
+
1783
+ if action == "get" then
1784
+ return { ok = true, selection = currentPaths() }
1785
+ elseif action == "set" then
1786
+ Selection:Set(resolveMany(args.paths))
1787
+ return { ok = true, selection = currentPaths() }
1788
+ elseif action == "add" then
1789
+ local current = Selection:Get()
1790
+ for _, inst in resolveMany(args.paths) do
1791
+ table.insert(current, inst)
1792
+ end
1793
+ Selection:Set(current)
1794
+ return { ok = true, selection = currentPaths() }
1795
+ elseif action == "clear" then
1796
+ Selection:Set({})
1797
+ return { ok = true, selection = {} }
1798
+ else
1799
+ return { ok = false, error = "unknown manage_selection action: " .. tostring(action) }
1800
+ end
1801
+ end
1802
+ ]]></string>
1803
+ </Properties>
1804
+ </Item>
1805
+ <Item class="ModuleScript" referent="22">
1806
+ <Properties>
1807
+ <string name="Name">Spatial</string>
1808
+ <string name="Source"><![CDATA[--!strict
1809
+ -- Handler for spatial_query: spatial searches over Workspace.
1810
+
1811
+ local Workspace = game:GetService("Workspace")
1812
+ local Serialize = require(script.Parent.Parent.Serialize)
1813
+
1814
+ local function toVector3(t: any, default: Vector3?): Vector3
1815
+ if type(t) == "table" then
1816
+ return Vector3.new(t.x or t[1] or 0, t.y or t[2] or 0, t.z or t[3] or 0)
1817
+ end
1818
+ return default or Vector3.zero
1819
+ end
1820
+
1821
+ local function serializePart(part: BasePart): any
1822
+ return {
1823
+ path = Serialize.path(part),
1824
+ name = part.Name,
1825
+ className = part.ClassName,
1826
+ position = Serialize.value(part.Position),
1827
+ size = Serialize.value(part.Size),
1828
+ }
1829
+ end
1830
+
1831
+ return function(args: any): any
1832
+ local query = args.query or "in_radius"
1833
+ local limit = args.limit or 100
1834
+
1835
+ if query == "in_box" then
1836
+ local cf = CFrame.new(toVector3(args.center))
1837
+ local size = toVector3(args.size, Vector3.new(10, 10, 10))
1838
+ local parts = Workspace:GetPartBoundsInBox(cf, size)
1839
+ local out = {}
1840
+ for i, p in parts do
1841
+ if i > limit then
1842
+ break
1843
+ end
1844
+ table.insert(out, serializePart(p))
1845
+ end
1846
+ return { query = query, count = #out, parts = out }
1847
+ elseif query == "in_radius" then
1848
+ local center = toVector3(args.center)
1849
+ local radius = args.radius or 10
1850
+ local parts = Workspace:GetPartBoundsInRadius(center, radius)
1851
+ local out = {}
1852
+ for i, p in parts do
1853
+ if i > limit then
1854
+ break
1855
+ end
1856
+ table.insert(out, serializePart(p))
1857
+ end
1858
+ return { query = query, count = #out, parts = out }
1859
+ elseif query == "raycast" then
1860
+ local origin = toVector3(args.origin)
1861
+ local direction = toVector3(args.direction, Vector3.new(0, -100, 0))
1862
+ local result = Workspace:Raycast(origin, direction)
1863
+ if not result then
1864
+ return { query = query, hit = false }
1865
+ end
1866
+ return {
1867
+ query = query,
1868
+ hit = true,
1869
+ instance = Serialize.path(result.Instance),
1870
+ position = Serialize.value(result.Position),
1871
+ normal = Serialize.value(result.Normal),
1872
+ distance = result.Distance,
1873
+ material = result.Material.Name,
1874
+ }
1875
+ elseif query == "nearest" then
1876
+ local point = toVector3(args.point)
1877
+ local className = args.class_name
1878
+ local maxDistance = args.max_distance or math.huge
1879
+ local best: BasePart? = nil
1880
+ local bestDist = maxDistance
1881
+ for _, inst in Workspace:GetDescendants() do
1882
+ if inst:IsA("BasePart") and (not className or inst.ClassName == className) then
1883
+ local dist = (inst.Position - point).Magnitude
1884
+ if dist < bestDist then
1885
+ bestDist = dist
1886
+ best = inst
1887
+ end
1888
+ end
1889
+ end
1890
+ if not best then
1891
+ return { query = query, found = false }
1892
+ end
1893
+ return {
1894
+ query = query,
1895
+ found = true,
1896
+ instance = Serialize.path(best),
1897
+ distance = bestDist,
1898
+ position = Serialize.value(best.Position),
1899
+ }
1900
+ else
1901
+ error("unknown spatial query: " .. tostring(query))
1902
+ end
1903
+ end
1904
+ ]]></string>
1905
+ </Properties>
1906
+ </Item>
1907
+ <Item class="ModuleScript" referent="23">
1908
+ <Properties>
1909
+ <string name="Name">Studio</string>
1910
+ <string name="Source"><![CDATA[--!strict
1911
+ -- Handler for manage_studio: read Studio environment info and theme.
1912
+
1913
+ local RunService = game:GetService("RunService")
1914
+
1915
+ return function(args: any): any
1916
+ local action = args.action or "info"
1917
+
1918
+ if action == "info" then
1919
+ local themeName: string? = nil
1920
+ pcall(function()
1921
+ themeName = settings().Studio.Theme.Name
1922
+ end)
1923
+ return {
1924
+ ok = true,
1925
+ studioVersion = version(),
1926
+ theme = themeName,
1927
+ isRunning = RunService:IsRunning(),
1928
+ isEdit = RunService:IsEdit(),
1929
+ placeId = game.PlaceId,
1930
+ gameId = game.GameId,
1931
+ }
1932
+ else
1933
+ return { ok = false, error = "unknown manage_studio action: " .. tostring(action) }
1934
+ end
1935
+ end
1936
+ ]]></string>
1937
+ </Properties>
1938
+ </Item>
1939
+ <Item class="ModuleScript" referent="24">
1940
+ <Properties>
1941
+ <string name="Name">SyncApply</string>
1942
+ <string name="Source"><![CDATA[--!strict
1943
+ -- Handler for sync_apply: apply a single filesystem->Studio change.
1944
+ -- Currently supports setting a script's source.
1945
+
1946
+ local ScriptEditorService = game:GetService("ScriptEditorService")
1947
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
1948
+ local Serialize = require(script.Parent.Parent.Serialize)
1949
+
1950
+ local function setSource(inst: Instance, source: string): (boolean, string?)
1951
+ -- Prefer the editor service so open documents stay consistent.
1952
+ local ok = pcall(function()
1953
+ ScriptEditorService:UpdateSourceAsync(inst, function()
1954
+ return source
1955
+ end)
1956
+ end)
1957
+ if ok then
1958
+ return true, nil
1959
+ end
1960
+ -- Fall back to a direct assignment.
1961
+ local ok2, err = pcall(function()
1962
+ (inst :: any).Source = source
1963
+ end)
1964
+ if ok2 then
1965
+ return true, nil
1966
+ end
1967
+ return false, tostring(err)
1968
+ end
1969
+
1970
+ return function(args: any): any
1971
+ local action = args.action or "set_source"
1972
+ local inst = args.path and Serialize.resolve(args.path) or nil
1973
+ if not inst then
1974
+ return { ok = false, error = "path not found: " .. tostring(args.path) }
1975
+ end
1976
+
1977
+ if action == "set_source" then
1978
+ local recording = ChangeHistoryService:TryBeginRecording("MCP sync set_source")
1979
+ local ok, err = setSource(inst, tostring(args.source))
1980
+ if recording then
1981
+ ChangeHistoryService:FinishRecording(
1982
+ recording,
1983
+ ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
1984
+ )
1985
+ end
1986
+ return { ok = ok, path = Serialize.path(inst), error = err }
1987
+ end
1988
+
1989
+ return { ok = false, error = "unknown sync_apply action: " .. tostring(action) }
1990
+ end
1991
+ ]]></string>
1992
+ </Properties>
1993
+ </Item>
1994
+ <Item class="ModuleScript" referent="25">
1995
+ <Properties>
1996
+ <string name="Name">SyncSnapshot</string>
1997
+ <string name="Source"><![CDATA[--!strict
1998
+ -- Handler for sync_snapshot: serialize whole subtrees (including script Source)
1999
+ -- into a nested tree the server can mirror to disk.
2000
+
2001
+ local ScriptEditorService = game:GetService("ScriptEditorService")
2002
+ local Serialize = require(script.Parent.Parent.Serialize)
2003
+
2004
+ local SCRIPT_CLASSES = {
2005
+ Script = true,
2006
+ LocalScript = true,
2007
+ ModuleScript = true,
2008
+ }
2009
+
2010
+ local function isValueObject(className: string): boolean
2011
+ return string.sub(className, -5) == "Value"
2012
+ end
2013
+
2014
+ local function readSource(inst: Instance): string?
2015
+ local ok, src = pcall(function()
2016
+ return ScriptEditorService:GetEditorSource(inst)
2017
+ end)
2018
+ if ok and type(src) == "string" then
2019
+ return src
2020
+ end
2021
+ local ok2, src2 = pcall(function()
2022
+ return (inst :: any).Source
2023
+ end)
2024
+ if ok2 and type(src2) == "string" then
2025
+ return src2
2026
+ end
2027
+ return nil
2028
+ end
2029
+
2030
+ local function serializeNode(inst: Instance): any
2031
+ local node: any = {
2032
+ name = inst.Name,
2033
+ className = inst.ClassName,
2034
+ properties = Serialize.properties(inst),
2035
+ }
2036
+ if SCRIPT_CLASSES[inst.ClassName] then
2037
+ node.source = readSource(inst)
2038
+ elseif isValueObject(inst.ClassName) then
2039
+ local ok, val = pcall(function()
2040
+ return (inst :: any).Value
2041
+ end)
2042
+ if ok then
2043
+ node.value = Serialize.value(val)
2044
+ end
2045
+ end
2046
+ local children = {}
2047
+ for _, child in inst:GetChildren() do
2048
+ table.insert(children, serializeNode(child))
2049
+ end
2050
+ node.children = children
2051
+ return node
2052
+ end
2053
+
2054
+ return function(args: any): any
2055
+ local roots = args.roots or {}
2056
+ local result: { any } = {}
2057
+ for _, rootPath in roots do
2058
+ local inst = Serialize.resolve(rootPath)
2059
+ if inst then
2060
+ table.insert(result, {
2061
+ path = Serialize.path(inst),
2062
+ tree = serializeNode(inst),
2063
+ })
2064
+ end
2065
+ end
2066
+ return { roots = result }
2067
+ end
2068
+ ]]></string>
2069
+ </Properties>
2070
+ </Item>
2071
+ <Item class="ModuleScript" referent="26">
2072
+ <Properties>
2073
+ <string name="Name">SyncWatch</string>
2074
+ <string name="Source"><![CDATA[--!strict
2075
+ -- Handler for sync_watch: emit Studio->server change events so the server can
2076
+ -- mirror live edits to disk. action = "watch" | "unwatch".
2077
+
2078
+ local ScriptEditorService = game:GetService("ScriptEditorService")
2079
+ local Serialize = require(script.Parent.Parent.Serialize)
2080
+ local Bridge = require(script.Parent.Parent.Bridge)
2081
+
2082
+ local connections: { RBXScriptConnection } = {}
2083
+ local watchedRoots: { Instance } = {}
2084
+
2085
+ local function isUnderRoots(inst: Instance): boolean
2086
+ for _, root in watchedRoots do
2087
+ if inst == root or inst:IsDescendantOf(root) then
2088
+ return true
2089
+ end
2090
+ end
2091
+ return false
2092
+ end
2093
+
2094
+ local function clear()
2095
+ for _, conn in connections do
2096
+ conn:Disconnect()
2097
+ end
2098
+ table.clear(connections)
2099
+ table.clear(watchedRoots)
2100
+ end
2101
+
2102
+ local function watch(roots: { string })
2103
+ clear()
2104
+ for _, rootPath in roots do
2105
+ local inst = Serialize.resolve(rootPath)
2106
+ if inst then
2107
+ table.insert(watchedRoots, inst)
2108
+ table.insert(
2109
+ connections,
2110
+ inst.DescendantAdded:Connect(function(desc: Instance)
2111
+ Bridge.postEvent({ kind = "added", path = Serialize.path(desc), data = { className = desc.ClassName } })
2112
+ end)
2113
+ )
2114
+ table.insert(
2115
+ connections,
2116
+ inst.DescendantRemoving:Connect(function(desc: Instance)
2117
+ Bridge.postEvent({ kind = "removing", path = Serialize.path(desc) })
2118
+ end)
2119
+ )
2120
+ end
2121
+ end
2122
+
2123
+ -- Live script edits (fires as the user types in the Script Editor).
2124
+ table.insert(
2125
+ connections,
2126
+ ScriptEditorService.TextDocumentDidChange:Connect(function(document: ScriptDocument)
2127
+ local ok, scriptInst = pcall(function()
2128
+ return document:GetScript()
2129
+ end)
2130
+ if not ok or not scriptInst or not isUnderRoots(scriptInst) then
2131
+ return
2132
+ end
2133
+ local text = document:GetText()
2134
+ Bridge.postEvent({
2135
+ kind = "source_changed",
2136
+ path = Serialize.path(scriptInst),
2137
+ data = { className = scriptInst.ClassName, source = text },
2138
+ })
2139
+ end)
2140
+ )
2141
+ end
2142
+
2143
+ return function(args: any): any
2144
+ local action = args.action or "watch"
2145
+ if action == "unwatch" then
2146
+ clear()
2147
+ return { watching = false }
2148
+ end
2149
+ watch(args.roots or {})
2150
+ return { watching = true, roots = #watchedRoots }
2151
+ end
2152
+ ]]></string>
2153
+ </Properties>
2154
+ </Item>
2155
+ <Item class="ModuleScript" referent="27">
2156
+ <Properties>
2157
+ <string name="Name">System</string>
2158
+ <string name="Source"><![CDATA[--!strict
2159
+ -- Handler for system_info: report basic Studio session details.
2160
+
2161
+ local RunService = game:GetService("RunService")
2162
+
2163
+ return function(_args: any): any
2164
+ local placeName: string? = nil
2165
+ pcall(function()
2166
+ placeName = game.Name
2167
+ end)
2168
+ return {
2169
+ placeId = game.PlaceId,
2170
+ placeName = placeName,
2171
+ studioVersion = version(),
2172
+ isRunning = RunService:IsRunning(),
2173
+ }
2174
+ end
2175
+ ]]></string>
2176
+ </Properties>
2177
+ </Item>
2178
+ <Item class="ModuleScript" referent="28">
2179
+ <Properties>
2180
+ <string name="Name">Terrain</string>
2181
+ <string name="Source"><![CDATA[--!strict
2182
+ -- Handler for manage_terrain: generate and edit Workspace.Terrain.
2183
+
2184
+ local Workspace = game:GetService("Workspace")
2185
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
2186
+
2187
+ local function toVector3(t: any, default: Vector3?): Vector3
2188
+ if type(t) == "table" then
2189
+ return Vector3.new(t.x or t[1] or 0, t.y or t[2] or 0, t.z or t[3] or 0)
2190
+ end
2191
+ return default or Vector3.zero
2192
+ end
2193
+
2194
+ local function material(name: any): Enum.Material
2195
+ if type(name) == "string" then
2196
+ for _, m in Enum.Material:GetEnumItems() do
2197
+ if m.Name == name then
2198
+ return m
2199
+ end
2200
+ end
2201
+ end
2202
+ return Enum.Material.Grass
2203
+ end
2204
+
2205
+ return function(args: any): any
2206
+ local terrain = Workspace.Terrain
2207
+ local action = args.action or "fill_block"
2208
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_terrain")
2209
+
2210
+ local result: any
2211
+ if action == "fill_block" then
2212
+ local cf = CFrame.new(toVector3(args.position))
2213
+ local size = toVector3(args.size, Vector3.new(16, 16, 16))
2214
+ terrain:FillBlock(cf, size, material(args.material))
2215
+ result = { ok = true, action = action }
2216
+ elseif action == "fill_ball" then
2217
+ terrain:FillBall(toVector3(args.center), args.radius or 8, material(args.material))
2218
+ result = { ok = true, action = action }
2219
+ elseif action == "fill_region" then
2220
+ local min = toVector3(args.min)
2221
+ local max = toVector3(args.max)
2222
+ local region = Region3.new(min, max):ExpandToGrid(4)
2223
+ terrain:FillRegion(region, 4, material(args.material))
2224
+ result = { ok = true, action = action }
2225
+ elseif action == "clear" then
2226
+ terrain:Clear()
2227
+ result = { ok = true, action = action }
2228
+ else
2229
+ result = { ok = false, action = action, error = "unknown terrain action" }
2230
+ end
2231
+
2232
+ if recording then
2233
+ ChangeHistoryService:FinishRecording(
2234
+ recording,
2235
+ result.ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
2236
+ )
2237
+ end
2238
+ return result
2239
+ end
2240
+ ]]></string>
2241
+ </Properties>
2242
+ </Item>
2243
+ <Item class="ModuleScript" referent="29">
2244
+ <Properties>
2245
+ <string name="Name">Tween</string>
2246
+ <string name="Source"><![CDATA[--!strict
2247
+ -- Handler for manage_tween: animate an instance's properties with TweenService.
2248
+
2249
+ local TweenService = game:GetService("TweenService")
2250
+ local Serialize = require(script.Parent.Parent.Serialize)
2251
+
2252
+ local function enumFrom(enumType: Enum, name: any, default: EnumItem): EnumItem
2253
+ if type(name) == "string" then
2254
+ for _, item in enumType:GetEnumItems() do
2255
+ if item.Name == name then
2256
+ return item
2257
+ end
2258
+ end
2259
+ end
2260
+ return default
2261
+ end
2262
+
2263
+ return function(args: any): any
2264
+ local inst = args.path and Serialize.resolve(args.path) or nil
2265
+ if not inst then
2266
+ return { ok = false, error = "path not found: " .. tostring(args.path) }
2267
+ end
2268
+ if type(args.properties) ~= "table" then
2269
+ return { ok = false, error = "tween requires a 'properties' goal map" }
2270
+ end
2271
+
2272
+ -- Coerce each goal value to the property's current type.
2273
+ local goal: { [string]: any } = {}
2274
+ for key, raw in pairs(args.properties) do
2275
+ local ok, coerced = Serialize.coerce(inst, key, raw)
2276
+ if not ok then
2277
+ return { ok = false, error = ("goal '%s': %s"):format(key, tostring(coerced)) }
2278
+ end
2279
+ goal[key] = coerced
2280
+ end
2281
+
2282
+ local info = TweenInfo.new(
2283
+ args.duration or 1,
2284
+ enumFrom(Enum.EasingStyle, args.easing_style, Enum.EasingStyle.Quad) :: Enum.EasingStyle,
2285
+ enumFrom(Enum.EasingDirection, args.easing_direction, Enum.EasingDirection.Out) :: Enum.EasingDirection,
2286
+ args.repeat_count or 0,
2287
+ args.reverses == true,
2288
+ args.delay_time or 0
2289
+ )
2290
+
2291
+ local okCreate, tween = pcall(function()
2292
+ return TweenService:Create(inst, info, goal)
2293
+ end)
2294
+ if not okCreate or not tween then
2295
+ return { ok = false, error = "failed to create tween: " .. tostring(tween) }
2296
+ end
2297
+ tween:Play()
2298
+ return { ok = true, path = Serialize.path(inst), duration = args.duration or 1 }
2299
+ end
2300
+ ]]></string>
2301
+ </Properties>
2302
+ </Item>
2303
+ <Item class="ModuleScript" referent="30">
2304
+ <Properties>
2305
+ <string name="Name">UI</string>
2306
+ <string name="Source"><![CDATA[--!strict
2307
+ -- Handler for manage_ui: build and edit GUI hierarchies.
2308
+
2309
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
2310
+ local Serialize = require(script.Parent.Parent.Serialize)
2311
+
2312
+ -- Recursively build a UI node spec: { className, name?, properties?, children? }
2313
+ local function build(spec: any, parent: Instance): (Instance?, string?)
2314
+ local okNew, inst = pcall(function()
2315
+ return Instance.new(spec.className)
2316
+ end)
2317
+ if not okNew or not inst then
2318
+ return nil, "invalid ClassName: " .. tostring(spec.className)
2319
+ end
2320
+ if spec.name then
2321
+ inst.Name = spec.name
2322
+ end
2323
+ local propErr = Serialize.applyProperties(inst, spec.properties)
2324
+ if propErr then
2325
+ inst:Destroy()
2326
+ return nil, propErr
2327
+ end
2328
+ for _, childSpec in spec.children or {} do
2329
+ local _, childErr = build(childSpec, inst)
2330
+ if childErr then
2331
+ inst:Destroy()
2332
+ return nil, childErr
2333
+ end
2334
+ end
2335
+ inst.Parent = parent
2336
+ return inst, nil
2337
+ end
2338
+
2339
+ return function(args: any): any
2340
+ local action = args.action or "create"
2341
+ local recording = ChangeHistoryService:TryBeginRecording("MCP manage_ui")
2342
+ local result: any
2343
+
2344
+ if action == "create" then
2345
+ local parentPath = args.parent or "StarterGui"
2346
+ local parent = Serialize.resolve(parentPath)
2347
+ if not parent then
2348
+ result = { ok = false, error = "parent not found: " .. tostring(parentPath) }
2349
+ elseif type(args.tree) ~= "table" then
2350
+ result = { ok = false, error = "create requires a 'tree' spec" }
2351
+ else
2352
+ -- Optional clean rebuild: drop an existing same-named child first.
2353
+ if args.replace and args.tree.name then
2354
+ local existing = parent:FindFirstChild(args.tree.name)
2355
+ if existing then
2356
+ existing:Destroy()
2357
+ end
2358
+ end
2359
+ local inst, err = build(args.tree, parent)
2360
+ result = inst and { ok = true, rootPath = Serialize.path(inst) }
2361
+ or { ok = false, error = err }
2362
+ end
2363
+ elseif action == "set" then
2364
+ local inst = args.path and Serialize.resolve(args.path) or nil
2365
+ if not inst then
2366
+ result = { ok = false, error = "path not found: " .. tostring(args.path) }
2367
+ else
2368
+ local err = Serialize.applyProperties(inst, args.properties)
2369
+ result = { ok = err == nil, path = Serialize.path(inst), error = err }
2370
+ end
2371
+ elseif action == "delete" then
2372
+ local inst = args.path and Serialize.resolve(args.path) or nil
2373
+ if not inst then
2374
+ result = { ok = false, error = "path not found: " .. tostring(args.path) }
2375
+ else
2376
+ local p = Serialize.path(inst)
2377
+ inst:Destroy()
2378
+ result = { ok = true, path = p }
2379
+ end
2380
+ else
2381
+ result = { ok = false, error = "unknown manage_ui action: " .. tostring(action) }
2382
+ end
2383
+
2384
+ if recording then
2385
+ ChangeHistoryService:FinishRecording(
2386
+ recording,
2387
+ result.ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel
2388
+ )
2389
+ end
2390
+ return result
2391
+ end
2392
+ ]]></string>
2393
+ </Properties>
2394
+ </Item>
2395
+ <Item class="ModuleScript" referent="31">
2396
+ <Properties>
2397
+ <string name="Name">UIPreview</string>
2398
+ <string name="Source"><![CDATA[--!strict
2399
+ -- Handler for ui_preview: render a GUI full-screen in EDIT mode on a solid
2400
+ -- backdrop (via CoreGui) so capture_studio gets a clean, isolated shot to
2401
+ -- compare against a mockup — without the 3D viewport behind it. Plugins are
2402
+ -- allowed to parent a ScreenGui into CoreGui, and it renders over the viewport.
2403
+ -- action = "show" (clone target onto backdrop) | "hide" (remove preview)
2404
+
2405
+ local CoreGui = game:GetService("CoreGui")
2406
+ local Serialize = require(script.Parent.Parent.Serialize)
2407
+
2408
+ local PREVIEW_NAME = "MCP_UIPreview"
2409
+
2410
+ local function clearPreview()
2411
+ local existing = CoreGui:FindFirstChild(PREVIEW_NAME)
2412
+ if existing then
2413
+ existing:Destroy()
2414
+ end
2415
+ end
2416
+
2417
+ return function(args: any): any
2418
+ local action = args.action or "show"
2419
+ if action == "hide" then
2420
+ clearPreview()
2421
+ return { ok = true, showing = false }
2422
+ end
2423
+
2424
+ local path = args.path
2425
+ if not path then
2426
+ return { ok = false, error = "ui_preview 'show' needs a path to a GUI (ScreenGui/Frame/...)" }
2427
+ end
2428
+ local target = Serialize.resolve(path)
2429
+ if not target then
2430
+ return { ok = false, error = "path not found: " .. tostring(path) }
2431
+ end
2432
+ if not (target:IsA("LayerCollector") or target:IsA("GuiObject")) then
2433
+ return { ok = false, error = "target is not a GUI (need ScreenGui/Frame/TextLabel/...)" }
2434
+ end
2435
+
2436
+ clearPreview()
2437
+
2438
+ local holder = Instance.new("ScreenGui")
2439
+ holder.Name = PREVIEW_NAME
2440
+ holder.ResetOnSpawn = false
2441
+ holder.IgnoreGuiInset = true
2442
+ holder.DisplayOrder = 1000000 -- sit above other UI
2443
+ holder.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
2444
+
2445
+ local bg = Instance.new("Frame")
2446
+ bg.Name = "Backdrop"
2447
+ bg.Size = UDim2.fromScale(1, 1)
2448
+ bg.BorderSizePixel = 0
2449
+ local c = args.background -- optional [r,g,b] 0-1
2450
+ if type(c) == "table" then
2451
+ bg.BackgroundColor3 = Color3.new(tonumber(c[1]) or 0.12, tonumber(c[2]) or 0.12, tonumber(c[3]) or 0.14)
2452
+ else
2453
+ bg.BackgroundColor3 = Color3.fromRGB(30, 30, 36)
2454
+ end
2455
+ bg.Parent = holder
2456
+
2457
+ -- Clone the visual content onto the backdrop (originals stay untouched).
2458
+ local cloned = 0
2459
+ if target:IsA("LayerCollector") then
2460
+ for _, child in target:GetChildren() do
2461
+ if child:IsA("GuiObject") then
2462
+ local copy = child:Clone()
2463
+ copy.Parent = bg
2464
+ cloned += 1
2465
+ end
2466
+ end
2467
+ else
2468
+ local copy = target:Clone()
2469
+ copy.Parent = bg
2470
+ cloned += 1
2471
+ end
2472
+
2473
+ holder.Parent = CoreGui
2474
+ return { ok = true, showing = true, name = PREVIEW_NAME, cloned = cloned }
2475
+ end
2476
+ ]]></string>
2477
+ </Properties>
2478
+ </Item>
2479
+ <Item class="ModuleScript" referent="32">
2480
+ <Properties>
2481
+ <string name="Name">WorkspaceState</string>
2482
+ <string name="Source"><![CDATA[--!strict
2483
+ -- Handler for workspace_state: a high-level read-only snapshot of the session.
2484
+
2485
+ local Workspace = game:GetService("Workspace")
2486
+ local Selection = game:GetService("Selection")
2487
+ local RunService = game:GetService("RunService")
2488
+ local Serialize = require(script.Parent.Parent.Serialize)
2489
+
2490
+ local SUMMARY_SERVICES = {
2491
+ "Workspace", "ServerScriptService", "ServerStorage", "ReplicatedStorage",
2492
+ "ReplicatedFirst", "StarterGui", "StarterPack", "StarterPlayer", "Lighting",
2493
+ }
2494
+
2495
+ return function(_args: any): any
2496
+ local services = {}
2497
+ for _, name in SUMMARY_SERVICES do
2498
+ local ok, svc = pcall(function()
2499
+ return game:GetService(name :: any)
2500
+ end)
2501
+ if ok and svc then
2502
+ services[name] = #svc:GetChildren()
2503
+ end
2504
+ end
2505
+
2506
+ local camera = Workspace.CurrentCamera
2507
+ local selectionCount = #Selection:Get()
2508
+
2509
+ return {
2510
+ ok = true,
2511
+ placeId = game.PlaceId,
2512
+ placeName = game.Name,
2513
+ isRunning = RunService:IsRunning(),
2514
+ gravity = Workspace.Gravity,
2515
+ childCounts = services,
2516
+ selectionCount = selectionCount,
2517
+ camera = camera and {
2518
+ position = Serialize.value(camera.CFrame.Position),
2519
+ fieldOfView = camera.FieldOfView,
2520
+ } or nil,
2521
+ }
2522
+ end
2523
+ ]]></string>
2524
+ </Properties>
2525
+ </Item>
2526
+ </Item>
2527
+ <Item class="ModuleScript" referent="33">
2528
+ <Properties>
2529
+ <string name="Name">Serialize</string>
2530
+ <string name="Source"><![CDATA[--!strict
2531
+ -- Serialize: convert between Roblox instances/datatypes and JSON-safe tables,
2532
+ -- and resolve dotted instance paths.
2533
+
2534
+ local Serialize = {}
2535
+
2536
+ -- Common properties we attempt to read when include_properties is requested.
2537
+ local CANDIDATE_PROPS = {
2538
+ "Anchored", "CanCollide", "Transparency", "Reflectance",
2539
+ "Size", "Position", "Orientation", "CFrame",
2540
+ "Color", "BrickColor", "Material",
2541
+ "Value", "Text", "Enabled", "Visible",
2542
+ "Locked", "CastShadow", "Massless",
2543
+ }
2544
+
2545
+ -- Round to ~4 decimals to drop float noise (0.14901961... -> 0.149,
2546
+ -- 6.199999809 -> 6.2). Whole numbers stay exact. Big token saver: a float
2547
+ -- component shrinks from ~18 chars to ~5 with no meaningful precision loss.
2548
+ local function r(n: number): number
2549
+ if n ~= n or n == math.huge or n == -math.huge then
2550
+ return n -- leave NaN/inf as-is
2551
+ end
2552
+ return math.floor(n * 10000 + 0.5) / 10000
2553
+ end
2554
+
2555
+ -- Convert a Roblox value into something JSON-encodable.
2556
+ function Serialize.value(v: any): any
2557
+ local t = typeof(v)
2558
+ if t == "number" then
2559
+ return r(v)
2560
+ elseif t == "nil" or t == "boolean" or t == "string" then
2561
+ return v
2562
+ elseif t == "Vector3" then
2563
+ return { r(v.X), r(v.Y), r(v.Z) } -- compact [x,y,z]; coerce accepts this back
2564
+ elseif t == "Vector2" then
2565
+ return { r(v.X), r(v.Y) } -- [x,y]
2566
+ elseif t == "CFrame" then
2567
+ local c = { v:GetComponents() }
2568
+ for i, n in c do
2569
+ c[i] = r(n)
2570
+ end
2571
+ return c
2572
+ elseif t == "Color3" then
2573
+ return { r(v.R), r(v.G), r(v.B) } -- [r,g,b]
2574
+ elseif t == "BrickColor" then
2575
+ return v.Name
2576
+ elseif t == "UDim" then
2577
+ return { scale = r(v.Scale), offset = v.Offset }
2578
+ elseif t == "UDim2" then
2579
+ return { { r(v.X.Scale), v.X.Offset }, { r(v.Y.Scale), v.Y.Offset } }
2580
+ elseif t == "EnumItem" then
2581
+ return v.Name
2582
+ elseif t == "Instance" then
2583
+ return Serialize.path(v)
2584
+ else
2585
+ return tostring(v)
2586
+ end
2587
+ end
2588
+
2589
+ -- Shallow structural equality for comparing a serialized value to an expected
2590
+ -- JSON value (used by find_instances match_props). Handles primitives and
2591
+ -- flat arrays like [r,g,b] / [x,y,z].
2592
+ function Serialize.shallowEqual(a: any, b: any): boolean
2593
+ if type(a) ~= type(b) then
2594
+ return false
2595
+ end
2596
+ if type(a) ~= "table" then
2597
+ return a == b
2598
+ end
2599
+ if #a ~= #b then
2600
+ return false
2601
+ end
2602
+ for i = 1, #a do
2603
+ if a[i] ~= b[i] then
2604
+ return false
2605
+ end
2606
+ end
2607
+ return true
2608
+ end
2609
+
2610
+ -- Build the full dotted path of an instance, rooted at "game".
2611
+ function Serialize.path(inst: Instance): string
2612
+ local segments = {}
2613
+ local current: Instance? = inst
2614
+ while current and current ~= game do
2615
+ table.insert(segments, 1, current.Name)
2616
+ current = current.Parent
2617
+ end
2618
+ return "game." .. table.concat(segments, ".")
2619
+ end
2620
+
2621
+ -- Resolve a dotted path to an instance, or nil. Accepts an optional leading "game".
2622
+ function Serialize.resolve(path: string): Instance?
2623
+ local segments = string.split(path, ".")
2624
+ local current: Instance = game
2625
+ local startIndex = 1
2626
+ if segments[1] == "game" then
2627
+ startIndex = 2
2628
+ end
2629
+ for i = startIndex, #segments do
2630
+ local name = segments[i]
2631
+ local nextInst: Instance?
2632
+ -- Try service first (handles Workspace, ServerStorage, etc.).
2633
+ local ok, svc = pcall(function()
2634
+ return game:GetService(name :: any)
2635
+ end)
2636
+ if current == game and ok and svc then
2637
+ nextInst = svc
2638
+ else
2639
+ nextInst = current:FindFirstChild(name)
2640
+ end
2641
+ if not nextInst then
2642
+ return nil
2643
+ end
2644
+ current = nextInst
2645
+ end
2646
+ return current
2647
+ end
2648
+
2649
+ -- Read readable properties from an instance. Pass `propList` to read only those
2650
+ -- (field projection — fewer tokens); otherwise a curated common set is read.
2651
+ function Serialize.properties(inst: Instance, propList: { string }?): { [string]: any }
2652
+ local names = (propList and #propList > 0) and propList or CANDIDATE_PROPS
2653
+ local out: { [string]: any } = {}
2654
+ for _, name in names do
2655
+ local ok, val = pcall(function()
2656
+ return (inst :: any)[name]
2657
+ end)
2658
+ if ok and val ~= nil then
2659
+ out[name] = Serialize.value(val)
2660
+ end
2661
+ end
2662
+ return out
2663
+ end
2664
+
2665
+ -- Coerce a JSON value into the right Roblox type for setting `prop` on `inst`.
2666
+ -- Uses the current property value's type as the target.
2667
+ function Serialize.coerce(inst: Instance, prop: string, raw: any): (boolean, any)
2668
+ local ok, current = pcall(function()
2669
+ return (inst :: any)[prop]
2670
+ end)
2671
+ if not ok then
2672
+ -- Property doesn't exist or isn't readable; pass raw through.
2673
+ return true, raw
2674
+ end
2675
+ local t = typeof(current)
2676
+ if t == "Vector3" and type(raw) == "table" then
2677
+ local x = raw.x or raw[1] or 0
2678
+ local y = raw.y or raw[2] or 0
2679
+ local z = raw.z or raw[3] or 0
2680
+ return true, Vector3.new(x, y, z)
2681
+ elseif t == "Color3" and type(raw) == "table" then
2682
+ local r = raw.r or raw[1] or 0
2683
+ local g = raw.g or raw[2] or 0
2684
+ local b = raw.b or raw[3] or 0
2685
+ return true, Color3.new(r, g, b)
2686
+ elseif t == "BrickColor" and type(raw) == "string" then
2687
+ return true, BrickColor.new(raw)
2688
+ elseif t == "EnumItem" and type(raw) == "string" then
2689
+ -- Match against the current enum's items.
2690
+ local enumType = (current :: EnumItem).EnumType
2691
+ for _, item in enumType:GetEnumItems() do
2692
+ if item.Name == raw then
2693
+ return true, item
2694
+ end
2695
+ end
2696
+ return false, ("'%s' is not a valid %s"):format(raw, tostring(enumType))
2697
+ elseif t == "UDim2" and type(raw) == "table" then
2698
+ local xs, xo, ys, yo
2699
+ if type(raw[1]) == "table" then
2700
+ xs, xo = raw[1][1] or 0, raw[1][2] or 0
2701
+ ys, yo = raw[2][1] or 0, raw[2][2] or 0
2702
+ else
2703
+ xs, xo, ys, yo = raw[1] or 0, raw[2] or 0, raw[3] or 0, raw[4] or 0
2704
+ end
2705
+ return true, UDim2.new(xs, xo, ys, yo)
2706
+ elseif t == "UDim" and type(raw) == "table" then
2707
+ return true, UDim.new(raw.scale or raw[1] or 0, raw.offset or raw[2] or 0)
2708
+ elseif t == "Vector2" and type(raw) == "table" then
2709
+ return true, Vector2.new(raw.x or raw[1] or 0, raw.y or raw[2] or 0)
2710
+ elseif t == "CFrame" and type(raw) == "table" and type(raw[1]) == "number" then
2711
+ local ok2, cf = pcall(function()
2712
+ return CFrame.new(table.unpack(raw, 1, #raw))
2713
+ end)
2714
+ if ok2 then
2715
+ return true, cf
2716
+ end
2717
+ return false, "invalid CFrame components"
2718
+ elseif t == "number" and type(raw) == "number" then
2719
+ return true, raw
2720
+ elseif t == "boolean" and type(raw) == "boolean" then
2721
+ return true, raw
2722
+ elseif t == "string" and type(raw) == "string" then
2723
+ return true, raw
2724
+ else
2725
+ -- Best effort: pass raw through and let the assignment validate.
2726
+ return true, raw
2727
+ end
2728
+ end
2729
+
2730
+ -- Apply a property map to an instance. Returns an error string, or nil on success.
2731
+ function Serialize.applyProperties(inst: Instance, properties: any): string?
2732
+ if type(properties) ~= "table" then
2733
+ return nil
2734
+ end
2735
+ for key, raw in pairs(properties) do
2736
+ local okCoerce, coerced = Serialize.coerce(inst, key, raw)
2737
+ if not okCoerce then
2738
+ return ("property '%s': %s"):format(key, tostring(coerced))
2739
+ end
2740
+ local okSet, setErr = pcall(function()
2741
+ (inst :: any)[key] = coerced
2742
+ end)
2743
+ if not okSet then
2744
+ return ("property '%s': %s"):format(key, tostring(setErr))
2745
+ end
2746
+ end
2747
+ return nil
2748
+ end
2749
+
2750
+ return Serialize
2751
+ ]]></string>
2752
+ </Properties>
2753
+ </Item>
2754
+ <Item class="ModuleScript" referent="34">
2755
+ <Properties>
2756
+ <string name="Name">UI</string>
2757
+ <string name="Source"><![CDATA[--!strict
2758
+ -- UI: builds the Roblox MCP Pro plugin panel inside a DockWidget.
2759
+ --
2760
+ -- The root is a ScrollingFrame stacking cards: Connection, Settings, Sync,
2761
+ -- Activity, plus a footer. Cards auto-size to content; the whole panel scrolls
2762
+ -- when the dock is short. Colors come from the live Studio theme (so it matches
2763
+ -- dark/light) with one accent color for primary actions and active states, kept
2764
+ -- in sync with the web dashboard.
2765
+
2766
+ local StudioService = game:GetService("StudioService")
2767
+ local HttpService = game:GetService("HttpService")
2768
+
2769
+ local UI = {}
2770
+
2771
+ -- Accent (matches the web dashboard's blue). Theme-independent on purpose.
2772
+ local ACCENT = Color3.fromRGB(88, 166, 255)
2773
+ local ACCENT_HOVER = Color3.fromRGB(115, 184, 255)
2774
+ local WHITE = Color3.fromRGB(255, 255, 255)
2775
+
2776
+ -- Direction labels shown in the Sync card.
2777
+ local MODE_LABELS: { [string]: string } = {
2778
+ ["two-way"] = "Two-way",
2779
+ ["studio-to-disk"] = "Studio → Disk",
2780
+ ["disk-to-studio"] = "Disk → Studio",
2781
+ }
2782
+
2783
+ -- State callbacks (wired up by init.server.luau)
2784
+ UI.onSettingsSaved = nil :: ((url: string, token: string?) -> ())?
2785
+ UI.onConnectionToggled = nil :: ((connect: boolean) -> ())?
2786
+ UI.onAutoConnectChanged = nil :: ((enabled: boolean) -> ())?
2787
+ UI.onSyncToggled = nil :: ((enabled: boolean, mode: string) -> ())?
2788
+ UI.onSyncModeChanged = nil :: ((mode: string) -> ())?
2789
+
2790
+ -- UI references
2791
+ local statusIndicator: Frame? = nil
2792
+ local statusLabel: TextLabel? = nil
2793
+ local toggleButton: TextButton? = nil
2794
+ local urlInput: TextBox? = nil
2795
+ local tokenInput: TextBox? = nil
2796
+ local statsLabel: TextLabel? = nil
2797
+ local logContainer: ScrollingFrame? = nil
2798
+ local syncStatusLabel: TextLabel? = nil
2799
+ local setSyncToggleVisual: ((on: boolean) -> ())? = nil
2800
+ local setSegmentedVisual: ((value: string) -> ())? = nil
2801
+
2802
+ -- Activity stats
2803
+ local isConnected = false
2804
+ local syncRunning = false
2805
+ local syncMode = "two-way"
2806
+ local totalRequests = 0
2807
+ local successRequests = 0
2808
+
2809
+ local MAX_LOG_ROWS = 60
2810
+
2811
+ -- ── Theme registry ────────────────────────────────────────────────────────────
2812
+ type ThemeItem = {
2813
+ element: Instance,
2814
+ property: string,
2815
+ styleColor: Enum.StudioStyleGuideColor,
2816
+ modifier: Enum.StudioStyleGuideModifier?,
2817
+ }
2818
+ local themeRegistry: { ThemeItem } = {}
2819
+
2820
+ local function registerTheme(
2821
+ element: Instance,
2822
+ property: string,
2823
+ styleColor: Enum.StudioStyleGuideColor,
2824
+ modifier: Enum.StudioStyleGuideModifier?
2825
+ )
2826
+ table.insert(themeRegistry, {
2827
+ element = element,
2828
+ property = property,
2829
+ styleColor = styleColor,
2830
+ modifier = modifier,
2831
+ })
2832
+ end
2833
+
2834
+ local function themeColor(styleColor: Enum.StudioStyleGuideColor, modifier: Enum.StudioStyleGuideModifier?): Color3
2835
+ return settings().Studio.Theme:GetColor(styleColor, modifier or Enum.StudioStyleGuideModifier.Default)
2836
+ end
2837
+
2838
+ local function applyTheme()
2839
+ for _, item in themeRegistry do
2840
+ local ok, color = pcall(themeColor, item.styleColor, item.modifier)
2841
+ if ok then
2842
+ (item.element :: any)[item.property] = color
2843
+ end
2844
+ end
2845
+ end
2846
+
2847
+ settings().Studio.ThemeChanged:Connect(applyTheme)
2848
+
2849
+ -- ── Small builder helpers ───────────────────────────────────────────────────────
2850
+ local function fill(element: GuiObject): UIFlexItem
2851
+ local flex = Instance.new("UIFlexItem")
2852
+ flex.FlexMode = Enum.UIFlexMode.Fill
2853
+ flex.Parent = element
2854
+ return flex
2855
+ end
2856
+
2857
+ local function listLayout(
2858
+ parent: Instance,
2859
+ direction: Enum.FillDirection,
2860
+ padding: number,
2861
+ vAlign: Enum.VerticalAlignment?
2862
+ ): UIListLayout
2863
+ local layout = Instance.new("UIListLayout")
2864
+ layout.FillDirection = direction
2865
+ layout.SortOrder = Enum.SortOrder.LayoutOrder
2866
+ layout.Padding = UDim.new(0, padding)
2867
+ if vAlign then
2868
+ layout.VerticalAlignment = vAlign
2869
+ end
2870
+ layout.Parent = parent
2871
+ return layout
2872
+ end
2873
+
2874
+ local function uiPadding(parent: Instance, amount: number)
2875
+ local pad = Instance.new("UIPadding")
2876
+ pad.PaddingTop = UDim.new(0, amount)
2877
+ pad.PaddingBottom = UDim.new(0, amount)
2878
+ pad.PaddingLeft = UDim.new(0, amount)
2879
+ pad.PaddingRight = UDim.new(0, amount)
2880
+ pad.Parent = parent
2881
+ end
2882
+
2883
+ local function corner(parent: Instance, radius: number)
2884
+ local c = Instance.new("UICorner")
2885
+ c.CornerRadius = UDim.new(0, radius)
2886
+ c.Parent = parent
2887
+ end
2888
+
2889
+ -- A transparent row container (its own list layout) that auto-sizes its height.
2890
+ local function row(parent: Instance, layoutOrder: number, height: number?): Frame
2891
+ local r = Instance.new("Frame")
2892
+ r.Name = "Row"
2893
+ r.BackgroundTransparency = 1
2894
+ r.BorderSizePixel = 0
2895
+ r.LayoutOrder = layoutOrder
2896
+ if height then
2897
+ r.Size = UDim2.new(1, 0, 0, height)
2898
+ else
2899
+ r.Size = UDim2.new(1, 0, 0, 0)
2900
+ r.AutomaticSize = Enum.AutomaticSize.Y
2901
+ end
2902
+ r.Parent = parent
2903
+ return r
2904
+ end
2905
+
2906
+ local function label(
2907
+ parent: Instance,
2908
+ text: string,
2909
+ fontSize: number,
2910
+ font: Enum.Font,
2911
+ styleColor: Enum.StudioStyleGuideColor,
2912
+ layoutOrder: number?
2913
+ ): TextLabel
2914
+ local l = Instance.new("TextLabel")
2915
+ l.Text = text
2916
+ l.TextSize = fontSize
2917
+ l.Font = font
2918
+ l.BackgroundTransparency = 1
2919
+ l.BorderSizePixel = 0
2920
+ l.AutomaticSize = Enum.AutomaticSize.XY
2921
+ l.Size = UDim2.new(0, 0, 0, 0)
2922
+ l.TextXAlignment = Enum.TextXAlignment.Left
2923
+ l.TextYAlignment = Enum.TextYAlignment.Center
2924
+ if layoutOrder then
2925
+ l.LayoutOrder = layoutOrder
2926
+ end
2927
+ l.Parent = parent
2928
+ registerTheme(l, "TextColor3", styleColor)
2929
+ return l
2930
+ end
2931
+
2932
+ local function spacer(parent: Instance, layoutOrder: number): Frame
2933
+ local s = Instance.new("Frame")
2934
+ s.Name = "Spacer"
2935
+ s.BackgroundTransparency = 1
2936
+ s.BorderSizePixel = 0
2937
+ s.Size = UDim2.new(0, 0, 1, 0)
2938
+ s.LayoutOrder = layoutOrder
2939
+ s.Parent = parent
2940
+ fill(s)
2941
+ return s
2942
+ end
2943
+
2944
+ local function button(parent: Instance, text: string, width: number, layoutOrder: number): TextButton
2945
+ local b = Instance.new("TextButton")
2946
+ b.Text = text
2947
+ b.TextSize = 12
2948
+ b.Font = Enum.Font.GothamMedium
2949
+ b.BorderSizePixel = 0
2950
+ b.AutoButtonColor = false
2951
+ b.Size = UDim2.new(0, width, 0, 26)
2952
+ b.LayoutOrder = layoutOrder
2953
+ b.Parent = parent
2954
+ corner(b, 5)
2955
+ registerTheme(b, "BackgroundColor3", Enum.StudioStyleGuideColor.Button)
2956
+ registerTheme(b, "TextColor3", Enum.StudioStyleGuideColor.ButtonText)
2957
+
2958
+ b.MouseEnter:Connect(function()
2959
+ b.BackgroundColor3 = themeColor(Enum.StudioStyleGuideColor.Button, Enum.StudioStyleGuideModifier.Hover)
2960
+ end)
2961
+ b.MouseLeave:Connect(function()
2962
+ b.BackgroundColor3 = themeColor(Enum.StudioStyleGuideColor.Button, Enum.StudioStyleGuideModifier.Default)
2963
+ end)
2964
+ b.MouseButton1Down:Connect(function()
2965
+ b.BackgroundColor3 = themeColor(Enum.StudioStyleGuideColor.Button, Enum.StudioStyleGuideModifier.Pressed)
2966
+ end)
2967
+ return b
2968
+ end
2969
+
2970
+ -- A full-width accent button for the primary action.
2971
+ local function primaryButton(parent: Instance, text: string, layoutOrder: number): TextButton
2972
+ local b = Instance.new("TextButton")
2973
+ b.Text = text
2974
+ b.TextSize = 13
2975
+ b.Font = Enum.Font.GothamBold
2976
+ b.BorderSizePixel = 0
2977
+ b.AutoButtonColor = false
2978
+ b.Size = UDim2.new(1, 0, 0, 32)
2979
+ b.LayoutOrder = layoutOrder
2980
+ b.BackgroundColor3 = ACCENT
2981
+ b.TextColor3 = WHITE
2982
+ b.Parent = parent
2983
+ corner(b, 6)
2984
+ b.MouseEnter:Connect(function()
2985
+ b.BackgroundColor3 = ACCENT_HOVER
2986
+ end)
2987
+ b.MouseLeave:Connect(function()
2988
+ b.BackgroundColor3 = ACCENT
2989
+ end)
2990
+ return b
2991
+ end
2992
+
2993
+ local function input(parent: Instance, placeholder: string, layoutOrder: number): TextBox
2994
+ local box = Instance.new("TextBox")
2995
+ box.Text = ""
2996
+ box.PlaceholderText = placeholder
2997
+ box.TextSize = 12
2998
+ box.Font = Enum.Font.Code
2999
+ box.BorderSizePixel = 0
3000
+ box.ClearTextOnFocus = false
3001
+ box.TextXAlignment = Enum.TextXAlignment.Left
3002
+ box.TextTruncate = Enum.TextTruncate.AtEnd
3003
+ box.Size = UDim2.new(1, 0, 0, 28)
3004
+ box.LayoutOrder = layoutOrder
3005
+ box.Parent = parent
3006
+ corner(box, 5)
3007
+ local pad = Instance.new("UIPadding")
3008
+ pad.PaddingLeft = UDim.new(0, 8)
3009
+ pad.PaddingRight = UDim.new(0, 8)
3010
+ pad.Parent = box
3011
+ registerTheme(box, "BackgroundColor3", Enum.StudioStyleGuideColor.InputFieldBackground)
3012
+ registerTheme(box, "TextColor3", Enum.StudioStyleGuideColor.MainText)
3013
+ registerTheme(box, "PlaceholderColor3", Enum.StudioStyleGuideColor.DimmedText)
3014
+ return box
3015
+ end
3016
+
3017
+ -- A bordered card with a vertical inner layout, auto-sizing to its content.
3018
+ local function card(parent: Instance, layoutOrder: number): Frame
3019
+ local c = Instance.new("Frame")
3020
+ c.Name = "Card"
3021
+ c.BorderSizePixel = 0
3022
+ c.LayoutOrder = layoutOrder
3023
+ c.Size = UDim2.new(1, 0, 0, 0)
3024
+ c.AutomaticSize = Enum.AutomaticSize.Y
3025
+ c.Parent = parent
3026
+ registerTheme(c, "BackgroundColor3", Enum.StudioStyleGuideColor.MainBackground)
3027
+
3028
+ corner(c, 8)
3029
+ local stroke = Instance.new("UIStroke")
3030
+ stroke.Thickness = 1
3031
+ stroke.Parent = c
3032
+ registerTheme(stroke, "Color", Enum.StudioStyleGuideColor.Border)
3033
+
3034
+ uiPadding(c, 12)
3035
+ listLayout(c, Enum.FillDirection.Vertical, 9)
3036
+ return c
3037
+ end
3038
+
3039
+ -- A sliding on/off switch. Returns a setter to update its visual state.
3040
+ local function toggleSwitch(
3041
+ parent: Instance,
3042
+ layoutOrder: number,
3043
+ initialOn: boolean,
3044
+ onChange: (boolean) -> ()
3045
+ ): (boolean) -> ()
3046
+ local track = Instance.new("Frame")
3047
+ track.Name = "Toggle"
3048
+ track.Size = UDim2.new(0, 40, 0, 22)
3049
+ track.BorderSizePixel = 0
3050
+ track.LayoutOrder = layoutOrder
3051
+ track.Parent = parent
3052
+ corner(track, 11)
3053
+
3054
+ local knob = Instance.new("Frame")
3055
+ knob.Name = "Knob"
3056
+ knob.Size = UDim2.new(0, 16, 0, 16)
3057
+ knob.BorderSizePixel = 0
3058
+ knob.BackgroundColor3 = WHITE
3059
+ knob.Parent = track
3060
+ corner(knob, 8)
3061
+
3062
+ local state = initialOn
3063
+
3064
+ local function apply()
3065
+ if state then
3066
+ track.BackgroundColor3 = ACCENT
3067
+ knob.Position = UDim2.new(1, -19, 0.5, -8)
3068
+ else
3069
+ track.BackgroundColor3 = themeColor(Enum.StudioStyleGuideColor.Border)
3070
+ knob.Position = UDim2.new(0, 3, 0.5, -8)
3071
+ end
3072
+ end
3073
+ apply()
3074
+
3075
+ local hit = Instance.new("TextButton")
3076
+ hit.Text = ""
3077
+ hit.BackgroundTransparency = 1
3078
+ hit.Size = UDim2.new(1, 0, 1, 0)
3079
+ hit.Parent = track
3080
+ hit.MouseButton1Click:Connect(function()
3081
+ state = not state
3082
+ apply()
3083
+ onChange(state)
3084
+ end)
3085
+
3086
+ return function(on: boolean)
3087
+ state = on
3088
+ apply()
3089
+ end
3090
+ end
3091
+
3092
+ -- A segmented (single-choice) control. Returns a setter to update the selection.
3093
+ local function segmented(
3094
+ parent: Instance,
3095
+ layoutOrder: number,
3096
+ options: { { label: string, value: string } },
3097
+ initial: string,
3098
+ onChange: (string) -> ()
3099
+ ): (string) -> ()
3100
+ local bar = Instance.new("Frame")
3101
+ bar.Name = "Segmented"
3102
+ bar.BackgroundTransparency = 1
3103
+ bar.BorderSizePixel = 0
3104
+ bar.Size = UDim2.new(1, 0, 0, 28)
3105
+ bar.LayoutOrder = layoutOrder
3106
+ bar.Parent = parent
3107
+ listLayout(bar, Enum.FillDirection.Horizontal, 6)
3108
+
3109
+ local buttons: { [string]: TextButton } = {}
3110
+ local current = initial
3111
+
3112
+ local function apply()
3113
+ for value, btn in buttons do
3114
+ if value == current then
3115
+ btn.BackgroundColor3 = ACCENT
3116
+ btn.TextColor3 = WHITE
3117
+ else
3118
+ btn.BackgroundColor3 = themeColor(Enum.StudioStyleGuideColor.Button)
3119
+ btn.TextColor3 = themeColor(Enum.StudioStyleGuideColor.ButtonText)
3120
+ end
3121
+ end
3122
+ end
3123
+
3124
+ for i, opt in options do
3125
+ local b = Instance.new("TextButton")
3126
+ b.Text = opt.label
3127
+ b.TextSize = 11
3128
+ b.Font = Enum.Font.GothamMedium
3129
+ b.BorderSizePixel = 0
3130
+ b.AutoButtonColor = false
3131
+ b.Size = UDim2.new(0, 0, 1, 0)
3132
+ b.LayoutOrder = i
3133
+ b.Parent = bar
3134
+ corner(b, 5)
3135
+ fill(b)
3136
+ buttons[opt.value] = b
3137
+ b.MouseButton1Click:Connect(function()
3138
+ current = opt.value
3139
+ apply()
3140
+ onChange(opt.value)
3141
+ end)
3142
+ end
3143
+ apply()
3144
+
3145
+ return function(value: string)
3146
+ current = value
3147
+ apply()
3148
+ end
3149
+ end
3150
+
3151
+ -- ── Build the panel ─────────────────────────────────────────────────────────────
3152
+ function UI.create(
3153
+ widget: DockWidgetPluginGui,
3154
+ initialSettings: { url: string, token: string?, autoConnect: boolean?, syncMode: string? }
3155
+ )
3156
+ widget.Title = "Roblox MCP Pro"
3157
+ themeRegistry = {}
3158
+ syncMode = initialSettings.syncMode or "two-way"
3159
+
3160
+ -- Clear any previous build (Studio reloads re-run this module).
3161
+ for _, child in widget:GetChildren() do
3162
+ child:Destroy()
3163
+ end
3164
+
3165
+ local root = Instance.new("ScrollingFrame")
3166
+ root.Name = "Root"
3167
+ root.Size = UDim2.new(1, 0, 1, 0)
3168
+ root.BorderSizePixel = 0
3169
+ root.ScrollBarThickness = 6
3170
+ root.CanvasSize = UDim2.new(0, 0, 0, 0)
3171
+ root.AutomaticCanvasSize = Enum.AutomaticSize.Y
3172
+ root.ScrollingDirection = Enum.ScrollingDirection.Y
3173
+ root.Parent = widget
3174
+ registerTheme(root, "BackgroundColor3", Enum.StudioStyleGuideColor.MainBackground)
3175
+ registerTheme(root, "ScrollBarImageColor3", Enum.StudioStyleGuideColor.ScrollBar)
3176
+ uiPadding(root, 10)
3177
+ listLayout(root, Enum.FillDirection.Vertical, 10)
3178
+
3179
+ -- ── Header ──
3180
+ local header = row(root, 0, 24)
3181
+ listLayout(header, Enum.FillDirection.Horizontal, 8, Enum.VerticalAlignment.Center)
3182
+ label(header, "Roblox MCP Pro", 15, Enum.Font.GothamBold, Enum.StudioStyleGuideColor.MainText, 1)
3183
+
3184
+ -- ── Connection card ──
3185
+ local connCard = card(root, 1)
3186
+ local connRow = row(connCard, 1, 24)
3187
+ listLayout(connRow, Enum.FillDirection.Horizontal, 8, Enum.VerticalAlignment.Center)
3188
+
3189
+ statusIndicator = Instance.new("Frame")
3190
+ local dot = statusIndicator :: Frame
3191
+ dot.Name = "Dot"
3192
+ dot.Size = UDim2.new(0, 10, 0, 10)
3193
+ dot.BorderSizePixel = 0
3194
+ dot.BackgroundColor3 = Color3.fromRGB(149, 165, 166)
3195
+ dot.LayoutOrder = 1
3196
+ dot.Parent = connRow
3197
+ corner(dot, 5)
3198
+
3199
+ statusLabel = label(connRow, "Disconnected", 14, Enum.Font.GothamMedium, Enum.StudioStyleGuideColor.MainText, 2)
3200
+
3201
+ toggleButton = primaryButton(connCard, "Connect", 2)
3202
+ ;(toggleButton :: TextButton).MouseButton1Click:Connect(function()
3203
+ if UI.onConnectionToggled then
3204
+ UI.onConnectionToggled(not isConnected)
3205
+ end
3206
+ end)
3207
+
3208
+ -- ── Settings card ──
3209
+ local settingsCard = card(root, 2)
3210
+ label(settingsCard, "SETTINGS", 10, Enum.Font.GothamBold, Enum.StudioStyleGuideColor.DimmedText, 1)
3211
+
3212
+ local autoRow = row(settingsCard, 2, 24)
3213
+ listLayout(autoRow, Enum.FillDirection.Horizontal, 8, Enum.VerticalAlignment.Center)
3214
+ label(autoRow, "Auto-connect on startup", 12, Enum.Font.Gotham, Enum.StudioStyleGuideColor.MainText, 1)
3215
+ spacer(autoRow, 2)
3216
+ toggleSwitch(autoRow, 3, initialSettings.autoConnect == true, function(on: boolean)
3217
+ if UI.onAutoConnectChanged then
3218
+ UI.onAutoConnectChanged(on)
3219
+ end
3220
+ end)
3221
+
3222
+ label(settingsCard, "Server URL", 11, Enum.Font.Gotham, Enum.StudioStyleGuideColor.DimmedText, 3)
3223
+ urlInput = input(settingsCard, "http://127.0.0.1:3690", 4)
3224
+ ;(urlInput :: TextBox).Text = initialSettings.url
3225
+
3226
+ label(settingsCard, "Auth Token (optional)", 11, Enum.Font.Gotham, Enum.StudioStyleGuideColor.DimmedText, 5)
3227
+ tokenInput = input(settingsCard, "shared secret", 6)
3228
+ ;(tokenInput :: TextBox).Text = initialSettings.token or ""
3229
+
3230
+ local applyRow = row(settingsCard, 7, 26)
3231
+ listLayout(applyRow, Enum.FillDirection.Horizontal, 8, Enum.VerticalAlignment.Center)
3232
+ spacer(applyRow, 1)
3233
+ local applyBtn = button(applyRow, "Apply", 80, 2)
3234
+ applyBtn.MouseButton1Click:Connect(function()
3235
+ if UI.onSettingsSaved and urlInput and tokenInput then
3236
+ local t = tokenInput.Text
3237
+ UI.onSettingsSaved(urlInput.Text, if t == "" then nil else t)
3238
+ end
3239
+ end)
3240
+
3241
+ -- ── Sync card ──
3242
+ local syncCard = card(root, 3)
3243
+ label(syncCard, "FILE SYNC", 10, Enum.Font.GothamBold, Enum.StudioStyleGuideColor.DimmedText, 1)
3244
+
3245
+ local syncRow = row(syncCard, 2, 24)
3246
+ listLayout(syncRow, Enum.FillDirection.Horizontal, 8, Enum.VerticalAlignment.Center)
3247
+ label(syncRow, "Enable sync", 12, Enum.Font.Gotham, Enum.StudioStyleGuideColor.MainText, 1)
3248
+ spacer(syncRow, 2)
3249
+ setSyncToggleVisual = toggleSwitch(syncRow, 3, false, function(on: boolean)
3250
+ if UI.onSyncToggled then
3251
+ UI.onSyncToggled(on, syncMode)
3252
+ end
3253
+ end)
3254
+
3255
+ label(syncCard, "Direction", 11, Enum.Font.Gotham, Enum.StudioStyleGuideColor.DimmedText, 3)
3256
+ setSegmentedVisual = segmented(syncCard, 4, {
3257
+ { label = "Two-way", value = "two-way" },
3258
+ { label = "Studio→Disk", value = "studio-to-disk" },
3259
+ { label = "Disk→Studio", value = "disk-to-studio" },
3260
+ }, syncMode, function(value: string)
3261
+ syncMode = value
3262
+ if UI.onSyncModeChanged then
3263
+ UI.onSyncModeChanged(value)
3264
+ end
3265
+ end)
3266
+
3267
+ syncStatusLabel = label(syncCard, "Off", 11, Enum.Font.Gotham, Enum.StudioStyleGuideColor.DimmedText, 5)
3268
+
3269
+ -- ── Activity card ──
3270
+ local activityCard = card(root, 4)
3271
+
3272
+ local activityHeader = row(activityCard, 1, 18)
3273
+ listLayout(activityHeader, Enum.FillDirection.Horizontal, 8, Enum.VerticalAlignment.Center)
3274
+ label(activityHeader, "ACTIVITY", 10, Enum.Font.GothamBold, Enum.StudioStyleGuideColor.DimmedText, 1)
3275
+ spacer(activityHeader, 2)
3276
+ statsLabel = label(activityHeader, "0 reqs · 100%", 10, Enum.Font.Gotham, Enum.StudioStyleGuideColor.DimmedText, 3)
3277
+ local clearBtn = button(activityHeader, "Clear", 52, 4)
3278
+ clearBtn.Size = UDim2.new(0, 52, 0, 18)
3279
+ clearBtn.TextSize = 10
3280
+ clearBtn.MouseButton1Click:Connect(function()
3281
+ UI.clearLogs()
3282
+ end)
3283
+
3284
+ logContainer = Instance.new("ScrollingFrame")
3285
+ local log = logContainer :: ScrollingFrame
3286
+ log.Name = "Log"
3287
+ log.Size = UDim2.new(1, 0, 0, 150)
3288
+ log.BorderSizePixel = 0
3289
+ log.ScrollBarThickness = 6
3290
+ log.CanvasSize = UDim2.new(0, 0, 0, 0)
3291
+ log.AutomaticCanvasSize = Enum.AutomaticSize.Y
3292
+ log.ScrollingDirection = Enum.ScrollingDirection.Y
3293
+ log.LayoutOrder = 2
3294
+ log.Parent = activityCard
3295
+ corner(log, 5)
3296
+ registerTheme(log, "BackgroundColor3", Enum.StudioStyleGuideColor.InputFieldBackground)
3297
+ registerTheme(log, "ScrollBarImageColor3", Enum.StudioStyleGuideColor.ScrollBar)
3298
+ local logLayout = listLayout(log, Enum.FillDirection.Vertical, 3)
3299
+ logLayout.Padding = UDim.new(0, 3)
3300
+ uiPadding(log, 6)
3301
+
3302
+ -- ── Footer (config helpers) ──
3303
+ local footer = row(root, 5, 26)
3304
+ listLayout(footer, Enum.FillDirection.Horizontal, 8, Enum.VerticalAlignment.Center)
3305
+
3306
+ local copyJsonBtn = button(footer, "Copy config", 96, 1)
3307
+ copyJsonBtn.MouseButton1Click:Connect(function()
3308
+ local configTable = {
3309
+ mcpServers = {
3310
+ ["roblox-mcp-pro"] = { command = "npx", args = { "-y", "roblox-mcp-pro" } },
3311
+ },
3312
+ }
3313
+ local okEncode, json = pcall(function()
3314
+ return HttpService:JSONEncode(configTable)
3315
+ end)
3316
+ if okEncode then
3317
+ pcall(function()
3318
+ StudioService:CopyToClipboard(json)
3319
+ print("[roblox-mcp-pro] Config JSON copied to clipboard.")
3320
+ end)
3321
+ end
3322
+ end)
3323
+
3324
+ local copyCliBtn = button(footer, "Copy install cmd", 120, 2)
3325
+ copyCliBtn.MouseButton1Click:Connect(function()
3326
+ local cmd = 'gh api repos/PeerapolSelanon/roblox-mcp-pro/contents/install.ps1 -H "Accept: application/vnd.github.v3.raw" | Out-String | iex'
3327
+ pcall(function()
3328
+ StudioService:CopyToClipboard(cmd)
3329
+ print("[roblox-mcp-pro] Install command copied to clipboard.")
3330
+ end)
3331
+ end)
3332
+
3333
+ applyTheme()
3334
+ end
3335
+
3336
+ -- ── Public API ──────────────────────────────────────────────────────────────────
3337
+
3338
+ -- Update the connection status shown in the UI. statusLabel color is driven manually
3339
+ -- here (not via registerTheme) so we never grow themeRegistry on repeated calls.
3340
+ function UI.setStatus(status: string)
3341
+ if not statusLabel or not statusIndicator or not toggleButton then
3342
+ return
3343
+ end
3344
+ local lbl = statusLabel :: TextLabel
3345
+ local ind = statusIndicator :: Frame
3346
+ local btn = toggleButton :: TextButton
3347
+
3348
+ lbl.Text = status
3349
+
3350
+ if status == "Connected" then
3351
+ isConnected = true
3352
+ ind.BackgroundColor3 = Color3.fromRGB(46, 204, 113) -- green
3353
+ lbl.TextColor3 = Color3.fromRGB(46, 204, 113)
3354
+ btn.Text = "Disconnect"
3355
+ elseif status == "Connecting" or status == "Reconnecting" then
3356
+ isConnected = true
3357
+ ind.BackgroundColor3 = Color3.fromRGB(241, 196, 15) -- amber
3358
+ lbl.TextColor3 = Color3.fromRGB(241, 196, 15)
3359
+ btn.Text = "Disconnect"
3360
+ elseif status == "Offline" then
3361
+ -- Server unreachable but the poll loop is still retrying, so keep "Disconnect".
3362
+ isConnected = true
3363
+ ind.BackgroundColor3 = Color3.fromRGB(231, 76, 60) -- red
3364
+ lbl.TextColor3 = Color3.fromRGB(231, 76, 60)
3365
+ btn.Text = "Disconnect"
3366
+ else
3367
+ isConnected = false
3368
+ ind.BackgroundColor3 = Color3.fromRGB(149, 165, 166) -- muted
3369
+ lbl.TextColor3 = themeColor(Enum.StudioStyleGuideColor.DimmedText)
3370
+ btn.Text = "Connect"
3371
+ end
3372
+ end
3373
+
3374
+ -- Reflect the broker's sync state in the Sync card.
3375
+ function UI.setSyncStatus(running: boolean, mode: string?, scriptCount: number?)
3376
+ syncRunning = running
3377
+ if mode then
3378
+ syncMode = mode
3379
+ if setSegmentedVisual then
3380
+ (setSegmentedVisual :: (string) -> ())(mode)
3381
+ end
3382
+ end
3383
+ if setSyncToggleVisual then
3384
+ (setSyncToggleVisual :: (boolean) -> ())(running)
3385
+ end
3386
+ if syncStatusLabel then
3387
+ local lbl = syncStatusLabel :: TextLabel
3388
+ if running then
3389
+ local count = scriptCount or 0
3390
+ lbl.Text = ("Syncing %d scripts · %s"):format(count, MODE_LABELS[syncMode] or syncMode)
3391
+ lbl.TextColor3 = Color3.fromRGB(46, 204, 113)
3392
+ else
3393
+ lbl.Text = "Off"
3394
+ lbl.TextColor3 = themeColor(Enum.StudioStyleGuideColor.DimmedText)
3395
+ end
3396
+ end
3397
+ end
3398
+
3399
+ function UI.updateStatsLabel()
3400
+ if not statsLabel then
3401
+ return
3402
+ end
3403
+ local rate = 100
3404
+ if totalRequests > 0 then
3405
+ rate = math.round((successRequests / totalRequests) * 100)
3406
+ end
3407
+ (statsLabel :: TextLabel).Text = ("%d reqs · %d%%"):format(totalRequests, rate)
3408
+ end
3409
+
3410
+ -- Append a log entry to the activity console.
3411
+ function UI.addLog(toolName: string, success: boolean, detail: string?)
3412
+ if not logContainer then
3413
+ return
3414
+ end
3415
+ local log = logContainer :: ScrollingFrame
3416
+
3417
+ totalRequests += 1
3418
+ if success then
3419
+ successRequests += 1
3420
+ end
3421
+ UI.updateStatsLabel()
3422
+
3423
+ local logRow = Instance.new("Frame")
3424
+ logRow.Name = "LogRow"
3425
+ logRow.Size = UDim2.new(1, 0, 0, 16)
3426
+ logRow.BackgroundTransparency = 1
3427
+ logRow.BorderSizePixel = 0
3428
+ logRow.Parent = log
3429
+ listLayout(logRow, Enum.FillDirection.Horizontal, 6, Enum.VerticalAlignment.Center)
3430
+
3431
+ local badge = Instance.new("TextLabel")
3432
+ badge.Name = "Badge"
3433
+ badge.Size = UDim2.new(0, 28, 0, 13)
3434
+ badge.BorderSizePixel = 0
3435
+ badge.TextSize = 9
3436
+ badge.Font = Enum.Font.GothamBold
3437
+ badge.LayoutOrder = 1
3438
+ badge.Parent = logRow
3439
+ corner(badge, 3)
3440
+ if success then
3441
+ badge.Text = "OK"
3442
+ badge.BackgroundColor3 = Color3.fromRGB(39, 174, 96)
3443
+ else
3444
+ badge.Text = "ERR"
3445
+ badge.BackgroundColor3 = Color3.fromRGB(192, 57, 43)
3446
+ end
3447
+ badge.TextColor3 = WHITE
3448
+
3449
+ local timestamp = os.date("%H:%M:%S")
3450
+ local entryText = ("[%s] %s"):format(timestamp, toolName)
3451
+ if detail and detail ~= "" then
3452
+ entryText = entryText .. " — " .. detail
3453
+ end
3454
+ -- Log rows are transient (trimmed after MAX_LOG_ROWS), so we set the color directly
3455
+ -- instead of via registerTheme — otherwise every entry would leak a registry item.
3456
+ local msg = Instance.new("TextLabel")
3457
+ msg.Text = entryText
3458
+ msg.TextSize = 11
3459
+ msg.Font = Enum.Font.Code
3460
+ msg.BackgroundTransparency = 1
3461
+ msg.BorderSizePixel = 0
3462
+ msg.TextXAlignment = Enum.TextXAlignment.Left
3463
+ msg.TextYAlignment = Enum.TextYAlignment.Center
3464
+ msg.TextTruncate = Enum.TextTruncate.AtEnd
3465
+ msg.Size = UDim2.new(1, -34, 1, 0)
3466
+ msg.LayoutOrder = 2
3467
+ msg.TextColor3 = themeColor(
3468
+ if success then Enum.StudioStyleGuideColor.MainText else Enum.StudioStyleGuideColor.WarningText
3469
+ )
3470
+ msg.Parent = logRow
3471
+
3472
+ -- Keep the newest entry in view.
3473
+ task.defer(function()
3474
+ if logContainer then
3475
+ (logContainer :: ScrollingFrame).CanvasPosition =
3476
+ Vector2.new(0, (logContainer :: ScrollingFrame).AbsoluteCanvasSize.Y)
3477
+ end
3478
+ end)
3479
+
3480
+ -- Trim old rows to bound memory.
3481
+ local rows = {}
3482
+ for _, child in log:GetChildren() do
3483
+ if child.Name == "LogRow" then
3484
+ table.insert(rows, child)
3485
+ end
3486
+ end
3487
+ if #rows > MAX_LOG_ROWS then
3488
+ for i = 1, #rows - MAX_LOG_ROWS do
3489
+ rows[i]:Destroy()
3490
+ end
3491
+ end
3492
+ end
3493
+
3494
+ function UI.clearLogs()
3495
+ if not logContainer then
3496
+ return
3497
+ end
3498
+ for _, child in (logContainer :: ScrollingFrame):GetChildren() do
3499
+ if child.Name == "LogRow" then
3500
+ child:Destroy()
3501
+ end
3502
+ end
3503
+ totalRequests = 0
3504
+ successRequests = 0
3505
+ UI.updateStatsLabel()
3506
+ end
3507
+
3508
+ return UI
3509
+ ]]></string>
3510
+ </Properties>
3511
+ </Item>
3512
+ </Item>
3513
+ </roblox>