@chrrxs/robloxstudio-mcp 2.16.0 → 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 +56 -15
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +446 -107
- package/studio-plugin/MCPPlugin.rbxmx +446 -107
- package/studio-plugin/src/modules/ClientBroker.ts +32 -6
- package/studio-plugin/src/modules/Communication.ts +40 -22
- 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/TestHandlers.ts +18 -13
- package/studio-plugin/src/server/index.server.ts +15 -4
|
@@ -7,6 +7,7 @@ import InputHandlers from "./handlers/InputHandlers";
|
|
|
7
7
|
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
8
8
|
import LuauExec from "./LuauExec";
|
|
9
9
|
import State from "./State";
|
|
10
|
+
import HttpDiagnostics from "./HttpDiagnostics";
|
|
10
11
|
|
|
11
12
|
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
12
13
|
CanLeaveTest(): boolean;
|
|
@@ -64,7 +65,8 @@ function resolvePlaceName(): string {
|
|
|
64
65
|
// is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
|
|
65
66
|
// signaling, which works regardless of MCP server state.)
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const DEFAULT_MCP_URL = "http://localhost:58741";
|
|
69
|
+
let mcpUrl = DEFAULT_MCP_URL;
|
|
68
70
|
const BROKER_NAME = "__MCPClientBroker";
|
|
69
71
|
const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
|
|
70
72
|
|
|
@@ -152,7 +154,7 @@ function forkRole(): "edit" | "server" | "client" {
|
|
|
152
154
|
function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
153
155
|
return pcall(() =>
|
|
154
156
|
HttpService.RequestAsync({
|
|
155
|
-
Url: `${
|
|
157
|
+
Url: `${mcpUrl}${endpoint}`,
|
|
156
158
|
Method: "POST",
|
|
157
159
|
Headers: { "Content-Type": "application/json" },
|
|
158
160
|
Body: HttpService.JSONEncode(body),
|
|
@@ -160,6 +162,20 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
|
160
162
|
);
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
function formatPostJsonFailure(endpoint: string, ok: boolean, res: unknown): string {
|
|
166
|
+
return HttpDiagnostics.formatRequestFailure(`${mcpUrl}${endpoint}`, ok, res);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setServerUrl(serverUrl: string | undefined): void {
|
|
170
|
+
if (serverUrl !== undefined && serverUrl !== "") {
|
|
171
|
+
mcpUrl = serverUrl;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getServerUrl(): string {
|
|
176
|
+
return mcpUrl;
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
function handleExecuteLuau(data: Record<string, unknown> | undefined) {
|
|
164
180
|
const code = data && (data.code as string | undefined);
|
|
165
181
|
if (typeIs(code, "string") === false || code === "") {
|
|
@@ -277,13 +293,14 @@ function setupClientBroker() {
|
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
const proxyByPlayer = new Map<Player, ProxyEntry>();
|
|
296
|
+
const proxyRegisterFailuresByPlayer = new Set<Player>();
|
|
280
297
|
let serverBrokerStarted = false;
|
|
281
298
|
|
|
282
299
|
function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
283
300
|
while (player.Parent !== undefined && proxyByPlayer.has(player)) {
|
|
284
301
|
const [ok, res] = pcall(() =>
|
|
285
302
|
HttpService.RequestAsync({
|
|
286
|
-
Url: `${
|
|
303
|
+
Url: `${mcpUrl}/poll?pluginSessionId=${proxyId}`,
|
|
287
304
|
Method: "GET",
|
|
288
305
|
Headers: { "Content-Type": "application/json" },
|
|
289
306
|
}),
|
|
@@ -341,18 +358,23 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
341
358
|
pluginVariant: State.PLUGIN_VARIANT,
|
|
342
359
|
});
|
|
343
360
|
if (!ok || !res || !res.Success) {
|
|
344
|
-
|
|
361
|
+
proxyRegisterFailuresByPlayer.add(player);
|
|
362
|
+
warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}: ${formatPostJsonFailure("/ready", ok, res)}`);
|
|
345
363
|
return;
|
|
346
364
|
}
|
|
347
365
|
const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
|
|
348
366
|
const assigned = body.assignedRole ?? "client";
|
|
349
367
|
proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
|
|
368
|
+
if (proxyRegisterFailuresByPlayer.has(player)) {
|
|
369
|
+
proxyRegisterFailuresByPlayer.delete(player);
|
|
370
|
+
print(`[robloxstudio-mcp] proxy registered for ${player.Name} as ${assigned} via ${mcpUrl}`);
|
|
371
|
+
}
|
|
350
372
|
task.spawn(pollProxy, proxyId, player, rf);
|
|
351
373
|
}
|
|
352
374
|
|
|
353
375
|
// (Removed: startEditProxyLoop. The play-server DM no longer registers an
|
|
354
376
|
// "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
|
|
355
|
-
// plugin:SetSetting
|
|
377
|
+
// plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
|
|
356
378
|
// which doesn't depend on MCP server state or peer registration at all.)
|
|
357
379
|
|
|
358
380
|
function setupServerBroker() {
|
|
@@ -377,6 +399,7 @@ function setupServerBroker() {
|
|
|
377
399
|
const entry = proxyByPlayer.get(p);
|
|
378
400
|
if (entry) {
|
|
379
401
|
proxyByPlayer.delete(p);
|
|
402
|
+
proxyRegisterFailuresByPlayer.delete(p);
|
|
380
403
|
postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
|
|
381
404
|
}
|
|
382
405
|
});
|
|
@@ -389,7 +412,10 @@ function setupServerBroker() {
|
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
export = {
|
|
392
|
-
MCP_URL,
|
|
415
|
+
MCP_URL: DEFAULT_MCP_URL,
|
|
416
|
+
DEFAULT_MCP_URL,
|
|
417
|
+
getServerUrl,
|
|
418
|
+
setServerUrl,
|
|
393
419
|
forkRole,
|
|
394
420
|
setupClientBroker,
|
|
395
421
|
setupServerBroker,
|
|
@@ -18,6 +18,8 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
|
18
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
19
19
|
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
20
20
|
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
21
|
+
import ServerUrlSettings from "./ServerUrlSettings";
|
|
22
|
+
import HttpDiagnostics from "./HttpDiagnostics";
|
|
21
23
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
22
24
|
|
|
23
25
|
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
@@ -50,6 +52,7 @@ let assignedRole: string | undefined;
|
|
|
50
52
|
let duplicateInstanceRole = false;
|
|
51
53
|
let hasVersionMismatch = false;
|
|
52
54
|
let lastVersionMismatchWarningKey: string | undefined;
|
|
55
|
+
const readyFailureLogKeys = new Set<string>();
|
|
53
56
|
|
|
54
57
|
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
55
58
|
// /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
@@ -249,30 +252,44 @@ function sendReady(conn: Connection): void {
|
|
|
249
252
|
}),
|
|
250
253
|
});
|
|
251
254
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (State.getActiveTabIndex() === 0) {
|
|
259
|
-
ui.statusLabel.Text = "Duplicate instance";
|
|
260
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
261
|
-
ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role";
|
|
262
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
263
|
-
}
|
|
264
|
-
warn(
|
|
265
|
-
`[MCPPlugin] Another Studio is already connected as (${instanceId}, ${detectRole()}). Close the other Studio window or this one.`,
|
|
266
|
-
);
|
|
255
|
+
const readyUrl = `${conn.serverUrl}/ready`;
|
|
256
|
+
const readyRole = detectRole();
|
|
257
|
+
const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
|
|
258
|
+
if (!readyOk) {
|
|
259
|
+
readyFailureLogKeys.add(readyLogKey);
|
|
260
|
+
warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
|
|
267
261
|
return;
|
|
268
262
|
}
|
|
269
|
-
if (readyResult.Success) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
263
|
+
if (!readyResult.Success) {
|
|
264
|
+
const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
|
|
265
|
+
readyFailureLogKeys.add(readyLogKey);
|
|
266
|
+
// 409 = duplicate_instance_role. Surface in UI and stop polling.
|
|
267
|
+
if (readyResult.StatusCode === 409) {
|
|
268
|
+
duplicateInstanceRole = true;
|
|
269
|
+
conn.isActive = false;
|
|
270
|
+
const ui = UI.getElements();
|
|
271
|
+
if (State.getActiveTabIndex() === 0) {
|
|
272
|
+
ui.statusLabel.Text = "Duplicate instance";
|
|
273
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
274
|
+
ui.detailStatusLabel.Text = reason;
|
|
275
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
276
|
+
}
|
|
277
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
278
|
+
return;
|
|
275
279
|
}
|
|
280
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const [parseOk, readyData] = pcall(
|
|
284
|
+
() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
|
|
285
|
+
);
|
|
286
|
+
if (parseOk && readyData.assignedRole) {
|
|
287
|
+
assignedRole = readyData.assignedRole;
|
|
288
|
+
}
|
|
289
|
+
const connectedRole = assignedRole ?? detectRole();
|
|
290
|
+
if (readyFailureLogKeys.has(readyLogKey)) {
|
|
291
|
+
readyFailureLogKeys.delete(readyLogKey);
|
|
292
|
+
print(`[robloxstudio-mcp] /ready connected for ${instanceId}/${connectedRole} via ${conn.serverUrl}`);
|
|
276
293
|
}
|
|
277
294
|
});
|
|
278
295
|
}
|
|
@@ -313,7 +330,7 @@ function pollForRequests(connIndex: number) {
|
|
|
313
330
|
const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
|
|
314
331
|
if (lastVersionMismatchWarningKey !== warningKey) {
|
|
315
332
|
lastVersionMismatchWarningKey = warningKey;
|
|
316
|
-
warn(`[
|
|
333
|
+
warn(`[robloxstudio-mcp] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
|
|
317
334
|
}
|
|
318
335
|
UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
|
|
319
336
|
} else if (hasVersionMismatch) {
|
|
@@ -470,6 +487,7 @@ function activatePlugin(connIndex?: number) {
|
|
|
470
487
|
UI.updateTabLabel(idx);
|
|
471
488
|
UI.updateUIState();
|
|
472
489
|
}
|
|
490
|
+
ServerUrlSettings.rememberServerUrl(conn.serverUrl);
|
|
473
491
|
UI.updateTabDot(idx);
|
|
474
492
|
|
|
475
493
|
if (!conn.heartbeatConnection) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { HttpService } from "@rbxts/services";
|
|
2
|
+
|
|
3
|
+
interface FailureBody {
|
|
4
|
+
error?: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
missingFields?: unknown;
|
|
7
|
+
request?: unknown;
|
|
8
|
+
existing?: unknown;
|
|
9
|
+
details?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodeForLog(value: unknown): string {
|
|
13
|
+
const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
|
|
14
|
+
return ok ? encoded : tostring(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatBody(body: string): string {
|
|
18
|
+
if (body === "") return "";
|
|
19
|
+
const [ok, decoded] = pcall(() => HttpService.JSONDecode(body));
|
|
20
|
+
if (ok && typeIs(decoded, "table")) {
|
|
21
|
+
const data = decoded as FailureBody;
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
if (typeIs(data.error, "string") && data.error !== "") parts.push(`error=${data.error}`);
|
|
24
|
+
if (typeIs(data.message, "string") && data.message !== "") parts.push(`message=${data.message}`);
|
|
25
|
+
if (data.missingFields !== undefined) parts.push(`missingFields=${encodeForLog(data.missingFields)}`);
|
|
26
|
+
if (data.request !== undefined) parts.push(`request=${encodeForLog(data.request)}`);
|
|
27
|
+
if (data.existing !== undefined) parts.push(`existing=${encodeForLog(data.existing)}`);
|
|
28
|
+
if (data.details !== undefined) parts.push(`details=${encodeForLog(data.details)}`);
|
|
29
|
+
if (parts.size() > 0) return parts.join(" ");
|
|
30
|
+
}
|
|
31
|
+
return `body=${body}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatRequestFailure(url: string, ok: boolean, res: unknown): string {
|
|
35
|
+
if (!ok) {
|
|
36
|
+
return `RequestAsync threw for ${url}: ${tostring(res)}`;
|
|
37
|
+
}
|
|
38
|
+
if (res === undefined) {
|
|
39
|
+
return `RequestAsync returned no response for ${url}`;
|
|
40
|
+
}
|
|
41
|
+
const response = res as RequestAsyncResponse;
|
|
42
|
+
const statusMessage = response.StatusMessage !== "" ? ` ${response.StatusMessage}` : "";
|
|
43
|
+
const body = formatBody(response.Body);
|
|
44
|
+
const suffix = body !== "" ? `: ${body}` : "";
|
|
45
|
+
return `HTTP ${response.StatusCode}${statusMessage} from ${url}${suffix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export = {
|
|
49
|
+
formatRequestFailure,
|
|
50
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { HttpService, ServerStorage } from "@rbxts/services";
|
|
2
|
+
|
|
3
|
+
const SETTING_KEY_PREFIX = "MCP_SERVER_URL_";
|
|
4
|
+
|
|
5
|
+
let pluginRef: Plugin | undefined;
|
|
6
|
+
|
|
7
|
+
function init(p: Plugin): void {
|
|
8
|
+
pluginRef = p;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function computeInstanceId(): string {
|
|
12
|
+
if (game.PlaceId !== 0) {
|
|
13
|
+
return `place:${tostring(game.PlaceId)}`;
|
|
14
|
+
}
|
|
15
|
+
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
16
|
+
if (typeIs(existing, "string") && existing !== "") {
|
|
17
|
+
return `anon:${existing as string}`;
|
|
18
|
+
}
|
|
19
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
20
|
+
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
21
|
+
return `anon:${fresh}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function settingKey(instanceId: string): string {
|
|
25
|
+
return SETTING_KEY_PREFIX + instanceId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rememberServerUrl(serverUrl: string): void {
|
|
29
|
+
if (!pluginRef || serverUrl === "") return;
|
|
30
|
+
const key = settingKey(computeInstanceId());
|
|
31
|
+
pcall(() => pluginRef!.SetSetting(key, serverUrl));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readServerUrl(): string | undefined {
|
|
35
|
+
if (!pluginRef) return undefined;
|
|
36
|
+
const key = settingKey(computeInstanceId());
|
|
37
|
+
const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
|
|
38
|
+
if (ok && typeIs(value, "string") && value !== "") {
|
|
39
|
+
return value as string;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export = {
|
|
45
|
+
init,
|
|
46
|
+
rememberServerUrl,
|
|
47
|
+
readServerUrl,
|
|
48
|
+
};
|
|
@@ -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 = {
|
|
@@ -231,7 +231,7 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
231
231
|
|
|
232
232
|
const [injected, injErr] = pcall(() => injectStopListener());
|
|
233
233
|
if (!injected) {
|
|
234
|
-
warn(`[
|
|
234
|
+
warn(`[robloxstudio-mcp] Failed to inject stop listener: ${injErr}`);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
task.spawn(() => {
|
|
@@ -266,15 +266,15 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
269
|
-
// Signal the play-server DM's StopPlayMonitor via plugin:SetSetting
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
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) {
|
|
275
274
|
return { error: "Plugin not ready. Try again in a moment." };
|
|
276
275
|
}
|
|
277
|
-
|
|
276
|
+
const consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId);
|
|
277
|
+
if (!consumption.ok) {
|
|
278
278
|
// Two distinct failure modes collapse here, distinguished by whether
|
|
279
279
|
// THIS edit DM has a playtest tracked:
|
|
280
280
|
//
|
|
@@ -286,19 +286,24 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
|
286
286
|
// from the caller's perspective — playtest may actually have ended).
|
|
287
287
|
// Tell the caller it's a timing issue and they can retry.
|
|
288
288
|
//
|
|
289
|
-
// Either way clean up the pending
|
|
289
|
+
// Either way clean up the pending request so a future playtest's monitor
|
|
290
290
|
// doesn't fire EndTest on startup against a stale signal.
|
|
291
|
-
StopPlayMonitor.clearPending();
|
|
291
|
+
StopPlayMonitor.clearPending(stopRequest.requestId);
|
|
292
292
|
if (testRunning) {
|
|
293
293
|
return {
|
|
294
294
|
error:
|
|
295
|
-
"Playtest stop signal
|
|
295
|
+
"Playtest stop signal failed or was not acknowledged. " +
|
|
296
296
|
"The playtest may have ended anyway; check get_connected_instances.",
|
|
297
|
+
detail: consumption.error,
|
|
297
298
|
};
|
|
298
299
|
}
|
|
299
|
-
|
|
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 };
|
|
300
304
|
}
|
|
301
|
-
|
|
305
|
+
StopPlayMonitor.clearPending(stopRequest.requestId);
|
|
306
|
+
// Request was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
302
307
|
// startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
303
308
|
// true until that yield completes and the post-block runs. Wait so
|
|
304
309
|
// back-to-back stop -> start sequences don't race against the prior
|
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
|
|
6
7
|
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
7
8
|
import StopPlayMonitor from "../modules/StopPlayMonitor";
|
|
@@ -21,6 +22,7 @@ RuntimeLogBuffer.install();
|
|
|
21
22
|
// edit DM (write the flag) and the play-server DM (read+act on the flag) can
|
|
22
23
|
// access plugin:SetSetting/GetSetting.
|
|
23
24
|
StopPlayMonitor.init(plugin);
|
|
25
|
+
ServerUrlSettings.init(plugin);
|
|
24
26
|
|
|
25
27
|
UI.init(plugin);
|
|
26
28
|
const elements = UI.getElements();
|
|
@@ -79,7 +81,7 @@ task.delay(2, () => {
|
|
|
79
81
|
} else {
|
|
80
82
|
const result = ensureRuntimeBridgeInstalled();
|
|
81
83
|
if (!result.installed) {
|
|
82
|
-
warn(`[
|
|
84
|
+
warn(`[robloxstudio-mcp] Runtime eval bridge install failed: ${result.error}`);
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
if (role === "edit" || role === "server") {
|
|
@@ -87,10 +89,19 @@ task.delay(2, () => {
|
|
|
87
89
|
const idx = State.getActiveTabIndex();
|
|
88
90
|
const conn = State.getConnection(idx);
|
|
89
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
|
+
}
|
|
90
100
|
// Defensive default: in invisible play-DM UIs, the input field
|
|
91
101
|
// may not be populated by the time we activate.
|
|
92
102
|
if (conn.serverUrl === undefined || conn.serverUrl === "") {
|
|
93
|
-
conn.serverUrl = ClientBroker.
|
|
103
|
+
conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
|
|
104
|
+
elements.urlInput.Text = conn.serverUrl;
|
|
94
105
|
}
|
|
95
106
|
Communication.activatePlugin(idx);
|
|
96
107
|
}
|
|
@@ -99,8 +110,8 @@ task.delay(2, () => {
|
|
|
99
110
|
if (role === "server") {
|
|
100
111
|
ClientBroker.setupServerBroker();
|
|
101
112
|
// The play-server DM is the only one where StudioTestService:EndTest is
|
|
102
|
-
// legal, so the stop-play monitor lives here.
|
|
103
|
-
//
|
|
113
|
+
// legal, so the stop-play monitor lives here. It consumes tokenized
|
|
114
|
+
// stop requests from plugin settings and acknowledges EndTest results.
|
|
104
115
|
StopPlayMonitor.startMonitor();
|
|
105
116
|
} else if (role === "client") {
|
|
106
117
|
ClientBroker.setupClientBroker();
|