@chrrxs/robloxstudio-mcp 2.11.1 → 2.11.3

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.
@@ -11,10 +11,15 @@ 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
13
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
14
+ local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
14
15
  -- Attach the per-peer LogService.MessageOut listener as early as possible so
15
16
  -- boot-time prints from the user's place scripts are captured. Powers the
16
17
  -- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
17
18
  RuntimeLogBuffer.install()
19
+ -- Share the plugin reference with the stop-play signaling module so both the
20
+ -- edit DM (write the flag) and the play-server DM (read+act on the flag) can
21
+ -- access plugin:SetSetting/GetSetting.
22
+ StopPlayMonitor.init(plugin)
18
23
  UI.init(plugin)
19
24
  local elements = UI.getElements()
20
25
  local ICON_DISCONNECTED = "rbxassetid://125921838360800"
@@ -65,6 +70,10 @@ task.delay(2, function()
65
70
  end
66
71
  if role == "server" then
67
72
  ClientBroker.setupServerBroker()
73
+ -- The play-server DM is the only one where StudioTestService:EndTest is
74
+ -- legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
75
+ -- at 1Hz and calls EndTest when the edit DM sets it.
76
+ StopPlayMonitor.startMonitor()
68
77
  elseif role == "client" then
69
78
  ClientBroker.setupClientBroker()
70
79
  end
@@ -94,12 +103,10 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
94
103
  -- in ReplicatedStorage; each player gets a proxy "client" registration on the
95
104
  -- MCP side, polled and dispatched by the server peer.
96
105
  --
97
- -- The same server peer also registers an "edit" proxy that intercepts
98
- -- /api/stop-playtest specifically - StudioTestService:EndTest only works from
99
- -- the play server DM, so the real edit DM cannot satisfy stop requests on its
100
- -- own. MCP returns the same pending request to multiple pollers until someone
101
- -- /responds, so non-stop edit-targeted requests fall through to the actual
102
- -- edit DM untouched.
106
+ -- (Previously the server peer also registered an "edit-proxy" role to
107
+ -- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
108
+ -- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
109
+ -- signaling, which works regardless of MCP server state.)
103
110
  local MCP_URL = "http://localhost:58741"
104
111
  local BROKER_NAME = "__MCPClientBroker"
105
112
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
@@ -329,59 +336,10 @@ local function registerProxy(player, rf)
329
336
  proxyByPlayer[_player_1] = _arg1
330
337
  task.spawn(pollProxy, proxyId, player, rf)
331
338
  end
332
- local function startEditProxyLoop()
333
- task.spawn(function()
334
- local proxyId = HttpService:GenerateGUID(false)
335
- local ok, res = postJson("/ready", {
336
- instanceId = proxyId,
337
- role = "edit-proxy",
338
- })
339
- if not ok or not res or not res.Success then
340
- warn("[MCPFork] edit-proxy register failed")
341
- return nil
342
- end
343
- while true do
344
- local okPoll, pollRes = pcall(function()
345
- return HttpService:RequestAsync({
346
- Url = `{MCP_URL}/poll?instanceId={proxyId}`,
347
- Method = "GET",
348
- Headers = {
349
- ["Content-Type"] = "application/json",
350
- },
351
- })
352
- end)
353
- if okPoll and pollRes and (pollRes.Success or pollRes.StatusCode == 503) then
354
- local okJson, body = pcall(function()
355
- return HttpService:JSONDecode(pollRes.Body)
356
- end)
357
- if okJson and body then
358
- -- Re-register if the server lost our edit-proxy registration.
359
- if body.knownInstance == false then
360
- reRegisterProxy(proxyId, "edit-proxy")
361
- end
362
- if body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
363
- local sts = game:GetService("StudioTestService")
364
- local endOk, endErr = pcall(function()
365
- return sts:EndTest("stopped_by_mcp")
366
- end)
367
- local response = if endOk then {
368
- success = true,
369
- message = "Playtest stopped via edit-proxy/EndTest",
370
- } else {
371
- success = false,
372
- error = `EndTest failed: {tostring(endErr)}`,
373
- }
374
- postJson("/response", {
375
- requestId = body.requestId,
376
- response = response,
377
- })
378
- end
379
- end
380
- end
381
- task.wait(0.15)
382
- end
383
- end)
384
- end
339
+ -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
340
+ -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
341
+ -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
342
+ -- which doesn't depend on MCP server state or peer registration at all.)
385
343
  local function setupServerBroker()
386
344
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
387
345
  if not rf then
@@ -415,7 +373,6 @@ local function setupServerBroker()
415
373
  end
416
374
  table.clear(proxyByPlayer)
417
375
  end)
