@chrrxs/robloxstudio-mcp 2.9.1 → 2.10.1
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 +151 -48
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +526 -190
- package/studio-plugin/MCPPlugin.rbxmx +526 -190
- package/studio-plugin/src/modules/ClientBroker.ts +125 -48
- package/studio-plugin/src/modules/Communication.ts +60 -27
- package/studio-plugin/src/modules/EvalBridges.ts +16 -53
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +138 -0
- package/studio-plugin/src/modules/UI.ts +36 -0
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +15 -0
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +12 -1
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +16 -1
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +1 -13
- package/studio-plugin/src/server/index.server.ts +11 -1
- package/studio-plugin/src/types/index.d.ts +4 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpService, Players, ReplicatedStorage, RunService } from "@rbxts/services";
|
|
2
|
+
import RuntimeLogBuffer from "./RuntimeLogBuffer";
|
|
2
3
|
|
|
3
4
|
// The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
4
5
|
// HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
@@ -22,7 +23,12 @@ interface ProxyEntry {
|
|
|
22
23
|
role: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
interface
|
|
26
|
+
interface BrokerEnvelope {
|
|
27
|
+
endpoint?: string;
|
|
28
|
+
data?: Record<string, unknown>;
|
|
29
|
+
// Backward-compat: older server-broker code (pre-v2.10) sent the raw
|
|
30
|
+
// {code} payload directly. If we see code at the top level and no
|
|
31
|
+
// endpoint, treat it as execute-luau.
|
|
26
32
|
code?: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -33,6 +39,14 @@ interface ExecuteResult {
|
|
|
33
39
|
error?: string;
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
// Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
43
|
+
// Each requires the client peer's plugin VM (because the buffer / require
|
|
44
|
+
// cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
45
|
+
const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
|
|
46
|
+
"/api/execute-luau",
|
|
47
|
+
"/api/get-runtime-logs",
|
|
48
|
+
]);
|
|
49
|
+
|
|
36
50
|
interface ReadyResponseBody {
|
|
37
51
|
assignedRole?: string;
|
|
38
52
|
}
|
|
@@ -43,6 +57,22 @@ interface PollResponseBody {
|
|
|
43
57
|
endpoint: string;
|
|
44
58
|
data?: Record<string, unknown>;
|
|
45
59
|
};
|
|
60
|
+
// Server signals knownInstance=false when our proxy isn't in its
|
|
61
|
+
// in-memory instances map (typically after an MCP process restart).
|
|
62
|
+
// Triggers a re-register POST to /ready.
|
|
63
|
+
knownInstance?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Throttle re-ready calls per proxyId so a brief window of unknownInstance
|
|
67
|
+
// polls doesn't cause a re-register stampede.
|
|
68
|
+
const lastReadyByProxy = new Map<string, number>();
|
|
69
|
+
|
|
70
|
+
function reRegisterProxy(proxyId: string, role: string): void {
|
|
71
|
+
const now = tick();
|
|
72
|
+
const last = lastReadyByProxy.get(proxyId) ?? 0;
|
|
73
|
+
if (now - last < 2) return;
|
|
74
|
+
lastReadyByProxy.set(proxyId, now);
|
|
75
|
+
pcall(() => postJson("/ready", { instanceId: proxyId, role }));
|
|
46
76
|
}
|
|
47
77
|
|
|
48
78
|
function forkRole(): "edit" | "server" | "client" {
|
|
@@ -62,37 +92,64 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
|
62
92
|
);
|
|
63
93
|
}
|
|
64
94
|
|
|
95
|
+
function handleExecuteLuau(data: Record<string, unknown> | undefined): ExecuteResult {
|
|
96
|
+
const code = data && (data.code as string | undefined);
|
|
97
|
+
if (typeIs(code, "string") === false || code === "") {
|
|
98
|
+
return { success: false, error: "code is required" };
|
|
99
|
+
}
|
|
100
|
+
const m = new Instance("ModuleScript");
|
|
101
|
+
m.Name = "__MCPClientEval";
|
|
102
|
+
const [okSet, setErr] = pcall(() => {
|
|
103
|
+
(m as unknown as { Source: string }).Source = code as string;
|
|
104
|
+
});
|
|
105
|
+
if (!okSet) {
|
|
106
|
+
m.Destroy();
|
|
107
|
+
return { success: false, error: `Source set failed: ${tostring(setErr)}` };
|
|
108
|
+
}
|
|
109
|
+
m.Parent = game.Workspace;
|
|
110
|
+
const [okReq, result] = pcall(() => require(m));
|
|
111
|
+
m.Destroy();
|
|
112
|
+
if (okReq) {
|
|
113
|
+
return {
|
|
114
|
+
success: true,
|
|
115
|
+
returnValue: result !== undefined ? tostring(result) : undefined,
|
|
116
|
+
message: "Code executed successfully",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { success: false, error: tostring(result) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknown {
|
|
123
|
+
const d = data ?? {};
|
|
124
|
+
const since = d.since as number | undefined;
|
|
125
|
+
const tail = d.tail as number | undefined;
|
|
126
|
+
const filter = d.filter as string | undefined;
|
|
127
|
+
// "client" is the generic peer tag; MCP-side aggregator overrides with
|
|
128
|
+
// the specific role (e.g. "client-1") on target=all fan-out.
|
|
129
|
+
return RuntimeLogBuffer.query({ since, tail, filter }, "client");
|
|
130
|
+
}
|
|
131
|
+
|
|
65
132
|
function setupClientBroker() {
|
|
66
133
|
const rf = ReplicatedStorage.WaitForChild(BROKER_NAME, 10);
|
|
67
134
|
if (!rf || !rf.IsA("RemoteFunction")) {
|
|
68
135
|
warn(`[MCPFork] client: ${BROKER_NAME} not found`);
|
|
69
136
|
return;
|
|
70
137
|
}
|
|
71
|
-
rf.OnClientInvoke = (payload:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
138
|
+
rf.OnClientInvoke = (payload: BrokerEnvelope | undefined) => {
|
|
139
|
+
// Two payload shapes in the wild:
|
|
140
|
+
// - {endpoint, data} from v2.10+ server-peer broker (this is the new
|
|
141
|
+
// discriminated form that lets us dispatch on endpoint)
|
|
142
|
+
// - {code} from pre-v2.10 server-peer broker (raw execute-luau payload)
|
|
143
|
+
// The shapes coexist gracefully because we fall back to execute-luau
|
|
144
|
+
// when endpoint is missing.
|
|
145
|
+
if (payload && payload.endpoint === "/api/get-runtime-logs") {
|
|
146
|
+
return handleGetRuntimeLogs(payload.data);
|
|
75
147
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const [okSet, setErr] = pcall(() => {
|
|
79
|
-
(m as unknown as { Source: string }).Source = code as string;
|
|
80
|
-
});
|
|
81
|
-
if (!okSet) {
|
|
82
|
-
m.Destroy();
|
|
83
|
-
return identity<ExecuteResult>({ success: false, error: `Source set failed: ${tostring(setErr)}` });
|
|
84
|
-
}
|
|
85
|
-
m.Parent = game.Workspace;
|
|
86
|
-
const [okReq, result] = pcall(() => require(m));
|
|
87
|
-
m.Destroy();
|
|
88
|
-
if (okReq) {
|
|
89
|
-
return identity<ExecuteResult>({
|
|
90
|
-
success: true,
|
|
91
|
-
returnValue: result !== undefined ? tostring(result) : undefined,
|
|
92
|
-
message: "Code executed successfully",
|
|
93
|
-
});
|
|
148
|
+
if (payload && payload.endpoint === "/api/execute-luau") {
|
|
149
|
+
return handleExecuteLuau(payload.data);
|
|
94
150
|
}
|
|
95
|
-
|
|
151
|
+
// Legacy: raw execute-luau payload at the top level.
|
|
152
|
+
return handleExecuteLuau(payload as Record<string, unknown> | undefined);
|
|
96
153
|
};
|
|
97
154
|
}
|
|
98
155
|
|
|
@@ -109,20 +166,34 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
|
109
166
|
);
|
|
110
167
|
if (ok && res && (res.Success || res.StatusCode === 503)) {
|
|
111
168
|
const [okJson, body] = pcall(() => HttpService.JSONDecode(res.Body) as PollResponseBody);
|
|
112
|
-
if (okJson && body
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
169
|
+
if (okJson && body) {
|
|
170
|
+
// Server lost our proxy registration (process restart, etc.) -
|
|
171
|
+
// re-register so the next poll cycle starts routing again.
|
|
172
|
+
if (body.knownInstance === false) {
|
|
173
|
+
reRegisterProxy(proxyId, "client");
|
|
174
|
+
}
|
|
175
|
+
if (body.request && body.requestId !== undefined) {
|
|
176
|
+
const request = body.request;
|
|
177
|
+
let response: unknown;
|
|
178
|
+
if (CLIENT_BROKER_ALLOWED_ENDPOINTS.has(request.endpoint)) {
|
|
179
|
+
// Forward as a discriminated envelope so the client-side
|
|
180
|
+
// OnClientInvoke knows which endpoint it's serving.
|
|
181
|
+
const envelope = { endpoint: request.endpoint, data: request.data };
|
|
182
|
+
const [okInvoke, invokeRes] = pcall(() => rf.InvokeClient(player, envelope));
|
|
183
|
+
if (okInvoke) {
|
|
184
|
+
response = invokeRes !== undefined ? invokeRes : { success: false, error: "nil response" };
|
|
185
|
+
} else {
|
|
186
|
+
response = { success: false, error: `InvokeClient failed: ${tostring(invokeRes)}` };
|
|
187
|
+
}
|
|
119
188
|
} else {
|
|
120
|
-
response = {
|
|
189
|
+
response = {
|
|
190
|
+
error:
|
|
191
|
+
`Client-proxy does not forward ${tostring(request.endpoint)}. ` +
|
|
192
|
+
`Allowed: /api/execute-luau, /api/get-runtime-logs.`,
|
|
193
|
+
};
|
|
121
194
|
}
|
|
122
|
-
|
|
123
|
-
response = { error: `Client-proxy only supports /api/execute-luau, got: ${tostring(request.endpoint)}` };
|
|
195
|
+
postJson("/response", { requestId: body.requestId, response });
|
|
124
196
|
}
|
|
125
|
-
postJson("/response", { requestId: body.requestId, response });
|
|
126
197
|
}
|
|
127
198
|
}
|
|
128
199
|
task.wait(0.5);
|
|
@@ -161,19 +232,25 @@ function startEditProxyLoop() {
|
|
|
161
232
|
);
|
|
162
233
|
if (okPoll && pollRes && (pollRes.Success || pollRes.StatusCode === 503)) {
|
|
163
234
|
const [okJson, body] = pcall(() => HttpService.JSONDecode(pollRes.Body) as PollResponseBody);
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
body
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
235
|
+
if (okJson && body) {
|
|
236
|
+
// Re-register if the server lost our edit-proxy registration.
|
|
237
|
+
if (body.knownInstance === false) {
|
|
238
|
+
reRegisterProxy(proxyId, "edit-proxy");
|
|
239
|
+
}
|
|
240
|
+
if (
|
|
241
|
+
body.request &&
|
|
242
|
+
body.request.endpoint === "/api/stop-playtest" &&
|
|
243
|
+
body.requestId !== undefined
|
|
244
|
+
) {
|
|
245
|
+
const sts = game.GetService("StudioTestService") as Instance & {
|
|
246
|
+
EndTest(reason: string): void;
|
|
247
|
+
};
|
|
248
|
+
const [endOk, endErr] = pcall(() => sts.EndTest("stopped_by_mcp"));
|
|
249
|
+
const response = endOk
|
|
250
|
+
? { success: true, message: "Playtest stopped via edit-proxy/EndTest" }
|
|
251
|
+
: { success: false, error: `EndTest failed: ${tostring(endErr)}` };
|
|
252
|
+
postJson("/response", { requestId: body.requestId, response });
|
|
253
|
+
}
|
|
177
254
|
}
|
|
178
255
|
}
|
|
179
256
|
task.wait(0.15);
|
|
@@ -12,6 +12,7 @@ import BuildHandlers from "./handlers/BuildHandlers";
|
|
|
12
12
|
import AssetHandlers from "./handlers/AssetHandlers";
|
|
13
13
|
import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
14
14
|
import InputHandlers from "./handlers/InputHandlers";
|
|
15
|
+
import LogHandlers from "./handlers/LogHandlers";
|
|
15
16
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
16
17
|
|
|
17
18
|
const instanceId = HttpService.GenerateGUID(false);
|
|
@@ -92,6 +93,8 @@ const routeMap: Record<string, Handler> = {
|
|
|
92
93
|
"/api/simulate-keyboard-input": InputHandlers.simulateKeyboardInput,
|
|
93
94
|
|
|
94
95
|
"/api/find-and-replace-in-scripts": ScriptHandlers.findAndReplaceInScripts,
|
|
96
|
+
|
|
97
|
+
"/api/get-runtime-logs": LogHandlers.getRuntimeLogs,
|
|
95
98
|
};
|
|
96
99
|
|
|
97
100
|
function processRequest(request: RequestPayload): unknown {
|
|
@@ -125,6 +128,40 @@ function getConnectionStatus(connIndex: number): string {
|
|
|
125
128
|
return "connecting";
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
// Throttle for re-issuing /ready after the server reports knownInstance=false.
|
|
132
|
+
// Without this, every poll during the brief window where the server has just
|
|
133
|
+
// restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
|
|
134
|
+
let lastReadyPostAt = 0;
|
|
135
|
+
|
|
136
|
+
function sendReady(conn: Connection): void {
|
|
137
|
+
const now = tick();
|
|
138
|
+
if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
|
|
139
|
+
lastReadyPostAt = now;
|
|
140
|
+
task.spawn(() => {
|
|
141
|
+
const [readyOk, readyResult] = pcall(() => {
|
|
142
|
+
return HttpService.RequestAsync({
|
|
143
|
+
Url: `${conn.serverUrl}/ready`,
|
|
144
|
+
Method: "POST",
|
|
145
|
+
Headers: { "Content-Type": "application/json" },
|
|
146
|
+
Body: HttpService.JSONEncode({
|
|
147
|
+
instanceId,
|
|
148
|
+
role: detectRole(),
|
|
149
|
+
pluginReady: true,
|
|
150
|
+
timestamp: tick(),
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
if (readyOk && readyResult.Success) {
|
|
155
|
+
const [parseOk, readyData] = pcall(
|
|
156
|
+
() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
|
|
157
|
+
);
|
|
158
|
+
if (parseOk && readyData.assignedRole) {
|
|
159
|
+
assignedRole = readyData.assignedRole;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
128
165
|
function pollForRequests(connIndex: number) {
|
|
129
166
|
const conn = State.getConnection(connIndex);
|
|
130
167
|
if (!conn || !conn.isActive) return;
|
|
@@ -144,6 +181,7 @@ function pollForRequests(connIndex: number) {
|
|
|
144
181
|
|
|
145
182
|
const ui = UI.getElements();
|
|
146
183
|
UI.updateTabDot(connIndex);
|
|
184
|
+
if (connIndex === State.getActiveTabIndex()) UI.updateToolbarIcon();
|
|
147
185
|
|
|
148
186
|
if (success && (result.Success || result.StatusCode === 503)) {
|
|
149
187
|
conn.consecutiveFailures = 0;
|
|
@@ -155,6 +193,15 @@ function pollForRequests(connIndex: number) {
|
|
|
155
193
|
conn.lastHttpOk = true;
|
|
156
194
|
conn.lastMcpOk = mcpConnected;
|
|
157
195
|
|
|
196
|
+
// Server tells us when its in-memory instances map doesn't have us
|
|
197
|
+
// (e.g. after an MCP process restart). Re-issue /ready immediately so
|
|
198
|
+
// target=server/client-N start routing again. The throttle inside
|
|
199
|
+
// sendReady() prevents duplicate registrations while the server
|
|
200
|
+
// catches up.
|
|
201
|
+
if (data.knownInstance === false) {
|
|
202
|
+
sendReady(conn);
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
if (connIndex === State.getActiveTabIndex()) {
|
|
159
206
|
const el = ui;
|
|
160
207
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
|
|
@@ -287,7 +334,6 @@ function activatePlugin(connIndex?: number) {
|
|
|
287
334
|
conn.isActive = true;
|
|
288
335
|
conn.consecutiveFailures = 0;
|
|
289
336
|
conn.currentRetryDelay = 0.5;
|
|
290
|
-
ui.screenGui.Enabled = true;
|
|
291
337
|
|
|
292
338
|
if (idx === State.getActiveTabIndex()) {
|
|
293
339
|
conn.serverUrl = ui.urlInput.Text;
|
|
@@ -298,33 +344,20 @@ function activatePlugin(connIndex?: number) {
|
|
|
298
344
|
}
|
|
299
345
|
UI.updateTabDot(idx);
|
|
300
346
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
pollForRequests(idx);
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const [readyOk, readyResult] = pcall(() => {
|
|
314
|
-
return HttpService.RequestAsync({
|
|
315
|
-
Url: `${conn.serverUrl}/ready`,
|
|
316
|
-
Method: "POST",
|
|
317
|
-
Headers: { "Content-Type": "application/json" },
|
|
318
|
-
Body: HttpService.JSONEncode({ instanceId, role: detectRole(), pluginReady: true, timestamp: tick() }),
|
|
319
|
-
});
|
|
320
|
-
});
|
|
321
|
-
if (readyOk && readyResult.Success) {
|
|
322
|
-
const [parseOk, readyData] = pcall(() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse);
|
|
323
|
-
if (parseOk && readyData.assignedRole) {
|
|
324
|
-
assignedRole = readyData.assignedRole;
|
|
347
|
+
if (!conn.heartbeatConnection) {
|
|
348
|
+
conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
|
|
349
|
+
const now = tick();
|
|
350
|
+
const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
|
|
351
|
+
if (now - conn.lastPoll > currentInterval) {
|
|
352
|
+
conn.lastPoll = now;
|
|
353
|
+
pollForRequests(idx);
|
|
325
354
|
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Initial /ready; pollForRequests will also re-fire ready if the server
|
|
359
|
+
// later reports knownInstance=false (process restart, etc).
|
|
360
|
+
sendReady(conn);
|
|
328
361
|
}
|
|
329
362
|
|
|
330
363
|
function deactivatePlugin(connIndex?: number) {
|
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
// `require(SomeModule)` returns a fresh copy, not the one the running game
|
|
6
6
|
// scripts hold. So runtime-mutated module state is invisible to probes.
|
|
7
7
|
//
|
|
8
|
-
// These bridges fix that by living inside the user's game scripts
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (
|
|
8
|
+
// These bridges fix that by living inside the user's game scripts. Both
|
|
9
|
+
// peers use the same symmetric shape:
|
|
10
|
+
// - Server: a Script in ServerScriptService that creates a BindableFunction.
|
|
11
|
+
// Plugin (server peer) invokes it with a fresh ModuleScript payload;
|
|
12
|
+
// require() runs inside the Script VM so it shares the running server's
|
|
13
|
+
// require cache.
|
|
12
14
|
// - Client: a LocalScript in StarterPlayer.StarterPlayerScripts that
|
|
13
15
|
// creates a BindableFunction. Plugin invokes it with a fresh ModuleScript
|
|
14
16
|
// payload; require() runs inside the LocalScript VM so it shares the
|
|
15
17
|
// game's require cache.
|
|
16
18
|
//
|
|
19
|
+
// Why ModuleScript+require on both sides (no loadstring): require'd modules
|
|
20
|
+
// run with the security level they were created at and don't need
|
|
21
|
+
// ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
|
|
22
|
+
// when LoadStringEnabled=false (the default in fresh places).
|
|
23
|
+
//
|
|
17
24
|
// Lifecycle: TestHandlers.startPlaytest inserts both scripts into the EDIT
|
|
18
25
|
// DM right before ExecutePlayModeAsync. ExecutePlayModeAsync clones the
|
|
19
26
|
// DataModel into the play DMs, so the scripts come along and run there.
|
|
@@ -47,7 +54,6 @@ const CLIENT_SCRIPT_NAME = "__MCP_ClientEvalBridge";
|
|
|
47
54
|
export const BRIDGE_NAMES = {
|
|
48
55
|
serverScript: SERVER_SCRIPT_NAME,
|
|
49
56
|
clientScript: CLIENT_SCRIPT_NAME,
|
|
50
|
-
serverRemote: "__MCP_ServerEvalRemote",
|
|
51
57
|
serverLocal: "__MCP_ServerEvalLocal",
|
|
52
58
|
clientLocal: "__MCP_ClientEvalBridge",
|
|
53
59
|
} as const;
|
|
@@ -59,7 +65,6 @@ const SERVER_BRIDGE_SOURCE = `
|
|
|
59
65
|
-- stop_playtest. Provides shared-require-cache eval on the server peer for
|
|
60
66
|
-- the eval_server_runtime MCP tool.
|
|
61
67
|
|
|
62
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
63
68
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
64
69
|
local RunService = game:GetService("RunService")
|
|
65
70
|
|
|
@@ -67,49 +72,18 @@ if not RunService:IsStudio() then
|
|
|
67
72
|
return
|
|
68
73
|
end
|
|
69
74
|
|
|
70
|
-
local function evalCode(source)
|
|
71
|
-
if type(source) ~= "string" then
|
|
72
|
-
return false, "source must be a string"
|
|
73
|
-
end
|
|
74
|
-
local fn, compileErr = loadstring(source, "MCPServerEval")
|
|
75
|
-
if not fn then
|
|
76
|
-
local errStr = tostring(compileErr or "loadstring returned nil")
|
|
77
|
-
-- Roblox returns nil from loadstring when LoadStringEnabled=false.
|
|
78
|
-
-- Surface a clear, actionable error.
|
|
79
|
-
if string.find(errStr, "not enabled", 1, true)
|
|
80
|
-
or string.find(errStr, "disabled", 1, true)
|
|
81
|
-
or errStr == "loadstring returned nil"
|
|
82
|
-
then
|
|
83
|
-
return false,
|
|
84
|
-
"ServerScriptService.LoadStringEnabled is false. eval_server_runtime requires it. "
|
|
85
|
-
.. "Enable it in Studio (ServerScriptService > Properties > LoadStringEnabled = true) "
|
|
86
|
-
.. "and restart the playtest."
|
|
87
|
-
end
|
|
88
|
-
return false, errStr
|
|
89
|
-
end
|
|
90
|
-
return pcall(fn)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
-- Defensive cleanup of stale instances from a prior session.
|
|
94
|
-
local prevRf = ReplicatedStorage:FindFirstChild("${BRIDGE_NAMES.serverRemote}")
|
|
95
|
-
if prevRf then prevRf:Destroy() end
|
|
96
75
|
local prevBf = ServerScriptService:FindFirstChild("${BRIDGE_NAMES.serverLocal}")
|
|
97
76
|
if prevBf then prevBf:Destroy() end
|
|
98
77
|
|
|
99
|
-
local rf = Instance.new("RemoteFunction")
|
|
100
|
-
rf.Name = "${BRIDGE_NAMES.serverRemote}"
|
|
101
|
-
rf.Archivable = false
|
|
102
|
-
rf.Parent = ReplicatedStorage
|
|
103
|
-
rf.OnServerInvoke = function(_player, source)
|
|
104
|
-
return evalCode(source)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
78
|
local bf = Instance.new("BindableFunction")
|
|
108
79
|
bf.Name = "${BRIDGE_NAMES.serverLocal}"
|
|
109
80
|
bf.Archivable = false
|
|
110
81
|
bf.Parent = ServerScriptService
|
|
111
|
-
bf.OnInvoke = function(
|
|
112
|
-
|
|
82
|
+
bf.OnInvoke = function(payload)
|
|
83
|
+
if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
|
|
84
|
+
return false, "payload must be a ModuleScript instance"
|
|
85
|
+
end
|
|
86
|
+
return pcall(require, payload)
|
|
113
87
|
end
|
|
114
88
|
`;
|
|
115
89
|
|
|
@@ -202,14 +176,3 @@ export function installBridges(): { installed: boolean; error?: string } {
|
|
|
202
176
|
return { installed: true };
|
|
203
177
|
}
|
|
204
178
|
|
|
205
|
-
// Heuristic check so start_playtest can surface a warning when
|
|
206
|
-
// LoadStringEnabled is false (eval_server_runtime won't work in that mode).
|
|
207
|
-
// We can't import the runtime LoadStringEnabled value cleanly without
|
|
208
|
-
// pulling in the type — read defensively.
|
|
209
|
-
export function loadStringEnabled(): boolean {
|
|
210
|
-
const [ok, value] = pcall(
|
|
211
|
-
() => (ServerScriptService as unknown as { LoadStringEnabled: boolean }).LoadStringEnabled,
|
|
212
|
-
);
|
|
213
|
-
return ok && value === true;
|
|
214
|
-
}
|
|
215
|
-
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Per-peer in-memory ring buffer for LogService.MessageOut events.
|
|
2
|
+
// Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
|
|
3
|
+
// primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
|
|
4
|
+
//
|
|
5
|
+
// Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
|
|
6
|
+
// play-server DM, play-client DM all run their own copy of this module).
|
|
7
|
+
// Captured entries live in plugin module-state; nothing is parented to the
|
|
8
|
+
// DataModel. The buffer is bounded by a message-byte budget; oldest entries
|
|
9
|
+
// drop when over budget.
|
|
10
|
+
//
|
|
11
|
+
// Peer-tag caveat: returned entries reflect which peer's plugin CAPTURED the
|
|
12
|
+
// entry, NOT which peer's script originated the print. LogService reflects
|
|
13
|
+
// prints across peers in Studio Play (a server print ends up in both the
|
|
14
|
+
// server and client LogService:GetLogHistory()) and origin is empirically
|
|
15
|
+
// undetectable from inside MessageOut. The MCP-side aggregator handles
|
|
16
|
+
// cross-peer dedup via a 2s timestamp window.
|
|
17
|
+
|
|
18
|
+
import { LogService, RunService } from "@rbxts/services";
|
|
19
|
+
|
|
20
|
+
type LogLevel = "OUT" | "WARN" | "ERR" | "INFO";
|
|
21
|
+
|
|
22
|
+
interface RuntimeLogEntry {
|
|
23
|
+
seq: number;
|
|
24
|
+
ts: number; // wall-clock seconds via DateTime, coherent across peers
|
|
25
|
+
level: LogLevel;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_BYTES = 64 * 1024;
|
|
30
|
+
const HARD_ENTRY_CAP = 50_000;
|
|
31
|
+
|
|
32
|
+
const entries: RuntimeLogEntry[] = [];
|
|
33
|
+
let totalBytes = 0;
|
|
34
|
+
let totalDropped = 0;
|
|
35
|
+
let nextSeq = 1;
|
|
36
|
+
let installed = false;
|
|
37
|
+
|
|
38
|
+
function levelTag(t: Enum.MessageType): LogLevel {
|
|
39
|
+
if (t === Enum.MessageType.MessageWarning) return "WARN";
|
|
40
|
+
if (t === Enum.MessageType.MessageError) return "ERR";
|
|
41
|
+
if (t === Enum.MessageType.MessageInfo) return "INFO";
|
|
42
|
+
return "OUT";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nowSec(): number {
|
|
46
|
+
return DateTime.now().UnixTimestampMillis / 1000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dropOldestUntilFits(incomingBytes: number): void {
|
|
50
|
+
while (
|
|
51
|
+
entries.size() > 0 &&
|
|
52
|
+
(totalBytes + incomingBytes > MAX_BYTES || entries.size() >= HARD_ENTRY_CAP)
|
|
53
|
+
) {
|
|
54
|
+
const dropped = entries.shift()!;
|
|
55
|
+
totalBytes -= dropped.message.size();
|
|
56
|
+
totalDropped += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function install(): void {
|
|
61
|
+
if (installed) return;
|
|
62
|
+
if (!RunService.IsStudio()) return;
|
|
63
|
+
installed = true;
|
|
64
|
+
LogService.MessageOut.Connect((msg, t) => {
|
|
65
|
+
const bytes = msg.size();
|
|
66
|
+
dropOldestUntilFits(bytes);
|
|
67
|
+
entries.push({
|
|
68
|
+
seq: nextSeq,
|
|
69
|
+
ts: nowSec(),
|
|
70
|
+
level: levelTag(t),
|
|
71
|
+
message: msg,
|
|
72
|
+
});
|
|
73
|
+
nextSeq += 1;
|
|
74
|
+
totalBytes += bytes;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function detectPeer(): "edit" | "server" | "client" {
|
|
79
|
+
if (!RunService.IsRunning()) return "edit";
|
|
80
|
+
if (RunService.IsServer()) return "server";
|
|
81
|
+
return "client";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface QueryOptions {
|
|
85
|
+
since?: number;
|
|
86
|
+
tail?: number;
|
|
87
|
+
filter?: string; // Plain substring match, applied to message
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface QueryResult {
|
|
91
|
+
peer: string;
|
|
92
|
+
entries: RuntimeLogEntry[];
|
|
93
|
+
totalDropped: number;
|
|
94
|
+
nextSince: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function query(opts: QueryOptions, peer: string): QueryResult {
|
|
98
|
+
let result = opts.since !== undefined
|
|
99
|
+
? entries.filter((e) => e.seq > (opts.since as number))
|
|
100
|
+
: [...entries];
|
|
101
|
+
|
|
102
|
+
if (opts.filter !== undefined) {
|
|
103
|
+
// Plain substring search (4th arg = true). Pattern matching here was
|
|
104
|
+
// surprising in practice - Lua magic chars in messages would silently
|
|
105
|
+
// not match (e.g. filter="MARK-EDIT" against "MARK-EDIT-001" fails
|
|
106
|
+
// because '-' means "0+" in Lua patterns). Substring search matches
|
|
107
|
+
// most users' mental model of "filter messages containing this text".
|
|
108
|
+
const needle = opts.filter;
|
|
109
|
+
result = result.filter((e) => {
|
|
110
|
+
const [start] = string.find(e.message, needle, 1, true);
|
|
111
|
+
return start !== undefined;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (opts.tail !== undefined && result.size() > opts.tail) {
|
|
116
|
+
// roblox-ts arrays don't expose .slice; manual tail copy.
|
|
117
|
+
const tailed: RuntimeLogEntry[] = [];
|
|
118
|
+
const start = result.size() - opts.tail;
|
|
119
|
+
for (let i = start; i < result.size(); i++) {
|
|
120
|
+
tailed.push(result[i]);
|
|
121
|
+
}
|
|
122
|
+
result = tailed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const last = entries.size() > 0 ? entries[entries.size() - 1] : undefined;
|
|
126
|
+
return {
|
|
127
|
+
peer,
|
|
128
|
+
entries: result,
|
|
129
|
+
totalDropped,
|
|
130
|
+
nextSince: last ? last.seq : (opts.since ?? 0),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export = {
|
|
135
|
+
install,
|
|
136
|
+
detectPeer,
|
|
137
|
+
query,
|
|
138
|
+
};
|
|
@@ -30,6 +30,39 @@ let elements: UIElements = undefined!;
|
|
|
30
30
|
let pulseAnimation: Tween | undefined;
|
|
31
31
|
let buttonHover = false;
|
|
32
32
|
|
|
33
|
+
interface ToolbarIcons {
|
|
34
|
+
disconnected: string;
|
|
35
|
+
connecting: string;
|
|
36
|
+
connected: string;
|
|
37
|
+
}
|
|
38
|
+
let toolbarButton: PluginToolbarButton | undefined;
|
|
39
|
+
let toolbarIcons: ToolbarIcons | undefined;
|
|
40
|
+
let lastToolbarIcon: string | undefined;
|
|
41
|
+
|
|
42
|
+
function setToolbarButton(btn: PluginToolbarButton, icons: ToolbarIcons) {
|
|
43
|
+
toolbarButton = btn;
|
|
44
|
+
toolbarIcons = icons;
|
|
45
|
+
lastToolbarIcon = undefined;
|
|
46
|
+
updateToolbarIcon();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function updateToolbarIcon() {
|
|
50
|
+
if (!toolbarButton || !toolbarIcons) return;
|
|
51
|
+
const conn = State.getActiveConnection();
|
|
52
|
+
let nextIcon: string;
|
|
53
|
+
if (!conn || !conn.isActive) {
|
|
54
|
+
nextIcon = toolbarIcons.disconnected;
|
|
55
|
+
} else if (conn.lastHttpOk && conn.lastMcpOk) {
|
|
56
|
+
nextIcon = toolbarIcons.connected;
|
|
57
|
+
} else {
|
|
58
|
+
nextIcon = toolbarIcons.connecting;
|
|
59
|
+
}
|
|
60
|
+
if (nextIcon !== lastToolbarIcon) {
|
|
61
|
+
(toolbarButton as unknown as { Icon: string }).Icon = nextIcon;
|
|
62
|
+
lastToolbarIcon = nextIcon;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
interface TabButton {
|
|
34
67
|
frame: Frame;
|
|
35
68
|
label: TextLabel;
|
|
@@ -596,6 +629,7 @@ function init(pluginRef: Plugin) {
|
|
|
596
629
|
}
|
|
597
630
|
|
|
598
631
|
function updateUIState() {
|
|
632
|
+
updateToolbarIcon();
|
|
599
633
|
const conn = State.getActiveConnection();
|
|
600
634
|
if (!conn) return;
|
|
601
635
|
const el = elements;
|
|
@@ -723,5 +757,7 @@ export = {
|
|
|
723
757
|
updateTabLabel,
|
|
724
758
|
stopPulseAnimation,
|
|
725
759
|
startPulseAnimation,
|
|
760
|
+
setToolbarButton,
|
|
761
|
+
updateToolbarIcon,
|
|
726
762
|
getElements: () => elements,
|
|
727
763
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import RuntimeLogBuffer from "../RuntimeLogBuffer";
|
|
2
|
+
|
|
3
|
+
function getRuntimeLogs(requestData: Record<string, unknown>): unknown {
|
|
4
|
+
const since = requestData.since as number | undefined;
|
|
5
|
+
const tail = requestData.tail as number | undefined;
|
|
6
|
+
const filter = requestData.filter as string | undefined;
|
|
7
|
+
// Plugin-side peer tag is generic ("edit"|"server"|"client"). The MCP-side
|
|
8
|
+
// aggregator overrides it with the specific instance role (e.g. "client-1")
|
|
9
|
+
// during fan-out for target=all, so this value is only authoritative for
|
|
10
|
+
// the single-peer query path.
|
|
11
|
+
const peer = RuntimeLogBuffer.detectPeer();
|
|
12
|
+
return RuntimeLogBuffer.query({ since, tail, filter }, peer);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export = { getRuntimeLogs };
|