@chrrxs/robloxstudio-mcp 2.9.0 → 2.10.0

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.
@@ -10,6 +10,11 @@ local State = TS.import(script, script, "modules", "State")
10
10
  local UI = TS.import(script, script, "modules", "UI")
11
11
  local Communication = TS.import(script, script, "modules", "Communication")
12
12
  local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
13
+ local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
14
+ -- Attach the per-peer LogService.MessageOut listener as early as possible so
15
+ -- boot-time prints from the user's place scripts are captured. Powers the
16
+ -- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
17
+ RuntimeLogBuffer.install()
13
18
  UI.init(plugin)
14
19
  local elements = UI.getElements()
15
20
  local toolbar = plugin:CreateToolbar("MCP Inspector")
@@ -72,6 +77,7 @@ local HttpService = _services.HttpService
72
77
  local Players = _services.Players
73
78
  local ReplicatedStorage = _services.ReplicatedStorage
74
79
  local RunService = _services.RunService
80
+ local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
75
81
  -- The client peer cannot reach the MCP HTTP server - Roblox forbids
76
82
  -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
77
83
  -- HttpEnabled reads as false there regardless of identity. So the server peer
@@ -87,6 +93,37 @@ local RunService = _services.RunService
87
93
  -- edit DM untouched.
88
94
  local MCP_URL = "http://localhost:58741"
89
95
  local BROKER_NAME = "__MCPClientBroker"
96
+ -- Endpoints the server-peer broker is allowed to forward to the client peer.
97
+ -- Each requires the client peer's plugin VM (because the buffer / require
98
+ -- cache / etc. lives there) so the server peer alone can't satisfy them.
99
+ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
100
+ ["/api/execute-luau"] = true,
101
+ ["/api/get-runtime-logs"] = true,
102
+ }
103
+ -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
104
+ -- polls doesn't cause a re-register stampede.
105
+ local lastReadyByProxy = {}
106
+ local postJson
107
+ local function reRegisterProxy(proxyId, role)
108
+ local now = tick()
109
+ local _proxyId = proxyId
110
+ local _condition = lastReadyByProxy[_proxyId]
111
+ if _condition == nil then
112
+ _condition = 0
113
+ end
114
+ local last = _condition
115
+ if now - last < 2 then
116
+ return nil
117
+ end
118
+ local _proxyId_1 = proxyId
119
+ lastReadyByProxy[_proxyId_1] = now
120
+ pcall(function()
121
+ return postJson("/ready", {
122
+ instanceId = proxyId,
123
+ role = role,
124
+ })
125
+ end)
126
+ end
90
127
  local function forkRole()
91
128
  if not RunService:IsRunning() then
92
129
  return "edit"
@@ -96,7 +133,7 @@ local function forkRole()
96
133
  end
97
134
  return "client"
98
135
  end
99
- local function postJson(endpoint, body)
136
+ function postJson(endpoint, body)
100
137
  return pcall(function()
101
138
  return HttpService:RequestAsync({
102
139
  Url = `{MCP_URL}{endpoint}`,
@@ -108,6 +145,56 @@ local function postJson(endpoint, body)
108
145
  })
109
146
  end)
110
147
  end
148
+ local function handleExecuteLuau(data)
149
+ local code = data and (data.code)
150
+ if type(code) == "string" == false or code == "" then
151
+ return {
152
+ success = false,
153
+ error = "code is required",
154
+ }
155
+ end
156
+ local m = Instance.new("ModuleScript")
157
+ m.Name = "__MCPClientEval"
158
+ local okSet, setErr = pcall(function()
159
+ m.Source = code
160
+ end)
161
+ if not okSet then
162
+ m:Destroy()
163
+ return {
164
+ success = false,
165
+ error = `Source set failed: {tostring(setErr)}`,
166
+ }
167
+ end
168
+ m.Parent = game.Workspace
169
+ local okReq, result = pcall(function()
170
+ return require(m)
171
+ end)
172
+ m:Destroy()
173
+ if okReq then
174
+ return {
175
+ success = true,
176
+ returnValue = if result ~= nil then tostring(result) else nil,
177
+ message = "Code executed successfully",
178
+ }
179
+ end
180
+ return {
181
+ success = false,
182
+ error = tostring(result),
183
+ }
184
+ end
185
+ local function handleGetRuntimeLogs(data)
186
+ local d = data or {}
187
+ local since = d.since
188
+ local tail = d.tail
189
+ local filter = d.filter
190
+ -- "client" is the generic peer tag; MCP-side aggregator overrides with
191
+ -- the specific role (e.g. "client-1") on target=all fan-out.
192
+ return RuntimeLogBuffer.query({
193
+ since = since,
194
+ tail = tail,
195
+ filter = filter,
196
+ }, "client")
197
+ end
111
198
  local function setupClientBroker()
