@chrrxs/robloxstudio-mcp 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,
|
|
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
|
|
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
|
|
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",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.4",
|
|
4
4
|
"description": "MCP Server for Roblox Studio Integration (fork of boshyxd/robloxstudio-mcp with per-peer execute_luau + stop_playtest fixes)",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -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
|
|
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
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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)
|
|
319
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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>) {
|