418
- startEditProxyLoop()
419
376
  end
420
377
  return {
421
378
  MCP_URL = MCP_URL,
@@ -3298,56 +3255,52 @@ local function executeLuau(requestData)
3298
3255
  error = "Code is required",
3299
3256
  }
3300
3257
  end
3301
- local output = {}
3302
- local oldPrint = print
3303
- local oldWarn = warn
3304
- local env = getfenv(0)
3305
- env.print = function(...)
3306
- local args = { ... }
3307
- local parts = {}
3308
- for _, a in args do
3309
- local _arg0 = tostring(a)
3310
- table.insert(parts, _arg0)
3311
- end
3312
- local _arg0 = table.concat(parts, "\t")
3313
- table.insert(output, _arg0)
3314
- oldPrint(unpack(args))
3315
- end
3316
- env.warn = function(...)
3317
- local args = { ... }
3318
- local parts = {}
3319
- for _, a in args do
3320
- local _arg0 = tostring(a)
3321
- table.insert(parts, _arg0)
3322
- end
3323
- local _arg0 = `[warn] {table.concat(parts, "\t")}`
3324
- table.insert(output, _arg0)
3325
- oldWarn(unpack(args))
3326
- end
3327
- -- Try loadstring first (preserves print/warn interception). When
3328
- -- ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
3329
- -- peer where the engine respects that gate (notably the play-server DM
3330
- -- in some Studio configurations), loadstring either returns nil with a
3331
- -- "loadstring() is not available" message OR throws that same message
3332
- -- directly. Both paths must trigger the ModuleScript + require
3333
- -- fallback. The fallback can't intercept print/warn since the
3334
- -- ModuleScript runs in its own environment, so the output array stays
3335
- -- empty in that branch - the playtest log buffer already captures
3336
- -- prints separately via LogService.MessageOut.
3258
+ -- Both execution paths (loadstring + ModuleScript-require fallback) run
3259
+ -- the SAME wrapped source so they return a uniform { ok, value, output }
3260
+ -- shape. Two problems the wrapper solves at once:
3261
+ --
3262
+ -- 1. pcall(require, m) swallows the real error and returns Roblox's
3263
+ -- generic "Requested module experienced an error while loading"
3264
+ -- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
3265
+ -- ModuleScript itself returning successfully the real error +
3266
+ -- traceback live in the returned table.
3267
+ --
3268
+ -- 2. The ModuleScript path runs in its own environment, so a plugin-
3269
+ -- side getfenv print/warn override never reached user prints. A
3270
+ -- lexical local print/warn inside the IIFE captures user prints
3271
+ -- regardless of which path executes. We also call the real global
3272
+ -- print/warn so messages still flow to Studio's output and
3273
+ -- LogService.MessageOut (which powers get_runtime_logs).
3274
+ --
3275
+ -- Prints from required sub-modules don't reach this capture (they have
3276
+ -- their own env) those go through the runtime log buffer.
3277
+ local wrapped = `return ((function()\
3278
+ \tlocal __mcp_output = \{\}\
3279
+ \tlocal __mcp_real_print = print\
3280
+ \tlocal __mcp_real_warn = warn\
3281
+ \tlocal print = function(...)\
3282
+ \t\t__mcp_real_print(...)\
3283
+ \t\tlocal args = \{...\}\
3284
+ \t\tlocal parts = table.create(#args)\
3285
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
3286
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
3287
+ \tend\
3288
+ \tlocal warn = function(...)\
3289
+ \t\t__mcp_real_warn(...)\
3290
+ \t\tlocal args = \{...\}\
3291
+ \t\tlocal parts = table.create(#args)\
3292
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
3293
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
3294
+ \tend\
3295
+ \tlocal function __mcp_run()\
3296
+ {code}\
3297
+ \tend\
3298
+ \tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
3299
+ \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
3300
+ end)())`
3337
3301
  local runViaModuleScript = function()
3338
3302
  local m = Instance.new("ModuleScript")
3339
3303
  m.Name = "__MCPExecLuauPayload"
