@chrrxs/robloxstudio-mcp-inspector 2.16.1 → 2.16.3
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 +193 -24
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +201 -102
- package/studio-plugin/MCPPlugin.rbxmx +201 -102
- package/studio-plugin/src/modules/ClientBroker.ts +23 -10
- package/studio-plugin/src/modules/Communication.ts +49 -19
- package/studio-plugin/src/modules/ServerUrlSettings.ts +25 -12
- package/studio-plugin/src/modules/StopPlayMonitor.ts +60 -33
|
@@ -18,6 +18,7 @@ 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 ClientBroker from "./ClientBroker";
|
|
21
22
|
import ServerUrlSettings from "./ServerUrlSettings";
|
|
22
23
|
import HttpDiagnostics from "./HttpDiagnostics";
|
|
23
24
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
@@ -47,11 +48,11 @@ function computeInstanceId(): string {
|
|
|
47
48
|
return `anon:${fresh}`;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
const instanceId = computeInstanceId();
|
|
51
51
|
let assignedRole: string | undefined;
|
|
52
52
|
let duplicateInstanceRole = false;
|
|
53
53
|
let hasVersionMismatch = false;
|
|
54
54
|
let lastVersionMismatchWarningKey: string | undefined;
|
|
55
|
+
let lastReadyInstanceId: string | undefined;
|
|
55
56
|
const readyFailureLogKeys = new Set<string>();
|
|
56
57
|
|
|
57
58
|
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
@@ -59,9 +60,12 @@ const readyFailureLogKeys = new Set<string>();
|
|
|
59
60
|
// from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
60
61
|
// once per plugin load; the published name doesn't change mid-session.
|
|
61
62
|
let cachedPlaceName: string | undefined;
|
|
63
|
+
let cachedPlaceNamePlaceId: number | undefined;
|
|
62
64
|
|
|
63
65
|
function resolvePlaceName(): string {
|
|
64
|
-
if (cachedPlaceName !== undefined) return cachedPlaceName;
|
|
66
|
+
if (cachedPlaceName !== undefined && cachedPlaceNamePlaceId === game.PlaceId) return cachedPlaceName;
|
|
67
|
+
cachedPlaceName = undefined;
|
|
68
|
+
cachedPlaceNamePlaceId = game.PlaceId;
|
|
65
69
|
if (game.PlaceId === 0) {
|
|
66
70
|
cachedPlaceName = game.Name;
|
|
67
71
|
return cachedPlaceName;
|
|
@@ -86,6 +90,8 @@ function detectRole(): string {
|
|
|
86
90
|
return "client";
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
const initialRole = detectRole();
|
|
94
|
+
|
|
89
95
|
type Handler = (data: Record<string, unknown>) => unknown;
|
|
90
96
|
|
|
91
97
|
const routeMap: Record<string, Handler> = {
|
|
@@ -209,21 +215,31 @@ function getConnectionStatus(connIndex: number): string {
|
|
|
209
215
|
// restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
|
|
210
216
|
let lastReadyPostAt = 0;
|
|
211
217
|
|
|
212
|
-
// game.Name
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// get_connected_instances doesn't show a stale dataModelName forever. Set
|
|
216
|
-
// up once per plugin load — the connection passed in is whichever was
|
|
217
|
-
// active when activatePlugin was first called.
|
|
218
|
+
// game.Name and game.PlaceId can both settle after plugin load. PlaceId also
|
|
219
|
+
// changes when an unpublished file is published while MCP is already active.
|
|
220
|
+
// Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
|
|
218
221
|
let nameChangeConn: RBXScriptConnection | undefined;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
222
|
+
let placeIdChangeConn: RBXScriptConnection | undefined;
|
|
223
|
+
function ensureIdentityWatcher(conn: Connection): void {
|
|
224
|
+
if (!nameChangeConn) {
|
|
225
|
+
const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
|
|
226
|
+
if (okSig && signal) {
|
|
227
|
+
nameChangeConn = signal.Connect(() => {
|
|
228
|
+
// sendReady has its own 2s throttle, so rapid burst changes coalesce.
|
|
229
|
+
sendReady(conn);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!placeIdChangeConn) {
|
|
234
|
+
const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("PlaceId"));
|
|
235
|
+
if (okSig && signal) {
|
|
236
|
+
placeIdChangeConn = signal.Connect(() => {
|
|
237
|
+
cachedPlaceName = undefined;
|
|
238
|
+
cachedPlaceNamePlaceId = undefined;
|
|
239
|
+
sendReady(conn);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
227
243
|
}
|
|
228
244
|
|
|
229
245
|
function sendReady(conn: Connection): void {
|
|
@@ -231,6 +247,7 @@ function sendReady(conn: Connection): void {
|
|
|
231
247
|
const now = tick();
|
|
232
248
|
if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
|
|
233
249
|
lastReadyPostAt = now;
|
|
250
|
+
const instanceId = computeInstanceId();
|
|
234
251
|
task.spawn(() => {
|
|
235
252
|
const [readyOk, readyResult] = pcall(() => {
|
|
236
253
|
return HttpService.RequestAsync({
|
|
@@ -286,6 +303,9 @@ function sendReady(conn: Connection): void {
|
|
|
286
303
|
if (parseOk && readyData.assignedRole) {
|
|
287
304
|
assignedRole = readyData.assignedRole;
|
|
288
305
|
}
|
|
306
|
+
lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
|
|
307
|
+
? readyData.instanceId
|
|
308
|
+
: instanceId;
|
|
289
309
|
const connectedRole = assignedRole ?? detectRole();
|
|
290
310
|
if (readyFailureLogKeys.has(readyLogKey)) {
|
|
291
311
|
readyFailureLogKeys.delete(readyLogKey);
|
|
@@ -493,6 +513,17 @@ function activatePlugin(connIndex?: number) {
|
|
|
493
513
|
if (!conn.heartbeatConnection) {
|
|
494
514
|
conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
|
|
495
515
|
const now = tick();
|
|
516
|
+
if (initialRole === "server" && !RunService.IsRunning()) {
|
|
517
|
+
ClientBroker.disconnectAllProxies();
|
|
518
|
+
deactivatePlugin(idx);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const currentInstanceId = computeInstanceId();
|
|
522
|
+
if (lastReadyInstanceId !== undefined && currentInstanceId !== lastReadyInstanceId) {
|
|
523
|
+
cachedPlaceName = undefined;
|
|
524
|
+
cachedPlaceNamePlaceId = undefined;
|
|
525
|
+
sendReady(conn);
|
|
526
|
+
}
|
|
496
527
|
const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
|
|
497
528
|
if (now - conn.lastPoll > currentInterval) {
|
|
498
529
|
conn.lastPoll = now;
|
|
@@ -511,9 +542,8 @@ function activatePlugin(connIndex?: number) {
|
|
|
511
542
|
task.spawn(cleanupLegacyEditBridges);
|
|
512
543
|
}
|
|
513
544
|
|
|
514
|
-
// Watch
|
|
515
|
-
|
|
516
|
-
ensureNameChangeWatcher(conn);
|
|
545
|
+
// Watch identity fields so stale name or anon instance ids are refreshed.
|
|
546
|
+
ensureIdentityWatcher(conn);
|
|
517
547
|
}
|
|
518
548
|
|
|
519
549
|
function deactivatePlugin(connIndex?: number) {
|
|
@@ -8,17 +8,26 @@ function init(p: Plugin): void {
|
|
|
8
8
|
pluginRef = p;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function
|
|
11
|
+
function addUnique(values: string[], value: string): void {
|
|
12
|
+
if (!values.includes(value)) {
|
|
13
|
+
values.push(value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function computeInstanceIds(): string[] {
|
|
18
|
+
const ids: string[] = [];
|
|
12
19
|
if (game.PlaceId !== 0) {
|
|
13
|
-
|
|
20
|
+
addUnique(ids, `place:${tostring(game.PlaceId)}`);
|
|
14
21
|
}
|
|
15
22
|
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
16
23
|
if (typeIs(existing, "string") && existing !== "") {
|
|
17
|
-
|
|
24
|
+
addUnique(ids, `anon:${existing as string}`);
|
|
25
|
+
} else if (game.PlaceId === 0) {
|
|
26
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
27
|
+
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
28
|
+
addUnique(ids, `anon:${fresh}`);
|
|
18
29
|
}
|
|
19
|
-
|
|
20
|
-
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
21
|
-
return `anon:${fresh}`;
|
|
30
|
+
return ids;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
function settingKey(instanceId: string): string {
|
|
@@ -27,16 +36,20 @@ function settingKey(instanceId: string): string {
|
|
|
27
36
|
|
|
28
37
|
function rememberServerUrl(serverUrl: string): void {
|
|
29
38
|
if (!pluginRef || serverUrl === "") return;
|
|
30
|
-
const
|
|
31
|
-
|
|
39
|
+
for (const instanceId of computeInstanceIds()) {
|
|
40
|
+
const key = settingKey(instanceId);
|
|
41
|
+
pcall(() => pluginRef!.SetSetting(key, serverUrl));
|
|
42
|
+
}
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
function readServerUrl(): string | undefined {
|
|
35
46
|
if (!pluginRef) return undefined;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
for (const instanceId of computeInstanceIds()) {
|
|
48
|
+
const key = settingKey(instanceId);
|
|
49
|
+
const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
|
|
50
|
+
if (ok && typeIs(value, "string") && value !== "") {
|
|
51
|
+
return value as string;
|
|
52
|
+
}
|
|
40
53
|
}
|
|
41
54
|
return undefined;
|
|
42
55
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
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
|
|
@@ -71,23 +74,36 @@ function init(p: Plugin): void {
|
|
|
71
74
|
// agree on the place identifier (published places: placeId; unpublished:
|
|
72
75
|
// UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
73
76
|
// into the play DM).
|
|
74
|
-
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[] = [];
|
|
75
85
|
if (game.PlaceId !== 0) {
|
|
76
|
-
|
|
86
|
+
addUnique(ids, `place:${tostring(game.PlaceId)}`);
|
|
77
87
|
}
|
|
78
88
|
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
79
89
|
if (typeIs(existing, "string") && existing !== "") {
|
|
80
|
-
|
|
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}`);
|
|
81
95
|
}
|
|
82
|
-
|
|
83
|
-
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
84
|
-
return `anon:${fresh}`;
|
|
96
|
+
return ids;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
function settingKey(instanceId: string): string {
|
|
88
100
|
return SETTING_KEY_PREFIX + instanceId;
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
function settingKeys(): string[] {
|
|
104
|
+
return computeInstanceIds().map((instanceId) => settingKey(instanceId));
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
function readSetting(key: string): unknown {
|
|
92
108
|
if (!pluginRef) return undefined;
|
|
93
109
|
const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
|
|
@@ -146,7 +162,12 @@ function handleStopRequest(key: string, request: StopPayload): void {
|
|
|
146
162
|
}
|
|
147
163
|
|
|
148
164
|
if (endTestIssued) {
|
|
149
|
-
writeResult(
|
|
165
|
+
writeResult(
|
|
166
|
+
key,
|
|
167
|
+
request,
|
|
168
|
+
false,
|
|
169
|
+
"StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.",
|
|
170
|
+
);
|
|
150
171
|
return;
|
|
151
172
|
}
|
|
152
173
|
|
|
@@ -168,18 +189,19 @@ function startMonitor(): void {
|
|
|
168
189
|
warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping");
|
|
169
190
|
return;
|
|
170
191
|
}
|
|
171
|
-
const myKey = settingKey(computeInstanceId());
|
|
172
192
|
task.spawn(() => {
|
|
173
193
|
while (true) {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
194
|
+
for (const myKey of settingKeys()) {
|
|
195
|
+
const value = readSetting(myKey);
|
|
196
|
+
if (value === true) {
|
|
197
|
+
// Legacy boolean requests are ambiguous and may be stale from
|
|
198
|
+
// a prior crashed session. New stop requests use token payloads.
|
|
199
|
+
writeSetting(myKey, false);
|
|
200
|
+
} else {
|
|
201
|
+
const payload = decodePayload(value);
|
|
202
|
+
if (payload) {
|
|
203
|
+
handleStopRequest(myKey, payload);
|
|
204
|
+
}
|
|
183
205
|
}
|
|
184
206
|
}
|
|
185
207
|
task.wait(POLL_INTERVAL_SEC);
|
|
@@ -189,28 +211,32 @@ function startMonitor(): void {
|
|
|
189
211
|
|
|
190
212
|
function requestStop(): StopRequestResult {
|
|
191
213
|
if (!pluginRef) return { ok: false };
|
|
192
|
-
const myKey = settingKey(computeInstanceId());
|
|
193
214
|
const requestId = HttpService.GenerateGUID(false);
|
|
194
|
-
const
|
|
215
|
+
const payload: StopPayload = {
|
|
195
216
|
kind: "request",
|
|
196
217
|
id: requestId,
|
|
197
218
|
requestedAt: tick(),
|
|
198
|
-
}
|
|
219
|
+
};
|
|
220
|
+
let ok = false;
|
|
221
|
+
for (const myKey of settingKeys()) {
|
|
222
|
+
ok = writePayload(myKey, payload) || ok;
|
|
223
|
+
}
|
|
199
224
|
return { ok, requestId: ok ? requestId : undefined };
|
|
200
225
|
}
|
|
201
226
|
|
|
202
227
|
function waitForConsumption(requestId: string): StopConsumptionResult {
|
|
203
228
|
if (!pluginRef) return { ok: false, consumed: false, error: "Plugin reference is not initialized." };
|
|
204
|
-
const myKey = settingKey(computeInstanceId());
|
|
205
229
|
const start = tick();
|
|
206
230
|
while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
231
|
+
for (const myKey of settingKeys()) {
|
|
232
|
+
const payload = decodePayload(readSetting(myKey));
|
|
233
|
+
if (payload && payload.kind === "result" && payload.id === requestId) {
|
|
234
|
+
return {
|
|
235
|
+
ok: payload.ok === true,
|
|
236
|
+
consumed: true,
|
|
237
|
+
error: payload.error,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
214
240
|
}
|
|
215
241
|
task.wait(WAIT_POLL_SEC);
|
|
216
242
|
}
|
|
@@ -223,12 +249,13 @@ function waitForConsumption(requestId: string): StopConsumptionResult {
|
|
|
223
249
|
|
|
224
250
|
function clearPending(requestId?: string): void {
|
|
225
251
|
if (!pluginRef) return;
|
|
226
|
-
const myKey
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
252
|
+
for (const myKey of settingKeys()) {
|
|
253
|
+
if (requestId !== undefined) {
|
|
254
|
+
const payload = decodePayload(readSetting(myKey));
|
|
255
|
+
if (payload && payload.id !== requestId) continue;
|
|
256
|
+
}
|
|
257
|
+
writeSetting(myKey, false);
|
|
230
258
|
}
|
|
231
|
-
writeSetting(myKey, false);
|
|
232
259
|
}
|
|
233
260
|
|
|
234
261
|
export = {
|