@el4cteo/rbx-studio-mcp 0.8.3 → 0.8.6

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 (68) hide show
  1. package/README.md +24 -6
  2. package/dist/bridge/rpc.js +1 -1
  3. package/dist/bridge/rpc.js.map +1 -1
  4. package/dist/index.js +7 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +75 -0
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/errors.js +5 -4
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/format.js +92 -25
  11. package/dist/lib/format.js.map +1 -1
  12. package/dist/lib/notices.js +29 -0
  13. package/dist/lib/notices.js.map +1 -0
  14. package/dist/lib/protocol.js.map +1 -1
  15. package/dist/lib/sync.js +1141 -0
  16. package/dist/lib/sync.js.map +1 -0
  17. package/dist/lib/syncplan.js +338 -0
  18. package/dist/lib/syncplan.js.map +1 -0
  19. package/dist/lib/tool.js +9 -2
  20. package/dist/lib/tool.js.map +1 -1
  21. package/dist/tools/discover.js +6 -1
  22. package/dist/tools/discover.js.map +1 -1
  23. package/dist/tools/exec.js +5 -0
  24. package/dist/tools/exec.js.map +1 -1
  25. package/dist/tools/instances.js +2 -2
  26. package/dist/tools/instances.js.map +1 -1
  27. package/dist/tools/perf.js +28 -3
  28. package/dist/tools/perf.js.map +1 -1
  29. package/dist/tools/screenshot.js +7 -3
  30. package/dist/tools/screenshot.js.map +1 -1
  31. package/dist/tools/scripts.js +110 -28
  32. package/dist/tools/scripts.js.map +1 -1
  33. package/dist/tools/sync.js +169 -0
  34. package/dist/tools/sync.js.map +1 -0
  35. package/package.json +4 -4
  36. package/plugin/src/Commands.luau +72 -5
  37. package/plugin/src/Config.luau +65 -65
  38. package/plugin/src/Console.luau +5 -0
  39. package/plugin/src/Dispatch.luau +131 -90
  40. package/plugin/src/ExecRuntime.luau +190 -168
  41. package/plugin/src/LogBuffer.luau +38 -9
  42. package/plugin/src/Paths.luau +42 -0
  43. package/plugin/src/Phrase.luau +41 -0
  44. package/plugin/src/Prompt.luau +23 -3
  45. package/plugin/src/ScriptEdit.luau +94 -8
  46. package/plugin/src/Serialize.luau +16 -1
  47. package/plugin/src/Transport.luau +5 -2
  48. package/plugin/src/Undo.luau +74 -10
  49. package/plugin/src/handlers/Capture.luau +818 -809
  50. package/plugin/src/handlers/Debug.luau +19 -16
  51. package/plugin/src/handlers/Discover.luau +31 -1
  52. package/plugin/src/handlers/Perf.luau +100 -21
  53. package/plugin/src/handlers/Scripts.luau +274 -90
  54. package/plugin/src/handlers/Sync.luau +968 -0
  55. package/plugin/src/init.server.luau +47 -2
  56. package/scripts/build.mjs +14 -0
  57. package/scripts/sync-fake.mjs +191 -0
  58. package/scripts/test-live-sync-scale.mjs +150 -0
  59. package/scripts/test-live-sync.mjs +232 -0
  60. package/scripts/test-live-tools.mjs +6 -1
  61. package/scripts/test-plugin.mjs +17 -0
  62. package/scripts/test-results.mjs +41 -0
  63. package/scripts/test-sync-more.mjs +228 -0
  64. package/scripts/test-sync.mjs +245 -0
  65. package/dist/tools/spatial.js +0 -135
  66. package/dist/tools/spatial.js.map +0 -1
  67. package/dist/tools/upload.js +0 -294
  68. package/dist/tools/upload.js.map +0 -1
@@ -441,7 +441,7 @@ function Debug.clear(params: { [string]: any }): { [string]: any }
441
441
  local ok, removed = pcall(function()
442
442
  return service:RemoveBreakpoint(target, line)
443
443
  end)
