@el4cteo/rbx-studio-mcp 0.1.0 → 0.1.1

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.
@@ -1,187 +1,366 @@
1
- --!strict
2
- --[[
3
- Screenshots, so an agent can look at the place instead of inferring it.
4
-
5
- Everything else this server exposes reads the data model: names, properties,
6
- numbers. None of that answers "does it look right", which for a 3D medium is
7
- most of the question. A part can sit at the correct position, anchored, the
8
- right size, and still be inside a wall.
9
-
10
- The path is four steps, none of them optional. `CaptureService` hands back a
11
- temporary content id rather than pixels; `AssetService` turns that id into an
12
- EditableImage; the image gives up raw RGBA; and PNG and base64 are written by
13
- hand because the engine has neither. See Png.luau.
14
- ]]
15
-
16
- local AssetService = game:GetService("AssetService")
17
- local CaptureService = game:GetService("CaptureService")
18
- local EncodingService = game:GetService("EncodingService")
19
- local RunService = game:GetService("RunService")
20
-
21
- local Dispatch = require(script.Parent.Parent.Dispatch)
22
- local Emulation = require(script.Parent.Parent.Emulation)
23
- local Png = require(script.Parent.Parent.Png)
24
-
25
- -- Wide enough to read a GUI label, small enough that the encode stays quick and
26
- -- the reply does not dominate the conversation it is part of.
27
- local DEFAULT_WIDTH = 800
28
- local MAX_WIDTH = 1600
29
- local MIN_WIDTH = 160
30
-
31
- -- The callback has never taken close to this. It exists so a capture that never
32
- -- calls back fails with something an agent can act on rather than hanging the
33
- -- session until the request deadline.
34
- local CAPTURE_TIMEOUT = 10
35
-
36
- -- Zstd level. 3 is its default and already gets most of the win on screen
37
- -- content; the higher levels cost Studio's main thread for a few percent.
38
- local COMPRESSION_LEVEL = 3
39
-
40
- local Capture = {}
41
-
42
- --[[
43
- Takes the shot and waits for the id.
44
-
45
- `CaptureService:CaptureScreenshot` answers through a callback rather than
46
- yielding, so this bridges the two: the handler is already running on its own
47
- task and is free to wait.
48
- ]]
49
- local function takeScreenshot(): string
50
- local contentId: string? = nil
51
- local failed: string? = nil
52
-
53
- local ok, err = pcall(function()
54
- CaptureService:CaptureScreenshot(function(id: string)
55
- contentId = id
56
- end)
57
- end)
58
- if not ok then
59
- failed = tostring(err)
60
- end
61
-
62
- if failed ~= nil then
63
- Dispatch.fail(
64
- "CAPTURE_REFUSED",
65
- string.format("Studio refused to take a screenshot: %s", failed)
66
- )
67
- end
68
-
69
- local waited = 0
70
- while contentId == nil and waited < CAPTURE_TIMEOUT do
71
- task.wait(0.05)
72
- waited += 0.05
73
- end
74
-
75
- if contentId == nil then
76
- Dispatch.fail(
77
- "CAPTURE_TIMEOUT",
78
- string.format("Studio did not return a screenshot within %ds.", CAPTURE_TIMEOUT),
79
- "The viewport may be hidden or Studio may be busy; try again."
80
- )
81
- end
82
-
83
- return contentId :: any
84
- end
85
-
86
- function Capture.screenshot(params: { [string]: any }): { [string]: any }
87
- local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
88
-
89
- local contentId = takeScreenshot()
90
-
91
- local okImage, image = pcall(function()
92
- return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
93
- end)
94
- if not okImage then
95
- Dispatch.fail(
96
- "CAPTURE_UNREADABLE",
97
- string.format("The screenshot could not be opened for reading: %s", tostring(image))
98
- )
99
- end
100
-
101
- local size = (image :: any).Size
102
- local sourceWidth = math.floor(size.X)
103
- local sourceHeight = math.floor(size.Y)
104
-
105
- --[[
106
- Both arguments are required. Called with none it reports "expects 2
107
- arguments" rather than defaulting to the whole image, which is worth
108
- stating because every other read on this object takes none.
109
- ]]
110
- local okPixels, pixels = pcall(function()
111
- return (image :: any):ReadPixelsBuffer(Vector2.zero, size)
112
- end)
113
- if not okPixels then
114
- Dispatch.fail(
115
- "CAPTURE_UNREADABLE",
116
- string.format("The screenshot's pixels could not be read: %s", tostring(pixels))
117
- )
118
- end
119
-
120
- local rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
121
-
122
- --[[
123
- Raw pixels, compressed by the engine, assembled into a PNG by Node.
124
-
125
- The plugin used to write the whole PNG itself, and could only write a bad
126
- one: Studio has no deflate, so `Png.encode` emitted zlib *stored* blocks
127
- -- the format's "compression not applied" escape hatch -- and every
128
- screenshot travelled and landed at full uncompressed size. It was also
129
- base64-ing by hand, a table lookup per byte in Luau.
130
-
131
- `EncodingService` has both, natively. Zstd is the only algorithm the
132
- engine exposes and PNG cannot use it, so the split is: the plugin
133
- compresses the pixels for the wire, and Node -- which has real zlib --
134
- decompresses and writes a properly deflated PNG. The side with the
135
- compressor does the compressing.
136
-
137
- Guarded rather than assumed: EncodingService is recent, and a Studio
138
- without it should cost a bigger screenshot, not a failed one.
139
- ]]
140
- local okPacked, packed = pcall(function()
141
- local compressed = (EncodingService :: any):CompressBuffer(
142
- rgb,
143
- (Enum :: any).CompressionAlgorithm.Zstd,
144
- COMPRESSION_LEVEL
145
- )
146
- return buffer.tostring((EncodingService :: any):Base64Encode(compressed))
147
- end)
148
-
149
- if okPacked then
150
- return {
151
- encoding = "zstd-rgb",
152
- data = packed,
153
- width = outWidth,
154
- height = outHeight,
155
- sourceWidth = sourceWidth,
156
- sourceHeight = sourceHeight,
157
- rawBytes = buffer.len(rgb),
158
- bytes = #(packed :: string),
159
- context = if RunService:IsEdit() then "edit" else "playtest",
160
- device = Emulation.deviceId(),
161
- }
162
- end
163
-
164
- local png = Png.encode(rgb, outWidth, outHeight)
165
-
166
- return {
167
- encoding = "png",
168
- data = Png.base64(png),
169
- width = outWidth,
170
- height = outHeight,
171
- sourceWidth = sourceWidth,
172
- sourceHeight = sourceHeight,
173
- bytes = buffer.len(png),
174
- -- Which window this came from. A screenshot carries no indication of
175
- -- whether it shows the editor or a running game, and the two look alike.
176
- context = if RunService:IsEdit() then "edit" else "playtest",
177
- device = Emulation.deviceId(),
178
- }
179
- end
180
-
181
- function Capture.register()
182
- Dispatch.registerAll("capture", {
183
- screenshot = Capture.screenshot,
184
- })
185
- end
186
-
187
- return Capture
1
+ --!strict
2
+ --[[
3
+ Screenshots, so an agent can look at the place instead of inferring it.
4
+
5
+ Everything else this server exposes reads the data model: names, properties,
6
+ numbers. None of that answers "does it look right", which for a 3D medium is
7
+ most of the question. A part can sit at the correct position, anchored, the
8
+ right size, and still be inside a wall.
9
+
10
+ The path is four steps, none of them optional. `CaptureService` hands back a
11
+ temporary content id rather than pixels; `AssetService` turns that id into an
12
+ EditableImage; the image gives up raw RGBA; and PNG and base64 are written by
13
+ hand because the engine has neither. See Png.luau.
14
+ ]]
15
+
16
+ local AssetService = game:GetService("AssetService")
17
+ local CaptureService = game:GetService("CaptureService")
18
+ local EncodingService = game:GetService("EncodingService")
19
+ local Players = game:GetService("Players")
20
+ local RunService = game:GetService("RunService")
21
+
22
+ local Dispatch = require(script.Parent.Parent.Dispatch)
23
+ local Emulation = require(script.Parent.Parent.Emulation)
24
+ local Png = require(script.Parent.Parent.Png)
25
+
26
+ -- Wide enough to read a GUI label, small enough that the encode stays quick and
27
+ -- the reply does not dominate the conversation it is part of.
28
+ local DEFAULT_WIDTH = 800
29
+ local MAX_WIDTH = 1600
30
+ local MIN_WIDTH = 160
31
+
32
+ -- The callback has never taken close to this. It exists so a capture that never
33
+ -- calls back fails with something an agent can act on rather than hanging the
34
+ -- session until the request deadline.
35
+ local CAPTURE_TIMEOUT = 10
36
+
37
+ -- Zstd level. 3 is its default and already gets most of the win on screen
38
+ -- content; the higher levels cost Studio's main thread for a few percent.
39
+ local COMPRESSION_LEVEL = 3
40
+
41
+ local Capture = {}
42
+
43
+ -- Only a content id comes back over the remote, so this is the time to start a
44
+ -- LocalScript and take one shot, not to move any pixels.
45
+ local CLIENT_TIMEOUT = 25
46
+
47
+ --[[
48
+ Screenshots during a playtest, taken by the client because only the client
49
+ may take them.
50
+
51
+ `CaptureService:CaptureScreenshot` refuses outright in the playtest's server
52
+ session -- "can only be called on the client" -- and the edit session, which
53
+ could call it, has stopped rendering its own data model and times out. Both
54
+ sessions this plugin can reach are therefore the wrong one, and for a long
55
+ time that was written down here as "screenshots are impossible mid-playtest".
56
+
57
+ They are not. The client is reachable the same way `input` reaches it: parent
58
+ a LocalScript into the player's PlayerGui and let it report back over a
59
+ RemoteEvent. The client cannot make HTTP requests, but it does not need to --
60
+ the server session it replicates to already holds an open bridge.
61
+
62
+ The client only takes the shot. It cannot read it: `CaptureService` hands
63
+ back a temporary texture id, and `CreateEditableImageAsync` refuses those at
64
+ script identity -- "cannot currently create editable image from temporary
65
+ texture id" -- which no amount of client-side code gets around.
66
+
67
+ The id is not client-local, though. It names a texture in the Studio
68
+ process, and the *editor* session's plugin runs there with plugin identity,
69
+ where the same call opens it without complaint. So the picture is taken in
70
+ one session and read in the other, and the pixels never cross a RemoteEvent
71
+ at all -- which also removes the size ceiling that a client-side encode had
72
+ to stay under. See `Capture.playtestId` and `Capture.decode`; `screenshot`
73
+ in the MCP server drives both halves.
74
+ ]]
75
+ local CLIENT_SOURCE = [==[
76
+ local CaptureService = game:GetService("CaptureService")
77
+
78
+ local relay = script
79
+ local report = relay:WaitForChild("Report", 10)
80
+ if report == nil then
81
+ return
82
+ end
83
+
84
+ local contentId = nil
85
+ local okShot, shotErr = pcall(function()
86
+ CaptureService:CaptureScreenshot(function(id)
87
+ contentId = id
88
+ end)
89
+ end)
90
+ if not okShot then
91
+ report:FireServer({ ok = false, reason = tostring(shotErr) })
92
+ return
93
+ end
94
+
95
+ local waited = 0
96
+ while contentId == nil and waited < 12 do
97
+ task.wait(0.05)
98
+ waited += 0.05
99
+ end
100
+ if contentId == nil then
101
+ report:FireServer({ ok = false, reason = "the client did not produce a screenshot within 12s" })
102
+ return
103
+ end
104
+
105
+ report:FireServer({ ok = true, contentId = contentId })
106
+ ]==]
107
+
108
+ --[[
109
+ Takes the shot and waits for the id.
110
+
111
+ `CaptureService:CaptureScreenshot` answers through a callback rather than
112
+ yielding, so this bridges the two: the handler is already running on its own
113
+ task and is free to wait.
114
+ ]]
115
+ local function takeScreenshot(): string
116
+ local contentId: string? = nil
117
+ local failed: string? = nil
118
+
119
+ local ok, err = pcall(function()
120
+ CaptureService:CaptureScreenshot(function(id: string)
121
+ contentId = id
122
+ end)
123
+ end)
124
+ if not ok then
125
+ failed = tostring(err)
126
+ end
127
+
128
+ if failed ~= nil then
129
+ Dispatch.fail(
130
+ "CAPTURE_REFUSED",
131
+ string.format("Studio refused to take a screenshot: %s", failed)
132
+ )
133
+ end
134
+
135
+ local waited = 0
136
+ while contentId == nil and waited < CAPTURE_TIMEOUT do
137
+ task.wait(0.05)
138
+ waited += 0.05
139
+ end
140
+
141
+ if contentId == nil then
142
+ Dispatch.fail(
143
+ "CAPTURE_TIMEOUT",
144
+ string.format("Studio did not return a screenshot within %ds.", CAPTURE_TIMEOUT),
145
+ "The viewport may be hidden or Studio may be busy; try again."
146
+ )
147
+ end
148
+
149
+ return contentId :: any
150
+ end
151
+
152
+ --[[
153
+ Half a playtest screenshot: the client takes the shot, this returns its id.
154
+
155
+ Deliberately does not try to read it. The editor session does that, because
156
+ only plugin identity may -- see the note on CLIENT_SOURCE.
157
+ ]]
158
+ function Capture.playtestId(_params: { [string]: any }): { [string]: any }
159
+ if RunService:IsEdit() or not RunService:IsRunning() then
160
+ Dispatch.fail(
161
+ "NOT_RUNNING",
162
+ "This is not a running playtest session, so there is no client to capture from.",
163
+ "Address this at the playtest's studioId from `list_studios`."
164
+ )
165
+ end
166
+
167
+ local players = Players:GetPlayers()
168
+ if #players == 0 then
169
+ Dispatch.fail(
170
+ "NO_PLAYER",
171
+ "No player is in this session, so there is no client to capture from.",
172
+ "Start a playtest with a character using `playtest op=\"play\"`."
173
+ )
174
+ end
175
+ local player = players[1]
176
+ local playerGui = player:FindFirstChildOfClass("PlayerGui")
177
+ if playerGui == nil then
178
+ Dispatch.fail("NO_PLAYER", string.format("%s has no PlayerGui yet.", player.Name))
179
+ end
180
+
181
+ local remote = Instance.new("RemoteEvent")
182
+ remote.Name = "Report"
183
+
184
+ local relay = Instance.new("LocalScript")
185
+ relay.Name = "MCPCaptureRelay"
186
+ relay.Source = CLIENT_SOURCE
187
+ remote.Parent = relay
188
+
189
+ local answer: { [string]: any }? = nil
190
+ local connection = remote.OnServerEvent:Connect(function(_from, payload)
191
+ if typeof(payload) == "table" then
192
+ answer = payload :: { [string]: any }
193
+ end
194
+ end)
195
+
196
+ -- Parented last, for the same reason as the input relay: the script runs the
197
+ -- instant it lands and immediately waits on the RemoteEvent.
198
+ relay.Parent = playerGui
199
+
200
+ local deadline = os.clock() + CLIENT_TIMEOUT
201
+ while answer == nil and os.clock() < deadline do
202
+ task.wait(0.05)
203
+ end
204
+
205
+ connection:Disconnect()
206
+ relay:Destroy()
207
+
208
+ if answer == nil then
209
+ Dispatch.fail(
210
+ "CAPTURE_TIMEOUT",
211
+ string.format("The client did not answer within %ds.", CLIENT_TIMEOUT),
212
+ "The client may still be loading; try again once the character has spawned."
213
+ )
214
+ end
215
+
216
+ local result = answer :: { [string]: any }
217
+ if result.ok ~= true then
218
+ Dispatch.fail("CAPTURE_REFUSED", string.format("The client could not capture: %s", tostring(result.reason)))
219
+ end
220
+
221
+ return { contentId = tostring(result.contentId), device = Emulation.deviceId() }
222
+ end
223
+
224
+ --[[
225
+ Turns a content id into pixels on the wire.
226
+
227
+ Split out from `screenshot` because a playtest shot is taken in one session
228
+ and read in another: the id is all that travels, and this is the reading
229
+ half, which needs plugin identity and therefore an editor session.
230
+ ]]
231
+ local function encode(contentId: string, width: number, context: string): { [string]: any }
232
+ local okImage, image = pcall(function()
233
+ return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
234
+ end)
235
+ if not okImage then
236
+ Dispatch.fail(
237
+ "CAPTURE_UNREADABLE",
238
+ string.format("The screenshot could not be opened for reading: %s", tostring(image))
239
+ )
240
+ end
241
+
242
+ local size = (image :: any).Size
243
+ local sourceWidth = math.floor(size.X)
244
+ local sourceHeight = math.floor(size.Y)
245
+
246
+ --[[
247
+ Both arguments are required. Called with none it reports "expects 2
248
+ arguments" rather than defaulting to the whole image, which is worth
249
+ stating because every other read on this object takes none.
250
+ ]]
251
+ local okPixels, pixels = pcall(function()
252
+ return (image :: any):ReadPixelsBuffer(Vector2.zero, size)
253
+ end)
254
+ if not okPixels then
255
+ Dispatch.fail(
256
+ "CAPTURE_UNREADABLE",
257
+ string.format("The screenshot's pixels could not be read: %s", tostring(pixels))
258
+ )
259
+ end
260
+
261
+ local rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
262
+
263
+ --[[
264
+ Raw pixels, compressed by the engine, assembled into a PNG by Node.
265
+
266
+ The plugin used to write the whole PNG itself, and could only write a bad
267
+ one: Studio has no deflate, so `Png.encode` emitted zlib *stored* blocks
268
+ -- the format's "compression not applied" escape hatch -- and every
269
+ screenshot travelled and landed at full uncompressed size. It was also
270
+ base64-ing by hand, a table lookup per byte in Luau.
271
+
272
+ `EncodingService` has both, natively. Zstd is the only algorithm the
273
+ engine exposes and PNG cannot use it, so the split is: the plugin
274
+ compresses the pixels for the wire, and Node -- which has real zlib --
275
+ decompresses and writes a properly deflated PNG. The side with the
276
+ compressor does the compressing.
277
+
278
+ Guarded rather than assumed: EncodingService is recent, and a Studio
279
+ without it should cost a bigger screenshot, not a failed one.
280
+ ]]
281
+ local okPacked, packed = pcall(function()
282
+ local compressed = (EncodingService :: any):CompressBuffer(
283
+ rgb,
284
+ (Enum :: any).CompressionAlgorithm.Zstd,
285
+ COMPRESSION_LEVEL
286
+ )
287
+ return buffer.tostring((EncodingService :: any):Base64Encode(compressed))
288
+ end)
289
+
290
+ if okPacked then
291
+ return {
292
+ encoding = "zstd-rgb",
293
+ data = packed,
294
+ width = outWidth,
295
+ height = outHeight,
296
+ sourceWidth = sourceWidth,
297
+ sourceHeight = sourceHeight,
298
+ rawBytes = buffer.len(rgb),
299
+ bytes = #(packed :: string),
300
+ context = context,
301
+ device = Emulation.deviceId(),
302
+ }
303
+ end
304
+
305
+ local png = Png.encode(rgb, outWidth, outHeight)
306
+
307
+ return {
308
+ encoding = "png",
309
+ data = Png.base64(png),
310
+ width = outWidth,
311
+ height = outHeight,
312
+ sourceWidth = sourceWidth,
313
+ sourceHeight = sourceHeight,
314
+ bytes = buffer.len(png),
315
+ context = context,
316
+ device = Emulation.deviceId(),
317
+ }
318
+ end
319
+
320
+ function Capture.screenshot(params: { [string]: any }): { [string]: any }
321
+ --[[
322
+ Refused rather than half-done. A playtest screenshot needs two sessions
323
+ and only the MCP server can see both, so failing here with the reason is
324
+ better than returning the editor's own idle viewport, which looks like a
325
+ valid answer to "what does the player see".
326
+ ]]
327
+ if RunService:IsRunning() and not RunService:IsEdit() then
328
+ Dispatch.fail(
329
+ "NEEDS_EDITOR_SESSION",
330
+ "A playtest screenshot is taken on the client and read in the editor session, "
331
+ .. "so it cannot be served from the playtest server alone.",
332
+ "Use the `screenshot` tool, which drives both halves. It needs the editor "
333
+ .. "session for the same place to still be connected."
334
+ )
335
+ end
336
+
337
+ local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
338
+ return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest")
339
+ end
340
+
341
+ --[[
342
+ Reads a content id someone else captured.
343
+
344
+ Only the editor session can do this, and only because the temporary texture
345
+ is owned by the Studio process rather than by the session that made it.
346
+ ]]
347
+ function Capture.decode(params: { [string]: any }): { [string]: any }
348
+ local contentId = params.contentId
349
+ if typeof(contentId) ~= "string" or contentId == "" then
350
+ Dispatch.fail("BAD_PARAMS", "decode needs a `contentId`.")
351
+ end
352
+
353
+ local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
354
+ local context = if typeof(params.context) == "string" then params.context else "playtest client"
355
+ return encode(contentId :: string, width, context)
356
+ end
357
+
358
+ function Capture.register()
359
+ Dispatch.registerAll("capture", {
360
+ screenshot = Capture.screenshot,
361
+ playtestId = Capture.playtestId,
362
+ decode = Capture.decode,
363
+ })
364
+ end
365
+
366
+ return Capture
@@ -215,7 +215,8 @@ local function requireService()
215
215
  "ScriptDebuggerService is not available in this session: %s",
216
216
  tostring(installError)
217
217
  ),
218
- "It is a beta API; the Studio build may not expose it yet."
218
+ "Turn on \"Debugger Luau API\" in File > Beta Features and restart Studio. "
219
+ .. "It is off by default, and the service is not registered until it is on."
219
220
  )
220
221
  end
221
222
  end