@chrrxs/robloxstudio-mcp-inspector 2.11.2 → 2.11.4

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,50 @@ 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
+ local errMsg = tostring(inner)
1265
+ -- pcall(require, payload) collapses parse/compile failures into the
1266
+ -- canned engine string below. The real parser diagnostic was emitted
1267
+ -- to LogService just before. Walk GetLogHistory backward for the most
1268
+ -- recent ERR entry tagged at our payload path and substitute.
1269
+ if errMsg == "Requested module experienced an error while loading" then
1270
+ -- The parser diagnostic is emitted to LogService on the next
1271
+ -- engine frame, not synchronously with pcall(require). task.wait(0)
1272
+ -- yields too early; 50ms is enough to let the frame complete and
1273
+ -- the message land in GetLogHistory.
1274
+ task.wait(0.05)
1275
+ local LogService = game:GetService("LogService")
1276
+ local hist = LogService:GetLogHistory()
1277
+ for i = #hist, 1, -1 do
1278
+ local e = hist[i]
1279
+ if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, 27) == "Workspace.__MCPEvalPayload:" then
1280
+ errMsg = e.message
1281
+ break
1282
+ end
1283
+ end
1284
+ end
1285
+ return HttpService:JSONEncode({ bridge = "ok", ok = false, error = errMsg })
1286
+ end
1287
+ -- inner is the {ok, value, output} table from our IIFE. Defensive: if it's
1288
+ -- somehow not a table (caller bypassed the wrapper), fall back to old shape.
1289
+ if typeof(inner) ~= "table" then
1290
+ return HttpService:JSONEncode({
1291
+ bridge = "ok",
1292
+ ok = true,
1293
+ result = if inner == nil then nil else tostring(inner),
1294
+ })
1295
+ end
1242
1296
  return HttpService:JSONEncode({
1243
1297
  bridge = "ok",
1244
- ok = ok,
1245
- result = if result == nil then nil else tostring(result),
1298
+ ok = inner.ok == true,
1299
+ result = if inner.ok and inner.value ~= nil then tostring(inner.value) else nil,
1300
+ error = if not inner.ok then tostring(inner.value) else nil,
1301
+ output = inner.output or {},
1246
1302
  })
