@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.
Files changed (75) hide show
  1. package/README.md +5 -3
  2. package/dist/bridge/api.js +2 -2
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +12 -4
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/harness.js +48 -6
  7. package/dist/bridge/harness.js.map +1 -1
  8. package/dist/bridge/remote.js +4 -2
  9. package/dist/bridge/remote.js.map +1 -1
  10. package/dist/bridge/rpc.js +43 -5
  11. package/dist/bridge/rpc.js.map +1 -1
  12. package/dist/bridge/server.js +26 -1
  13. package/dist/bridge/server.js.map +1 -1
  14. package/dist/lib/apidump.js +82 -15
  15. package/dist/lib/apidump.js.map +1 -1
  16. package/dist/lib/cloudassets.js +2 -2
  17. package/dist/lib/cloudassets.js.map +1 -1
  18. package/dist/lib/livedata.js +24 -1
  19. package/dist/lib/livedata.js.map +1 -1
  20. package/dist/lib/liveops.js +79 -0
  21. package/dist/lib/liveops.js.map +1 -1
  22. package/dist/lib/monetization.js +119 -0
  23. package/dist/lib/monetization.js.map +1 -0
  24. package/dist/lib/opencloud.js +16 -6
  25. package/dist/lib/opencloud.js.map +1 -1
  26. package/dist/lib/png.js +19 -31
  27. package/dist/lib/png.js.map +1 -1
  28. package/dist/tools/audio.js +1 -1
  29. package/dist/tools/audio.js.map +1 -1
  30. package/dist/tools/device.js +8 -7
  31. package/dist/tools/device.js.map +1 -1
  32. package/dist/tools/discover.js +86 -5
  33. package/dist/tools/discover.js.map +1 -1
  34. package/dist/tools/instances.js +3 -1
  35. package/dist/tools/instances.js.map +1 -1
  36. package/dist/tools/perf.js +27 -5
  37. package/dist/tools/perf.js.map +1 -1
  38. package/dist/tools/playtest.js +35 -10
  39. package/dist/tools/playtest.js.map +1 -1
  40. package/dist/tools/session.js +11 -3
  41. package/dist/tools/session.js.map +1 -1
  42. package/dist/tools/terrain.js +2 -2
  43. package/dist/tools/terrain.js.map +1 -1
  44. package/dist/tools/universe.js +136 -11
  45. package/dist/tools/universe.js.map +1 -1
  46. package/dist/tools/world.js +30 -7
  47. package/dist/tools/world.js.map +1 -1
  48. package/package.json +4 -4
  49. package/plugin/src/ClientRelay.luau +20 -2
  50. package/plugin/src/Config.luau +1 -1
  51. package/plugin/src/Emulation.luau +44 -16
  52. package/plugin/src/ExecRuntime.luau +168 -161
  53. package/plugin/src/LogBuffer.luau +58 -3
  54. package/plugin/src/Phrase.luau +2 -0
  55. package/plugin/src/handlers/Assets.luau +27 -18
  56. package/plugin/src/handlers/Audio.luau +36 -15
  57. package/plugin/src/handlers/Character.luau +38 -23
  58. package/plugin/src/handlers/Data.luau +12 -1
  59. package/plugin/src/handlers/Debug.luau +42 -0
  60. package/plugin/src/handlers/Device.luau +38 -27
  61. package/plugin/src/handlers/Exec.luau +5 -1
  62. package/plugin/src/handlers/Generate.luau +8 -5
  63. package/plugin/src/handlers/Geometry.luau +7 -5
  64. package/plugin/src/handlers/Perf.luau +1 -0
  65. package/plugin/src/handlers/Playtest.luau +40 -3
  66. package/plugin/src/handlers/Spatial.luau +34 -0
  67. package/plugin/src/handlers/Terrain.luau +19 -24
  68. package/plugin/src/handlers/Viewport.luau +37 -5
  69. package/plugin/src/handlers/World.luau +38 -2
  70. package/plugin/src/init.server.luau +11 -0
  71. package/scripts/check-plugin.mjs +54 -0
  72. package/scripts/test-apidump.mjs +170 -0
  73. package/scripts/test-bridge.mjs +111 -4
  74. package/scripts/test-live-tools.mjs +382 -0
  75. 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. The bounding box
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
- Formatted here rather than through `Serialize.value`.
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
- It reads like the bounding box of the terrain that exists, and it is
342
- not: it is the fixed limit of where terrain is allowed to go, and it
343
- answers -32000, -32000, -32000 to 32000, 32000, 32000 on an empty place
344
- and on a full one alike. Reporting it as "where the terrain sits" was a
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` already answers the one that
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
- waterWaveSize = field.WaterWaveSize,
358
- limitStuds = 32000,
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 = Serialize.value(cast.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
- direction = toVector(params.from, "`from`").Unit
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 = tostring(view.CFrame.Position),
291
- lookVector = tostring(view.CFrame.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
- return { group = name, assigned = #assigned, parts = assigned, undoable = undoable, world = Paths.of(world) }
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()
@@ -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
+ }
@@ -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
- await late.call("studio.ping", {}, { timeoutMs: 50 }).catch((cause) => {
277
- assert.notEqual(cause.code, "AMBIGUOUS_STUDIO", "the user's pick routes the call");
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
- assert.throws(() => bridge.call("studio.ping", {}, {clientId:"test",timeoutMs}), {code:"BAD_TIMEOUT"});
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);