@el4cteo/rbx-studio-mcp 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,270 +1,314 @@
1
- --!strict
2
- --[[
3
- Arbitrary Luau, run in the plugin's context.
4
-
5
- This is the escape hatch, not the default. Every dedicated tool validates its
6
- input, types values from the API dump, and wraps writes in an undo recording;
7
- code run here does none of that. It exists for the cases nothing else covers.
8
-
9
- `LogService:ExecuteScript` -- the API the command bar uses -- is
10
- RobloxScriptSecurity, so this compiles with `loadstring` instead. Output is
11
- captured while the chunk runs, because "what did it print" is almost always
12
- the reason for running it at all.
13
- ]]
14
-
15
- local LogService = game:GetService("LogService")
16
-
17
- local Dispatch = require(script.Parent.Parent.Dispatch)
18
- local Serialize = require(script.Parent.Parent.Serialize)
19
-
20
- -- A chunk that prints in a loop would otherwise return a response no context
21
- -- window can hold.
22
- local MAX_OUTPUT_LINES = 200
23
- local MAX_RETURN_VALUES = 10
24
-
25
- -- Returned tables are walked rather than counted, but not without limit: a
26
- -- chunk that returns a whole config tree, or one with a cycle in it, must not be
27
- -- able to produce a response nothing can read.
28
- local MAX_TABLE_ENTRIES = 50
29
- local MAX_TABLE_DEPTH = 4
30
-
31
- local CHUNK_NAME = "StudioMCP.execute_luau"
32
-
33
- local Exec = {}
34
-
35
- --[[
36
- Describes a returned value.
37
-
38
- Tables used to come back as "<table with 5 entries>", which threw away the
39
- answer whenever the answer was a table -- and returning a table is the natural
40
- way to report several things at once, so this hit constantly. They are walked
41
- now, bounded by breadth and depth. Everything else goes through the same
42
- serializer the rest of the server uses, so a Vector3 reads the way it does
43
- everywhere else.
44
- ]]
45
- local function describe(value: any, depth: number?): any
46
- local level = depth or 0
47
- local kind = typeof(value)
48
-
49
- if kind == "function" or kind == "thread" then
50
- return string.format("<%s>", kind)
51
- end
52
- if kind ~= "table" then
53
- return Serialize.value(value)
54
- end
55
-
56
- local source = value :: { [any]: any }
57
- -- Also the cycle guard: a self-referencing table stops here rather than
58
- -- recursing until the stack gives out.
59
- if level >= MAX_TABLE_DEPTH then
60
- return "<nested table>"
61
- end
62
-
63
- local count = 0
64
- for _ in source do
65
- count += 1
66
- end
67
-
68
- -- Arrays keep their shape so they read as lists on the other side; anything
69
- -- with non-sequential keys becomes a map with stringified keys.
70
- if count == #source then
71
- local list: { any } = {}
72
- for index, item in ipairs(source) do
73
- if index > MAX_TABLE_ENTRIES then
74
- table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
75
- break
76
- end
77
- table.insert(list, describe(item, level + 1))
78
- end
79
- return list
80
- end
81
-
82
- local map: { [string]: any } = {}
83
- local shown = 0
84
- for key, item in source do
85
- shown += 1
86
- if shown > MAX_TABLE_ENTRIES then
87
- map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
88
- break
89
- end
90
- map[tostring(key)] = describe(item, level + 1)
91
- end
92
- return map
93
- end
94
-
95
- --[[
96
- Compiles the chunk by whichever route this session actually allows.
97
-
98
- `loadstring` is the direct one and the only one that keeps plugin identity,
99
- but the global exists and *throws* unless `ServerScriptService.LoadStringEnabled`
100
- is set -- which it is not by default. So every call against a running playtest
101
- server failed with "loadstring() is not available" while the identical code ran
102
- fine in the editor session, which is the worst shape a limitation can take.
103
-
104
- The fallback compiles through a ModuleScript: a plugin may set `.Source`, and
105
- requiring a fresh, unparented instance runs it without touching the place. The
106
- cost is real and gets reported back rather than hidden -- a required module
107
- runs at script identity, so plugin-only APIs are out of reach on that path.
108
-
109
- Returns the callable, a compile error, which route was taken, and any module
110
- to clean up once the chunk has finished with it.
111
- ]]
112
- local function compile(
113
- source: string
114
- ): (((...any) -> ...any)?, string?, string, ModuleScript?)
115
- local ok, chunk, syntaxError = pcall(loadstring :: any, source, CHUNK_NAME)
116
- if ok then
117
- if typeof(chunk) == "function" then
118
- return chunk, nil, "loadstring", nil
119
- end
120
- return nil, tostring(syntaxError), "loadstring", nil
121
- end
122
-
123
- -- `chunk` carries the pcall failure when `ok` is false.
124
- local refusal = tostring(chunk)
125
-
126
- local module = Instance.new("ModuleScript")
127
- -- The wrapper opens on the same line the source starts on, so a syntax error
128
- -- still reports the line the caller wrote.
129
- local settable = pcall(function()
130
- module.Source = "return function(...) " .. source .. "\nend"
131
- end)
132
- if not settable then
133
- module:Destroy()
134
- return nil,
135
- string.format(
136
- "no route to compile is open in this session: loadstring refused (%s) "
137
- .. "and setting a script's source is not permitted either",
138
- refusal
139
- ),
140
- "none",
141
- nil
142
- end
143
-
144
- local required, result = pcall(require, module)
145
- if not required then
146
- module:Destroy()
147
- return nil, tostring(result), "module", nil
148
- end
149
- if typeof(result) ~= "function" then
150
- module:Destroy()
151
- return nil, "the chunk compiled but produced nothing callable", "module", nil
152
- end
153
- return result :: any, nil, "module", module
154
- end
155
-
156
- function Exec.run(params: { [string]: any }): { [string]: any }
157
- local source = params.source
158
- if typeof(source) ~= "string" or source == "" then
159
- Dispatch.fail(
160
- "BAD_PARAMS",
161
- "execute_luau requires `source`.",
162
- "Pass the Luau to run as a string."
163
- )
164
- end
165
-
166
- local chunk, compileError, route, module = compile(source)
167
- if chunk == nil then
168
- if route == "none" then
169
- Dispatch.fail(
170
- "NO_COMPILER",
171
- string.format("Cannot run code in this session: %s", tostring(compileError)),
172
- "Use the dedicated tools instead: create, modify, script_edit and find "
173
- .. "cover most of what execute_luau is reached for."
174
- )
175
- end
176
- Dispatch.fail(
177
- "COMPILE_ERROR",
178
- string.format("The code did not compile: %s", tostring(compileError)),
179
- "Fix the syntax and try again. The chunk is compiled as a whole, so a "
180
- .. "stray end or missing then reports here rather than at a line."
181
- )
182
- end
183
-
184
- -- Captured rather than inferred: a chunk's prints are usually the point, and
185
- -- reading them back from the log afterwards would also pick up whatever else
186
- -- the place logged in the meantime.
187
- local output: { { [string]: any } } = {}
188
- local connection = LogService.MessageOut:Connect(function(message, messageType)
189
- if #output < MAX_OUTPUT_LINES then
190
- table.insert(output, {
191
- message = message,
192
- level = if messageType == Enum.MessageType.MessageError
193
- then "error"
194
- elseif messageType == Enum.MessageType.MessageWarning then "warning"
195
- else "print",
196
- })
197
- end
198
- end)
199
-
200
- local started = os.clock()
201
- local results = table.pack(pcall(chunk :: () -> ...any))
202
- local elapsed = os.clock() - started
203
-
204
- --[[
205
- MessageOut is deferred: `print` returns before the event fires, so
206
- disconnecting straight after the chunk finished captured nothing at all.
207
- A short settle picks the lines up.
208
-
209
- It is bounded and stops as soon as the flow stops, rather than waiting a
210
- fixed period, so a chunk that logged nothing costs almost no time and one
211
- that logged plenty is not truncated.
212
- ]]
213
- local settleDeadline = os.clock() + 0.5
214
- local seen = #output
215
- local quietFrames = 0
216
- while os.clock() < settleDeadline and quietFrames < 3 do
217
- task.wait()
218
- if #output == seen then
219
- quietFrames += 1
220
- else
221
- seen = #output
222
- quietFrames = 0
223
- end
224
- end
225
-
226
- connection:Disconnect()
227
-
228
- if module then
229
- module:Destroy()
230
- end
231
-
232
- -- Only mentioned when it constrains what the code could do. On the direct
233
- -- route there is nothing to say.
234
- local identity = if route == "module"
235
- then "Ran through a ModuleScript because loadstring is disabled in this "
236
- .. "session, so the code held script identity rather than plugin "
237
- .. "identity -- plugin-only APIs would have been unavailable to it."
238
- else nil
239
-
240
- if not results[1] then
241
- return {
242
- ok = false,
243
- error = tostring(results[2]),
244
- output = output,
245
- note = identity,
246
- milliseconds = math.floor(elapsed * 1000 + 0.5),
247
- }
248
- end
249
-
250
- local returned: { any } = {}
251
- for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
252
- table.insert(returned, describe(results[index]))
253
- end
254
-
255
- return {
256
- ok = true,
257
- returned = returned,
258
- output = output,
259
- note = identity,
260
- milliseconds = math.floor(elapsed * 1000 + 0.5),
261
- }
262
- end
263
-
264
- function Exec.register()
265
- Dispatch.registerAll("exec", {
266
- run = Exec.run,
267
- })
268
- end
269
-
270
- return Exec
1
+ --!strict
2
+ --[[
3
+ Arbitrary Luau, run in the plugin's context.
4
+
5
+ This is the escape hatch, not the default. Every dedicated tool validates its
6
+ input, types values from the API dump, and wraps writes in an undo recording;
7
+ code run here does none of that. It exists for the cases nothing else covers.
8
+
9
+ `LogService:ExecuteScript` -- the API the command bar uses -- is
10
+ RobloxScriptSecurity, so this compiles with `loadstring` instead. Output is
11
+ captured while the chunk runs, because "what did it print" is almost always
12
+ the reason for running it at all.
13
+ ]]
14
+
15
+ local LogService = game:GetService("LogService")
16
+ local RunService = game:GetService("RunService")
17
+
18
+ local Dispatch = require(script.Parent.Parent.Dispatch)
19
+ local Serialize = require(script.Parent.Parent.Serialize)
20
+
21
+ -- A chunk that prints in a loop would otherwise return a response no context
22
+ -- window can hold.
23
+ local MAX_OUTPUT_LINES = 200
24
+ local MAX_RETURN_VALUES = 10
25
+
26
+ -- Returned tables are walked rather than counted, but not without limit: a
27
+ -- chunk that returns a whole config tree, or one with a cycle in it, must not be
28
+ -- able to produce a response nothing can read.
29
+ local MAX_TABLE_ENTRIES = 50
30
+ local MAX_TABLE_DEPTH = 4
31
+
32
+ local CHUNK_NAME = "StudioMCP.execute_luau"
33
+
34
+ local Exec = {}
35
+
36
+ --[[
37
+ Describes a returned value.
38
+
39
+ Tables used to come back as "<table with 5 entries>", which threw away the
40
+ answer whenever the answer was a table -- and returning a table is the natural
41
+ way to report several things at once, so this hit constantly. They are walked
42
+ now, bounded by breadth and depth. Everything else goes through the same
43
+ serializer the rest of the server uses, so a Vector3 reads the way it does
44
+ everywhere else.
45
+ ]]
46
+ local function describe(value: any, depth: number?): any
47
+ local level = depth or 0
48
+ local kind = typeof(value)
49
+
50
+ if kind == "function" or kind == "thread" then
51
+ return string.format("<%s>", kind)
52
+ end
53
+ if kind ~= "table" then
54
+ return Serialize.value(value)
55
+ end
56
+
57
+ local source = value :: { [any]: any }
58
+ -- Also the cycle guard: a self-referencing table stops here rather than
59
+ -- recursing until the stack gives out.
60
+ if level >= MAX_TABLE_DEPTH then
61
+ return "<nested table>"
62
+ end
63
+
64
+ local count = 0
65
+ for _ in source do
66
+ count += 1
67
+ end
68
+
69
+ -- Arrays keep their shape so they read as lists on the other side; anything
70
+ -- with non-sequential keys becomes a map with stringified keys.
71
+ if count == #source then
72
+ local list: { any } = {}
73
+ for index, item in ipairs(source) do
74
+ if index > MAX_TABLE_ENTRIES then
75
+ table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
76
+ break
77
+ end
78
+ table.insert(list, describe(item, level + 1))
79
+ end
80
+ return list
81
+ end
82
+
83
+ local map: { [string]: any } = {}
84
+ local shown = 0
85
+ for key, item in source do
86
+ shown += 1
87
+ if shown > MAX_TABLE_ENTRIES then
88
+ map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
89
+ break
90
+ end
91
+ map[tostring(key)] = describe(item, level + 1)
92
+ end
93
+ return map
94
+ end
95
+
96
+ --[[
97
+ Compiles the chunk by whichever route this session actually allows.
98
+
99
+ `loadstring` is the direct one and the only one that keeps plugin identity,
100
+ but the global exists and *throws* unless `ServerScriptService.LoadStringEnabled`
101
+ is set -- which it is not by default. So every call against a running playtest
102
+ server failed with "loadstring() is not available" while the identical code ran
103
+ fine in the editor session, which is the worst shape a limitation can take.
104
+
105
+ The fallback compiles through a ModuleScript: a plugin may set `.Source`, and
106
+ requiring a fresh, unparented instance runs it without touching the place. The
107
+ cost is real and gets reported back rather than hidden -- a required module
108
+ runs at script identity, so plugin-only APIs are out of reach on that path.
109
+
110
+ Returns the callable, a compile error, which route was taken, and any module
111
+ to clean up once the chunk has finished with it.
112
+ ]]
113
+ local function compile(
114
+ source: string
115
+ ): (((...any) -> ...any)?, string?, string, ModuleScript?)
116
+ local ok, chunk, syntaxError = pcall(loadstring :: any, source, CHUNK_NAME)
117
+ if ok then
118
+ if typeof(chunk) == "function" then
119
+ return chunk, nil, "loadstring", nil
120
+ end
121
+ return nil, tostring(syntaxError), "loadstring", nil
122
+ end
123
+
124
+ -- `chunk` carries the pcall failure when `ok` is false.
125
+ local refusal = tostring(chunk)
126
+
127
+ local module = Instance.new("ModuleScript")
128
+ -- The wrapper opens on the same line the source starts on, so a syntax error
129
+ -- still reports the line the caller wrote.
130
+ local settable = pcall(function()
131
+ module.Source = "return function(...) " .. source .. "\nend"
132
+ end)
133
+ if not settable then
134
+ module:Destroy()
135
+ return nil,
136
+ string.format(
137
+ "no route to compile is open in this session: loadstring refused (%s) "
138
+ .. "and setting a script's source is not permitted either",
139
+ refusal
140
+ ),
141
+ "none",
142
+ nil
143
+ end
144
+
145
+ local required, result = pcall(require, module)
146
+ if not required then
147
+ module:Destroy()
148
+ return nil, tostring(result), "module", nil
149
+ end
150
+ if typeof(result) ~= "function" then
151
+ module:Destroy()
152
+ return nil, "the chunk compiled but produced nothing callable", "module", nil
153
+ end
154
+ return result :: any, nil, "module", module
155
+ end
156
+
157
+ --[[
158
+ Warnings about what the code could and could not see, in the result.
159
+
160
+ The module-cache one is the important one, and it cost a session before it
161
+ existed. This chunk runs in the plugin's VM, which keeps its own `require`
162
+ cache; the running game keeps its own. So `require(SomeModule)` here loads a
163
+ *second copy* -- fresh upvalues, fresh state -- and reading a counter off it
164
+ returns the zero it was initialised with while the real module, in the game's
165
+ VM, is working perfectly. A zero that means "wrong copy" is indistinguishable
166
+ from a zero that means "nothing happened", so the only defence is saying so
167
+ every time it could be happening.
168
+
169
+ Only raised while the game is actually running, since in an editor session
170
+ there is no other copy for the answer to disagree with.
171
+ ]]
172
+ local function notes(route: string, source: string): string?
173
+ local lines: { string } = {}
174
+
175
+ -- Only mentioned when it constrains what the code could do. On the direct
176
+ -- route there is nothing to say.
177
+ if route == "module" then
178
+ table.insert(
179
+ lines,
180
+ "Ran through a ModuleScript because loadstring is disabled in this "
181
+ .. "session, so the code held script identity rather than plugin "
182
+ .. "identity -- plugin-only APIs would have been unavailable to it."
183
+ )
184
+ end
185
+
186
+ if string.find(source, "require", 1, true) and RunService:IsRunning() then
187
+ table.insert(
188
+ lines,
189
+ "This code called `require` while the game is running, and it does NOT "
190
+ .. "share the running game's module cache -- it got its own fresh copy "
191
+ .. "of each ModuleScript, with state reset to whatever the module "
192
+ .. "initialises. Any counter, cache or table read that way reports its "
193
+ .. "starting value, not the live one, and a zero here does not mean "
194
+ .. "nothing happened. To read live state, read it off the DataModel "
195
+ .. "(instances, attributes, properties) or have the running code print "
196
+ .. "it and read that with `console`."
197
+ )
198
+ end
199
+
200
+ if #lines == 0 then
201
+ return nil
202
+ end
203
+ return table.concat(lines, "\n\n")
204
+ end
205
+
206
+ function Exec.run(params: { [string]: any }): { [string]: any }
207
+ local source = params.source
208
+ if typeof(source) ~= "string" or source == "" then
209
+ Dispatch.fail(
210
+ "BAD_PARAMS",
211
+ "execute_luau requires `source`.",
212
+ "Pass the Luau to run as a string."
213
+ )
214
+ end
215
+
216
+ local chunk, compileError, route, module = compile(source)
217
+ if chunk == nil then
218
+ if route == "none" then
219
+ Dispatch.fail(
220
+ "NO_COMPILER",
221
+ string.format("Cannot run code in this session: %s", tostring(compileError)),
222
+ "Use the dedicated tools instead: create, modify, script_edit and find "
223
+ .. "cover most of what execute_luau is reached for."
224
+ )
225
+ end
226
+ Dispatch.fail(
227
+ "COMPILE_ERROR",
228
+ string.format("The code did not compile: %s", tostring(compileError)),
229
+ "Fix the syntax and try again. The chunk is compiled as a whole, so a "
230
+ .. "stray end or missing then reports here rather than at a line."
231
+ )
232
+ end
233
+
234
+ -- Captured rather than inferred: a chunk's prints are usually the point, and
235
+ -- reading them back from the log afterwards would also pick up whatever else
236
+ -- the place logged in the meantime.
237
+ local output: { { [string]: any } } = {}
238
+ local connection = LogService.MessageOut:Connect(function(message, messageType)
239
+ if #output < MAX_OUTPUT_LINES then
240
+ table.insert(output, {
241
+ message = message,
242
+ level = if messageType == Enum.MessageType.MessageError
243
+ then "error"
244
+ elseif messageType == Enum.MessageType.MessageWarning then "warning"
245
+ else "print",
246
+ })
247
+ end
248
+ end)
249
+
250
+ local started = os.clock()
251
+ local results = table.pack(pcall(chunk :: () -> ...any))
252
+ local elapsed = os.clock() - started
253
+
254
+ --[[
255
+ MessageOut is deferred: `print` returns before the event fires, so
256
+ disconnecting straight after the chunk finished captured nothing at all.
257
+ A short settle picks the lines up.
258
+
259
+ It is bounded and stops as soon as the flow stops, rather than waiting a
260
+ fixed period, so a chunk that logged nothing costs almost no time and one
261
+ that logged plenty is not truncated.
262
+ ]]
263
+ local settleDeadline = os.clock() + 0.5
264
+ local seen = #output
265
+ local quietFrames = 0
266
+ while os.clock() < settleDeadline and quietFrames < 3 do
267
+ task.wait()
268
+ if #output == seen then
269
+ quietFrames += 1
270
+ else
271
+ seen = #output
272
+ quietFrames = 0
273
+ end
274
+ end
275
+
276
+ connection:Disconnect()
277
+
278
+ if module then
279
+ module:Destroy()
280
+ end
281
+
282
+ local identity = notes(route, source)
283
+
284
+ if not results[1] then
285
+ return {
286
+ ok = false,
287
+ error = tostring(results[2]),
288
+ output = output,
289
+ note = identity,
290
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
291
+ }
292
+ end
293
+
294
+ local returned: { any } = {}
295
+ for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
296
+ table.insert(returned, describe(results[index]))
297
+ end
298
+
299
+ return {
300
+ ok = true,
301
+ returned = returned,
302
+ output = output,
303
+ note = identity,
304
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
305
+ }
306
+ end
307
+
308
+ function Exec.register()
309
+ Dispatch.registerAll("exec", {
310
+ run = Exec.run,
311
+ })
312
+ end
313
+
314
+ return Exec