3340
- -- Wrap user code in an IIFE so require() always gets exactly one
3341
- -- return value. Without this, code like `print("x")` errors with
3342
- -- "Module code did not return exactly one value" because top-level
3343
- -- ModuleScripts must return exactly one value.
3344
- --
3345
- -- The DOUBLE parens around the call are load-bearing: in Luau,
3346
- -- `return f()` propagates whatever multi-value tuple f returns,
3347
- -- including zero values. Outer parens adjust the call to exactly
3348
- -- one value (the first, or nil). So `return ((f)())` always
3349
- -- returns exactly one value, regardless of what f does.
3350
- local wrapped = `return ((function()\n{code}\nend)())`
3351
3304
  local okSet, setErr = pcall(function()
3352
3305
  m.Source = wrapped
3353
3306
  end)
@@ -3371,7 +3324,7 @@ local function executeLuau(requestData)
3371
3324
  return matchStart ~= nil
3372
3325
  end
3373
3326
  local success, result = pcall(function()
3374
- local fn, compileError = loadstring(code)
3327
+ local fn, compileError = loadstring(wrapped)
3375
3328
  if not fn then
3376
3329
  if isLoadstringUnavailable(compileError) then
3377
3330
  return runViaModuleScript()
@@ -3381,27 +3334,39 @@ local function executeLuau(requestData)
3381
3334
  return fn()
3382
3335
  end)
3383
3336
  -- loadstring throws (not returns nil) in some plugin contexts when
3384
- -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3337
+ -- LoadStringEnabled=false. Catch that as a second-chance fallback.
3385
3338
  if not success and isLoadstringUnavailable(result) then
3386
3339
  success, result = pcall(runViaModuleScript)
3387
3340
  end
3388
- env.print = oldPrint
3389
- env.warn = oldWarn
3390
- if success then
3391
- return {
3392
- success = true,
3393
- returnValue = if result ~= nil then tostring(result) else nil,
3394
- output = output,
3395
- message = "Code executed successfully",
3396
- }
3397
- else
3341
+ if not success then
3342
+ -- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
3343
+ -- error in the user code, or ModuleScript setup error). 'result' is the
3344
+ -- raw error string from pcall.
3398
3345
  return {
3399
3346
  success = false,
3400
3347
  error = tostring(result),
3401
- output = output,
3348
+ output = {},
3402
3349
  message = "Code execution failed",
3403
3350
  }
3404
3351
  end
3352
+ -- Wrapper executed - unpack { ok, value, output }.
3353
+ local r = result
3354
+ local capturedOutput = r.output
3355
+ local output = if capturedOutput ~= nil then capturedOutput else ({})
3356
+ if r.ok == true then
3357
+ return {
3358
+ success = true,
3359
+ returnValue = if r.value ~= nil then tostring(r.value) else nil,
3360
+ output = output,
3361
+ message = "Code executed successfully",
3362
+ }
3363
+ end
3364
+ return {
3365
+ success = false,
3366
+ error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
3367
+ output = output,
3368
+ message = "Code execution failed",
3369
+ }
3405
3370
  end
3406
3371
  local function undo(_requestData)
