@chrrxs/robloxstudio-mcp 2.11.4 → 2.12.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.
@@ -94,8 +94,50 @@ local HttpService = _services.HttpService
94
94
  local Players = _services.Players
95
95
  local ReplicatedStorage = _services.ReplicatedStorage
96
96
  local RunService = _services.RunService
97
+ local ServerStorage = _services.ServerStorage
97
98
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
98
99
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
100
+ local LuauExec = TS.import(script, script.Parent, "LuauExec")
101
+ -- Mirror of Communication.computeInstanceId() — duplicated here because the
102
+ -- client broker runs in the play-server DM where it can't easily import from
103
+ -- the edit-side module, and the place identifier must match what the edit-DM
104
+ -- plugin reports. Both use the same algorithm against the shared DataModel.
105
+ local function computeInstanceId()
106
+ if game.PlaceId ~= 0 then
107
+ return `place:{tostring(game.PlaceId)}`
108
+ end
109
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
110
+ if type(existing) == "string" and existing ~= "" then
111
+ return `anon:{existing}`
112
+ end
113
+ local fresh = HttpService:GenerateGUID(false)
114
+ pcall(function()
115
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
116
+ end)
117
+ return `anon:{fresh}`
118
+ end
119
+ local cachedPlaceName
120
+ local function resolvePlaceName()
121
+ if cachedPlaceName ~= nil then
122
+ return cachedPlaceName
123
+ end
124
+ if game.PlaceId == 0 then
125
+ cachedPlaceName = game.Name
126
+ return cachedPlaceName
127
+ end
128
+ local MarketplaceService = game:GetService("MarketplaceService")
129
+ local ok, info = pcall(function()
130
+ return MarketplaceService:GetProductInfo(game.PlaceId)
131
+ end)
132
+ if ok and info ~= nil then
133
+ local name = info.Name
134
+ if type(name) == "string" and name ~= "" then
135
+ cachedPlaceName = name
136
+ return cachedPlaceName
137
+ end
138
+ end
139
+ return game.Name
140
+ end
99
141
  -- The client peer cannot reach the MCP HTTP server - Roblox forbids
100
142
  -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
101
143
  -- HttpEnabled reads as false there regardless of identity. So the server peer
@@ -136,8 +178,13 @@ local function reRegisterProxy(proxyId, role)
136
178
  lastReadyByProxy[_proxyId_1] = now
137
179
  pcall(function()
138
180
  return postJson("/ready", {
139
- instanceId = proxyId,
181
+ pluginSessionId = proxyId,
182
+ instanceId = computeInstanceId(),
140
183
  role = role,
184
+ placeId = game.PlaceId,
185
+ placeName = resolvePlaceName(),
186
+ dataModelName = game.Name,
187
+ isRunning = RunService:IsRunning(),
141
188
  })
142
189
  end)
143
190
  end
@@ -170,34 +217,11 @@ local function handleExecuteLuau(data)
170
217
  error = "code is required",
171
218
  }
172
219
  end
