@chrrxs/robloxstudio-mcp-inspector 2.15.2 → 2.16.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.
- package/dist/index.js +1288 -60
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +616 -236
- package/studio-plugin/MCPPlugin.rbxmx +616 -236
- package/studio-plugin/src/modules/ClientBroker.ts +32 -6
- package/studio-plugin/src/modules/Communication.ts +44 -34
- package/studio-plugin/src/modules/EvalBridges.ts +91 -64
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +48 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +152 -35
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +34 -6
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +20 -46
- package/studio-plugin/src/server/index.server.ts +41 -11
|
@@ -5,39 +5,62 @@
|
|
|
5
5
|
// `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
6
6
|
// shared across every DataModel the plugin runs in (edit DMs, play-server
|
|
7
7
|
// DMs, play-client DMs). For each connected place we use a dedicated key
|
|
8
|
-
// "MCP_STOP_PLAY_<instanceId>" as a
|
|
8
|
+
// "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
|
|
9
9
|
//
|
|
10
|
-
// * The edit DM's
|
|
10
|
+
// * The edit DM's handler writes a tokenized stop request into its own key
|
|
11
11
|
// (computed from its placeId / ServerStorage anon UUID).
|
|
12
12
|
// * Each play-server DM's monitor loop polls the key matching its own
|
|
13
|
-
// instanceId at
|
|
14
|
-
//
|
|
15
|
-
// touch this key.
|
|
16
|
-
// * The edit DM waits up to ~8s for its
|
|
17
|
-
//
|
|
13
|
+
// instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
|
|
14
|
+
// and writes a matching result token. Play-server DMs for other places
|
|
15
|
+
// never touch this key.
|
|
16
|
+
// * The edit DM waits up to ~8s for its result token, confirming a matching
|
|
17
|
+
// play-server actually consumed the request.
|
|
18
18
|
//
|
|
19
19
|
// Earlier versions used a single shared boolean flag, which let any
|
|
20
20
|
// play-server DM in the same Studio process consume any place's stop
|
|
21
21
|
// request — silently yanking teammates' playtests. The per-key scoping
|
|
22
22
|
// below is the fix.
|
|
23
23
|
|
|
24
|
-
import { HttpService, ServerStorage } from "@rbxts/services";
|
|
24
|
+
import { HttpService, RunService, ServerStorage } from "@rbxts/services";
|
|
25
25
|
|
|
26
26
|
const StudioTestService = game.GetService("StudioTestService");
|
|
27
27
|
|
|
28
28
|
const SETTING_KEY_PREFIX = "MCP_STOP_PLAY_";
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const POLL_INTERVAL_SEC =
|
|
29
|
+
// Keep this conservative. plugin:GetSetting is backed by Studio's plugin
|
|
30
|
+
// settings store, and this monitor runs during every play session, including
|
|
31
|
+
// manually-started Play. The official reference implementation polls at 1s.
|
|
32
|
+
const POLL_INTERVAL_SEC = 1;
|
|
33
33
|
// Total time we wait for the matching play-server DM to consume the
|
|
34
34
|
// signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
|
|
35
35
|
// StudioTestService:EndTest teardown (several seconds on heavier places).
|
|
36
|
-
// 8s is
|
|
36
|
+
// 8s is intentionally shorter than the MCP request timeout but long enough
|
|
37
|
+
// for the 1s monitor cadence plus ordinary Studio teardown latency.
|
|
37
38
|
const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
|
|
38
39
|
const WAIT_POLL_SEC = 0.1;
|
|
40
|
+
const REQUEST_TTL_SEC = 12.0;
|
|
39
41
|
|
|
40
42
|
let pluginRef: Plugin | undefined;
|
|
43
|
+
let endTestIssued = false;
|
|
44
|
+
|
|
45
|
+
interface StopPayload {
|
|
46
|
+
kind?: string;
|
|
47
|
+
id?: string;
|
|
48
|
+
requestedAt?: number;
|
|
49
|
+
consumedAt?: number;
|
|
50
|
+
ok?: boolean;
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface StopRequestResult {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
requestId?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface StopConsumptionResult {
|
|
60
|
+
ok: boolean;
|
|
61
|
+
consumed: boolean;
|
|
62
|
+
error?: string;
|
|
63
|
+
}
|
|
41
64
|
|
|
42
65
|
function init(p: Plugin): void {
|
|
43
66
|
pluginRef = p;
|
|
@@ -65,53 +88,147 @@ function settingKey(instanceId: string): string {
|
|
|
65
88
|
return SETTING_KEY_PREFIX + instanceId;
|
|
66
89
|
}
|
|
67
90
|
|
|
91
|
+
function readSetting(key: string): unknown {
|
|
92
|
+
if (!pluginRef) return undefined;
|
|
93
|
+
const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
|
|
94
|
+
return ok ? value : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeSetting(key: string, value: unknown): boolean {
|
|
98
|
+
if (!pluginRef) return false;
|
|
99
|
+
const [ok] = pcall(() => pluginRef!.SetSetting(key, value));
|
|
100
|
+
return ok;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function decodePayload(value: unknown): StopPayload | undefined {
|
|
104
|
+
let decoded = value;
|
|
105
|
+
if (typeIs(value, "string")) {
|
|
106
|
+
const [ok, result] = pcall(() => HttpService.JSONDecode(value as string));
|
|
107
|
+
if (!ok) return undefined;
|
|
108
|
+
decoded = result;
|
|
109
|
+
}
|
|
110
|
+
if (!typeIs(decoded, "table")) return undefined;
|
|
111
|
+
const payload = decoded as StopPayload;
|
|
112
|
+
if (!typeIs(payload.kind, "string") || !typeIs(payload.id, "string")) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
return payload;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writePayload(key: string, payload: StopPayload): boolean {
|
|
119
|
+
const [encodedOk, encoded] = pcall(() => HttpService.JSONEncode(payload));
|
|
120
|
+
if (!encodedOk || !typeIs(encoded, "string")) return false;
|
|
121
|
+
return writeSetting(key, encoded);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeResult(key: string, request: StopPayload, ok: boolean, errText?: string): void {
|
|
125
|
+
writePayload(key, {
|
|
126
|
+
kind: "result",
|
|
127
|
+
id: request.id,
|
|
128
|
+
requestedAt: request.requestedAt,
|
|
129
|
+
consumedAt: tick(),
|
|
130
|
+
ok,
|
|
131
|
+
error: errText,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleStopRequest(key: string, request: StopPayload): void {
|
|
136
|
+
if (request.kind !== "request" || !typeIs(request.id, "string")) return;
|
|
137
|
+
if (!typeIs(request.requestedAt, "number")) {
|
|
138
|
+
writeSetting(key, false);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const age = tick() - request.requestedAt;
|
|
143
|
+
if (age < -5 || age > REQUEST_TTL_SEC) {
|
|
144
|
+
writeSetting(key, false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (endTestIssued) {
|
|
149
|
+
writeResult(key, request, true);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!RunService.IsRunning() || !RunService.IsServer()) {
|
|
154
|
+
writeResult(key, request, false, "StopPlayMonitor is not running in the server DataModel.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
endTestIssued = true;
|
|
159
|
+
const [endOk, endErr] = pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
|
|
160
|
+
writeResult(key, request, endOk, endOk ? undefined : tostring(endErr));
|
|
161
|
+
if (!endOk) {
|
|
162
|
+
endTestIssued = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
68
166
|
function startMonitor(): void {
|
|
69
167
|
if (!pluginRef) {
|
|
70
|
-
warn("[
|
|
168
|
+
warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping");
|
|
71
169
|
return;
|
|
72
170
|
}
|
|
73
171
|
const myKey = settingKey(computeInstanceId());
|
|
74
|
-
// Clear any stale value left from a prior session. If a real stop
|
|
75
|
-
// request is in-flight when this runs, the requesting edit DM will
|
|
76
|
-
// write again within its consumption-confirmation window.
|
|
77
|
-
pcall(() => pluginRef!.SetSetting(myKey, false));
|
|
78
172
|
task.spawn(() => {
|
|
79
173
|
while (true) {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
174
|
+
const value = readSetting(myKey);
|
|
175
|
+
if (value === true) {
|
|
176
|
+
// Legacy boolean requests are ambiguous and may be stale from
|
|
177
|
+
// a prior crashed session. New stop requests use token payloads.
|
|
178
|
+
writeSetting(myKey, false);
|
|
179
|
+
} else {
|
|
180
|
+
const payload = decodePayload(value);
|
|
181
|
+
if (payload) {
|
|
182
|
+
handleStopRequest(myKey, payload);
|
|
183
|
+
}
|
|
86
184
|
}
|
|
87
185
|
task.wait(POLL_INTERVAL_SEC);
|
|
88
186
|
}
|
|
89
187
|
});
|
|
90
188
|
}
|
|
91
189
|
|
|
92
|
-
function requestStop():
|
|
93
|
-
if (!pluginRef) return false;
|
|
190
|
+
function requestStop(): StopRequestResult {
|
|
191
|
+
if (!pluginRef) return { ok: false };
|
|
94
192
|
const myKey = settingKey(computeInstanceId());
|
|
95
|
-
const
|
|
96
|
-
|
|
193
|
+
const requestId = HttpService.GenerateGUID(false);
|
|
194
|
+
const ok = writePayload(myKey, {
|
|
195
|
+
kind: "request",
|
|
196
|
+
id: requestId,
|
|
197
|
+
requestedAt: tick(),
|
|
198
|
+
});
|
|
199
|
+
return { ok, requestId: ok ? requestId : undefined };
|
|
97
200
|
}
|
|
98
201
|
|
|
99
|
-
function waitForConsumption():
|
|
100
|
-
if (!pluginRef) return false;
|
|
202
|
+
function waitForConsumption(requestId: string): StopConsumptionResult {
|
|
203
|
+
if (!pluginRef) return { ok: false, consumed: false, error: "Plugin reference is not initialized." };
|
|
101
204
|
const myKey = settingKey(computeInstanceId());
|
|
102
205
|
const start = tick();
|
|
103
206
|
while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
207
|
+
const payload = decodePayload(readSetting(myKey));
|
|
208
|
+
if (payload && payload.kind === "result" && payload.id === requestId) {
|
|
209
|
+
return {
|
|
210
|
+
ok: payload.ok === true,
|
|
211
|
+
consumed: true,
|
|
212
|
+
error: payload.error,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
106
215
|
task.wait(WAIT_POLL_SEC);
|
|
107
216
|
}
|
|
108
|
-
return
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
consumed: false,
|
|
220
|
+
error: "Timed out waiting for the play-server DataModel to acknowledge stop_playtest.",
|
|
221
|
+
};
|
|
109
222
|
}
|
|
110
223
|
|
|
111
|
-
function clearPending(): void {
|
|
224
|
+
function clearPending(requestId?: string): void {
|
|
112
225
|
if (!pluginRef) return;
|
|
113
226
|
const myKey = settingKey(computeInstanceId());
|
|
114
|
-
|
|
227
|
+
if (requestId !== undefined) {
|
|
228
|
+
const payload = decodePayload(readSetting(myKey));
|
|
229
|
+
if (payload && payload.id !== requestId) return;
|
|
230
|
+
}
|
|
231
|
+
writeSetting(myKey, false);
|
|
115
232
|
}
|
|
116
233
|
|
|
117
234
|
export = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LogService, ReplicatedStorage, RunService, ServerScriptService } from "@rbxts/services";
|
|
2
|
-
import { BRIDGE_NAMES } from "../EvalBridges";
|
|
2
|
+
import { BRIDGE_NAMES, ensureRuntimeBridgeInstalled } from "../EvalBridges";
|
|
3
3
|
import LuauExec from "../LuauExec";
|
|
4
4
|
|
|
5
5
|
const PAYLOAD_INSTANCE_NAME = "__MCPEvalPayload";
|
|
@@ -15,6 +15,21 @@ interface WrapperResult {
|
|
|
15
15
|
output?: unknown;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function findBridge(config: { service: Instance; bridgeName: string }): BindableFunction | undefined {
|
|
19
|
+
const bridge = config.service.FindFirstChild(config.bridgeName);
|
|
20
|
+
return bridge && bridge.IsA("BindableFunction") ? bridge : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function waitForBridge(config: { service: Instance; bridgeName: string }, timeoutSec = 2): BindableFunction | undefined {
|
|
24
|
+
const deadline = tick() + timeoutSec;
|
|
25
|
+
let bridge = findBridge(config);
|
|
26
|
+
while (!bridge && tick() < deadline) {
|
|
27
|
+
task.wait(0.05);
|
|
28
|
+
bridge = findBridge(config);
|
|
29
|
+
}
|
|
30
|
+
return bridge;
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
function getBridgeConfig() {
|
|
19
34
|
if (!RunService.IsRunning()) {
|
|
20
35
|
return {
|
|
@@ -25,13 +40,13 @@ function getBridgeConfig() {
|
|
|
25
40
|
return {
|
|
26
41
|
service: ServerScriptService,
|
|
27
42
|
bridgeName: BRIDGE_NAMES.serverLocal,
|
|
28
|
-
missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically
|
|
43
|
+
missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically in the runtime server peer, including for manually-started playtests.",
|
|
29
44
|
};
|
|
30
45
|
}
|
|
31
46
|
return {
|
|
32
47
|
service: ReplicatedStorage,
|
|
33
48
|
bridgeName: BRIDGE_NAMES.clientLocal,
|
|
34
|
-
missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically
|
|
49
|
+
missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically in the runtime client peer, including for manually-started playtests.",
|
|
35
50
|
};
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -44,9 +59,22 @@ function evalRuntime(requestData: Record<string, unknown>) {
|
|
|
44
59
|
return { bridge: "missing", error: config.error };
|
|
45
60
|
}
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
if (!bridge
|
|
49
|
-
|
|
62
|
+
let bridge = findBridge(config);
|
|
63
|
+
if (!bridge) {
|
|
64
|
+
const install = ensureRuntimeBridgeInstalled();
|
|
65
|
+
if (!install.installed) {
|
|
66
|
+
return {
|
|
67
|
+
bridge: "missing",
|
|
68
|
+
error: `${config.missingError} Runtime bridge install failed: ${install.error}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
bridge = waitForBridge(config);
|
|
72
|
+
}
|
|
73
|
+
if (!bridge) {
|
|
74
|
+
return {
|
|
75
|
+
bridge: "missing",
|
|
76
|
+
error: `${config.missingError} Runtime bridge was installed but did not become ready.`,
|
|
77
|
+
};
|
|
50
78
|
}
|
|
51
79
|
|
|
52
80
|
const m = new Instance("ModuleScript");
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { HttpService, LogService, Players, RunService } from "@rbxts/services";
|
|
2
|
-
import { installBridges, ensureBridgesInstalled } from "../EvalBridges";
|
|
3
2
|
import StopPlayMonitor from "../StopPlayMonitor";
|
|
4
3
|
|
|
5
4
|
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
@@ -200,9 +199,8 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
200
199
|
logConnection = undefined;
|
|
201
200
|
}
|
|
202
201
|
cleanupStopListener();
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
// EvalBridges.ts lifecycle comment.
|
|
202
|
+
// Runtime eval bridges are created by the play server/client plugin
|
|
203
|
+
// peers and disappear with the play DataModels.
|
|
206
204
|
}
|
|
207
205
|
|
|
208
206
|
if (testRunning) {
|
|
@@ -233,16 +231,7 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
233
231
|
|
|
234
232
|
const [injected, injErr] = pcall(() => injectStopListener());
|
|
235
233
|
if (!injected) {
|
|
236
|
-
warn(`[
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
|
|
240
|
-
// right before cloning so the play DMs get the current source. They also
|
|
241
|
-
// live permanently in the edit DM (installed on connect) so manually-started
|
|
242
|
-
// playtests get them too; here we just ensure they're fresh.
|
|
243
|
-
const bridgeInstall = installBridges();
|
|
244
|
-
if (!bridgeInstall.installed) {
|
|
245
|
-
warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
|
|
234
|
+
warn(`[robloxstudio-mcp] Failed to inject stop listener: ${injErr}`);
|
|
246
235
|
}
|
|
247
236
|
|
|
248
237
|
task.spawn(() => {
|
|
@@ -266,36 +255,26 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
266
255
|
testRunning = false;
|
|
267
256
|
|
|
268
257
|
cleanupStopListener();
|
|
269
|
-
// Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
|
|
270
|
-
// clean up here, so the next manual playtest still gets them.
|
|
271
|
-
ensureBridgesInstalled();
|
|
272
258
|
});
|
|
273
259
|
|
|
274
260
|
const response: Record<string, unknown> = {
|
|
275
261
|
success: true,
|
|
276
262
|
message: `Playtest started in ${mode} mode.`,
|
|
277
263
|
};
|
|
278
|
-
// Only mention eval bridges when they failed — when they're fine, the
|
|
279
|
-
// detail is noise. eval_server_runtime / eval_client_runtime will surface
|
|
280
|
-
// their own clear errors if the caller tries to use them after a failed
|
|
281
|
-
// install.
|
|
282
|
-
if (!bridgeInstall.installed) {
|
|
283
|
-
response.evalBridgesError = bridgeInstall.error;
|
|
284
|
-
}
|
|
285
264
|
|
|
286
265
|
return response;
|
|
287
266
|
}
|
|
288
267
|
|
|
289
268
|
function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
290
|
-
// Signal the play-server DM's StopPlayMonitor via plugin:SetSetting
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (!StopPlayMonitor.requestStop()) {
|
|
269
|
+
// Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
|
|
270
|
+
// The monitor acknowledges with the matching request id only after its
|
|
271
|
+
// StudioTestService:EndTest call returns from pcall.
|
|
272
|
+
const stopRequest = StopPlayMonitor.requestStop();
|
|
273
|
+
if (!stopRequest.ok || stopRequest.requestId === undefined) {
|
|
296
274
|
return { error: "Plugin not ready. Try again in a moment." };
|
|
297
275
|
}
|
|
298
|
-
|
|
276
|
+
const consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId);
|
|
277
|
+
if (!consumption.ok) {
|
|
299
278
|
// Two distinct failure modes collapse here, distinguished by whether
|
|
300
279
|
// THIS edit DM has a playtest tracked:
|
|
301
280
|
//
|
|
@@ -307,19 +286,24 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
|
307
286
|
// from the caller's perspective — playtest may actually have ended).
|
|
308
287
|
// Tell the caller it's a timing issue and they can retry.
|
|
309
288
|
//
|
|
310
|
-
// Either way clean up the pending
|
|
289
|
+
// Either way clean up the pending request so a future playtest's monitor
|
|
311
290
|
// doesn't fire EndTest on startup against a stale signal.
|
|
312
|
-
StopPlayMonitor.clearPending();
|
|
291
|
+
StopPlayMonitor.clearPending(stopRequest.requestId);
|
|
313
292
|
if (testRunning) {
|
|
314
293
|
return {
|
|
315
294
|
error:
|
|
316
|
-
"Playtest stop signal
|
|
295
|
+
"Playtest stop signal failed or was not acknowledged. " +
|
|
317
296
|
"The playtest may have ended anyway; check get_connected_instances.",
|
|
297
|
+
detail: consumption.error,
|
|
318
298
|
};
|
|
319
299
|
}
|
|
320
|
-
|
|
300
|
+
if (consumption.consumed) {
|
|
301
|
+
return { error: "Playtest stop request reached the play server, but EndTest failed.", detail: consumption.error };
|
|
302
|
+
}
|
|
303
|
+
return { error: "No active playtest to stop.", detail: consumption.error };
|
|
321
304
|
}
|
|
322
|
-
|
|
305
|
+
StopPlayMonitor.clearPending(stopRequest.requestId);
|
|
306
|
+
// Request was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
323
307
|
// startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
324
308
|
// true until that yield completes and the post-block runs. Wait so
|
|
325
309
|
// back-to-back stop -> start sequences don't race against the prior
|
|
@@ -370,11 +354,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
|
|
|
370
354
|
const testArgs = requestData.testArgs !== undefined ? requestData.testArgs : {};
|
|
371
355
|
const testId = HttpService.GenerateGUID(false);
|
|
372
356
|
|
|
373
|
-
const bridgeInstall = installBridges();
|
|
374
|
-
if (!bridgeInstall.installed) {
|
|
375
|
-
warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
357
|
multiplayerState = {
|
|
379
358
|
phase: "starting",
|
|
380
359
|
testId,
|
|
@@ -400,8 +379,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
|
|
|
400
379
|
multiplayerState.result = undefined;
|
|
401
380
|
multiplayerState.error = tostring(result);
|
|
402
381
|
}
|
|
403
|
-
|
|
404
|
-
ensureBridgesInstalled();
|
|
405
382
|
});
|
|
406
383
|
|
|
407
384
|
const response: Record<string, unknown> = {
|
|
@@ -412,9 +389,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
|
|
|
412
389
|
numPlayers,
|
|
413
390
|
testArgs,
|
|
414
391
|
};
|
|
415
|
-
if (!bridgeInstall.installed) {
|
|
416
|
-
response.evalBridgesError = bridgeInstall.error;
|
|
417
|
-
}
|
|
418
392
|
return response;
|
|
419
393
|
}
|
|
420
394
|
|
|
@@ -2,6 +2,8 @@ import State from "../modules/State";
|
|
|
2
2
|
import UI from "../modules/UI";
|
|
3
3
|
import Communication from "../modules/Communication";
|
|
4
4
|
import ClientBroker from "../modules/ClientBroker";
|
|
5
|
+
import ServerUrlSettings from "../modules/ServerUrlSettings";
|
|
6
|
+
import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
|
|
5
7
|
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
6
8
|
import StopPlayMonitor from "../modules/StopPlayMonitor";
|
|
7
9
|
import * as RenderMonitor from "../modules/RenderMonitor";
|
|
@@ -20,6 +22,7 @@ RuntimeLogBuffer.install();
|
|
|
20
22
|
// edit DM (write the flag) and the play-server DM (read+act on the flag) can
|
|
21
23
|
// access plugin:SetSetting/GetSetting.
|
|
22
24
|
StopPlayMonitor.init(plugin);
|
|
25
|
+
ServerUrlSettings.init(plugin);
|
|
23
26
|
|
|
24
27
|
UI.init(plugin);
|
|
25
28
|
const elements = UI.getElements();
|
|
@@ -28,10 +31,24 @@ const elements = UI.getElements();
|
|
|
28
31
|
const ICON_DISCONNECTED = "rbxassetid://__BUTTON_ICON_DISCONNECTED__";
|
|
29
32
|
const ICON_CONNECTING = "rbxassetid://__BUTTON_ICON_CONNECTING__";
|
|
30
33
|
const ICON_CONNECTED = "rbxassetid://__BUTTON_ICON_CONNECTED__";
|
|
34
|
+
const TOOLBAR_REGISTRATION_DELAY_SECONDS = 1;
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
let toolbarButtonRegistered = false;
|
|
37
|
+
|
|
38
|
+
function registerToolbarButton() {
|
|
39
|
+
if (toolbarButtonRegistered) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
toolbarButtonRegistered = true;
|
|
43
|
+
|
|
44
|
+
const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
|
|
45
|
+
const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", ICON_DISCONNECTED);
|
|
46
|
+
UI.setToolbarButton(button, { disconnected: ICON_DISCONNECTED, connecting: ICON_CONNECTING, connected: ICON_CONNECTED });
|
|
47
|
+
|
|
48
|
+
button.Click.Connect(() => {
|
|
49
|
+
elements.screenGui.Enabled = !elements.screenGui.Enabled;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
35
52
|
|
|
36
53
|
|
|
37
54
|
elements.connectButton.Activated.Connect(() => {
|
|
@@ -44,11 +61,6 @@ elements.connectButton.Activated.Connect(() => {
|
|
|
44
61
|
});
|
|
45
62
|
|
|
46
63
|
|
|
47
|
-
button.Click.Connect(() => {
|
|
48
|
-
elements.screenGui.Enabled = !elements.screenGui.Enabled;
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
|
|
52
64
|
plugin.Unloading.Connect(() => {
|
|
53
65
|
Communication.deactivateAll();
|
|
54
66
|
});
|
|
@@ -56,6 +68,7 @@ plugin.Unloading.Connect(() => {
|
|
|
56
68
|
|
|
57
69
|
UI.updateUIState();
|
|
58
70
|
Communication.checkForUpdates();
|
|
71
|
+
task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton);
|
|
59
72
|
|
|
60
73
|
// Auto-activate per peer. The boshyxd plugin only registers with MCP when the
|
|
61
74
|
// user clicks Connect in its UI, but that UI is invisible in play DMs - so
|
|
@@ -63,15 +76,32 @@ Communication.checkForUpdates();
|
|
|
63
76
|
// short delay so the UI/State have a chance to initialize first.
|
|
64
77
|
task.delay(2, () => {
|
|
65
78
|
const role = ClientBroker.forkRole();
|
|
79
|
+
if (role === "edit") {
|
|
80
|
+
cleanupLegacyEditBridges();
|
|
81
|
+
} else {
|
|
82
|
+
const result = ensureRuntimeBridgeInstalled();
|
|
83
|
+
if (!result.installed) {
|
|
84
|
+
warn(`[robloxstudio-mcp] Runtime eval bridge install failed: ${result.error}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
66
87
|
if (role === "edit" || role === "server") {
|
|
67
88
|
pcall(() => {
|
|
68
89
|
const idx = State.getActiveTabIndex();
|
|
69
90
|
const conn = State.getConnection(idx);
|
|
70
91
|
if (conn && !conn.isActive) {
|
|
92
|
+
if (role === "server") {
|
|
93
|
+
const inheritedServerUrl = ServerUrlSettings.readServerUrl() ?? ClientBroker.DEFAULT_MCP_URL;
|
|
94
|
+
conn.serverUrl = inheritedServerUrl;
|
|
95
|
+
elements.urlInput.Text = inheritedServerUrl;
|
|
96
|
+
const [portStr] = conn.serverUrl.match(":(%d+)$");
|
|
97
|
+
if (portStr) conn.port = tonumber(portStr) ?? conn.port;
|
|
98
|
+
ClientBroker.setServerUrl(inheritedServerUrl);
|
|
99
|
+
}
|
|
71
100
|
// Defensive default: in invisible play-DM UIs, the input field
|
|
72
101
|
// may not be populated by the time we activate.
|
|
73
102
|
if (conn.serverUrl === undefined || conn.serverUrl === "") {
|
|
74
|
-
conn.serverUrl = ClientBroker.
|
|
103
|
+
conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
|
|
104
|
+
elements.urlInput.Text = conn.serverUrl;
|
|
75
105
|
}
|
|
76
106
|
Communication.activatePlugin(idx);
|
|
77
107
|
}
|
|
@@ -80,8 +110,8 @@ task.delay(2, () => {
|
|
|
80
110
|
if (role === "server") {
|
|
81
111
|
ClientBroker.setupServerBroker();
|
|
82
112
|
// The play-server DM is the only one where StudioTestService:EndTest is
|
|
83
|
-
// legal, so the stop-play monitor lives here.
|
|
84
|
-
//
|
|
113
|
+
// legal, so the stop-play monitor lives here. It consumes tokenized
|
|
114
|
+
// stop requests from plugin settings and acknowledges EndTest results.
|
|
85
115
|
StopPlayMonitor.startMonitor();
|
|
86
116
|
} else if (role === "client") {
|
|
87
117
|
ClientBroker.setupClientBroker();
|