@chrrxs/robloxstudio-mcp 2.11.4 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,43 @@
1
- import { HttpService, Players, ReplicatedStorage, RunService } from "@rbxts/services";
1
+ import { HttpService, Players, ReplicatedStorage, RunService, ServerStorage } from "@rbxts/services";
2
2
  import RuntimeLogBuffer from "./RuntimeLogBuffer";
3
3
  import MemoryHandlers from "./handlers/MemoryHandlers";
4
+ import LuauExec from "./LuauExec";
5
+
6
+ // Mirror of Communication.computeInstanceId() — duplicated here because the
7
+ // client broker runs in the play-server DM where it can't easily import from
8
+ // the edit-side module, and the place identifier must match what the edit-DM
9
+ // plugin reports. Both use the same algorithm against the shared DataModel.
10
+ function computeInstanceId(): string {
11
+ if (game.PlaceId !== 0) {
12
+ return `place:${tostring(game.PlaceId)}`;
13
+ }
14
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
15
+ if (typeIs(existing, "string") && existing !== "") {
16
+ return `anon:${existing as string}`;
17
+ }
18
+ const fresh = HttpService.GenerateGUID(false);
19
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
20
+ return `anon:${fresh}`;
21
+ }
22
+
23
+ let cachedPlaceName: string | undefined;
24
+ function resolvePlaceName(): string {
25
+ if (cachedPlaceName !== undefined) return cachedPlaceName;
26
+ if (game.PlaceId === 0) {
27
+ cachedPlaceName = game.Name;
28
+ return cachedPlaceName;
29
+ }
30
+ const MarketplaceService = game.GetService("MarketplaceService");
31
+ const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
32
+ if (ok && info !== undefined) {
33
+ const name = (info as { Name?: string }).Name;
34
+ if (typeIs(name, "string") && name !== "") {
35
+ cachedPlaceName = name;
36
+ return cachedPlaceName;
37
+ }
38
+ }
39
+ return game.Name;
40
+ }
4
41
 
5
42
  // The client peer cannot reach the MCP HTTP server - Roblox forbids
6
43
  // HttpService:RequestAsync from the client DM even under PluginSecurity, and
@@ -18,7 +55,7 @@ const MCP_URL = "http://localhost:58741";
18
55
  const BROKER_NAME = "__MCPClientBroker";
19
56
 
20
57
  interface ProxyEntry {
21
- instanceId: string;
58
+ pluginSessionId: string;
22
59
  role: string;
23
60
  }
24
61
 
@@ -31,12 +68,6 @@ interface BrokerEnvelope {
31
68
  code?: string;
32
69
  }
33
70
 
34
- interface ExecuteResult {
35
- success: boolean;
36
- returnValue?: string;
37
- message?: string;
38
- error?: string;
39
- }
40
71
 
41
72
  // Endpoints the server-peer broker is allowed to forward to the client peer.
42
73
  // Each requires the client peer's plugin VM (because the buffer / require
@@ -72,7 +103,17 @@ function reRegisterProxy(proxyId: string, role: string): void {
72
103
  const last = lastReadyByProxy.get(proxyId) ?? 0;
73
104
  if (now - last < 2) return;
74
105
  lastReadyByProxy.set(proxyId, now);
75
- pcall(() => postJson("/ready", { instanceId: proxyId, role }));
106
+ pcall(() =>
107
+ postJson("/ready", {
108
+ pluginSessionId: proxyId,
109
+ instanceId: computeInstanceId(),
110
+ role,
111
+ placeId: game.PlaceId,
112
+ placeName: resolvePlaceName(),
113
+ dataModelName: game.Name,
114
+ isRunning: RunService.IsRunning(),
115
+ }),
116
+ );
76
117
  }
77
118
 
78
119
  function forkRole(): "edit" | "server" | "client" {
@@ -92,31 +133,16 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
92
133
  );
93
134
  }
94
135
 
