@chrrxs/robloxstudio-mcp 3.0.0 → 3.0.2

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.
Files changed (48) hide show
  1. package/dist/index.js +9401 -9047
  2. package/package.json +2 -2
  3. package/studio-plugin/MCPPlugin.rbxmx +400 -97
  4. package/studio-plugin/.Carbon.rbxm.lock +0 -0
  5. package/studio-plugin/Carbon.rbxm +0 -0
  6. package/studio-plugin/INSTALLATION.md +0 -170
  7. package/studio-plugin/MCPInspectorPlugin.rbxmx +0 -169759
  8. package/studio-plugin/default.project.json +0 -19
  9. package/studio-plugin/dev.project.json +0 -23
  10. package/studio-plugin/include/LibMP.lua +0 -156378
  11. package/studio-plugin/inspector-icon.png +0 -0
  12. package/studio-plugin/package-lock.json +0 -706
  13. package/studio-plugin/package.json +0 -19
  14. package/studio-plugin/plugin.json +0 -10
  15. package/studio-plugin/src/modules/AssetSanitizationPolicy.ts +0 -127
  16. package/studio-plugin/src/modules/ClientBroker.ts +0 -450
  17. package/studio-plugin/src/modules/Communication.ts +0 -601
  18. package/studio-plugin/src/modules/EvalBridges.ts +0 -255
  19. package/studio-plugin/src/modules/HttpDiagnostics.ts +0 -50
  20. package/studio-plugin/src/modules/LuauExec.ts +0 -403
  21. package/studio-plugin/src/modules/Recording.ts +0 -28
  22. package/studio-plugin/src/modules/RenderMonitor.ts +0 -60
  23. package/studio-plugin/src/modules/RuntimeLogBuffer.ts +0 -210
  24. package/studio-plugin/src/modules/ServerUrlSettings.ts +0 -117
  25. package/studio-plugin/src/modules/State.ts +0 -39
  26. package/studio-plugin/src/modules/StopPlayMonitor.ts +0 -267
  27. package/studio-plugin/src/modules/UI.ts +0 -597
  28. package/studio-plugin/src/modules/Utils.ts +0 -527
  29. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +0 -391
  30. package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +0 -460
  31. package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +0 -170
  32. package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +0 -149
  33. package/studio-plugin/src/modules/handlers/GenerateModelHandlers.ts +0 -168
  34. package/studio-plugin/src/modules/handlers/InputHandlers.ts +0 -163
  35. package/studio-plugin/src/modules/handlers/LogHandlers.ts +0 -14
  36. package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +0 -44
  37. package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +0 -96
  38. package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +0 -1263
  39. package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +0 -62
  40. package/studio-plugin/src/modules/handlers/QueryHandlers.ts +0 -716
  41. package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +0 -216
  42. package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +0 -531
  43. package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +0 -386
  44. package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +0 -172
  45. package/studio-plugin/src/modules/handlers/TestHandlers.ts +0 -350
  46. package/studio-plugin/src/server/index.server.ts +0 -135
  47. package/studio-plugin/src/types/index.d.ts +0 -57
  48. package/studio-plugin/tsconfig.json +0 -20
