@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.
- package/dist/index.js +1292 -281
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +494 -203
- package/studio-plugin/MCPPlugin.rbxmx +494 -203
- package/studio-plugin/src/modules/ClientBroker.ts +7 -2
- package/studio-plugin/src/modules/Communication.ts +6 -12
- package/studio-plugin/src/modules/EvalBridges.ts +96 -68
- package/studio-plugin/src/modules/LuauExec.ts +134 -36
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +9 -9
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +149 -0
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +5 -6
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +2 -33
- package/studio-plugin/src/server/index.server.ts +27 -8
|
@@ -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
|
|
179
|
-
// the specific role (e.g. "client-1")
|
|
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 {
|
|
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
|
-
//
|
|
489
|
-
//
|
|
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:
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
124
|
-
// hash of the actual bridge source plus the plugin version, so ANY
|
|
125
|
-
// the source (or a version bump) yields a new stamp
|
|
126
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
const
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
7
|
-
//
|
|
8
|
-
// always returns
|
|
9
|
-
//
|
|
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
|
|
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
|
|
50
|
-
// prefix lines, update this constant
|
|
51
|
-
const WRAPPER_LINE_OFFSET =
|
|
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
|
|
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
|
|
103
|
-
\t\t--
|
|
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, "
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
396
|
+
export = {
|
|
397
|
+
buildWrapper,
|
|
398
|
+
countLines,
|
|
399
|
+
execute,
|
|
400
|
+
formatReturnValue,
|
|
401
|
+
recoverPayloadRequireError,
|
|
402
|
+
remapPayloadLines,
|
|
403
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Per-
|
|
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
|
-
//
|
|
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
|
|
14
|
-
// server and client LogService:GetLogHistory())
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
91
|
+
capturedBy: string;
|
|
92
92
|
entries: RuntimeLogEntry[];
|
|
93
93
|
totalDropped: number;
|
|
94
94
|
nextSince: number;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function query(opts: QueryOptions,
|
|
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
|
-
|
|
127
|
+
capturedBy,
|
|
128
128
|
entries: result,
|
|
129
129
|
totalDropped,
|
|
130
130
|
nextSince: last ? last.seq : (opts.since ?? 0),
|