@chrrxs/robloxstudio-mcp-inspector 2.15.0 → 2.15.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.
@@ -4,7 +4,9 @@ import MemoryHandlers from "./handlers/MemoryHandlers";
4
4
  import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
5
5
  import CaptureHandlers from "./handlers/CaptureHandlers";
6
6
  import InputHandlers from "./handlers/InputHandlers";
7
+ import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
7
8
  import LuauExec from "./LuauExec";
9
+ import State from "./State";
8
10
 
9
11
  interface StudioTestServiceMultiplayer extends StudioTestService {
10
12
  CanLeaveTest(): boolean;
@@ -86,6 +88,7 @@ interface BrokerEnvelope {
86
88
  // cache / etc. lives there) so the server peer alone can't satisfy them.
87
89
  const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
88
90
  "/api/execute-luau",
91
+ "/api/eval-runtime",
89
92
  "/api/get-runtime-logs",
90
93
  "/api/get-memory-breakdown",
91
94
  "/api/get-scene-analysis",
@@ -134,6 +137,8 @@ function reRegisterProxy(proxyId: string, role: string): void {
134
137
  placeName: resolvePlaceName(),
135
138
  dataModelName: game.Name,
136
139
  isRunning: RunService.IsRunning(),
140
+ pluginVersion: State.CURRENT_VERSION,
141
+ pluginVariant: State.PLUGIN_VARIANT,
137
142
  }),
138
143
  );
139
144
  }
@@ -172,8 +177,8 @@ function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknow
172
177
  const since = d.since as number | undefined;
173
178
  const tail = d.tail as number | undefined;
174
179
  const filter = d.filter as string | undefined;
175
- // "client" is the generic peer tag; MCP-side aggregator overrides with
176
- // the specific role (e.g. "client-1") on target=all fan-out.
180
+ // "client" is the generic capture tag; MCP-side aggregation overrides it
181
+ // with the specific role (e.g. "client-1") for capturedBy.
177
182
  return RuntimeLogBuffer.query({ since, tail, filter }, "client");
178
183
  }
179
184
 
@@ -263,6 +268,9 @@ function setupClientBroker() {
263
268
  if (payload && payload.endpoint === "/api/execute-luau") {
264
269
  return handleExecuteLuau(payload.data);
265
270
  }
271
+ if (payload && payload.endpoint === "/api/eval-runtime") {
272
+ return EvalRuntimeHandlers.evalRuntime(payload.data ?? {});
273
+ }
266
274
  // Legacy: raw execute-luau payload at the top level.
267
275
  return handleExecuteLuau(payload as Record<string, unknown> | undefined);
268
276
  };
@@ -329,6 +337,8 @@ function registerProxy(player: Player, rf: RemoteFunction) {
329
337
  placeName: resolvePlaceName(),
330
338
  dataModelName: game.Name,
331
339
  isRunning: RunService.IsRunning(),
340
+ pluginVersion: State.CURRENT_VERSION,
341
+ pluginVariant: State.PLUGIN_VARIANT,
332
342
  });
333
343
  if (!ok || !res || !res.Success) {
334
344
  warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}`);
@@ -17,6 +17,7 @@ import LogHandlers from "./handlers/LogHandlers";
17
17
  import SerializationHandlers from "./handlers/SerializationHandlers";
18
18
  import MemoryHandlers from "./handlers/MemoryHandlers";
19
19
  import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
20
+ import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
20
21
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
21
22
 
22
23
  // Per-plugin-load random GUID. Used as the /poll URL param so the server
@@ -47,6 +48,8 @@ function computeInstanceId(): string {
47
48
  const instanceId = computeInstanceId();
48
49
  let assignedRole: string | undefined;
49
50
  let duplicateInstanceRole = false;
51
+ let hasVersionMismatch = false;
52
+ let lastVersionMismatchWarningKey: string | undefined;
50
53
 
51
54
  // Cache the published place name from MarketplaceService:GetProductInfo so
52
55
  // /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
@@ -127,6 +130,7 @@ const routeMap: Record<string, Handler> = {
127
130
  "/api/get-tagged": MetadataHandlers.getTagged,
128
131
  "/api/get-selection": MetadataHandlers.getSelection,
129
132
  "/api/execute-luau": MetadataHandlers.executeLuau,
133
+ "/api/eval-runtime": EvalRuntimeHandlers.evalRuntime,
130
134
  "/api/undo": MetadataHandlers.undo,
131
135
  "/api/redo": MetadataHandlers.redo,
132
136
  "/api/bulk-set-attributes": MetadataHandlers.bulkSetAttributes,
@@ -238,6 +242,8 @@ function sendReady(conn: Connection): void {
238
242
  placeName: resolvePlaceName(),
239
243
  dataModelName: game.Name,
240
244
  isRunning: RunService.IsRunning(),
245
+ pluginVersion: State.CURRENT_VERSION,
246
+ pluginVariant: State.PLUGIN_VARIANT,
241
247
  pluginReady: true,
242
248
  timestamp: tick(),
243
249
  }),
@@ -301,6 +307,19 @@ function pollForRequests(connIndex: number) {
301
307
  const mcpConnected = data.mcpConnected === true;
302
308
  conn.lastHttpOk = true;
303
309
  conn.lastMcpOk = mcpConnected;
310
+ const serverVersion = data.serverVersion ?? "unknown";
311
+ if (data.versionMismatch === true) {
312
+ hasVersionMismatch = true;
313
+ const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
314
+ if (lastVersionMismatchWarningKey !== warningKey) {
315
+ lastVersionMismatchWarningKey = warningKey;
316
+ warn(`[MCPPlugin] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
317
+ }
318
+ UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
319
+ } else if (hasVersionMismatch) {
320
+ hasVersionMismatch = false;
321
+ UI.hideBanner("version-mismatch");
322
+ }
304
323
 