173
- local m = Instance.new("ModuleScript")
174
- m.Name = "__MCPClientEval"
175
- local okSet, setErr = pcall(function()
176
- m.Source = code
177
- end)
178
- if not okSet then
179
- m:Destroy()
180
- return {
181
- success = false,
182
- error = `Source set failed: {tostring(setErr)}`,
183
- }
184
- end
185
- m.Parent = game.Workspace
186
- local okReq, result = pcall(function()
187
- return require(m)
188
- end)
189
- m:Destroy()
190
- if okReq then
191
- return {
192
- success = true,
193
- returnValue = if result ~= nil then tostring(result) else nil,
194
- message = "Code executed successfully",
195
- }
196
- end
197
- return {
198
- success = false,
199
- error = tostring(result),
200
- }
220
+ -- Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
221
+ -- wrapper (so `print("hi")` with no return doesn't fail the
222
+ -- ModuleScript's "must return one value" rule) and JSON-encodes table
223
+ -- returns instead of yielding "table: 0xaddr".
224
+ return LuauExec.execute(code)
201
225
  end
202
226
  local function handleGetRuntimeLogs(data)
203
227
  local d = data or {}
@@ -251,7 +275,7 @@ local function pollProxy(proxyId, player, rf)
251
275
  end
252
276
  local ok, res = pcall(function()
253
277
  return HttpService:RequestAsync({
254
- Url = `{MCP_URL}/poll?instanceId={proxyId}`,
278
+ Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
255
279
  Method = "GET",
256
280
  Headers = {
257
281
  ["Content-Type"] = "application/json",
@@ -315,8 +339,13 @@ local function registerProxy(player, rf)
315
339
  end
316
340
  local proxyId = HttpService:GenerateGUID(false)
317
341
  local ok, res = postJson("/ready", {
318
- instanceId = proxyId,
342
+ pluginSessionId = proxyId,
343
+ instanceId = computeInstanceId(),
319
344
  role = "client",
345
+ placeId = game.PlaceId,
346
+ placeName = resolvePlaceName(),
347
+ dataModelName = game.Name,
348
+ isRunning = RunService:IsRunning(),
320
349
  })
321
350
  if not ok or not res or not res.Success then
322
351
  warn(`[MCPFork] proxy register failed for {player.Name}`)
@@ -330,7 +359,7 @@ local function registerProxy(player, rf)
330
359
  local assigned = _condition
331
360
  local _player_1 = player
332
361
  local _arg1 = {
333
- instanceId = proxyId,
362
+ pluginSessionId = proxyId,
334
363
  role = assigned,
335
364
  }
336
365
  proxyByPlayer[_player_1] = _arg1
@@ -361,14 +390,14 @@ local function setupServerBroker()
361
390
  local _p_1 = p
362
391
  proxyByPlayer[_p_1] = nil
363
392
  postJson("/disconnect", {
364
- instanceId = entry.instanceId,
393
+ pluginSessionId = entry.pluginSessionId,
365
394
  })
366
395
  end
367
396
  end)
368
397
  game:BindToClose(function()
369
398
  for _, entry in proxyByPlayer do
370
399
  postJson("/disconnect", {
371
- instanceId = entry.instanceId,
400
+ pluginSessionId = entry.pluginSessionId,
372
401
  })
373
402
  end
374
403
  table.clear(proxyByPlayer)
@@ -391,6 +420,7 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
391
420
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
392
421
  local HttpService = _services.HttpService
393
422
  local RunService = _services.RunService
423
+ local ServerStorage = _services.ServerStorage
394
424
  local State = TS.import(script, script.Parent, "State")
395
425
  local Utils = TS.import(script, script.Parent, "Utils")
396
426
  local UI = TS.import(script, script.Parent, "UI")
@@ -407,8 +437,61 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
407
437
  local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
408
438
  local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
409
439
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
410
- local instanceId = HttpService:GenerateGUID(false)
440
+ -- Per-plugin-load random GUID. Used as the /poll URL param so the server
441
+ -- can tell our polls apart from any other plugin's polls. Not user-facing —
442
+ -- MCP tools and the LLM operate on instanceId (the place identifier).
443
+ local pluginSessionId = HttpService:GenerateGUID(false)
444
+ -- Place-level identifier shared by every plugin running in DataModels of
445
+ -- the same place file (edit DM + playtest server DM + playtest clients).
446
+ -- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
447
+ -- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
448
+ -- and travels with the .rbxl.
449
+ local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
450
+ local function computeInstanceId()
451
+ if game.PlaceId ~= 0 then
452
+ return `place:{tostring(game.PlaceId)}`
453
+ end
454
+ local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
455
+ if type(existing) == "string" and existing ~= "" then
456
+ return `anon:{existing}`
457
+ end
458
+ local fresh = HttpService:GenerateGUID(false)
459
+ pcall(function()
460
+ return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
461
+ end)
462
+ return `anon:{fresh}`
463
+ end
464
+ local instanceId = computeInstanceId()
411
465
  local assignedRole
466
+ local duplicateInstanceRole = false
467
+ -- Cache the published place name from MarketplaceService:GetProductInfo so
468
+ -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
469
+ -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
470
+ -- once per plugin load; the published name doesn't change mid-session.
471
+ local cachedPlaceName
472
+ local function resolvePlaceName()
473
+ if cachedPlaceName ~= nil then
474
+ return cachedPlaceName
475
+ end
476
+ if game.PlaceId == 0 then
477
+ cachedPlaceName = game.Name
478
+ return cachedPlaceName
479
+ end
480
+ local MarketplaceService = game:GetService("MarketplaceService")
481
+ local ok, info = pcall(function()
482
+ return MarketplaceService:GetProductInfo(game.PlaceId)
483
+ end)
484
+ if ok and info ~= nil then
485
+ local name = info.Name
486
+ if type(name) == "string" and name ~= "" then
487
+ cachedPlaceName = name
488
+ return cachedPlaceName
489
+ end
490
+ end
491
+ -- Don't cache failures — could be transient (offline, rate-limited).
492
+ -- Next /ready will retry. Return game.Name as fallback.
493
+ return game.Name
494
+ end
412
495
  local function detectRole()
413
496
  if not RunService:IsRunning() then
414
497
  return "edit"
@@ -524,7 +607,33 @@ end
524
607
  -- Without this, every poll during the brief window where the server has just
525
608
  -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
526
609
  local lastReadyPostAt = 0
527
- local function sendReady(conn)
610
+ -- game.Name is sometimes "Place1" at plugin-load time and only settles to
611
+ -- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
612
+ -- after Studio finishes wiring things up. Re-fire /ready when it changes so
613
+ -- get_connected_instances doesn't show a stale dataModelName forever. Set
614
+ -- up once per plugin load — the connection passed in is whichever was
615
+ -- active when activatePlugin was first called.
616
+ local nameChangeConn
617
+ local sendReady
618
+ local function ensureNameChangeWatcher(conn)
619
+ if nameChangeConn then
620
+ return nil
621
+ end
622
+ local okSig, signal = pcall(function()
623
+ return game:GetPropertyChangedSignal("Name")
624
+ end)
625
+ if not okSig or not signal then
626
+ return nil
627
+ end
628
+ nameChangeConn = signal:Connect(function()
629
+ -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
630
+ sendReady(conn)
631
+ end)
632
+ end
633
+ function sendReady(conn)
634
+ if duplicateInstanceRole then
635
+ return nil
636
+ end
528
637
  local now = tick()
529
638
  if now - lastReadyPostAt < 2 then
530
639
  return nil
@@ -539,14 +648,36 @@ local function sendReady(conn)
539
648
  ["Content-Type"] = "application/json",
540
649
  },
541
650
  Body = HttpService:JSONEncode({
651
+ pluginSessionId = pluginSessionId,
542
652
  instanceId = instanceId,
543
653
  role = detectRole(),
654
+ placeId = game.PlaceId,
655
+ placeName = resolvePlaceName(),
656
+ dataModelName = game.Name,
657
+ isRunning = RunService:IsRunning(),
544
658
  pluginReady = true,
545
659
  timestamp = tick(),
546
660
  }),
547
661
  })
548
662
  end)
549
- if readyOk and readyResult.Success then
663
+ if not readyOk then
664
+ return nil
665
+ end
666
+ -- 409 = duplicate_instance_role. Surface in UI and stop polling.
667
+ if readyResult.StatusCode == 409 then
668
+ duplicateInstanceRole = true
669
+ conn.isActive = false
670
+ local ui = UI.getElements()
671
+ if State.getActiveTabIndex() == 0 then
672
+ ui.statusLabel.Text = "Duplicate instance"
673
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
674
+ ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
675
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
676
+ end
677
+ warn(`[MCPPlugin] Another Studio is already connected as ({instanceId}, {detectRole()}). Close the other Studio window or this one.`)
678
+ return nil
679
+ end
680
+ if readyResult.Success then
550
681
  local parseOk, readyData = pcall(function()
551
682
  return HttpService:JSONDecode(readyResult.Body)
552
683
  end)
@@ -568,7 +699,7 @@ local function pollForRequests(connIndex)
568
699
  conn.isPolling = true
569
700
  local success, result = pcall(function()
570
701
  return HttpService:RequestAsync({
571
- Url = `{conn.serverUrl}/poll?instanceId={instanceId}`,
702
+ Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
572
703
  Method = "GET",
573
704
  Headers = {
574
705
  ["Content-Type"] = "application/json",
@@ -764,6 +895,9 @@ local function activatePlugin(connIndex)
764
895
  -- Initial /ready; pollForRequests will also re-fire ready if the server
765
896
  -- later reports knownInstance=false (process restart, etc).
766
897
  sendReady(conn)
898
+ -- Watch for game.Name updates so a stale "Place1" captured at first
899
+ -- /ready gets refreshed once Studio settles on the real DM name.
900
+ ensureNameChangeWatcher(conn)
767
901
  end
768
902
  local function deactivatePlugin(connIndex)
769
903
  local _condition = connIndex
@@ -789,7 +923,7 @@ local function deactivatePlugin(connIndex)
789
923
  ["Content-Type"] = "application/json",
790
924
  },
791
925
  Body = HttpService:JSONEncode({
792
- instanceId = instanceId,
926
+ pluginSessionId = pluginSessionId,
793
927
  timestamp = tick(),
794
928
  }),
795
929
  })
@@ -2827,11 +2961,10 @@ return {
2827
2961
  <string name="Name">MetadataHandlers</string>
2828
2962
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2829
2963
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2830
- local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
2831
- local CollectionService = _services.CollectionService
2832
- local LogService = _services.LogService
2964
+ local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
2833
2965
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2834
2966
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
2967
+ local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
2835
2968
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
2836
2969
  local Selection = game:GetService("Selection")
2837
2970
  local _binding = Utils
@@ -3257,137 +3390,11 @@ local function executeLuau(requestData)
3257
3390
  error = "Code is required",
3258
3391
  }
3259
3392
  end
3260
- -- Both execution paths (loadstring + ModuleScript-require fallback) run
3261
- -- the SAME wrapped source so they return a uniform { ok, value, output }
3262
- -- shape. Two problems the wrapper solves at once:
3263
- --
3264
- -- 1. pcall(require, m) swallows the real error and returns Roblox's
3265
- -- generic "Requested module experienced an error while loading"
3266
- -- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
3267
- -- ModuleScript itself returning successfully — the real error +
3268
- -- traceback live in the returned table.
3269
- --
3270
- -- 2. The ModuleScript path runs in its own environment, so a plugin-
3271
- -- side getfenv print/warn override never reached user prints. A
3272
- -- lexical local print/warn inside the IIFE captures user prints
3273
- -- regardless of which path executes. We also call the real global
3274
- -- print/warn so messages still flow to Studio's output and
3275
- -- LogService.MessageOut (which powers get_runtime_logs).
3276
- --
3277
- -- Prints from required sub-modules don't reach this capture (they have
3278
- -- their own env) — those go through the runtime log buffer.
3279
- local wrapped = `return ((function()\
3280
- \tlocal __mcp_output = \{\}\
3281
- \tlocal __mcp_real_print = print\
3282
- \tlocal __mcp_real_warn = warn\
3283
- \tlocal print = function(...)\
3284
- \t\t__mcp_real_print(...)\
3285
- \t\tlocal args = \{...\}\
3286
- \t\tlocal parts = table.create(#args)\
3287
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
3288
- \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
3289
- \tend\
3290
- \tlocal warn = function(...)\
3291
- \t\t__mcp_real_warn(...)\
3292
- \t\tlocal args = \{...\}\
3293
- \t\tlocal parts = table.create(#args)\
3294
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
3295
- \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
3296
- \tend\
3297
- \tlocal function __mcp_run()\
3298
- {code}\
3299
- \tend\
3300
- \tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
3301
- \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
3302
- end)())`
3303
- local runViaModuleScript = function()
3304
- local m = Instance.new("ModuleScript")
3305
- m.Name = "__MCPExecLuauPayload"
3306
- local okSet, setErr = pcall(function()
3307
- m.Source = wrapped
3308
- end)
3309
- if not okSet then
3310
- m:Destroy()
3311
- error(`ModuleScript Source set failed: {tostring(setErr)}`)
3312
- end
3313
- m.Parent = game:GetService("Workspace")
3314
- local okReq, reqResult = pcall(function()
3315
- return require(m)
3316
- end)
3317
- m:Destroy()
3318
- if not okReq then
3319
- local errMsg = tostring(reqResult)
3320
- -- pcall(require, m) collapses parse/compile failures into the
3321
- -- canned engine string below. Walk LogService backward for the
3322
- -- real diagnostic, which was emitted to MessageOut just before.
3323
- if errMsg == "Requested module experienced an error while loading" then
3324
- -- The parser diagnostic is emitted to LogService on the next
3325
- -- engine frame, not synchronously with pcall(require). task.wait(0)
3326
- -- yields too early; 50ms is enough to let the frame complete and
3327
- -- the message land in GetLogHistory.
3328
- task.wait(0.05)
3329
- local hist = LogService:GetLogHistory()
3330
- for i = #hist - 1, 0, -1 do
3331
- local e = hist[i + 1]
3332
- if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, 31) == "Workspace.__MCPExecLuauPayload:" then
3333
- errMsg = e.message
3334
- break
3335
- end
3336
- end
3337
- end
3338
- error(errMsg)
3339
- end
3340
- return reqResult
3341
- end
3342
- local isLoadstringUnavailable = function(err)
3343
- local errStr = tostring(err)
3344
- local matchStart = string.find(errStr, "not available", 1, true)
3345
- return matchStart ~= nil
3346
- end
3347
- local success, result = pcall(function()
3348
- local fn, compileError = loadstring(wrapped)
3349
- if not fn then
3350
- if isLoadstringUnavailable(compileError) then
3351
- return runViaModuleScript()
3352
- end
3353
- error(`Compile error: {compileError}`)
3354
- end
3355
- return fn()
3356
- end)
3357
- -- loadstring throws (not returns nil) in some plugin contexts when
3358
- -- LoadStringEnabled=false. Catch that as a second-chance fallback.
3359
- if not success and isLoadstringUnavailable(result) then
3360
- success, result = pcall(runViaModuleScript)
3361
- end
3362
- if not success then
3363
- -- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
3364
- -- error in the user code, or ModuleScript setup error). 'result' is the
3365
- -- raw error string from pcall.
3366
- return {
3367
- success = false,
3368
- error = tostring(result),
3369
- output = {},
3370
- message = "Code execution failed",
3371
- }
3372
- end
3373
- -- Wrapper executed - unpack { ok, value, output }.
3374
- local r = result
3375
- local capturedOutput = r.output
3376
- local output = if capturedOutput ~= nil then capturedOutput else ({})
3377
- if r.ok == true then
3378
- return {
3379
- success = true,
3380
- returnValue = if r.value ~= nil then tostring(r.value) else nil,
3381
- output = output,
3382
- message = "Code executed successfully",
3383
- }
3384
- end
3385
- return {
3386
- success = false,
3387
- error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
3388
- output = output,
3389
- message = "Code execution failed",
3390
- }
3393
+ -- All wrapping, print/warn capture, loadstring fallback, JSON-encoding
3394
+ -- of table returns, and parse-error recovery live in LuauExec so the
3395
+ -- edit/server (this handler) and the play-client (ClientBroker) take
3396
+ -- the same code path and produce identical output shapes.
3397
+ return LuauExec.execute(code)
3391
3398
  end
