@chrrxs/robloxstudio-mcp 2.16.0 → 2.16.2
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 +210 -37
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +556 -141
- package/studio-plugin/MCPPlugin.rbxmx +556 -141
- package/studio-plugin/src/modules/ClientBroker.ts +32 -6
- package/studio-plugin/src/modules/Communication.ts +81 -41
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +61 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +184 -45
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +18 -13
- package/studio-plugin/src/server/index.server.ts +15 -4
|
@@ -1,43 +1,69 @@
|
|
|
1
1
|
// Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
|
|
2
2
|
// per-instance setting key so the same Studio process can host playtests
|
|
3
3
|
// for multiple places without one place's stop_playtest yanking another's.
|
|
4
|
+
// During publish-after-connect, both "anon:<uuid>" and "place:<PlaceId>"
|
|
5
|
+
// can refer to the same Studio place, so stop requests are mirrored across
|
|
6
|
+
// both keys while the monitor waits for a matching result on either key.
|
|
4
7
|
//
|
|
5
8
|
// `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
6
9
|
// shared across every DataModel the plugin runs in (edit DMs, play-server
|
|
7
10
|
// DMs, play-client DMs). For each connected place we use a dedicated key
|
|
8
|
-
// "MCP_STOP_PLAY_<instanceId>" as a
|
|
11
|
+
// "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
|
|
9
12
|
//
|
|
10
|
-
// * The edit DM's
|
|
13
|
+
// * The edit DM's handler writes a tokenized stop request into its own key
|
|
11
14
|
// (computed from its placeId / ServerStorage anon UUID).
|
|
12
15
|
// * 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
|
-
//
|
|
16
|
+
// instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
|
|
17
|
+
// and writes a matching result token. Play-server DMs for other places
|
|
18
|
+
// never touch this key.
|
|
19
|
+
// * The edit DM waits up to ~8s for its result token, confirming a matching
|
|
20
|
+
// play-server actually consumed the request.
|
|
18
21
|
//
|
|
19
22
|
// Earlier versions used a single shared boolean flag, which let any
|
|
20
23
|
// play-server DM in the same Studio process consume any place's stop
|
|
21
24
|
// request — silently yanking teammates' playtests. The per-key scoping
|
|
22
25
|
// below is the fix.
|
|
23
26
|
|
|
24
|
-
import { HttpService, ServerStorage } from "@rbxts/services";
|
|
27
|
+
import { HttpService, RunService, ServerStorage } from "@rbxts/services";
|
|
25
28
|
|
|
26
29
|
const StudioTestService = game.GetService("StudioTestService");
|
|
27
30
|
|
|
28
31
|
const SETTING_KEY_PREFIX = "MCP_STOP_PLAY_";
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const POLL_INTERVAL_SEC =
|
|
32
|
+
// Keep this conservative. plugin:GetSetting is backed by Studio's plugin
|
|
33
|
+
// settings store, and this monitor runs during every play session, including
|
|
34
|
+
// manually-started Play. The official reference implementation polls at 1s.
|
|
35
|
+
const POLL_INTERVAL_SEC = 1;
|
|
33
36
|
// Total time we wait for the matching play-server DM to consume the
|
|
34
37
|
// signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
|
|
35
38
|
// StudioTestService:EndTest teardown (several seconds on heavier places).
|
|
36
|
-
// 8s is
|
|
39
|
+
// 8s is intentionally shorter than the MCP request timeout but long enough
|
|
40
|
+
// for the 1s monitor cadence plus ordinary Studio teardown latency.
|
|
37
41
|
const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
|
|
38
42
|
const WAIT_POLL_SEC = 0.1;
|
|
43
|
+
const REQUEST_TTL_SEC = 12.0;
|
|
39
44
|
|
|
40
45
|
let pluginRef: Plugin | undefined;
|
|
46
|
+
let endTestIssued = false;
|
|
47
|
+
|
|
48
|
+
interface StopPayload {
|
|
49
|
+
kind?: string;
|
|
50
|
+
id?: string;
|
|
51
|
+
requestedAt?: number;
|
|
52
|
+
consumedAt?: number;
|
|
53
|
+
ok?: boolean;
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface StopRequestResult {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
requestId?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface StopConsumptionResult {
|
|
63
|
+
ok: boolean;
|
|
64
|
+
consumed: boolean;
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
41
67
|
|
|
42
68
|
function init(p: Plugin): void {
|
|
43
69
|
pluginRef = p;
|
|
@@ -48,70 +74,183 @@ function init(p: Plugin): void {
|
|
|
48
74
|
// agree on the place identifier (published places: placeId; unpublished:
|
|
49
75
|
// UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
50
76
|
// into the play DM).
|
|
51
|
-
function
|
|
77
|
+
function addUnique(values: string[], value: string): void {
|
|
78
|
+
if (!values.includes(value)) {
|
|
79
|
+
values.push(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function computeInstanceIds(): string[] {
|
|
84
|
+
const ids: string[] = [];
|
|
52
85
|
if (game.PlaceId !== 0) {
|
|
53
|
-
|
|
86
|
+
addUnique(ids, `place:${tostring(game.PlaceId)}`);
|
|
54
87
|
}
|
|
55
88
|
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
56
89
|
if (typeIs(existing, "string") && existing !== "") {
|
|
57
|
-
|
|
90
|
+
addUnique(ids, `anon:${existing as string}`);
|
|
91
|
+
} else if (game.PlaceId === 0) {
|
|
92
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
93
|
+
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
94
|
+
addUnique(ids, `anon:${fresh}`);
|
|
58
95
|
}
|
|
59
|
-
|
|
60
|
-
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
61
|
-
return `anon:${fresh}`;
|
|
96
|
+
return ids;
|
|
62
97
|
}
|
|
63
98
|
|
|
64
99
|
function settingKey(instanceId: string): string {
|
|
65
100
|
return SETTING_KEY_PREFIX + instanceId;
|
|
66
101
|
}
|
|
67
102
|
|
|
103
|
+
function settingKeys(): string[] {
|
|
104
|
+
return computeInstanceIds().map((instanceId) => settingKey(instanceId));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readSetting(key: string): unknown {
|
|
108
|
+
if (!pluginRef) return undefined;
|
|
109
|
+
const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
|
|
110
|
+
return ok ? value : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeSetting(key: string, value: unknown): boolean {
|
|
114
|
+
if (!pluginRef) return false;
|
|
115
|
+
const [ok] = pcall(() => pluginRef!.SetSetting(key, value));
|
|
116
|
+
return ok;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function decodePayload(value: unknown): StopPayload | undefined {
|
|
120
|
+
let decoded = value;
|
|
121
|
+
if (typeIs(value, "string")) {
|
|
122
|
+
const [ok, result] = pcall(() => HttpService.JSONDecode(value as string));
|
|
123
|
+
if (!ok) return undefined;
|
|
124
|
+
decoded = result;
|
|
125
|
+
}
|
|
126
|
+
if (!typeIs(decoded, "table")) return undefined;
|
|
127
|
+
const payload = decoded as StopPayload;
|
|
128
|
+
if (!typeIs(payload.kind, "string") || !typeIs(payload.id, "string")) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return payload;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writePayload(key: string, payload: StopPayload): boolean {
|
|
135
|
+
const [encodedOk, encoded] = pcall(() => HttpService.JSONEncode(payload));
|
|
136
|
+
if (!encodedOk || !typeIs(encoded, "string")) return false;
|
|
137
|
+
return writeSetting(key, encoded);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeResult(key: string, request: StopPayload, ok: boolean, errText?: string): void {
|
|
141
|
+
writePayload(key, {
|
|
142
|
+
kind: "result",
|
|
143
|
+
id: request.id,
|
|
144
|
+
requestedAt: request.requestedAt,
|
|
145
|
+
consumedAt: tick(),
|
|
146
|
+
ok,
|
|
147
|
+
error: errText,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function handleStopRequest(key: string, request: StopPayload): void {
|
|
152
|
+
if (request.kind !== "request" || !typeIs(request.id, "string")) return;
|
|
153
|
+
if (!typeIs(request.requestedAt, "number")) {
|
|
154
|
+
writeSetting(key, false);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const age = tick() - request.requestedAt;
|
|
159
|
+
if (age < -5 || age > REQUEST_TTL_SEC) {
|
|
160
|
+
writeSetting(key, false);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (endTestIssued) {
|
|
165
|
+
writeResult(key, request, true);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!RunService.IsRunning() || !RunService.IsServer()) {
|
|
170
|
+
writeResult(key, request, false, "StopPlayMonitor is not running in the server DataModel.");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
endTestIssued = true;
|
|
175
|
+
const [endOk, endErr] = pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
|
|
176
|
+
writeResult(key, request, endOk, endOk ? undefined : tostring(endErr));
|
|
177
|
+
if (!endOk) {
|
|
178
|
+
endTestIssued = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
68
182
|
function startMonitor(): void {
|
|
69
183
|
if (!pluginRef) {
|
|
70
|
-
warn("[
|
|
184
|
+
warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping");
|
|
71
185
|
return;
|
|
72
186
|
}
|
|
73
|
-
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
187
|
task.spawn(() => {
|
|
79
188
|
while (true) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
189
|
+
for (const myKey of settingKeys()) {
|
|
190
|
+
const value = readSetting(myKey);
|
|
191
|
+
if (value === true) {
|
|
192
|
+
// Legacy boolean requests are ambiguous and may be stale from
|
|
193
|
+
// a prior crashed session. New stop requests use token payloads.
|
|
194
|
+
writeSetting(myKey, false);
|
|
195
|
+
} else {
|
|
196
|
+
const payload = decodePayload(value);
|
|
197
|
+
if (payload) {
|
|
198
|
+
handleStopRequest(myKey, payload);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
86
201
|
}
|
|
87
202
|
task.wait(POLL_INTERVAL_SEC);
|
|
88
203
|
}
|
|
89
204
|
});
|
|
90
205
|
}
|
|
91
206
|
|
|
92
|
-
function requestStop():
|
|
93
|
-
if (!pluginRef) return false;
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
207
|
+
function requestStop(): StopRequestResult {
|
|
208
|
+
if (!pluginRef) return { ok: false };
|
|
209
|
+
const requestId = HttpService.GenerateGUID(false);
|
|
210
|
+
const payload: StopPayload = {
|
|
211
|
+
kind: "request",
|
|
212
|
+
id: requestId,
|
|
213
|
+
requestedAt: tick(),
|
|
214
|
+
};
|
|
215
|
+
let ok = false;
|
|
216
|
+
for (const myKey of settingKeys()) {
|
|
217
|
+
ok = writePayload(myKey, payload) || ok;
|
|
218
|
+
}
|
|
219
|
+
return { ok, requestId: ok ? requestId : undefined };
|
|
97
220
|
}
|
|
98
221
|
|
|
99
|
-
function waitForConsumption():
|
|
100
|
-
if (!pluginRef) return false;
|
|
101
|
-
const myKey = settingKey(computeInstanceId());
|
|
222
|
+
function waitForConsumption(requestId: string): StopConsumptionResult {
|
|
223
|
+
if (!pluginRef) return { ok: false, consumed: false, error: "Plugin reference is not initialized." };
|
|
102
224
|
const start = tick();
|
|
103
225
|
while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
|
|
104
|
-
const
|
|
105
|
-
|
|
226
|
+
for (const myKey of settingKeys()) {
|
|
227
|
+
const payload = decodePayload(readSetting(myKey));
|
|
228
|
+
if (payload && payload.kind === "result" && payload.id === requestId) {
|
|
229
|
+
return {
|
|
230
|
+
ok: payload.ok === true,
|
|
231
|
+
consumed: true,
|
|
232
|
+
error: payload.error,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
106
236
|
task.wait(WAIT_POLL_SEC);
|
|
107
237
|
}
|
|
108
|
-
return
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
consumed: false,
|
|
241
|
+
error: "Timed out waiting for the play-server DataModel to acknowledge stop_playtest.",
|
|
242
|
+
};
|
|
109
243
|
}
|
|
110
244
|
|
|
111
|
-
function clearPending(): void {
|
|
245
|
+
function clearPending(requestId?: string): void {
|
|
112
246
|
if (!pluginRef) return;
|
|
113
|
-
const myKey
|
|
114
|
-
|
|
247
|
+
for (const myKey of settingKeys()) {
|
|
248
|
+
if (requestId !== undefined) {
|
|
249
|
+
const payload = decodePayload(readSetting(myKey));
|
|
250
|
+
if (payload && payload.id !== requestId) continue;
|
|
251
|
+
}
|
|
252
|
+
writeSetting(myKey, false);
|
|
253
|
+
}
|
|
115
254
|
}
|
|
116
255
|
|
|
117
256
|
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();
|