95
- function handleExecuteLuau(data: Record<string, unknown> | undefined): ExecuteResult {
136
+ function handleExecuteLuau(data: Record<string, unknown> | undefined) {
96
137
  const code = data && (data.code as string | undefined);
97
138
  if (typeIs(code, "string") === false || code === "") {
98
139
  return { success: false, error: "code is required" };
99
140
  }
100
- const m = new Instance("ModuleScript");
101
- m.Name = "__MCPClientEval";
102
- const [okSet, setErr] = pcall(() => {
103
- (m as unknown as { Source: string }).Source = code as string;
104
- });
105
- if (!okSet) {
106
- m.Destroy();
107
- return { success: false, error: `Source set failed: ${tostring(setErr)}` };
108
- }
109
- m.Parent = game.Workspace;
110
- const [okReq, result] = pcall(() => require(m));
111
- m.Destroy();
112
- if (okReq) {
113
- return {
114
- success: true,
115
- returnValue: result !== undefined ? tostring(result) : undefined,
116
- message: "Code executed successfully",
117
- };
118
- }
119
- return { success: false, error: tostring(result) };
141
+ // Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
142
+ // wrapper (so `print("hi")` with no return doesn't fail the
143
+ // ModuleScript's "must return one value" rule) and JSON-encodes table
144
+ // returns instead of yielding "table: 0xaddr".
145
+ return LuauExec.execute(code as string);
120
146
  }
121
147
 
122
148
  function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknown {
@@ -162,7 +188,7 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
162
188
  while (player.Parent !== undefined && proxyByPlayer.has(player)) {
163
189
  const [ok, res] = pcall(() =>
164
190
  HttpService.RequestAsync({
165
- Url: `${MCP_URL}/poll?instanceId=${proxyId}`,
191
+ Url: `${MCP_URL}/poll?pluginSessionId=${proxyId}`,
166
192
  Method: "GET",
167
193
  Headers: { "Content-Type": "application/json" },
168
194
  }),
@@ -206,14 +232,22 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
206
232
  function registerProxy(player: Player, rf: RemoteFunction) {
207
233
  if (proxyByPlayer.has(player)) return;
208
234
  const proxyId = HttpService.GenerateGUID(false);
209
- const [ok, res] = postJson("/ready", { instanceId: proxyId, role: "client" });
235
+ const [ok, res] = postJson("/ready", {
236
+ pluginSessionId: proxyId,
237
+ instanceId: computeInstanceId(),
238
+ role: "client",
239
+ placeId: game.PlaceId,
240
+ placeName: resolvePlaceName(),
241
+ dataModelName: game.Name,
242
+ isRunning: RunService.IsRunning(),
243
+ });
210
244
  if (!ok || !res || !res.Success) {
211
245
  warn(`[MCPFork] proxy register failed for ${player.Name}`);
212
246
  return;
213
247
  }
214
248
  const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
215
249
  const assigned = body.assignedRole ?? "client";
216
- proxyByPlayer.set(player, { instanceId: proxyId, role: assigned });
250
+ proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
217
251
  task.spawn(pollProxy, proxyId, player, rf);
218
252
  }
219
253
 
@@ -238,12 +272,12 @@ function setupServerBroker() {
238
272
  const entry = proxyByPlayer.get(p);
239
273
  if (entry) {
240
274
  proxyByPlayer.delete(p);
241
- postJson("/disconnect", { instanceId: entry.instanceId });
275
+ postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
242
276
  }
243
277
  });
244
278
  game.BindToClose(() => {
245
279
  for (const [, entry] of proxyByPlayer) {
246
- postJson("/disconnect", { instanceId: entry.instanceId });
280
+ postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
247
281
  }
248
282
  proxyByPlayer.clear();
249
283
  });
@@ -1,4 +1,4 @@
1
- import { HttpService, RunService } from "@rbxts/services";
1
+ 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";
@@ -17,8 +17,60 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
17
17
  import MemoryHandlers from "./handlers/MemoryHandlers";
18
18
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
19
19
 
20
- const instanceId = HttpService.GenerateGUID(false);
20
+ // Per-plugin-load random GUID. Used as the /poll URL param so the server
21
+ // can tell our polls apart from any other plugin's polls. Not user-facing —
22
+ // MCP tools and the LLM operate on instanceId (the place identifier).
23
+ const pluginSessionId = HttpService.GenerateGUID(false);
24
+
25
+ // Place-level identifier shared by every plugin running in DataModels of
26
+ // the same place file (edit DM + playtest server DM + playtest clients).
27
+ // Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
28
+ // places where the UUID lives on ServerStorage's __MCPPlaceId attribute
29
+ // and travels with the .rbxl.
30
+ const MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId";
31
+
32
+ function computeInstanceId(): string {
33
+ if (game.PlaceId !== 0) {
34
+ return `place:${tostring(game.PlaceId)}`;
35
+ }
36
+ const existing = ServerStorage.GetAttribute(MCP_PLACE_ID_ATTRIBUTE);
37
+ if (typeIs(existing, "string") && existing !== "") {
38
+ return `anon:${existing as string}`;
39
+ }
40
+ const fresh = HttpService.GenerateGUID(false);
41
+ pcall(() => ServerStorage.SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh));
42
+ return `anon:${fresh}`;
43
+ }
44
+
45
+ const instanceId = computeInstanceId();
21
46
  let assignedRole: string | undefined;
47
+ let duplicateInstanceRole = false;
48
+
49
+ // Cache the published place name from MarketplaceService:GetProductInfo so
50
+ // /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
51
+ // from game.Name (the DataModel name, often "Place1" in edit). We only fetch
52
+ // once per plugin load; the published name doesn't change mid-session.
53
+ let cachedPlaceName: string | undefined;
54
+
55
+ function resolvePlaceName(): string {
56
+ if (cachedPlaceName !== undefined) return cachedPlaceName;
57
+ if (game.PlaceId === 0) {
58
+ cachedPlaceName = game.Name;
59
+ return cachedPlaceName;
60
+ }
61
+ const MarketplaceService = game.GetService("MarketplaceService");
62
+ const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
63
+ if (ok && info !== undefined) {
64
+ const name = (info as { Name?: string }).Name;
65
+ if (typeIs(name, "string") && name !== "") {
66
+ cachedPlaceName = name;
67
+ return cachedPlaceName;
68
+ }
69
+ }
70
+ // Don't cache failures — could be transient (offline, rate-limited).
71
+ // Next /ready will retry. Return game.Name as fallback.
72
+ return game.Name;
73
+ }
22
74
 
23
75
  function detectRole(): string {
24
76
  if (!RunService.IsRunning()) return "edit";
@@ -140,7 +192,25 @@ function getConnectionStatus(connIndex: number): string {
140
192
  // restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
141
193
  let lastReadyPostAt = 0;
142
194
 
195
+ // game.Name is sometimes "Place1" at plugin-load time and only settles to
196
+ // the real DataModel name (e.g. "Game" once playtest spawns the play DM)
197
+ // after Studio finishes wiring things up. Re-fire /ready when it changes so
198
+ // get_connected_instances doesn't show a stale dataModelName forever. Set
199
+ // up once per plugin load — the connection passed in is whichever was
200
+ // active when activatePlugin was first called.
201
+ let nameChangeConn: RBXScriptConnection | undefined;
202
+ function ensureNameChangeWatcher(conn: Connection): void {
203
+ if (nameChangeConn) return;
204
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
205
+ if (!okSig || !signal) return;
206
+ nameChangeConn = signal.Connect(() => {
207
+ // sendReady has its own 2s throttle, so rapid burst changes coalesce.
208
+ sendReady(conn);
209
+ });
210
+ }
211
+
143
212
  function sendReady(conn: Connection): void {
213
+ if (duplicateInstanceRole) return; // stop retrying once the server has rejected us
144
214
  const now = tick();
145
215
  if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
146
216
  lastReadyPostAt = now;
@@ -151,14 +221,36 @@ function sendReady(conn: Connection): void {
151
221
  Method: "POST",
152
222
  Headers: { "Content-Type": "application/json" },
153
223
  Body: HttpService.JSONEncode({
224
+ pluginSessionId,
154
225
  instanceId,
155
226
  role: detectRole(),
227
+ placeId: game.PlaceId,
228
+ placeName: resolvePlaceName(),
229
+ dataModelName: game.Name,
230
+ isRunning: RunService.IsRunning(),
156
231
  pluginReady: true,
157
232
  timestamp: tick(),
158
233
  }),
159
234
  });
160
235
  });
161
- if (readyOk && readyResult.Success) {
236
+ if (!readyOk) return;
237
+ // 409 = duplicate_instance_role. Surface in UI and stop polling.
238
+ if (readyResult.StatusCode === 409) {
239
+ duplicateInstanceRole = true;
240
+ conn.isActive = false;
241
+ const ui = UI.getElements();
242
+ if (State.getActiveTabIndex() === 0) {
243
+ ui.statusLabel.Text = "Duplicate instance";
244
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
245
+ ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role";
246
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
247
+ }
248
+ warn(
249
+ `[MCPPlugin] Another Studio is already connected as (${instanceId}, ${detectRole()}). Close the other Studio window or this one.`,
250
+ );
251
+ return;
252
+ }
253
+ if (readyResult.Success) {
162
254
  const [parseOk, readyData] = pcall(
163
255
  () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
164
256
  );
@@ -178,7 +270,7 @@ function pollForRequests(connIndex: number) {
178
270
 
179
271
  const [success, result] = pcall(() => {
180
272
  return HttpService.RequestAsync({
181
- Url: `${conn.serverUrl}/poll?instanceId=${instanceId}`,
273
+ Url: `${conn.serverUrl}/poll?pluginSessionId=${pluginSessionId}`,
182
274
  Method: "GET",
183
275
  Headers: { "Content-Type": "application/json" },
184
276
  });
@@ -365,6 +457,10 @@ function activatePlugin(connIndex?: number) {
365
457
  // Initial /ready; pollForRequests will also re-fire ready if the server
366
458
  // later reports knownInstance=false (process restart, etc).
367
459
  sendReady(conn);
460
+
461
+ // Watch for game.Name updates so a stale "Place1" captured at first
462
+ // /ready gets refreshed once Studio settles on the real DM name.
463
+ ensureNameChangeWatcher(conn);
368
464
  }
369
465
 
370
466
  function deactivatePlugin(connIndex?: number) {
@@ -383,7 +479,7 @@ function deactivatePlugin(connIndex?: number) {
383
479
  Url: `${conn.serverUrl}/disconnect`,
384
480
  Method: "POST",
385
481
  Headers: { "Content-Type": "application/json" },
386
- Body: HttpService.JSONEncode({ instanceId, timestamp: tick() }),
482
+ Body: HttpService.JSONEncode({ pluginSessionId, timestamp: tick() }),
387
483
  });
388
484
  });
389
485
 
@@ -0,0 +1,305 @@
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, 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").
10
+ //
11
+ // 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
12
+ // recovery hack that pulls the real diagnostic from LogService.
13
+ //
14
+ // 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
15
+ // caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
16
+ // pass through tostring. The encode is pcall'd so cycles or
17
+ // non-serializable values gracefully fall back to tostring.
18
+ //
19
+ // Before this module existed, the client peer used a stripped-down
20
+ // require-only execution path that lacked both the wrapper and the JSON
21
+ // formatting, producing two well-known papercuts:
22
+ // - `print("hi")` (no return) failed with "Module code did not return..."
23
+ // - Returning a table yielded `table: 0xaddr` instead of structured data.
24
+
25
+ const HttpService = game.GetService("HttpService");
26
+ const LogService = game.GetService("LogService");
27
+
28
+ interface WrapperResult {
29
+ ok?: boolean;
30
+ value?: unknown;
31
+ output?: defined;
32
+ }
33
+
34
+ interface ExecuteResult {
35
+ success: boolean;
36
+ returnValue?: string;
37
+ output?: string[];
38
+ error?: string;
39
+ message?: string;
40
+ }
41
+
42
+ const PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload";
43
+ const PAYLOAD_PATH_PREFIX = `Workspace.${PAYLOAD_INSTANCE_NAME}:`;
44
+
45
+ // Number of lines the wrapper emits BEFORE the first line of user code.
46
+ // Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
47
+ // (remapPayloadLines, for compile errors recovered from LogService) so user
48
+ // 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;
52
+
53
+ // Count source lines so the wrapper can filter traceback frames that fall
54
+ // outside the user code range (the wrapper's own preamble/postamble lines).
55
+ function countLines(s: string): number {
56
+ let n = 1;
57
+ const size = s.size();
58
+ for (let i = 1; i <= size; i++) {
59
+ if (string.sub(s, i, i) === "\n") n++;
60
+ }
61
+ return n;
62
+ }
63
+
64
+ function buildWrapper(code: string): string {
65
+ // If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
66
+ // match the number of lines emitted BEFORE the ${code} substitution.
67
+ // The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
68
+ // used by remapPayloadLines on the TS side.
69
+ const userLines = countLines(code);
70
+ return `return ((function()
71
+ \tlocal __mcp_traceback
72
+ \tlocal __mcp_remap
73
+ \tlocal __mcp_LINE_OFFSET = ${WRAPPER_LINE_OFFSET}
74
+ \tlocal __mcp_USER_LINES = ${userLines}
75
+ \tlocal __mcp_output = {}
76
+ \tlocal __mcp_real_print = print
77
+ \tlocal __mcp_real_warn = warn
78
+ \tlocal print = function(...)
79
+ \t\t__mcp_real_print(...)
80
+ \t\tlocal args = {...}
81
+ \t\tlocal parts = table.create(#args)
82
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
83
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
84
+ \tend
85
+ \tlocal warn = function(...)
86
+ \t\t__mcp_real_warn(...)
87
+ \t\tlocal args = {...}
88
+ \t\tlocal parts = table.create(#args)
89
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
90
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
91
+ \tend
92
+ \tlocal function __mcp_run()
93
+ ${code}
94
+ \tend
95
+ \t__mcp_remap = function(s)
96
+ \t\t-- Two chunk-name formats can reference our payload:
97
+ \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path
98
+ \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)
99
+ \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.
100
+ \t\t-- Clamping matters for unclosed constructs ("local x = (") where the
101
+ \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.
104
+ \t\tlocal function __mcp_user_line(payload_n)
105
+ \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET
106
+ \t\t\tif user_n < 1 then return "1" end
107
+ \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
108
+ \t\t\treturn tostring(user_n)
109
+ \t\tend
110
+ \t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)
111
+ \t\t\tlocal n = tonumber(num)
112
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
113
+ \t\t\treturn "user_code:" .. num
114
+ \t\tend)
115
+ \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
116
+ \t\t\tlocal n = tonumber(num)
117
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
118
+ \t\t\treturn "user_code:" .. num
119
+ \t\tend)
120
+ \t\treturn s
121
+ \tend
122
+ \t__mcp_traceback = function(err)
123
+ \t\tlocal raw = debug.traceback(tostring(err), 2)
124
+ \t\tlocal kept = {}
125
+ \t\tfor line in string.gmatch(raw, "[^\\n]+") do
126
+ \t\t\t-- Extract referenced line number (either chunk-name format).
127
+ \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")
128
+ \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')
129
+ \t\t\tlocal n = num_str and tonumber(num_str)
130
+ \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing
131
+ \t\t\t-- any filtering, because user-code frames carry that suffix —
132
+ \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY
133
+ \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and
134
+ \t\t\t-- get dropped. Strip first, then apply filters.
135
+ \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))
136
+ \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)
137
+ \t\t\t\tor string.find(line, "__mcp_", 1, true)
138
+ \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)
139
+ \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside
140
+ \t\t\t-- user range) are wrapper internals — drop them. Lines without
141
+ \t\t\t-- a payload-chunk line number (the traceback header / engine
142
+ \t\t\t-- C frames) are kept; remap is a no-op for them.
143
+ \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then
144
+ \t\t\t\tskip = true
145
+ \t\t\tend
146
+ \t\t\tif not skip then
147
+ \t\t\t\ttable.insert(kept, __mcp_remap(line))
148
+ \t\t\tend
149
+ \t\tend
150
+ \t\treturn table.concat(kept, "\\n")
151
+ \tend
152
+ \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)
153
+ \treturn { ok = ok, value = errOrValue, output = __mcp_output }
154
+ end)())`;
155
+ }
156
+
157
+ // TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
158
+ // pulling the real compile-error diagnostic out of LogService — that error
159
+ // references the payload module's line number directly, and never passes
160
+ // through the IIFE's runtime wrapper.
161
+ function remapPayloadLines(s: string, userLines: number): string {
162
+ // Mirror of the Lua __mcp_remap inside the wrapper, for paths that
163
+ // don't pass through the IIFE (compile errors recovered from
164
+ // LogService, the immediate loadstring compileError surface). Same
165
+ // two-format coverage plus the same clamp: unclosed user constructs
166
+ // let the parser consume wrapper postamble, so the raw payload line
167
+ // is sometimes well past user EOF — clamp to [1, userLines] and
168
+ // annotate so the error doesn't say "user_code:49" for one-line input.
169
+ const userLine = (payload: number): string => {
170
+ const u = payload - WRAPPER_LINE_OFFSET;
171
+ if (u < 1) return "1";
172
+ if (u > userLines) return `${tostring(userLines)} (at end of input)`;
173
+ return tostring(u);
174
+ };
175
+ let out = s;
176
+ const [a] = string.gsub(out, "__MCPExecLuauPayload:(%d+)", (num: string) => {
177
+ const n = tonumber(num);
178
+ if (n !== undefined) return `user_code:${userLine(n)}`;
179
+ return `user_code:${num}`;
180
+ });
181
+ out = a;
182
+ const [b] = string.gsub(out, '%[string "[^"]+"%]:(%d+)', (num: string) => {
183
+ const n = tonumber(num);
184
+ if (n !== undefined) return `user_code:${userLine(n)}`;
185
+ return `user_code:${num}`;
186
+ });
187
+ out = b;
188
+ return out;
189
+ }
190
+
191
+ function runViaModuleScript(wrapped: string, userLines: number): WrapperResult {
192
+ const m = new Instance("ModuleScript");
193
+ m.Name = PAYLOAD_INSTANCE_NAME;
194
+ const [okSet, setErr] = pcall(() => {
195
+ (m as unknown as { Source: string }).Source = wrapped;
196
+ });
197
+ if (!okSet) {
198
+ m.Destroy();
199
+ // error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
200
+ // prefix that error() would otherwise prepend, keeping the visible
201
+ // message focused on the user-actionable error rather than our path.
202
+ error(`ModuleScript Source set failed: ${tostring(setErr)}`, 0);
203
+ }
204
+ m.Parent = game.GetService("Workspace");
205
+ const [okReq, reqResult] = pcall(() => require(m));
206
+ m.Destroy();
207
+ 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
+ // Compile errors reference the payload module's line number directly
227
+ // — remap + clamp to user-relative line numbers so `local x = 1 +`
228
+ // reports :1: instead of :23:, and reports the clamp annotation
229
+ // when the parser ran off the end of user code into wrapper code.
230
+ error(remapPayloadLines(errMsg, userLines), 0);
231
+ }
232
+ return reqResult as unknown as WrapperResult;
233
+ }
234
+
235
+ function isLoadstringUnavailable(err: unknown): boolean {
236
+ const errStr = tostring(err);
237
+ const [matchStart] = string.find(errStr, "not available", 1, true);
238
+ return matchStart !== undefined;
239
+ }
240
+
241
+ // Returns a string suitable for `returnValue`. Tables get JSON-encoded so
242
+ // the caller sees structured data instead of "table: 0xaddr". Anything that
243
+ // JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
244
+ function formatReturnValue(value: unknown): string {
245
+ if (value === undefined) return "";
246
+ if (typeIs(value, "table")) {
247
+ const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
248
+ if (ok) return encoded as string;
249
+ }
250
+ return tostring(value);
251
+ }
252
+
253
+ function execute(code: string): ExecuteResult {
254
+ if (!code || code === "") {
255
+ return { success: false, error: "code is required" };
256
+ }
257
+ const wrapped = buildWrapper(code);
258
+ const userLines = countLines(code);
259
+
260
+ let [success, result] = pcall(() => {
261
+ const [fn, compileError] = loadstring(wrapped);
262
+ if (!fn) {
263
+ if (isLoadstringUnavailable(compileError)) {
264
+ return runViaModuleScript(wrapped, userLines);
265
+ }
266
+ error(`Compile error: ${remapPayloadLines(tostring(compileError), userLines)}`, 0);
267
+ }
268
+ return fn() as unknown as WrapperResult;
269
+ });
270
+
271
+ // loadstring can throw (not return nil) when ServerScriptService.
272
+ // LoadStringEnabled is false; treat that as a second-chance fallback.
273
+ if (!success && isLoadstringUnavailable(result)) {
274
+ [success, result] = pcall(() => runViaModuleScript(wrapped, userLines));
275
+ }
276
+
277
+ if (!success) {
278
+ return {
279
+ success: false,
280
+ error: tostring(result),
281
+ output: [],
282
+ message: "Code execution failed",
283
+ };
284
+ }
285
+
286
+ const r = result as unknown as WrapperResult;
287
+ const capturedOutput = r.output as unknown as string[] | undefined;
288
+ const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
289
+ if (r.ok === true) {
290
+ return {
291
+ success: true,
292
+ returnValue: r.value !== undefined ? formatReturnValue(r.value) : undefined,
293
+ output,
294
+ message: "Code executed successfully",
295
+ };
296
+ }
297
+ return {
298
+ success: false,
299
+ error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
300
+ output,
301
+ message: "Code execution failed",
302
+ };
303
+ }
304
+
305
+ export = { execute };