@chrrxs/robloxstudio-mcp-inspector 2.11.4 → 2.13.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.
@@ -1,55 +1,101 @@
1
- interface VIMethods {
2
- SendMouseButtonEvent(x: number, y: number, button: number, isDown: boolean): void;
3
- SendMouseMoveEvent(x: number, y: number): void;
4
- SendMouseWheelEvent(x: number, y: number, isForward: boolean): void;
5
- SendKeyEvent(isPressed: boolean, keyCode: Enum.KeyCode, isRepeatedKey: boolean): void;
1
+ // Virtual input via UserInputService:CreateVirtualInput().
2
+ //
3
+ // We deliberately do NOT use VirtualInputManager:Send*Event — those methods
4
+ // are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
5
+ // in every context a plugin can reach (edit DM, play server/client DMs), so
6
+ // they silently never worked. CreateVirtualInput() is callable without that
7
+ // capability and drives the REAL input pipeline: SendKey feeds
8
+ // UserInputService.InputBegan/Ended and the control modules (so WASD walks the
9
+ // character at full WalkSpeed with controls intact, no Humanoid hijack),
10
+ // SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
11
+ // CoreGui), and SendTextInput types into the focused TextBox.
12
+ //
13
+ // Method set on the VirtualInput object (verified live):
14
+ // SendKey(isDown: boolean, keyCode: Enum.KeyCode)
15
+ // SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
16
+ // SendTextInput(text: string)
17
+ // There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
18
+ // "scroll" mouse actions are not supported.
19
+ //
20
+ // Coordinate space: SendMouseButton coordinates are viewport pixels matching
21
+ // what capture_screenshot returns (window space, origin at the top-left of the
22
+ // rendered viewport). Pass screenshot pixel coordinates straight through. Note
23
+ // that UserInputService reports input positions in GUI space, which is offset
24
+ // from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
25
+ // callers who pick coordinates off a screenshot, which is why we do not
26
+ // translate here.
27
+
28
+ import * as RenderMonitor from "../RenderMonitor";
29
+
30
+ const UserInputService = game.GetService("UserInputService");
31
+
32
+ interface VirtualInput {
33
+ SendKey(isDown: boolean, keyCode: Enum.KeyCode): void;
34
+ SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean): void;
35
+ SendTextInput(text: string): void;
6
36
  }
7
37
 