444
- untrack(target, line)
444
+ if ok and removed == true then untrack(target, line) end
445
445
  return { removed = ok and removed == true, path = Paths.of(target), line = line }
446
446
  end
447
447
 
@@ -458,27 +458,29 @@ function Debug.clear(params: { [string]: any }): { [string]: any }
458
458
  local removedLines: { number } = {}
459
459
  if lines then
460
460
  for lineNumber in lines do
461
- local ok = pcall(function()
462
- service:RemoveBreakpoint(target, lineNumber)
461
+ local ok, removed = pcall(function()
462
+ return service:RemoveBreakpoint(target, lineNumber)
463
463
  end)
464
- if ok then
464
+ if ok and removed == true then
465
465
  table.insert(removedLines, lineNumber)
466
+ lines[lineNumber] = nil
466
467
  end
467
468
  end
468
- trackedBreakpoints[target] = nil
469
+ if next(lines) == nil then trackedBreakpoints[target] = nil end
469
470
  end
470
471
  table.sort(removedLines)
471
472
  return { removed = #removedLines > 0, path = Paths.of(target), lines = removedLines }
472
473
  end
473
474
 
474
- local ok, err = pcall(function()
475
- service:ClearBreakpoints()
476
- end)
477
- if not ok then
478
- Dispatch.fail("REFUSED", string.format("ClearBreakpoints refused: %s", tostring(err)))
475
+ -- Only remove MCP-owned breakpoints, preserving those set in Studio by users.
476
+ for target, lines in trackedBreakpoints do
477
+ for lineNumber in lines do
478
+ local ok, removed = pcall(function() return service:RemoveBreakpoint(target, lineNumber) end)
479
+ if ok and removed then lines[lineNumber] = nil end
480
+ end
481
+ if next(lines) == nil then trackedBreakpoints[target] = nil end
479
482
  end
480
- table.clear(trackedBreakpoints)
481
- return { cleared = true }
483
+ return { cleared = next(trackedBreakpoints) == nil }
482
484
  end
483
485
 
484
486
  function Debug.snapshots(params: { [string]: any }): { [string]: any }
@@ -490,15 +492,16 @@ function Debug.snapshots(params: { [string]: any }): { [string]: any }
490
492
  table.insert(out, snapshots[index])
491
493
  end
492
494
 
495
+ local total, lost = #snapshots, overflow
493
496
  if params.clear == true then
494
- snapshots = {}
495
- overflow = 0
497
+ for index = #snapshots, first, -1 do table.remove(snapshots, index) end
498
+ if #snapshots == 0 then overflow = 0 end
496
499
  end
497
500
 
498
501
  return {
499
502
  items = out,
500
- total = #snapshots,
501
- overflow = if overflow > 0 then overflow else nil,
503
+ total = total,
504
+ overflow = if lost > 0 then lost else nil,
502
505
  installed = installed,
503
506
  }
504
507
  end
@@ -660,10 +660,40 @@ function Discover.find(params: { [string]: any }): { [string]: any }
660
660
 
661
661
  stableOrder(matched)
662
662
 
663
+ --[[
664
+ `properties` reads named properties onto each row returned.
665
+
666
+ The usual follow-up to a find is an inspect of every match for the one or
667
+ two values that decide what to do with it -- a call per row, or one wide
668
+ call returning every property. Projecting just those names here makes it
669
+ one call returning what was asked. Names that cannot be read on a row are
670
+ listed on that row, since a class that lacks the property is an answer too.
671
+ ]]
672
+ local projected = params.properties
673
+ if projected ~= nil and (typeof(projected) ~= "table" or #projected > 16) then
674
+ Dispatch.fail("BAD_PARAMS", "`properties` must be a list of at most 16 property names.")
675
+ end
676
+
663
677
  local items: { { [string]: any } } = {}
664
678
  local memo: Paths.NameIndex = {}
665
679
  for index = offset + 1, math.min(offset + limit, #matched) do
666
- table.insert(items, summarise(matched[index] :: Instance, memo))
680
+ local instance = matched[index] :: Instance
681
+ local entry = summarise(instance, memo)
682
+ if projected ~= nil then
683
+ local values: { [string]: any } = {}
684
+ local unreadable: { string } = {}
685
+ for _, name in projected do
686
+ local ok, value = Serialize.readProperty(instance, tostring(name))
687
+ if ok then
688
+ values[tostring(name)] = value
689
+ else
690
+ table.insert(unreadable, tostring(name))
691
+ end
692
+ end
693
+ entry.properties = values
694
+ entry.unreadable = if #unreadable > 0 then unreadable else nil
695
+ end
696
+ table.insert(items, entry)
667
697
  end
668
698
 
669
699
  return {
@@ -28,6 +28,8 @@ local Scope = require(script.Parent.Parent.Scope)
28
28
  -- swamp any context window.
29
29
  local MAX_LOG_ENTRIES = 500
30
30
  local DEFAULT_LOG_ENTRIES = 100
31
+ -- Characters of log rows one console read picks. See `take` in Perf.console.
32
+ local CONSOLE_BUDGET = 16_000
31
33
 
32
34
  -- Profiling is a blocking wait, so the ceiling is what a caller will sit through
33
35
  -- rather than what the profiler can manage.
@@ -116,21 +118,21 @@ function Perf.console(params: { [string]: any }): { [string]: any }
116
118
  local oldest = if #history > 0 then history[1].sequence or 1 else latest + 1
117
119
  local missed = if since ~= nil then math.max(0, oldest - since - 1) else 0
118
120
 
119
- local entries: { { [string]: any } } = {}
120
- local matched = 0
121
+ local mode = params.mode or "tail"
122
+ if mode ~= "tail" and mode ~= "drain" then
123
+ Dispatch.fail("BAD_PARAMS", string.format("%q is not a console mode.", tostring(mode)), "Use tail or drain.")
124
+ end
121
125
 
126
+ local matches: { LogBuffer.Entry } = {}
122
127
  for _, item in history do
123
128
  if since ~= nil and (item.sequence or 0) <= since then continue end
124
- local kind = item.level
125
- if level and kind ~= level then
129
+ if level and item.level ~= level then
126
130
  continue
127
131
  end
128
-
129
- local message = item.message
130
132
  if pattern then
131
133
  -- An invalid pattern raises rather than failing to match, so it is
132
134
  -- reported as a pattern problem instead of an empty result.
133
- local valid, position = pcall(string.find, message, pattern)
135
+ local valid, position = pcall(string.find, item.message, pattern)
134
136
  if not valid then
135
137
  Dispatch.fail(
136
138
  "BAD_PATTERN",
@@ -142,34 +144,111 @@ function Perf.console(params: { [string]: any }): { [string]: any }
142
144
  continue
143
145
  end
144
146
  end
147
+ table.insert(matches, item)
148
+ end
149
+
150
+ --[[
151
+ Rows are picked against a size budget as well as `limit`.
145
152
 
146
- matched += 1
147
- table.insert(entries, {
148
- level = kind,
149
- message = message,
153
+ One noisy script printing long lines used to fill a read with a few
154
+ hundred of them, which the server then cut down to fit its response
155
+ limit -- dropping lines this side had already reported as delivered. A
156
+ budget here means what is picked is what is shown. The first row is always
157
+ taken, whatever its size, so a single huge entry cannot stall a drain.
158
+ ]]
159
+ local selected: { { [string]: any } } = {}
160
+ local budget = CONSOLE_BUDGET
161
+ local function take(item: LogBuffer.Entry): boolean
162
+ local stack = item.stack
163
+ local cost = #item.message + 300
164
+ + (if stack then #stack + #string.split(stack, "\n") * 8 else 0)
165
+ + (if item.source then #item.source else 0)
166
+ + (if item.context then #item.context else 0)
167
+ if #selected >= limit or (cost > budget and #selected > 0) then
168
+ return false
169
+ end
170
+ budget -= cost
171
+ table.insert(selected, {
172
+ sequence = item.sequence,
173
+ updated = item.updated,
174
+ level = item.level,
175
+ message = item.message,
150
176
  timestamp = item.timestamp,
151
- stack = item.stack,
177
+ stack = stack,
152
178
  source = item.source,
153
179
  context = item.context,
154
180
  })
181
+ return true
155
182
  end
156
183
 
157
- -- Trimmed from the front: the newest lines are the ones worth keeping.
158
- local dropped = 0
159
- if #entries > limit then
160
- dropped = #entries - limit
161
- entries = table.move(entries, dropped + 1, #entries, 1, {})
184
+ --[[
185
+ `tail` answers "what just happened": the newest matches, and a cursor at
186
+ the end of the log, so a following read sees only what comes after.
187
+
188
+ `drain` answers "what happened, all of it": the oldest unread matches,
189
+ and a cursor just before the first one not shown, so reading on in a loop
190
+ delivers every match exactly once however busy the log is. That only
191
+ holds with the same filters each time -- the cursor is a position, and it
192
+ does not remember what the filter skipped.
193
+ ]]
194
+ local watermark = latest
195
+ if mode == "drain" then
196
+ for _, item in matches do
197
+ if not take(item) then
198
+ watermark = (item.sequence or latest + 1) - 1
199
+ break
200
+ end
201
+ end
202
+ else
203
+ for index = #matches, 1, -1 do
204
+ if not take(matches[index]) then
205
+ break
206
+ end
207
+ end
208
+ -- Picked newest first; read oldest first, the way the Output window does.
209
+ local ordered: { { [string]: any } } = {}
210
+ for index = #selected, 1, -1 do
211
+ table.insert(ordered, selected[index])
212
+ end
213
+ selected = ordered
214
+ end
215
+ local consumed = #selected
216
+
217
+ -- Identical lines collapse into one row with a count, after the cursor is
218
+ -- settled: grouping changes how rows are shown, never which were consumed.
219
+ if params.group == true then
220
+ local grouped: { { [string]: any } } = {}
221
+ local byKey: { [string]: { [string]: any } } = {}
222
+ for _, item in selected do
223
+ local key = table.concat({
224
+ item.level, item.message, item.source or "", item.stack or "", item.context or "",
225
+ if item.updated then "u" else "",
226
+ }, "\0")
227
+ local group = byKey[key]
228
+ if group then
229
+ group.count += 1
230
+ group.lastTimestamp = item.timestamp
231
+ else
232
+ item.count = 1
233
+ item.firstTimestamp = item.timestamp
234
+ item.lastTimestamp = item.timestamp
235
+ byKey[key] = item
236
+ table.insert(grouped, item)
237
+ end
238
+ end
239
+ selected = grouped
162
240
  end
163
241
 
164
242
  return {
165
- items = entries,
166
- total = matched,
167
- dropped = dropped,
243
+ items = selected,
244
+ total = #matches,
245
+ dropped = if mode == "tail" then #matches - consumed else 0,
246
+ hasMore = mode == "drain" and consumed < #matches,
168
247
  -- Lines lost to the buffer's own capacity, as opposed to ones trimmed to
169
248
  -- satisfy this call's limit. Only the first is a reason to worry.
170
249
  evicted = if since ~= nil then (if missed > 0 then missed else nil) else (if evicted > 0 then evicted else nil),
171
250
  recordingSeconds = recordingSeconds,
172
- nextCursor = token .. ":" .. tostring(latest),
251
+ nextCursor = token .. ":" .. tostring(watermark),
173
252
  player = if player then player.Name else nil,
174
253
  capturing = if player then LogBuffer.clientReady(player) else true,
175
254
  }