@chrrxs/robloxstudio-mcp-inspector 2.11.2 → 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.
package/dist/index.js CHANGED
@@ -1217,7 +1217,28 @@ ${s}
1217
1217
  }
1218
1218
  function buildModuleScriptInvokeWrapper(opts) {
1219
1219
  const wrapped = `return ((function()
1220
+ local __mcp_output = {}
1221
+ local __mcp_real_print = print
1222
+ local __mcp_real_warn = warn
1223
+ local print = function(...)
1224
+ __mcp_real_print(...)
1225
+ local args = {...}
1226
+ local parts = table.create(#args)
1227
+ for i, a in ipairs(args) do parts[i] = tostring(a) end
1228
+ table.insert(__mcp_output, table.concat(parts, "\\t"))
1229
+ end
1230
+ local warn = function(...)
1231
+ __mcp_real_warn(...)
1232
+ local args = {...}
1233
+ local parts = table.create(#args)
1234
+ for i, a in ipairs(args) do parts[i] = tostring(a) end
1235
+ table.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
1236
+ end
1237
+ local function __mcp_run()
1220
1238
  ${opts.userCode}
1239
+ end
1240
+ local ok, errOrValue = xpcall(__mcp_run, debug.traceback)
1241
+ return { ok = ok, value = errOrValue, output = __mcp_output }
1221
1242
  end)())`;
1222
1243
  return `
1223
1244
  local HttpService = game:GetService("HttpService")
@@ -1234,15 +1255,29 @@ m.Name = "__MCPEvalPayload"
1234
1255
  local okSet, setErr = pcall(function() m.Source = USER_CODE end)
1235
1256
  if not okSet then
1236
1257
  m:Destroy()
1237
- return HttpService:JSONEncode({ bridge = "ok", ok = false, result = "ModuleScript Source set failed: " .. tostring(setErr) })
1258
+ return HttpService:JSONEncode({ bridge = "ok", ok = false, error = "ModuleScript Source set failed: " .. tostring(setErr) })
1238
1259
  end
1239
1260
  m.Parent = workspace
1240
- local ok, result = bf:Invoke(m)
1261
+ local bridgeOk, inner = bf:Invoke(m)
1241
1262
  m:Destroy()
1263
+ if not bridgeOk then
1264
+ return HttpService:JSONEncode({ bridge = "ok", ok = false, error = tostring(inner) })
1265
+ end
1266
+ -- inner is the {ok, value, output} table from our IIFE. Defensive: if it's
1267
+ -- somehow not a table (caller bypassed the wrapper), fall back to old shape.
1268
+ if typeof(inner) ~= "table" then
1269
+ return HttpService:JSONEncode({
1270
+ bridge = "ok",
1271
+ ok = true,
1272
+ result = if inner == nil then nil else tostring(inner),
1273
+ })
1274
+ end
1242
1275
  return HttpService:JSONEncode({
1243
1276
  bridge = "ok",
1244
- ok = ok,
1245
- result = if result == nil then nil else tostring(result),
1277
+ ok = inner.ok == true,
1278
+ result = if inner.ok and inner.value ~= nil then tostring(inner.value) else nil,
1279
+ error = if not inner.ok then tostring(inner.value) else nil,
1280
+ output = inner.output or {},
1246
1281
  })
1247
1282
  `;
1248
1283
  }
