@chrrxs/robloxstudio-mcp 2.9.1 → 2.10.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 +106 -3
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +451 -125
- package/studio-plugin/MCPPlugin.rbxmx +451 -125
- package/studio-plugin/src/modules/ClientBroker.ts +125 -48
- package/studio-plugin/src/modules/Communication.ts +59 -26
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +138 -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/server/index.server.ts +5 -0
- 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;
|
|
@@ -155,6 +192,15 @@ function pollForRequests(connIndex: number) {
|
|
|
155
192
|
conn.lastHttpOk = true;
|
|
156
193
|
conn.lastMcpOk = mcpConnected;
|
|
157
194
|
|
|
195
|
+
// Server tells us when its in-memory instances map doesn't have us
|
|
196
|
+
// (e.g. after an MCP process restart). Re-issue /ready immediately so
|
|
197
|
+
// target=server/client-N start routing again. The throttle inside
|
|
198
|
+
// sendReady() prevents duplicate registrations while the server
|
|
199
|
+
// catches up.
|
|
200
|
+
if (data.knownInstance === false) {
|
|
201
|
+
sendReady(conn);
|
|
202
|
+
}
|
|
203
|
+
|
|
158
204
|
if (connIndex === State.getActiveTabIndex()) {
|
|
159
205
|
const el = ui;
|
|
160
206
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
|
|
@@ -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) {
|
|
@@ -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
|
+
};
|
|
@@ -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 };
|
|
@@ -294,8 +294,19 @@ function executeLuau(requestData: Record<string, unknown>) {
|
|
|
294
294
|
const runViaModuleScript = () => {
|
|
295
295
|
const m = new Instance("ModuleScript");
|
|
296
296
|
m.Name = "__MCPExecLuauPayload";
|
|
297
|
+
// Wrap user code in an IIFE so require() always gets exactly one
|
|
298
|
+
// return value. Without this, code like `print("x")` errors with
|
|
299
|
+
// "Module code did not return exactly one value" because top-level
|
|
300
|
+
// ModuleScripts must return exactly one value.
|
|
301
|
+
//
|
|
302
|
+
// The DOUBLE parens around the call are load-bearing: in Luau,
|
|
303
|
+
// `return f()` propagates whatever multi-value tuple f returns,
|
|
304
|
+
// including zero values. Outer parens adjust the call to exactly
|
|
305
|
+
// one value (the first, or nil). So `return ((f)())` always
|
|
306
|
+
// returns exactly one value, regardless of what f does.
|
|
307
|
+
const wrapped = `return ((function()\n${code}\nend)())`;
|
|
297
308
|
const [okSet, setErr] = pcall(() => {
|
|
298
|
-
(m as unknown as { Source: string }).Source =
|
|
309
|
+
(m as unknown as { Source: string }).Source = wrapped;
|
|
299
310
|
});
|
|
300
311
|
if (!okSet) {
|
|
301
312
|
m.Destroy();
|
|
@@ -2,7 +2,12 @@ import State from "../modules/State";
|
|
|
2
2
|
import UI from "../modules/UI";
|
|
3
3
|
import Communication from "../modules/Communication";
|
|
4
4
|
import ClientBroker from "../modules/ClientBroker";
|
|
5
|
+
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
5
6
|
|
|
7
|
+
// Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
8
|
+
// boot-time prints from the user's place scripts are captured. Powers the
|
|
9
|
+
// get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
|
|
10
|
+
RuntimeLogBuffer.install();
|
|
6
11
|
|
|
7
12
|
UI.init(plugin);
|
|
8
13
|
const elements = UI.getElements();
|
|
@@ -32,6 +32,10 @@ export interface PollResponse {
|
|
|
32
32
|
mcpConnected: boolean;
|
|
33
33
|
request?: RequestPayload;
|
|
34
34
|
requestId?: string;
|
|
35
|
+
// Server signals knownInstance=false when its in-memory instances map
|
|
36
|
+
// doesn't contain our instanceId (typically after an MCP process restart).
|
|
37
|
+
// The plugin re-issues /ready when it sees this.
|
|
38
|
+
knownInstance?: boolean;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface ReadyResponse {
|