112
199
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
113
200
  if not rf or not rf:IsA("RemoteFunction") then
@@ -115,44 +202,20 @@ local function setupClientBroker()
115
202
  return nil
116
203
  end
117
204
  rf.OnClientInvoke = function(payload)
118
- local code = payload and payload.code
119
- if type(code) == "string" == false or code == "" then
120
- return {
121
- success = false,
122
- error = "code is required",
123
- }
205
+ -- Two payload shapes in the wild:
206
+ -- - {endpoint, data} from v2.10+ server-peer broker (this is the new
207
+ -- discriminated form that lets us dispatch on endpoint)
208
+ -- - {code} from pre-v2.10 server-peer broker (raw execute-luau payload)
209
+ -- The shapes coexist gracefully because we fall back to execute-luau
210
+ -- when endpoint is missing.
211
+ if payload and payload.endpoint == "/api/get-runtime-logs" then
212
+ return handleGetRuntimeLogs(payload.data)
124
213
  end
125
- local m = Instance.new("ModuleScript")
126
- m.Name = "__MCPClientEval"
127
- local okSet, setErr = pcall(function()
128
- m.Source = code
129
- end)
130
- if not okSet then
131
- m:Destroy()
132
- local _arg0 = {
133
- success = false,
134
- error = `Source set failed: {tostring(setErr)}`,
135
- }
136
- return _arg0
137
- end
138
- m.Parent = game.Workspace
139
- local okReq, result = pcall(function()
140
- return require(m)
141
- end)
142
- m:Destroy()
143
- if okReq then
144
- local _arg0 = {
145
- success = true,
146
- returnValue = if result ~= nil then tostring(result) else nil,
147
- message = "Code executed successfully",
148
- }
149
- return _arg0
214
+ if payload and payload.endpoint == "/api/execute-luau" then
215
+ return handleExecuteLuau(payload.data)
150
216
  end
151
- local _arg0 = {
152
- success = false,
153
- error = tostring(result),
154
- }
155
- return _arg0
217
+ -- Legacy: raw execute-luau payload at the top level.
218
+ return handleExecuteLuau(payload)
156
219
  end
157
220
  end
158
221
  local proxyByPlayer = {}
@@ -179,33 +242,47 @@ local function pollProxy(proxyId, player, rf)
179
242
  local okJson, body = pcall(function()
180
243
  return HttpService:JSONDecode(res.Body)
181
244
  end)
