@chrrxs/robloxstudio-mcp 2.15.1 → 2.16.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.
@@ -4,6 +4,7 @@ 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";
8
9
  import State from "./State";
9
10
 
@@ -87,6 +88,7 @@ interface BrokerEnvelope {
87
88
  // cache / etc. lives there) so the server peer alone can't satisfy them.
88
89
  const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
89
90
  "/api/execute-luau",
91
+ "/api/eval-runtime",
90
92
  "/api/get-runtime-logs",
91
93
  "/api/get-memory-breakdown",
92
94
  "/api/get-scene-analysis",
@@ -175,8 +177,8 @@ function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknow
175
177
  const since = d.since as number | undefined;
176
178
  const tail = d.tail as number | undefined;
177
179
  const filter = d.filter as string | undefined;
178
- // "client" is the generic peer tag; MCP-side aggregator overrides with
179
- // 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.
180
182
  return RuntimeLogBuffer.query({ since, tail, filter }, "client");
181
183
  }
182
184
 
@@ -266,6 +268,9 @@ function setupClientBroker() {
266
268
  if (payload && payload.endpoint === "/api/execute-luau") {
267
269
  return handleExecuteLuau(payload.data);
268
270
  }
271
+ if (payload && payload.endpoint === "/api/eval-runtime") {
272
+ return EvalRuntimeHandlers.evalRuntime(payload.data ?? {});
273
+ }
269
274
  // Legacy: raw execute-luau payload at the top level.
270
275
  return handleExecuteLuau(payload as Record<string, unknown> | undefined);
271
276
  };
@@ -2,7 +2,7 @@ import { HttpService, RunService, ServerStorage } from "@rbxts/services";
2
2
  import State from "./State";
3
3
  import Utils from "./Utils";
4
4
  import UI from "./UI";
5
- import { ensureBridgesInstalled } from "./EvalBridges";
5
+ import { cleanupLegacyEditBridges } from "./EvalBridges";
6
6
  import QueryHandlers from "./handlers/QueryHandlers";
7
7
  import PropertyHandlers from "./handlers/PropertyHandlers";
8
8
  import InstanceHandlers from "./handlers/InstanceHandlers";
