@chrrxs/robloxstudio-mcp-inspector 2.17.0 → 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.
@@ -152,7 +152,6 @@ const routeMap: Record<string, Handler> = {
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,
@@ -308,6 +307,7 @@ function sendReady(conn: Connection): void {
308
307
  lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
309
308
  ? readyData.instanceId
310
309
  : instanceId;
310
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl);
311
311
  const connectedRole = assignedRole ?? detectRole();
312
312
  if (readyFailureLogKeys.has(readyLogKey)) {
313
313
  readyFailureLogKeys.delete(readyLogKey);
@@ -503,13 +503,15 @@ function activatePlugin(connIndex?: number) {
503
503
  conn.currentRetryDelay = 0.5;
504
504
 
505
505
  if (idx === State.getActiveTabIndex()) {
506
- conn.serverUrl = ui.urlInput.Text;
507
- const [portStr] = conn.serverUrl.match(":(%d+)$");
508
- 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;
509
512
  UI.updateTabLabel(idx);
510
513
  UI.updateUIState();
511
514
  }
512
- ServerUrlSettings.rememberServerUrl(conn.serverUrl);
513
515
  UI.updateTabDot(idx);
514
516
 
515
517
  if (!conn.heartbeatConnection) {
@@ -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
  }
@@ -132,6 +132,9 @@ function setScriptSource(requestData: Record<string, unknown>) {
132
132
  const oldSourceLength = readScriptSource(instance).size();
133
133
 
134
134
  ScriptEditorService.UpdateSourceAsync(instance, () => sourceToSet);
135
+ if (readScriptSource(instance) !== sourceToSet) {
136
+ error("UpdateSourceAsync completed without updating the script source");
137
+ }
135
138
 
136
139
  return {
137
140
  success: true, instancePath,
@@ -1,4 +1,4 @@
1
- import { HttpService, LogService, Players, RunService } from "@rbxts/services";
1
+ import { HttpService, Players, RunService } from "@rbxts/services";
2
2
  import StopPlayMonitor from "../StopPlayMonitor";
3
3
 
4
4
  interface StudioTestServiceMultiplayer extends StudioTestService {
@@ -10,20 +10,8 @@ interface StudioTestServiceMultiplayer extends StudioTestService {
10
10
  }
11
11
 
12
12
  const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
13
- const ServerScriptService = game.GetService("ServerScriptService");
14
- const ScriptEditorService = game.GetService("ScriptEditorService");
15
-
16
- // NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
17
- // __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
18
- // off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
19
- // reflection from edit -> play-server does not work in practice.
20
- const NAV_SIGNAL = "__MCP_NAV__";
21
- const NAV_RESULT = "__MCP_NAV_RESULT__";
22
13
 
23
14
  let testRunning = false;
24
- let navLogConnection: RBXScriptConnection | undefined;
25
- let stopListenerScript: Script | undefined;
26
- let navResultCallback: ((json: string) => void) | undefined;
27
15
 
28
16
  type MultiplayerPhase = "idle" | "starting" | "running" | "completed" | "failed";
29
17
 
@@ -78,102 +66,6 @@ function normalizeNumPlayers(value: unknown): number | undefined {
78
66
  return n;
79
67
  }
80
68
 
81
- function buildCommandListenerSource(): string {
82
- return `local LogService = game:GetService("LogService")
83
- local PathfindingService = game:GetService("PathfindingService")
84
- local Players = game:GetService("Players")
85
- local HttpService = game:GetService("HttpService")
86
- local NAV_SIG = "${NAV_SIGNAL}"
87
- local NAV_RES = "${NAV_RESULT}"
88
- LogService.MessageOut:Connect(function(msg)
89
- if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
90
- local json = string.sub(msg, #NAV_SIG + 2)
91
- task.spawn(function()
92
- local ok, d = pcall(function() return HttpService:JSONDecode(json) end)
93
- if not ok or not d then
94
- print(NAV_RES .. ':{"success":false,"error":"parse_error"}')
95
- return
96
- end
97
- local ps = Players:GetPlayers()
98
- if #ps == 0 then
99
- print(NAV_RES .. ':{"success":false,"error":"no_players"}')
100
- return
101
- end
102
- local char = ps[1].Character or ps[1].CharacterAdded:Wait()
103
- local hum = char:FindFirstChildOfClass("Humanoid")
104
- local root = char:FindFirstChild("HumanoidRootPart")
105
- if not hum or not root then
106
- print(NAV_RES .. ':{"success":false,"error":"no_humanoid"}')
107
- return
108
- end
109
- local target
110
- if d.instancePath then
111
- local parts = string.split(d.instancePath, ".")
112
- local cur = game
113
- for i = 2, #parts do
114
- cur = cur:FindFirstChild(parts[i])
115
- if not cur then
116
- print(NAV_RES .. ':{"success":false,"error":"instance_not_found"}')
117
- return
118
- end
119
- end
120
- if cur:IsA("BasePart") then target = cur.Position
121
- elseif cur:IsA("Model") and cur.PrimaryPart then target = cur.PrimaryPart.Position
122
- else target = cur:GetPivot().Position end
123
- else
124
- target = Vector3.new(d.x or 0, d.y or 0, d.z or 0)
125
- end
126
- local path = PathfindingService:CreatePath({AgentRadius=2,AgentHeight=5,AgentCanJump=true})
127
- local pok = pcall(function() path:ComputeAsync(root.Position, target) end)
128
- local method = "direct"
129
- if pok and path.Status == Enum.PathStatus.Success then
130
- method = "pathfinding"
131
- for _, wp in ipairs(path:GetWaypoints()) do
132
- hum:MoveTo(wp.Position)
133
- if wp.Action == Enum.PathWaypointAction.Jump then hum.Jump = true end
134
- hum.MoveToFinished:Wait()
135
- end
136
- else
137
- hum:MoveTo(target)
138
- hum.MoveToFinished:Wait()
139
- end
140
- local fp = root.Position
141
- print(NAV_RES .. ':{"success":true,"method":"' .. method .. '","position":[' .. fp.X .. ',' .. fp.Y .. ',' .. fp.Z .. ']}')
142
- end)
143
- end
144
- end)`;
145
- }
146
-
147
- function injectStopListener() {
148
- const listener = new Instance("Script");
149
- listener.Name = "__MCP_CommandListener";
150
- listener.Parent = ServerScriptService;
151
-
152
- const source = buildCommandListenerSource();
153
- const [seOk] = pcall(() => {
154
- ScriptEditorService.UpdateSourceAsync(listener, () => source);
155
- });
156
- if (!seOk) {
157
- (listener as unknown as { Source: string }).Source = source;
158
- }
159
-
160
- stopListenerScript = listener;
161
- }
162
-
163
- function cleanupStopListener() {
164
- if (stopListenerScript) {
165
- pcall(() => stopListenerScript!.Destroy());
166
- stopListenerScript = undefined;
167
- }
168
- }
169
-
170
- function disconnectNavLogListener() {
171
- if (navLogConnection) {
172
- navLogConnection.Disconnect();
173
- navLogConnection = undefined;
174
- }
175
- }
176
-
177
69
  function startPlaytest(requestData: Record<string, unknown>) {
178
70
  const mode = requestData.mode as string | undefined;
179
71
  const numPlayers = requestData.numPlayers as number | undefined;
@@ -192,8 +84,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
192
84
  // Reset it so subsequent starts don't hit a false "already running".
193
85
  if (testRunning && !RunService.IsRunning()) {
194
86
  testRunning = false;
195
- disconnectNavLogListener();
196
- cleanupStopListener();
197
87
  // Runtime eval bridges are created by the play server/client plugin
198
88
  // peers and disappear with the play DataModels.
199
89
  }
@@ -204,22 +94,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
204
94
 
205
95
  testRunning = true;
206
96
 
207
- cleanupStopListener();
208
- disconnectNavLogListener();
209
-
210
- navLogConnection = LogService.MessageOut.Connect((message) => {
211
- if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
212
- if (navResultCallback) {
213
- navResultCallback(message.sub(NAV_RESULT.size() + 2));
214
- }
215
- }
216
- });
217
-
218
- const [injected, injErr] = pcall(() => injectStopListener());
219
- if (!injected) {
220
- warn(`[robloxstudio-mcp] Failed to inject stop listener: ${injErr}`);
221
- }
222
-
223
97
  task.spawn(() => {
224
98
  const [ok, result] = pcall(() => {
225
99
  if (mode === "play") {
@@ -232,10 +106,7 @@ function startPlaytest(requestData: Record<string, unknown>) {
232
106
  warn(`[robloxstudio-mcp] Playtest ended with error: ${result}`);
233
107
  }
234
108
 
235
- disconnectNavLogListener();
236
109
  testRunning = false;
237
-
238
- cleanupStopListener();
239
110
  });
240
111
 
241
112
  const response: Record<string, unknown> = {
@@ -468,52 +339,6 @@ function multiplayerTestEnd(requestData: Record<string, unknown>) {
468
339
  };
469
340
  }
470
341
 
471
- function characterNavigation(requestData: Record<string, unknown>) {
472
- if (!testRunning) {
473
- return { error: "Playtest must be running. Start a playtest in 'play' mode first." };
474
- }
475
-
476
- const position = requestData.position as number[] | undefined;
477
- const instancePath = requestData.instancePath as string | undefined;
478
- const waitForCompletion = (requestData.waitForCompletion as boolean) ?? true;
479
- const timeout = (requestData.timeout as number) ?? 25;
480
-
481
- if (!position && !instancePath) {
482
- return { error: "Either position [x, y, z] or instancePath is required" };
483
- }
484
-
485
- let navData: string;
486
- if (position) {
487
- navData = HttpService.JSONEncode({ x: position[0], y: position[1], z: position[2] });
488
- } else {
489
- navData = HttpService.JSONEncode({ instancePath });
490
- }
491
-
492
- warn(`${NAV_SIGNAL}:${navData}`);
493
-
494
- if (!waitForCompletion) {
495
- return { success: true, message: "Navigation command sent" };
496
- }
497
-
498
- let result: string | undefined;
499
- navResultCallback = (json: string) => {
500
- result = json;
501
- };
502
-
503
- const startTime = tick();
504
- while (!result && tick() - startTime < timeout) {
505
- task.wait(0.2);
506
- }
507
- navResultCallback = undefined;
508
-
509
- if (result) {
510
- const [ok, parsed] = pcall(() => HttpService.JSONDecode(result!));
511
- if (ok) return parsed;
512
- return { success: true, rawResult: result };
513
- }
514
- return { error: `Navigation timed out after ${timeout} seconds` };
515
- }
516
-
517
342
  export = {
518
343
  startPlaytest,
519
344
  stopPlaytest,
@@ -522,5 +347,4 @@ export = {
522
347
  multiplayerTestAddPlayers,
523
348
  multiplayerTestLeaveClient,
524
349
  multiplayerTestEnd,
525
- characterNavigation,
526
350
  };
@@ -26,6 +26,19 @@ StopPlayMonitor.init(plugin);
26
26
  BreakpointHandlers.init(plugin);
27
27
  ServerUrlSettings.init(plugin);
28
28
 
29
+ function applyRememberedServerUrl(): void {
30
+ const rememberedServerUrl = ServerUrlSettings.readServerUrl();
31
+ if (rememberedServerUrl === undefined) return;
32
+
33
+ const conn = State.getActiveConnection();
34
+ conn.serverUrl = rememberedServerUrl;
35
+ const port = ServerUrlSettings.extractPort(rememberedServerUrl);
36
+ if (port !== undefined) conn.port = port;
37
+ ClientBroker.setServerUrl(rememberedServerUrl);
38
+ }
39
+
40
+ applyRememberedServerUrl();
41
+
29
42
  UI.init(plugin);
30
43
  const elements = UI.getElements();
31
44
 
@@ -93,11 +106,11 @@ task.delay(2, () => {
93
106
  if (conn && !conn.isActive) {
94
107
  if (role === "server") {
95
108
  const inheritedServerUrl = ServerUrlSettings.readServerUrl() ?? ClientBroker.DEFAULT_MCP_URL;
96
- conn.serverUrl = inheritedServerUrl;
97
- elements.urlInput.Text = inheritedServerUrl;
98
- const [portStr] = conn.serverUrl.match(":(%d+)$");
99
- if (portStr) conn.port = tonumber(portStr) ?? conn.port;
100
- ClientBroker.setServerUrl(inheritedServerUrl);
109
+ conn.serverUrl = ServerUrlSettings.normalizeServerUrl(inheritedServerUrl);
110
+ elements.urlInput.Text = conn.serverUrl;
111
+ const port = ServerUrlSettings.extractPort(conn.serverUrl);
112
+ if (port !== undefined) conn.port = port;
113
+ ClientBroker.setServerUrl(conn.serverUrl);
101
114
  }
102
115
  // Defensive default: in invisible play-DM UIs, the input field
103
116
  // may not be populated by the time we activate.