182
- if okJson and body and body.request and body.requestId ~= nil then
183
- local request = body.request
184
- local response
185
- if request.endpoint == "/api/execute-luau" then
186
- local okInvoke, invokeRes = pcall(function()
187
- return rf:InvokeClient(player, request.data)
188
- end)
189
- if okInvoke then
190
- response = if invokeRes ~= nil then invokeRes else {
191
- success = false,
192
- error = "nil response",
245
+ if okJson and body then
246
+ -- Server lost our proxy registration (process restart, etc.) -
247
+ -- re-register so the next poll cycle starts routing again.
248
+ if body.knownInstance == false then
249
+ reRegisterProxy(proxyId, "client")
250
+ end
251
+ if body.request and body.requestId ~= nil then
252
+ local request = body.request
253
+ local response
254
+ local _endpoint = request.endpoint
255
+ if CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil then
256
+ -- Forward as a discriminated envelope so the client-side
257
+ -- OnClientInvoke knows which endpoint it's serving.
258
+ local envelope = {
259
+ endpoint = request.endpoint,
260
+ data = request.data,
193
261
  }
262
+ local okInvoke, invokeRes = pcall(function()
263
+ return rf:InvokeClient(player, envelope)
264
+ end)
265
+ if okInvoke then
266
+ response = if invokeRes ~= nil then invokeRes else {
267
+ success = false,
268
+ error = "nil response",
269
+ }
270
+ else
271
+ response = {
272
+ success = false,
273
+ error = `InvokeClient failed: {tostring(invokeRes)}`,
274
+ }
275
+ end
194
276
  else
195
277
  response = {
196
- success = false,
197
- error = `InvokeClient failed: {tostring(invokeRes)}`,
278
+ error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
198
279
  }
199
280
  end
200
- else
201
- response = {
202
- error = `Client-proxy only supports /api/execute-luau, got: {tostring(request.endpoint)}`,
203
- }
281
+ postJson("/response", {
282
+ requestId = body.requestId,
283
+ response = response,
284
+ })
204
285
  end
205
- postJson("/response", {
206
- requestId = body.requestId,
207
- response = response,
208
- })
209
286
  end
210
287
  end
211
288
  task.wait(0.5)
@@ -264,22 +341,28 @@ local function startEditProxyLoop()
264
341
  local okJson, body = pcall(function()
265
342
  return HttpService:JSONDecode(pollRes.Body)
266
343
  end)
267
- if okJson and body and body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
268
- local sts = game:GetService("StudioTestService")
269
- local endOk, endErr = pcall(function()
270
- return sts:EndTest("stopped_by_mcp")
271
- end)
272
- local response = if endOk then {
273
- success = true,
274
- message = "Playtest stopped via edit-proxy/EndTest",
275
- } else {
276
- success = false,
277
- error = `EndTest failed: {tostring(endErr)}`,
278
- }
279
- postJson("/response", {
280
- requestId = body.requestId,
281
- response = response,
282
- })
344
+ if okJson and body then
345
+ -- Re-register if the server lost our edit-proxy registration.
346
+ if body.knownInstance == false then
347
+ reRegisterProxy(proxyId, "edit-proxy")
348
+ end
349
+ if body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
350
+ local sts = game:GetService("StudioTestService")
351
+ local endOk, endErr = pcall(function()
352
+ return sts:EndTest("stopped_by_mcp")
353
+ end)
354
+ local response = if endOk then {
355
+ success = true,
356
+ message = "Playtest stopped via edit-proxy/EndTest",
357
+ } else {
358
+ success = false,
359
+ error = `EndTest failed: {tostring(endErr)}`,
360
+ }
361
+ postJson("/response", {
362
+ requestId = body.requestId,
363
+ response = response,
364
+ })
365
+ end
283
366
  end
284
367
  end
285
368
  task.wait(0.15)
@@ -351,6 +434,7 @@ local BuildHandlers = TS.import(script, script.Parent, "handlers", "BuildHandler
351
434
  local AssetHandlers = TS.import(script, script.Parent, "handlers", "AssetHandlers")
352
435
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
353
436
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
437
+ local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
354
438
  local instanceId = HttpService:GenerateGUID(false)
355
439
  local assignedRole
356
440
  local function detectRole()
@@ -419,6 +503,7 @@ local routeMap = {
419
503
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
420
504
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
421
505
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
506
+ ["/api/get-runtime-logs"] = LogHandlers.getRuntimeLogs,
422
507
  }
423
508
  local function processRequest(request)
424
509
  local endpoint = request.endpoint
@@ -460,6 +545,43 @@ local function getConnectionStatus(connIndex)
460
545
  end
461
546
  return "connecting"
462
547
  end
548
+ -- Throttle for re-issuing /ready after the server reports knownInstance=false.
549
+ -- Without this, every poll during the brief window where the server has just
550
+ -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
551
+ local lastReadyPostAt = 0
552
+ local function sendReady(conn)
553
+ local now = tick()
554
+ if now - lastReadyPostAt < 2 then
555
+ return nil
556
+ end
557
+ lastReadyPostAt = now
558
+ task.spawn(function()
559
+ local readyOk, readyResult = pcall(function()
560
+ return HttpService:RequestAsync({
561
+ Url = `{conn.serverUrl}/ready`,
562
+ Method = "POST",
563
+ Headers = {
564
+ ["Content-Type"] = "application/json",
565
+ },
566
+ Body = HttpService:JSONEncode({
567
+ instanceId = instanceId,
568
+ role = detectRole(),
569
+ pluginReady = true,
570
+ timestamp = tick(),
571
+ }),
572
+ })
573
+ end)
574
+ if readyOk and readyResult.Success then
575
+ local parseOk, readyData = pcall(function()
576
+ return HttpService:JSONDecode(readyResult.Body)
577
+ end)
578
+ local _value = parseOk and readyData.assignedRole
579
+ if _value ~= "" and _value then
580
+ assignedRole = readyData.assignedRole
581
+ end
582
+ end
583
+ end)
584
+ end
463
585
  local function pollForRequests(connIndex)
464
586
  local conn = State.getConnection(connIndex)
465
587
  if not conn or not conn.isActive then
@@ -489,6 +611,14 @@ local function pollForRequests(connIndex)
489
611
  local mcpConnected = data.mcpConnected == true
490
612
  conn.lastHttpOk = true
491
613
  conn.lastMcpOk = mcpConnected
614
+ -- Server tells us when its in-memory instances map doesn't have us
615
+ -- (e.g. after an MCP process restart). Re-issue /ready immediately so
616
+ -- target=server/client-N start routing again. The throttle inside
617
+ -- sendReady() prevents duplicate registrations while the server
618
+ -- catches up.
619
+ if data.knownInstance == false then
620
+ sendReady(conn)
621
+ end
492
622
  if connIndex == State.getActiveTabIndex() then
493
623
  local el = ui
494
624
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
@@ -644,42 +774,19 @@ local function activatePlugin(connIndex)
644
774
  UI.updateUIState()
645
775
  end
646
776
  UI.updateTabDot(idx)
647
- task.spawn(function()
648
- if not conn.heartbeatConnection then
649
- conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
650
- local now = tick()
651
- local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
652
- if now - conn.lastPoll > currentInterval then
653
- conn.lastPoll = now
654
- pollForRequests(idx)
655
- end
656
- end)
657
- end
658
- local readyOk, readyResult = pcall(function()
659
- return HttpService:RequestAsync({
660
- Url = `{conn.serverUrl}/ready`,
661
- Method = "POST",
662
- Headers = {
663
- ["Content-Type"] = "application/json",
664
- },
665
- Body = HttpService:JSONEncode({
666
- instanceId = instanceId,
667
- role = detectRole(),
668
- pluginReady = true,
669
- timestamp = tick(),
670
- }),
671
- })
672
- end)
673
- if readyOk and readyResult.Success then
674
- local parseOk, readyData = pcall(function()
675
- return HttpService:JSONDecode(readyResult.Body)
676
- end)
677
- local _value = parseOk and readyData.assignedRole
678
- if _value ~= "" and _value then
679
- assignedRole = readyData.assignedRole
777
+ if not conn.heartbeatConnection then
778
+ conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
779
+ local now = tick()
780
+ local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
781
+ if now - conn.lastPoll > currentInterval then
782
+ conn.lastPoll = now
783
+ pollForRequests(idx)
680
784
  end
681
- end
682
- end)
785
+ end)
786
+ end
787
+ -- Initial /ready; pollForRequests will also re-fire ready if the server
788
+ -- later reports knownInstance=false (process restart, etc).
789
+ sendReady(conn)
683
790
  end
