@el4cteo/rbx-studio-mcp 0.7.1 → 0.7.2
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/bridge/api.js +8 -8
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/rpc.js +18 -18
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +17 -21
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/opencloud.js +7 -7
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/lib/tool.js +7 -0
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/scripts.js +55 -12
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +0 -7
- package/dist/tools/world.js.map +1 -1
- package/package.json +4 -3
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +21 -21
- package/plugin/src/Emulation.luau +8 -8
- package/plugin/src/ScriptEdit.luau +14 -14
- package/plugin/src/Secret.luau +0 -9
- package/plugin/src/Transport.luau +58 -1
- package/plugin/src/Visuals.luau +8 -8
- package/plugin/src/handlers/Anim.luau +50 -50
- package/plugin/src/handlers/Character.luau +8 -8
- package/plugin/src/handlers/Discover.luau +11 -11
- package/plugin/src/handlers/Instances.luau +5 -5
- package/plugin/src/handlers/Scripts.luau +68 -19
- package/scripts/test-bridge.mjs +40 -0
- package/scripts/test-failover.mjs +107 -99
- package/scripts/test-live.mjs +115 -0
- package/scripts/test-tools.mjs +39 -0
- package/scripts/test-transport.mjs +71 -58
|
@@ -130,22 +130,6 @@ local function flatten(node: Instance, prefix: string, into: { { [string]: any }
|
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
--[[
|
|
134
|
-
Which rig an animation was made for, read off the joints it moves.
|
|
135
|
-
|
|
136
|
-
This matters more than it sounds. An R15 animation played on an R6 character
|
|
137
|
-
does nothing at all -- no error, no warning, no movement -- because the joint
|
|
138
|
-
names it addresses ("LeftUpperArm") do not exist on that rig ("Left Arm").
|
|
139
|
-
It is one of the quietest failures on the platform, and the asset id carries
|
|
140
|
-
no hint of which kind it is.
|
|
141
|
-
|
|
142
|
-
The two skeletons are told apart by naming rather than by counting. R6 uses
|
|
143
|
-
six limbs with spaces in their names, unchanged since 2007. R15 splits each
|
|
144
|
-
limb in two and writes them without spaces. Anything that matches neither is
|
|
145
|
-
a custom rig, and is reported as such rather than guessed at -- a wrong
|
|
146
|
-
confident answer here is worse than "unknown", because the whole point of
|
|
147
|
-
the field is to be trusted.
|
|
148
|
-
]]
|
|
149
133
|
--[[
|
|
150
134
|
Which joint hangs off which, for the two rigs Roblox ships.
|
|
151
135
|
|
|
@@ -207,6 +191,22 @@ local R15_PARTS = {
|
|
|
207
191
|
RightLowerArm = true,
|
|
208
192
|
}
|
|
209
193
|
|
|
194
|
+
--[[
|
|
195
|
+
Which rig an animation was made for, read off the joints it moves.
|
|
196
|
+
|
|
197
|
+
This matters more than it sounds. An R15 animation played on an R6 character
|
|
198
|
+
does nothing at all -- no error, no warning, no movement -- because the joint
|
|
199
|
+
names it addresses ("LeftUpperArm") do not exist on that rig ("Left Arm").
|
|
200
|
+
It is one of the quietest failures on the platform, and the asset id carries
|
|
201
|
+
no hint of which kind it is.
|
|
202
|
+
|
|
203
|
+
The two skeletons are told apart by naming rather than by counting. R6 uses
|
|
204
|
+
six limbs with spaces in their names, unchanged since 2007. R15 splits each
|
|
205
|
+
limb in two and writes them without spaces. Anything that matches neither is
|
|
206
|
+
a custom rig, and is reported as such rather than guessed at -- a wrong
|
|
207
|
+
confident answer here is worse than "unknown", because the whole point of
|
|
208
|
+
the field is to be trusted.
|
|
209
|
+
]]
|
|
210
210
|
local function rigOf(joints: { [string]: boolean }): (string, string?)
|
|
211
211
|
local r6, r15 = 0, 0
|
|
212
212
|
for joint in joints do
|
|
@@ -418,21 +418,6 @@ function Anim.read(params: { [string]: any }): { [string]: any }
|
|
|
418
418
|
}
|
|
419
419
|
end
|
|
420
420
|
|
|
421
|
-
--[[
|
|
422
|
-
Builds a KeyframeSequence in memory and registers it for playback.
|
|
423
|
-
|
|
424
|
-
The registration is what makes this more than an instance tree: it returns a
|
|
425
|
-
content id that an Animation can use immediately, in this Studio, with no
|
|
426
|
-
upload and no moderation wait. That is the whole loop an agent needs to try
|
|
427
|
-
an idea -- build, set AnimationId, look -- and it is entirely reversible,
|
|
428
|
-
because nothing left the session.
|
|
429
|
-
|
|
430
|
-
Poses arrive flat, named by joint, and are nested under one root pose here.
|
|
431
|
-
Flat is what a caller can write and nesting is what the engine resolves, and
|
|
432
|
-
the rig's own hierarchy is not knowable from a list of joint names -- so a
|
|
433
|
-
single root is the honest structure: it animates the named joints and claims
|
|
434
|
-
nothing about how they connect.
|
|
435
|
-
]]
|
|
436
421
|
--[[
|
|
437
422
|
The joint tree to nest poses against.
|
|
438
423
|
|
|
@@ -477,6 +462,21 @@ local function parentMap(params: { [string]: any }, joints: { [string]: boolean
|
|
|
477
462
|
return R6_PARENT, "R6"
|
|
478
463
|
end
|
|
479
464
|
|
|
465
|
+
--[[
|
|
466
|
+
Builds a KeyframeSequence in memory and registers it for playback.
|
|
467
|
+
|
|
468
|
+
The registration is what makes this more than an instance tree: it returns a
|
|
469
|
+
content id that an Animation can use immediately, in this Studio, with no
|
|
470
|
+
upload and no moderation wait. That is the whole loop an agent needs to try
|
|
471
|
+
an idea -- build, set AnimationId, look -- and it is entirely reversible,
|
|
472
|
+
because nothing left the session.
|
|
473
|
+
|
|
474
|
+
Poses arrive flat, named by joint, and are nested under one root pose here.
|
|
475
|
+
Flat is what a caller can write and nesting is what the engine resolves, and
|
|
476
|
+
the rig's own hierarchy is not knowable from a list of joint names -- so a
|
|
477
|
+
single root is the honest structure: it animates the named joints and claims
|
|
478
|
+
nothing about how they connect.
|
|
479
|
+
]]
|
|
480
480
|
function Anim.build(params: { [string]: any }): { [string]: any }
|
|
481
481
|
local frames = params.keyframes
|
|
482
482
|
if typeof(frames) ~= "table" or #(frames :: { any }) == 0 then
|
|
@@ -636,25 +636,6 @@ function Anim.build(params: { [string]: any }): { [string]: any }
|
|
|
636
636
|
}
|
|
637
637
|
end
|
|
638
638
|
|
|
639
|
-
--[[
|
|
640
|
-
Plays an animation on a real rig, in edit mode, so it can be looked at.
|
|
641
|
-
|
|
642
|
-
`read` says what an animation contains and `build` makes one; neither answers
|
|
643
|
-
"does it look right", which for a piece of motion is most of the question.
|
|
644
|
-
Measured: an `Animator` loads and plays a track in an ordinary edit session
|
|
645
|
-
-- `Length` comes back populated and `IsPlaying` is true -- so the rig in the
|
|
646
|
-
place can be posed without pressing Play.
|
|
647
|
-
|
|
648
|
-
The frame is the point. Playing an animation and returning immediately shows
|
|
649
|
-
whatever pose the rig happened to be in, so this seeks to a chosen moment and
|
|
650
|
-
holds it there: play, jump to `at`, pause. Then `screenshot` sees that exact
|
|
651
|
-
pose, and asking for three different moments gives three comparable frames.
|
|
652
|
-
|
|
653
|
-
Deliberately leaves the rig posed rather than restoring it. The whole purpose
|
|
654
|
-
is to leave something on screen to photograph, and a tool that tidied up
|
|
655
|
-
before returning would always photograph the idle pose. `op="stop"` puts it
|
|
656
|
-
back.
|
|
657
|
-
]]
|
|
658
639
|
--[[
|
|
659
640
|
Puts a rig back the way it was before anything was played on it.
|
|
660
641
|
|
|
@@ -722,6 +703,25 @@ local function restPose(model: Instance, animator: Animator)
|
|
|
722
703
|
animator:StepAnimations(1 / 60)
|
|
723
704
|
end
|
|
724
705
|
|
|
706
|
+
--[[
|
|
707
|
+
Plays an animation on a real rig, in edit mode, so it can be looked at.
|
|
708
|
+
|
|
709
|
+
`read` says what an animation contains and `build` makes one; neither answers
|
|
710
|
+
"does it look right", which for a piece of motion is most of the question.
|
|
711
|
+
Measured: an `Animator` loads and plays a track in an ordinary edit session
|
|
712
|
+
-- `Length` comes back populated and `IsPlaying` is true -- so the rig in the
|
|
713
|
+
place can be posed without pressing Play.
|
|
714
|
+
|
|
715
|
+
The frame is the point. Playing an animation and returning immediately shows
|
|
716
|
+
whatever pose the rig happened to be in, so this seeks to a chosen moment and
|
|
717
|
+
holds it there: play, jump to `at`, pause. Then `screenshot` sees that exact
|
|
718
|
+
pose, and asking for three different moments gives three comparable frames.
|
|
719
|
+
|
|
720
|
+
Deliberately leaves the rig posed rather than restoring it. The whole purpose
|
|
721
|
+
is to leave something on screen to photograph, and a tool that tidied up
|
|
722
|
+
before returning would always photograph the idle pose. `op="stop"` puts it
|
|
723
|
+
back.
|
|
724
|
+
]]
|
|
725
725
|
function Anim.preview(params: { [string]: any }): { [string]: any }
|
|
726
726
|
local rigPath = params.rig
|
|
727
727
|
if typeof(rigPath) ~= "string" or rigPath == "" then
|
|
@@ -178,14 +178,6 @@ local function takeControl(model: Model): boolean
|
|
|
178
178
|
return ok
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
-
--[[
|
|
182
|
-
Walks to a point, following a computed path around obstacles.
|
|
183
|
-
|
|
184
|
-
Reports where it ended up rather than only whether the call returned, because
|
|
185
|
-
`MoveTo` succeeds at being asked and says nothing about arriving. A route
|
|
186
|
-
blocked by a wall the agent did not know about looks identical to a
|
|
187
|
-
successful walk unless the final distance is measured.
|
|
188
|
-
]]
|
|
189
181
|
--[[
|
|
190
182
|
Agent settings, shared by the route check and the walk.
|
|
191
183
|
|
|
@@ -336,6 +328,14 @@ function Character.path(params: { [string]: any }): { [string]: any }
|
|
|
336
328
|
}
|
|
337
329
|
end
|
|
338
330
|
|
|
331
|
+
--[[
|
|
332
|
+
Walks to a point, following a computed path around obstacles.
|
|
333
|
+
|
|
334
|
+
Reports where it ended up rather than only whether the call returned, because
|
|
335
|
+
`MoveTo` succeeds at being asked and says nothing about arriving. A route
|
|
336
|
+
blocked by a wall the agent did not know about looks identical to a
|
|
337
|
+
successful walk unless the final distance is measured.
|
|
338
|
+
]]
|
|
339
339
|
function Character.moveTo(params: { [string]: any }): { [string]: any }
|
|
340
340
|
local model, humanoid = humanoidOf(params)
|
|
341
341
|
|
|
@@ -184,10 +184,6 @@ function Discover.tree(params: { [string]: any }): { [string]: any }
|
|
|
184
184
|
}
|
|
185
185
|
end
|
|
186
186
|
|
|
187
|
-
--[[
|
|
188
|
-
Detailed read of specific instances. `properties` is chosen server-side from
|
|
189
|
-
the live API dump, so this handler never needs its own class table.
|
|
190
|
-
]]
|
|
191
187
|
--[[
|
|
192
188
|
The physical facts about a part, which no property panel shows.
|
|
193
189
|
|
|
@@ -323,6 +319,10 @@ local function physicsOf(target: Instance): { [string]: any }?
|
|
|
323
319
|
return facts
|
|
324
320
|
end
|
|
325
321
|
|
|
322
|
+
--[[
|
|
323
|
+
Detailed read of specific instances. `properties` is chosen server-side from
|
|
324
|
+
the live API dump, so this handler never needs its own class table.
|
|
325
|
+
]]
|
|
326
326
|
function Discover.inspect(params: { [string]: any }): { [string]: any }
|
|
327
327
|
local paths = params.paths
|
|
328
328
|
if typeof(paths) ~= "table" or #paths == 0 then
|
|
@@ -415,13 +415,6 @@ function Discover.inspect(params: { [string]: any }): { [string]: any }
|
|
|
415
415
|
return { items = results, failures = failures }
|
|
416
416
|
end
|
|
417
417
|
|
|
418
|
-
--[[
|
|
419
|
-
One search over name, class, property value and tag.
|
|
420
|
-
|
|
421
|
-
Every filter supplied must match (AND), which is what lets a single tool
|
|
422
|
-
answer "every anchored Part under Workspace whose name contains 'door'"
|
|
423
|
-
without the agent chaining three calls and intersecting the results itself.
|
|
424
|
-
]]
|
|
425
418
|
--[[
|
|
426
419
|
Runs a selector through the engine's own matcher.
|
|
427
420
|
|
|
@@ -582,6 +575,13 @@ function Discover.tags(params: { [string]: any }): { [string]: any }
|
|
|
582
575
|
}
|
|
583
576
|
end
|
|
584
577
|
|
|
578
|
+
--[[
|
|
579
|
+
One search over name, class, property value and tag.
|
|
580
|
+
|
|
581
|
+
Every filter supplied must match (AND), which is what lets a single tool
|
|
582
|
+
answer "every anchored Part under Workspace whose name contains 'door'"
|
|
583
|
+
without the agent chaining three calls and intersecting the results itself.
|
|
584
|
+
]]
|
|
585
585
|
function Discover.find(params: { [string]: any }): { [string]: any }
|
|
586
586
|
local root = if params.path then Paths.resolve(params.path) else game
|
|
587
587
|
local limit = tonumber(params.limit) or 100
|
|
@@ -109,6 +109,11 @@ local function applyTags(target: Instance, tags: { [string]: any })
|
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
--[[
|
|
113
|
+
One property that could not be applied yet, kept for a second attempt.
|
|
114
|
+
]]
|
|
115
|
+
type Deferred = { target: Instance, name: string, spec: PropertySpec, className: string }
|
|
116
|
+
|
|
112
117
|
--[[
|
|
113
118
|
Builds one instance and its descendants.
|
|
114
119
|
|
|
@@ -116,11 +121,6 @@ end
|
|
|
116
121
|
model appears in the Explorer complete rather than assembling itself piece by
|
|
117
122
|
piece in front of the user.
|
|
118
123
|
]]
|
|
119
|
-
--[[
|
|
120
|
-
One property that could not be applied yet, kept for a second attempt.
|
|
121
|
-
]]
|
|
122
|
-
type Deferred = { target: Instance, name: string, spec: PropertySpec, className: string }
|
|
123
|
-
|
|
124
124
|
local function build(
|
|
125
125
|
spec: { [string]: any },
|
|
126
126
|
parent: Instance,
|
|
@@ -56,11 +56,6 @@ local function resolveScript(path: string): LuaSourceContainer
|
|
|
56
56
|
return instance :: LuaSourceContainer
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
--[[
|
|
60
|
-
Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
|
|
61
|
-
inclusive, matching the numbers script_edit takes back, so a read and a write
|
|
62
|
-
need no off-by-one conversion between them.
|
|
63
|
-
]]
|
|
64
59
|
--[[
|
|
65
60
|
A short fingerprint of a script's source, for detecting that it moved.
|
|
66
61
|
|
|
@@ -136,6 +131,11 @@ local function syncedFile(target: Instance): string?
|
|
|
136
131
|
return name
|
|
137
132
|
end
|
|
138
133
|
|
|
134
|
+
--[[
|
|
135
|
+
Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
|
|
136
|
+
inclusive, matching the numbers script_edit takes back, so a read and a write
|
|
137
|
+
need no off-by-one conversion between them.
|
|
138
|
+
]]
|
|
139
139
|
function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
140
140
|
local paths = params.paths
|
|
141
141
|
if typeof(paths) ~= "table" or #paths == 0 then
|
|
@@ -381,12 +381,45 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
381
381
|
)
|
|
382
382
|
end
|
|
383
383
|
|
|
384
|
+
local function badPattern(reason: any): never
|
|
385
|
+
Dispatch.fail(
|
|
386
|
+
"BAD_PATTERN",
|
|
387
|
+
string.format("%s is not a valid Lua pattern: %s", pattern, tostring(reason)),
|
|
388
|
+
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
389
|
+
.. "Set `literal` to search for the text exactly as written."
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
--[[
|
|
394
|
+
One search over the whole file before splitting it into lines.
|
|
395
|
+
|
|
396
|
+
Most scripts in a place do not contain what is being looked for, and
|
|
397
|
+
splitting each into a line table only to find nothing was most of the cost
|
|
398
|
+
of a place-wide search. A pattern that matches no line cannot match the
|
|
399
|
+
whole file either -- except one anchored with `^` or `$`, which mean line
|
|
400
|
+
ends here but file ends there, so those skip this shortcut.
|
|
401
|
+
]]
|
|
402
|
+
-- Any trailing `$` counts, escaped or not: skipping the shortcut costs speed,
|
|
403
|
+
-- taking it wrongly would hide matches.
|
|
404
|
+
local anchored = not literal and (string.sub(needle, 1, 1) == "^" or string.sub(needle, -1) == "$")
|
|
405
|
+
|
|
384
406
|
local matches: { { [string]: any } } = {}
|
|
385
407
|
local total = 0
|
|
386
408
|
local memo: Paths.NameIndex = {}
|
|
387
409
|
|
|
388
410
|
for _, target in targets do
|
|
389
|
-
local
|
|
411
|
+
local source = ScriptEdit.read(target)
|
|
412
|
+
if not anchored then
|
|
413
|
+
local ok, found = pcall(string.find, if ignoreCase then string.lower(source) else source, needle, 1, literal)
|
|
414
|
+
if not ok then
|
|
415
|
+
badPattern(found)
|
|
416
|
+
end
|
|
417
|
+
if not found then
|
|
418
|
+
continue
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
local lines = TextEdit.toLines(source)
|
|
390
423
|
local path: string? = nil
|
|
391
424
|
|
|
392
425
|
for number, line in lines do
|
|
@@ -395,12 +428,7 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
395
428
|
-- has to be caught and reported as a pattern problem, not a no-match.
|
|
396
429
|
local ok, from = pcall(string.find, haystack, needle, 1, literal)
|
|
397
430
|
if not ok then
|
|
398
|
-
|
|
399
|
-
"BAD_PATTERN",
|
|
400
|
-
string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
|
|
401
|
-
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
402
|
-
.. "Set `literal` to search for the text exactly as written."
|
|
403
|
-
)
|
|
431
|
+
badPattern(from)
|
|
404
432
|
end
|
|
405
433
|
if not from then
|
|
406
434
|
continue
|
|
@@ -440,12 +468,6 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
440
468
|
}
|
|
441
469
|
end
|
|
442
470
|
|
|
443
|
-
--[[
|
|
444
|
-
Creates scripts. Source is assigned directly here rather than through
|
|
445
|
-
`UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
|
|
446
|
-
open in the editor and there is no buffer to conflict with. Every later edit
|
|
447
|
-
goes through the editor path.
|
|
448
|
-
]]
|
|
449
471
|
--[[
|
|
450
472
|
Names the starter container a script was just parented into, or nil.
|
|
451
473
|
|
|
@@ -457,6 +479,14 @@ end
|
|
|
457
479
|
Client over LocalScript" advice writes a double-running script and is given
|
|
458
480
|
no way to find out.
|
|
459
481
|
]]
|
|
482
|
+
--[[
|
|
483
|
+
Assigning `Source` fails at 200,000 characters or more ("Provided string
|
|
484
|
+
length ... is greater than or equal to max length (200000)"), while
|
|
485
|
+
`UpdateSourceAsync` takes far more -- measured writing 279,000. Sources at
|
|
486
|
+
the limit are written through the editor instead of refused.
|
|
487
|
+
]]
|
|
488
|
+
local SOURCE_PROPERTY_LIMIT = 200000
|
|
489
|
+
|
|
460
490
|
local STARTER_CONTAINERS = {
|
|
461
491
|
"StarterGui",
|
|
462
492
|
"StarterPack",
|
|
@@ -473,6 +503,12 @@ local function starterContainer(instance: Instance): string?
|
|
|
473
503
|
return nil
|
|
474
504
|
end
|
|
475
505
|
|
|
506
|
+
--[[
|
|
507
|
+
Creates scripts. Source is assigned directly here rather than through
|
|
508
|
+
`UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
|
|
509
|
+
open in the editor and there is no buffer to conflict with. Every later edit
|
|
510
|
+
goes through the editor path.
|
|
511
|
+
]]
|
|
476
512
|
function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
477
513
|
local requests = params.scripts
|
|
478
514
|
if typeof(requests) ~= "table" or #requests == 0 then
|
|
@@ -503,6 +539,7 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
|
503
539
|
-- No shared path memo here: each creation changes its parent's children, so a
|
|
504
540
|
-- cached sibling grouping would go stale mid-batch and mis-number the paths.
|
|
505
541
|
local warnings: { string } = {}
|
|
542
|
+
local oversized: { { target: LuaSourceContainer, source: string } } = {}
|
|
506
543
|
|
|
507
544
|
local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
|
|
508
545
|
local created: { { [string]: any } } = {}
|
|
@@ -513,7 +550,11 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
|
513
550
|
|
|
514
551
|
instance.Name = request.name
|
|
515
552
|
if typeof(request.source) == "string" then
|
|
516
|
-
|
|
553
|
+
if #request.source < SOURCE_PROPERTY_LIMIT then
|
|
554
|
+
(instance :: ScriptEdit.SourceContainer).Source = request.source
|
|
555
|
+
else
|
|
556
|
+
table.insert(oversized, { target = instance, source = request.source })
|
|
557
|
+
end
|
|
517
558
|
end
|
|
518
559
|
|
|
519
560
|
if typeof(request.runContext) == "string" and instance:IsA("Script") then
|
|
@@ -605,6 +646,14 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
|
605
646
|
return created
|
|
606
647
|
end)
|
|
607
648
|
|
|
649
|
+
-- After the recording, because the editor write yields. Undoing the create
|
|
650
|
+
-- still removes these scripts whole, source and all.
|
|
651
|
+
for _, entry in oversized do
|
|
652
|
+
ScriptEdit.write(entry.target, function()
|
|
653
|
+
return entry.source
|
|
654
|
+
end)
|
|
655
|
+
end
|
|
656
|
+
|
|
608
657
|
return {
|
|
609
658
|
items = created,
|
|
610
659
|
undoStep = if recorded then "MCP create script" else nil,
|
package/scripts/test-bridge.mjs
CHANGED
|
@@ -322,6 +322,46 @@ function twoStudios() {
|
|
|
322
322
|
second.destroy();
|
|
323
323
|
await settle();
|
|
324
324
|
assert.equal(await count(), 0, "a stream that dies without a goodbye is dropped");
|
|
325
|
+
|
|
326
|
+
// A command far bigger than one socket read goes down the stream as ONE
|
|
327
|
+
// event, and its answer comes back. The plugin half of this -- joining the
|
|
328
|
+
// reads back together -- is covered by the frameReader cases in
|
|
329
|
+
// tests/sseframes.luau; this pins the bridge half over real sockets.
|
|
330
|
+
const source = "local x = 1\n".repeat(100_000);
|
|
331
|
+
const events = [];
|
|
332
|
+
const plugin = await new Promise((resolve) => {
|
|
333
|
+
const req = request(
|
|
334
|
+
{ host: "127.0.0.1", port, path: "/events", method: "POST", headers: { "x-roblox-studio-mcp": "test" } },
|
|
335
|
+
(res) => {
|
|
336
|
+
let pending = "";
|
|
337
|
+
res.setEncoding("utf8");
|
|
338
|
+
res.on("data", (chunk) => {
|
|
339
|
+
pending += chunk;
|
|
340
|
+
let cut;
|
|
341
|
+
while ((cut = pending.indexOf("\n\n")) !== -1) {
|
|
342
|
+
const block = pending.slice(0, cut);
|
|
343
|
+
pending = pending.slice(cut + 2);
|
|
344
|
+
if (!block.startsWith("data: ")) continue;
|
|
345
|
+
const frame = JSON.parse(block.slice(6));
|
|
346
|
+
if (typeof frame.id !== "string") continue;
|
|
347
|
+
events.push(frame);
|
|
348
|
+
void fetch(`http://127.0.0.1:${port}/result?studioId=big`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: { "x-roblox-studio-mcp": "test" },
|
|
351
|
+
body: JSON.stringify({ id: frame.id, ok: true, data: { length: frame.params.source.length } }),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
resolve(req);
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
req.end(JSON.stringify({ studioId: "big", placeName: "p", placeId: 1 }));
|
|
359
|
+
});
|
|
360
|
+
const answer = await server.bridge.call("script.create", { source }, { studioId: "big" });
|
|
361
|
+
assert.equal(answer.length, source.length, "a 1.2MB command arrives whole and is answered");
|
|
362
|
+
assert.equal(events.length, 1, "as exactly one event");
|
|
363
|
+
plugin.destroy();
|
|
364
|
+
|
|
325
365
|
await server.close();
|
|
326
366
|
}
|
|
327
367
|
|
|
@@ -20,7 +20,15 @@ import { startBridgeServer } from "../dist/bridge/server.js";
|
|
|
20
20
|
import { CLIENT_HEADER } from "../dist/lib/protocol.js";
|
|
21
21
|
import { PEER_HEADER } from "../dist/bridge/remote.js";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// A free port, not a fixed one: a fixed port fails the whole suite whenever
|
|
24
|
+
// anything else on the machine happens to hold it.
|
|
25
|
+
const PORT = await new Promise((resolve, reject) => {
|
|
26
|
+
const probe = createServer().listen(0, "127.0.0.1", () => {
|
|
27
|
+
const { port } = probe.address();
|
|
28
|
+
probe.close(() => resolve(port));
|
|
29
|
+
});
|
|
30
|
+
probe.once("error", reject);
|
|
31
|
+
});
|
|
24
32
|
|
|
25
33
|
/** Waits for `check` to hold, or gives up loudly rather than hanging the suite. */
|
|
26
34
|
async function until(check, what, timeoutMs = 20_000) {
|
|
@@ -133,102 +141,102 @@ function handshake(port, studioId) {
|
|
|
133
141
|
await owner.close();
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
// POST /hello carries the panel's spawned marker all the way to the roster.
|
|
137
|
-
//
|
|
138
|
-
// The shipped bug: every layer marked the agent the panel starts -- the spawn's
|
|
139
|
-
// environment, the MCP config handed to the agent, the hello body -- and this
|
|
140
|
-
// route typed the body with three fields and dropped the fourth. So the panel
|
|
141
|
-
// said "2 MCP clients connected" on every prompt and kept a stopped agent in
|
|
142
|
-
// `clients` until the stale sweep. Tested at the route, because the route is
|
|
143
|
-
// the layer that was wrong while the bridge underneath it was right.
|
|
144
|
-
{
|
|
145
|
-
const owner = await startBridgeServer({ port: PORT });
|
|
146
|
-
|
|
147
|
-
const hello = async (id, body) => {
|
|
148
|
-
const sent = await fetch(`http://127.0.0.1:${PORT}/hello`, {
|
|
149
|
-
method: "POST",
|
|
150
|
-
headers: {
|
|
151
|
-
[CLIENT_HEADER]: "test",
|
|
152
|
-
[PEER_HEADER]: id,
|
|
153
|
-
"Content-Type": "application/json",
|
|
154
|
-
},
|
|
155
|
-
body: JSON.stringify(body),
|
|
156
|
-
});
|
|
157
|
-
return (await sent.json()).clients;
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const alone = await hello("a-stranger", { name: "codex", version: "1", pid: 1 });
|
|
161
|
-
const withAgent = await hello("panel-agent", {
|
|
162
|
-
name: "claude-code",
|
|
163
|
-
version: "1",
|
|
164
|
-
pid: 2,
|
|
165
|
-
spawned: true,
|
|
166
|
-
});
|
|
167
|
-
assert.equal(withAgent, alone, "an agent the panel started is not another client");
|
|
168
|
-
// A second stranger does count, or the filter would be hiding everyone.
|
|
169
|
-
const withStranger = await hello("another-stranger", { name: "opencode", pid: 3 });
|
|
170
|
-
assert.equal(withStranger, alone + 1, "a client nobody asked for still counts");
|
|
171
|
-
|
|
172
|
-
// A keepalive that says nothing must not erase what the first hello said, and
|
|
173
|
-
// must not re-announce the client as if it had just arrived.
|
|
174
|
-
assert.equal(
|
|
175
|
-
await hello("a-stranger", { pid: 1 }),
|
|
176
|
-
withStranger,
|
|
177
|
-
"a nameless keepalive is not a new client",
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
await owner.close();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// An owner that dies WHILE answering is still OWNER_GONE, not a raw Node error.
|
|
184
|
-
//
|
|
185
|
-
// The shipped bug: only the fetch was guarded, so a socket cut after the
|
|
186
|
-
// headers threw "TypeError: fetch failed / SocketError: other side closed"
|
|
187
|
-
// straight past the catch. It surfaced right under the takeover notice, which
|
|
188
|
-
// is the exact moment a handover cuts a live response.
|
|
189
|
-
//
|
|
190
|
-
// The fake owner declares a Content-Length it never delivers, then hangs up.
|
|
191
|
-
// That distinction is the whole test: a socket destroyed BEFORE the headers
|
|
192
|
-
// makes Node reject the fetch itself, which the old code already caught -- the
|
|
193
|
-
// bug only shows when the reply has started and stops halfway. Two earlier
|
|
194
|
-
// versions of this test cut the socket too early, passed with the bug present,
|
|
195
|
-
// and proved nothing.
|
|
196
|
-
{
|
|
197
|
-
const halfDead = createServer((req, res) => {
|
|
198
|
-
if (req.url === "/identity") {
|
|
199
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
200
|
-
res.end(JSON.stringify({ server: "roblox-studio-mcp", protocolVersion: 1, pid: 1 }));
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": "5000" });
|
|
204
|
-
res.write('{"ok":true,"data":"' + "x".repeat(100));
|
|
205
|
-
setTimeout(() => res.socket.destroy(), 150);
|
|
206
|
-
});
|
|
207
|
-
await new Promise((resolve) => halfDead.listen(PORT, "127.0.0.1", resolve));
|
|
208
|
-
|
|
209
|
-
const peer = await startBridgeServer({ port: PORT });
|
|
210
|
-
assert.equal(peer.owner, false, "the peer proxies to whatever holds the port");
|
|
211
|
-
|
|
212
|
-
let raised;
|
|
213
|
-
try {
|
|
214
|
-
await peer.bridge.call("studio.status", {});
|
|
215
|
-
} catch (cause) {
|
|
216
|
-
raised = cause;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
assert.ok(raised, "a truncated answer must not resolve as success");
|
|
220
|
-
assert.equal(
|
|
221
|
-
raised.code,
|
|
222
|
-
"OWNER_GONE",
|
|
223
|
-
`a body cut short is the owner going away, not a raw ${raised?.name}`,
|
|
224
|
-
);
|
|
225
|
-
assert.ok(
|
|
226
|
-
/try the call again/.test(raised.hint ?? ""),
|
|
227
|
-
"and says what to do about it, which a raw socket error never does",
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
await peer.close();
|
|
231
|
-
await new Promise((resolve) => halfDead.close(resolve));
|
|
232
|
-
}
|
|
233
|
-
|
|
144
|
+
// POST /hello carries the panel's spawned marker all the way to the roster.
|
|
145
|
+
//
|
|
146
|
+
// The shipped bug: every layer marked the agent the panel starts -- the spawn's
|
|
147
|
+
// environment, the MCP config handed to the agent, the hello body -- and this
|
|
148
|
+
// route typed the body with three fields and dropped the fourth. So the panel
|
|
149
|
+
// said "2 MCP clients connected" on every prompt and kept a stopped agent in
|
|
150
|
+
// `clients` until the stale sweep. Tested at the route, because the route is
|
|
151
|
+
// the layer that was wrong while the bridge underneath it was right.
|
|
152
|
+
{
|
|
153
|
+
const owner = await startBridgeServer({ port: PORT });
|
|
154
|
+
|
|
155
|
+
const hello = async (id, body) => {
|
|
156
|
+
const sent = await fetch(`http://127.0.0.1:${PORT}/hello`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
[CLIENT_HEADER]: "test",
|
|
160
|
+
[PEER_HEADER]: id,
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
});
|
|
165
|
+
return (await sent.json()).clients;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const alone = await hello("a-stranger", { name: "codex", version: "1", pid: 1 });
|
|
169
|
+
const withAgent = await hello("panel-agent", {
|
|
170
|
+
name: "claude-code",
|
|
171
|
+
version: "1",
|
|
172
|
+
pid: 2,
|
|
173
|
+
spawned: true,
|
|
174
|
+
});
|
|
175
|
+
assert.equal(withAgent, alone, "an agent the panel started is not another client");
|
|
176
|
+
// A second stranger does count, or the filter would be hiding everyone.
|
|
177
|
+
const withStranger = await hello("another-stranger", { name: "opencode", pid: 3 });
|
|
178
|
+
assert.equal(withStranger, alone + 1, "a client nobody asked for still counts");
|
|
179
|
+
|
|
180
|
+
// A keepalive that says nothing must not erase what the first hello said, and
|
|
181
|
+
// must not re-announce the client as if it had just arrived.
|
|
182
|
+
assert.equal(
|
|
183
|
+
await hello("a-stranger", { pid: 1 }),
|
|
184
|
+
withStranger,
|
|
185
|
+
"a nameless keepalive is not a new client",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await owner.close();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// An owner that dies WHILE answering is still OWNER_GONE, not a raw Node error.
|
|
192
|
+
//
|
|
193
|
+
// The shipped bug: only the fetch was guarded, so a socket cut after the
|
|
194
|
+
// headers threw "TypeError: fetch failed / SocketError: other side closed"
|
|
195
|
+
// straight past the catch. It surfaced right under the takeover notice, which
|
|
196
|
+
// is the exact moment a handover cuts a live response.
|
|
197
|
+
//
|
|
198
|
+
// The fake owner declares a Content-Length it never delivers, then hangs up.
|
|
199
|
+
// That distinction is the whole test: a socket destroyed BEFORE the headers
|
|
200
|
+
// makes Node reject the fetch itself, which the old code already caught -- the
|
|
201
|
+
// bug only shows when the reply has started and stops halfway. Two earlier
|
|
202
|
+
// versions of this test cut the socket too early, passed with the bug present,
|
|
203
|
+
// and proved nothing.
|
|
204
|
+
{
|
|
205
|
+
const halfDead = createServer((req, res) => {
|
|
206
|
+
if (req.url === "/identity") {
|
|
207
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
208
|
+
res.end(JSON.stringify({ server: "roblox-studio-mcp", protocolVersion: 1, pid: 1 }));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": "5000" });
|
|
212
|
+
res.write('{"ok":true,"data":"' + "x".repeat(100));
|
|
213
|
+
setTimeout(() => res.socket.destroy(), 150);
|
|
214
|
+
});
|
|
215
|
+
await new Promise((resolve) => halfDead.listen(PORT, "127.0.0.1", resolve));
|
|
216
|
+
|
|
217
|
+
const peer = await startBridgeServer({ port: PORT });
|
|
218
|
+
assert.equal(peer.owner, false, "the peer proxies to whatever holds the port");
|
|
219
|
+
|
|
220
|
+
let raised;
|
|
221
|
+
try {
|
|
222
|
+
await peer.bridge.call("studio.status", {});
|
|
223
|
+
} catch (cause) {
|
|
224
|
+
raised = cause;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
assert.ok(raised, "a truncated answer must not resolve as success");
|
|
228
|
+
assert.equal(
|
|
229
|
+
raised.code,
|
|
230
|
+
"OWNER_GONE",
|
|
231
|
+
`a body cut short is the owner going away, not a raw ${raised?.name}`,
|
|
232
|
+
);
|
|
233
|
+
assert.ok(
|
|
234
|
+
/try the call again/.test(raised.hint ?? ""),
|
|
235
|
+
"and says what to do about it, which a raw socket error never does",
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await peer.close();
|
|
239
|
+
await new Promise((resolve) => halfDead.close(resolve));
|
|
240
|
+
}
|
|
241
|
+
|
|
234
242
|
process.stdout.write("failover: ok\n");
|