@chrrxs/robloxstudio-mcp 2.12.0 → 2.14.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 +1508 -64
- package/package.json +2 -2
- package/studio-plugin/INSTALLATION.md +1 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +1312 -325
- package/studio-plugin/MCPPlugin.rbxmx +705 -97
- package/studio-plugin/src/modules/ClientBroker.ts +91 -1
- package/studio-plugin/src/modules/Communication.ts +22 -0
- package/studio-plugin/src/modules/EvalBridges.ts +60 -11
- package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +45 -3
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +100 -39
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +257 -18
- package/studio-plugin/src/server/index.server.ts +6 -0
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { HttpService, LogService, RunService } from "@rbxts/services";
|
|
2
|
-
import { installBridges,
|
|
1
|
+
import { HttpService, LogService, Players, RunService } from "@rbxts/services";
|
|
2
|
+
import { installBridges, ensureBridgesInstalled } from "../EvalBridges";
|
|
3
3
|
import StopPlayMonitor from "../StopPlayMonitor";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
6
|
+
ExecuteMultiplayerTestAsync(numPlayers: number, testArgs: unknown): unknown;
|
|
7
|
+
AddPlayers(numPlayers: number): void;
|
|
8
|
+
CanLeaveTest(): boolean;
|
|
9
|
+
LeaveTest(): void;
|
|
10
|
+
EditModeActive: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
|
|
6
14
|
const ServerScriptService = game.GetService("ServerScriptService");
|
|
7
15
|
const ScriptEditorService = game.GetService("ScriptEditorService");
|
|
8
16
|
|
|
@@ -27,6 +35,59 @@ let testError: string | undefined;
|
|
|
27
35
|
let stopListenerScript: Script | undefined;
|
|
28
36
|
let navResultCallback: ((json: string) => void) | undefined;
|
|
29
37
|
|
|
38
|
+
type MultiplayerPhase = "idle" | "starting" | "running" | "completed" | "failed";
|
|
39
|
+
|
|
40
|
+
interface MultiplayerSessionState {
|
|
41
|
+
phase: MultiplayerPhase;
|
|
42
|
+
testId?: string;
|
|
43
|
+
numPlayers?: number;
|
|
44
|
+
testArgs?: unknown;
|
|
45
|
+
startedAt?: number;
|
|
46
|
+
completedAt?: number;
|
|
47
|
+
ok?: boolean;
|
|
48
|
+
result?: unknown;
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let multiplayerState: MultiplayerSessionState = { phase: "idle" };
|
|
53
|
+
|
|
54
|
+
function detectPeerRole(): string {
|
|
55
|
+
if (!RunService.IsRunning()) return "edit";
|
|
56
|
+
if (RunService.IsServer()) return "server";
|
|
57
|
+
return "client";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getPlayersSnapshot() {
|
|
61
|
+
const players = Players.GetPlayers().map((player) => ({
|
|
62
|
+
name: player.Name,
|
|
63
|
+
userId: player.UserId,
|
|
64
|
+
displayName: player.DisplayName,
|
|
65
|
+
}));
|
|
66
|
+
players.sort((a, b) => a.name < b.name);
|
|
67
|
+
return players;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cloneMultiplayerState(): MultiplayerSessionState {
|
|
71
|
+
return {
|
|
72
|
+
phase: multiplayerState.phase,
|
|
73
|
+
testId: multiplayerState.testId,
|
|
74
|
+
numPlayers: multiplayerState.numPlayers,
|
|
75
|
+
testArgs: multiplayerState.testArgs,
|
|
76
|
+
startedAt: multiplayerState.startedAt,
|
|
77
|
+
completedAt: multiplayerState.completedAt,
|
|
78
|
+
ok: multiplayerState.ok,
|
|
79
|
+
result: multiplayerState.result,
|
|
80
|
+
error: multiplayerState.error,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeNumPlayers(value: unknown): number | undefined {
|
|
85
|
+
if (!typeIs(value, "number")) return undefined;
|
|
86
|
+
const n = math.floor(value);
|
|
87
|
+
if (n !== value || n < 1 || n > 8) return undefined;
|
|
88
|
+
return n;
|
|
89
|
+
}
|
|
90
|
+
|
|
30
91
|
function buildCommandListenerSource(): string {
|
|
31
92
|
return `local LogService = game:GetService("LogService")
|
|
32
93
|
local PathfindingService = game:GetService("PathfindingService")
|
|
@@ -124,6 +185,10 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
124
185
|
return { error: 'mode must be "play" or "run"' };
|
|
125
186
|
}
|
|
126
187
|
|
|
188
|
+
if (numPlayers !== undefined) {
|
|
189
|
+
return { error: "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions." };
|
|
190
|
+
}
|
|
191
|
+
|
|
127
192
|
// Self-heal: if testRunning is stuck true but Studio reports no active
|
|
128
193
|
// playtest, the previous start_playtest's task.spawn was orphaned
|
|
129
194
|
// (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
@@ -135,7 +200,9 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
135
200
|
logConnection = undefined;
|
|
136
201
|
}
|
|
137
202
|
cleanupStopListener();
|
|
138
|
-
|
|
203
|
+
// Note: eval bridges are intentionally NOT cleaned up — they live
|
|
204
|
+
// permanently in the edit DM so manual playtests also get them. See
|
|
205
|
+
// EvalBridges.ts lifecycle comment.
|
|
139
206
|
}
|
|
140
207
|
|
|
141
208
|
if (testRunning) {
|
|
@@ -169,19 +236,15 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
169
236
|
warn(`[MCP] Failed to inject stop listener: ${injErr}`);
|
|
170
237
|
}
|
|
171
238
|
|
|
172
|
-
//
|
|
173
|
-
// so
|
|
174
|
-
//
|
|
239
|
+
// Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
|
|
240
|
+
// right before cloning so the play DMs get the current source. They also
|
|
241
|
+
// live permanently in the edit DM (installed on connect) so manually-started
|
|
242
|
+
// playtests get them too; here we just ensure they're fresh.
|
|
175
243
|
const bridgeInstall = installBridges();
|
|
176
244
|
if (!bridgeInstall.installed) {
|
|
177
245
|
warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
|
|
178
246
|
}
|
|
179
247
|
|
|
180
|
-
if (numPlayers !== undefined && mode === "run") {
|
|
181
|
-
const TestService = game.GetService("TestService") as TestService & { NumberOfPlayers: number };
|
|
182
|
-
TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
248
|
task.spawn(() => {
|
|
186
249
|
const [ok, result] = pcall(() => {
|
|
187
250
|
if (mode === "play") {
|
|
@@ -203,16 +266,14 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
203
266
|
testRunning = false;
|
|
204
267
|
|
|
205
268
|
cleanupStopListener();
|
|
206
|
-
|
|
269
|
+
// Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
|
|
270
|
+
// clean up here, so the next manual playtest still gets them.
|
|
271
|
+
ensureBridgesInstalled();
|
|
207
272
|
});
|
|
208
273
|
|
|
209
|
-
const msg = numPlayers !== undefined
|
|
210
|
-
? `Playtest started in ${mode} mode with ${numPlayers} player(s).`
|
|
211
|
-
: `Playtest started in ${mode} mode.`;
|
|
212
|
-
|
|
213
274
|
const response: Record<string, unknown> = {
|
|
214
275
|
success: true,
|
|
215
|
-
message:
|
|
276
|
+
message: `Playtest started in ${mode} mode.`,
|
|
216
277
|
};
|
|
217
278
|
// Only mention eval bridges when they failed — when they're fine, the
|
|
218
279
|
// detail is noise. eval_server_runtime / eval_client_runtime will surface
|
|
@@ -289,6 +350,179 @@ function getPlaytestOutput(_requestData: Record<string, unknown>) {
|
|
|
289
350
|
};
|
|
290
351
|
}
|
|
291
352
|
|
|
353
|
+
function multiplayerTestStart(requestData: Record<string, unknown>) {
|
|
354
|
+
if (RunService.IsRunning()) {
|
|
355
|
+
return { error: "multiplayer_test_start must be called on the edit DataModel. Route with target=edit." };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const numPlayers = normalizeNumPlayers(requestData.numPlayers);
|
|
359
|
+
if (numPlayers === undefined) {
|
|
360
|
+
return { error: "numPlayers must be an integer from 1 to 8" };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (multiplayerState.phase === "starting" || multiplayerState.phase === "running") {
|
|
364
|
+
return {
|
|
365
|
+
error: "A multiplayer Studio test is already running",
|
|
366
|
+
state: cloneMultiplayerState(),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const testArgs = requestData.testArgs !== undefined ? requestData.testArgs : {};
|
|
371
|
+
const testId = HttpService.GenerateGUID(false);
|
|
372
|
+
|
|
373
|
+
const bridgeInstall = installBridges();
|
|
374
|
+
if (!bridgeInstall.installed) {
|
|
375
|
+
warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
multiplayerState = {
|
|
379
|
+
phase: "starting",
|
|
380
|
+
testId,
|
|
381
|
+
numPlayers,
|
|
382
|
+
testArgs,
|
|
383
|
+
startedAt: tick(),
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
task.spawn(() => {
|
|
387
|
+
multiplayerState.phase = "running";
|
|
388
|
+
const [ok, result] = pcall(() => {
|
|
389
|
+
return StudioTestService.ExecuteMultiplayerTestAsync(numPlayers, testArgs);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
multiplayerState.completedAt = tick();
|
|
393
|
+
multiplayerState.ok = ok;
|
|
394
|
+
if (ok) {
|
|
395
|
+
multiplayerState.phase = "completed";
|
|
396
|
+
multiplayerState.result = result;
|
|
397
|
+
multiplayerState.error = undefined;
|
|
398
|
+
} else {
|
|
399
|
+
multiplayerState.phase = "failed";
|
|
400
|
+
multiplayerState.result = undefined;
|
|
401
|
+
multiplayerState.error = tostring(result);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
ensureBridgesInstalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const response: Record<string, unknown> = {
|
|
408
|
+
success: true,
|
|
409
|
+
message: `Multiplayer Studio test starting with ${numPlayers} player(s).`,
|
|
410
|
+
testId,
|
|
411
|
+
phase: multiplayerState.phase,
|
|
412
|
+
numPlayers,
|
|
413
|
+
testArgs,
|
|
414
|
+
};
|
|
415
|
+
if (!bridgeInstall.installed) {
|
|
416
|
+
response.evalBridgesError = bridgeInstall.error;
|
|
417
|
+
}
|
|
418
|
+
return response;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function multiplayerTestState(_requestData: Record<string, unknown>) {
|
|
422
|
+
const peer = detectPeerRole();
|
|
423
|
+
const response: Record<string, unknown> = {
|
|
424
|
+
success: true,
|
|
425
|
+
peer,
|
|
426
|
+
isRunning: RunService.IsRunning(),
|
|
427
|
+
isRunMode: RunService.IsRunMode(),
|
|
428
|
+
editModeActive: StudioTestService.EditModeActive,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
if (peer === "edit") {
|
|
432
|
+
response.session = cloneMultiplayerState();
|
|
433
|
+
return response;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [argsOk, args] = pcall(() => StudioTestService.GetTestArgs());
|
|
437
|
+
response.testArgsOk = argsOk;
|
|
438
|
+
response.testArgs = argsOk ? args : undefined;
|
|
439
|
+
if (!argsOk) response.testArgsError = tostring(args);
|
|
440
|
+
|
|
441
|
+
const players = getPlayersSnapshot();
|
|
442
|
+
response.players = players;
|
|
443
|
+
response.playerCount = players.size();
|
|
444
|
+
|
|
445
|
+
if (peer === "client") {
|
|
446
|
+
response.localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
|
|
447
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
448
|
+
response.canLeaveOk = canLeaveOk;
|
|
449
|
+
response.canLeave = canLeaveOk ? canLeave : false;
|
|
450
|
+
if (!canLeaveOk) response.canLeaveError = tostring(canLeave);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return response;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function multiplayerTestAddPlayers(requestData: Record<string, unknown>) {
|
|
457
|
+
if (!RunService.IsRunning() || !RunService.IsServer()) {
|
|
458
|
+
return { error: "multiplayer_test_add_players must be called on the running server peer. Route with target=server." };
|
|
459
|
+
}
|
|
460
|
+
const numPlayers = normalizeNumPlayers(requestData.numPlayers);
|
|
461
|
+
if (numPlayers === undefined) {
|
|
462
|
+
return { error: "numPlayers must be an integer from 1 to 8" };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const before = Players.GetPlayers().size();
|
|
466
|
+
const [ok, result] = pcall(() => StudioTestService.AddPlayers(numPlayers));
|
|
467
|
+
if (!ok) {
|
|
468
|
+
return { error: tostring(result) };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const deadline = tick() + ((requestData.timeout as number | undefined) ?? 10);
|
|
472
|
+
while (Players.GetPlayers().size() < before + numPlayers && tick() < deadline) {
|
|
473
|
+
task.wait(0.1);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const players = getPlayersSnapshot();
|
|
477
|
+
return {
|
|
478
|
+
success: true,
|
|
479
|
+
message: `Requested ${numPlayers} additional player(s).`,
|
|
480
|
+
playerCount: players.size(),
|
|
481
|
+
players,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function multiplayerTestLeaveClient(_requestData: Record<string, unknown>) {
|
|
486
|
+
if (!RunService.IsRunning() || RunService.IsServer()) {
|
|
487
|
+
return { error: "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N." };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
491
|
+
if (!canLeaveOk) {
|
|
492
|
+
return { error: tostring(canLeave), canLeaveOk: false };
|
|
493
|
+
}
|
|
494
|
+
if (!canLeave) {
|
|
495
|
+
return { error: "This client cannot leave the current test session.", canLeaveOk: true, canLeave: false };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
|
|
499
|
+
task.defer(() => {
|
|
500
|
+
pcall(() => StudioTestService.LeaveTest());
|
|
501
|
+
});
|
|
502
|
+
return {
|
|
503
|
+
success: true,
|
|
504
|
+
message: "Client leave requested.",
|
|
505
|
+
localPlayer,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function multiplayerTestEnd(requestData: Record<string, unknown>) {
|
|
510
|
+
if (!RunService.IsRunning() || !RunService.IsServer()) {
|
|
511
|
+
return { error: "multiplayer_test_end must be called on the running server peer. Route with target=server." };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const value = requestData.value !== undefined ? requestData.value : "ended_by_mcp";
|
|
515
|
+
const [ok, result] = pcall(() => StudioTestService.EndTest(value));
|
|
516
|
+
if (!ok) {
|
|
517
|
+
return { error: tostring(result) };
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
success: true,
|
|
521
|
+
message: "Multiplayer Studio test end requested.",
|
|
522
|
+
value,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
292
526
|
function characterNavigation(requestData: Record<string, unknown>) {
|
|
293
527
|
if (!testRunning) {
|
|
294
528
|
return { error: "Playtest must be running. Start a playtest in 'play' mode first." };
|
|
@@ -339,5 +573,10 @@ export = {
|
|
|
339
573
|
startPlaytest,
|
|
340
574
|
stopPlaytest,
|
|
341
575
|
getPlaytestOutput,
|
|
576
|
+
multiplayerTestStart,
|
|
577
|
+
multiplayerTestState,
|
|
578
|
+
multiplayerTestAddPlayers,
|
|
579
|
+
multiplayerTestLeaveClient,
|
|
580
|
+
multiplayerTestEnd,
|
|
342
581
|
characterNavigation,
|
|
343
582
|
};
|
|
@@ -4,6 +4,12 @@ import Communication from "../modules/Communication";
|
|
|
4
4
|
import ClientBroker from "../modules/ClientBroker";
|
|
5
5
|
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
6
6
|
import StopPlayMonitor from "../modules/StopPlayMonitor";
|
|
7
|
+
import * as RenderMonitor from "../modules/RenderMonitor";
|
|
8
|
+
|
|
9
|
+
// Track render-loop liveness so input/screenshot tools can report "window
|
|
10
|
+
// minimized / not rendering" instead of silently no-op'ing. No-op in the
|
|
11
|
+
// server DM (RenderStepped can't connect there).
|
|
12
|
+
RenderMonitor.start();
|
|
7
13
|
|
|
8
14
|
// Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
9
15
|
// boot-time prints from the user's place scripts are captured. Powers the
|