@chrrxs/robloxstudio-mcp 2.9.1 → 2.10.1

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,10 +10,23 @@ 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()
20
+ local ICON_DISCONNECTED = "rbxassetid://75876056391496"
21
+ local ICON_CONNECTING = "rbxassetid://71302583919560"
22
+ local ICON_CONNECTED = "rbxassetid://130958234173611"
15
23
  local toolbar = plugin:CreateToolbar("MCP Integration")
16
- local button = toolbar:CreateButton("MCP Server", "Connect to MCP Server for AI Integration", "rbxassetid://10734944444")
24
+ local button = toolbar:CreateButton("MCP Server", "Connect to MCP Server for AI Integration", ICON_DISCONNECTED)
25
+ UI.setToolbarButton(button, {
26
+ disconnected = ICON_DISCONNECTED,
27
+ connecting = ICON_CONNECTING,
28
+ connected = ICON_CONNECTED,
29
+ })
17
30
  elements.connectButton.Activated:Connect(function()
18
31
  local conn = State.getActiveConnection()
19
32
  if conn and conn.isActive then
@@ -72,6 +85,7 @@ local HttpService = _services.HttpService
72
85
  local Players = _services.Players
73
86
  local ReplicatedStorage = _services.ReplicatedStorage
74
87
  local RunService = _services.RunService
88
+ local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
75
89
  -- The client peer cannot reach the MCP HTTP server - Roblox forbids
76
90
  -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
77
91
  -- HttpEnabled reads as false there regardless of identity. So the server peer
@@ -87,6 +101,37 @@ local RunService = _services.RunService
87
101
  -- edit DM untouched.
88
102
  local MCP_URL = "http://localhost:58741"
89
103
  local BROKER_NAME = "__MCPClientBroker"
104
+ -- Endpoints the server-peer broker is allowed to forward to the client peer.
105
+ -- Each requires the client peer's plugin VM (because the buffer / require
106
+ -- cache / etc. lives there) so the server peer alone can't satisfy them.
107
+ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
108
+ ["/api/execute-luau"] = true,
109
+ ["/api/get-runtime-logs"] = true,
110
+ }
111
+ -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
112
+ -- polls doesn't cause a re-register stampede.
113
+ local lastReadyByProxy = {}
114
+ local postJson
115
+ local function reRegisterProxy(proxyId, role)
116
+ local now = tick()
117
+ local _proxyId = proxyId
118
+ local _condition = lastReadyByProxy[_proxyId]
119
+ if _condition == nil then
120
+ _condition = 0
121
+ end
122
+ local last = _condition
123
+ if now - last < 2 then
124
+ return nil
125
+ end
126
+ local _proxyId_1 = proxyId
127
+ lastReadyByProxy[_proxyId_1] = now
128
+ pcall(function()
129
+ return postJson("/ready", {
130
+ instanceId = proxyId,
131
+ role = role,
132
+ })
133
+ end)
134
+ end
90
135
  local function forkRole()
91
136
  if not RunService:IsRunning() then
92
137
  return "edit"
@@ -96,7 +141,7 @@ local function forkRole()
96
141
  end
97
142
  return "client"
98
143
  end
99
- local function postJson(endpoint, body)
144
+ function postJson(endpoint, body)
100
145
  return pcall(function()
101
146
  return HttpService:RequestAsync({
102
147
  Url = `{MCP_URL}{endpoint}`,
@@ -108,6 +153,56 @@ local function postJson(endpoint, body)
108
153
  })
109
154
  end)
110
155
  end
156
+ local function handleExecuteLuau(data)
157
+ local code = data and (data.code)
158
+ if type(code) == "string" == false or code == "" then
159
+ return {
160
+ success = false,
161
+ error = "code is required",
162
+ }
163
+ end
164
+ local m = Instance.new("ModuleScript")
165
+ m.Name = "__MCPClientEval"
166
+ local okSet, setErr = pcall(function()
167
+ m.Source = code
168
+ end)
169
+ if not okSet then
170
+ m:Destroy()
171
+ return {
172
+ success = false,
173
+ error = `Source set failed: {tostring(setErr)}`,
174
+ }
175
+ end
176
+ m.Parent = game.Workspace
177
+ local okReq, result = pcall(function()
178
+ return require(m)
179
+ end)
180
+ m:Destroy()
181
+ if okReq then
182
+ return {
183
+ success = true,
184
+ returnValue = if result ~= nil then tostring(result) else nil,
185
+ message = "Code executed successfully",
186
+ }
187
+ end
188
+ return {
189
+ success = false,
190
+ error = tostring(result),
191
+ }
192
+ end
193
+ local function handleGetRuntimeLogs(data)
194
+ local d = data or {}
195
+ local since = d.since
196
+ local tail = d.tail
197
+ local filter = d.filter
198
+ -- "client" is the generic peer tag; MCP-side aggregator overrides with
199
+ -- the specific role (e.g. "client-1") on target=all fan-out.
200
+ return RuntimeLogBuffer.query({
201
+ since = since,
202
+ tail = tail,
203
+ filter = filter,
204
+ }, "client")
205
+ end
111
206
  local function setupClientBroker()