3407
3372
  local success, result = pcall(function()
@@ -5686,13 +5651,18 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5686
5651
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5687
5652
  local HttpService = _services.HttpService
5688
5653
  local LogService = _services.LogService
5654
+ local RunService = _services.RunService
5689
5655
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5690
5656
  local installBridges = _EvalBridges.installBridges
5691
5657
  local cleanupBridges = _EvalBridges.cleanupBridges
5658
+ local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5692
5659
  local StudioTestService = game:GetService("StudioTestService")
5693
5660
  local ServerScriptService = game:GetService("ServerScriptService")
5694
5661
  local ScriptEditorService = game:GetService("ScriptEditorService")
5695
- local STOP_SIGNAL = "__MCP_STOP__"
5662
+ -- NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
5663
+ -- __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
5664
+ -- off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
5665
+ -- reflection from edit -> play-server does not work in practice.
5696
5666
  local NAV_SIGNAL = "__MCP_NAV__"
5697
5667
  local NAV_RESULT = "__MCP_NAV_RESULT__"
5698
5668
  local testRunning = false
@@ -5704,16 +5674,13 @@ local stopListenerScript
5704
5674
  local navResultCallback
5705
5675
  local function buildCommandListenerSource()
5706
5676
  return `local LogService = game:GetService("LogService")\
5707
- local StudioTestService = game:GetService("StudioTestService")\
5708
5677
  local PathfindingService = game:GetService("PathfindingService")\
5709
5678
  local Players = game:GetService("Players")\
5710
5679
  local HttpService = game:GetService("HttpService")\
5711
5680
  local NAV_SIG = "{NAV_SIGNAL}"\
5712
5681
  local NAV_RES = "{NAV_RESULT}"\
5713
5682
  LogService.MessageOut:Connect(function(msg)\
5714
- if msg == "{STOP_SIGNAL}" then\
5715
- pcall(function() StudioTestService:EndTest("stopped_by_mcp") end)\
5716
- elseif string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
5683
+ if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
5717
5684
  local json = string.sub(msg, #NAV_SIG + 2)\
5718
5685
  task.spawn(function()\
5719
5686
  local ok, d = pcall(function() return HttpService:JSONDecode(json) end)\
@@ -5801,6 +5768,19 @@ local function startPlaytest(requestData)
5801
5768
  error = 'mode must be "play" or "run"',
5802
5769
  }
5803
5770
  end
5771
+ -- Self-heal: if testRunning is stuck true but Studio reports no active
5772
+ -- playtest, the previous start_playtest's task.spawn was orphaned
5773
+ -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
5774
+ -- Reset it so subsequent starts don't hit a false "already running".
5775
+ if testRunning and not RunService:IsRunning() then
5776
+ testRunning = false
5777
+ if logConnection then
5778
+ logConnection:Disconnect()
5779
+ logConnection = nil
5780
+ end
5781
+ cleanupStopListener()
5782
+ cleanupBridges()
5783
+ end
5804
5784
  if testRunning then
5805
5785
  return {
5806
5786
  error = "A test is already running",
@@ -5812,9 +5792,6 @@ local function startPlaytest(requestData)
5812
5792
  testError = nil
5813
5793
  cleanupStopListener()
5814
5794
  logConnection = LogService.MessageOut:Connect(function(message, messageType)
5815
- if message == STOP_SIGNAL then
5816
- return nil
5817
- end
5818
5795
  local _message = message
5819
5796
  local _arg1 = #NAV_SIGNAL
5820
5797
  if string.sub(_message, 1, _arg1) == NAV_SIGNAL then
@@ -5876,25 +5853,60 @@ local function startPlaytest(requestData)
5876
5853
  cleanupStopListener()
5877
5854
  cleanupBridges()
5878
5855
  end)
5879
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s)` else `Playtest started in {mode} mode`
5856
+ local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5880
5857
  local response = {
5881
5858
  success = true,
5882
5859
  message = msg,
5883
- evalBridges = if bridgeInstall.installed then "installed" else `failed: {bridgeInstall.error}`,
5884
5860
  }
5861
+ -- Only mention eval bridges when they failed — when they're fine, the
5862
+ -- detail is noise. eval_server_runtime / eval_client_runtime will surface
5863
+ -- their own clear errors if the caller tries to use them after a failed
5864
+ -- install.
5865
+ if not bridgeInstall.installed then
5866
+ response.evalBridgesError = bridgeInstall.error
5867
+ end
5885
5868
  return response
5886
5869
  end
5887
5870
  local function stopPlaytest(_requestData)
5888
- -- Server-side routing (tools/index.ts:stopPlaytest) sends /api/stop-playtest
5889
- -- to the role="edit-proxy" instance whenever one is registered. This handler
5890
- -- is only reached when there's no edit-proxy - i.e. no active playtest, or
5891
- -- the play DMs haven't completed plugin auto-activation yet. Calling
5892
- -- StudioTestService:EndTest from the edit DM is illegal ("can only be
5893
- -- called from the server DataModel of a running Studio play session"), so
5894
- -- don't try - return a clean "no active playtest" response instead.
5871
+ -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
5872
+ -- cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
5873
+ -- calls StudioTestService:EndTest, then resets the flag. We wait up to
5874
+ -- 2.5s for the reset to confirm a play DM actually consumed the request,
5875
+ -- which avoids returning success when nothing is running.
5876
+ if not StopPlayMonitor.requestStop() then
5877
+ return {
5878
+ error = "Plugin not ready. Try again in a moment.",
5879
+ }
5880
+ end
5881
+ if not StopPlayMonitor.waitForConsumption() then
5882
+ -- Clean up the pending flag so a future playtest's monitor doesn't fire
5883
+ -- EndTest on its own startup against a stale signal.
5884
+ StopPlayMonitor.clearPending()
5885
+ return {
5886
+ error = "No active playtest to stop.",
5887
+ }
5888
+ end
5889
+ -- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
5890
+ -- startPlaytest task.spawn is still unwinding though — testRunning stays
5891
+ -- true until that yield completes and the post-block runs. Wait so
5892
+ -- back-to-back stop -> start sequences don't race against the prior
5893
+ -- teardown and get "A test is already running". 10s covers play-DM
5894
+ -- teardown on heavier places; if it still hasn't cleared we return
5895
+ -- anyway so users aren't stuck — but note that in the response so the
5896
+ -- caller knows a subsequent start may need a moment.
5897
+ local deadline = tick() + 10
5898
+ while testRunning and tick() < deadline do
5899
+ task.wait(0.1)
5900
+ end
5901
+ if testRunning then
5902
+ return {
5903
+ success = true,
5904
+ message = "Playtest stop signal sent; teardown still in progress.",
5905
+ }
5906
+ end
5895
5907
  return {
5896
- error = "No active playtest to stop (edit-proxy not registered).",
5897
- hint = "If a playtest is running, the play-server DM may not have completed plugin auto-activation yet. " .. "Wait a moment and retry, or call execute_luau target=server with StudioTestService:EndTest as a manual fallback.",
5908
+ success = true,
5909
+ message = "Playtest stopped.",
5898
5910
  }
5899
5911
  end
5900
5912
  local function getPlaytestOutput(_requestData)
@@ -6203,7 +6215,7 @@ return {
6203
6215
  <Properties>
6204
6216
  <string name="Name">State</string>
6205
6217
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6206
- local CURRENT_VERSION = "2.11.1"
6218
+ local CURRENT_VERSION = "2.11.3"
6207
6219
  local MAX_CONNECTIONS = 5
6208
6220
  local BASE_PORT = 58741
6209
6221
  local activeTabIndex = 0
@@ -6296,6 +6308,113 @@ return {
6296
6308
  </Properties>
6297
6309
  </Item>
6298
6310
  <Item class="ModuleScript" referent="22">
6311
+ <Properties>
6312
+ <string name="Name">StopPlayMonitor</string>
6313
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6314
+ -- Cross-DM stop_playtest signaling via plugin:SetSetting.
6315
+ --
6316
+ -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
6317
+ -- that's shared across every DataModel the plugin runs in (edit, play-server,
6318
+ -- play-clients). We use it as a one-bit flag for "please call EndTest in the
6319
+ -- play-server DM":
6320
+ --
6321
+ -- * The edit DM's stopPlaytest handler writes the flag (requestStop).
6322
+ -- * A monitor loop running inside the play-server DM polls the flag at 1Hz
6323
+ -- and calls StudioTestService:EndTest when it flips true, then resets it.
6324
+ -- * The edit DM then waits up to ~2.5s for the flag to be reset, which
6325
+ -- tells us a play-server actually consumed the request (no false-positive
6326
+ -- success when nothing was running).
6327
+ --
6328
+ -- Why this is simpler than the previous edit-proxy registration:
6329
+ -- * Doesn't depend on the MCP server tracking peer roles at all.
6330
+ -- * Survives MCP server restarts: monitor loop is local to the play-server
6331
+ -- plugin lifetime, not to any HTTP/registration state.
6332
+ -- * No need for cross-DM LogService.MessageOut reflection (which we verified
6333
+ -- does not work edit -> play-server anyway).
6334
+ --
6335
+ -- Pattern mirrors the official Roblox Studio MCP
6336
+ -- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
6337
+ local StudioTestService = game:GetService("StudioTestService")
6338
+ local SETTING_KEY = "MCP_STOP_PLAY_SIGNAL"
6339
+ local POLL_INTERVAL_SEC = 1
6340
+ local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5
6341
+ local WAIT_POLL_SEC = 0.1
6342
+ local pluginRef
6343
+ local function init(p)
6344
+ pluginRef = p
6345
+ end
6346
+ local function startMonitor()
6347
+ if not pluginRef then
6348
+ warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
6349
+ return nil
6350
+ end
6351
+ -- Clear any stale value left from a prior session. If a real stop request
6352
+ -- is in-flight when this runs, the requesting edit DM will set it again
6353
+ -- within its 2.5s wait window.
6354
+ pcall(function()
6355
+ return pluginRef:SetSetting(SETTING_KEY, false)
6356
+ end)
6357
+ task.spawn(function()
6358
+ while true do
6359
+ local okGet, val = pcall(function()
6360
+ return pluginRef:GetSetting(SETTING_KEY)
6361
+ end)
6362
+ if okGet and val == true then
6363
+ pcall(function()
6364
+ return pluginRef:SetSetting(SETTING_KEY, false)
6365
+ end)
6366
+ pcall(function()
6367
+ return StudioTestService:EndTest("stopped_by_mcp")
6368
+ end)
6369
+ end
6370
+ task.wait(POLL_INTERVAL_SEC)
6371
+ end
6372
+ end)
6373
+ end
6374
+ local function requestStop()
6375
+ if not pluginRef then
6376
+ return false
6377
+ end
6378
+ local ok = pcall(function()
6379
+ return pluginRef:SetSetting(SETTING_KEY, true)
6380
+ end)
6381
+ return ok
6382
+ end
6383
+ local function waitForConsumption()
6384
+ if not pluginRef then
6385
+ return false
6386
+ end
6387
+ local start = tick()
6388
+ while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
6389
+ local okGet, val = pcall(function()
6390
+ return pluginRef:GetSetting(SETTING_KEY)
6391
+ end)
6392
+ if okGet and val ~= true then
6393
+ return true
6394
+ end
6395
+ task.wait(WAIT_POLL_SEC)
6396
+ end
6397
+ return false
6398
+ end
6399
+ local function clearPending()
6400
+ if not pluginRef then
6401
+ return nil
6402
+ end
6403
+ pcall(function()
6404
+ return pluginRef:SetSetting(SETTING_KEY, false)
6405
+ end)
6406
+ end
6407
+ return {
6408
+ init = init,
6409
+ startMonitor = startMonitor,
6410
+ requestStop = requestStop,
6411
+ waitForConsumption = waitForConsumption,
6412
+ clearPending = clearPending,
6413
+ }
6414
+ ]]></string>
6415
+ </Properties>
6416
+ </Item>
6417
+ <Item class="ModuleScript" referent="23">
6299
6418
  <Properties>
6300
6419
  <string name="Name">UI</string>
6301
6420
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7046,7 +7165,7 @@ return {
7046
7165
  ]]></string>
