@chrrxs/robloxstudio-mcp 2.17.0 → 2.18.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.
- package/dist/index.js +968 -253
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +361 -253
- package/studio-plugin/MCPPlugin.rbxmx +361 -253
- package/studio-plugin/src/modules/Communication.ts +7 -5
- package/studio-plugin/src/modules/ServerUrlSettings.ts +62 -9
- package/studio-plugin/src/modules/UI.ts +11 -4
- package/studio-plugin/src/modules/Utils.ts +147 -13
- package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +3 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +1 -177
- package/studio-plugin/src/server/index.server.ts +18 -5
|
@@ -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
|
-
|
|
507
|
-
|
|
508
|
-
if (
|
|
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
|
|
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
|
-
|
|
83
|
+
const normalized = normalizeServerUrl(serverUrl);
|
|
84
|
+
if (!pluginRef || normalized === "") return;
|
|
85
|
+
writeSettingString(GLOBAL_SETTING_KEY, normalized);
|
|
39
86
|
for (const instanceId of computeInstanceIds()) {
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
49
|
-
|
|
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 =
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
147
|
+
pathParts.unshift(getRootSegment(current));
|
|
23
148
|
current = current.Parent as Instance | undefined;
|
|
24
149
|
}
|
|
25
150
|
|
|
26
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
const parts
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
41
|
-
for (
|
|
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,
|
|
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 =
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
ClientBroker.setServerUrl(
|
|
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.
|