112
207
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
113
208
  if not rf or not rf:IsA("RemoteFunction") then
@@ -115,44 +210,20 @@ local function setupClientBroker()
115
210
  return nil
116
211
  end
117
212
  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
- }
124
- 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
213
+ -- Two payload shapes in the wild:
214
+ -- - {endpoint, data} from v2.10+ server-peer broker (this is the new
215
+ -- discriminated form that lets us dispatch on endpoint)
216
+ -- - {code} from pre-v2.10 server-peer broker (raw execute-luau payload)
217
+ -- The shapes coexist gracefully because we fall back to execute-luau
218
+ -- when endpoint is missing.
219
+ if payload and payload.endpoint == "/api/get-runtime-logs" then
220
+ return handleGetRuntimeLogs(payload.data)
137
221
  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
222
+ if payload and payload.endpoint == "/api/execute-luau" then
223
+ return handleExecuteLuau(payload.data)
150
224
  end
151
- local _arg0 = {
152
- success = false,
153
- error = tostring(result),
154
- }
155
- return _arg0
225
+ -- Legacy: raw execute-luau payload at the top level.
226
+ return handleExecuteLuau(payload)
156
227
  end
157
228
  end
158
229
  local proxyByPlayer = {}
@@ -179,33 +250,47 @@ local function pollProxy(proxyId, player, rf)
179
250
  local okJson, body = pcall(function()
180
251
  return HttpService:JSONDecode(res.Body)
181
252
  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",
253
+ if okJson and body then
254
+ -- Server lost our proxy registration (process restart, etc.) -
255
+ -- re-register so the next poll cycle starts routing again.
256
+ if body.knownInstance == false then
257
+ reRegisterProxy(proxyId, "client")
258
+ end
259
+ if body.request and body.requestId ~= nil then
260
+ local request = body.request
261
+ local response
262
+ local _endpoint = request.endpoint
263
+ if CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil then
264
+ -- Forward as a discriminated envelope so the client-side
265
+ -- OnClientInvoke knows which endpoint it's serving.
266
+ local envelope = {
267
+ endpoint = request.endpoint,
268
+ data = request.data,
193
269
  }
270
+ local okInvoke, invokeRes = pcall(function()
271
+ return rf:InvokeClient(player, envelope)
272
+ end)
273
+ if okInvoke then
274
+ response = if invokeRes ~= nil then invokeRes else {
275
+ success = false,
276
+ error = "nil response",
277
+ }
278
+ else
279
+ response = {
280
+ success = false,
281
+ error = `InvokeClient failed: {tostring(invokeRes)}`,
282
+ }
283
+ end
194
284
  else
195
285
  response = {
196
- success = false,
197
- error = `InvokeClient failed: {tostring(invokeRes)}`,
286
+ error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
198
287
  }
199
288
  end
200
- else
201
- response = {
202
- error = `Client-proxy only supports /api/execute-luau, got: {tostring(request.endpoint)}`,
203
- }
289
+ postJson("/response", {
290
+ requestId = body.requestId,
291
+ response = response,
292
+ })
204
293
  end
205
- postJson("/response", {
206
- requestId = body.requestId,
207
- response = response,
208
- })
209
294
  end
210
295
  end
211
296
  task.wait(0.5)
@@ -264,22 +349,28 @@ local function startEditProxyLoop()
264
349
  local okJson, body = pcall(function()
265
350
  return HttpService:JSONDecode(pollRes.Body)
266
351
  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
- })
352
+ if okJson and body then
353
+ -- Re-register if the server lost our edit-proxy registration.
354
+ if body.knownInstance == false then
355
+ reRegisterProxy(proxyId, "edit-proxy")
356
+ end
357
+ if body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
358
+ local sts = game:GetService("StudioTestService")
359
+ local endOk, endErr = pcall(function()
360
+ return sts:EndTest("stopped_by_mcp")
361
+ end)
362
+ local response = if endOk then {
363
+ success = true,
364
+ message = "Playtest stopped via edit-proxy/EndTest",
365
+ } else {
366
+ success = false,
367
+ error = `EndTest failed: {tostring(endErr)}`,
368
+ }
369
+ postJson("/response", {
370
+ requestId = body.requestId,
371
+ response = response,
372
+ })
373
+ end
283
374
  end
284
375
  end
285
376
  task.wait(0.15)