3392
3399
  local function undo(_requestData)
3393
3400
  local success, result = pcall(function()
@@ -5900,9 +5907,25 @@ local function stopPlaytest(_requestData)
5900
5907
  }
5901
5908
  end
5902
5909
  if not StopPlayMonitor.waitForConsumption() then
5903
- -- Clean up the pending flag so a future playtest's monitor doesn't fire
5904
- -- EndTest on its own startup against a stale signal.
5910
+ -- Two distinct failure modes collapse here, distinguished by whether
5911
+ -- THIS edit DM has a playtest tracked:
5912
+ --
5913
+ -- - testRunning=false: no playtest was running from this edit DM
5914
+ -- (true negative). Return "no active playtest" — fine to retry only
5915
+ -- after actually starting a playtest.
5916
+ -- - testRunning=true: a playtest IS running but the cross-DM signal
5917
+ -- didn't propagate within the consumption timeout (false negative
5918
+ -- from the caller's perspective — playtest may actually have ended).
5919
+ -- Tell the caller it's a timing issue and they can retry.
5920
+ --
5921
+ -- Either way clean up the pending flag so a future playtest's monitor
5922
+ -- doesn't fire EndTest on startup against a stale signal.
5905
5923
  StopPlayMonitor.clearPending()
5924
+ if testRunning then
5925
+ return {
5926
+ error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
5927
+ }
5928
+ end
5906
5929
  return {
5907
5930
  error = "No active playtest to stop.",
5908
5931
  }