684
791
  local function deactivatePlugin(connIndex)
685
792
  local _condition = connIndex
@@ -796,7 +903,18 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
796
903
  -- DataModel into the play DMs, so the scripts come along and run there.
797
904
  -- TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
798
905
  -- returns (test ended for any reason: stop_playtest, manual close, EndTest).
799
- -- Both scripts have Archivable=false so a user save doesn't persist them.
906
+ --
907
+ -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
908
+ -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
909
+ -- never reached the play DMs because we'd set them to false). We now keep
910
+ -- Archivable=true so the clone works, and rely on cleanupBridges() to
911
+ -- remove the scripts from the edit DM when the test ends. The only failure
912
+ -- mode is the user saving DURING an active playtest, which would persist
913
+ -- the bridges to the .rbxl - that's a no-op next session because
914
+ -- installBridges() always calls cleanupBridges() first to clear stale
915
+ -- instances. The RemoteFunction/BindableFunction that the bridge scripts
916
+ -- CREATE at runtime stay Archivable=false (they're runtime-only and should
917
+ -- never appear in a save).
800
918
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
801
919
  local ServerScriptService = _services.ServerScriptService
802
920
  local StarterPlayer = _services.StarterPlayer
@@ -944,7 +1062,9 @@ local function installBridges()
944
1062
  local ok, err = pcall(function()
945
1063
  local serverScript = Instance.new("Script")
946
1064
  serverScript.Name = SERVER_SCRIPT_NAME
947
- serverScript.Archivable = false
1065
+ -- Archivable=true so ExecutePlayModeAsync's deep-clone includes the
1066
+ -- script. cleanupBridges() removes it from the edit DM when the
1067
+ -- playtest ends.
948
1068
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
949
1069
  serverScript.Parent = ServerScriptService
950
1070
  local sps = getStarterPlayerScripts()
@@ -953,7 +1073,6 @@ local function installBridges()
953
1073
  end
954
1074
  local clientScript = Instance.new("LocalScript")
955
1075
  clientScript.Name = CLIENT_SCRIPT_NAME
956
- clientScript.Archivable = false
957
1076
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
958
1077
  clientScript.Parent = sps
959
1078
  end)