@@ -351,6 +442,7 @@ local BuildHandlers = TS.import(script, script.Parent, "handlers", "BuildHandler
351
442
  local AssetHandlers = TS.import(script, script.Parent, "handlers", "AssetHandlers")
352
443
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
353
444
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
445
+ local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
354
446
  local instanceId = HttpService:GenerateGUID(false)
355
447
  local assignedRole
356
448
  local function detectRole()
@@ -419,6 +511,7 @@ local routeMap = {
419
511
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
420
512
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
421
513
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
514
+ ["/api/get-runtime-logs"] = LogHandlers.getRuntimeLogs,
422
515
  }
423
516
  local function processRequest(request)
424
517
  local endpoint = request.endpoint
@@ -460,6 +553,43 @@ local function getConnectionStatus(connIndex)
460
553
  end
461
554
  return "connecting"
462
555
  end
556
+ -- Throttle for re-issuing /ready after the server reports knownInstance=false.
557
+ -- Without this, every poll during the brief window where the server has just
558
+ -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
559
+ local lastReadyPostAt = 0
560
+ local function sendReady(conn)
561
+ local now = tick()
562
+ if now - lastReadyPostAt < 2 then
563
+ return nil
564
+ end
565
+ lastReadyPostAt = now
566
+ task.spawn(function()
567
+ local readyOk, readyResult = pcall(function()
568
+ return HttpService:RequestAsync({
569
+ Url = `{conn.serverUrl}/ready`,
570
+ Method = "POST",
571
+ Headers = {
572
+ ["Content-Type"] = "application/json",
573
+ },
574
+ Body = HttpService:JSONEncode({
575
+ instanceId = instanceId,
576
+ role = detectRole(),
577
+ pluginReady = true,
578
+ timestamp = tick(),
579
+ }),
580
+ })
581
+ end)
582
+ if readyOk and readyResult.Success then
583
+ local parseOk, readyData = pcall(function()
584
+ return HttpService:JSONDecode(readyResult.Body)
585
+ end)
586
+ local _value = parseOk and readyData.assignedRole
587
+ if _value ~= "" and _value then
588
+ assignedRole = readyData.assignedRole
589
+ end
590
+ end
591
+ end)
592
+ end
463
593
  local function pollForRequests(connIndex)
464
594
  local conn = State.getConnection(connIndex)
465
595
  if not conn or not conn.isActive then
@@ -481,6 +611,9 @@ local function pollForRequests(connIndex)
481
611
  conn.isPolling = false
482
612
  local ui = UI.getElements()
483
613
  UI.updateTabDot(connIndex)
614
+ if connIndex == State.getActiveTabIndex() then
615
+ UI.updateToolbarIcon()
616
+ end
484
617
  if success and (result.Success or result.StatusCode == 503) then
485
618
  conn.consecutiveFailures = 0
486
619
  conn.currentRetryDelay = 0.5
@@ -489,6 +622,14 @@ local function pollForRequests(connIndex)
489
622
  local mcpConnected = data.mcpConnected == true
490
623
  conn.lastHttpOk = true
491
624
  conn.lastMcpOk = mcpConnected
625
+ -- Server tells us when its in-memory instances map doesn't have us
626
+ -- (e.g. after an MCP process restart). Re-issue /ready immediately so
627
+ -- target=server/client-N start routing again. The throttle inside
628
+ -- sendReady() prevents duplicate registrations while the server
629
+ -- catches up.
630
+ if data.knownInstance == false then
631
+ sendReady(conn)
632
+ end
492
633
  if connIndex == State.getActiveTabIndex() then
493
634
  local el = ui
494
635
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
@@ -629,7 +770,6 @@ local function activatePlugin(connIndex)
629
770
  conn.isActive = true
630
771
  conn.consecutiveFailures = 0
631
772
  conn.currentRetryDelay = 0.5
632
- ui.screenGui.Enabled = true
633
773
  if idx == State.getActiveTabIndex() then
634
774
  conn.serverUrl = ui.urlInput.Text
635
775
  local portStr = string.match(conn.serverUrl, ":(%d+)$")
@@ -644,42 +784,19 @@ local function activatePlugin(connIndex)
644
784
  UI.updateUIState()
645
785
  end
646
786
  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
787
+ if not conn.heartbeatConnection then
788
+ conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
789
+ local now = tick()
790
+ local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
791
+ if now - conn.lastPoll > currentInterval then
792
+ conn.lastPoll = now
793
+ pollForRequests(idx)
680
794
  end
681
- end
682
- end)
795
+ end)
796
+ end
797
+ -- Initial /ready; pollForRequests will also re-fire ready if the server
798
+ -- later reports knownInstance=false (process restart, etc).
799
+ sendReady(conn)
683
800
  end
684
801
  local function deactivatePlugin(connIndex)
685
802
  local _condition = connIndex
@@ -782,15 +899,22 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
782
899
  -- `require(SomeModule)` returns a fresh copy, not the one the running game
783
900
  -- scripts hold. So runtime-mutated module state is invisible to probes.
784
901
  --