@@ -6022,6 +6045,324 @@ return {
6022
6045
  </Item>
6023
6046
  </Item>
6024
6047
  <Item class="ModuleScript" referent="19">
6048
+ <Properties>
6049
+ <string name="Name">LuauExec</string>
6050
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6051
+ -- eslint-disable
6052
+ -- Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
6053
+ -- and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
6054
+ -- module owns:
6055
+ --
6056
+ -- 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
6057
+ -- and always returns { ok, value, output } so the ModuleScript itself
6058
+ -- always returns exactly one value (otherwise `print("hi")` with no
6059
+ -- return would fail with "Module code did not return exactly one value").
6060
+ --
6061
+ -- 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
6062
+ -- recovery hack that pulls the real diagnostic from LogService.
6063
+ --
6064
+ -- 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
6065
+ -- caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
6066
+ -- pass through tostring. The encode is pcall'd so cycles or
6067
+ -- non-serializable values gracefully fall back to tostring.
6068
+ --
6069
+ -- Before this module existed, the client peer used a stripped-down
6070
+ -- require-only execution path that lacked both the wrapper and the JSON
6071
+ -- formatting, producing two well-known papercuts:
6072
+ -- - `print("hi")` (no return) failed with "Module code did not return..."
6073
+ -- - Returning a table yielded `table: 0xaddr` instead of structured data.
6074
+ local HttpService = game:GetService("HttpService")
6075
+ local LogService = game:GetService("LogService")
6076
+ local PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload"
6077
+ local PAYLOAD_PATH_PREFIX = `Workspace.{PAYLOAD_INSTANCE_NAME}:`
6078
+ -- Number of lines the wrapper emits BEFORE the first line of user code.
6079
+ -- Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
6080
+ -- (remapPayloadLines, for compile errors recovered from LogService) so user
6081
+ -- code errors report user-relative line numbers instead of the inflated
6082
+ -- "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
6083
+ -- prefix lines, update this constant — there's a self-check below.
6084
+ local WRAPPER_LINE_OFFSET = 23
6085
+ -- Count source lines so the wrapper can filter traceback frames that fall
6086
+ -- outside the user code range (the wrapper's own preamble/postamble lines).
6087
+ local function countLines(s)
6088
+ local n = 1
6089
+ local size = #s
6090
+ do
6091
+ local i = 1
6092
+ local _shouldIncrement = false
6093
+ while true do
6094
+ if _shouldIncrement then
6095
+ i += 1
6096
+ else
6097
+ _shouldIncrement = true
6098
+ end
6099
+ if not (i <= size) then
6100
+ break
6101
+ end
6102
+ if string.sub(s, i, i) == "\n" then
6103
+ n += 1
6104
+ end
6105
+ end
6106
+ end
6107
+ return n
6108
+ end
6109
+ local function buildWrapper(code)
6110
+ -- If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
6111
+ -- match the number of lines emitted BEFORE the ${code} substitution.
6112
+ -- The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
6113
+ -- used by remapPayloadLines on the TS side.
6114
+ local userLines = countLines(code)
6115
+ return `return ((function()\
6116
+ \tlocal __mcp_traceback\
6117
+ \tlocal __mcp_remap\
6118
+ \tlocal __mcp_LINE_OFFSET = {WRAPPER_LINE_OFFSET}\
6119
+ \tlocal __mcp_USER_LINES = {userLines}\
6120
+ \tlocal __mcp_output = \{\}\
6121
+ \tlocal __mcp_real_print = print\
6122
+ \tlocal __mcp_real_warn = warn\
6123
+ \tlocal print = function(...)\
6124
+ \t\t__mcp_real_print(...)\
6125
+ \t\tlocal args = \{...\}\
6126
+ \t\tlocal parts = table.create(#args)\
6127
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6128
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
6129
+ \tend\
6130
+ \tlocal warn = function(...)\
6131
+ \t\t__mcp_real_warn(...)\
6132
+ \t\tlocal args = \{...\}\
6133
+ \t\tlocal parts = table.create(#args)\
6134
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6135
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
6136
+ \tend\
6137
+ \tlocal function __mcp_run()\
6138
+ {code}\
6139
+ \tend\
6140
+ \t__mcp_remap = function(s)\
6141
+ \t\t-- Two chunk-name formats can reference our payload:\
6142
+ \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path\
6143
+ \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)\
6144
+ \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.\
6145
+ \t\t-- Clamping matters for unclosed constructs ("local x = (") where the\
6146
+ \t\t-- parser keeps reading into wrapper postamble and reports a payload\
6147
+ \t\t-- line past user EOF. Without clamping the message says "user_code:49"\
6148
+ \t\t-- for one-line input, framing the wrapper as user code.\
6149
+ \t\tlocal function __mcp_user_line(payload_n)\
6150
+ \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET\
6151
+ \t\t\tif user_n < 1 then return "1" end\
6152
+ \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end\
6153
+ \t\t\treturn tostring(user_n)\
6154
+ \t\tend\
6155
+ \t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)\
6156
+ \t\t\tlocal n = tonumber(num)\
6157
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6158
+ \t\t\treturn "user_code:" .. num\
6159
+ \t\tend)\
6160
+ \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)\
6161
+ \t\t\tlocal n = tonumber(num)\
6162
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6163
+ \t\t\treturn "user_code:" .. num\
6164
+ \t\tend)\
6165
+ \t\treturn s\
6166
+ \tend\
6167
+ \t__mcp_traceback = function(err)\
6168
+ \t\tlocal raw = debug.traceback(tostring(err), 2)\
6169
+ \t\tlocal kept = \{\}\
6170
+ \t\tfor line in string.gmatch(raw, "[^\\n]+") do\
6171
+ \t\t\t-- Extract referenced line number (either chunk-name format).\
6172
+ \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")\
6173
+ \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')\
6174
+ \t\t\tlocal n = num_str and tonumber(num_str)\
6175
+ \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing\
6176
+ \t\t\t-- any filtering, because user-code frames carry that suffix —\
6177
+ \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY\
6178
+ \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and\
6179
+ \t\t\t-- get dropped. Strip first, then apply filters.\
6180
+ \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))\
6181
+ \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)\
6182
+ \t\t\t\tor string.find(line, "__mcp_", 1, true)\
6183
+ \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)\
6184
+ \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside\
6185
+ \t\t\t-- user range) are wrapper internals — drop them. Lines without\
6186
+ \t\t\t-- a payload-chunk line number (the traceback header / engine\
6187
+ \t\t\t-- C frames) are kept; remap is a no-op for them.\
6188
+ \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then\
6189
+ \t\t\t\tskip = true\
6190
+ \t\t\tend\
6191
+ \t\t\tif not skip then\
6192
+ \t\t\t\ttable.insert(kept, __mcp_remap(line))\
6193
+ \t\t\tend\
6194
+ \t\tend\
6195
+ \t\treturn table.concat(kept, "\\n")\
6196
+ \tend\
6197
+ \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)\
6198
+ \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
6199
+ end)())`
6200
+ end
6201
+ -- TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
6202
+ -- pulling the real compile-error diagnostic out of LogService — that error
6203
+ -- references the payload module's line number directly, and never passes
6204
+ -- through the IIFE's runtime wrapper.
6205
+ local function remapPayloadLines(s, userLines)
6206
+ -- Mirror of the Lua __mcp_remap inside the wrapper, for paths that
6207
+ -- don't pass through the IIFE (compile errors recovered from
6208
+ -- LogService, the immediate loadstring compileError surface). Same
6209
+ -- two-format coverage plus the same clamp: unclosed user constructs
6210
+ -- let the parser consume wrapper postamble, so the raw payload line
6211
+ -- is sometimes well past user EOF — clamp to [1, userLines] and
6212
+ -- annotate so the error doesn't say "user_code:49" for one-line input.
6213
+ local userLine = function(payload)
6214
+ local u = payload - WRAPPER_LINE_OFFSET
6215
+ if u < 1 then
6216
+ return "1"
6217
+ end
6218
+ if u > userLines then
6219
+ return `{tostring(userLines)} (at end of input)`
6220
+ end
6221
+ return tostring(u)
6222
+ end
6223
+ local out = s
6224
+ local a = string.gsub(out, "__MCPExecLuauPayload:(%d+)", function(num)
6225
+ local n = tonumber(num)
6226
+ if n ~= nil then
6227
+ return `user_code:{userLine(n)}`
6228
+ end
6229
+ return `user_code:{num}`
6230
+ end)
6231
+ out = a
6232
+ local b = string.gsub(out, '%[string "[^"]+"%]:(%d+)', function(num)
6233
+ local n = tonumber(num)
6234
+ if n ~= nil then
6235
+ return `user_code:{userLine(n)}`
6236
+ end
6237
+ return `user_code:{num}`
6238
+ end)
6239
+ out = b
6240
+ return out
6241
+ end
6242
+ local function runViaModuleScript(wrapped, userLines)
6243
+ local m = Instance.new("ModuleScript")
6244
+ m.Name = PAYLOAD_INSTANCE_NAME
6245
+ local okSet, setErr = pcall(function()
6246
+ m.Source = wrapped
6247
+ end)
6248
+ if not okSet then
6249
+ m:Destroy()
6250
+ -- error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
6251
+ -- prefix that error() would otherwise prepend, keeping the visible
6252
+ -- message focused on the user-actionable error rather than our path.
6253
+ error(`ModuleScript Source set failed: {tostring(setErr)}`, 0)
6254
+ end
6255
+ m.Parent = game:GetService("Workspace")
6256
+ local okReq, reqResult = pcall(function()
6257
+ return require(m)
6258
+ end)
6259
+ m:Destroy()
6260
+ if not okReq then
6261
+ local errMsg = tostring(reqResult)
6262
+ -- pcall(require, m) collapses parse/compile failures into the canned
6263
+ -- engine string. The real diagnostic was emitted to LogService on the
6264
+ -- next engine frame — give it ~50ms to land then scan backward.
6265
+ if errMsg == "Requested module experienced an error while loading" then
6266
+ task.wait(0.05)
6267
+ local hist = LogService:GetLogHistory()
6268
+ for i = #hist - 1, 0, -1 do
6269
+ local e = hist[i + 1]
6270
+ if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, #PAYLOAD_PATH_PREFIX) == PAYLOAD_PATH_PREFIX then
6271
+ errMsg = e.message
6272
+ break
6273
+ end
6274
+ end
6275
+ end
6276
+ -- Compile errors reference the payload module's line number directly
6277
+ -- — remap + clamp to user-relative line numbers so `local x = 1 +`
6278
+ -- reports :1: instead of :23:, and reports the clamp annotation
6279
+ -- when the parser ran off the end of user code into wrapper code.
6280
+ error(remapPayloadLines(errMsg, userLines), 0)
6281
+ end
6282
+ return reqResult
6283
+ end
6284
+ local function isLoadstringUnavailable(err)
6285
+ local errStr = tostring(err)
6286
+ local matchStart = string.find(errStr, "not available", 1, true)
6287
+ return matchStart ~= nil
6288
+ end
6289
+ -- Returns a string suitable for `returnValue`. Tables get JSON-encoded so
6290
+ -- the caller sees structured data instead of "table: 0xaddr". Anything that
6291
+ -- JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
6292
+ local function formatReturnValue(value)
6293
+ if value == nil then
6294
+ return ""
6295
+ end
6296
+ local _value = value
6297
+ if type(_value) == "table" then
6298
+ local ok, encoded = pcall(function()
6299
+ return HttpService:JSONEncode(value)
6300
+ end)
6301
+ if ok then
6302
+ return encoded
6303
+ end
6304
+ end
6305
+ return tostring(value)
6306
+ end
6307
+ local function execute(code)
6308
+ if not (code ~= "" and code) or code == "" then
6309
+ return {
6310
+ success = false,
6311
+ error = "code is required",
6312
+ }
6313
+ end
6314
+ local wrapped = buildWrapper(code)
6315
+ local userLines = countLines(code)
6316
+ local success, result = pcall(function()
6317
+ local fn, compileError = loadstring(wrapped)
6318
+ if not fn then
6319
+ if isLoadstringUnavailable(compileError) then
6320
+ return runViaModuleScript(wrapped, userLines)
6321
+ end
6322
+ error(`Compile error: {remapPayloadLines(tostring(compileError), userLines)}`, 0)
6323
+ end
6324
+ return fn()
6325
+ end)
6326
+ -- loadstring can throw (not return nil) when ServerScriptService.
6327
+ -- LoadStringEnabled is false; treat that as a second-chance fallback.
6328
+ if not success and isLoadstringUnavailable(result) then
6329
+ success, result = pcall(function()
6330
+ return runViaModuleScript(wrapped, userLines)
6331
+ end)
6332
+ end
6333
+ if not success then
6334
+ return {
6335
+ success = false,
6336
+ error = tostring(result),
6337
+ output = {},
6338
+ message = "Code execution failed",
6339
+ }
6340
+ end
6341
+ local r = result
6342
+ local capturedOutput = r.output
6343
+ local output = if capturedOutput ~= nil then capturedOutput else ({})
6344
+ if r.ok == true then
6345
+ return {
6346
+ success = true,
6347
+ returnValue = if r.value ~= nil then formatReturnValue(r.value) else nil,
6348
+ output = output,
6349
+ message = "Code executed successfully",
6350
+ }
6351
+ end
6352
+ return {
6353
+ success = false,
6354
+ error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
6355
+ output = output,
6356
+ message = "Code execution failed",
6357
+ }
6358
+ end
6359
+ return {
6360
+ execute = execute,
6361
+ }
6362
+ ]]></string>
6363
+ </Properties>
6364
+ </Item>
6365
+ <Item class="ModuleScript" referent="20">
6025
6366
  <Properties>
