@chrrxs/robloxstudio-mcp-inspector 2.16.4 → 2.17.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.
@@ -5,6 +5,8 @@ import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
5
5
  import CaptureHandlers from "./handlers/CaptureHandlers";
6
6
  import InputHandlers from "./handlers/InputHandlers";
7
7
  import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
8
+ import BreakpointHandlers from "./handlers/BreakpointHandlers";
9
+ import ScriptProfilerHandlers from "./handlers/ScriptProfilerHandlers";
8
10
  import LuauExec from "./LuauExec";
9
11
  import State from "./State";
10
12
  import HttpDiagnostics from "./HttpDiagnostics";
@@ -94,6 +96,8 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
94
96
  "/api/get-runtime-logs",
95
97
  "/api/get-memory-breakdown",
96
98
  "/api/get-scene-analysis",
99
+ "/api/breakpoints",
100
+ "/api/capture-script-profiler",
97
101
  "/api/multiplayer-test-state",
98
102
  "/api/multiplayer-test-leave-client",
99
103
  // Screenshot capture must run in the client peer (CaptureService captures
@@ -266,6 +270,12 @@ function setupClientBroker() {
266
270
  if (payload && payload.endpoint === "/api/get-scene-analysis") {
267
271
  return SceneAnalysisHandlers.getSceneAnalysis(payload.data ?? {});
268
272
  }
273
+ if (payload && payload.endpoint === "/api/breakpoints") {
274
+ return BreakpointHandlers.breakpoints(payload.data ?? {});
275
+ }
276
+ if (payload && payload.endpoint === "/api/capture-script-profiler") {
277
+ return ScriptProfilerHandlers.captureScriptProfiler(payload.data ?? {});
278
+ }
269
279
  if (payload && payload.endpoint === "/api/multiplayer-test-state") {
270
280
  return handleMultiplayerTestState();
271
281
  }
@@ -17,6 +17,8 @@ import LogHandlers from "./handlers/LogHandlers";
17
17
  import SerializationHandlers from "./handlers/SerializationHandlers";
18
18
  import MemoryHandlers from "./handlers/MemoryHandlers";
19
19
  import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
20
+ import BreakpointHandlers from "./handlers/BreakpointHandlers";
21
+ import ScriptProfilerHandlers from "./handlers/ScriptProfilerHandlers";
20
22
  import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
21
23
  import ClientBroker from "./ClientBroker";
22
24
  import ServerUrlSettings from "./ServerUrlSettings";
@@ -109,7 +111,6 @@ const routeMap: Record<string, Handler> = {
109
111
  "/api/grep-scripts": QueryHandlers.grepScripts,
110
112
  "/api/get-descendants": QueryHandlers.getDescendants,
111
113
  "/api/compare-instances": QueryHandlers.compareInstances,
112
- "/api/get-output-log": QueryHandlers.getOutputLog,
113
114
 
114
115
  "/api/set-property": PropertyHandlers.setProperty,
115
116
  "/api/set-properties": PropertyHandlers.setProperties,
@@ -146,13 +147,11 @@ const routeMap: Record<string, Handler> = {
146
147
 
147
148
  "/api/start-playtest": TestHandlers.startPlaytest,
148
149
  "/api/stop-playtest": TestHandlers.stopPlaytest,
149
- "/api/get-playtest-output": TestHandlers.getPlaytestOutput,
150
150
  "/api/multiplayer-test-start": TestHandlers.multiplayerTestStart,
151
151
  "/api/multiplayer-test-state": TestHandlers.multiplayerTestState,
152
152
  "/api/multiplayer-test-add-players": TestHandlers.multiplayerTestAddPlayers,
153
153
  "/api/multiplayer-test-leave-client": TestHandlers.multiplayerTestLeaveClient,
154
154
  "/api/multiplayer-test-end": TestHandlers.multiplayerTestEnd,
155
- "/api/character-navigation": TestHandlers.characterNavigation,
156
155
 
157
156
  "/api/export-build": BuildHandlers.exportBuild,
158
157
  "/api/import-build": BuildHandlers.importBuild,
@@ -171,6 +170,8 @@ const routeMap: Record<string, Handler> = {
171
170
  "/api/find-and-replace-in-scripts": ScriptHandlers.findAndReplaceInScripts,
172
171
 
173
172
  "/api/get-runtime-logs": LogHandlers.getRuntimeLogs,
173
+ "/api/breakpoints": BreakpointHandlers.breakpoints,
174
+ "/api/capture-script-profiler": ScriptProfilerHandlers.captureScriptProfiler,
174
175
 
175
176
  "/api/export-rbxm": SerializationHandlers.exportRbxm,
176
177
  "/api/import-rbxm": SerializationHandlers.importRbxm,
@@ -306,6 +307,7 @@ function sendReady(conn: Connection): void {
306
307
  lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
307
308
  ? readyData.instanceId
308
309
  : instanceId;
310
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl);
309
311
  const connectedRole = assignedRole ?? detectRole();
310
312
  if (readyFailureLogKeys.has(readyLogKey)) {
311
313
  readyFailureLogKeys.delete(readyLogKey);
@@ -501,13 +503,15 @@ function activatePlugin(connIndex?: number) {
501
503
  conn.currentRetryDelay = 0.5;
502
504
 
503
505
  if (idx === State.getActiveTabIndex()) {
504
- conn.serverUrl = ui.urlInput.Text;
505
- const [portStr] = conn.serverUrl.match(":(%d+)$");
506
- if (portStr) conn.port = tonumber(portStr) ?? conn.port;
506
+ const normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text);
507
+ conn.serverUrl = normalizedUrl !== "" ? normalizedUrl : conn.serverUrl;
508
+ if (conn.serverUrl === "") conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
509
+ ui.urlInput.Text = conn.serverUrl;
510
+ const port = ServerUrlSettings.extractPort(conn.serverUrl);
511
+ if (port !== undefined) conn.port = port;
507
512
  UI.updateTabLabel(idx);
508
513
  UI.updateUIState();
509
514
  }
510
- ServerUrlSettings.rememberServerUrl(conn.serverUrl);
511
515
  UI.updateTabDot(idx);
512
516
 
513
517
  if (!conn.heartbeatConnection) {
@@ -57,21 +57,47 @@ function dropOldestUntilFits(incomingBytes: number): void {
57
57
  }
58
58
  }
59
59
 
60
+ function pushEntry(msg: string, t: Enum.MessageType, ts = nowSec()): void {
61
+ const bytes = msg.size();
62
+ dropOldestUntilFits(bytes);
63
+ entries.push({
64
+ seq: nextSeq,
65
+ ts,
66
+ level: levelTag(t),
67
+ message: msg,
68
+ });
69
+ nextSeq += 1;
70
+ totalBytes += bytes;
71
+ }
72
+
73
+ interface LogHistoryEntry {
74
+ message: string;
75
+ messageType: Enum.MessageType;
76
+ timestamp: number;
77
+ }
78
+
79
+ function seedRuntimeHistory(): void {
80
+ if (!RunService.IsRunning()) return;
81
+
82
+ const [ok, history] = pcall(() => LogService.GetLogHistory() as LogHistoryEntry[]);
83
+ if (!ok) return;
84
+
85
+ for (const entry of history) {
86
+ if (!typeIs(entry.message, "string")) continue;
87
+ pushEntry(entry.message, entry.messageType, typeIs(entry.timestamp, "number") ? entry.timestamp : undefined);
88
+ }
89
+ }
90
+
60
91
  function install(): void {
61
92
  if (installed) return;
62
93
  if (!RunService.IsStudio()) return;
63
94
  installed = true;
95
+ // Play peers can emit startup logs before the plugin finishes loading.
96
+ // Seed from per-DataModel LogHistory so get_runtime_logs can still see
97
+ // those early messages; skip edit mode to avoid stale prior-session logs.
98
+ seedRuntimeHistory();
64
99
  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;
100
+ pushEntry(msg, t);
75
101
  });
76
102
  }
77
103
 
@@ -1,6 +1,8 @@
1
1
  import { HttpService, ServerStorage } from "@rbxts/services";
2
2
 
3
- const SETTING_KEY_PREFIX = "MCP_SERVER_URL_";
3
+ const LEGACY_SETTING_KEY_PREFIX = "MCP_SERVER_URL_";
4
+ const SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_";
5
+ const GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1";
4
6
 
5
7
  let pluginRef: Plugin | undefined;
6
8
 
@@ -8,6 +10,31 @@ function init(p: Plugin): void {
8
10
  pluginRef = p;
9
11
  }
10
12
 
13
+ function normalizeServerUrl(serverUrl: string | undefined): string {
14
+ let normalized = (serverUrl ?? "").gsub("^%s+", "")[0].gsub("%s+$", "")[0];
15
+ if (normalized === "") return "";
16
+
17
+ if (normalized.match("^%a[%w+.-]*://")[0] === undefined) {
18
+ normalized = `http://${normalized}`;
19
+ }
20
+
21
+ while (
22
+ normalized.size() > 0 &&
23
+ normalized.sub(-1) === "/" &&
24
+ normalized.match("^%a[%w+.-]*://$")[0] === undefined
25
+ ) {
26
+ normalized = normalized.sub(1, -2);
27
+ }
28
+
29
+ return normalized;
30
+ }
31
+
32
+ function extractPort(serverUrl: string): number | undefined {
33
+ const [portStr] = serverUrl.match(":(%d+)$");
34
+ if (portStr === undefined) return undefined;
35
+ return tonumber(portStr);
36
+ }
37
+
11
38
  function addUnique(values: string[], value: string): void {
12
39
  if (!values.includes(value)) {
13
40
  values.push(value);
@@ -34,28 +61,54 @@ function settingKey(instanceId: string): string {
34
61
  return SETTING_KEY_PREFIX + instanceId;
35
62
  }
36
63
 
64
+ function legacySettingKey(instanceId: string): string {
65
+ return LEGACY_SETTING_KEY_PREFIX + instanceId;
66
+ }
67
+
68
+ function readSettingString(key: string): string | undefined {
69
+ if (!pluginRef) return undefined;
70
+ const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
71
+ if (!ok || !typeIs(value, "string")) return undefined;
72
+
73
+ const normalized = normalizeServerUrl(value as string);
74
+ return normalized !== "" ? normalized : undefined;
75
+ }
76
+
77
+ function writeSettingString(key: string, serverUrl: string): void {
78
+ if (!pluginRef) return;
79
+ pcall(() => pluginRef!.SetSetting(key, serverUrl));
80
+ }
81
+
37
82
  function rememberServerUrl(serverUrl: string): void {
38
- if (!pluginRef || serverUrl === "") return;
83
+ const normalized = normalizeServerUrl(serverUrl);
84
+ if (!pluginRef || normalized === "") return;
85
+ writeSettingString(GLOBAL_SETTING_KEY, normalized);
39
86
  for (const instanceId of computeInstanceIds()) {
40
- const key = settingKey(instanceId);
41
- pcall(() => pluginRef!.SetSetting(key, serverUrl));
87
+ writeSettingString(settingKey(instanceId), normalized);
88
+ writeSettingString(legacySettingKey(instanceId), normalized);
42
89
  }
43
90
  }
44
91
 
45
92
  function readServerUrl(): string | undefined {
46
93
  if (!pluginRef) return undefined;
47
94
  for (const instanceId of computeInstanceIds()) {
48
- const key = settingKey(instanceId);
49
- const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
50
- if (ok && typeIs(value, "string") && value !== "") {
51
- return value as string;
52
- }
95
+ const remembered = readSettingString(settingKey(instanceId));
96
+ if (remembered !== undefined) return remembered;
53
97
  }
98
+ const globalRemembered = readSettingString(GLOBAL_SETTING_KEY);
99
+ if (globalRemembered !== undefined) return globalRemembered;
100
+ for (const instanceId of computeInstanceIds()) {
101
+ const legacyRemembered = readSettingString(legacySettingKey(instanceId));
102
+ if (legacyRemembered !== undefined) return legacyRemembered;
103
+ }
104
+
54
105
  return undefined;
55
106
  }
56
107
 
57
108
  export = {
58
109
  init,
110
+ normalizeServerUrl,
111
+ extractPort,
59
112
  rememberServerUrl,
60
113
  readServerUrl,
61
114
  };
@@ -1,5 +1,6 @@
1
1
  import { TweenService } from "@rbxts/services";
2
2
  import State from "./State";
3
+ import ServerUrlSettings from "./ServerUrlSettings";
3
4
  import { Connection } from "../types";
4
5
 
5
6
  interface UIElements {
@@ -473,7 +474,7 @@ function init(pluginRef: Plugin) {
473
474
  urlInput.Size = new UDim2(1, 0, 0, 26);
474
475
  urlInput.BackgroundColor3 = C.bg;
475
476
  urlInput.BorderSizePixel = 0;
476
- urlInput.Text = "http://localhost:58741";
477
+ urlInput.Text = State.getActiveConnection().serverUrl;
477
478
  urlInput.TextColor3 = C.label;
478
479
  urlInput.TextSize = 11;
479
480
  urlInput.Font = Enum.Font.GothamMedium;
@@ -495,9 +496,15 @@ function init(pluginRef: Plugin) {
495
496
  urlInput.FocusLost.Connect(() => {
496
497
  const conn = State.getActiveConnection();
497
498
  if (!conn || conn.isActive) return;
498
- conn.serverUrl = urlInput.Text;
499
- const [portStr] = conn.serverUrl.match(":(%d+)$");
500
- if (portStr) conn.port = tonumber(portStr) ?? conn.port;
499
+ const normalizedUrl = ServerUrlSettings.normalizeServerUrl(urlInput.Text);
500
+ if (normalizedUrl === "") {
501
+ urlInput.Text = conn.serverUrl;
502
+ return;
503
+ }
504
+ conn.serverUrl = normalizedUrl;
505
+ urlInput.Text = normalizedUrl;
506
+ const port = ServerUrlSettings.extractPort(conn.serverUrl);
507
+ if (port !== undefined) conn.port = port;
501
508
  updateTabLabel(State.getActiveTabIndex());
502
509
  });
503
510
 
@@ -1,5 +1,11 @@
1
1
  const ScriptEditorService = game.GetService("ScriptEditorService");
2
2
 
3
+ const LUAU_KEYWORDS = new Set<string>([
4
+ "and", "break", "continue", "do", "else", "elseif", "end", "export",
5
+ "false", "for", "function", "if", "in", "local", "nil", "not", "or",
6
+ "repeat", "return", "then", "true", "type", "until", "while",
7
+ ]);
8
+
3
9
  function safeCall<T>(func: (...args: never[]) => T, ...args: never[]): T | undefined {
4
10
  const [success, result] = pcall(func, ...args);
5
11
  if (success) {
@@ -10,6 +16,125 @@ function safeCall<T>(func: (...args: never[]) => T, ...args: never[]): T | undef
10
16
  }
11
17
  }
12
18
 
19
+ function isSimplePathSegment(segment: string): boolean {
20
+ return segment.match("^[%a_][%w_]*$")[0] !== undefined && !LUAU_KEYWORDS.has(segment);
21
+ }
22
+
23
+ function quotePathSegment(segment: string): string {
24
+ let escaped = segment.gsub("\\", "\\\\")[0];
25
+ escaped = escaped.gsub("\n", "\\n")[0];
26
+ escaped = escaped.gsub("\r", "\\r")[0];
27
+ escaped = escaped.gsub("\t", "\\t")[0];
28
+ escaped = escaped.gsub('"', '\\"')[0];
29
+ return `"${escaped}"`;
30
+ }
31
+
32
+ function unescapePathSegment(segment: string): string {
33
+ const chars: string[] = [];
34
+ let i = 1;
35
+ while (i <= segment.size()) {
36
+ const ch = segment.sub(i, i);
37
+ if (ch === "\\" && i < segment.size()) {
38
+ const nextChar = segment.sub(i + 1, i + 1);
39
+ if (nextChar === "n") {
40
+ chars.push("\n");
41
+ } else if (nextChar === "r") {
42
+ chars.push("\r");
43
+ } else if (nextChar === "t") {
44
+ chars.push("\t");
45
+ } else {
46
+ chars.push(nextChar);
47
+ }
48
+ i += 2;
49
+ } else {
50
+ chars.push(ch);
51
+ i += 1;
52
+ }
53
+ }
54
+ return chars.join("");
55
+ }
56
+
57
+ function isCanonicalBracketStart(path: string, index: number): boolean {
58
+ const quote = path.sub(index + 1, index + 1);
59
+ return (quote === '"' || quote === "'") && path.sub(index - 1, index - 1) !== ".";
60
+ }
61
+
62
+ function parseInstancePath(path: string): string[] | undefined {
63
+ let i = 1;
64
+ const len = path.size();
65
+ const parts: string[] = [];
66
+ let current = "";
67
+
68
+ if (path === "" || path === "game") return parts;
69
+ if (path.sub(1, 5) === "game.") {
70
+ i = 6;
71
+ } else if (path.sub(1, 5) === "game[") {
72
+ i = 5;
73
+ }
74
+
75
+ while (i <= len) {
76
+ const ch = path.sub(i, i);
77
+
78
+ if (ch === ".") {
79
+ if (current !== "") {
80
+ parts.push(current);
81
+ current = "";
82
+ i += 1;
83
+ } else if (i > 1 && path.sub(i - 1, i - 1) === "." && i < len && path.sub(i + 1, i + 1) !== "[") {
84
+ // Back-compat for previously emitted paths such as
85
+ // game.ServerScriptService..dir.ReproScript, where ".dir"
86
+ // was an actual instance name.
87
+ current = ".";
88
+ i += 1;
89
+ } else {
90
+ i += 1;
91
+ }
92
+ } else if (ch === "[" && i < len && isCanonicalBracketStart(path, i)) {
93
+ if (current !== "") {
94
+ parts.push(current);
95
+ current = "";
96
+ }
97
+
98
+ const quote = path.sub(i + 1, i + 1);
99
+ if (quote !== '"' && quote !== "'") return undefined;
100
+ let j = i + 2;
101
+ let raw = "";
102
+ while (j <= len) {
103
+ const c = path.sub(j, j);
104
+ if (c === "\\") {
105
+ if (j >= len) return undefined;
106
+ raw += c + path.sub(j + 1, j + 1);
107
+ j += 2;
108
+ } else if (c === quote) {
109
+ break;
110
+ } else {
111
+ raw += c;
112
+ j += 1;
113
+ }
114
+ }
115
+ if (j > len || path.sub(j, j) !== quote || path.sub(j + 1, j + 1) !== "]") return undefined;
116
+ parts.push(unescapePathSegment(raw));
117
+ i = j + 2;
118
+ } else {
119
+ current += ch;
120
+ i += 1;
121
+ }
122
+ }
123
+
124
+ if (current !== "") parts.push(current);
125
+ return parts;
126
+ }
127
+
128
+ function getRootSegment(instance: Instance): string {
129
+ if (instance.Parent === game) {
130
+ const [ok, service] = pcall(() => game.GetService(instance.ClassName as keyof Services));
131
+ if (ok && service === instance) {
132
+ return instance.ClassName;
133
+ }
134
+ }
135
+ return instance.Name;
136
+ }
137
+
13
138
  function getInstancePath(instance: Instance): string {
14
139
  if (!instance || instance === game) {
15
140
  return "game";
@@ -19,26 +144,35 @@ function getInstancePath(instance: Instance): string {
19
144
  let current: Instance | undefined = instance;
20
145
 
21
146
  while (current && current !== game) {
22
- pathParts.unshift(current.Name);
147
+ pathParts.unshift(getRootSegment(current));
23
148
  current = current.Parent as Instance | undefined;
24
149
  }
25
150
 
26
- return `game.${pathParts.join(".")}`;
151
+ let path = "game";
152
+ for (const part of pathParts) {
153
+ if (isSimplePathSegment(part)) {
154
+ path += `.${part}`;
155
+ } else {
156
+ path += `[${quotePathSegment(part)}]`;
157
+ }
158
+ }
159
+ return path;
27
160
  }
28
161
 
29
- function getInstanceByPath(path: string): Instance | undefined {
30
- if (path === "game" || path === "") {
31
- return game;
32
- }
162
+ function getRootInstance(segment: string): Instance | undefined {
163
+ const [ok, service] = pcall(() => game.GetService(segment as keyof Services));
164
+ if (ok && service) return service as Instance;
165
+ return game.FindFirstChild(segment);
166
+ }
33
167
 
34
- const cleaned = path.gsub("^game%.", "")[0];
35
- const parts: string[] = [];
36
- for (const [part] of cleaned.gmatch("[^%.]+")) {
37
- parts.push(part as string);
38
- }
168
+ function getInstanceByPath(path: string): Instance | undefined {
169
+ const parts = parseInstancePath(path);
170
+ if (parts === undefined) return undefined;
171
+ if (parts.size() === 0) return game;
39
172
 
40
- let current: Instance | undefined = game;
41
- for (const part of parts) {
173
+ let current: Instance | undefined = getRootInstance(parts[0]);
174
+ for (let i = 1; i < parts.size(); i++) {
175
+ const part = parts[i];
42
176
  if (!current) return undefined;
43
177
  current = current.FindFirstChild(part);
44
178
  }