@el4cteo/rbx-studio-mcp 0.8.0 → 0.8.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 +5 -3
- package/dist/bridge/api.js +2 -2
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/failover.js +12 -4
- package/dist/bridge/failover.js.map +1 -1
- package/dist/bridge/harness.js +48 -6
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/remote.js +4 -2
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +43 -5
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +26 -1
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/apidump.js +82 -15
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/cloudassets.js +2 -2
- package/dist/lib/cloudassets.js.map +1 -1
- package/dist/lib/livedata.js +24 -1
- package/dist/lib/livedata.js.map +1 -1
- package/dist/lib/liveops.js +79 -0
- package/dist/lib/liveops.js.map +1 -1
- package/dist/lib/monetization.js +119 -0
- package/dist/lib/monetization.js.map +1 -0
- package/dist/lib/opencloud.js +16 -6
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/lib/png.js +19 -31
- package/dist/lib/png.js.map +1 -1
- package/dist/tools/audio.js +1 -1
- package/dist/tools/audio.js.map +1 -1
- package/dist/tools/device.js +8 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +86 -5
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/instances.js +3 -1
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +27 -5
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +35 -10
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/session.js +11 -3
- package/dist/tools/session.js.map +1 -1
- package/dist/tools/terrain.js +2 -2
- package/dist/tools/terrain.js.map +1 -1
- package/dist/tools/universe.js +136 -11
- package/dist/tools/universe.js.map +1 -1
- package/dist/tools/world.js +30 -7
- package/dist/tools/world.js.map +1 -1
- package/package.json +4 -4
- package/plugin/src/ClientRelay.luau +20 -2
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Emulation.luau +44 -16
- package/plugin/src/ExecRuntime.luau +168 -161
- package/plugin/src/LogBuffer.luau +58 -3
- package/plugin/src/Phrase.luau +2 -0
- package/plugin/src/handlers/Assets.luau +27 -18
- package/plugin/src/handlers/Audio.luau +36 -15
- package/plugin/src/handlers/Character.luau +38 -23
- package/plugin/src/handlers/Data.luau +12 -1
- package/plugin/src/handlers/Debug.luau +42 -0
- package/plugin/src/handlers/Device.luau +38 -27
- package/plugin/src/handlers/Exec.luau +5 -1
- package/plugin/src/handlers/Generate.luau +8 -5
- package/plugin/src/handlers/Geometry.luau +7 -5
- package/plugin/src/handlers/Perf.luau +1 -0
- package/plugin/src/handlers/Playtest.luau +40 -3
- package/plugin/src/handlers/Spatial.luau +34 -0
- package/plugin/src/handlers/Terrain.luau +19 -24
- package/plugin/src/handlers/Viewport.luau +37 -5
- package/plugin/src/handlers/World.luau +38 -2
- package/plugin/src/init.server.luau +11 -0
- package/scripts/check-plugin.mjs +54 -0
- package/scripts/test-apidump.mjs +170 -0
- package/scripts/test-bridge.mjs +111 -4
- package/scripts/test-live-tools.mjs +382 -0
- package/scripts/test-tools.mjs +222 -0
|
@@ -130,6 +130,16 @@ end
|
|
|
130
130
|
something smaller.
|
|
131
131
|
]]
|
|
132
132
|
local function guard(size: Vector3, what: string)
|
|
133
|
+
-- An empty extent is not a small fill, it is no fill: the engine raised
|
|
134
|
+
-- "Extents cannot be empty" from inside the recording, and that came back as
|
|
135
|
+
-- an unstructured HANDLER_ERROR with no hint which side was zero.
|
|
136
|
+
if size.X <= 0 or size.Y <= 0 or size.Z <= 0 then
|
|
137
|
+
Dispatch.fail(
|
|
138
|
+
"BAD_PARAMS",
|
|
139
|
+
string.format("%s has a size of %s, which is empty.", what, tostring(size)),
|
|
140
|
+
"Every side of the size has to be greater than zero."
|
|
141
|
+
)
|
|
142
|
+
end
|
|
133
143
|
local voxels = math.ceil(size.X / RESOLUTION)
|
|
134
144
|
* math.ceil(size.Y / RESOLUTION)
|
|
135
145
|
* math.ceil(size.Z / RESOLUTION)
|
|
@@ -319,43 +329,28 @@ end
|
|
|
319
329
|
What is there, without reading a voxel.
|
|
320
330
|
|
|
321
331
|
`CountCells` is the cheap answer to "does this place even use terrain",
|
|
322
|
-
which is the question worth asking before any of the above.
|
|
323
|
-
tells an agent where to aim without guessing at the origin.
|
|
332
|
+
which is the question worth asking before any of the above.
|
|
324
333
|
]]
|
|
325
334
|
function Terrain.stats(_params: { [string]: any }): { [string]: any }
|
|
326
335
|
local field = terrain()
|
|
327
336
|
|
|
328
337
|
--[[
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
`MaxExtents` is a Region3int16 and its corners are Vector3int16, which is
|
|
332
|
-
a type `Serialize.value` has no case for -- it would have fallen through
|
|
333
|
-
to the generic tail and reported whatever `tostring` makes of it. The
|
|
334
|
-
extents are also in VOXELS, not studs, which is the sort of unit
|
|
335
|
-
confusion that produces a fill a thousand studs from where it was meant,
|
|
336
|
-
so they are converted and named for what they are.
|
|
337
|
-
]]
|
|
338
|
-
--[[
|
|
339
|
-
`MaxExtents` is deliberately not reported.
|
|
338
|
+
No bounds, and no limit.
|
|
340
339
|
|
|
341
|
-
|
|
342
|
-
not: it
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
misreading of the name -- checked against a place with 2220 cells in a
|
|
346
|
-
160-stud patch, which returned exactly the same numbers as one with
|
|
347
|
-
none.
|
|
340
|
+
`MaxExtents` reads like the bounding box of the terrain that exists and is
|
|
341
|
+
not: it answers -32000..32000 on an empty place and a full one alike. Nor
|
|
342
|
+
is it a limit in studs -- a fill at 140,000 studs out still landed -- so
|
|
343
|
+
the `limitStuds = 32000` this used to report was wrong as well.
|
|
348
344
|
|
|
349
345
|
There is no engine call for the occupied box, and finding it means
|
|
350
346
|
reading every voxel in the world, which is far too expensive for a
|
|
351
|
-
question meant to be cheap. `cells`
|
|
352
|
-
matters: whether this place uses terrain at all.
|
|
347
|
+
question meant to be cheap. `cells` answers the one that matters.
|
|
353
348
|
]]
|
|
354
349
|
return {
|
|
355
350
|
cells = field:CountCells(),
|
|
356
351
|
waterColor = Serialize.value(field.WaterColor),
|
|
357
|
-
|
|
358
|
-
|
|
352
|
+
-- Through Serialize so a float32 reads 0.15, not 0.15000000596046448.
|
|
353
|
+
waterWaveSize = Serialize.value(field.WaterWaveSize),
|
|
359
354
|
}
|
|
360
355
|
end
|
|
361
356
|
|
|
@@ -67,6 +67,20 @@ function Viewport.raycast(params: { [string]: any }): { [string]: any }
|
|
|
67
67
|
local direction = toVector(params.direction, "direction")
|
|
68
68
|
local length = tonumber(params.maxDistance) or DEFAULT_RAY_LENGTH
|
|
69
69
|
|
|
70
|
+
--[[
|
|
71
|
+
A zero direction has no `Unit` -- it is NaN -- and a ray along NaN hits
|
|
72
|
+
nothing, which the reply then reported as "nothing within 1000 studs". That
|
|
73
|
+
is a confident answer about empty space to a question that was never asked,
|
|
74
|
+
so the input is refused instead.
|
|
75
|
+
]]
|
|
76
|
+
if direction.Magnitude == 0 then
|
|
77
|
+
Dispatch.fail(
|
|
78
|
+
"BAD_PARAMS",
|
|
79
|
+
"`direction` cannot be zero: a ray needs a way to point.",
|
|
80
|
+
'Give a direction such as "0, -1, 0" for straight down.'
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
local filter: { Instance } = {}
|
|
71
85
|
for _, path in (params.ignore or {}) :: { string } do
|
|
72
86
|
table.insert(filter, Paths.resolve(path))
|
|
@@ -92,7 +106,7 @@ function Viewport.raycast(params: { [string]: any }): { [string]: any }
|
|
|
92
106
|
position = Serialize.value(cast.Position),
|
|
93
107
|
normal = Serialize.value(cast.Normal),
|
|
94
108
|
distance = math.floor(cast.Distance * 1000 + 0.5) / 1000,
|
|
95
|
-
material =
|
|
109
|
+
material = cast.Material.Name,
|
|
96
110
|
}
|
|
97
111
|
end
|
|
98
112
|
|
|
@@ -225,7 +239,17 @@ function Viewport.focus(params: { [string]: any }): { [string]: any }
|
|
|
225
239
|
-- with a flat surface where it would vanish to a line.
|
|
226
240
|
local direction: Vector3
|
|
227
241
|
if typeof(params.from) == "string" and params.from ~= "" then
|
|
228
|
-
|
|
242
|
+
local requested = toVector(params.from, "`from`")
|
|
243
|
+
-- A zero vector has no direction: its `Unit` is NaN, so the eye position was
|
|
244
|
+
-- NaN and the camera was handed a CFrame that is not a place at all.
|
|
245
|
+
if requested.Magnitude == 0 then
|
|
246
|
+
Dispatch.fail(
|
|
247
|
+
"BAD_PARAMS",
|
|
248
|
+
"`from` cannot be zero: it is the direction to view the subject from.",
|
|
249
|
+
'Give a direction such as "0, 1, 0" to look down from above.'
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
direction = requested.Unit
|
|
229
253
|
else
|
|
230
254
|
direction = Vector3.new(0.45, 0.35, 1).Unit
|
|
231
255
|
end
|
|
@@ -278,6 +302,14 @@ function Viewport.camera(params: { [string]: any }): { [string]: any }
|
|
|
278
302
|
local target = if params.lookAt ~= nil
|
|
279
303
|
then toVector(params.lookAt, "`lookAt`")
|
|
280
304
|
else eye + view.CFrame.LookVector
|
|
305
|
+
-- Aiming at where the camera already is has no direction to face.
|
|
306
|
+
if (target - eye).Magnitude == 0 then
|
|
307
|
+
Dispatch.fail(
|
|
308
|
+
"BAD_PARAMS",
|
|
309
|
+
"`position` and `lookAt` are the same point, so there is nothing to face.",
|
|
310
|
+
"Move `lookAt` to what the camera should be looking at."
|
|
311
|
+
)
|
|
312
|
+
end
|
|
281
313
|
view.CFrame = CFrame.lookAt(eye, target)
|
|
282
314
|
-- Same reason as focus: without this the rotation survives a frame.
|
|
283
315
|
view.Focus = CFrame.new(target)
|
|
@@ -287,9 +319,9 @@ function Viewport.camera(params: { [string]: any }): { [string]: any }
|
|
|
287
319
|
end
|
|
288
320
|
|
|
289
321
|
return {
|
|
290
|
-
position =
|
|
291
|
-
lookVector =
|
|
292
|
-
fieldOfView = view.FieldOfView,
|
|
322
|
+
position = Serialize.value(view.CFrame.Position),
|
|
323
|
+
lookVector = Serialize.value(view.CFrame.LookVector),
|
|
324
|
+
fieldOfView = Serialize.value(view.FieldOfView),
|
|
293
325
|
}
|
|
294
326
|
end
|
|
295
327
|
|
|
@@ -213,6 +213,19 @@ function World.collision(params: { [string]: any }): { [string]: any }
|
|
|
213
213
|
if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
|
|
214
214
|
Dispatch.fail("BAD_PARAMS", "assign needs a `paths` array.")
|
|
215
215
|
end
|
|
216
|
+
-- The engine takes any string here, registered or not, and a part in an
|
|
217
|
+
-- unregistered group collides as Default: a typo that changes nothing
|
|
218
|
+
-- and reports success. Measured, so refused up front.
|
|
219
|
+
local okRegistered, registered = pcall(function()
|
|
220
|
+
return world:IsCollisionGroupRegistered(name)
|
|
221
|
+
end)
|
|
222
|
+
if okRegistered and registered == false then
|
|
223
|
+
Dispatch.fail(
|
|
224
|
+
"NO_SUCH_GROUP",
|
|
225
|
+
string.format("No collision group %q is registered in %s.", name, Paths.of(world)),
|
|
226
|
+
'Create it first with action="create", or check the name with action="list".'
|
|
227
|
+
)
|
|
228
|
+
end
|
|
216
229
|
local assigned: { string } = {}
|
|
217
230
|
local _, undoable = Undo.record("MCPCollision", "MCP collision group", function()
|
|
218
231
|
for _, path in paths :: { string } do
|
|
@@ -228,7 +241,10 @@ function World.collision(params: { [string]: any }): { [string]: any }
|
|
|
228
241
|
end
|
|
229
242
|
end
|
|
230
243
|
end)
|
|
231
|
-
|
|
244
|
+
-- A model can hold thousands of parts; the count is the answer and a
|
|
245
|
+
-- sample of names is enough to see it landed where intended.
|
|
246
|
+
local sample = if #assigned > 20 then table.move(assigned, 1, 20, 1, {}) else assigned
|
|
247
|
+
return { group = name, assigned = #assigned, parts = sample, undoable = undoable, world = Paths.of(world) }
|
|
232
248
|
end
|
|
233
249
|
|
|
234
250
|
if action == "remove" then
|
|
@@ -241,13 +257,33 @@ function World.collision(params: { [string]: any }): { [string]: any }
|
|
|
241
257
|
way to remove it short of `execute_luau`. `UnregisterCollisionGroup`
|
|
242
258
|
has existed the whole time; it was just never wired up.
|
|
243
259
|
]]
|
|
260
|
+
--[[
|
|
261
|
+
The engine unregisters a group that was never registered without a word,
|
|
262
|
+
so a misspelt name answered `removed = true` and removed nothing -- while
|
|
263
|
+
the group the caller meant stayed registered. Said plainly instead, and
|
|
264
|
+
not as an error: removing something already gone is a cleanup that
|
|
265
|
+
worked, and treating it as a failure would break every loop that ends
|
|
266
|
+
by tidying up.
|
|
267
|
+
]]
|
|
268
|
+
local okRegistered, registered = pcall(function()
|
|
269
|
+
return world:IsCollisionGroupRegistered(name)
|
|
270
|
+
end)
|
|
271
|
+
if okRegistered and registered == false then
|
|
272
|
+
return {
|
|
273
|
+
group = name,
|
|
274
|
+
removed = false,
|
|
275
|
+
existed = false,
|
|
276
|
+
world = Paths.of(world),
|
|
277
|
+
note = 'No group by that name is registered, so nothing was removed. Check the spelling with action="list".',
|
|
278
|
+
}
|
|
279
|
+
end
|
|
244
280
|
local ok, err = pcall(function()
|
|
245
281
|
world:UnregisterCollisionGroup(name)
|
|
246
282
|
end)
|
|
247
283
|
if not ok then
|
|
248
284
|
Dispatch.fail("COLLISION_FAILED", string.format("Could not remove %q: %s", name, tostring(err)))
|
|
249
285
|
end
|
|
250
|
-
return { group = name, removed = true, world = Paths.of(world) }
|
|
286
|
+
return { group = name, removed = true, existed = true, world = Paths.of(world) }
|
|
251
287
|
end
|
|
252
288
|
|
|
253
289
|
if action == "collidable" then
|
|
@@ -398,10 +398,21 @@ local function trace(what: string)
|
|
|
398
398
|
end)
|
|
399
399
|
end
|
|
400
400
|
|
|
401
|
+
--[[
|
|
402
|
+
Set the moment the plugin starts to unload.
|
|
403
|
+
|
|
404
|
+
`persistSize` checked a variable of this name that was never declared, so it
|
|
405
|
+
read as nil and the guard never applied. A widget that is being torn down
|
|
406
|
+
reports whatever size it collapses through, and only the minimum-size check
|
|
407
|
+
stood between that and the saved layout.
|
|
408
|
+
]]
|
|
409
|
+
local unloading = false
|
|
410
|
+
|
|
401
411
|
if RunService:IsEdit() then
|
|
402
412
|
trace(string.format("load open=%s", tostring(storedOpen)))
|
|
403
413
|
|
|
404
414
|
plugin.Unloading:Connect(function()
|
|
415
|
+
unloading = true
|
|
405
416
|
trace("plugin.Unloading")
|
|
406
417
|
end)
|
|
407
418
|
widget.Destroying:Connect(function()
|
package/scripts/check-plugin.mjs
CHANGED
|
@@ -136,6 +136,60 @@ const kindless = new Set();
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/*
|
|
140
|
+
* A module cloned into a client relay has to bring everything it requires.
|
|
141
|
+
*
|
|
142
|
+
* The relay is a LocalScript in the player's client VM, where the plugin's own
|
|
143
|
+
* module tree does not exist, so `require(script.Parent.X)` inside a cloned module
|
|
144
|
+
* finds only what was cloned beside it. ExecRuntime gained a `LogBuffer` require
|
|
145
|
+
* and the list of modules the exec relay clones did not, so every
|
|
146
|
+
* `execute_luau target="client"` failed with "Requested module experienced an
|
|
147
|
+
* error while loading" -- from a client VM nothing offline ever runs, and not
|
|
148
|
+
* noticed until a live playtest. Each clone list is checked here against the
|
|
149
|
+
* `require(script.Parent.X)` calls of the modules it names, transitively.
|
|
150
|
+
*/
|
|
151
|
+
const missingClones = [];
|
|
152
|
+
{
|
|
153
|
+
const modules = new Map(
|
|
154
|
+
files.map((file) => [
|
|
155
|
+
file.replace(/\\/g, "/").replace(/^.*plugin\/src\//, "").replace(/\.luau$/, ""),
|
|
156
|
+
readFileSync(file, "utf8"),
|
|
157
|
+
]),
|
|
158
|
+
);
|
|
159
|
+
const requiresOf = (name) =>
|
|
160
|
+
[...(modules.get(name) ?? "").matchAll(/require\(script\.Parent\.(\w+)\)/g)].map((m) => m[1]);
|
|
161
|
+
|
|
162
|
+
for (const [where, source] of modules) {
|
|
163
|
+
for (const list of source.matchAll(
|
|
164
|
+
/for _, name in \{([^}]*)\} do\s*local copy = script\.Parent\.Parent\[name\]:Clone\(\)/g,
|
|
165
|
+
)) {
|
|
166
|
+
const cloned = new Set([...list[1].matchAll(/"(\w+)"/g)].map((m) => m[1]));
|
|
167
|
+
const needed = new Set();
|
|
168
|
+
const pending = [...cloned];
|
|
169
|
+
while (pending.length > 0) {
|
|
170
|
+
for (const dependency of requiresOf(pending.pop())) {
|
|
171
|
+
if (!needed.has(dependency)) {
|
|
172
|
+
needed.add(dependency);
|
|
173
|
+
pending.push(dependency);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const dependency of needed) {
|
|
178
|
+
if (!cloned.has(dependency)) {
|
|
179
|
+
missingClones.push(`${where}: clones ${[...cloned].join(", ")} but they require ${dependency}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (missingClones.length > 0) {
|
|
186
|
+
failures.push(
|
|
187
|
+
"A client relay clones modules that require others it does not clone, so the " +
|
|
188
|
+
"relay fails to load in the client VM:\n " +
|
|
189
|
+
missingClones.sort().join("\n "),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
139
193
|
if (unnamed.length > 0) {
|
|
140
194
|
failures.push(
|
|
141
195
|
"These operations have no entry in Phrase.luau, so the Studio panel shows " +
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API dump: its cache, how a long-lived server keeps it current, and what
|
|
3
|
+
* the discovery tools say from it.
|
|
4
|
+
*
|
|
5
|
+
* Run against a tiny fixture in a private temp directory, so nothing here reads
|
|
6
|
+
* the real cache or touches the network -- and so it cannot pass or fail on
|
|
7
|
+
* whether Roblox happened to be reachable.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node scripts/test-apidump.mjs
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
// Redirected before anything is imported: the cache path is built from
|
|
17
|
+
// os.tmpdir() at call time, and this keeps every read and write in the sandbox.
|
|
18
|
+
const sandbox = mkdtempSync(join(tmpdir(), "rbx-apidump-test-"));
|
|
19
|
+
for (const name of ["TMPDIR", "TEMP", "TMP"]) process.env[name] = sandbox;
|
|
20
|
+
const cacheDir = join(sandbox, "roblox-studio-mcp");
|
|
21
|
+
const cacheFile = join(cacheDir, "api-dump.json");
|
|
22
|
+
|
|
23
|
+
const property = (name, type, extra = {}) => ({
|
|
24
|
+
MemberType: "Property",
|
|
25
|
+
Name: name,
|
|
26
|
+
ValueType: { Name: type, Category: "Primitive" },
|
|
27
|
+
...extra,
|
|
28
|
+
});
|
|
29
|
+
const dumpOf = (extraClasses = []) => ({
|
|
30
|
+
Classes: [
|
|
31
|
+
{ Name: "Instance", Superclass: "", Members: [property("Name", "string"), property("Archivable", "bool")] },
|
|
32
|
+
{ Name: "Model", Superclass: "Instance", Members: [property("PrimaryPart", "BasePart")] },
|
|
33
|
+
{
|
|
34
|
+
Name: "Part",
|
|
35
|
+
Superclass: "Instance",
|
|
36
|
+
Members: [
|
|
37
|
+
property("Size", "Vector3"),
|
|
38
|
+
property("Anchored", "bool"),
|
|
39
|
+
// Above plugin identity: present in the dump, not reachable from here.
|
|
40
|
+
property("Secret", "string", { Security: { Read: "RobloxScriptSecurity", Write: "RobloxScriptSecurity" } }),
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
...extraClasses,
|
|
44
|
+
],
|
|
45
|
+
Enums: [],
|
|
46
|
+
});
|
|
47
|
+
const widget = { Name: "Widget", Superclass: "Instance", Members: [property("Gizmo", "number")] };
|
|
48
|
+
const seed = (dump, fetchedAt = Date.now()) => {
|
|
49
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
50
|
+
writeFileSync(cacheFile, JSON.stringify({ fetchedAt, dump }));
|
|
51
|
+
};
|
|
52
|
+
const until = async (test, what) => {
|
|
53
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
54
|
+
if (await test()) return;
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
56
|
+
}
|
|
57
|
+
assert.fail(`timed out waiting for ${what}`);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const realFetch = globalThis.fetch;
|
|
61
|
+
const realNow = Date.now;
|
|
62
|
+
try {
|
|
63
|
+
// ---- what the dump answers ----------------------------------------------------
|
|
64
|
+
seed(dumpOf());
|
|
65
|
+
const dump = await import("../dist/lib/apidump.js");
|
|
66
|
+
const names = (await dump.propertiesOf("Part")).map((entry) => entry.name);
|
|
67
|
+
assert.deepEqual([...names].sort(), ["Anchored", "Archivable", "Name", "Size"], "own and inherited, nothing above plugin identity");
|
|
68
|
+
assert.ok((await dump.restrictionsOf("Part")).has("Secret"), "a restricted property is known about, separately");
|
|
69
|
+
assert.deepEqual(await dump.suggestProperty("Part", "size"), ["Size"], "a wrong-case name is suggested");
|
|
70
|
+
assert.deepEqual(await dump.suggestClass("Prat"), ["Part"], "a mistyped class is suggested");
|
|
71
|
+
|
|
72
|
+
// ---- what the tools say from it ----------------------------------------------------
|
|
73
|
+
const { z } = await import("zod");
|
|
74
|
+
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
75
|
+
const registered = new Map();
|
|
76
|
+
const replies = {};
|
|
77
|
+
registerDiscoverTools({
|
|
78
|
+
server: { registerTool: (name, spec, handler) => registered.set(name, { spec, handler }) },
|
|
79
|
+
bridge: {
|
|
80
|
+
sessions: async () => ({ list: [], activeId: null, activeIsChosen: false }),
|
|
81
|
+
call: async (op) => replies[op],
|
|
82
|
+
notePlaceName: async () => {},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const run = (name, args) => {
|
|
86
|
+
const tool = registered.get(name);
|
|
87
|
+
return tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
88
|
+
};
|
|
89
|
+
const textOf = (result) => result.content.map((part) => part.text ?? "").join("\n");
|
|
90
|
+
|
|
91
|
+
replies["discover.inspect"] = {
|
|
92
|
+
items: [{ path: "Workspace.P", className: "Part", childCount: 0, properties: { Name: "P", Size: "1, 1, 1" } }],
|
|
93
|
+
failures: [],
|
|
94
|
+
};
|
|
95
|
+
const inspected = textOf(await run("inspect", {
|
|
96
|
+
paths: ["Workspace.P"],
|
|
97
|
+
properties: ["Name", "Size", "Sizee", "Anchored", "Secret"],
|
|
98
|
+
}));
|
|
99
|
+
assert.match(inspected, /Requested but not returned:/);
|
|
100
|
+
assert.match(inspected, /Sizee \(not a property of Part; did you mean Size\?\)/, "a typo is named as one");
|
|
101
|
+
assert.match(inspected, /Anchored \(unset, or not readable in this session\)/, "a real property with no value is not called a typo");
|
|
102
|
+
assert.match(inspected, /Secret \(exists, but a plugin cannot read it\)/, "a restricted one says so");
|
|
103
|
+
assert.doesNotMatch(inspected.split("Requested but not returned:")[1], /\bName\b|\bSize \(/, "what did come back is not listed");
|
|
104
|
+
|
|
105
|
+
const complete = textOf(await run("inspect", { paths: ["Workspace.P"], properties: ["Name", "Size"] }));
|
|
106
|
+
assert.doesNotMatch(complete, /Requested but not returned/, "nothing to say when everything came back");
|
|
107
|
+
|
|
108
|
+
replies["discover.find"] = { items: [], total: 0, offset: 0, searched: 12 };
|
|
109
|
+
const misspelt = textOf(await run("find", { className: "Prat" }));
|
|
110
|
+
assert.match(misspelt, /"Prat" is not a Roblox class/);
|
|
111
|
+
assert.match(misspelt, /Did you mean: Part\?/);
|
|
112
|
+
const genuine = textOf(await run("find", { className: "Part" }));
|
|
113
|
+
assert.doesNotMatch(genuine, /is not a Roblox class/, "a real class that simply matched nothing is not accused");
|
|
114
|
+
const bare = textOf(await run("find", { nameContains: "zzz" }));
|
|
115
|
+
assert.doesNotMatch(bare, /is not a Roblox class/);
|
|
116
|
+
|
|
117
|
+
replies["discover.tree"] = { items: [], total: 0, offset: 0 };
|
|
118
|
+
assert.match(textOf(await run("tree", { className: "Prat" })), /"Prat" is not a Roblox class/);
|
|
119
|
+
|
|
120
|
+
// ---- a stale cache is served at once, then replaced in the running process ------------
|
|
121
|
+
// Each case gets its own copy of the module: the state under test is per process.
|
|
122
|
+
const fresh = (tag) => import(`../dist/lib/apidump.js?${tag}`);
|
|
123
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
124
|
+
let downloads = 0;
|
|
125
|
+
const serve = (value) => {
|
|
126
|
+
globalThis.fetch = async () => {
|
|
127
|
+
downloads += 1;
|
|
128
|
+
return { ok: true, json: async () => value };
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
seed(dumpOf(), Date.now() - 3 * dayMs);
|
|
133
|
+
serve(dumpOf([widget]));
|
|
134
|
+
const stale = await fresh("stale");
|
|
135
|
+
assert.equal((await stale.propertiesOf("Widget")).length, 0, "the stale copy is what answers first");
|
|
136
|
+
await until(async () => (await stale.propertiesOf("Widget")).length > 0, "the refreshed dump to replace the stale one");
|
|
137
|
+
assert.equal(downloads, 1, "one download, however many calls were made meanwhile");
|
|
138
|
+
assert.ok((await stale.propertiesOf("Widget")).some((entry) => entry.name === "Gizmo"), "and the answers come from the new one");
|
|
139
|
+
assert.deepEqual(readdirSync(cacheDir), ["api-dump.json"], "the cache was replaced whole, leaving no staging file behind");
|
|
140
|
+
assert.ok(Date.now() - JSON.parse(readFileSync(cacheFile, "utf8")).fetchedAt < dayMs, "and is stamped as fresh");
|
|
141
|
+
|
|
142
|
+
// A fresh cache is not refreshed... until the process outlives it.
|
|
143
|
+
seed(dumpOf());
|
|
144
|
+
downloads = 0;
|
|
145
|
+
serve(dumpOf([widget]));
|
|
146
|
+
const longLived = await fresh("long-lived");
|
|
147
|
+
assert.equal((await longLived.propertiesOf("Widget")).length, 0);
|
|
148
|
+
assert.equal(downloads, 0, "a fresh cache needs no download");
|
|
149
|
+
Date.now = () => realNow() + 2 * dayMs;
|
|
150
|
+
await longLived.loadApiDump();
|
|
151
|
+
await until(async () => (await longLived.propertiesOf("Widget")).length > 0, "a process that outlived the cache to refresh it");
|
|
152
|
+
Date.now = realNow;
|
|
153
|
+
assert.equal(downloads, 1);
|
|
154
|
+
|
|
155
|
+
// A failed refresh leaves the old dump answering rather than emptying it.
|
|
156
|
+
seed(dumpOf(), Date.now() - 3 * dayMs);
|
|
157
|
+
globalThis.fetch = async () => {
|
|
158
|
+
throw new Error("offline");
|
|
159
|
+
};
|
|
160
|
+
const offline = await fresh("offline");
|
|
161
|
+
assert.ok((await offline.propertiesOf("Part")).length > 0, "no network, still answers from the stale copy");
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
163
|
+
assert.ok((await offline.propertiesOf("Part")).length > 0, "and still does after the refresh failed");
|
|
164
|
+
|
|
165
|
+
process.stdout.write("apidump: ok\n");
|
|
166
|
+
} finally {
|
|
167
|
+
globalThis.fetch = realFetch;
|
|
168
|
+
Date.now = realNow;
|
|
169
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
170
|
+
}
|
package/scripts/test-bridge.mjs
CHANGED
|
@@ -273,10 +273,16 @@ function twoStudios() {
|
|
|
273
273
|
bridge.setActiveForAll("studio-b");
|
|
274
274
|
const late = new LocalBridge(bridge);
|
|
275
275
|
assert.equal((await late.sessions()).activeId, "studio-b");
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
const routed = late.call("studio.ping", {}, { timeoutMs: 50 });
|
|
277
|
+
// Read while the call is outstanding: once it times out the command is taken
|
|
278
|
+
// back off the queue, so the queue is only evidence of routing until then.
|
|
279
279
|
assert.equal(bridge.sessions.get("studio-b").queue.length, 1, "the call went to the pick");
|
|
280
|
+
assert.equal(bridge.sessions.get("studio-a").queue.length, 0, "and not to the other one");
|
|
281
|
+
await assert.rejects(
|
|
282
|
+
routed,
|
|
283
|
+
(cause) => cause.code === "TIMEOUT",
|
|
284
|
+
"the user's pick routes the call, so it fails by timing out and not as ambiguous",
|
|
285
|
+
);
|
|
280
286
|
|
|
281
287
|
await assert.rejects(
|
|
282
288
|
async () => late.call("studio.ping", {}, { studioId: "gone", timeoutMs: 50 }),
|
|
@@ -285,6 +291,83 @@ function twoStudios() {
|
|
|
285
291
|
);
|
|
286
292
|
}
|
|
287
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A call that has timed out must not be run afterwards.
|
|
296
|
+
*
|
|
297
|
+
* The timeout answered the agent with a failure and dropped the request from the
|
|
298
|
+
* in-flight table, but left the command on the queue. The plugin's next poll --
|
|
299
|
+
* or the next stream to connect -- then picked it up and ran it: a create or a
|
|
300
|
+
* delete nobody was waiting for any more, usually after the agent had retried it.
|
|
301
|
+
*/
|
|
302
|
+
{
|
|
303
|
+
const bridge = new Bridge();
|
|
304
|
+
bridge.attach(identity("studio-a", 111), null);
|
|
305
|
+
|
|
306
|
+
await assert.rejects(
|
|
307
|
+
bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: 20 }),
|
|
308
|
+
(cause) => cause.code === "TIMEOUT" && /never reached Studio/.test(cause.message),
|
|
309
|
+
"nothing collected it, and the error says so",
|
|
310
|
+
);
|
|
311
|
+
assert.equal(bridge.sessions.get("studio-a").queue.length, 0, "the abandoned command is gone");
|
|
312
|
+
|
|
313
|
+
// A poll arriving now has nothing to take, rather than the command that failed.
|
|
314
|
+
const held = new AbortController();
|
|
315
|
+
const waiting = bridge.waitForCommand("studio-a", held.signal);
|
|
316
|
+
setTimeout(() => held.abort(), 20);
|
|
317
|
+
assert.equal(await waiting, null, "a later poll is not handed the dead command");
|
|
318
|
+
|
|
319
|
+
// The same for a stream that connects after the deadline: nothing to flush.
|
|
320
|
+
const written = [];
|
|
321
|
+
const stream = {
|
|
322
|
+
writableEnded: false,
|
|
323
|
+
write(chunk) {
|
|
324
|
+
written.push(chunk);
|
|
325
|
+
return true;
|
|
326
|
+
},
|
|
327
|
+
end() {
|
|
328
|
+
this.writableEnded = true;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
bridge.attach(identity("studio-a", 111), stream);
|
|
332
|
+
assert.deepEqual(written, [], "a reconnecting stream is not sent the dead command");
|
|
333
|
+
|
|
334
|
+
// And a live one is still delivered, so the queue is not simply being dropped.
|
|
335
|
+
const live = bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: 1000 });
|
|
336
|
+
assert.equal(written.length, 1, "a command issued to an open stream is written at once");
|
|
337
|
+
const frame = JSON.parse(written[0].replace(/^data: /, ""));
|
|
338
|
+
bridge.settle("studio-a", { id: frame.id, ok: true, data: "pong" });
|
|
339
|
+
assert.equal(await live, "pong");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* `call` returns a promise, so every way it can fail is a rejection.
|
|
344
|
+
*
|
|
345
|
+
* NO_STUDIO and UNKNOWN_STUDIO used to be thrown from inside `call`, before a
|
|
346
|
+
* promise existed. `bridge.call(...).catch(...)` -- how `playtest stop` shrugs
|
|
347
|
+
* off a session that closed under it -- never got the chance to catch them.
|
|
348
|
+
*/
|
|
349
|
+
{
|
|
350
|
+
const bridge = new Bridge();
|
|
351
|
+
let pending;
|
|
352
|
+
assert.doesNotThrow(() => {
|
|
353
|
+
pending = bridge.call("studio.ping", {}, { clientId: "c" });
|
|
354
|
+
}, "no Studio connected does not throw synchronously");
|
|
355
|
+
await assert.rejects(pending, (cause) => cause.code === "NO_STUDIO");
|
|
356
|
+
|
|
357
|
+
bridge.attach(identity("studio-a", 111), null);
|
|
358
|
+
assert.doesNotThrow(() => {
|
|
359
|
+
pending = bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: -1 });
|
|
360
|
+
}, "a bad timeout does not throw synchronously");
|
|
361
|
+
await assert.rejects(pending, (cause) => cause.code === "BAD_TIMEOUT");
|
|
362
|
+
|
|
363
|
+
const local = new LocalBridge(bridge);
|
|
364
|
+
let caught = false;
|
|
365
|
+
await local.call("studio.ping", {}, { studioId: "gone" }).catch(() => {
|
|
366
|
+
caught = true;
|
|
367
|
+
});
|
|
368
|
+
assert.equal(caught, true, "a `.catch` on the local bridge sees an unknown Studio");
|
|
369
|
+
}
|
|
370
|
+
|
|
288
371
|
// A stream that dies without /bye (a crashed Studio) removes its session, and a
|
|
289
372
|
// replaced stream closing late does not remove the session that replaced it.
|
|
290
373
|
{
|
|
@@ -314,6 +397,28 @@ function twoStudios() {
|
|
|
314
397
|
.list.length;
|
|
315
398
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 250));
|
|
316
399
|
|
|
400
|
+
// A page that rebinds its own hostname to 127.0.0.1 is same-origin as far as
|
|
401
|
+
// the browser is concerned: it can send the custom header without a preflight,
|
|
402
|
+
// and a same-origin GET carries no Origin to reject. What it cannot do is make
|
|
403
|
+
// the Host header say 127.0.0.1, so that is what is checked.
|
|
404
|
+
const statusFor = (host) =>
|
|
405
|
+
new Promise((resolve, reject) => {
|
|
406
|
+
const req = request(
|
|
407
|
+
{ host: "127.0.0.1", port, path: "/sessions", method: "GET", headers: { host, "x-roblox-studio-mcp": "test" } },
|
|
408
|
+
(res) => {
|
|
409
|
+
res.resume();
|
|
410
|
+
resolve(res.statusCode);
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
req.on("error", reject);
|
|
414
|
+
req.end();
|
|
415
|
+
});
|
|
416
|
+
assert.equal(await statusFor(`127.0.0.1:${port}`), 200, "the address the plugin uses is served");
|
|
417
|
+
assert.equal(await statusFor(`localhost:${port}`), 200, "and so is localhost");
|
|
418
|
+
assert.equal(await statusFor(`evil.example:${port}`), 403, "a rebound hostname is refused");
|
|
419
|
+
assert.equal(await statusFor(`127.0.0.1.evil.example:${port}`), 403, "even one that merely starts like loopback");
|
|
420
|
+
assert.equal(await statusFor("127.0.0.1"), 403, "and a Host that names no port is not this server");
|
|
421
|
+
|
|
317
422
|
const first = await open();
|
|
318
423
|
const second = await open();
|
|
319
424
|
first.destroy();
|
|
@@ -432,7 +537,9 @@ function twoStudios() {
|
|
|
432
537
|
}
|
|
433
538
|
const before = delays.length;
|
|
434
539
|
for (const timeoutMs of [NaN, Infinity, -Infinity, -1, 0, null, "1000", {}, 2 ** 31, Number.MAX_SAFE_INTEGER]) {
|
|
435
|
-
|
|
540
|
+
// A rejection rather than a throw: `call` returns a promise, so a caller's
|
|
541
|
+
// `.catch` has to be able to see this one too.
|
|
542
|
+
await assert.rejects(bridge.call("studio.ping", {}, {clientId:"test",timeoutMs}), {code:"BAD_TIMEOUT"});
|
|
436
543
|
}
|
|
437
544
|
assert.equal(delays.length, before, "invalid deadlines never create timers");
|
|
438
545
|
assert.equal(bridge.sessions.get("timeout-test").pending.size, 0);
|