@chrrxs/robloxstudio-mcp 2.10.0 → 2.11.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.
@@ -1,5 +1,6 @@
1
1
  import { HttpService, Players, ReplicatedStorage, RunService } from "@rbxts/services";
2
2
  import RuntimeLogBuffer from "./RuntimeLogBuffer";
3
+ import MemoryHandlers from "./handlers/MemoryHandlers";
3
4
 
4
5
  // The client peer cannot reach the MCP HTTP server - Roblox forbids
5
6
  // HttpService:RequestAsync from the client DM even under PluginSecurity, and
@@ -45,6 +46,7 @@ interface ExecuteResult {
45
46
  const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
46
47
  "/api/execute-luau",
47
48
  "/api/get-runtime-logs",
49
+ "/api/get-memory-breakdown",
48
50
  ]);
49
51
 
50
52
  interface ReadyResponseBody {
@@ -145,6 +147,9 @@ function setupClientBroker() {
145
147
  if (payload && payload.endpoint === "/api/get-runtime-logs") {
146
148
  return handleGetRuntimeLogs(payload.data);
147
149
  }
150
+ if (payload && payload.endpoint === "/api/get-memory-breakdown") {
151
+ return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
152
+ }
148
153
  if (payload && payload.endpoint === "/api/execute-luau") {
149
154
  return handleExecuteLuau(payload.data);
150
155
  }
@@ -13,6 +13,8 @@ import AssetHandlers from "./handlers/AssetHandlers";
13
13
  import CaptureHandlers from "./handlers/CaptureHandlers";
14
14
  import InputHandlers from "./handlers/InputHandlers";
15
15
  import LogHandlers from "./handlers/LogHandlers";
16
+ import SerializationHandlers from "./handlers/SerializationHandlers";
17
+ import MemoryHandlers from "./handlers/MemoryHandlers";
16
18
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
17
19
 
18
20
  const instanceId = HttpService.GenerateGUID(false);
@@ -95,6 +97,11 @@ const routeMap: Record<string, Handler> = {
95
97
  "/api/find-and-replace-in-scripts": ScriptHandlers.findAndReplaceInScripts,
96
98
 
97
99
  "/api/get-runtime-logs": LogHandlers.getRuntimeLogs,
100
+
101
+ "/api/export-rbxm": SerializationHandlers.exportRbxm,
102
+ "/api/import-rbxm": SerializationHandlers.importRbxm,
103
+
104
+ "/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
98
105
  };
99
106
 
100
107
  function processRequest(request: RequestPayload): unknown {
@@ -181,6 +188,7 @@ function pollForRequests(connIndex: number) {
181
188
 
182
189
  const ui = UI.getElements();
183
190
  UI.updateTabDot(connIndex);
191
+ if (connIndex === State.getActiveTabIndex()) UI.updateToolbarIcon();
184
192
 
185
193
  if (success && (result.Success || result.StatusCode === 503)) {
186
194
  conn.consecutiveFailures = 0;
@@ -333,7 +341,6 @@ function activatePlugin(connIndex?: number) {
333
341
  conn.isActive = true;
334
342
  conn.consecutiveFailures = 0;
335
343
  conn.currentRetryDelay = 0.5;
336
- ui.screenGui.Enabled = true;
337
344
 
338
345
  if (idx === State.getActiveTabIndex()) {
339
346
  conn.serverUrl = ui.urlInput.Text;
@@ -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
-
@@ -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,44 @@
1
+ const Stats = game.GetService("Stats");
2
+
3
+ // GetMemoryUsageMbAllCategories is gated by capability "InternalTest" and not
4
+ // callable from plugin context. GetMemoryUsageMbForTag is not - so we iterate
5
+ // Enum.DeveloperMemoryTag and ask per-tag.
6
+ function getMemoryBreakdown(requestData: Record<string, unknown>): unknown {
7
+ if (!Stats.MemoryTrackingEnabled) {
8
+ return { error: "MemoryTrackingEnabled is false on this peer" };
9
+ }
10
+
11
+ const requested = requestData.tags as string[] | undefined;
12
+ const requestedSet = requested && requested.size() > 0 ? new Set(requested) : undefined;
13
+
14
+ const categories: Record<string, number> = {};
15
+ for (const item of Enum.DeveloperMemoryTag.GetEnumItems()) {
16
+ const name = item.Name;
17
+ if (requestedSet && !requestedSet.has(name)) continue;
18
+ const [ok, mb] = pcall(() => Stats.GetMemoryUsageMbForTag(item));
19
+ categories[name] = ok ? (mb as number) : 0;
20
+ }
21
+
22
+ const unknownTags: string[] = [];
23
+ if (requestedSet) {
24
+ const known = new Set<string>();
25
+ for (const i of Enum.DeveloperMemoryTag.GetEnumItems()) known.add(i.Name);
26
+ for (const t of requestedSet) {
27
+ if (!known.has(t)) {
28
+ unknownTags.push(t);
29
+ categories[t] = 0;
30
+ }
31
+ }
32
+ }
33
+
34
+ const result: Record<string, unknown> = {
35
+ total_mb: Stats.GetTotalMemoryUsageMb(),
36
+ categories,
37
+ memory_tracking_enabled: true,
38
+ timestamp: DateTime.now().UnixTimestampMillis,
39
+ };
40
+ if (unknownTags.size() > 0) result.unknown_tags = unknownTags;
41
+ return result;
42
+ }
43
+
44
+ export = { getMemoryBreakdown };
@@ -96,8 +96,23 @@ function searchFiles(requestData: Record<string, unknown>) {
96
96
  }
97
97
 
98
98
  function getPlaceInfo(_requestData: Record<string, unknown>) {
99
+ const dataModelName = game.Name;
100
+ let placeName = dataModelName;
101
+
102
+ if (game.PlaceId > 0) {
103
+ const MarketplaceService = game.GetService("MarketplaceService");
104
+ const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
105
+ if (ok && info !== undefined) {
106
+ const name = (info as { Name?: string }).Name;
107
+ if (typeIs(name, "string") && name !== "") {
108
+ placeName = name;
109
+ }
110
+ }
111
+ }
112
+
99
113
  return {
100
- placeName: game.Name,
114
+ placeName,
115
+ dataModelName,
101
116
  placeId: game.PlaceId,
102
117
  gameId: game.GameId,
103
118
  jobId: game.JobId,
@@ -0,0 +1,172 @@
1
+ import { RunService } from "@rbxts/services";
2
+ import Utils from "../Utils";
3
+ import Recording from "../Recording";
4
+
5
+ // SerializationService:SerializeInstancesAsync / DeserializeInstancesAsync were
6
+ // added in engine v668 and are PluginSecurity. They are not in @rbxts/types yet,
7
+ // so we resolve the service through an untyped GetService cast and treat the
8
+ // methods as opaque (buffer in / buffer out).
9
+ type SerializationServiceShape = {
10
+ SerializeInstancesAsync(this: SerializationServiceShape, instances: Instance[]): buffer;
11
+ DeserializeInstancesAsync(this: SerializationServiceShape, b: buffer): Instance[];
12
+ };
13
+
14
+ const SerializationService = (game as unknown as {
15
+ GetService(name: string): SerializationServiceShape;
16
+ }).GetService("SerializationService");
17
+
18
+ // EncodingService:Base64Encode / Base64Decode take and return `buffer` (not
19
+ // `string`). The signature is in @rbxts/types under None.d.ts so a normal
20
+ // GetService("EncodingService") would already give correct types, but @rbxts
21
+ // generates a per-service nominal interface and roblox.d.ts doesn't re-export
22
+ // EncodingService from the services barrel module - so the typed cast below
23
+ // matches what GetService would give us if it did.
24
+ type EncodingServiceShape = {
25
+ Base64Encode(this: EncodingServiceShape, input: buffer): buffer;
26
+ Base64Decode(this: EncodingServiceShape, input: buffer): buffer;
27
+ };
28
+
29
+ const EncodingService = (game as unknown as {
30
+ GetService(name: string): EncodingServiceShape;
31
+ }).GetService("EncodingService");
32
+
33
+ const { getInstanceByPath, getInstancePath } = Utils;
34
+ const { beginRecording, finishRecording } = Recording;
35
+
36
+ function exportRbxm(requestData: Record<string, unknown>): unknown {
37
+ const instancePaths = requestData.instance_paths as string[] | undefined;
38
+ if (!instancePaths || !typeIs(instancePaths, "table") || instancePaths.size() === 0) {
39
+ return { error: "instance_paths must be a non-empty array" };
40
+ }
41
+
42
+ const instances: Instance[] = [];
43
+ for (const p of instancePaths) {
44
+ const inst = getInstanceByPath(p);
45
+ if (!inst) {
46
+ return { error: `instance not found: ${p}` };
47
+ }
48
+ instances.push(inst);
49
+ }
50
+
51
+ const [serializeOk, serializeResult] = pcall(() => {
52
+ return SerializationService.SerializeInstancesAsync(instances);
53
+ });
54
+ if (!serializeOk) {
55
+ return { error: `SerializeInstancesAsync failed: ${tostring(serializeResult)}` };
56
+ }
57
+
58
+ const buf = serializeResult as buffer;
59
+ const [encodeOk, encodeResult] = pcall(() => EncodingService.Base64Encode(buf));
60
+ if (!encodeOk) {
61
+ return { error: `EncodingService:Base64Encode failed: ${tostring(encodeResult)}` };
62
+ }
63
+ // Base64Encode returns a buffer of ASCII bytes; convert to a Lua string so
64
+ // HttpService:JSONEncode (called by the harness in Communication.ts) accepts
65
+ // it. Base64 is by definition pure ASCII so this round-trips cleanly.
66
+ const base64Str = buffer.tostring(encodeResult as buffer);
67
+
68
+ return {
69
+ base64: base64Str,
70
+ instance_count: instances.size(),
71
+ };
72
+ }
73
+
74
+ function importRbxm(requestData: Record<string, unknown>): unknown {
75
+ const b64 = requestData.base64 as string | undefined;
76
+ const parentPath = requestData.parent_path as string | undefined;
77
+ const sourceLabel = (requestData.source_label as string | undefined) ?? "unknown";
78
+
79
+ if (!b64 || !typeIs(b64, "string")) {
80
+ return { error: "base64 payload is required" };
81
+ }
82
+ if (!parentPath || !typeIs(parentPath, "string")) {
83
+ return { error: "parent_path is required" };
84
+ }
85
+
86
+ const parentInstance = getInstanceByPath(parentPath);
87
+ if (!parentInstance) {
88
+ return { error: `parent instance not found: ${parentPath}` };
89
+ }
90
+
91
+ // b64 is an ASCII-only Lua string from the wire; lift it into a buffer for
92
+ // EncodingService:Base64Decode, which returns a buffer of raw rbxm bytes
93
+ // ready for DeserializeInstancesAsync.
94
+ const [b64BufOk, b64BufResult] = pcall(() => buffer.fromstring(b64));
95
+ if (!b64BufOk) {
96
+ return { error: `buffer.fromstring(base64) failed: ${tostring(b64BufResult)}` };
97
+ }
98
+ const [decodeOk, decodeResult] = pcall(() => EncodingService.Base64Decode(b64BufResult as buffer));
99
+ if (!decodeOk) {
100
+ return { error: `EncodingService:Base64Decode failed: ${tostring(decodeResult)}` };
101
+ }
102
+ const buf = decodeResult as buffer;
103
+
104
+ const [deserOk, deserResult] = pcall(() => {
105
+ return SerializationService.DeserializeInstancesAsync(buf);
106
+ });
107
+ if (!deserOk) {
108
+ return { error: `DeserializeInstancesAsync failed: ${tostring(deserResult)}` };
109
+ }
110
+ const deserialized = deserResult as Instance[];
111
+
112
+ // All-or-nothing parenting. Track every instance we've attached and roll back
113
+ // (unparent + Destroy) if any later one fails - partial imports leave the DM
114
+ // in a worse state than failing cleanly.
115
+ const isEdit = !RunService.IsRunning();
116
+ const recordingId = isEdit ? beginRecording(`Import rbxm`) : undefined;
117
+
118
+ const attached: Instance[] = [];
119
+ let failureMessage: string | undefined;
120
+ for (const inst of deserialized) {
121
+ const [parentOk, parentErr] = pcall(() => {
122
+ inst.Parent = parentInstance;
123
+ });
124
+ if (!parentOk) {
125
+ failureMessage = `failed to parent ${inst.Name} (${inst.ClassName}) under ${parentPath}: ${tostring(parentErr)}`;
126
+ break;
127
+ }
128
+ attached.push(inst);
129
+ }
130
+
131
+ if (failureMessage !== undefined) {
132
+ for (const inst of attached) {
133
+ pcall(() => {
134
+ inst.Parent = undefined;
135
+ inst.Destroy();
136
+ });
137
+ }
138
+ // Also destroy any unparented deserialized instances so they don't leak.
139
+ for (const inst of deserialized) {
140
+ if (inst.Parent === undefined) {
141
+ pcall(() => inst.Destroy());
142
+ }
143
+ }
144
+ finishRecording(recordingId, false);
145
+ return { error: failureMessage };
146
+ }
147
+
148
+ const names: string[] = [];
149
+ const paths: string[] = [];
150
+ for (const inst of attached) {
151
+ names.push(inst.Name);
152
+ paths.push(getInstancePath(inst));
153
+ }
154
+
155
+ // The recording shows "MCP: Import rbxm" in Studio's undo stack -
156
+ // ChangeHistoryService doesn't expose a way to set a richer displayName
157
+ // after TryBeginRecording, so the count/source only land in the JSON response.
158
+ finishRecording(recordingId, true);
159
+
160
+ return {
161
+ instance_count: attached.size(),
162
+ instance_names: names,
163
+ instance_paths: paths,
164
+ parent_path: parentPath,
165
+ source: sourceLabel,
166
+ };
167
+ }
168
+
169
+ export = {
170
+ exportRbxm,
171
+ importRbxm,
172
+ };
@@ -1,5 +1,5 @@
1
1
  import { HttpService, LogService } from "@rbxts/services";
2
- import { installBridges, cleanupBridges, loadStringEnabled } from "../EvalBridges";
2
+ import { installBridges, cleanupBridges } from "../EvalBridges";
3
3
 
4
4
  const StudioTestService = game.GetService("StudioTestService");
5
5
  const ServerScriptService = game.GetService("ServerScriptService");
@@ -159,7 +159,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
159
159
  // so eval_server_runtime / eval_client_runtime work without manual setup.
160
160
  // Bridges are cleaned up from the edit DM after the play DMs tear down.
161
161
  const bridgeInstall = installBridges();
162
- const hasLoadString = loadStringEnabled();
163
162
  if (!bridgeInstall.installed) {
164
163
  warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
165
164
  }
@@ -203,17 +202,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
203
202
  evalBridges: bridgeInstall.installed ? "installed" : `failed: ${bridgeInstall.error}`,
204
203
  };
205
204
 
206
- // Surface loadstring availability up-front so callers know whether
207
- // eval_server_runtime will work before they try it. eval_client_runtime
208
- // doesn't need loadstring (it uses ModuleScript+require), so this only
209
- // affects the server bridge.
210
- if (!hasLoadString) {
211
- response.serverEvalNote =
212
- "ServerScriptService.LoadStringEnabled is false. eval_server_runtime will not work " +
213
- "until you enable it (ServerScriptService > Properties > LoadStringEnabled = true) " +
214
- "and restart the playtest. eval_client_runtime is unaffected.";
215
- }
216
-
217
205
  return response;
218
206
  }
219
207
 
@@ -13,8 +13,13 @@ UI.init(plugin);
13
13
  const elements = UI.getElements();
14
14
 
15
15
 
16
+ const ICON_DISCONNECTED = "rbxassetid://__BUTTON_ICON_DISCONNECTED__";
17
+ const ICON_CONNECTING = "rbxassetid://__BUTTON_ICON_CONNECTING__";
18
+ const ICON_CONNECTED = "rbxassetid://__BUTTON_ICON_CONNECTED__";
19
+
16
20
  const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
17
- const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", "rbxassetid://__BUTTON_ICON_ID__");
21
+ const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", ICON_DISCONNECTED);
22
+ UI.setToolbarButton(button, { disconnected: ICON_DISCONNECTED, connecting: ICON_CONNECTING, connected: ICON_CONNECTED });
18
23
 
19
24
 
20
25
  elements.connectButton.Activated.Connect(() => {