6026
6367
  <string name="Name">Recording</string>
6027
6368
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6051,7 +6392,7 @@ return {
6051
6392
  ]]></string>
6052
6393
  </Properties>
6053
6394
  </Item>
6054
- <Item class="ModuleScript" referent="20">
6395
+ <Item class="ModuleScript" referent="21">
6055
6396
  <Properties>
6056
6397
  <string name="Name">RuntimeLogBuffer</string>
6057
6398
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6232,11 +6573,11 @@ return {
6232
6573
  ]]></string>
6233
6574
  </Properties>
6234
6575
  </Item>
6235
- <Item class="ModuleScript" referent="21">
6576
+ <Item class="ModuleScript" referent="22">
6236
6577
  <Properties>
6237
6578
  <string name="Name">State</string>
6238
6579
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6239
- local CURRENT_VERSION = "2.11.4"
6580
+ local CURRENT_VERSION = "2.12.0"
6240
6581
  local MAX_CONNECTIONS = 5
6241
6582
  local BASE_PORT = 58741
6242
6583
  local activeTabIndex = 0
@@ -6328,61 +6669,96 @@ return {
6328
6669
  ]]></string>
6329
6670
  </Properties>
6330
6671
  </Item>
6331
- <Item class="ModuleScript" referent="22">
6672
+ <Item class="ModuleScript" referent="23">
6332
6673
  <Properties>
