@chrrxs/robloxstudio-mcp 2.15.0 → 2.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +201 -271
- package/package.json +2 -2
- package/studio-plugin/INSTALLATION.md +13 -3
- package/studio-plugin/MCPInspectorPlugin.rbxmx +388 -92
- package/studio-plugin/MCPPlugin.rbxmx +388 -92
- package/studio-plugin/src/modules/ClientBroker.ts +12 -2
- package/studio-plugin/src/modules/Communication.ts +22 -5
- package/studio-plugin/src/modules/EvalBridges.ts +6 -5
- package/studio-plugin/src/modules/LuauExec.ts +134 -36
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +9 -9
- package/studio-plugin/src/modules/State.ts +2 -0
- package/studio-plugin/src/modules/UI.ts +20 -0
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +121 -0
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +5 -6
- package/studio-plugin/src/types/index.d.ts +6 -0
|
@@ -4,7 +4,9 @@ import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
|
4
4
|
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
5
5
|
import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
6
6
|
import InputHandlers from "./handlers/InputHandlers";
|
|
7
|
+
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
7
8
|
import LuauExec from "./LuauExec";
|
|
9
|
+
import State from "./State";
|
|
8
10
|
|
|
9
11
|
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
10
12
|
CanLeaveTest(): boolean;
|
|
@@ -86,6 +88,7 @@ interface BrokerEnvelope {
|
|
|
86
88
|
// cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
87
89
|
const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
|
|
88
90
|
"/api/execute-luau",
|
|
91
|
+
"/api/eval-runtime",
|
|
89
92
|
"/api/get-runtime-logs",
|
|
90
93
|
"/api/get-memory-breakdown",
|
|
91
94
|
"/api/get-scene-analysis",
|
|
@@ -134,6 +137,8 @@ function reRegisterProxy(proxyId: string, role: string): void {
|
|
|
134
137
|
placeName: resolvePlaceName(),
|
|
135
138
|
dataModelName: game.Name,
|
|
136
139
|
isRunning: RunService.IsRunning(),
|
|
140
|
+
pluginVersion: State.CURRENT_VERSION,
|
|
141
|
+
pluginVariant: State.PLUGIN_VARIANT,
|
|
137
142
|
}),
|
|
138
143
|
);
|
|
139
144
|
}
|
|
@@ -172,8 +177,8 @@ function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknow
|
|
|
172
177
|
const since = d.since as number | undefined;
|
|
173
178
|
const tail = d.tail as number | undefined;
|
|
174
179
|
const filter = d.filter as string | undefined;
|
|
175
|
-
// "client" is the generic
|
|
176
|
-
// 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.
|
|
177
182
|
return RuntimeLogBuffer.query({ since, tail, filter }, "client");
|
|
178
183
|
}
|
|
179
184
|
|
|
@@ -263,6 +268,9 @@ function setupClientBroker() {
|
|
|
263
268
|
if (payload && payload.endpoint === "/api/execute-luau") {
|
|
264
269
|
return handleExecuteLuau(payload.data);
|
|
265
270
|
}
|
|
271
|
+
if (payload && payload.endpoint === "/api/eval-runtime") {
|
|
272
|
+
return EvalRuntimeHandlers.evalRuntime(payload.data ?? {});
|
|
273
|
+
}
|
|
266
274
|
// Legacy: raw execute-luau payload at the top level.
|
|
267
275
|
return handleExecuteLuau(payload as Record<string, unknown> | undefined);
|
|
268
276
|
};
|
|
@@ -329,6 +337,8 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
329
337
|
placeName: resolvePlaceName(),
|
|
330
338
|
dataModelName: game.Name,
|
|
331
339
|
isRunning: RunService.IsRunning(),
|
|
340
|
+
pluginVersion: State.CURRENT_VERSION,
|
|
341
|
+
pluginVariant: State.PLUGIN_VARIANT,
|
|
332
342
|
});
|
|
333
343
|
if (!ok || !res || !res.Success) {
|
|
334
344
|
warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}`);
|
|
@@ -17,6 +17,7 @@ import LogHandlers from "./handlers/LogHandlers";
|
|
|
17
17
|
import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
18
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
19
19
|
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
20
|
+
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
20
21
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
21
22
|
|
|
22
23
|
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
@@ -47,6 +48,8 @@ function computeInstanceId(): string {
|
|
|
47
48
|
const instanceId = computeInstanceId();
|
|
48
49
|
let assignedRole: string | undefined;
|
|
49
50
|
let duplicateInstanceRole = false;
|
|
51
|
+
let hasVersionMismatch = false;
|
|
52
|
+
let lastVersionMismatchWarningKey: string | undefined;
|
|
50
53
|
|
|
51
54
|
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
52
55
|
// /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
@@ -127,6 +130,7 @@ const routeMap: Record<string, Handler> = {
|
|
|
127
130
|
"/api/get-tagged": MetadataHandlers.getTagged,
|
|
128
131
|
"/api/get-selection": MetadataHandlers.getSelection,
|
|
129
132
|
"/api/execute-luau": MetadataHandlers.executeLuau,
|
|
133
|
+
"/api/eval-runtime": EvalRuntimeHandlers.evalRuntime,
|
|
130
134
|
"/api/undo": MetadataHandlers.undo,
|
|
131
135
|
"/api/redo": MetadataHandlers.redo,
|
|
132
136
|
"/api/bulk-set-attributes": MetadataHandlers.bulkSetAttributes,
|
|
@@ -238,6 +242,8 @@ function sendReady(conn: Connection): void {
|
|
|
238
242
|
placeName: resolvePlaceName(),
|
|
239
243
|
dataModelName: game.Name,
|
|
240
244
|
isRunning: RunService.IsRunning(),
|
|
245
|
+
pluginVersion: State.CURRENT_VERSION,
|
|
246
|
+
pluginVariant: State.PLUGIN_VARIANT,
|
|
241
247
|
pluginReady: true,
|
|
242
248
|
timestamp: tick(),
|
|
243
249
|
}),
|
|
@@ -301,6 +307,19 @@ function pollForRequests(connIndex: number) {
|
|
|
301
307
|
const mcpConnected = data.mcpConnected === true;
|
|
302
308
|
conn.lastHttpOk = true;
|
|
303
309
|
conn.lastMcpOk = mcpConnected;
|
|
310
|
+
const serverVersion = data.serverVersion ?? "unknown";
|
|
311
|
+
if (data.versionMismatch === true) {
|
|
312
|
+
hasVersionMismatch = true;
|
|
313
|
+
const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
|
|
314
|
+
if (lastVersionMismatchWarningKey !== warningKey) {
|
|
315
|
+
lastVersionMismatchWarningKey = warningKey;
|
|
316
|
+
warn(`[MCPPlugin] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
|
|
317
|
+
}
|
|
318
|
+
UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
|
|
319
|
+
} else if (hasVersionMismatch) {
|
|
320
|
+
hasVersionMismatch = false;
|
|
321
|
+
UI.hideBanner("version-mismatch");
|
|
322
|
+
}
|
|
304
323
|
|
|
305
324
|
// Server tells us when its in-memory instances map doesn't have us
|
|
306
325
|
// (e.g. after an MCP process restart). Re-issue /ready immediately so
|
|
@@ -539,11 +558,9 @@ function checkForUpdates() {
|
|
|
539
558
|
if (ok && data?.version) {
|
|
540
559
|
const latestVersion = data.version;
|
|
541
560
|
if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
ui.contentFrame.Position = new UDim2(0, 8, 0, 92);
|
|
546
|
-
ui.contentFrame.Size = new UDim2(1, -16, 1, -100);
|
|
561
|
+
if (!hasVersionMismatch) {
|
|
562
|
+
UI.showBanner("update", `v${latestVersion} available - github.com/chrrxs/robloxstudio-mcp`);
|
|
563
|
+
}
|
|
547
564
|
}
|
|
548
565
|
}
|
|
549
566
|
}
|
|
@@ -86,9 +86,10 @@ bf.Archivable = false
|
|
|
86
86
|
bf.Parent = ServerScriptService
|
|
87
87
|
bf.OnInvoke = function(payload)
|
|
88
88
|
if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
|
|
89
|
-
return false, "payload must be a ModuleScript instance"
|
|
89
|
+
return { ok = false, value = "payload must be a ModuleScript instance" }
|
|
90
90
|
end
|
|
91
|
-
|
|
91
|
+
local ok, value = pcall(require, payload)
|
|
92
|
+
return { ok = ok, value = value }
|
|
92
93
|
end
|
|
93
94
|
`;
|
|
94
95
|
|
|
@@ -113,9 +114,10 @@ bf.Archivable = false
|
|
|
113
114
|
bf.Parent = ReplicatedStorage
|
|
114
115
|
bf.OnInvoke = function(payload)
|
|
115
116
|
if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
|
|
116
|
-
return false, "payload must be a ModuleScript instance"
|
|
117
|
+
return { ok = false, value = "payload must be a ModuleScript instance" }
|
|
117
118
|
end
|
|
118
|
-
|
|
119
|
+
local ok, value = pcall(require, payload)
|
|
120
|
+
return { ok = ok, value = value }
|
|
119
121
|
end
|
|
120
122
|
`;
|
|
121
123
|
|
|
@@ -224,4 +226,3 @@ export function installBridges(): { installed: boolean; error?: string } {
|
|
|
224
226
|
}
|
|
225
227
|
return { installed: true };
|
|
226
228
|
}
|
|
227
|
-
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
|
|
4
4
|
// module owns:
|
|
5
5
|
//
|
|
6
|
-
// 1. The IIFE wrapper that captures print/warn,
|
|
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),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Connection } from "../types";
|
|
2
2
|
|
|
3
3
|
const CURRENT_VERSION = "__VERSION__";
|
|
4
|
+
const PLUGIN_VARIANT = "__PLUGIN_VARIANT__";
|
|
4
5
|
const MAX_CONNECTIONS = 5;
|
|
5
6
|
const BASE_PORT = 58741;
|
|
6
7
|
let activeTabIndex = 0;
|
|
@@ -81,6 +82,7 @@ function getConnections(): Connection[] {
|
|
|
81
82
|
|
|
82
83
|
export = {
|
|
83
84
|
CURRENT_VERSION,
|
|
85
|
+
PLUGIN_VARIANT,
|
|
84
86
|
MAX_CONNECTIONS,
|
|
85
87
|
BASE_PORT,
|
|
86
88
|
connections,
|
|
@@ -38,6 +38,7 @@ interface ToolbarIcons {
|
|
|
38
38
|
let toolbarButton: PluginToolbarButton | undefined;
|
|
39
39
|
let toolbarIcons: ToolbarIcons | undefined;
|
|
40
40
|
let lastToolbarIcon: string | undefined;
|
|
41
|
+
let activeBannerKind: string | undefined;
|
|
41
42
|
|
|
42
43
|
function setToolbarButton(btn: PluginToolbarButton, icons: ToolbarIcons) {
|
|
43
44
|
toolbarButton = btn;
|
|
@@ -77,6 +78,23 @@ function tweenProp(instance: Instance, props: Record<string, unknown>) {
|
|
|
77
78
|
TweenService.Create(instance, TWEEN_QUICK, props as unknown as { [key: string]: unknown }).Play();
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
function showBanner(kind: string, text: string) {
|
|
82
|
+
activeBannerKind = kind;
|
|
83
|
+
elements.updateBannerText.Text = text;
|
|
84
|
+
elements.updateBanner.Visible = true;
|
|
85
|
+
elements.contentFrame.Position = new UDim2(0, 8, 0, 92);
|
|
86
|
+
elements.contentFrame.Size = new UDim2(1, -16, 1, -100);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hideBanner(kind?: string) {
|
|
90
|
+
if (kind !== undefined && activeBannerKind !== kind) return;
|
|
91
|
+
activeBannerKind = undefined;
|
|
92
|
+
elements.updateBanner.Visible = false;
|
|
93
|
+
elements.updateBannerText.Text = "";
|
|
94
|
+
elements.contentFrame.Position = new UDim2(0, 8, 0, 66);
|
|
95
|
+
elements.contentFrame.Size = new UDim2(1, -16, 1, -74);
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
const C = {
|
|
81
99
|
bg: Color3.fromRGB(14, 14, 14),
|
|
82
100
|
card: Color3.fromRGB(22, 22, 22),
|
|
@@ -759,5 +777,7 @@ export = {
|
|
|
759
777
|
startPulseAnimation,
|
|
760
778
|
setToolbarButton,
|
|
761
779
|
updateToolbarIcon,
|
|
780
|
+
showBanner,
|
|
781
|
+
hideBanner,
|
|
762
782
|
getElements: () => elements,
|
|
763
783
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { LogService, ReplicatedStorage, RunService, ServerScriptService } from "@rbxts/services";
|
|
2
|
+
import { BRIDGE_NAMES } from "../EvalBridges";
|
|
3
|
+
import LuauExec from "../LuauExec";
|
|
4
|
+
|
|
5
|
+
const PAYLOAD_INSTANCE_NAME = "__MCPEvalPayload";
|
|
6
|
+
|
|
7
|
+
interface BridgeInvokeResult {
|
|
8
|
+
ok?: boolean;
|
|
9
|
+
value?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface WrapperResult {
|
|
13
|
+
ok?: boolean;
|
|
14
|
+
value?: unknown;
|
|
15
|
+
output?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getBridgeConfig() {
|
|
19
|
+
if (!RunService.IsRunning()) {
|
|
20
|
+
return {
|
|
21
|
+
error: "eval_*_runtime requires a running playtest.",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (RunService.IsServer()) {
|
|
25
|
+
return {
|
|
26
|
+
service: ServerScriptService,
|
|
27
|
+
bridgeName: BRIDGE_NAMES.serverLocal,
|
|
28
|
+
missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
service: ReplicatedStorage,
|
|
33
|
+
bridgeName: BRIDGE_NAMES.clientLocal,
|
|
34
|
+
missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function evalRuntime(requestData: Record<string, unknown>) {
|
|
39
|
+
const code = requestData.code as string;
|
|
40
|
+
if (!code || code === "") return { error: "Code is required" };
|
|
41
|
+
|
|
42
|
+
const config = getBridgeConfig();
|
|
43
|
+
if (config.error !== undefined) {
|
|
44
|
+
return { bridge: "missing", error: config.error };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const bridge = config.service.FindFirstChild(config.bridgeName);
|
|
48
|
+
if (!bridge || !bridge.IsA("BindableFunction")) {
|
|
49
|
+
return { bridge: "missing", error: config.missingError };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const m = new Instance("ModuleScript");
|
|
53
|
+
m.Name = PAYLOAD_INSTANCE_NAME;
|
|
54
|
+
const userLines = LuauExec.countLines(code);
|
|
55
|
+
const wrapped = LuauExec.buildWrapper(code, PAYLOAD_INSTANCE_NAME);
|
|
56
|
+
|
|
57
|
+
const [okSet, setErr] = pcall(() => {
|
|
58
|
+
(m as unknown as { Source: string }).Source = wrapped;
|
|
59
|
+
});
|
|
60
|
+
if (!okSet) {
|
|
61
|
+
m.Destroy();
|
|
62
|
+
return {
|
|
63
|
+
bridge: "ok",
|
|
64
|
+
ok: false,
|
|
65
|
+
error: `ModuleScript Source set failed: ${tostring(setErr)}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
m.Parent = game.GetService("Workspace");
|
|
70
|
+
const historyStart = LogService.GetLogHistory().size();
|
|
71
|
+
const [invokeOk, invokeResult] = pcall(() => bridge.Invoke(m) as BridgeInvokeResult);
|
|
72
|
+
m.Destroy();
|
|
73
|
+
|
|
74
|
+
if (!invokeOk) {
|
|
75
|
+
return {
|
|
76
|
+
bridge: "ok",
|
|
77
|
+
ok: false,
|
|
78
|
+
error: tostring(invokeResult),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!typeIs(invokeResult, "table")) {
|
|
83
|
+
return {
|
|
84
|
+
bridge: "ok",
|
|
85
|
+
ok: false,
|
|
86
|
+
error: `Eval bridge returned invalid result: ${tostring(invokeResult)}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const bridgeResult = invokeResult as BridgeInvokeResult;
|
|
91
|
+
if (bridgeResult.ok !== true) {
|
|
92
|
+
return {
|
|
93
|
+
bridge: "ok",
|
|
94
|
+
ok: false,
|
|
95
|
+
error: LuauExec.recoverPayloadRequireError(bridgeResult.value, userLines, PAYLOAD_INSTANCE_NAME, historyStart),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const inner = bridgeResult.value;
|
|
100
|
+
if (!typeIs(inner, "table")) {
|
|
101
|
+
return {
|
|
102
|
+
bridge: "ok",
|
|
103
|
+
ok: true,
|
|
104
|
+
result: inner === undefined ? undefined : LuauExec.formatReturnValue(inner),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const r = inner as WrapperResult;
|
|
109
|
+
const ok = r.ok === true;
|
|
110
|
+
return {
|
|
111
|
+
bridge: "ok",
|
|
112
|
+
ok,
|
|
113
|
+
result: ok && r.value !== undefined ? LuauExec.formatReturnValue(r.value) : undefined,
|
|
114
|
+
error: !ok ? tostring(r.value) : undefined,
|
|
115
|
+
output: r.output ?? [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export = {
|
|
120
|
+
evalRuntime,
|
|
121
|
+
};
|
|
@@ -4,12 +4,11 @@ function getRuntimeLogs(requestData: Record<string, unknown>): unknown {
|
|
|
4
4
|
const since = requestData.since as number | undefined;
|
|
5
5
|
const tail = requestData.tail as number | undefined;
|
|
6
6
|
const filter = requestData.filter as string | undefined;
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return RuntimeLogBuffer.query({ since, tail, filter }, peer);
|
|
7
|
+
// This is the buffer that captured the LogService event, not necessarily
|
|
8
|
+
// the script-origin peer. Ordinary playtests share/reflect logs across
|
|
9
|
+
// edit/server/client LogService buffers.
|
|
10
|
+
const capturedBy = RuntimeLogBuffer.detectPeer();
|
|
11
|
+
return RuntimeLogBuffer.query({ since, tail, filter }, capturedBy);
|
|
13
12
|
}
|
|
14
13
|
|
|
15
14
|
export = { getRuntimeLogs };
|