@@ -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
@@ -129,6 +130,7 @@ const routeMap: Record<string, Handler> = {
129
130
  "/api/get-tagged": MetadataHandlers.getTagged,
130
131
  "/api/get-selection": MetadataHandlers.getSelection,
131
132
  "/api/execute-luau": MetadataHandlers.executeLuau,
133
+ "/api/eval-runtime": EvalRuntimeHandlers.evalRuntime,
132
134
  "/api/undo": MetadataHandlers.undo,
133
135
  "/api/redo": MetadataHandlers.redo,
134
136
  "/api/bulk-set-attributes": MetadataHandlers.bulkSetAttributes,
@@ -485,18 +487,10 @@ function activatePlugin(connIndex?: number) {
485
487
  // later reports knownInstance=false (process restart, etc).
486
488
  sendReady(conn);
487
489
 
488
- // Keep the eval bridges present in the edit DM so that ANY playtest —
489
- // including one the dev starts manually via the Studio Play button —
490
- // clones them into the play DMs and eval_*_runtime works with no setup
491
- // roundtrip. Only the edit DM installs; play DMs already have the cloned
492
- // copies. Idempotent, so reconnects don't re-dirty the place.
490
+ // Remove legacy edit-mode eval bridge scripts from older plugin builds.
491
+ // Current bridges are created only in running play DataModels.
493
492
  if (!RunService.IsRunning()) {
494
- task.spawn(() => {
495
- const result = ensureBridgesInstalled();
496
- if (!result.installed) {
497
- warn(`[MCPPlugin] Eval bridge install failed: ${result.error}`);
498
- }
499
- });
493
+ task.spawn(cleanupLegacyEditBridges);
500
494
  }
501
495
 
502
496
  // Watch for game.Name updates so a stale "Place1" captured at first
@@ -21,30 +21,13 @@
21
21
  // ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
22
22
  // when LoadStringEnabled=false (the default in fresh places).
23
23
  //
24
- // Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
25
- // installs them (ensureBridgesInstalled) when the plugin connects in edit,
26
- // and TestHandlers.startPlaytest force-refreshes them right before
27
- // ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
28
- // play DMs, so the scripts come along and run there. We keep them in the edit
29
- // DM after a playtest ends (rather than cleaning up) so that a playtest the
30
- // dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
31
- // tool — also gets the bridges cloned in. This is intentionally a little
32
- // intrusive (two helper scripts visible in Explorer) in exchange for a
33
- // zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
34
- //
35
- // Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
36
- // with Archivable=false (verified empirically in v2.9.0 testing - bridges
37
- // never reached the play DMs because we'd set them to false). We now keep
38
- // Archivable=true so the clone works, and rely on cleanupBridges() to
39
- // remove the scripts from the edit DM when the test ends. The only failure
40
- // mode is the user saving DURING an active playtest, which would persist
41
- // the bridges to the .rbxl - that's a no-op next session because
42
- // installBridges() always calls cleanupBridges() first to clear stale
43
- // instances. The RemoteFunction/BindableFunction that the bridge scripts
44
- // CREATE at runtime stay Archivable=false (they're runtime-only and should
45
- // never appear in a save).
46
-
47
- import { ServerScriptService, StarterPlayer } from "@rbxts/services";
24
+ // Lifecycle: bridge scripts are created only in running play DataModels.
25
+ // The server plugin peer creates the Script in runtime ServerScriptService;
26
+ // each client plugin peer creates its LocalScript in that client's
27
+ // PlayerScripts. Nothing is installed into the edit DataModel anymore.
28
+ // Runtime-created scripts disappear naturally when the playtest stops.
29
+
30
+ import { Players, ReplicatedStorage, RunService, ServerScriptService, StarterPlayer } from "@rbxts/services";
48
31
 
49
32
  const ScriptEditorService = game.GetService("ScriptEditorService");
50
33
 
@@ -86,9 +69,10 @@ bf.Archivable = false
86
69
  bf.Parent = ServerScriptService
87
70
  bf.OnInvoke = function(payload)
88
71
  if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
89
- return false, "payload must be a ModuleScript instance"
72
+ return { ok = false, value = "payload must be a ModuleScript instance" }
90
73
  end
91
- return pcall(require, payload)
74
+ local ok, value = pcall(require, payload)
75
+ return { ok = ok, value = value }
92
76
  end
93
77
  `;
94
78
 
@@ -113,19 +97,18 @@ bf.Archivable = false
113
97
  bf.Parent = ReplicatedStorage
114
98
  bf.OnInvoke = function(payload)
115
99
  if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
116
- return false, "payload must be a ModuleScript instance"
100
+ return { ok = false, value = "payload must be a ModuleScript instance" }
117
101
  end
118
- return pcall(require, payload)
102
+ local ok, value = pcall(require, payload)
103
+ return { ok = ok, value = value }
119
104
  end
120
105
  `;
121
106
 
122
107
  // Stamp written onto each installed bridge Script so we can tell whether the
123
- // bridge currently in the DM was produced by THIS plugin build. It's a djb2
124
- // hash of the actual bridge source plus the plugin version, so ANY change to
125
- // the source (or a version bump) yields a new stamp which makes
126
- // ensureBridgesInstalled() force a refresh on the next plugin load instead of
127
- // keeping a stale bridge that happens to still be present (e.g. one saved into
128
- // the .rbxl from an older build).
108
+ // runtime bridge currently in the play DM was produced by THIS plugin build.
109
+ // It's a djb2 hash of the actual bridge source plus the plugin version, so ANY
110
+ // change to the source (or a version bump) yields a new stamp and triggers a
111
+ // runtime refresh instead of keeping a stale bridge.
129
112
  const STAMP_ATTR = "__MCPBridgeStamp";
130
113
 
131
114
  function computeBridgeStamp(): string {
@@ -153,7 +136,12 @@ function setSource(scriptInst: Script | LocalScript, source: string): void {
153
136
  }
154
137
  }
155
138
 
156
- function findBridges(): { server?: Instance; client?: Instance } {
139
+ interface InstallResult {
140
+ installed: boolean;
141
+ error?: string;
142
+ }
143
+
144
+ function findLegacyEditBridges(): { server?: Instance; client?: Instance } {
157
145
  const sps = getStarterPlayerScripts();
158
146
  return {
159
147
  server: ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME),
@@ -161,8 +149,16 @@ function findBridges(): { server?: Instance; client?: Instance } {
161
149
  };
162
150
  }
163
151
 
164
- export function cleanupBridges(): void {
165
- const { server, client } = findBridges();
152
+ function destroyIfPresent(parent: Instance, name: string): void {
153
+ const existing = parent.FindFirstChild(name);
154
+ if (existing) {
155
+ pcall(() => existing.Destroy());
156
+ }
157
+ }
158
+
159
+ export function cleanupLegacyEditBridges(): void {
160
+ if (RunService.IsRunning()) return;
161
+ const { server, client } = findLegacyEditBridges();
166
162
  if (server) {
167
163
  pcall(() => server.Destroy());
168
164
  }
@@ -171,52 +167,75 @@ export function cleanupBridges(): void {
171
167
  }
172
168
  }
173
169
 
174
- // Idempotent variant: install only if the bridge scripts aren't already
175
- // present in the edit DM. Used to keep the bridges always available (so a
176
- // playtest the dev starts manually — not via the MCP start_playtest tool —
177
- // still clones them into the play DMs). Cheap no-op when already installed,
178
- // which avoids re-dirtying the place on every plugin reconnect.
179
- export function ensureBridgesInstalled(): { installed: boolean; error?: string } {
180
- const { server, client } = findBridges();
181
- if (server && client) {
182
- // Both present — but only skip the reinstall if they were produced by
183
- // THIS build. A mismatched/absent stamp means a stale bridge (older
184
- // plugin, or one persisted in the saved place), so force a refresh.
185
- const sStamp = server.GetAttribute(STAMP_ATTR);
186
- const cStamp = client.GetAttribute(STAMP_ATTR);
187
- if (sStamp === BRIDGE_STAMP && cStamp === BRIDGE_STAMP) {
188
- return { installed: true };
189
- }
170
+ function serverRuntimeBridgeReady(): boolean {
171
+ const scriptInst = ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME);
172
+ const bindable = ServerScriptService.FindFirstChild(BRIDGE_NAMES.serverLocal);
173
+ return scriptInst !== undefined &&
174
+ scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
175
+ bindable !== undefined &&
176
+ bindable.IsA("BindableFunction");
177
+ }
178
+
179
+ function getPlayerScripts(): Instance | undefined {
180
+ const localPlayer = Players.LocalPlayer;
181
+ if (!localPlayer) return undefined;
182
+ let playerScripts = localPlayer.FindFirstChild("PlayerScripts");
183
+ if (!playerScripts) {
184
+ playerScripts = localPlayer.WaitForChild("PlayerScripts", 5);
190
185
  }
191
- return installBridges();
186
+ return playerScripts;
187
+ }
188
+
189
+ function clientRuntimeBridgeReady(): boolean {
190
+ const playerScripts = getPlayerScripts();
191
+ if (!playerScripts) return false;
192
+ const scriptInst = playerScripts.FindFirstChild(CLIENT_SCRIPT_NAME);
193
+ const bindable = ReplicatedStorage.FindFirstChild(BRIDGE_NAMES.clientLocal);
194
+ return scriptInst !== undefined &&
195
+ scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
196
+ bindable !== undefined &&
197
+ bindable.IsA("BindableFunction");
192
198
  }
193
199
 
194
- export function installBridges(): { installed: boolean; error?: string } {
195
- // Defensive: clear any stale bridges from a prior unclean exit before
196
- // inserting fresh. The injected script also self-cleans its
197
- // ReplicatedStorage/ServerScriptService children at startup, but the
198
- // containing Script/LocalScript objects themselves we must clear here.
199
- cleanupBridges();
200
+ function installServerRuntimeBridge(): InstallResult {
201
+ if (serverRuntimeBridgeReady()) return { installed: true };
200
202
 
201
203
  const [ok, err] = pcall(() => {
204
+ destroyIfPresent(ServerScriptService, SERVER_SCRIPT_NAME);
205
+ destroyIfPresent(ServerScriptService, BRIDGE_NAMES.serverLocal);
206
+
202
207
  const serverScript = new Instance("Script");
203
208
  serverScript.Name = SERVER_SCRIPT_NAME;
204
- // Archivable=true so ExecutePlayModeAsync's deep-clone includes the
205
- // script. cleanupBridges() removes it from the edit DM when the
206
- // playtest ends.
209
+ serverScript.Archivable = false;
207
210
  setSource(serverScript, SERVER_BRIDGE_SOURCE);
208
211
  serverScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
209
212
  serverScript.Parent = ServerScriptService;
213
+ });
214
+
215
+ if (!ok) {
216
+ return { installed: false, error: tostring(err) };
217
+ }
218
+ return { installed: true };
219
+ }
220
+
221
+ function installClientRuntimeBridge(): InstallResult {
222
+ if (clientRuntimeBridgeReady()) return { installed: true };
223
+
224
+ const playerScripts = getPlayerScripts();
225
+ if (!playerScripts) {
226
+ return { installed: false, error: "Players.LocalPlayer.PlayerScripts not found - cannot install client eval bridge" };
227
+ }
228
+
229
+ const [ok, err] = pcall(() => {
230
+ destroyIfPresent(playerScripts, CLIENT_SCRIPT_NAME);
231
+ destroyIfPresent(ReplicatedStorage, BRIDGE_NAMES.clientLocal);
210
232
 
211
- const sps = getStarterPlayerScripts();
212
- if (!sps) {
213
- error("StarterPlayer.StarterPlayerScripts not found - cannot install client eval bridge");
214
- }
215
233
  const clientScript = new Instance("LocalScript");
216
234
  clientScript.Name = CLIENT_SCRIPT_NAME;
235
+ clientScript.Archivable = false;
217
236
  setSource(clientScript, CLIENT_BRIDGE_SOURCE);
218
237
  clientScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
219
- clientScript.Parent = sps;
238
+ clientScript.Parent = playerScripts;
220
239
  });
221
240
 
222
241
  if (!ok) {
@@ -225,3 +244,12 @@ export function installBridges(): { installed: boolean; error?: string } {
225
244
  return { installed: true };
226
245
  }
227
246
 
247
+ export function ensureRuntimeBridgeInstalled(): InstallResult {
248
+ if (!RunService.IsRunning()) {
249
+ return { installed: false, error: "Eval bridges are installed only in running play DataModels" };
250
+ }
251
+ if (RunService.IsServer()) {
252
+ return installServerRuntimeBridge();
253
+ }
254
+ return installClientRuntimeBridge();
255
+ }
@@ -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),