6333
6674
  <string name="Name">StopPlayMonitor</string>
6334
6675
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6335
- -- Cross-DM stop_playtest signaling via plugin:SetSetting.
6676
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
6677
+ -- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
6678
+ -- per-instance setting key so the same Studio process can host playtests
6679
+ -- for multiple places without one place's stop_playtest yanking another's.
6336
6680
  --
6337
6681
  -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
6338
- -- that's shared across every DataModel the plugin runs in (edit, play-server,
6339
- -- play-clients). We use it as a one-bit flag for "please call EndTest in the
6340
- -- play-server DM":
6682
+ -- shared across every DataModel the plugin runs in (edit DMs, play-server
6683
+ -- DMs, play-client DMs). For each connected place we use a dedicated key
6684
+ -- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
6341
6685
  --
6342
- -- * The edit DM's stopPlaytest handler writes the flag (requestStop).
6343
- -- * A monitor loop running inside the play-server DM polls the flag at 1Hz
6344
- -- and calls StudioTestService:EndTest when it flips true, then resets it.
6345
- -- * The edit DM then waits up to ~2.5s for the flag to be reset, which
6346
- -- tells us a play-server actually consumed the request (no false-positive
6347
- -- success when nothing was running).
6686
+ -- * The edit DM's stopPlaytest handler writes `true` into its own key
6687
+ -- (computed from its placeId / ServerStorage anon UUID).
6688
+ -- * Each play-server DM's monitor loop polls the key matching its own
6689
+ -- instanceId at 0.1Hz; on `true` it clears the key and calls
6690
+ -- StudioTestService:EndTest. Play-server DMs for other places never
6691
+ -- touch this key.
6692
+ -- * The edit DM waits up to ~8s for its key to be cleared, confirming a
6693
+ -- matching play-server actually consumed the request.
6348
6694
  --
