@el4cteo/rbx-studio-mcp 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +211 -211
  2. package/dist/bridge/api.js +3 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +13 -0
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/remote.js +15 -1
  7. package/dist/bridge/remote.js.map +1 -1
  8. package/dist/bridge/rpc.js +35 -6
  9. package/dist/bridge/rpc.js.map +1 -1
  10. package/dist/bridge/server.js +55 -7
  11. package/dist/bridge/server.js.map +1 -1
  12. package/dist/doctor.js +160 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/index.js +29 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/protocol.js.map +1 -1
  17. package/dist/tools/exec.js +53 -3
  18. package/dist/tools/exec.js.map +1 -1
  19. package/dist/tools/generate.js +120 -0
  20. package/dist/tools/generate.js.map +1 -0
  21. package/dist/tools/scripts.js +20 -1
  22. package/dist/tools/scripts.js.map +1 -1
  23. package/dist/tools/world.js +203 -20
  24. package/dist/tools/world.js.map +1 -1
  25. package/package.json +2 -1
  26. package/plugin/src/Config.luau +1 -1
  27. package/plugin/src/Console.luau +233 -9
  28. package/plugin/src/Transport.luau +6 -1
  29. package/plugin/src/Visuals.luau +52 -1
  30. package/plugin/src/handlers/Assets.luau +352 -145
  31. package/plugin/src/handlers/Generate.luau +386 -0
  32. package/plugin/src/handlers/Geometry.luau +170 -0
  33. package/plugin/src/handlers/Scripts.luau +79 -1
  34. package/plugin/src/handlers/Viewport.luau +416 -302
  35. package/plugin/src/handlers/World.luau +15 -5
  36. package/plugin/src/init.server.luau +725 -674
  37. package/scripts/test-bridge.mjs +56 -0
  38. package/scripts/test-failover.mjs +136 -95
@@ -203,6 +203,21 @@ type Record = {
203
203
  stamp: string,
204
204
  }
205
205
 
