@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.
@@ -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 lines = TextEdit.toLines(ScriptEdit.read(target))
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
- Dispatch.fail(
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
- (instance :: ScriptEdit.SourceContainer).Source = request.source
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,
@@ -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
- const PORT = 44799;
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");