@el4cteo/rbx-studio-mcp 0.2.0 → 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.
@@ -1,6 +1,6 @@
1
1
  --!strict
2
2
  --[[
3
- The Studio MCP console.
3
+ The rbx-studio console.
4
4
 
5
5
  This is the only feedback channel the plugin has. It deliberately never
6
6
  writes to Studio's Output window, because that log is also what the agent
@@ -107,12 +107,22 @@ end
107
107
 
108
108
  type State = {
109
109
  lines: { string },
110
+ --[[
111
+ Rows written, as opposed to lines rendered.
112
+
113
+ `lines` also holds the indented continuations a long detail wraps onto,
114
+ so counting it would report a number nobody wrote -- "cleared 41 logs"
115
+ for twelve commands. This counts calls to `log`, which is what a person
116
+ means by a log line.
117
+ ]]
118
+ entries: number,
110
119
  label: TextLabel?,
111
120
  scroller: ScrollingFrame?,
112
121
  statusDot: TextLabel?,
113
122
  statusText: TextLabel?,
114
123
  metaText: TextLabel?,
115
124
  countersText: TextLabel?,
125
+ clientsChip: TextLabel?,
116
126
  pinned: boolean,
117
127
  calls: number,
118
128
  errors: number,
@@ -120,23 +130,43 @@ type State = {
120
130
  -- The command in flight, shown in the footer while it runs. One value that
121
131
  -- is replaced, never a list that grows.
122
132
  running: string?,
133
+ --[[
134
+ Bumped by every call, so a pending idle timer can tell whether it is still
135
+ the most recent one. Cheaper and less error-prone than cancelling timers:
136
+ `task.delay` has no handle to cancel, and a stale closure that checks a
137
+ counter simply does nothing.
138
+ ]]
139
+ generation: number,
140
+ -- How many MCP clients share this bridge. Only ever displayed above one.
141
+ clients: number,
142
+ -- Set while a repaint is already scheduled for the end of this frame.
143
+ dirty: boolean,
123
144
  }
124
145
 
125
146
  local state: State = {
126
147
  lines = {},
148
+ entries = 0,
127
149
  label = nil,
128
150
  scroller = nil,
129
151
  statusDot = nil,
130
152
  statusText = nil,
131
153
  metaText = nil,
132
154
  countersText = nil,
155
+ clientsChip = nil,
133
156
  pinned = true,
134
157
  calls = 0,
135
158
  errors = 0,
136
159
  totalMs = 0,
137
160
  running = nil,
161
+ generation = 0,
162
+ clients = 1,
163
+ dirty = false,
138
164
  }
139
165
 
166
+ -- How long the session must be silent before the console says so. Long enough
167
+ -- that an agent pausing to think is not announced as having stopped.
168
+ local QUIET_SECONDS = 20
169
+
140
170
  local function hex(color: Color3): string
141
171
  return string.format(
142
172
  "#%02X%02X%02X",
@@ -159,7 +189,8 @@ local function span(color: Color3, value: string): string
159
189
  return string.format('<font color="%s">%s</font>', hex(color), escape(value))
160
190
  end
161
191
 
162
- local function redraw()
192
+ local function paint()
193
+ state.dirty = false
163
194
  local label = state.label
164
195
  if not label then
165
196
  return
@@ -177,6 +208,24 @@ local function redraw()
177
208
  end
178
209
  end
179
210
 
211
+ --[[
212
+ Asks for a repaint, at most one per frame.
213
+
214
+ Painting is a concat of up to three hundred strings followed by a RichText
215
+ relayout of the whole label, and it used to run once per appended row. A
216
+ failed command writes three rows -- the failure, its message, sometimes a
217
+ hint -- so a single unlucky call repainted the entire console three times,
218
+ and all of that sat in front of the reply on its way back to the agent.
219
+ Deferring collapses them into the one paint that was always sufficient.
220
+ ]]
221
+ local function redraw()
222
+ if state.dirty then
223
+ return
224
+ end
225
+ state.dirty = true
226
+ task.defer(paint)
227
+ end
228
+
180
229
  local function refreshCounters()
181
230
  local counters = state.countersText
182
231
  if not counters then
@@ -257,6 +306,7 @@ function Console.log(level: Level, message: string, detail: string?)
257
306
  off beneath it.
258
307
  ]]
259
308
  table.insert(state.lines, line)
309
+ state.entries += 1
260
310
  if detail then
261
311
  if inlineDetail then
262
312
  local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
@@ -303,6 +353,7 @@ local KIND_LOOK: { [string]: { color: Color3, urgency: number } } = {
303
353
 
304
354
  function Console.beginCall(title: string, kind: string)
305
355
  state.running = title
356
+ state.generation += 1
306
357
  local look = KIND_LOOK[kind] or KIND_LOOK.read
307
358
  Visuals.setCaption(title)
308
359
  Visuals.setKind(look.color, look.urgency)
@@ -319,6 +370,9 @@ function Console.recordCall(ok: boolean, milliseconds: number)
319
370
  if not ok then
320
371
  state.errors += 1
321
372
  end
373
+ -- Read before it is cleared: the bar wants the same phrase the log row uses,
374
+ -- and this is the only place that still has it.
375
+ local title = state.running or "call"
322
376
  state.running = nil
323
377
  refreshCounters()
324
378
  -- The footer reports totals when idle, so the band shows what just ran
@@ -327,7 +381,35 @@ function Console.recordCall(ok: boolean, milliseconds: number)
327
381
 
328
382
  -- The band plots it: bar height is how long it took, colour is whether it
329
383
  -- worked, and a failure knocks the solid off its axis as well.
330
- Visuals.recordCall(milliseconds, ok)
384
+ Visuals.recordCall(milliseconds, ok, title)
385
+
386
+ --[[
387
+ Says so when the session goes quiet, once per burst.
388
+
389
+ There is no "the agent has finished" message in MCP -- an agent that has
390
+ stopped and one that is thinking are the same silence -- so this reports
391
+ the silence rather than claiming to know what caused it: what ran, how
392
+ much of it, and how fast. The generation check is what makes it once per
393
+ burst: every later call bumps the counter, so all but the newest timer
394
+ wake up, find they are stale, and do nothing.
395
+ ]]
396
+ state.generation += 1
397
+ local mine = state.generation
398
+ task.delay(QUIET_SECONDS, function()
399
+ if state.generation ~= mine or state.calls == 0 then
400
+ return
401
+ end
402
+ Console.log(
403
+ "dim",
404
+ string.format(
405
+ "agent idle -- %d call%s, avg %.0fms",
406
+ state.calls,
407
+ if state.calls == 1 then "" else "s",
408
+ state.totalMs / state.calls
409
+ )
410
+ )
411
+ Visuals.setQuiet()
412
+ end)
331
413
  end
332
414
 
333
415
  --[[
@@ -342,13 +424,99 @@ function Console.setCaption(message: string)
342
424
  Visuals.setCaption(message)
343
425
  end
344
426
 
427
+ --[[
428
+ Announces that an MCP client disconnected.
429
+
430
+ This is the one moment the bridge can be certain a session ended rather than
431
+ paused -- the client's process is gone -- so it is the one place the console
432
+ is allowed to state it outright. Everything else it knows about agent
433
+ activity is inference, and is worded as inference.
434
+ ]]
435
+ function Console.agentFinished()
436
+ --[[
437
+ Named for what happened, not for what it might have meant.
438
+
439
+ This said "Agent finished task." and that was overclaiming: the bridge
440
+ sees a client disconnect and nothing more. Quitting the editor, a crash
441
+ and a restart all arrive here identically, and the first time one was
442
+ watched live it announced a completed task for an agent that had simply
443
+ been closed mid-idle.
444
+ ]]
445
+ Console.log("ok", "Agent disconnected.", "client left")
446
+ Visuals.setQuiet()
447
+ end
448
+
449
+ --[[
450
+ How many MCP clients are sharing this bridge.
451
+
452
+ Reported only above one. A single client is the ordinary case and needs no
453
+ badge -- a permanent "1 client connected" is a label, not a signal, and the
454
+ header has better uses for the width.
455
+ ]]
456
+ function Console.setClients(count: number)
457
+ local previous = state.clients
458
+ state.clients = count
459
+
460
+ local chip = state.clientsChip
461
+ if chip then
462
+ chip.Visible = count > 1
463
+ -- Pluralised even though the badge hides at one. A string that is only
464
+ -- ever correct because nobody can see it is a trap for whoever changes
465
+ -- the visibility rule later.
466
+ chip.Text = string.format(
467
+ "\u{25C6} %d client%s",
468
+ count,
469
+ if count == 1 then "" else "s"
470
+ )
471
+ end
472
+
473
+ local meta = state.metaText
474
+ if meta then
475
+ meta.Size = UDim2.new(1, if count > 1 then -486 else -394, 1, 0)
476
+ end
477
+
478
+ --[[
479
+ Only the arrival is worth a row.
480
+
481
+ The departure had one too, and seeing it live made the redundancy plain:
482
+ "one MCP client connected" landed in the same second as "Agent finished
483
+ task.", saying the same thing less well, while the badge disappearing
484
+ said it a third time. The count going up is news because nothing else
485
+ reports it; the count coming down is already covered twice over.
486
+ ]]
487
+ if count > 1 and previous <= 1 then
488
+ Console.log("info", string.format("%d MCP clients connected", count))
489
+ end
490
+ end
491
+
345
492
  function Console.clear()
493
+ --[[
494
+ Says what it did, with a timestamp, like everything else in here.
495
+
496
+ A button that empties the screen and leaves no trace is indistinguishable
497
+ from one that crashed the panel. The count is read before the clear and
498
+ written after it, so the first row of the fresh log is the receipt for
499
+ the one that went.
500
+ ]]
501
+ local cleared = state.entries
346
502
  table.clear(state.lines)
503
+ state.entries = 0
347
504
  state.calls = 0
348
505
  state.errors = 0
349
506
  state.totalMs = 0
350
507
  refreshCounters()
508
+ -- The bars belonged to the log that just went. Leaving forty timings from an
509
+ -- erased session on screen while the footer reads "idle" is two answers to
510
+ -- one question.
511
+ Visuals.clearTrace()
351
512
  redraw()
513
+
514
+ if cleared > 0 then
515
+ Console.log(
516
+ "dim",
517
+ string.format("cleared %d log%s", cleared, if cleared == 1 then "" else "s")
518
+ )
519
+ end
352
520
  end
353
521
 
354
522
  --[[
@@ -482,6 +650,38 @@ function Console.mount(parent: Instance, handlers: Handlers)
482
650
  meta.Parent = header
483
651
  state.metaText = meta
484
652
 
653
+ --[[
654
+ The shared-bridge badge, hidden until there is something to share.
655
+
656
+ Sits between the meta line and the buttons rather than in the activity
657
+ band, because it is a fact about the connection and the header is where
658
+ connection facts live. `meta` gives up the width when it appears; see
659
+ `Console.setClients`.
660
+ ]]
661
+ local clientsChip = Instance.new("TextLabel")
662
+ clientsChip.Name = "Clients"
663
+ clientsChip.AnchorPoint = Vector2.new(1, 0.5)
664
+ clientsChip.Position = UDim2.new(1, -252, 0.5, 0)
665
+ clientsChip.Size = UDim2.new(0, 84, 0, 18)
666
+ clientsChip.BackgroundColor3 = PALETTE.background
667
+ clientsChip.BorderSizePixel = 0
668
+ clientsChip.Font = Enum.Font.Code
669
+ clientsChip.TextSize = 11
670
+ clientsChip.TextColor3 = PALETTE.cyan
671
+ clientsChip.Text = ""
672
+ clientsChip.Visible = false
673
+ clientsChip.Parent = header
674
+ state.clientsChip = clientsChip
675
+
676
+ local chipCorner = Instance.new("UICorner")
677
+ chipCorner.CornerRadius = UDim.new(0, 3)
678
+ chipCorner.Parent = clientsChip
679
+
680
+ local chipStroke = Instance.new("UIStroke")
681
+ chipStroke.Color = PALETTE.cyan
682
+ chipStroke.Transparency = 0.6
683
+ chipStroke.Parent = clientsChip
684
+
485
685
  local buttons = Instance.new("Frame")
486
686
  buttons.AnchorPoint = Vector2.new(1, 0.5)
487
687
  buttons.Position = UDim2.new(1, 0, 0.5, 0)
@@ -571,7 +771,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
571
771
  footerPadding.Parent = footer
572
772
 
573
773
  local prompt = Instance.new("TextLabel")
574
- prompt.Text = "studio\u{00B7}mcp"
774
+ prompt.Text = "rbx\u{00B7}studio"
575
775
  prompt.Font = Enum.Font.Code
576
776
  prompt.TextSize = 11
577
777
  prompt.TextColor3 = PALETTE.violet
@@ -28,6 +28,19 @@ export type Status = "disconnected" | "connecting" | "connected"
28
28
  type Callbacks = {
29
29
  onCommand: (id: string, op: string, params: { [string]: any }?) -> (),
30
30
  onStatus: (status: Status, detail: string?) -> (),
31
+ --[[
32
+ Anything the bridge wants to tell this plugin that is not a command.
33
+
34
+ The stream was one-way in practice -- commands down, results back over a
35
+ separate POST -- and there was no way for the server to say something the
36
+ plugin should merely know, like how many agents are now sharing it. A
37
+ frame carrying `event` instead of `id`/`op` is that channel.
38
+
39
+ No protocol bump: a plugin older than this drops any frame without an id
40
+ and an op, which is exactly the right behaviour for a frame it does not
41
+ understand.
42
+ ]]
43
+ onEvent: (event: { [string]: any }) -> (),
31
44
  }
32
45
 
33
46
  local running = false
@@ -87,23 +100,53 @@ local function identity(transport: string): { [string]: any }
87
100
  end
88
101
 
89
102
  --[[
90
- Parses one SSE payload. `MessageReceived` hands over the event data, but the
91
- exact framing is not contractual, so a stray `data:` prefix is tolerated and
92
- comment/keepalive lines are dropped.
103
+ Parses one delivery from the stream, which may hold more than one event.
104
+
105
+ This used to assume one event per `MessageReceived` and decode the whole
106
+ payload as a single JSON object. That held only because the bridge had never
107
+ written two frames in the same tick -- and the moment it did, both were lost:
108
+ the two writes arrived in one chunk, so the decode saw
109
+ `{...}<blank>data: {...}` and returned nil for the pair. The visible symptom
110
+ was a client-count badge that went up and never came back down. The invisible
111
+ one is why this is written properly rather than patched around: nothing stops
112
+ a *command* from sharing a chunk with anything else, and a dropped command is
113
+ a tool call that disappears with no error on either side.
114
+
115
+ So the payload is split into events on blank lines, and each event's `data:`
116
+ lines are concatenated the way the SSE spec says they should be. Comments --
117
+ the `: connected` greeting and the `: ping` keepalives -- are skipped rather
118
+ than being allowed to poison the block they arrive with.
93
119
  ]]
94
- local function parseFrame(message: string): { [string]: any }?
95
- local text = (string.gsub(message, "^%s+", ""))
96
- if text == "" or string.sub(text, 1, 1) == ":" then
97
- return nil
98
- end
99
- if string.sub(text, 1, 5) == "data:" then
100
- text = (string.gsub(string.sub(text, 6), "^%s+", ""))
101
- end
102
- local decoded = Net.decode(text)
103
- if typeof(decoded) ~= "table" then
104
- return nil
120
+ local function parseFrames(message: string): { { [string]: any } }
121
+ local frames: { { [string]: any } } = {}
122
+
123
+ -- Normalised so one split handles either line ending.
124
+ local normalised = (string.gsub(message, "\r\n", "\n"))
125
+
126
+ -- The appended separator gives every event, the last one included, a
127
+ -- trailing blank line, so a single pattern finds them all.
128
+ for block in string.gmatch(normalised .. "\n\n", "(.-)\n\n") do
129
+ local payload: string? = nil
130
+ for line in string.gmatch(block, "[^\n]+") do
131
+ if string.sub(line, 1, 1) ~= ":" then
132
+ local value = line
133
+ if string.sub(value, 1, 5) == "data:" then
134
+ value = (string.gsub(string.sub(value, 6), "^%s+", ""))
135
+ end
136
+ -- Several data lines in one event concatenate, per the spec.
137
+ payload = if payload == nil then value else payload .. "\n" .. value
138
+ end
139
+ end
140
+
141
+ if payload ~= nil and payload ~= "" then
142
+ local decoded = Net.decode(payload)
143
+ if typeof(decoded) == "table" then
144
+ table.insert(frames, decoded)
145
+ end
146
+ end
105
147
  end
106
- return decoded
148
+
149
+ return frames
107
150
  end
108
151
 
109
152
  local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
@@ -111,6 +154,10 @@ local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
111
154
  local op = frame.op
112
155
  if typeof(id) == "string" and typeof(op) == "string" then
113
156
  callbacks.onCommand(id, op, frame.params)
157
+ return
158
+ end
159
+ if typeof(frame.event) == "string" then
160
+ callbacks.onEvent(frame)
114
161
  end
115
162
  end
116
163
 
@@ -151,8 +198,7 @@ local function runStream(callbacks: Callbacks): boolean
151
198
  table.insert(
152
199
  connections,
153
200
  client.MessageReceived:Connect(function(message: string)
154
- local frame = parseFrame(message)
155
- if frame then
201
+ for _, frame in parseFrames(message) do
156
202
  handleFrame(callbacks, frame)
157
203
  end
158
204
  end)
@@ -224,8 +270,21 @@ local function runPolling(callbacks: Callbacks): boolean
224
270
  return false
225
271
  else
226
272
  local payload = Net.decode(response.body)
227
- if typeof(payload) == "table" and typeof(payload.command) == "table" then
228
- handleFrame(callbacks, payload.command)
273
+ if typeof(payload) == "table" then
274
+ if typeof(payload.command) == "table" then
275
+ handleFrame(callbacks, payload.command)
276
+ end
277
+ --[[
278
+ Poll sessions get the same news, riding on the answer they were
279
+ already waiting for. The bridge has nowhere to push to here, so
280
+ every poll response carries the current value rather than only
281
+ the moments it changed -- the console filters out the repeats,
282
+ and a poll session that missed a change while it was handling a
283
+ command would otherwise never hear about it.
284
+ ]]
285
+ if typeof(payload.clients) == "number" then
286
+ callbacks.onEvent({ event = "clients", count = payload.clients })
287
+ end
229
288
  end
230
289
  end
231
290