@el4cteo/rbx-studio-mcp 0.1.6 → 0.2.7
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 +67 -5
- package/dist/bridge/api.js +29 -0
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/remote.js +35 -0
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +81 -2
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +60 -3
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +86 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js +23 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/discover.js +7 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/input.js +63 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/instances.js +9 -1
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +73 -16
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +8 -1
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/session.js +32 -6
- package/dist/tools/session.js.map +1 -1
- package/dist/tools/world.js +9 -3
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +204 -4
- package/plugin/src/Net.luau +23 -1
- package/plugin/src/Phrase.luau +158 -5
- package/plugin/src/Transport.luau +84 -19
- package/plugin/src/Visuals.luau +217 -20
- package/plugin/src/handlers/Debug.luau +63 -6
- package/plugin/src/handlers/Geometry.luau +20 -1
- package/plugin/src/handlers/Input.luau +41 -1
- package/plugin/src/handlers/Perf.luau +662 -645
- package/plugin/src/handlers/World.luau +19 -0
- package/plugin/src/init.server.luau +48 -11
- package/scripts/check-plugin.mjs +29 -1
- package/scripts/test-bridge.mjs +104 -0
- package/scripts/test-transport.mjs +58 -0
|
@@ -147,6 +147,25 @@ function World.collision(params: { [string]: any }): { [string]: any }
|
|
|
147
147
|
return { group = name, assigned = #assigned, parts = assigned, undoable = undoable }
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
if action == "remove" then
|
|
151
|
+
--[[
|
|
152
|
+
Registering a group is one line and nothing here undid it: create,
|
|
153
|
+
assign and collidable all had a way back through the ordinary undo
|
|
154
|
+
stack, and this did not, because nothing called it at all. Over a
|
|
155
|
+
session that experiments -- try a group, decide against it -- that
|
|
156
|
+
leaves the place permanently carrying a group nobody wants, with no
|
|
157
|
+
way to remove it short of `execute_luau`. `UnregisterCollisionGroup`
|
|
158
|
+
has existed the whole time; it was just never wired up.
|
|
159
|
+
]]
|
|
160
|
+
local ok, err = pcall(function()
|
|
161
|
+
PhysicsService:UnregisterCollisionGroup(name)
|
|
162
|
+
end)
|
|
163
|
+
if not ok then
|
|
164
|
+
Dispatch.fail("COLLISION_FAILED", string.format("Could not remove %q: %s", name, tostring(err)))
|
|
165
|
+
end
|
|
166
|
+
return { group = name, removed = true }
|
|
167
|
+
end
|
|
168
|
+
|
|
150
169
|
if action == "collidable" then
|
|
151
170
|
local other = params.with
|
|
152
171
|
if typeof(other) ~= "string" or other == "" then
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
--!strict
|
|
2
2
|
--[[
|
|
3
|
-
|
|
3
|
+
rbx-studio -- plugin entry point.
|
|
4
4
|
|
|
5
5
|
Owns the toolbar UI, this window's Studio identity, and the command loop.
|
|
6
6
|
Handlers do the actual work; this file only wires them to the transport and
|
|
@@ -121,10 +121,10 @@ Input.register()
|
|
|
121
121
|
Device.register()
|
|
122
122
|
Api.register()
|
|
123
123
|
|
|
124
|
-
local toolbar = plugin:CreateToolbar("
|
|
124
|
+
local toolbar = plugin:CreateToolbar("rbx-studio")
|
|
125
125
|
local button = toolbar:CreateButton(
|
|
126
|
-
"
|
|
127
|
-
"Show the
|
|
126
|
+
"rbx-studio",
|
|
127
|
+
"Show the rbx-studio console",
|
|
128
128
|
"rbxasset://textures/ui/common/robux.png"
|
|
129
129
|
)
|
|
130
130
|
button.ClickableWhenViewportHidden = true
|
|
@@ -136,7 +136,7 @@ local widget = plugin:CreateDockWidgetPluginGuiAsync(
|
|
|
136
136
|
"StudioMCP_Console",
|
|
137
137
|
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 560, 320, 360, 200)
|
|
138
138
|
)
|
|
139
|
-
widget.Title = "
|
|
139
|
+
widget.Title = "rbx-studio"
|
|
140
140
|
|
|
141
141
|
local currentStatus: Transport.Status = "disconnected"
|
|
142
142
|
|
|
@@ -167,7 +167,7 @@ Console.mount(widget, {
|
|
|
167
167
|
end,
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
-
Console.log("info", string.format("
|
|
170
|
+
Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
|
|
171
171
|
Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
|
|
172
172
|
|
|
173
173
|
local function onStatus(status: Transport.Status, detail: string?)
|
|
@@ -210,12 +210,36 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
|
|
|
210
210
|
something needs debugging.
|
|
211
211
|
]]
|
|
212
212
|
local title = Phrase.of(op, params)
|
|
213
|
+
--[[
|
|
214
|
+
Announced synchronously, unlike the logging below.
|
|
215
|
+
|
|
216
|
+
Deferring this looked like free latency and was not. `beginCall` sets
|
|
217
|
+
two labels and some fields -- it never touches the RichText log, which
|
|
218
|
+
is where the cost actually is -- and deferring it let a call that
|
|
219
|
+
finished inside one frame record its result before the call had been
|
|
220
|
+
announced, so the bar on the trace lost the name it was supposed to
|
|
221
|
+
carry. A microsecond is not worth an ordering hazard.
|
|
222
|
+
]]
|
|
213
223
|
Console.beginCall(title, Phrase.kindOf(op))
|
|
214
224
|
|
|
215
225
|
local result = Dispatch.invoke(id, op, params)
|
|
216
|
-
local
|
|
226
|
+
local milliseconds = (os.clock() - startedAt) * 1000
|
|
217
227
|
|
|
218
|
-
|
|
228
|
+
--[[
|
|
229
|
+
The answer goes out before the console hears about it.
|
|
230
|
+
|
|
231
|
+
This used to be the last line of the function, which put a
|
|
232
|
+
`table.concat` of three hundred strings, a RichText relayout of the
|
|
233
|
+
whole log, two `Instance.new` calls and a forty-bar relayout in front
|
|
234
|
+
of the reply on its way back to the agent. None of that is work the
|
|
235
|
+
caller asked for, and all of it was being billed to the round trip
|
|
236
|
+
this project measures. Drawing happens below, on time the agent is no
|
|
237
|
+
longer waiting for.
|
|
238
|
+
]]
|
|
239
|
+
Transport.sendResult(result)
|
|
240
|
+
|
|
241
|
+
local elapsed = string.format("%.0fms", milliseconds)
|
|
242
|
+
Console.recordCall(result.ok, milliseconds)
|
|
219
243
|
|
|
220
244
|
if result.ok then
|
|
221
245
|
Console.log("reply", title, elapsed)
|
|
@@ -230,11 +254,24 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
|
|
|
230
254
|
Console.log("dim", " " .. err.message)
|
|
231
255
|
end
|
|
232
256
|
end
|
|
233
|
-
|
|
234
|
-
Transport.sendResult(result)
|
|
235
257
|
end)
|
|
236
258
|
end
|
|
237
259
|
|
|
260
|
+
--[[
|
|
261
|
+
Bridge news that is not a command.
|
|
262
|
+
|
|
263
|
+
Kept deliberately narrow: an unknown event is ignored rather than logged,
|
|
264
|
+
because a newer server talking to an older plugin is a supported situation
|
|
265
|
+
and "unknown event" rows would be the only symptom of it working correctly.
|
|
266
|
+
]]
|
|
267
|
+
local function onEvent(event: { [string]: any })
|
|
268
|
+
if event.event == "clients" and typeof(event.count) == "number" then
|
|
269
|
+
Console.setClients(event.count)
|
|
270
|
+
elseif event.event == "agent" and event.state == "finished" then
|
|
271
|
+
Console.agentFinished()
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
238
275
|
function connect()
|
|
239
276
|
local allowed, reason = canConnect()
|
|
240
277
|
if not allowed then
|
|
@@ -249,7 +286,7 @@ function connect()
|
|
|
249
286
|
Console.setCaption("switch to the Server view to watch this playtest")
|
|
250
287
|
return
|
|
251
288
|
end
|
|
252
|
-
Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus })
|
|
289
|
+
Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
|
|
253
290
|
plugin:SetSetting(SETTING_AUTOCONNECT, true)
|
|
254
291
|
end
|
|
255
292
|
|
package/scripts/check-plugin.mjs
CHANGED
|
@@ -72,14 +72,34 @@ for (const file of files) {
|
|
|
72
72
|
* filtering to this one diagnostic gets the signal without needing a
|
|
73
73
|
* definitions file that would then have to be kept current with the engine.
|
|
74
74
|
*/
|
|
75
|
+
/*
|
|
76
|
+
* Diagnostics about a table this file declares its own type for.
|
|
77
|
+
*
|
|
78
|
+
* The LocalShadow filter was the only thing let through, and that let a whole
|
|
79
|
+
* class of error ship: a field read or written on a `--!strict` table type that
|
|
80
|
+
* does not declare it. The plugin failed to load on the first line it logged --
|
|
81
|
+
* "attempt to perform arithmetic on nil" -- because a batch of edits added five
|
|
82
|
+
* uses of `state.entries` and the edit declaring it never landed. The analyser
|
|
83
|
+
* had said so, four times, and this script threw it away.
|
|
84
|
+
*
|
|
85
|
+
* These two patterns are safe to surface where the rest is not. Without Roblox
|
|
86
|
+
* type definitions the analyser cannot know what `Instance` or `Color3` are, so
|
|
87
|
+
* it reports engine globals in their hundreds -- but those come out as unknown
|
|
88
|
+
* *globals* and unknown *types*. A key missing from a named table type can only
|
|
89
|
+
* be a table this file declared itself.
|
|
90
|
+
*/
|
|
91
|
+
const TYPE_PATTERNS = [/Key '[^']+' not found in table/, /Cannot add property '[^']+' to table/];
|
|
92
|
+
|
|
75
93
|
const analyser = locateLuau("LUAU_ANALYZE", ["luau-analyze.exe", "luau-analyze"]);
|
|
76
94
|
const shadowed = [];
|
|
95
|
+
const mistyped = [];
|
|
77
96
|
if (analyser !== null) {
|
|
78
97
|
for (const file of files) {
|
|
79
98
|
const result = spawnSync(analyser, [file], { encoding: "utf8" });
|
|
80
99
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
81
100
|
for (const line of output.split("\n")) {
|
|
82
101
|
if (line.includes("LocalShadow:")) shadowed.push(line.trim());
|
|
102
|
+
else if (TYPE_PATTERNS.some((pattern) => pattern.test(line))) mistyped.push(line.trim());
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
105
|
}
|
|
@@ -94,4 +114,12 @@ if (shadowed.length > 0) {
|
|
|
94
114
|
`${shadowed.join("\n")}\n`,
|
|
95
115
|
);
|
|
96
116
|
}
|
|
97
|
-
|
|
117
|
+
if (mistyped.length > 0) {
|
|
118
|
+
process.stderr.write(
|
|
119
|
+
"\nA field is used on a table whose type does not declare it. It is nil at " +
|
|
120
|
+
"runtime, and the plugin fails the first time that line runs:\n" +
|
|
121
|
+
`${mistyped.join("\n")}
|
|
122
|
+
`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
process.exit(failures.length > 0 || shadowed.length > 0 || mistyped.length > 0 ? 1 : 0);
|
package/scripts/test-bridge.mjs
CHANGED
|
@@ -20,6 +20,7 @@ const identity = (studioId, placeId) => ({
|
|
|
20
20
|
placeId,
|
|
21
21
|
pluginVersion: "test",
|
|
22
22
|
buildId: "test",
|
|
23
|
+
protocolVersion: 1,
|
|
23
24
|
transport: "poll",
|
|
24
25
|
context: "edit",
|
|
25
26
|
});
|
|
@@ -105,3 +106,106 @@ function twoStudios() {
|
|
|
105
106
|
"but the survivor was never chosen",
|
|
106
107
|
);
|
|
107
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Counting the agents that share one bridge, and noticing when one leaves.
|
|
112
|
+
*
|
|
113
|
+
* The Studio console shows this count and announces the departure, so getting
|
|
114
|
+
* it wrong is not a cosmetic fault -- it either claims a second agent is
|
|
115
|
+
* editing the user's place when none is, or stays silent when one really has
|
|
116
|
+
* gone. Both are the kind of wrong that is only visible from outside the
|
|
117
|
+
* process, which is what this covers.
|
|
118
|
+
*/
|
|
119
|
+
{
|
|
120
|
+
const bridge = new Bridge();
|
|
121
|
+
const seen = [];
|
|
122
|
+
bridge.watchClients((count) => seen.push(count));
|
|
123
|
+
|
|
124
|
+
assert.equal(bridge.clientCount(), 0, "a fresh bridge has no clients");
|
|
125
|
+
|
|
126
|
+
const alice = new LocalBridge(bridge);
|
|
127
|
+
assert.equal(bridge.clientCount(), 1, "constructing a local bridge registers it");
|
|
128
|
+
|
|
129
|
+
bridge.noteClient("peer-1");
|
|
130
|
+
assert.equal(bridge.clientCount(), 2, "a peer counts too");
|
|
131
|
+
|
|
132
|
+
bridge.noteClient("peer-1");
|
|
133
|
+
assert.equal(bridge.clientCount(), 2, "and saying hello twice is not two peers");
|
|
134
|
+
|
|
135
|
+
assert.deepEqual(seen, [1, 2], "only real changes are announced");
|
|
136
|
+
|
|
137
|
+
assert.equal(bridge.forgetClient("peer-1"), true, "goodbye drops the peer");
|
|
138
|
+
assert.equal(bridge.clientCount(), 1, "leaving one behind");
|
|
139
|
+
assert.equal(
|
|
140
|
+
bridge.forgetClient("peer-1"),
|
|
141
|
+
false,
|
|
142
|
+
"a repeated goodbye is not a second departure",
|
|
143
|
+
);
|
|
144
|
+
assert.deepEqual(seen, [1, 2, 1], "and is not announced twice");
|
|
145
|
+
|
|
146
|
+
// A client's chosen Studio goes with it, which is the leak forgetClient was
|
|
147
|
+
// written for and never called to fix.
|
|
148
|
+
bridge.attach(identity("studio-a", 111), null);
|
|
149
|
+
bridge.attach(identity("studio-b", 222), null);
|
|
150
|
+
await alice.setActive("studio-b");
|
|
151
|
+
assert.equal((await alice.sessions()).activeIsChosen, true, "alice chose one");
|
|
152
|
+
alice.goodbye();
|
|
153
|
+
assert.equal(bridge.clientCount(), 0, "and left");
|
|
154
|
+
assert.equal(
|
|
155
|
+
(await alice.sessions()).activeIsChosen,
|
|
156
|
+
false,
|
|
157
|
+
"taking her choice with her",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A client killed rather than closed is swept by the reaper. */
|
|
162
|
+
{
|
|
163
|
+
const bridge = new Bridge();
|
|
164
|
+
bridge.noteClient("ghost");
|
|
165
|
+
assert.equal(bridge.clientCount(), 1, "the ghost registered");
|
|
166
|
+
|
|
167
|
+
bridge.reapStale();
|
|
168
|
+
assert.equal(bridge.clientCount(), 1, "a fresh client survives a sweep");
|
|
169
|
+
|
|
170
|
+
// Reach past the clock rather than wait ninety seconds for it.
|
|
171
|
+
const original = Date.now;
|
|
172
|
+
Date.now = () => original() + 120_000;
|
|
173
|
+
try {
|
|
174
|
+
bridge.reapStale();
|
|
175
|
+
} finally {
|
|
176
|
+
Date.now = original;
|
|
177
|
+
}
|
|
178
|
+
assert.equal(bridge.clientCount(), 0, "a silent one does not");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The bridge must not sweep away the process it is running inside.
|
|
183
|
+
*
|
|
184
|
+
* It did. `LocalBridge` announced itself once at construction and nothing ever
|
|
185
|
+
* refreshed it, so ninety seconds later the reaper -- which cannot tell an
|
|
186
|
+
* absent client from a quiet one -- dropped the owner from its own client list
|
|
187
|
+
* while it was actively serving. The count then read one short, and the drop
|
|
188
|
+
* was broadcast to the Studio console as an agent having finished. Found by a
|
|
189
|
+
* user asking why the panel said three clients when there was one.
|
|
190
|
+
*/
|
|
191
|
+
{
|
|
192
|
+
const bridge = new Bridge();
|
|
193
|
+
const owner = new LocalBridge(bridge);
|
|
194
|
+
bridge.attach(identity("studio-a", 111), null);
|
|
195
|
+
assert.equal(bridge.clientCount(), 1, "the owner counts as a client");
|
|
196
|
+
|
|
197
|
+
const real = Date.now;
|
|
198
|
+
Date.now = () => real() + 120_000;
|
|
199
|
+
try {
|
|
200
|
+
// Working is what proves it is here, and the owner works constantly.
|
|
201
|
+
await owner.sessions();
|
|
202
|
+
bridge.reapStale();
|
|
203
|
+
} finally {
|
|
204
|
+
Date.now = real;
|
|
205
|
+
}
|
|
206
|
+
assert.equal(bridge.clientCount(), 1, "an owner that is working is not stale");
|
|
207
|
+
|
|
208
|
+
// And it still goes when it actually goes.
|
|
209
|
+
owner.goodbye();
|
|
210
|
+
assert.equal(bridge.clientCount(), 0, "goodbye still drops it");
|
|
211
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exercises the plugin's SSE frame parser outside Studio.
|
|
3
|
+
*
|
|
4
|
+
* `Transport.luau` cannot be loaded whole by the interpreter -- it reaches for
|
|
5
|
+
* HttpService at module scope -- and `parseFrames` is a local, so neither the
|
|
6
|
+
* existing bundling harness nor a plain require can get at it. It is lifted out
|
|
7
|
+
* of the real file by name instead, so this cannot quietly drift from what ships:
|
|
8
|
+
* rename or delete the function and the extraction fails the test run.
|
|
9
|
+
*
|
|
10
|
+
* The bug it covers cost a session to find and would have been invisible in
|
|
11
|
+
* review. Two SSE writes in the same tick arrive as one chunk, and the old
|
|
12
|
+
* parser JSON-decoded the whole chunk, so BOTH frames were dropped. It showed up
|
|
13
|
+
* as a client-count badge that would not come back down; the part that mattered
|
|
14
|
+
* is that a command sharing a chunk with a keepalive would have vanished the
|
|
15
|
+
* same way, losing a tool call with no error at either end.
|
|
16
|
+
*
|
|
17
|
+
* Usage: node scripts/test-transport.mjs
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { locateLuau, missingLuau } from "./locate-luau.mjs";
|
|
25
|
+
|
|
26
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
|
|
28
|
+
if (luau === null) {
|
|
29
|
+
process.stderr.write(missingLuau("luau", "LUAU"));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Lifts one top-level `local function` out of a module, body and all. */
|
|
34
|
+
function extractFunction(source, name) {
|
|
35
|
+
// Normalised first: the plugin sources are CRLF and every anchor here is LF.
|
|
36
|
+
const text = source.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10));
|
|
37
|
+
const opener = `local function ${name}(`;
|
|
38
|
+
const start = text.indexOf(opener);
|
|
39
|
+
if (start === -1) throw new Error(`${name} is not in Transport.luau any more`);
|
|
40
|
+
const closer = String.fromCharCode(10) + 'end' + String.fromCharCode(10);
|
|
41
|
+
const end = text.indexOf(closer, start);
|
|
42
|
+
if (end === -1) throw new Error(`could not find the end of ${name}`);
|
|
43
|
+
return text.slice(start, end + closer.length).replace(opener, `function ${name}(`);
|
|
44
|
+
}
|
|
45
|
+
const transport = readFileSync(join(root, "plugin", "src", "Transport.luau"), "utf8");
|
|
46
|
+
const stub = readFileSync(join(root, "tests", "jsonstub.luau"), "utf8").replace(/^return Net$/m, "");
|
|
47
|
+
const cases = readFileSync(join(root, "tests", "sseframes.luau"), "utf8");
|
|
48
|
+
|
|
49
|
+
const bundle = [stub, extractFunction(transport, "parseFrames"), cases].join("\n");
|
|
50
|
+
const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-sse-")), "bundle.luau");
|
|
51
|
+
writeFileSync(bundlePath, bundle, "utf8");
|
|
52
|
+
|
|
53
|
+
const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
|
|
54
|
+
if (result.error) {
|
|
55
|
+
process.stderr.write(`could not run '${luau}': ${result.error.message}\n`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
process.exit(result.status ?? 1);
|