@@ -2669,6 +2788,33 @@ return {
2669
2788
  </Properties>
2670
2789
  </Item>
2671
2790
  <Item class="ModuleScript" referent="11">
2791
+ <Properties>
2792
+ <string name="Name">LogHandlers</string>
2793
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2794
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2795
+ local RuntimeLogBuffer = TS.import(script, script.Parent.Parent, "RuntimeLogBuffer")
2796
+ local function getRuntimeLogs(requestData)
2797
+ local since = requestData.since
2798
+ local tail = requestData.tail
2799
+ local filter = requestData.filter
2800
+ -- Plugin-side peer tag is generic ("edit"|"server"|"client"). The MCP-side
2801
+ -- aggregator overrides it with the specific instance role (e.g. "client-1")
2802
+ -- during fan-out for target=all, so this value is only authoritative for
2803
+ -- the single-peer query path.
2804
+ local peer = RuntimeLogBuffer.detectPeer()
2805
+ return RuntimeLogBuffer.query({
2806
+ since = since,
2807
+ tail = tail,
2808
+ filter = filter,
2809
+ }, peer)
2810
+ end
2811
+ return {
2812
+ getRuntimeLogs = getRuntimeLogs,
2813
+ }
2814
+ ]]></string>
2815
+ </Properties>
2816
+ </Item>
2817
+ <Item class="ModuleScript" referent="12">
2672
2818
  <Properties>
2673
2819
  <string name="Name">MetadataHandlers</string>
2674
2820
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3127,13 +3273,67 @@ local function executeLuau(requestData)
3127
3273
  table.insert(output, _arg0)
3128
3274
  oldWarn(unpack(args))
3129
3275
  end
3276
+ -- Try loadstring first (preserves print/warn interception). When
3277
+ -- ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
3278
+ -- peer where the engine respects that gate (notably the play-server DM
3279
+ -- in some Studio configurations), loadstring either returns nil with a
3280
+ -- "loadstring() is not available" message OR throws that same message
3281
+ -- directly. Both paths must trigger the ModuleScript + require
3282
+ -- fallback. The fallback can't intercept print/warn since the
3283
+ -- ModuleScript runs in its own environment, so the output array stays
3284
+ -- empty in that branch - the playtest log buffer already captures
3285
+ -- prints separately via LogService.MessageOut.
3286
+ local runViaModuleScript = function()
3287
+ local m = Instance.new("ModuleScript")
3288
+ m.Name = "__MCPExecLuauPayload"
3289
+ -- Wrap user code in an IIFE so require() always gets exactly one
3290
+ -- return value. Without this, code like `print("x")` errors with
3291
+ -- "Module code did not return exactly one value" because top-level
3292
+ -- ModuleScripts must return exactly one value.
3293
+ --
3294
+ -- The DOUBLE parens around the call are load-bearing: in Luau,
3295
+ -- `return f()` propagates whatever multi-value tuple f returns,
3296
+ -- including zero values. Outer parens adjust the call to exactly
3297
+ -- one value (the first, or nil). So `return ((f)())` always
3298
+ -- returns exactly one value, regardless of what f does.
3299
+ local wrapped = `return ((function()\n{code}\nend)())`
3300
+ local okSet, setErr = pcall(function()
3301
+ m.Source = wrapped
3302
+ end)
3303
+ if not okSet then
3304
+ m:Destroy()
3305
+ error(`ModuleScript Source set failed: {tostring(setErr)}`)
3306
+ end
3307
+ m.Parent = game:GetService("Workspace")
3308
+ local okReq, reqResult = pcall(function()
3309
+ return require(m)
3310
+ end)
3311
+ m:Destroy()
3312
+ if not okReq then
3313
+ error(tostring(reqResult))
3314
+ end
3315
+ return reqResult
3316
+ end
3317
+ local isLoadstringUnavailable = function(err)
3318
+ local errStr = tostring(err)
3319
+ local matchStart = string.find(errStr, "not available", 1, true)
3320
+ return matchStart ~= nil
3321
+ end
3130
3322
  local success, result = pcall(function()
3131
3323
  local fn, compileError = loadstring(code)
3132
3324
  if not fn then
3325
+ if isLoadstringUnavailable(compileError) then
3326
+ return runViaModuleScript()
3327
+ end
3133
3328
  error(`Compile error: {compileError}`)
3134
3329
  end
3135
3330
  return fn()
3136
3331
  end)