8
- function getVIM(): VIMethods | undefined {
9
- const [ok, result] = pcall(() => {
10
- return (game as unknown as { GetService(name: string): Instance }).GetService("VirtualInputManager");
38
+ // One VirtualInput per plugin VM, reused across calls so that a key held down
39
+ // in one call (action="press") and released in a later call (action="release")
40
+ // share the same input source.
41
+ let cachedVI: VirtualInput | undefined;
42
+
43
+ function getVI(): VirtualInput | undefined {
44
+ if (cachedVI) return cachedVI;
45
+ const [ok, vi] = pcall(() => {
46
+ return (UserInputService as unknown as { CreateVirtualInput(): unknown }).CreateVirtualInput();
11
47
  });
12
- if (ok && result) return result as unknown as VIMethods;
48
+ if (ok && vi !== undefined) {
49
+ cachedVI = vi as VirtualInput;
50
+ return cachedVI;
51
+ }
13
52
  return undefined;
14
53
  }
15
54
 
16
- const BUTTON_MAP: Record<string, number> = { Left: 0, Right: 1, Middle: 2 };
55
+ const MOUSE_TYPE_MAP: Record<string, Enum.UserInputType> = {
56
+ Left: Enum.UserInputType.MouseButton1,
57
+ Right: Enum.UserInputType.MouseButton2,
58
+ Middle: Enum.UserInputType.MouseButton3,
59
+ };
17
60
 
18
61
  function simulateMouseInput(requestData: Record<string, unknown>) {
19
62
  const action = requestData.action as string;
20
63
  const x = requestData.x as number | undefined;
21
64
  const y = requestData.y as number | undefined;
22
65
  const button = (requestData.button as string) ?? "Left";
23
- const scrollDirection = requestData.scrollDirection as string | undefined;
24
66
 
25
67
  if (!action) return { error: "action is required" };
68
+ if (x === undefined || y === undefined) {
69
+ return { error: "x and y are required" };
70
+ }
26
71
 
27
- const vim = getVIM();
28
- if (!vim) return { error: "VirtualInputManager is not available in this context" };
72
+ // Input is silently dropped by the engine when the window isn't rendering
73
+ // (e.g. minimized). Surface that instead of returning a false success.
74
+ const notRendering = RenderMonitor.notRenderingReason();
75
+ if (notRendering !== undefined) return { error: notRendering };
76
+
77
+ const vi = getVI();
78
+ if (!vi) {
79
+ return { error: "UserInputService:CreateVirtualInput() is not available in this context" };
80
+ }
29
81
 
30
- const buttonNum = BUTTON_MAP[button] ?? 0;
82
+ const inputType = MOUSE_TYPE_MAP[button] ?? Enum.UserInputType.MouseButton1;
83
+ const pos = new Vector2(x, y);
31
84
 
32
85
  const [success, err] = pcall(() => {
33
86
  if (action === "click") {
34
- if (x === undefined || y === undefined) error("x and y are required for click");
35
- vim.SendMouseButtonEvent(x, y, buttonNum, true);
87
+ vi.SendMouseButton(pos, inputType, true);
36
88
  task.wait(0.05);
37
- vim.SendMouseButtonEvent(x, y, buttonNum, false);
89
+ vi.SendMouseButton(pos, inputType, false);
38
90
  } else if (action === "mouseDown") {
39
- if (x === undefined || y === undefined) error("x and y are required for mouseDown");
40
- vim.SendMouseButtonEvent(x, y, buttonNum, true);
91
+ vi.SendMouseButton(pos, inputType, true);
41
92
  } else if (action === "mouseUp") {
42
- if (x === undefined || y === undefined) error("x and y are required for mouseUp");
43
- vim.SendMouseButtonEvent(x, y, buttonNum, false);
44
- } else if (action === "move") {
45
- if (x === undefined || y === undefined) error("x and y are required for move");
46
- vim.SendMouseMoveEvent(x, y);
47
- } else if (action === "scroll") {
48
- if (x === undefined || y === undefined) error("x and y are required for scroll");
49
- if (!scrollDirection) error("scrollDirection is required for scroll");
50
- vim.SendMouseWheelEvent(x, y, scrollDirection === "up");
93
+ vi.SendMouseButton(pos, inputType, false);
51
94
  } else {
52
- error(`Unknown action: ${action}`);
95
+ error(
96
+ `Unsupported action "${action}". CreateVirtualInput supports click, mouseDown, mouseUp ` +
97
+ `(no move/scroll — those methods don't exist on VirtualInput).`,
98
+ );
53
99
  }
54
100
  });
55
101
 
@@ -60,31 +106,46 @@ function simulateMouseInput(requestData: Record<string, unknown>) {
60
106
  }
61
107
 
62
108
  function simulateKeyboardInput(requestData: Record<string, unknown>) {
109
+ const notRendering = RenderMonitor.notRenderingReason();
110
+ if (notRendering !== undefined) return { error: notRendering };
111
+
112
+ const vi = getVI();
113
+ if (!vi) {
114
+ return { error: "UserInputService:CreateVirtualInput() is not available in this context" };
115
+ }
116
+
117
+ // Text mode: type a string into the focused TextBox.
118
+ const text = requestData.text as string | undefined;
119
+ if (text !== undefined) {
120
+ const [ok, err] = pcall(() => vi.SendTextInput(text));
121
+ if (ok) return { success: true, text };
122
+ return { error: `Failed to send text input: ${err}` };
123
+ }
124
+
63
125
  const keyCodeName = requestData.keyCode as string;
126
+ if (!keyCodeName) return { error: "keyCode (or text) is required" };
127
+
64
128
  const action = (requestData.action as string) ?? "tap";
65
129
  const duration = (requestData.duration as number) ?? 0.1;
66
130
 
67
- if (!keyCodeName) return { error: "keyCode is required" };
68
-
69
- const vim = getVIM();
70
- if (!vim) return { error: "VirtualInputManager is not available in this context" };
71
-
72
131
  const [enumOk, keyCode] = pcall(() => {
73
132
  return (Enum.KeyCode as unknown as Record<string, Enum.KeyCode>)[keyCodeName];
74
133
  });
75
134
  if (!enumOk || !keyCode) {
76
- return { error: `Unknown keyCode: ${keyCodeName}. Use Enum.KeyCode names like "W", "Space", "E", "LeftShift", etc.` };
135
+ return {
136
+ error: `Unknown keyCode: ${keyCodeName}. Use Enum.KeyCode names like "W", "Space", "E", "LeftShift", etc.`,
137
+ };
77
138
  }
78
139
 
79
140
  const [success, err] = pcall(() => {
80
141
  if (action === "press") {
81
- vim.SendKeyEvent(true, keyCode, false);
142
+ vi.SendKey(true, keyCode);
82
143
  } else if (action === "release") {
83
- vim.SendKeyEvent(false, keyCode, false);
144
+ vi.SendKey(false, keyCode);
84
145
  } else if (action === "tap") {
85
- vim.SendKeyEvent(true, keyCode, false);
146
+ vi.SendKey(true, keyCode);
86
147
  task.wait(duration);
87
- vim.SendKeyEvent(false, keyCode, false);
148
+ vi.SendKey(false, keyCode);
88
149
  } else {
89
150
  error(`Unknown action: ${action}`);
90
151
  }
@@ -1,6 +1,7 @@
1
- import { CollectionService, LogService } from "@rbxts/services";
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,151 +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
- // 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
- }
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) {
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;
355
- };
356
-
357
- const isLoadstringUnavailable = (err: unknown): boolean => {
358
- const errStr = tostring(err);
359
- const [matchStart] = string.find(errStr, "not available", 1, true);
360
- return matchStart !== undefined;
361
- };
362
-
363
- let [success, result] = pcall(() => {
364
- const [fn, compileError] = loadstring(wrapped);
365
- if (!fn) {
366
- if (isLoadstringUnavailable(compileError)) {
367
- return runViaModuleScript();
368
- }
369
- error(`Compile error: ${compileError}`);
370
- }
371
- return fn() as unknown as WrapperResult;
372
- });
373
-
374
- // loadstring throws (not returns nil) in some plugin contexts when
375
- // LoadStringEnabled=false. Catch that as a second-chance fallback.
376
- if (!success && isLoadstringUnavailable(result)) {
377
- [success, result] = pcall(runViaModuleScript);
378
- }
379
-
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
- }
391
-
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) {
397
- return {
398
- success: true,
399
- returnValue: r.value !== undefined ? tostring(r.value) : undefined,
400
- output,
401
- message: "Code executed successfully",
402
- };
403
- }
404
- return {
405
- success: false,
406
- error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
407
- output,
408
- message: "Code execution failed",
409
- };
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);
410
271
  }
411
272
 
412
273
  function undo(_requestData: Record<string, unknown>) {
@@ -1,5 +1,5 @@
1
1
  import { HttpService, LogService, RunService } from "@rbxts/services";
2
- import { installBridges, cleanupBridges } from "../EvalBridges";
2
+ import { installBridges, ensureBridgesInstalled } from "../EvalBridges";
3
3
  import StopPlayMonitor from "../StopPlayMonitor";
4
4
 
5
5
  const StudioTestService = game.GetService("StudioTestService");
@@ -135,7 +135,9 @@ function startPlaytest(requestData: Record<string, unknown>) {
135
135
  logConnection = undefined;
136
136
  }
137
137
  cleanupStopListener();
138
- cleanupBridges();
138
+ // Note: eval bridges are intentionally NOT cleaned up — they live
139
+ // permanently in the edit DM so manual playtests also get them. See
140
+ // EvalBridges.ts lifecycle comment.
139
141
  }
140
142
 
141
143
  if (testRunning) {
@@ -169,9 +171,10 @@ function startPlaytest(requestData: Record<string, unknown>) {
169
171
  warn(`[MCP] Failed to inject stop listener: ${injErr}`);
170
172
  }
171
173
 
172
- // Auto-install the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
173
- // so eval_server_runtime / eval_client_runtime work without manual setup.
174
- // Bridges are cleaned up from the edit DM after the play DMs tear down.
174
+ // Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
175
+ // right before cloning so the play DMs get the current source. They also
176
+ // live permanently in the edit DM (installed on connect) so manually-started
177
+ // playtests get them too; here we just ensure they're fresh.
175
178
  const bridgeInstall = installBridges();
176
179
  if (!bridgeInstall.installed) {
177
180
  warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
@@ -203,7 +206,9 @@ function startPlaytest(requestData: Record<string, unknown>) {
203
206
  testRunning = false;
204
207
 
205
208
  cleanupStopListener();
206
- cleanupBridges();
209
+ // Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
210
+ // clean up here, so the next manual playtest still gets them.
211
+ ensureBridgesInstalled();
207
212
  });
208
213
 
209
214
  const msg = numPlayers !== undefined
@@ -235,9 +240,27 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
235
240
  return { error: "Plugin not ready. Try again in a moment." };
236
241
  }
237
242
  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.
243
+ // Two distinct failure modes collapse here, distinguished by whether
244
+ // THIS edit DM has a playtest tracked:
245
+ //
246
+ // - testRunning=false: no playtest was running from this edit DM
247
+ // (true negative). Return "no active playtest" — fine to retry only
248
+ // after actually starting a playtest.
249
+ // - testRunning=true: a playtest IS running but the cross-DM signal
250
+ // didn't propagate within the consumption timeout (false negative
251
+ // from the caller's perspective — playtest may actually have ended).
252
+ // Tell the caller it's a timing issue and they can retry.
253
+ //
254
+ // Either way clean up the pending flag so a future playtest's monitor
255
+ // doesn't fire EndTest on startup against a stale signal.
240
256
  StopPlayMonitor.clearPending();
257
+ if (testRunning) {
258
+ return {
259
+ error:
260
+ "Playtest stop signal sent but consumption confirmation timed out. " +
261
+ "The playtest may have ended anyway; check get_connected_instances.",
262
+ };
263
+ }
241
264
  return { error: "No active playtest to stop." };
242
265
  }
243
266
  // Flag was consumed (EndTest called). ExecutePlayModeAsync in our
@@ -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
@@ -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 instanceId (typically after an MCP process restart).
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 {