1247
1303
  `;
1248
1304
  }
@@ -3073,14 +3129,42 @@ var init_proxy_bridge_service = __esm({
3073
3129
  "../core/dist/proxy-bridge-service.js"() {
3074
3130
  "use strict";
3075
3131
  init_bridge_service();
3076
- ProxyBridgeService = class extends BridgeService {
3132
+ ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
3077
3133
  primaryBaseUrl;
3078
3134
  proxyInstanceId;
3079
3135
  proxyRequestTimeout = 3e4;
3136
+ cachedInstances = [];
3137
+ refreshTimer;
3138
+ static REFRESH_INTERVAL_MS = 1e3;
3080
3139
  constructor(primaryBaseUrl) {
3081
3140
  super();
3082
3141
  this.primaryBaseUrl = primaryBaseUrl;
3083
3142
  this.proxyInstanceId = uuidv42();
3143
+ this.refreshInstances();
3144
+ this.refreshTimer = setInterval(() => this.refreshInstances(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
3145
+ }
3146
+ async refreshInstances() {
3147
+ try {
3148
+ const res = await fetch(`${this.primaryBaseUrl}/instances`);
3149
+ if (!res.ok)
3150
+ return;
3151
+ const body = await res.json();
3152
+ if (Array.isArray(body.instances)) {
3153
+ this.cachedInstances = body.instances;
3154
+ }
3155
+ } catch {
3156
+ }
3157
+ }
3158
+ getInstances() {
3159
+ return this.cachedInstances;
3160
+ }
3161
+ /** Called when this proxy is being discarded (e.g. promotion to primary
3162
+ replaced it). Stops the background refresh so it doesn't leak. */
3163
+ stop() {
3164
+ if (this.refreshTimer) {
3165
+ clearInterval(this.refreshTimer);
3166
+ this.refreshTimer = void 0;
3167
+ }
3084
3168
  }
3085
3169
  async sendRequest(endpoint, data, target = "edit") {
3086
3170
  const controller = new AbortController();
@@ -3208,8 +3292,12 @@ var init_server = __esm({
3208
3292
  const candidateApp = createHttpServer(candidateTools, candidateBridge, this.allowedToolNames, this.config);
3209
3293
  try {
3210
3294
  const result = await listenWithRetry(candidateApp, host, basePort, 1);
3295
+ const oldBridge = this.bridge;
3211
3296
  this.bridge = candidateBridge;
3212
3297
  this.tools = candidateTools;
3298
+ if (oldBridge instanceof ProxyBridgeService) {
3299
+ oldBridge.stop();
3300
+ }
3213
3301
  httpHandle = result.server;
3214
3302
  boundPort = result.port;
3215
3303
  primaryApp = candidateApp;
@@ -3272,6 +3360,9 @@ var init_server = __esm({
3272
3360
  clearInterval(cleanupInterval);
3273
3361
  if (promotionInterval)
3274
3362
  clearInterval(promotionInterval);
3363
+ if (this.bridge instanceof ProxyBridgeService) {
3364
+ this.bridge.stop();
3365
+ }
3275
3366
  await this.server.close().catch(() => {
3276
3367
  });
3277
3368
  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.4",
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",
@@ -37,7 +37,7 @@
37
37
  "url": "https://github.com/chrrxs/robloxstudio-mcp/issues"
38
38
  },
39
39
  "dependencies": {
40
- "@modelcontextprotocol/sdk": "^0.6.0",
40
+ "@modelcontextprotocol/sdk": "^1.27.1",
41
41
  "cors": "^2.8.5",
42
42
  "express": "^4.18.2",
43
43
  "node-fetch": "^3.3.2",
@@ -2827,7 +2827,9 @@ return {
2827
2827
  <string name="Name">MetadataHandlers</string>
2828
2828
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2829
2829
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2830
- local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
2830
+ local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
2831
+ local CollectionService = _services.CollectionService
2832
+ local LogService = _services.LogService
2831
2833
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2832
2834
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
2833
2835
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
@@ -3255,56 +3257,52 @@ local function executeLuau(requestData)
3255
3257
  error = "Code is required",
3256
3258
  }
3257
3259
  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.
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)())`
3294
3303
  local runViaModuleScript = function()
3295
3304
  local m = Instance.new("ModuleScript")
3296
3305
  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
3306
  local okSet, setErr = pcall(function()
3309
3307
  m.Source = wrapped
3310
3308
  end)
@@ -3318,7 +3316,26 @@ local function executeLuau(requestData)
3318
3316
  end)
3319
3317
  m:Destroy()
3320
3318
  if not okReq then
3321
- error(tostring(reqResult))
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)
3322
3339
  end
3323
3340
  return reqResult
3324
3341
  end
@@ -3328,7 +3345,7 @@ local function executeLuau(requestData)
3328
3345
  return matchStart ~= nil
3329
3346
  end
3330
3347
  local success, result = pcall(function()
3331
- local fn, compileError = loadstring(code)
3348
+ local fn, compileError = loadstring(wrapped)
3332
3349
  if not fn then
3333
3350
  if isLoadstringUnavailable(compileError) then
3334
3351
  return runViaModuleScript()
@@ -3338,27 +3355,39 @@ local function executeLuau(requestData)
3338
3355
  return fn()
3339
3356
  end)
3340
3357
  -- loadstring throws (not returns nil) in some plugin contexts when
3341
- -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3358
+ -- LoadStringEnabled=false. Catch that as a second-chance fallback.
3342
3359
  if not success and isLoadstringUnavailable(result) then
3343
3360
  success, result = pcall(runViaModuleScript)
3344
3361
  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
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.
3355
3366
  return {
3356
3367
  success = false,
3357
3368
  error = tostring(result),
3358
- output = output,
3369
+ output = {},
3359
3370
  message = "Code execution failed",
3360
3371
  }
3361
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
+ }
3362
3391
  end
3363
3392
  local function undo(_requestData)
3364
3393
  local success, result = pcall(function()
@@ -5643,6 +5672,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5643
5672
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5644
5673
  local HttpService = _services.HttpService
5645
5674
  local LogService = _services.LogService
5675
+ local RunService = _services.RunService
5646
5676
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5647
5677
  local installBridges = _EvalBridges.installBridges
5648
5678
  local cleanupBridges = _EvalBridges.cleanupBridges
@@ -5759,6 +5789,19 @@ local function startPlaytest(requestData)
5759
5789
  error = 'mode must be "play" or "run"',
5760
5790
  }