@@ -3073,14 +3108,42 @@ var init_proxy_bridge_service = __esm({
3073
3108
  "../core/dist/proxy-bridge-service.js"() {
3074
3109
  "use strict";
3075
3110
  init_bridge_service();
3076
- ProxyBridgeService = class extends BridgeService {
3111
+ ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
3077
3112
  primaryBaseUrl;
3078
3113
  proxyInstanceId;
3079
3114
  proxyRequestTimeout = 3e4;
3115
+ cachedInstances = [];
3116
+ refreshTimer;
3117
+ static REFRESH_INTERVAL_MS = 1e3;
3080
3118
  constructor(primaryBaseUrl) {
3081
3119
  super();
3082
3120
  this.primaryBaseUrl = primaryBaseUrl;
3083
3121
  this.proxyInstanceId = uuidv42();
3122
+ this.refreshInstances();
3123
+ this.refreshTimer = setInterval(() => this.refreshInstances(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
3124
+ }
3125
+ async refreshInstances() {
3126
+ try {
3127
+ const res = await fetch(`${this.primaryBaseUrl}/instances`);
3128
+ if (!res.ok)
3129
+ return;
3130
+ const body = await res.json();
3131
+ if (Array.isArray(body.instances)) {
3132
+ this.cachedInstances = body.instances;
3133
+ }
3134
+ } catch {
3135
+ }
3136
+ }
3137
+ getInstances() {
3138
+ return this.cachedInstances;
3139
+ }
3140
+ /** Called when this proxy is being discarded (e.g. promotion to primary
3141
+ replaced it). Stops the background refresh so it doesn't leak. */
3142
+ stop() {
3143
+ if (this.refreshTimer) {
3144
+ clearInterval(this.refreshTimer);
3145
+ this.refreshTimer = void 0;
3146
+ }
3084
3147
  }
3085
3148
  async sendRequest(endpoint, data, target = "edit") {
3086
3149
  const controller = new AbortController();
@@ -3208,8 +3271,12 @@ var init_server = __esm({
3208
3271
  const candidateApp = createHttpServer(candidateTools, candidateBridge, this.allowedToolNames, this.config);
3209
3272
  try {
3210
3273
  const result = await listenWithRetry(candidateApp, host, basePort, 1);
3274
+ const oldBridge = this.bridge;
3211
3275
  this.bridge = candidateBridge;
3212
3276
  this.tools = candidateTools;
3277
+ if (oldBridge instanceof ProxyBridgeService) {
3278
+ oldBridge.stop();
3279
+ }
3213
3280
  httpHandle = result.server;
3214
3281
  boundPort = result.port;
3215
3282
  primaryApp = candidateApp;
@@ -3272,6 +3339,9 @@ var init_server = __esm({
3272
3339
  clearInterval(cleanupInterval);
3273
3340
  if (promotionInterval)
3274
3341
  clearInterval(promotionInterval);
3342
+ if (this.bridge instanceof ProxyBridgeService) {
3343
+ this.bridge.stop();
3344
+ }
3275
3345
  await this.server.close().catch(() => {
3276
3346
  });
3277
3347
  if (httpHandle)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.11.2",
3
+ "version": "2.11.3",
4
4
  "description": "Read-only MCP Server for Roblox Studio (fork of boshyxd/robloxstudio-mcp-inspector with per-peer execute_luau fixes baked in)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -3255,56 +3255,52 @@ local function executeLuau(requestData)
3255
3255
  error = "Code is required",
3256
3256
  }
3257
3257
  end
3258
- local output = {}
3259
- local oldPrint = print
3260
- local oldWarn = warn
3261
- local env = getfenv(0)
3262
- env.print = function(...)
3263
- local args = { ... }
3264
- local parts = {}
3265
- for _, a in args do
3266
- local _arg0 = tostring(a)
3267
- table.insert(parts, _arg0)
3268
- end
3269
- local _arg0 = table.concat(parts, "\t")
3270
- table.insert(output, _arg0)
3271
- oldPrint(unpack(args))
3272
- end
3273
- env.warn = function(...)
3274
- local args = { ... }
3275
- local parts = {}
3276
- for _, a in args do
3277
- local _arg0 = tostring(a)
3278
- table.insert(parts, _arg0)
3279
- end
3280
- local _arg0 = `[warn] {table.concat(parts, "\t")}`
3281
- table.insert(output, _arg0)
3282
- oldWarn(unpack(args))
3283
- end
3284
- -- Try loadstring first (preserves print/warn interception). When
3285
- -- ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
3286
- -- peer where the engine respects that gate (notably the play-server DM
3287
- -- in some Studio configurations), loadstring either returns nil with a
3288
- -- "loadstring() is not available" message OR throws that same message
3289
- -- directly. Both paths must trigger the ModuleScript + require
3290
- -- fallback. The fallback can't intercept print/warn since the
3291
- -- ModuleScript runs in its own environment, so the output array stays
3292
- -- empty in that branch - the playtest log buffer already captures
3293
- -- 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)())`
3294
3301
  local runViaModuleScript = function()
3295
3302
  local m = Instance.new("ModuleScript")
3296
3303
  m.Name = "__MCPExecLuauPayload"
3297
- -- Wrap user code in an IIFE so require() always gets exactly one
3298
- -- return value. Without this, code like `print("x")` errors with
3299
- -- "Module code did not return exactly one value" because top-level
3300
- -- ModuleScripts must return exactly one value.
3301
- --
3302
- -- The DOUBLE parens around the call are load-bearing: in Luau,
3303
- -- `return f()` propagates whatever multi-value tuple f returns,
3304
- -- including zero values. Outer parens adjust the call to exactly
3305
- -- one value (the first, or nil). So `return ((f)())` always
3306
- -- returns exactly one value, regardless of what f does.
3307
- local wrapped = `return ((function()\n{code}\nend)())`
3308
3304
  local okSet, setErr = pcall(function()
3309
3305
  m.Source = wrapped
3310
3306
  end)
@@ -3328,7 +3324,7 @@ local function executeLuau(requestData)
3328
3324
  return matchStart ~= nil
3329
3325
  end
3330
3326
  local success, result = pcall(function()
3331
- local fn, compileError = loadstring(code)
3327
+ local fn, compileError = loadstring(wrapped)
3332
3328
  if not fn then
3333
3329
  if isLoadstringUnavailable(compileError) then
3334
3330
  return runViaModuleScript()
@@ -3338,27 +3334,39 @@ local function executeLuau(requestData)
3338
3334
  return fn()
3339
3335
  end)
3340
3336
  -- loadstring throws (not returns nil) in some plugin contexts when
3341
- -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3337
+ -- LoadStringEnabled=false. Catch that as a second-chance fallback.
3342
3338
  if not success and isLoadstringUnavailable(result) then
3343
3339
  success, result = pcall(runViaModuleScript)
3344
3340
  end
3345
- env.print = oldPrint
3346
- env.warn = oldWarn
3347
- if success then
3348
- return {
3349
- success = true,
3350
- returnValue = if result ~= nil then tostring(result) else nil,
3351
- output = output,
3352
- message = "Code executed successfully",
3353
- }
3354
- 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.
3355
3345
  return {
3356
3346
  success = false,
3357
3347
  error = tostring(result),
3358
- output = output,
3348
+ output = {},
3359
3349
  message = "Code execution failed",
3360
3350
  }
3361
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
+ }
3362
3370
  end
3363
3371
  local function undo(_requestData)
3364
3372
  local success, result = pcall(function()
@@ -5643,6 +5651,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5643
5651
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5644
5652
  local HttpService = _services.HttpService
5645
5653
  local LogService = _services.LogService
5654
+ local RunService = _services.RunService
5646
5655
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5647
5656
  local installBridges = _EvalBridges.installBridges
5648
5657
  local cleanupBridges = _EvalBridges.cleanupBridges
@@ -5759,6 +5768,19 @@ local function startPlaytest(requestData)
5759
5768
  error = 'mode must be "play" or "run"',
5760
5769
  }
5761
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
5762
5784
  if testRunning then
5763
5785
  return {
5764
5786
  error = "A test is already running",
@@ -5856,17 +5878,35 @@ local function stopPlaytest(_requestData)
5856
5878
  error = "Plugin not ready. Try again in a moment.",
5857
5879
  }
5858
5880
  end
5859
- if StopPlayMonitor.waitForConsumption() then
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
5860
5902
  return {
5861
5903
  success = true,
5862
- message = "Playtest stopped.",
5904
+ message = "Playtest stop signal sent; teardown still in progress.",
5863
5905
  }
5864
5906
  end
5865
- -- Clean up the pending flag so a future playtest's monitor doesn't fire
5866
- -- EndTest on its own startup against a stale signal.
5867
- StopPlayMonitor.clearPending()
5868
5907
  return {
5869
- error = "No active playtest to stop.",
5908
+ success = true,
5909
+ message = "Playtest stopped.",
5870
5910
  }
5871
5911
  end
5872
5912
  local function getPlaytestOutput(_requestData)
@@ -6175,7 +6215,7 @@ return {
6175
6215
  <Properties>
6176
6216
  <string name="Name">State</string>
6177
6217
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6178
- local CURRENT_VERSION = "2.11.2"
6218
+ local CURRENT_VERSION = "2.11.3"
6179
6219
  local MAX_CONNECTIONS = 5
6180
6220
  local BASE_PORT = 58741
6181
6221
  local activeTabIndex = 0
@@ -3255,56 +3255,52 @@ local function executeLuau(requestData)
3255
3255
  error = "Code is required",
3256
3256
  }
3257
3257
  end
3258
- local output = {}
3259
- local oldPrint = print
3260
- local oldWarn = warn
3261
- local env = getfenv(0)
3262
- env.print = function(...)
3263
- local args = { ... }
3264
- local parts = {}
3265
- for _, a in args do
3266
- local _arg0 = tostring(a)
3267
- table.insert(parts, _arg0)
3268
- end
3269
- local _arg0 = table.concat(parts, "\t")
3270
- table.insert(output, _arg0)
3271
- oldPrint(unpack(args))
3272
- end
3273
- env.warn = function(...)
3274
- local args = { ... }
3275
- local parts = {}
3276
- for _, a in args do
3277
- local _arg0 = tostring(a)
3278
- table.insert(parts, _arg0)
3279
- end
3280
- local _arg0 = `[warn] {table.concat(parts, "\t")}`
3281
- table.insert(output, _arg0)
3282
- oldWarn(unpack(args))
3283
- end
3284
- -- Try loadstring first (preserves print/warn interception). When
3285
- -- ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
3286
- -- peer where the engine respects that gate (notably the play-server DM
3287
- -- in some Studio configurations), loadstring either returns nil with a
3288
- -- "loadstring() is not available" message OR throws that same message
3289
- -- directly. Both paths must trigger the ModuleScript + require
3290
- -- fallback. The fallback can't intercept print/warn since the
3291
- -- ModuleScript runs in its own environment, so the output array stays
3292
- -- empty in that branch - the playtest log buffer already captures
3293
- -- 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)())`
3294
3301
  local runViaModuleScript = function()
3295
3302
  local m = Instance.new("ModuleScript")
3296
3303
  m.Name = "__MCPExecLuauPayload"
3297
- -- Wrap user code in an IIFE so require() always gets exactly one
3298
- -- return value. Without this, code like `print("x")` errors with
3299
- -- "Module code did not return exactly one value" because top-level
3300
- -- ModuleScripts must return exactly one value.
3301
- --
3302
- -- The DOUBLE parens around the call are load-bearing: in Luau,
3303
- -- `return f()` propagates whatever multi-value tuple f returns,
3304
- -- including zero values. Outer parens adjust the call to exactly
3305
- -- one value (the first, or nil). So `return ((f)())` always
3306
- -- returns exactly one value, regardless of what f does.
3307
- local wrapped = `return ((function()\n{code}\nend)())`
3308
3304
  local okSet, setErr = pcall(function()
3309
3305
  m.Source = wrapped
3310
3306
  end)
@@ -3328,7 +3324,7 @@ local function executeLuau(requestData)
3328
3324
  return matchStart ~= nil
3329
3325
  end
3330
3326
  local success, result = pcall(function()
3331
- local fn, compileError = loadstring(code)
3327
+ local fn, compileError = loadstring(wrapped)
3332
3328
  if not fn then
3333
3329
  if isLoadstringUnavailable(compileError) then
3334
3330
  return runViaModuleScript()
@@ -3338,27 +3334,39 @@ local function executeLuau(requestData)
3338
3334
  return fn()
3339
3335
  end)
3340
3336
  -- loadstring throws (not returns nil) in some plugin contexts when
3341
- -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3337
+ -- LoadStringEnabled=false. Catch that as a second-chance fallback.
3342
3338
  if not success and isLoadstringUnavailable(result) then
3343
3339
  success, result = pcall(runViaModuleScript)
3344
3340
  end
3345
- env.print = oldPrint
3346
- env.warn = oldWarn
3347
- if success then
3348
- return {
3349
- success = true,
3350
- returnValue = if result ~= nil then tostring(result) else nil,
3351
- output = output,
3352
- message = "Code executed successfully",
3353
- }
3354
- 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.
3355
3345
  return {
3356
3346
  success = false,
3357
3347
  error = tostring(result),
3358
- output = output,
3348
+ output = {},
3359
3349
  message = "Code execution failed",
3360
3350
  }
3361
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
+ }
3362
3370
  end
3363
3371
  local function undo(_requestData)
3364
3372
  local success, result = pcall(function()
@@ -5643,6 +5651,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5643
5651
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5644
5652
  local HttpService = _services.HttpService
5645
5653
  local LogService = _services.LogService
5654
+ local RunService = _services.RunService
5646
5655
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5647
5656
  local installBridges = _EvalBridges.installBridges
5648
5657
  local cleanupBridges = _EvalBridges.cleanupBridges
@@ -5759,6 +5768,19 @@ local function startPlaytest(requestData)
5759
5768
  error = 'mode must be "play" or "run"',
5760
5769
  }
5761
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
5762
5784
  if testRunning then
5763
5785
  return {
5764
5786
  error = "A test is already running",
@@ -5856,17 +5878,35 @@ local function stopPlaytest(_requestData)
5856
5878
  error = "Plugin not ready. Try again in a moment.",
5857
5879
  }
5858
5880
  end
5859
- if StopPlayMonitor.waitForConsumption() then
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
5860
5902
  return {
5861
5903
  success = true,
5862
- message = "Playtest stopped.",
5904
+ message = "Playtest stop signal sent; teardown still in progress.",
5863
5905
  }
5864
5906
  end
5865
- -- Clean up the pending flag so a future playtest's monitor doesn't fire
5866
- -- EndTest on its own startup against a stale signal.
5867
- StopPlayMonitor.clearPending()
5868
5907
  return {
5869
- error = "No active playtest to stop.",
5908
+ success = true,
5909
+ message = "Playtest stopped.",
5870
5910
  }
5871
5911
  end
5872
5912
  local function getPlaytestOutput(_requestData)
@@ -6175,7 +6215,7 @@ return {
6175
6215
  <Properties>
6176
6216
  <string name="Name">State</string>
6177
6217
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6178
- local CURRENT_VERSION = "2.11.2"
6218
+ local CURRENT_VERSION = "2.11.3"
6179
6219
  local MAX_CONNECTIONS = 5
6180
6220
  local BASE_PORT = 58741
6181
6221
  local activeTabIndex = 0
@@ -263,48 +263,59 @@ function executeLuau(requestData: Record<string, unknown>) {
263
263
  const code = requestData.code as string;
264
264
  if (!code || code === "") return { error: "Code is required" };
265
265
 
266
- const output: string[] = [];
267
- const oldPrint = print;
268
- const oldWarn = warn;
269
-
270
- const env = getfenv(0) as unknown as Record<string, unknown>;
271
- env["print"] = (...args: defined[]) => {
272
- const parts: string[] = [];
273
- for (const a of args) parts.push(tostring(a));
274
- output.push(parts.join("\t"));
275
- oldPrint(...(args as [defined, ...defined[]]));
276
- };
277
- env["warn"] = (...args: defined[]) => {
278
- const parts: string[] = [];
279
- for (const a of args) parts.push(tostring(a));
280
- output.push(`[warn] ${parts.join("\t")}`);
281
- oldWarn(...(args as [defined, ...defined[]]));
282
- };
266
+ // Both execution paths (loadstring + ModuleScript-require fallback) run
267
+ // the SAME wrapped source so they return a uniform { ok, value, output }
268
+ // shape. Two problems the wrapper solves at once:
269
+ //
270
+ // 1. pcall(require, m) swallows the real error and returns Roblox's
271
+ // generic "Requested module experienced an error while loading"
272
+ // message. Wrapping user code in xpcall INSIDE the IIFE keeps the
273
+ // ModuleScript itself returning successfully the real error +
274
+ // traceback live in the returned table.
275
+ //
276
+ // 2. The ModuleScript path runs in its own environment, so a plugin-
277
+ // side getfenv print/warn override never reached user prints. A
278
+ // lexical local print/warn inside the IIFE captures user prints
279
+ // regardless of which path executes. We also call the real global
280
+ // print/warn so messages still flow to Studio's output and
281
+ // LogService.MessageOut (which powers get_runtime_logs).
282
+ //
283
+ // Prints from required sub-modules don't reach this capture (they have
284
+ // their own env) — those go through the runtime log buffer.
285
+ const wrapped = `return ((function()
286
+ \tlocal __mcp_output = {}
287
+ \tlocal __mcp_real_print = print
288
+ \tlocal __mcp_real_warn = warn
289
+ \tlocal print = function(...)
290
+ \t\t__mcp_real_print(...)
291
+ \t\tlocal args = {...}
292
+ \t\tlocal parts = table.create(#args)
293
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
294
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
295
+ \tend
296
+ \tlocal warn = function(...)
297
+ \t\t__mcp_real_warn(...)
298
+ \t\tlocal args = {...}
299
+ \t\tlocal parts = table.create(#args)
300
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
301
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
302
+ \tend
303
+ \tlocal function __mcp_run()
304
+ ${code}
305
+ \tend
306
+ \tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)
307
+ \treturn { ok = ok, value = errOrValue, output = __mcp_output }
308
+ end)())`;
309
+
310
+ interface WrapperResult {
311
+ ok?: boolean;
312
+ value?: unknown;
313
+ output?: defined;
314
+ }
283
315
 
284
- // Try loadstring first (preserves print/warn interception). When
285
- // ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
286
- // peer where the engine respects that gate (notably the play-server DM
287
- // in some Studio configurations), loadstring either returns nil with a
288
- // "loadstring() is not available" message OR throws that same message
289
- // directly. Both paths must trigger the ModuleScript + require
290
- // fallback. The fallback can't intercept print/warn since the
291
- // ModuleScript runs in its own environment, so the output array stays
292
- // empty in that branch - the playtest log buffer already captures
293
- // prints separately via LogService.MessageOut.
294
- const runViaModuleScript = () => {
316
+ const runViaModuleScript = (): WrapperResult => {
295
317
  const m = new Instance("ModuleScript");
296
318
  m.Name = "__MCPExecLuauPayload";
297
- // Wrap user code in an IIFE so require() always gets exactly one
298
- // return value. Without this, code like `print("x")` errors with
299
- // "Module code did not return exactly one value" because top-level
300
- // ModuleScripts must return exactly one value.
301
- //
302
- // The DOUBLE parens around the call are load-bearing: in Luau,
303
- // `return f()` propagates whatever multi-value tuple f returns,
304
- // including zero values. Outer parens adjust the call to exactly
305
- // one value (the first, or nil). So `return ((f)())` always
306
- // returns exactly one value, regardless of what f does.
307
- const wrapped = `return ((function()\n${code}\nend)())`;
308
319
  const [okSet, setErr] = pcall(() => {
309
320
  (m as unknown as { Source: string }).Source = wrapped;
310
321
  });
@@ -316,7 +327,7 @@ function executeLuau(requestData: Record<string, unknown>) {
316
327
  const [okReq, reqResult] = pcall(() => require(m));
317
328
  m.Destroy();
318
329
  if (!okReq) error(tostring(reqResult));
319
- return reqResult;
330
+ return reqResult as unknown as WrapperResult;
320
331
  };
321
332
 
322
333
  const isLoadstringUnavailable = (err: unknown): boolean => {
@@ -326,40 +337,52 @@ function executeLuau(requestData: Record<string, unknown>) {
326
337
  };
327
338
 
328
339
  let [success, result] = pcall(() => {
329
- const [fn, compileError] = loadstring(code);
340
+ const [fn, compileError] = loadstring(wrapped);
330
341
  if (!fn) {
331
342
  if (isLoadstringUnavailable(compileError)) {
332
343
  return runViaModuleScript();
333
344
  }
334
345
  error(`Compile error: ${compileError}`);
335
346
  }
336
- return fn();
347
+ return fn() as unknown as WrapperResult;
337
348
  });
338
349
 
339
350
  // loadstring throws (not returns nil) in some plugin contexts when
340
- // LoadStringEnabled=false. Catch that here as a second-chance fallback.
351
+ // LoadStringEnabled=false. Catch that as a second-chance fallback.
341
352
  if (!success && isLoadstringUnavailable(result)) {
342
353
  [success, result] = pcall(runViaModuleScript);
343
354
  }
344
355
 
345
- env["print"] = oldPrint;
346
- env["warn"] = oldWarn;
356
+ if (!success) {
357
+ // Outer pcall failed - the wrapper itself didn't even run (e.g. compile
358
+ // error in the user code, or ModuleScript setup error). 'result' is the
359
+ // raw error string from pcall.
360
+ return {
361
+ success: false,
362
+ error: tostring(result),
363
+ output: [],
364
+ message: "Code execution failed",
365
+ };
366
+ }
347
367
 
348
- if (success) {
368
+ // Wrapper executed - unpack { ok, value, output }.
369
+ const r = result as unknown as WrapperResult;
370
+ const capturedOutput = r.output as unknown as string[] | undefined;
371
+ const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
372
+ if (r.ok === true) {
349
373
  return {
350
374
  success: true,
351
- returnValue: result !== undefined ? tostring(result) : undefined,
375
+ returnValue: r.value !== undefined ? tostring(r.value) : undefined,
352
376
  output,
353
377
  message: "Code executed successfully",
354
378
  };
355
- } else {
356
- return {
357
- success: false,
358
- error: tostring(result),
359
- output,
360
- message: "Code execution failed",
361
- };
362
379
  }
380
+ return {
381
+ success: false,
382
+ error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
383
+ output,
384
+ message: "Code execution failed",
385
+ };
363
386
  }
364
387
 
365
388
  function undo(_requestData: Record<string, unknown>) {
@@ -1,4 +1,4 @@
1
- import { HttpService, LogService } from "@rbxts/services";
1
+ import { HttpService, LogService, RunService } from "@rbxts/services";
2
2
  import { installBridges, cleanupBridges } from "../EvalBridges";
3
3
  import StopPlayMonitor from "../StopPlayMonitor";
4
4
 
@@ -124,6 +124,20 @@ function startPlaytest(requestData: Record<string, unknown>) {
124
124
  return { error: 'mode must be "play" or "run"' };
125
125
  }
126
126
 
127
+ // Self-heal: if testRunning is stuck true but Studio reports no active
128
+ // playtest, the previous start_playtest's task.spawn was orphaned
129
+ // (plugin reload mid-test, Studio entered some inconsistent state, etc).
130
+ // Reset it so subsequent starts don't hit a false "already running".
131
+ if (testRunning && !RunService.IsRunning()) {
132
+ testRunning = false;
133
+ if (logConnection) {
134
+ logConnection.Disconnect();
135
+ logConnection = undefined;
136
+ }
137
+ cleanupStopListener();
138
+ cleanupBridges();
139
+ }
140
+
127
141
  if (testRunning) {
128
142
  return { error: "A test is already running" };
129
143
  }
@@ -220,13 +234,31 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
220
234
  if (!StopPlayMonitor.requestStop()) {
221
235
  return { error: "Plugin not ready. Try again in a moment." };
222
236
  }
223
- if (StopPlayMonitor.waitForConsumption()) {
224
- return { success: true, message: "Playtest stopped." };
237
+ if (!StopPlayMonitor.waitForConsumption()) {
238
+ // Clean up the pending flag so a future playtest's monitor doesn't fire
239
+ // EndTest on its own startup against a stale signal.
240
+ StopPlayMonitor.clearPending();
241
+ return { error: "No active playtest to stop." };
242
+ }
243
+ // Flag was consumed (EndTest called). ExecutePlayModeAsync in our
244
+ // startPlaytest task.spawn is still unwinding though — testRunning stays
245
+ // true until that yield completes and the post-block runs. Wait so
246
+ // back-to-back stop -> start sequences don't race against the prior
247
+ // teardown and get "A test is already running". 10s covers play-DM
248
+ // teardown on heavier places; if it still hasn't cleared we return
249
+ // anyway so users aren't stuck — but note that in the response so the
250
+ // caller knows a subsequent start may need a moment.
251
+ const deadline = tick() + 10;
252
+ while (testRunning && tick() < deadline) {
253
+ task.wait(0.1);
254
+ }
255
+ if (testRunning) {
256
+ return {
257
+ success: true,
258
+ message: "Playtest stop signal sent; teardown still in progress.",
259
+ };
225
260
  }
226
- // Clean up the pending flag so a future playtest's monitor doesn't fire
227
- // EndTest on its own startup against a stale signal.
228
- StopPlayMonitor.clearPending();
229
- return { error: "No active playtest to stop." };
261
+ return { success: true, message: "Playtest stopped." };
230
262
  }
231
263
 
232
264
  function getPlaytestOutput(_requestData: Record<string, unknown>) {