6349
- -- Why this is simpler than the previous edit-proxy registration:
6350
- -- * Doesn't depend on the MCP server tracking peer roles at all.
6351
- -- * Survives MCP server restarts: monitor loop is local to the play-server
6352
- -- plugin lifetime, not to any HTTP/registration state.
6353
- -- * No need for cross-DM LogService.MessageOut reflection (which we verified
6354
- -- does not work edit -> play-server anyway).
6355
- --
6356
- -- Pattern mirrors the official Roblox Studio MCP
6357
- -- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
6695
+ -- Earlier versions used a single shared boolean flag, which let any
6696
+ -- play-server DM in the same Studio process consume any place's stop
6697
+ -- request silently yanking teammates' playtests. The per-key scoping
6698
+ -- below is the fix.
6699
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
6700
+ local HttpService = _services.HttpService
6701
+ local ServerStorage = _services.ServerStorage
6358
6702
  local StudioTestService = game:GetService("StudioTestService")
6359
- local SETTING_KEY = "MCP_STOP_PLAY_SIGNAL"
6360
- local POLL_INTERVAL_SEC = 1
6361
- local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5
6703
+ local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
6704
+ -- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
6705
+ -- lag tight so the consumption-confirmation window doesn't have to absorb
6706
+ -- polling jitter on top of EndTest's teardown time.
6707
+ local POLL_INTERVAL_SEC = 0.1
6708
+ -- Total time we wait for the matching play-server DM to consume the
6709
+ -- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
6710
+ -- StudioTestService:EndTest teardown (several seconds on heavier places).
6711
+ -- 8s is comfortable; the tighter poll above keeps real cases well under.
6712
+ local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
6362
6713
  local WAIT_POLL_SEC = 0.1
6363
6714
  local pluginRef
6364
6715
  local function init(p)
6365
6716
  pluginRef = p
6366
6717
  end
6718
+ -- Mirror of Communication.computeInstanceId(). Duplicated here because
6719
+ -- StopPlayMonitor runs in both edit and play-server DMs, and both must
6720
+ -- agree on the place identifier (published places: placeId; unpublished:
6721
+ -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
6722
+ -- into the play DM).
6723
+ local function computeInstanceId()
6724
+ if game.PlaceId ~= 0 then
6725
+ return `place:{tostring(game.PlaceId)}`
6726
+ end
6727
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
6728
+ if type(existing) == "string" and existing ~= "" then
6729
+ return `anon:{existing}`
6730
+ end
6731
+ local fresh = HttpService:GenerateGUID(false)
6732
+ pcall(function()
6733
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
6734
+ end)
6735
+ return `anon:{fresh}`
6736
+ end
6737
+ local function settingKey(instanceId)
6738
+ return SETTING_KEY_PREFIX .. instanceId
6739
+ end
6367
6740
  local function startMonitor()