3332
+ -- loadstring throws (not returns nil) in some plugin contexts when
3333
+ -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3334
+ if not success and isLoadstringUnavailable(result) then
3335
+ success, result = pcall(runViaModuleScript)
3336
+ end
3137
3337
  env.print = oldPrint
3138
3338
  env.warn = oldWarn
3139
3339
  if success then
@@ -3251,7 +3451,7 @@ return {
3251
3451
  ]]></string>
3252
3452
  </Properties>
3253
3453
  </Item>
3254
- <Item class="ModuleScript" referent="12">
3454
+ <Item class="ModuleScript" referent="13">
3255
3455
  <Properties>
3256
3456
  <string name="Name">PropertyHandlers</string>
3257
3457
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3503,7 +3703,7 @@ return {
3503
3703
  ]]></string>
3504
3704
  </Properties>
3505
3705
  </Item>
3506
- <Item class="ModuleScript" referent="13">
3706
+ <Item class="ModuleScript" referent="14">
3507
3707
  <Properties>
3508
3708
  <string name="Name">QueryHandlers</string>
3509
3709
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -4530,7 +4730,7 @@ return {
4530
4730
  ]]></string>
4531
4731
  </Properties>
4532
4732
  </Item>
4533
- <Item class="ModuleScript" referent="14">
4733
+ <Item class="ModuleScript" referent="15">
4534
4734
  <Properties>
4535
4735
  <string name="Name">ScriptHandlers</string>
4536
4736
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5226,7 +5426,7 @@ return {
5226
5426
  ]]></string>
5227
5427
  </Properties>
5228
5428
  </Item>
5229
- <Item class="ModuleScript" referent="15">
5429
+ <Item class="ModuleScript" referent="16">
5230
5430
  <Properties>
5231
5431
  <string name="Name">TestHandlers</string>
5232
5432
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5545,7 +5745,7 @@ return {
5545
5745
  </Properties>
5546
5746
  </Item>
5547
5747
  </Item>
5548
- <Item class="ModuleScript" referent="16">
5748
+ <Item class="ModuleScript" referent="17">
5549
5749
  <Properties>
5550
5750
  <string name="Name">Recording</string>
5551
5751
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5575,11 +5775,192 @@ return {
5575
5775
  ]]></string>
5576
5776
  </Properties>
5577
5777
  </Item>
