@chrrxs/robloxstudio-mcp 2.11.3 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1024 -404
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +24 -3
- package/studio-plugin/MCPPlugin.rbxmx +596 -196
- package/studio-plugin/src/modules/ClientBroker.ts +69 -35
- package/studio-plugin/src/modules/Communication.ts +101 -5
- package/studio-plugin/src/modules/LuauExec.ts +305 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +67 -31
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +6 -121
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +20 -2
- package/studio-plugin/src/types/index.d.ts +5 -2
|
@@ -1,32 +1,40 @@
|
|
|
1
|
-
// Cross-DM stop_playtest signaling via plugin:SetSetting
|
|
1
|
+
// Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
|
|
2
|
+
// per-instance setting key so the same Studio process can host playtests
|
|
3
|
+
// for multiple places without one place's stop_playtest yanking another's.
|
|
2
4
|
//
|
|
3
5
|
// `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
4
|
-
//
|
|
5
|
-
// play-
|
|
6
|
-
//
|
|
6
|
+
// shared across every DataModel the plugin runs in (edit DMs, play-server
|
|
7
|
+
// DMs, play-client DMs). For each connected place we use a dedicated key
|
|
8
|
+
// "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
|
|
7
9
|
//
|
|
8
|
-
// * The edit DM's stopPlaytest handler writes
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// * The edit DM's stopPlaytest handler writes `true` into its own key
|
|
11
|
+
// (computed from its placeId / ServerStorage anon UUID).
|
|
12
|
+
// * Each play-server DM's monitor loop polls the key matching its own
|
|
13
|
+
// instanceId at 0.1Hz; on `true` it clears the key and calls
|
|
14
|
+
// StudioTestService:EndTest. Play-server DMs for other places never
|
|
15
|
+
// touch this key.
|
|
16
|
+
// * The edit DM waits up to ~8s for its key to be cleared, confirming a
|
|
17
|
+
// matching play-server actually consumed the request.
|
|
14
18
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
// Pattern mirrors the official Roblox Studio MCP
|
|
23
|
-
// (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
|
|
19
|
+
// Earlier versions used a single shared boolean flag, which let any
|
|
20
|
+
// play-server DM in the same Studio process consume any place's stop
|
|
21
|
+
// request — silently yanking teammates' playtests. The per-key scoping
|
|
22
|
+
// below is the fix.
|
|
23
|
+
|
|
24
|
+
import { HttpService, ServerStorage } from "@rbxts/services";
|
|
24
25
|
|
|
25
26
|
const StudioTestService = game.GetService("StudioTestService");
|
|
26
27
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const SETTING_KEY_PREFIX = "MCP_STOP_PLAY_";
|
|
29
|
+
// Monitor checks the key at this cadence. 0.1s keeps worst-case detection
|
|
30
|
+
// lag tight so the consumption-confirmation window doesn't have to absorb
|
|
31
|
+
// polling jitter on top of EndTest's teardown time.
|
|
32
|
+
const POLL_INTERVAL_SEC = 0.1;
|
|
33
|
+
// Total time we wait for the matching play-server DM to consume the
|
|
34
|
+
// signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
|
|
35
|
+
// StudioTestService:EndTest teardown (several seconds on heavier places).
|
|
36
|
+
// 8s is comfortable; the tighter poll above keeps real cases well under.
|
|
37
|
+
const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
|
|
30
38
|
const WAIT_POLL_SEC = 0.1;
|
|
31
39
|
|
|
32
40
|
let pluginRef: Plugin | undefined;
|
|
@@ -35,20 +43,45 @@ function init(p: Plugin): void {
|
|
|
35
43
|
pluginRef = p;
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
// Mirror of Communication.computeInstanceId(). Duplicated here because
|
|
47
|
+
// StopPlayMonitor runs in both edit and play-server DMs, and both must
|
|
48
|
+
// agree on the place identifier (published places: placeId; unpublished:
|
|
49
|
+
// UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
50
|
+
// into the play DM).
|
|
51
|
+
function computeInstanceId(): string {
|
|
52
|
+
if (game.PlaceId !== 0) {
|
|
53
|
+
return `place:${tostring(game.PlaceId)}`;
|
|
54
|
+
}
|
|
55
|
+
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
56
|
+
if (typeIs(existing, "string") && existing !== "") {
|
|
57
|
+
return `anon:${existing as string}`;
|
|
58
|
+
}
|
|
59
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
60
|
+
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
61
|
+
return `anon:${fresh}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function settingKey(instanceId: string): string {
|
|
65
|
+
return SETTING_KEY_PREFIX + instanceId;
|
|
66
|
+
}
|
|
67
|
+
|
|
38
68
|
function startMonitor(): void {
|
|
39
69
|
if (!pluginRef) {
|
|
40
70
|
warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping");
|
|
41
71
|
return;
|
|
42
72
|
}
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
73
|
+
const myKey = settingKey(computeInstanceId());
|
|
74
|
+
// Clear any stale value left from a prior session. If a real stop
|
|
75
|
+
// request is in-flight when this runs, the requesting edit DM will
|
|
76
|
+
// write again within its consumption-confirmation window.
|
|
77
|
+
pcall(() => pluginRef!.SetSetting(myKey, false));
|
|
47
78
|
task.spawn(() => {
|
|
48
79
|
while (true) {
|
|
49
|
-
const [okGet, val] = pcall(() => pluginRef!.GetSetting(
|
|
80
|
+
const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
|
|
50
81
|
if (okGet && val === true) {
|
|
51
|
-
|
|
82
|
+
// Consume the flag first so requestStop's
|
|
83
|
+
// waitForConsumption returns success, then end the test.
|
|
84
|
+
pcall(() => pluginRef!.SetSetting(myKey, false));
|
|
52
85
|
pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
|
|
53
86
|
}
|
|
54
87
|
task.wait(POLL_INTERVAL_SEC);
|
|
@@ -58,15 +91,17 @@ function startMonitor(): void {
|
|
|
58
91
|
|
|
59
92
|
function requestStop(): boolean {
|
|
60
93
|
if (!pluginRef) return false;
|
|
61
|
-
const
|
|
94
|
+
const myKey = settingKey(computeInstanceId());
|
|
95
|
+
const [ok] = pcall(() => pluginRef!.SetSetting(myKey, true));
|
|
62
96
|
return ok;
|
|
63
97
|
}
|
|
64
98
|
|
|
65
99
|
function waitForConsumption(): boolean {
|
|
66
100
|
if (!pluginRef) return false;
|
|
101
|
+
const myKey = settingKey(computeInstanceId());
|
|
67
102
|
const start = tick();
|
|
68
103
|
while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
|
|
69
|
-
const [okGet, val] = pcall(() => pluginRef!.GetSetting(
|
|
104
|
+
const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
|
|
70
105
|
if (okGet && val !== true) return true;
|
|
71
106
|
task.wait(WAIT_POLL_SEC);
|
|
72
107
|
}
|
|
@@ -75,7 +110,8 @@ function waitForConsumption(): boolean {
|
|
|
75
110
|
|
|
76
111
|
function clearPending(): void {
|
|
77
112
|
if (!pluginRef) return;
|
|
78
|
-
|
|
113
|
+
const myKey = settingKey(computeInstanceId());
|
|
114
|
+
pcall(() => pluginRef!.SetSetting(myKey, false));
|
|
79
115
|
}
|
|
80
116
|
|
|
81
117
|
export = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CollectionService } from "@rbxts/services";
|
|
2
2
|
import Utils from "../Utils";
|
|
3
3
|
import Recording from "../Recording";
|
|
4
|
+
import LuauExec from "../LuauExec";
|
|
4
5
|
|
|
5
6
|
const ChangeHistoryService = game.GetService("ChangeHistoryService");
|
|
6
7
|
const Selection = game.GetService("Selection");
|
|
@@ -262,127 +263,11 @@ function getSelection(_requestData: Record<string, unknown>) {
|
|
|
262
263
|
function executeLuau(requestData: Record<string, unknown>) {
|
|
263
264
|
const code = requestData.code as string;
|
|
264
265
|
if (!code || code === "") return { error: "Code is required" };
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
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
|
-
}
|
|
315
|
-
|
|
316
|
-
const runViaModuleScript = (): WrapperResult => {
|
|
317
|
-
const m = new Instance("ModuleScript");
|
|
318
|
-
m.Name = "__MCPExecLuauPayload";
|
|
319
|
-
const [okSet, setErr] = pcall(() => {
|
|
320
|
-
(m as unknown as { Source: string }).Source = wrapped;
|
|
321
|
-
});
|
|
322
|
-
if (!okSet) {
|
|
323
|
-
m.Destroy();
|
|
324
|
-
error(`ModuleScript Source set failed: ${tostring(setErr)}`);
|
|
325
|
-
}
|
|
326
|
-
m.Parent = game.GetService("Workspace");
|
|
327
|
-
const [okReq, reqResult] = pcall(() => require(m));
|
|
328
|
-
m.Destroy();
|
|
329
|
-
if (!okReq) error(tostring(reqResult));
|
|
330
|
-
return reqResult as unknown as WrapperResult;
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
const isLoadstringUnavailable = (err: unknown): boolean => {
|
|
334
|
-
const errStr = tostring(err);
|
|
335
|
-
const [matchStart] = string.find(errStr, "not available", 1, true);
|
|
336
|
-
return matchStart !== undefined;
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
let [success, result] = pcall(() => {
|
|
340
|
-
const [fn, compileError] = loadstring(wrapped);
|
|
341
|
-
if (!fn) {
|
|
342
|
-
if (isLoadstringUnavailable(compileError)) {
|
|
343
|
-
return runViaModuleScript();
|
|
344
|
-
}
|
|
345
|
-
error(`Compile error: ${compileError}`);
|
|
346
|
-
}
|
|
347
|
-
return fn() as unknown as WrapperResult;
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// loadstring throws (not returns nil) in some plugin contexts when
|
|
351
|
-
// LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
352
|
-
if (!success && isLoadstringUnavailable(result)) {
|
|
353
|
-
[success, result] = pcall(runViaModuleScript);
|
|
354
|
-
}
|
|
355
|
-
|
|
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
|
-
}
|
|
367
|
-
|
|
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) {
|
|
373
|
-
return {
|
|
374
|
-
success: true,
|
|
375
|
-
returnValue: r.value !== undefined ? tostring(r.value) : undefined,
|
|
376
|
-
output,
|
|
377
|
-
message: "Code executed successfully",
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
return {
|
|
381
|
-
success: false,
|
|
382
|
-
error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
|
|
383
|
-
output,
|
|
384
|
-
message: "Code execution failed",
|
|
385
|
-
};
|
|
266
|
+
// All wrapping, print/warn capture, loadstring fallback, JSON-encoding
|
|
267
|
+
// of table returns, and parse-error recovery live in LuauExec so the
|
|
268
|
+
// edit/server (this handler) and the play-client (ClientBroker) take
|
|
269
|
+
// the same code path and produce identical output shapes.
|
|
270
|
+
return LuauExec.execute(code);
|
|
386
271
|
}
|
|
387
272
|
|
|
388
273
|
function undo(_requestData: Record<string, unknown>) {
|
|
@@ -235,9 +235,27 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
|
235
235
|
return { error: "Plugin not ready. Try again in a moment." };
|
|
236
236
|
}
|
|
237
237
|
if (!StopPlayMonitor.waitForConsumption()) {
|
|
238
|
-
//
|
|
239
|
-
//
|
|
238
|
+
// Two distinct failure modes collapse here, distinguished by whether
|
|
239
|
+
// THIS edit DM has a playtest tracked:
|
|
240
|
+
//
|
|
241
|
+
// - testRunning=false: no playtest was running from this edit DM
|
|
242
|
+
// (true negative). Return "no active playtest" — fine to retry only
|
|
243
|
+
// after actually starting a playtest.
|
|
244
|
+
// - testRunning=true: a playtest IS running but the cross-DM signal
|
|
245
|
+
// didn't propagate within the consumption timeout (false negative
|
|
246
|
+
// from the caller's perspective — playtest may actually have ended).
|
|
247
|
+
// Tell the caller it's a timing issue and they can retry.
|
|
248
|
+
//
|
|
249
|
+
// Either way clean up the pending flag so a future playtest's monitor
|
|
250
|
+
// doesn't fire EndTest on startup against a stale signal.
|
|
240
251
|
StopPlayMonitor.clearPending();
|
|
252
|
+
if (testRunning) {
|
|
253
|
+
return {
|
|
254
|
+
error:
|
|
255
|
+
"Playtest stop signal sent but consumption confirmation timed out. " +
|
|
256
|
+
"The playtest may have ended anyway; check get_connected_instances.",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
241
259
|
return { error: "No active playtest to stop." };
|
|
242
260
|
}
|
|
243
261
|
// Flag was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
@@ -33,14 +33,17 @@ export interface PollResponse {
|
|
|
33
33
|
request?: RequestPayload;
|
|
34
34
|
requestId?: string;
|
|
35
35
|
// Server signals knownInstance=false when its in-memory instances map
|
|
36
|
-
// doesn't contain our
|
|
37
|
-
// The plugin re-issues /ready when it sees this.
|
|
36
|
+
// doesn't contain our pluginSessionId (typically after an MCP process
|
|
37
|
+
// restart). The plugin re-issues /ready when it sees this.
|
|
38
38
|
knownInstance?: boolean;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface ReadyResponse {
|
|
42
42
|
success: boolean;
|
|
43
43
|
assignedRole?: string;
|
|
44
|
+
instanceId?: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
message?: string;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
declare global {
|