785
- -- These bridges fix that by living inside the user's game scripts:
786
- -- - Server: a Script in ServerScriptService that creates a BindableFunction
787
- -- (for our server-peer plugin to invoke directly) plus a RemoteFunction
788
- -- (kept for parity with the upstream primitive's client-callable shape).
902
+ -- These bridges fix that by living inside the user's game scripts. Both
903
+ -- peers use the same symmetric shape:
904
+ -- - Server: a Script in ServerScriptService that creates a BindableFunction.
905
+ -- Plugin (server peer) invokes it with a fresh ModuleScript payload;
906
+ -- require() runs inside the Script VM so it shares the running server's
907
+ -- require cache.
789
908
  -- - Client: a LocalScript in StarterPlayer.StarterPlayerScripts that
790
909
  -- creates a BindableFunction. Plugin invokes it with a fresh ModuleScript
791
910
  -- payload; require() runs inside the LocalScript VM so it shares the
792
911
  -- game's require cache.
793
912
  --
913
+ -- Why ModuleScript+require on both sides (no loadstring): require'd modules
914
+ -- run with the security level they were created at and don't need
915
+ -- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
916
+ -- when LoadStringEnabled=false (the default in fresh places).
917
+ --
794
918
  -- Lifecycle: TestHandlers.startPlaytest inserts both scripts into the EDIT
795
919
  -- DM right before ExecutePlayModeAsync. ExecutePlayModeAsync clones the
796
920
  -- DataModel into the play DMs, so the scripts come along and run there.
@@ -821,7 +945,6 @@ local CLIENT_SCRIPT_NAME = "__MCP_ClientEvalBridge"
821
945
  local BRIDGE_NAMES = {
822
946
  serverScript = SERVER_SCRIPT_NAME,
823
947
  clientScript = CLIENT_SCRIPT_NAME,
824
- serverRemote = "__MCP_ServerEvalRemote",
825
948
  serverLocal = "__MCP_ServerEvalLocal",
826
949
  clientLocal = "__MCP_ClientEvalBridge",
827
950
  }
@@ -832,7 +955,6 @@ local SERVER_BRIDGE_SOURCE = `\
832
955
  -- stop_playtest. Provides shared-require-cache eval on the server peer for\
833
956
  -- the eval_server_runtime MCP tool.\
834
957
  \
835
- local ReplicatedStorage = game:GetService("ReplicatedStorage")\
836
958
  local ServerScriptService = game:GetService("ServerScriptService")\
837
959
  local RunService = game:GetService("RunService")\
838
960
  \
@@ -840,49 +962,18 @@ if not RunService:IsStudio() then\
840
962
  return\
841
963
  end\
842
964
  \
843
- local function evalCode(source)\
844
- if type(source) ~= "string" then\
845
- return false, "source must be a string"\
846
- end\
847
- local fn, compileErr = loadstring(source, "MCPServerEval")\
848
- if not fn then\
849
- local errStr = tostring(compileErr or "loadstring returned nil")\
850
- -- Roblox returns nil from loadstring when LoadStringEnabled=false.\
851
- -- Surface a clear, actionable error.\
852
- if string.find(errStr, "not enabled", 1, true)\
853
- or string.find(errStr, "disabled", 1, true)\
854
- or errStr == "loadstring returned nil"\
855
- then\
856
- return false,\
857
- "ServerScriptService.LoadStringEnabled is false. eval_server_runtime requires it. "\
858
- .. "Enable it in Studio (ServerScriptService > Properties > LoadStringEnabled = true) "\
859
- .. "and restart the playtest."\
860
- end\
861
- return false, errStr\
862
- end\
863
- return pcall(fn)\
864
- end\
865
- \
866
- -- Defensive cleanup of stale instances from a prior session.\
867
- local prevRf = ReplicatedStorage:FindFirstChild("{BRIDGE_NAMES.serverRemote}")\
868
- if prevRf then prevRf:Destroy() end\
869
965
  local prevBf = ServerScriptService:FindFirstChild("{BRIDGE_NAMES.serverLocal}")\
870
966
  if prevBf then prevBf:Destroy() end\
871
967
  \
872
- local rf = Instance.new("RemoteFunction")\
873
- rf.Name = "{BRIDGE_NAMES.serverRemote}"\
874
- rf.Archivable = false\
875
- rf.Parent = ReplicatedStorage\
876
- rf.OnServerInvoke = function(_player, source)\
877
- return evalCode(source)\
878
- end\
879
- \
880
968
  local bf = Instance.new("BindableFunction")\
881
969
  bf.Name = "{BRIDGE_NAMES.serverLocal}"\
882
970
  bf.Archivable = false\
883
971
  bf.Parent = ServerScriptService\
884
- bf.OnInvoke = function(source)\
885
- return evalCode(source)\
972
+ bf.OnInvoke = function(payload)\
973
+ if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then\
974
+ return false, "payload must be a ModuleScript instance"\
975
+ end\
976
+ return pcall(require, payload)\
886
977
  end\
887
978
  `
888
979
  local CLIENT_BRIDGE_SOURCE = `\
@@ -979,20 +1070,9 @@ local function installBridges()
979
1070
  installed = true,
980
1071
  }
981
1072
  end
982
- -- Heuristic check so start_playtest can surface a warning when
983
- -- LoadStringEnabled is false (eval_server_runtime won't work in that mode).
984
- -- We can't import the runtime LoadStringEnabled value cleanly without
985
- -- pulling in the type — read defensively.
986
- local function loadStringEnabled()
987
- local ok, value = pcall(function()
988
- return ServerScriptService.LoadStringEnabled
989
- end)
990
- return ok and value == true
991
- end
992
1073
  return {
993
1074
  cleanupBridges = cleanupBridges,
994
1075
  installBridges = installBridges,
995
- loadStringEnabled = loadStringEnabled,
996
1076
  BRIDGE_NAMES = BRIDGE_NAMES,
997
1077
  }
998
1078
  ]]></string>
@@ -2681,6 +2761,33 @@ return {
2681
2761
  </Properties>
2682
2762
  </Item>
2683
2763
  <Item class="ModuleScript" referent="11">
2764
+ <Properties>
2765
+ <string name="Name">LogHandlers</string>
2766
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2767
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2768
+ local RuntimeLogBuffer = TS.import(script, script.Parent.Parent, "RuntimeLogBuffer")
2769
+ local function getRuntimeLogs(requestData)
2770
+ local since = requestData.since
2771
+ local tail = requestData.tail
2772
+ local filter = requestData.filter
2773
+ -- Plugin-side peer tag is generic ("edit"|"server"|"client"). The MCP-side
2774
+ -- aggregator overrides it with the specific instance role (e.g. "client-1")
2775
+ -- during fan-out for target=all, so this value is only authoritative for
2776
+ -- the single-peer query path.
2777
+ local peer = RuntimeLogBuffer.detectPeer()
2778
+ return RuntimeLogBuffer.query({
2779
+ since = since,
2780
+ tail = tail,
2781
+ filter = filter,
2782
+ }, peer)
2783
+ end
2784
+ return {
2785
+ getRuntimeLogs = getRuntimeLogs,
2786
+ }
2787
+ ]]></string>
2788
+ </Properties>
2789
+ </Item>
2790
+ <Item class="ModuleScript" referent="12">
2684
2791
  <Properties>
2685
2792
  <string name="Name">MetadataHandlers</string>
2686
2793
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3152,8 +3259,19 @@ local function executeLuau(requestData)
3152
3259
  local runViaModuleScript = function()
3153
3260
  local m = Instance.new("ModuleScript")
3154
3261
  m.Name = "__MCPExecLuauPayload"
3262
+ -- Wrap user code in an IIFE so require() always gets exactly one
3263
+ -- return value. Without this, code like `print("x")` errors with
3264
+ -- "Module code did not return exactly one value" because top-level
3265
+ -- ModuleScripts must return exactly one value.
3266
+ --
3267
+ -- The DOUBLE parens around the call are load-bearing: in Luau,
3268
+ -- `return f()` propagates whatever multi-value tuple f returns,
3269
+ -- including zero values. Outer parens adjust the call to exactly
3270
+ -- one value (the first, or nil). So `return ((f)())` always
3271
+ -- returns exactly one value, regardless of what f does.
3272
+ local wrapped = `return ((function()\n{code}\nend)())`
3155
3273
  local okSet, setErr = pcall(function()
3156
- m.Source = code
3274
+ m.Source = wrapped
3157
3275
  end)
3158
3276
  if not okSet then
3159
3277
  m:Destroy()
@@ -3306,7 +3424,7 @@ return {
3306
3424
  ]]></string>
3307
3425
  </Properties>
3308
3426
  </Item>
3309
- <Item class="ModuleScript" referent="12">
3427
+ <Item class="ModuleScript" referent="13">
3310
3428
  <Properties>
3311
3429
  <string name="Name">PropertyHandlers</string>
3312
3430
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3558,7 +3676,7 @@ return {
3558
3676
  ]]></string>
3559
3677
  </Properties>
3560
3678
  </Item>
3561
- <Item class="ModuleScript" referent="13">
3679
+ <Item class="ModuleScript" referent="14">
3562
3680
  <Properties>
3563
3681
  <string name="Name">QueryHandlers</string>
3564
3682
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3666,8 +3784,23 @@ local function searchFiles(requestData)
3666
3784
  }
3667
3785
  end
3668
3786
  local function getPlaceInfo(_requestData)
3787
+ local dataModelName = game.Name
3788
+ local placeName = dataModelName
3789
+ if game.PlaceId > 0 then
3790
+ local MarketplaceService = game:GetService("MarketplaceService")
3791
+ local ok, info = pcall(function()
3792
+ return MarketplaceService:GetProductInfo(game.PlaceId)
3793
+ end)
3794
+ if ok and info ~= nil then
3795
+ local name = info.Name
3796
+ if type(name) == "string" and name ~= "" then
3797
+ placeName = name
3798
+ end
3799
+ end
3800
+ end
3669
3801
  return {
3670
- placeName = game.Name,
3802
+ placeName = placeName,
3803
+ dataModelName = dataModelName,
3671
3804
  placeId = game.PlaceId,
3672
3805
  gameId = game.GameId,
3673
3806
  jobId = game.JobId,
@@ -4585,7 +4718,7 @@ return {
4585
4718
  ]]></string>
4586
4719
  </Properties>
4587
4720
  </Item>
4588
- <Item class="ModuleScript" referent="14">
4721
+ <Item class="ModuleScript" referent="15">
4589
4722
  <Properties>
4590
4723
  <string name="Name">ScriptHandlers</string>
4591
4724
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5281,7 +5414,7 @@ return {
5281
5414
  ]]></string>
5282
5415
  </Properties>
5283
5416
  </Item>
5284
- <Item class="ModuleScript" referent="15">
5417
+ <Item class="ModuleScript" referent="16">
5285
5418
  <Properties>
5286
5419
  <string name="Name">TestHandlers</string>
5287
5420
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5292,7 +5425,6 @@ local LogService = _services.LogService
5292
5425
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5293
5426
  local installBridges = _EvalBridges.installBridges
5294
5427
  local cleanupBridges = _EvalBridges.cleanupBridges
5295
- local loadStringEnabled = _EvalBridges.loadStringEnabled
5296
5428
  local StudioTestService = game:GetService("StudioTestService")
5297
5429
  local ServerScriptService = game:GetService("ServerScriptService")
5298
5430
  local ScriptEditorService = game:GetService("ScriptEditorService")
@@ -5453,7 +5585,6 @@ local function startPlaytest(requestData)
5453
5585
  -- so eval_server_runtime / eval_client_runtime work without manual setup.
5454
5586
  -- Bridges are cleaned up from the edit DM after the play DMs tear down.
5455
5587
  local bridgeInstall = installBridges()
5456
- local hasLoadString = loadStringEnabled()
5457
5588
  if not bridgeInstall.installed then
5458
5589
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
5459
5590
  end
@@ -5487,13 +5618,6 @@ local function startPlaytest(requestData)
5487
5618
  message = msg,
5488
5619
  evalBridges = if bridgeInstall.installed then "installed" else `failed: {bridgeInstall.error}`,
5489
5620
  }
5490
- -- Surface loadstring availability up-front so callers know whether
5491
- -- eval_server_runtime will work before they try it. eval_client_runtime
5492
- -- doesn't need loadstring (it uses ModuleScript+require), so this only
5493
- -- affects the server bridge.
5494
- if not hasLoadString then
5495
- response.serverEvalNote = "ServerScriptService.LoadStringEnabled is false. eval_server_runtime will not work " .. "until you enable it (ServerScriptService > Properties > LoadStringEnabled = true) " .. "and restart the playtest. eval_client_runtime is unaffected."
5496
- end
5497
5621
  return response
5498
5622
  end
5499
5623
  local function stopPlaytest(_requestData)
@@ -5600,7 +5724,7 @@ return {
5600
5724
  </Properties>
5601
5725
  </Item>
5602
5726
  </Item>
5603
- <Item class="ModuleScript" referent="16">
5727
+ <Item class="ModuleScript" referent="17">
5604
5728
  <Properties>
5605
5729
  <string name="Name">Recording</string>
5606
5730
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5630,11 +5754,192 @@ return {
5630
5754
  ]]></string>
5631
5755
  </Properties>
5632
5756
  </Item>
5633
- <Item class="ModuleScript" referent="17">
5757
+ <Item class="ModuleScript" referent="18">
5758
+ <Properties>
5759
+ <string name="Name">RuntimeLogBuffer</string>
5760
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5761
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
5762
+ -- Per-peer in-memory ring buffer for LogService.MessageOut events.
5763
+ -- Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
5764
+ -- primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
5765
+ --
5766
+ -- Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
5767
+ -- play-server DM, play-client DM all run their own copy of this module).
5768
+ -- Captured entries live in plugin module-state; nothing is parented to the
5769
+ -- DataModel. The buffer is bounded by a message-byte budget; oldest entries
5770
+ -- drop when over budget.
5771
+ --
5772
+ -- Peer-tag caveat: returned entries reflect which peer's plugin CAPTURED the
5773
+ -- entry, NOT which peer's script originated the print. LogService reflects
5774
+ -- prints across peers in Studio Play (a server print ends up in both the
5775
+ -- server and client LogService:GetLogHistory()) and origin is empirically
5776
+ -- undetectable from inside MessageOut. The MCP-side aggregator handles
5777
+ -- cross-peer dedup via a 2s timestamp window.
5778
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
5779
+ local LogService = _services.LogService
5780
+ local RunService = _services.RunService
5781
+ local MAX_BYTES = 64 * 1024
5782
+ local HARD_ENTRY_CAP = 50_000
5783
+ local entries = {}
5784
+ local totalBytes = 0
5785
+ local totalDropped = 0
5786
+ local nextSeq = 1
5787
+ local installed = false
5788
+ local function levelTag(t)
5789
+ if t == Enum.MessageType.MessageWarning then
5790
+ return "WARN"
5791
+ end
5792
+ if t == Enum.MessageType.MessageError then
5793
+ return "ERR"
5794
+ end
5795
+ if t == Enum.MessageType.MessageInfo then
5796
+ return "INFO"
5797
+ end
5798
+ return "OUT"
5799
+ end
5800
+ local function nowSec()
5801
+ return DateTime.now().UnixTimestampMillis / 1000
5802
+ end
5803
+ local function dropOldestUntilFits(incomingBytes)
5804
+ while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
5805
+ local dropped = table.remove(entries, 1)
5806
+ totalBytes -= #dropped.message
5807
+ totalDropped += 1
5808
+ end
5809
+ end
5810
+ local function install()
5811
+ if installed then
5812
+ return nil
5813
+ end
5814
+ if not RunService:IsStudio() then
5815
+ return nil
5816
+ end
5817
+ installed = true
5818
+ LogService.MessageOut:Connect(function(msg, t)
5819
+ local bytes = #msg
5820
+ dropOldestUntilFits(bytes)
5821
+ local _arg0 = {
5822
+ seq = nextSeq,
5823
+ ts = nowSec(),
5824
+ level = levelTag(t),
5825
+ message = msg,
5826
+ }
5827
+ table.insert(entries, _arg0)
5828
+ nextSeq += 1
5829
+ totalBytes += bytes
5830
+ end)
5831
+ end
5832
+ local function detectPeer()
5833
+ if not RunService:IsRunning() then
5834
+ return "edit"
5835
+ end
5836
+ if RunService:IsServer() then
5837
+ return "server"
5838
+ end
5839
+ return "client"
5840
+ end
5841
+ local function query(opts, peer)
5842
+ local _result
5843
+ if opts.since ~= nil then
5844
+ -- ▼ ReadonlyArray.filter ▼
5845
+ local _newValue = {}
5846
+ local _callback = function(e)
5847
+ return e.seq > (opts.since)
5848
+ end
5849
+ local _length = 0
5850
+ for _k, _v in entries do
5851
+ if _callback(_v, _k - 1, entries) == true then
5852
+ _length += 1
5853
+ _newValue[_length] = _v
5854
+ end
5855
+ end
5856
+ -- ▲ ReadonlyArray.filter ▲
5857
+ _result = _newValue
5858
+ else
5859
+ local _array = {}
5860
+ local _length = #_array
5861
+ table.move(entries, 1, #entries, _length + 1, _array)
5862
+ _result = _array
5863
+ end
5864
+ local result = _result
5865
+ if opts.filter ~= nil then
5866
+ -- Plain substring search (4th arg = true). Pattern matching here was
5867
+ -- surprising in practice - Lua magic chars in messages would silently
5868
+ -- not match (e.g. filter="MARK-EDIT" against "MARK-EDIT-001" fails
5869
+ -- because '-' means "0+" in Lua patterns). Substring search matches
5870
+ -- most users' mental model of "filter messages containing this text".
5871
+ local needle = opts.filter
5872
+ -- ▼ ReadonlyArray.filter ▼
5873
+ local _newValue = {}
5874
+ local _callback = function(e)
5875
+ local start = string.find(e.message, needle, 1, true)
5876
+ return start ~= nil
5877
+ end
5878
+ local _length = 0
5879
+ for _k, _v in result do
5880
+ if _callback(_v, _k - 1, result) == true then
5881
+ _length += 1
5882
+ _newValue[_length] = _v
5883
+ end
5884
+ end
5885
+ -- ▲ ReadonlyArray.filter ▲
5886
+ result = _newValue
5887
+ end
5888
+ if opts.tail ~= nil and #result > opts.tail then
5889
+ -- roblox-ts arrays don't expose .slice; manual tail copy.
5890
+ local tailed = {}
5891
+ local start = #result - opts.tail
5892
+ do
5893
+ local i = start
5894
+ local _shouldIncrement = false
5895
+ while true do
5896
+ if _shouldIncrement then
5897
+ i += 1
5898
+ else
5899
+ _shouldIncrement = true
5900
+ end
5901
+ if not (i < #result) then
5902
+ break
5903
+ end
5904
+ local _arg0 = result[i + 1]
5905
+ table.insert(tailed, _arg0)
5906
+ end
5907
+ end
5908
+ result = tailed
5909
+ end
5910
+ local last = if #entries > 0 then entries[#entries] else nil
5911
+ local _object = {
5912
+ peer = peer,
5913
+ entries = result,
5914
+ totalDropped = totalDropped,
5915
+ }
5916
+ local _left = "nextSince"
5917
+ local _result_1
5918
+ if last then
5919
+ _result_1 = last.seq
5920
+ else
5921
+ local _condition = opts.since
5922
+ if _condition == nil then
5923
+ _condition = 0
5924
+ end
5925
+ _result_1 = _condition
5926
+ end
5927
+ _object[_left] = _result_1
5928
+ return _object
5929
+ end
5930
+ return {
5931
+ install = install,
5932
+ detectPeer = detectPeer,
5933
+ query = query,
5934
+ }
5935
+ ]]></string>
5936
+ </Properties>
5937
+ </Item>
5938
+ <Item class="ModuleScript" referent="19">
5634
5939
  <Properties>
5635
5940
  <string name="Name">State</string>
5636
5941
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5637
- local CURRENT_VERSION = "2.9.1"
5942
+ local CURRENT_VERSION = "2.10.1"
5638
5943
  local MAX_CONNECTIONS = 5
5639
5944
  local BASE_PORT = 58741
5640
5945
  local activeTabIndex = 0
@@ -5726,7 +6031,7 @@ return {
5726
6031
  ]]></string>
5727
6032
  </Properties>
5728
6033
  </Item>
5729
- <Item class="ModuleScript" referent="18">
6034
+ <Item class="ModuleScript" referent="20">
5730
6035
  <Properties>
5731
6036
  <string name="Name">UI</string>
5732
6037
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5736,6 +6041,34 @@ local State = TS.import(script, script.Parent, "State")
5736
6041
  local elements = nil
5737
6042
  local pulseAnimation
5738
6043
  local buttonHover = false
6044
+ local toolbarButton
6045
+ local toolbarIcons
6046
+ local lastToolbarIcon
6047
+ local updateToolbarIcon
6048
+ local function setToolbarButton(btn, icons)
6049
+ toolbarButton = btn
6050
+ toolbarIcons = icons
6051
+ lastToolbarIcon = nil
6052
+ updateToolbarIcon()
6053
+ end
6054
+ function updateToolbarIcon()
6055
+ if not toolbarButton or not toolbarIcons then
6056
+ return nil
6057
+ end
6058
+ local conn = State.getActiveConnection()
6059
+ local nextIcon
6060
+ if not conn or not conn.isActive then
6061
+ nextIcon = toolbarIcons.disconnected
6062
+ elseif conn.lastHttpOk and conn.lastMcpOk then
6063
+ nextIcon = toolbarIcons.connected
6064
+ else
6065
+ nextIcon = toolbarIcons.connecting
6066
+ end
6067
+ if nextIcon ~= lastToolbarIcon then
6068
+ toolbarButton.Icon = nextIcon
6069
+ lastToolbarIcon = nextIcon
6070
+ end
6071
+ end
5739
6072
  local tabButtons = {}
5740
6073
  local TWEEN_QUICK = TweenInfo.new(0.15, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
5741
6074
  local function tweenProp(instance, props)
@@ -6314,6 +6647,7 @@ local function init(pluginRef)
6314
6647
  refreshTabBar()
6315
6648
  end
6316
6649
  function updateUIState()
6650
+ updateToolbarIcon()
6317
6651
  local conn = State.getActiveConnection()
6318
6652
  if not conn then
6319
6653
  return nil
@@ -6439,6 +6773,8 @@ return {
6439
6773
  updateTabLabel = updateTabLabel,
6440
6774
  stopPulseAnimation = stopPulseAnimation,
6441
6775
  startPulseAnimation = startPulseAnimation,
6776
+ setToolbarButton = setToolbarButton,
6777
+ updateToolbarIcon = updateToolbarIcon,
6442
6778
  getElements = function()
6443
6779
  return elements
6444
6780
  end,
@@ -6446,7 +6782,7 @@ return {
6446
6782
  ]]></string>
6447
6783
  </Properties>
6448
6784
  </Item>
6449
- <Item class="ModuleScript" referent="19">
6785
+ <Item class="ModuleScript" referent="21">
6450
6786
  <Properties>
6451
6787
  <string name="Name">Utils</string>
6452
6788
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6976,11 +7312,11 @@ return {
6976
7312
  </Properties>
6977
7313
  </Item>
6978
7314
  </Item>
6979
- <Item class="Folder" referent="23">
7315
+ <Item class="Folder" referent="25">
6980
7316
  <Properties>
6981
7317
  <string name="Name">include</string>
6982
7318
  </Properties>
6983
- <Item class="ModuleScript" referent="20">
7319
+ <Item class="ModuleScript" referent="22">
6984
7320
  <Properties>
6985
7321
  <string name="Name">Promise</string>
6986
7322
  <string name="Source"><![CDATA[--[[
@@ -9054,7 +9390,7 @@ return Promise
9054
9390
  ]]></string>
9055
9391
  </Properties>
9056
9392
  </Item>
9057
- <Item class="ModuleScript" referent="21">
9393
+ <Item class="ModuleScript" referent="23">
9058
9394
  <Properties>
9059
9395
  <string name="Name">RuntimeLib</string>
9060
9396
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -9321,15 +9657,15 @@ return TS
9321
9657
  </Properties>
9322
9658
  </Item>
9323
9659
  </Item>
9324
- <Item class="Folder" referent="24">
9660
+ <Item class="Folder" referent="26">
9325
9661
  <Properties>
9326
9662
  <string name="Name">node_modules</string>
9327
9663
  </Properties>
9328
- <Item class="Folder" referent="25">
9664
+ <Item class="Folder" referent="27">
9329
9665
  <Properties>
9330
9666
  <string name="Name">@rbxts</string>
9331
9667
  </Properties>
9332
- <Item class="ModuleScript" referent="22">
9668
+ <Item class="ModuleScript" referent="24">
9333
9669
  <Properties>
9334
9670
  <string name="Name">services</string>
9335
9671
  <string name="Source"><![CDATA[return setmetatable({}, {