5761
5791
  end
5792
+ -- Self-heal: if testRunning is stuck true but Studio reports no active
5793
+ -- playtest, the previous start_playtest's task.spawn was orphaned
5794
+ -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
5795
+ -- Reset it so subsequent starts don't hit a false "already running".
5796
+ if testRunning and not RunService:IsRunning() then
5797
+ testRunning = false
5798
+ if logConnection then
5799
+ logConnection:Disconnect()
5800
+ logConnection = nil
5801
+ end
5802
+ cleanupStopListener()
5803
+ cleanupBridges()
5804
+ end
5762
5805
  if testRunning then
5763
5806
  return {
5764
5807
  error = "A test is already running",
@@ -5856,17 +5899,35 @@ local function stopPlaytest(_requestData)
5856
5899
  error = "Plugin not ready. Try again in a moment.",
5857
5900
  }
5858
5901
  end
5859
- if StopPlayMonitor.waitForConsumption() then
5902
+ 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.
5905
+ StopPlayMonitor.clearPending()
5906
+ return {
5907
+ error = "No active playtest to stop.",
5908
+ }
5909
+ end
5910
+ -- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
5911
+ -- startPlaytest task.spawn is still unwinding though — testRunning stays
5912
+ -- true until that yield completes and the post-block runs. Wait so
5913
+ -- back-to-back stop -> start sequences don't race against the prior
5914
+ -- teardown and get "A test is already running". 10s covers play-DM
5915
+ -- teardown on heavier places; if it still hasn't cleared we return
5916
+ -- anyway so users aren't stuck — but note that in the response so the
5917
+ -- caller knows a subsequent start may need a moment.
5918
+ local deadline = tick() + 10
5919
+ while testRunning and tick() < deadline do
5920
+ task.wait(0.1)
5921
+ end
5922
+ if testRunning then
5860
5923
  return {
5861
5924
  success = true,
5862
- message = "Playtest stopped.",
5925
+ message = "Playtest stop signal sent; teardown still in progress.",
5863
5926
  }
5864
5927
  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
5928
  return {
5869
- error = "No active playtest to stop.",
5929
+ success = true,
5930
+ message = "Playtest stopped.",
5870
5931
  }
5871
5932
  end
5872
5933
  local function getPlaytestOutput(_requestData)
