@chrrxs/robloxstudio-mcp-inspector 2.16.4 → 2.17.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.
@@ -0,0 +1,386 @@
1
+ const HttpService = game.GetService("HttpService");
2
+ const Players = game.GetService("Players");
3
+ const RunService = game.GetService("RunService");
4
+
5
+ interface ScriptProfilerServiceLike extends Instance {
6
+ ServerStart(this: ScriptProfilerServiceLike, frequency?: number): void;
7
+ ServerStop(this: ScriptProfilerServiceLike): void;
8
+ ServerRequestData(this: ScriptProfilerServiceLike): void;
9
+ ClientStart(this: ScriptProfilerServiceLike, player: Player, frequency?: number): void;
10
+ ClientStop(this: ScriptProfilerServiceLike, player: Player): void;
11
+ ClientRequestData(this: ScriptProfilerServiceLike, player: Player): void;
12
+ OnNewData: RBXScriptSignal<(player: Player | undefined, jsonString: string) => void>;
13
+ }
14
+
15
+ interface CategoryInfo {
16
+ Name?: string;
17
+ NodeId?: number;
18
+ }
19
+
20
+ interface FunctionInfo {
21
+ Source?: string;
22
+ Name?: string;
23
+ Line?: number;
24
+ TotalDuration?: number;
25
+ Flags?: number;
26
+ }
27
+
28
+ interface ProfilingInfo {
29
+ Version?: number;
30
+ SessionStartTime?: number;
31
+ SessionEndTime?: number;
32
+ Categories?: CategoryInfo[];
33
+ Nodes?: unknown[];
34
+ Functions?: FunctionInfo[];
35
+ }
36
+
37
+ interface FunctionRow {
38
+ function_index: number;
39
+ name: string;
40
+ source?: string;
41
+ line?: number;
42
+ total_us: number;
43
+ is_native?: boolean;
44
+ is_plugin?: boolean;
45
+ is_debug_label?: boolean;
46
+ }
47
+
48
+ const DEFAULT_DURATION_MS = 1000;
49
+ const MIN_DURATION_MS = 100;
50
+ const MAX_DURATION_MS = 15000;
51
+ const DEFAULT_FREQUENCY = 1000;
52
+ const DEFAULT_MAX_FUNCTIONS = 20;
53
+
54
+ function getProfilerService(): ScriptProfilerServiceLike | Record<string, unknown> {
55
+ const provider = game as unknown as { GetService(serviceName: string): Instance };
56
+ const [ok, service] = pcall(() => provider.GetService("ScriptProfilerService") as ScriptProfilerServiceLike);
57
+ if (!ok || !service) {
58
+ return {
59
+ error: "script_profiler_unavailable",
60
+ message: `ScriptProfilerService is unavailable: ${tostring(service)}`,
61
+ };
62
+ }
63
+ return service;
64
+ }
65
+
66
+ function normalizeDurationMs(value: unknown): number {
67
+ if (!typeIs(value, "number")) return DEFAULT_DURATION_MS;
68
+ return math.clamp(math.floor(value), MIN_DURATION_MS, MAX_DURATION_MS);
69
+ }
70
+
71
+ function normalizeFrequency(value: unknown): number {
72
+ if (!typeIs(value, "number")) return DEFAULT_FREQUENCY;
73
+ return math.clamp(math.floor(value), 1, 10000);
74
+ }
75
+
76
+ function normalizeMaxFunctions(value: unknown): number {
77
+ if (!typeIs(value, "number")) return DEFAULT_MAX_FUNCTIONS;
78
+ return math.clamp(math.floor(value), 1, 100);
79
+ }
80
+
81
+ function normalizeMinTotalUs(requestData: Record<string, unknown>): number {
82
+ const value = requestData.min_total_us;
83
+ if (typeIs(value, "number")) return math.max(0, value);
84
+ return 0;
85
+ }
86
+
87
+ function stringContains(haystack: string, needle: string): boolean {
88
+ return string.find(string.lower(haystack), string.lower(needle), 1, true)[0] !== undefined;
89
+ }
90
+
91
+ function localPlayer(): Player | undefined {
92
+ let player = Players.LocalPlayer;
93
+ const started = tick();
94
+ while (!player && tick() - started < 5) {
95
+ task.wait(0.05);
96
+ player = Players.LocalPlayer;
97
+ }
98
+ return player;
99
+ }
100
+
101
+ function functionDisplayName(func: FunctionInfo): string {
102
+ if (typeIs(func.Name, "string") && func.Name !== "") return func.Name;
103
+ if (typeIs(func.Source, "string") && func.Source !== "") {
104
+ if (typeIs(func.Line, "number") && func.Line > 0) return `${func.Source}:${func.Line}`;
105
+ return func.Source;
106
+ }
107
+ return "<anonymous>";
108
+ }
109
+
110
+ function flagsOf(func: FunctionInfo): number {
111
+ return typeIs(func.Flags, "number") ? func.Flags : 0;
112
+ }
113
+
114
+ function isNativeFunction(func: FunctionInfo): boolean {
115
+ return bit32.band(flagsOf(func), 1) !== 0;
116
+ }
117
+
118
+ function isPluginFunction(func: FunctionInfo): boolean {
119
+ if (bit32.band(flagsOf(func), 2) !== 0) return true;
120
+ return typeIs(func.Source, "string") && string.find(func.Source, "MCPPlugin", 1, true)[0] !== undefined;
121
+ }
122
+
123
+ function isDebugLabel(func: FunctionInfo, filter: string | undefined): boolean {
124
+ if (filter === undefined) return false;
125
+ if (!typeIs(func.Name, "string") || func.Name === "") return false;
126
+ if (!typeIs(func.Source, "string") || func.Source === "" || func.Source === "[C]" || func.Source === "GC") return false;
127
+ if (func.Line !== undefined && func.Line !== 0) return false;
128
+ if (isNativeFunction(func) || isPluginFunction(func)) return false;
129
+ return stringContains(func.Name, filter) || stringContains(func.Source, filter);
130
+ }
131
+
132
+ function pctOfCapture(row: FunctionRow, durationMs: number): number | undefined {
133
+ const captureUs = durationMs * 1000;
134
+ if (captureUs <= 0) return undefined;
135
+ return math.floor((row.total_us / captureUs) * 10000 + 0.5) / 100;
136
+ }
137
+
138
+ function compactFunction(row: FunctionRow, rank: number, durationMs: number): Record<string, unknown> {
139
+ const out: Record<string, unknown> = {
140
+ rank,
141
+ function_index: row.function_index,
142
+ name: row.name,
143
+ total_us: math.floor(row.total_us + 0.5),
144
+ };
145
+ const pct = pctOfCapture(row, durationMs);
146
+ if (pct !== undefined) out.pct_of_capture = pct;
147
+ if (row.source !== undefined) out.source = row.source;
148
+ if (row.line !== undefined) out.line = row.line;
149
+ if (row.is_native === true) out.is_native = true;
150
+ if (row.is_plugin === true) out.is_plugin = true;
151
+ if (row.is_debug_label === true) out.is_debug_label = true;
152
+ return out;
153
+ }
154
+
155
+ function summarizeProfile(
156
+ rawJson: string,
157
+ profile: ProfilingInfo,
158
+ requestData: Record<string, unknown>,
159
+ durationMs: number,
160
+ frequency: number,
161
+ eventPlayerName: string | undefined,
162
+ ): Record<string, unknown> {
163
+ const funcs = typeIs(profile.Functions, "table") ? profile.Functions : [];
164
+ const nodes = typeIs(profile.Nodes, "table") ? profile.Nodes : [];
165
+ const categories = typeIs(profile.Categories, "table") ? profile.Categories : [];
166
+ const maxFunctions = normalizeMaxFunctions(requestData.max_functions);
167
+ const minTotalUs = normalizeMinTotalUs(requestData);
168
+ const includeNative = requestData.include_native === true;
169
+ const includePlugin = requestData.include_plugin === true;
170
+ const filter = typeIs(requestData.filter, "string") && requestData.filter !== "" ? requestData.filter as string : undefined;
171
+
172
+ const rows: FunctionRow[] = [];
173
+ const debugRows: FunctionRow[] = [];
174
+ let omittedNative = 0;
175
+ let omittedPlugin = 0;
176
+ let omittedBelowThreshold = 0;
177
+ let omittedByFilter = 0;
178
+
179
+ for (let i = 0; i < funcs.size(); i++) {
180
+ const func = funcs[i];
181
+ if (!typeIs(func, "table")) continue;
182
+ const info = func as FunctionInfo;
183
+ const totalUs = typeIs(info.TotalDuration, "number") ? info.TotalDuration : 0;
184
+ const name = functionDisplayName(info);
185
+ const row: FunctionRow = {
186
+ function_index: i + 1,
187
+ name,
188
+ source: typeIs(info.Source, "string") ? info.Source : undefined,
189
+ line: typeIs(info.Line, "number") ? info.Line : undefined,
190
+ total_us: totalUs,
191
+ is_native: isNativeFunction(info) ? true : undefined,
192
+ is_plugin: isPluginFunction(info) ? true : undefined,
193
+ is_debug_label: isDebugLabel(info, filter) ? true : undefined,
194
+ };
195
+
196
+ if (!includeNative && row.is_native === true) {
197
+ omittedNative += 1;
198
+ continue;
199
+ }
200
+ if (!includePlugin && row.is_plugin === true) {
201
+ omittedPlugin += 1;
202
+ continue;
203
+ }
204
+ if (totalUs < minTotalUs) {
205
+ omittedBelowThreshold += 1;
206
+ continue;
207
+ }
208
+ if (filter !== undefined) {
209
+ const text = `${row.name} ${row.source ?? ""}`;
210
+ if (!stringContains(text, filter)) {
211
+ omittedByFilter += 1;
212
+ continue;
213
+ }
214
+ }
215
+ rows.push(row);
216
+ if (row.is_debug_label === true) debugRows.push(row);
217
+ }
218
+
219
+ rows.sort((a, b) => a.total_us > b.total_us);
220
+ debugRows.sort((a, b) => a.total_us > b.total_us);
221
+
222
+ const topFunctions: Record<string, unknown>[] = [];
223
+ for (let i = 0; i < math.min(maxFunctions, rows.size()); i++) {
224
+ topFunctions.push(compactFunction(rows[i], i + 1, durationMs));
225
+ }
226
+
227
+ const debugLabels: Record<string, unknown>[] = [];
228
+ for (let i = 0; i < math.min(maxFunctions, debugRows.size()); i++) {
229
+ debugLabels.push(compactFunction(debugRows[i], i + 1, durationMs));
230
+ }
231
+
232
+ const categoryNames: string[] = [];
233
+ for (let i = 0; i < categories.size(); i++) {
234
+ const category = categories[i];
235
+ if (typeIs(category, "table")) {
236
+ const name = (category as CategoryInfo).Name;
237
+ if (typeIs(name, "string")) categoryNames.push(name);
238
+ }
239
+ }
240
+
241
+ const omitted: Record<string, number> = {};
242
+ let hasOmitted = false;
243
+ if (omittedNative > 0) {
244
+ omitted.native = omittedNative;
245
+ hasOmitted = true;
246
+ }
247
+ if (omittedPlugin > 0) {
248
+ omitted.plugin = omittedPlugin;
249
+ hasOmitted = true;
250
+ }
251
+ if (omittedBelowThreshold > 0) {
252
+ omitted.below_min_total_us = omittedBelowThreshold;
253
+ hasOmitted = true;
254
+ }
255
+ if (omittedByFilter > 0) {
256
+ omitted.filtered_out = omittedByFilter;
257
+ hasOmitted = true;
258
+ }
259
+
260
+ const result: Record<string, unknown> = {
261
+ ok: true,
262
+ duration_ms: durationMs,
263
+ frequency,
264
+ applied: {
265
+ filter: filter ?? undefined,
266
+ min_total_us: minTotalUs,
267
+ include_native: includeNative,
268
+ include_plugin: includePlugin,
269
+ max_functions: maxFunctions,
270
+ sort: "total_us_desc",
271
+ },
272
+ json_bytes: rawJson.size(),
273
+ counts: {
274
+ categories: categories.size(),
275
+ nodes: nodes.size(),
276
+ functions: funcs.size(),
277
+ },
278
+ top_functions: topFunctions,
279
+ debug_labels: debugLabels,
280
+ };
281
+ if (categoryNames.size() > 0) result.categories = categoryNames;
282
+ if (hasOmitted) result.omitted = omitted;
283
+ if (profile.Version !== undefined) result.version = profile.Version;
284
+ if (profile.SessionStartTime !== undefined || profile.SessionEndTime !== undefined) {
285
+ result.session = {
286
+ start_time: profile.SessionStartTime,
287
+ end_time: profile.SessionEndTime,
288
+ };
289
+ }
290
+ if (eventPlayerName !== undefined) result.player = eventPlayerName;
291
+ if (requestData.__mcp_include_raw_json === true) result.raw_json = rawJson;
292
+ return result;
293
+ }
294
+
295
+ function captureScriptProfiler(requestData: Record<string, unknown>): unknown {
296
+ if (!RunService.IsRunning()) {
297
+ return {
298
+ error: "runtime_target_required",
299
+ message: "Script profiler capture requires a running playtest target such as target=\"server\" or target=\"client-1\".",
300
+ };
301
+ }
302
+
303
+ const serviceOrError = getProfilerService();
304
+ if (!serviceOrError.IsA) return serviceOrError;
305
+ const service = serviceOrError as ScriptProfilerServiceLike;
306
+
307
+ const durationMs = normalizeDurationMs(requestData.duration_ms);
308
+ const frequency = normalizeFrequency(requestData.frequency);
309
+ const isServer = RunService.IsServer();
310
+ const isClient = RunService.IsClient() && !isServer;
311
+ const player = isClient ? localPlayer() : undefined;
312
+ if (!isServer && !player) {
313
+ return {
314
+ error: "client_player_unavailable",
315
+ message: "Could not resolve Players.LocalPlayer for client profiling.",
316
+ };
317
+ }
318
+
319
+ let rawJson: string | undefined;
320
+ let eventPlayerName: string | undefined;
321
+ const connection = service.OnNewData.Connect((playerArg: Player | undefined, jsonString: string) => {
322
+ if (rawJson !== undefined) return;
323
+ rawJson = jsonString;
324
+ if (playerArg) eventPlayerName = playerArg.Name;
325
+ });
326
+
327
+ const [startOk, startErr] = pcall(() => {
328
+ if (isServer) {
329
+ service.ServerStart(frequency);
330
+ } else {
331
+ service.ClientStart(player!, frequency);
332
+ }
333
+ });
334
+ if (!startOk) {
335
+ connection.Disconnect();
336
+ return {
337
+ error: "script_profiler_start_failed",
338
+ message: tostring(startErr),
339
+ };
340
+ }
341
+
342
+ task.wait(durationMs / 1000);
343
+
344
+ const [stopOk, stopErr] = pcall(() => {
345
+ if (isServer) {
346
+ service.ServerStop();
347
+ service.ServerRequestData();
348
+ } else {
349
+ service.ClientStop(player!);
350
+ service.ClientRequestData(player!);
351
+ }
352
+ });
353
+ if (!stopOk) {
354
+ connection.Disconnect();
355
+ return {
356
+ error: "script_profiler_stop_failed",
357
+ message: tostring(stopErr),
358
+ };
359
+ }
360
+
361
+ const requestedAt = tick();
362
+ while (rawJson === undefined && tick() - requestedAt < 5) {
363
+ task.wait(0.05);
364
+ }
365
+ connection.Disconnect();
366
+
367
+ if (rawJson === undefined) {
368
+ return {
369
+ error: "script_profiler_data_timeout",
370
+ message: "ScriptProfilerService did not emit OnNewData after requesting profiler data.",
371
+ };
372
+ }
373
+
374
+ const [decodeOk, decoded] = pcall(() => HttpService.JSONDecode(rawJson!));
375
+ if (!decodeOk) {
376
+ return {
377
+ error: "script_profiler_decode_failed",
378
+ message: tostring(decoded),
379
+ json_bytes: rawJson.size(),
380
+ };
381
+ }
382
+
383
+ return summarizeProfile(rawJson, decoded as ProfilingInfo, requestData, durationMs, frequency, eventPlayerName);
384
+ }
385
+
386
+ export = { captureScriptProfiler };
@@ -20,17 +20,8 @@ const ScriptEditorService = game.GetService("ScriptEditorService");
20
20
  const NAV_SIGNAL = "__MCP_NAV__";