6368
6741
  if not pluginRef then
6369
6742
  warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
6370
6743
  return nil
6371
6744
  end
6372
- -- Clear any stale value left from a prior session. If a real stop request
6373
- -- is in-flight when this runs, the requesting edit DM will set it again
6374
- -- within its 2.5s wait window.
6745
+ local myKey = settingKey(computeInstanceId())
6746
+ -- Clear any stale value left from a prior session. If a real stop
6747
+ -- request is in-flight when this runs, the requesting edit DM will
6748
+ -- write again within its consumption-confirmation window.
6375
6749
  pcall(function()
6376
- return pluginRef:SetSetting(SETTING_KEY, false)
6750
+ return pluginRef:SetSetting(myKey, false)
6377
6751
  end)
6378
6752
  task.spawn(function()
6379
6753
  while true do
6380
6754
  local okGet, val = pcall(function()
6381
- return pluginRef:GetSetting(SETTING_KEY)
6755
+ return pluginRef:GetSetting(myKey)
6382
6756
  end)
6383
6757
  if okGet and val == true then
6758
+ -- Consume the flag first so requestStop's
6759
+ -- waitForConsumption returns success, then end the test.
6384
6760
  pcall(function()
6385
- return pluginRef:SetSetting(SETTING_KEY, false)
6761
+ return pluginRef:SetSetting(myKey, false)
6386
6762
  end)
6387
6763
  pcall(function()
6388
6764
  return StudioTestService:EndTest("stopped_by_mcp")
@@ -6396,8 +6772,9 @@ local function requestStop()
6396
6772
  if not pluginRef then
6397
6773
  return false
6398
6774
  end
6775
+ local myKey = settingKey(computeInstanceId())
6399
6776
  local ok = pcall(function()
6400
- return pluginRef:SetSetting(SETTING_KEY, true)
6777
+ return pluginRef:SetSetting(myKey, true)
6401
6778
  end)
6402
6779
  return ok
6403
6780
  end
@@ -6405,10 +6782,11 @@ local function waitForConsumption()
6405
6782
  if not pluginRef then
6406
6783
  return false
6407
6784
  end
6785
+ local myKey = settingKey(computeInstanceId())
6408
6786
  local start = tick()
6409
6787
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
6410
6788
  local okGet, val = pcall(function()
6411
- return pluginRef:GetSetting(SETTING_KEY)
6789
+ return pluginRef:GetSetting(myKey)
6412
6790
  end)
6413
6791
  if okGet and val ~= true then
6414
6792
  return true
@@ -6421,8 +6799,9 @@ local function clearPending()
6421
6799
  if not pluginRef then
6422
6800
  return nil
6423
6801
  end
6802
+ local myKey = settingKey(computeInstanceId())
6424
6803
  pcall(function()
6425
- return pluginRef:SetSetting(SETTING_KEY, false)
6804
+ return pluginRef:SetSetting(myKey, false)
6426
6805
  end)
6427
6806
  end
6428
6807
  return {
@@ -6435,7 +6814,7 @@ return {
6435
6814
  ]]></string>
6436
6815
  </Properties>
6437
6816
  </Item>
6438
- <Item class="ModuleScript" referent="23">
6817
+ <Item class="ModuleScript" referent="24">
6439
6818
  <Properties>
6440
6819
  <string name="Name">UI</string>
6441
6820
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7186,7 +7565,7 @@ return {
7186
7565
  ]]></string>
7187
7566
  </Properties>
7188
7567
  </Item>
7189
- <Item class="ModuleScript" referent="24">
7568
+ <Item class="ModuleScript" referent="25">
7190
7569
  <Properties>
7191
7570
  <string name="Name">Utils</string>
7192
7571
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7716,11 +8095,11 @@ return {
7716
8095
  </Properties>
7717
8096
  </Item>
7718
8097
  </Item>
7719
- <Item class="Folder" referent="28">
8098
+ <Item class="Folder" referent="29">
7720
8099
  <Properties>
7721
8100
  <string name="Name">include</string>
7722
8101
  </Properties>
7723
- <Item class="ModuleScript" referent="25">
8102
+ <Item class="ModuleScript" referent="26">
7724
8103
  <Properties>
7725
8104
  <string name="Name">Promise</string>
7726
8105
  <string name="Source"><![CDATA[--[[
@@ -9794,7 +10173,7 @@ return Promise
9794
10173
  ]]></string>
9795
10174
  </Properties>
9796
10175
  </Item>
9797
- <Item class="ModuleScript" referent="26">
10176
+ <Item class="ModuleScript" referent="27">
9798
10177
  <Properties>
9799
10178
  <string name="Name">RuntimeLib</string>
9800
10179
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -10061,15 +10440,15 @@ return TS
10061
10440
  </Properties>
10062
10441
  </Item>
10063
10442
  </Item>
10064
- <Item class="Folder" referent="29">
10443
+ <Item class="Folder" referent="30">
10065
10444
  <Properties>
10066
10445
  <string name="Name">node_modules</string>
10067
10446
  </Properties>
10068
- <Item class="Folder" referent="30">
10447
+ <Item class="Folder" referent="31">
10069
10448
  <Properties>
10070
10449
  <string name="Name">@rbxts</string>
10071
10450
  </Properties>
10072
- <Item class="ModuleScript" referent="27">
10451
+ <Item class="ModuleScript" referent="28">
10073
10452
  <Properties>
10074
10453
  <string name="Name">services</string>
10075
10454
  <string name="Source"><![CDATA[return setmetatable({}, {