5578
- <Item class="ModuleScript" referent="17">
5778
+ <Item class="ModuleScript" referent="18">
5779
+ <Properties>
5780
+ <string name="Name">RuntimeLogBuffer</string>
5781
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5782
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
5783
+ -- Per-peer in-memory ring buffer for LogService.MessageOut events.
5784
+ -- Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
5785
+ -- primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
5786
+ --
5787
+ -- Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
5788
+ -- play-server DM, play-client DM all run their own copy of this module).
5789
+ -- Captured entries live in plugin module-state; nothing is parented to the
5790
+ -- DataModel. The buffer is bounded by a message-byte budget; oldest entries
5791
+ -- drop when over budget.
5792
+ --
5793
+ -- Peer-tag caveat: returned entries reflect which peer's plugin CAPTURED the
5794
+ -- entry, NOT which peer's script originated the print. LogService reflects
5795
+ -- prints across peers in Studio Play (a server print ends up in both the
5796
+ -- server and client LogService:GetLogHistory()) and origin is empirically
5797
+ -- undetectable from inside MessageOut. The MCP-side aggregator handles
5798
+ -- cross-peer dedup via a 2s timestamp window.
5799
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
5800
+ local LogService = _services.LogService
5801
+ local RunService = _services.RunService
5802
+ local MAX_BYTES = 64 * 1024
5803
+ local HARD_ENTRY_CAP = 50_000
5804
+ local entries = {}
5805
+ local totalBytes = 0
5806
+ local totalDropped = 0
5807
+ local nextSeq = 1
5808
+ local installed = false
5809
+ local function levelTag(t)
5810
+ if t == Enum.MessageType.MessageWarning then
5811
+ return "WARN"
5812
+ end
5813
+ if t == Enum.MessageType.MessageError then
5814
+ return "ERR"
5815
+ end
5816
+ if t == Enum.MessageType.MessageInfo then
5817
+ return "INFO"
5818
+ end
5819
+ return "OUT"
5820
+ end
5821
+ local function nowSec()
5822
+ return DateTime.now().UnixTimestampMillis / 1000
5823
+ end
5824
+ local function dropOldestUntilFits(incomingBytes)
5825
+ while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
5826
+ local dropped = table.remove(entries, 1)
5827
+ totalBytes -= #dropped.message
5828
+ totalDropped += 1
5829
+ end
5830
+ end
5831
+ local function install()
5832
+ if installed then
5833
+ return nil
5834
+ end
5835
+ if not RunService:IsStudio() then
5836
+ return nil
5837
+ end
5838
+ installed = true
5839
+ LogService.MessageOut:Connect(function(msg, t)
5840
+ local bytes = #msg
5841
+ dropOldestUntilFits(bytes)
5842
+ local _arg0 = {
5843
+ seq = nextSeq,
5844
+ ts = nowSec(),
5845
+ level = levelTag(t),
5846
+ message = msg,
5847
+ }
5848
+ table.insert(entries, _arg0)
5849
+ nextSeq += 1
5850
+ totalBytes += bytes
5851
+ end)
5852
+ end
5853
+ local function detectPeer()
5854
+ if not RunService:IsRunning() then
5855
+ return "edit"
5856
+ end
5857
+ if RunService:IsServer() then
5858
+ return "server"
5859
+ end
5860
+ return "client"
5861
+ end
5862
+ local function query(opts, peer)
5863
+ local _result
5864
+ if opts.since ~= nil then
5865
+ -- ▼ ReadonlyArray.filter ▼
5866
+ local _newValue = {}
5867
+ local _callback = function(e)
5868
+ return e.seq > (opts.since)
5869
+ end
5870
+ local _length = 0
5871
+ for _k, _v in entries do
5872
+ if _callback(_v, _k - 1, entries) == true then
5873
+ _length += 1
5874
+ _newValue[_length] = _v
5875
+ end
5876
+ end
5877
+ -- ▲ ReadonlyArray.filter ▲
5878
+ _result = _newValue
5879
+ else
5880
+ local _array = {}
5881
+ local _length = #_array
5882
+ table.move(entries, 1, #entries, _length + 1, _array)
5883
+ _result = _array
5884
+ end
5885
+ local result = _result
5886
+ if opts.filter ~= nil then
5887
+ -- Plain substring search (4th arg = true). Pattern matching here was
5888
+ -- surprising in practice - Lua magic chars in messages would silently
5889
+ -- not match (e.g. filter="MARK-EDIT" against "MARK-EDIT-001" fails
5890
+ -- because '-' means "0+" in Lua patterns). Substring search matches
5891
+ -- most users' mental model of "filter messages containing this text".
5892
+ local needle = opts.filter
5893
+ -- ▼ ReadonlyArray.filter ▼
5894
+ local _newValue = {}
5895
+ local _callback = function(e)
5896
+ local start = string.find(e.message, needle, 1, true)
5897
+ return start ~= nil
5898
+ end
5899
+ local _length = 0
5900
+ for _k, _v in result do
5901
+ if _callback(_v, _k - 1, result) == true then
5902
+ _length += 1
5903
+ _newValue[_length] = _v
5904
+ end
5905
+ end
5906
+ -- ▲ ReadonlyArray.filter ▲
5907
+ result = _newValue
5908
+ end
5909
+ if opts.tail ~= nil and #result > opts.tail then
5910
+ -- roblox-ts arrays don't expose .slice; manual tail copy.
5911
+ local tailed = {}
5912
+ local start = #result - opts.tail
5913
+ do
5914
+ local i = start
5915
+ local _shouldIncrement = false
5916
+ while true do
5917
+ if _shouldIncrement then
5918
+ i += 1
5919
+ else
5920
+ _shouldIncrement = true
5921
+ end
5922
+ if not (i < #result) then
5923
+ break
5924
+ end
5925
+ local _arg0 = result[i + 1]
5926
+ table.insert(tailed, _arg0)
5927
+ end
5928
+ end
5929
+ result = tailed
5930
+ end
5931
+ local last = if #entries > 0 then entries[#entries] else nil
5932
+ local _object = {
5933
+ peer = peer,
5934
+ entries = result,
5935
+ totalDropped = totalDropped,
5936
+ }
5937
+ local _left = "nextSince"
5938
+ local _result_1
5939
+ if last then
5940
+ _result_1 = last.seq
5941
+ else
5942
+ local _condition = opts.since
5943
+ if _condition == nil then
5944
+ _condition = 0
5945
+ end
5946
+ _result_1 = _condition
5947
+ end
5948
+ _object[_left] = _result_1
5949
+ return _object
5950
+ end
5951
+ return {
5952
+ install = install,
5953
+ detectPeer = detectPeer,
5954
+ query = query,
5955
+ }
5956
+ ]]></string>
5957
+ </Properties>
5958
+ </Item>
5959
+ <Item class="ModuleScript" referent="19">
5579
5960
  <Properties>