21
21
  const NAV_RESULT = "__MCP_NAV_RESULT__";
22
22
 
23
- interface OutputEntry {
24
- message: string;
25
- messageType: string;
26
- timestamp: number;
27
- }
28
-
29
23
  let testRunning = false;
30
- let outputBuffer: OutputEntry[] = [];
31
- let logConnection: RBXScriptConnection | undefined;
32
- let testResult: unknown;
33
- let testError: string | undefined;
24
+ let navLogConnection: RBXScriptConnection | undefined;
34
25
  let stopListenerScript: Script | undefined;
35
26
  let navResultCallback: ((json: string) => void) | undefined;
36
27
 
@@ -176,6 +167,13 @@ function cleanupStopListener() {
176
167
  }
177
168
  }
178
169
 
170
+ function disconnectNavLogListener() {
171
+ if (navLogConnection) {
172
+ navLogConnection.Disconnect();
173
+ navLogConnection = undefined;
174
+ }
175
+ }
176
+
179
177
  function startPlaytest(requestData: Record<string, unknown>) {
180
178
  const mode = requestData.mode as string | undefined;
181
179
  const numPlayers = requestData.numPlayers as number | undefined;
@@ -194,10 +192,7 @@ function startPlaytest(requestData: Record<string, unknown>) {
194
192
  // Reset it so subsequent starts don't hit a false "already running".
195
193
  if (testRunning && !RunService.IsRunning()) {
196
194
  testRunning = false;
197
- if (logConnection) {
198
- logConnection.Disconnect();
199
- logConnection = undefined;
200
- }
195
+ disconnectNavLogListener();
201
196
  cleanupStopListener();
202
197
  // Runtime eval bridges are created by the play server/client plugin
203
198
  // peers and disappear with the play DataModels.
@@ -208,25 +203,16 @@ function startPlaytest(requestData: Record<string, unknown>) {
208
203
  }
209
204
 
210
205
  testRunning = true;
211
- outputBuffer = [];
212
- testResult = undefined;
213
- testError = undefined;
214
206
 
215
207
  cleanupStopListener();
208
+ disconnectNavLogListener();
216
209
 
217
- logConnection = LogService.MessageOut.Connect((message, messageType) => {
218
- if (message.sub(1, NAV_SIGNAL.size()) === NAV_SIGNAL) return;
210
+ navLogConnection = LogService.MessageOut.Connect((message) => {
219
211
  if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
220
212
  if (navResultCallback) {
221
213
  navResultCallback(message.sub(NAV_RESULT.size() + 2));
222
214
  }
223
- return;
224
215
  }
225
- outputBuffer.push({
226
- message,
227
- messageType: messageType.Name,
228
- timestamp: tick(),
229
- });
230
216
  });
231
217
 
232
218
  const [injected, injErr] = pcall(() => injectStopListener());
@@ -242,16 +228,11 @@ function startPlaytest(requestData: Record<string, unknown>) {
242
228
  return StudioTestService.ExecuteRunModeAsync({});
243
229
  });
244
230
 
245
- if (ok) {
246
- testResult = result;
247
- } else {
248
- testError = tostring(result);
231
+ if (!ok) {
232
+ warn(`[robloxstudio-mcp] Playtest ended with error: ${result}`);
249
233
  }
250
234
 
251
- if (logConnection) {
252
- logConnection.Disconnect();
253
- logConnection = undefined;
254
- }
235
+ disconnectNavLogListener();
255
236
  testRunning = false;
256
237
 
257
238
  cleanupStopListener();
@@ -324,16 +305,6 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
324
305
  return { success: true, message: "Playtest stopped." };
325
306
  }
326
307
 
327
- function getPlaytestOutput(_requestData: Record<string, unknown>) {
328
- return {
329
- isRunning: testRunning,
330
- output: [...outputBuffer],
331
- outputCount: outputBuffer.size(),
332
- testResult: testResult !== undefined ? tostring(testResult) : undefined,
333
- testError,
334
- };
335
- }
336
-
337
308
  function multiplayerTestStart(requestData: Record<string, unknown>) {
338
309
  if (RunService.IsRunning()) {
339
310
  return { error: "multiplayer_test_start must be called on the edit DataModel. Route with target=edit." };
@@ -546,7 +517,6 @@ function characterNavigation(requestData: Record<string, unknown>) {
546
517
  export = {
547
518
  startPlaytest,
548
519
  stopPlaytest,
549
- getPlaytestOutput,
550
520
  multiplayerTestStart,
551
521
  multiplayerTestState,
552
522
  multiplayerTestAddPlayers,
@@ -6,6 +6,7 @@ import ServerUrlSettings from "../modules/ServerUrlSettings";
6
6
  import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
7
7
  import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
8
8
  import StopPlayMonitor from "../modules/StopPlayMonitor";
9
+ import BreakpointHandlers from "../modules/handlers/BreakpointHandlers";
9
10
  import * as RenderMonitor from "../modules/RenderMonitor";
10
11
 
11
12
  // Track render-loop liveness so input/screenshot tools can report "window
@@ -22,6 +23,7 @@ RuntimeLogBuffer.install();
22
23
  // edit DM (write the flag) and the play-server DM (read+act on the flag) can
23
24
  // access plugin:SetSetting/GetSetting.
24
25
  StopPlayMonitor.init(plugin);
26
+ BreakpointHandlers.init(plugin);
25
27
  ServerUrlSettings.init(plugin);
26
28
 
27
29
  UI.init(plugin);