roblox-mcp-pro 0.2.1 → 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.
- package/README.md +25 -16
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/install-plugin.d.ts +18 -6
- package/dist/install-plugin.d.ts.map +1 -1
- package/dist/install-plugin.js +47 -19
- package/dist/install-plugin.js.map +1 -1
- package/dist/tools/system.d.ts.map +1 -1
- package/dist/tools/system.js +3 -1
- package/dist/tools/system.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +14 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
- package/plugin/RobloxMcpPro.rbxmx +3513 -0
|
@@ -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>
|