5580
5961
  <string name="Name">State</string>
5581
5962
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5582
- local CURRENT_VERSION = "2.9.0"
5963
+ local CURRENT_VERSION = "2.10.0"
5583
5964
  local MAX_CONNECTIONS = 5
5584
5965
  local BASE_PORT = 58741
5585
5966
  local activeTabIndex = 0
@@ -5671,7 +6052,7 @@ return {
5671
6052
  ]]></string>
5672
6053
  </Properties>
5673
6054
  </Item>
5674
- <Item class="ModuleScript" referent="18">
6055
+ <Item class="ModuleScript" referent="20">
5675
6056
  <Properties>
5676
6057
  <string name="Name">UI</string>
5677
6058
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6391,7 +6772,7 @@ return {
6391
6772
  ]]></string>
6392
6773
  </Properties>
6393
6774
  </Item>
6394
- <Item class="ModuleScript" referent="19">
6775
+ <Item class="ModuleScript" referent="21">
6395
6776
  <Properties>
6396
6777
  <string name="Name">Utils</string>
6397
6778
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6921,11 +7302,11 @@ return {
6921
7302
  </Properties>
6922
7303
  </Item>
6923
7304
  </Item>
6924
- <Item class="Folder" referent="23">
7305
+ <Item class="Folder" referent="25">
6925
7306
  <Properties>
6926
7307
  <string name="Name">include</string>
6927
7308
  </Properties>
6928
- <Item class="ModuleScript" referent="20">
7309
+ <Item class="ModuleScript" referent="22">
6929
7310
  <Properties>
6930
7311
  <string name="Name">Promise</string>
6931
7312
  <string name="Source"><![CDATA[--[[
@@ -8999,7 +9380,7 @@ return Promise
8999
9380
  ]]></string>
9000
9381
  </Properties>
9001
9382
  </Item>
9002
- <Item class="ModuleScript" referent="21">
9383
+ <Item class="ModuleScript" referent="23">
9003
9384
  <Properties>
9004
9385
  <string name="Name">RuntimeLib</string>
9005
9386
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -9266,15 +9647,15 @@ return TS
9266
9647
  </Properties>
9267
9648
  </Item>
9268
9649
  </Item>
9269
- <Item class="Folder" referent="24">
9650
+ <Item class="Folder" referent="26">
9270
9651
  <Properties>
9271
9652
  <string name="Name">node_modules</string>
9272
9653
  </Properties>
9273
- <Item class="Folder" referent="25">
9654
+ <Item class="Folder" referent="27">
9274
9655
  <Properties>
9275
9656
  <string name="Name">@rbxts</string>
9276
9657
  </Properties>
9277
- <Item class="ModuleScript" referent="22">
9658
+ <Item class="ModuleScript" referent="24">
9278
9659
  <Properties>
9279
9660
  <string name="Name">services</string>
9280
9661
  <string name="Source"><![CDATA[return setmetatable({}, {