@@ -1,403 +0,0 @@
1
- /* eslint-disable */
2
- // Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
3
- // and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
4
- // module owns:
5
- //
6
- // 1. The IIFE wrapper that captures print/warn, wraps require() so nested
7
- // ModuleScript load failures can recover the real LogService diagnostic,
8
- // runs user code in xpcall, and always returns { ok, value, output } so
9
- // the ModuleScript itself always returns exactly one value (otherwise
10
- // `print("hi")` with no return would fail with "Module code did not
11
- // return exactly one value").
12
- //
13
- // 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
14
- // recovery hack that pulls the real diagnostic from LogService.
15
- //
16
- // 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
17
- // caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
18
- // pass through tostring. The encode is pcall'd so cycles or
19
- // non-serializable values gracefully fall back to tostring.
20
- //
21
- // Before this module existed, the client peer used a stripped-down
22
- // require-only execution path that lacked both the wrapper and the JSON
23
- // formatting, producing two well-known papercuts:
24
- // - `print("hi")` (no return) failed with "Module code did not return..."
25
- // - Returning a table yielded `table: 0xaddr` instead of structured data.
26
-
27
- const HttpService = game.GetService("HttpService");
28
- const LogService = game.GetService("LogService");
29
-
30
- interface WrapperResult {
31
- ok?: boolean;
32
- value?: unknown;
33
- output?: defined;
34
- }
35
-
36
- interface ExecuteResult {
37
- success: boolean;
38
- returnValue?: string;
39
- output?: string[];
40
- error?: string;
41
- message?: string;
42
- }
43
-
44
- const PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload";
45
- const REQUIRE_GENERIC_ERROR = "Requested module experienced an error while loading";
46
-
47
- // Number of lines the wrapper emits BEFORE the first line of user code.
48
- // Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
49
- // (remapPayloadLines, for compile errors recovered from LogService) so user
50
- // code errors report user-relative line numbers instead of the inflated
51
- // "line 49" the wrapper would otherwise expose. If you reorder buildWrapper's
52
- // prefix lines, update this constant.
53
- const WRAPPER_LINE_OFFSET = 84;
54
-
55
- // Count source lines so the wrapper can filter traceback frames that fall
56
- // outside the user code range (the wrapper's own preamble/postamble lines).
57
- function countLines(s: string): number {
58
- let n = 1;
59
- const size = s.size();
60
- for (let i = 1; i <= size; i++) {
61
- if (string.sub(s, i, i) === "\n") n++;
62
- }
63
- return n;
64
- }
65
-
66
- function luaPatternEscape(s: string): string {
67
- const [escaped] = string.gsub(s, "([^%w])", "%%%1");
68
- return escaped;
69
- }
70
-
71
- function buildWrapper(code: string, payloadInstanceName = PAYLOAD_INSTANCE_NAME): string {
72
- // If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
73
- // match the number of lines emitted BEFORE the ${code} substitution.
74
- // The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
75
- // used by remapPayloadLines on the TS side.
76
- const userLines = countLines(code);
77
- const payloadPattern = luaPatternEscape(payloadInstanceName);
78
- return `return ((function()
79
- \tlocal __mcp_traceback
80
- \tlocal __mcp_remap
81
- \tlocal __mcp_LINE_OFFSET = ${WRAPPER_LINE_OFFSET}
82
- \tlocal __mcp_USER_LINES = ${userLines}
83
- \tlocal __mcp_LogService = game:GetService("LogService")
84
- \tlocal __mcp_REQUIRE_GENERIC = "${REQUIRE_GENERIC_ERROR}"
85
- \tlocal __mcp_output = {}
86
- \tlocal __mcp_real_print = print
87
- \tlocal __mcp_real_warn = warn
88
- \tlocal __mcp_real_require = require
89
- \tlocal print = function(...)
90
- \t\t__mcp_real_print(...)
91
- \t\tlocal args = {...}
92
- \t\tlocal parts = table.create(#args)
93
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
94
- \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
95
- \tend
96
- \tlocal warn = function(...)
97
- \t\t__mcp_real_warn(...)
98
- \t\tlocal args = {...}
99
- \t\tlocal parts = table.create(#args)
100
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
101
- \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
102
- \tend
103
- \tlocal function __mcp_is_stack_noise(msg)
104
- \t\treturn msg == "Stack Begin" or msg == "Stack End" or string.sub(msg, 1, 8) == "Script '"
105
- \tend
106
- \tlocal function __mcp_is_actionable_require_log(entry)
107
- \t\tif not entry or entry.messageType ~= Enum.MessageType.MessageError then return false end
108
- \t\tlocal msg = tostring(entry.message)
109
- \t\treturn msg ~= __mcp_REQUIRE_GENERIC and not __mcp_is_stack_noise(msg)
110
- \tend
111
- \tlocal function __mcp_entry_mentions_module(entry, module_path)
112
- \t\tif not entry or not module_path or module_path == "" then return false end
113
- \t\treturn string.find(tostring(entry.message), module_path, 1, true) ~= nil
114
- \tend
115
- \tlocal function __mcp_prior_module_error(hist, module_path)
116
- \t\tif not module_path or module_path == "" then return nil end
117
- \t\tfor i = #hist, 1, -1 do
118
- \t\t\tlocal entry = hist[i]
119
- \t\t\tif __mcp_entry_mentions_module(entry, module_path) then
120
- \t\t\t\tif __mcp_is_actionable_require_log(entry) then
121
- \t\t\t\t\treturn tostring(entry.message)
122
- \t\t\t\tend
123
- \t\t\t\tfor j = i - 1, math.max(1, i - 6), -1 do
124
- \t\t\t\t\tlocal previous = hist[j]
125
- \t\t\t\t\tif __mcp_is_actionable_require_log(previous) then
126
- \t\t\t\t\t\treturn tostring(previous.message)
127
- \t\t\t\t\tend
128
- \t\t\t\tend
129
- \t\t\tend
130
- \t\tend
131
- \t\treturn nil
132
- \tend
133
- \tlocal function __mcp_recover_require_error(err, history_start, module)
134
- \t\tlocal err_msg = tostring(err)
135
- \t\tif err_msg ~= __mcp_REQUIRE_GENERIC then return err_msg end
136
- \t\tlocal module_path
137
- \t\tif typeof(module) == "Instance" then
138
- \t\t\tlocal ok_path, path = pcall(function()
139
- \t\t\t\treturn module:GetFullName()
140
- \t\t\tend)
141
- \t\t\tif ok_path then module_path = path end
142
- \t\tend
143
- \t\ttask.wait(0.05)
144
- \t\tlocal hist = __mcp_LogService:GetLogHistory()
145
- \t\tfor i = #hist, history_start + 1, -1 do
146
- \t\t\tlocal entry = hist[i]
147
- \t\t\tif __mcp_is_actionable_require_log(entry) then
148
- \t\t\t\treturn tostring(entry.message)
149
- \t\t\tend
150
- \t\tend
151
- \t\tlocal prior = __mcp_prior_module_error(hist, module_path)
152
- \t\tif prior then return prior end
153
- \t\treturn err_msg
154
- \tend
155
- \tlocal function require(module)
156
- \t\tlocal history_start = #__mcp_LogService:GetLogHistory()
157
- \t\tlocal ok, value = pcall(__mcp_real_require, module)
158
- \t\tif ok then return value end
159
- \t\terror(__mcp_recover_require_error(value, history_start, module), 0)
160
- \tend
161
- \tlocal function __mcp_run()
162
- ${code}
163
- \tend
164
- \t__mcp_remap = function(s)
165
- \t\t-- Two chunk-name formats can reference our payload:
166
- \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path
167
- \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)
168
- \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.
169
- \t\t-- Clamping matters for unclosed constructs ("local x = (") where the
170
- \t\t-- parser keeps reading into wrapper postamble and reports a payload
171
- \t\t-- line past user EOF. Without clamping, that frames wrapper postamble
172
- \t\t-- as user code.
173
- \t\tlocal function __mcp_user_line(payload_n)
174
- \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET
175
- \t\t\tif user_n < 1 then return "1" end
176
- \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
177
- \t\t\treturn tostring(user_n)
178
- \t\tend
179
- \t\ts = string.gsub(s, "Workspace%.${payloadPattern}:(%d+)", function(num)
180
- \t\t\tlocal n = tonumber(num)
181
- \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
182
- \t\t\treturn "user_code:" .. num
183
- \t\tend)
184
- \t\ts = string.gsub(s, "${payloadPattern}:(%d+)", function(num)
185
- \t\t\tlocal n = tonumber(num)
186
- \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
187
- \t\t\treturn "user_code:" .. num
188
- \t\tend)
189
- \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
190
- \t\t\tlocal n = tonumber(num)
191
- \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
192
- \t\t\treturn "user_code:" .. num
193
- \t\tend)
194
- \t\treturn s
195
- \tend
196
- \t__mcp_traceback = function(err)
197
- \t\tlocal raw = debug.traceback(tostring(err), 2)
198
- \t\tlocal kept = {}
199
- \t\tfor line in string.gmatch(raw, "[^\\n]+") do
200
- \t\t\t-- Extract referenced line number (either chunk-name format).
201
- \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")
202
- \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')
203
- \t\t\tlocal n = num_str and tonumber(num_str)
204
- \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing
205
- \t\t\t-- any filtering, because user-code frames carry that suffix —
206
- \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY
207
- \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and
208
- \t\t\t-- get dropped. Strip first, then apply filters.
209
- \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))
210
- \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)
211
- \t\t\t\tor string.find(line, "__mcp_", 1, true)
212
- \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)
213
- \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside
214
- \t\t\t-- user range) are wrapper internals — drop them. Lines without
215
- \t\t\t-- a payload-chunk line number (the traceback header / engine
216
- \t\t\t-- C frames) are kept; remap is a no-op for them.
217
- \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then
218
- \t\t\t\tskip = true
219
- \t\t\tend
220
- \t\t\tif not skip then
221
- \t\t\t\ttable.insert(kept, __mcp_remap(line))
222
- \t\t\tend
223
- \t\tend
224
- \t\treturn table.concat(kept, "\\n")
225
- \tend
226
- \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)
227
- \treturn { ok = ok, value = errOrValue, output = __mcp_output }
228
- end)())`;
229
- }
230
-
231
- // TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
232
- // pulling the real compile-error diagnostic out of LogService — that error
233
- // references the payload module's line number directly, and never passes
234
- // through the IIFE's runtime wrapper.
235
- function remapPayloadLines(s: string, userLines: number, payloadInstanceName = PAYLOAD_INSTANCE_NAME): string {
236
- // Mirror of the Lua __mcp_remap inside the wrapper, for paths that
237
- // don't pass through the IIFE (compile errors recovered from
238
- // LogService, the immediate loadstring compileError surface). Same
239
- // two-format coverage plus the same clamp: unclosed user constructs
240
- // let the parser consume wrapper postamble, so the raw payload line
241
- // is sometimes well past user EOF — clamp to [1, userLines] and
242
- // annotate so the error doesn't say "user_code:49" for one-line input.
243
- const userLine = (payload: number): string => {
244
- const u = payload - WRAPPER_LINE_OFFSET;
245
- if (u < 1) return "1";
246
- if (u > userLines) return `${tostring(userLines)} (at end of input)`;
247
- return tostring(u);
248
- };
249
- const payloadPattern = luaPatternEscape(payloadInstanceName);
250
- let out = s;
251
- const [a] = string.gsub(out, `Workspace%.${payloadPattern}:(%d+)`, (num: string) => {
252
- const n = tonumber(num);
253
- if (n !== undefined) return `user_code:${userLine(n)}`;
254
- return `user_code:${num}`;
255
- });
256
- out = a;
257
- const [b] = string.gsub(out, `${payloadPattern}:(%d+)`, (num: string) => {
258
- const n = tonumber(num);
259
- if (n !== undefined) return `user_code:${userLine(n)}`;
260
- return `user_code:${num}`;
261
- });
262
- out = b;
263
- const [c] = string.gsub(out, '%[string "[^"]+"%]:(%d+)', (num: string) => {
264
- const n = tonumber(num);
265
- if (n !== undefined) return `user_code:${userLine(n)}`;
266
- return `user_code:${num}`;
267
- });
268
- return c;
269
- }
270
-
271
- function runViaModuleScript(wrapped: string, userLines: number): WrapperResult {
272
- const m = new Instance("ModuleScript");
273
- m.Name = PAYLOAD_INSTANCE_NAME;
274
- const [okSet, setErr] = pcall(() => {
275
- (m as unknown as { Source: string }).Source = wrapped;
276
- });
277
- if (!okSet) {
278
- m.Destroy();
279
- // error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
280
- // prefix that error() would otherwise prepend, keeping the visible
281
- // message focused on the user-actionable error rather than our path.
282
- error(`ModuleScript Source set failed: ${tostring(setErr)}`, 0);
283
- }
284
- m.Parent = game.GetService("Workspace");
285
- const [okReq, reqResult] = pcall(() => require(m));
286
- m.Destroy();
287
- if (!okReq) {
288
- // Compile errors reference the payload module's line number directly
289
- // — remap + clamp to user-relative line numbers so `local x = 1 +`
290
- // reports :1: instead of :23:, and reports the clamp annotation
291
- // when the parser ran off the end of user code into wrapper code.
292
- error(recoverPayloadRequireError(reqResult, userLines, PAYLOAD_INSTANCE_NAME), 0);
293
- }
294
- return reqResult as unknown as WrapperResult;
295
- }
296
-
297
- function isLoadstringUnavailable(err: unknown): boolean {
298
- const errStr = tostring(err);
299
- const [matchStart] = string.find(errStr, "not available", 1, true);
300
- return matchStart !== undefined;
301
- }
302
-
303
- // Returns a string suitable for `returnValue`. Tables get JSON-encoded so
304
- // the caller sees structured data instead of "table: 0xaddr". Anything that
305
- // JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
306
- function formatReturnValue(value: unknown): string {
307
- if (value === undefined) return "";
308
- if (typeIs(value, "table")) {
309
- const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
310
- if (ok) return encoded as string;
311
- }
312
- return tostring(value);
313
- }
314
-
315
- function recoverPayloadRequireError(
316
- err: unknown,
317
- userLines: number,
318
- payloadInstanceName = PAYLOAD_INSTANCE_NAME,
319
- historyStart = 0,
320
- ): string {
321
- let errMsg = tostring(err);
322
- // pcall(require, m) collapses parse/compile failures into the canned
323
- // engine string. The real diagnostic is emitted to LogService on the
324
- // next engine frame — give it ~50ms to land then scan backward.
325
- if (errMsg === REQUIRE_GENERIC_ERROR) {
326
- task.wait(0.05);
327
- const payloadPathPrefix = `Workspace.${payloadInstanceName}:`;
328
- const hist = LogService.GetLogHistory();
329
- const start = math.max(0, historyStart);
330
- for (let i = hist.size() - 1; i >= start; i--) {
331
- const e = hist[i];
332
- if (
333
- e.messageType === Enum.MessageType.MessageError &&
334
- string.sub(e.message, 1, payloadPathPrefix.size()) === payloadPathPrefix
335
- ) {
336
- errMsg = e.message;
337
- break;
338
- }
339
- }
340
- }
341
- return remapPayloadLines(errMsg, userLines, payloadInstanceName);
342
- }
343
-
344
- function execute(code: string): ExecuteResult {
345
- if (!code || code === "") {
346
- return { success: false, error: "code is required" };
347
- }
348
- const wrapped = buildWrapper(code);
349
- const userLines = countLines(code);
350
-
351
- let [success, result] = pcall(() => {
352
- const [fn, compileError] = loadstring(wrapped);
353
- if (!fn) {
354
- if (isLoadstringUnavailable(compileError)) {
355
- return runViaModuleScript(wrapped, userLines);
356
- }
357
- error(`Compile error: ${remapPayloadLines(tostring(compileError), userLines)}`, 0);
358
- }
359
- return fn() as unknown as WrapperResult;
360
- });
361
-
362
- // loadstring can throw (not return nil) when ServerScriptService.
363
- // LoadStringEnabled is false; treat that as a second-chance fallback.
364
- if (!success && isLoadstringUnavailable(result)) {
365
- [success, result] = pcall(() => runViaModuleScript(wrapped, userLines));
366
- }
367
-
368
- if (!success) {
369
- return {
370
- success: false,
371
- error: tostring(result),
372
- output: [],
373
- message: "Code execution failed",
374
- };
375
- }
376
-
377
- const r = result as unknown as WrapperResult;
378
- const capturedOutput = r.output as unknown as string[] | undefined;
379
- const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
380
- if (r.ok === true) {
381
- return {
382
- success: true,
383
- returnValue: r.value !== undefined ? formatReturnValue(r.value) : undefined,
384
- output,
385
- message: "Code executed successfully",
386
- };
387
- }
388
- return {
389
- success: false,
390
- error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
391
- output,
392
- message: "Code execution failed",
393
- };
394
- }
395
-
396
- export = {
397
- buildWrapper,
398
- countLines,
399
- execute,
400
- formatReturnValue,
401
- recoverPayloadRequireError,
402
- remapPayloadLines,
403
- };
@@ -1,28 +0,0 @@
1
- const ChangeHistoryService = game.GetService("ChangeHistoryService");
2
-
3
- type RecordingId = string | undefined;
4
-
5
- function beginRecording(actionName: string): RecordingId {
6
- const [success, result] = pcall(() => ChangeHistoryService.TryBeginRecording(`MCP: ${actionName}`));
7
- if (success) {
8
- return result as RecordingId;
9
- }
10
- return undefined;
11
- }
12
-
13
- function finishRecording(recordingId: RecordingId, shouldCommit: boolean) {
14
- if (recordingId === undefined) return;
15
-
16
- const operation = shouldCommit
17
- ? Enum.FinishRecordingOperation.Commit
18
- : Enum.FinishRecordingOperation.Cancel;
19
-
20
- pcall(() => {
21
- ChangeHistoryService.FinishRecording(recordingId, operation);
22
- });
23
- }
24
-
25
- export = {
26
- beginRecording,
27
- finishRecording,
28
- };
@@ -1,60 +0,0 @@
1
- // Detects whether the Studio window is actually rendering, so virtual input
2
- // and screenshot tools can surface a clear reason instead of silently failing.
3
- //
4
- // When a Studio window is MINIMIZED, the engine suspends the render loop AND
5
- // input processing, but keeps running scripts (Heartbeat keeps firing). That's
6
- // why simulate_*_input would return success while having zero effect, and
7
- // CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
8
- // minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
9
- // 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
10
- // signal; Heartbeat is not.
11
-
12
- import { RunService } from "@rbxts/services";
13
-
14
- let lastFrame = 0;
15
- let connected = false;
16
-
17
- // Above this many seconds since the last rendered frame, we treat the window
18
- // as not rendering. RenderStepped normally fires every ~16ms; a multi-second
19
- // gap only happens when minimized/suspended, so 1s cleanly avoids false
20
- // positives from ordinary frame hitches while still catching the real case.
21
- const STALE_THRESHOLD = 1.0;
22
-
23
- export function start(): void {
24
- if (connected) return;
25
- // RenderStepped can only be connected from a client/edit render loop; it
26
- // throws in the play-server DM. pcall so a server-DM call is a safe no-op
27
- // (connected stays false → notRenderingReason() returns undefined there).
28
- const [ok] = pcall(() => {
29
- RunService.RenderStepped.Connect(() => {
30
- lastFrame = tick();
31
- });
32
- });
33
- if (ok) {
34
- connected = true;
35
- lastFrame = tick();
36
- }
37
- }
38
-
39
- export function secondsSinceFrame(): number {
40
- if (!connected) return 0;
41
- return tick() - lastFrame;
42
- }
43
-
44
- // Returns a human-readable reason if the window appears minimized / not
45
- // rendering (so input + screenshots won't work), else undefined. Fail-open:
46
- // when the monitor isn't active in this DM (server peer, or connect failed) it
47
- // returns undefined so we never block on a false signal.
48
- export function notRenderingReason(): string | undefined {
49
- if (!connected) return undefined;
50
- const gap = secondsSinceFrame();
51
- if (gap > STALE_THRESHOLD) {
52
- return string.format(
53
- "Studio window appears minimized or not rendering (no frame in %.1fs). " +
54
- "Virtual input and screenshots only work while the window is visible — " +
55
- "restore/un-minimize the Studio window and retry.",
56
- gap,
57
- );
58
- }
59
- return undefined;
60
- }
@@ -1,210 +0,0 @@
1
- // Per-capture in-memory ring buffer for LogService.MessageOut events.
2
- // Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
3
- // primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
4
- //
5
- // Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
6
- // play-server DM, play-client DM all run their own copy of this module).
7
- // Captured entries live in plugin module-state; nothing is parented to the
8
- // DataModel. The buffer is bounded by a message-byte budget; oldest entries
9
- // drop when over budget.
10
- //
11
- // Capture caveat: returned entries reflect which plugin buffer CAPTURED the
12
- // entry, NOT which peer's script originated the print. LogService reflects
13
- // prints across peers in ordinary Studio Play (a server print can appear in
14
- // server and client LogService:GetLogHistory()). The MCP-side aggregator
15
- // exposes that as capturedBy, and only promotes it to origin peer in
16
- // StudioTestService multiplayer sessions where peer attribution is reliable.
17
-
18
- import { LogService, RunService } from "@rbxts/services";
19
-
20
- type LogLevel = "OUT" | "WARN" | "ERR" | "INFO";
21
-
22
- interface RuntimeLogEntry {
23
- seq: number;
24
- ts: number; // wall-clock seconds via DateTime, coherent across peers
25
- level: LogLevel;
26
- message: string;
27
- data?: Record<string, unknown>;
28
- }
29
-
30
- const MAX_BYTES = 64 * 1024;
31
- const HARD_ENTRY_CAP = 50_000;
32
-
33
- const entries: RuntimeLogEntry[] = [];
34
- let totalBytes = 0;
35
- let totalDropped = 0;
36
- let nextSeq = 1;
37
- let installed = false;
38
-
39
- function levelTag(t: Enum.MessageType): LogLevel {
40
- if (t === Enum.MessageType.MessageWarning) return "WARN";
41
- if (t === Enum.MessageType.MessageError) return "ERR";
42
- if (t === Enum.MessageType.MessageInfo) return "INFO";
43
- return "OUT";
44
- }
45
-
46
- function nowSec(): number {
47
- return DateTime.now().UnixTimestampMillis / 1000;
48
- }
49
-
50
- // Studio occasionally exposes binary-bearing Output messages through
51
- // LogService (for example, plugin hydration diagnostics containing raw CSG
52
- // data). HttpService:JSONEncode rejects those strings outright. Preserve all
53
- // valid UTF-8 verbatim and make only malformed bytes JSON-safe and visible.
54
- function escapeInvalidUtf8(msg: string): string {
55
- const [valid] = utf8.len(msg);
56
- // Roblox currently returns nil (not the false declared by @rbxts/types)
57
- // when it encounters a malformed sequence. A numeric result is the only
58
- // portable success discriminator across both representations.
59
- if (typeIs(valid, "number")) return msg;
60
-
61
- const parts: string[] = [];
62
- let cursor = 1;
63
- while (cursor <= msg.size()) {
64
- const [suffixValid, invalidPosition] = utf8.len(msg, cursor);
65
- if (typeIs(suffixValid, "number")) {
66
- parts.push(string.sub(msg, cursor));
67
- break;
68
- }
69
- if (!typeIs(invalidPosition, "number")) break;
70
-
71
- if (invalidPosition > cursor) {
72
- parts.push(string.sub(msg, cursor, invalidPosition - 1));
73
- }
74
- const [invalidByte] = string.byte(msg, invalidPosition);
75
- parts.push(string.format("\\x%02X", invalidByte));
76
- cursor = invalidPosition + 1;
77
- }
78
- return parts.join("");
79
- }
80
-
81
- function dropOldestUntilFits(incomingBytes: number): void {
82
- while (
83
- entries.size() > 0 &&
84
- (totalBytes + incomingBytes > MAX_BYTES || entries.size() >= HARD_ENTRY_CAP)
85
- ) {
86
- const dropped = entries.shift()!;
87
- totalBytes -= dropped.message.size();
88
- totalDropped += 1;
89
- }
90
- }
91
-
92
- function pushEntry(
93
- msg: string,
94
- t: Enum.MessageType,
95
- ts = nowSec(),
96
- data?: Record<string, unknown>,
97
- ): void {
98
- const safeMessage = escapeInvalidUtf8(msg);
99
- const bytes = safeMessage.size();
100
- dropOldestUntilFits(bytes);
101
- entries.push({
102
- seq: nextSeq,
103
- ts,
104
- level: levelTag(t),
105
- message: safeMessage,
106
- data,
107
- });
108
- nextSeq += 1;
109
- totalBytes += bytes;
110
- }
111
-
112
- interface LogHistoryEntry {
113
- message: string;
114
- messageType: Enum.MessageType;
115
- timestamp: number;
116
- }
117
-
118
- function seedRuntimeHistory(): void {
119
- const [ok, history] = pcall(() => LogService.GetLogHistory() as LogHistoryEntry[]);
120
- if (!ok) return;
121
- const isEdit = !RunService.IsRunning();
122
- // GetLogHistory timestamps and DateTime.now() share Unix time, while
123
- // os.clock() is elapsed time for this Studio process. Their difference is
124
- // therefore the process launch boundary. Edit-mode history is filtered to
125
- // that boundary so startup errors from this launch are recovered without
126
- // importing history left by an earlier Studio process.
127
- const processStartedAt = nowSec() - os.clock();
128
-
129
- for (const entry of history) {
130
- if (!typeIs(entry.message, "string")) continue;
131
- const timestamp = typeIs(entry.timestamp, "number") ? entry.timestamp : undefined;
132
- if (isEdit && (timestamp === undefined || timestamp < processStartedAt - 1)) continue;
133
- pushEntry(entry.message, entry.messageType, timestamp);
134
- }
135
- }
136
-
137
- function install(): void {
138
- if (installed) return;
139
- if (!RunService.IsStudio()) return;
140
- installed = true;
141
- // Every peer can emit startup logs before the plugin finishes loading.
142
- // Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
143
- // edit history is bounded to the current Studio process above.
144
- seedRuntimeHistory();
145
- LogService.MessageOut.Connect((msg, t, context?: Record<string, unknown>) => {
146
- pushEntry(msg, t, undefined, context);
147
- });
148
- }
149
-
150
- function detectPeer(): "edit" | "server" | "client" {
151
- if (!RunService.IsRunning()) return "edit";
152
- if (RunService.IsServer()) return "server";
153
- return "client";
154
- }
155
-
156
- interface QueryOptions {
157
- since?: number;
158
- tail?: number;
159
- filter?: string; // Plain substring match, applied to message
160
- }
161
-
162
- interface QueryResult {
163
- capturedBy: string;
164
- entries: RuntimeLogEntry[];
165
- totalDropped: number;
166
- nextSince: number;
167
- }
168
-
169
- function query(opts: QueryOptions, capturedBy: string): QueryResult {
170
- let result = opts.since !== undefined
171
- ? entries.filter((e) => e.seq > (opts.since as number))
172
- : [...entries];
173
-
174
- if (opts.filter !== undefined) {
175
- // Plain substring search (4th arg = true). Pattern matching here was
176
- // surprising in practice - Lua magic chars in messages would silently
177
- // not match (e.g. filter="MARK-EDIT" against "MARK-EDIT-001" fails
178
- // because '-' means "0+" in Lua patterns). Substring search matches
179
- // most users' mental model of "filter messages containing this text".
180
- const needle = opts.filter;
181
- result = result.filter((e) => {
182
- const [start] = string.find(e.message, needle, 1, true);
183
- return start !== undefined;
184
- });
185
- }
186
-
187
- if (opts.tail !== undefined && result.size() > opts.tail) {
188
- // roblox-ts arrays don't expose .slice; manual tail copy.
189
- const tailed: RuntimeLogEntry[] = [];
190
- const start = result.size() - opts.tail;
191
- for (let i = start; i < result.size(); i++) {
192
- tailed.push(result[i]);
193
- }
194
- result = tailed;
195
- }
196
-
197
- const last = entries.size() > 0 ? entries[entries.size() - 1] : undefined;
198
- return {
199
- capturedBy,
200
- entries: result,
201
- totalDropped,
202
- nextSince: last ? last.seq : (opts.since ?? 0),
203
- };
204
- }
205
-
206
- export = {
207
- install,
208
- detectPeer,
209
- query,
210
- };