7047
7166
  </Properties>
7048
7167
  </Item>
7049
- <Item class="ModuleScript" referent="23">
7168
+ <Item class="ModuleScript" referent="24">
7050
7169
  <Properties>
7051
7170
  <string name="Name">Utils</string>
7052
7171
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7576,11 +7695,11 @@ return {
7576
7695
  </Properties>
7577
7696
  </Item>
7578
7697
  </Item>
7579
- <Item class="Folder" referent="27">
7698
+ <Item class="Folder" referent="28">
7580
7699
  <Properties>
7581
7700
  <string name="Name">include</string>
7582
7701
  </Properties>
7583
- <Item class="ModuleScript" referent="24">
7702
+ <Item class="ModuleScript" referent="25">
7584
7703
  <Properties>
7585
7704
  <string name="Name">Promise</string>
7586
7705
  <string name="Source"><![CDATA[--[[
@@ -9654,7 +9773,7 @@ return Promise
9654
9773
  ]]></string>
9655
9774
  </Properties>
9656
9775
  </Item>
9657
- <Item class="ModuleScript" referent="25">
9776
+ <Item class="ModuleScript" referent="26">
9658
9777
  <Properties>
9659
9778
  <string name="Name">RuntimeLib</string>
9660
9779
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -9921,15 +10040,15 @@ return TS
9921
10040
  </Properties>
9922
10041
  </Item>
9923
10042
  </Item>
9924
- <Item class="Folder" referent="28">
10043
+ <Item class="Folder" referent="29">
9925
10044
  <Properties>
9926
10045
  <string name="Name">node_modules</string>
9927
10046
  </Properties>
9928
- <Item class="Folder" referent="29">
10047
+ <Item class="Folder" referent="30">
9929
10048
  <Properties>
9930
10049
  <string name="Name">@rbxts</string>
9931
10050
  </Properties>
9932
- <Item class="ModuleScript" referent="26">
10051
+ <Item class="ModuleScript" referent="27">
9933
10052
  <Properties>
9934
10053
  <string name="Name">services</string>
9935
10054
  <string name="Source"><![CDATA[return setmetatable({}, {