@@ -6175,7 +6236,7 @@ return {
6175
6236
  <Properties>
6176
6237
  <string name="Name">State</string>
6177
6238
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6178
- local CURRENT_VERSION = "2.11.2"
6239
+ local CURRENT_VERSION = "2.11.4"
6179
6240
  local MAX_CONNECTIONS = 5
6180
6241
  local BASE_PORT = 58741
6181
6242
  local activeTabIndex = 0
@@ -2827,7 +2827,9 @@ return {
2827
2827
  <string name="Name">MetadataHandlers</string>
2828
2828
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2829
2829
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2830
- local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
2830
+ local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
2831
+ local CollectionService = _services.CollectionService
2832
+ local LogService = _services.LogService
2831
2833
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2832
2834
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
2833
2835
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
@@ -3255,56 +3257,52 @@ local function executeLuau(requestData)
3255
3257
  error = "Code is required",
3256
3258
  }
3257
3259
  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.
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)())`
3294
3303
  local runViaModuleScript = function()
3295
3304
  local m = Instance.new("ModuleScript")
3296
3305
  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
3306
  local okSet, setErr = pcall(function()
3309
3307
  m.Source = wrapped
3310
3308
  end)
@@ -3318,7 +3316,26 @@ local function executeLuau(requestData)
3318
3316
  end)
3319
3317
  m:Destroy()
3320
3318
  if not okReq then
3321
- error(tostring(reqResult))
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)
3322
3339
  end
3323
3340
  return reqResult
3324
3341
  end
@@ -3328,7 +3345,7 @@ local function executeLuau(requestData)
3328
3345
  return matchStart ~= nil
3329
3346
  end
3330
3347
  local success, result = pcall(function()
3331
- local fn, compileError = loadstring(code)
3348
+ local fn, compileError = loadstring(wrapped)
3332
3349
  if not fn then
3333
3350
  if isLoadstringUnavailable(compileError) then
3334
3351
  return runViaModuleScript()
@@ -3338,27 +3355,39 @@ local function executeLuau(requestData)
3338
3355
  return fn()
3339
3356
  end)
3340
3357
  -- loadstring throws (not returns nil) in some plugin contexts when
3341
- -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3358
+ -- LoadStringEnabled=false. Catch that as a second-chance fallback.
3342
3359
  if not success and isLoadstringUnavailable(result) then
3343
3360
  success, result = pcall(runViaModuleScript)
3344
3361
  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
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.
3355
3366
  return {
3356
3367
  success = false,
3357
3368
  error = tostring(result),
3358
- output = output,
3369
+ output = {},
3359
3370
  message = "Code execution failed",
3360
3371
  }
3361
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
+ }
3362
3391
  end
3363
3392
  local function undo(_requestData)
3364
3393
  local success, result = pcall(function()
@@ -5643,6 +5672,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5643
5672
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5644
5673
  local HttpService = _services.HttpService
5645
5674
  local LogService = _services.LogService
5675
+ local RunService = _services.RunService
5646
5676
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5647
5677
  local installBridges = _EvalBridges.installBridges
5648
5678
  local cleanupBridges = _EvalBridges.cleanupBridges
@@ -5759,6 +5789,19 @@ local function startPlaytest(requestData)
5759
5789
  error = 'mode must be "play" or "run"',
5760
5790
  }
5761
5791
  end
5792
+ -- Self-heal: if testRunning is stuck true but Studio reports no active
5793
+ -- playtest, the previous start_playtest's task.spawn was orphaned
5794
+ -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
5795
+ -- Reset it so subsequent starts don't hit a false "already running".
5796
+ if testRunning and not RunService:IsRunning() then
5797
+ testRunning = false
5798
+ if logConnection then
5799
+ logConnection:Disconnect()
5800
+ logConnection = nil
5801
+ end
5802
+ cleanupStopListener()
5803
+ cleanupBridges()
5804
+ end
5762
5805
  if testRunning then
5763
5806
  return {
5764
5807
  error = "A test is already running",
@@ -5856,17 +5899,35 @@ local function stopPlaytest(_requestData)
5856
5899
  error = "Plugin not ready. Try again in a moment.",
5857
5900
  }
5858
5901
  end
5859
- if StopPlayMonitor.waitForConsumption() then
5902
+ 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.
5905
+ StopPlayMonitor.clearPending()
5906
+ return {
5907
+ error = "No active playtest to stop.",
5908
+ }
5909
+ end
5910
+ -- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
5911
+ -- startPlaytest task.spawn is still unwinding though — testRunning stays
5912
+ -- true until that yield completes and the post-block runs. Wait so
5913
+ -- back-to-back stop -> start sequences don't race against the prior
5914
+ -- teardown and get "A test is already running". 10s covers play-DM
5915
+ -- teardown on heavier places; if it still hasn't cleared we return
5916
+ -- anyway so users aren't stuck — but note that in the response so the
5917
+ -- caller knows a subsequent start may need a moment.
5918
+ local deadline = tick() + 10
5919
+ while testRunning and tick() < deadline do
5920
+ task.wait(0.1)
5921
+ end
5922
+ if testRunning then
5860
5923
  return {
5861
5924
  success = true,
5862
- message = "Playtest stopped.",
5925
+ message = "Playtest stop signal sent; teardown still in progress.",
5863
5926
  }
5864
5927
  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
5928
  return {
5869
- error = "No active playtest to stop.",
5929
+ success = true,
5930
+ message = "Playtest stopped.",
5870
5931
  }
5871
5932
  end
5872
5933
  local function getPlaytestOutput(_requestData)
@@ -6175,7 +6236,7 @@ return {
6175
6236
  <Properties>
6176
6237
  <string name="Name">State</string>
6177
6238
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6178
- local CURRENT_VERSION = "2.11.2"
6239
+ local CURRENT_VERSION = "2.11.4"
6179
6240
  local MAX_CONNECTIONS = 5
6180
6241
  local BASE_PORT = 58741
6181
6242
  local activeTabIndex = 0
@@ -1,4 +1,4 @@
1
- import { CollectionService } from "@rbxts/services";
1
+ import { CollectionService, LogService } from "@rbxts/services";
2
2
  import Utils from "../Utils";
3
3
  import Recording from "../Recording";
4
4
 
@@ -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
  });
@@ -315,8 +326,32 @@ function executeLuau(requestData: Record<string, unknown>) {
315
326
  m.Parent = game.GetService("Workspace");
316
327
  const [okReq, reqResult] = pcall(() => require(m));
317
328
  m.Destroy();
318
- if (!okReq) error(tostring(reqResult));
319
- return reqResult;
329
+ if (!okReq) {
330
+ let errMsg = tostring(reqResult);
331
+ // pcall(require, m) collapses parse/compile failures into the
332
+ // canned engine string below. Walk LogService backward for the
333
+ // real diagnostic, which was emitted to MessageOut just before.
334
+ if (errMsg === "Requested module experienced an error while loading") {
335
+ // The parser diagnostic is emitted to LogService on the next
336
+ // engine frame, not synchronously with pcall(require). task.wait(0)
337
+ // yields too early; 50ms is enough to let the frame complete and
338
+ // the message land in GetLogHistory.
339
+ task.wait(0.05);
340
+ const hist = LogService.GetLogHistory();
341
+ for (let i = hist.size() - 1; i >= 0; i--) {
342
+ const e = hist[i];
343
+ if (
344
+ e.messageType === Enum.MessageType.MessageError &&
345
+ string.sub(e.message, 1, 31) === "Workspace.__MCPExecLuauPayload:"
346
+ ) {
347
+ errMsg = e.message;
348
+ break;
349
+ }
350
+ }
351
+ }
352
+ error(errMsg);
353
+ }
354
+ return reqResult as unknown as WrapperResult;
320
355
  };
321
356
 
322
357
  const isLoadstringUnavailable = (err: unknown): boolean => {
@@ -326,40 +361,52 @@ function executeLuau(requestData: Record<string, unknown>) {
326
361
  };
327
362
 
328
363
  let [success, result] = pcall(() => {
329
- const [fn, compileError] = loadstring(code);
364
+ const [fn, compileError] = loadstring(wrapped);
330
365
  if (!fn) {
331
366
  if (isLoadstringUnavailable(compileError)) {
332
367
  return runViaModuleScript();
333
368
  }
334
369
  error(`Compile error: ${compileError}`);
335
370
  }
336
- return fn();
371
+ return fn() as unknown as WrapperResult;
337
372
  });
338
373
 
339
374
  // loadstring throws (not returns nil) in some plugin contexts when
340
- // LoadStringEnabled=false. Catch that here as a second-chance fallback.
375
+ // LoadStringEnabled=false. Catch that as a second-chance fallback.
341
376
  if (!success && isLoadstringUnavailable(result)) {
342
377
  [success, result] = pcall(runViaModuleScript);
343
378
  }
344
379
 
345
- env["print"] = oldPrint;
346
- env["warn"] = oldWarn;
380
+ if (!success) {
381
+ // Outer pcall failed - the wrapper itself didn't even run (e.g. compile
382
+ // error in the user code, or ModuleScript setup error). 'result' is the
383
+ // raw error string from pcall.
384
+ return {
385
+ success: false,
386
+ error: tostring(result),
387
+ output: [],
388
+ message: "Code execution failed",
389
+ };
390
+ }
347
391
 
348
- if (success) {
392
+ // Wrapper executed - unpack { ok, value, output }.
393
+ const r = result as unknown as WrapperResult;
394
+ const capturedOutput = r.output as unknown as string[] | undefined;
395
+ const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
396
+ if (r.ok === true) {
349
397
  return {
350
398
  success: true,
351
- returnValue: result !== undefined ? tostring(result) : undefined,
399
+ returnValue: r.value !== undefined ? tostring(r.value) : undefined,
352
400
  output,
353
401
  message: "Code executed successfully",
354
402
  };
355
- } else {
356
- return {
357
- success: false,
358
- error: tostring(result),
359
- output,
360
- message: "Code execution failed",
361
- };
362
403
  }
404
+ return {
405
+ success: false,
406
+ error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
407
+ output,
408
+ message: "Code execution failed",
409
+ };
363
410
  }
364
411
 
365
412
  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>) {