305
324
  // Server tells us when its in-memory instances map doesn't have us
306
325
  // (e.g. after an MCP process restart). Re-issue /ready immediately so
@@ -539,11 +558,9 @@ function checkForUpdates() {
539
558
  if (ok && data?.version) {
540
559
  const latestVersion = data.version;
541
560
  if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
542
- const ui = UI.getElements();
543
- ui.updateBannerText.Text = `v${latestVersion} available - github.com/chrrxs/robloxstudio-mcp`;
544
- ui.updateBanner.Visible = true;
545
- ui.contentFrame.Position = new UDim2(0, 8, 0, 92);
546
- ui.contentFrame.Size = new UDim2(1, -16, 1, -100);
561
+ if (!hasVersionMismatch) {
562
+ UI.showBanner("update", `v${latestVersion} available - github.com/chrrxs/robloxstudio-mcp`);
563
+ }
547
564
  }
548
565
  }
549
566
  }
@@ -86,9 +86,10 @@ bf.Archivable = false
86
86
  bf.Parent = ServerScriptService
87
87
  bf.OnInvoke = function(payload)
88
88
  if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
89
- return false, "payload must be a ModuleScript instance"
89
+ return { ok = false, value = "payload must be a ModuleScript instance" }
90
90
  end
91
- return pcall(require, payload)
91
+ local ok, value = pcall(require, payload)
92
+ return { ok = ok, value = value }
92
93
  end
93
94
  `;
94
95
 
@@ -113,9 +114,10 @@ bf.Archivable = false
113
114
  bf.Parent = ReplicatedStorage
114
115
  bf.OnInvoke = function(payload)
115
116
  if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
116
- return false, "payload must be a ModuleScript instance"
117
+ return { ok = false, value = "payload must be a ModuleScript instance" }
117
118
  end
118
- return pcall(require, payload)
119
+ local ok, value = pcall(require, payload)
120
+ return { ok = ok, value = value }
119
121
  end
120
122
  `;
121
123
 
@@ -224,4 +226,3 @@ export function installBridges(): { installed: boolean; error?: string } {
224
226
  }
225
227
  return { installed: true };
226
228
  }
227
-
@@ -3,10 +3,12 @@
3
3
  // and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
4
4
  // module owns:
5
5
  //
6
- // 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
7
- // and always returns { ok, value, output } so the ModuleScript itself
8
- // always returns exactly one value (otherwise `print("hi")` with no
9
- // return would fail with "Module code did not return exactly one value").
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").
10
12
  //
11
13
  // 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
12
14
  // recovery hack that pulls the real diagnostic from LogService.
@@ -40,15 +42,15 @@ interface ExecuteResult {
40
42
  }
41
43
 
42
44
  const PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload";
43
- const PAYLOAD_PATH_PREFIX = `Workspace.${PAYLOAD_INSTANCE_NAME}:`;
45
+ const REQUIRE_GENERIC_ERROR = "Requested module experienced an error while loading";
44
46
 
45
47
  // Number of lines the wrapper emits BEFORE the first line of user code.
