@chrrxs/robloxstudio-mcp 2.11.3 → 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.
- package/dist/index.js +1024 -404
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +24 -3
- package/studio-plugin/MCPPlugin.rbxmx +596 -196
- package/studio-plugin/src/modules/ClientBroker.ts +69 -35
- package/studio-plugin/src/modules/Communication.ts +101 -5
- package/studio-plugin/src/modules/LuauExec.ts +305 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +67 -31
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +6 -121
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +20 -2
- package/studio-plugin/src/types/index.d.ts +5 -2
|
@@ -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
|
-
|
|
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(() =>
|
|
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)
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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?
|
|
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", {
|
|
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, {
|
|
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", {
|
|
275
|
+
postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
|
|
242
276
|
}
|
|
243
277
|
});
|
|
244
278
|
game.BindToClose(() => {
|
|
245
279
|
for (const [, entry] of proxyByPlayer) {
|
|
246
|
-
postJson("/disconnect", {
|
|
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
|
-
|
|
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
|
|
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?
|
|
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({
|
|
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 };
|