206
+ --[[
207
+ One MCP client sharing this bridge, as the server describes it.
208
+
209
+ `name` is what the agent calls itself in the MCP handshake, so it is
210
+ "claude-code" or "codex" rather than anything the bridge guessed. A client
211
+ that never introduced itself arrives as "unknown", which is honest: it is
212
+ connected, we just do not know what it is.
213
+ ]]
214
+ export type Client = {
215
+ name: string,
216
+ version: string,
217
+ pid: number,
218
+ connectedAt: number,
219
+ }
220
+
206
221
  type State = {
207
222
  records: { Record },
208
223
  -- The last status reported, replayed after a theme switch. Without it the
@@ -227,7 +242,7 @@ type State = {
227
242
  statusText: TextLabel?,
228
243
  metaText: TextLabel?,
229
244
  countersText: TextLabel?,
230
- clientsChip: TextLabel?,
245
+ clientsChip: TextButton?,
231
246
  pinned: boolean,
232
247
  calls: number,
233
248
  errors: number,
@@ -244,6 +259,24 @@ type State = {
244
259
  generation: number,
245
260
  -- How many MCP clients share this bridge. Only ever displayed above one.
246
261
  clients: number,
262
+ --[[
263
+ Whether the count above was learned from this connection or the last.
264
+
265
+ The bridge sends the roster the moment a stream opens, so the first
266
+ frame after every reconnect looks exactly like an agent arriving. It is
267
+ not: the same agents were there a second ago, and the connect itself has
268
+ already played its flourish. Cleared whenever the transport leaves
269
+ "connected", so only a genuine arrival mid-session celebrates.
270
+ ]]
271
+ clientsKnown: boolean,
272
+ --[[
273
+ Who those clients are, newest last, as the bridge last reported them.
274
+
275
+ Kept even at one client, unlike the badge: the roster is what answers
276
+ "is this extra one a problem?", and the moment it becomes worth asking
277
+ is the moment a second appears -- too late to start recording.
278
+ ]]
279
+ clientList: { Client },
247
280
  -- Set while a repaint is already scheduled for the end of this frame.
248
281
  dirty: boolean,
249
282
 
@@ -286,6 +319,8 @@ local state: State = {
286
319
  running = nil,
287
320
  generation = 0,
288
321
  clients = 1,
322
+ clientsKnown = false,
323
+ clientList = {},
289
324
  dirty = false,
290
325
  crt = nil,
291
326
  crtScreen = nil,
@@ -295,6 +330,11 @@ local state: State = {
295
330
  crtGeneration = 0,
296
331
  }
297
332
 
333
+ -- Declared ahead of its definition: `setClients` calls it and is written
334
+ -- above it, and a plain `function layoutHeader()` there would have quietly
335
+ -- become a global.
336
+ local layoutHeader: () -> ()
337
+
298
338
  -- How long the session must be silent before the console says so. Long enough
299
339
  -- that an agent pausing to think is not announced as having stopped.
300
340
  local QUIET_SECONDS = 20
@@ -681,13 +721,30 @@ end
681
721
  badge -- a permanent "1 client connected" is a label, not a signal, and the
682
722
  header has better uses for the width.
683
723
  ]]
684
- function Console.setClients(count: number)
724
+ function Console.setClients(count: number, list: { Client }?)
685
725
  local previous = state.clients
726
+ local known = state.clientsKnown
686
727
  state.clients = count
728
+ state.clientsKnown = true
729
+ if list ~= nil then
730
+ state.clientList = list
731
+ end
732
+
733
+ --[[
734
+ An agent joining gets the same flourish as a session landing.
735
+
736
+ It is the same kind of news -- something that can now drive this Studio
737
+ has arrived -- and the trace is where this panel says so. Guarded on
738
+ `known` because the bridge re-sends the roster on every reconnect, and
739
+ replaying the arrival of agents that never left would fire it several
740
+ times a session until it stopped meaning anything.
741
+ ]]
742
+ if known and count > previous then
743
+ Visuals.celebrate()
744
+ end
687
745
 
688
746
  local chip = state.clientsChip
689
747
  if chip then
690
- chip.Visible = count > 1
691
748
  -- Pluralised even though the badge hides at one. A string that is only
692
749
  -- ever correct because nobody can see it is a trap for whoever changes
693
750
  -- the visibility rule later.
@@ -698,10 +755,7 @@ function Console.setClients(count: number)
698
755
  )
699
756
  end
700
757
 
701
- local meta = state.metaText
702
- if meta then
703
- meta.Size = UDim2.new(1, if count > 1 then -486 else -394, 1, 0)
704
- end
758
+ layoutHeader()
705
759
 
706
760
  --[[
707
761
  Only the arrival is worth a row.
@@ -713,8 +767,152 @@ function Console.setClients(count: number)
713
767
  reports it; the count coming down is already covered twice over.
714
768
  ]]
715
769
  if count > 1 and previous <= 1 then
716
- Console.log("info", string.format("%d MCP clients connected", count))
770
+ --[[
771
+ Named, not just counted.
772
+
773
+ "3 MCP clients connected" is the line people bring to us asking
774
+ whether something is wrong, because a number cannot say whether the
775
+ extra ones are agents they started or processes they forgot. The
776
+ names can, so they are in the row that raises the question rather
777
+ than only in a panel the reader has to know to hover.
778
+ ]]
779
+ Console.log(
780
+ "info",
781
+ string.format("%d MCP clients connected", count),
782
+ Console.clientSummary()
783
+ )
784
+ end
785
+ end
786
+
787
+ --[[
788
+ The roster on one line, for the caption and the arrival row.
789
+
790
+ Names only, deduplicated by name with a count where it repeats: two Codex
791
+ windows are "codex x2", not "codex, codex". The interesting fact is which
792
+ tools are attached, and a list that repeats a name reads as a mistake.
793
+ ]]
794
+ --[[
795
+ Places the header's right-hand controls, and gives the meta line what is left.
796
+
797
+ One function rather than each control minding its own position, because the
798
+ clients badge comes and goes: it only appears above one client. With the
799
+ positions written at each call site, the meta line's width had to encode
800
+ every combination as a constant, and the two had to be kept in step by hand.
801
+
802
+ Laid out right to left from the buttons, each control claiming its width
803
+ plus a gap. The meta line then ends a fixed distance further left, which is
804
+ the rule it always followed -- it just no longer has to be told the answer.
805
+ ]]
806
+ local BUTTONS_WIDTH = 244
807
+ local HEADER_GAP = 8
808
+ local CHIP_WIDTH = 84
809
+ local META_CLEARANCE = 150
810
+
811
+ layoutHeader = function()
812
+ local edge = BUTTONS_WIDTH
813
+
814
+ local chip = state.clientsChip
815
+ if chip then
816
+ chip.Visible = state.clients > 1
817
+ if chip.Visible then
818
+ edge += HEADER_GAP
819
+ chip.Position = UDim2.new(1, -edge, 0.5, 0)
820
+ edge += CHIP_WIDTH
821
+ end
822
+ end
823
+
824
+ local meta = state.metaText
825
+ if meta then
826
+ meta.Size = UDim2.new(1, -(edge + META_CLEARANCE), 1, 0)
827
+ end
828
+ end
829
+
830
+
831
+
832
+
833
+ function Console.clientSummary(): string
834
+ local list = state.clientList
835
+ if #list == 0 then
836
+ return ""
837
+ end
838
+ local order: { string } = {}
839
+ local seen: { [string]: number } = {}
840
+ for _, client in list do
841
+ local name = if client.name ~= "" then client.name else "unknown"
842
+ if seen[name] == nil then
843
+ seen[name] = 0
844
+ table.insert(order, name)
845
+ end
846
+ seen[name] += 1
717
847
  end
848
+ local parts: { string } = {}
849
+ for _, name in order do
850
+ local total = seen[name]
851
+ table.insert(parts, if total > 1 then string.format("%s x%d", name, total) else name)
852
+ end
853
+ return table.concat(parts, ", ")
854
+ end
855
+
856
+ --[[
857
+ The roster in full, one row per client, written into the log.
858
+
859
+ In the log rather than a hover panel because that is where this console puts
860
+ facts it wants the user to be able to scroll back to -- and because a list
861
+ that only exists while the pointer is on it cannot be read and acted on at
862
+ the same time.
863
+ ]]
864
+ function Console.reportClients()
865
+ local list = state.clientList
866
+ if #list == 0 then
867
+ --[[
868
+ An empty roster beside a count above one is not "nobody is here",
869
+ it is a server too old to say who. Worth distinguishing: the first
870
+ reading sends someone looking for a connection problem that does
871
+ not exist, and the fix -- restart the MCP server -- is not one
872
+ anybody guesses from "no client has introduced itself".
873
+ ]]
874
+ if state.clients > 0 then
875
+ Console.log(
876
+ "dim",
877
+ string.format("%d connected, but this bridge did not say which", state.clients),
878
+ "restart the MCP server"
879
+ )
880
+ else
881
+ Console.log("dim", "no MCP client is connected")
882
+ end
883
+ return
884
+ end
885
+ Console.log("info", string.format("%d MCP client%s on this bridge", #list, if #list == 1 then "" else "s"))
886
+ local now = os.time()
887
+ for _, client in list do
888
+ local name = if client.name ~= "" then client.name else "unknown"
889
+ local version = if client.version ~= "" then " " .. client.version else ""
890
+ -- Seconds since the epoch on both sides, so this is a real elapsed time
891
+ -- and not a guess: the bridge and Studio are the same machine.
892
+ local since = math.max(0, now - math.floor(client.connectedAt / 1000))
893
+ Console.log(
894
+ "dim",
895
+ string.format(" %s%s", name, version),
896
+ string.format("pid %d %s", client.pid, Console.humanDuration(since))
897
+ )
898
+ end
899
+ end
900
+
901
+ --[[
902
+ A duration a person reads at a glance, not a precise one.
903
+
904
+ Rounded down deliberately: "2m" for anything in that minute is what someone
905
+ scanning a list wants, and a ticking "2m 47s" invites the reader to watch it
906
+ rather than to read past it.
907
+ ]]
908
+ function Console.humanDuration(seconds: number): string
909
+ if seconds < 60 then
910
+ return string.format("%ds", seconds)
911
+ end
912
+ if seconds < 3600 then
913
+ return string.format("%dm", math.floor(seconds / 60))
914
+ end
915
+ return string.format("%dh %dm", math.floor(seconds / 3600), math.floor(seconds % 3600 / 60))
718
916
  end
719
917
 
720
918
  --[[
@@ -913,6 +1111,11 @@ function Console.setStatus(status: string, meta: string)
913
1111
  if status == "connected" and previous ~= "connected" then
914
1112
  Visuals.celebrate()
915
1113
  end
1114
+
1115
+ -- A roster learned on the old connection says nothing about this one.
1116
+ if status ~= "connected" then
1117
+ state.clientsKnown = false
1118
+ end
916
1119
  end
917
1120
 
918
1121
  --[[
@@ -1054,7 +1257,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
1054
1257
  connection facts live. `meta` gives up the width when it appears; see
1055
1258
  `Console.setClients`.
1056
1259
  ]]
1057
- local clientsChip = Instance.new("TextLabel")
1260
+ local clientsChip = Instance.new("TextButton")
1058
1261
  clientsChip.Name = "Clients"
1059
1262
  clientsChip.AnchorPoint = Vector2.new(1, 0.5)
1060
1263
  clientsChip.Position = UDim2.new(1, -252, 0.5, 0)
@@ -1065,10 +1268,31 @@ function Console.mount(parent: Instance, handlers: Handlers)
1065
1268
  clientsChip.TextSize = 11
1066
1269
  themed(clientsChip, "TextColor3", "cyan")
1067
1270
  clientsChip.Text = ""
1271
+ clientsChip.AutoButtonColor = false
1068
1272
  clientsChip.Visible = false
1069
1273
  clientsChip.Parent = header
1070
1274
  state.clientsChip = clientsChip
1071
1275
 
1276
+ --[[
1277
+ A count raises a question the count cannot answer.
1278
+
1279
+ "3 clients" is the exact thing users bring to us asking whether it is a
1280
+ problem, and it never is answerable from a number: three agents they
1281
+ started and three processes they forgot look identical. Hovering names
1282
+ them in the caption -- cheap, no click, no panel to dismiss -- and
1283
+ clicking writes the full roster into the log, where it can be scrolled
1284
+ back to and acted on.
1285
+ ]]
1286
+ clientsChip.MouseEnter:Connect(function()
1287
+ Visuals.showNote(Console.clientSummary())
1288
+ end)
1289
+ clientsChip.MouseLeave:Connect(function()
1290
+ Visuals.clearNote()
1291
+ end)
1292
+ clientsChip.Activated:Connect(function()
1293
+ Console.reportClients()
1294
+ end)
1295
+
1072
1296
  local chipCorner = Instance.new("UICorner")
1073
1297
  chipCorner.CornerRadius = UDim.new(0, 3)
1074
1298
  chipCorner.Parent = clientsChip
@@ -45,6 +45,7 @@ type Callbacks = {
45
45
 
46
46
  local running = false
47
47
  local studioId: string = ""
48
+
48
49
  local activeStream: any = nil
49
50
  local mode: "sse" | "poll" = "sse"
50
51
 
@@ -283,7 +284,11 @@ local function runPolling(callbacks: Callbacks): boolean
283
284
  command would otherwise never hear about it.
284
285
  ]]
285
286
  if typeof(payload.clients) == "number" then
286
- callbacks.onEvent({ event = "clients", count = payload.clients })
287
+ callbacks.onEvent({
288
+ event = "clients",
289
+ count = payload.clients,
290
+ list = payload.clientList,
291
+ })
287
292
  end
288
293
  end
289
294
  end
@@ -232,6 +232,15 @@ type Runtime = {
232
232
  highlight: Frame?,
233
233
  readout: TextLabel?,
234
234
  caption: TextLabel?,
235
+ --[[
236
+ What the caption said before a note took it over.
237
+
238
+ Nil means no note is showing, which is why this is the flag as well as
239
+ the storage: a second `showNote` while one is up must not overwrite the
240
+ text being held, or clearing would restore a note instead of the
241
+ caption.
242
+ ]]
243
+ noted: string?,
235
244
  bars: { Bar },
236
245
  connection: RBXScriptConnection?,
237
246
 
@@ -289,6 +298,7 @@ local runtime: Runtime = {
289
298
  highlight = nil,
290
299
  readout = nil,
291
300
  caption = nil,
301
+ noted = nil,
292
302
  bars = {},
293
303
  connection = nil,
294
304
  spin = Vector3.new(0.18, 0.27, 0.11),
@@ -1012,11 +1022,46 @@ end
1012
1022
  ]]
1013
1023
  function Visuals.setCaption(title: string)
1014
1024
  runtime.lastTitle = title
1025
+ -- A note is holding the caption's real text for later. Update what will be
1026
+ -- restored, not what is on screen, or the note is wiped by the next command
1027
+ -- and clearing it would put back a line that is already out of date.
1028
+ if runtime.noted ~= nil then
1029
+ runtime.noted = title
1030
+ return
1031
+ end
1015
1032
  if runtime.caption then
1016
1033
  runtime.caption.Text = title
1017
1034
  end
1018
1035
  end
1019
1036
 
1037
+ --[[
1038
+ Borrows the caption to answer something the user is pointing at.
1039
+
1040
+ The caption is the band's one line of prose, so a note goes there rather
1041
+ than into a floating panel: there is exactly one place on this widget that
1042
+ explains what you are looking at, and two would be one too many. Whatever
1043
+ the caption was saying is put back by `clearNote`, so a note never costs the
1044
+ user the line they were reading.
1045
+ ]]
1046
+ function Visuals.showNote(text: string)
1047
+ if runtime.caption == nil or text == "" then
1048
+ return
1049
+ end
1050
+ if runtime.noted == nil then
1051
+ runtime.noted = runtime.caption.Text
1052
+ end
1053
+ runtime.caption.Text = text
1054
+ end
1055
+
1056
+ function Visuals.clearNote()
1057
+ local held = runtime.noted
1058
+ if held == nil or runtime.caption == nil then
1059
+ return
1060
+ end
1061
+ runtime.noted = nil
1062
+ runtime.caption.Text = held
1063
+ end
1064
+
1020
1065
  --[[
1021
1066
  Falls back to the last thing that ran once a command finishes.
1022
1067
 
@@ -1028,9 +1073,15 @@ function Visuals.setIdle()
1028
1073
  if runtime.caption == nil then
1029
1074
  return
1030
1075
  end
1031
- runtime.caption.Text = if runtime.lastTitle ~= nil
1076
+ local text = if runtime.lastTitle ~= nil
1032
1077
  then "last: " .. runtime.lastTitle
1033
1078
  else "waiting for a command"
1079
+ -- Same rule as setCaption: a note owns the line until it is cleared.
1080
+ if runtime.noted ~= nil then
1081
+ runtime.noted = text
1082
+ return
1083
+ end
1084
+ runtime.caption.Text = text
1034
1085
  end
1035
1086
 
1036
1087
  --[[