@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.
- package/dist/index.js +1 -1
- package/dist/lib/apidump.js +88 -6
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/pluginbuild.js +31 -0
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/tools/discover.js +51 -6
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +10 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +17 -1
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/scripts.js +37 -10
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/session.js +21 -5
- package/dist/tools/session.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Paths.luau +288 -255
- package/plugin/src/handlers/Exec.luau +314 -270
- package/plugin/src/handlers/Scripts.luau +405 -387
|
@@ -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
|
-
|
|
18
|
-
local
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
--
|
|
22
|
-
|
|
23
|
-
local
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
--
|
|
27
|
-
--
|
|
28
|
-
|
|
29
|
-
local
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
local
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
--
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
--
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
local
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
--
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
local
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|