@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.
@@ -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 ExecutePayload {
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: ExecutePayload | undefined) => {
72
- const code = payload && payload.code;
73
- if (typeIs(code, "string") === false || code === "") {
74
- return identity<ExecuteResult>({ success: false, error: "code is required" });
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
- const m = new Instance("ModuleScript");
77
- m.Name = "__MCPClientEval";
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
- return identity<ExecuteResult>({ success: false, error: tostring(result) });
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 && body.request && body.requestId !== undefined) {
113
- const request = body.request;
114
- let response: unknown;
115
- if (request.endpoint === "/api/execute-luau") {
116
- const [okInvoke, invokeRes] = pcall(() => rf.InvokeClient(player, request.data));
117
- if (okInvoke) {
118
- response = invokeRes !== undefined ? invokeRes : { success: false, error: "nil response" };
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 = { success: false, error: `InvokeClient failed: ${tostring(invokeRes)}` };
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
- } else {
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
- okJson &&
166
- body &&
167
- body.request &&
168
- body.request.endpoint === "/api/stop-playtest" &&
169
- body.requestId !== undefined
170
- ) {
171
- const sts = game.GetService("StudioTestService") as Instance & { EndTest(reason: string): void };
172
- const [endOk, endErr] = pcall(() => sts.EndTest("stopped_by_mcp"));
173
- const response = endOk
174
- ? { success: true, message: "Playtest stopped via edit-proxy/EndTest" }
175
- : { success: false, error: `EndTest failed: ${tostring(endErr)}` };
176
- postJson("/response", { requestId: body.requestId, response });
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
- task.spawn(() => {
302
- if (!conn.heartbeatConnection) {
303
- conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
304
- const now = tick();
305
- const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
306
- if (now - conn.lastPoll > currentInterval) {
307
- conn.lastPoll = now;
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
- // - Server: a Script in ServerScriptService that creates a BindableFunction
10
- // (for our server-peer plugin to invoke directly) plus a RemoteFunction
11
- // (kept for parity with the upstream primitive's client-callable shape).
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(source)
112
- return evalCode(source)
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 };