46
48
  // Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
47
49
  // (remapPayloadLines, for compile errors recovered from LogService) so user
48
50
  // code errors report user-relative line numbers instead of the inflated
49
- // "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
50
- // prefix lines, update this constant — there's a self-check below.
51
- const WRAPPER_LINE_OFFSET = 23;
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;
52
54
 
53
55
  // Count source lines so the wrapper can filter traceback frames that fall
54
56
  // outside the user code range (the wrapper's own preamble/postamble lines).
@@ -61,20 +63,29 @@ function countLines(s: string): number {
61
63
  return n;
62
64
  }
63
65
 
64
- function buildWrapper(code: string): string {
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 {
65
72
  // If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
66
73
  // match the number of lines emitted BEFORE the ${code} substitution.
67
74
  // The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
68
75
  // used by remapPayloadLines on the TS side.
69
76
  const userLines = countLines(code);
77
+ const payloadPattern = luaPatternEscape(payloadInstanceName);
70
78
  return `return ((function()
71
79
  \tlocal __mcp_traceback
72
80
  \tlocal __mcp_remap
73
81
  \tlocal __mcp_LINE_OFFSET = ${WRAPPER_LINE_OFFSET}
74
82
  \tlocal __mcp_USER_LINES = ${userLines}
83
+ \tlocal __mcp_LogService = game:GetService("LogService")
84
+ \tlocal __mcp_REQUIRE_GENERIC = "${REQUIRE_GENERIC_ERROR}"
75
85
  \tlocal __mcp_output = {}
76
86
  \tlocal __mcp_real_print = print
77
87
  \tlocal __mcp_real_warn = warn
88
+ \tlocal __mcp_real_require = require
78
89
  \tlocal print = function(...)
79
90
  \t\t__mcp_real_print(...)
80
91
  \t\tlocal args = {...}
@@ -89,6 +100,64 @@ function buildWrapper(code: string): string {
89
100
  \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
90
101
  \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
91
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
92
161
  \tlocal function __mcp_run()
93
162
  ${code}
94
163
  \tend
@@ -99,15 +168,20 @@ ${code}
99
168
  \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.
100
169
  \t\t-- Clamping matters for unclosed constructs ("local x = (") where the
101
170
  \t\t-- parser keeps reading into wrapper postamble and reports a payload
102
- \t\t-- line past user EOF. Without clamping the message says "user_code:49"
103
- \t\t-- for one-line input, framing the wrapper as user code.
171
+ \t\t-- line past user EOF. Without clamping, that frames wrapper postamble
172
+ \t\t-- as user code.
104
173
  \t\tlocal function __mcp_user_line(payload_n)
105
174
  \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET
106
175
  \t\t\tif user_n < 1 then return "1" end
107
176
  \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
108
177
  \t\t\treturn tostring(user_n)
109
178
  \t\tend
110
- \t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)
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)
111
185
  \t\t\tlocal n = tonumber(num)
112
186
  \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
113
187
  \t\t\treturn "user_code:" .. num
@@ -158,7 +232,7 @@ end)())`;
158
232
  // pulling the real compile-error diagnostic out of LogService — that error
159
233
  // references the payload module's line number directly, and never passes
160
234
  // through the IIFE's runtime wrapper.
161
- function remapPayloadLines(s: string, userLines: number): string {
235
+ function remapPayloadLines(s: string, userLines: number, payloadInstanceName = PAYLOAD_INSTANCE_NAME): string {
162
236
  // Mirror of the Lua __mcp_remap inside the wrapper, for paths that
163
237
  // don't pass through the IIFE (compile errors recovered from
164
238
  // LogService, the immediate loadstring compileError surface). Same
@@ -172,20 +246,26 @@ function remapPayloadLines(s: string, userLines: number): string {
172
246
  if (u > userLines) return `${tostring(userLines)} (at end of input)`;
173
247
  return tostring(u);
174
248
  };
249
+ const payloadPattern = luaPatternEscape(payloadInstanceName);
175
250
  let out = s;
176
- const [a] = string.gsub(out, "__MCPExecLuauPayload:(%d+)", (num: string) => {
251
+ const [a] = string.gsub(out, `Workspace%.${payloadPattern}:(%d+)`, (num: string) => {
177
252
  const n = tonumber(num);
178
253
  if (n !== undefined) return `user_code:${userLine(n)}`;
179
254
  return `user_code:${num}`;
180
255
  });
181
256
  out = a;
182
- const [b] = string.gsub(out, '%[string "[^"]+"%]:(%d+)', (num: string) => {
257
+ const [b] = string.gsub(out, `${payloadPattern}:(%d+)`, (num: string) => {
183
258
  const n = tonumber(num);
184
259
  if (n !== undefined) return `user_code:${userLine(n)}`;
185
260
  return `user_code:${num}`;
186
261
  });
187
262
  out = b;
188
- return out;
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;
189
269
  }
190
270
 
191
271
  function runViaModuleScript(wrapped: string, userLines: number): WrapperResult {
@@ -205,29 +285,11 @@ function runViaModuleScript(wrapped: string, userLines: number): WrapperResult {
205
285
  const [okReq, reqResult] = pcall(() => require(m));
206
286
  m.Destroy();
207
287
  if (!okReq) {
208
- let errMsg = tostring(reqResult);
209
- // pcall(require, m) collapses parse/compile failures into the canned
210
- // engine string. The real diagnostic was emitted to LogService on the
211
- // next engine frame — give it ~50ms to land then scan backward.
212
- if (errMsg === "Requested module experienced an error while loading") {
213
- task.wait(0.05);
214
- const hist = LogService.GetLogHistory();
215
- for (let i = hist.size() - 1; i >= 0; i--) {
216
- const e = hist[i];
217
- if (
218
- e.messageType === Enum.MessageType.MessageError &&
219
- string.sub(e.message, 1, PAYLOAD_PATH_PREFIX.size()) === PAYLOAD_PATH_PREFIX
220
- ) {
221
- errMsg = e.message;
222
- break;
223
- }
224
- }
225
- }
226
288
  // Compile errors reference the payload module's line number directly
227
289
  // — remap + clamp to user-relative line numbers so `local x = 1 +`
228
290
  // reports :1: instead of :23:, and reports the clamp annotation
229
291
  // when the parser ran off the end of user code into wrapper code.
230
- error(remapPayloadLines(errMsg, userLines), 0);
292
+ error(recoverPayloadRequireError(reqResult, userLines, PAYLOAD_INSTANCE_NAME), 0);
231
293
  }
232
294
  return reqResult as unknown as WrapperResult;
233
295
  }
@@ -250,6 +312,35 @@ function formatReturnValue(value: unknown): string {
250
312
  return tostring(value);
251
313
  }
252
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
+
253
344
  function execute(code: string): ExecuteResult {
254
345
  if (!code || code === "") {
255
346
  return { success: false, error: "code is required" };
@@ -302,4 +393,11 @@ function execute(code: string): ExecuteResult {
302
393
  };
303
394
  }
304
395
 
305
- export = { execute };
396
+ export = {
397
+ buildWrapper,
398
+ countLines,
399
+ execute,
400
+ formatReturnValue,
401
+ recoverPayloadRequireError,
402
+ remapPayloadLines,
403
+ };
@@ -1,4 +1,4 @@
1
- // Per-peer in-memory ring buffer for LogService.MessageOut events.
1
+ // Per-capture in-memory ring buffer for LogService.MessageOut events.
2
2
  // Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
3
3
  // primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
4
4
  //
@@ -8,12 +8,12 @@
8
8
  // DataModel. The buffer is bounded by a message-byte budget; oldest entries
9
9
  // drop when over budget.
10
10
  //
11
- // Peer-tag caveat: returned entries reflect which peer's plugin CAPTURED the
11
+ // Capture caveat: returned entries reflect which plugin buffer CAPTURED the
12
12
  // entry, NOT which peer's script originated the print. LogService reflects
13
- // prints across peers in Studio Play (a server print ends up in both the
14
- // server and client LogService:GetLogHistory()) and origin is empirically
15
- // undetectable from inside MessageOut. The MCP-side aggregator handles
16
- // cross-peer dedup via a 2s timestamp window.
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
17
 
18
18
  import { LogService, RunService } from "@rbxts/services";
19
19
 
@@ -88,13 +88,13 @@ interface QueryOptions {
88
88
  }
89
89
 
90
90
  interface QueryResult {
91
- peer: string;
91
+ capturedBy: string;
92
92
  entries: RuntimeLogEntry[];
93
93
  totalDropped: number;
94
94
  nextSince: number;
95
95
  }
96
96
 
97
- function query(opts: QueryOptions, peer: string): QueryResult {
97
+ function query(opts: QueryOptions, capturedBy: string): QueryResult {
98
98
  let result = opts.since !== undefined
99
99
  ? entries.filter((e) => e.seq > (opts.since as number))
100
100
  : [...entries];
@@ -124,7 +124,7 @@ function query(opts: QueryOptions, peer: string): QueryResult {
124
124
 
125
125
  const last = entries.size() > 0 ? entries[entries.size() - 1] : undefined;
126
126
  return {
127
- peer,
127
+ capturedBy,
128
128
  entries: result,
129
129
  totalDropped,
130
130
  nextSince: last ? last.seq : (opts.since ?? 0),
@@ -1,6 +1,7 @@
1
1
  import { Connection } from "../types";
2
2
 
3
3
  const CURRENT_VERSION = "__VERSION__";
4
+ const PLUGIN_VARIANT = "__PLUGIN_VARIANT__";
4
5
  const MAX_CONNECTIONS = 5;
5
6
  const BASE_PORT = 58741;
6
7
  let activeTabIndex = 0;
@@ -81,6 +82,7 @@ function getConnections(): Connection[] {
81
82
 
82
83
  export = {
83
84
  CURRENT_VERSION,
85
+ PLUGIN_VARIANT,
84
86
  MAX_CONNECTIONS,
85
87
  BASE_PORT,
86
88
  connections,
@@ -38,6 +38,7 @@ interface ToolbarIcons {
38
38
  let toolbarButton: PluginToolbarButton | undefined;
39
39
  let toolbarIcons: ToolbarIcons | undefined;
40
40
  let lastToolbarIcon: string | undefined;
41
+ let activeBannerKind: string | undefined;
41
42
 
42
43
  function setToolbarButton(btn: PluginToolbarButton, icons: ToolbarIcons) {
43
44
  toolbarButton = btn;
@@ -77,6 +78,23 @@ function tweenProp(instance: Instance, props: Record<string, unknown>) {
77
78
  TweenService.Create(instance, TWEEN_QUICK, props as unknown as { [key: string]: unknown }).Play();
78
79
  }
79
80
 
81
+ function showBanner(kind: string, text: string) {
82
+ activeBannerKind = kind;
83
+ elements.updateBannerText.Text = text;
84
+ elements.updateBanner.Visible = true;
85
+ elements.contentFrame.Position = new UDim2(0, 8, 0, 92);
86
+ elements.contentFrame.Size = new UDim2(1, -16, 1, -100);
87
+ }
88
+
89
+ function hideBanner(kind?: string) {
90
+ if (kind !== undefined && activeBannerKind !== kind) return;
91
+ activeBannerKind = undefined;
92
+ elements.updateBanner.Visible = false;
93
+ elements.updateBannerText.Text = "";
94
+ elements.contentFrame.Position = new UDim2(0, 8, 0, 66);
95
+ elements.contentFrame.Size = new UDim2(1, -16, 1, -74);
96
+ }
97
+
80
98
  const C = {
81
99
  bg: Color3.fromRGB(14, 14, 14),
82
100
  card: Color3.fromRGB(22, 22, 22),
@@ -759,5 +777,7 @@ export = {
759
777
  startPulseAnimation,
760
778
  setToolbarButton,
761
779
  updateToolbarIcon,
780
+ showBanner,
781
+ hideBanner,
762
782
  getElements: () => elements,
763
783
  };
@@ -0,0 +1,121 @@
1
+ import { LogService, ReplicatedStorage, RunService, ServerScriptService } from "@rbxts/services";
2
+ import { BRIDGE_NAMES } from "../EvalBridges";
3
+ import LuauExec from "../LuauExec";
4
+
5
+ const PAYLOAD_INSTANCE_NAME = "__MCPEvalPayload";
6
+
7
+ interface BridgeInvokeResult {
8
+ ok?: boolean;
9
+ value?: unknown;
10
+ }
11
+
12
+ interface WrapperResult {
13
+ ok?: boolean;
14
+ value?: unknown;
15
+ output?: unknown;
16
+ }
17
+
18
+ function getBridgeConfig() {
19
+ if (!RunService.IsRunning()) {
20
+ return {
21
+ error: "eval_*_runtime requires a running playtest.",
22
+ };
23
+ }
24
+ if (RunService.IsServer()) {
25
+ return {
26
+ service: ServerScriptService,
27
+ bridgeName: BRIDGE_NAMES.serverLocal,
28
+ missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
29
+ };
30
+ }
31
+ return {
32
+ service: ReplicatedStorage,
33
+ bridgeName: BRIDGE_NAMES.clientLocal,
34
+ missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
35
+ };
36
+ }
37
+
38
+ function evalRuntime(requestData: Record<string, unknown>) {
39
+ const code = requestData.code as string;
40
+ if (!code || code === "") return { error: "Code is required" };
41
+
42
+ const config = getBridgeConfig();
43
+ if (config.error !== undefined) {
44
+ return { bridge: "missing", error: config.error };
45
+ }
46
+
47
+ const bridge = config.service.FindFirstChild(config.bridgeName);
48
+ if (!bridge || !bridge.IsA("BindableFunction")) {
49
+ return { bridge: "missing", error: config.missingError };
50
+ }
51
+
52
+ const m = new Instance("ModuleScript");
53
+ m.Name = PAYLOAD_INSTANCE_NAME;
54
+ const userLines = LuauExec.countLines(code);
55
+ const wrapped = LuauExec.buildWrapper(code, PAYLOAD_INSTANCE_NAME);
56
+
57
+ const [okSet, setErr] = pcall(() => {
58
+ (m as unknown as { Source: string }).Source = wrapped;
59
+ });
60
+ if (!okSet) {
61
+ m.Destroy();
62
+ return {
63
+ bridge: "ok",
64
+ ok: false,
65
+ error: `ModuleScript Source set failed: ${tostring(setErr)}`,
66
+ };
67
+ }
68
+
69
+ m.Parent = game.GetService("Workspace");
70
+ const historyStart = LogService.GetLogHistory().size();
71
+ const [invokeOk, invokeResult] = pcall(() => bridge.Invoke(m) as BridgeInvokeResult);
72
+ m.Destroy();
73
+
74
+ if (!invokeOk) {
75
+ return {
76
+ bridge: "ok",
77
+ ok: false,
78
+ error: tostring(invokeResult),
79
+ };
80
+ }
81
+
82
+ if (!typeIs(invokeResult, "table")) {
83
+ return {
84
+ bridge: "ok",
85
+ ok: false,
86
+ error: `Eval bridge returned invalid result: ${tostring(invokeResult)}`,
87
+ };
88
+ }
89
+
90
+ const bridgeResult = invokeResult as BridgeInvokeResult;
91
+ if (bridgeResult.ok !== true) {
92
+ return {
93
+ bridge: "ok",
94
+ ok: false,
95
+ error: LuauExec.recoverPayloadRequireError(bridgeResult.value, userLines, PAYLOAD_INSTANCE_NAME, historyStart),
96
+ };
97
+ }
98
+
99
+ const inner = bridgeResult.value;
100
+ if (!typeIs(inner, "table")) {
101
+ return {
102
+ bridge: "ok",
103
+ ok: true,
104
+ result: inner === undefined ? undefined : LuauExec.formatReturnValue(inner),
105
+ };
106
+ }
107
+
108
+ const r = inner as WrapperResult;
109
+ const ok = r.ok === true;
110
+ return {
111
+ bridge: "ok",
112
+ ok,
113
+ result: ok && r.value !== undefined ? LuauExec.formatReturnValue(r.value) : undefined,
114
+ error: !ok ? tostring(r.value) : undefined,
115
+ output: r.output ?? [],
116
+ };
117
+ }
118
+
119
+ export = {
120
+ evalRuntime,
121
+ };
@@ -4,12 +4,11 @@ function getRuntimeLogs(requestData: Record<string, unknown>): unknown {
4
4
  const since = requestData.since as number | undefined;
5
5
  const tail = requestData.tail as number | undefined;
6
6
  const filter = requestData.filter as string | undefined;
7
- // Plugin-side peer tag is generic ("edit"|"server"|"client"). The MCP-side
8
- // aggregator overrides it with the specific instance role (e.g. "client-1")
9
- // during fan-out for target=all, so this value is only authoritative for
10
- // the single-peer query path.
11
- const peer = RuntimeLogBuffer.detectPeer();
12
- return RuntimeLogBuffer.query({ since, tail, filter }, peer);
7
+ // This is the buffer that captured the LogService event, not necessarily
8
+ // the script-origin peer. Ordinary playtests share/reflect logs across
9
+ // edit/server/client LogService buffers.
10
+ const capturedBy = RuntimeLogBuffer.detectPeer();
11
+ return RuntimeLogBuffer.query({ since, tail, filter }, capturedBy);
13
12
  }
14
13
 
15
14
  export = { getRuntimeLogs };